Skill: goai/goai-video-gen (version 1.0.0)

--- name: goai-video-gen description: Generate AI videos via GoAI API. Use when the user asks to create, generate, render, or make videos, animations, clips, shorts, or motion content, including Chinese requests like 生成视频, 生成一段视频, 做视频, 出视频, 做个动画, 文生视频, and 图生视频. version: 0.3.0 homepage: https://mustgoai.com metadata: { "openclaw": { "emoji": "🎬", "os": ["darwin", "linux", "win32"], "requires": { "bins": ["uv"], "env": ["GOAI_API_KEY"] }, "primaryEnv": "GOAI_API_KEY" } } --- # When To Use Use this skill when the user wants text-to-video or image-to-video generation through GoAI, especially for prompts like 生成视频, 做视频, 出视频, 文生视频, 图生视频, and 做个动画. Pass the user's video prompt through as-is unless the user explicitly asks for prompt optimization, prompt polishing, rewriting, translation, or additional creative detail. Do not silently embellish short prompts. Do not translate the user's prompt to another language. If the user writes in Chinese, pass Chinese to `--prompt`. If the user writes in English, pass English to `--prompt`. You may strip only lightweight request wrappers such as `帮我`, `给我`, `请`, `生成`, or `做个`, but do not rewrite the core prompt content. Examples: - User: `给我生成一段狮子奔跑的视频` -> `--prompt "一段狮子奔跑的视频"` or `--prompt "给我生成一段狮子奔跑的视频"` - User: `做个海浪视频` -> `--prompt "做个海浪视频"` or `--prompt "海浪视频"` - Never turn `给我生成一段狮子奔跑的视频` into `a lion running video` unless the user explicitly asks for English translation. # Default Behavior This package currently targets the production environment at `https://mustgoai.com`. Override `GOAI_BASE_URL` only when you intentionally need a different endpoint. This skill now uses a single cross-platform Python entrypoint through `uv`. If the user does not specify a model, the script queries `/api/v1/models/capabilities`, sorts enabled video models by `display.priority`, filters them by the requested `video_mode`, and picks the highest-priority compatible model. When the user omits video parameters, the script derives default aspect ratio, duration, and resolution from the same capability response. Treat `uv` as the only runtime dependency the user needs to install manually. On first run, `uv` may create a local environment, install `httpx`, and download Python if the machine does not already have a usable interpreter. That first-run setup is expected and should not be described as an error. If the user provides local image paths, the script uploads them first and sends the resulting URLs as `images`. If the user provides remote URLs, the script passes them through unchanged. # Script Rules Always use the Python entrypoint through `uv`: ```bash uv run --project . python scripts/generate_video.py \ --prompt "..." \ [--model "..."] \ [--aspect-ratio "..."] \ [--duration "8"] \ [--resolution "1080p"] \ [--video-mode "frames"] \ [--image "/path/to/image.png"] \ [--image "https://example.com/image.png"] ``` The Python path validates `GOAI_API_KEY`, defaults to `https://mustgoai.com` unless `GOAI_BASE_URL` is set, and treats `401`, `402`, `429`, content blocking, missing `task_id`, missing `video_url`, and backend terminal failure states as hard failures. Polling follows the web client behavior: it checks every 3 seconds, retries transient polling errors, and waits for a backend terminal status instead of enforcing a client-side timeout. The Python entrypoint self-heals into `uv run` if it is accidentally invoked as `python scripts/generate_video.py ...` or `python3 scripts/generate_video.py ...`. Do not try to repair the system Python environment for this skill. When invoking this skill through OpenClaw's `exec` tool, always use `timeout=600` to allow up to 10 minutes for long-running video generation. Do not describe the run as timed out or killed unless the script itself exits non-zero or the backend reports a terminal failure state. If `uv` is missing, explicitly guide the user to install it first: - macOS: `brew install uv` - Windows: `winget install astral-sh.uv` After `uv` is installed, rerun the same command. Do not ask the user to install Python packages by hand; `uv` is responsible for preparing Python and the skill dependencies. If execution reports a missing Python module such as `httpx`, do not run `pip install`, `pip install --user`, or `pip install --break-system-packages`. Re-run the skill through `uv`; the dependency set comes only from `pyproject.toml` and `uv.lock`. If `GOAI_API_KEY` is missing, explicitly guide the user to visit `https://mustgoai.com`, register or log in, open `Settings -> API Key`, create a key, and then configure the skill env in `~/.openclaw/openclaw.json`. # Output Contract On success, print all four lines below in this order: - `MEDIA:/absolute/path/to/generated-file` - `MEDIA_URL:https://...` - `RESULT_PATH:/absolute/path/to/generated-file` - `RESULT_URL:https://...` When responding to the user after a successful run, always include both the exact local file path and the exact public URL in plain text, even if OpenClaw already rendered or read the local media file. Do not omit the URL just because the media preview succeeded. On failure, exit non-zero and print a concise error message. Do not inline the binary output back into the conversation.

