feat(video-render): providers/seedance.py — Seedance 2.0 BytePlus client (SP-7)
POST /seedance/v1/videos → GET /videos/{id} 폴링 (8초 × 60) → output.video_url 다운로드.
Bearer 토큰. resolution 1080p/720p/2k, duration 4~15s.
references 배열로 image-to-video 지원.
Plan-B-Video Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
121
services/video-render/providers/seedance.py
Normal file
121
services/video-render/providers/seedance.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user