"""Seedance 2.0 video generation — ByteDance Volcano Engine (BytePlus 국제 endpoint). POST https://api.byteplus.com/seedance/v1/videos → GET /videos/{id} 폴링 → output.video_url 다운로드. """ from __future__ import annotations import logging import os import time from typing import Optional import requests from nas_client import webhook_update_task logger = logging.getLogger(__name__) SEEDANCE_BASE_URL = "https://api.byteplus.com/seedance/v1" VIDEO_MEDIA_ROOT = os.getenv("VIDEO_MEDIA_ROOT", "/mnt/nas/webpage/data/video") VIDEO_MEDIA_URL_PREFIX = os.getenv("VIDEO_MEDIA_URL_PREFIX", "/media/video") POLL_INTERVAL = 8 # Seedance는 30~120초 POLL_MAX_ATTEMPTS = 60 DEFAULT_MODEL = "seedance-2.0" def _headers() -> dict: api_key = os.getenv("SEEDANCE_API_KEY", "") return { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } def run_seedance_generation(task_id: str, params: dict) -> None: """Seedance로 영상 생성 → mp4 → NAS SMB → webhook.""" try: if not os.getenv("SEEDANCE_API_KEY"): webhook_update_task(task_id, "failed", 0, "", error="SEEDANCE_API_KEY 미설정") return webhook_update_task(task_id, "processing", 5, "Seedance API 호출 중...") body = { "model": params.get("model") or DEFAULT_MODEL, "prompt": params["prompt"][:2000], "resolution": params.get("resolution", "1080p"), "duration": params.get("duration", 5), "aspect_ratio": params.get("aspect_ratio", "16:9"), } if params.get("negative_prompt"): body["negative_prompt"] = params["negative_prompt"] if params.get("image_url"): body["references"] = [{"type": "image", "data": params["image_url"], "role": "subject"}] if params.get("audio") is not None: body["audio"] = bool(params["audio"]) if params.get("seed") is not None: body["seed"] = int(params["seed"]) resp = requests.post(f"{SEEDANCE_BASE_URL}/videos", headers=_headers(), json=body, timeout=30) if resp.status_code not in (200, 201): webhook_update_task(task_id, "failed", 0, "", error=f"Seedance API 오류: {resp.status_code} {resp.text[:300]}") return body_json = resp.json() job_id = body_json.get("id", "") if not job_id: webhook_update_task(task_id, "failed", 0, "", error="Seedance 응답에 id 없음") return webhook_update_task(task_id, "processing", 15, "Seedance 작업 등록됨") # 폴링 video_url = None for attempt in range(POLL_MAX_ATTEMPTS): time.sleep(POLL_INTERVAL) fetch = requests.get(f"{SEEDANCE_BASE_URL}/videos/{job_id}", headers=_headers(), timeout=30) if fetch.status_code != 200: continue fd = fetch.json() status = fd.get("status", "") scaled = min(15 + int((attempt / POLL_MAX_ATTEMPTS) * 65), 79) webhook_update_task(task_id, "processing", scaled, f"Seedance 생성 중... ({status})") if status == "completed": video_url = (fd.get("output") or {}).get("video_url", "") break elif status == "failed": err = fd.get("error") or "Seedance 작업 실패" webhook_update_task(task_id, "failed", 0, "", error=str(err)[:300]) return else: webhook_update_task(task_id, "failed", 0, "", error="Seedance 폴링 timeout (10분)") return if not video_url: webhook_update_task(task_id, "failed", 0, "", error="Seedance 완료했으나 video_url 없음") return webhook_update_task(task_id, "processing", 85, "Seedance 결과 다운로드 중...") filename = f"{task_id}.mp4" os.makedirs(VIDEO_MEDIA_ROOT, exist_ok=True) file_path = os.path.join(VIDEO_MEDIA_ROOT, filename) dl = requests.get(video_url, stream=True, timeout=300) dl.raise_for_status() with open(file_path, "wb") as f: for chunk in dl.iter_content(chunk_size=8192): f.write(chunk) local_url = f"{VIDEO_MEDIA_URL_PREFIX}/{filename}" webhook_update_task(task_id, "succeeded", 100, "Seedance 생성 완료", video_url=local_url) except requests.Timeout: webhook_update_task(task_id, "failed", 0, "", error="Seedance API 타임아웃") except Exception as e: logger.exception("Seedance generation error task=%s", task_id) webhook_update_task(task_id, "failed", 0, "", error=str(e))