@wavespeed/cli 0.4.21 → 0.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,6 +147,10 @@ wavespeed setup [agent] Point a coding agent (claude, codex, ge
147
147
  wavespeed run [model|alias] -p "…" Run any model or alias (uses defaultModel if omitted)
148
148
  wavespeed run <model|alias> --sync Attempt sync wait; timed-out tasks remain queryable
149
149
  wavespeed run <model|alias> -h Dynamic schema-based help (alias-aware)
150
+ wavespeed generate image|video "…" /chat models; picks the endpoint from your inputs (--image/--first-frame/--ref-*)
151
+ wavespeed edit image|video "…" Edit images (--image) or a video (--video)
152
+ wavespeed extend video "…" --video … Continue a video
153
+ Shortcuts adapt the request to the model (printed); add --estimate to price without running
150
154
  wavespeed schema <model> Pretty-print a model's input schema
151
155
  wavespeed models [query] Browse the live catalog (cached 1h)
152
156
  wavespeed aliases List aliases from wavespeed.json + user config
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import { printBanner } from './lib/banner.js';
5
5
  import { registerAuth } from './commands/auth.js';
6
6
  import { registerRun } from './commands/run.js';
7
+ import { registerMedia } from './commands/media.js';
7
8
  import { registerUpload } from './commands/upload.js';
8
9
  import { registerDownload } from './commands/download.js';
9
10
  import { registerModels } from './commands/models.js';
@@ -46,6 +47,7 @@ ${chalk.gray('Docs:')} ${chalk.cyan('https://wavespeed.ai/docs')} ${chalk.gra
46
47
  `);
47
48
  registerAuth(program);
48
49
  registerRun(program);
50
+ registerMedia(program);
49
51
  registerUpload(program);
50
52
  registerDownload(program);
51
53
  registerModels(program);
@@ -0,0 +1,177 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import path from 'node:path';
4
+ import { requireClient } from '../lib/client.js';
5
+ import { resolveLocalFiles } from '../lib/local-files.js';
6
+ import { fetchModels, fetchPricing, submitPrediction, waitForPrediction } from '../lib/api.js';
7
+ import { uploadWithCache } from '../lib/upload-cache.js';
8
+ import { downloadOutputs, printOutputs } from '../lib/output.js';
9
+ import { emitJson, emitJsonError, isJsonMode, log, setJsonMode } from '../lib/log.js';
10
+ import { downloadOptsFromFlag } from '../lib/download-flag.js';
11
+ import { IMAGE_MODEL_KEYS, VIDEO_MODEL_KEYS } from '../lib/media-models.js';
12
+ import { planImage, planVideo } from '../lib/media-plan.js';
13
+ // The shortcut verbs: generate / edit / extend. They use the models of the
14
+ // WaveSpeed /chat page and, unlike `run`, deliberately adapt the request to the
15
+ // chosen model (endpoint, enum snapping, prompt references). Every adaptation
16
+ // is printed, and `--estimate` shows the final input without spending anything.
17
+ // `wavespeed run <model>` stays the exact, unmodified path.
18
+ async function catalogProps(modelId) {
19
+ const { models } = await fetchModels();
20
+ const schema = models.find((m) => m.model_id === modelId)?.api_schema?.api_schemas?.[0]?.request_schema;
21
+ return schema?.properties ?? null;
22
+ }
23
+ const collect = (v, prev) => [...prev, v];
24
+ function addCommon(cmd, models) {
25
+ return cmd
26
+ .argument('<prompt>', 'What to create or change. Refer to media as "@image 1", "@video 1", "@audio 1".')
27
+ .option('-m, --model <name>', `Model (${models.join(', ')}); default ${models[0]}. Without it another model is used only if the default cannot take the inputs`)
28
+ .option('--estimate', 'Show the endpoint, final input and price, then stop without submitting')
29
+ .option('--download [path]', 'Save outputs locally (optional path template, e.g. "./out/{index}.{ext}")')
30
+ .option('--output-dir <dir>', 'Directory for --download when no path is given')
31
+ .option('--json', 'Emit a single JSON object on stdout (progress goes to stderr)');
32
+ }
33
+ async function execute(prompt, opts, build, media, rest) {
34
+ setJsonMode(!!opts.json);
35
+ const fail = (message) => {
36
+ if (isJsonMode())
37
+ emitJsonError(message);
38
+ else
39
+ console.error(chalk.red('Error: ') + message);
40
+ process.exit(1);
41
+ };
42
+ const client = requireClient();
43
+ let plan;
44
+ let uploaded = 0;
45
+ try {
46
+ const spinner = !isJsonMode() ? ora({ color: 'magenta' }) : null;
47
+ const resolved = await resolveLocalFiles(media, {
48
+ upload: async (file) => (await uploadWithCache(file, (f) => client.upload(f))).url,
49
+ onUpload: (file, i, total) => spinner?.start(total > 1 ? `Uploading ${path.basename(file)} (${i + 1}/${total})…` : `Uploading ${path.basename(file)}…`),
50
+ });
51
+ uploaded = resolved.uploaded;
52
+ if (uploaded > 0)
53
+ spinner?.succeed(`Uploaded ${uploaded} file${uploaded === 1 ? '' : 's'}.`);
54
+ plan = await build({ ...rest, ...resolved.input, prompt, model: opts.model });
55
+ }
56
+ catch (err) {
57
+ return fail(err.message ?? String(err));
58
+ }
59
+ let estimate = null;
60
+ let estimateError;
61
+ try {
62
+ estimate = await fetchPricing(plan.modelId, plan.input);
63
+ }
64
+ catch (err) {
65
+ estimateError = err?.message ?? String(err);
66
+ }
67
+ log('');
68
+ log(chalk.bold('▶ ') + chalk.cyan(plan.modelId) + chalk.gray(` (${plan.label}, ${plan.mode})`));
69
+ for (const n of plan.notes)
70
+ log(chalk.yellow(' · ') + n);
71
+ const price = estimate;
72
+ const cost = price?.discounted_price ?? price?.price;
73
+ if (cost !== undefined)
74
+ log(chalk.gray(' estimate: ') + chalk.white(`$${cost}`));
75
+ if (opts.estimate) {
76
+ if (isJsonMode())
77
+ emitJson({ estimate_only: true, model: plan.modelId, label: plan.label, mode: plan.mode, input: plan.input, notes: plan.notes, estimate, ...(estimateError ? { estimate_error: estimateError } : {}) });
78
+ else
79
+ log(chalk.gray(' input: ') + JSON.stringify(plan.input));
80
+ return;
81
+ }
82
+ const startedAt = Date.now();
83
+ const spinner = ora({ text: 'Submitting…', color: 'magenta' });
84
+ if (!isJsonMode())
85
+ spinner.start();
86
+ let id;
87
+ try {
88
+ const submitted = await submitPrediction(plan.modelId, plan.input);
89
+ id = submitted.id;
90
+ if (!id)
91
+ throw new Error(`No prediction ID in response: ${JSON.stringify(submitted)}`);
92
+ if (!isJsonMode()) {
93
+ spinner.stopAndPersist({ symbol: chalk.gray('·'), text: chalk.gray('prediction: ') + chalk.white(id) });
94
+ spinner.start('Generating…');
95
+ }
96
+ const onInt = () => {
97
+ process.stderr.write('\n' + chalk.yellow('Interrupted — the task keeps running on the server.') + '\n' + chalk.gray(' resume: ') + chalk.cyan(`wavespeed show ${id}`) + '\n');
98
+ process.exit(130);
99
+ };
100
+ process.on('SIGINT', onInt);
101
+ const iv = setInterval(() => { spinner.text = `Generating… ${Math.floor((Date.now() - startedAt) / 1000)}s`; }, 200);
102
+ let result;
103
+ try {
104
+ result = submitted.status === 'completed' ? submitted : await waitForPrediction(id, { intervalMs: 1000 });
105
+ }
106
+ finally {
107
+ clearInterval(iv);
108
+ process.removeListener('SIGINT', onInt);
109
+ }
110
+ spinner.succeed(`Done in ${Math.floor((Date.now() - startedAt) / 1000)}s.`);
111
+ const urls = (result.outputs ?? []).filter((u) => typeof u === 'string');
112
+ let saved = [];
113
+ const dl = downloadOptsFromFlag(opts.download, { model: plan.modelId, prompt, outputDir: opts.outputDir });
114
+ if (urls.length > 0 && dl)
115
+ saved = await downloadOutputs(urls, dl);
116
+ if (isJsonMode()) {
117
+ emitJson({ id, model: plan.modelId, label: plan.label, mode: plan.mode, prompt, outputs: urls, saved, notes: plan.notes, estimate, elapsed_ms: Date.now() - startedAt, raw: result });
118
+ return;
119
+ }
120
+ if (urls.length === 0)
121
+ log(chalk.yellow('No output URLs in response.'));
122
+ else
123
+ printOutputs(saved, urls);
124
+ }
125
+ catch (err) {
126
+ const message = err.message ?? String(err);
127
+ spinner.fail(message);
128
+ if (id && !isJsonMode())
129
+ log(chalk.gray(' query: ') + chalk.cyan(`wavespeed show ${id}`));
130
+ if (isJsonMode())
131
+ emitJsonError(message, id ? { prediction_id: id, query_command: `wavespeed show ${id}` } : {});
132
+ process.exit(1);
133
+ }
134
+ }
135
+ export function registerMedia(program) {
136
+ const generate = program.command('generate').description('Create an image or video with the /chat models (picks the endpoint from your inputs)');
137
+ const edit = program.command('edit').description('Edit existing images or a video with the /chat models');
138
+ const extend = program.command('extend').description('Continue an existing video');
139
+ addCommon(generate.command('image'), IMAGE_MODEL_KEYS)
140
+ .description('Create an image; with --image, use the pictures as subject/product/face/style')
141
+ .option('--image <ref...>', 'Reference image URL or @path (repeatable)', collect, [])
142
+ .option('--aspect-ratio <r>', 'e.g. 1:1, 16:9, 9:16 (snapped to what the model supports)')
143
+ .option('--resolution <r>', '1k, 2k or 4k')
144
+ .action((prompt, o) => execute(prompt, o, (a) => planImage(a, catalogProps), { reference_images: o.image }, { aspect_ratio: o.aspectRatio, resolution: o.resolution }));
145
+ addCommon(edit.command('image'), IMAGE_MODEL_KEYS)
146
+ .description('Change existing images: edits, restyles, backgrounds, combining several')
147
+ .requiredOption('--image <ref...>', 'Image URL or @path to edit (repeatable)', collect, [])
148
+ .option('--aspect-ratio <r>', 'e.g. 1:1, 16:9, 9:16')
149
+ .option('--resolution <r>', '1k, 2k or 4k')
150
+ .action((prompt, o) => execute(prompt, o, (a) => planImage(a, catalogProps), { images: o.image }, { aspect_ratio: o.aspectRatio, resolution: o.resolution }));
151
+ const videoOpts = (c) => c
152
+ .option('--duration <s>', 'Seconds', (v) => parseInt(v, 10))
153
+ .option('--resolution <r>', '480p, 540p, 720p, 768p or 1080p (MiniMax H3 maps 720p to 768p)')
154
+ .option('--aspect-ratio <r>', '16:9, 9:16, 1:1, 4:3, 3:4, 21:9');
155
+ const videoRest = (o) => ({ duration: o.duration, resolution: o.resolution, aspect_ratio: o.aspectRatio });
156
+ videoOpts(addCommon(generate.command('video'), VIDEO_MODEL_KEYS))
157
+ .description('Create a video; text, image or reference mode is picked from the inputs')
158
+ .option('--first-frame <ref>', 'Image to start from (URL or @path)')
159
+ .option('--last-frame <ref>', 'Image to end on (needs --first-frame)')
160
+ .option('--ref-image <ref...>', 'Reference image (repeatable)', collect, [])
161
+ .option('--ref-video <ref...>', 'Reference video (repeatable)', collect, [])
162
+ .option('--ref-audio <ref...>', 'Reference audio (repeatable)', collect, [])
163
+ .action((prompt, o) => execute(prompt, o, (a) => planVideo(a, 'generate', catalogProps), { first_frame: o.firstFrame, last_frame: o.lastFrame, reference_images: o.refImage, reference_videos: o.refVideo, reference_audios: o.refAudio }, videoRest(o)));
164
+ videoOpts(addCommon(edit.command('video'), VIDEO_MODEL_KEYS))
165
+ .description('Edit a video; name it and any references in the prompt ("replace the cat in @video 1 with @image 1")')
166
+ .requiredOption('--video <ref>', 'Video to edit (URL or @path)')
167
+ .option('--ref-image <ref...>', 'Reference image (repeatable)', collect, [])
168
+ .option('--ref-audio <ref...>', 'Reference audio (repeatable)', collect, [])
169
+ .action((prompt, o) => execute(prompt, o, (a) => planVideo(a, 'edit', catalogProps), { video: o.video, reference_images: o.refImage, reference_audios: o.refAudio }, videoRest(o)));
170
+ videoOpts(addCommon(extend.command('video'), VIDEO_MODEL_KEYS))
171
+ .description('Continue a video from where it ends')
172
+ .requiredOption('--video <ref>', 'Video to extend (URL or @path)')
173
+ .option('--last-frame <ref>', 'Image to end on')
174
+ .option('--ref-image <ref...>', 'Reference image (repeatable)', collect, [])
175
+ .option('--ref-audio <ref...>', 'Reference audio (repeatable)', collect, [])
176
+ .action((prompt, o) => execute(prompt, o, (a) => planVideo(a, 'extend', catalogProps), { video: o.video, last_frame: o.lastFrame, reference_images: o.refImage, reference_audios: o.refAudio }, videoRest(o)));
177
+ }
@@ -18,6 +18,8 @@ const GOOSE_PROVIDER_FILE = path.join(GOOSE_DIR, 'custom_providers', 'wavespeed.
18
18
  const AGENTS = [
19
19
  { id: 'codex', name: 'Codex', file: path.join(home, '.codex', 'config.toml'), command: 'codex', kind: 'toml', keyInFile: false },
20
20
  { id: 'claude', name: 'Claude Code', file: path.join(home, '.claude', 'settings.json'), command: 'claude', kind: 'json', keyInFile: true },
21
+ // Gemini CLI reads the base URL and key from ~/.gemini/.env (trusted folders only); the model and auth type live in settings.json.
22
+ { id: 'gemini', name: 'Gemini CLI', file: path.join(home, '.gemini', '.env'), command: 'gemini', kind: 'gemini', keyInFile: true },
21
23
  { id: 'opencode', name: 'OpenCode', file: path.join(home, '.config', 'opencode', 'opencode.json'), command: 'opencode', kind: 'json', keyInFile: false },
22
24
  { id: 'openclaw', name: 'OpenClaw', file: path.join(home, '.openclaw', 'openclaw.json'), command: 'openclaw', kind: 'json5', keyInFile: false },
23
25
  { id: 'hermes', name: 'Hermes', file: path.join(home, '.hermes', 'config.yaml'), command: 'hermes', kind: 'yaml', keyInFile: false },
@@ -30,8 +32,6 @@ const AGENTS = [
30
32
  { id: 'continue', name: 'Continue CLI', file: path.join(home, '.continue', 'config.yaml'), command: 'cn', kind: 'yamlseq', keyInFile: false },
31
33
  { id: 'goose', name: 'Goose', file: path.join(GOOSE_DIR, 'config.yaml'), command: 'goose', kind: 'goose', keyInFile: false },
32
34
  { id: 'dsh', name: 'DeepSeek Harness', file: path.join(process.env.DSH_HOME || path.join(home, '.dsh'), 'cordis.patch.yml'), command: 'dsh', kind: 'dsh', keyInFile: false },
33
- // Gemini CLI reads the base URL and key from ~/.gemini/.env (trusted folders only); the model and auth type live in settings.json.
34
- { id: 'gemini', name: 'Gemini CLI', file: path.join(home, '.gemini', '.env'), command: 'gemini', kind: 'gemini', keyInFile: true },
35
35
  // Cline's CLI keeps provider settings (including the key) in this file; we write it only through `cline auth`.
36
36
  { id: 'cline', name: 'Cline', file: path.join(home, '.cline', 'data', 'settings', 'providers.json'), command: 'cline', kind: 'cline', keyInFile: true },
37
37
  // Cline's CLI keeps provider settings (including the key) in this file; we write it only through `cline auth`.
@@ -0,0 +1,102 @@
1
+ // The image and video models the dedicated generate/edit tools offer: the
2
+ // same list, in the same order, as the WaveSpeed /chat page (api-dashboard
3
+ // lib/media-models.ts, checked 2026-10-26). The first of each is the default.
4
+ // This is a vendored copy: when /chat changes its models, change this file and
5
+ // its test (src/lib/media-models.test.ts) with it. Capabilities mirror the
6
+ // endpoints' input schemas; the tools still fit every request to the live
7
+ // schema, so a stale entry degrades to a trimmed request rather than a wrong one.
8
+ // The code is kept in the dashboard's style (no semicolons) so the two files diff cleanly.
9
+ const WIDE_RATIOS = ['1:1', '1:2', '2:1', '1:3', '3:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '9:21', '21:9'];
10
+ const NANO_BANANA_RATIOS = ['1:1', '3:2', '2:3', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'];
11
+ const NANO_BANANA_2_RATIOS = [...NANO_BANANA_RATIOS, '1:4', '4:1', '1:8', '8:1'];
12
+ const image = (id, label, base, generate, resolutions, aspectRatios, maxImages) => ({
13
+ id, label, generate: `${base}/${generate}`.replace(/\/$/, ''), edit: `${base}/edit`, resolutions, aspectRatios, maxImages,
14
+ });
15
+ /** In menu order; the first is the default. */
16
+ export const IMAGE_MODELS = {
17
+ 'seedream-5-pro': image('seedream-5-pro', 'Seedream 5 Pro', 'bytedance/seedream-v5.0-pro', '', ['1k', '1.5k', '2k'], WIDE_RATIOS, 10),
18
+ 'nano-banana-2.1': image('nano-banana-2.1', 'Nano Banana 2.1', 'google/nano-banana-2.1', 'text-to-image', ['1k', '2k', '4k'], NANO_BANANA_2_RATIOS, 14),
19
+ 'nano-banana-2': image('nano-banana-2', 'Nano Banana 2', 'google/nano-banana-2', 'text-to-image', ['0.5k', '1k', '2k', '4k'], NANO_BANANA_2_RATIOS, 14),
20
+ 'nano-banana-pro': image('nano-banana-pro', 'Nano Banana Pro', 'google/nano-banana-pro', 'text-to-image', ['1k', '2k', '4k'], NANO_BANANA_RATIOS, 14),
21
+ 'gpt-image-2.5': image('gpt-image-2.5', 'GPT Image 2.5', 'openai/gpt-image-2.5-sunburst', 'text-to-image', ['1k', '2k', '4k'], WIDE_RATIOS, 16),
22
+ 'gpt-image-2': image('gpt-image-2', 'GPT Image 2', 'openai/gpt-image-2', 'text-to-image', ['1k', '2k', '4k'], WIDE_RATIOS, 16),
23
+ };
24
+ export const IMAGE_MODEL_KEYS = Object.keys(IMAGE_MODELS);
25
+ export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_KEYS[0];
26
+ export function findImageModel(id) {
27
+ return IMAGE_MODELS[id] ?? IMAGE_MODELS[DEFAULT_IMAGE_MODEL];
28
+ }
29
+ const SEEDANCE_ASPECTS = ['16:9', '9:16', '4:3', '3:4', '1:1', '21:9'];
30
+ const ALL_RES = ['480p', '720p', '1080p', '4k'];
31
+ const TURBO_RES = ['720p', '1080p'];
32
+ const seedance = (id, label, base, turbo, v25) => ({
33
+ id, label, family: v25 ? 'seedance-2.5' : 'seedance-2.0', base, turbo,
34
+ // Turbo endpoints have no extend.
35
+ modes: turbo ? ['generate', 'edit'] : ['generate', 'edit', 'extend'],
36
+ resolutions: turbo ? TURBO_RES : ALL_RES, defaultResolution: '720p',
37
+ durations: v25 ? [4, 30] : [4, 15], aspectRatios: SEEDANCE_ASPECTS, audio: true,
38
+ maxRefs: v25 ? { images: 30, videos: 10, audios: 10 } : { images: 9, videos: 3, audios: 3 },
39
+ });
40
+ const wan = (id, label, base) => ({
41
+ id, label, family: 'wan-3.0', base, turbo: false, modes: ['generate', 'edit', 'extend'],
42
+ resolutions: ['480p', '720p', '1080p'], defaultResolution: '720p', durations: [2, 30],
43
+ aspectRatios: ['16:9', '9:16', '4:3', '3:4', '1:1'], audio: true, maxRefs: { images: 10, videos: 5, audios: 5 },
44
+ });
45
+ /** In menu order; the first is the default. */
46
+ export const VIDEO_MODELS = {
47
+ 'seedance-2.5': seedance('seedance-2.5', 'Seedance 2.5', 'bytedance/seedance-2.5', false, true),
48
+ 'seedance-2.5-turbo': seedance('seedance-2.5-turbo', 'Seedance 2.5 Turbo', 'bytedance/seedance-2.5', true, true),
49
+ 'seedance-2.0': seedance('seedance-2.0', 'Seedance 2.0', 'bytedance/seedance-2.0', false, false),
50
+ 'seedance-2.0-turbo': seedance('seedance-2.0-turbo', 'Seedance 2.0 Turbo', 'bytedance/seedance-2.0', true, false),
51
+ 'seedance-2.0-fast': seedance('seedance-2.0-fast', 'Seedance 2.0 Fast', 'bytedance/seedance-2.0-fast', false, false),
52
+ 'seedance-2.0-fast-turbo': seedance('seedance-2.0-fast-turbo', 'Seedance 2.0 Fast Turbo', 'bytedance/seedance-2.0-fast', true, false),
53
+ 'seedance-2.0-mini': seedance('seedance-2.0-mini', 'Seedance 2.0 Mini', 'bytedance/seedance-2.0-mini', false, false),
54
+ 'wan-3.0': wan('wan-3.0', 'Wan 3.0', 'alibaba/wan-3.0'),
55
+ 'wan-3.0-prime': wan('wan-3.0-prime', 'Wan 3.0 Prime', 'alibaba/wan-3.0-prime'),
56
+ 'kling-4.0': {
57
+ id: 'kling-4.0', label: 'Kling 4.0', family: 'kling-4.0', base: 'kwaivgi/kling-v4.0', turbo: false, modes: ['generate', 'edit', 'extend'],
58
+ resolutions: ['720p', '1080p'], defaultResolution: '720p', durations: [3, 30],
59
+ // Sound is always generated with the picture; there is no switch.
60
+ aspectRatios: ['16:9', '9:16', '1:1', '21:9'], audio: false, maxRefs: { images: 10, videos: 5, audios: 3 },
61
+ },
62
+ 'minimax-h3': {
63
+ id: 'minimax-h3', label: 'MiniMax H3', family: 'minimax-h3', base: 'wavespeed-ai/minimax-h3', turbo: false, modes: ['generate', 'edit', 'extend'],
64
+ resolutions: ['480p', '540p', '768p', '1080p'], defaultResolution: '768p', durations: [3, 15],
65
+ aspectRatios: ['16:9', '9:16', '4:3', '3:4', '1:1', '21:9', '9:21'], audio: false, maxRefs: { images: 9, videos: 3, audios: 3 },
66
+ },
67
+ };
68
+ export const VIDEO_MODEL_KEYS = Object.keys(VIDEO_MODELS);
69
+ export const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_KEYS[0];
70
+ export function findVideoModel(id) {
71
+ return VIDEO_MODELS[id] ?? VIDEO_MODELS[DEFAULT_VIDEO_MODEL];
72
+ }
73
+ /** The model a full endpoint id belongs to, e.g. bytedance/seedance-2.0-fast/image-to-video-turbo. */
74
+ export function videoModelForEndpoint(modelId) {
75
+ const turbo = modelId.endsWith('-turbo');
76
+ return VIDEO_MODEL_KEYS.map((k) => VIDEO_MODELS[k]).find((m) => m.turbo === turbo && modelId.startsWith(`${m.base}/`));
77
+ }
78
+ /**
79
+ * A model's endpoints. Wan, Kling 4.0 and H3 take references on a dedicated
80
+ * reference-to-video endpoint; Seedance takes them on text-to-video. Turbo
81
+ * endpoints have no extend.
82
+ */
83
+ export function videoEndpoints(m) {
84
+ const suffix = m.turbo ? '-turbo' : '';
85
+ return {
86
+ text: `${m.base}/text-to-video${suffix}`,
87
+ image: `${m.base}/image-to-video${suffix}`,
88
+ ...(m.family === 'wan-3.0' || m.family === 'kling-4.0' || m.family === 'minimax-h3' ? { reference: `${m.base}/reference-to-video` } : {}),
89
+ edit: `${m.base}/video-edit${suffix}`,
90
+ ...(m.modes.includes('extend') ? { extend: `${m.base}/video-extend` } : {}),
91
+ };
92
+ }
93
+ /** The input names a model takes its reference media under (Kling 4.0 drops the reference_ prefix). */
94
+ export function referenceFields(m) {
95
+ return m.family === 'kling-4.0'
96
+ ? { images: 'images', videos: 'videos', audios: 'audios' }
97
+ : { images: 'reference_images', videos: 'reference_videos', audios: 'reference_audios' };
98
+ }
99
+ /** Image-to-video follows the start image's frame on these families; the others take aspect_ratio. */
100
+ export function imageToVideoTakesAspectRatio(m) {
101
+ return m.family === 'seedance-2.0' || m.family === 'wan-3.0';
102
+ }
@@ -0,0 +1,188 @@
1
+ // Turn a generate/edit request into the endpoint and input to submit: the
2
+ // model choice, the endpoint for the inputs given, and the input fitted to
3
+ // that endpoint's live schema. A port of /chat's runImage and proposeVideo
4
+ // (api-dashboard lib/chat/server.ts) without the chat session around them, so
5
+ // the same words give the same model, endpoint and input on both.
6
+ import { DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, IMAGE_MODELS, IMAGE_MODEL_KEYS, VIDEO_MODELS, VIDEO_MODEL_KEYS, referenceFields, videoEndpoints, } from './media-models.js';
7
+ import { renameRefs } from './prompt-refs.js';
8
+ /** Keep only fields the model accepts, snapping values onto its enums and ranges. */
9
+ export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
10
+ const properties = await propsOf(modelId);
11
+ if (!properties)
12
+ throw new Error(`No schema for ${modelId} in the model catalog.`);
13
+ const input = {};
14
+ // Spell out the defaults that set the price, so the estimate shows them.
15
+ for (const key of showDefaults) {
16
+ if (properties[key]?.default !== undefined && (wanted[key] === undefined || wanted[key] === null || wanted[key] === '')) {
17
+ wanted = { ...wanted, [key]: properties[key].default };
18
+ }
19
+ }
20
+ for (const [key, value] of Object.entries(wanted)) {
21
+ const prop = properties[key];
22
+ if (!prop || value === undefined || value === null || value === '')
23
+ continue;
24
+ if (Array.isArray(value)) {
25
+ if (!value.length)
26
+ continue;
27
+ input[key] = prop.maxItems ? value.slice(0, prop.maxItems) : value;
28
+ continue;
29
+ }
30
+ if (prop.enum?.length) {
31
+ if (prop.enum.includes(value))
32
+ input[key] = value;
33
+ else if (typeof value === 'number' && prop.enum.every((v) => typeof v === 'number')) {
34
+ input[key] = prop.enum.reduce((a, b) => (Math.abs(b - value) < Math.abs(a - value) ? b : a));
35
+ }
36
+ continue;
37
+ }
38
+ if (typeof value === 'number' && (prop.type === 'integer' || prop.type === 'number')) {
39
+ let v = prop.type === 'integer' ? Math.round(value) : value;
40
+ if (typeof prop.minimum === 'number')
41
+ v = Math.max(prop.minimum, v);
42
+ if (typeof prop.maximum === 'number')
43
+ v = Math.min(prop.maximum, v);
44
+ input[key] = v;
45
+ continue;
46
+ }
47
+ input[key] = value;
48
+ }
49
+ return input;
50
+ }
51
+ function imageModelKey(named) {
52
+ if (named === undefined || named === '')
53
+ return DEFAULT_IMAGE_MODEL;
54
+ if (IMAGE_MODEL_KEYS.includes(named))
55
+ return named;
56
+ throw new Error(`Unknown image model "${named}". Choose one of: ${IMAGE_MODEL_KEYS.join(', ')}.`);
57
+ }
58
+ /** Images go to the model's edit endpoint whenever any image is given, and to text-to-image otherwise. */
59
+ export async function planImage(args, propsOf) {
60
+ const model = IMAGE_MODELS[imageModelKey(args.model)];
61
+ const images = [...(args.images ?? []), ...(args.reference_images ?? [])];
62
+ const edit = images.length > 0;
63
+ const modelId = edit ? model.edit : model.generate;
64
+ const wanted = { prompt: args.prompt, aspect_ratio: args.aspect_ratio, resolution: args.resolution };
65
+ if (edit)
66
+ wanted.images = images;
67
+ const input = await fitInput(modelId, wanted, propsOf);
68
+ if (typeof input.prompt === 'string')
69
+ input.prompt = renameRefs(input.prompt, input, modelId);
70
+ const notes = [];
71
+ if (edit && Array.isArray(input.images) && input.images.length < images.length) {
72
+ notes.push(`${model.label} takes at most ${input.images.length} images, so only the first ${input.images.length} were used.`);
73
+ }
74
+ return { modelId, label: model.label, mode: edit ? 'edit' : 'generate', input, notes };
75
+ }
76
+ const MODE_LABEL = { text: 'text to video', image: 'image to video', reference: 'reference to video', edit: 'video edit', extend: 'video extend' };
77
+ // Reference media goes under reference_* on most models and under images, videos, audios on Kling 4.0 (referenceFields).
78
+ const OPTIONAL_INPUTS = ['reference_images', 'reference_videos', 'reference_audios', 'images', 'videos', 'audios', 'last_image'];
79
+ function videoModelKey(named) {
80
+ if (named === undefined || named === '')
81
+ return DEFAULT_VIDEO_MODEL;
82
+ if (VIDEO_MODEL_KEYS.includes(named))
83
+ return named;
84
+ throw new Error(`Unknown video model "${named}". Choose one of: ${VIDEO_MODEL_KEYS.join(', ')}.`);
85
+ }
86
+ /** Which allowed video model + mode accepts every one of these inputs, read from their schemas. */
87
+ async function whoTakes(fields, propsOf) {
88
+ const options = [];
89
+ for (const key of VIDEO_MODEL_KEYS) {
90
+ const e = videoEndpoints(VIDEO_MODELS[key]);
91
+ for (const [mode, id] of [['text to video', e.text], ['image to video', e.image], ['reference to video', e.reference]]) {
92
+ if (!id)
93
+ continue;
94
+ const props = await propsOf(id).catch(() => null);
95
+ if (props && fields.every((f) => props[f]))
96
+ options.push(`${key} (${mode})`);
97
+ }
98
+ }
99
+ return options.length ? `Models that take them: ${options.join('; ')}.` : 'No available video model takes them.';
100
+ }
101
+ /**
102
+ * Pick the model + mode for a video. Candidates are tried in order and the
103
+ * first whose schema takes every input given wins: an exact first frame
104
+ * (image to video), then a dedicated reference-to-video endpoint, then text to
105
+ * video with the references. Other models are tried only when the caller did
106
+ * not name one.
107
+ */
108
+ export async function planVideo(args, action, propsOf) {
109
+ const firstFrame = action === 'generate' ? args.first_frame : undefined;
110
+ const lastFrame = action !== 'edit' ? args.last_frame : undefined;
111
+ const video = action !== 'generate' ? args.video : undefined;
112
+ const refImages = args.reference_images?.length ? args.reference_images : undefined;
113
+ const refVideos = action === 'generate' && args.reference_videos?.length ? args.reference_videos : undefined;
114
+ const refAudios = args.reference_audios?.length ? args.reference_audios : undefined;
115
+ const pinned = args.model !== undefined && args.model !== '';
116
+ const first = videoModelKey(args.model);
117
+ const keys = pinned ? [first] : [first, ...VIDEO_MODEL_KEYS.filter((k) => k !== first)];
118
+ const candidates = [];
119
+ for (const key of keys) {
120
+ // H3's native 768p tier is the counterpart of other models' 720p default.
121
+ // Keep explicit supported choices, including cheaper drafts, intact.
122
+ const resolution = key === 'minimax-h3' && (args.resolution == null || args.resolution === '' || args.resolution === '720p') ? '768p' : args.resolution;
123
+ const base = { prompt: args.prompt, resolution };
124
+ const names = referenceFields(VIDEO_MODELS[key]);
125
+ const refs = { [names.images]: refImages, [names.videos]: refVideos, [names.audios]: refAudios };
126
+ if (action === 'edit') {
127
+ candidates.push({ key, mode: 'edit', wanted: { ...base, video, [names.images]: refImages, [names.audios]: refAudios }, lost: [] });
128
+ }
129
+ else if (action === 'extend') {
130
+ candidates.push({ key, mode: 'extend', wanted: { ...base, video, duration: args.duration, last_image: lastFrame }, lost: [] });
131
+ }
132
+ else if (firstFrame) {
133
+ candidates.push({ key, mode: 'image', wanted: { ...base, duration: args.duration, image: firstFrame, last_image: lastFrame, ...refs }, lost: [] });
134
+ // No exact first frame: the start image becomes a reference instead.
135
+ const asRef = { ...base, duration: args.duration, aspect_ratio: args.aspect_ratio, ...refs, [names.images]: [firstFrame, ...(refImages ?? [])] };
136
+ const lost = lastFrame ? ['exact first/last frame'] : ['exact first frame'];
137
+ candidates.push({ key, mode: 'reference', wanted: asRef, lost });
138
+ candidates.push({ key, mode: 'text', wanted: asRef, lost });
139
+ }
140
+ else if (refImages || refVideos || refAudios) {
141
+ const withRefs = { ...base, duration: args.duration, aspect_ratio: args.aspect_ratio, ...refs };
142
+ candidates.push({ key, mode: 'reference', wanted: withRefs, lost: [] });
143
+ candidates.push({ key, mode: 'text', wanted: withRefs, lost: [] });
144
+ }
145
+ else {
146
+ candidates.push({ key, mode: 'text', wanted: { ...base, duration: args.duration, aspect_ratio: args.aspect_ratio }, lost: [] });
147
+ }
148
+ }
149
+ const endpoints = (key) => videoEndpoints(VIDEO_MODELS[key]);
150
+ const usable = candidates.filter((c) => endpoints(c.key)[c.mode]);
151
+ if (!usable.length) {
152
+ // Turbo tiers have no extend endpoint.
153
+ throw new Error(`${VIDEO_MODELS[first].label} cannot ${action} videos. Try another video model, such as ${DEFAULT_VIDEO_MODEL}.`);
154
+ }
155
+ const needed = (c) => OPTIONAL_INPUTS.filter((k) => c.wanted[k] !== undefined);
156
+ const takesAll = async (c) => {
157
+ const props = await propsOf(endpoints(c.key)[c.mode]).catch(() => null);
158
+ return !!props && needed(c).every((k) => props[k]);
159
+ };
160
+ let chosen = usable[0];
161
+ for (const c of usable) {
162
+ if (await takesAll(c)) {
163
+ chosen = c;
164
+ break;
165
+ }
166
+ }
167
+ const model = VIDEO_MODELS[chosen.key];
168
+ const modelId = endpoints(chosen.key)[chosen.mode];
169
+ // Image-to-video follows the first frame's shape: no aspect_ratio, not even the schema default.
170
+ const input = await fitInput(modelId, chosen.wanted, propsOf, chosen.mode === 'image' ? ['duration', 'resolution'] : ['duration', 'resolution', 'aspect_ratio']);
171
+ if (chosen.mode === 'image')
172
+ delete input.aspect_ratio;
173
+ if (typeof input.prompt === 'string')
174
+ input.prompt = renameRefs(input.prompt, input, modelId);
175
+ const dropped = needed(chosen).filter((k) => input[k] === undefined);
176
+ const notes = [];
177
+ if (chosen.key !== usable[0].key || chosen.lost.length) {
178
+ notes.push(`Used ${model.label} ${MODE_LABEL[chosen.mode]} so that ${needed(chosen).join(', ') || 'the inputs'} could be used` +
179
+ (chosen.lost.length ? `; this means no ${chosen.lost.join(', ')}: the start image is used as a visual reference` : '') +
180
+ '.');
181
+ }
182
+ if (dropped.length) {
183
+ notes.push(pinned
184
+ ? `${model.label} does not take ${dropped.join(', ')} in any mode, so those were left out; the model you named was not switched. ${await whoTakes(dropped, propsOf)}`
185
+ : `No available video model takes ${dropped.join(', ')} for this request, so those were left out.`);
186
+ }
187
+ return { modelId, label: model.label, mode: MODE_LABEL[chosen.mode], input, notes };
188
+ }
@@ -0,0 +1,58 @@
1
+ // How a prompt names its input media. Callers write "@image 1", "@video 1",
2
+ // "@audio 1" (the older "@image1" also works); each model documents its own
3
+ // way to refer to them, so before submitting we swap the caller's names for
4
+ // the model's. A subset of api-dashboard lib/prompt-refs.ts: the syntaxes of
5
+ // the models the generate/edit tools offer. Models that document none (the
6
+ // image models) get plain words ("Image 1").
7
+ const word = (kind) => kind[0].toUpperCase() + kind.slice(1);
8
+ // Vendor docs, as researched for /chat on 2026-09-28.
9
+ const SYNTAXES = [
10
+ [/^wavespeed-ai\/minimax-h3(-[a-z0-9]+)?\//, { tag: (k, n) => `<${k === 'image' ? 'Picture' : word(k)} ${n}>`, inputVideo: null }],
11
+ [/^kwaivgi\/kling-v4\.0\/(reference-to-video|video-edit)/, { tag: (k, n) => `@${word(k)}${n}` }],
12
+ [/^alibaba\/wan-3\.0/, { tag: (k, n) => `${word(k)} ${n}` }],
13
+ [/^bytedance\/seedance-2\.[05]/, { tag: (k, n) => `@${word(k)} ${n}` }],
14
+ ];
15
+ const list = (v) => (Array.isArray(v) ? v : []);
16
+ /** The media of a request in the order models number them: the edited video, the image and reference lists, then the frames. */
17
+ export function promptRefs(input, modelId) {
18
+ const syntax = SYNTAXES.find(([pattern]) => pattern.test(modelId))?.[1];
19
+ const refs = [];
20
+ const labels = { image: 0, video: 0, audio: 0 };
21
+ const counts = { image: 0, video: 0, audio: 0 };
22
+ const plainCounts = { image: 0, video: 0, audio: 0 };
23
+ const add = (url, kind, role) => {
24
+ if (typeof url !== 'string' || !url || refs.some((r) => r.url === url))
25
+ return;
26
+ labels[kind] += 1;
27
+ let name;
28
+ if (role && role.name !== undefined)
29
+ name = role.name;
30
+ else if (!syntax)
31
+ name = null;
32
+ else {
33
+ counts[kind] += 1;
34
+ name = syntax.tag(kind, counts[kind]);
35
+ }
36
+ const plain = role?.plain ?? `${word(kind)} ${++plainCounts[kind]}`;
37
+ refs.push({ label: `${kind}${labels[kind]}`, kind, url, name, plain });
38
+ };
39
+ add(input.video, 'video', syntax && syntax.inputVideo !== undefined ? { name: syntax.inputVideo, plain: 'the input video' } : undefined);
40
+ list(input.images).forEach((u) => add(u, 'image'));
41
+ list(input.reference_images).forEach((u) => add(u, 'image'));
42
+ list(input.reference_videos).concat(list(input.videos)).forEach((u) => add(u, 'video'));
43
+ list(input.reference_audios).forEach((u) => add(u, 'audio'));
44
+ list(input.audios).forEach((u) => add(u, 'audio'));
45
+ add(input.image, 'image', { name: null, plain: 'the first frame' });
46
+ add(input.last_image, 'image', { name: null, plain: 'the last frame' });
47
+ return refs;
48
+ }
49
+ // "@image 1" or "@image1", in any case; "image1" without the @ is not a ref here.
50
+ const PROMPT_REF_PATTERN = /@(image|video|audio) ?(\d+)\b/gi;
51
+ /** Swap the caller's @image 1-style names for the model's documented terms (or plain words); refs the request does not carry stay as typed. */
52
+ export function renameRefs(prompt, input, modelId) {
53
+ const refs = new Map(promptRefs(input, modelId).map((r) => [r.label, r]));
54
+ return prompt.replace(PROMPT_REF_PATTERN, (whole, kind, n) => {
55
+ const ref = refs.get(`${kind.toLowerCase()}${n}`);
56
+ return ref ? (ref.name ?? ref.plain) : whole;
57
+ });
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "WaveSpeed CLI — one command to run any WaveSpeed AI model from your terminal.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/skills/SKILL.md CHANGED
@@ -79,3 +79,7 @@ The CLI never modifies the user's prompt or inputs. The single exception is expl
79
79
  - `wavespeed delete` requires `--yes` when run non-interactively (that includes you).
80
80
  - Cost and spend: `wavespeed price <id> -i k=v --json` estimates a run without generating or charging; `wavespeed balance` shows remaining credit; `wavespeed usage` (totals, per-model) and `wavespeed billings` (per-charge records) show what was spent.
81
81
  - A `run` that fails with a gateway or network error (502/503/504, connection reset, timeout) does NOT mean the task failed, and the submit request is never retried automatically, so re-running can create a duplicate paid task. Do not re-run immediately. If the error contains a task id (`task_id` / prediction id), keep checking it with `wavespeed show <id> --json` until its status is terminal (`completed`, `failed`, `cancelled`, `timeout`, `deleted`). If no id was returned, check recent predictions with `wavespeed history --json` (add `--model <id>`; default window is the last 24h) and re-run once only if none matches. A client-side wait timeout or an interrupted `run` does not mean the task failed: tasks can run for a long time, so do not give up after a fixed time.
82
+
83
+ ## Shortcuts for common image/video work
84
+
85
+ `wavespeed generate image|video`, `wavespeed edit image|video`, `wavespeed extend video` use the WaveSpeed /chat models and choose the endpoint from the inputs (`--image`, `--first-frame`, `--ref-image/--ref-video/--ref-audio`, `--video`; `@./path` uploads). Pass `-m` only when the user names a model. Run with `--estimate --json` first for videos; the request may be adapted to the model and the changes are printed. Use `wavespeed run <model>` for exact control.