@wji.ai/cli 0.6.1 → 0.7.1

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.
Files changed (49) hide show
  1. package/README.md +23 -3
  2. package/dist/index.js +7162 -76
  3. package/package.json +6 -9
  4. package/skill/backreel/SKILL.md +171 -0
  5. package/skill/backreel/reference/crowdfunding.md +82 -0
  6. package/skill/backreel/reference/enquiries.md +80 -0
  7. package/skill/backreel/reference/episodes.md +104 -0
  8. package/skill/backreel/reference/images.md +76 -0
  9. package/skill/npc-character/SKILL.md +111 -0
  10. package/skill/npc-character/examples/nagi.character.md +117 -0
  11. package/skill/npc-character/examples/nagi.stage.md +34 -0
  12. package/skill/npc-character/examples/yuzu.character.md +110 -0
  13. package/skill/npc-character/examples/yuzu.stage.md +30 -0
  14. package/skill/npc-character/reference/character-craft.md +153 -0
  15. package/skill/npc-character/reference/document-format.md +151 -0
  16. package/skill/npc-character/reference/interview.md +44 -0
  17. package/skill/npc-character/reference/life.md +92 -0
  18. package/skill/npc-character/reference/runtime.md +95 -0
  19. package/skill/npc-character/reference/stage-craft.md +109 -0
  20. package/skill/npc-character/reference/visual-kit.md +81 -0
  21. package/skill/npc-character/reference/voice.md +101 -0
  22. package/skill/wji/SKILL.md +1 -1
  23. package/skill/wji/reference/commands.md +25 -5
  24. package/dist/commands/agent.js +0 -294
  25. package/dist/commands/asset.js +0 -241
  26. package/dist/commands/auth.js +0 -216
  27. package/dist/commands/backreel.js +0 -320
  28. package/dist/commands/canvas.js +0 -1706
  29. package/dist/commands/class.js +0 -395
  30. package/dist/commands/config.js +0 -113
  31. package/dist/commands/crew.js +0 -205
  32. package/dist/commands/fsnode.js +0 -288
  33. package/dist/commands/gen.js +0 -352
  34. package/dist/commands/model.js +0 -136
  35. package/dist/commands/npc.js +0 -413
  36. package/dist/commands/org.js +0 -85
  37. package/dist/commands/preset.js +0 -176
  38. package/dist/commands/profile.js +0 -129
  39. package/dist/commands/skill.js +0 -272
  40. package/dist/commands/workspace.js +0 -470
  41. package/dist/core/archive.js +0 -95
  42. package/dist/core/client.js +0 -252
  43. package/dist/core/config.js +0 -95
  44. package/dist/core/crew.js +0 -546
  45. package/dist/core/members.js +0 -54
  46. package/dist/core/npc.js +0 -108
  47. package/dist/core/output.js +0 -174
  48. package/dist/core/prompt.js +0 -118
  49. package/dist/core/zip.js +0 -87
