91 lines
3.5 KiB
Python
91 lines
3.5 KiB
Python
"""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 정수.
|
|
# generate_audio=False: Seedance 2.0 의 AI 자동 생성 BGM이 ByteDance 자체
|
|
# content policy(partner_validation_failed)에 걸리는 케이스가 빈번하므로 비활성화.
|
|
# 오디오가 필요하면 Remotion 단계에서 별도 트랙을 얹는 방식을 사용한다.
|
|
arguments: dict = {
|
|
"prompt": prompt,
|
|
"image_urls": image_urls,
|
|
"aspect_ratio": cfg.FAL_ASPECT_RATIO,
|
|
"resolution": cfg.FAL_RESOLUTION,
|
|
"generate_audio": False,
|
|
}
|
|
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
|