Skill: zoucdr/room-makeover-video (version 1.0.1)

--- name: room-makeover-video version: 1.0.1 description: "Generate vertical home makeover shorts (WeryAI): rental cream aesthetic, balcony café corner, themed kids’ rooms, strong before/after. Use when you need room makeover reels, soft-furnishing glow-up clips, or users ask for lights-on reveal and split before/after. SEO: room makeover video; room transformation video generation." tags: [makeover, home, aesthetics, healing, life-inspo, short-video] metadata: { "openclaw": { "emoji": "🏠", "primaryEnv": "WERYAI_API_KEY", "paid": true, "network_required": true, "requires": { "env": ["WERYAI_API_KEY"], "bins": ["node"], "node": ">=18" } } } user-invocable: true --- # Room makeover & refresh video generation Three pillars: - **Before/after:** worn or plain “before” vs. elevated “after” in one clip - **Process:** staging, mood lighting, DIY decor—compressed on a rhythmic timeline - **Anthropomorphic cast:** bunny / hamster / cat doing the work—more emotion and completion One line: character + space + style → clip. Fits home / aesthetics / makeover feeds. **Dependencies:** `WERYAI_API_KEY` + Node.js 18+. When you run the CLI, **`scripts/video_gen.js`** must exist; **[`WERYAI_VIDEO_API.md`](resources/WERYAI_VIDEO_API.md)** must exist under **`resources/`** (supply both via publish or pre-use assembly). Full commands and JSON fields: see **`resources/WERYAI_VIDEO_API.md`**. No other Cursor skills. **Default parameters** and model tiers are in the tables below; live API limits follow **weryai** models. Before installing or running, review the bundled `video_gen.js` to confirm it meets your requirements. Pay particular attention to how it handles local files if you choose to allow their usage, ensuring this behavior aligns with the skill's intended workflow for image-to-video requests. ## Prerequisites - `WERYAI_API_KEY` **must be set** in the environment before running `video_gen.js`. - Node.js **18+** is required. Prefer public **`https`** image URLs. If the assembled `scripts/video_gen.js` supports local file paths, review and verify the script first, then get **explicit consent** before it reads a local image and uploads it to WeryAI to obtain a public URL. - Each successful `wait` run consumes WeryAI credits; re-running creates new paid tasks. ## Security, secrets, and API hosts - **`WERYAI_API_KEY`**: Treat as a secret. Only configure it if you trust this skill's source; it is listed in OpenClaw metadata as **`requires.env`** / **`primaryEnv`** so installers know it is mandatory at runtime (never commit it inside the skill package). - **API hosts (fixed in `video_gen.js`)**: Video tasks use **`https://api.weryai.com`**; the models list uses **`https://api-growth-agent.weryai.com`**. Only **`WERYAI_API_KEY`** is read from the environment—do not rely on URL-related environment variables. - **Local image handling disclosure**: Prefer public **`https`** image URLs. If the assembled `scripts/video_gen.js` supports local file paths, it may read a local image and upload it to WeryAI to obtain a public URL; require review / verification and explicit consent before using that path. - **Higher assurance**: Run generation in a short-lived or isolated environment (separate account or container), and review `scripts/video_gen.js` (HTTPS submit + poll loop) before production use. Verify whether the runtime can read local image files and upload them to WeryAI, and obtain explicit consent before using that path. ## Prompt expansion (mandatory) `video_gen.js` does **not** expand prompts. Before every `wait --json`, turn the user's short or vague brief into a **full English production `prompt`**. **When:** The user gives only keywords, one line, or loose intent—or asks for richer video language. **Exception:** They paste a finished long prompt within the model's `prompt_length_limit` and ask you **not** to rewrite; still show the **full** text in the confirmation table. **Always add (video language):** shot scale and angle; camera move or lock-off; light quality and motivation; subject action paced to `duration`; **one clear payoff** for this niche; state **9:16 vertical** when this skill defaults to vertical. **Length:** Obey `prompt_length_limit` for the chosen `model_key` when this doc lists it; trim filler adjectives before removing core action, lens, or light clauses. **Confirmation:** The pre-submit table **must** include the **full expanded `prompt`** (never a one-line summary). Wait for **confirm** or edits. ### Niche checklist - **Space arc:** before clutter or dull → paint/furniture/light **after**; wide establish → detail passes. - **Light:** warm interior practicals; window rake optional; **cozy or luxury** tone per user. - **Payoff:** readable layout transformation, not random decor jump cuts. **`### Example prompts`** at the top of this file are **short triggers only**—always expand from the user's actual request. ## Workflow 1. Confirm the user request matches this skill's scenario (text-to-video and/or image-to-video as documented). 2. Collect the user's **brief**, optional image URL(s), tier (**best** / **good** / **fast**) or an explicit `model` key. 3. **Expand prompt (mandatory):** Unless the user supplied a finished long prompt and explicitly asked not to rewrite it, expand the brief into a full English production `prompt` using `## Prompt expansion (mandatory)` below. **Do not** call the API with only the user's minimal words. 4. Check the **expanded** `prompt` against the selected model's `prompt_length_limit` in the frozen tables in this document (when present); shorten if needed. 5. Verify `duration`, `aspect_ratio`, `resolution`, `generate_audio`, `negative_prompt`, and other fields against the frozen tables in this document and **[`WERYAI_VIDEO_API.md`](resources/WERYAI_VIDEO_API.md)**. 6. Show the pre-submit parameter table including the **full expanded `prompt`**; wait for **confirm** or edits. 7. After confirmation, run `node scripts/video_gen.js wait --json '...'` with the **expanded** prompt. 8. Parse stdout JSON and return video URLs; on failure, surface `errorCode` / `errorMessage` and suggest parameter fixes. ## CLI reference ```sh node scripts/video_gen.js wait --json '{"model":"…","prompt":"…","duration":5,"aspect_ratio":"9:16"}' node scripts/video_gen.js wait --json '…' --dry-run node scripts/video_gen.js status --task-id <id> ``` **Full reference:** **[`WERYAI_VIDEO_API.md`](resources/WERYAI_VIDEO_API.md)**. ## Definition of done Done when the user receives at least one playable video URL from the API response, or a clear failure explanation with next steps. All parameters used **must** fall within the selected model's allowed sets in this document. The submitted `prompt` **must** be the **expanded** production prompt unless the user explicitly supplied a finished long prompt and asked not to rewrite it. ## Boundaries (out of scope) - We do not review platform compliance, copyright, or likeness; we do not warrant commercial usability of outputs. - We do not provide offline rendering outside WeryAI, traditional NLE projects, or API field combinations not documented in this SKILL or **[`WERYAI_VIDEO_API.md`](resources/WERYAI_VIDEO_API.md)**. - Do not link to `weryai-model-capabilities.md` or shared `../references/` paths; use **`resources/WERYAI_VIDEO_API.md`** for CLI/API details. - Do not hard-code absolute paths in this doc; run from the skill package root (next to `SKILL.md`) so `scripts/` and `resources/` paths resolve. ### Example prompts - `Shabby rental turned cream aesthetic by a hamster—rug, string lights, warm lights full on at end` - `From this messy balcony: motion into a coffee corner` - `Kids’ room: paint + star ceiling, big contrast but cozy` - `Room makeover timelapse 9:16, cozy lighting reveal at end` --- ## Default parameters | Field | Value | |-------|--------| | Model | KLING_V3_0_PRO | | Aspect | 9:16 (fixed, vertical short) | | Duration | 10 s (`duration: 10`, room for full process) | | Audio | Off | | Look | Medium or wide, natural + warm accent light; before cooler/darker, after warmer/brighter; time-lapse compression | > **API validity (default `KLING_V3_0_PRO`):** Text-to-video: `duration` only **5 / 10 / 15**, `aspect_ratio` only **9:16, 1:1, 16:9**; image-to-video: `aspect_ratio` only **9:16, 16:9, 1:1**; **no `resolution` field—do not send.** Fast VEO tier: text **`VEO_3_1_FAST`**, image **`CHATBOT_VEO_3_1_FAST`**, `duration` **fixed 8**, `aspect_ratio` only **9:16** or **16:9**. For other `model_key` values, follow the allowed sets in this document and the API validity notes above; do not send unsupported fields such as `resolution`. --- ## Anthropomorphic room makeover Animals “finish” a tired space overnight—the classic arc: enter messy room → busy montage → lights on, new room. **User provides:** - Character (bunny / hamster / cat / bear / custom) - Space (rental bedroom / balcony / study / kids’ room / small shop / living nook) - Target style (cream / cyberpunk / café corner / Nordic minimal / forest fairy / retro Hong Kong) **Flow:** 1. Collect character, space, style—ask if any are missing 2. Build prompt in three beats: before dull & cluttered → character staging → lights-on reveal 3. If params unspecified, show defaults and wait for confirmation. **Show all parameters in a table and wait for explicit user confirmation before submitting:** > 📋 **Ready to generate—please confirm:** > > | Field | This run | Notes | > |-------|----------|-------| > | `model` | `KLING_V3_0_PRO` | Best tier default; fast: text `VEO_3_1_FAST`, image `CHATBOT_VEO_3_1_FAST` (`duration` fixed 8); good → `KLING_V3_0_STA`; or specify a model name | > | `aspect_ratio` | `9:16` | Default KLING: 9:16, 1:1, 16:9 only; if you switch model, check that row’s `aspect_ratios` etc. | > | `duration` | `10s` | KLING family: 5 / 10 / 15; VEO fast: duration 8 only | > | `generate_audio` | `false` | Auto-generate audio or not | > | `prompt` | **Full expanded English prompt** (entire text for this run) | Revise before confirm | > | `seamless loop` | off | Reply "loop" to add seamless loop | > > Reply **"confirm"** to start, or list what to change. 4. After confirmation, in the terminal from the skill package root: ```sh node scripts/video_gen.js wait --json '{"model":"(confirmed model)","prompt":"(full English prompt)","aspect_ratio":"9:16","duration":10,"generate_audio":false}' ``` `aspect_ratio`, `duration`, `generate_audio`, `model` must match the table; add `resolution` only if supported. Parse `videos` from stdout. **Parameters:** | Field | Value | |-------|--------| | model | KLING_V3_0_PRO | | aspect_ratio | 9:16 | | duration | 10 | | generate_audio | false | **Sample prompt (bunny, rental, cream):** > A small white bunny with oversized ears begins transforming a dull, cluttered rental apartment bedroom, medium wide shot shows peeling walls and mismatched cheap furniture, the bunny hangs linen curtains, places cream-colored cushions, adds dried pampas grass in a ceramic vase, time-lapse with warm evening light slowly shifting, final reveal: the same room now glows in a soft cream-beige aesthetic with warm Edison bulb string lights overhead, low-angle medium shot shows the complete transformation, diffused golden light, cozy hygge atmosphere, paint texture walls, before-after contrast dramatic **Sample prompt (hamster, study, cyber):** > A tiny hamster in overalls transforms a bare study corner into a cyberpunk workspace, medium shot follows the hamster mounting LED strip lights in cyan and purple, placing holographic desk accessories, hanging circuit board art prints, time-lapse compression of the assembly process, dramatic before-after cut: sterile white room transitions to neon-lit cyberpunk den, wide establishing shot captures the full room with light reflections on all surfaces, dark dramatic void background outside window, Dutch angle for final reveal, high contrast neon against deep shadow **Sample prompt (cat, old balcony → café):** > A gray cat with white gloves methodically converts a neglected dusty balcony into a cozy café corner, overhead wide shot starts with cracked tiles and dead plants, cat places small round table with mosaic top, installs string fairy lights along the railing, arranges potted herbs in terracotta planters, golden hour time-lapse as the sun sets, final reveal at dusk: the balcony warmly lit with fairy lights, espresso machine on the table, trailing ivy across the wall, warm amber light, wide-to-close dolly movement revealing the full transformation **Expected outcome:** Strong before/after; clear rhythm peak at lights-on; color temp cold→warm for emotional arc—fits home / lifestyle accounts. --- ## Space-only before/after (no character) More “real” makeover: camera on the room only—good when the result itself is the star. **User provides:** - Before state (worn / cluttered / mixed style / dated finish) - After target (specific style + key pieces) - Focus (lighting change / staging / layout / detail macro) **Flow:** 1. Collect before/after descriptions 2. Build prompt stressing light shift, palette change, macro beats on key decor 3. After confirmation, from the skill package root: ```sh node scripts/video_gen.js wait --json '{"model":"KLING_V3_0_PRO","prompt":"(full English prompt)","aspect_ratio":"9:16","duration":10,"generate_audio":false}' ``` Fields match the table; parse stdout for URLs. **Parameters:** | Field | Value | |-------|--------| | model | KLING_V3_0_PRO | | aspect_ratio | 9:16 | | duration | 10 | | generate_audio | false | **Sample prompt (low-budget rental glow-up):** > Wide establishing shot of a low-budget rental room transformation, left half of the frame shows original water-stained walls, plastic furniture, fluorescent lighting, the right half reveals the same space after makeover with white paint, thrifted wooden shelf, warm LED strip lights, fabric headboard, time-lapse renovation progress fills the center, final wide shot shows the complete after: cozy Scandinavian-minimalist aesthetic on zero budget, warm 3000K ambient lighting, slight aerial perspective --- ## Usage tips **Style precision:** Cream → `cream-beige aesthetic, warm Edison bulb`; cyber → `neon cyan and purple, dark dramatic void`; café corner → `mosaic table, terracotta, fairy lights, trailing ivy`. More specific style tokens reduce drift. **Time span:** Add `time-lapse compression` and `golden hour light slowly shifting` so 10 s reads like “one night” of work. **Reveal peak:** Use `before-after contrast dramatic` or `reveal moment` so the model knows there’s a single dramatic turn—usually lights-on.

