@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.
- package/README.md +76 -2
- package/dist/commands/backreel.js +260 -0
- package/dist/commands/canvas.js +751 -86
- package/dist/commands/class.js +395 -0
- package/dist/commands/crew.js +205 -0
- package/dist/commands/npc.js +413 -0
- package/dist/commands/workspace.js +109 -24
- package/dist/core/archive.js +95 -0
- package/dist/core/crew.js +546 -0
- package/dist/core/members.js +54 -0
- package/dist/core/npc.js +108 -0
- package/dist/core/output.js +4 -2
- package/dist/index.js +10 -0
- package/package.json +1 -1
- package/skill/short-drama/SKILL.md +86 -8
- package/skill/short-drama/reference/action-bullet-time.md +47 -0
- package/skill/short-drama/reference/action-car-chase.md +51 -0
- package/skill/short-drama/reference/action-gun-fu.md +52 -0
- package/skill/short-drama/reference/action-historical-realist.md +50 -0
- package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
- package/skill/short-drama/reference/action-street-brawl.md +49 -0
- package/skill/short-drama/reference/action-superhero.md +50 -0
- package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
- package/skill/short-drama/reference/action.md +94 -0
- package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
- package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
- package/skill/short-drama/reference/cinematography-low-key.md +115 -0
- package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
- package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
- package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
- package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
- package/skill/short-drama/reference/cinematography.md +83 -0
- package/skill/short-drama/reference/composition-art-first.md +74 -0
- package/skill/short-drama/reference/craft-shots.md +3 -1
- package/skill/short-drama/reference/craft-story.md +4 -1
- package/skill/short-drama/reference/format.md +94 -0
- package/skill/short-drama/reference/grammar-comedy.md +105 -0
- package/skill/short-drama/reference/grammar-faceslap.md +67 -0
- package/skill/short-drama/reference/grammar.md +103 -0
- package/skill/short-drama/reference/operations-cli.md +1 -1
- package/skill/short-drama/reference/schema.md +34 -3
- package/skill/short-drama/reference/stage-art.md +1 -1
- package/skill/short-drama/reference/stage-outline.md +21 -0
- package/skill/short-drama/reference/stage-script.md +10 -0
- package/skill/short-drama/reference/stage-storyboard.md +15 -0
- package/skill/short-drama/reference/story-angst.md +117 -0
- package/skill/short-drama/reference/story-comedy.md +136 -0
- package/skill/short-drama/reference/story-cultivation.md +109 -0
- package/skill/short-drama/reference/story-family.md +119 -0
- package/skill/short-drama/reference/story-horror.md +124 -0
- package/skill/short-drama/reference/story-mystery.md +127 -0
- package/skill/short-drama/reference/story-palace.md +103 -0
- package/skill/short-drama/reference/story-revenge.md +124 -0
- package/skill/short-drama/reference/story-scifi.md +112 -0
- package/skill/short-drama/reference/story-sweet.md +128 -0
- package/skill/short-drama/reference/story.md +127 -0
- package/skill/short-drama/scripts/selftest.mjs +809 -1
- package/skill/short-drama/scripts/short-drama.mjs +1608 -16
- package/skill/wji/SKILL.md +84 -2
- package/skill/wji/reference/commands.md +96 -6
- package/skill/wji-classroom/SKILL.md +161 -0
package/dist/commands/canvas.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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: '
|
|
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
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
|
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
|
|
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('--
|
|
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
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
+
}
|