Skill: jinhuadeng/volcengine-video-studio (version 0.1.4)

--- name: volcengine-video-studio description: Production-ready Volcengine/ARK video generation for prompt-to-video, image-to-video, and draft-video refinement. Use when users want a hands-on skill that can submit jobs, poll status, inspect task payloads, and download finished outputs with Seedance-compatible models. 中文：适合火山引擎 / ARK 兼容接口的视频生成实战工作流；支持文生视频、图生视频、草稿视频优化、任务提交与轮询、结果检查和成片下载。 --- # volcengine-video-studio Use this skill to actually submit, poll, inspect, and download Volcengine / ARK video generation jobs instead of only drafting prompts. ## 中文说明 这是一个面向 **火山引擎 / ARK 兼容视频生成接口** 的生产可用型 skill，重点不是只写视频提示词，而是把整条生成链路真正跑通，从提交任务、轮询状态，到拿回结果、下载成片，都能一把做完。 它特别适合下面这些对外场景： - 文生视频 - 图生视频 - 草稿视频二次优化 - 提交生成任务并持续轮询状态 - 检查任务返回内容与原始 payload - 将生成好的视频自动下载到本地 如果你想要的是一个**能真实发起任务、能追踪任务进度、能拿到最终视频文件、适合实战交付**的火山视频 skill，这个版本就是为这种需求准备的。 Treat the prompt and optional reference media as runtime input for each task. Do not reuse documentation example prompts or example media in a real generation request unless the user explicitly asks for that exact example. ## What this skill is good at - text-to-video from a task-specific prompt - image-to-video using a local file, data URL, or remote image URL - draft-video refinement with a guiding prompt - polling existing tasks and downloading finished outputs - switching between a faster default model and a stronger alternate model ## Default path Run the bundled script with the actual prompt for the current task: ```bash python3 scripts/generate_video.py "当前任务的实际视频提示词" ``` To switch to the alternate model for one run: ```bash VOLCENGINE_VIDEO_MODEL=doubao-seedance-1-5-pro-251215 \ python3 scripts/generate_video.py "当前任务的实际视频提示词" ``` By default the script: - submits the task - polls until the task finishes - extracts returned video URLs from the task payload - downloads generated files into `~/Desktop/volcengine-videos/<timestamp>-<slug>/` ## Required config The script reads config from env vars: - `VOLCENGINE_API_KEY` or `ARK_API_KEY` - `VOLCENGINE_VIDEO_MODEL` (recommended) - `VOLCENGINE_VIDEO_ENDPOINT` or `VOLCENGINE_ENDPOINT` or `ARK_BASE_URL` Recommended video models: - [REDACTED_SECRET] — default, faster iteration - `doubao-seedance-1-5-pro-251215` — alternate higher-tier option Default behavior: - if `VOLCENGINE_VIDEO_MODEL` is unset, the script defaults to [REDACTED_SECRET] - avoid relying on `VOLCENGINE_MODEL` for video runs when that env var is also used for image generation Typical endpoint: - `https://ark.cn-beijing.volces.com/api/v3` The script calls: - `POST /contents/generations/tasks` - `GET /contents/generations/tasks/{task_id}` ## Common workflows ### 1. Text to video ```bash python3 scripts/generate_video.py "为当前需求编写的视频提示词" \ --ratio 16:9 \ --duration 5 ``` ### 2. Image to video ```bash python3 scripts/generate_video.py "描述你希望图片如何动起来" \ --image ~/Desktop/reference.png \ --ratio 9:16 \ --duration 5 ``` ### 3. Draft/sample video to final video ```bash python3 scripts/generate_video.py "说明要保留什么、增强什么" \ --video ~/Desktop/draft.mp4 ``` ### 4. Inspect an existing task only ```bash python3 scripts/generate_video.py --task-id <task_id> --wait false ``` ### 5. Submit only, do not wait ```bash python3 scripts/generate_video.py "当前任务的实际视频提示词" \ --ratio 16:9 \ --wait false ``` ### 6. Use raw content JSON when the target model needs a custom body ```bash python3 scripts/generate_video.py --content-json '[ {"type":"text","text":"当前任务的实际视频提示词"}, {"type":"image_url","image_url":{"url":"https://example.com/reference.png"}} ]' ``` ## Local media support For `--image` and `--video`: - local file path → converted to `data:` URL automatically - `https://...` URL → sent as-is - `data:...` URL → sent as-is This makes local reference media usable without manual upload steps. ## Key options - `--ratio 16:9|9:16|1:1|adaptive` - `--duration <seconds>` - `--frames <count>` - `--seed <int>` - `--resolution <value>` - `--camera-fixed true|false` - `--watermark true|false` - `--callback-url <https-url>` - `--poll-interval <seconds>` - `--timeout <seconds>` - `--download-results true|false` - `--download-dir <path>` - `--print-request` ## Execution checklist 1. Confirm whether the user wants text-to-video, image-to-video, or draft-video refinement. 2. Treat the prompt as task-specific runtime input; never carry over example prompt text or example media into a real run unless explicitly requested. 3. Choose prompt-first mode by default; use `--content-json` only when the API shape must be customized. 4. Pass local reference media directly with `--image` or `--video`. 5. Prefer `--duration` for whole-second clips and `--frames` only when finer control is required. 6. Poll by default so the final answer includes actual output URLs or downloaded files. 7. Mention the saved file paths when downloads are enabled. 8. If the API returns an unexpected structure, surface the raw JSON instead of guessing. ## Troubleshooting - Missing key → set `VOLCENGINE_API_KEY` - Missing or wrong model → set `VOLCENGINE_VIDEO_MODEL` - If a video run accidentally picks an image model (for example `doubao-seedream-4-5`) → explicitly set `VOLCENGINE_VIDEO_MODEL` instead of reusing `VOLCENGINE_MODEL` - Missing endpoint → set `VOLCENGINE_VIDEO_ENDPOINT` - 401/403 → key invalid or missing permission - 404 → endpoint wrong or region mismatch - 400 → unsupported model/parameter combination - Task remains queued too long → check quota, rate limit, or model availability - No obvious video URL in response → inspect `raw` ## References - `references/sources.md` - `references/api-notes.md` ing `VOLCENGINE_MODEL` - Missing endpoint → set `VOLCENGINE_VIDEO_ENDPOINT` - 401/403 → key invalid or missing permission - 404 → endpoint wrong or region mismatch - 400 → unsupported model/parameter combination - Task remains queued too long → check quota, rate limit, or model availability - No obvious video URL in response → inspect `raw` ## References - `references/sources.md` - `references/api-notes.md`

