diff --git a/.env.example b/.env.example index 9b4a5a54..f9ed1da7 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,13 @@ TENCENT_TOKENHUB_API_KEY= SUNO_API_KEY= # --- Video Generation --- +# Volcengine Ark direct Seedance 2.0 API key body (without the "Bearer " prefix). +# Get one at https://console.volcengine.com/ark/region:cn-beijing/apiKey +ARK_API_KEY= +# Optional overrides; uncomment only when needed. +# ARK_SEEDANCE_MODEL=doubao-seedance-2-0-260128 +# ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +# ARK_CNY_PER_USD=7.2 # HeyGen API (VEO, Sora, Runway, Kling, Seedance via single key). HEYGEN_API_KEY= # Runway Gen-4 (direct API, alternative to fal.ai routing). diff --git a/README.md b/README.md index 6dd89ecd..999e98f8 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,7 @@ XAI_API_KEY=your-key # xAI Grok image edits/generation + Grok video ge GOOGLE_API_KEY=your-key # Google Imagen images, Google TTS (700+ voices) # More video providers: +ARK_API_KEY=your-key # Volcengine Ark direct — Seedance 2.0 Standard/Fast/Mini HEYGEN_API_KEY=your-key # HeyGen — VEO, Sora, Runway, Kling via single gateway RUNWAY_API_KEY=your-key # Runway Gen-4 direct ``` @@ -483,12 +484,13 @@ Each tool declares which Layer 3 skills it relies on. The agent reads Layer 1 to > **Full setup guide with pricing and free tiers:** [`docs/PROVIDERS.md`](docs/PROVIDERS.md)
-Video Generation — 15 providers +Video Generation — 16 providers | Provider | Type | Notes | |----------|------|-------| | **Kling (fal.ai)** | Cloud API | High quality, fast via fal.ai gateway | | **Kling Official** | Cloud API | Official direct API with separate `kling_official` provider | +| **Seedance 2.0 (Volcengine Ark)** | Cloud API | Official direct API with separate `seedance_ark` provider | | **Runway Gen-4** | Cloud API | Cinematic quality, Gen-3 Alpha Turbo / Gen-4 Turbo / Gen-4 Aleph | | **Google Veo 3** | Cloud API | Long-form, cinematic. Via fal.ai or HeyGen. | | **Grok Imagine Video** | Cloud API | Strong reference-image video and xAI-native short-form generation | diff --git a/README_zh-CN.md b/README_zh-CN.md index b8e3b7f2..9c6d27e9 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -187,6 +187,7 @@ XAI_API_KEY=your-key # xAI Grok 图像编辑/生成 + Grok 视频生 GOOGLE_API_KEY=your-key # Google Imagen 图像、Google TTS(700+ 种声音) # 更多视频提供商: +ARK_API_KEY=your-key # 火山方舟直连 — Seedance 2.0 Standard/Fast/Mini HEYGEN_API_KEY=your-key # HeyGen — 汇集 VEO、Sora、Runway、Kling 的统一网关 RUNWAY_API_KEY=your-key # Runway Gen-4 直连 ``` @@ -417,11 +418,12 @@ OpenMontage/ > **包含定价与免费额度的完整设置指南:** [`docs/PROVIDERS.md`](docs/PROVIDERS.md)
-视频生成 — 14 家提供商 +视频生成 — 15 家提供商 | 提供商 | 类型 | 备注 | |----------|------|-------| | **Kling** | 云端 API | 高质量,速度快 | +| **Seedance 2.0(火山方舟)** | 云端 API | 独立的 `seedance_ark` 官方直连接口 | | **Runway Gen-4** | 云端 API | 电影级质量,Gen-3 Alpha Turbo / Gen-4 Turbo / Gen-4 Aleph | | **Google Veo 3** | 云端 API | 长篇幅,电影级。通过 fal.ai 或 HeyGen 接入。 | | **Grok Imagine Video** | 云端 API | 强大的基于参考图的视频和 xAI 原生短视频生成 | diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 551c13ed..768c5984 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -18,11 +18,12 @@ Everything you need to know about every provider in OpenMontage — setup instru | 6 | **~$0.05/image** | OpenAI | GPT Image 2 images + OpenAI TTS | | 7 | **~$0.04/image** | Google Imagen | Imagen 4 images (shares the Google API key) | | 8 | **pay-as-you-go** | Kling Official | Official direct Kling video, image, TTS, avatar, and lip-sync API, separate from fal.ai Kling | -| 9 | **$12/month** | Runway | Gen-4 video — highest quality AI video | -| 10 | **pay-as-you-go** | HeyGen | Avatar videos, multi-model video gateway | -| 11 | **pay-as-you-go** | Suno | Full song generation with vocals and lyrics | -| 12 | **$0 + GPU** | Local video gen | WAN 2.1, Hunyuan, CogVideo, LTX — free, offline | -| 13 | **$0 + GPU** | Local Diffusion | Stable Diffusion images — free, offline | +| 9 | **pay-as-you-go** | Volcengine Ark | Official direct Seedance 2.0 Standard/Fast/Mini API | +| 10 | **$12/month** | Runway | Gen-4 video — highest quality AI video | +| 11 | **pay-as-you-go** | HeyGen | Avatar videos, multi-model video gateway | +| 12 | **pay-as-you-go** | Suno | Full song generation with vocals and lyrics | +| 13 | **$0 + GPU** | Local video gen | WAN 2.1, Hunyuan, CogVideo, LTX — free, offline | +| 14 | **$0 + GPU** | Local Diffusion | Stable Diffusion images — free, offline | ### Environment Variable Summary @@ -57,6 +58,9 @@ MINIMAX_API_KEY= # MiniMax first-party image generation KLING_API_KEY= # Official Kling video, image, TTS, avatar, lip sync KLING_API_BASE_URL= # Optional; default https://api-singapore.klingai.com +# VOLCENGINE ARK DIRECT SEEDANCE 2.0 API +ARK_API_KEY= # API key body only; do not include the "Bearer " prefix + # VIDEO HEYGEN_API_KEY= # HeyGen avatar video gateway RUNWAY_API_KEY= # Runway Gen-4 video (direct) @@ -153,6 +157,64 @@ The `req_key` for video is `jimeng_ti2v_v30_pro`. Success code is `10000`. Task --- +### Volcengine Ark — Direct Seedance 2.0 Video Generation + +> **Official direct Seedance API.** Calls Volcengine Ark without routing through fal.ai or Replicate, while keeping those existing provider paths available as independent fallbacks. + +**Tool unlocked:** `seedance_ark` + +**Env var:** `ARK_API_KEY` + +#### Setup + +1. Open the [Volcengine Ark API key console](https://console.volcengine.com/ark/region:cn-beijing/apiKey) +2. Enable the Seedance 2.0 model family and confirm that the account has balance or a valid resource package +3. Create a long-lived API key +4. Add the key body to `.env`: `ARK_API_KEY=...` + +Do not include the `Bearer ` prefix in the environment value. The tool adds the authorization scheme when it sends a request. + +Optional overrides: + +```bash +ARK_SEEDANCE_MODEL=doubao-seedance-2-0-260128 +ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +ARK_CNY_PER_USD=7.2 +``` + +#### Models and capabilities + +| Variant | Default model ID | Output | +|---------|------------------|--------| +| Standard | `doubao-seedance-2-0-260128` | 480p, 720p, 1080p, or 4K | +| Fast | `doubao-seedance-2-0-fast-260128` | 480p or 720p | +| Mini | `doubao-seedance-2-0-mini-260615` | 480p or 720p | + +The adapter supports: + +- text-to-video, first-frame image-to-video, and multimodal reference-to-video +- local image and audio inputs encoded as validated Data URIs +- remote reference image, video, and audio URLs +- task create, query, cancel, and bounded polling +- synchronized audio, optional last-frame return, web search for text-only requests, and output download +- pre-submit dry-run and token-based cost estimates + +Local reference videos are intentionally rejected because the public API does not document video Data URI support. Use a provider-accessible HTTPS URL or an Ark asset reference instead. + +#### API and billing notes + +The asynchronous API flow is: + +`POST /contents/generations/tasks` → `GET /contents/generations/tasks/{id}` → download the successful result URL. + +Queued tasks can be cancelled with `DELETE /contents/generations/tasks/{id}`. Task records are retained for a limited period, and successful result URLs are short-lived, so the tool downloads outputs promptly. + +Ark bills Seedance 2.0 by completion tokens. Rates vary by model, resolution, and whether the request includes reference video. OpenMontage estimates cost before submission and reconciles against provider-returned usage when available. Check the Ark console for current rates before a paid run; custom endpoint IDs require an explicit custom price so unknown pricing is never treated as free. + +Official references: [model list](https://www.volcengine.com/docs/82379/1330310?lang=zh), [create task](https://www.volcengine.com/docs/82379/1520757?lang=zh), [query task](https://www.volcengine.com/docs/82379/1521309?lang=zh). + +--- + ### Alibaba DashScope — Qwen Image + TTS + ASR > **Best for Chinese-language production.** One key unlocks Qwen-Image generation, Qwen-TTS Mandarin narration, and Qwen-ASR with word-level timestamps — the only DashScope path that provides word-level granularity for subtitle alignment. @@ -1032,6 +1094,7 @@ These tools require only FFmpeg or Python packages — no GPU, no API key. | **fish.audio** | `FISH_AUDIO_API_KEY` | `fish_audio_tts` | Free tier (s2.1-pro-free) + paid | | **fal.ai** | `FAL_KEY` | `flux_image`, `recraft_image`, `kling_video`, `veo_video`, `minimax_video` | Pay-as-you-go | | **Kling Official** | `KLING_API_KEY` | `kling_official_video`, `kling_official_image`, `kling_tts`, `kling_avatar`, `kling_lip_sync` | Pay-as-you-go | +| **Volcengine Ark** | `ARK_API_KEY` | `seedance_ark` | Pay-as-you-go | | **OpenAI** | `OPENAI_API_KEY` | `openai_tts`, `openai_image` | Paid only | | **xAI** | `XAI_API_KEY` | `grok_image`, `grok_video` | Paid only | | **Runway** | `RUNWAY_API_KEY` | `runway_video` | Free trial + paid | @@ -1051,7 +1114,7 @@ How many providers cover each capability: | Capability | Cloud Providers | Local Providers | Free Options | |-----------|----------------|-----------------|--------------| | **Image Generation** | FLUX, Kling Official, Grok, Google Imagen, GPT Image 2, Recraft | Local Diffusion | Pexels, Pixabay (stock) | -| **Video Generation** | Grok, Kling Official, Kling via fal.ai, Runway, Veo, Gemini Omni, Higgsfield, MiniMax, HeyGen | WAN, Hunyuan, CogVideo, LTX | Pexels, Pixabay (stock) | +| **Video Generation** | Grok, Kling Official, Kling via fal.ai, Seedance via Volcengine Ark, Runway, Veo, Gemini Omni, Higgsfield, MiniMax, HeyGen | WAN, Hunyuan, CogVideo, LTX | Pexels, Pixabay (stock) | | **Text-to-Speech** | ElevenLabs, fish.audio, Google TTS, Kling Official, OpenAI | Piper | Piper, Google free tier, ElevenLabs free tier, fish.audio s2.1-pro-free | | **Music Generation** | ElevenLabs, Suno, Google Lyria | — | ElevenLabs free tier | | **Post-Production** | — | FFmpeg (compose, stitch, trim, mix, enhance, grade) | All free | diff --git a/tests/contracts/test_seedance_ark_video.py b/tests/contracts/test_seedance_ark_video.py new file mode 100644 index 00000000..884211ce --- /dev/null +++ b/tests/contracts/test_seedance_ark_video.py @@ -0,0 +1,875 @@ +"""Contract tests for direct Volcengine Ark Seedance 2.0 video generation. + +All HTTP calls are mocked. This suite must never create a paid task. +""" + +from __future__ import annotations + +import base64 +import wave + +import pytest + +from tools.base_tool import BaseTool, ToolRuntime, ToolStatus +from tools.video.seedance_ark import SeedanceArkVideo + + +class _FakeResponse: + def __init__( + self, + payload: dict | None = None, + *, + content: bytes = b"", + status_code: int = 200, + ) -> None: + self._payload = payload if payload is not None else {} + self.content = content + self.status_code = status_code + + def json(self) -> dict: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}: {self._payload}") + + +class TestContract: + def test_identity_and_capabilities(self): + assert issubclass(SeedanceArkVideo, BaseTool) + tool = SeedanceArkVideo() + assert tool.name == "seedance_ark" + assert tool.provider == "ark" + assert tool.capability == "video_generation" + assert tool.runtime == ToolRuntime.API + assert tool.supports["text_to_video"] is True + assert tool.supports["image_to_video"] is True + assert tool.supports["reference_to_video"] is True + assert "env:ARK_API_KEY" in tool.dependencies + + def test_status_requires_ark_api_key(self, monkeypatch): + monkeypatch.delenv("ARK_API_KEY", raising=False) + assert SeedanceArkVideo().get_status() == ToolStatus.UNAVAILABLE + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + assert SeedanceArkVideo().get_status() == ToolStatus.AVAILABLE + + def test_official_model_ids(self): + assert SeedanceArkVideo.MODEL_IDS == { + "standard": "doubao-seedance-2-0-260128", + "fast": "doubao-seedance-2-0-fast-260128", + "mini": "doubao-seedance-2-0-mini-260615", + } + + +class TestTaskActions: + def test_create_uses_official_endpoint_bearer_auth_and_body(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + captured = {} + + def fake_post(url, *, headers, json, timeout): + captured.update(url=url, headers=headers, json=json, timeout=timeout) + return _FakeResponse({"id": "cgt-test-123"}) + + monkeypatch.setattr("requests.post", fake_post) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "A paper bird takes flight", + "model_variant": "standard", + "duration": 5, + "aspect_ratio": "16:9", + "resolution": "720p", + "generate_audio": True, + "watermark": False, + "return_last_frame": True, + } + ) + + assert result.success is True + assert result.data["task_id"] == "cgt-test-123" + assert result.data["status"] == "submitted" + assert captured["url"] == ( + "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks" + ) + assert captured["headers"]["Authorization"] == "Bearer fake-ark-key" + assert captured["json"] == { + "model": "doubao-seedance-2-0-260128", + "content": [{"type": "text", "text": "A paper bird takes flight"}], + "duration": 5, + "ratio": "16:9", + "resolution": "720p", + "generate_audio": True, + "watermark": False, + "return_last_frame": True, + } + + def test_query_uses_get_and_returns_task_payload(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + captured = {} + + def fake_get(url, *, headers, timeout): + captured.update(url=url, headers=headers, timeout=timeout) + return _FakeResponse( + { + "id": "cgt-test-123", + "status": "succeeded", + "content": {"video_url": "https://example.com/result.mp4"}, + "usage": {"completion_tokens": 250000}, + } + ) + + monkeypatch.setattr("requests.get", fake_get) + result = SeedanceArkVideo().execute( + {"task_action": "query", "task_id": "cgt-test-123"} + ) + + assert result.success is True + assert result.data["task"]["status"] == "succeeded" + assert captured["url"].endswith( + "/api/v3/contents/generations/tasks/cgt-test-123" + ) + assert captured["headers"]["Authorization"] == "Bearer fake-ark-key" + + def test_cancel_uses_delete(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + captured = {} + + def fake_delete(url, *, headers, timeout): + captured.update(url=url, headers=headers, timeout=timeout) + return _FakeResponse({}) + + monkeypatch.setattr("requests.delete", fake_delete) + result = SeedanceArkVideo().execute( + {"task_action": "cancel", "task_id": "cgt-test-123"} + ) + + assert result.success is True + assert result.data == { + "task_id": "cgt-test-123", + "status": "cancel_requested", + } + assert captured["url"].endswith( + "/api/v3/contents/generations/tasks/cgt-test-123" + ) + + def test_generate_polls_and_downloads_without_extra_submission( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + calls = {"post": 0, "task_get": 0, "download_get": 0} + + def fake_post(url, *, headers, json, timeout): + calls["post"] += 1 + return _FakeResponse({"id": "cgt-test-123"}) + + def fake_get(url, *, headers=None, timeout): + if url.endswith("/cgt-test-123"): + calls["task_get"] += 1 + return _FakeResponse( + { + "id": "cgt-test-123", + "model": "doubao-seedance-2-0-260128", + "status": "succeeded", + "content": { + "video_url": "https://example.com/result.mp4", + "last_frame_url": "https://example.com/last.png", + }, + "duration": 5, + "resolution": "720p", + "ratio": "16:9", + "usage": {"completion_tokens": 250000}, + } + ) + calls["download_get"] += 1 + return _FakeResponse(content=b"fake-mp4") + + monkeypatch.setattr("requests.post", fake_post) + monkeypatch.setattr("requests.get", fake_get) + monkeypatch.setattr("time.sleep", lambda *_: None) + monkeypatch.setattr( + "tools.video._shared.probe_output", + lambda *_: {"duration": 5.0, "width": 1280, "height": 720}, + ) + + output = tmp_path / "ark.mp4" + result = SeedanceArkVideo().execute( + { + "prompt": "A paper bird takes flight", + "poll_interval_seconds": 0, + "output_path": str(output), + } + ) + + assert result.success is True + assert output.read_bytes() == b"fake-mp4" + assert result.data["task_id"] == "cgt-test-123" + assert result.data["video_url"] == "https://example.com/result.mp4" + assert result.data["last_frame_url"] == "https://example.com/last.png" + assert calls == {"post": 1, "task_get": 1, "download_get": 1} + + +class TestInputSafety: + def test_local_first_frame_is_embedded_without_fal_upload( + self, monkeypatch, tmp_path + ): + from PIL import Image + + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + captured = {} + image = tmp_path / "anchor.png" + Image.new("RGB", (640, 640), "white").save(image) + + def fail_fal_upload(*args, **kwargs): + raise AssertionError("Ark local references must never use FAL") + + def fake_post(url, *, headers, json, timeout): + captured["payload"] = json + return _FakeResponse({"id": "cgt-test-123"}) + + monkeypatch.setattr("tools.video._shared.upload_image_fal", fail_fal_upload) + monkeypatch.setattr("requests.post", fake_post) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "The bird opens its wings", + "operation": "image_to_video", + "reference_image_path": str(image), + } + ) + + assert result.success is True + media = captured["payload"]["content"][1] + assert media["type"] == "image_url" + assert media["role"] == "first_frame" + prefix, encoded = media["image_url"]["url"].split(",", 1) + assert prefix == "data:image/png;base64" + assert base64.b64decode(encoded) == image.read_bytes() + + def test_reference_media_roles_follow_official_contract(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + captured = {} + + def fake_post(url, *, headers, json, timeout): + captured["payload"] = json + return _FakeResponse({"id": "cgt-test-123"}) + + monkeypatch.setattr("requests.post", fake_post) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "Match the supplied look and rhythm", + "operation": "reference_to_video", + "reference_image_urls": ["https://example.com/look.png"], + "reference_video_urls": ["https://example.com/motion.mp4"], + "reference_audio_urls": ["https://example.com/voice.mp3"], + } + ) + + assert result.success is True + assert captured["payload"]["content"][1:] == [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/look.png"}, + "role": "reference_image", + }, + { + "type": "video_url", + "video_url": {"url": "https://example.com/motion.mp4"}, + "role": "reference_video", + }, + { + "type": "audio_url", + "audio_url": {"url": "https://example.com/voice.mp3"}, + "role": "reference_audio", + }, + ] + + @pytest.mark.parametrize( + "inputs, message", + [ + ({"task_action": "create", "prompt": ""}, "prompt"), + ( + { + "task_action": "create", + "prompt": "x", + "duration": 3, + }, + "duration", + ), + ( + { + "task_action": "create", + "prompt": "x", + "operation": "image_to_video", + }, + "reference image", + ), + ( + {"task_action": "query", "task_id": "../not-valid"}, + "task_id", + ), + ], + ) + def test_invalid_input_never_reaches_network( + self, inputs, message, monkeypatch + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fail_network(*args, **kwargs): + raise AssertionError("invalid input must not call the network") + + monkeypatch.setattr("requests.post", fail_network) + monkeypatch.setattr("requests.get", fail_network) + monkeypatch.setattr("requests.delete", fail_network) + result = SeedanceArkVideo().execute(inputs) + assert result.success is False + assert message in result.error.lower() + + def test_errors_redact_api_key(self, monkeypatch): + api_key = "unit-test-api-key-redaction-marker" + monkeypatch.setenv("ARK_API_KEY", api_key) + + def failing_post(*args, **kwargs): + raise RuntimeError(f"request failed using {api_key}") + + monkeypatch.setattr("requests.post", failing_post) + result = SeedanceArkVideo().execute( + {"task_action": "create", "prompt": "x"} + ) + assert result.success is False + assert api_key not in result.error + assert "[redacted]" in result.error + + def test_download_errors_redact_signed_url_and_preserve_task_id( + self, monkeypatch + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fake_post(*args, **kwargs): + return _FakeResponse({"id": "cgt-paid-123"}) + + def fake_get(url, **kwargs): + if url.endswith("/cgt-paid-123"): + return _FakeResponse( + { + "id": "cgt-paid-123", + "status": "succeeded", + "content": { + "video_url": ( + "https://cdn.example/video.mp4" + "?X-Signature=unit-test-signature-marker" + ) + }, + } + ) + raise RuntimeError(f"download failed: {url}") + + monkeypatch.setattr("requests.post", fake_post) + monkeypatch.setattr("requests.get", fake_get) + result = SeedanceArkVideo().execute({"prompt": "x"}) + assert result.success is False + assert result.data["task_id"] == "cgt-paid-123" + assert result.data["recovery_action"] == "query" + assert "unit-test-signature-marker" not in result.error + assert "?[redacted]" in result.error + + def test_terminal_failure_redacts_signed_input_url(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fake_post(*args, **kwargs): + return _FakeResponse({"id": "cgt-paid-123"}) + + def fake_get(*args, **kwargs): + return _FakeResponse( + { + "id": "cgt-paid-123", + "status": "failed", + "error": { + "code": "InputFetchFailed", + "message": ( + "cannot fetch https://media.example/a.mp4" + "?X-Signature=unit-test-signature-marker" + ), + }, + } + ) + + monkeypatch.setattr("requests.post", fake_post) + monkeypatch.setattr("requests.get", fake_get) + result = SeedanceArkVideo().execute({"prompt": "x"}) + assert result.success is False + assert result.data["task_id"] == "cgt-paid-123" + assert "unit-test-signature-marker" not in result.error + assert "?[redacted]" in result.error + + def test_corrupt_or_tiny_local_image_never_reaches_network( + self, monkeypatch, tmp_path + ): + from PIL import Image + + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fail_network(*args, **kwargs): + raise AssertionError("invalid local image must not call network") + + monkeypatch.setattr("requests.post", fail_network) + corrupt = tmp_path / "corrupt.png" + corrupt.write_bytes(b"\x89PNG\r\n\x1a\nmock") + corrupt_result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "x", + "operation": "image_to_video", + "reference_image_path": str(corrupt), + } + ) + assert corrupt_result.success is False + assert "unreadable or corrupt" in corrupt_result.error + + tiny = tmp_path / "tiny.png" + Image.new("RGB", (1, 1), "white").save(tiny) + tiny_result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "x", + "operation": "image_to_video", + "reference_image_path": str(tiny), + } + ) + assert tiny_result.success is False + assert "300 to 6000" in tiny_result.error + + def test_corrupt_or_tiny_image_data_uri_never_reaches_network( + self, monkeypatch + ): + from io import BytesIO + + from PIL import Image + + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fail_network(*args, **kwargs): + raise AssertionError("invalid image Data URI must not call network") + + monkeypatch.setattr("requests.post", fail_network) + corrupt = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "x", + "operation": "image_to_video", + "reference_image_url": "data:image/png;base64,NOT-BASE64", + } + ) + assert corrupt.success is False + assert "strict base64" in corrupt.error + + buffer = BytesIO() + Image.new("RGB", (1, 1), "white").save(buffer, format="PNG") + tiny_uri = ( + "data:image/png;base64," + + base64.b64encode(buffer.getvalue()).decode("ascii") + ) + tiny = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "x", + "operation": "image_to_video", + "reference_image_url": tiny_uri, + } + ) + assert tiny.success is False + assert "300 to 6000" in tiny.error + + def test_rejects_bearer_prefix_before_network(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "Bearer fake-ark-key") + + def fail_network(*args, **kwargs): + raise AssertionError("invalid API Key config must not call network") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().execute( + {"task_action": "create", "prompt": "x"} + ) + assert result.success is False + assert "remove the 'Bearer ' prefix" in result.error + + def test_rejects_local_reference_video_before_network( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + video = tmp_path / "reference.mp4" + video.write_bytes(b"not-a-real-video") + + def fail_network(*args, **kwargs): + raise AssertionError("local video must not reach the Ark API") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "match this motion", + "operation": "reference_to_video", + "reference_video_path": str(video), + } + ) + assert result.success is False + assert "reference_video_path is not supported by ark" in result.error.lower() + + def test_rejects_fast_1080p_before_network(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fail_network(*args, **kwargs): + raise AssertionError("unsupported resolution must not call network") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "x", + "model_variant": "fast", + "resolution": "1080p", + } + ) + assert result.success is False + assert "only 480p or 720p" in result.error + + def test_short_local_audio_never_reaches_network( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + audio = tmp_path / "short.wav" + with wave.open(str(audio), "wb") as writer: + writer.setnchannels(1) + writer.setsampwidth(2) + writer.setframerate(8000) + writer.writeframes(b"\0\0" * 8000) + + def fail_network(*args, **kwargs): + raise AssertionError("invalid local audio must not call network") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "match audio1", + "operation": "reference_to_video", + "reference_image_urls": ["https://example.com/look.png"], + "reference_audio_path": str(audio), + } + ) + assert result.success is False + assert "2 to 15 seconds" in result.error + + def test_invalid_exchange_rate_fails_before_paid_post(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + monkeypatch.setenv("ARK_CNY_PER_USD", "not-a-number") + + def fail_network(*args, **kwargs): + raise AssertionError("local cost config error must precede paid POST") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().execute( + {"task_action": "create", "prompt": "x"} + ) + assert result.success is False + assert result.data == {} + + @pytest.mark.parametrize("exchange_rate", ["nan", "inf", "-inf"]) + def test_non_finite_exchange_rate_never_reaches_paid_post( + self, monkeypatch, exchange_rate + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + monkeypatch.setenv("ARK_CNY_PER_USD", exchange_rate) + + def fail_network(*args, **kwargs): + raise AssertionError("non-finite exchange rate reached paid POST") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().execute( + {"task_action": "create", "prompt": "x"} + ) + assert result.success is False + assert "finite" in result.error + + @pytest.mark.parametrize("exchange_rate", ["nan", "inf", "-inf"]) + def test_non_finite_exchange_rate_never_returns_query_cost( + self, monkeypatch, exchange_rate + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + monkeypatch.setenv("ARK_CNY_PER_USD", exchange_rate) + monkeypatch.setattr( + "requests.get", + lambda *args, **kwargs: _FakeResponse( + { + "id": "task-query-cost", + "status": "succeeded", + "model": "doubao-seedance-2-0-260128", + "usage": {"completion_tokens": 108_000}, + } + ), + ) + result = SeedanceArkVideo().execute( + {"task_action": "query", "task_id": "task-query-cost"} + ) + assert result.success is False + assert "finite" in result.error + + @pytest.mark.parametrize("custom_price", ["nan", "inf", "-inf"]) + def test_non_finite_custom_price_never_returns_query_cost( + self, monkeypatch, custom_price + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + monkeypatch.setattr( + "requests.get", + lambda *args, **kwargs: _FakeResponse( + { + "id": "task-query-custom-cost", + "status": "succeeded", + "model": "ep-custom-account-pricing", + "usage": {"completion_tokens": 108_000}, + } + ), + ) + result = SeedanceArkVideo().execute( + { + "task_action": "query", + "task_id": "task-query-custom-cost", + "custom_price_cny_per_million_tokens": custom_price, + } + ) + assert result.success is False + assert "finite" in result.error + + def test_custom_query_without_price_reports_unknown_not_zero( + self, monkeypatch + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + monkeypatch.setattr( + "requests.get", + lambda *args, **kwargs: _FakeResponse( + { + "id": "task-query-custom-unknown", + "status": "succeeded", + "model": "ep-custom-account-pricing", + "usage": {"completion_tokens": 100_000}, + } + ), + ) + result = SeedanceArkVideo().execute( + { + "task_action": "query", + "task_id": "task-query-custom-unknown", + } + ) + assert result.success is True + assert result.cost_usd is None + assert ( + result.data["cost_estimate_status"] + == "unknown_custom_model_or_missing_usage" + ) + + def test_query_retries_transient_server_error(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + calls = {"count": 0} + + def fake_get(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == 1: + return _FakeResponse( + {"error": {"code": "InternalError"}}, + status_code=503, + ) + return _FakeResponse( + {"id": "cgt-test-123", "status": "running"} + ) + + monkeypatch.setattr("requests.get", fake_get) + monkeypatch.setattr("time.sleep", lambda *_: None) + result = SeedanceArkVideo().execute( + {"task_action": "query", "task_id": "cgt-test-123"} + ) + assert result.success is True + assert calls["count"] == 2 + + def test_dry_run_is_offline_and_never_submits(self, monkeypatch): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fail_network(*args, **kwargs): + raise AssertionError("dry_run must not call the network") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().dry_run( + { + "prompt": "A paper bird takes flight", + "duration": 5, + "resolution": "720p", + } + ) + assert result["would_execute"] is False + assert result["paid_submission"] is False + assert result["api_contract"] == { + "create": ( + "POST https://ark.cn-beijing.volces.com/api/v3/" + "contents/generations/tasks" + ), + "query": ( + "GET https://ark.cn-beijing.volces.com/api/v3/" + "contents/generations/tasks/{task_id}" + ), + "cancel": ( + "DELETE https://ark.cn-beijing.volces.com/api/v3/" + "contents/generations/tasks/{task_id}" + ), + } + assert "authorization" not in str(result).lower() + assert "fake-ark-key" not in str(result) + + +class TestOfficialCostFormula: + def test_standard_720p_five_seconds_without_video(self): + tool = SeedanceArkVideo() + inputs = { + "prompt": "x", + "model_variant": "standard", + "duration": 5, + "resolution": "720p", + "aspect_ratio": "16:9", + } + assert tool.estimate_token_usage(inputs) == 108000 + assert tool.estimate_cost_cny(inputs) == pytest.approx(4.968) + + def test_input_video_duration_uses_video_price(self): + tool = SeedanceArkVideo() + inputs = { + "prompt": "x", + "operation": "reference_to_video", + "model_variant": "standard", + "duration": 5, + "resolution": "720p", + "aspect_ratio": "16:9", + "reference_video_urls": ["https://example.com/motion.mp4"], + "reference_video_durations": [2], + } + assert tool.estimate_token_usage(inputs) == 151200 + assert tool.estimate_cost_cny(inputs) == pytest.approx(4.2336) + + def test_missing_video_duration_uses_conservative_fifteen_seconds(self): + tool = SeedanceArkVideo() + inputs = { + "prompt": "x", + "operation": "reference_to_video", + "model_variant": "standard", + "duration": 5, + "resolution": "720p", + "aspect_ratio": "16:9", + "reference_video_urls": ["https://example.com/motion.mp4"], + } + assert tool.estimate_token_usage(inputs) == 432000 + assert tool.estimate_cost_cny(inputs) == pytest.approx(12.096) + + def test_custom_endpoint_cost_is_unknown_not_standard_price(self): + tool = SeedanceArkVideo() + inputs = { + "prompt": "x", + "model": "ep-custom-account-pricing", + "duration": 5, + "resolution": "720p", + "aspect_ratio": "16:9", + } + with pytest.raises(ValueError, match="pricing is unknown"): + tool.estimate_cost_cny(inputs) + dry = tool.dry_run(inputs) + assert dry["valid"] is False + assert "pricing is unknown" in dry["error"] + + def test_custom_endpoint_requires_price_before_paid_post( + self, monkeypatch + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fail_network(*args, **kwargs): + raise AssertionError("unknown custom price must precede paid POST") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "model": "ep-custom-account-pricing", + "prompt": "x", + } + ) + assert result.success is False + assert "pricing is unknown" in result.error + + def test_custom_endpoint_accepts_explicit_price(self): + tool = SeedanceArkVideo() + cost = tool.estimate_cost_cny( + { + "model": "ep-custom-account-pricing", + "prompt": "x", + "duration": 5, + "resolution": "720p", + "aspect_ratio": "16:9", + "custom_price_cny_per_million_tokens": 50, + } + ) + assert cost == pytest.approx(5.4) + + @pytest.mark.parametrize("custom_price", ["nan", "inf", "-inf"]) + def test_non_finite_custom_price_never_reaches_paid_post( + self, monkeypatch, custom_price + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + + def fail_network(*args, **kwargs): + raise AssertionError("non-finite custom price reached paid POST") + + monkeypatch.setattr("requests.post", fail_network) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "model": "ep-custom-account-pricing", + "custom_price_cny_per_million_tokens": custom_price, + "prompt": "x", + } + ) + assert result.success is False + assert "finite" in result.error + + def test_valid_audio_data_uri_reaches_mocked_create( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("ARK_API_KEY", "fake-ark-key") + audio = tmp_path / "two-seconds.wav" + with wave.open(str(audio), "wb") as writer: + writer.setnchannels(1) + writer.setsampwidth(2) + writer.setframerate(8_000) + writer.writeframes(b"\x00\x00" * 16_000) + audio_uri = ( + "data:audio/wav;base64," + + base64.b64encode(audio.read_bytes()).decode("ascii") + ) + observed = {} + + def fake_post(url, **kwargs): + observed["payload"] = kwargs["json"] + return _FakeResponse({"id": "task-audio-data-uri"}) + + monkeypatch.setattr("requests.post", fake_post) + result = SeedanceArkVideo().execute( + { + "task_action": "create", + "prompt": "match this audio", + "operation": "reference_to_video", + "reference_image_url": "https://example.com/look.png", + "reference_audio_url": audio_uri, + } + ) + assert result.success is True + audio_items = [ + item + for item in observed["payload"]["content"] + if item["type"] == "audio_url" + ] + assert audio_items[0]["audio_url"]["url"] == audio_uri diff --git a/tests/tools/test_video_selector_routing.py b/tests/tools/test_video_selector_routing.py index 162e907d..5b61b34f 100644 --- a/tests/tools/test_video_selector_routing.py +++ b/tests/tools/test_video_selector_routing.py @@ -23,7 +23,7 @@ from typing import Any import pytest -from tools.base_tool import ToolStatus +from tools.base_tool import ToolResult, ToolStatus from tools.video.video_selector import VideoSelector @@ -54,6 +54,7 @@ class _StubTool: self._status = status self._cost = cost self._runtime = runtime + self.last_execute_inputs: dict[str, Any] | None = None # --- BaseTool surface used by the selector ------------------------------- def get_status(self) -> ToolStatus: @@ -78,6 +79,10 @@ class _StubTool: def estimate_runtime(self, inputs: dict[str, Any]) -> float: return self._runtime + def execute(self, inputs: dict[str, Any]) -> ToolResult: + self.last_execute_inputs = dict(inputs) + return ToolResult(success=True, data={}) + # ProviderScore.weighted_score is a read-only computed property, so we can't # override it per-instance. Instead _ScoreStub exposes the same attribute surface @@ -279,3 +284,40 @@ def test_estimate_cost_zero_when_no_providers(): sel = VideoSelector() sel._providers = lambda: [] # type: ignore[assignment] assert sel.estimate_cost({"prompt": "x"}) == 0.0 + + +def test_ark_local_reference_routes_without_fal_upload(rankings, monkeypatch, tmp_path): + """An explicit Ark route preserves the local path for Ark's own encoder.""" + ark = _StubTool("seedance_ark", "ark") + ark.input_schema = { + "properties": { + "prompt": {}, + "reference_image_path": {}, + "reference_image_url": {}, + } + } + rankings.append(_ScoreStub("seedance_ark", "ark", 0.99)) + + def fail_upload(*args, **kwargs): + raise AssertionError("Ark local references must never be uploaded via FAL") + + monkeypatch.setattr("tools.video._shared.upload_image_fal", fail_upload) + image_path = tmp_path / "anchor.png" + image_path.write_bytes(b"not-read-by-selector") + + selector = VideoSelector() + selector._providers = lambda: [ark] # type: ignore[assignment] + result = selector.execute({ + "prompt": "motion", + "operation": "image_to_video", + "preferred_provider": "ark", + "allowed_providers": ["ark"], + "reference_image_path": str(image_path), + }) + + assert result.success is True + assert ark.last_execute_inputs is not None + assert ark.last_execute_inputs["reference_image_path"] == str(image_path) + assert "image_url" not in ark.last_execute_inputs + assert result.data["selected_tool"] == "seedance_ark" + assert result.data["selected_provider"] == "ark" diff --git a/tools/video/seedance_ark.py b/tools/video/seedance_ark.py new file mode 100644 index 00000000..8412d8a1 --- /dev/null +++ b/tools/video/seedance_ark.py @@ -0,0 +1,1554 @@ +"""Direct Volcengine Ark adapter for the Seedance 2.0 model family. + +The Ark API is asynchronous: create a task, poll its status, then download the +24-hour result URL immediately. This provider is intentionally independent +from the existing fal.ai and Replicate Seedance adapters. +""" + +from __future__ import annotations + +import base64 +import binascii +import io +import math +import mimetypes +import os +import re +import shutil +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +class SeedanceArkVideo(BaseTool): + """Generate Seedance 2.0 video through Volcengine Ark's official REST API.""" + + name = "seedance_ark" + version = "0.1.0" + tier = ToolTier.GENERATE + capability = "video_generation" + provider = "ark" + stability = ToolStability.BETA + execution_mode = ExecutionMode.ASYNC + determinism = Determinism.STOCHASTIC + runtime = ToolRuntime.API + + BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" + MODEL_IDS = { + "standard": "doubao-seedance-2-0-260128", + "fast": "doubao-seedance-2-0-fast-260128", + "mini": "doubao-seedance-2-0-mini-260615", + } + TASK_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") + TERMINAL_STATUSES = frozenset( + {"succeeded", "failed", "cancelled", "expired"} + ) + IMAGE_SUFFIX_TO_MIME = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".gif": "image/gif", + } + AUDIO_SUFFIX_TO_MIME = { + ".wav": "audio/wav", + ".mp3": "audio/mp3", + } + MAX_IMAGE_BYTES = 30 * 1024 * 1024 + MAX_AUDIO_BYTES = 15 * 1024 * 1024 + MAX_REQUEST_BYTES = 64 * 1024 * 1024 + + # Official 2026-07-26 on-demand prices, CNY per million completion tokens. + PRICE_CNY_PER_MILLION = { + "standard": { + "without_video": { + "480p": 46.0, + "720p": 46.0, + "1080p": 51.0, + "4k": 26.0, + }, + "with_video": { + "480p": 28.0, + "720p": 28.0, + "1080p": 31.0, + "4k": 16.0, + }, + }, + "fast": { + "without_video": {"480p": 37.0, "720p": 37.0}, + "with_video": {"480p": 22.0, "720p": 22.0}, + }, + "mini": { + "without_video": {"480p": 23.0, "720p": 23.0}, + "with_video": {"480p": 14.0, "720p": 14.0}, + }, + } + OUTPUT_DIMENSIONS = { + "480p": { + "16:9": (864, 496), + "4:3": (752, 560), + "1:1": (640, 640), + "3:4": (560, 752), + "9:16": (496, 864), + "21:9": (992, 432), + }, + "720p": { + "16:9": (1280, 720), + "4:3": (1112, 834), + "1:1": (960, 960), + "3:4": (834, 1112), + "9:16": (720, 1280), + "21:9": (1470, 630), + }, + "1080p": { + "16:9": (1920, 1080), + "4:3": (1664, 1248), + "1:1": (1440, 1440), + "3:4": (1248, 1664), + "9:16": (1080, 1920), + "21:9": (2206, 946), + }, + "4k": { + "16:9": (3840, 2160), + "4:3": (3326, 2494), + "1:1": (2880, 2880), + "3:4": (2494, 3326), + "9:16": (2160, 3840), + "21:9": (4398, 1886), + }, + } + + dependencies = ["env:ARK_API_KEY"] + install_instructions = ( + "Set ARK_API_KEY to the API Key body from Volcengine Ark (without " + "the 'Bearer ' prefix). Optional: ARK_SEEDANCE_MODEL and ARK_BASE_URL." + ) + agent_skills = ["seedance-2-0", "ai-video-gen"] + + capabilities = [ + "text_to_video", + "image_to_video", + "reference_to_video", + "task_create", + "task_query", + "task_cancel", + ] + supports = { + "text_to_video": True, + "image_to_video": True, + "reference_to_video": True, + "multiple_reference_images": True, + "reference_image": True, + "reference_video": True, + "reference_audio": True, + "native_audio": True, + "local_image_data_uri": True, + "local_audio_data_uri": True, + "local_video": False, + "return_last_frame": True, + "web_search": True, + "aspect_ratio": True, + } + best_for = [ + "direct Volcengine Ark Seedance 2.0 generation without a gateway", + "multimodal reference video with native synchronized audio", + "Standard 1080p or 4K output and Fast/Mini lower-cost output", + ] + not_good_for = [ + "offline generation", + "unapproved paid generation", + "direct local reference-video upload", + ] + fallback_tools = ["seedance_video", "seedance_replicate", "jimeng_video"] + + input_schema = { + "type": "object", + "properties": { + "task_action": { + "type": "string", + "enum": ["generate", "create", "query", "cancel"], + "default": "generate", + }, + "task_id": { + "type": "string", + "description": "Required for task_action=query/cancel.", + }, + "prompt": {"type": "string"}, + "operation": { + "type": "string", + "enum": ["text_to_video", "image_to_video", "reference_to_video"], + "default": "text_to_video", + }, + "model_variant": { + "type": "string", + "enum": ["standard", "fast", "mini"], + "default": "standard", + }, + "model": { + "type": "string", + "description": ( + "Optional Ark Model ID or Endpoint ID. Overrides " + "ARK_SEEDANCE_MODEL and model_variant." + ), + }, + "custom_price_cny_per_million_tokens": { + "type": "number", + "exclusiveMinimum": 0, + "description": ( + "Required for a custom Endpoint or unknown future model " + "so budget checks never treat unknown pricing as free." + ), + }, + "duration": { + "description": "Integer seconds 4-15, or -1/'auto'.", + "default": 5, + }, + "aspect_ratio": { + "type": "string", + "enum": [ + "adaptive", + "21:9", + "16:9", + "4:3", + "1:1", + "3:4", + "9:16", + ], + "default": "16:9", + }, + "resolution": { + "type": "string", + "enum": ["480p", "720p", "1080p", "4k"], + "default": "720p", + }, + "generate_audio": {"type": "boolean", "default": True}, + "watermark": {"type": "boolean", "default": False}, + "return_last_frame": {"type": "boolean", "default": False}, + "callback_url": {"type": "string"}, + "execution_expires_after": { + "type": "integer", + "minimum": 3600, + "maximum": 259200, + }, + "priority": {"type": "integer", "minimum": 0, "maximum": 9}, + "safety_identifier": {"type": "string", "maxLength": 64}, + "web_search": { + "type": "boolean", + "description": "Officially limited to pure text input.", + }, + "reference_image_path": {"type": "string"}, + "reference_image_url": {"type": "string"}, + "reference_image_paths": { + "type": "array", + "items": {"type": "string"}, + }, + "reference_image_urls": { + "type": "array", + "items": {"type": "string"}, + }, + "end_image_path": {"type": "string"}, + "end_image_url": {"type": "string"}, + "reference_video_url": {"type": "string"}, + "reference_video_urls": { + "type": "array", + "items": {"type": "string"}, + }, + "reference_video_path": { + "type": "string", + "description": "Rejected: Ark does not document video Base64.", + }, + "reference_video_durations": { + "type": "array", + "items": {"type": "number", "minimum": 2, "maximum": 15}, + "description": "Optional durations used for preflight cost estimation.", + }, + "reference_audio_url": {"type": "string"}, + "reference_audio_urls": { + "type": "array", + "items": {"type": "string"}, + }, + "reference_audio_path": {"type": "string"}, + "reference_audio_paths": { + "type": "array", + "items": {"type": "string"}, + }, + "reference_audio_durations": { + "type": "array", + "items": {"type": "number", "minimum": 2, "maximum": 15}, + "description": ( + "Optional durations for remote audio preflight; local " + "audio is probed automatically." + ), + }, + "poll_interval_seconds": { + "type": "number", + "minimum": 0, + "maximum": 60, + "default": 3, + }, + "timeout_seconds": { + "type": "number", + "minimum": 1, + "default": 1200, + }, + "output_path": {"type": "string"}, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=1, + ram_mb=512, + vram_mb=0, + disk_mb=2048, + network_required=True, + ) + retry_policy = RetryPolicy( + max_retries=2, + backoff_seconds=2.0, + retryable_errors=["rate_limit", "timeout", "server_error"], + ) + idempotency_key_fields = [ + "prompt", + "operation", + "model_variant", + "model", + "duration", + "aspect_ratio", + "resolution", + "generate_audio", + "watermark", + "reference_image_url", + "reference_image_path", + "reference_image_urls", + "reference_image_paths", + "reference_video_urls", + "reference_audio_urls", + ] + side_effects = [ + "submits a paid task to the Volcengine Ark API", + "writes the completed video to output_path", + ] + user_visible_verification = [ + "Watch the downloaded clip for visual continuity and synchronized audio", + "Confirm the local artifact before the 24-hour remote URL expires", + ] + + def _get_api_key(self) -> str | None: + return os.environ.get("ARK_API_KEY") + + def _get_base_url(self) -> str: + base_url = os.environ.get("ARK_BASE_URL", self.BASE_URL).rstrip("/") + if not base_url.startswith("https://"): + raise ValueError("ARK_BASE_URL must be an https:// URL") + return base_url + + def get_status(self) -> ToolStatus: + api_key = self._get_api_key() + if not api_key or api_key.lower().startswith("bearer "): + return ToolStatus.UNAVAILABLE + return ToolStatus.AVAILABLE + + def estimate_token_usage(self, inputs: dict[str, Any]) -> int: + """Estimate billable completion tokens using Ark's published formula.""" + duration = self._normalize_duration(inputs.get("duration", 5)) + output_seconds = 15 if duration == -1 else duration + video_refs = list(inputs.get("reference_video_urls") or []) + if inputs.get("reference_video_url"): + video_refs.append(inputs["reference_video_url"]) + video_durations = list( + inputs.get("reference_video_durations") or [] + ) + # A video reference changes both the token formula and the price tier. + # When duration metadata is absent, use the official 15-second combined + # maximum as a conservative preflight upper bound instead of reporting + # a deceptively cheap output-only estimate. + if video_refs and len(video_durations) != len(video_refs): + input_video_seconds = 15.0 + else: + input_video_seconds = sum(float(value) for value in video_durations) + resolution = str(inputs.get("resolution", "720p")).lower() + ratio = str(inputs.get("aspect_ratio", "16:9")) + if ratio == "adaptive": + ratio = "16:9" + width, height = self.OUTPUT_DIMENSIONS[resolution][ratio] + return round( + (input_video_seconds + output_seconds) + * width + * height + * 24 + / 1024 + ) + + def estimate_cost_cny(self, inputs: dict[str, Any]) -> float: + model, variant = self._resolve_model(inputs) + del model + if variant is None: + rate = self._get_custom_price(inputs, required=True) + return round( + self.estimate_token_usage(inputs) * rate / 1_000_000, + 4, + ) + resolution = str(inputs.get("resolution", "720p")).lower() + with_video = bool( + inputs.get("reference_video_url") + or inputs.get("reference_video_urls") + ) + condition = "with_video" if with_video else "without_video" + try: + rate = self.PRICE_CNY_PER_MILLION[variant][condition][resolution] + except KeyError: + # A custom Endpoint/Model may have different pricing. Returning + # zero is safer than presenting a fabricated official estimate. + return 0.0 + return round(self.estimate_token_usage(inputs) * rate / 1_000_000, 4) + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + cny_per_usd = self._get_cny_per_usd() + return round(self.estimate_cost_cny(inputs) / cny_per_usd, 4) + + @staticmethod + def _get_custom_price( + inputs: dict[str, Any], *, required: bool + ) -> float: + try: + raw = inputs["custom_price_cny_per_million_tokens"] + except KeyError as exc: + if not required: + return 0.0 + raise ValueError( + "pricing is unknown for a custom Ark Endpoint/Model; " + "set custom_price_cny_per_million_tokens before a paid create" + ) from exc + try: + value = float(raw) + except (TypeError, ValueError) as exc: + raise ValueError( + "custom_price_cny_per_million_tokens must be a finite " + "number greater than 0" + ) from exc + if not math.isfinite(value) or value <= 0: + raise ValueError( + "custom_price_cny_per_million_tokens must be a finite " + "number greater than 0" + ) + return value + + @staticmethod + def _get_cny_per_usd() -> float: + try: + value = float(os.environ.get("ARK_CNY_PER_USD", "7.2")) + except (TypeError, ValueError) as exc: + raise ValueError( + "ARK_CNY_PER_USD must be a finite number greater than 0" + ) from exc + if not math.isfinite(value) or value <= 0: + raise ValueError( + "ARK_CNY_PER_USD must be a finite number greater than 0" + ) + return value + + def estimate_runtime(self, inputs: dict[str, Any]) -> float: + _, variant = self._resolve_model(inputs) + return 90.0 if variant in {"fast", "mini"} else 180.0 + + def dry_run(self, inputs: dict[str, Any]) -> dict[str, Any]: + """Validate and estimate locally; never submit Ark's paid POST.""" + action = str(inputs.get("task_action", "generate")) + result: dict[str, Any] = { + "tool": self.name, + "task_action": action, + "status": self.get_status().value, + "would_execute": False, + "paid_submission": False, + "api_contract": { + "create": f"POST {self.BASE_URL}/contents/generations/tasks", + "query": ( + f"GET {self.BASE_URL}/contents/generations/tasks/" + "{task_id}" + ), + "cancel": ( + f"DELETE {self.BASE_URL}/contents/generations/tasks/" + "{task_id}" + ), + }, + } + try: + base_url = self._get_base_url() + api_key = self._get_api_key() + if api_key and api_key.lower().startswith("bearer "): + raise ValueError( + "ARK_API_KEY must contain only the API Key body; remove " + "the 'Bearer ' prefix" + ) + result["api_contract"] = { + "create": ( + f"POST {base_url}/contents/generations/tasks" + ), + "query": ( + f"GET {base_url}/contents/generations/tasks/" + "{task_id}" + ), + "cancel": ( + f"DELETE {base_url}/contents/generations/tasks/" + "{task_id}" + ), + } + if action in {"query", "cancel"}: + self._validate_task_id(inputs.get("task_id")) + else: + payload = self._build_payload(inputs) + result.update( + { + "model": payload["model"], + "operation": inputs.get( + "operation", "text_to_video" + ), + "resolution": payload.get("resolution", "720p"), + "ratio": payload.get("ratio", "16:9"), + "duration": payload.get("duration", 5), + "generate_audio": payload.get( + "generate_audio", True + ), + "media_counts": self._media_counts(payload["content"]), + "estimated_tokens": self.estimate_token_usage(inputs), + "estimated_cost_cny": self.estimate_cost_cny(inputs), + "estimated_cost_usd": self.estimate_cost(inputs), + "cost_estimate_note": ( + "Official formula estimate; actual usage comes " + "from usage.completion_tokens and minimum billable " + "tokens may apply. Missing reference-video duration " + "is estimated at the official 15-second maximum." + ), + "cost_estimate_status": ( + "unknown_custom_model" + if self._resolve_model(inputs)[1] is None + else "estimated" + ), + } + ) + result["valid"] = True + except (TypeError, ValueError, OSError) as exc: + result["valid"] = False + result["error"] = self._safe_error(exc) + return result + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + """Create, query, cancel, or synchronously finish an Ark task.""" + started = time.time() + action = str(inputs.get("task_action", "generate")) + task_id: str | None = None + estimated_cost_usd = 0.0 + try: + if action not in {"generate", "create", "query", "cancel"}: + raise ValueError( + "task_action must be generate, create, query, or cancel" + ) + if action in {"query", "cancel"}: + self._validate_task_id(inputs.get("task_id")) + else: + payload = self._build_payload(inputs) + # Complete all local cost/config parsing before the paid POST. + # A malformed exchange-rate override must never create an + # untracked task and then fail while constructing ToolResult. + estimated_cost_usd = self.estimate_cost(inputs) + except (TypeError, ValueError, OSError) as exc: + return ToolResult(success=False, error=self._safe_error(exc)) + + api_key = self._get_api_key() + if not api_key: + return ToolResult( + success=False, + error="ARK_API_KEY not set. " + self.install_instructions, + ) + if api_key.lower().startswith("bearer "): + return ToolResult( + success=False, + error=( + "ARK_API_KEY must contain only the API Key body; remove " + "the 'Bearer ' prefix" + ), + ) + + try: + if action == "query": + task = self._query_task(str(inputs["task_id"]), api_key) + actual_cost_usd = self._cost_from_task(task, inputs) + cost_status = ( + "actual_from_usage" + if actual_cost_usd is not None + else "unknown_custom_model_or_missing_usage" + ) + return ToolResult( + success=True, + data={ + "task": task, + "cost_estimate_status": cost_status, + }, + cost_usd=actual_cost_usd, # type: ignore[arg-type] + model=task.get("model"), + ) + + if action == "cancel": + task_id = str(inputs["task_id"]) + self._cancel_task(task_id, api_key) + return ToolResult( + success=True, + data={ + "task_id": task_id, + "status": "cancel_requested", + }, + ) + + task_id = self._create_task(payload, api_key) + model = str(payload["model"]) + if action == "create": + return ToolResult( + success=True, + data={ + "task_id": task_id, + "status": "submitted", + "provider": self.provider, + "model": model, + }, + cost_usd=estimated_cost_usd, + model=model, + ) + + task = self._poll_task(task_id, api_key, inputs) + status = str(task.get("status", "")).lower() + if status != "succeeded": + detail = self._task_error(task) + safe_detail = ( + self._safe_error(RuntimeError(detail), api_key) + if detail + else "" + ) + return ToolResult( + success=False, + data={"task_id": task_id, "status": status}, + error=( + f"Ark Seedance task {status or 'failed'}" + + (f": {safe_detail}" if safe_detail else "") + ), + duration_seconds=round(time.time() - started, 2), + model=str(task.get("model") or model), + ) + + content = task.get("content") or {} + video_url = content.get("video_url") + if not video_url: + raise RuntimeError( + "Ark task succeeded without content.video_url" + ) + output_path = Path( + inputs.get("output_path", "seedance_ark_output.mp4") + ) + self._download_video(str(video_url), output_path) + + from tools.video._shared import probe_output + + probed = probe_output(output_path) + cost_usd = self._cost_from_task(task, inputs) + return ToolResult( + success=True, + data={ + "provider": self.provider, + "task_id": task_id, + "status": status, + "model": task.get("model") or model, + "prompt": inputs.get("prompt"), + "operation": inputs.get( + "operation", "text_to_video" + ), + "video_url": video_url, + "last_frame_url": content.get("last_frame_url"), + "output": str(output_path), + "output_path": str(output_path), + "format": "mp4", + "resolution": task.get( + "resolution", payload.get("resolution") + ), + "aspect_ratio": task.get( + "ratio", payload.get("ratio") + ), + "duration": task.get( + "duration", payload.get("duration") + ), + "generate_audio": task.get("generate_audio"), + "usage": task.get("usage") or {}, + "estimated_cost_cny": self._cost_from_task_cny( + task, inputs + ), + **probed, + }, + artifacts=[str(output_path)], + cost_usd=cost_usd, + duration_seconds=round(time.time() - started, 2), + model=str(task.get("model") or model), + ) + except Exception as exc: + error_data: dict[str, Any] = {} + if task_id: + error_data = { + "task_id": task_id, + "status": "submitted_result_unknown", + "recovery_action": "query", + } + elif action in {"query", "cancel"} and inputs.get("task_id"): + error_data = {"task_id": str(inputs["task_id"])} + return ToolResult( + success=False, + data=error_data, + error=( + "Ark Seedance request failed: " + f"{self._safe_error(exc, api_key)}" + ), + duration_seconds=round(time.time() - started, 2), + ) + + def _build_payload(self, inputs: dict[str, Any]) -> dict[str, Any]: + operation = str(inputs.get("operation", "text_to_video")) + if operation not in { + "text_to_video", + "image_to_video", + "reference_to_video", + }: + raise ValueError( + "operation must be text_to_video, image_to_video, or " + "reference_to_video" + ) + + model, variant = self._resolve_model(inputs) + resolution = str(inputs.get("resolution", "720p")).lower() + if resolution not in self.OUTPUT_DIMENSIONS: + raise ValueError( + "resolution must be 480p, 720p, 1080p, or 4k" + ) + if variant in {"fast", "mini"} and resolution not in { + "480p", + "720p", + }: + raise ValueError( + f"{variant} supports only 480p or 720p resolution" + ) + + ratio = str(inputs.get("aspect_ratio", "16:9")) + valid_ratios = { + "adaptive", + "21:9", + "16:9", + "4:3", + "1:1", + "3:4", + "9:16", + } + if ratio not in valid_ratios: + raise ValueError( + "aspect_ratio must be adaptive, 21:9, 16:9, 4:3, " + "1:1, 3:4, or 9:16" + ) + + duration = self._normalize_duration(inputs.get("duration", 5)) + prompt = str(inputs.get("prompt") or "").strip() + content: list[dict[str, Any]] = [] + if prompt: + content.append({"type": "text", "text": prompt}) + + if inputs.get("reference_video_path"): + raise ValueError( + "reference_video_path is not supported by Ark; upload the " + "video to a public/signed HTTPS URL or Ark asset first" + ) + + if operation == "text_to_video": + if not prompt: + raise ValueError("prompt is required for text_to_video") + if self._has_any_media(inputs): + raise ValueError( + "text_to_video does not accept reference media; use " + "image_to_video or reference_to_video" + ) + elif operation == "image_to_video": + first_refs = self._single_image_refs(inputs) + if len(first_refs) != 1: + raise ValueError( + "image_to_video requires exactly one reference image" + ) + content.append( + self._image_content(first_refs[0], role="first_frame") + ) + end_refs = [ + value + for value in ( + inputs.get("end_image_url"), + inputs.get("end_image_path"), + ) + if value + ] + if len(end_refs) > 1: + raise ValueError( + "provide only one of end_image_url/end_image_path" + ) + if end_refs: + content.append( + self._image_content(end_refs[0], role="last_frame") + ) + else: + image_refs = list(inputs.get("reference_image_urls") or []) + image_refs.extend(inputs.get("reference_image_paths") or []) + if inputs.get("reference_image_url"): + image_refs.append(inputs["reference_image_url"]) + if inputs.get("reference_image_path"): + image_refs.append(inputs["reference_image_path"]) + if len(image_refs) > 9: + raise ValueError( + "reference_to_video accepts at most 9 reference images" + ) + + video_refs = list(inputs.get("reference_video_urls") or []) + if inputs.get("reference_video_url"): + video_refs.append(inputs["reference_video_url"]) + if len(video_refs) > 3: + raise ValueError( + "reference_to_video accepts at most 3 reference videos" + ) + self._validate_remote_refs(video_refs, "reference video") + video_durations = list( + inputs.get("reference_video_durations") or [] + ) + if video_durations: + if len(video_durations) != len(video_refs): + raise ValueError( + "reference_video_durations must match the number of " + "reference videos" + ) + if any( + float(value) < 2 or float(value) > 15 + for value in video_durations + ): + raise ValueError( + "each reference video duration must be 2 to 15 seconds" + ) + if sum(float(value) for value in video_durations) > 15: + raise ValueError( + "all reference videos together must be at most " + "15 seconds" + ) + + audio_refs = list(inputs.get("reference_audio_urls") or []) + audio_refs.extend(inputs.get("reference_audio_paths") or []) + if inputs.get("reference_audio_url"): + audio_refs.append(inputs["reference_audio_url"]) + if inputs.get("reference_audio_path"): + audio_refs.append(inputs["reference_audio_path"]) + if len(audio_refs) > 3: + raise ValueError( + "reference_to_video accepts at most 3 reference audio " + "clips" + ) + audio_durations = list( + inputs.get("reference_audio_durations") or [] + ) + if audio_durations: + if len(audio_durations) != len(audio_refs): + raise ValueError( + "reference_audio_durations must match the number of " + "reference audio clips" + ) + if any( + float(value) < 2 or float(value) > 15 + for value in audio_durations + ): + raise ValueError( + "each reference audio duration must be 2 to 15 seconds" + ) + if sum(float(value) for value in audio_durations) > 15: + raise ValueError( + "all reference audio clips together must be at most " + "15 seconds" + ) + local_audio_durations = [ + duration + for ref in audio_refs + if ( + duration := self._local_or_data_audio_duration(str(ref)) + ) + is not None + ] + if sum(local_audio_durations) > 15: + raise ValueError( + "all local reference audio clips together must be at " + "most 15 seconds" + ) + if audio_refs and not (image_refs or video_refs): + raise ValueError( + "reference audio requires at least one reference image " + "or video" + ) + if not (image_refs or video_refs): + raise ValueError( + "reference_to_video requires at least one image or video" + ) + + content.extend( + self._image_content(ref, role="reference_image") + for ref in image_refs + ) + content.extend( + { + "type": "video_url", + "video_url": {"url": str(ref)}, + "role": "reference_video", + } + for ref in video_refs + ) + content.extend( + self._audio_content(ref, role="reference_audio") + for ref in audio_refs + ) + + if inputs.get("web_search") and len(content) != 1: + raise ValueError( + "web_search is supported only for pure text input" + ) + + payload: dict[str, Any] = { + "model": model, + "content": content, + "duration": duration, + "ratio": ratio, + "resolution": resolution, + "generate_audio": bool(inputs.get("generate_audio", True)), + "watermark": bool(inputs.get("watermark", False)), + "return_last_frame": bool( + inputs.get("return_last_frame", False) + ), + } + optional = ( + "callback_url", + "execution_expires_after", + "priority", + "safety_identifier", + ) + for key in optional: + if inputs.get(key) is not None: + payload[key] = inputs[key] + if inputs.get("web_search"): + payload["tools"] = [{"type": "web_search"}] + + self._validate_optional_parameters(payload) + self._validate_request_size(payload) + return payload + + def _resolve_model( + self, inputs: dict[str, Any] + ) -> tuple[str, str | None]: + variant = str(inputs.get("model_variant", "standard")).lower() + if variant not in self.MODEL_IDS: + raise ValueError("model_variant must be standard, fast, or mini") + model = str( + inputs.get("model") + or os.environ.get("ARK_SEEDANCE_MODEL") + or self.MODEL_IDS[variant] + ) + if not model or any(char.isspace() for char in model): + raise ValueError("model must be a non-empty Ark Model/Endpoint ID") + for known_variant, known_model in self.MODEL_IDS.items(): + if model == known_model: + return model, known_variant + # Endpoint IDs and future model IDs can have account-specific pricing. + # Keep the caller's requested model, but never pretend its price is the + # public price of model_variant. + return model, None + + @staticmethod + def _normalize_duration(value: Any) -> int: + if value == "auto": + return -1 + if isinstance(value, bool): + raise ValueError("duration must be an integer from 4 to 15 or -1") + try: + duration = int(value) + except (TypeError, ValueError) as exc: + raise ValueError( + "duration must be an integer from 4 to 15 or -1" + ) from exc + if str(value).strip() not in {str(duration), "auto"}: + raise ValueError( + "duration must be an integer from 4 to 15 or -1" + ) + if duration != -1 and not 4 <= duration <= 15: + raise ValueError("duration must be between 4 and 15 or -1") + return duration + + @staticmethod + def _single_image_refs(inputs: dict[str, Any]) -> list[Any]: + refs = [ + inputs.get("reference_image_url") or inputs.get("image_url"), + inputs.get("reference_image_path") or inputs.get("image_path"), + ] + return [ref for ref in refs if ref] + + @staticmethod + def _has_any_media(inputs: dict[str, Any]) -> bool: + keys = ( + "reference_image_url", + "reference_image_path", + "reference_image_urls", + "reference_image_paths", + "reference_video_url", + "reference_video_path", + "reference_video_urls", + "reference_audio_url", + "reference_audio_path", + "reference_audio_urls", + "reference_audio_paths", + "image_url", + "image_path", + "end_image_url", + "end_image_path", + ) + return any(inputs.get(key) for key in keys) + + def _image_content(self, ref: Any, *, role: str) -> dict[str, Any]: + url = self._media_url( + ref, + suffix_to_mime=self.IMAGE_SUFFIX_TO_MIME, + max_bytes=self.MAX_IMAGE_BYTES, + label="image", + ) + return { + "type": "image_url", + "image_url": {"url": url}, + "role": role, + } + + def _audio_content(self, ref: Any, *, role: str) -> dict[str, Any]: + url = self._media_url( + ref, + suffix_to_mime=self.AUDIO_SUFFIX_TO_MIME, + max_bytes=self.MAX_AUDIO_BYTES, + label="audio", + ) + return { + "type": "audio_url", + "audio_url": {"url": url}, + "role": role, + } + + def _media_url( + self, + ref: Any, + *, + suffix_to_mime: dict[str, str], + max_bytes: int, + label: str, + ) -> str: + value = str(ref) + if value.startswith("data:"): + return self._validate_data_uri( + value, + suffix_to_mime=suffix_to_mime, + max_bytes=max_bytes, + label=label, + ) + if self._is_remote_or_asset(value): + return value + path = Path(value).expanduser() + if not path.is_file(): + raise ValueError( + f"{label} reference must be a public URL, asset:// ID, " + f"or existing local file: {value}" + ) + size = path.stat().st_size + if size >= max_bytes: + raise ValueError( + f"local {label} must be smaller than " + f"{max_bytes // (1024 * 1024)} MB" + ) + suffix = path.suffix.lower() + mime = suffix_to_mime.get(suffix) + if not mime: + guessed, _ = mimetypes.guess_type(path.name) + mime = guessed if guessed in suffix_to_mime.values() else None + if not mime: + formats = ", ".join(sorted(suffix_to_mime)) + raise ValueError( + f"unsupported local {label} format; expected one of {formats}" + ) + if label == "image": + self._validate_local_image(path) + elif label == "audio": + self._probe_local_audio_duration(path) + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + return f"data:{mime};base64,{encoded}" + + def _validate_local_image(self, path: Path) -> None: + self._validate_image_bytes(path.read_bytes(), str(path)) + + @staticmethod + def _validate_image_bytes(data: bytes, source: str) -> None: + try: + from PIL import Image + + with Image.open(io.BytesIO(data)) as image: + width, height = image.size + image.verify() + except Exception as exc: + raise ValueError( + f"image is unreadable or corrupt: {source}" + ) from exc + if not (300 <= width <= 6000 and 300 <= height <= 6000): + raise ValueError( + "local image width and height must each be 300 to 6000 pixels" + ) + ratio = width / height + if not 0.4 <= ratio <= 2.5: + raise ValueError( + "local image width/height ratio must be between 0.4 and 2.5" + ) + + def _validate_data_uri( + self, + value: str, + *, + suffix_to_mime: dict[str, str], + max_bytes: int, + label: str, + ) -> str: + mime, decoded = self._decode_data_uri( + value, + suffix_to_mime=suffix_to_mime, + max_bytes=max_bytes, + label=label, + ) + if label == "image": + self._validate_image_bytes(decoded, "Data URI") + elif label == "audio": + self._probe_audio_bytes(decoded, mime) + return value + + @staticmethod + def _decode_data_uri( + value: str, + *, + suffix_to_mime: dict[str, str], + max_bytes: int, + label: str, + ) -> tuple[str, bytes]: + match = re.fullmatch( + r"data:([a-z]+/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=]+)", + value, + flags=re.IGNORECASE, + ) + if not match: + raise ValueError( + f"{label} Data URI must use a supported MIME type and " + "strict base64 encoding" + ) + mime = match.group(1).lower() + if mime not in set(suffix_to_mime.values()): + raise ValueError(f"unsupported {label} Data URI MIME type: {mime}") + try: + decoded = base64.b64decode(match.group(2), validate=True) + except (ValueError, binascii.Error) as exc: + raise ValueError(f"invalid base64 in {label} Data URI") from exc + if len(decoded) >= max_bytes: + raise ValueError( + f"decoded {label} Data URI must be smaller than " + f"{max_bytes // (1024 * 1024)} MB" + ) + return mime, decoded + + def _local_or_data_audio_duration(self, value: str) -> float | None: + if value.startswith("data:"): + mime, decoded = self._decode_data_uri( + value, + suffix_to_mime=self.AUDIO_SUFFIX_TO_MIME, + max_bytes=self.MAX_AUDIO_BYTES, + label="audio", + ) + return self._probe_audio_bytes(decoded, mime) + if self._is_remote_or_asset(value): + return None + return self._probe_local_audio_duration(Path(value).expanduser()) + + def _probe_audio_bytes(self, decoded: bytes, mime: str) -> float: + suffix = ".wav" if mime == "audio/wav" else ".mp3" + # Windows does not allow ffprobe to reopen a NamedTemporaryFile while + # Python still holds the file handle. Close it before probing, then + # remove it explicitly on every path. + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp: + temp.write(decoded) + temp.flush() + temp_path = Path(temp.name) + try: + return self._probe_local_audio_duration(temp_path) + finally: + temp_path.unlink(missing_ok=True) + + @staticmethod + def _probe_local_audio_duration(path: Path) -> float: + if not path.is_file(): + raise ValueError(f"local audio file does not exist: {path}") + ffprobe = shutil.which("ffprobe") + if not ffprobe: + raise ValueError( + "ffprobe is required to validate local reference audio" + ) + try: + proc = subprocess.run( + [ + ffprobe, + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(path), + ], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + duration = float(proc.stdout.strip()) if proc.returncode == 0 else 0 + except (OSError, ValueError, subprocess.SubprocessError) as exc: + raise ValueError( + f"failed to probe local reference audio: {path}" + ) from exc + if not 2 <= duration <= 15: + raise ValueError( + "each local reference audio clip must be 2 to 15 seconds" + ) + return duration + + @staticmethod + def _is_remote_or_asset(value: str) -> bool: + return value.startswith(("https://", "http://", "asset://")) + + def _validate_remote_refs( + self, refs: list[Any], label: str + ) -> None: + for ref in refs: + value = str(ref) + if not value.startswith(("https://", "http://", "asset://")): + raise ValueError( + f"{label} must be a public/signed URL or asset:// ID; " + "Ark does not document video Base64 or local paths" + ) + + def _validate_optional_parameters( + self, payload: dict[str, Any] + ) -> None: + callback = payload.get("callback_url") + if callback is not None and not str(callback).startswith( + ("https://", "http://") + ): + raise ValueError("callback_url must be an http(s) URL") + expires = payload.get("execution_expires_after") + if expires is not None and not 3600 <= int(expires) <= 259200: + raise ValueError( + "execution_expires_after must be between 3600 and 259200" + ) + priority = payload.get("priority") + if priority is not None and not 0 <= int(priority) <= 9: + raise ValueError("priority must be between 0 and 9") + safety = payload.get("safety_identifier") + if safety is not None and len(str(safety)) > 64: + raise ValueError("safety_identifier must be at most 64 characters") + + def _validate_request_size(self, payload: dict[str, Any]) -> None: + # Base64 dominates request size; summing encoded media is a conservative + # lower-cost check that avoids building a second complete JSON string. + encoded_bytes = 0 + for item in payload["content"]: + media = ( + item.get("image_url") + or item.get("audio_url") + or item.get("video_url") + or {} + ) + url = str(media.get("url", "")) + if url.startswith("data:"): + encoded_bytes += len(url.encode("ascii")) + if encoded_bytes >= self.MAX_REQUEST_BYTES: + raise ValueError("Ark request body must be smaller than 64 MB") + + @staticmethod + def _media_counts(content: list[dict[str, Any]]) -> dict[str, int]: + return { + kind: sum(1 for item in content if item.get("type") == kind) + for kind in ("text", "image_url", "video_url", "audio_url") + } + + def _headers(self, api_key: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + def _create_task( + self, payload: dict[str, Any], api_key: str + ) -> str: + import requests + + response = requests.post( + f"{self._get_base_url()}/contents/generations/tasks", + headers=self._headers(api_key), + json=payload, + timeout=30, + ) + self._raise_for_status(response) + data = response.json() + task_id = data.get("id") + self._validate_task_id(task_id) + return str(task_id) + + def _query_task(self, task_id: str, api_key: str) -> dict[str, Any]: + import requests + + url = f"{self._get_base_url()}/contents/generations/tasks/{task_id}" + response = None + for attempt in range(self.retry_policy.max_retries + 1): + try: + response = requests.get( + url, + headers=self._headers(api_key), + timeout=30, + ) + retryable_status = ( + response.status_code == 429 + or response.status_code >= 500 + ) + if ( + retryable_status + and attempt < self.retry_policy.max_retries + ): + time.sleep( + self.retry_policy.backoff_seconds * (2**attempt) + ) + continue + self._raise_for_status(response) + break + except requests.RequestException: + if attempt >= self.retry_policy.max_retries: + raise + time.sleep(self.retry_policy.backoff_seconds * (2**attempt)) + if response is None: + raise RuntimeError("Ark query returned no response") + data = response.json() + if not isinstance(data, dict): + raise RuntimeError("Ark query returned a non-object response") + return data + + def _cancel_task(self, task_id: str, api_key: str) -> None: + import requests + + response = requests.delete( + f"{self._get_base_url()}/contents/generations/tasks/{task_id}", + headers=self._headers(api_key), + timeout=30, + ) + # The official DELETE success body is undefined and may be empty. + self._raise_for_status(response) + + def _poll_task( + self, + task_id: str, + api_key: str, + inputs: dict[str, Any], + ) -> dict[str, Any]: + interval = float(inputs.get("poll_interval_seconds", 3)) + timeout = float(inputs.get("timeout_seconds", 1200)) + if not 0 <= interval <= 60: + raise ValueError("poll_interval_seconds must be between 0 and 60") + if timeout <= 0: + raise ValueError("timeout_seconds must be greater than 0") + deadline = time.monotonic() + timeout + while True: + task = self._query_task(task_id, api_key) + status = str(task.get("status", "")).lower() + if status in self.TERMINAL_STATUSES: + return task + if status not in {"queued", "running"}: + raise RuntimeError( + f"Ark returned unknown task status: {status or ''}" + ) + if time.monotonic() >= deadline: + raise TimeoutError( + f"Ark task {task_id} did not finish within {timeout}s" + ) + time.sleep(interval) + + @staticmethod + def _download_video(video_url: str, output_path: Path) -> None: + import requests + + response = requests.get(video_url, timeout=120) + response.raise_for_status() + output_path.parent.mkdir(parents=True, exist_ok=True) + partial = output_path.with_name(output_path.name + ".part") + partial.write_bytes(response.content) + partial.replace(output_path) + + @staticmethod + def _validate_task_id(task_id: Any) -> None: + value = str(task_id or "") + if not SeedanceArkVideo.TASK_ID_PATTERN.fullmatch(value): + raise ValueError("task_id is missing or invalid") + + @staticmethod + def _raise_for_status(response: Any) -> None: + try: + response.raise_for_status() + except Exception as exc: + detail = "" + try: + payload = response.json() + error = payload.get("error") if isinstance(payload, dict) else None + if isinstance(error, dict): + detail = ": ".join( + str(error.get(key)) + for key in ("code", "message") + if error.get(key) + ) + except Exception: + pass + raise RuntimeError( + f"{exc}" + (f"; {detail}" if detail else "") + ) from exc + + @staticmethod + def _task_error(task: dict[str, Any]) -> str: + error = task.get("error") + if isinstance(error, dict): + return ": ".join( + str(error.get(key)) + for key in ("code", "message") + if error.get(key) + ) + return str(error or "") + + def _cost_from_task_cny( + self, task: dict[str, Any], inputs: dict[str, Any] + ) -> float | None: + usage = task.get("usage") or {} + tokens = usage.get("completion_tokens") + if ( + not isinstance(tokens, (int, float)) + or not math.isfinite(float(tokens)) + or float(tokens) < 0 + ): + return None + model_inputs = dict(inputs) + if task.get("model"): + model_inputs["model"] = task["model"] + _, variant = self._resolve_model(model_inputs) + resolution = str( + task.get("resolution") + or inputs.get("resolution", "720p") + ).lower() + condition = ( + "with_video" + if inputs.get("input_includes_video") + or inputs.get("reference_video_url") + or inputs.get("reference_video_urls") + else "without_video" + ) + if variant is None: + if "custom_price_cny_per_million_tokens" not in inputs: + return None + rate = self._get_custom_price(inputs, required=False) + else: + try: + rate = self.PRICE_CNY_PER_MILLION[variant][condition][ + resolution + ] + except KeyError: + return None + return round(float(tokens) * rate / 1_000_000, 4) + + def _cost_from_task( + self, task: dict[str, Any], inputs: dict[str, Any] + ) -> float | None: + cost_cny = self._cost_from_task_cny(task, inputs) + if cost_cny is None: + return None + cny_per_usd = self._get_cny_per_usd() + return round(cost_cny / cny_per_usd, 4) + + def _safe_error( + self, exc: Exception, api_key: str | None = None + ) -> str: + message = str(exc) + secrets = { + value + for value in (api_key, self._get_api_key()) + if value + } + for secret in secrets: + message = message.replace(secret, "[redacted]") + message = re.sub( + r"data:(?:image|audio)/[^;\s]+;base64,[A-Za-z0-9+/=]+", + "[redacted data URI]", + message, + flags=re.IGNORECASE, + ) + + def redact_url(match: re.Match[str]) -> str: + raw = match.group(0) + trailing = "" + while raw and raw[-1] in ".,;:)]}": + trailing = raw[-1] + trailing + raw = raw[:-1] + try: + parsed = urlsplit(raw) + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + safe = urlunsplit( + ( + parsed.scheme, + host, + parsed.path, + "[redacted]" if parsed.query else "", + "", + ) + ) + return safe + trailing + except ValueError: + return "[redacted URL]" + + message = re.sub( + r"https?://[^\s'\"<>]+", + redact_url, + message, + flags=re.IGNORECASE, + ) + message = re.sub( + r"(?i)authorization\s*[:=]\s*bearer\s+\S+", + "Authorization: Bearer [redacted]", + message, + ) + return message