path: pyproject.toml
content: [project]
name = "goai-video-gen"
version = "0.3.0"
requires-python = ">=3.10"
dependencies = [
  "httpx>=0.27,<1",
]


path: scripts/bootstrap.py
content: from __future__ import annotations

import os
import shutil
import sys
from pathlib import Path


def _resolved(path: str | Path) -> Path | None:
    try:
        return Path(path).resolve()
    except OSError:
        return None


def _uv_install_hint() -> str:
    if sys.platform == "darwin":
        return (
            "uv is required to run this skill. Install it with: brew install uv. "
            "After uv is installed, rerun the skill; uv will prepare Python and dependencies automatically."
        )
    if sys.platform == "win32":
        return (
            "uv is required to run this skill. Install it with: winget install astral-sh.uv. "
            "After uv is installed, rerun the skill; uv will prepare Python and dependencies automatically."
        )
    return (
        "uv is required to run this skill. Install uv and rerun this command. "
        "After uv is installed, it will prepare Python and dependencies automatically."
    )


def ensure_uv_runtime(entrypoint: str | Path) -> None:
    script_path = Path(entrypoint).resolve()
    skill_dir = script_path.parent.parent
    expected_venv = _resolved(skill_dir / ".venv")
    prefix = _resolved(sys.prefix)
    virtual_env = _resolved(os.environ["VIRTUAL_ENV"]) if "VIRTUAL_ENV" in os.environ else None

    if os.environ.get("UV_RUN_RECURSION_DEPTH"):
        return
    if expected_venv is not None and (prefix == expected_venv or virtual_env == expected_venv):
        return

    uv_bin = shutil.which("uv")
    if uv_bin is None:
        print(_uv_install_hint(), file=sys.stderr)
        raise SystemExit(1)

    os.execvpe(
        uv_bin,
        [
            uv_bin,
            "run",
            "--project",
            str(skill_dir),
            "python",
            str(script_path),
            *sys.argv[1:],
        ],
        os.environ.copy(),
    )


path: scripts/generate_video.py
content: from __future__ import annotations

import argparse
import sys

from bootstrap import ensure_uv_runtime

ensure_uv_runtime(__file__)

from common import GoAIClient, GoAIError, derive_video_defaults, print_error


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Generate GoAI videos with optional input images."
    )
    parser.add_argument("--prompt", required=True, help="Required video prompt.")
    parser.add_argument("--model", default="", help="Optional model id.")
    parser.add_argument("--aspect-ratio", default="", help="Optional aspect ratio.")
    parser.add_argument("--duration", type=int, default=None, help="Optional duration in seconds.")
    parser.add_argument("--resolution", default="", help="Optional resolution.")
    parser.add_argument(
        "--video-mode",
        choices=["frames", "subject"],
        default="frames",
        help="Optional video mode: frames or subject.",
    )
    parser.add_argument(
        "--image",
        action="append",
        default=[],
        help="Optional local image path or remote URL. May be repeated.",
    )
    return parser


