@wji.ai/cli 0.4.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 (58) hide show
  1. package/README.md +48 -2
  2. package/dist/commands/backreel.js +260 -0
  3. package/dist/commands/canvas.js +588 -88
  4. package/dist/commands/class.js +395 -0
  5. package/dist/commands/npc.js +413 -0
  6. package/dist/commands/workspace.js +109 -24
  7. package/dist/core/members.js +54 -0
  8. package/dist/core/npc.js +108 -0
  9. package/dist/core/output.js +4 -2
  10. package/dist/index.js +8 -0
  11. package/package.json +1 -1
  12. package/skill/short-drama/SKILL.md +86 -8
  13. package/skill/short-drama/reference/action-bullet-time.md +47 -0
  14. package/skill/short-drama/reference/action-car-chase.md +51 -0
  15. package/skill/short-drama/reference/action-gun-fu.md +52 -0
  16. package/skill/short-drama/reference/action-historical-realist.md +50 -0
  17. package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
  18. package/skill/short-drama/reference/action-street-brawl.md +49 -0
  19. package/skill/short-drama/reference/action-superhero.md +50 -0
  20. package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
  21. package/skill/short-drama/reference/action.md +94 -0
  22. package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
  23. package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
  24. package/skill/short-drama/reference/cinematography-low-key.md +115 -0
  25. package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
  26. package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
  27. package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
  28. package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
  29. package/skill/short-drama/reference/cinematography.md +83 -0
  30. package/skill/short-drama/reference/composition-art-first.md +74 -0
  31. package/skill/short-drama/reference/craft-shots.md +3 -1
  32. package/skill/short-drama/reference/craft-story.md +4 -1
  33. package/skill/short-drama/reference/format.md +94 -0
  34. package/skill/short-drama/reference/grammar-comedy.md +105 -0
  35. package/skill/short-drama/reference/grammar-faceslap.md +67 -0
  36. package/skill/short-drama/reference/grammar.md +103 -0
  37. package/skill/short-drama/reference/operations-cli.md +1 -1
  38. package/skill/short-drama/reference/schema.md +34 -3
  39. package/skill/short-drama/reference/stage-art.md +1 -1
  40. package/skill/short-drama/reference/stage-outline.md +21 -0
  41. package/skill/short-drama/reference/stage-script.md +10 -0
  42. package/skill/short-drama/reference/stage-storyboard.md +15 -0
  43. package/skill/short-drama/reference/story-angst.md +117 -0
  44. package/skill/short-drama/reference/story-comedy.md +136 -0
  45. package/skill/short-drama/reference/story-cultivation.md +109 -0
  46. package/skill/short-drama/reference/story-family.md +119 -0
  47. package/skill/short-drama/reference/story-horror.md +124 -0
  48. package/skill/short-drama/reference/story-mystery.md +127 -0
  49. package/skill/short-drama/reference/story-palace.md +103 -0
  50. package/skill/short-drama/reference/story-revenge.md +124 -0
  51. package/skill/short-drama/reference/story-scifi.md +112 -0
  52. package/skill/short-drama/reference/story-sweet.md +128 -0
  53. package/skill/short-drama/reference/story.md +127 -0
  54. package/skill/short-drama/scripts/selftest.mjs +809 -1
  55. package/skill/short-drama/scripts/short-drama.mjs +1608 -16
  56. package/skill/wji/SKILL.md +66 -2
  57. package/skill/wji/reference/commands.md +76 -15
  58. 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
  };
@@ -52,7 +53,7 @@ const PENDING = 0;
52
53
  * folder as `content.colorTagNames {<colorId>: label}`. `alias` is the plain
53
54
  * color name the tag reads as, so `--tag red` finds `rose`.
54
55
  */
