"""Seedance B-roll via Volcengine Ark (SPEC_NEW §15.3). Image-to-video from the product image + a Claude-written prompt (§15.4). The owner uses Volcengine Ark; the provider is still env-switchable. DRY_RUN copies a fixture so the overlay pipeline is fully demoable without credentials. Ark video API (verify the exact model id + param flags in YOUR Ark console — they drift, like Veo's model ids, SPEC_NEW §3): POST {base}/contents/generations/tasks {model, content:[{type:text,text}, {type:image_url,image_url:{url}}]} -> {id} GET {base}/contents/generations/tasks/{id} -> {status: queued|running|succeeded|failed, content:{video_url}} Seedance generation params ride in the text as flags: --resolution 720p --ratio 9:16 --duration {5|10} --watermark false. """ from __future__ import annotations import base64 import shutil import time import uuid from pathlib import Path import httpx from . import db from .config import FIXTURES_DIR, MEDIA_DIR, get_settings BROLL_DIR = MEDIA_DIR / "broll" DRY_RUN_OP_PREFIX = "dry-run-broll/" FIXTURE_CLIP = FIXTURES_DIR / "good_clip.mp4" # placeholder until a real B-roll fixture exists POLL_INTERVAL_S = 10 POLL_TIMEOUT_S = 12 * 60 class SeedanceNotConfigured(RuntimeError): pass class SeedanceError(RuntimeError): pass def _image_url_value(image: str | None) -> str | None: """Ark wants a URL or a base64 data URI for the product image.""" if not image: return None if image.startswith(("http://", "https://")): return image p = Path(image) if not p.is_file(): return None mime = {".png": "image/png", ".webp": "image/webp"}.get(p.suffix.lower(), "image/jpeg") return f"data:{mime};base64,{base64.b64encode(p.read_bytes()).decode()}" def _ark_headers() -> dict[str, str]: s = get_settings() return {"Authorization": f"Bearer {s.require('SEEDANCE_API_KEY')}", "content-type": "application/json"} def start_broll(broll_id: str, prompt: str, product_image: str | None, duration_s: int) -> str: s = get_settings() BROLL_DIR.mkdir(parents=True, exist_ok=True) db.execute("update broll set status = 'generating' where id = %s", (broll_id,)) if s.dry_run: op = f"{DRY_RUN_OP_PREFIX}{uuid.uuid4()}" db.execute("update broll set seedance_operation = %s where id = %s", (op, broll_id)) return op provider = (s.seedance_provider or "").lower() if provider != "volcengine": raise SeedanceNotConfigured( f"only the Volcengine provider is wired (SEEDANCE_PROVIDER={provider!r}). Set it to " f"'volcengine' + SEEDANCE_API_KEY, or ask to wire another provider (SPEC_NEW §15.8)." ) dur = 5 if (duration_s or 6) <= 5 else 10 # Seedance lite supports 5 s / 10 s; overlay loops/trims text = f"{prompt.strip()} --resolution 720p --ratio 9:16 --duration {dur} --watermark false" content: list[dict] = [{"type": "text", "text": text}] img = _image_url_value(product_image) if img: content.append({"type": "image_url", "image_url": {"url": img}}) body = {"model": s.require("SEEDANCE_MODEL"), "content": content} r = httpx.post(f"{s.seedance_base_url}/contents/generations/tasks", headers=_ark_headers(), json=body, timeout=120) if r.status_code >= 400: raise SeedanceError(f"Ark create {r.status_code}: {r.text[:400]}") op = r.json().get("id") if not op: raise SeedanceError(f"Ark create returned no task id: {r.text[:300]}") db.execute("update broll set seedance_operation = %s where id = %s", (op, broll_id)) return op def poll(op: str, broll_id: str) -> dict: s = get_settings() if op.startswith(DRY_RUN_OP_PREFIX): dest = BROLL_DIR / f"{broll_id}.mp4" dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(FIXTURE_CLIP, dest) return {"status": "done", "file": str(dest)} r = httpx.get(f"{s.seedance_base_url}/contents/generations/tasks/{op}", headers=_ark_headers(), timeout=60) if r.status_code >= 400: raise SeedanceError(f"Ark poll {r.status_code}: {r.text[:300]}") data = r.json() status = (data.get("status") or "").lower() if status in ("queued", "running", "pending", "processing", ""): return {"status": "running"} if status not in ("succeeded", "success", "done"): raise SeedanceError(f"Ark task {status}: {data.get('error') or str(data)[:300]}") url = (data.get("content") or {}).get("video_url") or data.get("video_url") if not url: raise SeedanceError(f"Ark succeeded but no video_url: {str(data)[:300]}") vid = httpx.get(url, timeout=300) if vid.status_code >= 400: raise SeedanceError(f"Ark video download {vid.status_code}") dest = BROLL_DIR / f"{broll_id}.mp4" dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(vid.content) return {"status": "done", "file": str(dest)} def generate_and_download(broll_id: str) -> str: """Submit + wait + download one B-roll clip; set broll.file_path + status='ready'.""" row = db.fetch_one( "select b.*, p.image_path from broll b left join products p on p.id = b.product_id where b.id = %s", (broll_id,), ) if row is None: raise RuntimeError(f"no broll {broll_id}") op = start_broll(broll_id, row["prompt"], row.get("image_path"), int(row.get("duration_s") or 6)) deadline = time.monotonic() + POLL_TIMEOUT_S while True: res = poll(op, broll_id) if res["status"] == "done": break if time.monotonic() > deadline: raise SeedanceError(f"Seedance task {op} still running after {POLL_TIMEOUT_S}s") time.sleep(POLL_INTERVAL_S) db.execute("update broll set file_path = %s, status = 'ready' where id = %s", (res["file"], broll_id)) return res["file"]