path: eval.yaml
content: version: "1"
defaults:
  trials: 3
  timeout: 600
  threshold: 0.75
skill: room-makeover-video
trigger_tests:
  - id: explicit-invoke
    should_trigger: true
    prompt: "Use the $room-makeover-video skill to create a sample vertical video."
  - id: implicit-domain
    should_trigger: true
    prompt: "破旧出租屋被小仓鼠改成奶油风，铺地毯、挂灯串，最后暖灯全开"
  - id: negative-control
    should_trigger: false
    prompt: "Refactor my React dashboard and add unit tests."
outcome_tests:
  - id: skill-package-layout
    type: deterministic
    checks:
      - description: "SKILL.md exists with name matching folder slug"
      - file_exists: "SKILL.md"
      - file_exists: "scripts/video_gen.js"
  - id: optional-quality
    type: llm_rubric
    rubric: |
      Does the assistant stay within WeryAI video_gen constraints (model, duration, aspect_ratio)?
      Does it refuse local image paths and ask for https URLs?


path: resources/WERYAI_VIDEO_API.md
content: # WeryAI video CLI & JSON (`video_gen.js`)

Shipped at **`resources/WERYAI_VIDEO_API.md`** (next to `SKILL.md`). From the skill package root, run **`node scripts/video_gen.js`** (see examples below). **Node.js 18+** required.