path: _meta.json
content: slug: volcengine-video-studio
version: 0.1.4

path: agents/openai.yaml
content: interface:
  display_name: "Volcengine Video Studio"
  short_description: "Production-ready prompt, image, and draft-video generation on Volcengine"
  default_prompt: "Use $volcengine-video-studio to run a real Volcengine video generation task from the user's prompt, image, or draft clip, then inspect or download the result if needed."


path: references/api-notes.md
content: # API notes

Use these notes when the task needs deeper parameter tuning or response debugging.

## Endpoint pattern

Base endpoint typically resolves to:

- `https://ark.cn-beijing.volces.com/api/v3`

Task endpoints:

- `POST /contents/generations/tasks`
- `GET /contents/generations/tasks/{task_id}`

## Practical payload shape used by this skill

The bundled script sends a request body shaped like:

```json
{
  "model": "<model-id>",
  "content": [
    {"type": "text", "text": "..."},
    {"type": "image_url", "image_url": {"url": "..."}},
    {"type": "video_url", "video_url": {"url": "..."}}
  ],
  "ratio": "16:9",
  "duration": 5,
  "frames": 120,
  "seed": 123,
  "resolution": "1080p",
  "camera_fixed": false,
  "watermark": false,
  "callback_url": "https://example.com/callback"
}
```

## Parameter notes pulled from docs

- `duration` and `frames` are alternatives; `frames` takes priority when both are supplied.
- `duration` is generally the easier control for whole-second clips.
- `camera_fixed` defaults to `false`.
- `watermark` defaults to `false`.
- `ratio=adaptive` can be model-dependent; for some text-to-video flows the platform chooses the final ratio automatically.
- Model support varies. When a model rejects a parameter, use the returned HTTP error or raw task payload instead of inventing behavior.

## Response handling guidance

Task payloads may evolve. Do not hardcode a single finished-response schema in explanations.

The bundled script therefore:

- extracts `task_id` from common keys (`task_id`, `id`, `taskId`)
- extracts `status` from common keys (`status`, `state`)
- recursively scans the returned task payload for likely output URLs
- preserves the full raw payload in stdout JSON for debugging

## Safe customization rule

If the public API shape changes or a model needs a slightly different `content` object schema, prefer passing `--content-json` with the exact body you want rather than rewriting the script on every run.


path: references/sources.md
content: # Sources

