""" KIE Seedance 2 — jobs/createTask + jobs/recordInfo. Models: bytedance/seedance-2, bytedance/seedance-2-fast. Falls back to Replicate (bytedance/seedance-2.0 / seedance-2.0-fast) when KIE errors. """ import json import logging import os import asyncio from datetime import datetime from typing import List import httpx from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field, field_validator from utils.public_url import get_public_base_url router = APIRouter() logger = logging.getLogger(__name__) KIE_API_BASE = "https://api.kie.ai" REPLICATE_API_BASE = "https://api.replicate.com/v1" REPLICATE_TASK_PREFIX = "repl_" DEFAULT_SEEDANCE_MODEL = "bytedance/seedance-2-fast" ALLOWED_SEEDANCE_MODELS = frozenset( {DEFAULT_SEEDANCE_MODEL, "bytedance/seedance-2"}, ) KIE_TO_REPLICATE_SEEDANCE_MODEL = { "bytedance/seedance-2": "bytedance/seedance-2.0", "bytedance/seedance-2-fast": "bytedance/seedance-2.0-fast", } # In-memory callback/SSE state for Seedance tasks seedance_results: dict[str, dict] = {} seedance_sse_clients: dict[str, asyncio.Queue] = {} def _normalize_terminal_state(state: str | None) -> str: s = (state or "").strip().lower() if s in {"success", "succeeded", "completed", "complete", "done"}: return "success" if s in {"fail", "failed", "error", "cancelled", "canceled"}: return "fail" return "processing" def _extract_result_url(parsed: dict | None) -> str | None: if not isinstance(parsed, dict): return None # KIE commonly returns resultUrls; keep fallbacks for shape drift. urls = parsed.get("resultUrls") or parsed.get("urls") or [] if isinstance(urls, list) and urls: first = urls[0] if isinstance(first, str) and first.strip(): return first.strip() direct = parsed.get("resultUrl") or parsed.get("url") or parsed.get("videoUrl") if isinstance(direct, str) and direct.strip(): return direct.strip() return None def _cleanup_old_seedance_results(max_age_hours: int = 24) -> None: cutoff = datetime.now().timestamp() - (max_age_hours * 3600) stale = [task_id for task_id, data in seedance_results.items() if data.get("timestamp", 0) < cutoff] for task_id in stale: del seedance_results[task_id] def _kie_key() -> str: api_key = os.getenv("KIE_API_KEY") if not api_key: raise HTTPException(status_code=500, detail="KIE_API_KEY not configured on server.") return api_key def _replicate_token() -> str | None: raw = (os.getenv("REPLICATE_API_TOKEN") or os.getenv("REPLICATE_API_KEY") or "").strip() return raw or None def _http_url_for_provider(u: str, public_base: str) -> str: """Replicate requires http(s) URIs. Map asset:// to our public base if needed.""" s = (u or "").strip() if s.startswith(("http://", "https://")): return s if s.startswith("asset://"): rest = s[9:].lstrip("/") return f"{public_base.rstrip('/')}/{rest}" return s async def _kie_seedance_create_task(payload: dict, api_key: str) -> str: try: async with httpx.AsyncClient(timeout=60.0) as client: r = await client.post( f"{KIE_API_BASE}/api/v1/jobs/createTask", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, json=payload, ) except httpx.RequestError as e: raise HTTPException(status_code=502, detail=f"KIE request error: {e}") from e if r.status_code != 200: try: err = r.json() msg = err.get("msg") or err.get("message") or r.text[:300] except (json.JSONDecodeError, ValueError): msg = r.text[:300] raise HTTPException(status_code=r.status_code, detail=f"KIE createTask failed: {msg}") data = r.json() if data.get("code") != 200: raise HTTPException( status_code=data.get("code", 502), detail=data.get("msg", "KIE createTask rejected"), ) task_id = (data.get("data") or {}).get("taskId") if not task_id: raise HTTPException(status_code=502, detail="KIE response missing taskId") return task_id def _replicate_input_from_seedance_urls( urls: List[str], prompt: str, aspect_ratio: str, duration: int, resolution: str, generate_audio: bool, public_base: str, ) -> dict: mapped = [_http_url_for_provider(u, public_base) for u in urls] mapped = [u for u in mapped if u.startswith(("http://", "https://"))] if not mapped: raise HTTPException( status_code=400, detail="Replicate requires public http(s) image URLs (convert or host assets first).", ) n = len(mapped) inp: dict = { "prompt": prompt, "generate_audio": generate_audio, "resolution": resolution, "aspect_ratio": aspect_ratio, "duration": duration, } if n == 1: inp["image"] = mapped[0] elif n == 2: inp["image"] = mapped[0] inp["last_frame_image"] = mapped[1] else: inp["reference_images"] = mapped[:9] return inp async def _replicate_seedance_start( model_slug: str, input_obj: dict, token: str, ) -> str: try: async with httpx.AsyncClient(timeout=90.0) as client: r = await client.post( f"{REPLICATE_API_BASE}/models/{model_slug}/predictions", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, json={"input": input_obj}, ) except httpx.RequestError as e: raise HTTPException(status_code=502, detail=f"Replicate request error: {e}") from e if r.status_code not in (200, 201): try: err = r.json() detail = err.get("detail") or err.get("message") or r.text[:500] except (json.JSONDecodeError, ValueError): detail = r.text[:500] raise HTTPException( status_code=502, detail=f"Replicate create prediction failed ({r.status_code}): {detail}", ) data = r.json() pred_id = data.get("id") if not pred_id: raise HTTPException(status_code=502, detail="Replicate response missing prediction id") return f"{REPLICATE_TASK_PREFIX}{pred_id}" async def _replicate_prediction_fetch(prediction_id: str, token: str) -> dict: try: async with httpx.AsyncClient(timeout=45.0) as client: r = await client.get( f"{REPLICATE_API_BASE}/predictions/{prediction_id}", headers={"Authorization": f"Bearer {token}"}, ) except httpx.RequestError as e: raise HTTPException(status_code=502, detail=f"Replicate request error: {e}") from e if r.status_code != 200: raise HTTPException(status_code=502, detail="Replicate prediction fetch failed") return r.json() def _replicate_prediction_to_job_shape(body: dict) -> dict: status = (body.get("status") or "").lower() err = body.get("error") out_url = None output = body.get("output") if isinstance(output, str): out_url = output elif isinstance(output, list) and output: first = output[0] out_url = first if isinstance(first, str) else None if status == "succeeded": state = "success" elif status in {"failed", "canceled", "cancelled"}: state = "fail" else: state = "processing" fail_msg = None if state == "fail": fail_msg = err if isinstance(err, str) else (str(err) if err else "Replicate prediction failed") return { "state": state, "url": out_url, "failMsg": fail_msg, "failCode": None, } def _clamp_seedance_duration(seconds: int) -> int: if seconds < 4: return 4 if seconds > 15: return 15 return int(seconds) def _valid_media_ref(u: str) -> bool: s = str(u).strip() return bool(s.startswith(("http://", "https://", "asset://"))) def _normalize_seedance_prompt(raw: str) -> str: """OpenAPI: prompt required, 3–20000 chars.""" p = (raw or "").strip() if not p: p = "Cinematic premium product showcase, photoreal, smooth camera motion." if len(p) < 3: p = f"{p} — product video." if len(p) > 20000: p = p[:20000] return p def _seedance_input_from_urls( urls: List[str], prompt: str, aspect_ratio: str, duration: int, resolution: str, generate_audio: bool, ) -> dict: """ Seedance modes are mutually exclusive (OpenAPI): - 1 URL → image-to-video (first frame only): first_frame_url - 2 URLs → first & last frames: first_frame_url + last_frame_url - 3+ URLs → multimodal reference-to-video: reference_image_urls only (max 9) Do not mix first/last/reference in one request. """ n = len(urls) base = { "prompt": prompt, "generate_audio": generate_audio, "resolution": resolution, "aspect_ratio": aspect_ratio, "duration": duration, "nsfw_checker": False, } if n == 1: base["first_frame_url"] = urls[0] elif n == 2: base["first_frame_url"] = urls[0] base["last_frame_url"] = urls[1] else: base["reference_image_urls"] = urls[:9] return base class SeedanceCreateBody(BaseModel): prompt: str = Field(..., max_length=20000) reference_image_urls: List[str] = Field(..., min_length=1) aspect_ratio: str = "9:16" duration: int = 15 resolution: str = "480p" generate_audio: bool = True model: str = Field(default=DEFAULT_SEEDANCE_MODEL, max_length=120) @field_validator("resolution") @classmethod def resolution_ok(cls, v: str) -> str: if v not in ("480p", "720p", "1080p"): raise ValueError("resolution must be 480p, 720p, or 1080p") return v @field_validator("model") @classmethod def seedance_model_ok(cls, v: str) -> str: m = (v or "").strip() if m not in ALLOWED_SEEDANCE_MODELS: raise ValueError( f"model must be one of: {', '.join(sorted(ALLOWED_SEEDANCE_MODELS))}" ) return m class SeedanceCreateResponse(BaseModel): taskId: str status: str = "processing" async def _send_seedance_sse_event(task_id: str, data: dict) -> None: queue = seedance_sse_clients.get(task_id) if queue is not None: await queue.put(data) def _extract_seedance_callback_state(payload: dict) -> dict: """ Normalize KIE callback payloads into a browser-friendly shape. Supports both: - jobs callback with `data.info.state/resultJson` - direct callback payloads with `data.state/resultJson` """ data = payload.get("data") if isinstance(payload.get("data"), dict) else {} info = data.get("info") if isinstance(data.get("info"), dict) else {} src = info or data state = _normalize_terminal_state(src.get("state")) fail_msg = src.get("failMsg") or payload.get("msg") fail_code = src.get("failCode") url = None result_json = src.get("resultJson") if result_json: try: parsed = json.loads(result_json) if isinstance(result_json, str) else result_json url = _extract_result_url(parsed) except (json.JSONDecodeError, TypeError, ValueError, AttributeError): pass if state == "processing": code = payload.get("code") if code == 200 and url: state = "success" elif code not in (None, 200): state = "fail" else: state = "processing" return { "state": state, "url": url, "failMsg": fail_msg, "failCode": fail_code, } @router.post("/seedance/create", response_model=SeedanceCreateResponse) async def seedance_create(body: SeedanceCreateBody): """Start a Seedance 2 video task. Maps 1–2 image URLs to first/last frame I2V; 3+ to reference_image_urls.""" dur = _clamp_seedance_duration(body.duration) allowed_ratio = {"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"} ar = body.aspect_ratio if body.aspect_ratio in allowed_ratio else "9:16" urls = [u for u in body.reference_image_urls if u and _valid_media_ref(u)] if not urls: raise HTTPException( status_code=400, detail="At least one image URL (http(s) or asset://) is required.", ) prompt = _normalize_seedance_prompt(body.prompt) public_url = get_public_base_url() callback_url = f"{public_url}/api/seedance/callback" inp = _seedance_input_from_urls( urls, prompt, ar, dur, body.resolution, body.generate_audio, ) payload = { "model": body.model, "input": inp, "callBackUrl": callback_url, } kie_err: HTTPException | None = None if os.getenv("KIE_API_KEY"): try: api_key = _kie_key() task_id = await _kie_seedance_create_task(payload, api_key) return SeedanceCreateResponse(taskId=task_id) except HTTPException as e: kie_err = e logger.warning("KIE Seedance create failed, trying Replicate if configured: %s", e.detail) if not os.getenv("KIE_API_KEY"): logger.info("KIE_API_KEY missing; using Replicate for Seedance when token is set.") rep = _replicate_token() if not rep: if kie_err: raise kie_err raise HTTPException( status_code=500, detail="Neither KIE_API_KEY nor REPLICATE_API_TOKEN is configured for Seedance.", ) rep_slug = KIE_TO_REPLICATE_SEEDANCE_MODEL.get(body.model) if not rep_slug: raise HTTPException(status_code=500, detail="No Replicate model mapping for this Seedance variant.") rep_input = _replicate_input_from_seedance_urls( urls, prompt, ar, dur, body.resolution, body.generate_audio, public_url, ) task_id = await _replicate_seedance_start(rep_slug, rep_input, rep) if kie_err: logger.info("Seedance task started via Replicate fallback (%s)", task_id) return SeedanceCreateResponse(taskId=task_id) @router.get("/seedance/status/{task_id}") async def seedance_status(task_id: str): """Poll KIE jobs/recordInfo or Replicate prediction; normalize for the browser.""" cached = seedance_results.get(task_id) if cached: return cached if task_id.startswith(REPLICATE_TASK_PREFIX): token = _replicate_token() if not token: raise HTTPException(status_code=500, detail="REPLICATE_API_TOKEN not configured on server.") pred_id = task_id[len(REPLICATE_TASK_PREFIX) :] raw = await _replicate_prediction_fetch(pred_id, token) out = _replicate_prediction_to_job_shape(raw) if out["state"] in {"success", "fail"}: seedance_results[task_id] = { **out, "timestamp": datetime.now().timestamp(), } _cleanup_old_seedance_results() return out api_key = _kie_key() try: async with httpx.AsyncClient(timeout=45.0) as client: r = await client.get( f"{KIE_API_BASE}/api/v1/jobs/recordInfo", params={"taskId": task_id}, headers={"Authorization": f"Bearer {api_key}"}, ) except httpx.RequestError as e: raise HTTPException(status_code=502, detail=f"KIE request error: {e}") from e if r.status_code != 200: raise HTTPException(status_code=r.status_code, detail="KIE recordInfo failed") body = r.json() if body.get("code") != 200: raise HTTPException( status_code=body.get("code", 502), detail=body.get("msg", "KIE recordInfo error"), ) d = body.get("data") or {} state = _normalize_terminal_state(d.get("state")) out: dict = { "state": state, "url": None, "failMsg": d.get("failMsg"), "failCode": d.get("failCode"), } if state == "success" and d.get("resultJson"): try: rj = json.loads(d["resultJson"]) out["url"] = _extract_result_url(rj) except (json.JSONDecodeError, TypeError, KeyError): pass if out["state"] in {"success", "fail"}: seedance_results[task_id] = { **out, "timestamp": datetime.now().timestamp(), } _cleanup_old_seedance_results() return out @router.get("/seedance/events/{task_id}") async def seedance_events(task_id: str): """Server-Sent Events stream for callback-driven Seedance status.""" async def event_generator(): queue: asyncio.Queue = asyncio.Queue() seedance_sse_clients[task_id] = queue try: existing = seedance_results.get(task_id) if existing: yield f"data: {json.dumps(existing)}\n\n" return if task_id.startswith(REPLICATE_TASK_PREFIX): token = _replicate_token() if not token: yield f"data: {json.dumps({'state': 'fail', 'url': None, 'failMsg': 'REPLICATE_API_TOKEN not configured'})}\n\n" return pred_id = task_id[len(REPLICATE_TASK_PREFIX) :] while True: raw = await _replicate_prediction_fetch(pred_id, token) out = _replicate_prediction_to_job_shape(raw) if out["state"] in {"success", "fail"}: seedance_results[task_id] = { **out, "timestamp": datetime.now().timestamp(), } _cleanup_old_seedance_results() yield f"data: {json.dumps(out)}\n\n" return await asyncio.sleep(2.0) while True: data = await queue.get() yield f"data: {json.dumps(data)}\n\n" if data.get("state") in {"success", "fail"}: return except asyncio.CancelledError: return finally: if task_id in seedance_sse_clients: del seedance_sse_clients[task_id] return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", }, ) @router.post("/seedance/callback") async def seedance_callback(request: Request): """ KIE callback endpoint; updates in-memory result and notifies SSE clients. """ try: payload = await request.json() if not isinstance(payload, dict): payload = {} except Exception: payload = {} data = payload.get("data") if isinstance(payload.get("data"), dict) else {} info = data.get("info") if isinstance(data.get("info"), dict) else {} task_id = info.get("taskId") or data.get("taskId") if task_id: out = _extract_seedance_callback_state(payload) seedance_results[task_id] = { **out, "timestamp": datetime.now().timestamp(), } await _send_seedance_sse_event(task_id, out) _cleanup_old_seedance_results() return JSONResponse(status_code=200, content={"code": 200, "msg": "ok"})