def main() -> int:
    args = build_parser().parse_args()
    client = GoAIClient(skill_name="goai-video-gen")

    try:
        client.require_api_key()

        model = args.model or client.get_default_model("video", mode=args.video_mode)
        aspect_ratio = args.aspect_ratio
        duration = args.duration
        resolution = args.resolution

        if not aspect_ratio or duration is None or not resolution:
            capability = client.get_model_capability(model)
            if capability is None:
                raise GoAIError(f"model capability missing: {model}")
            default_aspect_ratio, default_duration, default_resolution = derive_video_defaults(capability)
            if not aspect_ratio:
                aspect_ratio = default_aspect_ratio
            if duration is None:
                duration = default_duration
            if not resolution:
                resolution = default_resolution

        images = client.resolve_image_inputs(args.image)
        payload: dict[str, object] = {
            "model": model,
            "prompt": args.prompt,
        }
        if aspect_ratio:
            payload["aspect_ratio"] = aspect_ratio
        if duration is not None:
            payload["duration"] = duration
        if resolution:
            payload["resolution"] = resolution
        if args.video_mode:
            payload["video_mode"] = args.video_mode
        if images:
            payload["images"] = images

        response = client.json_request(
            "POST",
            f"{client.base_url}/api/v2/videos/generate",
            "auth",
            body=payload,
        )
        client.api_assert_ok(response)

        data = response.get("data")
        task_id = str(data.get("task_id") or "") if isinstance(data, dict) else ""
        if not task_id:
            raise GoAIError("task_id missing in generate response")

        media_url = client.poll_task(
            f"{client.base_url}/api/v2/videos/tasks/{task_id}",
            "video_url",
            interval_seconds=3,
            max_attempts=0,
            timeout_message="video generation timed out",
        )
        output_path = client.download_media(media_url, f"goai-video-{task_id}.mp4")

        print(f"MEDIA:{output_path}")
        print(f"MEDIA_URL:{media_url}")
        print(f"RESULT_PATH:{output_path}")
        print(f"RESULT_URL:{media_url}")
        return 0
    except GoAIError as exc:
        print_error(str(exc))
        return 1
    finally:
        client.close()


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


path: scripts/common.py
content: from __future__ import annotations

import os
import sys
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import httpx


DEFAULT_BASE_URL = "https://mustgoai.com"
TERMINAL_FAILURE_STATES = {
    "failed",
    "error",
    "content_blocked",
    "quota_exceeded",
    "cancelled",
    "expired",
}


class GoAIError(RuntimeError):
    pass


def print_error(message: str) -> None:
    print(f"Error: {message}", file=sys.stderr)


def print_warning(message: str) -> None:
    print(f"Warning: {message}", file=sys.stderr)


def is_url(value: str) -> bool:
    parsed = urlparse(value)
    return parsed.scheme in {"http", "https"} and bool(parsed.netloc)


def content_type_for_file(path: Path) -> str:
    suffix = path.suffix.lower()
    if suffix == ".png":
        return "image/png"
    if suffix in {".jpg", ".jpeg"}:
        return "image/jpeg"
    if suffix == ".webp":
        return "image/webp"
    if suffix == ".gif":
        return "image/gif"
    raise GoAIError(f"unsupported image file type: {path}")