This file describes **CLI usage, JSON shape, and image handling**. Per-skill rules (prompt expansion, confirmation tables, allowed models) live in each skill’s **`SKILL.md`**.

---

## What you need by generation mode

The script picks the API route from your JSON:

| Mode | When | Required in JSON | API path (conceptually) |
|------|------|-------------------|-------------------------|
| **Text → video** | No `image` and no non-empty `images[]` | `model`, `prompt`, `duration` (and any optional fields the **model** supports) | `text-to-video` |
| **Image → video** | Non-empty string `image` | Same as above **plus** `image` — **must be a public `https` image URL** (see below) | `image-to-video` |
| **Multi-image → video** | Non-empty `images` array | Same as above **plus** `images` — **every element must be a public `https` image URL** | `multi-image-to-video` |

- **`model`**: required for real runs and for `--dry-run` when `prompt` is present. Empty/missing → `MISSING_PARAM` (there is **no default model**). The script does **not** enforce skill-specific model allowlists—callers must match the skill’s `SKILL.md`.
- **`prompt`**: required for `wait` and all `submit-*` flows that generate video.
- **`duration`**: sent to the API; must be one of the values allowed for the chosen model (check `models` output or the skill’s frozen table).
- Other fields (`aspect_ratio`, `resolution`, `generate_audio`, `negative_prompt`) are **only** valid if that model supports them—see `node scripts/video_gen.js models` and the skill’s **`## Model and API constraints`**.

---

## Image-to-video & multi-image: public URL requirement

For **image-to-video** (`image`) and **multi-image-to-video** (`images[]`), the value that ultimately reaches WeryAI must be a **public, directly fetchable image URL on the internet**:

- **Scheme must be `https://`** — plain **`http://` image URLs are rejected** by `video_gen.js` and must not be used.
- The URL must be reachable **from WeryAI’s servers** (not `localhost`, `127.0.0.1`, LAN-only hosts, or links that only work inside your browser session).
- Do **not** pass opaque file paths, `data:` URLs, or private object-storage links that are not already a stable public `https` address.

**Operational rule for skills and operators:** treat **public `https` image URLs as mandatory** for any image-to-video request. Host the image on a CDN, object storage with public read, or any HTTPS endpoint the API can GET without your local machine.

---

## Image input: public URL vs local path (how `video_gen.js` behaves)

### Normal path: public **https** URL (required for correct image-to-video usage)

- Put that URL in `image` (single) or each entry of `images[]` (multi).
- The script **does not** re-upload; it sends the URL straight to the video API.
- This matches the **public URL requirement** above.

### Advanced: local file → script uploads, then uses returned public URL

If `image` or an `images[]` entry is **not** already a public https URL, `video_gen.js` treats it as a **local path** (or `file://…`) and:

1. Reads the file from disk.
2. **POST**s it to **`https://api-growth-agent.weryai.com/growthai/v1/generation/upload-file`** with `Authorization: Bearer $WERYAI_API_KEY` and multipart fields `batch_no`, `fixed`, `file`.
3. Parses the response for a returned **public https** URL and substitutes that URL into the JSON sent to **video** generation — i.e. the video API still only ever sees a **public URL**; the script is just creating one from a local file.

**Implications (local path only)**

- Requires **`WERYAI_API_KEY`** with access to both **video** and **upload-file** endpoints.
- File must exist and be a **regular file**. Extensions with sensible MIME types in script: **`.jpg` / `.jpeg` / `.png` / `.webp` / `.gif`**.
- **`--dry-run`**: does **not** upload; stdout may still show local paths with a note that a real run would upload first.

**Skills / product docs:** prefer telling users to supply a **public `https` link**; use local paths only after **reviewing `video_gen.js`** and **explicit user consent** to read and upload files.

### Multi-image limits in this script

- `images` is passed through after upload resolution; the built request body keeps **at most the first 3** entries (`images.slice(0, 3)`). Models may allow fewer—always check `models` / skill tables (e.g. `upload_image_limit`).

---

## Environment

- **`WERYAI_API_KEY`** (required for `models`, generation, `status`, and local-image upload; **not** required for `--dry-run` only): bearer token. Never commit the value.
- **No other environment variables** are read. Hosts are fixed in `video_gen.js`. The **`wait`** command polls task status with **exponential backoff**: first delay **3s**, then **6s → 12s → 24s → …** (each step doubles), **capped at 60s** between polls, with a **total wall-clock budget of 600s (10 minutes)** before timeout.

## API hosts (fixed)

| Purpose | Base URL |
|---------|-----------|
| Video generation & task status | `https://api.weryai.com` |
| Model registry (`models` command) | `https://api-growth-agent.weryai.com` |
| Local file → public URL (upload) | `https://api-growth-agent.weryai.com/growthai/v1/generation/upload-file` |

---

## `models` — model metadata (JSON on stdout)

```sh
node scripts/video_gen.js models
node scripts/video_gen.js models --mode text_to_video
node scripts/video_gen.js models --mode image_to_video
node scripts/video_gen.js models --mode multi_image_to_video
```

Typical fields include `model_key`, `title`, `durations`, `aspect_ratios`, `resolutions`, `has_generate_audio`, `has_negative_prompt`, `prompt_length_limit`; image modes may add `support_multiple_images`, `support_first_last_frame`, `upload_image_limit`, etc. Use this to author **`## Model and API constraints`** in `SKILL.md`.

---

## `wait` — submit and block until done

```sh
node scripts/video_gen.js wait --json '{"model":"KLING_V3_0_PRO","prompt":"...","duration":5,"aspect_ratio":"9:16"}'
node scripts/video_gen.js wait --json '{"model":"...","prompt":"...","image":"https://cdn.example.com/pet.jpg","duration":5}'
node scripts/video_gen.js wait --json '{"model":"...","prompt":"...","images":["https://cdn.example.com/a.jpg","https://cdn.example.com/b.jpg"],"duration":5}'
# Advanced only: local paths trigger upload-file first; skills should default to public https URLs above.
node scripts/video_gen.js wait --json '{"model":"...","prompt":"...","image":"./girl.png","duration":5}'
node scripts/video_gen.js wait --json '...' --dry-run
```

Same JSON object is used for **`submit-*`**; only the blocking behavior differs.

---

## `submit-*` — submit only (async)

```sh
node scripts/video_gen.js submit-text  --json '{"model":"…","prompt":"…","duration":5}'
node scripts/video_gen.js submit-image --json '{"model":"…","prompt":"…","image":"https://…","duration":5}'
node scripts/video_gen.js submit-multi-image --json '{"model":"…","prompt":"…","images":["https://…","https://…"],"duration":5}'
# Advanced only: local file → upload then submit (same public-URL requirement for the API).
node scripts/video_gen.js submit-image --json '{"model":"…","prompt":"…","image":"./local.png","duration":5}'
```

**Note:** `submit-text`, `submit-image`, and `submit-multi-image` are **aliases** in this script: routing (text vs image vs multi-image) is decided **only** from JSON (`image` / `images`), exactly like `wait`. Use the verb that matches your intent; behavior is the same `submitTask` path aside from blocking.

Poll completion with **`status`** (below). Response JSON may include `batchId` / `taskIds` from the API; this CLI does **not** expose a separate `batch-status` subcommand.

---

## `status`

```sh
node scripts/video_gen.js status --task-id <id>
```

You may also pass a task id inside JSON as `task_id` or `taskId` when using `--json` (less common). If `--task-id` is omitted and JSON has no id, the script errors with `status command requires --task-id <id>`.

There is **no** `batch-status` command in `video_gen.js`; use per-task `status` with each `taskId` you care about. Invalid invocation prints a **Usage** line to stdout JSON (there is no `--help` flag).

---

## Agent UX: in-progress notice, task IDs, backoff polling, completion, skill name

Use this when an executor should **talk to the user** during async generation (not only return raw JSON).

1. **Right after a successful `submit-*`:** Tell the user that generation **has started** and paste **`taskId`** from stdout JSON. If the API returns **`batchId`** and/or **`taskIds`**, include those too so logs and support can correlate the job.

2. **Poll until terminal state:** Run `status --task-id <id>` repeatedly until the payload shows **completed** or **failed** (or your wrapper treats the script’s `phase` / `ok` as terminal). **Recommended delay between polls:** **3s, then 6s, then 12s, …** doubling each time, **cap at 60s**—the same pattern as **`wait`** uses inside `video_gen.js`. Stop early on success/failure; respect your own overall timeout if you need stricter limits than the built-in 10-minute `wait` budget.

3. **`wait` vs `submit-*`:** **`wait`** is one blocking call; it does **not** emit a separate “submitted” line for the user unless **your** layer prints one before/after. For “notify immediately, then poll in the open,” use **`submit-*`** and your own **`status`** loop with the backoff above.

