From 35ffce85e535779047cfc0579c650f6d8cea14c0 Mon Sep 17 00:00:00 2001 From: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:12:16 +0800 Subject: [PATCH] feat(office): add atlas video provider Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> --- .claude-plugin/marketplace.json | 4 +- office/.claude-plugin/plugin.json | 2 +- office/skills/generate-video/SKILL.md | 16 +- .../generate-video/scripts/generate_video.py | 151 +++++++++++++++--- office/tests/test_generate_video_atlas.py | 118 ++++++++++++++ 5 files changed, 259 insertions(+), 32 deletions(-) create mode 100644 office/tests/test_generate_video_atlas.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9971d405..bd26cca4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -63,7 +63,7 @@ { "name": "office", "displayName": "Office", - "version": "0.6.1", + "version": "0.6.2", "description": "Office productivity skills for patent applications, PRD generation, image and video generation, Feishu document creation, browser automation, Lark/Feishu CLI routing (denested sub-skill entries), Remotion programmatic video authoring, and AI writing trope detection", "author": { "name": "Frad LEE", @@ -245,4 +245,4 @@ "homepage": "https://github.com/FradSer/dotclaude/tree/main/memory" } ] -} \ No newline at end of file +} diff --git a/office/.claude-plugin/plugin.json b/office/.claude-plugin/plugin.json index 458440c6..5bfd5dc0 100644 --- a/office/.claude-plugin/plugin.json +++ b/office/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "office", "displayName": "Office", - "version": "0.6.1", + "version": "0.6.2", "license": "MIT", "description": "Office productivity skills for patent applications, PRD generation, image and video generation, Feishu document creation, browser automation, Lark/Feishu CLI routing (denested sub-skill entries), Remotion programmatic video authoring, and AI writing trope detection", "keywords": [ diff --git a/office/skills/generate-video/SKILL.md b/office/skills/generate-video/SKILL.md index 55784d0e..b0cc5f93 100644 --- a/office/skills/generate-video/SKILL.md +++ b/office/skills/generate-video/SKILL.md @@ -1,12 +1,12 @@ --- name: generate-video -description: Generate short videos from a text prompt or from still images using ByteDance Seedance on Volcengine Ark (火山方舟). Use this skill whenever the user wants to create, generate, render, or animate a video, clip, animation, motion graphic, or product demo — including text-to-video ("a drone shot over a forest at sunrise") and image-to-video, where a still becomes the first frame, or two stills are morphed start-to-end. Triggers include "generate a video", "make a clip", "animate this image", "turn this storyboard into video", "生成视频", "做个视频", "让这张图动起来", "图生视频", "首尾帧生成视频". Prefer this skill over describing a video in text. +description: Generate short videos from a text prompt or from still images using ByteDance Seedance on Volcengine Ark or Atlas Cloud. Use this skill whenever the user wants to create, generate, render, or animate a video, clip, animation, motion graphic, or product demo — including text-to-video ("a drone shot over a forest at sunrise") and image-to-video, where a still becomes the first frame, or two stills are morphed start-to-end. Triggers include "generate a video", "make a clip", "animate this image", "turn this storyboard into video", "生成视频", "做个视频", "让这张图动起来", "图生视频", "首尾帧生成视频". Prefer this skill over describing a video in text. user-invocable: true argument-hint: "\"PROMPT\" [-o out.mp4] [--first-frame img] [--last-frame img] [--duration 5] [--resolution 720p] [--ratio 16:9] [--no-audio] [--seed N]" allowed-tools: ["Read", "Write", "AskUserQuestion", "Bash(uv run:*)", "Bash(*/generate_video.py:*)"] --- -# Generate Video (Seedance on Volcengine Ark) +# Generate Video (Seedance) Turn a text prompt — optionally anchored by a first frame, a last frame, or reference images — into a short video. The script submits an async task, polls until it finishes, @@ -15,11 +15,13 @@ and downloads the `.mp4`. Generation takes a few minutes, so set expectations an ## Prerequisites - `uv` available (self-contained `uv run` script; deps install on first run). -- A Volcengine Ark key. Resolved progressively, so any one works: +- A key for the selected provider. Ark remains the default. Resolve it progressively from: - `export ARK_API_KEY=...` in the shell, or - a `.env` file (checked in order: `$PWD/.env`, then `${CLAUDE_PLUGIN_ROOT}/.env`), or - `--api-key ...` on the command line. + For Atlas Cloud, pass `--provider atlas` and configure `ATLASCLOUD_API_KEY` instead. + **CRITICAL** -- Never paste the API key into chat or commit a `.env`. If the key is missing, the script prints exactly how to set it — relay that instead of guessing. @@ -66,6 +68,7 @@ Flags: | `--no-audio` | Disable native audio (2.0 generates synced audio by default) | audio on | | `--seed` | Integer seed for reproducibility | random | | `--model` | `pro`, `fast`, `mini`, or a raw id (else `SEEDANCE_MODEL`) | `pro` | +| `--provider` | `ark` or `atlas` | `ark` | **Models** (pass the alias to `--model` or set `SEEDANCE_MODEL`): @@ -78,6 +81,11 @@ Flags: The script blocks while polling (status prints to stderr). For batch work, prefer longer single runs over many tiny ones — each task has fixed overhead, and the API bills per task. +Atlas Cloud uses the matching Seedance 2.0 text-to-video or image-to-video route. It accepts +one first frame and one optional last frame; use Ark for `reference_image` inputs. Set +`ATLAS_SEEDANCE_MODEL` or pass a raw Atlas model id to override the provider-specific alias. +The generation POST is sent once, then result polling stops after a bounded timeout. + ### 4. Report Tell the user the saved path and the settings used (duration, resolution). The output is an @@ -99,6 +107,6 @@ Two consequences worth knowing: ## Files -- `scripts/generate_video.py` — the generator (Seedance via Ark REST, async + poll + download). +- `scripts/generate_video.py` — the generator (Seedance via Ark or Atlas Cloud REST). - `references/prompting.md` — prompt-writing guide and full parameter reference. - `${CLAUDE_PLUGIN_ROOT}/lib/progressive_env.py` — shared progressive config resolver. diff --git a/office/skills/generate-video/scripts/generate_video.py b/office/skills/generate-video/scripts/generate_video.py index ee83aaf3..65a9bcfd 100755 --- a/office/skills/generate-video/scripts/generate_video.py +++ b/office/skills/generate-video/scripts/generate_video.py @@ -3,7 +3,7 @@ # requires-python = ">=3.10" # dependencies = ["requests>=2.31.0"] # /// -"""Generate video with ByteDance Seedance on Volcengine Ark (火山方舟). +"""Generate video with ByteDance Seedance on Volcengine Ark or Atlas Cloud. Submits an async generation task, polls until it succeeds, and downloads the result. Supports pure text-to-video and image-to-video, where reference images @@ -22,6 +22,9 @@ ARK_API_KEY required — Volcengine Ark key (https://console.volcengine.com/ark) SEEDANCE_MODEL default doubao-seedance-2-0-260128 — set this to switch model versions ARK_BASE_URL default https://ark.cn-beijing.volces.com/api/v3 — e.g. an int'l region + ATLASCLOUD_API_KEY required with --provider atlas + ATLAS_SEEDANCE_MODEL default bytedance/seedance-2.0/text-to-video + ATLAS_BASE_URL default https://api.atlascloud.ai/api/v1 """ import argparse @@ -58,8 +61,15 @@ } DEFAULT_MODEL = "pro" DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" +ATLAS_MODELS = { + "pro": "bytedance/seedance-2.0/text-to-video", + "fast": "bytedance/seedance-2.0-fast/text-to-video", + "mini": "bytedance/seedance-2.0-mini/text-to-video", +} +DEFAULT_ATLAS_BASE_URL = "https://api.atlascloud.ai/api/v1" ROLES = {"first_frame", "last_frame", "reference_image"} POLL_INTERVAL = 10 +ATLAS_MAX_POLLS = 120 def encode_image(path: str) -> str: @@ -103,6 +113,82 @@ def poll_task(session, base_url, task_id) -> dict: time.sleep(POLL_INTERVAL) +def unwrap_atlas_response(response: dict) -> dict: + data = response.get("data", response) + if not isinstance(data, dict): + fail(f"unexpected Atlas Cloud response: {response}") + return data + + +def atlas_model(model_sel: str, has_images: bool) -> str: + model = ATLAS_MODELS.get(model_sel, model_sel) + if has_images and model.endswith("/text-to-video"): + model = model.removesuffix("/text-to-video") + "/image-to-video" + return model + + +def build_atlas_payload(prompt: str, images: list[tuple[str, str]], args) -> dict: + by_role: dict[str, list[str]] = {} + for path, role in images: + by_role.setdefault(role, []).append(path) + if by_role.get("reference_image"): + fail("Atlas Cloud supports first/last frames here; use Ark for reference_image inputs") + if len(by_role.get("first_frame", [])) > 1 or len(by_role.get("last_frame", [])) > 1: + fail("Atlas Cloud accepts at most one first frame and one last frame") + if by_role.get("last_frame") and not by_role.get("first_frame"): + fail("Atlas Cloud requires a first frame when a last frame is provided") + + payload = { + "model": atlas_model(args.model_sel, bool(images)), + "prompt": prompt, + "ratio": args.ratio, + "duration": args.duration, + "resolution": args.resolution, + "watermark": args.watermark, + "generate_audio": args.audio, + } + if args.seed is not None: + payload["seed"] = args.seed + if by_role.get("first_frame"): + payload["image"] = encode_image(by_role["first_frame"][0]) + if by_role.get("last_frame"): + payload["last_image"] = encode_image(by_role["last_frame"][0]) + return payload + + +def create_atlas_task(session, base_url, payload) -> str: + # Generation is billable, so this POST is deliberately issued exactly once. + resp = session.post(f"{base_url}/model/generateVideo", json=payload, timeout=60) + if resp.status_code >= 400: + fail(f"task creation failed ({resp.status_code}): {resp.text}") + resp.raise_for_status() + result = unwrap_atlas_response(resp.json()) + task_id = result.get("id") + if not task_id: + fail(f"task creation returned no id: {result}") + return task_id + + +def poll_atlas_task(session, base_url, task_id) -> dict: + for attempt in range(ATLAS_MAX_POLLS): + if attempt: + time.sleep(POLL_INTERVAL) + try: + resp = session.get(f"{base_url}/model/prediction/{task_id}", timeout=30) + resp.raise_for_status() + except requests.RequestException as exc: + print(f" Poll attempt {attempt + 1} failed: {exc}", file=sys.stderr) + continue + result = unwrap_atlas_response(resp.json()) + status = result.get("status", "unknown") + print(f" Status: {status}", file=sys.stderr) + if status in ("completed", "succeeded"): + return result + if status in ("failed", "cancelled", "expired", "timeout"): + fail(f"task {status}: {result.get('error') or result}") + fail(f"task still processing after {ATLAS_MAX_POLLS * POLL_INTERVAL} seconds") + + def download(url: str, output: Path) -> None: resp = requests.get(url, timeout=300) resp.raise_for_status() @@ -129,7 +215,7 @@ def collect_images(args) -> list[tuple[str, str]]: def main() -> int: - ap = argparse.ArgumentParser(description="Generate video with Seedance on Volcengine Ark.") + ap = argparse.ArgumentParser(description="Generate video with Seedance on Ark or Atlas Cloud.") ap.add_argument("prompt", help="Text prompt describing the shot / motion.") ap.add_argument("-o", "--output", default="output.mp4", help="Output path (default: output.mp4)") ap.add_argument("--first-frame", metavar="IMG", help="Start-frame image (image-to-video).") @@ -143,47 +229,62 @@ def main() -> int: ap.add_argument("--no-audio", dest="audio", action="store_false", help="Disable native audio (Seedance 2.0 generates synced audio by default).") ap.add_argument("--seed", type=int, help="Seed for reproducible output (optional).") - ap.add_argument("--model", help="Model: pro|fast|mini alias, or a full id (else SEEDANCE_MODEL). Default: pro.") - ap.add_argument("--api-key", help="Override the API key (else ARK_API_KEY).") + ap.add_argument("--model", help="Model: pro|fast|mini alias or a provider model id (default: pro).") + ap.add_argument("--provider", choices=("ark", "atlas"), default="ark", + help="API provider (default: ark).") + ap.add_argument("--api-key", help="Override the selected provider's API key.") args = ap.parse_args() + key_var = "ATLASCLOUD_API_KEY" if args.provider == "atlas" else "ARK_API_KEY" + key_console = "https://www.atlascloud.ai/console" if args.provider == "atlas" else "https://console.volcengine.com/ark" try: api_key = resolve_secret( - "ARK_API_KEY", + key_var, cli=args.api_key, - hint="ARK_API_KEY is not set. Create one at https://console.volcengine.com/ark, " - "then `export ARK_API_KEY=...` or add it to a .env file.", + hint=f"{key_var} is not set. Create one at {key_console}, " + f"then `export {key_var}=...` or add it to a .env file.", ) except MissingConfig as e: return fail(str(e)) or 1 - model_sel = resolve("SEEDANCE_MODEL", cli=args.model, default=DEFAULT_MODEL) - model = MODELS.get(model_sel, model_sel) # expand alias; pass a raw id through - base_url = resolve("ARK_BASE_URL", default=DEFAULT_BASE_URL).rstrip("/") - images = collect_images(args) - payload = { - "model": model, - "content": build_content(args.prompt, images), - "ratio": args.ratio, - "duration": args.duration, - "resolution": args.resolution, - "watermark": args.watermark, - "generate_audio": args.audio, - } - if args.seed is not None: - payload["seed"] = args.seed + model_var = "ATLAS_SEEDANCE_MODEL" if args.provider == "atlas" else "SEEDANCE_MODEL" + args.model_sel = resolve(model_var, cli=args.model, default=DEFAULT_MODEL) + if args.provider == "atlas": + base_url = resolve("ATLAS_BASE_URL", default=DEFAULT_ATLAS_BASE_URL).rstrip("/") + payload = build_atlas_payload(args.prompt, images, args) + model = payload["model"] + else: + model = MODELS.get(args.model_sel, args.model_sel) + base_url = resolve("ARK_BASE_URL", default=DEFAULT_BASE_URL).rstrip("/") + payload = { + "model": model, + "content": build_content(args.prompt, images), + "ratio": args.ratio, + "duration": args.duration, + "resolution": args.resolution, + "watermark": args.watermark, + "generate_audio": args.audio, + } + if args.seed is not None: + payload["seed"] = args.seed session = requests.Session() session.headers.update({"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}) mode = "image-to-video" if images else "text-to-video" print(f"Creating {mode} task with {model} ({args.resolution}, {args.duration}s, {args.ratio})...", file=sys.stderr) - task_id = create_task(session, base_url, payload) + task_id = (create_atlas_task if args.provider == "atlas" else create_task)(session, base_url, payload) print(f" Task: {task_id}", file=sys.stderr) - result = poll_task(session, base_url, task_id) - video_url = (result.get("content") or {}).get("video_url") + result = (poll_atlas_task if args.provider == "atlas" else poll_task)(session, base_url, task_id) + if args.provider == "atlas": + outputs = result.get("outputs") or result.get("output") or [] + if isinstance(outputs, str): + outputs = [outputs] + video_url = outputs[0] if outputs else None + else: + video_url = (result.get("content") or {}).get("video_url") if not video_url: return fail(f"task succeeded but no video_url in response: {result}") or 1 diff --git a/office/tests/test_generate_video_atlas.py b/office/tests/test_generate_video_atlas.py new file mode 100644 index 00000000..5d6a8906 --- /dev/null +++ b/office/tests/test_generate_video_atlas.py @@ -0,0 +1,118 @@ +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + + +requests_stub = ModuleType("requests") +requests_stub.RequestException = type("RequestException", (Exception,), {}) +previous_requests = sys.modules.get("requests") +sys.modules["requests"] = requests_stub + +SCRIPT = ( + Path(__file__).parents[1] + / "skills" + / "generate-video" + / "scripts" + / "generate_video.py" +) +spec = importlib.util.spec_from_file_location("generate_video", SCRIPT) +generate_video = importlib.util.module_from_spec(spec) +spec.loader.exec_module(generate_video) +if previous_requests is None: + del sys.modules["requests"] +else: + sys.modules["requests"] = previous_requests + + +def args(model="mini"): + return SimpleNamespace( + model_sel=model, + ratio="16:9", + duration=5, + resolution="720p", + watermark=False, + audio=True, + seed=7, + ) + + +def test_builds_text_to_video_payload(): + payload = generate_video.build_atlas_payload("test prompt", [], args()) + + assert payload == { + "model": "bytedance/seedance-2.0-mini/text-to-video", + "prompt": "test prompt", + "ratio": "16:9", + "duration": 5, + "resolution": "720p", + "watermark": False, + "generate_audio": True, + "seed": 7, + } + + +def test_switches_alias_to_image_to_video(tmp_path): + frame = tmp_path / "frame.png" + frame.write_bytes(b"png") + + payload = generate_video.build_atlas_payload( + "animate", + [(str(frame), "first_frame")], + args("fast"), + ) + + assert payload["model"] == "bytedance/seedance-2.0-fast/image-to-video" + assert payload["image"].startswith("data:image/png;base64,") + + +def test_generation_post_is_issued_once(): + class Response: + status_code = 200 + text = "" + + def raise_for_status(self): + pass + + def json(self): + return {"code": 200, "data": {"id": "prediction-1"}} + + class Session: + posts = 0 + + def post(self, *unused_args, **unused_kwargs): + self.posts += 1 + return Response() + + session = Session() + task_id = generate_video.create_atlas_task(session, "https://example.test", {}) + + assert task_id == "prediction-1" + assert session.posts == 1 + + +def test_poll_retries_get_timeout(monkeypatch): + class Response: + def raise_for_status(self): + pass + + def json(self): + return {"data": {"status": "completed", "outputs": ["https://example.test/video.mp4"]}} + + class Session: + gets = 0 + + def get(self, *unused_args, **unused_kwargs): + self.gets += 1 + if self.gets == 1: + raise generate_video.requests.RequestException("timeout") + return Response() + + monkeypatch.setattr(generate_video, "POLL_INTERVAL", 0) + monkeypatch.setattr(generate_video, "ATLAS_MAX_POLLS", 2) + session = Session() + + result = generate_video.poll_atlas_task(session, "https://example.test", "prediction-1") + + assert result["status"] == "completed" + assert session.gets == 2