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