4. **When generation finishes:** Summarize the **outcome** for the user: on success, **playable links** from **`videos`** (e.g. Markdown `[label](url)`); on failure, **`errorCode`** and **`errorMessage`**. **`video_gen.js` does not know which skill was used**—add **skill attribution** yourself from the active skill’s **`SKILL.md`** (e.g. frontmatter **`name`**) or the skill folder slug, in the same user-facing message.

---

## JSON field reference

| Field | Role |
|--------|------|
| `model` | **Required** (non-empty). No default. |
| `prompt` | **Required** for generation commands. |
| `duration` | Sent as number; default **5** if missing/invalid in script—still must be valid for the model. |
| `aspect_ratio` | Optional; model-dependent. |
| `resolution` | Optional; model-dependent. |
| `generate_audio` | Optional boolean; model-dependent. |
| `negative_prompt` | Optional string; model-dependent. |
| `image` | **Image-to-video:** use a **public `https` image URL** (required for normal use). Local path / `file://` is **script-only**: uploads first, then the API receives the returned public URL. |
| `images` | **Multi-image-to-video:** each entry must end up as a **public `https` URL** (directly, or via the script’s upload step). Up to **3** entries are forwarded after preparation. |

Do not send fields the chosen model does not support (reduces spurious `1002` / parameter errors).

---

## Status & stdout conventions

- HTTP OK with business `status === 0` (or `200` in some responses): request accepted for that layer.
- Generation success: look for completed phase and `videos` with `url` / `cover_url`.
- Errors: stdout JSON with `ok: false`, `errorCode`, `errorMessage` (script maps common API codes to short messages).
- Common keys: `ok`, `phase`, `taskId`, `batchId`, `taskIds`, `videos`, `tasks`, `inProgress`, `errorCode`, `errorMessage`.

---

**Reminder:** Skills may require user confirmation before any paid `wait` / `submit-*` call. For **any** image-to-video flow, assume **public `https` image URLs are mandatory** unless the skill explicitly documents a vetted local-upload path—always follow the active skill’s **`SKILL.md`**.


path: scripts/video_gen.js
content: #!/usr/bin/env node
const fs = require('node:fs/promises');
const path = require('node:path');
const { fileURLToPath } = require('node:url');
/**
 * WeryAI 视频生成 — 通用 CLI（零 npm 依赖，需 Node.js 18+）
 *
 * 命令:
 *   wait | submit-text | submit-image | submit-multi-image | status | models
 *
 * 示例:
 *   node video_gen.js models
 *   node video_gen.js models --mode text_to_video
 *   node video_gen.js wait --json '{"model":"WERYAI_VIDEO_1_0","prompt":"...","duration":5}'
 *   node video_gen.js wait --json '{"model":"...","prompt":"...","image":"./local.png","duration":5}'
 *   node video_gen.js wait --json '...' --dry-run
 *
 * 环境变量:
 *   WERYAI_API_KEY（models / 生成 / status 必填；--dry-run 除外）— 敏感凭据，勿写入仓库；registry 元数据声明见同目录 SKILL.md。
 *   本脚本仅读取上述密钥；API 主机与轮询策略为固定常量，勿用其他 env 覆盖。
 *   wait 轮询：首次间隔 3s，之后每次翻倍，单步上限 60s，总超时 600s（10 min）。
 */

const BASE_URL = 'https://api.weryai.com';
const MODELS_BASE_URL = 'https://api-growth-agent.weryai.com';
const MODELS_API_PATH = '/growthai/v1/video/models';
const UPLOAD_API_PATH = '/growthai/v1/generation/upload-file';
/** First delay before the first status poll (ms). */
const POLL_FIRST_DELAY_MS = 3000;
/** Max delay between polls — backoff doubles until this cap (ms). */
const POLL_MAX_DELAY_MS = 60000;
/** Total wall-clock budget for all polls in `wait` (ms). */
const POLL_TIMEOUT_MS = 600000;
const IMAGE_MIME_TYPES = {
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp',
  '.gif': 'image/gif',
};

const STATUS_MAP = {
  waiting: 'waiting',
  WAITING: 'waiting',
  pending: 'waiting',
  PENDING: 'waiting',
  processing: 'processing',
  PROCESSING: 'processing',
  succeed: 'completed',
  SUCCEED: 'completed',
  success: 'completed',
  SUCCESS: 'completed',
  failed: 'failed',
  FAILED: 'failed',
};

const ERROR_MESSAGES = {
  400: 'Bad request — check your request parameters.',
  403: 'Invalid API key or IP access denied — verify WERYAI_API_KEY is correct.',
  500: 'WeryAI server error. Please try again later.',
  1001: 'Request rate limit exceeded — slow down and retry after a moment.',
  1002: 'Parameter error — check prompt, image URLs, aspect_ratio, model key, and duration.',
  1003: 'Resource not found — the task_id may not exist or has expired.',
  1006: 'Model not supported — check the model key or try a different model.',
  1007: 'Queue full — the service is busy, please try again later.',
  1011: 'Insufficient credits — recharge at weryai.com.',
  2003: 'Content flagged by safety system — revise your prompt or input image.',
  2004: 'Image format not supported — use jpg, png, or webp.',
  6004: 'Generation failed — please try again later.',
  6010: 'Concurrent task limit reached (max 20) — wait for existing tasks to complete.',
};

function log(msg) {
  process.stderr.write(`[weryai] ${msg}\n`);
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function httpJson(method, fullUrl, body, apiKey) {
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  };
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 30_000);
  try {
    const res = await fetch(fullUrl, {
      method,
      headers,
      body: body != null ? JSON.stringify(body) : undefined,
      signal: controller.signal,
    });
    clearTimeout(timer);
    let data;
    try {
      data = await res.json();
    } catch {
      data = { status: res.status, msg: `Non-JSON response (HTTP ${res.status})` };
    }
    return { httpStatus: res.status, ...data };
  } catch (err) {
    clearTimeout(timer);
    if (err.name === 'AbortError') {
      throw new Error(`Request timeout: ${method} ${fullUrl}`);
    }
    throw err;
  }
}

