Skill: darker314159/runninghub-video (version 1.0.0)

--- name: runninghub-video description: Use RunningHub official standard-model APIs for image-to-video generation. Trigger when the user asks to use RunningHub, 可灵, Seedance, 万相, or other RunningHub video endpoints to turn one image or start/end frames into a video, especially when local files need to be uploaded first, tasks need polling via `/openapi/v2/query`, or completed videos should be downloaded automatically. --- # RunningHub Video Use this skill to submit image-to-video jobs to RunningHub, poll task status, and download the finished video locally. ## Quick Start 1. Confirm the user wants RunningHub image-to-video rather than HTML/CSS animation or local FFmpeg edits. 2. Use `scripts/runninghub_video.py` instead of hand-writing `curl` unless the user explicitly asks for raw API calls. 3. Accept either: - a public image URL - a local image path that should be uploaded through RunningHub's binary upload endpoint first 4. Wait for `/openapi/v2/query` unless the user explicitly asks for submit-only behavior. 5. Download the returned media immediately because RunningHub-hosted outputs and uploaded media links can expire. ## Default Workflow ### 1. Pick the model Use these stable shortcuts unless the user names a different endpoint: - `wan-2.2`: default choice for general image-to-video generation in this skill - `kling-v3.0-std`: strong alternative for high-quality single-image or start/end-frame generation - `seedance-2.0-global`: quality-oriented alternative with resolution and audio switches - `seedance-2.0-global-fast`: faster/cheaper Seedance variant - `wan-2.2`: Wan 2.2 image-to-video endpoint with RunningHub's numeric field names ### 2. Prepare the inputs - If the user provides a local file path, pass it directly to the script. The script uploads it to `POST /openapi/v2/media/upload/binary` and reuses the returned `download_url`. - If the user provides a public URL or a `data:` URI, pass it through unchanged. - If the user wants stronger transition control and the chosen model supports it, include an end frame. ### 3. Submit and wait Run the helper from the skill directory: ```powershell python "C:\Users\Administrator\.codex\skills\runninghub-video\scripts\runninghub_video.py" ` --image "C:\path\to\start.png" ` --prompt "镜头缓慢推进，人物抬头微笑，风吹动头发" ` --out-dir "C:\path\to\outputs" ``` For start/end frame generation: ```powershell python "C:\Users\Administrator\.codex\skills\runninghub-video\scripts\runninghub_video.py" ` --image "C:\path\to\start.png" ` --end-image "C:\path\to\end.png" ` --prompt "从平静站立过渡到转身回望，镜头平滑推进" ` --duration 5 ` --out-dir "C:\path\to\outputs" ``` ### 4. Return useful output When you finish, report: - which model/endpoint was used - the `taskId` - whether local images were uploaded first - the saved output path(s) - any prompt or parameter choices worth remembering ## Command Patterns ### Kling 3.0 std ```powershell python "C:\Users\Administrator\.codex\skills\runninghub-video\scripts\runninghub_video.py" ` --model kling-v3.0-std ` --image "C:\path\to\image.png" ` --prompt "电影感镜头，小幅推近，人物表情逐渐变化" ` --duration 5 ` --cfg-scale 0.8 ` --sound true ``` ### Seedance 2.0 global ```powershell python "C:\Users\Administrator\.codex\skills\runninghub-video\scripts\runninghub_video.py" ` --model seedance-2.0-global ` --image "C:\path\to\image.png" ` --prompt "书页翻动时，文字化作发光蝴蝶飞散" ` --resolution 720p ` --ratio adaptive ` --audio true ` --real-person-mode true ``` ### Wan 2.2 ```powershell python "C:\Users\Administrator\.codex\skills\runninghub-video\scripts\runninghub_video.py" ` --image "C:\path\to\image.png" ` --prompt "产品绕镜头缓慢旋转，补光扫过金属表面" ` --duration 5 ` --wan-resolution auto ``` ## Parameters Worth Tuning - `--prompt`: motion, camera movement, atmosphere, and audio intent - `--duration`: model-specific duration string; keep it to values shown in the official endpoint docs - `--end-image`: use when the endpoint supports start/end-frame control - `--out-dir`: always set an explicit output directory for easier follow-up work - `--submit-only`: use when the user wants a task id without waiting - `--poll-interval` and `--timeout`: useful for long renders ## Troubleshooting - If upload fails, verify the local file exists and the API key is valid. - If the task returns moderation or content-verification errors, keep the same image but soften the prompt or remove risky wording. - If the task succeeds but no download happens, inspect the raw query response and the returned `results` array. - If the user asks for a model not covered by the helper yet, read [references/api_reference.md](references/api_reference.md), then extend the script instead of crafting a one-off request. ## References - Read [references/api_reference.md](references/api_reference.md) for the exact endpoints, payload shapes, and polling behavior used by this skill.

