@wji.ai/cli 0.1.0

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.
@@ -0,0 +1,915 @@
1
+ import { Command } from 'commander';
2
+ import { WjiClient } from '../core/client.js';
3
+ import { confirm, parseKeyValues, readValue } from '../core/prompt.js';
4
+ import { color, detail, info, isJsonMode, printJson, render, success, table, truncate, warn } from '../core/output.js';
5
+ import { pollHistory, reportHistory, resolveAssets } from './gen.js';
6
+ import { fetchModels } from './model.js';
7
+ /**
8
+ * Infinite Canvas over the plain fsnode API.
9
+ *
10
+ * A canvas is a folder (`workspace-v2/canvas`). Each node is a child file with
11
+ * mimetype `canvas/node/<kind>`, and each connection is a child file with
12
+ * mimetype `canvas/edge` whose content is `{fromNodeId, toNodeId, …}`. Edges
13
+ * into a media generate node are its references, numbered per kind in edge
14
+ * order (`@image1`, `@image2`, …).
15
+ *
16
+ * The browser can "link" a canvas (link button in the canvas top bar). That
17
+ * stores the open workspace, canvas and selection on the user for 5 minutes,
18
+ * so these commands default to the linked canvas and can read what is
19
+ * selected. Writes go straight to the API; an open canvas picks them up
20
+ * through its normal live sync.
21
+ */
22
+ const NODE_PREFIX = 'canvas/node/';
23
+ const EDGE_MIMETYPE = 'canvas/edge';
24
+ const CANVAS_MIMETYPE = 'workspace-v2/canvas';
25
+ const CANVASES_MIMETYPE = 'workspace-v2/canvases';
26
+ const CREATABLE_KINDS = ['text', 'title', 'comment', 'image', 'video', 'audio'];
27
+ const MEDIA_KINDS = new Set(['image', 'video', 'audio']);
28
+ const TEXT_KINDS = new Set(['text', 'title', 'comment']);
29
+ /** Default names the canvas UI shows localized. */
30
+ const DEFAULT_NAMES = {
31
+ text: '文本',
32
+ title: '标题',
33
+ comment: '备注',
34
+ image: '图片',
35
+ video: '视频',
36
+ audio: '音频',
37
+ };
38
+ /** The canvas lays media out on a 250px short side; text nodes are wider. */
39
+ const MEDIA_BASE = 250;
40
+ const PLACE_GAP = 80;
41
+ const PENDING = 0;
42
+ const idOf = (value) => value == null ? undefined : typeof value === 'string' ? value : value.id;
43
+ function toList(result) {
44
+ return Array.isArray(result) ? result : result?.data || [];
45
+ }
46
+ // ── loading ─────────────────────────────────────────────────────────────────
47
+ async function linkedState(client) {
48
+ const res = await client.get('/canvas-link');
49
+ return res?.linked ? res.state : null;
50
+ }
51
+ /** `--canvas` wins; otherwise the canvas linked from the browser. */
52
+ async function resolveCanvasId(client, explicit) {
53
+ if (explicit)
54
+ return explicit;
55
+ const state = await linkedState(client);
56
+ if (state?.canvasId)
57
+ return String(state.canvasId);
58
+ throw new Error('No canvas. Pass --canvas <id>, or open the canvas in the browser and press the link button in its top bar.');
59
+ }
60
+ async function loadCanvas(client, canvasId) {
61
+ // getFresh: read-merge-write must not see the 10s server cache
62
+ const folder = await client.getFresh(`/fsnode/list/${canvasId}`);
63
+ if (folder?.mimetype !== CANVAS_MIMETYPE) {
64
+ throw new Error(`${canvasId} is not a canvas (mimetype ${folder?.mimetype || 'none'})`);
65
+ }
66
+ const children = toList(await client.getFresh(`/fsnode/list/${canvasId}/children`, { limit: -1 }));
67
+ const nodes = [];
68
+ const edges = [];
69
+ for (const raw of children) {
70
+ const mimetype = String(raw.mimetype || '');
71
+ if (mimetype === EDGE_MIMETYPE) {
72
+ edges.push({
73
+ id: raw.id,
74
+ sort: Number(raw.sort || 0),
75
+ fromNodeId: String(raw.content?.fromNodeId || ''),
76
+ toNodeId: String(raw.content?.toNodeId || ''),
77
+ content: raw.content || {},
78
+ });
79
+ }
80
+ else if (mimetype.startsWith(NODE_PREFIX)) {
81
+ nodes.push({
82
+ id: raw.id,
83
+ kind: mimetype.slice(NODE_PREFIX.length),
84
+ name: raw.name || '',
85
+ sort: Number(raw.sort || 0),
86
+ file: idOf(raw.file),
87
+ content: raw.content || {},
88
+ raw,
89
+ });
90
+ }
91
+ }
92
+ const assetIds = [...new Set(nodes.map((n) => n.file).filter(Boolean))];
93
+ const assets = new Map();
94
+ if (assetIds.length) {
95
+ for (const asset of await client.checkAssetStatus(assetIds))
96
+ assets.set(asset.id, asset);
97
+ }
98
+ return {
99
+ id: folder.id,
100
+ name: folder.name,
101
+ workspaceId: idOf(folder.workspace) || idOf(folder.root) || '',
102
+ nodes,
103
+ edges,
104
+ byId: new Map(nodes.map((n) => [n.id, n])),
105
+ assets,
106
+ };
107
+ }
108
+ function requireNode(canvas, id) {
109
+ const node = canvas.byId.get(id);
110
+ if (!node)
111
+ throw new Error(`Node ${id} is not on canvas "${canvas.name}" (${canvas.id})`);
112
+ return node;
113
+ }
114
+ /** Incoming edges in the order the canvas numbers them: sort, then id. */
115
+ function referencesOf(canvas, node) {
116
+ const refs = canvas.edges
117
+ .filter((e) => e.toNodeId === node.id && canvas.byId.has(e.fromNodeId))
118
+ .sort((a, b) => a.sort - b.sort || a.id.localeCompare(b.id));
119
+ const counts = {};
120
+ return refs.map((edge) => {
121
+ const from = canvas.byId.get(edge.fromNodeId);
122
+ counts[from.kind] = (counts[from.kind] || 0) + 1;
123
+ return {
124
+ edgeId: edge.id,
125
+ nodeId: from.id,
126
+ kind: from.kind,
127
+ name: from.name,
128
+ label: edge.content.referenceLabel || `@${from.kind}${counts[from.kind]}`,
129
+ frameSlot: edge.content.videoFrameSlot,
130
+ };
131
+ });
132
+ }
133
+ function describe(canvas, node) {
134
+ const asset = node.file ? canvas.assets.get(node.file) : undefined;
135
+ const generate = node.content.generate;
136
+ const pending = asset && asset.status === PENDING && !asset.error;
137
+ return {
138
+ id: node.id,
139
+ kind: node.kind,
140
+ name: node.name,
141
+ position: node.content.position,
142
+ mode: MEDIA_KINDS.has(node.kind) ? node.content.mode || 'display' : undefined,
143
+ text: TEXT_KINDS.has(node.kind) ? node.content.text ?? '' : undefined,
144
+ assetId: node.file,
145
+ url: asset?.url || undefined,
146
+ assetType: asset?.type,
147
+ status: generate?.historyId ? (pending ? 'generating' : asset?.error ? 'failed' : asset?.url ? 'done' : 'pending') : undefined,
148
+ error: asset?.error?.message || asset?.error || undefined,
149
+ generate,
150
+ references: referencesOf(canvas, node),
151
+ outgoing: canvas.edges.filter((e) => e.fromNodeId === node.id).map((e) => e.toNodeId),
152
+ };
153
+ }
154
+ // ── models ──────────────────────────────────────────────────────────────────
155
+ async function findModel(client, kind, name, workspaceId) {
156
+ const rows = await fetchModels(client, { features: [kind], workspace: workspaceId });
157
+ const usable = rows.filter((m) => m.enabled !== false);
158
+ const model = usable.find((m) => m.nickname === name) ?? usable.find((m) => m.name === name);
159
+ if (!model) {
160
+ throw new Error(`No ${kind} model "${name}". See \`wji models list --feature ${kind}\`.`);
161
+ }
162
+ return model;
163
+ }
164
+ /**
165
+ * Fill and validate generate settings the way the canvas generate panel does
166
+ * (shared/generate/formDefaults.ts): ratio from the model's list, resolution
167
+ * from `form.other.resolution`, other params from their defaults, and unknown
168
+ * params dropped.
169
+ */
170
+ function applyModelDefaults(model, kind, generate, opts) {
171
+ const form = model.form || {};
172
+ const other = form.other || {};
173
+ const next = { ...generate, model: model.nickname || model.name };
174
+ const ratios = form.aspectRatio || [];
175
+ if (kind !== 'audio' && ratios.length) {
176
+ if (next.ratio && !ratios.includes(next.ratio)) {
177
+ if (opts.explicit.has('ratio')) {
178
+ throw new Error(`${next.model} does not support ratio ${next.ratio}. Use one of: ${ratios.join(' ')}`);
179
+ }
180
+ next.ratio = undefined;
181
+ }
182
+ if (!next.ratio) {
183
+ next.ratio = opts.workspaceRatio && ratios.includes(opts.workspaceRatio) ? opts.workspaceRatio : ratios[0];
184
+ }
185
+ }
186
+ const resField = other.resolution;
187
+ if (resField?.options?.length) {
188
+ const valid = resField.options.map((o) => String(o.value));
189
+ if (next.resolution && !valid.includes(String(next.resolution))) {
190
+ if (opts.explicit.has('resolution')) {
191
+ throw new Error(`${next.model} resolution must be one of: ${valid.join(' ')}`);
192
+ }
193
+ next.resolution = undefined;
194
+ }
195
+ if (!next.resolution)
196
+ next.resolution = String(resField.default ?? valid[0]);
197
+ }
198
+ const durationForm = form.video?.duration;
199
+ if (kind === 'video' && durationForm?.enable) {
200
+ if (next.duration != null) {
201
+ const d = Number(next.duration);
202
+ if (d < durationForm.min || d > durationForm.max) {
203
+ throw new Error(`${next.model} duration must be ${durationForm.min}–${durationForm.max}s`);
204
+ }
205
+ }
206
+ else {
207
+ next.duration = durationForm.default;
208
+ }
209
+ }
210
+ const values = {};
211
+ for (const [key, value] of Object.entries(next.values || {})) {
212
+ if (key in other && key !== 'resolution')
213
+ values[key] = value;
214
+ else if (opts.explicit.has(`values.${key}`))
215
+ warn(`${next.model} has no parameter "${key}" — ignored`);
216
+ }
217
+ for (const [key, field] of Object.entries(other)) {
218
+ if (key === 'resolution')
219
+ continue;
220
+ const current = values[key];
221
+ const empty = current === undefined || current === null || current === '';
222
+ if (empty && field.default !== undefined)
223
+ values[key] = field.default;
224
+ if (field.type === 'select' && field.options?.length) {
225
+ const valid = field.options.map((o) => o.value);
226
+ if (!empty && !valid.includes(current)) {
227
+ if (opts.explicit.has(`values.${key}`)) {
228
+ throw new Error(`${next.model} parameter ${key} must be one of: ${valid.join(' ')}`);
229
+ }
230
+ values[key] = field.default ?? valid[0];
231
+ }
232
+ }
233
+ }
234
+ // An explicit duration is ignored while auto-duration is on, so turn it off.
235
+ if (kind === 'video' && opts.explicit.has('duration') && 'autoDuration' in other && !opts.explicit.has('values.autoDuration')) {
236
+ values.autoDuration = false;
237
+ }
238
+ next.values = values;
239
+ if (next.improvePromptEnabled === undefined)
240
+ next.improvePromptEnabled = false;
241
+ return next;
242
+ }
243
+ // ── writes ──────────────────────────────────────────────────────────────────
244
+ /** Short side 250, like the canvas; text-ish nodes use a typical card size. */
245
+ function estimateSize(canvas, node) {
246
+ if (!MEDIA_KINDS.has(node.kind))
247
+ return { width: 320, height: 200 };
248
+ const ratioText = node.content.mode === 'generate' ? node.content.generate?.ratio : undefined;
249
+ const asset = node.file ? canvas.assets.get(node.file) : undefined;
250
+ let ratio = node.kind === 'video' ? 16 / 9 : 1;
251
+ if (asset?.width && asset?.height)
252
+ ratio = asset.width / asset.height;
253
+ else if (ratioText && /^\d+(\.\d+)?:\d+(\.\d+)?$/.test(ratioText)) {
254
+ const [w, h] = ratioText.split(':').map(Number);
255
+ ratio = w / h;
256
+ }
257
+ if (node.kind === 'audio')
258
+ ratio = 1;
259
+ return ratio >= 1
260
+ ? { width: Math.round(MEDIA_BASE * ratio), height: MEDIA_BASE }
261
+ : { width: MEDIA_BASE, height: Math.round(MEDIA_BASE / ratio) };
262
+ }
263
+ /**
264
+ * Right of the anchor nodes, top-aligned (else right of everything), then
265
+ * pushed down past any node already sitting there.
266
+ */
267
+ function placeNear(canvas, anchors, size) {
268
+ const pool = anchors.length ? anchors : canvas.nodes;
269
+ if (!pool.length)
270
+ return { x: 0, y: 0 };
271
+ let right = -Infinity;
272
+ let top = Infinity;
273
+ for (const node of pool) {
274
+ const p = node.content.position || { x: 0, y: 0 };
275
+ right = Math.max(right, Number(p.x) + estimateSize(canvas, node).width);
276
+ top = Math.min(top, Number(p.y));
277
+ }
278
+ const x = Math.round(right + PLACE_GAP);
279
+ let y = Math.round(top);
280
+ for (let guard = 0; guard < 200; guard++) {
281
+ const hit = canvas.nodes.find((node) => {
282
+ const p = node.content.position || { x: 0, y: 0 };
283
+ const s = estimateSize(canvas, node);
284
+ return x < p.x + s.width && x + size.width > p.x && y < p.y + s.height && y + size.height > p.y;
285
+ });
286
+ if (!hit)
287
+ break;
288
+ y = Math.round(Number(hit.content.position?.y || 0) + estimateSize(canvas, hit).height + PLACE_GAP);
289
+ }
290
+ return { x, y };
291
+ }
292
+ function usesFrameSlots(model) {
293
+ const support = model?.form?.video?.support || [];
294
+ return { start: support.includes('start'), end: support.includes('end') };
295
+ }
296
+ async function createEdge(client, canvas, fromId, toId, opts = {}) {
297
+ const from = requireNode(canvas, fromId);
298
+ const to = requireNode(canvas, toId);
299
+ if (from.id === to.id)
300
+ throw new Error('A node cannot reference itself');
301
+ if (canvas.edges.some((e) => e.fromNodeId === from.id && e.toNodeId === to.id)) {
302
+ throw new Error(`${from.id} is already connected to ${to.id}`);
303
+ }
304
+ const content = {
305
+ fromNodeId: from.id,
306
+ toNodeId: to.id,
307
+ sourceHandle: 'right',
308
+ targetHandle: 'left',
309
+ };
310
+ // First/last-frame video models take connected images as frame slots, in order.
311
+ if (opts.frameModel && to.kind === 'video' && from.kind === 'image') {
312
+ const support = usesFrameSlots(opts.frameModel);
313
+ const taken = new Set(referencesOf(canvas, to).map((r) => r.frameSlot).filter(Boolean));
314
+ if (support.start && !taken.has('start'))
315
+ content.videoFrameSlot = 'start';
316
+ else if (support.end && !taken.has('end'))
317
+ content.videoFrameSlot = 'end';
318
+ }
319
+ const raw = await client.post('/fsnode/create', {
320
+ name: '连线',
321
+ type: 'file',
322
+ mimetype: EDGE_MIMETYPE,
323
+ parent: canvas.id,
324
+ content,
325
+ sort: 0,
326
+ });
327
+ const edge = { id: raw.id, sort: 0, fromNodeId: from.id, toNodeId: to.id, content };
328
+ canvas.edges.push(edge);
329
+ return edge;
330
+ }
331
+ async function patchNode(client, node, body) {
332
+ return client.patch(`/fsnode/list/${node.id}`, body);
333
+ }
334
+ // ── output ──────────────────────────────────────────────────────────────────
335
+ function summary(n) {
336
+ if (n.text !== undefined)
337
+ return String(n.text).replace(/\s+/g, ' ');
338
+ if (n.mode === 'generate') {
339
+ const g = n.generate || {};
340
+ const state = n.status === 'done' ? color.green('done') : n.status === 'failed' ? color.red('failed') : n.status ? color.yellow(n.status) : '';
341
+ return [state, [g.model, g.ratio, g.duration && `${g.duration}s`].filter(Boolean).join(' '), g.prompt && `"${g.prompt}"`]
342
+ .filter(Boolean)
343
+ .join(' · ');
344
+ }
345
+ return n.url || n.assetId || '';
346
+ }
347
+ function nodeTable(rows) {
348
+ table(rows, [
349
+ { header: 'id', value: (n) => n.id },
350
+ { header: 'kind', value: (n) => (n.mode === 'generate' ? `${n.kind}*` : n.kind) },
351
+ { header: 'name', value: (n) => truncate(n.name, 20) },
352
+ { header: 'refs', value: (n) => n.references.map((r) => r.label).join(',') },
353
+ { header: 'summary', value: (n) => truncate(summary(n), 64) },
354
+ ]);
355
+ }
356
+ function printNode(n) {
357
+ const g = n.generate || {};
358
+ detail([
359
+ ['id', n.id],
360
+ ['kind', n.mode ? `${n.kind} (${n.mode})` : n.kind],
361
+ ['name', n.name],
362
+ ['position', n.position && `${Math.round(n.position.x)},${Math.round(n.position.y)}`],
363
+ ['text', n.text],
364
+ ['asset', n.assetId],
365
+ ['url', n.url],
366
+ ['status', n.status],
367
+ ['error', n.error],
368
+ ['prompt', g.prompt],
369
+ ['model', g.model],
370
+ ['ratio', g.ratio],
371
+ ['resolution', g.resolution],
372
+ ['duration', g.duration],
373
+ ['params', g.values && Object.keys(g.values).length ? JSON.stringify(g.values) : undefined],
374
+ ['history', g.historyId],
375
+ [
376
+ 'references',
377
+ n.references.length
378
+ ? n.references.map((r) => `${r.label}${r.frameSlot ? `(${r.frameSlot})` : ''} ← ${r.nodeId}`).join(', ')
379
+ : undefined,
380
+ ],
381
+ ]);
382
+ }
383
+ /** Prompts start with `@image1`, so a leading `@` is text here, not a file. */
384
+ async function literalOrFile(value, file) {
385
+ if (file !== undefined)
386
+ return readValue(`@${file}`);
387
+ if (value === undefined)
388
+ return undefined;
389
+ return value === '-' ? readValue('-') : value;
390
+ }
391
+ function parsePoint(value, label) {
392
+ const [x, y] = value.split(',').map((v) => Number(v.trim()));
393
+ if (!Number.isFinite(x) || !Number.isFinite(y))
394
+ throw new Error(`${label} must look like x,y`);
395
+ return { x, y };
396
+ }
397
+ /** Collect generate options; `explicit` records what the user actually passed. */
398
+ async function generateOptions(opts) {
399
+ const patch = {};
400
+ const explicit = new Set();
401
+ const prompt = await literalOrFile(opts.prompt, opts.promptFile);
402
+ if (prompt !== undefined)
403
+ (patch.prompt = prompt), explicit.add('prompt');
404
+ if (opts.model !== undefined)
405
+ (patch.model = opts.model), explicit.add('model');
406
+ if (opts.ratio !== undefined)
407
+ (patch.ratio = opts.ratio), explicit.add('ratio');
408
+ if (opts.resolution !== undefined)
409
+ (patch.resolution = opts.resolution), explicit.add('resolution');
410
+ if (opts.duration !== undefined) {
411
+ const d = Number(opts.duration);
412
+ if (!Number.isFinite(d))
413
+ throw new Error('--duration must be seconds');
414
+ patch.duration = d;
415
+ explicit.add('duration');
416
+ }
417
+ if (opts.set?.length) {
418
+ patch.values = parseKeyValues(opts.set);
419
+ for (const key of Object.keys(patch.values))
420
+ explicit.add(`values.${key}`);
421
+ }
422
+ return { patch, explicit };
423
+ }
424
+ function withGenerateOptions(cmd) {
425
+ return cmd
426
+ .option('-p, --prompt <text>', 'generation prompt; @image1 etc. are literal (- reads stdin)')
427
+ .option('--prompt-file <path>', 'read the prompt from a file')
428
+ .option('-m, --model <model>', 'model nickname (see `wji models list --feature <kind>`)')
429
+ .option('-r, --ratio <ratio>', 'aspect ratio, e.g. 16:9')
430
+ .option('--resolution <res>', 'e.g. 720p, 1K')
431
+ .option('-d, --duration <sec>', 'video duration (turns auto-duration off)')
432
+ .option('--set <key=value...>', 'model parameters from `wji models get`, merged into existing ones');
433
+ }
434
+ // ── generation ──────────────────────────────────────────────────────────────
435
+ const UNSUPPORTED_REF_KINDS = new Set(['table', 'youtube', 'dataset', 'bundle', 'sprite', 'layer', 'panorama']);
436
+ /**
437
+ * Build the same request the canvas generate button sends
438
+ * (shared/generate/buildSubmitPayload.ts): references in edge order, text
439
+ * references prefixed to the prompt, first/last frames from frame slots.
440
+ */
441
+ function buildSubmit(canvas, node, model) {
442
+ const kind = node.kind;
443
+ const g = node.content.generate || {};
444
+ const userPrompt = String(g.prompt || '').trim();
445
+ const form = model.form || {};
446
+ const frames = usesFrameSlots(model);
447
+ const firstLast = frames.start && frames.end;
448
+ const texts = [];
449
+ const images = [];
450
+ const videos = [];
451
+ const audios = [];
452
+ let startImage;
453
+ let endImage;
454
+ for (const ref of referencesOf(canvas, node)) {
455
+ const from = canvas.byId.get(ref.nodeId);
456
+ if (from.kind === 'text') {
457
+ const body = String(from.content.text || '').trim();
458
+ if (body)
459
+ texts.push(from.name ? `[${from.name}]\n${body}` : body);
460
+ continue;
461
+ }
462
+ if (TEXT_KINDS.has(from.kind))
463
+ continue;
464
+ if (UNSUPPORTED_REF_KINDS.has(from.kind)) {
465
+ throw new Error(`Reference ${ref.label} is a ${from.kind} node, which only the canvas UI can submit. Generate this node from the browser.`);
466
+ }
467
+ const asset = from.file ? canvas.assets.get(from.file) : undefined;
468
+ if (!asset?.url) {
469
+ throw new Error(`Reference ${ref.label} (${from.id}) has no finished asset yet`);
470
+ }
471
+ const item = { assetId: asset.id };
472
+ const category = String(asset.category || asset.type || from.kind);
473
+ if (category.startsWith('image')) {
474
+ if (kind === 'video' && (frames.start || frames.end) && ref.frameSlot) {
475
+ if (ref.frameSlot === 'start' && frames.start && !startImage)
476
+ startImage = item;
477
+ if (ref.frameSlot === 'end' && frames.end && !endImage)
478
+ endImage = item;
479
+ continue;
480
+ }
481
+ images.push(item);
482
+ }
483
+ else if (category.startsWith('video'))
484
+ videos.push({ ...item, duration: asset.duration });
485
+ else if (category.startsWith('audio'))
486
+ audios.push(item);
487
+ }
488
+ const refMax = form.reference?.enable ? Number(form.reference.max || 0) : 0;
489
+ const imageRefs = kind === 'video' && firstLast ? [] : images;
490
+ if (imageRefs.length > refMax) {
491
+ throw new Error(`${g.model} accepts ${refMax} reference image(s); this node has ${imageRefs.length}. Disconnect some first.`);
492
+ }
493
+ if (videos.length && kind === 'video' && !form.referenceVideos) {
494
+ throw new Error(`${g.model} does not take reference videos`);
495
+ }
496
+ if (audios.length && !form.referenceAudios) {
497
+ throw new Error(`${g.model} does not take reference audio`);
498
+ }
499
+ const prompt = texts.length ? `${texts.join('\n\n')}${userPrompt ? `\n\n${userPrompt}` : ''}` : userPrompt;
500
+ if (!prompt)
501
+ throw new Error(`Node ${node.id} has no prompt. Set one with \`wji canvas set ${node.id} -p "…"\`.`);
502
+ const values = { ...(g.values || {}) };
503
+ const other = form.other || {};
504
+ let aspectRatio = g.ratio;
505
+ let duration = g.duration;
506
+ if (kind === 'video') {
507
+ const seedanceRef = !!other.taskMode;
508
+ const taskMode = values.taskMode === 'edit' || values.taskMode === 'extend' ? values.taskMode : 'generate';
509
+ if (seedanceRef && taskMode !== 'generate')
510
+ aspectRatio = 'adaptive';
511
+ const autoDuration = 'autoDuration' in other && !(seedanceRef && taskMode === 'edit') && values.autoDuration !== false;
512
+ if ((seedanceRef && taskMode === 'edit') || autoDuration)
513
+ duration = -1;
514
+ if (seedanceRef && taskMode === 'edit')
515
+ values.outputFormat = 'mov';
516
+ }
517
+ const body = {
518
+ originalPrompt: userPrompt,
519
+ prompt,
520
+ model: g.model,
521
+ aspectRatio,
522
+ n: kind === 'image' ? Math.max(1, Number(values.n) || 1) : 1,
523
+ workspace: canvas.workspaceId,
524
+ fsnode: node.id,
525
+ scope: 'canvas',
526
+ scopeId: canvas.id,
527
+ values: {
528
+ mode: kind === 'audio' ? 'text-to-audio' : kind,
529
+ ...values,
530
+ ...(kind !== 'audio' && g.resolution ? { resolution: g.resolution } : {}),
531
+ },
532
+ };
533
+ if (imageRefs.length)
534
+ body.referenceImages = imageRefs;
535
+ if (kind === 'video') {
536
+ body.duration = duration ?? 5;
537
+ if (startImage)
538
+ body.startImage = startImage;
539
+ if (endImage)
540
+ body.endImage = endImage;
541
+ if (videos.length)
542
+ body.referenceVideos = videos.map(({ assetId }) => ({ assetId }));
543
+ }
544
+ if (audios.length)
545
+ body.referenceAudios = audios;
546
+ return body;
547
+ }
548
+ // ── commands ────────────────────────────────────────────────────────────────
549
+ export function canvasCommand(globals) {
550
+ const canvas = new Command('canvas').description('Read and edit Infinite Canvas nodes (defaults to the canvas linked from the browser)');
551
+ const client = () => new WjiClient(globals());
552
+ canvas
553
+ .command('status')
554
+ .alias('link')
555
+ .description('The canvas linked from the browser and the nodes selected there')
556
+ .action(async () => {
557
+ const c = client();
558
+ const res = await c.get('/canvas-link');
559
+ if (!res?.linked) {
560
+ if (isJsonMode())
561
+ return printJson(res);
562
+ return info('Not linked. Open a canvas in the browser and press the link button in its top bar.');
563
+ }
564
+ const state = res.state;
565
+ // The link carries ids; read the selected nodes fresh so prompts and assets are current.
566
+ let selected = [];
567
+ let canvasName = state.canvasName;
568
+ if (state.selectedNodeIds?.length) {
569
+ const loaded = await loadCanvas(c, state.canvasId);
570
+ canvasName = loaded.name;
571
+ selected = state.selectedNodeIds
572
+ .filter((id) => loaded.byId.has(id))
573
+ .map((id) => describe(loaded, loaded.byId.get(id)));
574
+ }
575
+ const out = { ...res, state: { ...state, canvasName }, selected };
576
+ render(out, () => {
577
+ const seconds = Math.max(0, Math.round((new Date(state.expiresAt).getTime() - Date.now()) / 1000));
578
+ detail([
579
+ ['workspace', `${state.workspaceName || ''} ${color.dim(state.workspaceId)}`],
580
+ ['canvas', `${canvasName || ''} ${color.dim(state.canvasId)}`],
581
+ ['selected', `${state.selectedNodeIds?.length || 0} node(s)`],
582
+ ['expires', seconds ? `in ${Math.floor(seconds / 60)}m ${seconds % 60}s` : color.red('expired')],
583
+ ['url', state.url],
584
+ ]);
585
+ if (selected.length) {
586
+ info('');
587
+ nodeTable(selected);
588
+ }
589
+ });
590
+ });
591
+ canvas
592
+ .command('unlink')
593
+ .description('Drop the browser link (the tab switches its link button off within a minute)')
594
+ .action(async () => {
595
+ const res = await client().delete('/canvas-link');
596
+ render(res, (r) => (r.released ? success('Unlinked') : info('Nothing was linked')));
597
+ });
598
+ canvas
599
+ .command('list')
600
+ .description('Canvases in a workspace')
601
+ .option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
602
+ .action(async (opts) => {
603
+ const c = client();
604
+ const linked = opts.workspace ? null : await linkedState(c);
605
+ const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
606
+ const ws = await c.get(`/workspace/list/${wsId}`);
607
+ const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
608
+ const rows = [];
609
+ for (const app of apps) {
610
+ const tree = await c.get(`/workspace/list/${wsId}/fs/${app.id}`);
611
+ const folder = (tree?.children || []).find((n) => n.mimetype === CANVASES_MIMETYPE);
612
+ if (!folder)
613
+ continue;
614
+ const canvases = toList(await c.getFresh(`/fsnode/list/${folder.id}/children`, { limit: -1 }));
615
+ for (const it of canvases.filter((n) => n.mimetype === CANVAS_MIMETYPE)) {
616
+ rows.push({ id: it.id, name: it.name, app: app.id, linked: it.id === linked?.canvasId, updatedAt: it.updatedAt });
617
+ }
618
+ }
619
+ render(rows, () => table(rows, [
620
+ { header: '', value: (r) => (r.linked ? color.green('●') : '') },
621
+ { header: 'id', value: (r) => r.id },
622
+ { header: 'name', value: (r) => r.name },
623
+ { header: 'app', value: (r) => r.app },
624
+ ]));
625
+ });
626
+ canvas
627
+ .command('nodes')
628
+ .description('Nodes with references and a content summary (* = generate node)')
629
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
630
+ .option('-k, --kind <kind>', 'only this kind')
631
+ .action(async (opts) => {
632
+ const c = client();
633
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
634
+ const rows = cv.nodes.filter((n) => !opts.kind || n.kind === opts.kind).map((n) => describe(cv, n));
635
+ render({ canvas: { id: cv.id, name: cv.name, workspaceId: cv.workspaceId }, nodes: rows }, () => {
636
+ info(color.dim(`${cv.name} (${cv.id}) · ${rows.length} node(s) · ${cv.edges.length} edge(s)`));
637
+ nodeTable(rows);
638
+ });
639
+ });
640
+ canvas
641
+ .command('get <nodeId>')
642
+ .description('One node in full, with its references')
643
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
644
+ .action(async (id, opts) => {
645
+ const c = client();
646
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
647
+ const n = describe(cv, requireNode(cv, id));
648
+ render(n, () => printNode(n));
649
+ });
650
+ canvas
651
+ .command('edges')
652
+ .description('Connections')
653
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
654
+ .option('--node <id>', 'only edges touching this node')
655
+ .action(async (opts) => {
656
+ const c = client();
657
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
658
+ const rows = cv.edges.filter((e) => !opts.node || e.fromNodeId === opts.node || e.toNodeId === opts.node);
659
+ render(rows, () => table(rows, [
660
+ { header: 'edge', value: (e) => e.id },
661
+ { header: 'from', value: (e) => `${e.fromNodeId} ${color.dim(cv.byId.get(e.fromNodeId)?.kind || '?')}` },
662
+ { header: 'to', value: (e) => `${e.toNodeId} ${color.dim(cv.byId.get(e.toNodeId)?.kind || '?')}` },
663
+ { header: 'slot', value: (e) => e.content.videoFrameSlot || '' },
664
+ ]));
665
+ });
666
+ withGenerateOptions(canvas
667
+ .command('add <kind>')
668
+ .description(`Create a node: ${CREATABLE_KINDS.join(' | ')}. Media with -p/-m becomes a generate node.`)
669
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
670
+ .option('-n, --name <name>', 'node name')
671
+ .option('-t, --text <text>', 'text for text/title/comment (- reads stdin)')
672
+ .option('--text-file <path>', 'read the text from a file')
673
+ .option('--asset <assetId>', 'show an existing asset (display node)')
674
+ .option('--from <nodeIds...>', 'connect these nodes in as references, in order (@image1, @image2…)')
675
+ .option('--at <x,y>', 'position; default is right of the --from nodes')).action(async (kind, opts) => {
676
+ if (!CREATABLE_KINDS.includes(kind)) {
677
+ throw new Error(`Unknown kind "${kind}". Use one of: ${CREATABLE_KINDS.join(', ')}`);
678
+ }
679
+ const c = client();
680
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
681
+ const from = (opts.from || []).map((id) => requireNode(cv, id));
682
+ const content = {};
683
+ const body = {
684
+ name: opts.name || DEFAULT_NAMES[kind],
685
+ type: 'file',
686
+ mimetype: `${NODE_PREFIX}${kind}`,
687
+ parent: cv.id,
688
+ sort: 0,
689
+ };
690
+ let model;
691
+ if (TEXT_KINDS.has(kind)) {
692
+ const text = await literalOrFile(opts.text, opts.textFile);
693
+ if (text === undefined)
694
+ throw new Error(`${kind} nodes need --text`);
695
+ content.text = text;
696
+ }
697
+ else {
698
+ const { patch, explicit } = await generateOptions(opts);
699
+ const wantsGenerate = Object.keys(patch).length > 0;
700
+ if (opts.asset && wantsGenerate)
701
+ throw new Error('Use --asset for a display node, or -p/-m for a generate node, not both');
702
+ if (opts.asset) {
703
+ content.mode = 'display';
704
+ body.file = opts.asset;
705
+ }
706
+ else {
707
+ const ws = await c.get(`/workspace/list/${cv.workspaceId}`);
708
+ const modelName = patch.model || ws.settings?.defaultModels?.[kind];
709
+ if (!modelName)
710
+ throw new Error(`Pass -m <model>; the workspace has no default ${kind} model`);
711
+ model = await findModel(c, kind, modelName, cv.workspaceId);
712
+ content.mode = 'generate';
713
+ content.generate = applyModelDefaults(model, kind, patch, { workspaceRatio: ws.aspectRatio, explicit });
714
+ }
715
+ }
716
+ if (opts.at)
717
+ content.position = parsePoint(opts.at, '--at');
718
+ else {
719
+ const draft = { id: '', kind, name: '', sort: 0, file: body.file, content, raw: null };
720
+ content.position = placeNear(cv, from, estimateSize(cv, draft));
721
+ }
722
+ const raw = await c.post('/fsnode/create', { ...body, content });
723
+ const node = { id: raw.id, kind, name: raw.name, sort: 0, file: idOf(raw.file), content, raw };
724
+ cv.nodes.push(node);
725
+ cv.byId.set(node.id, node);
726
+ if (node.file) {
727
+ for (const asset of await c.checkAssetStatus([node.file]))
728
+ cv.assets.set(asset.id, asset);
729
+ }
730
+ for (const src of from) {
731
+ await createEdge(c, cv, src.id, node.id, { frameModel: kind === 'video' ? model : undefined });
732
+ }
733
+ const out = describe(cv, node);
734
+ render(out, () => {
735
+ success(`Created ${kind} node ${node.id}`);
736
+ printNode(out);
737
+ });
738
+ });
739
+ withGenerateOptions(canvas
740
+ .command('set <nodeId>')
741
+ .description('Update a node; only what you pass changes (content is merged, not replaced)')
742
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
743
+ .option('-n, --name <name>', 'rename')
744
+ .option('-t, --text <text>', 'replace the text of a text/title/comment node (- reads stdin)')
745
+ .option('--text-file <path>', 'read the text from a file')
746
+ .option('--move <x,y>', 'move to this position')).action(async (id, opts) => {
747
+ const c = client();
748
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
749
+ const node = requireNode(cv, id);
750
+ const body = {};
751
+ const content = { ...node.content };
752
+ let changed = false;
753
+ if (opts.name !== undefined)
754
+ (body.name = opts.name), (changed = true);
755
+ const text = await literalOrFile(opts.text, opts.textFile);
756
+ if (text !== undefined) {
757
+ if (!TEXT_KINDS.has(node.kind))
758
+ throw new Error(`${node.kind} nodes have no text`);
759
+ content.text = text;
760
+ changed = true;
761
+ }
762
+ if (opts.move)
763
+ (content.position = parsePoint(opts.move, '--move')), (changed = true);
764
+ const { patch, explicit } = await generateOptions(opts);
765
+ if (Object.keys(patch).length) {
766
+ if (!MEDIA_KINDS.has(node.kind))
767
+ throw new Error(`${node.kind} nodes cannot generate`);
768
+ const current = content.generate || {};
769
+ const merged = {
770
+ ...current,
771
+ ...patch,
772
+ values: { ...(current.values || {}), ...(patch.values || {}) },
773
+ };
774
+ const modelName = merged.model;
775
+ if (!modelName)
776
+ throw new Error('This node has no model yet — pass -m');
777
+ const model = await findModel(c, node.kind, modelName, cv.workspaceId);
778
+ const ws = await c.get(`/workspace/list/${cv.workspaceId}`);
779
+ content.generate = applyModelDefaults(model, node.kind, merged, { workspaceRatio: ws.aspectRatio, explicit });
780
+ content.mode = 'generate';
781
+ changed = true;
782
+ }
783
+ if (!changed)
784
+ throw new Error('Nothing to update — pass at least one option');
785
+ body.content = content;
786
+ await patchNode(c, node, body);
787
+ node.content = content;
788
+ if (body.name)
789
+ node.name = body.name;
790
+ const out = describe(cv, node);
791
+ render(out, () => {
792
+ success(`Updated ${id}`);
793
+ printNode(out);
794
+ });
795
+ });
796
+ canvas
797
+ .command('connect <fromId> <toId>')
798
+ .description('Connect from → to; into a media node this adds a reference')
799
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
800
+ .action(async (fromId, toId, opts) => {
801
+ const c = client();
802
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
803
+ const to = requireNode(cv, toId);
804
+ const g = to.content.generate;
805
+ const model = to.kind === 'video' && g?.model ? await findModel(c, 'video', g.model, cv.workspaceId).catch(() => undefined) : undefined;
806
+ await createEdge(c, cv, fromId, toId, { frameModel: model });
807
+ const out = describe(cv, to);
808
+ render(out, () => {
809
+ const ref = out.references.find((r) => r.nodeId === fromId);
810
+ success(`Connected ${fromId} → ${toId}${ref ? ` as ${ref.label}${ref.frameSlot ? ` (${ref.frameSlot} frame)` : ''}` : ''}`);
811
+ });
812
+ });
813
+ canvas
814
+ .command('disconnect <fromId> <toId>')
815
+ .description('Remove the connection from → to')
816
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
817
+ .action(async (fromId, toId, opts) => {
818
+ const c = client();
819
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
820
+ const edges = cv.edges.filter((e) => e.fromNodeId === fromId && e.toNodeId === toId);
821
+ if (!edges.length)
822
+ throw new Error(`No connection ${fromId} → ${toId}`);
823
+ for (const edge of edges)
824
+ await c.delete(`/fsnode/list/${edge.id}`);
825
+ render({ removed: edges.map((e) => e.id) }, () => success(`Disconnected ${fromId} → ${toId}`));
826
+ });
827
+ canvas
828
+ .command('rm <nodeIds...>')
829
+ .description('Delete nodes and their connections')
830
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
831
+ .option('-y, --yes', 'skip confirmation')
832
+ .action(async (ids, opts) => {
833
+ const c = client();
834
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
835
+ const nodes = ids.map((id) => requireNode(cv, id));
836
+ const edges = cv.edges.filter((e) => ids.includes(e.fromNodeId) || ids.includes(e.toNodeId));
837
+ const busy = nodes.filter((n) => describe(cv, n).status === 'generating');
838
+ if (busy.length)
839
+ throw new Error(`Still generating, not deleted: ${busy.map((n) => n.id).join(', ')}`);
840
+ const lines = nodes.map((n) => `${n.kind} "${n.name}" (${n.id})`).join('\n ');
841
+ const prompt = `Delete from canvas "${cv.name}":\n ${lines}\nand ${edges.length} connection(s)?`;
842
+ if (!(await confirm(prompt, opts.yes)))
843
+ return info('Aborted');
844
+ for (const edge of edges)
845
+ await c.delete(`/fsnode/list/${edge.id}`);
846
+ for (const node of nodes)
847
+ await c.delete(`/fsnode/list/${node.id}`);
848
+ render({ deleted: ids, edges: edges.map((e) => e.id) }, () => success(`Deleted ${nodes.length} node(s) and ${edges.length} connection(s)`));
849
+ });
850
+ canvas
851
+ .command('gen <nodeId>')
852
+ .description('Submit a generate node with its references (costs credits)')
853
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
854
+ .option('-y, --yes', 'skip the cost confirmation')
855
+ .option('-w, --wait', 'wait for the result')
856
+ .option('--timeout <sec>', 'with --wait, give up after this many seconds', '600')
857
+ .option('--interval <sec>', 'with --wait, poll every N seconds', '5')
858
+ .action(async (id, opts) => {
859
+ const c = client();
860
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
861
+ const node = requireNode(cv, id);
862
+ if (!MEDIA_KINDS.has(node.kind) || node.content.mode !== 'generate') {
863
+ throw new Error(`${id} is not a generate node. Create one with \`wji canvas add ${MEDIA_KINDS.has(node.kind) ? node.kind : 'image'} -m … -p …\`.`);
864
+ }
865
+ if (describe(cv, node).status === 'generating')
866
+ throw new Error(`${id} is already generating`);
867
+ const g = node.content.generate || {};
868
+ if (!g.model)
869
+ throw new Error(`${id} has no model — set one with \`wji canvas set ${id} -m …\``);
870
+ const model = await findModel(c, node.kind, g.model, cv.workspaceId);
871
+ const body = buildSubmit(cv, node, model);
872
+ const cost = await c.post('/ai/assets/preComputeCost', {
873
+ type: node.kind,
874
+ model: body.model,
875
+ n: body.n,
876
+ prompt: body.prompt,
877
+ // Auto-duration submits -1; price the node's configured length instead.
878
+ duration: body.duration && body.duration > 0 ? body.duration : g.duration,
879
+ aspectRatio: body.aspectRatio,
880
+ referenceImageCount: body.referenceImages?.length,
881
+ values: body.values,
882
+ });
883
+ const credits = typeof cost === 'object' ? cost?.cost ?? cost?.credits ?? cost?.total : cost;
884
+ const refs = referencesOf(cv, node).map((r) => r.label).join(', ') || 'none';
885
+ const auto = body.duration === -1 ? ` (auto duration — estimated for ${g.duration ?? '?'}s, final cost follows the real length)` : '';
886
+ const question = `Generate ${node.kind} "${node.name}" with ${body.model} (refs: ${refs}) for ${credits ?? '?'} credits${auto}?`;
887
+ if (!(await confirm(question, opts.yes)))
888
+ return info('Aborted');
889
+ const history = await c.post(`/ai/assets/history/${node.kind}/generations`, body);
890
+ const assetId = idOf((history?.images || [])[0]);
891
+ const nextGenerate = { ...g, historyId: String(history.id), pendingAssetId: assetId };
892
+ await patchNode(c, node, {
893
+ ...(assetId ? { file: assetId } : {}),
894
+ content: { ...node.content, generate: nextGenerate },
895
+ });
896
+ if (!opts.wait) {
897
+ if (isJsonMode())
898
+ return printJson(history);
899
+ success(`Submitted ${node.kind} generation for node ${id}`);
900
+ reportHistory(history, await resolveAssets(c, history));
901
+ info(color.dim(`The canvas shows progress. Poll with: wji canvas get ${id} or wji history get ${history.id} --wait`));
902
+ return;
903
+ }
904
+ info(color.dim(`Submitted ${history.id}, waiting…`));
905
+ const done = await pollHistory(c, history.id, {
906
+ timeoutMs: Number(opts.timeout) * 1000,
907
+ intervalMs: Number(opts.interval) * 1000,
908
+ });
909
+ if (isJsonMode())
910
+ return printJson({ ...done.history, images: done.assets });
911
+ success(`Node ${id} generated`);
912
+ reportHistory(done.history, done.assets);
913
+ });
914
+ return canvas;
915
+ }