
Videoagent Video Studio
- 10.4k installs
- 762 repo stars
- Updated July 21, 2026
- pexoai/pexo-skills
Generate AI videos (4-12 seconds) from text prompts or animate images with motion descriptions using 7 backend models through a hosted proxy.
About
VideoAgent Video Studio generates 4-12 second AI videos using 7 backend models (minimax, kling, veo, hunyuan, grok, seedance, pixverse). Developers invoke text-to-video mode for prompt-only generation or image-to-video mode to animate still images with motion descriptions. The skill auto-enhances prompts, routes to appropriate models via hosted proxy, and returns video URLs. Key workflows include cinematic text generation, image animation with motion descriptions, and reference-based consistency for multi-element scenes. Zero API key configuration required by default.
- 7 video generation models with auto-routing; manual model selection via --model flag
- Text-to-video: detailed prompt expansion for camera movement and lighting; image-to-video: motion description applied to
- Reference-based generation for character/subject consistency across minimax, kling, veo, hunyuan, grok, seedance
- Configurable duration (4-12 seconds) and aspect ratios (16:9, 9:16, 1:1, 4:3, 3:4) per generation
- Hosted proxy eliminates API key setup; optional custom proxy and token via environment variables
Videoagent Video Studio by the numbers
- 10,365 all-time installs (skills.sh)
- +14 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #86 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
videoagent-video-studio capabilities & compatibility
- Capabilities
- text to video generation with prompt enhancement · image to video animation with motion description · reference based generation for consistency · multi model routing with auto selection or manua · async job status checking via job id · flexible aspect ratio and duration configuration
- Works with
- openai
- Use cases
- video generation · image generation
- Platforms
- macOS · Windows · Linux
- Runs
- Hosted SaaS
- Pricing
- Freemium
What videoagent-video-studio says it does
Generate short AI videos with 7 backends. This skill picks the right mode (text-to-video or image-to-video), enhances the prompt for best results, and returns the video URL.
npx skills add https://github.com/pexoai/pexo-skills --skill videoagent-video-studioAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10.4k |
|---|---|
| repo stars | ★ 762 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 21, 2026 |
| Repository | pexoai/pexo-skills ↗ |
What it does
Generate short AI videos from text descriptions or animate images without API key setup.
Who is it for?
Content creation pipelines, user-generated video features, animated social media clips, product demos, cinematic storytelling, portrait animation.
Skip if: Long-form video editing, real-time streaming, high-resolution (film-grade) production, complex multi-scene composition.
When should I use this skill?
User requests video generation from text, image animation, short clip creation, or cinematic content.
What you get
Video URL returned as JSON for immediate embedding or distribution; supports text-to-video, image-to-video, and reference-based generation modes.
- Generated video files
- Proxy API request payloads
By the numbers
- 7 supported video generation models (minimax, kling, veo, hunyuan, grok, seedance, pixverse)
- 5 configurable aspect ratios (16:9, 9:16, 1:1, 4:3, 3:4)
- Typical video duration range 4-12 seconds
Files
🎬 VideoAgent Video Studio
Use when: User asks to generate a video, create a video from text, animate an image, make a short clip, or produce AI video.
Generate short AI videos with 7 backends. This skill picks the right mode (text-to-video or image-to-video), enhances the prompt for best results, and returns the video URL.
---
Quick Reference
| User Intent | Mode | Typical Duration |
|---|---|---|
| "Make a video of..." (no image) | text-to-video | 4–10 s |
| "Animate this image" / "Make this move" | image-to-video | 4–6 s |
| "Turn this into a video with..." | image-to-video | 4–6 s |
| Cinematic, story, ad | Prefer text-to-video with detailed prompt | 5–10 s |
Generation Modes
| Mode | Description | Models |
|---|---|---|
| text-to-video | Text prompt only → video | minimax, kling, veo, hunyuan, grok, seedance |
| image-to-video | Single image + prompt → animated clip | minimax, kling, veo, pixverse, grok, seedance |
| reference-based | Reference images/video → consistent output | minimax, kling, veo, hunyuan, grok, seedance |
Models (use --model <id>)
| Model ID | T2V | I2V | Reference | Notes |
|---|---|---|---|---|
minimax | ✅ | ✅ | ✅ | Subject reference image, character consistency |
kling | ✅ | ✅ | ✅ | Multi-element / character / keyframe (O3) |
veo | ✅ | ✅ | ✅ | Google Veo 3.1, multiple reference images |
hunyuan | ✅ | — | ✅ | Video-to-video style transfer |
pixverse | — | ✅ | — | Stylized image-to-video |
grok | ✅ | ✅ | ✅ | Video editing via reference video |
seedance | ✅ | ✅ | ✅ | Seedance 1.5 Pro, synchronized audio, 4–12 s |
Full model details and endpoint reference: references/models.md.
---
How to Generate a Video
Step 1 — Choose mode and enhance the prompt
- Text-to-video: Expand with subject, action, camera movement, lighting, and style. Be specific about motion (e.g. "camera slowly zooms in", "character walks left to right").
- Image-to-video: Describe the motion to apply to the image (e.g. "gentle breeze in the hair", "camera pans across the scene"). See references/prompt_guide.md for patterns.
Step 2 — Run the script
Text-to-video:
node {baseDir}/tools/generate.js \
--mode text-to-video \
--prompt "<enhanced prompt>" \
--duration <seconds> \
--aspect-ratio <ratio>Image-to-video:
node {baseDir}/tools/generate.js \
--mode image-to-video \
--prompt "<motion description>" \
--image-url "<public image URL>" \
--duration <seconds> \
--aspect-ratio <ratio>Parameters:
| Parameter | Default | Description |
|---|---|---|
--mode | text-to-video | text-to-video or image-to-video |
--prompt | (required) | Scene or motion description |
--image-url | — | Required for image-to-video; public image URL |
--duration | 5 | Length in seconds (typically 4–10) |
--aspect-ratio | 16:9 | 16:9, 9:16, 1:1, 4:3, 3:4 |
--model | auto | Model ID (e.g. kling, veo, grok, seedance); auto = proxy picks |
Other commands:
| Command | Description |
|---|---|
node tools/generate.js --list-models | List available models from the proxy |
node tools/generate.js --status --job-id <id> | Check async job status |
Step 3 — Return the result
The script returns JSON:
{
"success": true,
"mode": "text-to-video",
"videoUrl": "https://...",
"duration": 5,
"aspectRatio": "16:9"
}Send videoUrl to the user.
---
Example Conversations
User: "Generate a short video of a cat walking in the rain, cinematic."
node {baseDir}/tools/generate.js \
--mode text-to-video \
--prompt "A cat walking through rain, wet streets, neon reflections, cinematic lighting, slow motion, 4K" \
--duration 5 \
--aspect-ratio 16:9---
User: "Animate this photo" (user uploads a landscape)
node {baseDir}/tools/generate.js \
--mode image-to-video \
--prompt "Gentle clouds moving across the sky, subtle grass movement, cinematic atmosphere" \
--image-url "https://..." \
--duration 5 \
--aspect-ratio 16:9---
User: "Make a 10-second vertical video of a coffee pour, slow motion."
node {baseDir}/tools/generate.js \
--mode text-to-video \
--prompt "Close-up of coffee pouring into a white cup, slow motion, steam rising, soft lighting, product shot" \
--duration 10 \
--aspect-ratio 9:16---
User: "Use Google Veo for a cinematic shot."
node {baseDir}/tools/generate.js \
--mode text-to-video \
--model veo \
--prompt "A dragon flying through cloudy skies, cinematic lighting, 8s" \
--duration 8 \
--aspect-ratio 16:9---
User: "Animate this portrait."
node {baseDir}/tools/generate.js \
--mode image-to-video \
--model grok \
--prompt "Gentle smile, subtle head turn" \
--image-url "https://..." \
--duration 5---
Setup
Zero API keys by default. Requests go through a hosted proxy. Set these for a custom proxy or token:
| Variable | Required | Description |
|---|---|---|
VIDEO_STUDIO_PROXY_URL | No | Proxy base URL |
VIDEO_STUDIO_TOKEN | No | Auth token if the proxy requires it |
---
Knowledge Base
- [references/prompt_guide.md](references/prompt_guide.md) — Prompt patterns for text-to-video and image-to-video.
- [references/models.md](references/models.md) — Model list, capabilities, and selection guide.
- [references/calling_guide.md](references/calling_guide.md) — Per-model endpoint details, input parameters, and special handling.
{
"name": "videoagent-video-studio",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "videoagent-video-studio",
"version": "1.0.0",
"engines": {
"node": ">=18"
}
}
}
}
{
"name": "videoagent-video-studio",
"version": "1.0.0",
"description": "OpenClaw skill for text-to-video and image-to-video — zero API keys via hosted proxy.",
"type": "module",
"engines": {
"node": ">=18"
}
}
/**
* videoagent-video-studio proxy — POST /api/generate
* Body: { mode, prompt, imageUrl?, duration?, aspectRatio?, model? }
* model: minimax | kling | veo | hunyuan | pixverse | grok | seedance
*/
const { fal } = require("@fal-ai/client");
const { getModel, listModels, resolveModel, FAL } = require("../models.js");
const FAL_KEY = process.env.FAL_KEY || "";
const VALID_TOKENS = (process.env.VALID_TOKENS || "").split(",").filter(Boolean);
const FREE_LIMIT_PER_IP = Math.max(0, parseInt(process.env.FREE_LIMIT_PER_IP || "100", 10));
const {
getIssuedToken,
getTokenUsage,
incrementTokenUsage,
trackGeneration,
trackError,
trackRateLimit,
} = require("../usage-store.js");
fal.config({ credentials: FAL_KEY });
function getClientIp(req) {
const xff = req.headers["x-forwarded-for"];
return (typeof xff === "string" ? xff.split(",")[0] : xff?.[0])?.trim()
|| req.headers["x-real-ip"]
|| req.socket?.remoteAddress
|| "unknown";
}
function json(res, status, data) {
res.setHeader("Content-Type", "application/json");
res.status(status).json(data);
}
function err(res, status, message, details = null) {
json(res, status, { success: false, error: message, ...(details && { details }) });
}
function getBearerToken(req) {
const h = req.headers.authorization;
return h && h.startsWith("Bearer ") ? h.slice(7).trim() : null;
}
function clampDuration(d, max = 10) {
const n = parseInt(d, 10);
if (Number.isNaN(n) || n < 1) return 5;
return Math.min(n, max);
}
async function runFal(modelId, mode, body) {
const m = getModel(modelId);
if (!m || m.provider !== FAL) throw new Error(`Unknown FAL model: ${modelId}`);
const endpoint = mode === "image-to-video" ? m.i2v : m.t2v;
if (!endpoint) throw new Error(`Model ${modelId} does not support ${mode}`);
const normalized = {
prompt: body.prompt,
imageUrl: body.imageUrl || body.image_url,
duration: clampDuration(body.duration),
aspectRatio: body.aspectRatio || "16:9",
};
const input = m.falInput(normalized, mode);
const result = await fal.subscribe(endpoint, { input, logs: false });
const url = result?.data?.video?.url || result?.data?.url;
if (!url) throw new Error("No video URL in response");
return { success: true, mode, model: modelId, videoUrl: url,
duration: normalized.duration, aspectRatio: normalized.aspectRatio };
}
function pickDefaultModel(mode) {
const list = listModels();
const m = list.find(m => m.provider === FAL && (mode === "text-to-video" ? m.t2v : m.i2v));
return m ? m.id : "minimax";
}
module.exports = async function handler(req, res) {
if (req.method === "OPTIONS") return res.status(204).end();
// ── GET: health + model list ────────────────────────────────────────────────
if (req.method === "GET") {
return json(res, 200, {
service: "videoagent-video-studio-proxy",
version: "2.1.0",
status: "ok",
modes: ["text-to-video", "image-to-video"],
models: listModels(),
free_limit_per_token: FREE_LIMIT_PER_IP,
free_limit_reset: "daily",
max_tokens_per_ip_per_day: VALID_TOKENS.length === 0
? parseInt(process.env.MAX_TOKENS_PER_IP_PER_DAY || "3", 10) : null,
});
}
if (req.method !== "POST") return err(res, 405, "Method not allowed");
// ── Auth ────────────────────────────────────────────────────────────────────
const bearerToken = getBearerToken(req);
if (VALID_TOKENS.length > 0) {
if (!bearerToken || !VALID_TOKENS.includes(bearerToken))
return err(res, 401, "Invalid or missing token.");
} else {
if (!bearerToken)
return err(res, 401, "Missing token. Get one from POST /api/token first.", {
hint: "POST /api/token → use token as Authorization: Bearer <token>",
});
const tokenData = await getIssuedToken(bearerToken);
if (!tokenData)
return err(res, 401, "Invalid or expired token. Get a new one from POST /api/token.", {
token_ttl_hours: 24,
});
if (FREE_LIMIT_PER_IP > 0) {
const used = await getTokenUsage(bearerToken);
if (used >= FREE_LIMIT_PER_IP) {
await trackRateLimit("token");
return json(res, 429, {
success: false,
error: "Free limit reached for this token",
free_limit: FREE_LIMIT_PER_IP,
used,
hint: "Get a new token from POST /api/token.",
});
}
}
}
// ── Validate input ──────────────────────────────────────────────────────────
const body = req.body || {};
const mode = (body.mode || "text-to-video").toLowerCase();
const isI2V = mode === "image-to-video";
if (!body.prompt)
return err(res, 400, "Missing prompt");
if (isI2V && !body.imageUrl && !body.image_url)
return err(res, 400, "Missing imageUrl for image-to-video");
const modelId = (body.model || "auto").toLowerCase().trim();
const resolvedId = modelId === "auto" ? pickDefaultModel(mode) : modelId;
const model = resolveModel(resolvedId, mode);
if (!model)
return err(res, 400,
`Unsupported model or mode: model=${resolvedId}, mode=${mode}. GET /api/generate for list.`);
if (model.provider === FAL && !FAL_KEY)
return err(res, 503, "Service not configured");
// ── Generate ────────────────────────────────────────────────────────────────
try {
let out;
if (model.provider === FAL) {
out = await runFal(resolvedId, mode, body);
} else {
return err(res, 500, "Unknown provider");
}
// Increment usage + track stats only on success
if (VALID_TOKENS.length === 0 && FREE_LIMIT_PER_IP > 0 && bearerToken) {
await incrementTokenUsage(bearerToken);
}
await trackGeneration(resolvedId, mode).catch(() => {});
return json(res, 200, out);
} catch (e) {
console.error("[vs-proxy]", resolvedId, mode, e.message);
await trackError(resolvedId).catch(() => {});
return err(res, 500, e.message || "Video generation failed");
}
};
/**
* GET /api/stats — JSON usage statistics (default)
* GET /api/stats?ui=1 — HTML dashboard (browser-friendly)
*
* Requires Authorization: Bearer <STATS_KEY> when STATS_KEY env is set.
*/
const STATS_KEY = process.env.STATS_KEY || "";
const { getStats, getDailyStats } = require("../usage-store.js");
function json(res, status, data) {
res.setHeader("Content-Type", "application/json");
res.status(status).json(data);
}
// ── HTML dashboard ────────────────────────────────────────────────────────────
function renderDashboard(stats, daily) {
const { total_generations, total_errors, total_tokens_issued,
rate_limit_hits, by_model, by_mode, using_kv } = stats;
const now = new Date().toLocaleString("en-US", { timeZone: "UTC", hour12: false });
const maxVal = Math.max(1, ...Object.values(by_model));
const modelRows = Object.entries(by_model)
.sort((a, b) => b[1] - a[1])
.map(([id, count]) => {
const pct = Math.round((count / maxVal) * 100);
return `
<tr>
<td class="label">${id}</td>
<td class="bar-cell">
<div class="bar-wrap">
<div class="bar" style="width:${pct}%"></div>
</div>
</td>
<td class="num">${count}</td>
</tr>`;
}).join("");
const modeRows = Object.entries(by_mode).map(([mode, count]) => `
<tr>
<td class="label">${mode}</td>
<td class="num">${count}</td>
</tr>`).join("");
const maxDay = Math.max(1, ...daily.map(d => d.total));
const dayRows = daily.map(d => {
const pct = Math.round((d.total / maxDay) * 100);
return `
<tr>
<td class="label">${d.date}</td>
<td class="bar-cell">
<div class="bar-wrap">
<div class="bar bar-day" style="width:${Math.max(pct, d.total > 0 ? 2 : 0)}%"></div>
</div>
</td>
<td class="num">${d.total}</td>
</tr>`;
}).join("");
const storageLabel = using_kv
? `<span class="badge green">Redis</span>`
: `<span class="badge yellow">In-memory</span>`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Video Studio — Usage Stats</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: #0d1117; color: #e6edf3; min-height: 100vh; padding: 32px 24px;
}
h1 { font-size: 1.3rem; font-weight: 600; letter-spacing: -.3px; }
h2 { font-size: .85rem; font-weight: 600; text-transform: uppercase;
letter-spacing: .08em; color: #7d8590; margin-bottom: 12px; }
.header { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
.header .meta { font-size: .8rem; color: #7d8590; margin-left: auto; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px; margin-bottom: 32px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 16px 18px; }
.card .val { font-size: 2rem; font-weight: 700; line-height: 1; margin-bottom: 4px; }
.card .lbl { font-size: .75rem; color: #7d8590; }
.card.red .val { color: #f85149; }
.card.orange .val{ color: #e3b341; }
.section { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 20px 22px; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; }
td { padding: 6px 4px; font-size: .85rem; vertical-align: middle; }
td.label { width: 140px; color: #c9d1d9; font-family: monospace; }
td.num { width: 60px; text-align: right; font-variant-numeric: tabular-nums; color: #58a6ff; font-weight: 600; }
td.bar-cell { padding: 6px 8px; }
.bar-wrap { background: #21262d; border-radius: 4px; height: 10px; overflow: hidden; }
.bar { height: 100%; background: #58a6ff; border-radius: 4px;
transition: width .3s ease; }
.bar-day { background: #3fb950; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px;
font-size: .7rem; font-weight: 600; }
.badge.green { background: #1a4731; color: #3fb950; }
.badge.yellow { background: #3d2e00; color: #e3b341; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 600px) { .two-col { grid-template-columns: 1fr; } }
.footer { font-size: .75rem; color: #484f58; margin-top: 24px; text-align: center; }
</style>
</head>
<body>
<div class="header">
<h1>🎬 Video Studio — Usage</h1>
<span class="meta">Storage: ${storageLabel} · ${now} UTC</span>
</div>
<div class="cards">
<div class="card">
<div class="val">${total_generations}</div>
<div class="lbl">Total generations</div>
</div>
<div class="card red">
<div class="val">${total_errors}</div>
<div class="lbl">Errors</div>
</div>
<div class="card">
<div class="val">${total_tokens_issued}</div>
<div class="lbl">Tokens issued</div>
</div>
<div class="card orange">
<div class="val">${(rate_limit_hits.token || 0) + (rate_limit_hits.ip || 0)}</div>
<div class="lbl">Rate limit hits</div>
</div>
</div>
<div class="section">
<h2>Last 14 days</h2>
<table>
<tbody>${dayRows}</tbody>
</table>
</div>
<div class="two-col">
<div class="section">
<h2>By model</h2>
<table><tbody>${modelRows}</tbody></table>
</div>
<div class="section">
<h2>By mode</h2>
<table><tbody>${modeRows}</tbody></table>
<br>
<h2 style="margin-top:16px">Rate limits</h2>
<table>
<tr><td class="label">token quota</td><td class="num">${rate_limit_hits.token || 0}</td></tr>
<tr><td class="label">IP daily cap</td><td class="num">${rate_limit_hits.ip || 0}</td></tr>
</table>
</div>
</div>
<div class="footer">Refresh to update · JSON: <code>/api/stats</code>
${STATS_KEY ? " · Protected by STATS_KEY" : ""}</div>
</body>
</html>`;
}
// ── Handler ───────────────────────────────────────────────────────────────────
module.exports = async function handler(req, res) {
if (req.method === "OPTIONS") return res.status(204).end();
if (req.method !== "GET") return json(res, 405, { error: "Method not allowed" });
if (STATS_KEY) {
const bearer = req.headers.authorization || "";
const token = bearer.startsWith("Bearer ") ? bearer.slice(7).trim() : "";
if (token !== STATS_KEY)
return json(res, 401, { error: "Unauthorized. Set Authorization: Bearer <STATS_KEY>." });
}
try {
const wantsHtml = (req.query && req.query.ui) ||
(req.headers.accept || "").includes("text/html");
const [stats, daily] = await Promise.all([
getStats(),
getDailyStats(14),
]);
if (wantsHtml) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.status(200).end(renderDashboard(stats, daily));
return;
}
return json(res, 200, { ...stats, daily, timestamp: new Date().toISOString() });
} catch (e) {
return json(res, 500, { error: "Failed to fetch stats", detail: e.message });
}
};
/**
* GET /api/status?jobId= — Optional async job status (for proxies that return jobId).
* This proxy waits for completion and returns videoUrl directly, so jobId is never returned.
* For custom proxies that do return jobId, implement status lookup here and return { status, videoUrl? }.
*/
function json(res, status, data) {
res.setHeader("Content-Type", "application/json");
res.status(status).json(data);
}
module.exports = async function handler(req, res) {
if (req.method === "OPTIONS") return res.status(204).end();
if (req.method !== "GET") return json(res, 405, { error: "Method not allowed" });
const jobId = (req.query?.jobId || req.query?.job_id || "").trim();
if (!jobId) {
return json(res, 400, {
success: false,
error: "Missing jobId",
hint: "Use GET /api/status?jobId=your-job-id",
});
}
// This proxy does not use async jobs; it waits for FAL/Grok/Seedance and returns videoUrl.
// If you deploy a custom proxy that returns jobId, implement your status store lookup here.
return json(res, 501, {
success: false,
error: "Async job status not implemented",
message: "This proxy waits for completion and returns videoUrl directly. No jobId is issued.",
jobId,
});
};
/**
* POST /api/token — issue free-tier tokens for anonymous users.
* Each token is capped at FREE_LIMIT_PER_IP uses.
* Token issuance is capped at MAX_TOKENS_PER_IP_PER_DAY per IP per day.
*/
const VALID_TOKENS = (process.env.VALID_TOKENS || "").split(",").filter(Boolean);
const FREE_LIMIT_PER_IP = Math.max(0, parseInt(process.env.FREE_LIMIT_PER_IP || "100", 10));
const MAX_TOKENS_PER_IP_PER_DAY = Math.max(1, parseInt(process.env.MAX_TOKENS_PER_IP_PER_DAY || "3", 10));
const {
setIssuedToken,
getIssuanceCount,
incrementIssuanceCount,
trackRateLimit,
} = require("../usage-store.js");
function getClientIp(req) {
const xff = req.headers["x-forwarded-for"];
return (typeof xff === "string" ? xff.split(",")[0] : xff?.[0])?.trim()
|| req.headers["x-real-ip"]
|| req.socket?.remoteAddress
|| "unknown";
}
function json(res, status, data) {
res.setHeader("Content-Type", "application/json");
res.status(status).json(data);
}
module.exports = async function handler(req, res) {
if (req.method === "OPTIONS") return res.status(204).end();
if (req.method !== "POST") return json(res, 405, { error: "Method not allowed" });
if (VALID_TOKENS.length > 0) {
return json(res, 200, {
token: null,
free_limit: 0,
message: "Proxy uses VALID_TOKENS; use one of those for Authorization.",
});
}
const ip = getClientIp(req);
const issued = await getIssuanceCount(ip);
if (issued >= MAX_TOKENS_PER_IP_PER_DAY) {
await trackRateLimit("ip").catch(() => {});
return json(res, 429, {
success: false,
error: "Too many tokens requested today",
max_tokens_per_ip_per_day: MAX_TOKENS_PER_IP_PER_DAY,
hint: "Limit resets daily.",
});
}
const token = `vs_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
await setIssuedToken(token, ip);
await incrementIssuanceCount(ip);
return json(res, 200, {
token,
free_limit: FREE_LIMIT_PER_IP,
free_limit_reset: "daily",
max_tokens_per_day: MAX_TOKENS_PER_IP_PER_DAY,
});
};
/**
* Video model registry: id -> { provider, endpoints, env key }.
* Used by api/generate.js to route and build requests.
*/
const FAL = "fal";
// Maps a duration (seconds) to Veo's accepted discrete values.
function dur4s6s8s(sec) {
const n = parseInt(sec, 10) || 5;
if (n <= 4) return "4s";
if (n <= 6) return "6s";
return "8s";
}
// Categories: t2v = text-to-video, i2v = image-to-video, ref = reference-based
const MODELS = {
minimax: {
provider: FAL,
name: "MiniMax Video 01",
categories: ["t2v", "i2v", "ref"],
t2v: "fal-ai/minimax/video-01",
i2v: "fal-ai/minimax/video-01-live/image-to-video",
// Subject reference: one reference image for character consistency
referenceToVideo: "fal-ai/minimax/video-01-subject-reference",
env: "FAL_KEY",
falInput: (body, mode) => ({
prompt: body.prompt.trim(),
...(mode === "image-to-video" && body.imageUrl && { image_url: body.imageUrl }),
output_format: "url",
}),
refInput: (body) => ({
prompt: body.prompt.trim(),
subject_reference_image_url: body.imageUrl || body.referenceUrl,
}),
},
// Kling uses v3 for T2V/I2V and a separate O3 endpoint for reference-based generation.
kling: {
provider: FAL,
name: "Kling 3.0",
categories: ["t2v", "i2v", "ref"],
t2v: "fal-ai/kling-video/v3/standard/text-to-video",
i2v: "fal-ai/kling-video/v3/standard/image-to-video",
referenceToVideo: "fal-ai/kling-video/o3/standard/reference-to-video",
env: "FAL_KEY",
falInput: (body, mode) => ({
prompt: body.prompt.trim(),
aspect_ratio: body.aspectRatio || "16:9",
...(mode === "image-to-video" && { image_url: body.imageUrl.trim() }),
output_format: "url",
}),
// Reference input: start/end keyframes, multi-image, or character elements
refInput: (body) => ({
prompt: body.prompt.trim(),
aspect_ratio: body.aspectRatio || "16:9",
duration: String(Math.min(15, Math.max(3, parseInt(body.duration, 10) || 5))),
...(body.imageUrl && { start_image_url: body.imageUrl }),
...(body.endImageUrl && { end_image_url: body.endImageUrl }),
...(body.imageUrls && { image_urls: body.imageUrls }),
...(body.elements && { elements: body.elements }),
}),
},
veo: {
provider: FAL,
name: "Google Veo 3.1",
categories: ["t2v", "i2v", "ref"],
t2v: "fal-ai/veo3.1",
i2v: "fal-ai/veo3.1/image-to-video",
referenceToVideo: "fal-ai/veo3.1/reference-to-video",
env: "FAL_KEY",
falInput: (body, mode) => ({
prompt: body.prompt.trim(),
aspect_ratio: body.aspectRatio || "16:9",
duration: dur4s6s8s(body.duration),
...(mode === "image-to-video" && body.imageUrl && { image_url: body.imageUrl }),
output_format: "url",
}),
// veo3.1/reference-to-video only accepts duration "8s" (API constraint).
refInput: (body) => ({
prompt: body.prompt.trim(),
aspect_ratio: body.aspectRatio || "16:9",
duration: "8s",
resolution: "720p",
image_urls: body.imageUrls || (body.imageUrl ? [body.imageUrl] : []),
}),
},
hunyuan: {
provider: FAL,
name: "Hunyuan Video",
categories: ["t2v", "ref"],
t2v: "fal-ai/hunyuan-video",
// Reference: video-to-video style transfer via prompt (strength controls deviation)
referenceToVideo: "fal-ai/hunyuan-video/video-to-video",
env: "FAL_KEY",
falInput: (body) => ({
prompt: body.prompt.trim(),
aspect_ratio: body.aspectRatio || "16:9",
output_format: "url",
}),
refInput: (body) => ({
prompt: body.prompt.trim(),
video_url: body.videoUrl || body.referenceUrl,
aspect_ratio: body.aspectRatio || "16:9",
strength: body.strength ?? 0.85,
}),
},
pixverse: {
provider: FAL,
name: "PixVerse v4.5",
categories: ["i2v"],
i2v: "fal-ai/pixverse/v4.5/image-to-video",
env: "FAL_KEY",
falInput: (body) => ({
prompt: body.prompt.trim(),
image_url: body.imageUrl.trim(),
aspect_ratio: body.aspectRatio || "16:9",
output_format: "url",
}),
},
grok: {
provider: FAL,
name: "Grok Imagine Video",
categories: ["t2v", "i2v", "ref"],
t2v: "xai/grok-imagine-video/text-to-video",
i2v: "xai/grok-imagine-video/image-to-video",
// Reference: edit-video — apply prompt changes to a reference video (colorize, restyle, etc.)
referenceToVideo: "xai/grok-imagine-video/edit-video",
env: "FAL_KEY",
falInput: (body, mode) => ({
prompt: body.prompt.trim(),
duration: Math.min(15, Math.max(1, parseInt(body.duration, 10) || 6)),
aspect_ratio: body.aspectRatio || "16:9",
resolution: "720p",
...(mode === "image-to-video" && body.imageUrl && { image_url: body.imageUrl }),
}),
refInput: (body) => ({
prompt: body.prompt.trim(),
video_url: body.videoUrl || body.referenceUrl,
}),
},
// Seedance 1.5 Pro (ByteDance) — synchronized audio generation
seedance: {
provider: FAL,
name: "Seedance 1.5 Pro",
categories: ["t2v", "i2v", "ref"],
t2v: "fal-ai/bytedance/seedance/v1.5/pro/text-to-video",
i2v: "fal-ai/bytedance/seedance/v1.5/pro/image-to-video",
env: "FAL_KEY",
falInput: (body, mode) => ({
prompt: body.prompt.trim(),
aspect_ratio: body.aspectRatio || "16:9",
resolution: "720p",
duration: String(Math.min(12, Math.max(4, parseInt(body.duration, 10) || 5))),
generate_audio: true,
...(mode === "image-to-video" && body.imageUrl && { image_url: body.imageUrl }),
}),
},
};
function getModel(id) {
const key = (id || "").toLowerCase().replace(/\s+/g, "-");
return MODELS[key] || null;
}
function listModels() {
return Object.entries(MODELS).map(([id, m]) => ({
id,
name: m.name,
provider: m.provider,
categories: m.categories || (m.t2v && m.i2v ? ["t2v", "i2v"] : m.t2v ? ["t2v"] : ["i2v"]),
t2v: !!m.t2v,
i2v: !!m.i2v,
ref: !!m.referenceToVideo,
...(m.referenceToVideo && { referenceToVideoEndpoint: m.referenceToVideo }),
}));
}
function resolveModel(modelId, mode) {
const m = getModel(modelId);
if (!m) return null;
if (mode === "text-to-video" && !m.t2v) return null;
if (mode === "image-to-video" && !m.i2v) return null;
return m;
}
module.exports = { MODELS, getModel, listModels, resolveModel, FAL };
{
"name": "videoagent-video-studio-proxy",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "videoagent-video-studio-proxy",
"version": "1.0.0",
"dependencies": {
"@fal-ai/client": "^1.2.0",
"@upstash/redis": "^1.34.9"
}
},
"node_modules/@fal-ai/client": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@fal-ai/client/-/client-1.9.4.tgz",
"integrity": "sha512-mDjF2QDq+oficSSxzmErNkseQeRXnvUBEhJy39n4PPe7jRPZeSqM2SNb27SW50rDtCtay+stwFU8zRZehlt1Qg==",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"eventsource-parser": "^1.1.2",
"robot3": "^0.4.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
"license": "ISC",
"engines": {
"node": ">= 18"
}
},
"node_modules/@upstash/redis": {
"version": "1.36.3",
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.3.tgz",
"integrity": "sha512-wxo1ei4OHDHm4UGMgrNVz9QUEela9N/Iwi4p1JlHNSowQiPi+eljlGnfbZVkV0V4PIrjGtGFJt5GjWM5k28enA==",
"license": "MIT",
"dependencies": {
"uncrypto": "^0.1.3"
}
},
"node_modules/eventsource-parser": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz",
"integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==",
"license": "MIT",
"engines": {
"node": ">=14.18"
}
},
"node_modules/robot3": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/robot3/-/robot3-0.4.1.tgz",
"integrity": "sha512-hzjy826lrxzx8eRgv80idkf8ua1JAepRc9Efdtj03N3KNJuznQCPlyCJ7gnUmDFwZCLQjxy567mQVKmdv2BsXQ==",
"license": "BSD-2-Clause"
},
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
}
}
}
{
"name": "videoagent-video-studio-proxy",
"version": "1.0.0",
"private": true,
"dependencies": {
"@fal-ai/client": "^1.2.0",
"@upstash/redis": "^1.34.9"
}
}
Video Studio Proxy
Vercel serverless proxy for videoagent-video-studio. All video generation routes through this proxy, so users never need API keys.
Deploy
npm install -g vercel
cd proxy
vercel deploy --prodEnvironment Variables
| Variable | Required | Description |
|---|---|---|
FAL_KEY | Yes | Video generation API key |
VALID_TOKENS | No | Comma-separated long-lived tokens. If set, free-tier rate limiting does not apply. |
FREE_LIMIT_PER_IP | No | Max free generations per token (default: 100). 0 = unlimited. |
MAX_TOKENS_PER_IP_PER_DAY | No | Max tokens an IP can issue per day (default: 3). |
STATS_KEY | No | Bearer token required to access GET /api/stats. Leave empty for open access. |
UPSTASH_REDIS_REST_URL | For production | Upstash Redis REST URL (auto-injected by Vercel integration). |
UPSTASH_REDIS_REST_TOKEN | For production | Upstash Redis REST token (auto-injected by Vercel integration). |
Production: Persistent Storage with Upstash Redis
Without Redis, usage data lives in memory and resets on cold starts — fine for testing, not for production.
Set up in 2 minutes via Vercel:
1. Vercel dashboard → your proxy project → Integrations tab → search Upstash Redis → Add 2. Create a Redis database and link it to your project 3. Vercel automatically injects UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN 4. Redeploy — done
Or use any Upstash Redis instance directly:
vercel env add UPSTASH_REDIS_REST_URL
vercel env add UPSTASH_REDIS_REST_TOKENWith Redis enabled:
- Rate limits work correctly across all serverless instances
- Usage data survives cold starts and redeployments
/api/statsreturns accurate lifetime totals
API Endpoints
GET /api/generate
Health check. Returns service info, model list, and free-tier config.
POST /api/token
Issue a free-tier token for anonymous users.
// Response
{ "token": "vs_...", "free_limit": 100, "free_limit_reset": "daily", "max_tokens_per_day": 3 }Returns 429 when the IP has exceeded its daily token limit.
POST /api/generate
Generate a video.
Headers: Authorization: Bearer <token>
Body:
{
"mode": "text-to-video",
"prompt": "A cat walking in the rain, cinematic",
"model": "kling",
"duration": 5,
"aspectRatio": "16:9",
"imageUrl": "https://..."
}Response:
{ "success": true, "videoUrl": "https://...", "mode": "text-to-video", "model": "kling", "duration": 5 }Returns 401 if token is missing or invalid, 429 if generation limit reached, 503 if API key not configured.
GET /api/stats
Real-time usage statistics. Requires Authorization: Bearer <STATS_KEY> if STATS_KEY env is set.
JSON response (default):
{
"using_kv": true,
"total_generations": 142,
"total_errors": 3,
"total_tokens_issued": 48,
"rate_limit_hits": { "token": 7, "ip": 2 },
"by_model": { "kling": 55, "minimax": 32, "veo": 28, "seedance": 18, "grok": 9 },
"by_mode": { "text-to-video": 98, "image-to-video": 44 },
"daily": [
{ "date": "2026-02-22", "total": 8 },
{ "date": "2026-02-23", "total": 14 }
],
"timestamp": "2026-03-07T10:00:00.000Z"
}HTML dashboard — open in browser or append ?ui=1:
https://your-proxy.vercel.app/api/stats?ui=1Shows total generations, errors, tokens issued, rate-limit hits, per-model bar chart, and a 14-day daily trend — no extra tooling needed.
GET /api/status?jobId=
Async job status placeholder. Returns 501 — this proxy waits for completion and returns videoUrl directly.
Local Dev
cd proxy && npm install
FAL_KEY=your_key node ../scripts/local-server.cjs 3777
# In another terminal:
export VIDEO_STUDIO_PROXY_URL=http://localhost:3777
node tools/generate.js --prompt "A cat walking in the rain" --model kling/**
* Usage store — Upstash Redis with in-memory fallback for local dev.
* Redis is used when UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN are set.
* Legacy Vercel KV env vars (KV_REST_API_URL / KV_REST_API_TOKEN) are also accepted.
* Falls back to in-process Maps when Redis is not configured.
*
* Key schema (prefix: vs:)
* vs:tok:{token} → { ip, createdAt } TTL 24 h
* vs:usage:{token} → int TTL 25 h
* vs:ip:{ip}:{dayKey} → int TTL 25 h
*
* vs:stats:total → int (lifetime successful generations)
* vs:stats:err → int (lifetime generation errors)
* vs:stats:issued → int (lifetime tokens issued)
* vs:stats:429:token → int (rate-limit hits by token quota)
* vs:stats:429:ip → int (rate-limit hits by IP daily limit)
* vs:stats:model:{id} → int (per-model lifetime total)
* vs:stats:mode:{mode} → int (per-mode lifetime total)
* vs:stats:err:{id} → int (per-model error count)
*
* vs:stats:day:{YYYYMMDD} → int (total generations that day)
* vs:stats:day:{YYYYMMDD}:{modelId} → int (per-model that day)
*/
const DAY_MS = 86400000;
const TOKEN_TTL_S = 86400; // 24 h
const DAY_TTL_S = 86400 * 90; // keep daily stats for 90 days
// ── Redis client (lazy-loaded) ────────────────────────────────────────────────
let _kv = null;
function getKV() {
if (_kv) return _kv;
const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
if (!url || !token) return null;
try {
const { Redis } = require("@upstash/redis");
_kv = new Redis({ url, token });
return _kv;
} catch {
return null;
}
}
// ── In-memory fallback ────────────────────────────────────────────────────────
const mem = {
tokens: global._vs_tokens ?? (global._vs_tokens = new Map()),
usage: global._vs_usage ?? (global._vs_usage = new Map()),
ip: global._vs_ip ?? (global._vs_ip = new Map()),
stats: global._vs_stats ?? (global._vs_stats = new Map()),
};
function dayKey() { return Math.floor(Date.now() / DAY_MS); }
function datestamp(d) { // d = Date obj or offset from today (0 = today, -1 = yesterday)
const dt = typeof d === "number"
? new Date(Date.now() + d * DAY_MS)
: d;
return dt.toISOString().slice(0, 10).replace(/-/g, "");
}
// ── Token ops ─────────────────────────────────────────────────────────────────
async function getIssuedToken(token) {
if (!token || typeof token !== "string") return null;
const kv = getKV();
if (kv) return (await kv.get(`vs:tok:${token}`)) || null;
const data = mem.tokens.get(token);
if (!data) return null;
if (Date.now() - data.createdAt > TOKEN_TTL_S * 1000) {
mem.tokens.delete(token);
mem.usage.delete(token);
return null;
}
return data;
}
async function setIssuedToken(token, ip) {
const data = { ip, createdAt: Date.now() };
const kv = getKV();
if (kv) {
await Promise.all([
kv.set(`vs:tok:${token}`, data, { ex: TOKEN_TTL_S }),
kv.set(`vs:usage:${token}`, 0, { ex: TOKEN_TTL_S + 3600 }),
kv.incr("vs:stats:issued"),
]);
return;
}
mem.tokens.set(token, data);
mem.usage.set(token, 0);
mem.stats.set("issued", (mem.stats.get("issued") || 0) + 1);
}
async function getTokenUsage(token) {
const kv = getKV();
if (kv) return (await kv.get(`vs:usage:${token}`)) || 0;
return mem.usage.get(token) || 0;
}
async function incrementTokenUsage(token) {
const kv = getKV();
if (kv) return await kv.incr(`vs:usage:${token}`);
const n = (mem.usage.get(token) || 0) + 1;
mem.usage.set(token, n);
return n;
}
// ── IP issuance ops ───────────────────────────────────────────────────────────
async function getIssuanceCount(ip) {
const key = `vs:ip:${ip}:${dayKey()}`;
const kv = getKV();
if (kv) return (await kv.get(key)) || 0;
return mem.ip.get(key) || 0;
}
async function incrementIssuanceCount(ip) {
const key = `vs:ip:${ip}:${dayKey()}`;
const kv = getKV();
if (kv) {
const n = await kv.incr(key);
if (n === 1) await kv.expire(key, Math.floor(DAY_MS / 1000) + 3600);
return n;
}
const n = (mem.ip.get(key) || 0) + 1;
mem.ip.set(key, n);
return n;
}
// ── Stats tracking ────────────────────────────────────────────────────────────
async function trackGeneration(modelId, mode) {
const ds = datestamp(0);
const kv = getKV();
if (!kv) {
mem.stats.set("total", (mem.stats.get("total") || 0) + 1);
mem.stats.set(`model:${modelId}`, (mem.stats.get(`model:${modelId}`) || 0) + 1);
mem.stats.set(`mode:${mode}`, (mem.stats.get(`mode:${mode}`) || 0) + 1);
mem.stats.set(`day:${ds}`, (mem.stats.get(`day:${ds}`) || 0) + 1);
mem.stats.set(`day:${ds}:${modelId}`, (mem.stats.get(`day:${ds}:${modelId}`) || 0) + 1);
return;
}
await Promise.all([
kv.incr("vs:stats:total"),
kv.incr(`vs:stats:model:${modelId}`),
kv.incr(`vs:stats:mode:${mode}`),
kv.incr(`vs:stats:day:${ds}`).then(n => n === 1 && kv.expire(`vs:stats:day:${ds}`, DAY_TTL_S)),
kv.incr(`vs:stats:day:${ds}:${modelId}`),
]);
}
async function trackError(modelId) {
const kv = getKV();
if (!kv) {
mem.stats.set("err", (mem.stats.get("err") || 0) + 1);
if (modelId) mem.stats.set(`err:${modelId}`, (mem.stats.get(`err:${modelId}`) || 0) + 1);
return;
}
await Promise.all([
kv.incr("vs:stats:err"),
modelId && kv.incr(`vs:stats:err:${modelId}`),
].filter(Boolean));
}
async function trackRateLimit(type) { // "token" | "ip"
const kv = getKV();
if (!kv) { mem.stats.set(`429:${type}`, (mem.stats.get(`429:${type}`) || 0) + 1); return; }
await kv.incr(`vs:stats:429:${type}`);
}
// ── Stats query ───────────────────────────────────────────────────────────────
const MODEL_IDS = ["minimax", "kling", "veo", "hunyuan", "pixverse", "grok", "seedance"];
const MODES = ["text-to-video", "image-to-video"];
async function getDailyStats(days = 14) {
const dates = Array.from({ length: days }, (_, i) => datestamp(-i)).reverse(); // oldest first
const kv = getKV();
if (!kv) {
return dates.map(ds => ({
date: ds,
total: mem.stats.get(`day:${ds}`) || 0,
}));
}
const keys = dates.map(ds => `vs:stats:day:${ds}`);
const vals = await kv.mget(...keys);
return dates.map((ds, i) => ({
date: `${ds.slice(0, 4)}-${ds.slice(4, 6)}-${ds.slice(6, 8)}`,
total: vals[i] || 0,
}));
}
async function getStats() {
const kv = getKV();
const using_kv = !!kv;
if (!kv) {
const byModel = {}, byMode = {};
MODEL_IDS.forEach(m => { byModel[m] = mem.stats.get(`model:${m}`) || 0; });
MODES.forEach(m => { byMode[m] = mem.stats.get(`mode:${m}`) || 0; });
return {
using_kv,
total_generations: mem.stats.get("total") || 0,
total_errors: mem.stats.get("err") || 0,
total_tokens_issued: mem.stats.get("issued") || 0,
rate_limit_hits: {
token: mem.stats.get("429:token") || 0,
ip: mem.stats.get("429:ip") || 0,
},
by_model: byModel,
by_mode: byMode,
};
}
const keys = [
"vs:stats:total", "vs:stats:err", "vs:stats:issued",
"vs:stats:429:token", "vs:stats:429:ip",
...MODEL_IDS.map(m => `vs:stats:model:${m}`),
...MODES.map(m => `vs:stats:mode:${m}`),
];
const vals = await kv.mget(...keys);
const [total, err, issued, rl_token, rl_ip] = vals.slice(0, 5).map(v => v || 0);
const byModel = {}, byMode = {};
MODEL_IDS.forEach((m, j) => { byModel[m] = vals[5 + j] || 0; });
MODES.forEach((m, j) => { byMode[m] = vals[5 + MODEL_IDS.length + j] || 0; });
return {
using_kv,
total_generations: total,
total_errors: err,
total_tokens_issued: issued,
rate_limit_hits: { token: rl_token, ip: rl_ip },
by_model: byModel,
by_mode: byMode,
};
}
module.exports = {
getIssuedToken, setIssuedToken,
getTokenUsage, incrementTokenUsage,
getIssuanceCount, incrementIssuanceCount,
trackGeneration, trackError, trackRateLimit,
getStats, getDailyStats,
TOKEN_TTL_MS: TOKEN_TTL_S * 1000,
};
{
"functions": {
"api/generate.js": {
"maxDuration": 300
},
"api/token.js": {
"maxDuration": 10
},
"api/status.js": {
"maxDuration": 10
},
"api/stats.js": {
"maxDuration": 10
}
},
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" }
]
}
]
}
🎬 VideoAgent Video Studio
Generate short AI videos from text or images — 7 models, zero API key setup.
Tired of juggling accounts for Kling, Veo, and Grok? This skill gives you one-command access to 7 state-of-the-art video models — with zero setup.
Features
- Text-to-video: Describe a scene; get a short video clip (4–15 s)
- Image-to-video: Upload an image and describe the motion; get an animated clip
- Reference-to-video: Use reference images or video to control character, style, or scene consistency
- 7 models: MiniMax, Kling 3.0, Google Veo 3.1, Grok, Hunyuan, Seedance 1.5, PixVerse
- Free tier: Built-in rate limiting on the hosted proxy — no API keys needed
- Deploy your own: Full serverless proxy included under
proxy/
Quick Start
The agent enhances your prompt and calls tools/generate.js automatically. You can also run it directly:
# Text-to-video
node tools/generate.js --prompt "A cat walking in the rain, cinematic 4K" --model kling
# Image-to-video
node tools/generate.js --mode image-to-video --prompt "Slowly pan right" --image-url "https://..." --model minimax
# List available models
node tools/generate.js --list-modelsModels
| Model | T2V | I2V | Reference | Notes |
|---|---|---|---|---|
minimax | ✅ | ✅ | ✅ | Subject reference image, character consistency |
kling | ✅ | ✅ | ✅ | Multi-element / character / keyframe (O3) |
veo | ✅ | ✅ | ✅ | Google Veo 3.1, multiple reference images |
grok | ✅ | ✅ | ✅ | Video editing via reference video |
hunyuan | ✅ | — | ✅ | Video-to-video style transfer |
seedance | ✅ | ✅ | ✅ | ByteDance 1.5 Pro, synchronized audio |
pixverse | — | ✅ | — | Stylized image-to-video, PixVerse v4.5 |
Reference mode: Supply reference images, video, or elements to control character, style, or scene consistency. Each model uses a dedicated endpoint.
Parameters
| Option | Description |
|---|---|
--mode | text-to-video (default) or image-to-video |
--prompt | Scene or motion description (required) |
--image-url | Public image URL (required for image-to-video) |
--duration | Length in seconds (default: 5) |
--aspect-ratio | 16:9, 9:16, 1:1, etc. (default: 16:9) |
--model | Model ID from table above, or auto |
--list-models | List all available models from proxy |
--status --job-id <id> | Check async job status (if proxy returns jobId) |
Setup
Option 1 — Hosted proxy (zero setup)
The default proxy is pre-configured. Just run the tool — it auto-fetches a free-tier token.
Free-tier limits (per token, per day):
- Default: 100 generations / token
- Max tokens per IP per day: 3
Option 2 — Deploy your own proxy
The proxy/ directory is a ready-to-deploy serverless app. Set your API key in the deployment dashboard, then point the tool to your URL:
cd proxy && npm install && vercel deploy --prodexport VIDEO_STUDIO_PROXY_URL=https://your-proxy.vercel.appOption 3 — Local dev
# Terminal 1: start local proxy
cd skills/videoagent-video-studio/proxy
npm install
API_KEY=your_key node ../scripts/local-server.cjs 3777
# Terminal 2: use the CLI
export VIDEO_STUDIO_PROXY_URL=http://localhost:3777
node tools/generate.js --prompt "A cat walking in the rain" --model klingOption 4 — Pre-configured token
export VIDEO_STUDIO_PROXY_URL=https://your-proxy.vercel.app
export VIDEO_STUDIO_TOKEN=your_token_here
node tools/generate.js --prompt "..."Proxy Endpoints
| Endpoint | Method | Description |
|---|---|---|
GET /api/generate | GET | Health check + model list |
POST /api/generate | POST | Generate video (returns videoUrl) |
POST /api/token | POST | Get free-tier token |
GET /api/status?jobId= | GET | Async job status |
See proxy/README.md for full API docs and environment variables.
References
- references/prompt_guide.md — Prompt patterns for better video output
- references/models.md — Model selection guide
- references/calling_guide.md — Per-model calling logic
License
MIT
Calling Guide — Per-Model Details
How each model is invoked by the proxy. Useful for debugging, building compatible integrations, or understanding endpoint routing.
All requests go through POST /api/generate. The proxy routes by model and mode, maps inputs, calls the backend, and returns { videoUrl } directly.
---
Common Request Body
{
"mode": "text-to-video",
"prompt": "A cat walking in the rain, cinematic",
"model": "kling",
"duration": 5,
"aspectRatio": "16:9",
"imageUrl": "https://..."
}| Field | Required | Description |
|---|---|---|
mode | No | text-to-video (default) or image-to-video |
prompt | Yes | Scene or motion description |
imageUrl | For I2V | Public image URL |
duration | No | Seconds (default 5); some models map to discrete steps |
aspectRatio | No | Default 16:9; common: 16:9, 9:16, 1:1 |
model | No | Model ID; auto = proxy picks first available |
---
MiniMax (minimax)
| T2V endpoint | fal-ai/minimax/video-01 |
| I2V endpoint | fal-ai/minimax/video-01-live/image-to-video |
| Reference endpoint | fal-ai/minimax/video-01-subject-reference |
| T2V / I2V input | prompt + output_format: "url"; I2V adds image_url. Duration and aspect_ratio are not passed. |
| Reference input | { prompt, subject_reference_image_url: "https://..." } — one subject reference image for character consistency |
---
Kling (kling)
Kling 3.0 uses different endpoints per capability: v3 for T2V/I2V, O3 for reference.
| T2V endpoint | fal-ai/kling-video/v3/standard/text-to-video |
| I2V endpoint | fal-ai/kling-video/v3/standard/image-to-video |
| Reference endpoint | fal-ai/kling-video/o3/standard/reference-to-video |
| T2V / I2V input | prompt, aspect_ratio, output_format: "url"; I2V adds image_url. Duration not passed (Kling uses its own steps). |
| Reference input | prompt, start_image_url?, end_image_url?, image_urls?, elements?, duration (3–15), aspect_ratio |
---
Google Veo (veo)
| T2V endpoint | fal-ai/veo3.1 |
| I2V endpoint | fal-ai/veo3.1/image-to-video |
| Reference endpoint | fal-ai/veo3.1/reference-to-video |
| T2V / I2V input | prompt, aspect_ratio, output_format; I2V adds image_url. Duration maps: 1–4 s → "4s", 5–6 s → "6s", 7+ s → "8s". |
| Reference input | { prompt, image_urls: ["url1", ...], aspect_ratio, duration: "8s", resolution: "720p" } |
| ⚠ Constraint | veo3.1/reference-to-video only accepts duration: "8s". The proxy fixes this — user duration is ignored for reference mode. |
---
Hunyuan (hunyuan)
| T2V endpoint | fal-ai/hunyuan-video |
| I2V | Not supported |
| Reference endpoint | fal-ai/hunyuan-video/video-to-video |
| T2V input | prompt, aspect_ratio, output_format: "url". Duration not passed. |
| Reference input | { prompt, video_url: "https://...", aspect_ratio, strength: 0.85 } — strength controls how far the output drifts from the reference (lower = closer to original) |
---
PixVerse (pixverse)
| T2V | Not supported |
| I2V endpoint | fal-ai/pixverse/v4.5/image-to-video |
| I2V input | prompt, image_url, aspect_ratio, output_format: "url" |
---
Grok (grok)
| T2V endpoint | xai/grok-imagine-video/text-to-video |
| I2V endpoint | xai/grok-imagine-video/image-to-video |
| Reference endpoint | xai/grok-imagine-video/edit-video |
| T2V / I2V input | prompt, duration (1–15 s, default 6), aspect_ratio, resolution: "720p"; I2V adds image_url |
| Reference input | { prompt: "Colorize the video", video_url: "https://..." } |
---
Seedance (seedance) — Seedance 1.5 Pro
| T2V endpoint | fal-ai/bytedance/seedance/v1.5/pro/text-to-video |
| I2V endpoint | fal-ai/bytedance/seedance/v1.5/pro/image-to-video |
| Reference | Body-distinguished (same endpoint; reference is controlled via image_url and prompt) |
Input parameters:
| Field | Default | Description |
|---|---|---|
prompt | required | Scene, action, dialogue, sound effects |
aspect_ratio | "16:9" | 21:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16 |
resolution | "720p" | "480p" (fast preview) or "720p" |
duration | "5" | "4"–"12" seconds (string); proxy clamps automatically |
generate_audio | true | Generate synchronized audio (dialogue, SFX, ambience) |
image_url | — | First-frame image for I2V mode |
Response:
{ "video": { "url": "https://...", "content_type": "video/mp4" }, "seed": 42 }---
Model Selection Guide
| Scenario | Mode | Recommended | Key Notes |
|---|---|---|---|
| General / default | T2V or I2V | auto or minimax | Pass prompt, optionally image_url |
| Cinematic / commercial | T2V | veo / kling / grok | Veo: duration maps to 4s/6s/8s; Grok: pass duration + resolution |
| Image-to-video only | I2V | pixverse | Requires image_url |
| Text-to-video only | T2V | hunyuan | No duration param |
| Synchronized audio | T2V / I2V | seedance | Pair with seedance-2.0-prompter for best results |
| Reference / consistency | Reference | kling, veo, minimax | Each has a dedicated reference endpoint |
Video Generation Models
All models supported by the video-studio proxy. Pass model in the request body or use --model in the CLI.
For per-model endpoint details and input parameters, see calling_guide.md.
Generation Modes
| Mode | Description | Supported Models |
|---|---|---|
| text-to-video | Text prompt only → video | minimax, kling, veo, hunyuan, grok, seedance |
| image-to-video | Single image + prompt → animated clip | minimax, kling, veo, pixverse, grok, seedance |
| reference-based | Reference images or video → consistent output | minimax, kling, veo, hunyuan, grok, seedance |
Reference-based generation uses a separate endpoint per model. Supported models return ref: true and referenceToVideoEndpoint in GET /api/generate.
Reference Endpoints
Each model's reference-based mode uses an independent endpoint:
| Model | Reference Endpoint | Input |
|---|---|---|
| minimax | …/video-01-subject-reference | Single subject reference image — character consistency |
| kling | …/kling-video/o3/standard/reference-to-video | Multi-element images, keyframes, character elements (O3) |
| veo | …/veo3.1/reference-to-video | Multiple reference images — style/character consistency |
| hunyuan | …/hunyuan-video/video-to-video | Reference video + prompt — style transfer (strength param) |
| grok | …/grok-imagine-video/edit-video | Reference video + prompt — content/style editing |
| seedance | Same endpoint, body-distinguished | Text / image / reference all routed by request body |
Model List
| Model ID | Name | T2V | I2V | Reference | Notes |
|---|---|---|---|---|---|
| minimax | MiniMax Video 01 | ✅ | ✅ | ✅ | Balanced, good default |
| kling | Kling 3.0 | ✅ | ✅ | ✅ | v3 for T2V/I2V; O3 for reference |
| veo | Google Veo 3.1 | ✅ | ✅ | ✅ | Latest Veo, 4K option |
| hunyuan | Hunyuan Video | ✅ | — | ✅ | Open-source T2V |
| pixverse | PixVerse v4.5 | — | ✅ | — | Stylized I2V only |
| grok | Grok Imagine Video | ✅ | ✅ | ✅ | 1–15 s, 480p/720p |
| seedance | Seedance 1.5 Pro | ✅ | ✅ | ✅ | ByteDance, synchronized audio, 4–12 s |
Choosing a Model
- Default (auto): Proxy picks the first available model for the requested mode (usually minimax).
- Cinematic / commercial:
veo,kling,grok. - Fast preview:
minimax. - Image-to-video only:
pixverse. - Open / self-hostable:
hunyuan. - Synchronized audio:
seedance(pair with seedance-2.0-prompter for best prompts).
Provider Limits
- Duration: Varies by model — Veo maps to 4s/6s/8s, Kling uses 5s/10s, Seedance 4–12 s, Grok 1–15 s.
- Resolution: Most default to 720p; some support 480p (preview) or 4K.
- Reference mode:
veo/reference-to-videoonly acceptsduration: "8s"(API constraint).
Video Generation Prompt Guide
Best practices for writing prompts that produce high-quality AI video.
Text-to-Video
Structure
1. Subject — What is in the shot (person, object, scene). 2. Action / motion — What is moving and how (walking, camera zoom, wind in trees). 3. Style & quality — Look and feel (cinematic, 4K, soft lighting).
Strong vs weak prompts
| Weak | Strong |
|---|---|
| "A dog" | "A golden retriever running through a sunlit meadow, grass swaying, shallow depth of field" |
| "Someone drinking coffee" | "Close-up of hands holding a white cup, steam rising, morning light from the left, cozy café background" |
| "City at night" | "Tokyo street at night, neon signs, light rain on pavement, camera slowly tracking forward" |
Useful phrases
- Camera: "slow zoom in", "static shot", "pan left to right", "dolly forward", "handheld".
- Motion: "gentle movement", "slow motion", "smooth transition", "subtle motion".
- Quality: "4K", "cinematic", "film grain", "professional", "high detail".
---
Image-to-Video
Focus on motion
The model animates the image. Describe what should move and how, not the whole scene again.
- "Gentle breeze moving the hair and leaves."
- "Camera slowly zooms into the subject."
- "Clouds drifting across the sky, water slightly rippling."
- "Character blinks and smiles slightly."
Avoid
- Repeating the full scene description (the image already defines it).
- Conflicting motion (e.g. "zoom in" and "zoom out" in one prompt).
- Too many moving elements in one short clip.
By content type
| Content | Example motion prompt |
|---|---|
| Portrait | "Subtle breathing, soft blink, slight head turn" |
| Landscape | "Clouds moving, water ripple, grass sway" |
| Product | "Soft rotation on stand, reflection shift" |
| Logo / graphic | "Clean fade-in, gentle scale or float" |
---
Duration and aspect ratio
- 4–5 s: Good default; stable quality.
- 6–10 s: Use when the user asks for "longer" or a specific length; some backends may cap at 5–6 s.
- 16:9 — Landscape (default for cinematic / desktop).
- 9:16 — Vertical (stories, reels, mobile).
- 1:1 — Square (feed, thumbnails).
For per-model duration and resolution limits (e.g. Veo 4s/6s/8s, Grok 1–15s), see models.md.
/**
* Minimal local HTTP server for testing the proxy handlers.
* No extra deps — uses Node.js built-in http module.
* Wraps Vercel-style handlers (req.body, res.status().json()) into real HTTP.
* Usage: node scripts/local-server.cjs [port]
*/
const http = require("http");
const path = require("path");
const PORT = parseInt(process.argv[2] || "3777", 10);
const proxyDir = path.join(__dirname, "../proxy");
// Load handlers (CommonJS)
const generateHandler = require(path.join(proxyDir, "api/generate.js"));
const tokenHandler = require(path.join(proxyDir, "api/token.js"));
const statusHandler = require(path.join(proxyDir, "api/status.js"));
// Adapt Node http IncomingMessage → Express-style req
async function adaptReq(nodeReq) {
return new Promise((resolve, reject) => {
let body = "";
nodeReq.on("data", (c) => (body += c));
nodeReq.on("end", () => {
const url = new URL(nodeReq.url, `http://localhost:${PORT}`);
const query = Object.fromEntries(url.searchParams.entries());
let parsed = {};
if (body) {
try { parsed = JSON.parse(body); } catch {}
}
resolve({
method: nodeReq.method,
headers: nodeReq.headers,
body: parsed,
query,
socket: { remoteAddress: nodeReq.socket?.remoteAddress || "127.0.0.1" },
});
});
nodeReq.on("error", reject);
});
}
// Adapt Express-style res → Node http ServerResponse
function adaptRes(nodeRes) {
const res = {
_status: 200,
setHeader: (k, v) => nodeRes.setHeader(k, v),
status: function(s) { res._status = s; return res; },
json: function(data) {
const body = JSON.stringify(data);
nodeRes.writeHead(res._status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
nodeRes.end(body);
return res;
},
end: function() { nodeRes.writeHead(res._status); nodeRes.end(); return res; },
};
return res;
}
const server = http.createServer(async (nodeReq, nodeRes) => {
const req = await adaptReq(nodeReq);
const res = adaptRes(nodeRes);
// CORS
nodeRes.setHeader("Access-Control-Allow-Origin", "*");
if (nodeReq.method === "OPTIONS") { nodeRes.writeHead(204); nodeRes.end(); return; }
const pathname = new URL(nodeReq.url, `http://localhost:${PORT}`).pathname;
if (pathname === "/api/generate") return generateHandler(req, res);
if (pathname === "/api/token") return tokenHandler(req, res);
if (pathname === "/api/status") return statusHandler(req, res);
nodeRes.writeHead(404, { "Content-Type": "application/json" });
nodeRes.end(JSON.stringify({ error: "Not found", path: pathname }));
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`[local-server] listening on http://127.0.0.1:${PORT}`);
console.log(`[local-server] FAL_KEY: ${process.env.FAL_KEY ? "set (" + process.env.FAL_KEY.slice(0, 8) + "...)" : "NOT SET"}`);
console.log("[local-server] ready");
});
server.on("error", (e) => {
console.error("[local-server] error:", e.message);
process.exit(1);
});
# ============================================================
# video-studio API test script (PowerShell, no node required)
# Usage:
# Test against the backend API directly:
# $env:FAL_KEY = "your-api-key"; .\test-api.ps1
# Test via local or deployed proxy:
# $env:FAL_KEY = "your-api-key"; $env:VIDEO_STUDIO_PROXY_URL = "http://localhost:3000"; .\test-api.ps1
# ============================================================
param(
[string]$FalKey = $env:FAL_KEY,
[string]$ProxyUrl = $env:VIDEO_STUDIO_PROXY_URL,
[string]$TestMode = "all" # all | fal | proxy
)
$ErrorActionPreference = "Continue"
# helpers
function Write-Header($msg) { Write-Host "`n===[ $msg ]===" -ForegroundColor Cyan }
function Write-Ok($msg) { Write-Host "[OK] $msg" -ForegroundColor Green }
function Write-Fail($msg) { Write-Host "[FAIL] $msg" -ForegroundColor Red }
function Write-Info($msg) { Write-Host " $msg" -ForegroundColor Gray }
function Invoke-Json {
param([string]$Uri, [string]$Method = "GET", $Body = $null, $Headers = @{})
try {
$params = @{ Uri = $Uri; Method = $Method; Headers = $Headers; ErrorAction = "Stop" }
if ($Body) {
$params.ContentType = "application/json"
$params.Body = ($Body | ConvertTo-Json -Depth 10)
}
$resp = Invoke-RestMethod @params
return @{ ok = $true; data = $resp }
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
$detail = $_.ErrorDetails.Message
return @{ ok = $false; status = $statusCode; error = $_.Exception.Message; detail = $detail }
}
}
# ============================================================
# 1. Direct backend API tests (no proxy)
# ============================================================
function Test-FalDirect {
Write-Header "Direct API Tests"
if (-not $FalKey) { Write-Fail "API key not set, skipping"; return }
$falHeaders = @{ Authorization = "Key $FalKey" }
# 1-A. minimax text-to-video
Write-Info "1-A: minimax text-to-video (submit)"
$r = Invoke-Json `
-Uri "https://queue.fal.run/fal-ai/minimax/video-01" `
-Method POST `
-Headers $falHeaders `
-Body @{ prompt = "A cat walking in the rain, cinematic, 4K" }
if ($r.ok) {
Write-Ok "minimax T2V submitted, request_id=$($r.data.request_id)"
$Script:minimaxReqId = $r.data.request_id
} else {
Write-Fail "minimax T2V failed status=$($r.status): $($r.detail)"
}
# 1-B. kling text-to-video
Write-Info "1-B: kling text-to-video (submit)"
$r = Invoke-Json `
-Uri "https://queue.fal.run/fal-ai/kling-video/v3/standard/text-to-video" `
-Method POST `
-Headers $falHeaders `
-Body @{ prompt = "Ocean waves at sunset, slow motion"; aspect_ratio = "16:9" }
if ($r.ok) {
Write-Ok "kling T2V submitted, request_id=$($r.data.request_id)"
} else {
Write-Fail "kling T2V failed status=$($r.status): $($r.detail)"
}
# 1-C. seedance 1.5 pro text-to-video
Write-Info "1-C: seedance 1.5 pro text-to-video (submit)"
$r = Invoke-Json `
-Uri "https://queue.fal.run/fal-ai/bytedance/seedance/v1.5/pro/text-to-video" `
-Method POST `
-Headers $falHeaders `
-Body @{ prompt = "A dog running in a park, sunny day"; duration = "5"; resolution = "720p"; generate_audio = $true }
if ($r.ok) {
Write-Ok "seedance 1.5 T2V submitted, request_id=$($r.data.request_id)"
} else {
Write-Fail "seedance 1.5 T2V failed status=$($r.status): $($r.detail)"
}
# 1-D. veo 3.1 reference-to-video (multiple reference images)
Write-Info "1-D: veo 3.1 reference-to-video (submit)"
$r = Invoke-Json `
-Uri "https://queue.fal.run/fal-ai/veo3.1/reference-to-video" `
-Method POST `
-Headers $falHeaders `
-Body @{
prompt = "A person walking through a sunlit park"
image_urls = @("https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png")
aspect_ratio = "16:9"
duration = "8s"
}
if ($r.ok) {
Write-Ok "veo reference-to-video submitted, request_id=$($r.data.request_id)"
} else {
Write-Fail "veo reference-to-video failed status=$($r.status): $($r.detail)"
}
# 1-E. hunyuan video-to-video (reference video)
Write-Info "1-E: hunyuan video-to-video (submit)"
$r = Invoke-Json `
-Uri "https://queue.fal.run/fal-ai/hunyuan-video/video-to-video" `
-Method POST `
-Headers $falHeaders `
-Body @{
prompt = "Same scene but at night with neon lights"
video_url = "https://storage.googleapis.com/falserverless/hunyuan_video/hunyuan_v2v_input.mp4"
aspect_ratio = "16:9"
strength = 0.85
}
if ($r.ok) {
Write-Ok "hunyuan video-to-video submitted, request_id=$($r.data.request_id)"
} else {
Write-Fail "hunyuan video-to-video failed status=$($r.status): $($r.detail)"
}
# 1-F. grok text-to-video
Write-Info "1-F: grok text-to-video (submit)"
$r = Invoke-Json `
-Uri "https://queue.fal.run/xai/grok-imagine-video/text-to-video" `
-Method POST `
-Headers $falHeaders `
-Body @{ prompt = "A futuristic city at night with flying cars"; duration = 5; aspect_ratio = "16:9"; resolution = "720p" }
if ($r.ok) {
Write-Ok "grok T2V submitted, request_id=$($r.data.request_id)"
} else {
Write-Fail "grok T2V failed status=$($r.status): $($r.detail)"
}
# 1-G. Poll minimax job status (from 1-A)
if ($Script:minimaxReqId) {
Write-Info "1-G: check minimax job status (request_id=$($Script:minimaxReqId))"
$r = Invoke-Json `
-Uri "https://queue.fal.run/fal-ai/minimax/video-01/requests/$($Script:minimaxReqId)/status" `
-Headers $falHeaders
if ($r.ok) {
Write-Ok "Queue status: $($r.data.status)"
} else {
Write-Fail "Status check failed: $($r.detail)"
}
}
}
# ============================================================
# 2. Proxy tests (full free-tier flow + generation)
# ============================================================
function Test-Proxy {
Write-Header "Proxy Tests"
if (-not $ProxyUrl) { Write-Fail "VIDEO_STUDIO_PROXY_URL not set, skipping"; return }
# 2-A. GET /api/generate (health check)
Write-Info "2-A: GET $ProxyUrl/api/generate"
$r = Invoke-Json -Uri "$ProxyUrl/api/generate"
if ($r.ok) {
Write-Ok "Proxy online: service=$($r.data.service) version=$($r.data.version)"
Write-Info " models: $(($r.data.models | ForEach-Object { $_.id }) -join ', ')"
Write-Info " free_limit_per_token=$($r.data.free_limit_per_token)"
} else {
Write-Fail "Proxy unreachable: $($r.error)"; return
}
# 2-B. POST /api/token (get free-tier token)
Write-Info "2-B: POST $ProxyUrl/api/token"
$r = Invoke-Json -Uri "$ProxyUrl/api/token" -Method POST
if ($r.ok) {
$proxyToken = $r.data.token
Write-Ok "Token issued: $proxyToken (free_limit=$($r.data.free_limit))"
} else {
Write-Fail "Token failed status=$($r.status): $($r.detail)"; return
}
$genHeaders = @{ Authorization = "Bearer $proxyToken" }
# 2-C. Missing prompt should return 400
Write-Info "2-C: Missing prompt (expect 400)"
$r = Invoke-Json -Uri "$ProxyUrl/api/generate" -Method POST -Headers $genHeaders `
-Body @{ mode = "text-to-video" }
if (-not $r.ok -and $r.status -eq 400) {
Write-Ok "Correct 400: $($r.detail)"
} else {
Write-Fail "Expected 400, got status=$($r.status)"
}
# 2-D. Invalid model should return 400
Write-Info "2-D: Unknown model (expect 400)"
$r = Invoke-Json -Uri "$ProxyUrl/api/generate" -Method POST -Headers $genHeaders `
-Body @{ mode = "text-to-video"; prompt = "test"; model = "nonexistent" }
if (-not $r.ok -and $r.status -eq 400) {
Write-Ok "Correct 400: $($r.detail)"
} else {
Write-Fail "Expected 400, got status=$($r.status) $($r.detail)"
}
# 2-E. Real generation — minimax T2V (~30–60s)
Write-Info "2-E: minimax text-to-video (real call, ~30-60s)"
$r = Invoke-Json -Uri "$ProxyUrl/api/generate" -Method POST -Headers $genHeaders `
-Body @{ mode = "text-to-video"; prompt = "A cat walking in the rain, cinematic"; model = "minimax"; duration = 5 }
if ($r.ok -and $r.data.videoUrl) {
Write-Ok "minimax T2V succeeded!"
Write-Info " videoUrl: $($r.data.videoUrl)"
} elseif ($r.ok -and $r.data.jobId) {
Write-Ok "minimax async, jobId=$($r.data.jobId) status=$($r.data.status)"
} else {
Write-Fail "minimax failed status=$($r.status): $($r.detail)"
}
# 2-F. Real generation — seedance 1.5 T2V
Write-Info "2-F: seedance 1.5 text-to-video (real call)"
$r = Invoke-Json -Uri "$ProxyUrl/api/generate" -Method POST -Headers $genHeaders `
-Body @{ mode = "text-to-video"; prompt = "A dog playing fetch on a sunny beach"; model = "seedance"; duration = 5 }
if ($r.ok -and $r.data.videoUrl) {
Write-Ok "seedance 1.5 succeeded!"
Write-Info " videoUrl: $($r.data.videoUrl)"
} elseif ($r.ok -and $r.data.jobId) {
Write-Ok "seedance 1.5 async, jobId=$($r.data.jobId)"
} else {
Write-Fail "seedance 1.5 failed status=$($r.status): $($r.detail)"
}
# 2-G. Rate limit test — try to exceed daily token issuance limit
Write-Info "2-G: Token issuance rate limit test (up to 10 attempts)"
$hit429 = $false
for ($i = 0; $i -lt 10; $i++) {
$r = Invoke-Json -Uri "$ProxyUrl/api/token" -Method POST
if (-not $r.ok -and $r.status -eq 429) { $hit429 = $true; break }
}
if ($hit429) {
Write-Ok "Correct 429 — daily token limit reached"
} else {
Write-Info "429 not triggered (limit higher than 10, or in-memory store not yet reset)"
}
}
# ============================================================
# 3. Run
# ============================================================
Write-Host "`n🎬 video-studio API Tests" -ForegroundColor Yellow
Write-Host " API Key: $(if ($FalKey) { 'set (' + $FalKey.Substring(0, [Math]::Min(8, $FalKey.Length)) + '...)' } else { 'not set' })"
Write-Host " ProxyUrl: $(if ($ProxyUrl) { $ProxyUrl } else { 'not set' })"
Write-Host " TestMode: $TestMode"
if ($TestMode -eq "all" -or $TestMode -eq "fal") { Test-FalDirect }
if ($TestMode -eq "all" -or $TestMode -eq "proxy") { Test-Proxy }
Write-Host "`n✅ Tests complete" -ForegroundColor Yellow
# Quick test for tools/generate.js. Set VIDEO_STUDIO_PROXY_URL to your proxy (e.g. http://localhost:3000).
$ErrorActionPreference = "Continue"
$SkillDir = Split-Path $PSScriptRoot -Parent
Set-Location $SkillDir
Write-Host "=== Test 1: missing --prompt (expect error) ==="
node tools/generate.js --mode text-to-video 2>&1
Write-Host ""
Write-Host "=== Test 2: text-to-video (fails if proxy unreachable) ==="
node tools/generate.js --mode text-to-video --prompt "A cat walking in the rain" --duration 5 2>&1
Write-Host ""
Write-Host "=== Test 3: image-to-video missing --image-url (expect error) ==="
node tools/generate.js --mode image-to-video --prompt "Gentle motion" 2>&1
Write-Host ""
Write-Host "=== Test 4: list-models (GET proxy; may fail if proxy unreachable) ==="
node tools/generate.js --list-models 2>&1
#!/usr/bin/env bash
# Quick test for tools/generate.js. Set VIDEO_STUDIO_PROXY_URL to your proxy (e.g. http://localhost:3000).
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$(dirname "$SCRIPT_DIR")"
cd "$BASE_DIR"
echo "=== Test 1: missing --prompt (expect error) ==="
node tools/generate.js --mode text-to-video 2>&1 || true
echo ""
echo "=== Test 2: text-to-video (will fail if proxy unreachable) ==="
node tools/generate.js --mode text-to-video --prompt "A cat walking in the rain" --duration 5 2>&1 || true
echo ""
echo "=== Test 3: image-to-video missing --image-url (expect error) ==="
node tools/generate.js --mode image-to-video --prompt "Gentle motion" 2>&1 || true
echo ""
echo "=== Test 4: list-models (GET proxy; may fail if proxy unreachable) ==="
node tools/generate.js --list-models 2>&1 || true
/**
* Local proxy unit test harness — no Vercel / HTTP server required.
* Directly invokes handler(req, res) with mock req/res objects.
* Run: node scripts/test-proxy.cjs
*/
process.env.FAL_KEY = process.env.FAL_KEY || "";
process.env.FREE_LIMIT_PER_IP = "3";
process.env.MAX_TOKENS_PER_IP_PER_DAY = "2";
delete process.env.VALID_TOKENS;
delete process.env.KV_REST_API_URL; // force in-memory fallback
const path = require("path");
const proxyDir = path.join(__dirname, "../proxy");
// ── Mock req / res ────────────────────────────────────────────────────────────
function mockReq(method, body = {}, headers = {}, ip = "1.2.3.4") {
return { method, body, headers: { ...headers },
socket: { remoteAddress: ip }, query: body };
}
function mockRes() {
const res = { _status: 200, _body: null, _headers: {} };
res.status = (s) => { res._status = s; return res; };
res.json = (d) => { res._body = d; return res; };
res.end = () => res;
res.setHeader = (k, v) => { res._headers[k] = v; };
return res;
}
// ── Runner ────────────────────────────────────────────────────────────────────
let passed = 0, failed = 0;
async function test(label, fn) {
try { await fn(); console.log(` ✓ ${label}`); passed++; }
catch (e) { console.error(` ✗ ${label}\n ${e.message}`); failed++; }
}
function expect(val, label) {
return {
toBe: (exp) => { if (val !== exp) throw new Error(`${label}: expected ${JSON.stringify(exp)}, got ${JSON.stringify(val)}`); },
toContain: (sub) => { if (!String(val).includes(sub)) throw new Error(`${label}: expected to contain "${sub}", got "${val}"`); },
toBeTruthy: () => { if (!val) throw new Error(`${label}: expected truthy, got ${JSON.stringify(val)}`); },
toBeNull: () => { if (val !== null) throw new Error(`${label}: expected null, got ${JSON.stringify(val)}`); },
};
}
// ── Tests ─────────────────────────────────────────────────────────────────────
async function runTests() {
console.log("\n═══ proxy unit tests ═══\n");
// ── usage-store (async) ───────────────────────────────────────────────────
console.log("── usage-store ──");
const store = require(path.join(proxyDir, "usage-store.js"));
await test("setIssuedToken + getIssuedToken round-trip", async () => {
await store.setIssuedToken("tok_test_1", "1.2.3.4");
const data = await store.getIssuedToken("tok_test_1");
expect(data.ip, "ip").toBe("1.2.3.4");
});
await test("getIssuedToken returns null for unknown token", async () => {
const data = await store.getIssuedToken("tok_nonexistent");
expect(data, "data").toBeNull();
});
await test("incrementTokenUsage increments counter", async () => {
await store.setIssuedToken("tok_usage_1", "1.2.3.4");
await store.incrementTokenUsage("tok_usage_1");
await store.incrementTokenUsage("tok_usage_1");
const n = await store.getTokenUsage("tok_usage_1");
expect(n, "usage").toBe(2);
});
await test("getIssuanceCount + incrementIssuanceCount", async () => {
const ip = "10.0.0.test_async";
expect(await store.getIssuanceCount(ip), "initial count").toBe(0);
await store.incrementIssuanceCount(ip);
await store.incrementIssuanceCount(ip);
expect(await store.getIssuanceCount(ip), "after 2 increments").toBe(2);
});
await test("getStats returns expected shape", async () => {
const stats = await store.getStats();
if (typeof stats.total_generations !== "number") throw new Error("missing total_generations");
if (typeof stats.by_model !== "object") throw new Error("missing by_model");
if (typeof stats.by_mode !== "object") throw new Error("missing by_mode");
});
// ── models.js ─────────────────────────────────────────────────────────────
console.log("\n── models.js ──");
const { getModel, listModels, resolveModel } = require(path.join(proxyDir, "models.js"));
await test("listModels returns array with id/name/categories", async () => {
const list = listModels();
if (!Array.isArray(list) || list.length === 0) throw new Error("empty list");
const m = list[0];
if (!m.id || !m.name || !Array.isArray(m.categories)) throw new Error("missing fields: " + JSON.stringify(m));
});
await test("getModel('minimax') returns correct config", async () => {
const m = getModel("minimax");
expect(m.t2v, "t2v").toBe("fal-ai/minimax/video-01");
expect(m.referenceToVideo, "ref endpoint").toBe("fal-ai/minimax/video-01-subject-reference");
});
await test("getModel('kling') has correct O3 reference endpoint", async () => {
const m = getModel("kling");
expect(m.referenceToVideo, "kling O3").toBe("fal-ai/kling-video/o3/standard/reference-to-video");
});
await test("getModel('veo') refInput always returns duration 8s", async () => {
const m = getModel("veo");
const input = m.refInput({ prompt: "test", imageUrl: "http://x.com/img.jpg", duration: 4 });
expect(input.duration, "veo ref duration").toBe("8s");
});
await test("getModel('seedance') uses fal-ai/bytedance endpoint", async () => {
const m = getModel("seedance");
expect(m.t2v, "seedance t2v").toContain("fal-ai/bytedance/seedance");
expect(m.provider, "provider").toBe("fal");
});
await test("resolveModel returns null for unsupported mode", async () => {
const m = resolveModel("pixverse", "text-to-video");
expect(m, "pixverse t2v should be null").toBeNull();
});
await test("all models have categories array", async () => {
for (const m of listModels()) {
if (!Array.isArray(m.categories) || m.categories.length === 0)
throw new Error(`${m.id} missing categories`);
}
});
// ── POST /api/token ───────────────────────────────────────────────────────
console.log("\n── POST /api/token ──");
const tokenHandler = require(path.join(proxyDir, "api/token.js"));
await test("GET /api/token → 405", async () => {
const res = mockRes();
await tokenHandler(mockReq("GET"), res);
expect(res._status, "status").toBe(405);
});
await test("POST /api/token → issues token", async () => {
const res = mockRes();
await tokenHandler(mockReq("POST", {}, {}, "192.168.1.1"), res);
expect(res._status, "status").toBe(200);
if (!res._body.token?.startsWith("vs_")) throw new Error("bad token: " + res._body.token);
});
await test("POST /api/token → second token same IP works", async () => {
const res = mockRes();
await tokenHandler(mockReq("POST", {}, {}, "192.168.1.2"), res);
const res2 = mockRes();
await tokenHandler(mockReq("POST", {}, {}, "192.168.1.2"), res2);
expect(res2._status, "second token").toBe(200);
});
await test("POST /api/token → 429 after MAX_TOKENS_PER_IP_PER_DAY (2)", async () => {
const ip = "192.168.1.50";
for (let i = 0; i < 2; i++) {
const res = mockRes();
await tokenHandler(mockReq("POST", {}, {}, ip), res);
if (res._status !== 200) throw new Error(`expected 200 on call ${i+1}, got ${res._status}`);
}
const res3 = mockRes();
await tokenHandler(mockReq("POST", {}, {}, ip), res3);
expect(res3._status, "3rd token should 429").toBe(429);
});
// ── POST /api/generate ────────────────────────────────────────────────────
console.log("\n── POST /api/generate ──");
const generateHandler = require(path.join(proxyDir, "api/generate.js"));
await test("GET /api/generate → health check 200", async () => {
const res = mockRes();
await generateHandler(mockReq("GET"), res);
expect(res._status, "status").toBe(200);
expect(res._body.service, "service").toBe("videoagent-video-studio-proxy");
if (!Array.isArray(res._body.models)) throw new Error("models not array");
expect(res._body.free_limit_per_token, "free_limit").toBe(3);
});
await test("POST /api/generate without token → 401", async () => {
const res = mockRes();
await generateHandler(mockReq("POST", { mode: "text-to-video", prompt: "test" }), res);
expect(res._status, "status").toBe(401);
});
await test("POST /api/generate with unknown token → 401", async () => {
const res = mockRes();
await generateHandler(mockReq("POST", { mode: "text-to-video", prompt: "test" }, {
authorization: "Bearer vs_fake_never_issued",
}), res);
expect(res._status, "status").toBe(401);
});
// issue real token for next tests
const tr = mockRes();
await tokenHandler(mockReq("POST", {}, {}, "test.harness.ip"), tr);
const testToken = tr._body.token;
await test("POST /api/generate missing prompt → 400", async () => {
const res = mockRes();
await generateHandler(mockReq("POST", { mode: "text-to-video" }, {
authorization: `Bearer ${testToken}`,
}), res);
expect(res._status, "status").toBe(400);
expect(res._body.error, "error").toContain("prompt");
});
await test("POST /api/generate image-to-video missing imageUrl → 400", async () => {
const res = mockRes();
await generateHandler(mockReq("POST", { mode: "image-to-video", prompt: "test" }, {
authorization: `Bearer ${testToken}`,
}), res);
expect(res._status, "status").toBe(400);
expect(res._body.error, "error").toContain("imageUrl");
});
await test("POST /api/generate invalid model → 400", async () => {
const res = mockRes();
await generateHandler(mockReq("POST",
{ mode: "text-to-video", prompt: "test", model: "nonexistent_xyz" },
{ authorization: `Bearer ${testToken}` }
), res);
expect(res._status, "status").toBe(400);
});
await test("POST /api/generate pixverse with text-to-video → 400 (i2v only)", async () => {
const res = mockRes();
await generateHandler(mockReq("POST",
{ mode: "text-to-video", prompt: "test", model: "pixverse" },
{ authorization: `Bearer ${testToken}` }
), res);
expect(res._status, "status").toBe(400);
});
await test("POST /api/generate with no FAL_KEY → 503", async () => {
const saved = process.env.FAL_KEY;
process.env.FAL_KEY = "";
delete require.cache[require.resolve(path.join(proxyDir, "api/generate.js"))];
const fresh = require(path.join(proxyDir, "api/generate.js"));
const tr2 = mockRes();
await tokenHandler(mockReq("POST", {}, {}, "no.fal.key.ip"), tr2);
const res = mockRes();
await fresh(mockReq("POST",
{ mode: "text-to-video", prompt: "test", model: "minimax" },
{ authorization: `Bearer ${tr2._body.token}` }
), res);
process.env.FAL_KEY = saved;
expect(res._status, "status").toBe(503);
});
await test("POST /api/generate rate-limit: 429 after FREE_LIMIT_PER_IP (3) calls", async () => {
const tr3 = mockRes();
await tokenHandler(mockReq("POST", {}, {}, "ratelimit.test.ip"), tr3);
const tok = tr3._body.token;
// burn usage to limit
for (let i = 0; i < 3; i++) await store.incrementTokenUsage(tok);
delete require.cache[require.resolve(path.join(proxyDir, "api/generate.js"))];
const fresh = require(path.join(proxyDir, "api/generate.js"));
process.env.FAL_KEY = "test_key";
const res = mockRes();
await fresh(mockReq("POST",
{ mode: "text-to-video", prompt: "test", model: "minimax" },
{ authorization: `Bearer ${tok}` }
), res);
process.env.FAL_KEY = "";
expect(res._status, "status").toBe(429);
expect(res._body.error, "error").toContain("Free limit");
});
// ── GET /api/status ───────────────────────────────────────────────────────
console.log("\n── GET /api/status ──");
const statusHandler = require(path.join(proxyDir, "api/status.js"));
await test("GET /api/status without jobId → 400", async () => {
const res = mockRes();
await statusHandler(mockReq("GET", {}), res);
expect(res._status, "status").toBe(400);
});
await test("GET /api/status with jobId → 501", async () => {
const req = mockReq("GET", {});
req.query = { jobId: "test-job-123" };
const res = mockRes();
await statusHandler(req, res);
expect(res._status, "status").toBe(501);
expect(res._body.jobId, "jobId echoed").toBe("test-job-123");
});
// ── GET /api/stats ────────────────────────────────────────────────────────
console.log("\n── GET /api/stats ──");
const statsHandler = require(path.join(proxyDir, "api/stats.js"));
await test("GET /api/stats open access (no STATS_KEY) → 200", async () => {
delete process.env.STATS_KEY;
delete require.cache[require.resolve(path.join(proxyDir, "api/stats.js"))];
const fresh = require(path.join(proxyDir, "api/stats.js"));
const res = mockRes();
await fresh(mockReq("GET"), res);
expect(res._status, "status").toBe(200);
if (typeof res._body.total_generations !== "number") throw new Error("missing total_generations");
if (!res._body.timestamp) throw new Error("missing timestamp");
});
await test("GET /api/stats with STATS_KEY set → 401 without token", async () => {
process.env.STATS_KEY = "secret123";
delete require.cache[require.resolve(path.join(proxyDir, "api/stats.js"))];
const fresh = require(path.join(proxyDir, "api/stats.js"));
const res = mockRes();
await fresh(mockReq("GET"), res);
expect(res._status, "status").toBe(401);
delete process.env.STATS_KEY;
});
await test("GET /api/stats with correct STATS_KEY → 200", async () => {
process.env.STATS_KEY = "secret123";
delete require.cache[require.resolve(path.join(proxyDir, "api/stats.js"))];
const fresh = require(path.join(proxyDir, "api/stats.js"));
const res = mockRes();
await fresh(mockReq("GET", {}, { authorization: "Bearer secret123" }), res);
expect(res._status, "status").toBe(200);
delete process.env.STATS_KEY;
});
await test("POST /api/stats → 405", async () => {
const res = mockRes();
await statsHandler(mockReq("POST"), res);
expect(res._status, "status").toBe(405);
});
// ── Summary ───────────────────────────────────────────────────────────────
console.log(`\n═══ Results: ${passed} passed, ${failed} failed ═══\n`);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(e => { console.error("Unhandled:", e); process.exit(1); });
#!/usr/bin/env node
/**
* videoagent-video-studio — generate.js
* CLI for text-to-video and image-to-video via a hosted proxy.
* Usage:
* node generate.js --mode text-to-video --prompt "<text>" [options]
* node generate.js --mode image-to-video --prompt "<motion>" --image-url "<url>" [options]
*/
import { parseArgs } from "util";
const PROXY_BASE = process.env.VIDEO_STUDIO_PROXY_URL || "https://video-gen-proxy.vercel.app";
const TOKEN = process.env.VIDEO_STUDIO_TOKEN || "";
const { values: args } = parseArgs({
options: {
mode: { type: "string", default: "text-to-video" },
prompt: { type: "string", default: "" },
"image-url": { type: "string", default: "" },
duration: { type: "string", default: "5" },
"aspect-ratio": { type: "string", default: "16:9" },
model: { type: "string", default: "" },
"list-models": { type: "boolean", default: false },
status: { type: "boolean", default: false },
"job-id": { type: "string", default: "" },
},
strict: false,
});
function output(data) {
console.log(JSON.stringify(data, null, 2));
}
function error(msg, details = null) {
console.error(JSON.stringify({ success: false, error: msg, details }, null, 2));
process.exit(1);
}
async function getToken() {
if (TOKEN) return TOKEN;
try {
const res = await fetch(`${PROXY_BASE}/api/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.token) return null;
return data.token;
} catch {
return null;
}
}
async function listModels() {
try {
const res = await fetch(`${PROXY_BASE}/api/generate`);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
error(data.error || "Failed to fetch models", data);
}
const info = {
service: data.service,
version: data.version,
status: data.status,
modes: data.modes,
models: data.models || [],
};
output(info);
} catch (err) {
error("Could not reach proxy. Is VIDEO_STUDIO_PROXY_URL set?", err.message);
}
}
async function checkStatus(jobId) {
if (!jobId) error("--job-id is required when using --status.");
const token = await getToken();
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
try {
const url = new URL("/api/status", PROXY_BASE);
url.searchParams.set("jobId", jobId);
const res = await fetch(url.toString(), { headers });
const data = await res.json().catch(() => ({}));
output({
success: res.ok && data.success !== false,
jobId,
status: data.status,
videoUrl: data.videoUrl,
error: data.error,
message: data.message,
});
if (!res.ok && res.status >= 400) process.exit(1);
} catch (err) {
error("Status check failed", err.message);
}
}
async function main() {
if (args["list-models"]) {
await listModels();
return;
}
if (args.status) {
await checkStatus(args["job-id"]);
return;
}
const mode = args.mode === "image-to-video" ? "image-to-video" : "text-to-video";
if (!args.prompt) error("--prompt is required.");
if (mode === "image-to-video" && !args["image-url"]) error("--image-url is required for image-to-video.");
const token = await getToken();
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const body = {
mode,
prompt: args.prompt,
duration: parseInt(args.duration, 10) || 5,
aspectRatio: args["aspect-ratio"] || "16:9",
model: args.model || "auto",
};
if (mode === "image-to-video") body.imageUrl = args["image-url"];
try {
const res = await fetch(`${PROXY_BASE}/api/generate`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
error(data.message || "Video generation failed", data);
}
if (data.videoUrl) {
output({
success: true,
mode: data.mode || mode,
videoUrl: data.videoUrl,
duration: data.duration ?? body.duration,
aspectRatio: data.aspectRatio ?? body.aspectRatio,
jobId: data.jobId || undefined,
status: data.status || undefined,
});
} else if (data.jobId) {
output({
success: true,
mode: data.mode || mode,
jobId: data.jobId,
status: data.status || "pending",
message: "Generation started. Poll the proxy for result or check callback.",
});
} else {
error("Unexpected response from proxy", data);
}
} catch (err) {
error("Request failed. Is VIDEO_STUDIO_PROXY_URL set and the proxy running?", err.message);
}
}
main();
Related skills
How it compares
Pick videoagent-video-studio over direct provider SDKs when you want one proxy endpoint covering multiple video models without key management.
FAQ
Do I need API keys?
No. Requests go through a hosted proxy by default. Optionally set VIDEO_STUDIO_PROXY_URL and VIDEO_STUDIO_TOKEN for custom infrastructure.
What video durations are supported?
Typically 4-10 seconds; seedance supports 4-12 seconds. Duration is set per request via --duration parameter.
How do I choose between text-to-video and image-to-video?
Use text-to-video for prompt-only generation (no image). Use image-to-video when you have a static image to animate with motion descriptions.
Is Videoagent Video Studio safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.