path: references/api_reference.md
content: # RunningHub Video API Reference

This file captures the official RunningHub endpoints that the skill currently automates.

## Authentication

- Header: `Authorization: Bearer <API_KEY>`
- Content type for submit/query: `application/json`
- Content type for file upload: `multipart/form-data`

## Upload Local Media First

Use this when the user gives a local image path instead of a public URL.

- Endpoint: `POST https://www.runninghub.cn/openapi/v2/media/upload/binary`
- Form field: `file=@/path/to/image.png`
- Important response field: `data.download_url`

Observed from RunningHub official API pages:

- resource parameters can accept a public URL or a Base64 data URI
- the upload endpoint returns a reusable `download_url`
- uploaded links may expire after about 1 day on some endpoint pages, so download final outputs promptly

## Poll Task Status

- Endpoint: `POST https://www.runninghub.cn/openapi/v2/query`
- Body:

```json
{
  "taskId": "2009217245938851841"
}
```

Typical success shape:

```json
{
  "taskId": "2009191190196789249",
  "status": "SUCCESS",
  "errorCode": "",
  "errorMessage": "",
  "results": [
    {
      "url": "https://...",
      "outputType": "mp4"
    }
  ]
}
```

## Supported Model Mappings

### `kling-v3.0-std`

- Endpoint: `POST https://www.runninghub.cn/openapi/v2/kling-v3.0-std/image-to-video`
- Explicit example fields from the official doc:
  - `prompt`
  - `firstImageUrl`
  - `lastImageUrl`
  - `duration`
  - `cfgScale`
  - `sound`
  - `multiShot`
  - `shotType`

### `seedance-2.0-global`

- Endpoint: `POST https://www.runninghub.cn/openapi/v2/bytedance/seedance-2.0-global/image-to-video`
- Explicit example fields from the official doc:
  - `prompt`
  - `resolution`
  - `duration`
  - `firstFrameUrl`
  - `generateAudio`
  - `ratio`
  - `realPersonMode`
  - `conversionSlots`
  - `returnLastFrame`
- Inference from the doc description: the endpoint text says it supports both single-frame and start/end-frame generation, so `lastFrameUrl` is treated as a likely optional field when an end frame is supplied.

### `seedance-2.0-global-fast`

- Endpoint: `POST https://www.runninghub.cn/openapi/v2/bytedance/seedance-2.0-global-fast/image-to-video`
- Payload pattern follows the same documented shape as `seedance-2.0-global`, but uses the fast endpoint.

### `wan-2.2`

- Endpoint: `POST https://www.runninghub.cn/openapi/v2/rhart-video/wan-2.2/image-to-video`
- Explicit example fields from the official doc:
  - `imageUrl`
  - `prompt`
  - `duration`
  - `resolution`

Observed from a live API validation on 2026-04-26:

- the old numeric field ids such as `219##image` and `183##text` now return `errorCode=1007`
- the endpoint explicitly asks for `imageUrl` and `prompt`
- `duration` must be passed as a string
- `resolution` is required; `auto` is accepted

## Notes For Extension

- If the user names a different RunningHub endpoint, add a new payload builder rather than overloading an existing one.
- Prefer preserving the exact field names shown in RunningHub docs, especially for `wan-*` endpoints with numeric field ids.


path: scripts/runninghub_video.py
content: #!/usr/bin/env python3
"""Submit RunningHub image-to-video tasks, poll status, and download outputs."""

