@wji.ai/cli 0.3.0 → 0.5.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.
Files changed (61) hide show
  1. package/README.md +76 -2
  2. package/dist/commands/backreel.js +260 -0
  3. package/dist/commands/canvas.js +751 -86
  4. package/dist/commands/class.js +395 -0
  5. package/dist/commands/crew.js +205 -0
  6. package/dist/commands/npc.js +413 -0
  7. package/dist/commands/workspace.js +109 -24
  8. package/dist/core/archive.js +95 -0
  9. package/dist/core/crew.js +546 -0
  10. package/dist/core/members.js +54 -0
  11. package/dist/core/npc.js +108 -0
  12. package/dist/core/output.js +4 -2
  13. package/dist/index.js +10 -0
  14. package/package.json +1 -1
  15. package/skill/short-drama/SKILL.md +86 -8
  16. package/skill/short-drama/reference/action-bullet-time.md +47 -0
  17. package/skill/short-drama/reference/action-car-chase.md +51 -0
  18. package/skill/short-drama/reference/action-gun-fu.md +52 -0
  19. package/skill/short-drama/reference/action-historical-realist.md +50 -0
  20. package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
  21. package/skill/short-drama/reference/action-street-brawl.md +49 -0
  22. package/skill/short-drama/reference/action-superhero.md +50 -0
  23. package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
  24. package/skill/short-drama/reference/action.md +94 -0
  25. package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
  26. package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
  27. package/skill/short-drama/reference/cinematography-low-key.md +115 -0
  28. package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
  29. package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
  30. package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
  31. package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
  32. package/skill/short-drama/reference/cinematography.md +83 -0
  33. package/skill/short-drama/reference/composition-art-first.md +74 -0
  34. package/skill/short-drama/reference/craft-shots.md +3 -1
  35. package/skill/short-drama/reference/craft-story.md +4 -1
  36. package/skill/short-drama/reference/format.md +94 -0
  37. package/skill/short-drama/reference/grammar-comedy.md +105 -0
  38. package/skill/short-drama/reference/grammar-faceslap.md +67 -0
  39. package/skill/short-drama/reference/grammar.md +103 -0
  40. package/skill/short-drama/reference/operations-cli.md +1 -1
  41. package/skill/short-drama/reference/schema.md +34 -3
  42. package/skill/short-drama/reference/stage-art.md +1 -1
  43. package/skill/short-drama/reference/stage-outline.md +21 -0
  44. package/skill/short-drama/reference/stage-script.md +10 -0
  45. package/skill/short-drama/reference/stage-storyboard.md +15 -0
  46. package/skill/short-drama/reference/story-angst.md +117 -0
  47. package/skill/short-drama/reference/story-comedy.md +136 -0
  48. package/skill/short-drama/reference/story-cultivation.md +109 -0
  49. package/skill/short-drama/reference/story-family.md +119 -0
  50. package/skill/short-drama/reference/story-horror.md +124 -0
  51. package/skill/short-drama/reference/story-mystery.md +127 -0
  52. package/skill/short-drama/reference/story-palace.md +103 -0
  53. package/skill/short-drama/reference/story-revenge.md +124 -0
  54. package/skill/short-drama/reference/story-scifi.md +112 -0
  55. package/skill/short-drama/reference/story-sweet.md +128 -0
  56. package/skill/short-drama/reference/story.md +127 -0
  57. package/skill/short-drama/scripts/selftest.mjs +809 -1
  58. package/skill/short-drama/scripts/short-drama.mjs +1608 -16
  59. package/skill/wji/SKILL.md +84 -2
  60. package/skill/wji/reference/commands.md +96 -6
  61. package/skill/wji-classroom/SKILL.md +161 -0
@@ -1,8 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { WjiClient } from '../core/client.js';
3
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';
4
+ import { color, detail, info, isJsonMode, printJson, relativeTime, render, success, table, truncate, warn } from '../core/output.js';
5
5
  import { pollHistory, reportHistory, resolveAssets } from './gen.js';
6
+ import { loadMembers, memberLabel, resolveMember } from '../core/members.js';
6
7
  import { fetchModels } from './model.js';
7
8
  /**
8
9
  * Infinite Canvas over the plain fsnode API.
@@ -21,7 +22,7 @@ import { fetchModels } from './model.js';
21
22
  */
22
23
  const NODE_PREFIX = 'canvas/node/';
23
24
  const EDGE_MIMETYPE = 'canvas/edge';
24
- const CANVAS_MIMETYPE = 'workspace-v2/canvas';
25
+ export const CANVAS_MIMETYPE = 'workspace-v2/canvas';
25
26
  const CANVASES_MIMETYPE = 'workspace-v2/canvases';
26
27
  const DATASET_MIMETYPE = 'workspace/dataset';
27
28
  const CREATABLE_KINDS = ['text', 'title', 'comment', 'image', 'video', 'audio', 'dataset'];
@@ -38,7 +39,7 @@ const DEFAULT_NAMES = {
38
39
  dataset: '数据集',
39
40
  };
40
41
  /** What the web writes into a brand-new canvas folder (CanvasAppFileSystem.createCanvas). */
