
Civitai Gen
- 75 installs
- 16 repo stars
- Updated June 12, 2026
- civitai/civitai-gen-skill
Generates images, videos, audio, TTS, music, and transcriptions through Civitai's orchestration Workflow API with a unified submit-poll-download CLI.
About
This skill provides a unified CLI over Civitai's orchestration Workflow API for text-to-image, video across 11+ engines, TTS, music, transcription, batches, and buzz cost estimation. A developer uses it to run Civitai generations programmatically rather than browsing models.
- Shared workflow lifecycle: submit, poll, download across all media types
- Dry-run buzz cost estimation and 11+ video engine listing
Civitai Gen by the numbers
- 75 all-time installs (skills.sh)
- Ranked #835 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/civitai/civitai-gen-skill --skill civitai-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 75 |
|---|---|
| repo stars | ★ 16 |
| Last updated | June 12, 2026 |
| Repository | civitai/civitai-gen-skill ↗ |
What it does
Generates images, videos, audio, TTS, music, and transcriptions through Civitai's orchestration Workflow API with a unified submit-poll-download CLI.
Files
civitai-gen
Unified CLI for Civitai's orchestration Workflow API. All generation types share the same workflow lifecycle: submit, poll, download.
Setup
Requires Node.js 18+. Set CIVITAI_API_KEY either as an environment variable or in a .env file in this skill's directory (next to generate.mjs). Copy .env.example to .env to start. Get a key at: https://civitai.com/user/account
Paths below are relative to this skill's directory. Run the scripts from there (cd into it), or prefix with the install path your runtime uses.Capabilities
| Command | What it does | Details |
|---|---|---|
wait | Submit + poll + download (default) | All-in-one blocking generation |
submit | Fire-and-forget, returns workflow ID | For async workflows |
status | Check workflow progress | --poll for live updates |
download | Fetch completed media | From a workflow ID |
cost | Dry-run buzz estimation | whatif=true, 0 buzz spent |
engines | List video engines + live status | 11+ engines |
tts | Text-to-speech | See docs/tts.md |
music | Music/song generation (ACE Step 1.5) | See docs/music.md |
transcribe | Speech-to-text transcription | See docs/transcription.md |
Quick Examples
# Image (defaults to Flux.1, 4 images)
node generate.mjs wait --prompt "A knight at sunset" -o ./out
# Multiple concurrent prompts
node generate.mjs wait --prompt "A warrior" --prompt "A mage" -o ./out
# Video (VEO 3)
node generate.mjs wait --engine veo3 --prompt "A robot walking" -o ./out
# Text-to-speech
node generate.mjs tts --text "Hello world" --speaker serena -o ./out
# Music
node generate.mjs music --prompt "upbeat electronic dance track" -o ./out
# Transcription
node generate.mjs transcribe --media-url "https://example.com/audio.mp3" -o ./out
# Cost check (any type, 0 buzz)
node generate.mjs cost --prompt "A cat" -n 100
node generate.mjs cost --engine veo3 --prompt "A robot" --duration 8
# Experiment mode (wildcard expansion)
node experiment.mjs --spec experiment.json -o ./outPosting What You Generate
Generate an image (the skill saves it locally), then post the local file in one command:
node generate.mjs wait --prompt "a red apple" -n 1 -o ./out
node mcp-cli.mjs post-image ./out/step_0-0.png --title "My apple"
# -> Post published. URL: https://civitai.com/posts/<id>post-image reads the local file, uploads it, and publishes the post — it prints the public URL. Pull mcp-cli.mjs first if you don't have it (curl -fsSL https://mcp.civitai.com/cli -o mcp-cli.mjs); CIVITAI_API_KEY is required.
Flags: --title (defaults to the filename), --detail, --nsfw <level>, --draft (leave unpublished), --json. For video/audio, or to attach a pre-uploaded image by UUID, call create_post directly (node mcp-cli.mjs schema create_post) — and prefer posting the local file over the generate output's remoteUrls[].url, which is a short-lived signed URL that expires quickly.
Choosing an Engine & Model
Read `docs/engines.md` to pick the right generator. The key split:
| Path | Engines | How to pick the model |
|---|---|---|
| Open-weight ecosystem | SD1, SDXL, Pony, Illustrious, Flux.1/2, Qwen, Z-Image, Chroma, Anima | Find a checkpoint AIR + compatible LoRAs via the Civitai MCP (search_models / get_model_version) → --model / --resources |
| Closed API engine | OpenAI, Google/Gemini, Seedream, Grok, MAI, ERNIE + all video/audio | Engine name only — no checkpoint search, no LoRA |
Model discovery uses the Civitai MCP server (hosted at https://mcp.civitai.com/mcp) — call its tools to search checkpoints/LoRAs and get AIR URNs. Don't search inside this skill. A LoRA's base model must match the checkpoint's ecosystem.
If the Civitai MCP isn't connected, add it (browse tools need no API key): claude mcp add --transport http civitai https://mcp.civitai.com/mcp
Can't add MCP config in your runtime? Pull the zero-dep CLI (Node 18+) and call tools over the shell instead: curl -fsSL https://mcp.civitai.com/cli -o mcp-cli.mjs node mcp-cli.mjs call search_models '{"query":"anime portrait","type":"Checkpoint","supportsGeneration":true,"baseModel":"SDXL 1.0"}'
# Call these Civitai MCP tools directly (they return AIR URNs ready for --model / --resources):
search_models { query: "anime portrait", type: "Checkpoint", supportsGeneration: true, baseModel: "SDXL 1.0" }
search_models { query: "neon style", type: "LORA", baseModel: "SDXL 1.0" } # LoRA base MUST match checkpoint
get_model_version { ids: [<versionId>] } # full version details + AIR URN + trigger wordsEngine availability/params drift — run node generate.mjs engines for the live list and see <https://developer.civitai.com/orchestration/recipes> for per-engine parameters.
Domain-Specific Docs
For detailed parameters, read the relevant doc:
- Images:
--prompt,--model,--resources,--aspect,--resolution,--source-image(img2img). Runnode generate.mjs --helpfor full flag list. - Videos:
--engine,--duration,--video-aspect,--generate-audio. Runnode generate.mjs enginesfor available engines. - TTS: Read `docs/tts.md` — built-in speakers, voice cloning, style instructions.
- Music: Read `docs/music.md` — ACE Step 1.5, lyrics, duration.
- Transcription: Read `docs/transcription.md` — ASR, timestamps, language hints.
- Experiments: Read
experiment.mjs --help— wildcards, parameter sweeps, naming.
Workflow Lifecycle
All generation types follow the same pattern:
1. Build steps — each job becomes a workflow step with a $type (textToImage, videoGen, textToSpeech, aceStepAudio, transcription) 2. Submit — POST to orchestration API, get workflow ID 3. Poll — check status until terminal state (succeeded/failed/expired) 4. Download — fetch output media (images, videos, audio files) 5. JSON summary — clean JSON to stdout with paths, costs, and metadata
Use cost (whatif) to estimate buzz before spending. Use --quiet for agent-friendly output.
Guidelines
- Default to 4 images per prompt. Use
-n 1only when a single image is needed. - For video: always check
costfirst. Video costs 500-2000+ buzz per clip. - Multiple
--promptflags create concurrent steps in one workflow. - Use
--bulk file.jsonfor large batches. - Use experiment mode for systematic parameter sweeps.
Developer Guide
See CLAUDE.md for architecture, module layout, and how to add new step types.
# Civitai API Key
# Get yours at: https://civitai.com/user/account (API Keys section)
CIVITAI_API_KEY=your_api_key_here
.env
Engines & Model Selection
How to pick the right generator for a job. Two questions decide everything:
1. What medium? image / video / audio / text 2. Open-weight ecosystem or closed API engine? — this changes how you pick resources.
Source of truth: engine availability and parameters drift. This file is a curated snapshot for choosing. For the live list run node generate.mjs engines, and for authoritative per-engine parameters ("recipes") see <https://developer.civitai.com/orchestration/recipes> and <https://developer.civitai.com/llms.txt>.---
The two-path model (read this first)
Image generation splits into two paths. Getting this wrong is the #1 mistake.
| Path | Engines | How you pick the model | LoRAs |
|---|---|---|---|
| Open-weight ecosystem | SD1, SDXL, Pony, Illustrious, NoobAI, Flux.1, Flux.2, Qwen, Chroma, Z-Image, Anima, HiDream | Search Civitai for a checkpoint AIR URN + compatible LoRAs. Pass via --model / --resources. | ✅ Yes — must match base ecosystem |
| Closed API engine | OpenAI (GPT-Image/DALL·E), Google (Imagen/Nano Banana), Gemini, Seedream, Grok, MAI Image, ERNIE | Engine name only. No checkpoint search. | ❌ No (engine-baked) |
Video and audio are always engine-only — pick an engine, not a checkpoint. Some video engines accept LoRAs (see table).
Picking checkpoints & LoRAs (open-weight path)
Use the Civitai MCP server — it owns model discovery. Do not search inside civitai-gen. If it's not connected: claude mcp add --transport http civitai https://mcp.civitai.com/mcp (browse tools need no API key). No way to edit MCP config in your runtime? Pull the CLI: curl -fsSL https://mcp.civitai.com/cli -o mcp-cli.mjs then node mcp-cli.mjs call search_models '{...}'.
# Call these Civitai MCP tools (hosted at https://mcp.civitai.com/mcp):
search_models { query: "anime portrait", type: "Checkpoint", supportsGeneration: true, baseModel: "SDXL 1.0" }
search_models { query: "neon style", type: "LORA", baseModel: "SDXL 1.0" } # MUST match checkpoint base
get_model_version { ids: [<versionId>] } # AIR URN + trigger wordsCompatibility rule: a LoRA's base model must match the checkpoint's ecosystem. SDXL LoRA ≠ Flux checkpoint.
The same Civitai MCP also posts what you generate: node mcp-cli.mjs post-image <local png> (see the "Posting What You Generate" section in SKILL.md).---
Image — which engine, and why
Open-weight (checkpoint + LoRA via the Civitai MCP)
| Ecosystem | Prefer for | Notes |
|---|---|---|
| Flux.1 | Default. Strong prompt-following, realism, text rendering | Platform default ecosystem |
| Flux.2 | Higher fidelity than Flux.1, newer | Klein default; Dev/Flex/Pro/Max tiers |
| SDXL | Huge LoRA/community ecosystem, versatile, cheap | 1024². DreamShaper etc. |
| Pony / Illustrious / NoobAI | Anime/stylized/character work | SDXL-family; match LoRA to the exact base |
| SD1 | Legacy, fastest/cheapest, niche LoRAs | 512² only |
| Qwen | Strong text-in-image, editing/variants | ~1328² |
| Z-Image | Lightweight, fast text-to-image | turbo (default) / base |
| Chroma / Anima / HiDream | Specialty ecosystems (Anima = anime-tuned w/ LoRA) | smaller communities |
Closed API engines (engine name, no LoRA)
| Engine | Prefer for | Notes |
|---|---|---|
| Seedream | Max native resolution (up to 4096), editing | ByteDance |
| Google (Imagen / Nano Banana) | Editing, web-search grounding, photoreal | also via nano-banana skill |
| Gemini | 2.5 Flash Image, fast multimodal | direct API |
| OpenAI | GPT-Image 1/1.5, DALL·E 2/3 | hosted |
| Grok | 21 aspect ratios, editing | xAI |
| MAI Image 2.5 | Flat pricing, 11 aspect ratios | Microsoft |
| ERNIE | standard / turbo | Baidu |
Default recommendation: start with Flux.1 for general work, SDXL/Pony/Illustrious when the user wants community LoRAs or anime, a closed engine (Seedream/Google) when they want max resolution or built-in editing.
---
Video — which engine, and why
Always run cost before video — clips run 500–2000+ buzz. Engine ids are the --engine value.
| Engine id | Prefer for | Audio | LoRA | img2vid | Notes |
|---|---|---|---|---|---|
veo3 | Realism + synchronized audio, premium | ✅ | ✅ | ✅ | Veo 3.0/3.1; std/fast/lite tiers. PG-only |
wan | Best LoRA support, flexible, cost-effective | — | ✅ | ✅ | v2.1–2.7; interpolation |
kling | Camera control, strong motion | — | — | ✅ | v1.6/v2/v2.5-turbo; V3 multi-prompt |
ltx2 | Style transfer, talking-head, has audio | ✅ | — | ✅ | Lightricks LTX2/2.3 |
hunyuan | Quality + LoRA, but compute-heavy | — | ✅ | — | Tencent |
vidu | Anime style, reference-to-video | — | — | ✅ | Vidu 2.0 / Q3 turbo |
grok | xAI Grok-Imagine, edit-video | — | — | ✅ | 480p/720p, per-second pricing |
happyHorse | Multi-character reference, video editing | edit only | — | ✅ | Alibaba via FAL; 720p/1080p |
sora | OpenAI Sora 2, pro mode | — | — | ✅ | 720p/1080p |
minimax | Hailuo, prompt enhancer | — | — | ✅ | |
haiper, mochi, lightricks | Older/cheaper options | — | — | varies | verify live status |
Quick picks:
- Need sound →
veo3(best) orltx2 - Need LoRA (custom character/style) →
wanorhunyuan - Need camera moves →
kling - Anime →
vidu - From a still image (img2vid) → most support it;
wan/kling/veo3are solid
---
Audio
| Subcommand | Engine | Prefer for |
|---|---|---|
tts | text-to-speech | Narration; built-in speakers, style instructions, voice cloning. See tts.md |
music | ACE-Step | Full songs from a style description; 2B turbo / 4B XL. See music.md |
transcribe | Qwen3-ASR | Speech→text, word-level timestamps, multilingual. See transcription.md |
Multi-speaker dialogue (debates/interviews) exists as a recipe but is not yet a skill subcommand — see roadmap in CLAUDE.md.
---
Not yet wired into this skill
Documented job types the orchestrator supports but the CLI doesn't build yet (see CLAUDE.md roadmap): chat completion, prompt enhancement, image conversion, image/video upscaling, frame interpolation, compose-media, multi-speaker dialogue, and LoRA training. Recipes: <https://developer.civitai.com/orchestration/recipes>.
Music Generation (ACE Step 1.5)
Generate full songs and music from text descriptions and structured lyrics.
Quick Start
# Simple music from text description
node generate.mjs music --prompt "upbeat electronic dance track with synths" -o ./out
# With structured lyrics
node generate.mjs music --prompt "acoustic folk ballad" \
--lyrics "[verse] Walking through the morning light..." \
--duration 30 -o ./out
# With specific model
node generate.mjs music --prompt "lo-fi hip hop beat" \
--model "urn:air:..." --duration 60 -o ./outParameters
| Flag | Required | Description |
|---|---|---|
--prompt <text> | yes | Text description of the music to generate |
--lyrics <text> | no | Structured lyrics for the song (use [verse], [chorus] tags) |
--duration <sec> | no | Duration in seconds |
--model <air> | no | Model identifier in AIR format |
Output
Music outputs a single .mp3 file per step. The JSON summary includes an audio array with paths.
Notes
- Use descriptive prompts: genre, instruments, tempo, mood
- Structured lyrics use tags like
[verse],[chorus],[bridge]to guide song structure - Duration controls the length of the generated audio
Transcription / Speech-to-Text (STT)
Transcribe audio/video files to text using automatic speech recognition (ASR).
Quick Start
# From a local file (auto-uploads to blob)
node generate.mjs stt recording.mp3 -o ./out
# From a URL
node generate.mjs transcribe --media-url "https://example.com/audio.mp3" -o ./out
# Record from microphone (10 seconds)
node generate.mjs stt --mic 10 -o ./out
# WAV files are auto-converted to MP3 before upload
node generate.mjs stt interview.wav -o ./out
# With language hint and context
node generate.mjs stt recording.mp3 \
--language en --context "Technical podcast about machine learning" -o ./out
# With word-level timestamps
node generate.mjs stt recording.mp3 --timestamps -o ./outParameters
| Flag | Required | Description |
|---|---|---|
| positional arg | no | Local audio file path (auto-uploaded) |
--media-file <path> | no | Local audio file path (explicit flag) |
--media-url <url> | no | URL or AIR URN of the audio/media file |
--mic <seconds> | no | Record from microphone first (default: 10s) |
--language <lang> | no | Language hint (e.g. "en") — improves accuracy |
--context <text> | no | Context about the audio (e.g. "Technical podcast about AI") |
--timestamps | no | Return word-level timestamps |
One of positional arg, --media-file, --media-url, or --mic is required.
Commands
Both transcribe and stt work as command aliases.
Output
Transcription does not produce downloadable files. Instead, the JSON summary includes a transcriptions array:
{
"transcriptions": [
{
"stepIndex": 0,
"text": "The full transcribed text...",
"segments": [
{ "text": "The", "start": 0.0, "end": 0.2 },
{ "text": "full", "start": 0.2, "end": 0.5 }
]
}
]
}Segments with timestamps are only included when --timestamps is used.
Notes
- Supports MP3, OGG, FLAC formats directly. WAV files are auto-converted to MP3.
- Language hint is optional but improves accuracy for non-English content
- Context helps the model with domain-specific vocabulary
- Mic recording requires ffmpeg with dshow (Windows), avfoundation (macOS), or alsa (Linux)
- Cost: 1 buzz per transcription
Text-to-Speech (TTS)
Synthesize speech from text using Civitai's orchestration API. Two modes: built-in speakers (CustomVoice) or voice cloning from reference audio (Base).
Quick Start
# Built-in speaker
node generate.mjs tts --text "Hello world" --speaker serena -o ./out
# With style instruction
node generate.mjs tts --text "Welcome to Civitai" --speaker dylan \
--instruct "cheerful and enthusiastic" -o ./out
# Voice cloning from reference audio
node generate.mjs tts --text "Cloned speech" --ref-audio "https://example.com/voice.wav" -o ./out
# Voice cloning with transcript (improves quality)
node generate.mjs tts --text "Cloned speech" \
--ref-audio "urn:air:other:other:orchestrator:blob@sample.wav" \
--ref-text "This is the transcript of the reference audio" -o ./out
# Speaker embedding only (no reference transcript needed)
node generate.mjs tts --text "Using embedding" --ref-audio "https://..." --x-vector-only -o ./outParameters
CustomVoice Mode (built-in speakers)
| Flag | Required | Description |
|---|---|---|
--text <text> | yes | Text to synthesize |
--speaker <name> | yes | Built-in speaker: aiden, dylan, eric, ono_anna, ryan, serena, sohee, uncle_fu, vivian |
--language <lang> | no | Language (default: "English") |
--instruct <text> | no | Style/tone instruction (e.g. "cheerful and enthusiastic", "whisper softly") |
Base Mode (voice cloning)
| Flag | Required | Description |
|---|---|---|
--text <text> | yes | Text to synthesize |
--ref-audio <url> | yes | URL or AIR URN of reference audio file |
--ref-text <text> | no | Transcript of the reference audio (improves cloning quality) |
--x-vector-only | no | Use speaker embedding only — no reference transcript needed |
--language <lang> | no | Language (default: "English") |
Output
TTS outputs a single .ogg file per step. The JSON summary includes an audio array with paths to downloaded files.
Notes
- The mode is auto-detected: if
--ref-audiois provided, Base (voice cloning) mode is used; otherwise CustomVoice mode requires--speaker. - Style instructions (
--instruct) only work in CustomVoice mode. - Reference audio should be a clean recording of the target voice (ideally 5-30 seconds).
- AIR URN format for blobs:
urn:air:other:other:orchestrator:blob@filename.wav
#!/usr/bin/env node
// civitai-gen experiment — Template expansion and parameter sweeps via generate.mjs (workflow-based)
// Wraps generate.mjs: expands wildcards → bulk JSON → generate (workflow API) → download with meaningful names
// Zero npm dependencies. Requires Node 18+ (native fetch).
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync, unlinkSync, readdirSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const GENERATE_SCRIPT = join(__dirname, 'generate.mjs');
const WILDCARDS_DIR = join(__dirname, 'wildcards');
// ---------------------------------------------------------------------------
// Spec loading
// ---------------------------------------------------------------------------
function loadSpec(specPath) {
const raw = readFileSync(resolve(specPath), 'utf-8');
return JSON.parse(raw);
}
// ---------------------------------------------------------------------------
// Wildcard resolution
// ---------------------------------------------------------------------------
// Wildcard values can be:
// - Array of strings/numbers: ["blue", "red", 0.75]
// - Array of named objects: [{ "name": "spiky", "value": "male with spiky hair" }]
// - String starting with "@": "@characters.json" or "@colors.txt" (file reference)
function resolveWildcard(wildcard, specDir) {
if (typeof wildcard === 'string' && wildcard.startsWith('@')) {
const ref = wildcard.slice(1);
// 1. Try as relative path from specDir
const relPath = resolve(specDir, ref);
if (existsSync(relPath)) {
return loadWildcardFile(relPath);
}
// 2. Try as short name from wildcards registry
const name = ref.replace(/\.(json|txt)$/, ''); // strip extension if given
const jsonPath = join(WILDCARDS_DIR, `${name}.json`);
const txtPath = join(WILDCARDS_DIR, `${name}.txt`);
if (existsSync(jsonPath)) return loadWildcardFile(jsonPath);
if (existsSync(txtPath)) return loadWildcardFile(txtPath);
// 3. Not found — list available wildcards
const available = listRegisteredWildcards();
throw new Error(
`Wildcard "@${ref}" not found.\n` +
` Checked: ${relPath}\n` +
` Registry: ${WILDCARDS_DIR}\n` +
(available.length > 0 ? ` Available: ${available.join(', ')}` : ' No wildcards registered.')
);
}
if (Array.isArray(wildcard)) return wildcard;
throw new Error(`Invalid wildcard value: ${JSON.stringify(wildcard)}`);
}
function loadWildcardFile(filePath) {
const content = readFileSync(filePath, 'utf-8');
if (filePath.endsWith('.json')) {
return JSON.parse(content);
}
// Plain text: one value per line
return content.split('\n').map(l => l.trim()).filter(Boolean);
}
function listRegisteredWildcards() {
try {
const files = readdirSync(WILDCARDS_DIR);
return files
.filter(f => f.endsWith('.json') || f.endsWith('.txt'))
.map(f => f.replace(/\.(json|txt)$/, ''));
} catch {
return [];
}
}
// Normalize a wildcard entry to { name, value }
// Supports multiple field conventions:
// { name, value } — standard experiment format
// { id, promptFragment } — civitai character format
// { name, text } — alternative
// { id, value } — alternative
// plain string/number — used as both name and value
function normalizeEntry(entry) {
if (entry != null && typeof entry === 'object') {
const name = entry.name ?? entry.id ?? null;
const value = entry.value ?? entry.promptFragment ?? entry.text ?? null;
if (name != null && value != null) {
return { name: String(name), value: String(value) };
}
// If only one field found, use it for both
if (name != null) return { name: String(name), value: String(name) };
if (value != null) return { name: String(value), value: String(value) };
// Fallback: stringify the object
return { name: JSON.stringify(entry), value: JSON.stringify(entry) };
}
return { name: String(entry), value: String(entry) };
}
// ---------------------------------------------------------------------------
// Expansion: cartesian product, zip, or random
// ---------------------------------------------------------------------------
function expandProduct(wildcardArrays) {
// wildcardArrays: [{ key, entries: [{ name, value }] }, ...]
if (wildcardArrays.length === 0) return [{}];
let combos = [{}];
for (const { key, entries } of wildcardArrays) {
const next = [];
for (const combo of combos) {
for (const entry of entries) {
next.push({ ...combo, [key]: entry });
}
}
combos = next;
}
return combos;
}
function expandZip(wildcardArrays) {
if (wildcardArrays.length === 0) return [{}];
const len = wildcardArrays[0].entries.length;
for (const { key, entries } of wildcardArrays) {
if (entries.length !== len) {
throw new Error(
`Zip expansion requires all wildcards to have the same length. ` +
`"${key}" has ${entries.length}, expected ${len}.`
);
}
}
const combos = [];
for (let i = 0; i < len; i++) {
const combo = {};
for (const { key, entries } of wildcardArrays) {
combo[key] = entries[i];
}
combos.push(combo);
}
return combos;
}
function expandRandom(wildcardArrays, count) {
if (wildcardArrays.length === 0) return [{}];
const combos = [];
for (let i = 0; i < count; i++) {
const combo = {};
for (const { key, entries } of wildcardArrays) {
combo[key] = entries[Math.floor(Math.random() * entries.length)];
}
combos.push(combo);
}
return combos;
}
function expand(wildcardArrays, mode) {
if (!mode || mode === 'product') return expandProduct(wildcardArrays);
if (mode === 'zip') return expandZip(wildcardArrays);
const randomMatch = mode.match(/^random:(\d+)$/);
if (randomMatch) return expandRandom(wildcardArrays, parseInt(randomMatch[1], 10));
throw new Error(`Unknown expansion mode: "${mode}". Use: product, zip, random:N`);
}
// ---------------------------------------------------------------------------
// Template substitution
// ---------------------------------------------------------------------------
function substitute(template, combo) {
if (template == null) return null;
let result = String(template);
for (const [key, entry] of Object.entries(combo)) {
// Replace all occurrences of {key} with the entry's value
result = result.replaceAll(`{${key}}`, entry.value);
}
return result;
}
// ---------------------------------------------------------------------------
// Filename generation
// ---------------------------------------------------------------------------
function sanitizeFilename(str) {
return str
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
function buildFilename(namingTemplate, combo, imageIndex, format) {
let name = namingTemplate;
for (const [key, entry] of Object.entries(combo)) {
name = name.replaceAll(`{${key}}`, sanitizeFilename(entry.name));
}
const ext = format === 'jpeg' ? 'jpeg' : 'png';
return imageIndex != null ? `${name}-${imageIndex}.${ext}` : name;
}
// ---------------------------------------------------------------------------
// Build expanded jobs from spec
// ---------------------------------------------------------------------------
function buildExpandedJobs(spec, specDir) {
const template = spec.template;
if (!template || !template.prompt) {
throw new Error('Spec must have a "template" with at least a "prompt" field.');
}
// Resolve all wildcards
const wildcardArrays = [];
for (const [key, raw] of Object.entries(spec.wildcards || {})) {
const resolved = resolveWildcard(raw, specDir);
const entries = resolved.map(normalizeEntry);
wildcardArrays.push({ key, entries });
}
// Expand into combinations
const combos = expand(wildcardArrays, spec.expansion);
// Build a bulk entry for each combination
const jobs = combos.map((combo) => {
const entry = {};
// Substitute wildcards into all template string fields
for (const [field, val] of Object.entries(template)) {
if (typeof val === 'string') {
entry[field] = substitute(val, combo);
} else {
entry[field] = val;
}
}
return { entry, combo };
});
return jobs;
}
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = argv.slice(2);
const opts = {
spec: null,
output: './output',
concurrency: null, // null = use spec value or default 5
dryRun: false,
preview: null,
help: false,
// Inline mode
template: null,
wildcards: [], // collected as ["key=val1,val2", ...]
model: null,
resources: null,
negativePrompt: null,
quantity: null,
aspect: null,
resolution: null,
steps: null,
cfgScale: null,
format: 'png',
naming: null,
expansion: null,
listWildcards: false,
saveWildcardName: null,
saveWildcardValues: null,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = () => {
i++;
if (i >= args.length) { console.error(`Missing value for ${arg}`); process.exit(1); }
return args[i];
};
switch (arg) {
case '--help': case '-h': opts.help = true; break;
case '--spec': opts.spec = next(); break;
case '--output': case '-o': opts.output = next(); break;
case '--concurrency': opts.concurrency = parseInt(next(), 10); break;
case '--dry-run': opts.dryRun = true; break;
case '--preview': opts.preview = parseInt(next(), 10); break;
case '--template': opts.template = next(); break;
case '--wildcard': opts.wildcards.push(next()); break;
case '--model': opts.model = next(); break;
case '--resources': opts.resources = next(); break;
case '--negative-prompt': opts.negativePrompt = next(); break;
case '--quantity': case '-n': opts.quantity = parseInt(next(), 10); break;
case '--aspect': opts.aspect = next(); break;
case '--resolution': opts.resolution = next(); break;
case '--steps': opts.steps = parseInt(next(), 10); break;
case '--cfg-scale': opts.cfgScale = parseFloat(next()); break;
case '--format': opts.format = next(); break;
case '--naming': opts.naming = next(); break;
case '--expansion': opts.expansion = next(); break;
case '--list-wildcards': opts.listWildcards = true; break;
case '--save-wildcard': opts.saveWildcardName = next(); opts.saveWildcardValues = next(); break;
default: console.error(`Unknown flag: ${arg}`); process.exit(1);
}
}
return opts;
}
// Build spec from inline CLI args
function specFromCli(opts) {
if (!opts.template) {
console.error('Error: --spec or --template is required. Run with --help for usage.');
process.exit(1);
}
const template = { prompt: opts.template };
if (opts.model) template.model = opts.model;
if (opts.resources) template.resources = opts.resources;
if (opts.negativePrompt) template.negativePrompt = opts.negativePrompt;
if (opts.quantity) template.quantity = opts.quantity;
if (opts.aspect) template.aspect = opts.aspect;
if (opts.resolution) template.resolution = opts.resolution;
if (opts.steps) template.steps = opts.steps;
if (opts.cfgScale) template.cfgScale = opts.cfgScale;
if (opts.format) template.format = opts.format;
const wildcards = {};
for (const wc of opts.wildcards) {
const eqIdx = wc.indexOf('=');
if (eqIdx === -1) {
console.error(`Invalid wildcard format: "${wc}". Use: key=val1,val2,...`);
process.exit(1);
}
const key = wc.slice(0, eqIdx);
const valStr = wc.slice(eqIdx + 1);
// Check if it's a file reference
if (valStr.startsWith('@')) {
wildcards[key] = valStr;
} else {
wildcards[key] = valStr.split(',').map(v => v.trim()).filter(Boolean);
}
}
return {
template,
wildcards,
expansion: opts.expansion || 'product',
naming: opts.naming || null,
concurrency: opts.concurrency || 5,
};
}
// ---------------------------------------------------------------------------
// Help text
// ---------------------------------------------------------------------------
function printHelp() {
const text = `
civitai-gen experiment — Template expansion and parameter sweeps via generate.mjs (workflow-based)
USAGE
# From a spec file
node experiment.mjs --spec avatars.json -o ./out
# Inline wildcards
node experiment.mjs \\
--template "Burton Style, {color} theme, {character}" \\
--wildcard color=blue,red,green \\
--wildcard character=@characters.txt \\
--model "urn:air:sdxl:checkpoint:civitai:827184@2514310" \\
--resources "urn:air:sdxl:lora:civitai:185166@207862:{weight}" \\
--wildcard weight=0.75,1.0,1.25 \\
--naming "{color}-{character}-w{weight}" \\
-n 2 -o ./out
# Dry run (show expansion count and sample prompts)
node experiment.mjs --spec avatars.json --dry-run
# Preview first N expanded prompts
node experiment.mjs --spec avatars.json --preview 5
SPEC FILE FORMAT
{
"template": {
"prompt": "Style, {color} theme, {character}",
"negativePrompt": "blurry, low quality",
"model": "urn:air:sdxl:checkpoint:civitai:...",
"resources": "urn:air:sdxl:lora:civitai:...:civitai:123@456:{weight}",
"quantity": 2,
"aspect": "square",
"steps": 25,
"cfgScale": 7
},
"wildcards": {
"color": ["blue", "red", "green"],
"character": [
{ "name": "spiky-male", "value": "male with spiky undercut, angular jaw" },
{ "name": "pixie-female", "value": "female with pixie cut, sharp cheekbones" }
],
"weight": [0.75, 1.0, 1.25]
},
"expansion": "product",
"naming": "{color}-{character}-w{weight}",
"concurrency": 5
}
WILDCARD VALUES
Simple array: ["blue", "red", "green"]
Named objects: [{ "name": "alias", "value": "full text for prompt" }]
File reference: "@characters.json" or "@values.txt" (one per line)
Numbers: [0.75, 1.0, 1.25] (for parameter sweeps)
Named objects use "name" for filenames and "value" for prompt substitution.
Simple values use the value itself for both.
EXPANSION MODES
product Cartesian product of all wildcards (default). 3×4×2 = 24 combos.
zip Parallel zip. All wildcards must have the same length.
random:N N random combinations sampled from all wildcards.
NAMING TEMPLATE
Uses wildcard keys as variables: "{color}-{character}-w{weight}"
Names are sanitized for filenames (lowercase, alphanumeric + hyphens).
Image index is appended automatically: "blue-spiky-male-w1-0.png"
When omitted, defaults to joining all wildcard names with hyphens.
FLAGS
--spec <file> Path to experiment spec JSON file
--output, -o Output directory (default: ./output)
--concurrency <n> Max parallel API requests (overrides spec)
--dry-run Show expansion stats without generating
--preview <n> Show first N expanded prompts without generating
--help, -h Show this help message
WILDCARD REGISTRY
Wildcards are stored in the wildcards/ directory next to this script
Reference by short name with @: --wildcard "color=@neon-colors"
--list-wildcards List all registered wildcards with previews
--save-wildcard <name> <values>
Save a wildcard list to the registry
Values: comma-separated or @file.json/@file.txt
Inline mode (alternative to --spec):
--template <prompt> Prompt template with {wildcards}
--wildcard <k=v,v> Define a wildcard (repeatable)
--model <urn> Checkpoint model AIR URN
--resources <str> Resources string (can contain {wildcards})
--negative-prompt Negative prompt
--quantity, -n Images per combo (default: 1)
--aspect Aspect ratio preset
--resolution Resolution scale
--steps Sampling steps
--cfg-scale CFG scale
--format Output format: png or jpeg
--naming Filename template
--expansion Expansion mode
OUTPUT
Images are saved with meaningful names based on the naming template.
A manifest.json is written alongside with the full mapping.
Prints a JSON summary to stdout when complete (includes workflowId).
`.trimStart();
console.log(text);
}
// ---------------------------------------------------------------------------
// Run generate.mjs as subprocess
// ---------------------------------------------------------------------------
function runGenerate(bulkFile, outDir) {
return new Promise((resolve, reject) => {
const args = [GENERATE_SCRIPT, 'wait', '--bulk', bulkFile, '-o', outDir, '--quiet'];
const proc = spawn(process.execPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
proc.stdout.on('data', (d) => { stdout += d.toString(); });
proc.stderr.on('data', (d) => { process.stderr.write(d); });
proc.on('close', (code) => {
let summary = null;
try {
summary = JSON.parse(stdout.trim());
} catch {}
resolve({ code, summary });
});
proc.on('error', reject);
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs(process.argv);
if (opts.help) {
printHelp();
process.exit(0);
}
// --- List wildcards ---
if (opts.listWildcards) {
const wildcards = listRegisteredWildcards();
if (wildcards.length === 0) {
console.log('No wildcards registered.');
console.log(`Registry directory: ${WILDCARDS_DIR}`);
} else {
console.log(`Registered wildcards (${WILDCARDS_DIR}):\n`);
for (const name of wildcards) {
// Show count of values
const jsonPath = join(WILDCARDS_DIR, `${name}.json`);
const txtPath = join(WILDCARDS_DIR, `${name}.txt`);
const filePath = existsSync(jsonPath) ? jsonPath : txtPath;
try {
const values = loadWildcardFile(filePath);
const preview = values.slice(0, 5).map(v =>
typeof v === 'object' ? (v.name || v.id || JSON.stringify(v)) : String(v)
);
const more = values.length > 5 ? `, ... (+${values.length - 5} more)` : '';
console.log(` @${name} (${values.length} values): ${preview.join(', ')}${more}`);
} catch {
console.log(` @${name} (error reading)`);
}
}
}
process.exit(0);
}
// --- Save wildcard ---
if (opts.saveWildcardName) {
mkdirSync(WILDCARDS_DIR, { recursive: true });
const name = opts.saveWildcardName;
const valuesArg = opts.saveWildcardValues;
let destPath;
if (valuesArg.startsWith('@')) {
// Copy from file
const srcPath = resolve(valuesArg.slice(1));
if (!existsSync(srcPath)) {
console.error(`Source file not found: ${srcPath}`);
process.exit(1);
}
const ext = srcPath.endsWith('.json') ? '.json' : '.txt';
destPath = join(WILDCARDS_DIR, `${name}${ext}`);
writeFileSync(destPath, readFileSync(srcPath));
} else {
// Comma-separated values → txt file
const values = valuesArg.split(',').map(v => v.trim()).filter(Boolean);
destPath = join(WILDCARDS_DIR, `${name}.txt`);
writeFileSync(destPath, values.join('\n') + '\n');
}
console.log(`Saved wildcard "@${name}" → ${destPath}`);
// Show the values
const loaded = loadWildcardFile(destPath);
console.log(` ${loaded.length} values: ${loaded.slice(0, 8).join(', ')}${loaded.length > 8 ? '...' : ''}`);
process.exit(0);
}
// Build spec from file or CLI
let spec;
let specDir = process.cwd();
if (opts.spec) {
spec = loadSpec(opts.spec);
specDir = dirname(resolve(opts.spec));
} else {
spec = specFromCli(opts);
}
// Override concurrency from CLI if provided
const concurrency = opts.concurrency ?? spec.concurrency ?? 5;
// Expand wildcards into job list
const jobs = buildExpandedJobs(spec, specDir);
const quantity = spec.template.quantity ?? 1;
const format = spec.template.format ?? opts.format ?? 'png';
const totalImages = jobs.length * quantity;
// Build naming template (default: join all wildcard keys)
const wildcardKeys = Object.keys(spec.wildcards || {});
const namingTemplate = opts.naming ?? spec.naming ??
(wildcardKeys.length > 0 ? wildcardKeys.map(k => `{${k}}`).join('-') : 'img');
// --- Dry run ---
if (opts.dryRun) {
console.log('=== DRY RUN ===');
console.log(`Wildcards: ${wildcardKeys.join(', ') || '(none)'}`);
for (const key of wildcardKeys) {
const resolved = resolveWildcard(spec.wildcards[key], specDir);
console.log(` ${key}: ${resolved.length} values`);
}
console.log(`Expansion: ${spec.expansion || 'product'}`);
console.log(`Combinations: ${jobs.length}`);
console.log(`Quantity per combo: ${quantity}`);
console.log(`Total images: ${totalImages}`);
console.log(`Concurrency: ${concurrency}`);
console.log(`Naming: ${namingTemplate}-{i}.${format === 'jpeg' ? 'jpeg' : 'png'}`);
if (jobs.length > 0) {
console.log('\nSample prompt (first combo):');
console.log(` ${jobs[0].entry.prompt.slice(0, 120)}...`);
console.log(`Sample filename: ${buildFilename(namingTemplate, jobs[0].combo, 0, format)}`);
}
process.exit(0);
}
// --- Preview ---
if (opts.preview != null) {
const count = Math.min(opts.preview, jobs.length);
console.log(`=== PREVIEW (${count} of ${jobs.length} combos, ${totalImages} total images) ===\n`);
for (let i = 0; i < count; i++) {
const { entry, combo } = jobs[i];
const fname = buildFilename(namingTemplate, combo, 0, format);
console.log(`[${i}] ${fname}`);
console.log(` prompt: ${entry.prompt.slice(0, 100)}${entry.prompt.length > 100 ? '...' : ''}`);
if (entry.resources) console.log(` resources: ${entry.resources}`);
console.log();
}
console.log(`... ${jobs.length} total combos × ${quantity} quantity = ${totalImages} images`);
process.exit(0);
}
// --- Generate ---
console.error(`Experiment: ${jobs.length} combos × ${quantity} qty = ${totalImages} images`);
console.error(`Output: ${resolve(opts.output)}
`);
// Write bulk JSON with labels from naming template
const outDir = resolve(opts.output);
mkdirSync(outDir, { recursive: true });
const bulkEntries = jobs.map(j => {
const entry = { ...j.entry };
entry.label = buildFilename(namingTemplate, j.combo, null, format);
return entry;
});
const bulkFile = join(outDir, '.experiment-bulk.json');
writeFileSync(bulkFile, JSON.stringify(bulkEntries, null, 2));
// Write manifest
const manifest = jobs.map((j, i) => ({
jobIndex: i,
wildcards: Object.fromEntries(
Object.entries(j.combo).map(([k, v]) => [k, { name: v.name, value: v.value }])
),
filename: buildFilename(namingTemplate, j.combo, null, format),
prompt: j.entry.prompt,
}));
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
const { code, summary } = await runGenerate(bulkFile, outDir);
// Clean up bulk file
try { unlinkSync(bulkFile); } catch {}
const failedCount = summary?.failedDownloads ?? summary?.failed ?? 0;
const result = {
success: code === 0 && failedCount === 0,
combos: jobs.length,
total: totalImages,
workflowId: summary?.workflowId ?? null,
images: summary?.images ?? [],
failed: failedCount,
};
console.log(JSON.stringify(result, null, 2));
process.exit(code);
}
main().catch((err) => {
console.error(`Fatal error: ${err.message}`);
process.exit(1);
});
#!/usr/bin/env node
// generate.mjs — Unified Civitai Orchestration CLI
//
// Subcommands:
// wait Submit, poll until done, then download (default for generation)
// submit Fire-and-forget workflow submission
// status Check workflow progress
// download Fetch completed media from a workflow
// cost Dry-run buzz estimation (whatif)
// engines List available video generation engines
// tts Text-to-speech generation
// music Music/song generation (ACE Step 1.5)
// transcribe Speech-to-text transcription (alias: stt)
//
// Domain logic split across lib/ modules:
// lib/api.mjs — Shared API layer (auth, workflows, downloads)
// lib/image.mjs — Image step builder, ecosystem configs
// lib/video.mjs — Video step builder, engine registry
// lib/audio.mjs — TTS, music, transcription step builders
//
// Zero npm dependencies. Requires Node 18+ (native fetch).
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, resolve } from 'path';
import { execFile as execFileCb, spawn } from 'child_process';
import {
loadEnv,
CIVITAI_API_URL,
WORKFLOWS_URL,
getApiKey,
authHeaders,
apiSubmitWorkflow,
apiWhatIf,
apiGetWorkflow,
downloadFile,
downloadAll,
pollWorkflow,
collectDownloads,
uploadBlob,
} from './lib/api.mjs';
import {
ECOSYSTEM_CONFIGS,
DEFAULT_ECOSYSTEM,
buildImageStep,
detectEcosystem,
IMAGE_ARG_HANDLERS,
IMAGE_HELP,
} from './lib/image.mjs';
import {
VIDEO_ENGINE_REGISTRY,
buildVideoStep,
VIDEO_ARG_HANDLERS,
VIDEO_HELP,
} from './lib/video.mjs';
import {
buildTTSStep,
buildMusicStep,
buildTranscriptionStep,
AUDIO_ARG_HANDLERS,
AUDIO_HELP,
} from './lib/audio.mjs';
loadEnv();
// ---------------------------------------------------------------------------
// Audio playback helpers
// ---------------------------------------------------------------------------
function playAudioFile(filePath) {
return new Promise((resolve) => {
execFileCb('ffplay', ['-nodisp', '-autoexit', '-loglevel', 'quiet', filePath], (err) => {
if (err) {
process.stderr.write(` Warning: Could not play audio (ffplay not found or failed): ${err.message}\n`);
}
resolve();
});
});
}
async function playAudioFiles(files) {
for (const f of files) {
process.stderr.write(` Playing: ${f}\n`);
await playAudioFile(f);
}
}
/**
* Stream a URL directly to ffplay and optionally save to disk.
* The streaming-blobs URL blocks until segments are ready, then streams in semi-realtime.
* Returns { firstByteMs, totalMs, savedPath? }
*/
async function streamAudioUrl(url, opts = {}) {
const { savePath, quiet } = opts;
const t0 = performance.now();
let firstByteMs = null;
const res = await fetch(url);
if (!res.ok) throw new Error(`Stream failed ${res.status}: ${url}`);
// We need to tee the stream: one to ffplay, one to save to disk
const reader = res.body.getReader();
const chunks = [];
// Spawn ffplay reading from stdin
const ffplay = spawn('ffplay', ['-nodisp', '-autoexit', '-loglevel', 'quiet', '-i', 'pipe:0'], {
stdio: ['pipe', 'ignore', 'ignore'],
});
const ffplayDone = new Promise((resolve) => {
ffplay.on('close', resolve);
ffplay.on('error', () => resolve());
});
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (firstByteMs === null) {
firstByteMs = Math.round(performance.now() - t0);
if (!quiet) process.stderr.write(` First byte: ${firstByteMs}ms\n`);
}
chunks.push(value);
// Feed to ffplay
if (!ffplay.stdin.destroyed) {
ffplay.stdin.write(value);
}
}
} finally {
if (!ffplay.stdin.destroyed) ffplay.stdin.end();
}
// Save to disk if requested
if (savePath) {
const fullBuf = Buffer.concat(chunks);
writeFileSync(savePath, fullBuf);
if (!quiet) process.stderr.write(` Saved: ${savePath} (${(fullBuf.byteLength / 1024).toFixed(1)}KB)\n`);
}
await ffplayDone;
const totalMs = Math.round(performance.now() - t0);
return { firstByteMs, totalMs, savedPath: savePath || null, size: chunks.reduce((s, c) => s + c.byteLength, 0) };
}
/**
* Stream a URL to disk only (no playback). Measures time-to-first-byte.
*/
async function streamToFile(url, savePath, quiet) {
const t0 = performance.now();
let firstByteMs = null;
const res = await fetch(url);
if (!res.ok) throw new Error(`Stream failed ${res.status}: ${url}`);
const reader = res.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (firstByteMs === null) {
firstByteMs = Math.round(performance.now() - t0);
if (!quiet) process.stderr.write(` First byte: ${firstByteMs}ms\n`);
}
chunks.push(value);
}
const fullBuf = Buffer.concat(chunks);
writeFileSync(savePath, fullBuf);
if (!quiet) process.stderr.write(` Saved: ${savePath} (${(fullBuf.byteLength / 1024).toFixed(1)}KB)\n`);
return { firstByteMs, totalMs: Math.round(performance.now() - t0), size: fullBuf.byteLength };
}
/**
* Submit TTS with ?wait=0 and extract the streaming blob URL.
* Returns { workflowId, streamUrl, cost, submitMs }
*/
async function submitTTSStreaming(apiKey, step) {
const t0 = performance.now();
const body = { tags: ['civitai', 'agent-gen', 'tts'], steps: [step] };
const res = await fetch(WORKFLOWS_URL, {
method: 'POST',
headers: authHeaders(apiKey),
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Submit failed ${res.status}: ${text}`);
}
const workflow = await res.json();
const submitMs = Math.round(performance.now() - t0);
// Extract streaming blob URL from the step output
const audioStep = workflow.steps?.[0];
const blob = audioStep?.output?.audioBlob;
const streamUrl = typeof blob === 'object' ? blob?.url : blob;
if (!streamUrl) {
throw new Error('No streaming blob URL in response. Step output: ' + JSON.stringify(audioStep?.output));
}
return {
workflowId: workflow.id,
streamUrl,
cost: workflow.cost?.total,
submitMs,
blobId: typeof blob === 'object' ? blob?.id : null,
};
}
// ---------------------------------------------------------------------------
// Step dispatcher — routes to the right builder based on job type
// ---------------------------------------------------------------------------
function buildStep(job, stepIndex) {
if (job.jobType === 'tts') return buildTTSStep(job, stepIndex);
if (job.jobType === 'music') return buildMusicStep(job, stepIndex);
if (job.jobType === 'transcribe') return buildTranscriptionStep(job, stepIndex);
if (job.engine) return buildVideoStep(job, stepIndex);
return buildImageStep(job, stepIndex);
}
function detectMediaType(steps) {
if (steps.length === 0) return 'image';
const type = steps[0].$type;
if (type === 'videoGen') return 'video';
if (type === 'textToSpeech') return 'tts';
if (type === 'aceStepAudio') return 'music';
if (type === 'transcription') return 'transcription';
return 'image';
}
// ---------------------------------------------------------------------------
// Build job list from CLI args or bulk file
// ---------------------------------------------------------------------------
function buildJobList(opts) {
if (opts.bulk) {
const raw = readFileSync(resolve(opts.bulk), 'utf-8');
const entries = JSON.parse(raw);
if (!Array.isArray(entries)) throw new Error('Bulk file must be a JSON array');
return entries.map((entry) => mergeJobDefaults(opts, entry));
}
// Audio commands use --text instead of --prompt
if (opts.jobType === 'tts') {
if (!opts.text) return [];
return [mergeJobDefaults(opts, { text: opts.text })];
}
if (opts.jobType === 'music') {
if (opts.prompt.length === 0) return [];
return opts.prompt.map((p, i) => mergeJobDefaults(opts, {
prompt: p,
label: opts.prompt.length > 1 ? `track_${i}` : null
}));
}
if (opts.jobType === 'transcribe') {
if (!opts.mediaUrl) return [];
return [mergeJobDefaults(opts, { mediaUrl: opts.mediaUrl })];
}
// Image/video: use --prompt
if (opts.prompt.length > 0) {
return opts.prompt.map((p, i) => mergeJobDefaults(opts, {
prompt: p,
label: opts.prompt.length > 1 ? `prompt_${i}` : null
}));
}
return [];
}
function mergeJobDefaults(defaults, entry) {
return {
// Job type routing
jobType: entry.jobType || defaults.jobType || null,
// Common
prompt: entry.prompt || (Array.isArray(defaults.prompt) ? defaults.prompt[0] : defaults.prompt),
negativePrompt: entry.negativePrompt ?? defaults.negativePrompt,
label: entry.label || null,
name: entry.name || null,
format: entry.format || defaults.format,
// Image-specific
model: entry.model || defaults.model,
resources: entry.resources || defaults.resources,
quantity: entry.quantity ?? defaults.quantity ?? 4,
aspect: entry.aspect || defaults.aspect,
resolution: entry.resolution || defaults.resolution,
width: entry.width || defaults.width,
height: entry.height || defaults.height,
widthExplicit: !!(entry.width || defaults.widthExplicit),
heightExplicit: !!(entry.height || defaults.heightExplicit),
steps: entry.steps ?? defaults.steps,
cfgScale: entry.cfgScale ?? defaults.cfgScale,
scheduler: entry.scheduler ?? defaults.scheduler,
seed: entry.seed ?? defaults.seed,
sourceImage: entry.sourceImage || defaults.sourceImage,
denoise: entry.denoise ?? defaults.denoise,
// Video-specific
engine: entry.engine || defaults.engine,
duration: entry.duration ?? defaults.duration,
videoAspect: entry.videoAspect || defaults.videoAspect,
generateAudio: entry.generateAudio ?? defaults.generateAudio,
fastMode: entry.fastMode ?? defaults.fastMode,
version: entry.version || defaults.version,
videoModel: entry.videoModel || defaults.videoModel,
enablePromptEnhancer: entry.enablePromptEnhancer ?? defaults.enablePromptEnhancer,
videoResolution: entry.videoResolution || defaults.videoResolution,
movementAmplitude: entry.movementAmplitude || defaults.movementAmplitude,
style: entry.style || defaults.style,
usePro: entry.usePro ?? defaults.usePro,
images: entry.images || defaults.images,
engineParams: entry.engineParams || defaults.engineParams,
// Audio-specific (TTS)
text: entry.text || defaults.text,
speaker: entry.speaker || defaults.speaker,
instruct: entry.instruct || defaults.instruct,
language: entry.language || defaults.language,
refAudioUrl: entry.refAudioUrl || defaults.refAudioUrl,
refText: entry.refText || defaults.refText,
xVectorOnly: entry.xVectorOnly ?? defaults.xVectorOnly,
// Audio-specific (Music)
lyrics: entry.lyrics || defaults.lyrics,
// Audio-specific (Transcription)
mediaUrl: entry.mediaUrl || defaults.mediaUrl,
context: entry.context || defaults.context,
timestamps: entry.timestamps ?? defaults.timestamps,
};
}
// ---------------------------------------------------------------------------
// Argument parsing — merges all domain arg handlers
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = argv.slice(2);
const opts = {
command: null,
jobType: null,
prompt: [],
output: null,
// Image defaults
model: null,
negativePrompt: null,
steps: null,
cfgScale: null,
scheduler: null,
seed: null,
format: 'png',
quantity: 4,
bulk: null,
resources: null,
aspect: null,
resolution: null,
width: null,
height: null,
widthExplicit: false,
heightExplicit: false,
sourceImage: null,
denoise: null,
// Workflow
workflowId: null,
poll: false,
interval: 5,
json: false,
concurrency: 5,
tags: [],
quiet: false,
timeout: 600,
play: false,
// Video
engine: null,
duration: null,
videoAspect: null,
generateAudio: null,
fastMode: null,
version: null,
videoModel: null,
enablePromptEnhancer: null,
videoResolution: null,
movementAmplitude: null,
style: null,
usePro: null,
images: null,
engineParams: null,
// TTS
text: null,
speaker: null,
instruct: null,
language: null,
refAudioUrl: null,
refText: null,
xVectorOnly: false,
// Music
lyrics: null,
// Transcription / STT
mediaUrl: null,
mediaFile: null, // local file path — auto-uploaded to blob
micDuration: null, // --mic <seconds> — record from mic first
context: null,
timestamps: false,
// Upload
uploadFiles: [],
};
// First arg is the command
if (args.length > 0 && !args[0].startsWith('-')) {
opts.command = args.shift();
}
// Audio subcommands set jobType and map to wait lifecycle
if (opts.command === 'tts') {
opts.jobType = 'tts';
opts.command = 'wait';
} else if (opts.command === 'music') {
opts.jobType = 'music';
opts.command = 'wait';
} else if (opts.command === 'transcribe' || opts.command === 'stt') {
opts.jobType = 'transcribe';
opts.command = 'wait';
}
// Merge all arg handlers
const allHandlers = {
...IMAGE_ARG_HANDLERS,
...VIDEO_ARG_HANDLERS,
...AUDIO_ARG_HANDLERS,
// Common args handled inline
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = () => args[++i];
// Common args
switch (arg) {
case '--prompt':
case '-p':
opts.prompt.push(next());
continue;
case '--output':
case '-o':
opts.output = next();
continue;
case '--bulk':
opts.bulk = next();
continue;
case '--workflow-id':
case '--id':
opts.workflowId = next();
continue;
case '--poll':
opts.poll = true;
continue;
case '--interval':
opts.interval = parseInt(next(), 10);
continue;
case '--json':
opts.json = true;
continue;
case '--concurrency':
opts.concurrency = parseInt(next(), 10);
continue;
case '--tag':
opts.tags.push(next());
continue;
case '--quiet':
case '-q':
opts.quiet = true;
continue;
case '--timeout':
opts.timeout = parseInt(next(), 10);
continue;
case '--play':
opts.play = true;
continue;
case '--file':
case '-f':
opts.uploadFiles.push(next());
continue;
case '--help':
case '-h':
showHelp();
process.exit(0);
}
// Domain-specific args
const handler = allHandlers[arg];
if (handler) {
handler(opts, next);
continue;
}
// Upload command: treat non-flag args as file paths
if (opts.command === 'upload' && !arg.startsWith('-')) {
opts.uploadFiles.push(arg);
continue;
}
// Transcribe/STT: treat non-flag args as local audio file paths
if (opts.jobType === 'transcribe' && !arg.startsWith('-')) {
opts.mediaFile = arg;
continue;
}
process.stderr.write(`Unknown option: ${arg}\n`);
process.exit(1);
}
return opts;
}
// ---------------------------------------------------------------------------
// Submit command
// ---------------------------------------------------------------------------
async function cmdSubmit(opts) {
const apiKey = getApiKey();
const jobs = buildJobList(opts);
if (jobs.length === 0) {
process.stderr.write('Error: No jobs to submit.\n');
process.exit(1);
}
const steps = jobs.map((job, i) => buildStep(job, i));
const mediaType = detectMediaType(steps);
const isVideo = mediaType === 'video';
const totalMedia = isVideo ? steps.length : jobs.reduce((sum, j) => sum + (j.quantity ?? 1), 0);
if (mediaType === 'image') {
const ecosystem = detectEcosystem(opts.model || jobs[0]?.model);
process.stderr.write(`Ecosystem: ${ECOSYSTEM_CONFIGS[ecosystem]?.label || ecosystem}\n`);
} else if (isVideo) {
process.stderr.write(`Video engine: ${jobs[0]?.engine || 'unknown'}\n`);
} else {
process.stderr.write(`Type: ${mediaType}\n`);
}
process.stderr.write(`Steps: ${steps.length}\n`);
const videoTags = isVideo ? ['vid', `engine:${jobs[0]?.engine || 'unknown'}`] : [];
const typeTags = mediaType !== 'image' && mediaType !== 'video' ? [mediaType] : [];
const workflowBody = {
tags: ['civitai', 'agent-gen', ...videoTags, ...typeTags, ...(opts.tags || [])],
steps,
};
process.stderr.write('Submitting workflow...\n');
const workflow = await apiSubmitWorkflow(apiKey, workflowBody);
const result = {
workflowId: workflow.id,
status: workflow.status,
type: mediaType,
steps: steps.length,
cost: workflow.cost,
};
if (opts.output) {
mkdirSync(opts.output, { recursive: true });
const manifestPath = join(opts.output, 'workflow.json');
writeFileSync(
manifestPath,
JSON.stringify(
{
workflowId: workflow.id,
submittedAt: new Date().toISOString(),
type: mediaType,
steps: jobs.map((j, i) => ({
index: i,
name: steps[i].name,
label: j.label || null,
...(j.prompt ? { prompt: j.prompt } : {}),
...(j.text ? { text: j.text } : {}),
})),
},
null,
2
)
);
process.stderr.write(`Manifest: ${manifestPath}\n`);
}
console.log(JSON.stringify(result, null, 2));
}
// ---------------------------------------------------------------------------
// Status command
// ---------------------------------------------------------------------------
function formatStepStatus(step, index) {
const status = step.status || 'unknown';
const jobs = step.jobs || [];
const totalJobs = jobs.length;
const completed = jobs.filter((j) => j.status === 'succeeded').length;
const failed = jobs.filter((j) => j.status === 'failed').length;
const processing = jobs.filter((j) => j.status === 'processing').length;
const queued = totalJobs - completed - failed - processing;
const label = step.metadata?.label || step.name || `step_${index}`;
const progress = step.estimatedProgressRate;
const progressStr = progress != null ? ` ${Math.round(progress * 100)}%` : '';
const imageCount = step.output?.images?.length || 0;
const hasVideo = step.output?.video?.available;
const isVideoStep = step.$type === 'videoGen';
const isAudioStep = step.$type === 'textToSpeech' || step.$type === 'aceStepAudio';
const isTranscription = step.$type === 'transcription';
let line = ` [${index}] ${label}: ${status}${progressStr}`;
if (totalJobs > 0) {
const parts = [];
if (completed > 0) parts.push(`${completed} done`);
if (processing > 0) parts.push(`${processing} running`);
if (queued > 0) parts.push(`${queued} queued`);
if (failed > 0) parts.push(`${failed} failed`);
line += ` (${parts.join(', ')})`;
}
if (imageCount > 0) line += ` [${imageCount} images]`;
if (hasVideo) line += ` [1 video]`;
else if (isVideoStep && !hasVideo && status === 'processing') line += ` [video]`;
if (isAudioStep) line += ` [audio]`;
if (isTranscription) line += ` [transcription]`;
return line;
}
async function cmdStatus(opts) {
const apiKey = getApiKey();
const workflowId = opts.workflowId;
if (!workflowId) {
process.stderr.write('Error: --workflow-id is required for status command.\n');
process.exit(1);
}
const poll = opts.poll || false;
const interval = (opts.interval || 5) * 1000;
let done = false;
while (!done) {
const workflow = await apiGetWorkflow(apiKey, workflowId);
const steps = workflow.steps || [];
let totalMedia = 0;
let availableMedia = 0;
let totalJobs = 0;
let completedJobs = 0;
let failedJobs = 0;
let hasVideos = false;
let hasAudio = false;
for (const step of steps) {
const imgs = step.output?.images || [];
totalMedia += imgs.length;
availableMedia += imgs.filter((img) => img.available && img.url).length;
if (step.$type === 'videoGen') {
hasVideos = true;
totalMedia += 1;
if (step.output?.video?.available) availableMedia += 1;
}
if (step.$type === 'textToSpeech' || step.$type === 'aceStepAudio') {
hasAudio = true;
totalMedia += 1;
const output = step.output || {};
if (output.blobUrl || output.url || output.audioBlob) availableMedia += 1;
}
for (const job of step.jobs || []) {
totalJobs++;
if (job.status === 'succeeded') completedJobs++;
if (job.status === 'failed') failedJobs++;
}
}
if (opts.json) {
const result = {
workflowId: workflow.id,
status: workflow.status,
createdAt: workflow.createdAt,
startedAt: workflow.startedAt,
completedAt: workflow.completedAt,
cost: workflow.cost,
steps: steps.map((s, i) => ({
index: i,
name: s.name,
type: s.$type,
label: s.metadata?.label || null,
status: s.status,
progress: s.estimatedProgressRate,
jobs: (s.jobs || []).length,
completedJobs: (s.jobs || []).filter((j) => j.status === 'succeeded').length,
failedJobs: (s.jobs || []).filter((j) => j.status === 'failed').length,
images: (s.output?.images || []).filter((img) => img.available).length,
video: s.$type === 'videoGen' ? (s.output?.video?.available ? 1 : 0) : undefined,
audio: (s.$type === 'textToSpeech' || s.$type === 'aceStepAudio')
? ((s.output?.blobUrl || s.output?.url || s.output?.audioBlob) ? 1 : 0) : undefined,
})),
summary: { totalJobs, completedJobs, failedJobs, totalMedia, availableMedia },
};
console.log(JSON.stringify(result, null, 2));
} else {
const lines = [];
lines.push(`Workflow: ${workflow.id}`);
lines.push(`Status: ${workflow.status}`);
if (workflow.cost) {
const buzzCost = workflow.cost.total ?? workflow.cost.base ?? 0;
lines.push(`Cost: ${buzzCost} buzz`);
}
lines.push(`Steps: ${steps.length}`);
lines.push('');
for (let i = 0; i < steps.length; i++) {
lines.push(formatStepStatus(steps[i], i));
}
lines.push('');
const mediaLabel = hasVideos ? 'media' : hasAudio ? 'audio files' : 'images';
lines.push(
`Progress: ${completedJobs}/${totalJobs} jobs` +
(failedJobs > 0 ? ` (${failedJobs} failed)` : '') +
` | ${availableMedia} ${mediaLabel} ready`
);
if (poll) {
process.stderr.write('\x1b[2J\x1b[H');
}
process.stderr.write(lines.join('\n') + '\n');
}
const terminal = ['succeeded', 'failed', 'expired', 'canceled'];
if (terminal.includes(workflow.status) || !poll) {
done = true;
} else {
await new Promise((r) => setTimeout(r, interval));
}
}
}
// ---------------------------------------------------------------------------
// Download command
// ---------------------------------------------------------------------------
async function cmdDownload(opts) {
const apiKey = getApiKey();
const workflowId = opts.workflowId;
if (!workflowId) {
process.stderr.write('Error: --workflow-id is required for download command.\n');
process.exit(1);
}
const outDir = opts.output || './output';
mkdirSync(outDir, { recursive: true });
const workflow = await apiGetWorkflow(apiKey, workflowId);
let manifest = null;
const manifestPath = join(outDir, 'workflow.json');
if (existsSync(manifestPath)) {
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
} catch {}
}
const downloads = collectDownloads(workflow, manifest, {
outDir,
format: opts.format || 'png',
});
// For transcription workflows, output the text directly
const transcriptionResults = [];
for (const step of workflow.steps || []) {
if (step.$type === 'transcription' && step.output) {
transcriptionResults.push({
stepIndex: step.metadata?.stepIndex,
text: step.output.text || '',
segments: step.output.segments || [],
});
}
}
if (downloads.length === 0 && transcriptionResults.length === 0) {
process.stderr.write('No media available for download.\n');
console.log(JSON.stringify({ success: true, images: [], videos: [], audio: [], transcriptions: [], failed: 0 }));
return;
}
let saved = [];
let failed = 0;
if (downloads.length > 0) {
process.stderr.write(`Downloading ${downloads.length} file(s)...\n`);
const result = await downloadAll(downloads, {
concurrency: opts.concurrency || 5,
});
saved = result.saved;
failed = result.failed;
}
console.log(
JSON.stringify({
success: failed === 0,
images: saved.filter((p) => !p.endsWith('.mp4') && !p.endsWith('.wav') && !p.endsWith('.mp3') && !p.endsWith('.ogg') && !p.endsWith('.flac')),
videos: saved.filter((p) => p.endsWith('.mp4')),
audio: saved.filter((p) => p.endsWith('.wav') || p.endsWith('.mp3') || p.endsWith('.ogg') || p.endsWith('.flac')),
transcriptions: transcriptionResults,
// Remote CDN download URLs, parallel to the local paths above. Pass one to
// the Civitai MCP create_post tool's images[].url. See docs/posting.md.
remoteUrls: downloads.map((d) => ({
url: d.url,
type: d.mediaType,
path: d.destPath,
})),
failed,
total: downloads.length,
})
);
}
// ---------------------------------------------------------------------------
// STT pre-flight — mic recording and local file upload
// ---------------------------------------------------------------------------
async function sttPreflight(opts) {
if (opts.jobType !== 'transcribe') return;
const apiKey = getApiKey();
// --mic: record from microphone using ffmpeg
if (opts.micDuration && !opts.mediaFile && !opts.mediaUrl) {
const duration = opts.micDuration;
const tmpPath = join(resolve('.'), `.mic-recording-${Date.now()}.mp3`);
process.stderr.write(`Recording from microphone for ${duration}s...\n`);
await new Promise((resolve, reject) => {
// Use dshow on Windows, alsa on Linux, avfoundation on macOS
const platform = process.platform;
let inputArgs;
if (platform === 'win32') {
inputArgs = ['-f', 'dshow', '-i', 'audio=default'];
} else if (platform === 'darwin') {
inputArgs = ['-f', 'avfoundation', '-i', ':default'];
} else {
inputArgs = ['-f', 'alsa', '-i', 'default'];
}
const ffmpeg = spawn('ffmpeg', [
...inputArgs,
'-t', String(duration),
'-ar', '16000', '-ac', '1',
'-c:a', 'libmp3lame', '-q:a', '2',
'-y', tmpPath,
], { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
ffmpeg.stderr.on('data', (d) => { stderr += d; });
ffmpeg.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`ffmpeg mic recording failed (code ${code}): ${stderr.slice(-500)}`));
});
});
process.stderr.write(`Recorded to ${tmpPath}\n`);
opts.mediaFile = tmpPath;
opts._cleanupMicFile = tmpPath;
}
// --media-file or positional: upload local file to blob, set mediaUrl
if (opts.mediaFile && !opts.mediaUrl) {
let filePath = resolve(opts.mediaFile);
if (!existsSync(filePath)) {
process.stderr.write(`Error: File not found: ${filePath}\n`);
process.exit(1);
}
// Auto-convert WAV to MP3 (blob API rejects WAV with 415)
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
let convertedPath = null;
if (ext === '.wav') {
convertedPath = filePath.replace(/\.wav$/i, '.mp3');
process.stderr.write(`Converting WAV to MP3...\n`);
await new Promise((res, rej) => {
const ff = spawn('ffmpeg', ['-i', filePath, '-c:a', 'libmp3lame', '-q:a', '2', '-y', convertedPath],
{ stdio: ['ignore', 'pipe', 'pipe'] });
ff.on('close', (code) => code === 0 ? res() : rej(new Error(`ffmpeg conversion failed (${code})`)));
});
filePath = convertedPath;
}
process.stderr.write(`Uploading ${filePath}...\n`);
const blob = await uploadBlob(apiKey, filePath);
process.stderr.write(` Blob ID: ${blob.id}\n`);
opts.mediaUrl = blob.url;
// Clean up temp files
const { unlinkSync } = await import('fs');
if (convertedPath) try { unlinkSync(convertedPath); } catch {}
if (opts._cleanupMicFile) try { unlinkSync(opts._cleanupMicFile); } catch {}
}
}
// ---------------------------------------------------------------------------
// Wait command — submit + poll + download in one shot
// ---------------------------------------------------------------------------
async function cmdWait(opts) {
const startTime = Date.now();
const apiKey = getApiKey();
// STT: handle mic recording and local file upload before building jobs
await sttPreflight(opts);
const jobs = buildJobList(opts);
const quiet = opts.quiet || false;
const timeoutSec = opts.timeout ?? 600;
if (jobs.length === 0) {
const hint = opts.jobType === 'tts' ? '--text' :
opts.jobType === 'transcribe' ? '--media-url, --media-file, --mic, or a file path' : '--prompt or --bulk';
process.stderr.write(`Error: No jobs to submit. Provide ${hint}.\n`);
process.exit(1);
}
const steps = jobs.map((job, i) => buildStep(job, i));
const mediaType = detectMediaType(steps);
const isVideo = mediaType === 'video';
const isAudio = mediaType === 'tts' || mediaType === 'music';
const isTranscription = mediaType === 'transcription';
const totalMedia = isVideo ? steps.length :
isAudio ? steps.length :
isTranscription ? steps.length :
jobs.reduce((sum, j) => sum + (j.quantity ?? 1), 0);
if (mediaType === 'image') {
const ecosystem = detectEcosystem(opts.model || jobs[0]?.model);
process.stderr.write(`Ecosystem: ${ECOSYSTEM_CONFIGS[ecosystem]?.label || ecosystem}\n`);
} else if (isVideo) {
const engineName = jobs[0]?.engine || 'unknown';
const reg = VIDEO_ENGINE_REGISTRY[engineName];
process.stderr.write(`Video engine: ${reg?.label || engineName}\n`);
} else {
process.stderr.write(`Type: ${mediaType}\n`);
}
process.stderr.write(`Steps: ${steps.length}\n`);
// ---------------------------------------------------------------------------
// Streaming TTS fast-path: submit with no wait, stream blob URL immediately.
// This gives us audio playback as fast as possible (sub-second first-byte).
// ---------------------------------------------------------------------------
if (isAudio && steps.length === 1) {
const outDir = opts.output || './output';
const absOutDir = resolve(outDir);
mkdirSync(absOutDir, { recursive: true });
process.stderr.write('Submitting (streaming mode)...\n');
const { workflowId, streamUrl, cost, submitMs, blobId } = await submitTTSStreaming(apiKey, steps[0]);
process.stderr.write(`Workflow ID: ${workflowId}\n`);
process.stderr.write(`Submit: ${submitMs}ms\n`);
if (cost) process.stderr.write(`Cost: ${cost} buzz\n`);
// Detect extension from blob ID
const extMatch = blobId?.match(/\.(ogg|mp3|wav|flac)$/i);
const ext = extMatch ? extMatch[1].toLowerCase() : 'ogg';
const label = jobs[0].label || 'step_0';
const filename = `${label}.${ext}`;
const savePath = join(absOutDir, filename);
let streamResult;
if (opts.play) {
process.stderr.write(`Streaming to ffplay + saving to ${filename}...\n`);
streamResult = await streamAudioUrl(streamUrl, { savePath, quiet });
} else {
process.stderr.write(`Streaming to ${filename}...\n`);
streamResult = await streamToFile(streamUrl, savePath, quiet);
}
const durationSec = Math.round((Date.now() - startTime) / 1000);
const summary = {
workflowId,
status: 'succeeded',
type: mediaType,
streaming: true,
totalMedia: 1,
downloadedMedia: 1,
failedDownloads: 0,
outputDir: absOutDir,
images: [],
videos: [],
audio: [savePath],
transcriptions: [],
duration: durationSec,
timing: {
submitMs,
firstByteMs: streamResult.firstByteMs,
totalStreamMs: streamResult.totalMs,
},
cost: cost || null,
};
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
return;
}
const videoTags = isVideo ? ['vid', `engine:${jobs[0]?.engine || 'unknown'}`] : [];
const typeTags = mediaType !== 'image' && mediaType !== 'video' ? [mediaType] : [];
const workflowBody = {
tags: ['civitai', 'agent-gen', ...videoTags, ...typeTags, ...(opts.tags || [])],
steps,
};
// Audio/transcription workflows use synchronous ?wait=N instead of async polling.
// The Civitai API requires this for TTS, music, and transcription step types.
const useSyncWait = isAudio || isTranscription;
process.stderr.write('Submitting workflow...\n');
const workflow = await apiSubmitWorkflow(apiKey, workflowBody, useSyncWait ? { wait: 60 } : {});
const workflowId = workflow.id;
process.stderr.write(`Workflow ID: ${workflowId}\n`);
process.stderr.write(`Status: ${workflow.status}\n`);
if (workflow.cost) {
process.stderr.write(`Cost: ${workflow.cost.total ?? workflow.cost.base ?? '?'} buzz\n`);
}
const outDir = opts.output || './output';
const absOutDir = resolve(outDir);
mkdirSync(absOutDir, { recursive: true });
const manifestPath = join(absOutDir, 'workflow.json');
writeFileSync(
manifestPath,
JSON.stringify(
{
workflowId,
submittedAt: new Date().toISOString(),
type: mediaType,
steps: jobs.map((j, i) => ({
index: i,
name: steps[i].name,
label: j.label || null,
...(j.prompt ? { prompt: j.prompt } : {}),
...(j.text ? { text: j.text } : {}),
...(j.mediaUrl ? { mediaUrl: j.mediaUrl } : {}),
})),
totalMedia,
},
null,
2
)
);
let finalWorkflow;
if (useSyncWait) {
// Synchronous wait: the POST with ?wait=N already returned the completed workflow.
// If it's not in a terminal state yet, poll for the remainder.
const terminal = ['succeeded', 'failed', 'expired', 'canceled'];
if (terminal.includes(workflow.status)) {
finalWorkflow = workflow;
} else {
process.stderr.write('Waiting for completion...\n');
const { workflow: polled, timedOut: to } = await pollWorkflow(apiKey, workflowId, {
interval: 3000,
timeout: timeoutSec * 1000,
});
if (to) {
const durationSec = Math.round((Date.now() - startTime) / 1000);
process.stderr.write(`Timeout: workflow did not complete within ${timeoutSec}s.\n`);
const summary = {
workflowId, status: 'timeout', type: mediaType, totalMedia,
downloadedMedia: 0, failedDownloads: 0, outputDir: absOutDir,
images: [], videos: [], audio: [], transcriptions: [],
duration: durationSec, cost: null,
error: `Workflow did not complete. Check: node generate.mjs status --workflow-id ${workflowId} --json`,
};
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
process.exit(1);
}
finalWorkflow = polled;
}
} else {
// Async workflow: poll until done
const onPoll = quiet
? undefined
: (wf) => {
const wfSteps = wf.steps || [];
let completedJobs = 0;
let totalJobs = 0;
for (const step of wfSteps) {
for (const job of step.jobs || []) {
totalJobs++;
if (job.status === 'succeeded' || job.status === 'failed') completedJobs++;
}
}
process.stderr.write(` Progress: ${completedJobs}/${totalJobs} jobs | status: ${wf.status}\n`);
};
const { workflow: polled, timedOut } = await pollWorkflow(apiKey, workflowId, {
interval: (opts.interval || 5) * 1000,
timeout: timeoutSec * 1000,
onPoll,
});
if (timedOut) {
const durationSec = Math.round((Date.now() - startTime) / 1000);
process.stderr.write(`Timeout: workflow did not complete within ${timeoutSec}s.\n`);
const summary = {
workflowId, status: 'timeout', type: mediaType, totalMedia,
downloadedMedia: 0, failedDownloads: 0, outputDir: absOutDir,
images: [], videos: [], audio: [], transcriptions: [],
duration: durationSec, cost: null,
error: `Workflow did not complete within ${timeoutSec}s. Check: node generate.mjs status --workflow-id ${workflowId} --json`,
};
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
process.exit(1);
}
finalWorkflow = polled;
}
const durationSec = Math.round((Date.now() - startTime) / 1000);
process.stderr.write(`Workflow ${finalWorkflow.status}. Processing output...\n`);
// Collect downloads
let manifest = null;
if (existsSync(manifestPath)) {
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
} catch {}
}
const downloads = collectDownloads(finalWorkflow, manifest, {
outDir: absOutDir,
format: opts.format || 'png',
});
// Collect transcription results
const transcriptionResults = [];
for (const step of finalWorkflow.steps || []) {
if (step.$type === 'transcription' && step.output) {
transcriptionResults.push({
stepIndex: step.metadata?.stepIndex,
text: step.output.text || '',
segments: step.output.segments || [],
});
}
}
let savedPaths = [];
let failedDownloadCount = 0;
if (downloads.length > 0) {
process.stderr.write(`Downloading ${downloads.length} file(s)...\n`);
const result = await downloadAll(downloads, {
concurrency: opts.concurrency || 5,
quiet,
});
savedPaths = result.saved;
failedDownloadCount = result.failed;
} else if (transcriptionResults.length === 0) {
process.stderr.write('No media available for download.\n');
}
const summary = {
workflowId,
status: finalWorkflow.status,
type: mediaType,
totalMedia,
downloadedMedia: savedPaths.length,
failedDownloads: failedDownloadCount,
outputDir: absOutDir,
images: savedPaths.filter((p) => !p.endsWith('.mp4') && !p.endsWith('.wav') && !p.endsWith('.mp3') && !p.endsWith('.ogg') && !p.endsWith('.flac')),
videos: savedPaths.filter((p) => p.endsWith('.mp4')),
audio: savedPaths.filter((p) => p.endsWith('.wav') || p.endsWith('.mp3') || p.endsWith('.ogg') || p.endsWith('.flac')),
transcriptions: transcriptionResults,
// Remote CDN download URLs for each media item (parallel to the local paths
// above). These are real https URLs on Civitai's CDN — hand one straight to
// the Civitai MCP `create_post` tool's images[].url to post without any
// manual upload. See docs/posting.md.
remoteUrls: downloads.map((d) => ({
url: d.url,
type: d.mediaType,
path: d.destPath,
})),
duration: durationSec,
cost: finalWorkflow.cost || null,
};
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
// Play audio files if --play flag is set
if (opts.play && summary.audio.length > 0) {
await playAudioFiles(summary.audio);
}
}
// ---------------------------------------------------------------------------
// Cost command — dry-run to estimate buzz cost
// ---------------------------------------------------------------------------
async function cmdCost(opts) {
const apiKey = getApiKey();
const jobs = buildJobList(opts);
if (jobs.length === 0) {
const hint = opts.jobType === 'tts' ? '--text' :
opts.jobType === 'transcribe' ? '--media-url, --media-file, --mic, or a file path' : '--prompt or --bulk';
process.stderr.write(`Error: No jobs to estimate. Provide ${hint}.\n`);
process.exit(1);
}
const steps = jobs.map((job, i) => buildStep(job, i));
const mediaType = detectMediaType(steps);
const isVideo = mediaType === 'video';
const totalMedia = isVideo ? steps.length :
(mediaType === 'tts' || mediaType === 'music' || mediaType === 'transcription') ? steps.length :
jobs.reduce((sum, j) => sum + (j.quantity ?? 1), 0);
const workflowBody = { steps };
process.stderr.write('Estimating cost (dry run)...\n');
const result = await apiWhatIf(apiKey, workflowBody);
const cost = result.cost || {};
const transactions = result.transactions || {};
let ready = true;
for (const step of result.steps || []) {
for (const job of step.jobs || []) {
if (job.queuePosition?.support !== 'available') ready = false;
}
}
const output = {
type: mediaType,
steps: steps.length,
totalMedia,
cost: {
total: cost.total ?? 0,
base: cost.base ?? 0,
factors: cost.factors || {},
},
insufficientBuzz: transactions.insufficientBuzz ?? false,
ready,
};
if (!opts.json) {
process.stderr.write(`\n ${totalMedia} ${mediaType} item(s) across ${steps.length} step(s)\n`);
process.stderr.write(` Estimated cost: ${cost.total ?? 0} buzz\n`);
if (cost.factors) {
const factorParts = Object.entries(cost.factors)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
if (factorParts) process.stderr.write(` Factors: ${factorParts}\n`);
}
if (transactions.insufficientBuzz) {
process.stderr.write(` WARNING: Insufficient buzz balance!\n`);
}
process.stderr.write(` Queue: ${ready ? 'ready' : 'may have wait times'}\n\n`);
}
console.log(JSON.stringify(output, null, 2));
}
// ---------------------------------------------------------------------------
// Upload command — upload files to the orchestrator blob store
// ---------------------------------------------------------------------------
async function cmdUpload(opts) {
const apiKey = getApiKey();
const files = opts.uploadFiles || [];
// Collect files from positional args or --file flags
if (files.length === 0) {
process.stderr.write('Error: No files to upload. Usage: node generate.mjs upload <file1> [file2] ...\n');
process.stderr.write('Supported: .mp3, .ogg, .wav, .flac, .png, .jpg, .webp, .mp4\n');
process.exit(1);
}
const results = [];
for (const filePath of files) {
process.stderr.write(`Uploading ${filePath}...\n`);
try {
const blob = await uploadBlob(apiKey, filePath);
process.stderr.write(` Blob ID: ${blob.id}\n`);
process.stderr.write(` URL: ${blob.url}\n`);
results.push({ file: filePath, ...blob });
} catch (err) {
process.stderr.write(` Failed: ${err.message}\n`);
results.push({ file: filePath, error: err.message });
}
}
process.stdout.write(JSON.stringify(results, null, 2) + '\n');
}
// ---------------------------------------------------------------------------
// Engines command — dynamic engine discovery
// ---------------------------------------------------------------------------
async function cmdEngines(opts) {
const apiKey = getApiKey();
process.stderr.write('Fetching available video engines...\n');
let liveEngines = [];
try {
const res = await fetch(`${CIVITAI_API_URL}/generation.getGenerationEngines`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (res.ok) {
const data = await res.json();
liveEngines = data?.result?.data?.json || [];
}
} catch (err) {
process.stderr.write(`Warning: Could not fetch live engine status: ${err.message}\n`);
}
const engines = [];
const seenEngines = new Set();
for (const [engineId, reg] of Object.entries(VIDEO_ENGINE_REGISTRY)) {
seenEngines.add(engineId);
const live = liveEngines.find((e) => e.engine === engineId);
const available = live ? (!live.disabled && live.status !== 'disabled') : null;
engines.push({
engine: engineId,
label: reg.label,
available,
status: live?.status || null,
message: live?.message || null,
processes: reg.processes,
durations: reg.durations,
aspectRatios: reg.aspectRatios,
defaults: reg.defaults,
features: reg.features || [],
...(reg.models ? { models: reg.models } : {}),
...(reg.versions ? { versions: reg.versions } : {}),
...(reg.resolutions ? { resolutions: reg.resolutions } : {}),
...(reg.notes ? { notes: reg.notes } : {}),
});
}
for (const live of liveEngines) {
if (seenEngines.has(live.engine)) continue;
if (live.engine === 'civitai') continue;
engines.push({
engine: live.engine,
label: live.engine,
available: !live.disabled && live.status !== 'disabled',
status: live.status || null,
message: live.message || null,
processes: ['txt2vid'],
durations: [],
aspectRatios: [],
defaults: {},
features: [],
_note: 'Engine not in local registry — params may vary.',
});
}
if (opts.json) {
console.log(JSON.stringify({ engines }, null, 2));
} else {
const lines = ['Video Generation Engines', ''];
for (const e of engines) {
const statusIcon = e.available === true ? '[OK]' : e.available === false ? '[OFF]' : '[?]';
lines.push(`${statusIcon} ${e.engine} — ${e.label}`);
if (e.message) lines.push(` Note: ${e.message}`);
if (e.processes.length > 0) lines.push(` Processes: ${e.processes.join(', ')}`);
if (e.durations.length > 0) lines.push(` Durations: ${e.durations.join(', ')}s`);
if (e.aspectRatios.length > 0) lines.push(` Aspects: ${e.aspectRatios.join(', ')}`);
if (e.features?.length > 0) lines.push(` Features: ${e.features.join(', ')}`);
lines.push('');
}
process.stderr.write(lines.join('\n') + '\n');
console.log(JSON.stringify({ engines }, null, 2));
}
}
// ---------------------------------------------------------------------------
// Help
// ---------------------------------------------------------------------------
function showHelp() {
const text = `
generate.mjs — Unified Civitai Orchestration CLI
PHILOSOPHY
Civitai generation is extremely cheap. Generating 4 images costs the same as 1
on most models. Default quantity is 4 to encourage exploration. Use -n 1 only
when you specifically need a single image.
COMMANDS
wait Submit, poll until done, then download (default / all-in-one)
submit Submit a workflow and get a workflow ID back immediately
status Check the status of a running workflow
download Download completed media from a workflow
cost Dry-run: estimate buzz cost without spending (what-if)
engines List available video generation engines with capabilities
tts Text-to-speech synthesis (maps to wait lifecycle)
music Music/song generation via ACE Step 1.5 (maps to wait lifecycle)
transcribe Speech-to-text transcription (alias: stt, maps to wait lifecycle)
${IMAGE_HELP}${VIDEO_HELP}${AUDIO_HELP}
STATUS
--workflow-id <id> Workflow ID to check
--poll Keep polling until workflow completes
--interval <sec> Poll interval in seconds (default: 5)
--json Output status as JSON
WAIT (additional flags)
--quiet / -q Suppress per-poll progress lines (agent-friendly)
--timeout <sec> Timeout in seconds (default: 600)
--play Play audio files after download (requires ffplay)
DOWNLOAD
--workflow-id <id> Workflow ID to download from
--output / -o <dir> Where to save media
--format <png|jpeg> Image format (default: png; videos always .mp4)
--concurrency <num> Parallel downloads (default: 5)
COMMON
--prompt <text> Text prompt (repeatable for multiple workflow steps)
--bulk <file.json> JSON array of job definitions
--output / -o <dir> Output directory (saves manifest + media)
--tag <name> Additional workflow tag (repeatable)
EXAMPLES
# Image generation (default: wait = submit + poll + download)
node generate.mjs wait --prompt "A cat at sunset" -o ./out
# Multiple prompts as separate workflow steps (all concurrent)
node generate.mjs wait --prompt "A cat" --prompt "A dog" -o ./out
# Video generation with VEO 3
node generate.mjs wait --engine veo3 --prompt "A robot walking" -o ./out
# Text-to-speech with built-in speaker
node generate.mjs tts --text "Hello world" --speaker Chelsie -o ./out
# TTS with style instruction
node generate.mjs tts --text "Welcome to Civitai" --speaker dylan \\
--instruct "cheerful and enthusiastic" -o ./out
# TTS with voice cloning from reference audio
node generate.mjs tts --text "Cloned voice" --ref-audio "https://..." -o ./out
# Music generation
node generate.mjs music --prompt "upbeat electronic dance track" \\
--duration 30 -o ./out
# Transcription (from URL, local file, or mic)
node generate.mjs transcribe --media-url "https://example.com/audio.mp3" -o ./out
node generate.mjs stt recording.mp3 -o ./out
node generate.mjs stt --mic 10 -o ./out
# Estimate cost before spending (works for all types)
node generate.mjs cost --prompt "A cat" -n 100
node generate.mjs cost --engine veo3 --prompt "A robot" --duration 8
# List available video engines
node generate.mjs engines
# Agent-friendly: quiet mode outputs clean JSON summary
node generate.mjs wait --prompt "A cat" --quiet -o ./out
`.trimStart();
console.log(text);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs(process.argv);
if (!opts.command) {
showHelp();
process.exit(0);
}
try {
switch (opts.command) {
case 'submit':
await cmdSubmit(opts);
break;
case 'status':
await cmdStatus(opts);
break;
case 'download':
await cmdDownload(opts);
break;
case 'wait':
await cmdWait(opts);
break;
case 'engines':
await cmdEngines(opts);
break;
case 'cost':
case 'whatif':
case 'estimate':
await cmdCost(opts);
break;
case 'upload':
await cmdUpload(opts);
break;
default:
process.stderr.write(`Unknown command: ${opts.command}\n`);
showHelp();
process.exit(1);
}
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exit(1);
}
}
main();
// lib/api.mjs — Shared API infrastructure for Civitai Orchestration
//
// Used by generate.mjs, experiment.mjs, and future scripts (train.mjs, etc.)
// Provides: env loading, auth, workflow submission/polling/what-if, file downloads.
//
// Zero npm dependencies. Requires Node 18+ (native fetch).
import { readFileSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ---------------------------------------------------------------------------
// Resolve skill root (parent of lib/)
// ---------------------------------------------------------------------------
const __libdir = dirname(fileURLToPath(import.meta.url));
const SKILL_ROOT = join(__libdir, '..');
// ---------------------------------------------------------------------------
// .env loader (reads from skill root directory)
// ---------------------------------------------------------------------------
export function loadEnv() {
try {
const envPath = join(SKILL_ROOT, '.env');
const content = readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
} catch {}
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const BASE_URL = 'https://orchestration-new.civitai.com';
export const WORKFLOWS_URL = `${BASE_URL}/v2/consumer/workflows`;
export const CIVITAI_API_URL = 'https://civitai.com/api/trpc';
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
export function getApiKey() {
const key = process.env.CIVITAI_API_KEY;
if (!key) {
process.stderr.write('Error: CIVITAI_API_KEY not set. Check .env file.\n');
process.exit(1);
}
return key;
}
export function authHeaders(apiKey) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
};
}
// ---------------------------------------------------------------------------
// Workflow API
// ---------------------------------------------------------------------------
export async function apiSubmitWorkflow(apiKey, body, opts = {}) {
const waitSec = opts.wait;
const url = waitSec ? `${WORKFLOWS_URL}?wait=${waitSec}` : WORKFLOWS_URL;
const res = await fetch(url, {
method: 'POST',
headers: authHeaders(apiKey),
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Submit failed ${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
export async function apiWhatIf(apiKey, body) {
const res = await fetch(`${WORKFLOWS_URL}?whatif=true`, {
method: 'POST',
headers: authHeaders(apiKey),
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`What-if failed ${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
export async function apiGetWorkflow(apiKey, workflowId) {
const url = `${WORKFLOWS_URL}/${workflowId}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Get workflow failed ${res.status}: ${text}`);
}
return res.json();
}
// ---------------------------------------------------------------------------
// Blob upload — upload binary files to the orchestrator for use as inputs
// ---------------------------------------------------------------------------
const MIME_MAP = {
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.wav': 'audio/wav',
'.flac': 'audio/flac',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.mp4': 'video/mp4',
};
/**
* Upload a file to the orchestrator blob store.
* Returns { id, available, url, urlExpiresAt }.
*
* @param {string} apiKey
* @param {string} filePath — local file path to upload
* @returns {Promise<{id: string, available: boolean, url: string, urlExpiresAt: string}>}
*/
export async function uploadBlob(apiKey, filePath) {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
const contentType = MIME_MAP[ext];
if (!contentType) {
throw new Error(`Unsupported file type "${ext}". Supported: ${Object.keys(MIME_MAP).join(', ')}`);
}
const body = readFileSync(filePath);
const res = await fetch(`${BASE_URL}/v2/consumer/blobs`, {
method: 'POST',
headers: {
'Content-Type': contentType,
Authorization: `Bearer ${apiKey}`,
},
body,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Blob upload failed ${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
// ---------------------------------------------------------------------------
// File download
// ---------------------------------------------------------------------------
export async function downloadFile(url, destPath) {
const res = await fetch(url);
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
const arrayBuf = await res.arrayBuffer();
writeFileSync(destPath, Buffer.from(arrayBuf));
}
/**
* Download multiple files with a concurrency limit.
*
* @param {Array<{url: string, destPath: string, filename?: string}>} items
* @param {object} opts
* @param {number} [opts.concurrency=5] — max parallel downloads
* @param {boolean} [opts.quiet=false] — suppress per-file progress
* @returns {Promise<{saved: string[], failed: number}>}
*/
export async function downloadAll(items, opts = {}) {
const concurrency = opts.concurrency || 5;
const quiet = opts.quiet || false;
const saved = [];
let failed = 0;
const semaphore = { active: 0, queue: [] };
function acquire() {
return new Promise((resolve) => {
if (semaphore.active < concurrency) {
semaphore.active++;
resolve();
} else {
semaphore.queue.push(resolve);
}
});
}
function release() {
if (semaphore.queue.length > 0) {
semaphore.queue.shift()();
} else {
semaphore.active--;
}
}
await Promise.all(
items.map(async (item) => {
await acquire();
try {
await downloadFile(item.url, item.destPath);
saved.push(item.destPath);
if (!quiet) {
process.stderr.write(` Saved: ${item.filename || item.destPath}\n`);
}
} catch (err) {
process.stderr.write(` Failed: ${item.filename || item.destPath} — ${err.message}\n`);
failed++;
} finally {
release();
}
})
);
return { saved, failed };
}
// ---------------------------------------------------------------------------
// Workflow polling helper
// ---------------------------------------------------------------------------
const TERMINAL_STATES = ['succeeded', 'failed', 'expired', 'canceled'];
/**
* Poll a workflow until it reaches a terminal state or timeout.
*
* @param {string} apiKey
* @param {string} workflowId
* @param {object} opts
* @param {number} [opts.interval=5000] — poll interval in ms
* @param {number} [opts.timeout=600000] — max wait in ms
* @param {function} [opts.onPoll] — callback(workflow) on each poll, for progress display
* @returns {Promise<{workflow: object, timedOut: boolean}>}
*/
export async function pollWorkflow(apiKey, workflowId, opts = {}) {
const interval = opts.interval || 5000;
const timeout = opts.timeout || 600000;
const startTime = Date.now();
while (true) {
await new Promise((r) => setTimeout(r, interval));
const elapsed = Date.now() - startTime;
if (elapsed >= timeout) {
return { workflow: null, timedOut: true };
}
const workflow = await apiGetWorkflow(apiKey, workflowId);
if (opts.onPoll) opts.onPoll(workflow);
if (TERMINAL_STATES.includes(workflow.status)) {
return { workflow, timedOut: false };
}
}
}
/**
* Collect downloadable media from a completed workflow's steps.
* Returns an array of download descriptors for use with downloadAll().
*
* @param {object} workflow — workflow response from API
* @param {object|null} manifest — optional manifest with step labels
* @param {object} opts
* @param {string} [opts.outDir] — output directory for destPath
* @param {string} [opts.format='png'] — image format extension
* @returns {Array<{url: string, destPath: string, filename: string, mediaType: string}>}
*/
export function collectDownloads(workflow, manifest, opts = {}) {
const outDir = opts.outDir || './output';
const format = opts.format || 'png';
const downloads = [];
const steps = workflow.steps || [];
for (let si = 0; si < steps.length; si++) {
const step = steps[si];
const stepLabel =
manifest?.steps?.[si]?.label || step.metadata?.label || step.name || `step${si}`;
// Video steps: single video blob
if (step.$type === 'videoGen') {
const video = step.output?.video;
if (video?.available && video?.url) {
const filename = `${stepLabel}.mp4`;
downloads.push({
url: video.url,
filename,
destPath: join(outDir, filename),
stepIndex: si,
mediaType: 'video',
});
}
continue;
}
// Audio steps (TTS, music): audioBlob object with { id, available, url }
if (step.$type === 'textToSpeech' || step.$type === 'aceStepAudio') {
const output = step.output || {};
// audioBlob can be an object { id, available, url } or a direct URL string
const blob = output.audioBlob;
const url = typeof blob === 'object' ? blob?.url :
typeof blob === 'string' ? blob :
output.blobUrl || output.url;
if (url) {
// Detect extension from blob id or URL, fallback to ogg for TTS
const blobId = typeof blob === 'object' ? blob?.id : '';
const extMatch = blobId?.match(/\.(ogg|mp3|wav|flac)$/i) ||
url?.match(/\.(ogg|mp3|wav|flac)/i);
const ext = extMatch ? extMatch[1].toLowerCase() : (step.$type === 'textToSpeech' ? 'ogg' : 'mp3');
const filename = `${stepLabel}.${ext}`;
downloads.push({
url,
filename,
destPath: join(outDir, filename),
stepIndex: si,
mediaType: 'audio',
});
}
continue;
}
// Transcription steps: text output, no file to download — emit JSON
if (step.$type === 'transcription') {
continue;
}
// Image steps: array of image blobs
const images = step.output?.images || [];
for (let ii = 0; ii < images.length; ii++) {
const img = images[ii];
if (!img.available || !img.url) continue;
const filename = `${stepLabel}-${ii}.${format}`;
downloads.push({
url: img.url,
filename,
destPath: join(outDir, filename),
stepIndex: si,
imageIndex: ii,
mediaType: 'image',
});
}
}
return downloads;
}
// lib/audio.mjs — Audio step builders: TTS, music generation, transcription
//
// Exports: buildTTSStep, buildMusicStep, buildTranscriptionStep,
// AUDIO_ARG_HANDLERS, AUDIO_HELP
//
// Zero npm dependencies. Requires Node 18+.
// ---------------------------------------------------------------------------
// TTS — Text-to-Speech via Qwen TTS
// ---------------------------------------------------------------------------
// Two modes:
// 1. CustomVoice — use a built-in speaker name (e.g. "Chelsie", "dylan")
// 2. Base — voice cloning from a reference audio file
export function buildTTSStep(job, stepIndex) {
if (!job.text) throw new Error('TTS requires --text');
const input = {
text: job.text,
language: job.language || 'English',
};
if (job.refAudioUrl) {
// Base mode: voice cloning
input.refAudioUrl = job.refAudioUrl;
if (job.refText) input.refText = job.refText;
if (job.xVectorOnly) input.xVectorOnlyMode = true;
} else {
// CustomVoice mode: built-in speaker
if (!job.speaker) throw new Error('TTS requires --speaker (built-in voice name) or --ref-audio (voice cloning)');
input.speaker = job.speaker;
if (job.instruct) input.instruct = job.instruct;
}
return {
$type: 'textToSpeech',
name: job.name || `step_${stepIndex}`,
metadata: {
stepIndex,
...(job.label ? { label: job.label } : {}),
},
input,
};
}
// ---------------------------------------------------------------------------
// Music — ACE Step 1.5 audio generation
// ---------------------------------------------------------------------------
export function buildMusicStep(job, stepIndex) {
if (!job.prompt) throw new Error('Music generation requires --prompt');
const input = {
prompt: job.prompt,
};
if (job.lyrics) input.lyrics = job.lyrics;
if (job.duration != null) input.duration = job.duration;
if (job.model) input.model = job.model;
return {
$type: 'aceStepAudio',
name: job.name || `step_${stepIndex}`,
metadata: {
stepIndex,
...(job.label ? { label: job.label } : {}),
},
input,
};
}
// ---------------------------------------------------------------------------
// Transcription — Speech-to-text / ASR
// ---------------------------------------------------------------------------
export function buildTranscriptionStep(job, stepIndex) {
if (!job.mediaUrl) throw new Error('Transcription requires --media-url');
const input = {
mediaUrl: job.mediaUrl,
};
if (job.language) input.language = job.language;
if (job.context) input.context = job.context;
if (job.timestamps) input.returnTimeStamps = true;
return {
$type: 'transcription',
name: job.name || `step_${stepIndex}`,
metadata: {
stepIndex,
...(job.label ? { label: job.label } : {}),
},
input,
};
}
// ---------------------------------------------------------------------------
// Audio-specific CLI arg extensions
// ---------------------------------------------------------------------------
export const AUDIO_ARG_HANDLERS = {
// TTS
'--text': (opts, next) => { opts.text = next(); },
'--speaker': (opts, next) => { opts.speaker = next(); },
'--instruct': (opts, next) => { opts.instruct = next(); },
'--language': (opts, next) => { opts.language = next(); },
'--ref-audio': (opts, next) => { opts.refAudioUrl = next(); },
'--ref-text': (opts, next) => { opts.refText = next(); },
'--x-vector-only': (opts) => { opts.xVectorOnly = true; },
// Music
'--lyrics': (opts, next) => { opts.lyrics = next(); },
// Transcription / STT
'--media-url': (opts, next) => { opts.mediaUrl = next(); },
'--media-file': (opts, next) => { opts.mediaFile = next(); },
'--mic': (opts, next) => {
const val = next();
opts.micDuration = val ? parseInt(val, 10) : 10;
},
'--context': (opts, next) => { opts.context = next(); },
'--timestamps': (opts) => { opts.timestamps = true; },
};
export const AUDIO_HELP = `
TEXT-TO-SPEECH (tts)
--text <text> Text to synthesize into speech (required)
--speaker <name> Built-in speaker name (e.g. "Chelsie", "dylan")
--instruct <text> Style/tone instruction (e.g. "cheerful and enthusiastic")
--language <lang> Language (default: English)
--ref-audio <url> Reference audio URL for voice cloning (Base mode)
--ref-text <text> Transcript of reference audio (improves cloning quality)
--x-vector-only Use speaker embedding only (no ref transcript needed)
MUSIC GENERATION (music)
--prompt <text> Text description of the music to generate (required)
--lyrics <text> Structured lyrics for the song
--duration <sec> Duration in seconds
--model <air> Model identifier (AIR format)
TRANSCRIPTION / STT (transcribe, stt)
--media-url <url> URL or AIR URN of audio/media file
--media-file <path> Local audio file (auto-uploaded to blob)
--mic <seconds> Record from microphone first (default: 10s)
--language <lang> Language hint (e.g. "en") — improves accuracy
--context <text> Context about the audio (e.g. "Technical podcast")
--timestamps Return word-level timestamps
Positional args are treated as local file paths for transcription.
`;
// lib/image.mjs — Image generation step builder & ecosystem configuration
//
// Exports: ECOSYSTEM_CONFIGS, DEFAULT_ECOSYSTEM, RESOLUTION_MULTIPLIERS,
// buildImageStep, detectEcosystem, parseResources, resolveDimensions
//
// Zero npm dependencies. Requires Node 18+.
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const DEFAULT_ECOSYSTEM = 'flux1';
// ---------------------------------------------------------------------------
// Ecosystem & aspect ratio configuration
// ---------------------------------------------------------------------------
export const ECOSYSTEM_CONFIGS = {
sd1: {
label: 'SD 1.5',
aspects: {
square: { width: 512, height: 512 },
landscape: { width: 768, height: 512 },
portrait: { width: 512, height: 768 },
},
},
sdxl: {
label: 'SDXL / Pony / Illustrious',
aspects: {
square: { width: 1024, height: 1024 },
landscape: { width: 1216, height: 832 },
portrait: { width: 832, height: 1216 },
},
},
flux1: {
label: 'Flux.1',
aspects: {
square: { width: 1024, height: 1024 },
landscape: { width: 1216, height: 832 },
portrait: { width: 832, height: 1216 },
},
},
flux2: {
label: 'Flux.2',
aspects: {
square: { width: 1024, height: 1024 },
landscape: { width: 1216, height: 832 },
portrait: { width: 832, height: 1216 },
},
},
auraflow: {
label: 'Pony V7',
aspects: {
square: { width: 1536, height: 1536 },
landscape: { width: 1536, height: 1024 },
portrait: { width: 1024, height: 1536 },
},
},
qwen: {
label: 'Qwen',
aspects: {
square: { width: 1328, height: 1328 },
landscape: { width: 1664, height: 928 },
portrait: { width: 928, height: 1664 },
},
},
zimage: {
label: 'Z-Image',
aspects: {
square: { width: 1024, height: 1024 },
landscape: { width: 1216, height: 832 },
portrait: { width: 832, height: 1216 },
},
},
chroma: {
label: 'Chroma',
aspects: {
square: { width: 1024, height: 1024 },
landscape: { width: 1216, height: 832 },
portrait: { width: 832, height: 1216 },
},
},
hidream: {
label: 'HiDream',
aspects: {
square: { width: 1024, height: 1024 },
landscape: { width: 1216, height: 832 },
portrait: { width: 832, height: 1216 },
},
},
seedream: {
label: 'Seedream',
aspects: {
square: { width: 2048, height: 2048 },
landscape: { width: 2560, height: 1440 },
portrait: { width: 1440, height: 2560 },
},
},
nanobanana: {
label: 'Nano Banana',
aspects: {
square: { width: 2048, height: 2048 },
landscape: { width: 2560, height: 1440 },
portrait: { width: 1440, height: 2560 },
},
},
};
export const RESOLUTION_MULTIPLIERS = { small: 0.75, medium: 1.0, large: 1.5 };
// ---------------------------------------------------------------------------
// AIR URN parsing & resource handling
// ---------------------------------------------------------------------------
export function parseAirUrn(urn) {
if (!urn || !urn.startsWith('urn:air:')) return null;
const parts = urn.slice(8).split(':');
if (parts.length < 4) return null;
const ecosystem = parts[0];
const type = parts[1];
const source = parts[2];
const idPart = parts.slice(3).join(':');
const [modelId, versionId] = idPart.split('@');
return { ecosystem, type, source, modelId, versionId };
}
export function detectEcosystem(modelUrn) {
const parsed = parseAirUrn(modelUrn);
if (!parsed) return DEFAULT_ECOSYSTEM;
const eco = parsed.ecosystem.toLowerCase();
if (ECOSYSTEM_CONFIGS[eco]) return eco;
const stripped = eco.replace(/-/g, '');
if (ECOSYSTEM_CONFIGS[stripped]) return stripped;
if (eco === 'sd1') return 'sd1';
if (eco === 'sdxl' || eco === 'pony') return 'sdxl';
if (eco.startsWith('flux1')) return 'flux1';
if (eco.startsWith('flux2')) return 'flux2';
return DEFAULT_ECOSYSTEM;
}
function mapUrnTypeToApiType(urnType) {
const map = {
checkpoint: 'Checkpoint',
lora: 'Lora',
lycoris: 'LoCon',
dora: 'DoRA',
embedding: 'TextualInversion',
hypernet: 'Hypernetwork',
vae: 'VAE',
};
return map[urnType?.toLowerCase()] || 'Lora';
}
export function parseResources(resourceStr) {
if (!resourceStr) return [];
const resources = [];
const entries = resourceStr.split(',').map((s) => s.trim()).filter(Boolean);
for (const entry of entries) {
const atIdx = entry.lastIndexOf('@');
if (atIdx === -1) throw new Error(`Invalid resource AIR URN (missing @): ${entry}`);
const afterAt = entry.slice(atIdx + 1);
const colonIdx = afterAt.indexOf(':');
let air, weight;
if (colonIdx !== -1) {
const weightStr = afterAt.slice(colonIdx + 1);
const parsed = parseFloat(weightStr);
if (!isNaN(parsed)) {
weight = parsed;
air = entry.slice(0, atIdx + 1 + colonIdx);
} else {
air = entry;
weight = 1.0;
}
} else {
air = entry;
weight = 1.0;
}
const parsedUrn = parseAirUrn(air);
const type = parsedUrn ? mapUrnTypeToApiType(parsedUrn.type) : 'Lora';
resources.push({ air, weight, type });
}
return resources;
}
export function resolveDimensions(opts) {
if (opts.widthExplicit && opts.heightExplicit) {
return { width: opts.width, height: opts.height };
}
const ecosystem = detectEcosystem(opts.model);
const config = ECOSYSTEM_CONFIGS[ecosystem] || ECOSYSTEM_CONFIGS[DEFAULT_ECOSYSTEM];
const aspect = opts.aspect || 'square';
const aspectDims = config.aspects[aspect];
if (!aspectDims) {
const available = Object.keys(config.aspects).join(', ');
throw new Error(`Unknown aspect "${aspect}" for ${config.label}. Available: ${available}`);
}
let { width, height } = aspectDims;
if (opts.resolution && opts.resolution !== 'medium') {
const mult = RESOLUTION_MULTIPLIERS[opts.resolution];
if (!mult) throw new Error(`Unknown resolution "${opts.resolution}". Use: small, medium, large`);
width = Math.round((width * mult) / 8) * 8;
height = Math.round((height * mult) / 8) * 8;
}
if (opts.widthExplicit) width = opts.width;
if (opts.heightExplicit) height = opts.height;
return { width, height };
}
// ---------------------------------------------------------------------------
// Build image workflow step
// ---------------------------------------------------------------------------
export function buildImageStep(job, stepIndex) {
const dims = resolveDimensions(job);
const resources = parseResources(job.resources);
const additionalNetworks = {};
for (const res of resources) {
additionalNetworks[res.air] = {
type: res.type,
strength: res.weight,
};
}
const input = {
prompt: job.prompt,
quantity: job.quantity ?? 1,
width: dims.width,
height: dims.height,
};
if (job.model) input.model = job.model;
if (job.negativePrompt) input.negativePrompt = job.negativePrompt;
if (job.steps) input.steps = job.steps;
if (job.cfgScale) input.cfgScale = job.cfgScale;
if (job.scheduler) input.scheduler = job.scheduler;
if (job.seed != null) input.seed = job.seed;
if (Object.keys(additionalNetworks).length > 0) {
input.additionalNetworks = additionalNetworks;
}
// img2img
if (job.sourceImage) {
input.image = job.sourceImage;
if (job.denoise != null) input.sourceImageDenoiseStrenght = job.denoise;
}
return {
$type: 'textToImage',
name: job.name || `step_${stepIndex}`,
timeout: '00:20:00',
metadata: {
stepIndex,
...(job.label ? { label: job.label } : {}),
},
input,
};
}
// ---------------------------------------------------------------------------
// Image-specific CLI arg extensions
// ---------------------------------------------------------------------------
export const IMAGE_ARG_HANDLERS = {
'--model': (opts, next) => { opts.model = next(); },
'--negative-prompt': (opts, next) => { opts.negativePrompt = next(); },
'--steps': (opts, next) => { opts.steps = parseInt(next(), 10); },
'--cfg-scale': (opts, next) => { opts.cfgScale = parseFloat(next()); },
'--scheduler': (opts, next) => { opts.scheduler = next(); },
'--seed': (opts, next) => { opts.seed = parseInt(next(), 10); },
'--format': (opts, next) => { opts.format = next(); },
'--quantity': (opts, next) => { opts.quantity = parseInt(next(), 10); },
'-n': (opts, next) => { opts.quantity = parseInt(next(), 10); },
'--resources': (opts, next) => { opts.resources = next(); },
'--aspect': (opts, next) => { opts.aspect = next(); },
'--resolution': (opts, next) => { opts.resolution = next(); },
'--width': (opts, next) => { opts.width = parseInt(next(), 10); opts.widthExplicit = true; },
'--height': (opts, next) => { opts.height = parseInt(next(), 10); opts.heightExplicit = true; },
'--source-image': (opts, next) => { opts.sourceImage = next(); },
'--denoise': (opts, next) => { opts.denoise = parseFloat(next()); },
};
export const IMAGE_HELP = `
IMAGE GENERATION (submit / wait)
--prompt <text> Text prompt (repeatable for multiple workflow steps)
--bulk <file.json> JSON array of job definitions
--model <air> Checkpoint model AIR URN
--resources <list> Comma-separated resources (LoRA AIRs with :weight)
--negative-prompt <t> Negative prompt
--quantity / -n <num> Images per step (default: 4)
--aspect <name> Aspect ratio preset (square, landscape, portrait)
--resolution <size> Resolution scale (small, medium, large)
--width / --height Explicit dimensions (overrides aspect)
--steps <num> Sampling steps
--cfg-scale <num> CFG scale
--scheduler <name> Sampler algorithm
--seed <num> Seed for reproducibility
--source-image <url> Source image URL for img2img
--denoise <0-1> Denoise strength for img2img
`;
// lib/video.mjs — Video generation step builder & engine registry
//
// Exports: VIDEO_ENGINE_REGISTRY, buildVideoStep, VIDEO_ARG_HANDLERS, VIDEO_HELP
//
// Zero npm dependencies. Requires Node 18+.
// ---------------------------------------------------------------------------
// Video engine registry — static capabilities for known engines.
// Unknown engines from the API still work in pass-through mode.
// ---------------------------------------------------------------------------
export const VIDEO_ENGINE_REGISTRY = {
veo3: {
label: 'Google VEO 3',
processes: ['txt2vid', 'img2vid'],
durations: [4, 6, 8],
aspectRatios: ['16:9', '9:16'],
defaults: { duration: 8, aspectRatio: '16:9', fastMode: true, version: '3.0' },
features: ['audio', 'lora', 'fastMode'],
notes: 'PG model — profanity/explicit language returns generic video, no refund.',
},
kling: {
label: 'Kling',
processes: ['txt2vid', 'img2vid'],
durations: [5, 10],
aspectRatios: ['16:9', '1:1', '9:16'],
defaults: { duration: 5, aspectRatio: '16:9', model: 'v2.5-turbo' },
features: ['cameraControl'],
models: ['v1.6', 'v2', 'v2.5-turbo'],
},
wan: {
label: 'Wan Video',
processes: ['txt2vid', 'img2vid'],
durations: [3, 5, 8, 10],
aspectRatios: ['16:9', '1:1', '9:16'],
defaults: { duration: 5, aspectRatio: '16:9', version: 'v2.5' },
features: ['lora', 'interpolation'],
versions: ['v2.1', 'v2.2', 'v2.2-5b', 'v2.5', 'v2.6'],
},
vidu: {
label: 'Vidu 2.0 / Q3',
processes: ['txt2vid', 'img2vid', 'ref2vid'],
durations: [4, 8],
aspectRatios: ['16:9', '1:1', '9:16'],
defaults: { duration: 4, aspectRatio: '16:9' },
features: ['movementAmplitude', 'style', 'promptEnhancer'],
},
sora: {
label: 'Sora 2',
processes: ['txt2vid', 'img2vid'],
durations: [4, 8],
aspectRatios: ['16:9', '9:16'],
defaults: { duration: 4, aspectRatio: '16:9' },
features: ['proMode'],
resolutions: ['720p', '1080p'],
},
haiper: {
label: 'Haiper 2.0',
processes: ['txt2vid', 'img2vid'],
durations: [2, 4, 8],
aspectRatios: ['16:9', '4:3', '1:1', '3:4', '9:16'],
defaults: { duration: 4, aspectRatio: '16:9' },
resolutions: ['720', '1080', '2160'],
},
mochi: {
label: 'Mochi 1',
processes: ['txt2vid'],
durations: [],
aspectRatios: [],
defaults: {},
features: ['promptEnhancer'],
},
hunyuan: {
label: 'Hunyuan Video',
processes: ['txt2vid'],
durations: [3, 5],
aspectRatios: ['16:9', '3:2', '1:1', '2:3', '9:16'],
defaults: { duration: 5, aspectRatio: '16:9' },
features: ['lora'],
},
minimax: {
label: 'Hailuo by MiniMax',
processes: ['txt2vid', 'img2vid'],
durations: [],
aspectRatios: [],
defaults: {},
features: ['promptEnhancer'],
},
lightricks: {
label: 'Lightricks',
processes: ['txt2vid', 'img2vid'],
durations: [5],
aspectRatios: ['16:9', '9:16'],
defaults: { duration: 5, aspectRatio: '16:9' },
},
ltx2: {
label: 'LTX Video 2',
processes: ['txt2vid', 'img2vid'],
durations: [3, 5],
aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4'],
defaults: { duration: 5, aspectRatio: '16:9' },
features: ['audio', 'distilled'],
},
grok: {
label: 'Grok Video (xAI)',
processes: ['txt2vid', 'img2vid', 'edit2vid'],
durations: [1, 4, 6, 15],
aspectRatios: ['16:9', '4:3', '3:2', '1:1', '2:3', '3:4', '9:16'],
defaults: { duration: 6, aspectRatio: '16:9' },
features: ['edit'],
resolutions: ['480p', '720p'],
notes: 'Per-second pricing (~65–104 buzz/s). img2vid supports aspectRatio "auto".',
},
happyHorse: {
label: 'Happy-Horse (Alibaba)',
processes: ['txt2vid', 'img2vid', 'edit2vid', 'ref2vid'],
durations: [3, 5, 15],
aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4'],
defaults: { duration: 5, aspectRatio: '16:9' },
features: ['multiReference', 'videoEdit'],
resolutions: ['720p', '1080p'],
notes: 'videoEdit billed at double rate (input + output seconds). sourceVideo must be Civitai-hosted URL/AIR.',
},
};
// ---------------------------------------------------------------------------
// Build video workflow step
// ---------------------------------------------------------------------------
export function buildVideoStep(job, stepIndex) {
const engine = job.engine;
const registry = VIDEO_ENGINE_REGISTRY[engine] || {};
const defaults = registry.defaults || {};
const input = {
engine,
prompt: job.prompt,
};
if (job.sourceImage) {
input.sourceImage = job.sourceImage;
}
if (job.images) {
input.images = Array.isArray(job.images) ? job.images : [job.images];
} else if (job.sourceImage && ['veo3', 'sora', 'wan'].includes(engine)) {
input.images = [job.sourceImage];
delete input.sourceImage;
}
const duration = job.duration ?? defaults.duration;
if (duration != null) input.duration = duration;
const aspectRatio = job.videoAspect ?? defaults.aspectRatio;
if (aspectRatio) input.aspectRatio = aspectRatio;
if (job.negativePrompt) input.negativePrompt = job.negativePrompt;
if (job.seed != null) input.seed = job.seed;
if (job.cfgScale != null) input.cfgScale = job.cfgScale;
if (job.generateAudio != null) input.generateAudio = job.generateAudio;
if (job.fastMode != null) input.fastMode = job.fastMode;
else if (defaults.fastMode != null) input.fastMode = defaults.fastMode;
if (job.version) input.version = job.version;
else if (defaults.version) input.version = defaults.version;
if (job.videoModel) input.model = job.videoModel;
else if (defaults.model) input.model = defaults.model;
if (job.enablePromptEnhancer != null) input.enablePromptEnhancer = job.enablePromptEnhancer;
if (job.videoResolution) input.resolution = job.videoResolution;
if (job.movementAmplitude) input.movementAmplitude = job.movementAmplitude;
if (job.style) input.style = job.style;
if (job.usePro != null) input.usePro = job.usePro;
if (job.engineParams && typeof job.engineParams === 'object') {
Object.assign(input, job.engineParams);
}
return {
$type: 'videoGen',
name: job.name || `step_${stepIndex}`,
metadata: {
stepIndex,
...(job.label ? { label: job.label } : {}),
},
input,
};
}
// ---------------------------------------------------------------------------
// Video-specific CLI arg extensions
// ---------------------------------------------------------------------------
export const VIDEO_ARG_HANDLERS = {
'--engine': (opts, next) => { opts.engine = next(); },
'--duration': (opts, next) => { opts.duration = parseInt(next(), 10); },
'--video-aspect': (opts, next) => { opts.videoAspect = next(); },
'--generate-audio': (opts) => { opts.generateAudio = true; },
'--no-audio': (opts) => { opts.generateAudio = false; },
'--fast-mode': (opts) => { opts.fastMode = true; },
'--no-fast-mode': (opts) => { opts.fastMode = false; },
'--version': (opts, next) => { opts.version = next(); },
'--video-model': (opts, next) => { opts.videoModel = next(); },
'--prompt-enhancer': (opts) => { opts.enablePromptEnhancer = true; },
'--video-resolution': (opts, next) => { opts.videoResolution = next(); },
'--movement': (opts, next) => { opts.movementAmplitude = next(); },
'--style': (opts, next) => { opts.style = next(); },
'--pro': (opts) => { opts.usePro = true; },
};
export const VIDEO_HELP = `
VIDEO GENERATION (submit / wait — requires --engine)
--engine <name> Video engine (veo3, kling, wan, vidu, sora, etc.)
--prompt <text> Video prompt (what should happen in the video)
--source-image <url> Source image for img2vid (animate an image)
--duration <sec> Video duration in seconds
--video-aspect <ratio> Aspect ratio (16:9, 9:16, 1:1)
--generate-audio Enable audio generation (veo3, ltx2)
--no-audio Disable audio generation
--fast-mode Use fast/turbo mode (veo3)
--no-fast-mode Use standard mode
--version <ver> Engine version (e.g., 3.0 for veo3, v2.5 for wan)
--video-model <model> Engine-specific model variant (e.g., v2.5-turbo for kling)
--prompt-enhancer Enable prompt enhancement
--video-resolution <r> Resolution (720p, 1080p, etc.)
--movement <amp> Movement amplitude: auto, small, medium, large (vidu)
--style <name> Style preset (e.g., anime for vidu)
--pro Use pro/professional mode (sora, kling)
`;