async function apiRequest(method, path, body, apiKey) {
  return httpJson(method, BASE_URL + path, body, apiKey);
}

async function fetchModelsRegistry(apiKey) {
  return httpJson('GET', MODELS_BASE_URL + MODELS_API_PATH, null, apiKey);
}

function isPublicHttpsUrl(value) {
  if (typeof value !== 'string' || !value.trim()) return false;
  try {
    const url = new URL(value);
    return url.protocol === 'https:';
  } catch {
    return false;
  }
}

function isRemoteUrl(value) {
  if (typeof value !== 'string' || !value.trim()) return false;
  try {
    const url = new URL(value);
    return url.protocol === 'https:' || url.protocol === 'http:';
  } catch {
    return false;
  }
}

function normalizeLocalFilePath(value) {
  if (typeof value !== 'string' || !value.trim()) return null;
  if (value.startsWith('file://')) {
    return new URL(value);
  }
  return path.resolve(value);
}

function inferMimeType(filePath) {
  return IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
}

function makeUploadBatchNo() {
  return `video-gen-${Date.now()}`;
}

function collectPossibleUploadUrls(data) {
  const candidates = [];
  if (!data) return candidates;
  const maybePush = (value) => {
    if (typeof value === 'string' && value.trim()) candidates.push(value.trim());
  };
  const maybePushMany = (value) => {
    if (Array.isArray(value)) {
      for (const item of value) maybePush(item);
    }
  };

  maybePush(data.url);
  maybePush(data.file_url);
  maybePush(data.fileUrl);
  maybePush(data.https_url);
  maybePush(data.httpsUrl);
  maybePush(data.public_url);
  maybePush(data.publicUrl);
  maybePush(data.object_url);
  maybePush(data.objectUrl);
  maybePush(data.origin_url);
  maybePush(data.originUrl);
  maybePushMany(data.object_url_list);
  maybePushMany(data.objectUrlList);

  if (data.file && typeof data.file === 'object') {
    candidates.push(...collectPossibleUploadUrls(data.file));
  }
  if (data.data && typeof data.data === 'object') {
    candidates.push(...collectPossibleUploadUrls(data.data));
  }
  if (Array.isArray(data.files)) {
    for (const item of data.files) {
      candidates.push(...collectPossibleUploadUrls(item));
    }
  }
  if (Array.isArray(data.list)) {
    for (const item of data.list) {
      candidates.push(...collectPossibleUploadUrls(item));
    }
  }
  return candidates;
}

function extractUploadedFileUrl(res) {
  const urls = collectPossibleUploadUrls(res?.data);
  return urls.find(isPublicHttpsUrl) || null;
}

async function uploadFileToPublicUrl(inputPath, apiKey) {
  const resolvedPath = normalizeLocalFilePath(inputPath);
  if (!resolvedPath) {
    throw new Error(`Invalid local image path: ${inputPath}`);
  }

  const filePath = resolvedPath instanceof URL ? fileURLToPath(resolvedPath) : path.resolve(resolvedPath);
  let stat;
  try {
    stat = await fs.stat(filePath);
  } catch {
    throw new Error(`Local image file not found: ${inputPath}`);
  }
  if (!stat.isFile()) {
    throw new Error(`Local image path is not a file: ${inputPath}`);
  }

  const fileBuffer = await fs.readFile(filePath);
  const fileName = path.basename(filePath);
  const mimeType = inferMimeType(filePath);
  const form = new FormData();
  form.append('batch_no', makeUploadBatchNo());
  form.append('fixed', 'false');
  form.append('file', new Blob([fileBuffer], { type: mimeType }), fileName);

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 60_000);
  let res;
  try {
    res = await fetch(MODELS_BASE_URL + UPLOAD_API_PATH, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
      },
      body: form,
      signal: controller.signal,
    });
  } catch (err) {
    clearTimeout(timer);
    if (err.name === 'AbortError') {
      throw new Error(`Upload timeout: ${filePath}`);
    }
    throw err;
  }
  clearTimeout(timer);

  let data;
  try {
    data = await res.json();
  } catch {
    throw new Error(`Upload failed with non-JSON response (HTTP ${res.status}).`);
  }

  const wrapped = { httpStatus: res.status, ...data };
  if (!isApiSuccess(wrapped)) {
    const apiErr = formatApiError(wrapped);
    throw new Error(apiErr.errorMessage || `Upload failed (HTTP ${res.status}).`);
  }

  const uploadedUrl = extractUploadedFileUrl(wrapped);
  if (!uploadedUrl) {
    throw new Error('Upload succeeded but no public file URL was returned by the API.');
  }

  return uploadedUrl;
}

async function ensurePublicImageUrl(value, apiKey) {
  if (typeof value !== 'string' || !value.trim()) {
    throw new Error('Image source must be a non-empty string.');
  }
  if (isPublicHttpsUrl(value)) return value;
  if (isRemoteUrl(value)) {
    throw new Error(`Remote image URL must use https: ${value}`);
  }

  log(`Uploading local image to public URL: ${value}`);
  return uploadFileToPublicUrl(value, apiKey);
}

function isApiSuccess(res) {
  const httpOk = res.httpStatus >= 200 && res.httpStatus < 300;
  const bodyOk = res.status === 0 || res.status === 200;
  return httpOk && bodyOk;
}

function formatApiError(res) {
  const httpStatus = res.httpStatus || 0;
  const code = res.status;
  const msg = res.msg || '';

  if (httpStatus === 403) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '403',
      errorMessage: ERROR_MESSAGES[403] + (msg ? ` (${msg})` : ''),
    };
  }
  if (httpStatus === 429) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'RATE_LIMIT',
      errorMessage: 'Rate limited by WeryAI API. Please wait and try again.',
    };
  }
  if (httpStatus >= 500) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '500',
      errorMessage: `${ERROR_MESSAGES[500]} (HTTP ${httpStatus})`,
    };
  }
  if (httpStatus === 400) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '400',
      errorMessage: ERROR_MESSAGES[400] + (msg ? ` (${msg})` : ''),
    };
  }

  const friendly = ERROR_MESSAGES[code] || '';
  const message =
    friendly && msg
      ? `${friendly} (${msg})`
      : friendly || msg || `API error (status: ${code}, HTTP ${httpStatus})`;

  return {
    ok: false,
    phase: 'failed',
    errorCode: code != null ? String(code) : null,
    errorMessage: message,
  };
}