from __future__ import annotations

import argparse
import json
import mimetypes
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Callable


API_BASE = "https://www.runninghub.cn"
UPLOAD_ENDPOINT = f"{API_BASE}/openapi/v2/media/upload/binary"
QUERY_ENDPOINT = f"{API_BASE}/openapi/v2/query"

RUNNING_STATUSES = {"RUNNING", "PENDING", "PROCESSING", "QUEUED", "WAITING", "SUBMITTED"}
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "COMPLETED", "DONE"}
FAILURE_STATUSES = {"FAILED", "ERROR", "CANCELLED", "REJECTED"}


def parse_bool(value: str) -> bool:
    lowered = value.strip().lower()
    if lowered in {"1", "true", "yes", "y", "on"}:
        return True
    if lowered in {"0", "false", "no", "n", "off"}:
        return False
    raise argparse.ArgumentTypeError(f"Unsupported boolean value: {value}")


def load_api_key(cli_value: str | None) -> str:
    if cli_value:
        return cli_value

    env_value = os.environ.get("RUNNINGHUB_API_KEY")
    if env_value:
        return env_value

    if os.name == "nt":
        try:
            import winreg

            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment") as key:
                value, _ = winreg.QueryValueEx(key, "RUNNINGHUB_API_KEY")
                if value:
                    return value
        except OSError:
            pass

    raise SystemExit(
        "Missing API key. Pass --api-key or set RUNNINGHUB_API_KEY in the user environment."
    )


def request_json(
    url: str,
    *,
    method: str = "POST",
    payload: dict | None = None,
    headers: dict[str, str] | None = None,
    data: bytes | None = None,
    timeout: int = 180,
) -> dict:
    request_headers = {"Accept": "application/json"}
    if headers:
        request_headers.update(headers)

    request_data = data
    if payload is not None:
        request_data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        request_headers.setdefault("Content-Type", "application/json")

    request = urllib.request.Request(url, data=request_data, headers=request_headers, method=method)

    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            raw = response.read()
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise SystemExit(f"HTTP {exc.code} calling {url}\n{body}") from exc
    except urllib.error.URLError as exc:
        raise SystemExit(f"Network error calling {url}: {exc}") from exc

    text = raw.decode("utf-8", errors="replace")
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise SystemExit(f"Expected JSON from {url}, got:\n{text}") from exc


def encode_multipart(file_path: Path) -> tuple[bytes, str]:
    boundary = f"----runninghub-{uuid.uuid4().hex}"
    mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
    file_bytes = file_path.read_bytes()

    chunks: list[bytes] = []
    chunks.append(f"--{boundary}\r\n".encode("utf-8"))
    chunks.append(
        (
            f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"\r\n'
            f"Content-Type: {mime_type}\r\n\r\n"
        ).encode("utf-8")
    )
    chunks.append(file_bytes)
    chunks.append(b"\r\n")
    chunks.append(f"--{boundary}--\r\n".encode("utf-8"))

    return b"".join(chunks), f"multipart/form-data; boundary={boundary}"


def upload_file(api_key: str, value: str) -> str:
    if value.startswith(("http://", "https://", "data:")):
        return value

    file_path = Path(value).expanduser().resolve()
    if not file_path.is_file():
        raise SystemExit(f"Input file does not exist: {file_path}")

    body, content_type = encode_multipart(file_path)
    response = request_json(
        UPLOAD_ENDPOINT,
        method="POST",
        data=body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": content_type,
        },
    )

    data = response.get("data") or {}
    upload_url = data.get("download_url") or data.get("downloadUrl")
    if not upload_url:
        raise SystemExit(
            "Upload succeeded but no download URL was returned:\n"
            f"{json.dumps(response, ensure_ascii=False, indent=2)}"
        )

    print(f"[upload] {file_path} -> {upload_url}")
    return upload_url


def build_kling_payload(args: argparse.Namespace, first_url: str, end_url: str | None) -> dict:
    payload = {
        "prompt": args.prompt,
        "firstImageUrl": first_url,
        "duration": args.duration,
        "cfgScale": args.cfg_scale,
        "sound": args.sound,
        "multiShot": args.multi_shot,
        "shotType": args.shot_type,
    }
    if end_url:
        payload["lastImageUrl"] = end_url
    return payload


