@wavespeed/cli 0.4.26 → 0.4.28

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
@@ -120,7 +120,7 @@ wavespeed extend video "the camera keeps rising" --video @./clip.mp4 --json
120
120
 
121
121
  | | Default | Alternatives (`-m`) |
122
122
  |---|---|---|
123
- | Image | `seedream-5-pro` | `gpt-image-2.5`, `nano-banana-2.1`, `nano-banana-pro`, `nano-banana-2`, `gpt-image-2` |
123
+ | Image | `seedream-5-pro` | `gpt-image-2.6`, `gpt-image-2.5`, `nano-banana-2.1`, `nano-banana-pro`, `nano-banana-2`, `gpt-image-2` |
124
124
  | Video | `seedance-2.5` | `seedance-2.5-turbo`, `kling-4.0`, `wan-3.0`, `wan-3.0-prime`, `minimax-h3` (cheap and fast, native stereo audio, 768p by default), Seedance 2.0 tiers |
125
125
 
126
126
  For everything else (audio, 3D, upscaling, a specific endpoint), find the model with `wavespeed models <query>` and call it with `wavespeed run <model-id>`.
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { fetchHistory, PREDICTION_STATUSES } from '../lib/api.js';
4
4
  import { toIso } from '../lib/time.js';
5
+ import { describeOutput } from '../lib/output.js';
5
6
  function statusBadge(status) {
6
7
  const map = {
7
8
  completed: chalk.bgGreen.black,
@@ -40,7 +41,7 @@ export function beyondWindow(since, until) {
40
41
  function firstOutput(item) {
41
42
  const out = item.outputs?.[0];
42
43
  if (typeof out === 'string')
43
- return out;
44
+ return describeOutput(out);
44
45
  return '';
45
46
  }
46
47
  export function registerHistory(program) {
@@ -1,6 +1,6 @@
1
1
  // The image and video models the dedicated generate/edit tools offer: the
2
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.
3
+ // lib/media-models.ts, checked 2026-11-05). The first of each is the default.
4
4
  // This is a vendored copy: when /chat changes its models, change this file and
5
5
  // its test (src/lib/media-models.test.ts) with it. Capabilities mirror the
6
6
  // endpoints' input schemas; the tools still fit every request to the live
@@ -18,6 +18,7 @@ export const IMAGE_MODELS = {
18
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
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
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.6': image('gpt-image-2.6', 'GPT Image 2.6', 'openai/gpt-image-2.6-sunburst', 'text-to-image', ['1k', '2k', '4k'], WIDE_RATIOS, 16),
21
22
  '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
23
  '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
  };
@@ -76,16 +77,16 @@ export function videoModelForEndpoint(modelId) {
76
77
  return VIDEO_MODEL_KEYS.map((k) => VIDEO_MODELS[k]).find((m) => m.turbo === turbo && modelId.startsWith(`${m.base}/`));
77
78
  }
78
79
  /**
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.
80
+ * A model's endpoints. Every family takes references on a dedicated
81
+ * reference-to-video endpoint. Seedance has turbo ones; the others do not.
82
+ * Turbo endpoints have no extend.
82
83
  */
83
84
  export function videoEndpoints(m) {
84
85
  const suffix = m.turbo ? '-turbo' : '';
85
86
  return {
86
87
  text: `${m.base}/text-to-video${suffix}`,
87
88
  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
+ reference: `${m.base}/reference-to-video${m.family === 'seedance-2.5' || m.family === 'seedance-2.0' ? suffix : ''}`,
89
90
  edit: `${m.base}/video-edit${suffix}`,
90
91
  ...(m.modes.includes('extend') ? { extend: `${m.base}/video-extend` } : {}),
91
92
  };
@@ -5,16 +5,73 @@
5
5
  // the same words give the same model, endpoint and input on both.
6
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
7
  import { renameRefs } from './prompt-refs.js';
8
- /** Keep only fields the model accepts, snapping values onto its enums and ranges. */
8
+ // Size tiers: 480p, 720p, 1080p (rank = N) and 0.5k, 1k, 2k, 4k (rank = X*1000),
9
+ // so 480p < 720p < 768p < 1080p < 2k < 4k on one scale.
10
+ function tierRank(v) {
11
+ if (typeof v !== 'string')
12
+ return undefined;
13
+ const m = /^(\d+(?:\.\d+)?)([pk])$/i.exec(v.trim());
14
+ if (!m)
15
+ return undefined;
16
+ const n = Number(m[1]);
17
+ return m[2].toLowerCase() === 'k' ? n * 1000 : n;
18
+ }
19
+ // ln(W/H) of a W:H ratio, so 2:1 and 1:2 are equally far from 1:1.
20
+ function ratioLog(v) {
21
+ if (typeof v !== 'string')
22
+ return undefined;
23
+ const m = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(v.trim());
24
+ if (!m)
25
+ return undefined;
26
+ const w = Number(m[1]);
27
+ const h = Number(m[2]);
28
+ return w > 0 && h > 0 ? Math.log(w / h) : undefined;
29
+ }
30
+ // Distances within this count as a tie (4:3 and 3:4 around 1:1 differ only in the last bit).
31
+ const TIE_EPSILON = 1e-9;
32
+ /** The member whose score is closest to target; on a tie, the one `better` prefers (default: the first). */
33
+ function closestMember(members, score, target, better = () => false) {
34
+ let best;
35
+ for (const v of members) {
36
+ const s = score(v);
37
+ if (s === undefined)
38
+ continue;
39
+ const d = Math.abs(s - target);
40
+ if (!best || d < best.d - TIE_EPSILON || (Math.abs(d - best.d) <= TIE_EPSILON && better({ v, s }, best)))
41
+ best = { v, s, d };
42
+ }
43
+ return best?.v;
44
+ }
45
+ /**
46
+ * Keep only fields the model accepts and fit each value to its schema. The
47
+ * rule is shared with the remote MCP (api-server) and the dashboard /chat:
48
+ * - a size tier (resolution, or an enum of Np / Nk values) the model does not
49
+ * offer becomes the closest tier it does; a tie goes to the lower one;
50
+ * - a W:H aspect ratio becomes the closest ratio (in ln(W/H)) among the
51
+ * members that are W:H; a tie goes to the first in enum order;
52
+ * - any other value outside an enum is left out, so the model default applies;
53
+ * - numbers go to the nearest numeric enum value, or are rounded (integers)
54
+ * and clamped to the range.
55
+ * Every value not sent as requested gets a note.
56
+ */
9
57
  export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
10
58
  const properties = await propsOf(modelId);
11
59
  if (!properties)
12
60
  throw new Error(`No schema for ${modelId} in the model catalog.`);
13
61
  const input = {};
62
+ const notes = [];
63
+ // Defaults filled in only to show the price are not the caller's request: no note for them.
64
+ const fromDefault = new Set();
65
+ const used = (key, from, to) => {
66
+ input[key] = to;
67
+ if (to !== from && !fromDefault.has(key))
68
+ notes.push(`${key} ${String(from)} is not offered by ${modelId}; used ${String(to)}.`);
69
+ };
14
70
  // Spell out the defaults that set the price, so the estimate shows them.
15
71
  for (const key of showDefaults) {
16
72
  if (properties[key]?.default !== undefined && (wanted[key] === undefined || wanted[key] === null || wanted[key] === '')) {
17
73
  wanted = { ...wanted, [key]: properties[key].default };
74
+ fromDefault.add(key);
18
75
  }
19
76
  }
20
77
  for (const [key, value] of Object.entries(wanted)) {
@@ -28,11 +85,28 @@ export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
28
85
  continue;
29
86
  }
30
87
  if (prop.enum?.length) {
31
- if (prop.enum.includes(value))
88
+ const members = prop.enum;
89
+ if (members.includes(value)) {
32
90
  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));
91
+ continue;
92
+ }
93
+ if (typeof value === 'number' && members.every((v) => typeof v === 'number')) {
94
+ used(key, value, members.reduce((a, b) => (Math.abs(b - value) < Math.abs(a - value) ? b : a)));
95
+ continue;
96
+ }
97
+ const rank = tierRank(value);
98
+ const tierMembers = members.filter((v) => tierRank(v) !== undefined);
99
+ if (rank !== undefined && tierMembers.length && (key === 'resolution' || tierMembers.length === members.length)) {
100
+ used(key, value, closestMember(tierMembers, tierRank, rank, (a, b) => a.s < b.s));
101
+ continue;
102
+ }
103
+ const ratio = ratioLog(value);
104
+ if (ratio !== undefined && members.some((v) => ratioLog(v) !== undefined)) {
105
+ used(key, value, closestMember(members, ratioLog, ratio));
106
+ continue;
35
107
  }
108
+ if (!fromDefault.has(key))
109
+ notes.push(`${key} ${String(value)} is not accepted by ${modelId}; the model default is used.`);
36
110
  continue;
37
111
  }
38
112
  if (typeof value === 'number' && (prop.type === 'integer' || prop.type === 'number')) {
@@ -41,12 +115,12 @@ export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
41
115
  v = Math.max(prop.minimum, v);
42
116
  if (typeof prop.maximum === 'number')
43
117
  v = Math.min(prop.maximum, v);
44
- input[key] = v;
118
+ used(key, value, v);
45
119
  continue;
46
120
  }
47
121
  input[key] = value;
48
122
  }
49
- return input;
123
+ return { input, notes };
50
124
  }
51
125
  function imageModelKey(named) {
52
126
  if (named === undefined || named === '')
@@ -64,10 +138,9 @@ export async function planImage(args, propsOf) {
64
138
  const wanted = { prompt: args.prompt, aspect_ratio: args.aspect_ratio, resolution: args.resolution };
65
139
  if (edit)
66
140
  wanted.images = images;
67
- const input = await fitInput(modelId, wanted, propsOf);
141
+ const { input, notes } = await fitInput(modelId, wanted, propsOf);
68
142
  if (typeof input.prompt === 'string')
69
143
  input.prompt = renameRefs(input.prompt, input, modelId);
70
- const notes = [];
71
144
  if (edit && Array.isArray(input.images) && input.images.length < images.length) {
72
145
  notes.push(`${model.label} takes at most ${input.images.length} images, so only the first ${input.images.length} were used.`);
73
146
  }
@@ -167,7 +240,8 @@ export async function planVideo(args, action, propsOf) {
167
240
  const model = VIDEO_MODELS[chosen.key];
168
241
  const modelId = endpoints(chosen.key)[chosen.mode];
169
242
  // 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']);
243
+ const fitted = await fitInput(modelId, chosen.wanted, propsOf, chosen.mode === 'image' ? ['duration', 'resolution'] : ['duration', 'resolution', 'aspect_ratio']);
244
+ const input = fitted.input;
171
245
  if (chosen.mode === 'image')
172
246
  delete input.aspect_ratio;
173
247
  if (typeof input.prompt === 'string')
@@ -184,5 +258,6 @@ export async function planVideo(args, action, propsOf) {
184
258
  ? `${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
259
  : `No available video model takes ${dropped.join(', ')} for this request, so those were left out.`);
186
260
  }
261
+ notes.push(...fitted.notes);
187
262
  return { modelId, label: model.label, mode: MODE_LABEL[chosen.mode], input, notes };
188
263
  }
@@ -11,6 +11,73 @@ function extFromUrl(url, fallback) {
11
11
  return m[1].toLowerCase();
12
12
  return fallback;
13
13
  }
14
+ // With enable_base64_output=true each URL output comes back as naked base64,
15
+ // but an output over 64 MiB or one the API failed to fetch stays a URL, and
16
+ // text/JSON outputs are unchanged — so the kind is decided per output.
17
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
18
+ const DATA_URI_RE = /^data:([^;,]*)(?:;[^,]*)?;base64,(.*)$/s;
19
+ export function isHttpUrl(value) {
20
+ return /^https?:\/\//i.test(value);
21
+ }
22
+ /** Naked base64 as returned for enable_base64_output (short strings are treated as text). */
23
+ export function isBase64Output(value) {
24
+ return value.length >= 64 && value.length % 4 === 0 && BASE64_RE.test(value);
25
+ }
26
+ const MAGIC = [
27
+ [[0x89, 0x50, 0x4e, 0x47], 'png'],
28
+ [[0xff, 0xd8, 0xff], 'jpg'],
29
+ [[0x47, 0x49, 0x46, 0x38], 'gif'],
30
+ [[0x1a, 0x45, 0xdf, 0xa3], 'webm'],
31
+ [[0x49, 0x44, 0x33], 'mp3'],
32
+ [[0x66, 0x4c, 0x61, 0x43], 'flac'],
33
+ [[0x4f, 0x67, 0x67, 0x53], 'ogg'],
34
+ [[0x67, 0x6c, 0x54, 0x46], 'glb'],
35
+ [[0x50, 0x4b, 0x03, 0x04], 'zip'],
36
+ [[0x25, 0x50, 0x44, 0x46], 'pdf'],
37
+ ];
38
+ export function extFromBytes(buf, fallback) {
39
+ for (const [sig, ext] of MAGIC) {
40
+ if (sig.every((b, i) => buf[i] === b))
41
+ return ext;
42
+ }
43
+ const at = (o, s) => buf.subarray(o, o + s.length).toString('latin1') === s;
44
+ if (at(0, 'RIFF') && at(8, 'WEBP'))
45
+ return 'webp';
46
+ if (at(0, 'RIFF') && at(8, 'WAVE'))
47
+ return 'wav';
48
+ if (at(4, 'ftyp'))
49
+ return at(8, 'M4A') ? 'm4a' : at(8, 'qt') ? 'mov' : 'mp4';
50
+ if (buf.length >= 2 && buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0)
51
+ return 'mp3';
52
+ return fallback;
53
+ }
54
+ const EXT_FROM_MIME = {
55
+ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif',
56
+ 'video/mp4': 'mp4', 'video/webm': 'webm', 'audio/mpeg': 'mp3', 'audio/wav': 'wav',
57
+ };
58
+ /** Bytes of an inline (base64 or data URI) output, or null when it is not inline. */
59
+ function inlineBytes(value, fallbackExt) {
60
+ const m = value.match(DATA_URI_RE);
61
+ if (m) {
62
+ const buf = Buffer.from(m[2], 'base64');
63
+ return { buf, ext: EXT_FROM_MIME[m[1].toLowerCase()] ?? extFromBytes(buf, fallbackExt) };
64
+ }
65
+ if (!isBase64Output(value))
66
+ return null;
67
+ const buf = Buffer.from(value, 'base64');
68
+ return { buf, ext: extFromBytes(buf, fallbackExt) };
69
+ }
70
+ /** Short label for an output: URLs as-is, inline data summarised instead of dumped. */
71
+ export function describeOutput(value) {
72
+ if (isHttpUrl(value))
73
+ return value;
74
+ const m = value.match(DATA_URI_RE);
75
+ const b64 = m ? m[2] : isBase64Output(value) ? value : null;
76
+ if (b64 === null)
77
+ return value;
78
+ const bytes = Math.floor((b64.length * 3) / 4);
79
+ return `(base64 data, ${bytes >= 1024 * 1024 ? (bytes / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(bytes / 1024) + ' KB'})`;
80
+ }
14
81
  function slug(input) {
15
82
  return input
16
83
  .toLowerCase()
@@ -31,7 +98,8 @@ export async function downloadOutputs(urls, opts = {}) {
31
98
  const saved = [];
32
99
  for (let i = 0; i < urls.length; i++) {
33
100
  const url = urls[i];
34
- const ext = extFromUrl(url, opts.ext || 'bin');
101
+ const inline = isHttpUrl(url) ? null : inlineBytes(url, opts.ext || 'bin');
102
+ const ext = inline ? inline.ext : extFromUrl(url, opts.ext || 'bin');
35
103
  let filepath;
36
104
  if (opts.template) {
37
105
  const rendered = renderTemplate(opts.template, {
@@ -53,6 +121,14 @@ export async function downloadOutputs(urls, opts = {}) {
53
121
  filepath = path.join(baseDir, `${ts}-${tag}${suffix}.${ext}`);
54
122
  }
55
123
  fs.mkdirSync(path.dirname(filepath), { recursive: true });
124
+ if (inline) {
125
+ fs.writeFileSync(filepath, inline.buf);
126
+ saved.push(filepath);
127
+ continue;
128
+ }
129
+ if (!isHttpUrl(url)) {
130
+ throw new Error(`Output ${i + 1} is neither a URL nor base64 data, so it cannot be downloaded: ${url.slice(0, 80)}`);
131
+ }
56
132
  const res = await fetch(url);
57
133
  if (!res.ok || !res.body) {
58
134
  throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`);
@@ -72,6 +148,6 @@ export function printOutputs(saved, remote) {
72
148
  }
73
149
  console.log(chalk.gray(saved.length ? 'Source URLs:' : 'URLs:'));
74
150
  for (const u of remote)
75
- console.log(' ' + chalk.cyan(u));
151
+ console.log(' ' + chalk.cyan(describeOutput(u)));
76
152
  console.log();
77
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/cli",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
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
@@ -33,7 +33,7 @@ They use the same models as the WaveSpeed /chat page and pick the endpoint (text
33
33
 
34
34
  | | Default | Alternatives (`-m` / `model`) |
35
35
  |---|---|---|
36
- | Image | `seedream-5-pro` (Seedream 5 Pro) | `gpt-image-2.5` (GPT Image 2.5, the model behind ChatGPT Images; strong at text and photorealism), `nano-banana-2.1`, `nano-banana-pro`, `nano-banana-2`, `gpt-image-2` |
36
+ | Image | `seedream-5-pro` (Seedream 5 Pro) | `gpt-image-2.6` (GPT Image 2.6, the model behind ChatGPT Images 2.6; strong at text and photorealism), `gpt-image-2.5`, `nano-banana-2.1`, `nano-banana-pro`, `nano-banana-2`, `gpt-image-2` |
37
37
  | Video | `seedance-2.5` (Seedance 2.5) | `seedance-2.5-turbo`, `kling-4.0`, `wan-3.0`, `wan-3.0-prime`, `minimax-h3` (cheap and fast, native stereo audio; defaults to 768p, its counterpart of 720p), older Seedance 2.0 tiers |
38
38
 
39
39
  Video resolution is 720p unless the user asks for another (MiniMax H3: 768p). Offer 480p for a cheaper draft.