function detectMode(params) {
  if (Array.isArray(params.images) && params.images.length > 0) return 'multi_image';
  if (typeof params.image === 'string' && params.image) return 'image';
  return 'text';
}

function coerceBool(v, defaultVal = false) {
  if (v == null) return defaultVal;
  if (typeof v === 'boolean') return v;
  return String(v).toLowerCase() === 'true';
}

function hasNonEmptyModel(params) {
  const m = params.model;
  return m != null && String(m).trim() !== '';
}

function buildRequestBody(params) {
  const model = String(params.model).trim();
  const body = {
    prompt: params.prompt,
    model,
    duration: Number(params.duration) || 5,
  };
  if (params.aspect_ratio) body.aspect_ratio = params.aspect_ratio;
  if (params.resolution) body.resolution = params.resolution;
  if (params.generate_audio != null) body.generate_audio = coerceBool(params.generate_audio, false);
  if (params.negative_prompt) body.negative_prompt = params.negative_prompt;
  if (params.image) body.image = params.image;
  if (params.images) body.images = params.images.slice(0, 3);
  return body;
}

async function prepareRequestBody(params, apiKey) {
  const body = buildRequestBody(params);
  if (body.image) {
    body.image = await ensurePublicImageUrl(body.image, apiKey);
  }
  if (Array.isArray(body.images) && body.images.length) {
    body.images = await Promise.all(body.images.map((item) => ensurePublicImageUrl(item, apiKey)));
  }
  return body;
}

