| 1 | """ |
| 2 | Seedance 视频生成 API 客户端 (字节跳动 ARK) |
| 3 | |
| 4 | """ |
| 5 | |
| 6 | import os |
| 7 | import time |
| 8 | import logging |
| 9 | import requests |
| 10 | import base64 |
| 11 | from typing import Optional |
| 12 | |
| 13 | logger = logging.getLogger(__name__) |
| 14 | |
| 15 | class SeedanceVideoClient: |
| 16 | """ |
| 17 | Seedance 视频生成客户端(字节跳动 ARK) |
| 18 | 支持图生视频功能,采用 提交任务 -> 轮询 -> 下载 的异步流程 |
| 19 | """ |
| 20 | |
| 21 | def __init__( |
| 22 | self, |
| 23 | api_key: Optional[str] = None, |
| 24 | base_url: Optional[str] = None, |
| 25 | local_proxy: Optional[str] = None, |
| 26 | timeout: int = 120, |
| 27 | ) -> None: |
| 28 | self.api_key = api_key or os.getenv("ARK_API_KEY") |
| 29 | self.base_url = (base_url or os.getenv("ARK_BASE_URL") or "https://ark.cn-beijing.volces.com/api/v3").rstrip("/") |
| 30 | self.local_proxy = local_proxy |
| 31 | self.timeout = timeout |
| 32 | |
| 33 | if not self.api_key: |
| 34 | logger.warning("SeedanceVideoClient: ARK_API_KEY 未设置") |
| 35 | |
| 36 | def _headers(self) -> dict: |
| 37 | return { |
| 38 | "Authorization": f"Bearer {self.api_key}", |
| 39 | "Content-Type": "application/json", |
| 40 | } |
| 41 | |
| 42 | def _proxies(self) -> Optional[dict]: |
| 43 | if not self.local_proxy: |
| 44 | return None |
| 45 | return {"http": self.local_proxy, "https": self.local_proxy} |
| 46 | |
| 47 | def generate_video( |
| 48 | self, |
| 49 | prompt: str, |
| 50 | image_path: Optional[str], |
| 51 | save_path: str, |
| 52 | model: str = "doubao-seedance-2-0-260128", |
| 53 | duration: int = 5, |
| 54 | **kwargs |
| 55 | ) -> str: |
| 56 | """ |
| 57 | 图生视频完整流程 |
| 58 | |
| 59 | Args: |
| 60 | prompt: 提示词 |
| 61 | image_path: 输入图片本地路径;为空时走文生视频 |
| 62 | save_path: 输出视频保存路径 |
| 63 | model: 模型名称 |
| 64 | duration: 视频时长 |
| 65 | """ |
| 66 | if not self.api_key: |
| 67 | raise RuntimeError("ARK_API_KEY not set.") |
| 68 | |
| 69 | # 1. 提交任务 |
| 70 | task_id = self._submit_task(prompt, image_path, model, duration, **kwargs) |
| 71 | |
| 72 | # 2. 轮询等待 |
| 73 | video_url = self._poll_until_done(task_id) |
| 74 | |
| 75 | # 3. 下载视频 |
| 76 | self._download_video(video_url, save_path) |
| 77 | |
| 78 | return video_url |
| 79 | |
| 80 | def _submit_task(self, prompt: str, image_path: Optional[str], model: str, duration: int, **kwargs) -> str: |
| 81 | # 根据 Seedance 2.0 文档更新接口路径 |
| 82 | url = f"{self.base_url}/contents/generations/tasks" |
| 83 | |
| 84 | # 构建 content 数组 |
| 85 | content = [] |
| 86 | if prompt: |
| 87 | content.append({ |
| 88 | "type": "text", |
| 89 | "text": prompt |
| 90 | }) |
| 91 | |
| 92 | if image_path: |
| 93 | if not os.path.exists(image_path): |
| 94 | raise FileNotFoundError(f"输入图片不存在: {image_path}") |
| 95 | |
| 96 | with open(image_path, "rb") as f: |
| 97 | img_data = base64.b64encode(f.read()).decode("utf-8") |
| 98 | ext = os.path.splitext(image_path)[1].lower() |
| 99 | mime = "image/png" if ext == ".png" else "image/jpeg" |
| 100 | image_base64 = f"data:{mime};base64,{img_data}" |
| 101 | |
| 102 | # 图生视频-首帧 |
| 103 | content.append({ |
| 104 | "type": "image_url", |
| 105 | "image_url": { |
| 106 | "url": image_base64 |
| 107 | }, |
| 108 | "role": "first_frame" |
| 109 | }) |
| 110 | |
| 111 | payload = { |
| 112 | "model": model, |
| 113 | "content": content, |
| 114 | "duration": duration, |
| 115 | "ratio": kwargs.get("ratio", "adaptive"), |
| 116 | "resolution": kwargs.get("resolution", "720p") |
| 117 | } |
| 118 | |
| 119 | # 合并其他可选参数 (如 seed, watermark) |
| 120 | for key in ["seed", "watermark", "generate_audio"]: |
| 121 | if key in kwargs and kwargs[key] is not None: |
| 122 | payload[key] = kwargs[key] |
| 123 | |
| 124 | logger.info(f"SeedanceVideoClient: 提交任务 model={model}, duration={duration}s") |
| 125 | resp = requests.post( |
| 126 | url, |
| 127 | headers=self._headers(), |
| 128 | json=payload, |
| 129 | timeout=self.timeout, |
| 130 | proxies=self._proxies(), |
| 131 | ) |
| 132 | |
| 133 | if not resp.ok: |
| 134 | logger.error(f"Seedance 提交失败: {resp.text}") |
| 135 | resp.raise_for_status() |
| 136 | |
| 137 | data = resp.json() |
| 138 | task_id = data.get("id") |
| 139 | if not task_id: |
| 140 | raise RuntimeError(f"Seedance API 未返回任务 ID: {data}") |
| 141 | |
| 142 | return task_id |
| 143 | |
| 144 | def _poll_until_done(self, task_id: str, max_polls: int = 120, interval: int = 5) -> str: |
| 145 | # 同步更新查询接口路径 |
| 146 | url = f"{self.base_url}/contents/generations/tasks/{task_id}" |
| 147 | |
| 148 | for i in range(max_polls): |
| 149 | resp = requests.get(url, headers=self._headers(), timeout=30, proxies=self._proxies()) |
| 150 | resp.raise_for_status() |
| 151 | data = resp.json() |
| 152 | |
| 153 | status = data.get("status") |
| 154 | if status == "succeeded": |
| 155 | # 根据实际返回体,URL 位于 content.video_url 或 video_url |
| 156 | video_url = data.get("content", {}).get("video_url") or data.get("video_url") |
| 157 | if not video_url: |
| 158 | raise RuntimeError(f"Seedance 任务成功但未返回视频 URL: {data}") |
| 159 | return video_url |
| 160 | elif status in ("failed", "expired"): |
| 161 | error_msg = data.get("error", {}).get("message") or data.get("status_msg") or "未知错误" |
| 162 | raise RuntimeError(f"Seedance 视频生成{status}: {error_msg}") |
| 163 | |
| 164 | logger.debug(f"SeedanceVideoClient: 任务进行中 {task_id}, status={status}, poll={i+1}") |
| 165 | time.sleep(interval) |
| 166 | |
| 167 | raise TimeoutError(f"Seedance 视频生成超时 (task_id={task_id})") |
| 168 | |
| 169 | def _download_video(self, url: str, save_path: str): |
| 170 | os.makedirs(os.path.dirname(save_path), exist_ok=True) |
| 171 | resp = requests.get(url, stream=True, timeout=120, proxies=self._proxies()) |
| 172 | resp.raise_for_status() |
| 173 | with open(save_path, "wb") as f: |
| 174 | for chunk in resp.iter_content(chunk_size=8192): |
| 175 | if chunk: |
| 176 | f.write(chunk) |
| 177 | logger.info(f"SeedanceVideoClient: 视频已保存: {save_path}") |
| 178 | |
| 179 | if __name__ == "__main__": |
| 180 | import sys |
| 181 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 182 | from config import Config |
| 183 | |
| 184 | logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| 185 | |
| 186 | # ── 测试参数(按需修改) ── |
| 187 | # IMAGE_PATH = "code/result/image/test_avail/test_input.png" |
| 188 | IMAGE_PATH = "code/result/image/test_avail/test_input_human.jpg" |
| 189 | OUTPUT_PATH = "code/result/video/test_avail/seedance_test_output.mp4" |
| 190 | PROMPT = "女生把财务报表交给男生,男生看到后喜极而泣" |
| 191 | # MODELS = ["doubao-seedance-2-0-fast-260128", "doubao-seedance-2-0-260128"] |
| 192 | MODELS = ["doubao-seedance-2-0-fast-260128"] |
| 193 | DURATION = 5 |
| 194 | |
| 195 | print("=== Seedance (ARK) 图生视频测试 ===") |
| 196 | api_key = Config.ARK_API_KEY |
| 197 | base_url = Config.ARK_BASE_URL |
| 198 | |
| 199 | if not api_key: |
| 200 | print("✗ ARK_API_KEY 未设置,请检查 .env 配置") |
| 201 | sys.exit(1) |
| 202 | |
| 203 | if not os.path.exists(IMAGE_PATH): |
| 204 | print(f"✗ 输入图片不存在: {IMAGE_PATH}") |
| 205 | sys.exit(1) |
| 206 | |
| 207 | print(f" API Key : {api_key[:6]}***{api_key[-4:]}") |
| 208 | print(f" Base URL : {base_url}") |
| 209 | |
| 210 | for model in MODELS: |
| 211 | print("\n" + "="*40) |
| 212 | print(f" 输入图片 : {IMAGE_PATH}") |
| 213 | print(f" 输出路径 : {OUTPUT_PATH}") |
| 214 | print(f" 模型 : {model}") |
| 215 | print(f" 时长 : {DURATION}s") |
| 216 | if PROMPT: |
| 217 | print(f" 提示词 : {PROMPT[:80]}") |
| 218 | |
| 219 | try: |
| 220 | client = SeedanceVideoClient(api_key=api_key, base_url=base_url) |
| 221 | print("✓ 客户端初始化成功") |
| 222 | |
| 223 | start = time.time() |
| 224 | video_url = client.generate_video( |
| 225 | prompt=PROMPT, |
| 226 | image_path=IMAGE_PATH, |
| 227 | save_path=OUTPUT_PATH, |
| 228 | model=model, |
| 229 | duration=DURATION, |
| 230 | ) |
| 231 | elapsed = time.time() - start |
| 232 | |
| 233 | print(f"✓ 视频生成完成!耗时 {elapsed:.1f}s") |
| 234 | print(f" 远端 URL : {video_url}") |
| 235 | print(f" 本地文件 : {os.path.abspath(OUTPUT_PATH)}") |
| 236 | print(f" 文件大小 : {os.path.getsize(OUTPUT_PATH) / 1024 / 1024:.2f} MB") |
| 237 | except Exception as e: |
| 238 | print(f"✗ 失败: {e}") |
| 239 | sys.exit(1) |
| 240 | break # 只测试第一个模型 |
| 241 |