@@ -1,1706 +0,0 @@
1
- import { Command } from 'commander';
2
- import { WjiClient } from '../core/client.js';
3
- import { confirm, parseKeyValues, readValue } from '../core/prompt.js';
4
- import { clipText, color, detail, detailLevel, info, isJsonMode, printJson, relativeTime, render, success, table, truncate, warn } from '../core/output.js';
5
- import { pollHistory, reportHistory, resolveAssets } from './gen.js';
6
- import { loadMembers, memberLabel, resolveMember } from '../core/members.js';
7
- import { fetchModels } from './model.js';
8
- /**
9
- * Infinite Canvas over the plain fsnode API.
10
- *
11
- * A canvas is a folder (`workspace-v2/canvas`). Each node is a child file with
12
- * mimetype `canvas/node/<kind>`, and each connection is a child file with
13
- * mimetype `canvas/edge` whose content is `{fromNodeId, toNodeId, …}`. Edges
14
- * into a media generate node are its references, numbered per kind in edge
15
- * order (`@image1`, `@image2`, …).
16
- *
17
- * The browser can "link" a canvas (link button in the canvas top bar). That
18
- * stores the open workspace, canvas and selection on the user for 5 minutes,
19
- * so these commands default to the linked canvas and can read what is
20
- * selected. Writes go straight to the API; an open canvas picks them up
21
- * through its normal live sync.
22
- */
23
- const NODE_PREFIX = 'canvas/node/';
24
- const EDGE_MIMETYPE = 'canvas/edge';
25
- export const CANVAS_MIMETYPE = 'workspace-v2/canvas';
26
- const CANVASES_MIMETYPE = 'workspace-v2/canvases';
27
- const DATASET_MIMETYPE = 'workspace/dataset';
28
- const CREATABLE_KINDS = ['text', 'title', 'comment', 'image', 'video', 'audio', 'dataset'];
29
- const MEDIA_KINDS = new Set(['image', 'video', 'audio']);
30
- const TEXT_KINDS = new Set(['text', 'title', 'comment']);
31
- /** Default names the canvas UI shows localized. */
32
- const DEFAULT_NAMES = {
33
- text: '文本',
34
- title: '标题',
35
- comment: '备注',
36
- image: '图片',
37
- video: '视频',
38
- audio: '音频',
39
- dataset: '数据集',
40
- };
41
- /** What the web writes into a brand-new canvas folder (CanvasAppFileSystem.createCanvas). */
42
- export const INITIAL_CANVAS_CONTENT = {
43
- data: { viewport: { x: 0, y: 0, zoom: 1 }, backgroundMode: 'dots' },
44
- metadata: {},
45
- };
46
- /** The canvas lays media out on a 250px short side; text nodes are wider. */
47
- const MEDIA_BASE = 250;
48
- const PLACE_GAP = 80;
49
- const PENDING = 0;
50
- /**
51
- * The canvas's color tags (nodeColorTags.ts on the web). A node stores only
52
- * `content.colorTag {colorId}`; the label is shared per color on the canvas
53
- * folder as `content.colorTagNames {<colorId>: label}`. `alias` is the plain
54
- * color name the tag reads as, so `--tag red` finds `rose`.
55
- */
56
- export const COLOR_TAGS = [
57
- { id: 'rose', alias: 'red', hex: '#f43f5e' },
58
- { id: 'orange', alias: 'orange', hex: '#f97316' },
59
- { id: 'amber', alias: 'yellow', hex: '#eab308' },
60
- { id: 'lime', alias: 'green', hex: '#84cc16' },
61
- { id: 'teal', alias: 'teal', hex: '#14b8a6' },
62
- { id: 'sky', alias: 'blue', hex: '#0ea5e9' },
63
- { id: 'violet', alias: 'purple', hex: '#8b5cf6' },
64
- { id: 'fuchsia', alias: 'pink', hex: '#d946ef' },
65
- ];
66
- export const idOf = (value) => value == null ? undefined : typeof value === 'string' ? value : value.id;
67
- function toList(result) {
68
- return Array.isArray(result) ? result : result?.data || [];
69
- }
70
- // ── loading ─────────────────────────────────────────────────────────────────
71
- export async function linkedState(client) {
72
- const res = await client.get('/canvas-link');
73
- return res?.linked ? res.state : null;
74
- }
75
- /** `--canvas` wins; otherwise the canvas linked from the browser. */
76
- export async function resolveCanvasId(client, explicit) {
77
- if (explicit)
78
- return explicit;
79
- const state = await linkedState(client);
80
- if (state?.canvasId)
81
- return String(state.canvasId);
82
- throw new Error('No canvas. Pass --canvas <id>, or open the canvas in the browser and press the link button in its top bar.');
83
- }
84
- export async function loadCanvas(client, canvasId) {
85
- // getFresh: read-merge-write must not see the 10s server cache
86
- const folder = await client.getFresh(`/fsnode/list/${canvasId}`);
87
- if (folder?.mimetype !== CANVAS_MIMETYPE) {
88
- throw new Error(`${canvasId} is not a canvas (mimetype ${folder?.mimetype || 'none'})`);
89
- }
90
- const children = toList(await client.getFresh(`/fsnode/list/${canvasId}/children`, { limit: -1 }));
91
- const nodes = [];
92
- const edges = [];
93
- for (const raw of children) {
94
- const mimetype = String(raw.mimetype || '');
95
- if (mimetype === EDGE_MIMETYPE) {
96
- edges.push({
97
- id: raw.id,
98
- sort: Number(raw.sort || 0),
99
- fromNodeId: String(raw.content?.fromNodeId || ''),
100
- toNodeId: String(raw.content?.toNodeId || ''),
101
- content: raw.content || {},
102
- });
103
- }
104
- else if (mimetype.startsWith(NODE_PREFIX)) {
105
- nodes.push({
106
- id: raw.id,
107
- kind: mimetype.slice(NODE_PREFIX.length),
108
- name: raw.name || '',
109
- sort: Number(raw.sort || 0),
110
- file: idOf(raw.file),
111
- content: raw.content || {},
112
- raw,
113
- });
114
- }
115
- }
116
- const assetIds = [...new Set(nodes.map((n) => n.file).filter(Boolean))];
117
- const assets = new Map();
118
- if (assetIds.length) {
119
- for (const asset of await client.checkAssetStatus(assetIds))
120
- assets.set(asset.id, asset);
121
- }
122
- return {
123
- id: folder.id,
124
- name: folder.name,
125
- workspaceId: idOf(folder.workspace) || idOf(folder.root) || '',
126
- folder,
127
- access: canvasAccessOf(folder.content),
128
- nodes,
129
- edges,
130
- byId: new Map(nodes.map((n) => [n.id, n])),
131
- assets,
132
- tagNames: parseTagNames(canvasEnvelope(folder.content).data.colorTagNames),
133
- };
134
- }
135
- // ── canvas folder: content envelope and ownership ──────────────────────────
136
- /**
137
- * A canvas folder stores `content {data, metadata}`: `data` is the board
138
- * (viewport, colorTagNames, generateDefaults…), `metadata` its access
139
- * `{ownerUserId, visibility}`. Legacy canvases hold `data` flat in `content`
140
- * (the backend then reads access from the top level too).
141
- */
142
- export function canvasEnvelope(content) {
143
- const c = content && typeof content === 'object' ? content : {};
144
- const hasEnvelope = c.data && typeof c.data === 'object' && !Array.isArray(c.data);
145
- if (hasEnvelope) {
146
- return { data: c.data, metadata: c.metadata && typeof c.metadata === 'object' ? c.metadata : {}, legacy: false };
147
- }
148
- const { metadata, ownerUserId, visibility, ...data } = c;
149
- const meta = metadata && typeof metadata === 'object' ? metadata : { ...(ownerUserId ? { ownerUserId } : {}), ...(visibility ? { visibility } : {}) };
150
- return { data, metadata: meta, legacy: true };
151
- }
152
- /** Same reading as the backend's readCanvasFolderAccess: only exactly 'owner' hides a canvas. */
153
- export function canvasAccessOf(content) {
154
- const meta = canvasEnvelope(content).metadata;
155
- return {
156
- ownerUserId: typeof meta.ownerUserId === 'string' ? meta.ownerUserId : '',
157
- visibility: meta.visibility === 'owner' ? 'owner' : 'members',
158
- };
159
- }
160
- /**
161
- * Write a canvas folder's `data` and/or `metadata`, always as the whole
162
- * envelope (dropping `metadata` would reset the owner). Versioned with
163
- * `baseUpdatedAt` and retried on a conflict so a browser saving the board at
164
- * the same moment is not overwritten.
165
- */
166
- export async function updateCanvasFolder(client, canvasId, change) {
167
- for (let attempt = 0;; attempt++) {
168
- const folder = await client.getFresh(`/fsnode/list/${canvasId}`);
169
- if (folder?.mimetype !== CANVAS_MIMETYPE)
170
- throw new Error(`${canvasId} is not a canvas`);
171
- const env = canvasEnvelope(folder.content);
172
- const next = { data: { ...env.data }, metadata: { ...env.metadata } };
173
- change(next);
174
- try {
175
- return await client.patch(`/fsnode/list/${canvasId}`, {
176
- content: next,
177
- ...(folder.updatedAt ? { baseUpdatedAt: folder.updatedAt } : {}),
178
- });
179
- }
180
- catch (err) {
181
- if (err?.status === 409 && attempt < 3)
182
- continue;
183
- if (err?.status === 403) {
184
- throw new Error(`${err.message} — only the workspace owner can change who owns a canvas`);
185
- }
186
- throw err;
187
- }
188
- }
189
- }
190
- /** Every canvas in the workspace you can see (owner-only canvases of others are hidden unless you own the workspace). */
191
- export async function listCanvases(client, wsId) {
192
- const rows = [];
193
- for (const { appId, folderId } of await canvasesFolders(client, wsId)) {
194
- const canvases = toList(await client.getFresh(`/fsnode/list/${folderId}/children`, { limit: -1 }));
195
- for (const it of canvases.filter((n) => n.mimetype === CANVAS_MIMETYPE)) {
196
- rows.push({ id: it.id, name: it.name, appId, ...canvasAccessOf(it.content), updatedAt: it.updatedAt, folder: it });
197
- }
198
- }
199
- return rows;
200
- }
201
- // ── color tags ──────────────────────────────────────────────────────────────
202
- function parseTagNames(raw) {
203
- const out = {};
204
- if (!raw || typeof raw !== 'object')
205
- return out;
206
- for (const { id } of COLOR_TAGS) {
207
- const value = raw[id];
208
- if (typeof value === 'string' && value.trim())
209
- out[id] = value.trim();
210
- }
211
- return out;
212
- }
213
- /** The node's tag with the canvas label (never the node's own legacy `name`, as on the web). */
214
- export function tagOf(canvas, node) {
215
- const colorId = node.content.colorTag?.colorId;
216
- const tag = COLOR_TAGS.find((t) => t.id === colorId);
217
- if (!tag)
218
- return undefined;
219
- return { color: tag.id, alias: tag.alias, hex: tag.hex, label: canvas.tagNames[tag.id] };
220
- }
221
- /**
222
- * Colors a `--tag` value means: a color id (`rose`), its plain name (`red`),
223
- * or a label (`C01`, case-insensitive), so several colors can match one label.
224
- */
225
- export function resolveTagFilter(canvas, values) {
226
- const colors = new Set();
227
- for (const value of values) {
228
- const v = value.trim().toLowerCase();
229
- const matched = COLOR_TAGS.filter((t) => t.id === v || t.alias === v || canvas.tagNames[t.id]?.toLowerCase() === v);
230
- if (!matched.length) {
231
- const known = COLOR_TAGS.map((t) => (canvas.tagNames[t.id] ? `${t.alias} "${canvas.tagNames[t.id]}"` : t.alias));
232
- throw new Error(`No color tag "${value}" on canvas "${canvas.name}". Tags: ${known.join(', ')}`);
233
- }
234
- for (const t of matched)
235
- colors.add(t.id);
236
- }
237
- return colors;
238
- }
239
- /** `C0[1-3]` is case-insensitive; `/C0[1-3]$/` keeps exactly the flags given. */
240
- export function parseRegex(value, flag) {
241
- const slashed = /^\/(.*)\/([a-z]*)$/s.exec(value);
242
- try {
243
- return slashed ? new RegExp(slashed[1], slashed[2].replace(/[gy]/g, '')) : new RegExp(value, 'i');
244
- }
245
- catch (err) {
246
- throw new Error(`${flag}: invalid regex ${value} — ${err.message}`);
247
- }
248
- }
249
- export function tagText(tag) {
250
- if (!tag)
251
- return '';
252
- return tag.label ? `${tag.alias} "${tag.label}"` : tag.alias;
253
- }
254
- /** The `canvases` folder of every InfiniteCanvas app in a workspace. */
255
- export async function canvasesFolders(client, wsId) {
256
- const ws = await client.get(`/workspace/list/${wsId}`);
257
- const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
258
- const out = [];
259
- for (const app of apps) {
260
- const tree = await client.get(`/workspace/list/${wsId}/fs/${app.id}`);
261
- const folder = (tree?.children || []).find((n) => n.mimetype === CANVASES_MIMETYPE);
262
- if (folder)
263
- out.push({ appId: String(app.id), folderId: String(folder.id) });
264
- }
265
- return out;
266
- }
267
- export function requireNode(canvas, id) {
268
- const node = canvas.byId.get(id);
269
- if (!node)
270
- throw new Error(`Node ${id} is not on canvas "${canvas.name}" (${canvas.id})`);
271
- return node;
272
- }
273
- /** Incoming edges in the order the canvas numbers them: sort, then id. */
274
- function referencesOf(canvas, node) {
275
- const refs = canvas.edges
276
- .filter((e) => e.toNodeId === node.id && canvas.byId.has(e.fromNodeId))
277
- .sort((a, b) => a.sort - b.sort || a.id.localeCompare(b.id));
278
- const counts = {};
279
- return refs.map((edge) => {
280
- const from = canvas.byId.get(edge.fromNodeId);
281
- counts[from.kind] = (counts[from.kind] || 0) + 1;
282
- return {
283
- edgeId: edge.id,
284
- nodeId: from.id,
285
- kind: from.kind,
286
- name: from.name,
287
- label: edge.content.referenceLabel || `@${from.kind}${counts[from.kind]}`,
288
- frameSlot: edge.content.videoFrameSlot,
289
- };
290
- });
291
- }
292
- export function describe(canvas, node) {
293
- const asset = node.file ? canvas.assets.get(node.file) : undefined;
294
- const generate = node.content.generate;
295
- const pending = asset && asset.status === PENDING && !asset.error;
296
- return {
297
- id: node.id,
298
- kind: node.kind,
299
- name: node.name,
300
- tag: tagOf(canvas, node),
301
- position: node.content.position,
302
- size: Number(node.content.width) > 0 && Number(node.content.height) > 0 ? { width: Number(node.content.width), height: Number(node.content.height) } : undefined,
303
- mode: MEDIA_KINDS.has(node.kind) ? node.content.mode || 'display' : undefined,
304
- text: TEXT_KINDS.has(node.kind) ? node.content.text ?? '' : undefined,
305
- assetId: node.file,
306
- url: asset?.url || undefined,
307
- assetType: asset?.type,
308
- dataset: node.kind === 'dataset' && node.content.datasetId
309
- ? { id: String(node.content.datasetId), name: node.content.datasetName, type: node.content.datasetType, folder: node.content.datasetParentName }
310
- : undefined,
311
- status: generate?.historyId ? (pending ? 'generating' : asset?.error ? 'failed' : asset?.url ? 'done' : 'pending') : undefined,
312
- error: asset?.error?.message || asset?.error || undefined,
313
- generate,
314
- references: referencesOf(canvas, node),
315
- outgoing: canvas.edges.filter((e) => e.fromNodeId === node.id).map((e) => e.toNodeId),
316
- };
317
- }
318
- /**
319
- * The canvas commands always load the full canvas (edits and generation need
320
- * every field), so --detail is applied here, to the JSON they print. Mirrors
321
- * the backend's canvas_list_nodes levels.
322
- */
323
- export function trimDescribed(row, level = detailLevel()) {
324
- if (!level || level === 'full')
325
- return row;
326
- if (level === 'brief') {
327
- return compactRow({
328
- id: row.id,
329
- kind: row.kind,
330
- name: row.name,
331
- tag: row.tag,
332
- mode: row.mode,
333
- status: row.status,
334
- assetId: row.assetId,
335
- position: row.position,
336
- references: row.references.map((r) => ({ nodeId: r.nodeId, label: r.label })),
337
- outgoing: row.outgoing,
338
- });
339
- }
340
- const g = row.generate;
341
- return compactRow({
342
- ...row,
343
- text: row.text === undefined ? undefined : clipText(row.text) ?? '',
344
- error: typeof row.error === 'string' ? clipText(row.error) : row.error,
345
- generate: g
346
- ? compactRow({
347
- prompt: clipText(g.prompt),
348
- model: g.model,
349
- ratio: g.ratio,
350
- resolution: g.resolution,
351
- duration: g.duration,
352
- historyId: g.historyId,
353
- pendingAssetId: g.pendingAssetId,
354
- })
355
- : undefined,
356
- });
357
- }
358
- function compactRow(value) {
359
- const out = {};
360
- for (const key of Object.keys(value))
361
- if (value[key] !== undefined)
362
- out[key] = value[key];
363
- return out;
364
- }
365
- // ── models ──────────────────────────────────────────────────────────────────
366
- /** An audio node is fed by either an `audio` or a `tts` model — the backend tags voice models `tts`. */
367
- const KIND_FEATURES = { audio: ['audio', 'tts'] };
368
- async function findModel(client, kind, name, workspaceId) {
369
- const features = KIND_FEATURES[kind] ?? [kind];
370
- const rows = (await Promise.all(features.map((f) => fetchModels(client, { features: [f], workspace: workspaceId })))).flat();
371
- const usable = rows.filter((m) => m.enabled !== false);
372
- const model = usable.find((m) => m.nickname === name) ?? usable.find((m) => m.name === name);
373
- if (!model) {
374
- const hint = features.map((f) => `--feature ${f}`).join(' / ');
375
- throw new Error(`No ${kind} model "${name}". See \`wji models list ${hint}\`.`);
376
- }
377
- return model;
378
- }
379
- /**
380
- * Fill and validate generate settings the way the canvas generate panel does
381
- * (shared/generate/formDefaults.ts): ratio from the model's list, resolution
382
- * from `form.other.resolution`, other params from their defaults, and unknown
383
- * params dropped.
384
- */
385
- function applyModelDefaults(model, kind, generate, opts) {
386
- const form = model.form || {};
387
- const other = form.other || {};
388
- const next = { ...generate, model: model.nickname || model.name };
389
- const ratios = form.aspectRatio || [];
390
- if (kind !== 'audio' && ratios.length) {
391
- if (next.ratio && !ratios.includes(next.ratio)) {
392
- if (opts.explicit.has('ratio')) {
393
- throw new Error(`${next.model} does not support ratio ${next.ratio}. Use one of: ${ratios.join(' ')}`);
394
- }
395
- next.ratio = undefined;
396
- }
397
- if (!next.ratio) {
398
- next.ratio = opts.workspaceRatio && ratios.includes(opts.workspaceRatio) ? opts.workspaceRatio : ratios[0];
399
- }
400
- }
401
- const resField = other.resolution;
402
- if (resField?.options?.length) {
403
- const valid = resField.options.map((o) => String(o.value));
404
- if (next.resolution && !valid.includes(String(next.resolution))) {
405
- if (opts.explicit.has('resolution')) {
406
- throw new Error(`${next.model} resolution must be one of: ${valid.join(' ')}`);
407
- }
408
- next.resolution = undefined;
409
- }
410
- if (!next.resolution)
411
- next.resolution = String(resField.default ?? valid[0]);
412
- }
413
- const durationForm = form.video?.duration;
414
- if (kind === 'video' && durationForm?.enable) {
415
- if (next.duration != null) {
416
- const d = Number(next.duration);
417
- if (d < durationForm.min || d > durationForm.max) {
418
- throw new Error(`${next.model} duration must be ${durationForm.min}–${durationForm.max}s`);
419
- }
420
- }
421
- else {
422
- next.duration = durationForm.default;
423
- }
424
- }
425
- const values = {};
426
- for (const [key, value] of Object.entries(next.values || {})) {
427
- if (key in other && key !== 'resolution')
428
- values[key] = value;
429
- else if (opts.explicit.has(`values.${key}`))
430
- warn(`${next.model} has no parameter "${key}" — ignored`);
431
- }
432
- for (const [key, field] of Object.entries(other)) {
433
- if (key === 'resolution')
434
- continue;
435
- const current = values[key];
436
- const empty = current === undefined || current === null || current === '';
437
- if (empty && field.default !== undefined)
438
- values[key] = field.default;
439
- if (field.type === 'select' && field.options?.length) {
440
- const valid = field.options.map((o) => o.value);
441
- if (!empty && !valid.includes(current)) {
442
- if (opts.explicit.has(`values.${key}`)) {
443
- throw new Error(`${next.model} parameter ${key} must be one of: ${valid.join(' ')}`);
444
- }
445
- values[key] = field.default ?? valid[0];
446
- }
447
- }
448
- }
449
- // An explicit duration is ignored while auto-duration is on, so turn it off.
450
- if (kind === 'video' && opts.explicit.has('duration') && 'autoDuration' in other && !opts.explicit.has('values.autoDuration')) {
451
- values.autoDuration = false;
452
- }
453
- next.values = values;
454
- if (next.improvePromptEnabled === undefined)
455
- next.improvePromptEnabled = false;
456
- return next;
457
- }
458
- // ── writes ──────────────────────────────────────────────────────────────────
459
- /** Short side 250, like the canvas; text-ish nodes use a typical card size. */
460
- export function estimateSize(canvas, node) {
461
- const w = Number(node.content.width);
462
- const h = Number(node.content.height);
463
- if (w > 0 && h > 0)
464
- return { width: w, height: h };
465
- if (node.kind === 'dataset')
466
- return { width: 480, height: 220 };
467
- if (!MEDIA_KINDS.has(node.kind))
468
- return { width: 320, height: 200 };
469
- const ratioText = node.content.mode === 'generate' ? node.content.generate?.ratio : undefined;
470
- const asset = node.file ? canvas.assets.get(node.file) : undefined;
471
- let ratio = node.kind === 'video' ? 16 / 9 : 1;
472
- if (asset?.width && asset?.height)
473
- ratio = asset.width / asset.height;
474
- else if (ratioText && /^\d+(\.\d+)?:\d+(\.\d+)?$/.test(ratioText)) {
475
- const [w, h] = ratioText.split(':').map(Number);
476
- ratio = w / h;
477
- }
478
- if (node.kind === 'audio')
479
- ratio = 1;
480
- return ratio >= 1
481
- ? { width: Math.round(MEDIA_BASE * ratio), height: MEDIA_BASE }
482
- : { width: MEDIA_BASE, height: Math.round(MEDIA_BASE / ratio) };
483
- }
484
- /**
485
- * Right of the anchor nodes, top-aligned (else right of everything), then
486
- * pushed down past any node already sitting there.
487
- */
488
- function placeNear(canvas, anchors, size) {
489
- const pool = anchors.length ? anchors : canvas.nodes;
490
- if (!pool.length)
491
- return { x: 0, y: 0 };
492
- let right = -Infinity;
493
- let top = Infinity;
494
- for (const node of pool) {
495
- const p = node.content.position || { x: 0, y: 0 };
496
- right = Math.max(right, Number(p.x) + estimateSize(canvas, node).width);
497
- top = Math.min(top, Number(p.y));
498
- }
499
- const x = Math.round(right + PLACE_GAP);
500
- let y = Math.round(top);
501
- for (let guard = 0; guard < 200; guard++) {
502
- const hit = canvas.nodes.find((node) => {
503
- const p = node.content.position || { x: 0, y: 0 };
504
- const s = estimateSize(canvas, node);
505
- return x < p.x + s.width && x + size.width > p.x && y < p.y + s.height && y + size.height > p.y;
506
- });
507
- if (!hit)
508
- break;
509
- y = Math.round(Number(hit.content.position?.y || 0) + estimateSize(canvas, hit).height + PLACE_GAP);
510
- }
511
- return { x, y };
512
- }
513
- function usesFrameSlots(model) {
514
- const support = model?.form?.video?.support || [];
515
- return { start: support.includes('start'), end: support.includes('end') };
516
- }
517
- async function createEdge(client, canvas, fromId, toId, opts = {}) {
518
- const from = requireNode(canvas, fromId);
519
- const to = requireNode(canvas, toId);
520
- if (from.id === to.id)
521
- throw new Error('A node cannot reference itself');
522
- if (canvas.edges.some((e) => e.fromNodeId === from.id && e.toNodeId === to.id)) {
523
- throw new Error(`${from.id} is already connected to ${to.id}`);
524
- }
525
- const content = {
526
- fromNodeId: from.id,
527
- toNodeId: to.id,
528
- sourceHandle: 'right',
529
- targetHandle: 'left',
530
- ...(opts.content || {}),
531
- };
532
- // First/last-frame video models take connected images as frame slots, in order.
533
- if (opts.frameModel && to.kind === 'video' && from.kind === 'image') {
534
- const support = usesFrameSlots(opts.frameModel);
535
- const taken = new Set(referencesOf(canvas, to).map((r) => r.frameSlot).filter(Boolean));
536
- if (support.start && !taken.has('start'))
537
- content.videoFrameSlot = 'start';
538
- else if (support.end && !taken.has('end'))
539
- content.videoFrameSlot = 'end';
540
- }
541
- const raw = await client.post('/fsnode/create', {
542
- name: '连线',
543
- type: 'file',
544
- mimetype: EDGE_MIMETYPE,
545
- parent: canvas.id,
546
- content,
547
- sort: 0,
548
- });
549
- const edge = { id: raw.id, sort: 0, fromNodeId: from.id, toNodeId: to.id, content };
550
- canvas.edges.push(edge);
551
- return edge;
552
- }
553
- /**
554
- * Write only the top-level content keys that changed (`contentPatch`), so an
555
- * edit here does not overwrite what someone else changed in another key at
556
- * the same moment in the browser. A backend without `contentPatch` strips it
557
- * silently; the response then lacks the change and we write the whole content.
558
- */
559
- async function patchNode(client, node, change) {
560
- const { content, ...rest } = change;
561
- const contentPatch = {};
562
- const contentUnset = [];
563
- if (content) {
564
- for (const key of new Set([...Object.keys(node.content), ...Object.keys(content)])) {
565
- if (!(key in content))
566
- contentUnset.push(key);
567
- else if (JSON.stringify(content[key]) !== JSON.stringify(node.content[key]))
568
- contentPatch[key] = content[key];
569
- }
570
- }
571
- const body = { ...rest };
572
- if (Object.keys(contentPatch).length)
573
- body.contentPatch = contentPatch;
574
- if (contentUnset.length)
575
- body.contentUnset = contentUnset;
576
- if (!Object.keys(body).length)
577
- return node.raw;
578
- const res = await client.patch(`/fsnode/list/${node.id}`, body);
579
- const landed = Object.entries(contentPatch).every(([k, v]) => JSON.stringify(res?.content?.[k]) === JSON.stringify(v)) &&
580
- contentUnset.every((k) => !(k in (res?.content || {})));
581
- if (content && !landed)
582
- return client.patch(`/fsnode/list/${node.id}`, { ...rest, content });
583
- return res;
584
- }
585
- // ── output ──────────────────────────────────────────────────────────────────
586
- function summary(n) {
587
- if (n.text !== undefined)
588
- return String(n.text).replace(/\s+/g, ' ');
589
- if (n.dataset)
590
- return [n.dataset.type, n.dataset.name, color.dim(n.dataset.id)].filter(Boolean).join(' ');
591
- if (n.mode === 'generate') {
592
- const g = n.generate || {};
593
- const state = n.status === 'done' ? color.green('done') : n.status === 'failed' ? color.red('failed') : n.status ? color.yellow(n.status) : '';
594
- return [state, [g.model, g.ratio, g.duration && `${g.duration}s`].filter(Boolean).join(' '), g.prompt && `"${g.prompt}"`]
595
- .filter(Boolean)
596
- .join(' · ');
597
- }
598
- return n.url || n.assetId || '';
599
- }
600
- export function nodeTable(rows) {
601
- table(rows, [
602
- { header: 'id', value: (n) => n.id },
603
- { header: 'kind', value: (n) => (n.mode === 'generate' ? `${n.kind}*` : n.kind) },
604
- { header: 'name', value: (n) => truncate(n.name, 20) },
605
- { header: 'tag', value: (n) => truncate(tagText(n.tag), 16) },
606
- { header: 'refs', value: (n) => n.references.map((r) => r.label).join(',') },
607
- { header: 'summary', value: (n) => truncate(summary(n), 64) },
608
- ]);
609
- }
610
- function printNode(n) {
611
- const g = n.generate || {};
612
- detail([
613
- ['id', n.id],
614
- ['kind', n.mode ? `${n.kind} (${n.mode})` : n.kind],
615
- ['name', n.name],
616
- ['tag', n.tag && `${tagText(n.tag)} ${color.dim(n.tag.color)}`],
617
- ['position', n.position && `${Math.round(n.position.x)},${Math.round(n.position.y)}`],
618
- ['size', n.size && `${n.size.width}×${n.size.height}`],
619
- ['text', n.text],
620
- ['dataset', n.dataset && `${n.dataset.type || 'dataset'} "${n.dataset.name || ''}" ${color.dim(n.dataset.id)}${n.dataset.folder ? ` in ${n.dataset.folder}` : ''}`],
621
- ['asset', n.assetId],
622
- ['url', n.url],
623
- ['status', n.status],
624
- ['error', n.error],
625
- ['prompt', g.prompt],
626
- ['model', g.model],
627
- ['ratio', g.ratio],
628
- ['resolution', g.resolution],
629
- ['duration', g.duration],
630
- ['params', g.values && Object.keys(g.values).length ? JSON.stringify(g.values) : undefined],
631
- ['history', g.historyId],
632
- [
633
- 'references',
634
- n.references.length
635
- ? n.references.map((r) => `${r.label}${r.frameSlot ? `(${r.frameSlot})` : ''} ← ${r.nodeId}`).join(', ')
636
- : undefined,
637
- ],
638
- ]);
639
- }
640
- /** Prompts start with `@image1`, so a leading `@` is text here, not a file. */
641
- async function literalOrFile(value, file) {
642
- if (file !== undefined)
643
- return readValue(`@${file}`);
644
- if (value === undefined)
645
- return undefined;
646
- return value === '-' ? readValue('-') : value;
647
- }
648
- /**
649
- * Text, title and comment nodes are Markdown on the web (a single line break joins the lines into
650
- * one paragraph). `--plain` keeps plain text as written: a line break becomes a Markdown hard break
651
- * (" \n"); blank lines stay paragraph breaks and fenced code blocks are left alone.
652
- */
653
- export function plainToMarkdown(text) {
654
- let fenced = false;
655
- const lines = text.replace(/\r\n?/g, '\n').split('\n');
656
- return lines
657
- .map((line, i) => {
658
- const fence = /^\s*(```|~~~)/.test(line);
659
- if (fence)
660
- fenced = !fenced;
661
- const next = lines[i + 1];
662
- const hard = !fence && !fenced && i < lines.length - 1 && line.trim() !== '' && next !== undefined && next.trim() !== '' && !/^\s*(```|~~~)/.test(next);
663
- return hard ? `${line.replace(/\s+$/, '')} ` : line;
664
- })
665
- .join('\n');
666
- }
667
- /** True when a single line break would be joined away by the web's Markdown rendering. */
668
- export function hasSoftBreaks(text) {
669
- return /[^\s\n][ \t]?\n(?![ \t]*\n)[ \t]*\S/.test(text.replace(/\r\n?/g, '\n').replace(/ {2,}\n/g, '\n\n'));
670
- }
671
- async function nodeText(kind, opts) {
672
- const text = await literalOrFile(opts.text, opts.textFile);
673
- if (text === undefined)
674
- return undefined;
675
- if (opts.plain)
676
- return plainToMarkdown(text);
677
- if (TEXT_KINDS.has(kind) && hasSoftBreaks(text) && !isJsonMode()) {
678
- warn('text nodes render as Markdown on the web — single line breaks join into one paragraph; pass --plain to keep them');
679
- }
680
- return text;
681
- }
682
- function parseSize(value) {
683
- const [width, height] = value.split(/[,x×]/i).map((v) => Number(v.trim()));
684
- if (!(width > 0 && height > 0))
685
- throw new Error('--size must look like width,height (e.g. 600,900)');
686
- return { width: Math.round(width), height: Math.round(height) };
687
- }
688
- function parsePoint(value, label) {
689
- const [x, y] = value.split(',').map((v) => Number(v.trim()));
690
- if (!Number.isFinite(x) || !Number.isFinite(y))
691
- throw new Error(`${label} must look like x,y`);
692
- return { x, y };
693
- }
694
- /** Collect generate options; `explicit` records what the user actually passed. */
695
- async function generateOptions(opts) {
696
- const patch = {};
697
- const explicit = new Set();
698
- const prompt = await literalOrFile(opts.prompt, opts.promptFile);
699
- if (prompt !== undefined)
700
- (patch.prompt = prompt), explicit.add('prompt');
701
- if (opts.model !== undefined)
702
- (patch.model = opts.model), explicit.add('model');
703
- if (opts.ratio !== undefined)
704
- (patch.ratio = opts.ratio), explicit.add('ratio');
705
- if (opts.resolution !== undefined)
706
- (patch.resolution = opts.resolution), explicit.add('resolution');
707
- if (opts.duration !== undefined) {
708
- const d = Number(opts.duration);
709
- if (!Number.isFinite(d))
710
- throw new Error('--duration must be seconds');
711
- patch.duration = d;
712
- explicit.add('duration');
713
- }
714
- if (opts.set?.length) {
715
- patch.values = parseKeyValues(opts.set);
716
- for (const key of Object.keys(patch.values))
717
- explicit.add(`values.${key}`);
718
- }
719
- return { patch, explicit };
720
- }
721
- function withGenerateOptions(cmd) {
722
- return cmd
723
- .option('-p, --prompt <text>', 'generation prompt; @image1 etc. are literal (- reads stdin)')
724
- .option('--prompt-file <path>', 'read the prompt from a file')
725
- .option('-m, --model <model>', 'model nickname (see `wji models list --feature <kind>`)')
726
- .option('-r, --ratio <ratio>', 'aspect ratio, e.g. 16:9')
727
- .option('--resolution <res>', 'e.g. 720p, 1K')
728
- .option('-d, --duration <sec>', 'video duration (turns auto-duration off)')
729
- .option('--set <key=value...>', 'model parameters from `wji models get`, merged into existing ones');
730
- }
731
- // ── generation ──────────────────────────────────────────────────────────────
732
- const UNSUPPORTED_REF_KINDS = new Set(['table', 'youtube', 'dataset', 'bundle', 'sprite', 'layer', 'panorama']);
733
- // Seedance 2.5 draft mode (frontend shared/func/seedanceDraft.ts): 2.5 + 480p is a draft, and a draft
734
- // becomes the finished 1080p clip only through its BytePlus task id (draftTaskId), within 7 days.
735
- const SEEDANCE_25_MODELS = new Set(['seedance-2.5', 'seedance-2.5-uncensored', 'seedance-2.5-ref', 'seedance-2.5-ref-uncensored']);
736
- const SEEDANCE_DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
737
- function isSeedance25(model) {
738
- return !!model && SEEDANCE_25_MODELS.has(model);
739
- }
740
- /** The flag the web form sets before submitting (syncSeedanceDraftFlag) — every 2.5 + 480p generation is a draft. */
741
- function syncSeedanceDraftFlag(model, values) {
742
- if (isSeedance25(model) && String(values.resolution || '') === '480p')
743
- values.draft = true;
744
- else
745
- delete values.draft;
746
- return values;
747
- }
748
- function draftTaskIdOf(values) {
749
- const raw = values?.draftTaskId;
750
- return typeof raw === 'string' ? raw.trim() : '';
751
- }
752
- /**
753
- * What a finished clip can be upscaled from — like the web's canSeedanceDraftUpscale: flagged as a
754
- * draft (config.values.draft), carrying its BytePlus task id (the draft id), and not past 7 days.
755
- */
756
- function seedanceDraftSource(asset) {
757
- const taskId = String(asset?.extra?.byteplus?.taskId || '').trim();
758
- const model = String(asset?.config?.model || '');
759
- const created = asset?.createdAt ? Date.parse(asset.createdAt) : Number.NaN;
760
- const expiresAt = Number.isFinite(created) ? new Date(created + SEEDANCE_DRAFT_TTL_MS) : undefined;
761
- return {
762
- taskId,
763
- model,
764
- flagged: asset?.config?.values?.draft === true,
765
- resolution: String(asset?.config?.values?.resolution || ''),
766
- expiresAt,
767
- expired: !expiresAt || Date.now() > expiresAt.getTime(),
768
- };
769
- }
770
- /**
771
- * Build the same request the canvas generate button sends
772
- * (shared/generate/buildSubmitPayload.ts): references in edge order, text
773
- * references prefixed to the prompt, first/last frames from frame slots.
774
- */
775
- function buildSubmit(canvas, node, model) {
776
- const kind = node.kind;
777
- const g = node.content.generate || {};
778
- // Draft → 1080p upscale (buildCanvasVideoSubmitPayload): only the draft task id counts — no
779
- // prompt, no references; the edge from the draft clip is there for people reading the canvas.
780
- const draftTaskId = kind === 'video' ? draftTaskIdOf(g.values) : '';
781
- if (draftTaskId) {
782
- const duration = typeof g.duration === 'number' && Number.isFinite(g.duration) ? g.duration : undefined;
783
- return {
784
- originalPrompt: g.prompt || 'N/A',
785
- prompt: g.prompt || 'N/A',
786
- model: g.model,
787
- aspectRatio: g.ratio,
788
- n: 1,
789
- workspace: canvas.workspaceId,
790
- fsnode: node.id,
791
- scope: 'canvas',
792
- scopeId: canvas.id,
793
- duration,
794
- values: {
795
- ...(g.values || {}),
796
- mode: 'video',
797
- resolution: '1080p',
798
- draft: false,
799
- draftTaskId,
800
- ...(duration !== undefined ? { duration } : {}),
801
- },
802
- };
803
- }
804
- const userPrompt = String(g.prompt || '').trim();
805
- const form = model.form || {};
806
- const frames = usesFrameSlots(model);
807
- const firstLast = frames.start && frames.end;
808
- const texts = [];
809
- const images = [];
810
- const videos = [];
811
- const audios = [];
812
- let startImage;
813
- let endImage;
814
- for (const ref of referencesOf(canvas, node)) {
815
- const from = canvas.byId.get(ref.nodeId);
816
- if (from.kind === 'text') {
817
- const body = String(from.content.text || '').trim();
818
- if (body)
819
- texts.push(from.name ? `[${from.name}]\n${body}` : body);
820
- continue;
821
- }
822
- if (TEXT_KINDS.has(from.kind))
823
- continue;
824
- if (UNSUPPORTED_REF_KINDS.has(from.kind)) {
825
- throw new Error(`Reference ${ref.label} is a ${from.kind} node, which only the canvas UI can submit. Generate this node from the browser.`);
826
- }
827
- const asset = from.file ? canvas.assets.get(from.file) : undefined;
828
- if (!asset?.url) {
829
- throw new Error(`Reference ${ref.label} (${from.id}) has no finished asset yet`);
830
- }
831
- const item = { assetId: asset.id };
832
- const category = String(asset.category || asset.type || from.kind);
833
- if (category.startsWith('image')) {
834
- if (kind === 'video' && (frames.start || frames.end) && ref.frameSlot) {
835
- if (ref.frameSlot === 'start' && frames.start && !startImage)
836
- startImage = item;
837
- if (ref.frameSlot === 'end' && frames.end && !endImage)
838
- endImage = item;
839
- continue;
840
- }
841
- images.push(item);
842
- }
843
- else if (category.startsWith('video'))
844
- videos.push({ ...item, duration: asset.duration });
845
- else if (category.startsWith('audio'))
846
- audios.push(item);
847
- }
848
- const refMax = form.reference?.enable ? Number(form.reference.max || 0) : 0;
849
- const imageRefs = kind === 'video' && firstLast ? [] : images;
850
- if (imageRefs.length > refMax) {
851
- throw new Error(`${g.model} accepts ${refMax} reference image(s); this node has ${imageRefs.length}. Disconnect some first.`);
852
- }
853
- if (videos.length && kind === 'video' && !form.referenceVideos) {
854
- throw new Error(`${g.model} does not take reference videos`);
855
- }
856
- if (audios.length && !form.referenceAudios) {
857
- throw new Error(`${g.model} does not take reference audio`);
858
- }
859
- const prompt = texts.length ? `${texts.join('\n\n')}${userPrompt ? `\n\n${userPrompt}` : ''}` : userPrompt;
860
- if (!prompt)
861
- throw new Error(`Node ${node.id} has no prompt. Set one with \`wji canvas set ${node.id} -p "…"\`.`);
862
- const values = { ...(g.values || {}) };
863
- const other = form.other || {};
864
- let aspectRatio = g.ratio;
865
- let duration = g.duration;
866
- if (kind === 'video') {
867
- const seedanceRef = !!other.taskMode;
868
- const taskMode = values.taskMode === 'edit' || values.taskMode === 'extend' ? values.taskMode : 'generate';
869
- if (seedanceRef && taskMode !== 'generate')
870
- aspectRatio = 'adaptive';
871
- const autoDuration = 'autoDuration' in other && !(seedanceRef && taskMode === 'edit') && values.autoDuration !== false;
872
- if ((seedanceRef && taskMode === 'edit') || autoDuration)
873
- duration = -1;
874
- if (seedanceRef && taskMode === 'edit')
875
- values.outputFormat = 'mov';
876
- }
877
- const body = {
878
- originalPrompt: userPrompt,
879
- prompt,
880
- model: g.model,
881
- aspectRatio,
882
- n: kind === 'image' ? Math.max(1, Number(values.n) || 1) : 1,
883
- workspace: canvas.workspaceId,
884
- fsnode: node.id,
885
- scope: 'canvas',
886
- scopeId: canvas.id,
887
- values: {
888
- mode: kind === 'audio' ? 'text-to-audio' : kind,
889
- ...values,
890
- ...(kind !== 'audio' && g.resolution ? { resolution: g.resolution } : {}),
891
- },
892
- };
893
- if (kind === 'video')
894
- syncSeedanceDraftFlag(g.model, body.values);
895
- if (imageRefs.length)
896
- body.referenceImages = imageRefs;
897
- if (kind === 'video') {
898
- body.duration = duration ?? 5;
899
- if (startImage)
900
- body.startImage = startImage;
901
- if (endImage)
902
- body.endImage = endImage;
903
- if (videos.length)
904
- body.referenceVideos = videos.map(({ assetId }) => ({ assetId }));
905
- }
906
- if (audios.length)
907
- body.referenceAudios = audios;
908
- return body;
909
- }
910
- // ── ownership, tags and copy helpers ───────────────────────────────────────
911
- export function parseVisibility(value) {
912
- const v = value.toLowerCase();
913
- if (v === 'owner' || v === 'private')
914
- return 'owner';
915
- if (v === 'members' || v === 'member' || v === 'workspace')
916
- return 'members';
917
- throw new Error(`--visibility must be owner or members (got ${value})`);
918
- }
919
- /** A new canvas folder the way the web creates one, with its access in the metadata envelope. */
920
- export async function createCanvasFolder(client, canvasesFolderId, name, access) {
921
- const metadata = {};
922
- if (access?.ownerUserId)
923
- metadata.ownerUserId = access.ownerUserId;
924
- if (access?.visibility)
925
- metadata.visibility = access.visibility;
926
- return client.post('/fsnode/create', {
927
- name,
928
- type: 'folder',
929
- mimetype: CANVAS_MIMETYPE,
930
- parent: canvasesFolderId,
931
- content: { ...INITIAL_CANVAS_CONTENT, metadata },
932
- });
933
- }
934
- /** One color id from a color name, id or this canvas's label. */
935
- export function colorOfTag(canvas, tag) {
936
- const colors = [...resolveTagFilter(canvas, [tag])];
937
- if (colors.length > 1)
938
- throw new Error(`Label "${tag}" is on several colors (${colors.join(', ')}) — name the color`);
939
- return colors[0];
940
- }
941
- /** The source canvas and the nodes to copy: ids given, and/or the linked selection. */
942
- export async function copySource(c, ids, opts) {
943
- const linked = opts.selected || !ids.length ? await linkedState(c) : null;
944
- const useSelection = opts.selected || !ids.length;
945
- if (useSelection && !linked?.selectedNodeIds?.length) {
946
- throw new Error('Nothing to copy: pass node ids, or select nodes in the browser canvas and press link');
947
- }
948
- const sourceId = opts.canvas || (useSelection ? linked.canvasId : await resolveCanvasId(c));
949
- if (useSelection && opts.canvas && opts.canvas !== linked.canvasId) {
950
- throw new Error(`The selection is on canvas ${linked.canvasId}, not ${opts.canvas}`);
951
- }
952
- const source = await loadCanvas(c, sourceId);
953
- const nodeIds = [...new Set([...ids, ...(useSelection ? linked.selectedNodeIds : [])])];
954
- return { source, nodes: nodeIds.map((id) => requireNode(source, id)) };
955
- }
956
- export function copyPlacement(opts) {
957
- return { at: opts.at ? parsePoint(opts.at, '--at') : undefined, samePosition: !!opts.samePosition };
958
- }
959
- /** The canvas's own copy rule (cloneContentForDuplicate): no position, no generation run, finished media shared. */
960
- function copyableFile(canvas, node) {
961
- const asset = node.file ? canvas.assets.get(node.file) : undefined;
962
- return asset?.url && asset.status !== PENDING && !asset.error ? node.file : undefined;
963
- }
964
- /**
965
- * Copy nodes from one canvas into another, like the canvas's copy-paste:
966
- * new nodes, connections among them re-created in reference order, color
967
- * tags kept (and their label carried over when the target has none).
968
- */
969
- export async function copyNodes(c, source, nodes, target, placement = {}) {
970
- const pos = (n) => ({ x: Number(n.content.position?.x) || 0, y: Number(n.content.position?.y) || 0 });
971
- const minX = Math.min(...nodes.map((n) => pos(n).x));
972
- const minY = Math.min(...nodes.map((n) => pos(n).y));
973
- let origin = placement.at;
974
- if (!origin && !placement.samePosition && target.nodes.length) {
975
- const right = Math.max(...target.nodes.map((n) => pos(n).x + estimateSize(target, n).width));
976
- const top = Math.min(...target.nodes.map((n) => pos(n).y));
977
- origin = { x: Math.round(right + PLACE_GAP * 2), y: Math.round(top) };
978
- }
979
- if (!origin)
980
- origin = { x: minX, y: minY };
981
- // Labels travel with the tag when the target has no label for that color yet.
982
- const labels = {};
983
- for (const n of nodes) {
984
- const colorId = n.content.colorTag?.colorId;
985
- const label = colorId && source.tagNames[colorId];
986
- if (!label)
987
- continue;
988
- if (!target.tagNames[colorId])
989
- labels[colorId] = label;
990
- else if (target.tagNames[colorId] !== label) {
991
- warn(`${target.name}: ${colorId} is labelled "${target.tagNames[colorId]}" there, not "${label}" — kept theirs`);
992
- }
993
- }
994
- if (Object.keys(labels).length) {
995
- await updateCanvasFolder(c, target.id, (env) => {
996
- env.data.colorTagNames = { ...labels, ...(env.data.colorTagNames || {}) };
997
- });
998
- Object.assign(target.tagNames, labels);
999
- }
1000
- const idMap = new Map();
1001
- const created = [];
1002
- for (const n of nodes) {
1003
- const content = JSON.parse(JSON.stringify(n.content || {}));
1004
- if (content.generate) {
1005
- delete content.generate.historyId;
1006
- delete content.generate.pendingAssetId;
1007
- }
1008
- const p = pos(n);
1009
- content.position = { x: Math.round(origin.x + p.x - minX), y: Math.round(origin.y + p.y - minY) };
1010
- const file = copyableFile(source, n);
1011
- const raw = await c.post('/fsnode/create', {
1012
- name: n.name || DEFAULT_NAMES[n.kind] || n.kind,
1013
- type: 'file',
1014
- mimetype: `${NODE_PREFIX}${n.kind}`,
1015
- parent: target.id,
1016
- sort: 0,
1017
- content,
1018
- ...(file ? { file } : {}),
1019
- });
1020
- idMap.set(n.id, raw.id);
1021
- const node = { id: raw.id, kind: n.kind, name: raw.name, sort: 0, file, content, raw };
1022
- created.push(node);
1023
- target.nodes.push(node);
1024
- target.byId.set(node.id, node);
1025
- if (file && source.assets.has(file))
1026
- target.assets.set(file, source.assets.get(file));
1027
- }
1028
- // Oldest edge first, so the new ids keep the same @image1/@image2 numbering.
1029
- const inside = source.edges
1030
- .filter((e) => idMap.has(e.fromNodeId) && idMap.has(e.toNodeId))
1031
- .sort((a, b) => a.sort - b.sort || a.id.localeCompare(b.id));
1032
- const skippedEdges = source.edges.filter((e) => idMap.has(e.toNodeId) && !idMap.has(e.fromNodeId)).length;
1033
- for (const e of inside) {
1034
- const { fromNodeId, toNodeId, ...rest } = e.content;
1035
- await createEdge(c, target, idMap.get(e.fromNodeId), idMap.get(e.toNodeId), { content: rest });
1036
- }
1037
- return {
1038
- canvasId: target.id,
1039
- canvasName: target.name,
1040
- nodes: created.map((n) => ({ id: n.id, from: [...idMap].find(([, v]) => v === n.id)?.[0], kind: n.kind, name: n.name })),
1041
- edges: inside.length,
1042
- skippedEdges,
1043
- };
1044
- }
1045
- // ── commands ────────────────────────────────────────────────────────────────
1046
- export function canvasCommand(globals) {
1047
- const canvas = new Command('canvas').description('Read and edit Infinite Canvas nodes (defaults to the canvas linked from the browser)');
1048
- const client = () => new WjiClient(globals());
1049
- canvas
1050
- .command('status')
1051
- .alias('link')
1052
- .description('The canvas linked from the browser and the nodes selected there')
1053
- .action(async () => {
1054
- const c = client();
1055
- const res = await c.get('/canvas-link');
1056
- if (!res?.linked) {
1057
- if (isJsonMode())
1058
- return printJson(res);
1059
- return info('Not linked. Open a canvas in the browser and press the link button in its top bar.');
1060
- }
1061
- const state = res.state;
1062
- // The link carries ids; read the selected nodes fresh so prompts and assets are current.
1063
- let selected = [];
1064
- let canvasName = state.canvasName;
1065
- if (state.selectedNodeIds?.length) {
1066
- const loaded = await loadCanvas(c, state.canvasId);
1067
- canvasName = loaded.name;
1068
- selected = state.selectedNodeIds
1069
- .filter((id) => loaded.byId.has(id))
1070
- .map((id) => describe(loaded, loaded.byId.get(id)));
1071
- }
1072
- const out = { ...res, state: { ...state, canvasName }, selected: selected.map((n) => trimDescribed(n)) };
1073
- render(out, () => {
1074
- const seconds = Math.max(0, Math.round((new Date(state.expiresAt).getTime() - Date.now()) / 1000));
1075
- detail([
1076
- ['workspace', `${state.workspaceName || ''} ${color.dim(state.workspaceId)}`],
1077
- ['canvas', `${canvasName || ''} ${color.dim(state.canvasId)}`],
1078
- ['selected', `${state.selectedNodeIds?.length || 0} node(s)`],
1079
- ['expires', seconds ? `in ${Math.floor(seconds / 60)}m ${seconds % 60}s` : color.red('expired')],
1080
- ['url', state.url],
1081
- ]);
1082
- if (selected.length) {
1083
- info('');
1084
- nodeTable(selected);
1085
- }
1086
- });
1087
- });
1088
- canvas
1089
- .command('unlink')
1090
- .description('Drop the browser link (the tab switches its link button off within a minute)')
1091
- .action(async () => {
1092
- const res = await client().delete('/canvas-link');
1093
- render(res, (r) => (r.released ? success('Unlinked') : info('Nothing was linked')));
1094
- });
1095
- canvas
1096
- .command('list')
1097
- .description('Canvases in a workspace, with owner and visibility')
1098
- .option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
1099
- .option('-o, --owner <member>', 'only canvases this member owns (name, email or user id; "none" = unowned)')
1100
- .action(async (opts) => {
1101
- const c = client();
1102
- const linked = opts.workspace ? null : await linkedState(c);
1103
- const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
1104
- const [canvases, { members }] = await Promise.all([listCanvases(c, wsId), loadMembers(c, wsId)]);
1105
- const byUser = new Map(members.map((m) => [m.userId, m]));
1106
- let filtered = canvases;
1107
- if (opts.owner !== undefined) {
1108
- const ownerId = opts.owner === 'none' ? '' : resolveMember(members, opts.owner).userId;
1109
- filtered = canvases.filter((r) => r.ownerUserId === ownerId);
1110
- }
1111
- const rows = filtered.map(({ folder, ...r }) => ({
1112
- ...r,
1113
- ownerName: r.ownerUserId ? (byUser.get(r.ownerUserId) ? memberLabel(byUser.get(r.ownerUserId)) : '(not a member)') : '',
1114
- linked: r.id === linked?.canvasId,
1115
- }));
1116
- render(rows, () => table(rows, [
1117
- { header: '', value: (r) => (r.linked ? color.green('●') : '') },
1118
- { header: 'id', value: (r) => r.id },
1119
- { header: 'name', value: (r) => r.name },
1120
- { header: 'owner', value: (r) => r.ownerName },
1121
- { header: 'visibility', value: (r) => (r.visibility === 'owner' ? 'owner only' : 'members') },
1122
- { header: 'updated', value: (r) => relativeTime(r.updatedAt) },
1123
- ]));
1124
- });
1125
- canvas
1126
- .command('create <name>')
1127
- .description('Create an empty canvas in a workspace (its InfiniteCanvas app)')
1128
- .option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
1129
- .option('--app <appId>', 'InfiniteCanvas app when the workspace has several')
1130
- .option('-o, --owner <member>', 'owner (name, email or user id); only the workspace owner can set it')
1131
- .option('--visibility <v>', 'owner (only the owner and the workspace owner see it) | members', 'members')
1132
- .action(async (name, opts) => {
1133
- const c = client();
1134
- const linked = opts.workspace ? null : await linkedState(c);
1135
- const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
1136
- const folders = await canvasesFolders(c, wsId);
1137
- if (!folders.length)
1138
- throw new Error(`Workspace ${wsId} has no InfiniteCanvas app. Add one with \`wji ws app add canvas\`.`);
1139
- const target = opts.app ? folders.find((f) => f.appId === opts.app) : folders[0];
1140
- if (!target)
1141
- throw new Error(`No InfiniteCanvas app ${opts.app} in workspace ${wsId}`);
1142
- if (!opts.app && folders.length > 1)
1143
- warn(`Several InfiniteCanvas apps — using ${target.appId}; pass --app to choose`);
1144
- const visibility = parseVisibility(opts.visibility);
1145
- const ownerUserId = opts.owner ? resolveMember((await loadMembers(c, wsId)).members, opts.owner).userId : '';
1146
- const raw = await createCanvasFolder(c, target.folderId, name, { ownerUserId, visibility });
1147
- const out = { id: raw.id, name: raw.name, app: target.appId, workspaceId: wsId, ownerUserId, visibility };
1148
- render(out, () => {
1149
- success(`Created canvas "${out.name}" ${color.dim(out.id)}`);
1150
- info(color.dim(`Target it with --canvas ${out.id}, or open it in the browser and press link.`));
1151
- });
1152
- });
1153
- canvas
1154
- .command('owner <canvasId> [member]')
1155
- .description('Show or set who owns a canvas and who can see it (setting needs the workspace owner)')
1156
- .option('--visibility <v>', 'owner (only the owner and the workspace owner see it) | members (everyone in the workspace)')
1157
- .option('--none', 'clear the owner')
1158
- .action(async (canvasId, token, opts) => {
1159
- const c = client();
1160
- const folder = await c.getFresh(`/fsnode/list/${canvasId}`);
1161
- if (folder?.mimetype !== CANVAS_MIMETYPE)
1162
- throw new Error(`${canvasId} is not a canvas`);
1163
- const wsId = idOf(folder.workspace) || idOf(folder.root) || '';
1164
- const { members } = await loadMembers(c, wsId);
1165
- if (token && opts.none)
1166
- throw new Error('Pass a member or --none, not both');
1167
- const show = (access) => {
1168
- const owner = members.find((m) => m.userId === access.ownerUserId);
1169
- const out = { id: canvasId, name: folder.name, ...access, ownerName: owner ? memberLabel(owner) : undefined };
1170
- render(out, () => detail([
1171
- ['canvas', `${folder.name} ${color.dim(canvasId)}`],
1172
- ['owner', access.ownerUserId ? `${owner ? memberLabel(owner) : '(not a member)'} ${color.dim(access.ownerUserId)}` : '(none)'],
1173
- ['visibility', access.visibility === 'owner' ? 'owner only' : 'all members'],
1174
- ]));
1175
- };
1176
- if (!token && !opts.none && !opts.visibility)
1177
- return show(canvasAccessOf(folder.content));
1178
- const ownerUserId = opts.none ? '' : token ? resolveMember(members, token).userId : undefined;
1179
- const visibility = opts.visibility ? parseVisibility(opts.visibility) : undefined;
1180
- const res = await updateCanvasFolder(c, canvasId, (env) => {
1181
- if (ownerUserId !== undefined)
1182
- env.metadata.ownerUserId = ownerUserId;
1183
- if (visibility)
1184
- env.metadata.visibility = visibility;
1185
- else if (!env.metadata.visibility)
1186
- env.metadata.visibility = 'members';
1187
- });
1188
- if (!isJsonMode())
1189
- success('Updated');
1190
- show(canvasAccessOf(res?.content));
1191
- });
1192
- canvas
1193
- .command('tag <tag> [nodeIds...]')
1194
- .description('Color-tag nodes: a color (red, rose…) or an existing label; "none" removes the tag. ' +
1195
- '--label names that color on this canvas (one label per color, shared by its nodes).')
1196
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1197
- .option('-l, --label <text>', 'set the label of this color on the canvas ("" clears it)')
1198
- .option('--selected', 'tag the nodes selected in the linked browser canvas')
1199
- .action(async (tag, ids, opts) => {
1200
- const c = client();
1201
- const linked = opts.selected ? await linkedState(c) : null;
1202
- if (opts.selected && !linked)
1203
- throw new Error('--selected needs a linked canvas (press link in the browser)');
1204
- const cv = await loadCanvas(c, opts.canvas || linked?.canvasId || (await resolveCanvasId(c)));
1205
- const nodeIds = [...ids, ...(opts.selected ? linked.selectedNodeIds || [] : [])];
1206
- const nodes = nodeIds.map((id) => requireNode(cv, id));
1207
- const clear = tag.toLowerCase() === 'none';
1208
- if (clear && opts.label !== undefined)
1209
- throw new Error('--label needs a color, not "none"');
1210
- const colorId = clear ? undefined : colorOfTag(cv, tag);
1211
- if (!nodes.length && opts.label === undefined)
1212
- throw new Error('Pass node ids (or --selected), and/or --label');
1213
- if (opts.label !== undefined) {
1214
- const label = opts.label.trim();
1215
- await updateCanvasFolder(c, cv.id, (env) => {
1216
- const names = { ...(env.data.colorTagNames || {}) };
1217
- if (label)
1218
- names[colorId] = label;
1219
- else
1220
- delete names[colorId];
1221
- env.data.colorTagNames = Object.keys(names).length ? names : null;
1222
- });
1223
- if (label)
1224
- cv.tagNames[colorId] = label;
1225
- else
1226
- delete cv.tagNames[colorId];
1227
- }
1228
- for (const node of nodes) {
1229
- const content = { ...node.content };
1230
- if (clear)
1231
- delete content.colorTag;
1232
- else
1233
- content.colorTag = { colorId };
1234
- await patchNode(c, node, { content });
1235
- node.content = content;
1236
- }
1237
- const out = { canvas: cv.id, tag: clear ? null : { color: colorId, label: cv.tagNames[colorId] }, nodes: nodes.map((n) => n.id) };
1238
- render(out, () => {
1239
- const what = clear ? 'untagged' : `tagged ${tagText(tagOf(cv, { ...nodes[0], content: { colorTag: { colorId } } }))}`;
1240
- if (nodes.length)
1241
- success(`${nodes.length} node(s) ${what}`);
1242
- if (opts.label !== undefined)
1243
- success(`Label of ${colorId}: ${cv.tagNames[colorId] || '(none)'}`);
1244
- });
1245
- });
1246
- canvas
1247
- .command('copy [nodeIds...]')
1248
- .description('Copy nodes (with the connections between them) into other canvases. Finished media is shared, not re-generated; ' +
1249
- 'generate nodes keep their prompt and settings.')
1250
- .option('-c, --canvas <id>', 'source canvas (defaults to the linked canvas)')
1251
- .option('--selected', 'copy the nodes selected in the linked browser canvas')
1252
- .requiredOption('--to <canvasIds...>', 'target canvases')
1253
- .option('--at <x,y>', 'top-left of the copied group; default is right of what is already there')
1254
- .option('--same-position', 'keep the original coordinates')
1255
- .option('-y, --yes', 'skip confirmation')
1256
- .action(async (ids, opts) => {
1257
- const c = client();
1258
- const { source, nodes } = await copySource(c, ids, opts);
1259
- const targets = opts.to.filter((id) => id !== source.id);
1260
- if (!targets.length)
1261
- throw new Error('--to must name at least one canvas other than the source');
1262
- const names = await Promise.all(targets.map(async (id) => (await c.getFresh(`/fsnode/list/${id}`))?.name ?? id));
1263
- const question = `Copy ${nodes.length} node(s) from "${source.name}" into ${targets.length} canvas(es): ${names.join(', ')}?`;
1264
- if (!(await confirm(question, opts.yes)))
1265
- return info('Aborted');
1266
- const results = [];
1267
- for (const id of targets) {
1268
- const target = await loadCanvas(c, id);
1269
- results.push(await copyNodes(c, source, nodes, target, copyPlacement(opts)));
1270
- }
1271
- render(results, () => {
1272
- for (const r of results)
1273
- success(`${r.canvasName}: ${r.nodes.length} node(s), ${r.edges} connection(s)`);
1274
- const skipped = results[0]?.skippedEdges ?? 0;
1275
- if (skipped)
1276
- info(color.dim(`${skipped} connection(s) from nodes outside the copy were left out`));
1277
- });
1278
- });
1279
- canvas
1280
- .command('nodes')
1281
- .description('Nodes with references and a content summary (* = generate node)')
1282
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1283
- .option('-k, --kind <kind>', 'only this kind')
1284
- .option('--tag <tags...>', 'only nodes with one of these color tags: label (C01), color (red) or id (rose)')
1285
- .option('--untagged', 'only nodes without a color tag')
1286
- .option('-n, --name <regex>', 'only nodes whose name matches this regex (case-insensitive; /re/flags for your own flags)')
1287
- .action(async (opts) => {
1288
- if (opts.tag && opts.untagged)
1289
- throw new Error('Pass --tag or --untagged, not both');
1290
- const nameRe = opts.name !== undefined ? parseRegex(opts.name, '--name') : undefined;
1291
- const c = client();
1292
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1293
- const colors = opts.tag ? resolveTagFilter(cv, opts.tag) : undefined;
1294
- const rows = cv.nodes
1295
- .filter((n) => !opts.kind || n.kind === opts.kind)
1296
- .filter((n) => !nameRe || nameRe.test(n.name))
1297
- .map((n) => describe(cv, n))
1298
- .filter((n) => (colors ? !!n.tag && colors.has(n.tag.color) : opts.untagged ? !n.tag : true));
1299
- render({ canvas: { id: cv.id, name: cv.name, workspaceId: cv.workspaceId }, nodes: rows.map((n) => trimDescribed(n)) }, () => {
1300
- info(color.dim(`${cv.name} (${cv.id}) · ${rows.length} node(s) · ${cv.edges.length} edge(s)`));
1301
- nodeTable(rows);
1302
- });
1303
- });
1304
- canvas
1305
- .command('tags')
1306
- .description('The canvas color tags: color, label and how many nodes carry each')
1307
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1308
- .option('-a, --all', 'also colors no node uses and that have no label')
1309
- .action(async (opts) => {
1310
- const c = client();
1311
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1312
- const rows = COLOR_TAGS.map((t) => ({
1313
- color: t.id,
1314
- alias: t.alias,
1315
- hex: t.hex,
1316
- label: cv.tagNames[t.id],
1317
- nodes: cv.nodes.filter((n) => n.content.colorTag?.colorId === t.id).length,
1318
- })).filter((r) => opts.all || r.nodes || r.label);
1319
- render({ canvas: { id: cv.id, name: cv.name }, tags: rows }, () => {
1320
- info(color.dim(`${cv.name} (${cv.id})`));
1321
- if (!rows.length)
1322
- return info('No color tags on this canvas (--all lists the colors)');
1323
- table(rows, [
1324
- { header: 'color', value: (r) => r.alias },
1325
- { header: 'id', value: (r) => color.dim(r.color) },
1326
- { header: 'label', value: (r) => r.label || '' },
1327
- { header: 'nodes', value: (r) => String(r.nodes) },
1328
- ]);
1329
- });
1330
- });
1331
- canvas
1332
- .command('get <nodeId>')
1333
- .description('One node with its references (--detail full for the whole generate / text)')
1334
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1335
- .action(async (id, opts) => {
1336
- const c = client();
1337
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1338
- const n = describe(cv, requireNode(cv, id));
1339
- render(trimDescribed(n), () => printNode(n));
1340
- });
1341
- canvas
1342
- .command('edges')
1343
- .description('Connections')
1344
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1345
- .option('--node <id>', 'only edges touching this node')
1346
- .action(async (opts) => {
1347
- const c = client();
1348
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1349
- const rows = cv.edges.filter((e) => !opts.node || e.fromNodeId === opts.node || e.toNodeId === opts.node);
1350
- render(rows, () => table(rows, [
1351
- { header: 'edge', value: (e) => e.id },
1352
- { header: 'from', value: (e) => `${e.fromNodeId} ${color.dim(cv.byId.get(e.fromNodeId)?.kind || '?')}` },
1353
- { header: 'to', value: (e) => `${e.toNodeId} ${color.dim(cv.byId.get(e.toNodeId)?.kind || '?')}` },
1354
- { header: 'slot', value: (e) => e.content.videoFrameSlot || '' },
1355
- ]));
1356
- });
1357
- withGenerateOptions(canvas
1358
- .command('add <kind>')
1359
- .description(`Create a node: ${CREATABLE_KINDS.join(' | ')}. Media with -p/-m becomes a generate node.`)
1360
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1361
- .option('-n, --name <name>', 'node name')
1362
- .option('-t, --text <text>', 'text for text/title/comment (- reads stdin)')
1363
- .option('--text-file <path>', 'read the text from a file')
1364
- .option('--plain', 'text is plain, not Markdown: keep every line break (the web renders text nodes as Markdown)')
1365
- .option('--size <w,h>', 'node width and height in canvas pixels (e.g. 600,900)')
1366
- .option('--asset <assetId>', 'show an existing asset (display node)')
1367
- .option('--dataset <fsnodeId>', 'for `add dataset`: the workspace dataset node to show on the canvas')
1368
- .option('--from <nodeIds...>', 'connect these nodes in as references, in order (@image1, @image2…)')
1369
- .option('--at <x,y>', 'position; default is right of the --from nodes')).action(async (kind, opts) => {
1370
- if (!CREATABLE_KINDS.includes(kind)) {
1371
- throw new Error(`Unknown kind "${kind}". Use one of: ${CREATABLE_KINDS.join(', ')}`);
1372
- }
1373
- const c = client();
1374
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1375
- const from = (opts.from || []).map((id) => requireNode(cv, id));
1376
- const content = {};
1377
- const body = {
1378
- name: opts.name || DEFAULT_NAMES[kind],
1379
- type: 'file',
1380
- mimetype: `${NODE_PREFIX}${kind}`,
1381
- parent: cv.id,
1382
- sort: 0,
1383
- };
1384
- let model;
1385
- if (TEXT_KINDS.has(kind)) {
1386
- const text = await nodeText(kind, opts);
1387
- if (text === undefined)
1388
- throw new Error(`${kind} nodes need --text`);
1389
- content.text = text;
1390
- }
1391
- else if (kind === 'dataset') {
1392
- // A dataset node is a card bound to a workspace dataset (CanvasAppFileSystem.createDatasetNodeFromDataset).
1393
- if (!opts.dataset)
1394
- throw new Error('dataset nodes need --dataset <fsnodeId>');
1395
- if (from.length)
1396
- throw new Error('dataset nodes take no references; connect them out to a generate node instead');
1397
- const ds = await c.getFresh(`/fsnode/list/${opts.dataset}`);
1398
- if (ds?.mimetype !== DATASET_MIMETYPE) {
1399
- throw new Error(`${opts.dataset} is not a dataset (mimetype ${ds?.mimetype || 'none'})`);
1400
- }
1401
- const parent = ds.parent ? await c.get(`/fsnode/list/${idOf(ds.parent)}`).catch(() => null) : null;
1402
- const dsName = ds.nickname || ds.name || DEFAULT_NAMES.dataset;
1403
- if (!opts.name)
1404
- body.name = dsName;
1405
- Object.assign(content, {
1406
- datasetId: String(ds.id),
1407
- datasetName: dsName,
1408
- datasetType: String(ds.content?.type || ''),
1409
- datasetParentName: parent ? parent.nickname || parent.name || '' : '',
1410
- description: String(ds.content?.description || ''),
1411
- });
1412
- }
1413
- else {
1414
- const { patch, explicit } = await generateOptions(opts);
1415
- const wantsGenerate = Object.keys(patch).length > 0;
1416
- if (opts.asset && wantsGenerate)
1417
- throw new Error('Use --asset for a display node, or -p/-m for a generate node, not both');
1418
- if (opts.asset) {
1419
- content.mode = 'display';
1420
- body.file = opts.asset;
1421
- }
1422
- else {
1423
- const ws = await c.get(`/workspace/list/${cv.workspaceId}`);
1424
- const modelName = patch.model || ws.settings?.defaultModels?.[kind];
1425
- if (!modelName)
1426
- throw new Error(`Pass -m <model>; the workspace has no default ${kind} model`);
1427
- model = await findModel(c, kind, modelName, cv.workspaceId);
1428
- content.mode = 'generate';
1429
- content.generate = applyModelDefaults(model, kind, patch, { workspaceRatio: ws.aspectRatio, explicit });
1430
- }
1431
- }
1432
- if (opts.size)
1433
- Object.assign(content, parseSize(opts.size));
1434
- if (opts.at)
1435
- content.position = parsePoint(opts.at, '--at');
1436
- else {
1437
- const draft = { id: '', kind, name: '', sort: 0, file: body.file, content, raw: null };
1438
- content.position = placeNear(cv, from, estimateSize(cv, draft));
1439
- }
1440
- const raw = await c.post('/fsnode/create', { ...body, content });
1441
- const node = { id: raw.id, kind, name: raw.name, sort: 0, file: idOf(raw.file), content, raw };
1442
- cv.nodes.push(node);
1443
- cv.byId.set(node.id, node);
1444
- if (node.file) {
1445
- for (const asset of await c.checkAssetStatus([node.file]))
1446
- cv.assets.set(asset.id, asset);
1447
- }
1448
- for (const src of from) {
1449
- await createEdge(c, cv, src.id, node.id, { frameModel: kind === 'video' ? model : undefined });
1450
- }
1451
- const out = describe(cv, node);
1452
- render(out, () => {
1453
- success(`Created ${kind} node ${node.id}`);
1454
- printNode(out);
1455
- });
1456
- });
1457
- withGenerateOptions(canvas
1458
- .command('set <nodeId>')
1459
- .description('Update a node; only what you pass changes (content is merged, not replaced)')
1460
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1461
- .option('-n, --name <name>', 'rename')
1462
- .option('-t, --text <text>', 'replace the text of a text/title/comment node (- reads stdin)')
1463
- .option('--text-file <path>', 'read the text from a file')
1464
- .option('--plain', 'text is plain, not Markdown: keep every line break')
1465
- .option('--move <x,y>', 'move to this position')
1466
- .option('--size <w,h>', 'resize: width and height in canvas pixels')).action(async (id, opts) => {
1467
- const c = client();
1468
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1469
- const node = requireNode(cv, id);
1470
- const body = {};
1471
- const content = { ...node.content };
1472
- let changed = false;
1473
- if (opts.name !== undefined)
1474
- (body.name = opts.name), (changed = true);
1475
- const text = await nodeText(node.kind, opts);
1476
- if (text !== undefined) {
1477
- if (!TEXT_KINDS.has(node.kind))
1478
- throw new Error(`${node.kind} nodes have no text`);
1479
- content.text = text;
1480
- changed = true;
1481
- }
1482
- if (opts.move)
1483
- (content.position = parsePoint(opts.move, '--move')), (changed = true);
1484
- if (opts.size)
1485
- Object.assign(content, parseSize(opts.size)), (changed = true);
1486
- const { patch, explicit } = await generateOptions(opts);
1487
- if (Object.keys(patch).length) {
1488
- if (!MEDIA_KINDS.has(node.kind))
1489
- throw new Error(`${node.kind} nodes cannot generate`);
1490
- const current = content.generate || {};
1491
- const merged = {
1492
- ...current,
1493
- ...patch,
1494
- values: { ...(current.values || {}), ...(patch.values || {}) },
1495
- };
1496
- const modelName = merged.model;
1497
- if (!modelName)
1498
- throw new Error('This node has no model yet — pass -m');
1499
- const model = await findModel(c, node.kind, modelName, cv.workspaceId);
1500
- const ws = await c.get(`/workspace/list/${cv.workspaceId}`);
1501
- content.generate = applyModelDefaults(model, node.kind, merged, { workspaceRatio: ws.aspectRatio, explicit });
1502
- content.mode = 'generate';
1503
- changed = true;
1504
- }
1505
- if (!changed)
1506
- throw new Error('Nothing to update — pass at least one option');
1507
- body.content = content;
1508
- await patchNode(c, node, body);
1509
- node.content = content;
1510
- if (body.name)
1511
- node.name = body.name;
1512
- const out = describe(cv, node);
1513
- render(out, () => {
1514
- success(`Updated ${id}`);
1515
- printNode(out);
1516
- });
1517
- });
1518
- canvas
1519
- .command('connect <fromId> <toId>')
1520
- .description('Connect from → to; into a media node this adds a reference')
1521
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1522
- .action(async (fromId, toId, opts) => {
1523
- const c = client();
1524
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1525
- const to = requireNode(cv, toId);
1526
- const g = to.content.generate;
1527
- const model = to.kind === 'video' && g?.model ? await findModel(c, 'video', g.model, cv.workspaceId).catch(() => undefined) : undefined;
1528
- await createEdge(c, cv, fromId, toId, { frameModel: model });
1529
- const out = describe(cv, to);
1530
- render(out, () => {
1531
- const ref = out.references.find((r) => r.nodeId === fromId);
1532
- success(`Connected ${fromId} → ${toId}${ref ? ` as ${ref.label}${ref.frameSlot ? ` (${ref.frameSlot} frame)` : ''}` : ''}`);
1533
- });
1534
- });
1535
- canvas
1536
- .command('disconnect <fromId> <toId>')
1537
- .description('Remove the connection from → to')
1538
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1539
- .action(async (fromId, toId, opts) => {
1540
- const c = client();
1541
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1542
- const edges = cv.edges.filter((e) => e.fromNodeId === fromId && e.toNodeId === toId);
1543
- if (!edges.length)
1544
- throw new Error(`No connection ${fromId} → ${toId}`);
1545
- for (const edge of edges)
1546
- await c.delete(`/fsnode/list/${edge.id}`);
1547
- render({ removed: edges.map((e) => e.id) }, () => success(`Disconnected ${fromId} → ${toId}`));
1548
- });
1549
- canvas
1550
- .command('rm <nodeIds...>')
1551
- .description('Delete nodes and their connections')
1552
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1553
- .option('-y, --yes', 'skip confirmation')
1554
- .action(async (ids, opts) => {
1555
- const c = client();
1556
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1557
- const nodes = ids.map((id) => requireNode(cv, id));
1558
- const edges = cv.edges.filter((e) => ids.includes(e.fromNodeId) || ids.includes(e.toNodeId));
1559
- const busy = nodes.filter((n) => describe(cv, n).status === 'generating');
1560
- if (busy.length)
1561
- throw new Error(`Still generating, not deleted: ${busy.map((n) => n.id).join(', ')}`);
1562
- const lines = nodes.map((n) => `${n.kind} "${n.name}" (${n.id})`).join('\n ');
1563
- const prompt = `Delete from canvas "${cv.name}":\n ${lines}\nand ${edges.length} connection(s)?`;
1564
- if (!(await confirm(prompt, opts.yes)))
1565
- return info('Aborted');
1566
- for (const edge of edges)
1567
- await c.delete(`/fsnode/list/${edge.id}`);
1568
- for (const node of nodes)
1569
- await c.delete(`/fsnode/list/${node.id}`);
1570
- render({ deleted: ids, edges: edges.map((e) => e.id) }, () => success(`Deleted ${nodes.length} node(s) and ${edges.length} connection(s)`));
1571
- });
1572
- canvas
1573
- .command('gen <nodeId>')
1574
- .description('Submit a generate node with its references (costs credits)')
1575
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1576
- .option('-y, --yes', 'skip the cost confirmation')
1577
- .option('-w, --wait', 'wait for the result')
1578
- .option('--timeout <sec>', 'with --wait, give up after this many seconds', '600')
1579
- .option('--interval <sec>', 'with --wait, poll every N seconds', '5')
1580
- .action(async (id, opts) => {
1581
- const c = client();
1582
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1583
- await submitGenerate(c, cv, requireNode(cv, id), opts);
1584
- });
1585
- canvas
1586
- .command('upscale <nodeId>')
1587
- .description('Upscale a Seedance 2.5 draft clip (480p) to the finished 1080p video — adds an upscale node beside it and generates it (costs credits)')
1588
- .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1589
- .option('-n, --name <name>', 'name of the upscale node (default: "<source name>-1080p")')
1590
- .option('--at <x,y>', 'position of the upscale node; default is right of the draft clip')
1591
- .option('--no-gen', 'only create the upscale node; generate it later with `wji canvas gen`')
1592
- .option('-y, --yes', 'skip the cost confirmation')
1593
- .option('-w, --wait', 'wait for the result')
1594
- .option('--timeout <sec>', 'with --wait, give up after this many seconds', '1200')
1595
- .option('--interval <sec>', 'with --wait, poll every N seconds', '5')
1596
- .action(async (id, opts) => {
1597
- const c = client();
1598
- const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1599
- const source = requireNode(cv, id);
1600
- const asset = source.file ? cv.assets.get(source.file) : undefined;
1601
- if (source.kind !== 'video' || !asset?.url)
1602
- throw new Error(`${id} is not a video node with a finished clip`);
1603
- const draft = seedanceDraftSource(asset);
1604
- if (!isSeedance25(draft.model))
1605
- throw new Error(`${id} was generated with ${draft.model || 'an unknown model'} — only Seedance 2.5 drafts can be upscaled`);
1606
- if (!draft.taskId)
1607
- throw new Error(`${id} has no draft task id (asset ${asset.id} carries no BytePlus task) — it cannot be upscaled`);
1608
- if (draft.expired)
1609
- throw new Error(`The draft task of ${id} expired ${draft.expiresAt ? `on ${draft.expiresAt.toISOString().slice(0, 10)}` : ''} (drafts last 7 days) — regenerate the clip instead`);
1610
- if (!draft.flagged)
1611
- throw new Error(`${id} is not a Seedance draft (asset ${asset.id} has no config.values.draft) — only draft clips can be upscaled`);
1612
- const model = await findModel(c, 'video', draft.model, cv.workspaceId);
1613
- const configDuration = Number(asset.config?.duration);
1614
- const duration = configDuration > 0 ? configDuration : Number(asset.duration) > 0 ? Math.round(Number(asset.duration)) : undefined;
1615
- const ratio = String(asset.config?.aspectRatio || '').includes(':') ? asset.config.aspectRatio : asset.aspectRatio;
1616
- const content = {
1617
- mode: 'generate',
1618
- generate: {
1619
- prompt: 'N/A',
1620
- model: model.nickname || model.name,
1621
- resolution: '1080p',
1622
- ratio,
1623
- duration,
1624
- values: { mode: 'video', resolution: '1080p', draft: false, draftTaskId: draft.taskId },
1625
- },
1626
- };
1627
- const body = {
1628
- name: opts.name || `${source.name || 'video'}-1080p`,
1629
- type: 'file',
1630
- mimetype: `${NODE_PREFIX}video`,
1631
- parent: cv.id,
1632
- sort: 0,
1633
- };
1634
- if (source.content.width)
1635
- Object.assign(content, { width: source.content.width, height: source.content.height });
1636
- content.position = opts.at
1637
- ? parsePoint(opts.at, '--at')
1638
- : placeNear(cv, [source], estimateSize(cv, { id: '', kind: 'video', name: '', sort: 0, file: undefined, content, raw: null }));
1639
- const raw = await c.post('/fsnode/create', { ...body, content });
1640
- const node = { id: raw.id, kind: 'video', name: raw.name, sort: 0, file: idOf(raw.file), content, raw };
1641
- cv.nodes.push(node);
1642
- cv.byId.set(node.id, node);
1643
- // The web draws this edge so the panel shows the draft; the submit itself reads only draftTaskId.
1644
- await createEdge(c, cv, source.id, node.id, { content: { referenceRole: 'video', referenceLabel: 'video1' } });
1645
- success(`Created upscale node ${node.id} from ${id} (draft task ${draft.taskId})`);
1646
- if (!opts.gen)
1647
- return info(color.dim(`Generate it with: wji canvas gen ${node.id} -c ${cv.id}`));
1648
- await submitGenerate(c, cv, node, opts);
1649
- });
1650
- return canvas;
1651
- }
1652
- /** Price, confirm and submit one generate node — what the canvas Generate button does. */
1653
- async function submitGenerate(c, cv, node, opts) {
1654
- const id = node.id;
1655
- if (!MEDIA_KINDS.has(node.kind) || node.content.mode !== 'generate') {
1656
- 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 …\`.`);
1657
- }
1658
- if (describe(cv, node).status === 'generating')
1659
- throw new Error(`${id} is already generating`);
1660
- const g = node.content.generate || {};
1661
- if (!g.model)
1662
- throw new Error(`${id} has no model — set one with \`wji canvas set ${id} -m …\``);
1663
- const model = await findModel(c, node.kind, g.model, cv.workspaceId);
1664
- const body = buildSubmit(cv, node, model);
1665
- const cost = await c.post('/ai/assets/preComputeCost', {
1666
- type: node.kind,
1667
- model: body.model,
1668
- n: body.n,
1669
- prompt: body.prompt,
1670
- // Auto-duration submits -1; price the node's configured length instead.
1671
- duration: body.duration && body.duration > 0 ? body.duration : g.duration,
1672
- aspectRatio: body.aspectRatio,
1673
- referenceImageCount: body.referenceImages?.length,
1674
- values: body.values,
1675
- });
1676
- const credits = typeof cost === 'object' ? cost?.cost ?? cost?.credits ?? cost?.total : cost;
1677
- const refs = referencesOf(cv, node).map((r) => r.label).join(', ') || 'none';
1678
- const auto = body.duration === -1 ? ` (auto duration — estimated for ${g.duration ?? '?'}s, final cost follows the real length)` : '';
1679
- const question = `Generate ${node.kind} "${node.name}" with ${body.model} (refs: ${refs}) for ${credits ?? '?'} credits${auto}?`;
1680
- if (!(await confirm(question, opts.yes)))
1681
- return info('Aborted');
1682
- const history = await c.post(`/ai/assets/history/${node.kind}/generations`, body);
1683
- const assetId = idOf((history?.images || [])[0]);
1684
- const nextGenerate = { ...g, historyId: String(history.id), pendingAssetId: assetId };
1685
- await patchNode(c, node, {
1686
- ...(assetId ? { file: assetId } : {}),
1687
- content: { ...node.content, generate: nextGenerate },
1688
- });
1689
- if (!opts.wait) {
1690
- if (isJsonMode())
1691
- return printJson(history);
1692
- success(`Submitted ${node.kind} generation for node ${id}`);
1693
- reportHistory(history, await resolveAssets(c, history));
1694
- info(color.dim(`The canvas shows progress. Poll with: wji canvas get ${id} or wji history get ${history.id} --wait`));
1695
- return;
1696
- }
1697
- info(color.dim(`Submitted ${history.id}, waiting…`));
1698
- const done = await pollHistory(c, history.id, {
1699
- timeoutMs: Number(opts.timeout) * 1000,
1700
- intervalMs: Number(opts.interval) * 1000,
1701
- });
1702
- if (isJsonMode())
1703
- return printJson({ ...done.history, images: done.assets });
1704
- success(`Node ${id} generated`);
1705
- reportHistory(done.history, done.assets);
1706
- }