41
- const INITIAL_CANVAS_CONTENT = {
42
+ export const INITIAL_CANVAS_CONTENT = {
42
43
  data: { viewport: { x: 0, y: 0, zoom: 1 }, backgroundMode: 'dots' },
43
44
  metadata: {},
44
45
  };
@@ -46,17 +47,33 @@ const INITIAL_CANVAS_CONTENT = {
46
47
  const MEDIA_BASE = 250;
47
48
  const PLACE_GAP = 80;
48
49
  const PENDING = 0;
49
- const idOf = (value) => value == null ? undefined : typeof value === 'string' ? value : value.id;
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;
50
67
  function toList(result) {
51
68
  return Array.isArray(result) ? result : result?.data || [];
52
69
  }
53
70
  // ── loading ─────────────────────────────────────────────────────────────────
54
- async function linkedState(client) {
71
+ export async function linkedState(client) {
55
72
  const res = await client.get('/canvas-link');
56
73
  return res?.linked ? res.state : null;
57
74
  }
58
75
  /** `--canvas` wins; otherwise the canvas linked from the browser. */
59
- async function resolveCanvasId(client, explicit) {
76
+ export async function resolveCanvasId(client, explicit) {
60
77
  if (explicit)
61
78
  return explicit;
62
79
  const state = await linkedState(client);
@@ -64,7 +81,7 @@ async function resolveCanvasId(client, explicit) {
64
81
  return String(state.canvasId);
65
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.');
66
83
  }
67
- async function loadCanvas(client, canvasId) {
84
+ export async function loadCanvas(client, canvasId) {
68
85
  // getFresh: read-merge-write must not see the 10s server cache
69
86
  const folder = await client.getFresh(`/fsnode/list/${canvasId}`);
70
87
  if (folder?.mimetype !== CANVAS_MIMETYPE) {
@@ -106,14 +123,136 @@ async function loadCanvas(client, canvasId) {
106
123
  id: folder.id,
107
124
  name: folder.name,
108
125
  workspaceId: idOf(folder.workspace) || idOf(folder.root) || '',
126
+ folder,
127
+ access: canvasAccessOf(folder.content),
109
128
  nodes,
110
129
  edges,
111
130
  byId: new Map(nodes.map((n) => [n.id, n])),
112
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',
113
158
  };
114
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
+ }
115
254
  /** The `canvases` folder of every InfiniteCanvas app in a workspace. */
116
- async function canvasesFolders(client, wsId) {
255
+ export async function canvasesFolders(client, wsId) {
117
256
  const ws = await client.get(`/workspace/list/${wsId}`);
118
257
  const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
119
258
  const out = [];
@@ -125,7 +264,7 @@ async function canvasesFolders(client, wsId) {
125
264
  }
126
265
  return out;
127
266
  }
128
- function requireNode(canvas, id) {
267
+ export function requireNode(canvas, id) {
129
268
  const node = canvas.byId.get(id);
130
269
  if (!node)
131
270
  throw new Error(`Node ${id} is not on canvas "${canvas.name}" (${canvas.id})`);
@@ -150,7 +289,7 @@ function referencesOf(canvas, node) {
150
289
  };
151
290
  });
152
291
  }
153
- function describe(canvas, node) {
292
+ export function describe(canvas, node) {
154
293
  const asset = node.file ? canvas.assets.get(node.file) : undefined;
155
294
  const generate = node.content.generate;
156
295
  const pending = asset && asset.status === PENDING && !asset.error;
@@ -158,7 +297,9 @@ function describe(canvas, node) {
158
297
  id: node.id,
159
298
  kind: node.kind,
160
299
  name: node.name,
300
+ tag: tagOf(canvas, node),
161
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,
162
303
  mode: MEDIA_KINDS.has(node.kind) ? node.content.mode || 'display' : undefined,
163
304
  text: TEXT_KINDS.has(node.kind) ? node.content.text ?? '' : undefined,
164
305
  assetId: node.file,
@@ -269,7 +410,11 @@ function applyModelDefaults(model, kind, generate, opts) {
269
410
  }
270
411
  // ── writes ──────────────────────────────────────────────────────────────────
271
412
  /** Short side 250, like the canvas; text-ish nodes use a typical card size. */
272
- function estimateSize(canvas, node) {
413
+ export function estimateSize(canvas, node) {
414
+ const w = Number(node.content.width);
415
+ const h = Number(node.content.height);
416
+ if (w > 0 && h > 0)
417
+ return { width: w, height: h };
273
418
  if (node.kind === 'dataset')
274
419
  return { width: 480, height: 220 };
275
420
  if (!MEDIA_KINDS.has(node.kind))
@@ -335,6 +480,7 @@ async function createEdge(client, canvas, fromId, toId, opts = {}) {
335
480
  toNodeId: to.id,
336
481
  sourceHandle: 'right',
337
482
  targetHandle: 'left',
483
+ ...(opts.content || {}),
338
484
  };
339
485
  // First/last-frame video models take connected images as frame slots, in order.
340
486
  if (opts.frameModel && to.kind === 'video' && from.kind === 'image') {
@@ -357,8 +503,37 @@ async function createEdge(client, canvas, fromId, toId, opts = {}) {
357
503
  canvas.edges.push(edge);
358
504
  return edge;
359
505
  }
360
- async function patchNode(client, node, body) {
361
- return client.patch(`/fsnode/list/${node.id}`, body);
506
+ /**
507
+ * Write only the top-level content keys that changed (`contentPatch`), so an
508
+ * edit here does not overwrite what someone else changed in another key at
509
+ * the same moment in the browser. A backend without `contentPatch` strips it
510
+ * silently; the response then lacks the change and we write the whole content.
511
+ */
512
+ async function patchNode(client, node, change) {
513
+ const { content, ...rest } = change;
514
+ const contentPatch = {};
515
+ const contentUnset = [];
516
+ if (content) {
517
+ for (const key of new Set([...Object.keys(node.content), ...Object.keys(content)])) {
518
+ if (!(key in content))
519
+ contentUnset.push(key);
520
+ else if (JSON.stringify(content[key]) !== JSON.stringify(node.content[key]))
521
+ contentPatch[key] = content[key];
522
+ }
523
+ }
524
+ const body = { ...rest };
525
+ if (Object.keys(contentPatch).length)
526
+ body.contentPatch = contentPatch;
527
+ if (contentUnset.length)
528
+ body.contentUnset = contentUnset;
529
+ if (!Object.keys(body).length)
530
+ return node.raw;
531
+ const res = await client.patch(`/fsnode/list/${node.id}`, body);
532
+ const landed = Object.entries(contentPatch).every(([k, v]) => JSON.stringify(res?.content?.[k]) === JSON.stringify(v)) &&
533
+ contentUnset.every((k) => !(k in (res?.content || {})));
534
+ if (content && !landed)
535
+ return client.patch(`/fsnode/list/${node.id}`, { ...rest, content });
536
+ return res;
362
537
  }
363
538
  // ── output ──────────────────────────────────────────────────────────────────
364
539
  function summary(n) {
@@ -375,11 +550,12 @@ function summary(n) {
375
550
  }
376
551
  return n.url || n.assetId || '';
377
552
  }
378
- function nodeTable(rows) {
553
+ export function nodeTable(rows) {
379
554
  table(rows, [
380
555
  { header: 'id', value: (n) => n.id },
381
556
  { header: 'kind', value: (n) => (n.mode === 'generate' ? `${n.kind}*` : n.kind) },
382
557
  { header: 'name', value: (n) => truncate(n.name, 20) },
558
+ { header: 'tag', value: (n) => truncate(tagText(n.tag), 16) },
383
559
  { header: 'refs', value: (n) => n.references.map((r) => r.label).join(',') },
384
560
  { header: 'summary', value: (n) => truncate(summary(n), 64) },
385
561
  ]);
@@ -390,7 +566,9 @@ function printNode(n) {
390
566
  ['id', n.id],
391
567
  ['kind', n.mode ? `${n.kind} (${n.mode})` : n.kind],
392
568
  ['name', n.name],
569
+ ['tag', n.tag && `${tagText(n.tag)} ${color.dim(n.tag.color)}`],
393
570
  ['position', n.position && `${Math.round(n.position.x)},${Math.round(n.position.y)}`],
571
+ ['size', n.size && `${n.size.width}×${n.size.height}`],
394
572
  ['text', n.text],
395
573
  ['dataset', n.dataset && `${n.dataset.type || 'dataset'} "${n.dataset.name || ''}" ${color.dim(n.dataset.id)}${n.dataset.folder ? ` in ${n.dataset.folder}` : ''}`],
396
574
  ['asset', n.assetId],
@@ -420,6 +598,46 @@ async function literalOrFile(value, file) {
420
598
  return undefined;
421
599
  return value === '-' ? readValue('-') : value;
422
600
  }
601
+ /**
602
+ * Text, title and comment nodes are Markdown on the web (a single line break joins the lines into
603
+ * one paragraph). `--plain` keeps plain text as written: a line break becomes a Markdown hard break
604
+ * (" \n"); blank lines stay paragraph breaks and fenced code blocks are left alone.
605
+ */
606
+ export function plainToMarkdown(text) {
607
+ let fenced = false;
608
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
609
+ return lines
610
+ .map((line, i) => {
611
+ const fence = /^\s*(```|~~~)/.test(line);
612
+ if (fence)
613
+ fenced = !fenced;
614
+ const next = lines[i + 1];
615
+ const hard = !fence && !fenced && i < lines.length - 1 && line.trim() !== '' && next !== undefined && next.trim() !== '' && !/^\s*(```|~~~)/.test(next);
616
+ return hard ? `${line.replace(/\s+$/, '')} ` : line;
617
+ })
618
+ .join('\n');
619
+ }
620
+ /** True when a single line break would be joined away by the web's Markdown rendering. */
621
+ export function hasSoftBreaks(text) {
622
+ return /[^\s\n][ \t]?\n(?![ \t]*\n)[ \t]*\S/.test(text.replace(/\r\n?/g, '\n').replace(/ {2,}\n/g, '\n\n'));
623
+ }
624
+ async function nodeText(kind, opts) {
625
+ const text = await literalOrFile(opts.text, opts.textFile);
626
+ if (text === undefined)
627
+ return undefined;
628
+ if (opts.plain)
629
+ return plainToMarkdown(text);
630
+ if (TEXT_KINDS.has(kind) && hasSoftBreaks(text) && !isJsonMode()) {
631
+ warn('text nodes render as Markdown on the web — single line breaks join into one paragraph; pass --plain to keep them');
632
+ }
633
+ return text;
634
+ }
635
+ function parseSize(value) {
636
+ const [width, height] = value.split(/[,x×]/i).map((v) => Number(v.trim()));
637
+ if (!(width > 0 && height > 0))
638
+ throw new Error('--size must look like width,height (e.g. 600,900)');
639
+ return { width: Math.round(width), height: Math.round(height) };
640
+ }
423
641
  function parsePoint(value, label) {
424
642
  const [x, y] = value.split(',').map((v) => Number(v.trim()));
425
643
  if (!Number.isFinite(x) || !Number.isFinite(y))
@@ -465,6 +683,43 @@ function withGenerateOptions(cmd) {
465
683
  }
466
684
  // ── generation ──────────────────────────────────────────────────────────────
467
685
  const UNSUPPORTED_REF_KINDS = new Set(['table', 'youtube', 'dataset', 'bundle', 'sprite', 'layer', 'panorama']);
686
+ // Seedance 2.5 draft mode (frontend shared/func/seedanceDraft.ts): 2.5 + 480p is a draft, and a draft
687
+ // becomes the finished 1080p clip only through its BytePlus task id (draftTaskId), within 7 days.
688
+ const SEEDANCE_25_MODELS = new Set(['seedance-2.5', 'seedance-2.5-uncensored', 'seedance-2.5-ref', 'seedance-2.5-ref-uncensored']);
689
+ const SEEDANCE_DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
690
+ function isSeedance25(model) {
691
+ return !!model && SEEDANCE_25_MODELS.has(model);
692
+ }
693
+ /** The flag the web form sets before submitting (syncSeedanceDraftFlag) — every 2.5 + 480p generation is a draft. */
694
+ function syncSeedanceDraftFlag(model, values) {
695
+ if (isSeedance25(model) && String(values.resolution || '') === '480p')
696
+ values.draft = true;
697
+ else
698
+ delete values.draft;
699
+ return values;
700
+ }
701
+ function draftTaskIdOf(values) {
702
+ const raw = values?.draftTaskId;
703
+ return typeof raw === 'string' ? raw.trim() : '';
704
+ }
705
+ /**
706
+ * What a finished clip can be upscaled from — like the web's canSeedanceDraftUpscale: flagged as a
707
+ * draft (config.values.draft), carrying its BytePlus task id (the draft id), and not past 7 days.
708
+ */
709
+ function seedanceDraftSource(asset) {
710
+ const taskId = String(asset?.extra?.byteplus?.taskId || '').trim();
711
+ const model = String(asset?.config?.model || '');
712
+ const created = asset?.createdAt ? Date.parse(asset.createdAt) : Number.NaN;
713
+ const expiresAt = Number.isFinite(created) ? new Date(created + SEEDANCE_DRAFT_TTL_MS) : undefined;
714
+ return {
715
+ taskId,
716
+ model,
717
+ flagged: asset?.config?.values?.draft === true,
718
+ resolution: String(asset?.config?.values?.resolution || ''),
719
+ expiresAt,
720
+ expired: !expiresAt || Date.now() > expiresAt.getTime(),
721
+ };
722
+ }
468
723
  /**
469
724
  * Build the same request the canvas generate button sends
470
725
  * (shared/generate/buildSubmitPayload.ts): references in edge order, text
@@ -473,6 +728,32 @@ const UNSUPPORTED_REF_KINDS = new Set(['table', 'youtube', 'dataset', 'bundle',
473
728
  function buildSubmit(canvas, node, model) {
474
729
  const kind = node.kind;
475
730
  const g = node.content.generate || {};
731
+ // Draft → 1080p upscale (buildCanvasVideoSubmitPayload): only the draft task id counts — no
732
+ // prompt, no references; the edge from the draft clip is there for people reading the canvas.
733
+ const draftTaskId = kind === 'video' ? draftTaskIdOf(g.values) : '';
734
+ if (draftTaskId) {
735
+ const duration = typeof g.duration === 'number' && Number.isFinite(g.duration) ? g.duration : undefined;
736
+ return {
737
+ originalPrompt: g.prompt || 'N/A',
738
+ prompt: g.prompt || 'N/A',
739
+ model: g.model,
740
+ aspectRatio: g.ratio,
741
+ n: 1,
742
+ workspace: canvas.workspaceId,
743
+ fsnode: node.id,
744
+ scope: 'canvas',
745
+ scopeId: canvas.id,
746
+ duration,
747
+ values: {
748
+ ...(g.values || {}),
749
+ mode: 'video',
750
+ resolution: '1080p',
751
+ draft: false,
752
+ draftTaskId,
753
+ ...(duration !== undefined ? { duration } : {}),
754
+ },
755
+ };
756
+ }
476
757
  const userPrompt = String(g.prompt || '').trim();
477
758
  const form = model.form || {};
478
759
  const frames = usesFrameSlots(model);
@@ -562,6 +843,8 @@ function buildSubmit(canvas, node, model) {
562
843
  ...(kind !== 'audio' && g.resolution ? { resolution: g.resolution } : {}),
563
844
  },
564
845
  };
846
+ if (kind === 'video')
847
+ syncSeedanceDraftFlag(g.model, body.values);
565
848
  if (imageRefs.length)
566
849
  body.referenceImages = imageRefs;
567
850
  if (kind === 'video') {
@@ -577,6 +860,141 @@ function buildSubmit(canvas, node, model) {
577
860
  body.referenceAudios = audios;
578
861
  return body;
579
862
  }
863
+ // ── ownership, tags and copy helpers ───────────────────────────────────────
864
+ export function parseVisibility(value) {
865
+ const v = value.toLowerCase();
866
+ if (v === 'owner' || v === 'private')
867
+ return 'owner';
868
+ if (v === 'members' || v === 'member' || v === 'workspace')
869
+ return 'members';
870
+ throw new Error(`--visibility must be owner or members (got ${value})`);
871
+ }
872
+ /** A new canvas folder the way the web creates one, with its access in the metadata envelope. */
873
+ export async function createCanvasFolder(client, canvasesFolderId, name, access) {
874
+ const metadata = {};
875
+ if (access?.ownerUserId)
876
+ metadata.ownerUserId = access.ownerUserId;
877
+ if (access?.visibility)
878
+ metadata.visibility = access.visibility;
879
+ return client.post('/fsnode/create', {
880
+ name,
881
+ type: 'folder',
882
+ mimetype: CANVAS_MIMETYPE,
883
+ parent: canvasesFolderId,
884
+ content: { ...INITIAL_CANVAS_CONTENT, metadata },
885
+ });
886
+ }
887
+ /** One color id from a color name, id or this canvas's label. */
888
+ export function colorOfTag(canvas, tag) {
889
+ const colors = [...resolveTagFilter(canvas, [tag])];
890
+ if (colors.length > 1)
891
+ throw new Error(`Label "${tag}" is on several colors (${colors.join(', ')}) — name the color`);
892
+ return colors[0];
893
+ }
894
+ /** The source canvas and the nodes to copy: ids given, and/or the linked selection. */
895
+ export async function copySource(c, ids, opts) {
896
+ const linked = opts.selected || !ids.length ? await linkedState(c) : null;
897
+ const useSelection = opts.selected || !ids.length;
898
+ if (useSelection && !linked?.selectedNodeIds?.length) {
899
+ throw new Error('Nothing to copy: pass node ids, or select nodes in the browser canvas and press link');
900
+ }
901
+ const sourceId = opts.canvas || (useSelection ? linked.canvasId : await resolveCanvasId(c));
902
+ if (useSelection && opts.canvas && opts.canvas !== linked.canvasId) {
903
+ throw new Error(`The selection is on canvas ${linked.canvasId}, not ${opts.canvas}`);
904
+ }
905
+ const source = await loadCanvas(c, sourceId);
906
+ const nodeIds = [...new Set([...ids, ...(useSelection ? linked.selectedNodeIds : [])])];
907
+ return { source, nodes: nodeIds.map((id) => requireNode(source, id)) };
908
+ }
909
+ export function copyPlacement(opts) {
910
+ return { at: opts.at ? parsePoint(opts.at, '--at') : undefined, samePosition: !!opts.samePosition };
911
+ }
912
+ /** The canvas's own copy rule (cloneContentForDuplicate): no position, no generation run, finished media shared. */
913
+ function copyableFile(canvas, node) {
914
+ const asset = node.file ? canvas.assets.get(node.file) : undefined;
915
+ return asset?.url && asset.status !== PENDING && !asset.error ? node.file : undefined;
916
+ }
917
+ /**
918
+ * Copy nodes from one canvas into another, like the canvas's copy-paste:
919
+ * new nodes, connections among them re-created in reference order, color
920
+ * tags kept (and their label carried over when the target has none).
921
+ */
922
+ export async function copyNodes(c, source, nodes, target, placement = {}) {
923
+ const pos = (n) => ({ x: Number(n.content.position?.x) || 0, y: Number(n.content.position?.y) || 0 });
924
+ const minX = Math.min(...nodes.map((n) => pos(n).x));
925
+ const minY = Math.min(...nodes.map((n) => pos(n).y));
926
+ let origin = placement.at;
927
+ if (!origin && !placement.samePosition && target.nodes.length) {
928
+ const right = Math.max(...target.nodes.map((n) => pos(n).x + estimateSize(target, n).width));
929
+ const top = Math.min(...target.nodes.map((n) => pos(n).y));
930
+ origin = { x: Math.round(right + PLACE_GAP * 2), y: Math.round(top) };
931
+ }
932
+ if (!origin)
933
+ origin = { x: minX, y: minY };
934
+ // Labels travel with the tag when the target has no label for that color yet.
935
+ const labels = {};
936
+ for (const n of nodes) {
937
+ const colorId = n.content.colorTag?.colorId;
938
+ const label = colorId && source.tagNames[colorId];
939
+ if (!label)
940
+ continue;
941
+ if (!target.tagNames[colorId])
942
+ labels[colorId] = label;
943
+ else if (target.tagNames[colorId] !== label) {
944
+ warn(`${target.name}: ${colorId} is labelled "${target.tagNames[colorId]}" there, not "${label}" — kept theirs`);
945
+ }
946
+ }
947
+ if (Object.keys(labels).length) {
948
+ await updateCanvasFolder(c, target.id, (env) => {
949
+ env.data.colorTagNames = { ...labels, ...(env.data.colorTagNames || {}) };
950
+ });
951
+ Object.assign(target.tagNames, labels);
952
+ }
953
+ const idMap = new Map();
954
+ const created = [];
955
+ for (const n of nodes) {
956
+ const content = JSON.parse(JSON.stringify(n.content || {}));
957
+ if (content.generate) {
958
+ delete content.generate.historyId;
959
+ delete content.generate.pendingAssetId;
960
+ }
961
+ const p = pos(n);
962
+ content.position = { x: Math.round(origin.x + p.x - minX), y: Math.round(origin.y + p.y - minY) };
963
+ const file = copyableFile(source, n);
964
+ const raw = await c.post('/fsnode/create', {
965
+ name: n.name || DEFAULT_NAMES[n.kind] || n.kind,
966
+ type: 'file',
967
+ mimetype: `${NODE_PREFIX}${n.kind}`,
968
+ parent: target.id,
969
+ sort: 0,
970
+ content,
971
+ ...(file ? { file } : {}),
972
+ });
973
+ idMap.set(n.id, raw.id);
974
+ const node = { id: raw.id, kind: n.kind, name: raw.name, sort: 0, file, content, raw };
975
+ created.push(node);
976
+ target.nodes.push(node);
977
+ target.byId.set(node.id, node);
978
+ if (file && source.assets.has(file))
979
+ target.assets.set(file, source.assets.get(file));
980
+ }
981
+ // Oldest edge first, so the new ids keep the same @image1/@image2 numbering.
982
+ const inside = source.edges
983
+ .filter((e) => idMap.has(e.fromNodeId) && idMap.has(e.toNodeId))
984
+ .sort((a, b) => a.sort - b.sort || a.id.localeCompare(b.id));
985
+ const skippedEdges = source.edges.filter((e) => idMap.has(e.toNodeId) && !idMap.has(e.fromNodeId)).length;
986
+ for (const e of inside) {
987
+ const { fromNodeId, toNodeId, ...rest } = e.content;
988
+ await createEdge(c, target, idMap.get(e.fromNodeId), idMap.get(e.toNodeId), { content: rest });
989
+ }
990
+ return {
991
+ canvasId: target.id,
992
+ canvasName: target.name,
993
+ nodes: created.map((n) => ({ id: n.id, from: [...idMap].find(([, v]) => v === n.id)?.[0], kind: n.kind, name: n.name })),
994
+ edges: inside.length,
995
+ skippedEdges,
996
+ };
997
+ }
580
998
  // ── commands ────────────────────────────────────────────────────────────────
581
999
  export function canvasCommand(globals) {
582
1000
  const canvas = new Command('canvas').description('Read and edit Infinite Canvas nodes (defaults to the canvas linked from the browser)');
@@ -629,24 +1047,32 @@ export function canvasCommand(globals) {
629
1047
  });
630
1048
  canvas
631
1049
  .command('list')
632
- .description('Canvases in a workspace')
1050
+ .description('Canvases in a workspace, with owner and visibility')
633
1051
  .option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
1052
+ .option('-o, --owner <member>', 'only canvases this member owns (name, email or user id; "none" = unowned)')
634
1053
  .action(async (opts) => {
635
1054
  const c = client();
636
1055
  const linked = opts.workspace ? null : await linkedState(c);
637
1056
  const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
638
- const rows = [];
639
- for (const { appId, folderId } of await canvasesFolders(c, wsId)) {
640
- const canvases = toList(await c.getFresh(`/fsnode/list/${folderId}/children`, { limit: -1 }));
641
- for (const it of canvases.filter((n) => n.mimetype === CANVAS_MIMETYPE)) {
642
- rows.push({ id: it.id, name: it.name, app: appId, linked: it.id === linked?.canvasId, updatedAt: it.updatedAt });
643
- }
1057
+ const [canvases, { members }] = await Promise.all([listCanvases(c, wsId), loadMembers(c, wsId)]);
1058
+ const byUser = new Map(members.map((m) => [m.userId, m]));
1059
+ let filtered = canvases;
1060
+ if (opts.owner !== undefined) {
1061
+ const ownerId = opts.owner === 'none' ? '' : resolveMember(members, opts.owner).userId;
1062
+ filtered = canvases.filter((r) => r.ownerUserId === ownerId);
644
1063
  }
1064
+ const rows = filtered.map(({ folder, ...r }) => ({
1065
+ ...r,
1066
+ ownerName: r.ownerUserId ? (byUser.get(r.ownerUserId) ? memberLabel(byUser.get(r.ownerUserId)) : '(not a member)') : '',
1067
+ linked: r.id === linked?.canvasId,
1068
+ }));
645
1069
  render(rows, () => table(rows, [
646
1070
  { header: '', value: (r) => (r.linked ? color.green('●') : '') },
647
1071
  { header: 'id', value: (r) => r.id },
648
1072
  { header: 'name', value: (r) => r.name },
649
- { header: 'app', value: (r) => r.app },
1073
+ { header: 'owner', value: (r) => r.ownerName },
1074
+ { header: 'visibility', value: (r) => (r.visibility === 'owner' ? 'owner only' : 'members') },
1075
+ { header: 'updated', value: (r) => relativeTime(r.updatedAt) },
650
1076
  ]));
651
1077
  });
652
1078
  canvas
@@ -654,6 +1080,8 @@ export function canvasCommand(globals) {
654
1080
  .description('Create an empty canvas in a workspace (its InfiniteCanvas app)')
655
1081
  .option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
656
1082
  .option('--app <appId>', 'InfiniteCanvas app when the workspace has several')
1083
+ .option('-o, --owner <member>', 'owner (name, email or user id); only the workspace owner can set it')
1084
+ .option('--visibility <v>', 'owner (only the owner and the workspace owner see it) | members', 'members')
657
1085
  .action(async (name, opts) => {
658
1086
  const c = client();
659
1087
  const linked = opts.workspace ? null : await linkedState(c);
@@ -666,33 +1094,193 @@ export function canvasCommand(globals) {
666
1094
  throw new Error(`No InfiniteCanvas app ${opts.app} in workspace ${wsId}`);
667
1095
  if (!opts.app && folders.length > 1)
668
1096
  warn(`Several InfiniteCanvas apps — using ${target.appId}; pass --app to choose`);
669
- const raw = await c.post('/fsnode/create', {
670
- name,
671
- type: 'folder',
672
- mimetype: CANVAS_MIMETYPE,
673
- parent: target.folderId,
674
- content: INITIAL_CANVAS_CONTENT,
675
- });
676
- const out = { id: raw.id, name: raw.name, app: target.appId, workspaceId: wsId };
1097
+ const visibility = parseVisibility(opts.visibility);
1098
+ const ownerUserId = opts.owner ? resolveMember((await loadMembers(c, wsId)).members, opts.owner).userId : '';
1099
+ const raw = await createCanvasFolder(c, target.folderId, name, { ownerUserId, visibility });
1100
+ const out = { id: raw.id, name: raw.name, app: target.appId, workspaceId: wsId, ownerUserId, visibility };
677
1101
  render(out, () => {
678
1102
  success(`Created canvas "${out.name}" ${color.dim(out.id)}`);
679
1103
  info(color.dim(`Target it with --canvas ${out.id}, or open it in the browser and press link.`));
680
1104
  });
681
1105
  });
1106
+ canvas
1107
+ .command('owner <canvasId> [member]')
1108
+ .description('Show or set who owns a canvas and who can see it (setting needs the workspace owner)')
1109
+ .option('--visibility <v>', 'owner (only the owner and the workspace owner see it) | members (everyone in the workspace)')
1110
+ .option('--none', 'clear the owner')
1111
+ .action(async (canvasId, token, opts) => {
1112
+ const c = client();
1113
+ const folder = await c.getFresh(`/fsnode/list/${canvasId}`);
1114
+ if (folder?.mimetype !== CANVAS_MIMETYPE)
1115
+ throw new Error(`${canvasId} is not a canvas`);
1116
+ const wsId = idOf(folder.workspace) || idOf(folder.root) || '';
1117
+ const { members } = await loadMembers(c, wsId);
1118
+ if (token && opts.none)
1119
+ throw new Error('Pass a member or --none, not both');
1120
+ const show = (access) => {
1121
+ const owner = members.find((m) => m.userId === access.ownerUserId);
1122
+ const out = { id: canvasId, name: folder.name, ...access, ownerName: owner ? memberLabel(owner) : undefined };
1123
+ render(out, () => detail([
1124
+ ['canvas', `${folder.name} ${color.dim(canvasId)}`],
1125
+ ['owner', access.ownerUserId ? `${owner ? memberLabel(owner) : '(not a member)'} ${color.dim(access.ownerUserId)}` : '(none)'],
1126
+ ['visibility', access.visibility === 'owner' ? 'owner only' : 'all members'],
1127
+ ]));
1128
+ };
1129
+ if (!token && !opts.none && !opts.visibility)
1130
+ return show(canvasAccessOf(folder.content));
1131
+ const ownerUserId = opts.none ? '' : token ? resolveMember(members, token).userId : undefined;
1132
+ const visibility = opts.visibility ? parseVisibility(opts.visibility) : undefined;
1133
+ const res = await updateCanvasFolder(c, canvasId, (env) => {
1134
+ if (ownerUserId !== undefined)
1135
+ env.metadata.ownerUserId = ownerUserId;
1136
+ if (visibility)
1137
+ env.metadata.visibility = visibility;
1138
+ else if (!env.metadata.visibility)
1139
+ env.metadata.visibility = 'members';
1140
+ });
1141
+ if (!isJsonMode())
1142
+ success('Updated');
1143
+ show(canvasAccessOf(res?.content));
1144
+ });
1145
+ canvas
1146
+ .command('tag <tag> [nodeIds...]')
1147
+ .description('Color-tag nodes: a color (red, rose…) or an existing label; "none" removes the tag. ' +
1148
+ '--label names that color on this canvas (one label per color, shared by its nodes).')
1149
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1150
+ .option('-l, --label <text>', 'set the label of this color on the canvas ("" clears it)')
1151
+ .option('--selected', 'tag the nodes selected in the linked browser canvas')
1152
+ .action(async (tag, ids, opts) => {
1153
+ const c = client();
1154
+ const linked = opts.selected ? await linkedState(c) : null;
1155
+ if (opts.selected && !linked)
1156
+ throw new Error('--selected needs a linked canvas (press link in the browser)');
1157
+ const cv = await loadCanvas(c, opts.canvas || linked?.canvasId || (await resolveCanvasId(c)));
1158
+ const nodeIds = [...ids, ...(opts.selected ? linked.selectedNodeIds || [] : [])];
1159
+ const nodes = nodeIds.map((id) => requireNode(cv, id));
1160
+ const clear = tag.toLowerCase() === 'none';
1161
+ if (clear && opts.label !== undefined)
1162
+ throw new Error('--label needs a color, not "none"');
1163
+ const colorId = clear ? undefined : colorOfTag(cv, tag);
1164
+ if (!nodes.length && opts.label === undefined)
1165
+ throw new Error('Pass node ids (or --selected), and/or --label');
1166
+ if (opts.label !== undefined) {
1167
+ const label = opts.label.trim();
1168
+ await updateCanvasFolder(c, cv.id, (env) => {
1169
+ const names = { ...(env.data.colorTagNames || {}) };
1170
+ if (label)
1171
+ names[colorId] = label;
1172
+ else
1173
+ delete names[colorId];
1174
+ env.data.colorTagNames = Object.keys(names).length ? names : null;
1175
+ });
1176
+ if (label)
1177
+ cv.tagNames[colorId] = label;
1178
+ else
1179
+ delete cv.tagNames[colorId];
1180
+ }
1181
+ for (const node of nodes) {
1182
+ const content = { ...node.content };
1183
+ if (clear)
1184
+ delete content.colorTag;
1185
+ else
1186
+ content.colorTag = { colorId };
1187
+ await patchNode(c, node, { content });
1188
+ node.content = content;
1189
+ }
1190
+ const out = { canvas: cv.id, tag: clear ? null : { color: colorId, label: cv.tagNames[colorId] }, nodes: nodes.map((n) => n.id) };
1191
+ render(out, () => {
1192
+ const what = clear ? 'untagged' : `tagged ${tagText(tagOf(cv, { ...nodes[0], content: { colorTag: { colorId } } }))}`;
1193
+ if (nodes.length)
1194
+ success(`${nodes.length} node(s) ${what}`);
1195
+ if (opts.label !== undefined)
1196
+ success(`Label of ${colorId}: ${cv.tagNames[colorId] || '(none)'}`);
1197
+ });
1198
+ });
1199
+ canvas
1200
+ .command('copy [nodeIds...]')
1201
+ .description('Copy nodes (with the connections between them) into other canvases. Finished media is shared, not re-generated; ' +
1202
+ 'generate nodes keep their prompt and settings.')
1203
+ .option('-c, --canvas <id>', 'source canvas (defaults to the linked canvas)')
1204
+ .option('--selected', 'copy the nodes selected in the linked browser canvas')
1205
+ .requiredOption('--to <canvasIds...>', 'target canvases')
1206
+ .option('--at <x,y>', 'top-left of the copied group; default is right of what is already there')
1207
+ .option('--same-position', 'keep the original coordinates')
1208
+ .option('-y, --yes', 'skip confirmation')
1209
+ .action(async (ids, opts) => {
1210
+ const c = client();
1211
+ const { source, nodes } = await copySource(c, ids, opts);
1212
+ const targets = opts.to.filter((id) => id !== source.id);
1213
+ if (!targets.length)
1214
+ throw new Error('--to must name at least one canvas other than the source');
1215
+ const names = await Promise.all(targets.map(async (id) => (await c.getFresh(`/fsnode/list/${id}`))?.name ?? id));
1216
+ const question = `Copy ${nodes.length} node(s) from "${source.name}" into ${targets.length} canvas(es): ${names.join(', ')}?`;
1217
+ if (!(await confirm(question, opts.yes)))
1218
+ return info('Aborted');
1219
+ const results = [];
1220
+ for (const id of targets) {
1221
+ const target = await loadCanvas(c, id);
1222
+ results.push(await copyNodes(c, source, nodes, target, copyPlacement(opts)));
1223
+ }
1224
+ render(results, () => {
1225
+ for (const r of results)
1226
+ success(`${r.canvasName}: ${r.nodes.length} node(s), ${r.edges} connection(s)`);
1227
+ const skipped = results[0]?.skippedEdges ?? 0;
1228
+ if (skipped)
1229
+ info(color.dim(`${skipped} connection(s) from nodes outside the copy were left out`));
1230
+ });
1231
+ });
682
1232
  canvas
683
1233
  .command('nodes')
684
1234
  .description('Nodes with references and a content summary (* = generate node)')
685
1235
  .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
686
1236
  .option('-k, --kind <kind>', 'only this kind')
1237
+ .option('--tag <tags...>', 'only nodes with one of these color tags: label (C01), color (red) or id (rose)')
1238
+ .option('--untagged', 'only nodes without a color tag')
1239
+ .option('-n, --name <regex>', 'only nodes whose name matches this regex (case-insensitive; /re/flags for your own flags)')
687
1240
  .action(async (opts) => {
1241
+ if (opts.tag && opts.untagged)
1242
+ throw new Error('Pass --tag or --untagged, not both');
1243
+ const nameRe = opts.name !== undefined ? parseRegex(opts.name, '--name') : undefined;
688
1244
  const c = client();
689
1245
  const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
690
- const rows = cv.nodes.filter((n) => !opts.kind || n.kind === opts.kind).map((n) => describe(cv, n));
1246
+ const colors = opts.tag ? resolveTagFilter(cv, opts.tag) : undefined;
1247
+ const rows = cv.nodes
1248
+ .filter((n) => !opts.kind || n.kind === opts.kind)
1249
+ .filter((n) => !nameRe || nameRe.test(n.name))
1250
+ .map((n) => describe(cv, n))
1251
+ .filter((n) => (colors ? !!n.tag && colors.has(n.tag.color) : opts.untagged ? !n.tag : true));
691
1252
  render({ canvas: { id: cv.id, name: cv.name, workspaceId: cv.workspaceId }, nodes: rows }, () => {
692
1253
  info(color.dim(`${cv.name} (${cv.id}) · ${rows.length} node(s) · ${cv.edges.length} edge(s)`));
693
1254
  nodeTable(rows);
694
1255
  });
695
1256
  });
1257
+ canvas
1258
+ .command('tags')
1259
+ .description('The canvas color tags: color, label and how many nodes carry each')
1260
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1261
+ .option('-a, --all', 'also colors no node uses and that have no label')
1262
+ .action(async (opts) => {
1263
+ const c = client();
1264
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1265
+ const rows = COLOR_TAGS.map((t) => ({
1266
+ color: t.id,
1267
+ alias: t.alias,
1268
+ hex: t.hex,
1269
+ label: cv.tagNames[t.id],
1270
+ nodes: cv.nodes.filter((n) => n.content.colorTag?.colorId === t.id).length,
1271
+ })).filter((r) => opts.all || r.nodes || r.label);
1272
+ render({ canvas: { id: cv.id, name: cv.name }, tags: rows }, () => {
1273
+ info(color.dim(`${cv.name} (${cv.id})`));
1274
+ if (!rows.length)
1275
+ return info('No color tags on this canvas (--all lists the colors)');
1276
+ table(rows, [
1277
+ { header: 'color', value: (r) => r.alias },
1278
+ { header: 'id', value: (r) => color.dim(r.color) },
1279
+ { header: 'label', value: (r) => r.label || '' },
1280
+ { header: 'nodes', value: (r) => String(r.nodes) },
1281
+ ]);
1282
+ });
1283
+ });
696
1284
  canvas
697
1285
  .command('get <nodeId>')
698
1286
  .description('One node in full, with its references')
@@ -726,6 +1314,8 @@ export function canvasCommand(globals) {
726
1314
  .option('-n, --name <name>', 'node name')
727
1315
  .option('-t, --text <text>', 'text for text/title/comment (- reads stdin)')
728
1316
  .option('--text-file <path>', 'read the text from a file')
1317
+ .option('--plain', 'text is plain, not Markdown: keep every line break (the web renders text nodes as Markdown)')
1318
+ .option('--size <w,h>', 'node width and height in canvas pixels (e.g. 600,900)')
729
1319
  .option('--asset <assetId>', 'show an existing asset (display node)')
730
1320
  .option('--dataset <fsnodeId>', 'for `add dataset`: the workspace dataset node to show on the canvas')
731
1321
  .option('--from <nodeIds...>', 'connect these nodes in as references, in order (@image1, @image2…)')
@@ -746,7 +1336,7 @@ export function canvasCommand(globals) {
746
1336
  };
747
1337
  let model;
748
1338
  if (TEXT_KINDS.has(kind)) {
749
- const text = await literalOrFile(opts.text, opts.textFile);
1339
+ const text = await nodeText(kind, opts);
750
1340
  if (text === undefined)
751
1341
  throw new Error(`${kind} nodes need --text`);
752
1342
  content.text = text;
@@ -792,6 +1382,8 @@ export function canvasCommand(globals) {
792
1382
  content.generate = applyModelDefaults(model, kind, patch, { workspaceRatio: ws.aspectRatio, explicit });
793
1383
  }
794
1384
  }
1385
+ if (opts.size)
1386
+ Object.assign(content, parseSize(opts.size));
795
1387
  if (opts.at)
796
1388
  content.position = parsePoint(opts.at, '--at');
797
1389
  else {
@@ -822,7 +1414,9 @@ export function canvasCommand(globals) {
822
1414
  .option('-n, --name <name>', 'rename')
823
1415
  .option('-t, --text <text>', 'replace the text of a text/title/comment node (- reads stdin)')
824
1416
  .option('--text-file <path>', 'read the text from a file')
825
- .option('--move <x,y>', 'move to this position')).action(async (id, opts) => {
1417
+ .option('--plain', 'text is plain, not Markdown: keep every line break')
1418
+ .option('--move <x,y>', 'move to this position')
1419
+ .option('--size <w,h>', 'resize: width and height in canvas pixels')).action(async (id, opts) => {
826
1420
  const c = client();
827
1421
  const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
828
1422
  const node = requireNode(cv, id);
@@ -831,7 +1425,7 @@ export function canvasCommand(globals) {
831
1425
  let changed = false;
832
1426
  if (opts.name !== undefined)
833
1427
  (body.name = opts.name), (changed = true);
834
- const text = await literalOrFile(opts.text, opts.textFile);
1428
+ const text = await nodeText(node.kind, opts);
835
1429
  if (text !== undefined) {
836
1430
  if (!TEXT_KINDS.has(node.kind))
837
1431
  throw new Error(`${node.kind} nodes have no text`);
@@ -840,6 +1434,8 @@ export function canvasCommand(globals) {
840
1434
  }
841
1435
  if (opts.move)
842
1436
  (content.position = parsePoint(opts.move, '--move')), (changed = true);
1437
+ if (opts.size)
1438
+ Object.assign(content, parseSize(opts.size)), (changed = true);
843
1439
  const { patch, explicit } = await generateOptions(opts);
844
1440
  if (Object.keys(patch).length) {
845
1441
  if (!MEDIA_KINDS.has(node.kind))
@@ -937,58 +1533,127 @@ export function canvasCommand(globals) {
937
1533
  .action(async (id, opts) => {
938
1534
  const c = client();
939
1535
  const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
940
- const node = requireNode(cv, id);
941
- if (!MEDIA_KINDS.has(node.kind) || node.content.mode !== 'generate') {
942
- 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 …\`.`);
943
- }
944
- if (describe(cv, node).status === 'generating')
945
- throw new Error(`${id} is already generating`);
946
- const g = node.content.generate || {};
947
- if (!g.model)
948
- throw new Error(`${id} has no model — set one with \`wji canvas set ${id} -m …\``);
949
- const model = await findModel(c, node.kind, g.model, cv.workspaceId);
950
- const body = buildSubmit(cv, node, model);
951
- const cost = await c.post('/ai/assets/preComputeCost', {
952
- type: node.kind,
953
- model: body.model,
954
- n: body.n,
955
- prompt: body.prompt,
956
- // Auto-duration submits -1; price the node's configured length instead.
957
- duration: body.duration && body.duration > 0 ? body.duration : g.duration,
958
- aspectRatio: body.aspectRatio,
959
- referenceImageCount: body.referenceImages?.length,
960
- values: body.values,
961
- });
962
- const credits = typeof cost === 'object' ? cost?.cost ?? cost?.credits ?? cost?.total : cost;
963
- const refs = referencesOf(cv, node).map((r) => r.label).join(', ') || 'none';
964
- const auto = body.duration === -1 ? ` (auto duration — estimated for ${g.duration ?? '?'}s, final cost follows the real length)` : '';
965
- const question = `Generate ${node.kind} "${node.name}" with ${body.model} (refs: ${refs}) for ${credits ?? '?'} credits${auto}?`;
966
- if (!(await confirm(question, opts.yes)))
967
- return info('Aborted');
968
- const history = await c.post(`/ai/assets/history/${node.kind}/generations`, body);
969
- const assetId = idOf((history?.images || [])[0]);
970
- const nextGenerate = { ...g, historyId: String(history.id), pendingAssetId: assetId };
971
- await patchNode(c, node, {
972
- ...(assetId ? { file: assetId } : {}),
973
- content: { ...node.content, generate: nextGenerate },
974
- });
975
- if (!opts.wait) {
976
- if (isJsonMode())
977
- return printJson(history);
978
- success(`Submitted ${node.kind} generation for node ${id}`);
979
- reportHistory(history, await resolveAssets(c, history));
980
- info(color.dim(`The canvas shows progress. Poll with: wji canvas get ${id} or wji history get ${history.id} --wait`));
981
- return;
982
- }
983
- info(color.dim(`Submitted ${history.id}, waiting…`));
984
- const done = await pollHistory(c, history.id, {
985
- timeoutMs: Number(opts.timeout) * 1000,
986
- intervalMs: Number(opts.interval) * 1000,
987
- });
988
- if (isJsonMode())
989
- return printJson({ ...done.history, images: done.assets });
990
- success(`Node ${id} generated`);
991
- reportHistory(done.history, done.assets);
1536
+ await submitGenerate(c, cv, requireNode(cv, id), opts);
1537
+ });
1538
+ canvas
1539
+ .command('upscale <nodeId>')
1540
+ .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)')
1541
+ .option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
1542
+ .option('-n, --name <name>', 'name of the upscale node (default: "<source name>-1080p")')
1543
+ .option('--at <x,y>', 'position of the upscale node; default is right of the draft clip')
1544
+ .option('--no-gen', 'only create the upscale node; generate it later with `wji canvas gen`')
1545
+ .option('-y, --yes', 'skip the cost confirmation')
1546
+ .option('-w, --wait', 'wait for the result')
1547
+ .option('--timeout <sec>', 'with --wait, give up after this many seconds', '1200')
1548
+ .option('--interval <sec>', 'with --wait, poll every N seconds', '5')
1549
+ .action(async (id, opts) => {
1550
+ const c = client();
1551
+ const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1552
+ const source = requireNode(cv, id);
1553
+ const asset = source.file ? cv.assets.get(source.file) : undefined;
1554
+ if (source.kind !== 'video' || !asset?.url)
1555
+ throw new Error(`${id} is not a video node with a finished clip`);
1556
+ const draft = seedanceDraftSource(asset);
1557
+ if (!isSeedance25(draft.model))
1558
+ throw new Error(`${id} was generated with ${draft.model || 'an unknown model'} — only Seedance 2.5 drafts can be upscaled`);
1559
+ if (!draft.taskId)
1560
+ throw new Error(`${id} has no draft task id (asset ${asset.id} carries no BytePlus task) — it cannot be upscaled`);
1561
+ if (draft.expired)
1562
+ 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`);
1563
+ if (!draft.flagged)
1564
+ throw new Error(`${id} is not a Seedance draft (asset ${asset.id} has no config.values.draft) — only draft clips can be upscaled`);
1565
+ const model = await findModel(c, 'video', draft.model, cv.workspaceId);
1566
+ const configDuration = Number(asset.config?.duration);
1567
+ const duration = configDuration > 0 ? configDuration : Number(asset.duration) > 0 ? Math.round(Number(asset.duration)) : undefined;
1568
+ const ratio = String(asset.config?.aspectRatio || '').includes(':') ? asset.config.aspectRatio : asset.aspectRatio;
1569
+ const content = {
1570
+ mode: 'generate',
1571
+ generate: {
1572
+ prompt: 'N/A',
1573
+ model: model.nickname || model.name,
1574
+ resolution: '1080p',
1575
+ ratio,
1576
+ duration,
1577
+ values: { mode: 'video', resolution: '1080p', draft: false, draftTaskId: draft.taskId },
1578
+ },
1579
+ };
1580
+ const body = {
1581
+ name: opts.name || `${source.name || 'video'}-1080p`,
1582
+ type: 'file',
1583
+ mimetype: `${NODE_PREFIX}video`,
1584
+ parent: cv.id,
1585
+ sort: 0,
1586
+ };
1587
+ if (source.content.width)
1588
+ Object.assign(content, { width: source.content.width, height: source.content.height });
1589
+ content.position = opts.at
1590
+ ? parsePoint(opts.at, '--at')
1591
+ : placeNear(cv, [source], estimateSize(cv, { id: '', kind: 'video', name: '', sort: 0, file: undefined, content, raw: null }));
1592
+ const raw = await c.post('/fsnode/create', { ...body, content });
1593
+ const node = { id: raw.id, kind: 'video', name: raw.name, sort: 0, file: idOf(raw.file), content, raw };
1594
+ cv.nodes.push(node);
1595
+ cv.byId.set(node.id, node);
1596
+ // The web draws this edge so the panel shows the draft; the submit itself reads only draftTaskId.
1597
+ await createEdge(c, cv, source.id, node.id, { content: { referenceRole: 'video', referenceLabel: 'video1' } });
1598
+ success(`Created upscale node ${node.id} from ${id} (draft task ${draft.taskId})`);
1599
+ if (!opts.gen)
1600
+ return info(color.dim(`Generate it with: wji canvas gen ${node.id} -c ${cv.id}`));
1601
+ await submitGenerate(c, cv, node, opts);
992
1602
  });
993
1603
  return canvas;
994
1604
  }
1605
+ /** Price, confirm and submit one generate node — what the canvas Generate button does. */
1606
+ async function submitGenerate(c, cv, node, opts) {
1607
+ const id = node.id;
1608
+ if (!MEDIA_KINDS.has(node.kind) || node.content.mode !== 'generate') {
1609
+ 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 …\`.`);
1610
+ }
1611
+ if (describe(cv, node).status === 'generating')
1612
+ throw new Error(`${id} is already generating`);
1613
+ const g = node.content.generate || {};
1614
+ if (!g.model)
1615
+ throw new Error(`${id} has no model — set one with \`wji canvas set ${id} -m …\``);
1616
+ const model = await findModel(c, node.kind, g.model, cv.workspaceId);
1617
+ const body = buildSubmit(cv, node, model);
1618
+ const cost = await c.post('/ai/assets/preComputeCost', {
1619
+ type: node.kind,
1620
+ model: body.model,
1621
+ n: body.n,
1622
+ prompt: body.prompt,
1623
+ // Auto-duration submits -1; price the node's configured length instead.
1624
+ duration: body.duration && body.duration > 0 ? body.duration : g.duration,
1625
+ aspectRatio: body.aspectRatio,
1626
+ referenceImageCount: body.referenceImages?.length,
1627
+ values: body.values,
1628
+ });
1629
+ const credits = typeof cost === 'object' ? cost?.cost ?? cost?.credits ?? cost?.total : cost;
1630
+ const refs = referencesOf(cv, node).map((r) => r.label).join(', ') || 'none';
1631
+ const auto = body.duration === -1 ? ` (auto duration — estimated for ${g.duration ?? '?'}s, final cost follows the real length)` : '';
1632
+ const question = `Generate ${node.kind} "${node.name}" with ${body.model} (refs: ${refs}) for ${credits ?? '?'} credits${auto}?`;
1633
+ if (!(await confirm(question, opts.yes)))
1634
+ return info('Aborted');
1635
+ const history = await c.post(`/ai/assets/history/${node.kind}/generations`, body);
1636
+ const assetId = idOf((history?.images || [])[0]);
1637
+ const nextGenerate = { ...g, historyId: String(history.id), pendingAssetId: assetId };
1638
+ await patchNode(c, node, {
1639
+ ...(assetId ? { file: assetId } : {}),
1640
+ content: { ...node.content, generate: nextGenerate },
1641
+ });
1642
+ if (!opts.wait) {
1643
+ if (isJsonMode())
1644
+ return printJson(history);
1645
+ success(`Submitted ${node.kind} generation for node ${id}`);
1646
+ reportHistory(history, await resolveAssets(c, history));
1647
+ info(color.dim(`The canvas shows progress. Poll with: wji canvas get ${id} or wji history get ${history.id} --wait`));
1648
+ return;
1649
+ }
1650
+ info(color.dim(`Submitted ${history.id}, waiting…`));
1651
+ const done = await pollHistory(c, history.id, {
1652
+ timeoutMs: Number(opts.timeout) * 1000,
1653
+ intervalMs: Number(opts.interval) * 1000,
1654
+ });
1655
+ if (isJsonMode())
1656
+ return printJson({ ...done.history, images: done.assets });
1657
+ success(`Node ${id} generated`);
1658
+ reportHistory(done.history, done.assets);
1659
+ }