def derive_video_defaults(capability: dict[str, Any]) -> tuple[str, int, str]:
    aspect_ratios = capability.get("aspect_ratios") or []
    durations = capability.get("durations") or []
    resolutions = capability.get("resolutions") or []
    default_aspect_ratio = str(capability.get("default_aspect_ratio") or "")
    default_duration = capability.get("default_duration")
    default_resolution = str(capability.get("default_resolution") or "")

    if "16:9" in aspect_ratios:
        aspect_ratio = "16:9"
    else:
        non_auto = [value for value in aspect_ratios if value != "auto"]
        if non_auto:
            aspect_ratio = str(non_auto[0])
        elif aspect_ratios:
            aspect_ratio = str(aspect_ratios[0])
        elif default_aspect_ratio:
            aspect_ratio = default_aspect_ratio
        else:
            aspect_ratio = "16:9"

    if isinstance(default_duration, int) and default_duration > 0:
        duration = default_duration
    elif durations:
        duration = int(durations[0])
    else:
        duration = 10

    if default_resolution and (not resolutions or default_resolution in resolutions):
        resolution = default_resolution
    elif resolutions:
        resolution = str(resolutions[0])
    else:
        resolution = ""

    return aspect_ratio, duration, resolution


class GoAIClient:
    def __init__(self, skill_name: str) -> None:
        self.skill_name = skill_name
        self.base_url = os.environ.get("GOAI_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
        self.[REDACTED_SECRET]"GOAI_API_KEY", "")
        self.last_payload: dict[str, Any] | None = None
        self.last_media_url = ""
        self._capabilities: dict[str, Any] | None = None
        self._http = httpx.Client(
            timeout=httpx.Timeout(60.0, connect=20.0),
            follow_redirects=True,
        )

    def close(self) -> None:
        self._http.close()

    def require_api_key(self) -> None:
        if self.api_key.strip():
            return

        home = Path(os.environ.get("USERPROFILE") or Path.home())
        config_path = home / ".openclaw" / "openclaw.json"
        raise GoAIError(
            "\n".join(
                [
                    "GOAI_API_KEY is required.",
                    "",
                    "Get an API key:",
                    "1. Visit https://mustgoai.com",
                    "2. Register or log in",
                    "3. Open Settings -> API Key",
                    "4. Create a new API key",
                    f"5. Configure this skill in {config_path}, for example:",
                    '   "skills": {',
                    '     "entries": {',
                    f'       "{self.skill_name}": {{',
                    '         "enabled": true,',
                    '         "env": {',
                    '           "GOAI_API_KEY": "sk-..."',
                    "         }",
                    "       }",
                    "     }",
                    "   }",
                    "",
                    "Optional:",
                    "- Set GOAI_BASE_URL only if you need to override the default production endpoint",
                ]
            )
        )

    def _headers(self, auth_mode: str) -> dict[str, str]:
        headers = {"Accept": "application/json"}
        if auth_mode == "auth":
            headers["Authorization"] = f"Bearer {self.api_key}"
        return headers

    def _error_message(self, payload: Any) -> str:
        if not isinstance(payload, dict):
            return ""
        data = payload.get("data")
        candidates = []
        if isinstance(data, dict):
            candidates.append(data.get("error_message"))
        candidates.extend(
            [
                payload.get("error_message"),
                payload.get("message"),
                payload.get("msg"),
                payload.get("error"),
            ]
        )
        for value in candidates:
            if value is None:
                continue
            if isinstance(value, str) and value.strip():
                return value
            if not isinstance(value, str):
                text = str(value).strip()
                if text:
                    return text
        return ""

    def _parse_json(self, response: httpx.Response) -> dict[str, Any] | None:
        try:
            payload = response.json()
        except ValueError:
            return None
        return payload if isinstance(payload, dict) else None

    def json_request(
        self,
        method: str,
        url: str,
        auth_mode: str,
        *,
        query: dict[str, Any] | None = None,
        body: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        try:
            response = self._http.request(
                method,
                url,
                headers=self._headers(auth_mode),
                params=query,
                json=body,
            )
        except httpx.RequestError:
            raise GoAIError(f"request failed: {url}") from None

        payload = self._parse_json(response)
        if response.status_code not in {200, 201}:
            message = self._error_message(payload)
            if response.status_code == 401:
                raise GoAIError("unauthorized: check GOAI_API_KEY")
            if response.status_code == 402:
                raise GoAIError("insufficient credits")
            if response.status_code == 429:
                raise GoAIError("rate limited")
            if message:
                raise GoAIError(f"request failed ({response.status_code}): {message}")
            raise GoAIError(f"request failed ({response.status_code})")

        if payload is None:
            raise GoAIError("invalid API response")

        self.last_payload = payload
        return payload

    def api_assert_ok(self, payload: dict[str, Any]) -> None:
        code = payload.get("code")
        if code is None or str(code) == "":
            raise GoAIError("invalid API response")
        if str(code) == "0":
            return

        message = self._error_message(payload)
        if str(code) == "-5":
            if message:
                raise GoAIError(f"content blocked: {message}")
            raise GoAIError("content blocked")
        if message:
            raise GoAIError(f"api error (code {code}): {message}")
        raise GoAIError(f"api error (code {code})")

    def get_capabilities(self) -> dict[str, Any]:
        if self._capabilities is not None:
            return self._capabilities

        payload = self.json_request(
            "GET",
            f"{self.base_url}/api/v1/models/capabilities",
            "public",
        )
        self.api_assert_ok(payload)

        data = payload.get("data")
        capabilities = data.get("capabilities") if isinstance(data, dict) else None
        if not isinstance(capabilities, dict):
            raise GoAIError("invalid capabilities response")

        self._capabilities = capabilities
        return capabilities

    def get_model_capability(self, model: str) -> dict[str, Any] | None:
        capability = self.get_capabilities().get(model)
        return capability if isinstance(capability, dict) else None

    def _video_mode_supported(self, capability: dict[str, Any], mode: str) -> bool:
        frame_support = str(capability.get("frame_support") or "none")
        if mode == "frames":
            return frame_support in {"first", "both", "first_reference", "all"}
        if mode == "subject":
            return frame_support in {"reference", "first_reference", "all"}
        return True

    def get_default_model(self, category: str, preferred: str = "", mode: str = "") -> str:
        capabilities = self.get_capabilities()
        all_candidates: list[tuple[int, str, dict[str, Any]]] = []
        compatible_candidates: list[tuple[int, str, dict[str, Any]]] = []

        for model_name, capability in capabilities.items():
            if not isinstance(capability, dict):
                continue
            display = capability.get("display")
            if not isinstance(display, dict):
                continue
            if display.get("category") != category or not bool(display.get("enabled")):
                continue

            priority_raw = display.get("priority")
            priority = int(priority_raw) if isinstance(priority_raw, int) else 999
            candidate = (priority, model_name, capability)
            all_candidates.append(candidate)
            if category != "video" or not mode or self._video_mode_supported(capability, mode):
                compatible_candidates.append(candidate)

        candidates = compatible_candidates or all_candidates
        if not candidates:
            raise GoAIError(f"no enabled {category} model available")

        candidates.sort(key=lambda item: (item[0], item[1]))
        return candidates[0][1]

    def upload_local_image(self, value: str) -> str:
        path = Path(value).expanduser()
        if not path.is_absolute():
            path = (Path.cwd() / path).resolve()
        if not path.is_file():
            raise GoAIError(f"local image not found or unreadable: {value}")

        mime_type = content_type_for_file(path)
        payload = self.json_request(
            "GET",
            f"{self.base_url}/api/v2/oss/sign-url",
            "auth",
            query={
                "filename": path.name,
                "contentType": mime_type,
                "folder": "inputs",
            },
        )
        self.api_assert_ok(payload)

        data = payload.get("data")
        if not isinstance(data, dict):
            raise GoAIError("invalid upload response")
        signed_url = str(data.get("signedUrl") or "")
        final_url = str(data.get("finalUrl") or "")
        if not signed_url:
            raise GoAIError("signedUrl missing from upload response")
        if not final_url:
            raise GoAIError("finalUrl missing from upload response")

        try:
            response = self._http.put(
                signed_url,
                headers={"Content-Type": mime_type},
                content=path.read_bytes(),
            )
        except httpx.RequestError:
            raise GoAIError(f"upload failed: {value}") from None

        if response.status_code not in {200, 201}:
            raise GoAIError(f"upload failed ({response.status_code}): {value}")

        return final_url

    def resolve_image_inputs(self, inputs: list[str]) -> list[str]:
        resolved: list[str] = []
        for item in inputs:
            if not item.strip():
                continue
            if is_url(item):
                resolved.append(item)
            else:
                resolved.append(self.upload_local_image(item))
        return resolved

    def _poll_request(self, url: str) -> tuple[dict[str, Any] | None, str]:
        try:
            response = self._http.get(url, headers=self._headers("auth"))
        except httpx.RequestError:
            return None, ""

        payload = self._parse_json(response)
        if response.status_code not in {200, 201}:
            return None, self._error_message(payload)
        if payload is None:
            return None, ""

        code = payload.get("code")
        if code is None or str(code) != "0":
            return None, self._error_message(payload)

        self.last_payload = payload
        return payload, ""

    def poll_task(
        self,
        task_url: str,
        media_field: str,
        interval_seconds: int = 3,
        max_attempts: int = 0,
        timeout_message: str = "generation timed out",
    ) -> str:
        attempt = 1
        while True:
            payload, message = self._poll_request(task_url)
            if payload is not None:
                data = payload.get("data")
                status = str(data.get("status") or "") if isinstance(data, dict) else ""
                if status in {"completed", "success"}:
                    media_url = str(data.get(media_field) or "") if isinstance(data, dict) else ""
                    if not media_url:
                        raise GoAIError(f"{media_field} missing in completed task response")
                    self.last_media_url = media_url
                    return media_url
                if status == "timeout":
                    raise GoAIError(self._error_message(payload) or timeout_message)
                if status in TERMINAL_FAILURE_STATES:
                    raise GoAIError(self._error_message(payload) or "generation failed")
                if not status:
                    print_warning(f"polling error for {task_url}: task status missing, retrying")
            else:
                if message:
                    print_warning(f"polling error for {task_url}: {message}, retrying")
                else:
                    print_warning(f"polling request failed for {task_url}, retrying")

            if max_attempts > 0 and attempt >= max_attempts:
                raise GoAIError(timeout_message)

            time.sleep(interval_seconds)
            attempt += 1

    def download_media(self, media_url: str, output_name: str) -> str:
        if not media_url:
            raise GoAIError("download URL is required")

        output_path = Path(output_name).expanduser()
        if not output_path.is_absolute():
            output_path = (Path.cwd() / output_path).resolve()
        output_path.parent.mkdir(parents=True, exist_ok=True)

        try:
            response = self._http.get(media_url)
        except httpx.RequestError:
            raise GoAIError(f"download failed: {media_url}") from None

        if response.status_code not in {200, 201}:
            raise GoAIError(f"download failed: {media_url}")

        output_path.write_bytes(response.content)
        return str(output_path)



path: agents/openai.yaml
content: interface:
  display_name: "GoAI Video Gen"
  short_description: "Generate GoAI videos with image reference support"
  default_prompt: "Use $goai-video-gen with the user's prompt as-is and keep the user's original language. Do not translate Chinese prompts to English. You may remove only light request wrappers like 帮我, 给我, 请, 生成, or 做个, but do not rewrite the core prompt content. Invoke the Python entrypoint through uv rather than any platform shell: uv run --project . python scripts/generate_video.py ... Treat uv as the only manual prerequisite. The first run may create a local environment, install dependencies, or download Python automatically; that is normal. If uv is missing, tell the user to install uv first and rerun. If you accidentally started it with python or python3, rerun it through uv instead of repairing system Python. Never use pip install, pip install --user, or pip install --break-system-packages for this skill. When you invoke it through OpenClaw exec, use timeout=600 and wait for the script's final result instead of calling it timed out early. Only report failure when the script exits non-zero or the backend returns a terminal error. After success, always include both the exact saved file path and the exact media URL in the final response, even if OpenClaw already rendered the media preview."


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