55
- const COLOR_TAGS = [
56
+ export const COLOR_TAGS = [
56
57
  { id: 'rose', alias: 'red', hex: '#f43f5e' },
57
58
  { id: 'orange', alias: 'orange', hex: '#f97316' },
58
59
  { id: 'amber', alias: 'yellow', hex: '#eab308' },
@@ -62,17 +63,17 @@ const COLOR_TAGS = [
62
63
  { id: 'violet', alias: 'purple', hex: '#8b5cf6' },
63
64
  { id: 'fuchsia', alias: 'pink', hex: '#d946ef' },
64
65
  ];
65
- const idOf = (value) => value == null ? undefined : typeof value === 'string' ? value : value.id;
66
+ export const idOf = (value) => value == null ? undefined : typeof value === 'string' ? value : value.id;
66
67
  function toList(result) {
67
68
  return Array.isArray(result) ? result : result?.data || [];
68
69
  }
69
70
  // ── loading ─────────────────────────────────────────────────────────────────
70
- async function linkedState(client) {
71
+ export async function linkedState(client) {
71
72
  const res = await client.get('/canvas-link');
72
73
  return res?.linked ? res.state : null;
73
74
  }
74
75
  /** `--canvas` wins; otherwise the canvas linked from the browser. */
75
- async function resolveCanvasId(client, explicit) {
76
+ export async function resolveCanvasId(client, explicit) {
76
77
  if (explicit)
77
78
  return explicit;
78
79
  const state = await linkedState(client);
@@ -80,7 +81,7 @@ async function resolveCanvasId(client, explicit) {
80
81
  return String(state.canvasId);
81
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.');
82
83
  }
83
- async function loadCanvas(client, canvasId) {
84
+ export async function loadCanvas(client, canvasId) {
84
85
  // getFresh: read-merge-write must not see the 10s server cache
85
86
  const folder = await client.getFresh(`/fsnode/list/${canvasId}`);
86
87
  if (folder?.mimetype !== CANVAS_MIMETYPE) {
@@ -122,13 +123,81 @@ async function loadCanvas(client, canvasId) {
122
123
  id: folder.id,
123
124
  name: folder.name,
124
125
  workspaceId: idOf(folder.workspace) || idOf(folder.root) || '',
126
+ folder,
127
+ access: canvasAccessOf(folder.content),
125
128
  nodes,
126
129
  edges,
127
130
  byId: new Map(nodes.map((n) => [n.id, n])),
128
131
  assets,
129
- tagNames: parseTagNames(folder.content?.colorTagNames),
132
+ tagNames: parseTagNames(canvasEnvelope(folder.content).data.colorTagNames),
130
133
  };
131
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
+ }
132
201
  // ── color tags ──────────────────────────────────────────────────────────────
133
202
  function parseTagNames(raw) {
134
203
  const out = {};
@@ -142,7 +211,7 @@ function parseTagNames(raw) {
142
211
  return out;
143
212
  }
144
213
  /** The node's tag with the canvas label (never the node's own legacy `name`, as on the web). */
145
- function tagOf(canvas, node) {
214
+ export function tagOf(canvas, node) {
146
215
  const colorId = node.content.colorTag?.colorId;
147
216
  const tag = COLOR_TAGS.find((t) => t.id === colorId);
148
217
  if (!tag)
@@ -153,7 +222,7 @@ function tagOf(canvas, node) {
153
222
  * Colors a `--tag` value means: a color id (`rose`), its plain name (`red`),
154
223
  * or a label (`C01`, case-insensitive), so several colors can match one label.
155
224
  */
156
- function resolveTagFilter(canvas, values) {
225
+ export function resolveTagFilter(canvas, values) {
157
226
  const colors = new Set();
158
227
  for (const value of values) {
159
228
  const v = value.trim().toLowerCase();
@@ -168,7 +237,7 @@ function resolveTagFilter(canvas, values) {
168
237
  return colors;
169
238
  }
170
239
  /** `C0[1-3]` is case-insensitive; `/C0[1-3]$/` keeps exactly the flags given. */
171
- function parseRegex(value, flag) {
240
+ export function parseRegex(value, flag) {
172
241
  const slashed = /^\/(.*)\/([a-z]*)$/s.exec(value);
173
242
  try {
174
243
  return slashed ? new RegExp(slashed[1], slashed[2].replace(/[gy]/g, '')) : new RegExp(value, 'i');
@@ -177,13 +246,13 @@ function parseRegex(value, flag) {
177
246
  throw new Error(`${flag}: invalid regex ${value} — ${err.message}`);
178
247
  }
179
248
  }
180
- function tagText(tag) {
249
+ export function tagText(tag) {
181
250
  if (!tag)
182
251
  return '';
183
252
  return tag.label ? `${tag.alias} "${tag.label}"` : tag.alias;
184
253
  }
185
254
  /** The `canvases` folder of every InfiniteCanvas app in a workspace. */
186
- async function canvasesFolders(client, wsId) {
255
+ export async function canvasesFolders(client, wsId) {
187
256
  const ws = await client.get(`/workspace/list/${wsId}`);
188
257
  const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
189
258
  const out = [];
@@ -195,7 +264,7 @@ async function canvasesFolders(client, wsId) {
195
264
  }
196
265
  return out;
197
266
  }
198
- function requireNode(canvas, id) {
267
+ export function requireNode(canvas, id) {
199
268
  const node = canvas.byId.get(id);
200
269
  if (!node)
201
270
  throw new Error(`Node ${id} is not on canvas "${canvas.name}" (${canvas.id})`);
@@ -220,7 +289,7 @@ function referencesOf(canvas, node) {
220
289
  };
221
290
  });
222
291
  }
223
- function describe(canvas, node) {
292
+ export function describe(canvas, node) {
224
293
  const asset = node.file ? canvas.assets.get(node.file) : undefined;
225
294
  const generate = node.content.generate;
226
295
  const pending = asset && asset.status === PENDING && !asset.error;
@@ -341,7 +410,7 @@ function applyModelDefaults(model, kind, generate, opts) {
341
410
  }
342
411
  // ── writes ──────────────────────────────────────────────────────────────────
343
412
  /** Short side 250, like the canvas; text-ish nodes use a typical card size. */
344
- function estimateSize(canvas, node) {
413
+ export function estimateSize(canvas, node) {
345
414
  const w = Number(node.content.width);
346
415
  const h = Number(node.content.height);
347
416
  if (w > 0 && h > 0)
@@ -411,6 +480,7 @@ async function createEdge(client, canvas, fromId, toId, opts = {}) {
411
480
  toNodeId: to.id,
412
481
  sourceHandle: 'right',
413
482
  targetHandle: 'left',
483
+ ...(opts.content || {}),
414
484
  };
415
485
  // First/last-frame video models take connected images as frame slots, in order.
416
486
  if (opts.frameModel && to.kind === 'video' && from.kind === 'image') {
@@ -433,8 +503,37 @@ async function createEdge(client, canvas, fromId, toId, opts = {}) {
433
503
  canvas.edges.push(edge);
434
504
  return edge;
435
505
  }
436
- async function patchNode(client, node, body) {
437
- 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;
438
537
  }
439
538
  // ── output ──────────────────────────────────────────────────────────────────
440
539
  function summary(n) {
@@ -451,7 +550,7 @@ function summary(n) {
451
550
  }
452
551
  return n.url || n.assetId || '';
453
552
  }
454
- function nodeTable(rows) {
553
+ export function nodeTable(rows) {
455
554
  table(rows, [
456
555
  { header: 'id', value: (n) => n.id },
457
556
  { header: 'kind', value: (n) => (n.mode === 'generate' ? `${n.kind}*` : n.kind) },
@@ -584,6 +683,43 @@ function withGenerateOptions(cmd) {
584
683
  }
585
684
  // ── generation ──────────────────────────────────────────────────────────────
586
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
+ }
587
723
  /**
588
724
  * Build the same request the canvas generate button sends
589
725
  * (shared/generate/buildSubmitPayload.ts): references in edge order, text
@@ -592,6 +728,32 @@ const UNSUPPORTED_REF_KINDS = new Set(['table', 'youtube', 'dataset', 'bundle',
592
728
  function buildSubmit(canvas, node, model) {
593
729
  const kind = node.kind;
594
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
+ }
595
757
  const userPrompt = String(g.prompt || '').trim();
596
758
  const form = model.form || {};
597
759
  const frames = usesFrameSlots(model);
@@ -681,6 +843,8 @@ function buildSubmit(canvas, node, model) {
681
843
  ...(kind !== 'audio' && g.resolution ? { resolution: g.resolution } : {}),
682
844
  },
683
845
  };
846
+ if (kind === 'video')
847
+ syncSeedanceDraftFlag(g.model, body.values);
684
848
  if (imageRefs.length)
685
849
  body.referenceImages = imageRefs;
686
850
  if (kind === 'video') {
@@ -696,6 +860,141 @@ function buildSubmit(canvas, node, model) {
696
860
  body.referenceAudios = audios;
697
861
  return body;
698
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
+ }
699
998
  // ── commands ────────────────────────────────────────────────────────────────
700
999
  export function canvasCommand(globals) {
701
1000
  const canvas = new Command('canvas').description('Read and edit Infinite Canvas nodes (defaults to the canvas linked from the browser)');
@@ -748,24 +1047,32 @@ export function canvasCommand(globals) {
748
1047
  });
749
1048
  canvas
750
1049
  .command('list')
751
- .description('Canvases in a workspace')
1050
+ .description('Canvases in a workspace, with owner and visibility')
752
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)')
753
1053
  .action(async (opts) => {
754
1054
  const c = client();
755
1055
  const linked = opts.workspace ? null : await linkedState(c);
756
1056
  const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
757
- const rows = [];
758
- for (const { appId, folderId } of await canvasesFolders(c, wsId)) {
759
- const canvases = toList(await c.getFresh(`/fsnode/list/${folderId}/children`, { limit: -1 }));
760
- for (const it of canvases.filter((n) => n.mimetype === CANVAS_MIMETYPE)) {
761
- rows.push({ id: it.id, name: it.name, app: appId, linked: it.id === linked?.canvasId, updatedAt: it.updatedAt });
762
- }
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);
763
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
+ }));
764
1069
  render(rows, () => table(rows, [
765
1070
  { header: '', value: (r) => (r.linked ? color.green('●') : '') },
766
1071
  { header: 'id', value: (r) => r.id },
767
1072
  { header: 'name', value: (r) => r.name },
768
- { 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) },
769
1076
  ]));
770
1077
  });
771
1078
  canvas
@@ -773,6 +1080,8 @@ export function canvasCommand(globals) {
773
1080
  .description('Create an empty canvas in a workspace (its InfiniteCanvas app)')
774
1081
  .option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
775
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')
776
1085
  .action(async (name, opts) => {
777
1086
  const c = client();
778
1087
  const linked = opts.workspace ? null : await linkedState(c);
@@ -785,19 +1094,141 @@ export function canvasCommand(globals) {
785
1094
  throw new Error(`No InfiniteCanvas app ${opts.app} in workspace ${wsId}`);
786
1095
  if (!opts.app && folders.length > 1)
787
1096
  warn(`Several InfiniteCanvas apps — using ${target.appId}; pass --app to choose`);
788
- const raw = await c.post('/fsnode/create', {
789
- name,
790
- type: 'folder',
791
- mimetype: CANVAS_MIMETYPE,
792
- parent: target.folderId,
793
- content: INITIAL_CANVAS_CONTENT,
794
- });
795
- 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 };
796
1101
  render(out, () => {
797
1102
  success(`Created canvas "${out.name}" ${color.dim(out.id)}`);
798
1103
  info(color.dim(`Target it with --canvas ${out.id}, or open it in the browser and press link.`));
799
1104
  });
800
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
+ });
801
1232
  canvas
802
1233
  .command('nodes')
803
1234
  .description('Nodes with references and a content summary (* = generate node)')
@@ -1102,58 +1533,127 @@ export function canvasCommand(globals) {
1102
1533
  .action(async (id, opts) => {
1103
1534
  const c = client();
1104
1535
  const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
1105
- const node = requireNode(cv, id);
1106
- if (!MEDIA_KINDS.has(node.kind) || node.content.mode !== 'generate') {
1107
- 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 …\`.`);
1108
- }
1109
- if (describe(cv, node).status === 'generating')
1110
- throw new Error(`${id} is already generating`);
1111
- const g = node.content.generate || {};
1112
- if (!g.model)
1113
- throw new Error(`${id} has no model — set one with \`wji canvas set ${id} -m …\``);
1114
- const model = await findModel(c, node.kind, g.model, cv.workspaceId);
1115
- const body = buildSubmit(cv, node, model);
1116
- const cost = await c.post('/ai/assets/preComputeCost', {
1117
- type: node.kind,
1118
- model: body.model,
1119
- n: body.n,
1120
- prompt: body.prompt,
1121
- // Auto-duration submits -1; price the node's configured length instead.
1122
- duration: body.duration && body.duration > 0 ? body.duration : g.duration,
1123
- aspectRatio: body.aspectRatio,
1124
- referenceImageCount: body.referenceImages?.length,
1125
- values: body.values,
1126
- });
1127
- const credits = typeof cost === 'object' ? cost?.cost ?? cost?.credits ?? cost?.total : cost;
1128
- const refs = referencesOf(cv, node).map((r) => r.label).join(', ') || 'none';
1129
- const auto = body.duration === -1 ? ` (auto duration — estimated for ${g.duration ?? '?'}s, final cost follows the real length)` : '';
1130
- const question = `Generate ${node.kind} "${node.name}" with ${body.model} (refs: ${refs}) for ${credits ?? '?'} credits${auto}?`;
1131
- if (!(await confirm(question, opts.yes)))
1132
- return info('Aborted');
1133
- const history = await c.post(`/ai/assets/history/${node.kind}/generations`, body);
1134
- const assetId = idOf((history?.images || [])[0]);
1135
- const nextGenerate = { ...g, historyId: String(history.id), pendingAssetId: assetId };
1136
- await patchNode(c, node, {
1137
- ...(assetId ? { file: assetId } : {}),
1138
- content: { ...node.content, generate: nextGenerate },
1139
- });
1140
- if (!opts.wait) {
1141
- if (isJsonMode())
1142
- return printJson(history);
1143
- success(`Submitted ${node.kind} generation for node ${id}`);
1144
- reportHistory(history, await resolveAssets(c, history));
1145
- info(color.dim(`The canvas shows progress. Poll with: wji canvas get ${id} or wji history get ${history.id} --wait`));
1146
- return;
1147
- }
1148
- info(color.dim(`Submitted ${history.id}, waiting…`));
1149
- const done = await pollHistory(c, history.id, {
1150
- timeoutMs: Number(opts.timeout) * 1000,
1151
- intervalMs: Number(opts.interval) * 1000,
1152
- });
1153
- if (isJsonMode())
1154
- return printJson({ ...done.history, images: done.assets });
1155
- success(`Node ${id} generated`);
1156
- 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);
1157
1602
  });
1158
1603
  return canvas;
1159
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
+ }