def build_seedance_payload(args: argparse.Namespace, first_url: str, end_url: str | None) -> dict:
    payload = {
        "prompt": args.prompt,
        "resolution": args.resolution,
        "duration": args.duration,
        "firstFrameUrl": first_url,
        "generateAudio": args.audio,
        "ratio": args.ratio,
        "realPersonMode": args.real_person_mode,
        "conversionSlots": ["all"],
        "returnLastFrame": args.return_last_frame,
    }
    if end_url:
        # This field name is inferred from RunningHub's start/end-frame description for Seedance.
        payload["lastFrameUrl"] = end_url
    return payload


def build_wan_payload(args: argparse.Namespace, first_url: str, _end_url: str | None) -> dict:
    return {
        "imageUrl": first_url,
        "prompt": args.prompt,
        "duration": str(args.duration),
        "resolution": args.wan_resolution,
    }


def model_spec(model_name: str) -> tuple[str, Callable[[argparse.Namespace, str, str | None], dict]]:
    normalized = model_name.strip().lower()
    aliases = {
        "kling": "kling-v3.0-std",
        "seedance": "seedance-2.0-global",
        "seedance-fast": "seedance-2.0-global-fast",
        "wan": "wan-2.2",
    }
    normalized = aliases.get(normalized, normalized)

    registry = {
        "kling-v3.0-std": (
            f"{API_BASE}/openapi/v2/kling-v3.0-std/image-to-video",
            build_kling_payload,
        ),
        "seedance-2.0-global": (
            f"{API_BASE}/openapi/v2/bytedance/seedance-2.0-global/image-to-video",
            build_seedance_payload,
        ),
        "seedance-2.0-global-fast": (
            f"{API_BASE}/openapi/v2/bytedance/seedance-2.0-global-fast/image-to-video",
            build_seedance_payload,
        ),
        "wan-2.2": (
            f"{API_BASE}/openapi/v2/rhart-video/wan-2.2/image-to-video",
            build_wan_payload,
        ),
    }

    if normalized not in registry:
        supported = ", ".join(sorted(registry))
        raise SystemExit(f"Unsupported model '{model_name}'. Supported models: {supported}")
    return registry[normalized]


def submit_task(api_key: str, endpoint: str, payload: dict) -> dict:
    response = request_json(
        endpoint,
        method="POST",
        payload=payload,
        headers={"Authorization": f"Bearer {api_key}"},
    )
    if response.get("errorMessage") and not response.get("taskId"):
        raise SystemExit(json.dumps(response, ensure_ascii=False, indent=2))
    return response


def query_task(api_key: str, task_id: str) -> dict:
    return request_json(
        QUERY_ENDPOINT,
        method="POST",
        payload={"taskId": task_id},
        headers={"Authorization": f"Bearer {api_key}"},
    )


def wait_for_task(api_key: str, task_id: str, poll_interval: int, timeout: int) -> dict:
    started_at = time.time()
    while True:
        result = query_task(api_key, task_id)
        status = str(result.get("status", "")).upper()
        print(f"[poll] taskId={task_id} status={status or 'UNKNOWN'}")

        if status in SUCCESS_STATUSES:
            return result
        if status in FAILURE_STATUSES:
            raise SystemExit(json.dumps(result, ensure_ascii=False, indent=2))

        if time.time() - started_at > timeout:
            raise SystemExit(f"Timed out after {timeout}s waiting for task {task_id}")

        if status not in RUNNING_STATUSES and result.get("results"):
            return result

        time.sleep(poll_interval)


def guess_extension(item: dict, index: int) -> str:
    output_type = item.get("outputType")
    if output_type:
        suffix = str(output_type).strip().lstrip(".")
        return suffix or "bin"

    parsed = urllib.parse.urlparse(str(item.get("url", "")))
    name = Path(parsed.path).name
    suffix = Path(name).suffix.lstrip(".")
    if suffix:
        return suffix
    return "mp4" if index == 0 else "bin"


