"""fal.ai 백엔드 — Seedance 2.0 reference-to-video (멀티이미지 9장, 9:16 세로). Higgsfield platform API 가 모든 모델(dop/seedance v1/kling)을 단일 이미지로만 노출하는 한계를 우회한다. 업로드한 사진 여러 장을 그대로 넣어 ① AI 환각(안 보이는 공간 창작)을 줄이고 ② 여러 공간을 둘러보는 영상을 만든다. 흐름: 로컬 사진 → fal 업로드(URL) → subscribe(자동 폴링) → 결과 video.url → 다운로드. 인증: fal SDK 가 FAL_KEY("keyid:secret") 를 환경에서 읽음(config 에서 주입). dry_run=True 면 호출 없이 번들 데모 클립 반환. """ from __future__ import annotations import os import uuid from pathlib import Path import httpx from app import config as cfg ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts TMP = ENGINE / "server" / ".tmp" DEMO_VIDEO = cfg.WEBAPP_DIR / "demo" / "mumum.mp4" def _download(url: str) -> Path: # httpx + 브라우저 UA (CDN이 기본 UA를 403 차단하는 경우 대비) TMP.mkdir(parents=True, exist_ok=True) dest = TMP / f"base_{uuid.uuid4().hex[:8]}.mp4" with httpx.stream("GET", url, follow_redirects=True, timeout=180.0, headers={"User-Agent": "Mozilla/5.0"}) as r: r.raise_for_status() with dest.open("wb") as f: for chunk in r.iter_bytes(): f.write(chunk) return dest def generate( prompt: str, image_paths: list[Path], duration: int | None = None, dry_run: bool = False, **_, ) -> tuple[Path, float]: """Return (local mp4 path, credits). credits 는 fal 응답에 없어 0.0.""" if dry_run: return DEMO_VIDEO, 0.0 try: import fal_client except ImportError as e: raise RuntimeError( "fal-client 미설치. `pip install fal-client` 또는 이미지 재빌드 필요." ) from e if not os.environ.get("FAL_KEY"): raise RuntimeError( "FAL_KEY 없음. .env 에 FAL_KEY='keyid:secret'(또는 FAL_API_KEY) 설정 필요." ) # 1) 입력 사진 업로드 → URL. Seedance 2.0 는 최대 9장. image_urls = [fal_client.upload_file(str(p)) for p in image_paths[:cfg.FAL_MAX_IMAGES]] if not image_urls: raise RuntimeError("업로드된 입력 이미지가 없습니다.") # 2) 요청 인자. duration 은 "auto" 또는 4~12 정수. arguments: dict = { "prompt": prompt, "image_urls": image_urls, "aspect_ratio": cfg.FAL_ASPECT_RATIO, "resolution": cfg.FAL_RESOLUTION, } dur = duration if duration is not None else cfg.FAL_DURATION if str(dur).lower() != "auto": arguments["duration"] = int(dur) # 3) 동기 호출(자동 폴링). subscribe 가 완료까지 블록. result = fal_client.subscribe(cfg.FAL_MODEL_ID, arguments=arguments) # 4) 결과 영상 URL 추출 → 다운로드. (응답: {"video": {"url": ...}, "seed": ...}) video = result.get("video") if isinstance(result, dict) else None url = video.get("url") if isinstance(video, dict) else None if not url: raise RuntimeError(f"fal 결과에서 영상 URL을 찾지 못함: {result!r}") return _download(url), 0.0