- Volcengine Ark 视频生成 API 文档（创建任务、查询任务、查询任务列表、取消或删除任务）
- Volcengine 视频生成模型与 Seedance 系列提示词/参数说明
- Existing local packaging/reference pattern from `volcengine-image-studio`


path: scripts/generate_video.py
content: #!/usr/bin/env python3
import argparse
import base64
import json
import mimetypes
import os
import pathlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional


def getenv(*names: str, default: str = "") -> str:
    for name in names:
        value = os.getenv(name)
        if value:
            return value
    return default


def parse_bool(value: Any) -> bool:
    return str(value).strip().lower() in {"1", "true", "yes", "on"}


def ensure_dir(path: pathlib.Path) -> None:
    path.mkdir(parents=True, exist_ok=True)


def safe_stem(text: str) -> str:
    cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in text.strip())
    cleaned = "-".join(filter(None, cleaned.split("-")))
    return (cleaned[:48] or "video")


def build_submit_url(base: str) -> str:
    base = base.strip().rstrip("/")
    if not base:
        raise SystemExit("Missing VOLCENGINE_ENDPOINT / ARK_BASE_URL / OPENAI_BASE_URL")
    if base.endswith("/contents/generations/tasks"):
        return base
    if base.endswith("/api/v3"):
        return base + "/contents/generations/tasks"
    return base + "/api/v3/contents/generations/tasks"


def build_task_url(base: str, task_id: str) -> str:
    return build_submit_url(base) + "/" + urllib.parse.quote(task_id)


def local_file_to_data_url(path_str: str) -> str:
    path = pathlib.Path(path_str).expanduser().resolve()
    if not path.exists():
        raise SystemExit(f"Local file not found: {path}")
    mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
    encoded = base64.b64encode(path.read_bytes()).decode("ascii")
    return f"data:{mime};base64,{encoded}"


def normalize_media_value(value: str) -> str:
    if value.startswith("http://") or value.startswith("https://") or value.startswith("data:"):
        return value
    return local_file_to_data_url(value)


def add_text_content(items: List[Dict[str, Any]], text: str) -> None:
    if text.strip():
        items.append({"type": "text", "text": text.strip()})


def add_image_content(items: List[Dict[str, Any]], value: str) -> None:
    url = normalize_media_value(value)
    items.append({"type": "image_url", "image_url": {"url": url}})


def add_video_content(items: List[Dict[str, Any]], value: str) -> None:
    url = normalize_media_value(value)
    items.append({"type": "video_url", "video_url": {"url": url}})


def extract_status(payload: Dict[str, Any]) -> Optional[str]:
    for key in ("status", "state"):
        if key in payload and payload[key]:
            return str(payload[key])
    data = payload.get("data")
    if isinstance(data, dict):
        return extract_status(data)
    if isinstance(data, list) and data and isinstance(data[0], dict):
        return extract_status(data[0])
    return None


def extract_task_id(payload: Dict[str, Any]) -> Optional[str]:
    for key in ("task_id", "id", "taskId"):
        if key in payload and payload[key]:
            return str(payload[key])
    data = payload.get("data")
    if isinstance(data, dict):
        return extract_task_id(data)
    if isinstance(data, list) and data and isinstance(data[0], dict):
        return extract_task_id(data[0])
    return None


def walk_urls(node: Any, found: List[str]) -> None:
    if isinstance(node, dict):
        for key, value in node.items():
            lowered = str(key).lower()
            if isinstance(value, str) and value.startswith(("http://", "https://")):
                if any(token in lowered for token in ["video", "file", "url", "output", "media", "cover"]):
                    found.append(value)
            else:
                walk_urls(value, found)
    elif isinstance(node, list):
        for item in node:
            walk_urls(item, found)


def download_file(url: str, out_dir: pathlib.Path, filename: str) -> pathlib.Path:
    ensure_dir(out_dir)
    out = out_dir / filename
    with urllib.request.urlopen(url, timeout=600) as resp:
        out.write_bytes(resp.read())
    return out