def download_result(url: str, destination: Path) -> None:
    request = urllib.request.Request(url, headers={"User-Agent": "runninghub-video-skill"})
    try:
        with urllib.request.urlopen(request, timeout=300) as response, destination.open("wb") as handle:
            handle.write(response.read())
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise SystemExit(f"Download failed with HTTP {exc.code} for {url}\n{body}") from exc
    except urllib.error.URLError as exc:
        raise SystemExit(f"Download failed for {url}: {exc}") from exc


def save_outputs(result: dict, out_dir: Path) -> list[Path]:
    results = result.get("results") or []
    if not results:
        raise SystemExit(
            "Task finished but no results were returned:\n"
            f"{json.dumps(result, ensure_ascii=False, indent=2)}"
        )

    out_dir.mkdir(parents=True, exist_ok=True)
    task_id = result.get("taskId", "runninghub-task")
    saved_paths: list[Path] = []

    for index, item in enumerate(results):
        url = item.get("url")
        if not url:
            continue
        extension = guess_extension(item, index)
        destination = out_dir / f"{task_id}-{index + 1}.{extension}"
        download_result(str(url), destination)
        saved_paths.append(destination)
        print(f"[download] {url} -> {destination}")

    if not saved_paths:
        raise SystemExit(f"No downloadable URLs were found:\n{json.dumps(result, ensure_ascii=False, indent=2)}")
    return saved_paths


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--model", default="wan-2.2")
    parser.add_argument("--image", required=True, help="Local path, public URL, or data URI for the first frame.")
    parser.add_argument("--end-image", help="Optional local path, public URL, or data URI for the ending frame.")
    parser.add_argument("--prompt", required=True, help="Motion/camera description.")
    parser.add_argument("--api-key", help="RunningHub API key. Defaults to RUNNINGHUB_API_KEY.")
    parser.add_argument("--out-dir", default=str(Path.cwd() / "runninghub-output"))
    parser.add_argument("--duration", default="5")
    parser.add_argument("--poll-interval", type=int, default=8)
    parser.add_argument("--timeout", type=int, default=1800)
    parser.add_argument("--submit-only", action="store_true")

    parser.add_argument("--cfg-scale", type=float, default=0.8)
    parser.add_argument("--sound", type=parse_bool, default=True)
    parser.add_argument("--multi-shot", type=parse_bool, default=False)
    parser.add_argument("--shot-type", default="customize")

    parser.add_argument("--resolution", default="720p")
    parser.add_argument("--ratio", default="adaptive")
    parser.add_argument("--audio", type=parse_bool, default=True)
    parser.add_argument("--real-person-mode", type=parse_bool, default=True)
    parser.add_argument("--return-last-frame", type=parse_bool, default=False)

    parser.add_argument("--wan-resolution", default="auto")
    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    [REDACTED_SECRET])
    endpoint, payload_builder = model_spec(args.model)

    first_url = upload_file(api_key, args.image)
    end_url = upload_file(api_key, args.end_image) if args.end_image else None
    payload = payload_builder(args, first_url, end_url)

    print(f"[submit] model={args.model} endpoint={endpoint}")
    print(json.dumps(payload, ensure_ascii=False, indent=2))

    submit_response = submit_task(api_key, endpoint, payload)
    task_id = submit_response.get("taskId")
    if not task_id:
        raise SystemExit(
            "RunningHub did not return a taskId:\n"
            f"{json.dumps(submit_response, ensure_ascii=False, indent=2)}"
        )

    print(f"[task] taskId={task_id} status={submit_response.get('status', '')}")
    if args.submit_only:
        print(json.dumps(submit_response, ensure_ascii=False, indent=2))
        return 0

    if str(submit_response.get("status", "")).upper() in SUCCESS_STATUSES and submit_response.get("results"):
        final_result = submit_response
    else:
        final_result = wait_for_task(api_key, task_id, args.poll_interval, args.timeout)

    saved = save_outputs(final_result, Path(args.out_dir).expanduser().resolve())
    print(json.dumps({"taskId": task_id, "saved": [str(path) for path in saved]}, ensure_ascii=False, indent=2))
    return 0


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


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