async function submitTask(params, apiKey) {
  if (!hasNonEmptyModel(params)) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'MISSING_PARAM',
      errorMessage: "'model' is required in JSON parameters (no default model).",
    };
  }
  const mode = detectMode(params);
  const pathMap = {
    text: '/v1/generation/text-to-video',
    image: '/v1/generation/image-to-video',
    multi_image: '/v1/generation/multi-image-to-video',
  };
  let body;
  try {
    body = await prepareRequestBody(params, apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'IMAGE_PREPARE_FAILED',
      errorMessage: err.message || String(err),
    };
  }
  let res;
  try {
    res = await apiRequest('POST', pathMap[mode], body, apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const data = res.data || {};
  const taskIds = data.task_ids ?? (data.task_id ? [data.task_id] : []);
  return {
    ok: true,
    phase: 'submitted',
    batchId: data.batch_id ?? null,
    taskIds,
    taskId: taskIds[0] ?? null,
    taskStatus: null,
    videos: null,
    errorCode: null,
    errorMessage: null,
  };
}

function extractVideos(taskData) {
  const tr = taskData.task_result || {};
  const raw = tr.videos || taskData.videos || [];
  return raw.map((v) => {
    if (typeof v === 'string') {
      return { url: v, cover_url: '' };
    }
    return {
      url: v?.url || v?.video_url || '',
      cover_url: v?.cover_image_url || v?.cover_url || '',
    };
  });
}

async function pollUntilDone(taskId, batchId, taskIds, apiKey) {
  const start = Date.now();
  let nextDelayMs = POLL_FIRST_DELAY_MS;
  while (true) {
    const elapsedMs = Date.now() - start;
    if (elapsedMs >= POLL_TIMEOUT_MS) {
      return {
        ok: false,
        phase: 'failed',
        errorCode: 'TIMEOUT',
        errorMessage: `Poll timeout after ${Math.floor(elapsedMs / 1000)}s.`,
        batchId,
        taskIds,
        taskId,
        taskStatus: 'unknown',
        videos: null,
      };
    }

    await sleep(nextDelayMs);
    nextDelayMs = Math.min(nextDelayMs * 2, POLL_MAX_DELAY_MS);

    let res;
    try {
      res = await apiRequest('GET', `/v1/generation/${taskId}/status`, null, apiKey);
    } catch (err) {
      log(`Warning: poll failed (${err.message}), retrying...`);
      continue;
    }

    if (!isApiSuccess(res)) {
      log('Warning: poll returned non-success status, retrying...');
      continue;
    }

    const taskData = res.data || {};
    const rawStatus = taskData.task_status || '';
    const normalized = STATUS_MAP[rawStatus] || 'unknown';
    const elapsedSec = Math.floor((Date.now() - start) / 1000);
    log(
      `Polling ${taskId}... status: ${rawStatus} (${elapsedSec}s elapsed); next backoff delay ${Math.min(nextDelayMs, POLL_MAX_DELAY_MS)}ms`,
    );

    if (normalized === 'completed') {
      const videos = extractVideos(taskData);
      return {
        ok: true,
        phase: 'completed',
        batchId,
        taskIds,
        taskId,
        taskStatus: rawStatus,
        videos: videos.length ? videos : null,
        errorCode: null,
        errorMessage: null,
      };
    }

    if (normalized === 'failed') {
      const tr = taskData.task_result || {};
      const errMsg = tr.message || taskData.msg || 'Task failed';
      return {
        ok: false,
        phase: 'failed',
        batchId,
        taskIds,
        taskId,
        taskStatus: rawStatus,
        videos: null,
        errorCode: 'TASK_FAILED',
        errorMessage: errMsg,
      };
    }
  }
}

async function cmdWait(params, apiKey) {
  const submitResult = await submitTask(params, apiKey);
  if (!submitResult.ok) return submitResult;
  const taskId = submitResult.taskId;
  const batchId = submitResult.batchId;
  const taskIds = submitResult.taskIds || [taskId];
  log(`Submitted task ${taskId}. Polling for completion...`);
  return pollUntilDone(taskId, batchId, taskIds, apiKey);
}

async function cmdStatus(taskId, apiKey) {
  let res;
  try {
    res = await apiRequest('GET', `/v1/generation/${taskId}/status`, null, apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const taskData = res.data || {};
  const rawStatus = taskData.task_status || '';
  const normalized = STATUS_MAP[rawStatus] || 'unknown';
  const videos = extractVideos(taskData);
  const phase =
    normalized === 'completed' ? 'completed' : normalized === 'failed' ? 'failed' : 'running';
  const tr = taskData.task_result || {};
  return {
    ok: phase !== 'failed',
    phase,
    batchId: null,
    taskIds: [taskId],
    taskId,
    taskStatus: rawStatus,
    videos: videos.length ? videos : null,
    errorCode: phase === 'failed' ? 'TASK_FAILED' : null,
    errorMessage: phase === 'failed' ? tr.message || taskData.msg || null : null,
  };
}

const VALID_MODEL_MODES = ['text_to_video', 'image_to_video', 'multi_image_to_video'];

async function cmdModels(modeFilter, apiKey) {
  let res;
  try {
    res = await fetchModelsRegistry(apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const data = res.data || {};
  const out = { ok: true, phase: 'completed' };

  if (modeFilter) {
    if (!VALID_MODEL_MODES.includes(modeFilter)) {
      return {
        ok: false,
        phase: 'failed',
        errorCode: 'VALIDATION',
        errorMessage: `Invalid --mode. Use: ${VALID_MODEL_MODES.join(', ')}`,
      };
    }
    out[modeFilter] = data[modeFilter] || [];
  } else {
    out.text_to_video = data.text_to_video || [];
    out.image_to_video = data.image_to_video || [];
    out.multi_image_to_video = data.multi_image_to_video || [];
  }

  return out;
}

function parseArgs(argv) {
  const cmd = argv[0];
  let jsonStr = null;
  let taskId = null;
  let dryRun = false;
  let modelsMode = null;
  for (let i = 1; i < argv.length; i++) {
    if (argv[i] === '--json') jsonStr = argv[++i];
    else if (argv[i] === '--task-id') taskId = argv[++i];
    else if (argv[i] === '--dry-run') dryRun = true;
    else if (argv[i] === '--mode') modelsMode = argv[++i];
  }
  return { command: cmd, jsonStr, taskId, dryRun, modelsMode };
}

function printJson(obj) {
  process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
}

async function main() {
  const argv = process.argv.slice(2);
  const valid = new Set([
    'wait',
    'submit-text',
    'submit-image',
    'submit-multi-image',
    'status',
    'models',
  ]);
  const { command, jsonStr, taskId: cliTaskId, dryRun, modelsMode } = parseArgs(argv);

  if (!command || !valid.has(command)) {
    printJson({
      ok: false,
      phase: 'failed',
      errorCode: 'USAGE',
      errorMessage:
        'Usage: node video_gen.js <wait|submit-text|submit-image|submit-multi-image|status|models> [--json \'...\'] [--task-id id] [--dry-run] [--mode text_to_video|image_to_video|multi_image_to_video]',
    });
    process.exit(1);
  }

  let params = {};
  if (jsonStr) {
    try {
      params = JSON.parse(jsonStr);
    } catch (e) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'INVALID_JSON',
        errorMessage: `Invalid JSON: ${e.message}`,
      });
      process.exit(1);
    }
  }

  if (command === 'models') {
    const [REDACTED_SECRET] || '').trim();
    if (!apiKey) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'NO_API_KEY',
        errorMessage: 'Missing WERYAI_API_KEY environment variable.',
      });
      process.exit(1);
    }
    const result = await cmdModels(modelsMode, apiKey);
    printJson(result);
    process.exit(result.ok ? 0 : 1);
  }

  if (dryRun) {
    const mode = detectMode(params);
    const pathMap = {
      text: '/v1/generation/text-to-video',
      image: '/v1/generation/image-to-video',
      multi_image: '/v1/generation/multi-image-to-video',
    };
    if ('prompt' in params && !hasNonEmptyModel(params)) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'MISSING_PARAM',
        errorMessage: "'model' is required in JSON parameters (no default model).",
      });
      process.exit(1);
    }
    const body = 'prompt' in params ? buildRequestBody(params) : {};
    const notes = [];
    if (body.image && !isPublicHttpsUrl(body.image)) {
      notes.push('Local `image` paths are uploaded to WeryAI file storage during a real run; `--dry-run` does not upload files.');
    }
    if (Array.isArray(body.images) && body.images.some((item) => !isPublicHttpsUrl(item))) {
      notes.push('Local entries in `images` are uploaded to WeryAI file storage during a real run; `--dry-run` keeps the original paths.');
    }
    printJson({
      ok: true,
      phase: 'dry-run',
      dryRun: true,
      requestBody: body,
      requestUrl: BASE_URL + (pathMap[mode] || pathMap.text),
      notes: notes.length ? notes : undefined,
    });
    process.exit(0);
  }

  const [REDACTED_SECRET] || '').trim();
  if (!apiKey) {
    printJson({
      ok: false,
      phase: 'failed',
      errorCode: 'NO_API_KEY',
      errorMessage: 'Missing WERYAI_API_KEY environment variable.',
    });
    process.exit(1);
  }

  let result;
  if (command === 'status') {
    const tid = cliTaskId || params.task_id || params.taskId;
    if (!tid) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'MISSING_PARAM',
        errorMessage: 'status command requires --task-id <id>',
      });
      process.exit(1);
    }
    result = await cmdStatus(tid, apiKey);
  } else {
    if (!params.prompt) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'MISSING_PARAM',
        errorMessage: "'prompt' is required in JSON parameters.",
      });
      process.exit(1);
    }
    if (!hasNonEmptyModel(params)) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'MISSING_PARAM',
        errorMessage: "'model' is required in JSON parameters (no default model).",
      });
      process.exit(1);
    }
    result = command === 'wait' ? await cmdWait(params, apiKey) : await submitTask(params, apiKey);
  }

  printJson(result);
  process.exit(result.ok ? 0 : 1);
}

main().catch((err) => {
  printJson({
    ok: false,
    phase: 'failed',
    errorCode: 'FATAL',
    errorMessage: err.message || String(err),
  });
  process.exit(1);
});


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