def request_json(req: urllib.request.Request, timeout: int) -> Dict[str, Any]:
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        raw = resp.read().decode("utf-8")
    return json.loads(raw)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Submit and optionally wait for a Volcengine/ARK video generation task")
    parser.add_argument("prompt", nargs="?", default="", help="Main text prompt. Ignored when --content-json is provided.")
    parser.add_argument(
        "--model",
        default=getenv(
            "VOLCENGINE_VIDEO_MODEL",
            default="doubao-seedance-1-0-pro-fast-251015",
        ),
        help="Video model id. Recommended: doubao-seedance-1-0-pro-fast-251015 (default) or doubao-seedance-1-5-pro-251215.",
    )
    parser.add_argument("--endpoint", default=getenv("VOLCENGINE_VIDEO_ENDPOINT", "VOLCENGINE_ENDPOINT", "ARK_BASE_URL", "OPENAI_BASE_URL"))
    parser.add_argument("--api-key", default=getenv("VOLCENGINE_API_KEY", "ARK_API_KEY", "OPENAI_API_KEY"))
    parser.add_argument("--image", action="append", default=[], help="Reference image URL, data URL, or local path. Repeat flag for multiple images.")
    parser.add_argument("--video", action="append", default=[], help="Reference video URL, data URL, or local path (for draft/sample video workflows).")
    parser.add_argument("--content-json", default="", help="Raw JSON array/object for the API content field. Overrides prompt/image/video assembly.")
    parser.add_argument("--ratio", default=os.getenv("VOLCENGINE_VIDEO_RATIO", ""), help="Examples: 16:9, 9:16, 1:1, adaptive")
    parser.add_argument("--duration", type=int, default=int(os.getenv("VOLCENGINE_VIDEO_DURATION", "0")), help="Seconds, usually 2-12. If 0, omit.")
    parser.add_argument("--frames", type=int, default=int(os.getenv("VOLCENGINE_VIDEO_FRAMES", "0")), help="Alternative to duration.")
    parser.add_argument("--seed", type=int, default=int(os.getenv("VOLCENGINE_VIDEO_SEED", "0")), help="If 0, omit.")
    parser.add_argument("--resolution", default=os.getenv("VOLCENGINE_VIDEO_RESOLUTION", ""))
    parser.add_argument("--camera-fixed", default=os.getenv("VOLCENGINE_VIDEO_CAMERA_FIXED", "false"))
    parser.add_argument("--watermark", default=os.getenv("VOLCENGINE_VIDEO_WATERMARK", "false"))
    parser.add_argument("--callback-url", default=os.getenv("VOLCENGINE_VIDEO_CALLBACK_URL", ""))
    parser.add_argument("--task-id", default="", help="Skip submission and inspect an existing task id.")
    parser.add_argument("--wait", default=os.getenv("VOLCENGINE_VIDEO_WAIT", "true"), help="Whether to poll until completion.")
    parser.add_argument("--poll-interval", type=int, default=int(os.getenv("VOLCENGINE_VIDEO_POLL_INTERVAL", "8")))
    parser.add_argument("--timeout", type=int, default=int(os.getenv("VOLCENGINE_TIMEOUT", "900")))
    parser.add_argument("--download-results", default=os.getenv("VOLCENGINE_VIDEO_DOWNLOAD_RESULTS", "true"))
    parser.add_argument("--download-dir", default=os.getenv("VOLCENGINE_VIDEO_DOWNLOAD_DIR", str(pathlib.Path.home() / "Desktop" / "volcengine-videos")))
    parser.add_argument("--print-request", action="store_true", help="Print the assembled request body before submission.")
    return parser.parse_args()


def assemble_content(args: argparse.Namespace) -> Any:
    if args.content_json:
        parsed = json.loads(args.content_json)
        return parsed
    items: List[Dict[str, Any]] = []
    add_text_content(items, args.prompt)
    for image in args.image:
        add_image_content(items, image)
    for video in args.video:
        add_video_content(items, video)
    if not items:
        raise SystemExit("Provide a prompt and/or --image/--video, or pass --content-json.")
    return items


def assemble_payload(args: argparse.Namespace) -> Dict[str, Any]:
    payload: Dict[str, Any] = {
        "model": args.model,
        "content": assemble_content(args),
    }
    if args.ratio:
        payload["ratio"] = args.ratio
    if args.duration > 0:
        payload["duration"] = args.duration
    if args.frames > 0:
        payload["frames"] = args.frames
    if args.seed > 0:
        payload["seed"] = args.seed
    if args.resolution:
        payload["resolution"] = args.resolution
    payload["camera_fixed"] = parse_bool(args.camera_fixed)
    payload["watermark"] = parse_bool(args.watermark)
    if args.callback_url:
        payload["callback_url"] = args.callback_url
    return payload


def make_headers(api_key: str) -> Dict[str, str]:
    return {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}",
    }


def submit_task(args: argparse.Namespace, payload: Dict[str, Any]) -> Dict[str, Any]:
    req = urllib.request.Request(
        build_submit_url(args.endpoint),
        data=json.dumps(payload).encode("utf-8"),
        headers=make_headers(args.api_key),
        method="POST",
    )
    return request_json(req, min(args.timeout, 120))


