
Happy Video Gen
- 162 installs
- 303 repo stars
- Updated April 20, 2026
- iamzhihuix/happy-claude-skills
Produce short promotional, tutorial, or social videos from agent workflows to support launches, onboarding, and ongoing content marketing without a separate video editor pipeline.
About
happy-video-gen from iamzhihuix/happy-claude-skills lets Claude Code generate videos for demos, ads, and social posts. It fits grow-stage content work when teams need motion assets quickly without exporting to dedicated video tools or freelance editors.
- In-agent video generation
- Launch and tutorial clip support
- Happy Claude skills integration
- Reduces external video editor handoffs
- Accelerates campaign creative cycles
Happy Video Gen by the numbers
- 162 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #676 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iamzhihuix/happy-claude-skills --skill happy-video-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 162 |
|---|---|
| repo stars | ★ 303 |
| Last updated | April 20, 2026 |
| Repository | iamzhihuix/happy-claude-skills ↗ |
What it does
Produce short promotional, tutorial, or social videos from agent workflows to support launches, onboarding, and ongoing content marketing without a separate video editor pipeline.
Files
happy-video-gen
Generates short videos (text-to-video or image-to-video) across 10 providers through one CLI: bun scripts/main.ts .... All providers are async — the CLI submits a job, polls until the provider finishes, then downloads the MP4 / WebM.
Quick usage
# Text-to-video
bun scripts/main.ts --prompt "camera slowly pushes into a calico cat on grass" --ar 16:9 --duration 5 --video ./out.mp4
# Image-to-video (first frame)
bun scripts/main.ts --prompt "subtle zoom, leaves swaying" --image ./keyframe.png --duration 5 --video ./out.mp4
# Image-to-video with last-frame control (provider-dependent)
bun scripts/main.ts --prompt "seamless morph" --image ./a.png --last-frame ./b.png --video ./out.mp4When to invoke this skill
- User asks to generate / create / make / synthesize a video from text.
- User asks to animate a still image, or provides a first-frame path.
- User names any video model family (Sora, Veo, Runway, Kling, Wan, Seedance, Hailuo, Pika, Dream Machine, Vidu).
Route to happy-dreamina if the user explicitly mentions 即梦 / Jimeng / dreamina CLI. Route to happy-image-gen if the user actually wants a still image.
Step 0: Preflight (BLOCKING)
1. Locate EXTEND.md (same resolution order as happy-image-gen):
./.happy-skills/happy-video-gen/EXTEND.md$XDG_CONFIG_HOME/happy-skills/happy-video-gen/EXTEND.md~/.happy-skills/happy-video-gen/EXTEND.md
If none, run bun scripts/main.ts --setup and walk the user through references/config/first-time-setup.md.
2. Verify one provider has credentials. Check env vars in the order the CLI auto-detects (see providers.md). Do not proceed without one usable provider.
3. Verify Bun. Fall back to npx -y bun if missing.
4. Warn about cost. Video generation is 10–100× more expensive per call than images. If the user asks for HD 1080P / 10-second clips, confirm before firing — show them the expected provider cost bracket from references/providers.md.
Step 1: Choose provider
Preference order:
1. --provider <id> explicitly passed. 2. EXTEND.md default_provider. 3. Auto-detect from env vars: fal > ark > minimax > runway > luma > pika > vidu > google > bailian > openai.
Pick by strength of the actual task:
- Chinese prompts / Chinese text in frame →
ark(Seedance) orbailian(Wanx). - Photorealistic portraits →
google(Veo 3) orrunway(Gen-4). - Anime / stylized →
fal(Kling) orluma. - Cheap draft →
arkSeedance Lite,falKling v2.5 turbo,viduQ1. - Voice-synced dialogue video (if applicable) →
googleVeo 3,openaiSora 2.
Step 2: Fill parameters
- `--prompt`: always double-quote.
- `--image <path>` / `--last-frame <path>`: local paths, will be base64-encoded as data URIs automatically.
--last-frameonly accepted by Luma and a few FAL endpoints. - `--duration <seconds>`: 5 is universal default. Caps: Sora-2 / Kling up to 10; Seedance up to 10; Luma up to 9.
- `--ar <ratio>`:
16:9 / 9:16 / 1:1 / 4:3 / 3:4. Seereferences/aspect_ratio_map.mdfor provider-specific quirks. - `--resolution`:
480p / 720p / 1080p. Not all providers honour it; most cap at 720p on cheap tiers. - `--poll-timeout`: default 600s (10 min). Increase for 1080P or >5s clips.
Step 3: Submit and wait
bun scripts/main.ts \
--prompt "..." \
--video ./out.mp4 \
--provider ark \
--duration 5 \
--ar 16:9 \
--resolution 720pWhile waiting, do not fire another job on the same provider — concurrency caps on cheap tiers are strict (often 1). On success the CLI writes the MP4 and reports size + path. JSON output:
{ "success": true, "provider": "ark", "model": "doubao-seedance-1-0-lite-t2v-250408", "video": "/abs/out.mp4", "size_bytes": 4823456, "format": "mp4" }Step 4: Timeouts and recovery
If polling exceeds --poll-timeout, the CLI throws with the provider-specific external id (task id / job id / operation name / request id). Capture it from stderr and resume later with provider-specific tooling. See references/async-protocol.md for the per-provider id format and manual resume commands.
References
- `references/providers.md` — all 10 providers with env vars, defaults, cost notes, feature matrix.
- `references/async-protocol.md` — external id format per provider + how to resume a stuck task.
- `references/aspect_ratio_map.md` —
--armapping per provider. - `references/error_codes.md` — common errors and fixes.
- `references/config/first-time-setup.md` — setup walkthrough.
- `references/config/extend-schema.md` — EXTEND.md schema.
- `assets/EXTEND.template.md` — config template.
happy-video-gen — EXTEND.md template
Copy this file to:
./.happy-skills/happy-video-gen/EXTEND.md(project-level)- or
~/.happy-skills/happy-video-gen/EXTEND.md(user-level)
Fill in only the sections you need. Delete providers you don't use.
version: 1
# Defaults applied when the CLI flag is missing
default_provider: fal
default_quality: hd # draft | hd | ultra
default_aspect_ratio: "16:9" # 16:9 | 9:16 | 1:1 | 4:3 | 3:4
default_duration: 5 # seconds
default_resolution: 720p # 480p | 720p | 1080p
default_save_dir: ~/Videos/claude-gen
# Per-provider default model (overrides hard-coded defaults)
default_model:
# openai: sora-2
# google: veo-3.0-generate-preview
# runway: gen4_turbo
# pika: pika-2.2
# luma: ray-2
fal: fal-ai/kling-video/v2.5-turbo/pro/text-to-video
# ark: doubao-seedance-1-0-lite-t2v-250408
# bailian: wanx2.1-i2v-turbo
# minimax: MiniMax-Hailuo-02
# vidu: viduq1
providers:
fal:
api_key_env: FAL_KEY
# openai:
# api_key_env: OPENAI_API_KEY
# google:
# api_key_env: GOOGLE_API_KEY
# runway:
# api_key_env: RUNWAY_API_KEY
# pika:
# api_key_env: PIKA_API_KEY
# luma:
# api_key_env: LUMA_API_KEY
# ark:
# api_key_env: ARK_API_KEY
# bailian:
# api_key_env: DASHSCOPE_API_KEY
# minimax:
# api_key_env: MINIMAX_API_KEY
# # base_url: https://api.minimaxi.com/v1 # uncomment if the default region errors
# vidu:
# api_key_env: VIDU_API_KEYSecurity
- Never paste raw keys into this file.
- If you commit this at project level, make sure every
providers.*usesapi_key_envorapi_key_source.
{
"name": "happy-video-gen",
"version": "0.1.0",
"description": "Universal AI video generation skill — OpenAI Sora, Google Veo, Runway, Pika, Luma, FAL, Ark Seedance, Bailian Wanx, MiniMax Hailuo, Vidu",
"type": "module",
"private": true,
"engines": {
"bun": ">=1.1.0"
},
"scripts": {
"gen": "bun scripts/main.ts"
}
}
happy-video-gen — aspect ratio per provider
All providers accept 16:9 and 9:16. Other ratios vary.
--ar | openai | runway | pika | luma | fal | ark | bailian | minimax | vidu | |
|---|---|---|---|---|---|---|---|---|---|---|
16:9 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
9:16 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
1:1 | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
4:3 | ✅ | ✅ | — | — | ✅ | partial | ✅ | — | — | — |
3:4 | ✅ | ✅ | — | — | ✅ | partial | ✅ | — | — | — |
21:9 | — | — | — | — | — | partial | — | — | — | — |
When a ratio is unsupported the CLI falls back to the closest match (typically 16:9 or 9:16) and prints a warning to stderr — the job still runs.
If you need an exact frame size rather than a ratio, pass --resolution 1080p (or the label the provider expects). Resolution labels are normalized where possible but will pass through unchanged to the provider — check the provider docs for allowed labels.
Async task protocol — external id format per provider
Every video provider is async: submit → poll → download. The CLI handles all three stages transparently, but when --poll-timeout expires the job keeps running on the provider's backend. The error message includes the provider-specific external id — use it to resume manually.
---
External id format
| Provider | Field in error | Endpoint to resume |
|---|---|---|
openai | job_id | GET /v1/videos/{job_id} |
google | operation (e.g. models/veo-3.0-generate-preview/operations/...) | GET /v1beta/{operation}?key=... |
runway | task id | GET /v1/tasks/{id} with X-Runway-Version |
pika | job id | GET /v1/generations/{id} |
luma | generation id | GET /dream-machine/v1/generations/{id} |
fal | request_id | The status_url and response_url returned on submit — copy them from the error or check the FAL dashboard |
ark | taskId (like cgt-...) | GET /api/v3/contents/generations/tasks/{taskId} |
bailian | task id | GET /api/v1/tasks/{task_id} |
minimax | task_id | GET /v1/query/video_generation?task_id=... → returns file_id → GET /v1/files/retrieve?file_id=... |
vidu | task id | GET /ent/v2/tasks/{id}/creations with Authorization: Token ... |
---
Resuming manually
The CLI does not yet expose a resume subcommand. If you have an external id:
1. Note the provider (prefix in the CLI error, e.g. [ark]). 2. Hit the resume endpoint directly via curl (use the provider's own API key from env). 3. When the task status is the "done" marker (SUCCEEDED / completed / COMPLETED / succeeded), fetch the video url from the response and curl -o out.mp4 '<url>'.
Example — recovering an Ark Seedance task whose local polling timed out:
curl -H "Authorization: Bearer $ARK_API_KEY" \
"https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/cgt-20260417-abc123" | jq
# when status == succeeded:
curl -o resumed.mp4 'https://tos-cn-beijing.volces.com/...'---
Why the CLI does not persist state
For MVP we deliberately keep the CLI stateless — the task id is only written to stderr / --json output. Rationale:
- Jobs complete within 10 minutes in almost all cases, so poll-in-one-process is simpler.
- Preserving a
tasks.db-style log would mean every project carries its own SQLite — awkward when the skill is dropped vianpx skills add.
If you need persistence, dump the --json output to a file and pick it up later.
---
Concurrency caps
Cheap tiers typically allow 1 concurrent job per provider. If you kick off two jobs on the same provider in parallel, expect the second to fail with 429 / concurrent limit exceeded. The CLI does not enforce any local queue — that is the caller's responsibility.
---
What if I resumed a task and it had already been deleted?
Most providers keep async results for 24h–7 days depending on tier. If the resume endpoint returns 404, the task bytes are gone; re-submit the original prompt.
EXTEND.md schema — happy-video-gen
Same resolution mechanics as happy-image-gen. First match wins:
1. ./.happy-skills/happy-video-gen/EXTEND.md 2. $XDG_CONFIG_HOME/happy-skills/happy-video-gen/EXTEND.md 3. ~/.happy-skills/happy-video-gen/EXTEND.md
---
Top-level fields
| Field | Type | Default | Description |
|---|---|---|---|
version | integer | 1 | Schema version. |
default_provider | provider id | (auto-detect) | Default when --provider not passed. |
default_quality | draft / hd / ultra | hd | Quality preset. |
default_aspect_ratio | string | 16:9 | |
default_duration | integer seconds | 5 | |
default_resolution | string | 720p | |
default_save_dir | path | (cwd) | Used for relative --video paths. |
default_model | object | {} | Per-provider default model. |
providers | object | {} | Per-provider credential references + endpoint overrides. |
---
default_model
default_model:
openai: sora-2
google: veo-3.0-generate-preview
runway: gen4_turbo
pika: pika-2.2
luma: ray-2
fal: fal-ai/kling-video/v2.5-turbo/pro/text-to-video
ark: doubao-seedance-1-0-lite-t2v-250408
bailian: wanx2.1-i2v-turbo
minimax: MiniMax-Hailuo-02
vidu: viduq1---
providers
Each provider slot supports:
api_key_env— shell env var name (recommended).api_key_source— 1Password reference (e.g.op://Vault/Item/field), requires the 1password skill.base_url— endpoint override.
providers:
fal:
api_key_env: FAL_KEY
runway:
api_key_env: RUNWAY_API_KEY
# optional: override API version header
# api_version: "2024-11-06"
ark:
api_key_env: ARK_API_KEY
# base_url: https://ark-ap-southeast-1.volces.com/api/v3 # for overseas region
bailian:
api_key_env: DASHSCOPE_API_KEY
minimax:
api_key_env: MINIMAX_API_KEY
base_url: https://api.minimaxi.com/v1 # use if default region failsNever paste raw keys
This file is likely committed. Only use api_key_env or api_key_source.
happy-video-gen — first-time setup
Video generation is expensive — typical 5-second clip costs ¥0.2 ~ $2 depending on provider. Walk the user through setup carefully.
1. Confirm Bun
command -v bunIf missing, install:
curl -fsSL https://bun.sh/install | bashOr use npx -y bun without installing.
2. Pick providers
Typical combinations:
- Budget-friendly:
fal(Kling Turbo) +ark(Seedance Lite) — both ~¥0.2–0.5 per 5s clip. - Best quality:
google(Veo 3) +runway(Gen-4 Alpha) — ~$0.40–2.00 per clip. - Chinese production:
ark(Seedance) +bailian(Wanx i2v) +minimax(Hailuo-02).
3. Issue API keys
Get keys from:
| Provider | Env var | Dashboard |
|---|---|---|
| openai | OPENAI_API_KEY | https://platform.openai.com/api-keys (Sora requires org access) |
GOOGLE_API_KEY | https://aistudio.google.com/app/apikey | |
| runway | RUNWAY_API_KEY | https://dev.runwayml.com |
| pika | PIKA_API_KEY | https://pika.art (API access by request) |
| luma | LUMA_API_KEY | https://lumalabs.ai/dream-machine/api/keys |
| fal | FAL_KEY | https://fal.ai/dashboard/keys |
| ark | ARK_API_KEY | https://console.volcengine.com/ark (also create an Endpoint ID) |
| bailian | DASHSCOPE_API_KEY | https://dashscope.console.aliyun.com/apiKey |
| minimax | MINIMAX_API_KEY | https://platform.minimaxi.com |
| vidu | VIDU_API_KEY | https://platform.vidu.com |
Export in your shell:
export FAL_KEY="..."
export ARK_API_KEY="..."Add to ~/.zshrc / ~/.bashrc for persistence.
4. Create EXTEND.md
Copy assets/EXTEND.template.md to one of:
./.happy-skills/happy-video-gen/EXTEND.md(project-level)~/.happy-skills/happy-video-gen/EXTEND.md(user-level)
Fill in only the sections for providers you use. Minimum:
default_provider: fal
default_duration: 5
default_aspect_ratio: "16:9"
default_resolution: 720p
default_save_dir: ~/Videos/claude-gen
providers:
fal:
api_key_env: FAL_KEYSee ./extend-schema.md for the full schema.
5. Smoke test (cheapest possible)
bun scripts/main.ts \
--prompt "slow zoom over a peaceful meadow at sunrise" \
--duration 5 \
--ar 16:9 \
--provider fal \
--video /tmp/test.mp4Expected: after 30–90 seconds, the CLI writes /tmp/test.mp4 (a few MB) and prints the path.
If it fails:
[fal] FAL submit 403= out of credits, top up.[fal] ... 422= prompt rejected or schema mismatch — use a neutral prompt for smoke testing.config: ...= setup issue, check env vars.
happy-video-gen — common errors and fixes
The CLI prefixes all provider errors with [<provider>]. Use the prefix to find the row.
---
Generic
| Message | Cause | Fix |
|---|---|---|
config: No prompt provided | --prompt / --promptfiles missing | Pass one. |
config: --video is required | Output path missing | Pass --video ./out.mp4. |
config: No provider selected ... | No --provider and no env key detected | Run --setup or export an env var. |
... Polling deadline ... exceeded ... | --poll-timeout passed before the provider finished | Increase timeout; capture external id from error for manual resume. |
---
Per-provider quick map
| Error contains | Meaning | Fix |
|---|---|---|
[openai] OpenAI videos 403 | Org lacks Sora video access | Contact OpenAI sales, or use another provider. |
[openai] ... 404 | Endpoint not rolled out for your key | Verify API shape at platform.openai.com docs. |
[google] Veo create 400 | Bad params (e.g., durationSeconds out of range) | Check Veo docs for valid values. |
[google] ... PERMISSION_DENIED | Veo not enabled on the project | Enable the Generative Language API. |
[runway] ... 401 | RUNWAY_API_KEY invalid | Refresh at dev.runwayml.com. |
[runway] ... 400 ratio | Unsupported --ar for the chosen model | Pick 16:9 or 9:16, or switch model. |
[luma] Luma create 402 | Out of credits | Top up. |
[fal] FAL submit 403 | Out of balance | Top up at fal.ai/dashboard/billing. |
[fal] ... 422 | Model expects different input field names | Read the raw FAL error — the input schema is model-specific. |
[ark] InvalidEndpointOrModel.NotFound | --model is not a valid endpoint id in your Volc account | Create an inference endpoint in the Volc Ark console, pass that id. |
[bailian] ... 400 DataInspectionFailed | Content filter | Surface raw error; do not rephrase silently. |
[minimax] ... 401 | Wrong env var or wrong region host | Verify MINIMAX_API_KEY and try MINIMAX_BASE_URL=https://api.minimaxi.com/v1. |
[vidu] ... 401 | Wrong auth scheme | Vidu uses Authorization: Token <key>, not Bearer. |
... concurrent limit exceeded | Too many jobs in flight on cheap tier | Reduce concurrency to 1 for that provider. |
---
Strategy when one provider blocks you
1. Read the raw error — surface it verbatim to the user. 2. If it's a credential / quota issue, suggest switching provider via --provider. 3. If it's content moderation, do not rephrase the prompt silently — tell the user the provider blocked it and let them decide. 4. If it's a schema mismatch (422, invalid parameter), check the provider's live docs; API fields evolve and this skill ships best-effort defaults.
happy-video-gen — provider reference
Feature matrix (all 10 providers are implemented in this release; verify API shape against each provider's live docs before relying on a specific field):
| Provider | Env var | Default model | Text→Video | Image→Video | Last-frame | Audio | Typical cost (5s/720p) |
|---|---|---|---|---|---|---|---|
openai | OPENAI_API_KEY | sora-2 | ✅ | ✅ | — | ✅ (with audio) | ~$0.50–2.00 |
google | GOOGLE_API_KEY or GEMINI_API_KEY | veo-3.0-generate-preview | ✅ | ✅ | — | ✅ (Veo 3) | ~$0.75 |
runway | RUNWAY_API_KEY | gen4_turbo | ✅ | ✅ | — | — | ~$0.40 |
pika | PIKA_API_KEY | pika-2.2 | ✅ | ✅ | — | — | ~$0.20 |
luma | LUMA_API_KEY | ray-2 | ✅ | ✅ | ✅ | — | ~$0.35 |
fal | FAL_KEY | fal-ai/kling-video/v2.5-turbo/pro/text-to-video | ✅ | via different model | partial | varies | ~$0.10–0.30 |
ark | ARK_API_KEY | doubao-seedance-1-0-lite-t2v-250408 | ✅ | ✅ | ✅ | — | ¥0.2–1.0 |
bailian | DASHSCOPE_API_KEY | wanx2.1-i2v-turbo | — | ✅ | — | — | ¥0.3 |
minimax | MINIMAX_API_KEY | MiniMax-Hailuo-02 | ✅ | ✅ | — | — | ¥0.5 |
vidu | VIDU_API_KEY | viduq1 | ✅ | ✅ | — | — | ¥0.2 |
Model ids evolve. Users can override per-provider via EXTEND.md default_model or --model, and per-provider env overrides (e.g., ARK_VIDEO_MODEL, FAL_VIDEO_MODEL, GOOGLE_VIDEO_MODEL).
---
1. OpenAI (Sora 2)
- Requires org access to the Sora video API.
- Supports image seed (first frame) and audio.
- Synchronous create, async poll on
/v1/videos/{id}.
2. Google (Veo)
- Uses the Generative Language API (not Vertex).
- Endpoint:
models/{model}:predictLongRunning, poll operation name. - Veo 3 includes audio generation by default.
3. Runway (Gen-3 / Gen-4)
- Requires
X-Runway-Version: 2024-11-06header (configurable viaRUNWAY_API_VERSION). - Two endpoints:
/text_to_videoand/image_to_video. - Gen-4 Turbo is the cheapest tier; Gen-4 Alpha for highest quality.
4. Pika (2.2)
- API access historically required approval; schema may change. Best effort in this release.
5. Luma (Dream Machine — Ray 2)
- Supports keyframe control (first + last frame).
- Duration strings use the
"5s"/"9s"format.
6. FAL (Kling / Wan / Veo / Sora wrappers)
- Queue-based submission (
https://queue.fal.run/{model}), then pollstatus_url, then fetchresponse_url. - Lots of model choices — default is Kling v2.5 turbo pro. Browse https://fal.ai/models for others.
7. Ark — Seedance (Volc Engine 方舟)
- Endpoint:
/contents/generations/tasks. - Model id must be one of the user's own endpoint ids (create via Volc console) — the default here may not work out of the box; expect 404 / InvalidEndpointOrModel.
- Seedance 1.5 Pro is the flagship; Lite is 5–10× cheaper.
8. Bailian — Wanx (Aliyun DashScope)
- Async submission (
X-DashScope-Async: enable), poll via/api/v1/tasks/{task_id}. - i2v turbo model is the cheapest route.
9. MiniMax — Hailuo 02
- Create → poll → retrieve file handle → download via signed url.
- Base URL:
api.minimax.io(verify region; some accounts useapi.minimaxi.com).
10. Vidu (Q1 / Q3)
- Two endpoints:
/text2videoand/img2video. - Auth header uses
Token <key>(notBearer).
---
Provider cheatsheet
| User intent | Recommended provider |
|---|---|
| Best Chinese text on screen | ark (Seedance) |
| Best voice-sync dialogue | openai (Sora 2) or google (Veo 3) |
| Cheap 5s draft | fal Kling Turbo, ark Seedance Lite, vidu Q1 |
| Photorealism | google Veo 3 or runway Gen-4 Alpha |
| Anime / stylized | luma Ray 2 or fal Kling |
| Animate a photograph (i2v) | bailian Wanx i2v turbo or ark Seedance i2v |
When in doubt and the user cares about latency + cost, pick fal Kling Turbo first.
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { CliArgs, Provider, ProviderModule, Quality } from "./types";
import { ConfigError, ProviderError } from "./types";
import { writeBytesToFile } from "./utils/output";
import { validateVideoMagic } from "./utils/async";
const KNOWN_PROVIDERS: Provider[] = [
"openai",
"google",
"runway",
"pika",
"luma",
"fal",
"ark",
"bailian",
"minimax",
"vidu",
];
const IMPLEMENTED: Set<Provider> = new Set(KNOWN_PROVIDERS);
function printUsage(): void {
console.log(`happy-video-gen — universal AI video generation CLI
Usage:
bun scripts/main.ts --prompt "camera pans across a meadow" --video out.mp4
bun scripts/main.ts --prompt "..." --image first.png --video out.mp4
bun scripts/main.ts --prompt "..." --image first.png --last-frame end.png --video out.mp4
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from files (concatenated)
--video <path> Output video path (REQUIRED)
--image <path> First-frame reference (image-to-video mode)
--last-frame <path> Last-frame reference (only a few providers support this)
--duration <seconds> Target duration in seconds (provider-capped)
--fps <n> Frame rate override (provider-capped)
--provider <id> openai | google | runway | pika | luma | fal | ark | bailian | minimax | vidu
-m, --model <id> Provider-specific model id
--ar <ratio> Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4)
--resolution <label> 480p | 720p | 1080p (provider-dependent)
--quality <preset> draft | hd (default) | ultra
--poll-timeout <seconds> Max time to wait for an async job (default 600)
--poll-interval <seconds> Poll cadence (default 5)
--json Emit JSON result to stdout
--setup Print first-time setup guide and exit
-h, --help Show this message
Environment variables (one per provider; set at least one):
OPENAI_API_KEY, GOOGLE_API_KEY (or GEMINI_API_KEY), RUNWAY_API_KEY,
PIKA_API_KEY, LUMA_API_KEY, FAL_KEY, ARK_API_KEY, DASHSCOPE_API_KEY,
MINIMAX_API_KEY, VIDU_API_KEY
See references/providers.md for per-provider notes and references/async-protocol.md
for how to recover from interrupted jobs using the external id in --json output.
`);
}
async function printSetupGuide(): Promise<void> {
const here = path.dirname(fileURLToPath(import.meta.url));
const guidePath = path.resolve(here, "../references/config/first-time-setup.md");
try {
const guide = await readFile(guidePath, "utf8");
console.log(guide);
} catch {
console.log("No first-time-setup.md found. Set a provider env var (e.g., FAL_KEY) and rerun.");
}
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
prompt: null,
promptFiles: [],
videoPath: null,
imagePath: null,
lastFramePath: null,
duration: null,
fps: null,
provider: null,
model: null,
aspectRatio: null,
resolution: null,
quality: null,
pollTimeoutMs: 600_000,
pollIntervalMs: 5_000,
json: false,
setup: false,
help: false,
};
const tokens = [...argv];
while (tokens.length > 0) {
const token = tokens.shift()!;
switch (token) {
case "-p":
case "--prompt":
args.prompt = tokens.shift() ?? null;
break;
case "--promptfiles":
while (tokens.length > 0 && !tokens[0]!.startsWith("--") && tokens[0] !== "-p") {
args.promptFiles.push(tokens.shift()!);
}
break;
case "--video":
args.videoPath = tokens.shift() ?? null;
break;
case "--image":
args.imagePath = tokens.shift() ?? null;
break;
case "--last-frame":
case "--lastframe":
args.lastFramePath = tokens.shift() ?? null;
break;
case "--duration":
args.duration = Number(tokens.shift() || 0);
break;
case "--fps":
args.fps = Number(tokens.shift() || 0);
break;
case "--provider":
args.provider = tokens.shift() as Provider | null;
break;
case "-m":
case "--model":
args.model = tokens.shift() ?? null;
break;
case "--ar":
case "--aspect":
args.aspectRatio = tokens.shift() ?? null;
break;
case "--resolution":
case "--res":
args.resolution = tokens.shift() ?? null;
break;
case "--quality":
args.quality = tokens.shift() as Quality | null;
break;
case "--poll-timeout":
args.pollTimeoutMs = Math.max(10, Number(tokens.shift() || 600)) * 1000;
break;
case "--poll-interval":
args.pollIntervalMs = Math.max(1, Number(tokens.shift() || 5)) * 1000;
break;
case "--json":
args.json = true;
break;
case "--setup":
args.setup = true;
break;
case "-h":
case "--help":
args.help = true;
break;
default:
if (!token.startsWith("-") && args.prompt === null && args.promptFiles.length === 0) {
args.prompt = token;
} else {
console.error(`Unknown argument: ${token}`);
args.help = true;
}
}
}
return args;
}
async function resolvePrompt(args: CliArgs): Promise<string> {
if (args.prompt && args.promptFiles.length === 0) return args.prompt;
if (args.promptFiles.length > 0) {
const parts: string[] = [];
if (args.prompt) parts.push(args.prompt);
for (const file of args.promptFiles) {
parts.push(await readFile(file, "utf8"));
}
return parts.join("\n\n").trim();
}
throw new ConfigError("No prompt provided. Use --prompt or --promptfiles.");
}
function detectProvider(explicit: Provider | null): Provider {
if (explicit) {
if (!KNOWN_PROVIDERS.includes(explicit)) {
throw new ConfigError(`Unknown provider: ${explicit}. Known: ${KNOWN_PROVIDERS.join(", ")}`);
}
return explicit;
}
if (process.env.FAL_KEY) return "fal";
if (process.env.ARK_API_KEY) return "ark";
if (process.env.MINIMAX_API_KEY) return "minimax";
if (process.env.RUNWAY_API_KEY) return "runway";
if (process.env.LUMA_API_KEY) return "luma";
if (process.env.PIKA_API_KEY) return "pika";
if (process.env.VIDU_API_KEY) return "vidu";
if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) return "google";
if (process.env.DASHSCOPE_API_KEY) return "bailian";
if (process.env.OPENAI_API_KEY) return "openai";
throw new ConfigError(
"No provider selected and no video-capable API key found. Pass --provider or set one of the env vars listed in --help."
);
}
async function loadProvider(provider: Provider): Promise<ProviderModule> {
if (!IMPLEMENTED.has(provider)) {
throw new ConfigError(
`Provider '${provider}' is not implemented in this release. Available: ${[...IMPLEMENTED].join(", ")}.`
);
}
const mod = (await import(`./providers/${provider}.ts`)) as ProviderModule;
if (typeof mod.defaultModel !== "function" || typeof mod.generate !== "function") {
throw new ConfigError(`Provider module '${provider}' missing defaultModel/generate exports.`);
}
return mod;
}
async function run(argv: string[]): Promise<number> {
const args = parseArgs(argv);
if (args.help) {
printUsage();
return 0;
}
if (args.setup) {
await printSetupGuide();
return 0;
}
if (!args.videoPath) {
console.error("--video is required (output path).");
printUsage();
return 2;
}
const prompt = await resolvePrompt(args);
const provider = detectProvider(args.provider);
const mod = await loadProvider(provider);
const model = args.model ?? mod.defaultModel();
if (!args.quality) args.quality = "hd";
if (args.duration == null) args.duration = 5;
const bytes = await mod.generate(prompt, model, args);
await writeBytesToFile(bytes, args.videoPath);
const kind = validateVideoMagic(bytes);
if (args.json) {
console.log(
JSON.stringify(
{
success: true,
provider,
model,
video: path.resolve(args.videoPath),
size_bytes: bytes.byteLength,
format: kind,
},
null,
2
)
);
} else {
console.log(
`✓ Generated ${kind.toUpperCase()} (${(bytes.byteLength / 1024).toFixed(1)} KB) via ${provider}/${model}`
);
console.log(` ${path.resolve(args.videoPath)}`);
}
return 0;
}
run(process.argv.slice(2))
.then((code) => process.exit(code))
.catch((err) => {
if (err instanceof ProviderError) {
console.error(`[${err.provider}] ${err.message}`);
} else if (err instanceof ConfigError) {
console.error(`config: ${err.message}`);
} else if (err instanceof Error) {
console.error(err.stack || err.message);
} else {
console.error(String(err));
}
process.exit(1);
});
import path from "node:path";
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const API_BASE = process.env.ARK_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
export function defaultModel(): string {
// Seedance 1.5 Pro (text-to-video + image-to-video). Users can override via --model
// or ARK_VIDEO_MODEL to point at their own endpoint id.
return process.env.ARK_VIDEO_MODEL || "doubao-seedance-1-0-lite-t2v-250408";
}
function requireApiKey(): string {
const key = process.env.ARK_API_KEY;
if (!key) {
throw new ProviderError(
"ARK_API_KEY not set. Get one at https://console.volcengine.com/ark",
"ark"
);
}
return key;
}
type TaskResponse = {
id?: string;
status?: "queued" | "running" | "succeeded" | "failed" | "cancelled";
content?: { video_url?: string };
error?: { code?: string; message?: string };
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const content: Array<Record<string, unknown>> = [];
// Seedance accepts a single text block plus optional image (first-frame).
// Per Volc docs the text block encodes direction flags inline: e.g. "镜头推近 --rs 720p --rt 16:9".
const directives: string[] = [];
if (args.duration) directives.push(`--dur ${args.duration}`);
if (args.resolution) directives.push(`--rs ${args.resolution}`);
if (args.aspectRatio) directives.push(`--rt ${args.aspectRatio}`);
const textWithDirectives = directives.length > 0 ? `${prompt} ${directives.join(" ")}`.trim() : prompt;
content.push({ type: "text", text: textWithDirectives });
if (args.imagePath) {
content.push({
type: "image_url",
image_url: { url: await imagePathToDataUri(args.imagePath) },
role: "first_frame",
});
}
if (args.lastFramePath) {
content.push({
type: "image_url",
image_url: { url: await imagePathToDataUri(args.lastFramePath) },
role: "last_frame",
});
}
const body = { model, content };
const createRes = await fetch(`${API_BASE}/contents/generations/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new ProviderError(`Ark create ${createRes.status}: ${text}`, "ark");
}
const created = (await createRes.json()) as TaskResponse;
const taskId = created.id;
if (!taskId) {
throw new ProviderError(
`Ark did not return a task id: ${created.error?.message ?? JSON.stringify(created)}`,
"ark"
);
}
const finished = await pollUntilDone<TaskResponse>(args, async () => {
const r = await fetch(`${API_BASE}/contents/generations/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `Ark poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as TaskResponse;
if (payload.status === "succeeded") return { state: "done", value: payload };
if (payload.status === "failed" || payload.status === "cancelled") {
return { state: "failed", reason: `Ark task ${payload.status}: ${payload.error?.message ?? "(no detail)"}` };
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(`${err instanceof Error ? err.message : String(err)} (taskId: ${taskId})`, "ark");
});
const videoUrl = finished.content?.video_url;
if (!videoUrl) {
throw new ProviderError("Ark task succeeded but no video_url in response", "ark");
}
try {
return await downloadToBytes(videoUrl);
} catch (err) {
throw new ProviderError(
`Failed to download Ark video: ${err instanceof Error ? err.message : String(err)}`,
"ark"
);
}
}
// re-export path helper so tests can import it
export { path };
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const API_BASE =
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1";
export function defaultModel(): string {
return process.env.DASHSCOPE_VIDEO_MODEL || "wanx2.1-i2v-turbo";
}
function requireApiKey(): string {
const key = process.env.DASHSCOPE_API_KEY;
if (!key) {
throw new ProviderError(
"DASHSCOPE_API_KEY not set. Get one at https://dashscope.console.aliyun.com/apiKey",
"bailian"
);
}
return key;
}
type SubmitResponse = {
output?: { task_id?: string; task_status?: string };
message?: string;
};
type QueryResponse = {
output?: {
task_id?: string;
task_status?: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "UNKNOWN";
video_url?: string;
message?: string;
};
message?: string;
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const input: Record<string, unknown> = { prompt };
if (args.imagePath) {
input.image_url = await imagePathToDataUri(args.imagePath);
}
const parameters: Record<string, unknown> = {};
if (args.duration) parameters.duration = args.duration;
if (args.resolution) parameters.resolution = args.resolution;
if (args.aspectRatio) parameters.aspect_ratio = args.aspectRatio;
const body = { model, input, parameters };
const submitRes = await fetch(`${API_BASE}/services/aigc/video-generation/video-synthesis`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-DashScope-Async": "enable",
},
body: JSON.stringify(body),
});
if (!submitRes.ok) {
const text = await submitRes.text();
throw new ProviderError(`Bailian submit ${submitRes.status}: ${text}`, "bailian");
}
const submit = (await submitRes.json()) as SubmitResponse;
const taskId = submit.output?.task_id;
if (!taskId) {
throw new ProviderError(
`Bailian submit returned no task id: ${submit.message ?? JSON.stringify(submit)}`,
"bailian"
);
}
const finished = await pollUntilDone<QueryResponse>(args, async () => {
const r = await fetch(`${API_BASE}/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `Bailian poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as QueryResponse;
const status = payload.output?.task_status;
if (status === "SUCCEEDED") return { state: "done", value: payload };
if (status === "FAILED" || status === "UNKNOWN") {
return {
state: "failed",
reason: `Bailian task ${status}: ${payload.output?.message ?? "(no detail)"}`,
};
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (task id: ${taskId})`,
"bailian"
);
});
const videoUrl = finished.output?.video_url;
if (!videoUrl) {
throw new ProviderError("Bailian task succeeded but no video_url in response", "bailian");
}
return downloadToBytes(videoUrl);
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const QUEUE_BASE = "https://queue.fal.run";
export function defaultModel(): string {
// Kling v2.5 turbo pro is a good default; cheap, reliable. Wan / Veo / Sora wrappers are also available on FAL.
return process.env.FAL_VIDEO_MODEL || "fal-ai/kling-video/v2.5-turbo/pro/text-to-video";
}
function requireApiKey(): string {
const key = process.env.FAL_KEY;
if (!key) {
throw new ProviderError("FAL_KEY not set. Get one at https://fal.ai/dashboard/keys", "fal");
}
return key;
}
type QueueSubmit = {
request_id?: string;
response_url?: string;
status_url?: string;
error?: string;
};
type QueueStatus = {
status?: "IN_QUEUE" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | "CANCELLED";
response_url?: string;
error?: string;
};
type KlingResponse = {
video?: { url?: string };
video_url?: string;
output?: { video?: { url?: string } } | { url?: string };
};
function extractVideoUrl(payload: KlingResponse): string | null {
if (payload.video?.url) return payload.video.url;
if (payload.video_url) return payload.video_url;
if (payload.output && typeof payload.output === "object") {
if ("url" in payload.output && typeof payload.output.url === "string") return payload.output.url;
if ("video" in payload.output && payload.output.video?.url) return payload.output.video.url;
}
return null;
}
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const input: Record<string, unknown> = { prompt };
if (args.duration) input.duration = String(args.duration);
if (args.aspectRatio) input.aspect_ratio = args.aspectRatio;
if (args.imagePath) {
input.image_url = await imagePathToDataUri(args.imagePath);
}
const submitRes = await fetch(`${QUEUE_BASE}/${model.replace(/^\/+/, "")}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Key ${apiKey}`,
},
body: JSON.stringify(input),
});
if (!submitRes.ok) {
const text = await submitRes.text();
throw new ProviderError(`FAL submit ${submitRes.status}: ${text}`, "fal");
}
const submit = (await submitRes.json()) as QueueSubmit;
const statusUrl = submit.status_url;
const responseUrl = submit.response_url;
if (!statusUrl || !responseUrl) {
throw new ProviderError(
`FAL submit returned no status/response url: ${submit.error ?? JSON.stringify(submit)}`,
"fal"
);
}
const finalStatus = await pollUntilDone<QueueStatus>(args, async () => {
const r = await fetch(statusUrl, { headers: { Authorization: `Key ${apiKey}` } });
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `FAL poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as QueueStatus;
if (payload.status === "COMPLETED") return { state: "done", value: payload };
if (payload.status === "FAILED" || payload.status === "CANCELLED") {
return { state: "failed", reason: `FAL job ${payload.status}: ${payload.error ?? "(no detail)"}` };
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (request_id: ${submit.request_id})`,
"fal"
);
});
const resultUrl = finalStatus.response_url ?? responseUrl;
const resRes = await fetch(resultUrl, { headers: { Authorization: `Key ${apiKey}` } });
if (!resRes.ok) {
const text = await resRes.text();
throw new ProviderError(`FAL fetch result ${resRes.status}: ${text}`, "fal");
}
const payload = (await resRes.json()) as KlingResponse;
const videoUrl = extractVideoUrl(payload);
if (!videoUrl) {
throw new ProviderError(
`FAL result contained no video url. Raw payload: ${JSON.stringify(payload)}`,
"fal"
);
}
return downloadToBytes(videoUrl);
}
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { getImageMimeType } from "../utils/output";
const API_BASE = "https://generativelanguage.googleapis.com/v1beta";
export function defaultModel(): string {
return process.env.GOOGLE_VIDEO_MODEL || "veo-3.0-generate-preview";
}
function requireApiKey(): string {
const key = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
if (!key) {
throw new ProviderError(
"GOOGLE_API_KEY or GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey",
"google"
);
}
return key;
}
type OperationResponse = {
name?: string;
done?: boolean;
response?: {
videos?: Array<{ bytesBase64Encoded?: string; video?: { uri?: string } }>;
};
error?: { message?: string; code?: number };
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const instance: Record<string, unknown> = { prompt };
if (args.imagePath) {
const bytes = await readFile(args.imagePath);
instance.image = {
bytesBase64Encoded: Buffer.from(bytes).toString("base64"),
mimeType: getImageMimeType(path.basename(args.imagePath)),
};
}
const parameters: Record<string, unknown> = {};
if (args.aspectRatio) parameters.aspectRatio = args.aspectRatio;
if (args.duration) parameters.durationSeconds = args.duration;
if (args.resolution) parameters.resolution = args.resolution;
const createUrl = `${API_BASE}/models/${model}:predictLongRunning?key=${encodeURIComponent(apiKey)}`;
const createRes = await fetch(createUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instances: [instance], parameters }),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new ProviderError(`Veo create ${createRes.status}: ${text}`, "google");
}
const created = (await createRes.json()) as OperationResponse;
const name = created.name;
if (!name) {
throw new ProviderError(`Veo did not return an operation name: ${JSON.stringify(created)}`, "google");
}
const finished = await pollUntilDone<OperationResponse>(args, async () => {
const r = await fetch(`${API_BASE}/${name}?key=${encodeURIComponent(apiKey)}`);
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `Veo poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as OperationResponse;
if (payload.error) {
return { state: "failed", reason: `Veo error: ${payload.error.message ?? "(no detail)"}` };
}
if (payload.done) return { state: "done", value: payload };
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (operation: ${name})`,
"google"
);
});
const video = finished.response?.videos?.[0];
if (video?.bytesBase64Encoded) {
return Uint8Array.from(Buffer.from(video.bytesBase64Encoded, "base64"));
}
if (video?.video?.uri) {
// URI form — authenticated download with API key.
return downloadToBytes(`${video.video.uri}?key=${encodeURIComponent(apiKey)}`);
}
throw new ProviderError("Veo operation completed but returned no video data", "google");
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const API_BASE = process.env.LUMA_BASE_URL || "https://api.lumalabs.ai/dream-machine/v1";
export function defaultModel(): string {
return process.env.LUMA_VIDEO_MODEL || "ray-2";
}
function requireApiKey(): string {
const key = process.env.LUMA_API_KEY;
if (!key) {
throw new ProviderError(
"LUMA_API_KEY not set. Get one at https://lumalabs.ai/dream-machine/api/keys",
"luma"
);
}
return key;
}
type GenerationResponse = {
id?: string;
state?: "queued" | "dreaming" | "completed" | "failed";
failure_reason?: string;
assets?: { video?: string };
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const body: Record<string, unknown> = { model, prompt };
if (args.aspectRatio) body.aspect_ratio = args.aspectRatio;
if (args.duration) body.duration = `${args.duration}s`;
if (args.resolution) body.resolution = args.resolution;
if (args.imagePath) {
const keyframes: Record<string, { type: string; url: string }> = {
frame0: { type: "image", url: await imagePathToDataUri(args.imagePath) },
};
if (args.lastFramePath) {
keyframes.frame1 = { type: "image", url: await imagePathToDataUri(args.lastFramePath) };
}
body.keyframes = keyframes;
}
const createRes = await fetch(`${API_BASE}/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new ProviderError(`Luma create ${createRes.status}: ${text}`, "luma");
}
const created = (await createRes.json()) as GenerationResponse;
const genId = created.id;
if (!genId) {
throw new ProviderError(`Luma create returned no id: ${JSON.stringify(created)}`, "luma");
}
const finished = await pollUntilDone<GenerationResponse>(args, async () => {
const r = await fetch(`${API_BASE}/generations/${genId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `Luma poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as GenerationResponse;
if (payload.state === "completed") return { state: "done", value: payload };
if (payload.state === "failed") {
return { state: "failed", reason: `Luma generation failed: ${payload.failure_reason ?? "(no detail)"}` };
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (generation id: ${genId})`,
"luma"
);
});
const videoUrl = finished.assets?.video;
if (!videoUrl) {
throw new ProviderError("Luma generation completed but returned no video url", "luma");
}
return downloadToBytes(videoUrl);
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const API_BASE = process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1";
export function defaultModel(): string {
return process.env.MINIMAX_VIDEO_MODEL || "MiniMax-Hailuo-02";
}
function requireApiKey(): string {
const key = process.env.MINIMAX_API_KEY;
if (!key) {
throw new ProviderError(
"MINIMAX_API_KEY not set. Get one at https://platform.minimaxi.com/user-center/basic-information",
"minimax"
);
}
return key;
}
type CreateResponse = {
task_id?: string;
base_resp?: { status_code?: number; status_msg?: string };
};
type QueryResponse = {
task_id?: string;
status?: "Queueing" | "Preparing" | "Processing" | "Success" | "Fail";
file_id?: string;
base_resp?: { status_code?: number; status_msg?: string };
};
type FileResponse = {
file?: { download_url?: string };
base_resp?: { status_code?: number; status_msg?: string };
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const body: Record<string, unknown> = { model, prompt };
if (args.duration) body.duration = args.duration;
if (args.resolution) body.resolution = args.resolution;
if (args.aspectRatio) body.aspect_ratio = args.aspectRatio;
if (args.imagePath) {
body.first_frame_image = await imagePathToDataUri(args.imagePath);
}
const submitRes = await fetch(`${API_BASE}/video_generation`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!submitRes.ok) {
const text = await submitRes.text();
throw new ProviderError(`MiniMax submit ${submitRes.status}: ${text}`, "minimax");
}
const submit = (await submitRes.json()) as CreateResponse;
const taskId = submit.task_id;
if (!taskId) {
throw new ProviderError(
`MiniMax submit returned no task_id: ${submit.base_resp?.status_msg ?? JSON.stringify(submit)}`,
"minimax"
);
}
const finished = await pollUntilDone<QueryResponse>(args, async () => {
const r = await fetch(`${API_BASE}/query/video_generation?task_id=${taskId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `MiniMax poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as QueryResponse;
if (payload.status === "Success" && payload.file_id) return { state: "done", value: payload };
if (payload.status === "Fail") {
return { state: "failed", reason: `MiniMax task Fail: ${payload.base_resp?.status_msg ?? "(no detail)"}` };
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (task_id: ${taskId})`,
"minimax"
);
});
const fileId = finished.file_id!;
const fileRes = await fetch(`${API_BASE}/files/retrieve?file_id=${fileId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!fileRes.ok) {
const text = await fileRes.text();
throw new ProviderError(`MiniMax file retrieve ${fileRes.status}: ${text}`, "minimax");
}
const filePayload = (await fileRes.json()) as FileResponse;
const downloadUrl = filePayload.file?.download_url;
if (!downloadUrl) {
throw new ProviderError(
`MiniMax file retrieve returned no download_url: ${filePayload.base_resp?.status_msg ?? JSON.stringify(filePayload)}`,
"minimax"
);
}
return downloadToBytes(downloadUrl);
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const API_BASE = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
export function defaultModel(): string {
return process.env.OPENAI_VIDEO_MODEL || "sora-2";
}
function requireApiKey(): string {
const key = process.env.OPENAI_API_KEY;
if (!key) {
throw new ProviderError(
"OPENAI_API_KEY not set. Sora also requires your org to have video access enabled.",
"openai"
);
}
return key;
}
type VideoJob = {
id?: string;
status?: "queued" | "in_progress" | "completed" | "failed";
error?: { message?: string };
result?: { url?: string };
video_url?: string;
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const body: Record<string, unknown> = { model, prompt };
if (args.duration) body.duration_seconds = args.duration;
if (args.aspectRatio) body.aspect_ratio = args.aspectRatio;
if (args.resolution) body.resolution = args.resolution;
if (args.imagePath) {
body.input_image = await imagePathToDataUri(args.imagePath);
}
const createRes = await fetch(`${API_BASE}/videos`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new ProviderError(`OpenAI videos ${createRes.status}: ${text}`, "openai");
}
const created = (await createRes.json()) as VideoJob;
const jobId = created.id;
if (!jobId) {
throw new ProviderError(`OpenAI videos returned no job id: ${JSON.stringify(created)}`, "openai");
}
const finished = await pollUntilDone<VideoJob>(args, async () => {
const r = await fetch(`${API_BASE}/videos/${jobId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `OpenAI poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as VideoJob;
if (payload.status === "completed") return { state: "done", value: payload };
if (payload.status === "failed") {
return { state: "failed", reason: `OpenAI video failed: ${payload.error?.message ?? "(no detail)"}` };
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (job_id: ${jobId})`,
"openai"
);
});
const videoUrl = finished.result?.url ?? finished.video_url;
if (!videoUrl) {
throw new ProviderError("OpenAI video job completed but returned no url", "openai");
}
return downloadToBytes(videoUrl, { Authorization: `Bearer ${apiKey}` });
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const API_BASE = process.env.PIKA_BASE_URL || "https://api.pika.art/v1";
export function defaultModel(): string {
return process.env.PIKA_VIDEO_MODEL || "pika-2.2";
}
function requireApiKey(): string {
const key = process.env.PIKA_API_KEY;
if (!key) {
throw new ProviderError(
"PIKA_API_KEY not set. Request API access at https://pika.art",
"pika"
);
}
return key;
}
type JobResponse = {
id?: string;
status?: "queued" | "processing" | "completed" | "failed";
video_url?: string;
videos?: Array<{ url?: string }>;
error?: string;
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const body: Record<string, unknown> = { model, prompt };
if (args.duration) body.duration = args.duration;
if (args.aspectRatio) body.aspect_ratio = args.aspectRatio;
if (args.resolution) body.resolution = args.resolution;
if (args.imagePath) body.image = await imagePathToDataUri(args.imagePath);
const createRes = await fetch(`${API_BASE}/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new ProviderError(`Pika create ${createRes.status}: ${text}`, "pika");
}
const created = (await createRes.json()) as JobResponse;
const jobId = created.id;
if (!jobId) {
throw new ProviderError(`Pika create returned no id: ${JSON.stringify(created)}`, "pika");
}
const finished = await pollUntilDone<JobResponse>(args, async () => {
const r = await fetch(`${API_BASE}/generations/${jobId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `Pika poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as JobResponse;
if (payload.status === "completed") return { state: "done", value: payload };
if (payload.status === "failed") {
return { state: "failed", reason: `Pika job failed: ${payload.error ?? "(no detail)"}` };
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (job id: ${jobId})`,
"pika"
);
});
const videoUrl = finished.video_url ?? finished.videos?.[0]?.url;
if (!videoUrl) {
throw new ProviderError("Pika job completed but returned no video url", "pika");
}
return downloadToBytes(videoUrl);
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const API_BASE = process.env.RUNWAY_BASE_URL || "https://api.dev.runwayml.com/v1";
const API_VERSION = process.env.RUNWAY_API_VERSION || "2024-11-06";
export function defaultModel(): string {
return process.env.RUNWAY_VIDEO_MODEL || "gen4_turbo";
}
function requireApiKey(): string {
const key = process.env.RUNWAY_API_KEY;
if (!key) {
throw new ProviderError(
"RUNWAY_API_KEY not set. Get one at https://dev.runwayml.com",
"runway"
);
}
return key;
}
type TaskResponse = {
id?: string;
status?: "PENDING" | "THROTTLED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED";
output?: string[];
error?: string;
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const isImageToVideo = !!args.imagePath;
const endpoint = isImageToVideo ? "image_to_video" : "text_to_video";
const body: Record<string, unknown> = {
model,
promptText: prompt,
};
if (args.aspectRatio) body.ratio = args.aspectRatio;
if (args.duration) body.duration = args.duration;
if (args.imagePath) {
body.promptImage = await imagePathToDataUri(args.imagePath);
}
const createRes = await fetch(`${API_BASE}/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-Runway-Version": API_VERSION,
},
body: JSON.stringify(body),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new ProviderError(`Runway create ${createRes.status}: ${text}`, "runway");
}
const created = (await createRes.json()) as TaskResponse;
const taskId = created.id;
if (!taskId) {
throw new ProviderError(`Runway create returned no task id: ${JSON.stringify(created)}`, "runway");
}
const finished = await pollUntilDone<TaskResponse>(args, async () => {
const r = await fetch(`${API_BASE}/tasks/${taskId}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"X-Runway-Version": API_VERSION,
},
});
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `Runway poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as TaskResponse;
if (payload.status === "SUCCEEDED") return { state: "done", value: payload };
if (payload.status === "FAILED" || payload.status === "CANCELLED") {
return { state: "failed", reason: `Runway task ${payload.status}: ${payload.error ?? "(no detail)"}` };
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (task id: ${taskId})`,
"runway"
);
});
const outputUrl = finished.output?.[0];
if (!outputUrl) {
throw new ProviderError("Runway task succeeded but returned no output url", "runway");
}
return downloadToBytes(outputUrl);
}
import type { CliArgs } from "../types";
import { ProviderError } from "../types";
import { downloadToBytes, pollUntilDone } from "../utils/async";
import { imagePathToDataUri } from "../utils/output";
const API_BASE = process.env.VIDU_BASE_URL || "https://api.vidu.com/ent/v2";
export function defaultModel(): string {
return process.env.VIDU_VIDEO_MODEL || "viduq1";
}
function requireApiKey(): string {
const key = process.env.VIDU_API_KEY || process.env.VIDU_API_TOKEN;
if (!key) {
throw new ProviderError(
"VIDU_API_KEY not set. Get one at https://platform.vidu.com",
"vidu"
);
}
return key;
}
type CreateResponse = { task_id?: string; state?: string; message?: string };
type QueryResponse = {
state?: "created" | "queueing" | "processing" | "success" | "failed";
creations?: Array<{ url?: string; cover_url?: string }>;
err_code?: string;
message?: string;
};
export async function generate(prompt: string, model: string, args: CliArgs): Promise<Uint8Array> {
const apiKey = requireApiKey();
const endpoint = args.imagePath ? "img2video" : "text2video";
const body: Record<string, unknown> = { model, prompt };
if (args.duration) body.duration = args.duration;
if (args.resolution) body.resolution = args.resolution;
if (args.aspectRatio) body.aspect_ratio = args.aspectRatio;
if (args.imagePath) {
body.images = [await imagePathToDataUri(args.imagePath)];
}
const createRes = await fetch(`${API_BASE}/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Token ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new ProviderError(`Vidu create ${createRes.status}: ${text}`, "vidu");
}
const created = (await createRes.json()) as CreateResponse;
const taskId = created.task_id;
if (!taskId) {
throw new ProviderError(
`Vidu create returned no task id: ${created.message ?? JSON.stringify(created)}`,
"vidu"
);
}
const finished = await pollUntilDone<QueryResponse>(args, async () => {
const r = await fetch(`${API_BASE}/tasks/${taskId}/creations`, {
headers: { Authorization: `Token ${apiKey}` },
});
if (!r.ok) {
const text = await r.text();
return { state: "failed", reason: `Vidu poll ${r.status}: ${text}` };
}
const payload = (await r.json()) as QueryResponse;
if (payload.state === "success") return { state: "done", value: payload };
if (payload.state === "failed") {
return { state: "failed", reason: `Vidu task failed: ${payload.message ?? payload.err_code ?? "(no detail)"}` };
}
return { state: "pending" };
}).catch((err) => {
throw new ProviderError(
`${err instanceof Error ? err.message : String(err)} (task id: ${taskId})`,
"vidu"
);
});
const videoUrl = finished.creations?.[0]?.url;
if (!videoUrl) {
throw new ProviderError("Vidu task succeeded but no video url", "vidu");
}
return downloadToBytes(videoUrl);
}
export type Provider =
| "openai"
| "google"
| "runway"
| "pika"
| "luma"
| "fal"
| "ark"
| "bailian"
| "minimax"
| "vidu";
export type Quality = "draft" | "hd" | "ultra";
export type CliArgs = {
prompt: string | null;
promptFiles: string[];
videoPath: string | null;
imagePath: string | null;
lastFramePath: string | null;
duration: number | null;
fps: number | null;
provider: Provider | null;
model: string | null;
aspectRatio: string | null;
resolution: string | null;
quality: Quality | null;
pollTimeoutMs: number;
pollIntervalMs: number;
json: boolean;
setup: boolean;
help: boolean;
};
export type ExtendConfig = {
version?: number;
default_provider?: Provider;
default_quality?: Quality;
default_aspect_ratio?: string;
default_duration?: number;
default_resolution?: string;
default_save_dir?: string;
default_model?: Partial<Record<Provider, string>>;
providers?: Partial<
Record<
Provider,
{
api_key_env?: string;
api_key_source?: string;
base_url?: string;
}
>
>;
};
export type ProviderModule = {
defaultModel: () => string;
generate: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
};
export class ConfigError extends Error {
code = "CONFIG_ERROR";
}
export class ProviderError extends Error {
constructor(message: string, public provider: Provider) {
super(message);
}
code = "PROVIDER_ERROR";
}
export class PollTimeoutError extends ProviderError {
constructor(provider: Provider, public externalId: string | null, public url: string | null) {
super(`Video task timed out waiting on ${provider}. External id: ${externalId ?? "(none)"}`, provider);
}
}
export type Orientation = "square" | "landscape" | "portrait";
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (!(w > 0) || !(h > 0)) return null;
return { width: w, height: h };
}
export function orientationOf(ar: string | null): Orientation {
if (!ar) return "landscape";
const parsed = parseAspectRatio(ar);
if (!parsed) return "landscape";
const ratio = parsed.width / parsed.height;
if (ratio > 1.15) return "landscape";
if (ratio < 0.87) return "portrait";
return "square";
}
import type { CliArgs } from "../types";
export type PollOutcome<T> =
| { state: "pending" }
| { state: "done"; value: T }
| { state: "failed"; reason: string };
export async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function pollUntilDone<T>(
args: Pick<CliArgs, "pollTimeoutMs" | "pollIntervalMs">,
check: () => Promise<PollOutcome<T>>
): Promise<T> {
const deadline = Date.now() + args.pollTimeoutMs;
while (Date.now() < deadline) {
const res = await check();
if (res.state === "done") return res.value;
if (res.state === "failed") {
throw new Error(res.reason);
}
await sleep(args.pollIntervalMs);
}
throw new Error(`Polling deadline (${args.pollTimeoutMs}ms) exceeded`);
}
export async function downloadToBytes(url: string, headers: Record<string, string> = {}): Promise<Uint8Array> {
const res = await fetch(url, { headers });
if (!res.ok) {
throw new Error(`Download failed (${res.status}): ${res.statusText}`);
}
return new Uint8Array(await res.arrayBuffer());
}
export function validateVideoMagic(bytes: Uint8Array): "mp4" | "webm" | "mov" | "unknown" {
if (bytes.length < 12) return "unknown";
// mp4 / mov: 4-byte size + 'ftyp' at offset 4
if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
const brand = String.fromCharCode(bytes[8]!, bytes[9]!, bytes[10]!, bytes[11]!);
if (brand.startsWith("qt")) return "mov";
return "mp4";
}
// webm / matroska: EBML magic 0x1A 0x45 0xDF 0xA3
if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) {
return "webm";
}
return "unknown";
}
import path from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
export async function writeBytesToFile(bytes: Uint8Array, outputPath: string): Promise<void> {
const dir = path.dirname(outputPath);
await mkdir(dir, { recursive: true });
await writeFile(outputPath, bytes);
}
export function getImageMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif";
return "image/png";
}
export async function imagePathToDataUri(imagePath: string): Promise<string> {
const bytes = await readFile(imagePath);
const mime = getImageMimeType(path.basename(imagePath));
return `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`;
}