def fetch_task(args: argparse.Namespace, task_id: str) -> Dict[str, Any]:
    req = urllib.request.Request(
        build_task_url(args.endpoint, task_id),
        headers=make_headers(args.api_key),
        method="GET",
    )
    return request_json(req, min(args.timeout, 120))


def finalize_result(args: argparse.Namespace, task_payload: Dict[str, Any], prompt_hint: str) -> Dict[str, Any]:
    urls: List[str] = []
    walk_urls(task_payload, urls)
    deduped_urls: List[str] = []
    seen = set()
    for url in urls:
        if url not in seen:
            seen.add(url)
            deduped_urls.append(url)

    downloads: List[str] = []
    if parse_bool(args.download_results) and deduped_urls:
        download_dir = pathlib.Path(args.download_dir).expanduser().resolve()
        stamp = int(time.time())
        stem = safe_stem(prompt_hint or extract_task_id(task_payload) or "video")
        run_dir = download_dir / f"{stamp}-{stem}"
        for idx, url in enumerate(deduped_urls, start=1):
            ext = pathlib.Path(url.split("?", 1)[0]).suffix or ".mp4"
            filename = f"{stamp}-{stem}-{idx}{ext}"
            downloads.append(str(download_file(url, run_dir, filename)))

    return {
        "ok": True,
        "task_id": extract_task_id(task_payload),
        "status": extract_status(task_payload),
        "videos": deduped_urls,
        "downloaded_files": downloads,
        "raw": task_payload,
    }


def main() -> int:
    args = parse_args()
    if not args.api_key:
        raise SystemExit("Missing API key. Set VOLCENGINE_API_KEY (or ARK_API_KEY / OPENAI_API_KEY).")
    if not args.endpoint:
        raise SystemExit("Missing endpoint. Set VOLCENGINE_VIDEO_ENDPOINT / VOLCENGINE_ENDPOINT / ARK_BASE_URL.")
    if not args.task_id and not args.model:
        raise SystemExit("Missing model. Set VOLCENGINE_VIDEO_MODEL / VOLCENGINE_MODEL / ARK_MODEL.")

    prompt_hint = args.prompt
    try:
        if args.task_id:
            latest = fetch_task(args, args.task_id)
        else:
            payload = assemble_payload(args)
            if args.print_request:
                print(json.dumps({"request": payload}, ensure_ascii=False, indent=2), file=sys.stderr)
            submit_response = submit_task(args, payload)
            task_id = extract_task_id(submit_response)
            if not task_id:
                print(json.dumps({"ok": False, "error": "No task id returned", "raw": submit_response}, ensure_ascii=False, indent=2))
                return 1
            latest = submit_response
            latest.setdefault("task_id", task_id)

        if not parse_bool(args.wait):
            print(json.dumps({"ok": True, "task_id": extract_task_id(latest), "status": extract_status(latest), "raw": latest}, ensure_ascii=False, indent=2))
            return 0

        task_id = extract_task_id(latest)
        if not task_id:
            print(json.dumps({"ok": False, "error": "No task id available for polling", "raw": latest}, ensure_ascii=False, indent=2))
            return 1

        deadline = time.time() + args.timeout
        terminal_ok = {"succeeded", "success", "completed", "done", "finished"}
        terminal_fail = {"failed", "error", "cancelled", "canceled", "deleted", "timeout"}
        while True:
            latest = fetch_task(args, task_id)
            status = (extract_status(latest) or "").strip().lower()
            if status in terminal_ok:
                print(json.dumps(finalize_result(args, latest, prompt_hint), ensure_ascii=False, indent=2))
                return 0
            if status in terminal_fail:
                print(json.dumps({"ok": False, "task_id": task_id, "status": extract_status(latest), "raw": latest}, ensure_ascii=False, indent=2))
                return 1
            if time.time() >= deadline:
                print(json.dumps({"ok": False, "task_id": task_id, "status": extract_status(latest), "error": f"Polling timed out after {args.timeout}s", "raw": latest}, ensure_ascii=False, indent=2))
                return 1
            time.sleep(max(1, args.poll_interval))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        print(json.dumps({"ok": False, "status": e.code, "error": body}, ensure_ascii=False, indent=2))
        return 1
    except json.JSONDecodeError as e:
        print(json.dumps({"ok": False, "error": f"Invalid JSON: {e}"}, ensure_ascii=False, indent=2))
        return 1
    except Exception as e:
        print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False, indent=2))
        return 1


if __name__ == "__main__":
    sys.exit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)