@wji.ai/cli 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/commands/backreel.js +260 -0
- package/dist/commands/canvas.js +588 -88
- package/dist/commands/class.js +395 -0
- package/dist/commands/npc.js +413 -0
- package/dist/commands/workspace.js +109 -24
- package/dist/core/members.js +54 -0
- package/dist/core/npc.js +108 -0
- package/dist/core/output.js +4 -2
- package/dist/index.js +8 -0
- package/package.json +1 -1
- package/skill/short-drama/SKILL.md +86 -8
- package/skill/short-drama/reference/action-bullet-time.md +47 -0
- package/skill/short-drama/reference/action-car-chase.md +51 -0
- package/skill/short-drama/reference/action-gun-fu.md +52 -0
- package/skill/short-drama/reference/action-historical-realist.md +50 -0
- package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
- package/skill/short-drama/reference/action-street-brawl.md +49 -0
- package/skill/short-drama/reference/action-superhero.md +50 -0
- package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
- package/skill/short-drama/reference/action.md +94 -0
- package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
- package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
- package/skill/short-drama/reference/cinematography-low-key.md +115 -0
- package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
- package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
- package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
- package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
- package/skill/short-drama/reference/cinematography.md +83 -0
- package/skill/short-drama/reference/composition-art-first.md +74 -0
- package/skill/short-drama/reference/craft-shots.md +3 -1
- package/skill/short-drama/reference/craft-story.md +4 -1
- package/skill/short-drama/reference/format.md +94 -0
- package/skill/short-drama/reference/grammar-comedy.md +105 -0
- package/skill/short-drama/reference/grammar-faceslap.md +67 -0
- package/skill/short-drama/reference/grammar.md +103 -0
- package/skill/short-drama/reference/operations-cli.md +1 -1
- package/skill/short-drama/reference/schema.md +34 -3
- package/skill/short-drama/reference/stage-art.md +1 -1
- package/skill/short-drama/reference/stage-outline.md +21 -0
- package/skill/short-drama/reference/stage-script.md +10 -0
- package/skill/short-drama/reference/stage-storyboard.md +15 -0
- package/skill/short-drama/reference/story-angst.md +117 -0
- package/skill/short-drama/reference/story-comedy.md +136 -0
- package/skill/short-drama/reference/story-cultivation.md +109 -0
- package/skill/short-drama/reference/story-family.md +119 -0
- package/skill/short-drama/reference/story-horror.md +124 -0
- package/skill/short-drama/reference/story-mystery.md +127 -0
- package/skill/short-drama/reference/story-palace.md +103 -0
- package/skill/short-drama/reference/story-revenge.md +124 -0
- package/skill/short-drama/reference/story-scifi.md +112 -0
- package/skill/short-drama/reference/story-sweet.md +128 -0
- package/skill/short-drama/reference/story.md +127 -0
- package/skill/short-drama/scripts/selftest.mjs +809 -1
- package/skill/short-drama/scripts/short-drama.mjs +1608 -16
- package/skill/wji/SKILL.md +66 -2
- package/skill/wji/reference/commands.md +76 -15
- package/skill/wji-classroom/SKILL.md +161 -0
package/dist/commands/canvas.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { WjiClient } from '../core/client.js';
|
|
3
3
|
import { confirm, parseKeyValues, readValue } from '../core/prompt.js';
|
|
4
|
-
import { color, detail, info, isJsonMode, printJson, render, success, table, truncate, warn } from '../core/output.js';
|
|
4
|
+
import { color, detail, info, isJsonMode, printJson, relativeTime, render, success, table, truncate, warn } from '../core/output.js';
|
|
5
5
|
import { pollHistory, reportHistory, resolveAssets } from './gen.js';
|
|
6
|
+
import { loadMembers, memberLabel, resolveMember } from '../core/members.js';
|
|
6
7
|
import { fetchModels } from './model.js';
|
|
7
8
|
/**
|
|
8
9
|
* Infinite Canvas over the plain fsnode API.
|
|
@@ -21,7 +22,7 @@ import { fetchModels } from './model.js';
|
|
|
21
22
|
*/
|
|
22
23
|
const NODE_PREFIX = 'canvas/node/';
|
|
23
24
|
const EDGE_MIMETYPE = 'canvas/edge';
|
|
24
|
-
const CANVAS_MIMETYPE = 'workspace-v2/canvas';
|
|
25
|
+
export const CANVAS_MIMETYPE = 'workspace-v2/canvas';
|
|
25
26
|
const CANVASES_MIMETYPE = 'workspace-v2/canvases';
|
|
26
27
|
const DATASET_MIMETYPE = 'workspace/dataset';
|
|
27
28
|
const CREATABLE_KINDS = ['text', 'title', 'comment', 'image', 'video', 'audio', 'dataset'];
|
|
@@ -38,7 +39,7 @@ const DEFAULT_NAMES = {
|
|
|
38
39
|
dataset: '数据集',
|
|
39
40
|
};
|
|
40
41
|
/** What the web writes into a brand-new canvas folder (CanvasAppFileSystem.createCanvas). */
|
|
41
|
-
const INITIAL_CANVAS_CONTENT = {
|
|
42
|
+
export const INITIAL_CANVAS_CONTENT = {
|
|
42
43
|
data: { viewport: { x: 0, y: 0, zoom: 1 }, backgroundMode: 'dots' },
|
|
43
44
|
metadata: {},
|
|
44
45
|
};
|
|
@@ -52,7 +53,7 @@ const PENDING = 0;
|
|
|
52
53
|
* folder as `content.colorTagNames {<colorId>: label}`. `alias` is the plain
|
|
53
54
|
* color name the tag reads as, so `--tag red` finds `rose`.
|
|
54
55
|
*/
|
|
55
|
-
const COLOR_TAGS = [
|
|
56
|
+
export const COLOR_TAGS = [
|
|
56
57
|
{ id: 'rose', alias: 'red', hex: '#f43f5e' },
|
|
57
58
|
{ id: 'orange', alias: 'orange', hex: '#f97316' },
|
|
58
59
|
{ id: 'amber', alias: 'yellow', hex: '#eab308' },
|
|
@@ -62,17 +63,17 @@ const COLOR_TAGS = [
|
|
|
62
63
|
{ id: 'violet', alias: 'purple', hex: '#8b5cf6' },
|
|
63
64
|
{ id: 'fuchsia', alias: 'pink', hex: '#d946ef' },
|
|
64
65
|
];
|
|
65
|
-
const idOf = (value) => value == null ? undefined : typeof value === 'string' ? value : value.id;
|
|
66
|
+
export const idOf = (value) => value == null ? undefined : typeof value === 'string' ? value : value.id;
|
|
66
67
|
function toList(result) {
|
|
67
68
|
return Array.isArray(result) ? result : result?.data || [];
|
|
68
69
|
}
|
|
69
70
|
// ── loading ─────────────────────────────────────────────────────────────────
|
|
70
|
-
async function linkedState(client) {
|
|
71
|
+
export async function linkedState(client) {
|
|
71
72
|
const res = await client.get('/canvas-link');
|
|
72
73
|
return res?.linked ? res.state : null;
|
|
73
74
|
}
|
|
74
75
|
/** `--canvas` wins; otherwise the canvas linked from the browser. */
|
|
75
|
-
async function resolveCanvasId(client, explicit) {
|
|
76
|
+
export async function resolveCanvasId(client, explicit) {
|
|
76
77
|
if (explicit)
|
|
77
78
|
return explicit;
|
|
78
79
|
const state = await linkedState(client);
|
|
@@ -80,7 +81,7 @@ async function resolveCanvasId(client, explicit) {
|
|
|
80
81
|
return String(state.canvasId);
|
|
81
82
|
throw new Error('No canvas. Pass --canvas <id>, or open the canvas in the browser and press the link button in its top bar.');
|
|
82
83
|
}
|
|
83
|
-
async function loadCanvas(client, canvasId) {
|
|
84
|
+
export async function loadCanvas(client, canvasId) {
|
|
84
85
|
// getFresh: read-merge-write must not see the 10s server cache
|
|
85
86
|
const folder = await client.getFresh(`/fsnode/list/${canvasId}`);
|
|
86
87
|
if (folder?.mimetype !== CANVAS_MIMETYPE) {
|
|
@@ -122,13 +123,81 @@ async function loadCanvas(client, canvasId) {
|
|
|
122
123
|
id: folder.id,
|
|
123
124
|
name: folder.name,
|
|
124
125
|
workspaceId: idOf(folder.workspace) || idOf(folder.root) || '',
|
|
126
|
+
folder,
|
|
127
|
+
access: canvasAccessOf(folder.content),
|
|
125
128
|
nodes,
|
|
126
129
|
edges,
|
|
127
130
|
byId: new Map(nodes.map((n) => [n.id, n])),
|
|
128
131
|
assets,
|
|
129
|
-
tagNames: parseTagNames(folder.content
|
|
132
|
+
tagNames: parseTagNames(canvasEnvelope(folder.content).data.colorTagNames),
|
|
130
133
|
};
|
|
131
134
|
}
|
|
135
|
+
// ── canvas folder: content envelope and ownership ──────────────────────────
|
|
136
|
+
/**
|
|
137
|
+
* A canvas folder stores `content {data, metadata}`: `data` is the board
|
|
138
|
+
* (viewport, colorTagNames, generateDefaults…), `metadata` its access
|
|
139
|
+
* `{ownerUserId, visibility}`. Legacy canvases hold `data` flat in `content`
|
|
140
|
+
* (the backend then reads access from the top level too).
|
|
141
|
+
*/
|
|
142
|
+
export function canvasEnvelope(content) {
|
|
143
|
+
const c = content && typeof content === 'object' ? content : {};
|
|
144
|
+
const hasEnvelope = c.data && typeof c.data === 'object' && !Array.isArray(c.data);
|
|
145
|
+
if (hasEnvelope) {
|
|
146
|
+
return { data: c.data, metadata: c.metadata && typeof c.metadata === 'object' ? c.metadata : {}, legacy: false };
|
|
147
|
+
}
|
|
148
|
+
const { metadata, ownerUserId, visibility, ...data } = c;
|
|
149
|
+
const meta = metadata && typeof metadata === 'object' ? metadata : { ...(ownerUserId ? { ownerUserId } : {}), ...(visibility ? { visibility } : {}) };
|
|
150
|
+
return { data, metadata: meta, legacy: true };
|
|
151
|
+
}
|
|
152
|
+
/** Same reading as the backend's readCanvasFolderAccess: only exactly 'owner' hides a canvas. */
|
|
153
|
+
export function canvasAccessOf(content) {
|
|
154
|
+
const meta = canvasEnvelope(content).metadata;
|
|
155
|
+
return {
|
|
156
|
+
ownerUserId: typeof meta.ownerUserId === 'string' ? meta.ownerUserId : '',
|
|
157
|
+
visibility: meta.visibility === 'owner' ? 'owner' : 'members',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Write a canvas folder's `data` and/or `metadata`, always as the whole
|
|
162
|
+
* envelope (dropping `metadata` would reset the owner). Versioned with
|
|
163
|
+
* `baseUpdatedAt` and retried on a conflict so a browser saving the board at
|
|
164
|
+
* the same moment is not overwritten.
|
|
165
|
+
*/
|
|
166
|
+
export async function updateCanvasFolder(client, canvasId, change) {
|
|
167
|
+
for (let attempt = 0;; attempt++) {
|
|
168
|
+
const folder = await client.getFresh(`/fsnode/list/${canvasId}`);
|
|
169
|
+
if (folder?.mimetype !== CANVAS_MIMETYPE)
|
|
170
|
+
throw new Error(`${canvasId} is not a canvas`);
|
|
171
|
+
const env = canvasEnvelope(folder.content);
|
|
172
|
+
const next = { data: { ...env.data }, metadata: { ...env.metadata } };
|
|
173
|
+
change(next);
|
|
174
|
+
try {
|
|
175
|
+
return await client.patch(`/fsnode/list/${canvasId}`, {
|
|
176
|
+
content: next,
|
|
177
|
+
...(folder.updatedAt ? { baseUpdatedAt: folder.updatedAt } : {}),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (err?.status === 409 && attempt < 3)
|
|
182
|
+
continue;
|
|
183
|
+
if (err?.status === 403) {
|
|
184
|
+
throw new Error(`${err.message} — only the workspace owner can change who owns a canvas`);
|
|
185
|
+
}
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Every canvas in the workspace you can see (owner-only canvases of others are hidden unless you own the workspace). */
|
|
191
|
+
export async function listCanvases(client, wsId) {
|
|
192
|
+
const rows = [];
|
|
193
|
+
for (const { appId, folderId } of await canvasesFolders(client, wsId)) {
|
|
194
|
+
const canvases = toList(await client.getFresh(`/fsnode/list/${folderId}/children`, { limit: -1 }));
|
|
195
|
+
for (const it of canvases.filter((n) => n.mimetype === CANVAS_MIMETYPE)) {
|
|
196
|
+
rows.push({ id: it.id, name: it.name, appId, ...canvasAccessOf(it.content), updatedAt: it.updatedAt, folder: it });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return rows;
|
|
200
|
+
}
|
|
132
201
|
// ── color tags ──────────────────────────────────────────────────────────────
|
|
133
202
|
function parseTagNames(raw) {
|
|
134
203
|
const out = {};
|
|
@@ -142,7 +211,7 @@ function parseTagNames(raw) {
|
|
|
142
211
|
return out;
|
|
143
212
|
}
|
|
144
213
|
/** The node's tag with the canvas label (never the node's own legacy `name`, as on the web). */
|
|
145
|
-
function tagOf(canvas, node) {
|
|
214
|
+
export function tagOf(canvas, node) {
|
|
146
215
|
const colorId = node.content.colorTag?.colorId;
|
|
147
216
|
const tag = COLOR_TAGS.find((t) => t.id === colorId);
|
|
148
217
|
if (!tag)
|
|
@@ -153,7 +222,7 @@ function tagOf(canvas, node) {
|
|
|
153
222
|
* Colors a `--tag` value means: a color id (`rose`), its plain name (`red`),
|
|
154
223
|
* or a label (`C01`, case-insensitive), so several colors can match one label.
|
|
155
224
|
*/
|
|
156
|
-
function resolveTagFilter(canvas, values) {
|
|
225
|
+
export function resolveTagFilter(canvas, values) {
|
|
157
226
|
const colors = new Set();
|
|
158
227
|
for (const value of values) {
|
|
159
228
|
const v = value.trim().toLowerCase();
|
|
@@ -168,7 +237,7 @@ function resolveTagFilter(canvas, values) {
|
|
|
168
237
|
return colors;
|
|
169
238
|
}
|
|
170
239
|
/** `C0[1-3]` is case-insensitive; `/C0[1-3]$/` keeps exactly the flags given. */
|
|
171
|
-
function parseRegex(value, flag) {
|
|
240
|
+
export function parseRegex(value, flag) {
|
|
172
241
|
const slashed = /^\/(.*)\/([a-z]*)$/s.exec(value);
|
|
173
242
|
try {
|
|
174
243
|
return slashed ? new RegExp(slashed[1], slashed[2].replace(/[gy]/g, '')) : new RegExp(value, 'i');
|
|
@@ -177,13 +246,13 @@ function parseRegex(value, flag) {
|
|
|
177
246
|
throw new Error(`${flag}: invalid regex ${value} — ${err.message}`);
|
|
178
247
|
}
|
|
179
248
|
}
|
|
180
|
-
function tagText(tag) {
|
|
249
|
+
export function tagText(tag) {
|
|
181
250
|
if (!tag)
|
|
182
251
|
return '';
|
|
183
252
|
return tag.label ? `${tag.alias} "${tag.label}"` : tag.alias;
|
|
184
253
|
}
|
|
185
254
|
/** The `canvases` folder of every InfiniteCanvas app in a workspace. */
|
|
186
|
-
async function canvasesFolders(client, wsId) {
|
|
255
|
+
export async function canvasesFolders(client, wsId) {
|
|
187
256
|
const ws = await client.get(`/workspace/list/${wsId}`);
|
|
188
257
|
const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
|
|
189
258
|
const out = [];
|
|
@@ -195,7 +264,7 @@ async function canvasesFolders(client, wsId) {
|
|
|
195
264
|
}
|
|
196
265
|
return out;
|
|
197
266
|
}
|
|
198
|
-
function requireNode(canvas, id) {
|
|
267
|
+
export function requireNode(canvas, id) {
|
|
199
268
|
const node = canvas.byId.get(id);
|
|
200
269
|
if (!node)
|
|
201
270
|
throw new Error(`Node ${id} is not on canvas "${canvas.name}" (${canvas.id})`);
|
|
@@ -220,7 +289,7 @@ function referencesOf(canvas, node) {
|
|
|
220
289
|
};
|
|
221
290
|
});
|
|
222
291
|
}
|
|
223
|
-
function describe(canvas, node) {
|
|
292
|
+
export function describe(canvas, node) {
|
|
224
293
|
const asset = node.file ? canvas.assets.get(node.file) : undefined;
|
|
225
294
|
const generate = node.content.generate;
|
|
226
295
|
const pending = asset && asset.status === PENDING && !asset.error;
|
|
@@ -341,7 +410,7 @@ function applyModelDefaults(model, kind, generate, opts) {
|
|
|
341
410
|
}
|
|
342
411
|
// ── writes ──────────────────────────────────────────────────────────────────
|
|
343
412
|
/** Short side 250, like the canvas; text-ish nodes use a typical card size. */
|
|
344
|
-
function estimateSize(canvas, node) {
|
|
413
|
+
export function estimateSize(canvas, node) {
|
|
345
414
|
const w = Number(node.content.width);
|
|
346
415
|
const h = Number(node.content.height);
|
|
347
416
|
if (w > 0 && h > 0)
|
|
@@ -411,6 +480,7 @@ async function createEdge(client, canvas, fromId, toId, opts = {}) {
|
|
|
411
480
|
toNodeId: to.id,
|
|
412
481
|
sourceHandle: 'right',
|
|
413
482
|
targetHandle: 'left',
|
|
483
|
+
...(opts.content || {}),
|
|
414
484
|
};
|
|
415
485
|
// First/last-frame video models take connected images as frame slots, in order.
|
|
416
486
|
if (opts.frameModel && to.kind === 'video' && from.kind === 'image') {
|
|
@@ -433,8 +503,37 @@ async function createEdge(client, canvas, fromId, toId, opts = {}) {
|
|
|
433
503
|
canvas.edges.push(edge);
|
|
434
504
|
return edge;
|
|
435
505
|
}
|
|
436
|
-
|
|
437
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Write only the top-level content keys that changed (`contentPatch`), so an
|
|
508
|
+
* edit here does not overwrite what someone else changed in another key at
|
|
509
|
+
* the same moment in the browser. A backend without `contentPatch` strips it
|
|
510
|
+
* silently; the response then lacks the change and we write the whole content.
|
|
511
|
+
*/
|
|
512
|
+
async function patchNode(client, node, change) {
|
|
513
|
+
const { content, ...rest } = change;
|
|
514
|
+
const contentPatch = {};
|
|
515
|
+
const contentUnset = [];
|
|
516
|
+
if (content) {
|
|
517
|
+
for (const key of new Set([...Object.keys(node.content), ...Object.keys(content)])) {
|
|
518
|
+
if (!(key in content))
|
|
519
|
+
contentUnset.push(key);
|
|
520
|
+
else if (JSON.stringify(content[key]) !== JSON.stringify(node.content[key]))
|
|
521
|
+
contentPatch[key] = content[key];
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const body = { ...rest };
|
|
525
|
+
if (Object.keys(contentPatch).length)
|
|
526
|
+
body.contentPatch = contentPatch;
|
|
527
|
+
if (contentUnset.length)
|
|
528
|
+
body.contentUnset = contentUnset;
|
|
529
|
+
if (!Object.keys(body).length)
|
|
530
|
+
return node.raw;
|
|
531
|
+
const res = await client.patch(`/fsnode/list/${node.id}`, body);
|
|
532
|
+
const landed = Object.entries(contentPatch).every(([k, v]) => JSON.stringify(res?.content?.[k]) === JSON.stringify(v)) &&
|
|
533
|
+
contentUnset.every((k) => !(k in (res?.content || {})));
|
|
534
|
+
if (content && !landed)
|
|
535
|
+
return client.patch(`/fsnode/list/${node.id}`, { ...rest, content });
|
|
536
|
+
return res;
|
|
438
537
|
}
|
|
439
538
|
// ── output ──────────────────────────────────────────────────────────────────
|
|
440
539
|
function summary(n) {
|
|
@@ -451,7 +550,7 @@ function summary(n) {
|
|
|
451
550
|
}
|
|
452
551
|
return n.url || n.assetId || '';
|
|
453
552
|
}
|
|
454
|
-
function nodeTable(rows) {
|
|
553
|
+
export function nodeTable(rows) {
|
|
455
554
|
table(rows, [
|
|
456
555
|
{ header: 'id', value: (n) => n.id },
|
|
457
556
|
{ header: 'kind', value: (n) => (n.mode === 'generate' ? `${n.kind}*` : n.kind) },
|
|
@@ -584,6 +683,43 @@ function withGenerateOptions(cmd) {
|
|
|
584
683
|
}
|
|
585
684
|
// ── generation ──────────────────────────────────────────────────────────────
|
|
586
685
|
const UNSUPPORTED_REF_KINDS = new Set(['table', 'youtube', 'dataset', 'bundle', 'sprite', 'layer', 'panorama']);
|
|
686
|
+
// Seedance 2.5 draft mode (frontend shared/func/seedanceDraft.ts): 2.5 + 480p is a draft, and a draft
|
|
687
|
+
// becomes the finished 1080p clip only through its BytePlus task id (draftTaskId), within 7 days.
|
|
688
|
+
const SEEDANCE_25_MODELS = new Set(['seedance-2.5', 'seedance-2.5-uncensored', 'seedance-2.5-ref', 'seedance-2.5-ref-uncensored']);
|
|
689
|
+
const SEEDANCE_DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
690
|
+
function isSeedance25(model) {
|
|
691
|
+
return !!model && SEEDANCE_25_MODELS.has(model);
|
|
692
|
+
}
|
|
693
|
+
/** The flag the web form sets before submitting (syncSeedanceDraftFlag) — every 2.5 + 480p generation is a draft. */
|
|
694
|
+
function syncSeedanceDraftFlag(model, values) {
|
|
695
|
+
if (isSeedance25(model) && String(values.resolution || '') === '480p')
|
|
696
|
+
values.draft = true;
|
|
697
|
+
else
|
|
698
|
+
delete values.draft;
|
|
699
|
+
return values;
|
|
700
|
+
}
|
|
701
|
+
function draftTaskIdOf(values) {
|
|
702
|
+
const raw = values?.draftTaskId;
|
|
703
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* What a finished clip can be upscaled from — like the web's canSeedanceDraftUpscale: flagged as a
|
|
707
|
+
* draft (config.values.draft), carrying its BytePlus task id (the draft id), and not past 7 days.
|
|
708
|
+
*/
|
|
709
|
+
function seedanceDraftSource(asset) {
|
|
710
|
+
const taskId = String(asset?.extra?.byteplus?.taskId || '').trim();
|
|
711
|
+
const model = String(asset?.config?.model || '');
|
|
712
|
+
const created = asset?.createdAt ? Date.parse(asset.createdAt) : Number.NaN;
|
|
713
|
+
const expiresAt = Number.isFinite(created) ? new Date(created + SEEDANCE_DRAFT_TTL_MS) : undefined;
|
|
714
|
+
return {
|
|
715
|
+
taskId,
|
|
716
|
+
model,
|
|
717
|
+
flagged: asset?.config?.values?.draft === true,
|
|
718
|
+
resolution: String(asset?.config?.values?.resolution || ''),
|
|
719
|
+
expiresAt,
|
|
720
|
+
expired: !expiresAt || Date.now() > expiresAt.getTime(),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
587
723
|
/**
|
|
588
724
|
* Build the same request the canvas generate button sends
|
|
589
725
|
* (shared/generate/buildSubmitPayload.ts): references in edge order, text
|
|
@@ -592,6 +728,32 @@ const UNSUPPORTED_REF_KINDS = new Set(['table', 'youtube', 'dataset', 'bundle',
|
|
|
592
728
|
function buildSubmit(canvas, node, model) {
|
|
593
729
|
const kind = node.kind;
|
|
594
730
|
const g = node.content.generate || {};
|
|
731
|
+
// Draft → 1080p upscale (buildCanvasVideoSubmitPayload): only the draft task id counts — no
|
|
732
|
+
// prompt, no references; the edge from the draft clip is there for people reading the canvas.
|
|
733
|
+
const draftTaskId = kind === 'video' ? draftTaskIdOf(g.values) : '';
|
|
734
|
+
if (draftTaskId) {
|
|
735
|
+
const duration = typeof g.duration === 'number' && Number.isFinite(g.duration) ? g.duration : undefined;
|
|
736
|
+
return {
|
|
737
|
+
originalPrompt: g.prompt || 'N/A',
|
|
738
|
+
prompt: g.prompt || 'N/A',
|
|
739
|
+
model: g.model,
|
|
740
|
+
aspectRatio: g.ratio,
|
|
741
|
+
n: 1,
|
|
742
|
+
workspace: canvas.workspaceId,
|
|
743
|
+
fsnode: node.id,
|
|
744
|
+
scope: 'canvas',
|
|
745
|
+
scopeId: canvas.id,
|
|
746
|
+
duration,
|
|
747
|
+
values: {
|
|
748
|
+
...(g.values || {}),
|
|
749
|
+
mode: 'video',
|
|
750
|
+
resolution: '1080p',
|
|
751
|
+
draft: false,
|
|
752
|
+
draftTaskId,
|
|
753
|
+
...(duration !== undefined ? { duration } : {}),
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
}
|
|
595
757
|
const userPrompt = String(g.prompt || '').trim();
|
|
596
758
|
const form = model.form || {};
|
|
597
759
|
const frames = usesFrameSlots(model);
|
|
@@ -681,6 +843,8 @@ function buildSubmit(canvas, node, model) {
|
|
|
681
843
|
...(kind !== 'audio' && g.resolution ? { resolution: g.resolution } : {}),
|
|
682
844
|
},
|
|
683
845
|
};
|
|
846
|
+
if (kind === 'video')
|
|
847
|
+
syncSeedanceDraftFlag(g.model, body.values);
|
|
684
848
|
if (imageRefs.length)
|
|
685
849
|
body.referenceImages = imageRefs;
|
|
686
850
|
if (kind === 'video') {
|
|
@@ -696,6 +860,141 @@ function buildSubmit(canvas, node, model) {
|
|
|
696
860
|
body.referenceAudios = audios;
|
|
697
861
|
return body;
|
|
698
862
|
}
|
|
863
|
+
// ── ownership, tags and copy helpers ───────────────────────────────────────
|
|
864
|
+
export function parseVisibility(value) {
|
|
865
|
+
const v = value.toLowerCase();
|
|
866
|
+
if (v === 'owner' || v === 'private')
|
|
867
|
+
return 'owner';
|
|
868
|
+
if (v === 'members' || v === 'member' || v === 'workspace')
|
|
869
|
+
return 'members';
|
|
870
|
+
throw new Error(`--visibility must be owner or members (got ${value})`);
|
|
871
|
+
}
|
|
872
|
+
/** A new canvas folder the way the web creates one, with its access in the metadata envelope. */
|
|
873
|
+
export async function createCanvasFolder(client, canvasesFolderId, name, access) {
|
|
874
|
+
const metadata = {};
|
|
875
|
+
if (access?.ownerUserId)
|
|
876
|
+
metadata.ownerUserId = access.ownerUserId;
|
|
877
|
+
if (access?.visibility)
|
|
878
|
+
metadata.visibility = access.visibility;
|
|
879
|
+
return client.post('/fsnode/create', {
|
|
880
|
+
name,
|
|
881
|
+
type: 'folder',
|
|
882
|
+
mimetype: CANVAS_MIMETYPE,
|
|
883
|
+
parent: canvasesFolderId,
|
|
884
|
+
content: { ...INITIAL_CANVAS_CONTENT, metadata },
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/** One color id from a color name, id or this canvas's label. */
|
|
888
|
+
export function colorOfTag(canvas, tag) {
|
|
889
|
+
const colors = [...resolveTagFilter(canvas, [tag])];
|
|
890
|
+
if (colors.length > 1)
|
|
891
|
+
throw new Error(`Label "${tag}" is on several colors (${colors.join(', ')}) — name the color`);
|
|
892
|
+
return colors[0];
|
|
893
|
+
}
|
|
894
|
+
/** The source canvas and the nodes to copy: ids given, and/or the linked selection. */
|
|
895
|
+
export async function copySource(c, ids, opts) {
|
|
896
|
+
const linked = opts.selected || !ids.length ? await linkedState(c) : null;
|
|
897
|
+
const useSelection = opts.selected || !ids.length;
|
|
898
|
+
if (useSelection && !linked?.selectedNodeIds?.length) {
|
|
899
|
+
throw new Error('Nothing to copy: pass node ids, or select nodes in the browser canvas and press link');
|
|
900
|
+
}
|
|
901
|
+
const sourceId = opts.canvas || (useSelection ? linked.canvasId : await resolveCanvasId(c));
|
|
902
|
+
if (useSelection && opts.canvas && opts.canvas !== linked.canvasId) {
|
|
903
|
+
throw new Error(`The selection is on canvas ${linked.canvasId}, not ${opts.canvas}`);
|
|
904
|
+
}
|
|
905
|
+
const source = await loadCanvas(c, sourceId);
|
|
906
|
+
const nodeIds = [...new Set([...ids, ...(useSelection ? linked.selectedNodeIds : [])])];
|
|
907
|
+
return { source, nodes: nodeIds.map((id) => requireNode(source, id)) };
|
|
908
|
+
}
|
|
909
|
+
export function copyPlacement(opts) {
|
|
910
|
+
return { at: opts.at ? parsePoint(opts.at, '--at') : undefined, samePosition: !!opts.samePosition };
|
|
911
|
+
}
|
|
912
|
+
/** The canvas's own copy rule (cloneContentForDuplicate): no position, no generation run, finished media shared. */
|
|
913
|
+
function copyableFile(canvas, node) {
|
|
914
|
+
const asset = node.file ? canvas.assets.get(node.file) : undefined;
|
|
915
|
+
return asset?.url && asset.status !== PENDING && !asset.error ? node.file : undefined;
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Copy nodes from one canvas into another, like the canvas's copy-paste:
|
|
919
|
+
* new nodes, connections among them re-created in reference order, color
|
|
920
|
+
* tags kept (and their label carried over when the target has none).
|
|
921
|
+
*/
|
|
922
|
+
export async function copyNodes(c, source, nodes, target, placement = {}) {
|
|
923
|
+
const pos = (n) => ({ x: Number(n.content.position?.x) || 0, y: Number(n.content.position?.y) || 0 });
|
|
924
|
+
const minX = Math.min(...nodes.map((n) => pos(n).x));
|
|
925
|
+
const minY = Math.min(...nodes.map((n) => pos(n).y));
|
|
926
|
+
let origin = placement.at;
|
|
927
|
+
if (!origin && !placement.samePosition && target.nodes.length) {
|
|
928
|
+
const right = Math.max(...target.nodes.map((n) => pos(n).x + estimateSize(target, n).width));
|
|
929
|
+
const top = Math.min(...target.nodes.map((n) => pos(n).y));
|
|
930
|
+
origin = { x: Math.round(right + PLACE_GAP * 2), y: Math.round(top) };
|
|
931
|
+
}
|
|
932
|
+
if (!origin)
|
|
933
|
+
origin = { x: minX, y: minY };
|
|
934
|
+
// Labels travel with the tag when the target has no label for that color yet.
|
|
935
|
+
const labels = {};
|
|
936
|
+
for (const n of nodes) {
|
|
937
|
+
const colorId = n.content.colorTag?.colorId;
|
|
938
|
+
const label = colorId && source.tagNames[colorId];
|
|
939
|
+
if (!label)
|
|
940
|
+
continue;
|
|
941
|
+
if (!target.tagNames[colorId])
|
|
942
|
+
labels[colorId] = label;
|
|
943
|
+
else if (target.tagNames[colorId] !== label) {
|
|
944
|
+
warn(`${target.name}: ${colorId} is labelled "${target.tagNames[colorId]}" there, not "${label}" — kept theirs`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (Object.keys(labels).length) {
|
|
948
|
+
await updateCanvasFolder(c, target.id, (env) => {
|
|
949
|
+
env.data.colorTagNames = { ...labels, ...(env.data.colorTagNames || {}) };
|
|
950
|
+
});
|
|
951
|
+
Object.assign(target.tagNames, labels);
|
|
952
|
+
}
|
|
953
|
+
const idMap = new Map();
|
|
954
|
+
const created = [];
|
|
955
|
+
for (const n of nodes) {
|
|
956
|
+
const content = JSON.parse(JSON.stringify(n.content || {}));
|
|
957
|
+
if (content.generate) {
|
|
958
|
+
delete content.generate.historyId;
|
|
959
|
+
delete content.generate.pendingAssetId;
|
|
960
|
+
}
|
|
961
|
+
const p = pos(n);
|
|
962
|
+
content.position = { x: Math.round(origin.x + p.x - minX), y: Math.round(origin.y + p.y - minY) };
|
|
963
|
+
const file = copyableFile(source, n);
|
|
964
|
+
const raw = await c.post('/fsnode/create', {
|
|
965
|
+
name: n.name || DEFAULT_NAMES[n.kind] || n.kind,
|
|
966
|
+
type: 'file',
|
|
967
|
+
mimetype: `${NODE_PREFIX}${n.kind}`,
|
|
968
|
+
parent: target.id,
|
|
969
|
+
sort: 0,
|
|
970
|
+
content,
|
|
971
|
+
...(file ? { file } : {}),
|
|
972
|
+
});
|
|
973
|
+
idMap.set(n.id, raw.id);
|
|
974
|
+
const node = { id: raw.id, kind: n.kind, name: raw.name, sort: 0, file, content, raw };
|
|
975
|
+
created.push(node);
|
|
976
|
+
target.nodes.push(node);
|
|
977
|
+
target.byId.set(node.id, node);
|
|
978
|
+
if (file && source.assets.has(file))
|
|
979
|
+
target.assets.set(file, source.assets.get(file));
|
|
980
|
+
}
|
|
981
|
+
// Oldest edge first, so the new ids keep the same @image1/@image2 numbering.
|
|
982
|
+
const inside = source.edges
|
|
983
|
+
.filter((e) => idMap.has(e.fromNodeId) && idMap.has(e.toNodeId))
|
|
984
|
+
.sort((a, b) => a.sort - b.sort || a.id.localeCompare(b.id));
|
|
985
|
+
const skippedEdges = source.edges.filter((e) => idMap.has(e.toNodeId) && !idMap.has(e.fromNodeId)).length;
|
|
986
|
+
for (const e of inside) {
|
|
987
|
+
const { fromNodeId, toNodeId, ...rest } = e.content;
|
|
988
|
+
await createEdge(c, target, idMap.get(e.fromNodeId), idMap.get(e.toNodeId), { content: rest });
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
canvasId: target.id,
|
|
992
|
+
canvasName: target.name,
|
|
993
|
+
nodes: created.map((n) => ({ id: n.id, from: [...idMap].find(([, v]) => v === n.id)?.[0], kind: n.kind, name: n.name })),
|
|
994
|
+
edges: inside.length,
|
|
995
|
+
skippedEdges,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
699
998
|
// ── commands ────────────────────────────────────────────────────────────────
|
|
700
999
|
export function canvasCommand(globals) {
|
|
701
1000
|
const canvas = new Command('canvas').description('Read and edit Infinite Canvas nodes (defaults to the canvas linked from the browser)');
|
|
@@ -748,24 +1047,32 @@ export function canvasCommand(globals) {
|
|
|
748
1047
|
});
|
|
749
1048
|
canvas
|
|
750
1049
|
.command('list')
|
|
751
|
-
.description('Canvases in a workspace')
|
|
1050
|
+
.description('Canvases in a workspace, with owner and visibility')
|
|
752
1051
|
.option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
|
|
1052
|
+
.option('-o, --owner <member>', 'only canvases this member owns (name, email or user id; "none" = unowned)')
|
|
753
1053
|
.action(async (opts) => {
|
|
754
1054
|
const c = client();
|
|
755
1055
|
const linked = opts.workspace ? null : await linkedState(c);
|
|
756
1056
|
const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1057
|
+
const [canvases, { members }] = await Promise.all([listCanvases(c, wsId), loadMembers(c, wsId)]);
|
|
1058
|
+
const byUser = new Map(members.map((m) => [m.userId, m]));
|
|
1059
|
+
let filtered = canvases;
|
|
1060
|
+
if (opts.owner !== undefined) {
|
|
1061
|
+
const ownerId = opts.owner === 'none' ? '' : resolveMember(members, opts.owner).userId;
|
|
1062
|
+
filtered = canvases.filter((r) => r.ownerUserId === ownerId);
|
|
763
1063
|
}
|
|
1064
|
+
const rows = filtered.map(({ folder, ...r }) => ({
|
|
1065
|
+
...r,
|
|
1066
|
+
ownerName: r.ownerUserId ? (byUser.get(r.ownerUserId) ? memberLabel(byUser.get(r.ownerUserId)) : '(not a member)') : '',
|
|
1067
|
+
linked: r.id === linked?.canvasId,
|
|
1068
|
+
}));
|
|
764
1069
|
render(rows, () => table(rows, [
|
|
765
1070
|
{ header: '', value: (r) => (r.linked ? color.green('●') : '') },
|
|
766
1071
|
{ header: 'id', value: (r) => r.id },
|
|
767
1072
|
{ header: 'name', value: (r) => r.name },
|
|
768
|
-
{ header: '
|
|
1073
|
+
{ header: 'owner', value: (r) => r.ownerName },
|
|
1074
|
+
{ header: 'visibility', value: (r) => (r.visibility === 'owner' ? 'owner only' : 'members') },
|
|
1075
|
+
{ header: 'updated', value: (r) => relativeTime(r.updatedAt) },
|
|
769
1076
|
]));
|
|
770
1077
|
});
|
|
771
1078
|
canvas
|
|
@@ -773,6 +1080,8 @@ export function canvasCommand(globals) {
|
|
|
773
1080
|
.description('Create an empty canvas in a workspace (its InfiniteCanvas app)')
|
|
774
1081
|
.option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
|
|
775
1082
|
.option('--app <appId>', 'InfiniteCanvas app when the workspace has several')
|
|
1083
|
+
.option('-o, --owner <member>', 'owner (name, email or user id); only the workspace owner can set it')
|
|
1084
|
+
.option('--visibility <v>', 'owner (only the owner and the workspace owner see it) | members', 'members')
|
|
776
1085
|
.action(async (name, opts) => {
|
|
777
1086
|
const c = client();
|
|
778
1087
|
const linked = opts.workspace ? null : await linkedState(c);
|
|
@@ -785,19 +1094,141 @@ export function canvasCommand(globals) {
|
|
|
785
1094
|
throw new Error(`No InfiniteCanvas app ${opts.app} in workspace ${wsId}`);
|
|
786
1095
|
if (!opts.app && folders.length > 1)
|
|
787
1096
|
warn(`Several InfiniteCanvas apps — using ${target.appId}; pass --app to choose`);
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
parent: target.folderId,
|
|
793
|
-
content: INITIAL_CANVAS_CONTENT,
|
|
794
|
-
});
|
|
795
|
-
const out = { id: raw.id, name: raw.name, app: target.appId, workspaceId: wsId };
|
|
1097
|
+
const visibility = parseVisibility(opts.visibility);
|
|
1098
|
+
const ownerUserId = opts.owner ? resolveMember((await loadMembers(c, wsId)).members, opts.owner).userId : '';
|
|
1099
|
+
const raw = await createCanvasFolder(c, target.folderId, name, { ownerUserId, visibility });
|
|
1100
|
+
const out = { id: raw.id, name: raw.name, app: target.appId, workspaceId: wsId, ownerUserId, visibility };
|
|
796
1101
|
render(out, () => {
|
|
797
1102
|
success(`Created canvas "${out.name}" ${color.dim(out.id)}`);
|
|
798
1103
|
info(color.dim(`Target it with --canvas ${out.id}, or open it in the browser and press link.`));
|
|
799
1104
|
});
|
|
800
1105
|
});
|
|
1106
|
+
canvas
|
|
1107
|
+
.command('owner <canvasId> [member]')
|
|
1108
|
+
.description('Show or set who owns a canvas and who can see it (setting needs the workspace owner)')
|
|
1109
|
+
.option('--visibility <v>', 'owner (only the owner and the workspace owner see it) | members (everyone in the workspace)')
|
|
1110
|
+
.option('--none', 'clear the owner')
|
|
1111
|
+
.action(async (canvasId, token, opts) => {
|
|
1112
|
+
const c = client();
|
|
1113
|
+
const folder = await c.getFresh(`/fsnode/list/${canvasId}`);
|
|
1114
|
+
if (folder?.mimetype !== CANVAS_MIMETYPE)
|
|
1115
|
+
throw new Error(`${canvasId} is not a canvas`);
|
|
1116
|
+
const wsId = idOf(folder.workspace) || idOf(folder.root) || '';
|
|
1117
|
+
const { members } = await loadMembers(c, wsId);
|
|
1118
|
+
if (token && opts.none)
|
|
1119
|
+
throw new Error('Pass a member or --none, not both');
|
|
1120
|
+
const show = (access) => {
|
|
1121
|
+
const owner = members.find((m) => m.userId === access.ownerUserId);
|
|
1122
|
+
const out = { id: canvasId, name: folder.name, ...access, ownerName: owner ? memberLabel(owner) : undefined };
|
|
1123
|
+
render(out, () => detail([
|
|
1124
|
+
['canvas', `${folder.name} ${color.dim(canvasId)}`],
|
|
1125
|
+
['owner', access.ownerUserId ? `${owner ? memberLabel(owner) : '(not a member)'} ${color.dim(access.ownerUserId)}` : '(none)'],
|
|
1126
|
+
['visibility', access.visibility === 'owner' ? 'owner only' : 'all members'],
|
|
1127
|
+
]));
|
|
1128
|
+
};
|
|
1129
|
+
if (!token && !opts.none && !opts.visibility)
|
|
1130
|
+
return show(canvasAccessOf(folder.content));
|
|
1131
|
+
const ownerUserId = opts.none ? '' : token ? resolveMember(members, token).userId : undefined;
|
|
1132
|
+
const visibility = opts.visibility ? parseVisibility(opts.visibility) : undefined;
|
|
1133
|
+
const res = await updateCanvasFolder(c, canvasId, (env) => {
|
|
1134
|
+
if (ownerUserId !== undefined)
|
|
1135
|
+
env.metadata.ownerUserId = ownerUserId;
|
|
1136
|
+
if (visibility)
|
|
1137
|
+
env.metadata.visibility = visibility;
|
|
1138
|
+
else if (!env.metadata.visibility)
|
|
1139
|
+
env.metadata.visibility = 'members';
|
|
1140
|
+
});
|
|
1141
|
+
if (!isJsonMode())
|
|
1142
|
+
success('Updated');
|
|
1143
|
+
show(canvasAccessOf(res?.content));
|
|
1144
|
+
});
|
|
1145
|
+
canvas
|
|
1146
|
+
.command('tag <tag> [nodeIds...]')
|
|
1147
|
+
.description('Color-tag nodes: a color (red, rose…) or an existing label; "none" removes the tag. ' +
|
|
1148
|
+
'--label names that color on this canvas (one label per color, shared by its nodes).')
|
|
1149
|
+
.option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
|
|
1150
|
+
.option('-l, --label <text>', 'set the label of this color on the canvas ("" clears it)')
|
|
1151
|
+
.option('--selected', 'tag the nodes selected in the linked browser canvas')
|
|
1152
|
+
.action(async (tag, ids, opts) => {
|
|
1153
|
+
const c = client();
|
|
1154
|
+
const linked = opts.selected ? await linkedState(c) : null;
|
|
1155
|
+
if (opts.selected && !linked)
|
|
1156
|
+
throw new Error('--selected needs a linked canvas (press link in the browser)');
|
|
1157
|
+
const cv = await loadCanvas(c, opts.canvas || linked?.canvasId || (await resolveCanvasId(c)));
|
|
1158
|
+
const nodeIds = [...ids, ...(opts.selected ? linked.selectedNodeIds || [] : [])];
|
|
1159
|
+
const nodes = nodeIds.map((id) => requireNode(cv, id));
|
|
1160
|
+
const clear = tag.toLowerCase() === 'none';
|
|
1161
|
+
if (clear && opts.label !== undefined)
|
|
1162
|
+
throw new Error('--label needs a color, not "none"');
|
|
1163
|
+
const colorId = clear ? undefined : colorOfTag(cv, tag);
|
|
1164
|
+
if (!nodes.length && opts.label === undefined)
|
|
1165
|
+
throw new Error('Pass node ids (or --selected), and/or --label');
|
|
1166
|
+
if (opts.label !== undefined) {
|
|
1167
|
+
const label = opts.label.trim();
|
|
1168
|
+
await updateCanvasFolder(c, cv.id, (env) => {
|
|
1169
|
+
const names = { ...(env.data.colorTagNames || {}) };
|
|
1170
|
+
if (label)
|
|
1171
|
+
names[colorId] = label;
|
|
1172
|
+
else
|
|
1173
|
+
delete names[colorId];
|
|
1174
|
+
env.data.colorTagNames = Object.keys(names).length ? names : null;
|
|
1175
|
+
});
|
|
1176
|
+
if (label)
|
|
1177
|
+
cv.tagNames[colorId] = label;
|
|
1178
|
+
else
|
|
1179
|
+
delete cv.tagNames[colorId];
|
|
1180
|
+
}
|
|
1181
|
+
for (const node of nodes) {
|
|
1182
|
+
const content = { ...node.content };
|
|
1183
|
+
if (clear)
|
|
1184
|
+
delete content.colorTag;
|
|
1185
|
+
else
|
|
1186
|
+
content.colorTag = { colorId };
|
|
1187
|
+
await patchNode(c, node, { content });
|
|
1188
|
+
node.content = content;
|
|
1189
|
+
}
|
|
1190
|
+
const out = { canvas: cv.id, tag: clear ? null : { color: colorId, label: cv.tagNames[colorId] }, nodes: nodes.map((n) => n.id) };
|
|
1191
|
+
render(out, () => {
|
|
1192
|
+
const what = clear ? 'untagged' : `tagged ${tagText(tagOf(cv, { ...nodes[0], content: { colorTag: { colorId } } }))}`;
|
|
1193
|
+
if (nodes.length)
|
|
1194
|
+
success(`${nodes.length} node(s) ${what}`);
|
|
1195
|
+
if (opts.label !== undefined)
|
|
1196
|
+
success(`Label of ${colorId}: ${cv.tagNames[colorId] || '(none)'}`);
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
canvas
|
|
1200
|
+
.command('copy [nodeIds...]')
|
|
1201
|
+
.description('Copy nodes (with the connections between them) into other canvases. Finished media is shared, not re-generated; ' +
|
|
1202
|
+
'generate nodes keep their prompt and settings.')
|
|
1203
|
+
.option('-c, --canvas <id>', 'source canvas (defaults to the linked canvas)')
|
|
1204
|
+
.option('--selected', 'copy the nodes selected in the linked browser canvas')
|
|
1205
|
+
.requiredOption('--to <canvasIds...>', 'target canvases')
|
|
1206
|
+
.option('--at <x,y>', 'top-left of the copied group; default is right of what is already there')
|
|
1207
|
+
.option('--same-position', 'keep the original coordinates')
|
|
1208
|
+
.option('-y, --yes', 'skip confirmation')
|
|
1209
|
+
.action(async (ids, opts) => {
|
|
1210
|
+
const c = client();
|
|
1211
|
+
const { source, nodes } = await copySource(c, ids, opts);
|
|
1212
|
+
const targets = opts.to.filter((id) => id !== source.id);
|
|
1213
|
+
if (!targets.length)
|
|
1214
|
+
throw new Error('--to must name at least one canvas other than the source');
|
|
1215
|
+
const names = await Promise.all(targets.map(async (id) => (await c.getFresh(`/fsnode/list/${id}`))?.name ?? id));
|
|
1216
|
+
const question = `Copy ${nodes.length} node(s) from "${source.name}" into ${targets.length} canvas(es): ${names.join(', ')}?`;
|
|
1217
|
+
if (!(await confirm(question, opts.yes)))
|
|
1218
|
+
return info('Aborted');
|
|
1219
|
+
const results = [];
|
|
1220
|
+
for (const id of targets) {
|
|
1221
|
+
const target = await loadCanvas(c, id);
|
|
1222
|
+
results.push(await copyNodes(c, source, nodes, target, copyPlacement(opts)));
|
|
1223
|
+
}
|
|
1224
|
+
render(results, () => {
|
|
1225
|
+
for (const r of results)
|
|
1226
|
+
success(`${r.canvasName}: ${r.nodes.length} node(s), ${r.edges} connection(s)`);
|
|
1227
|
+
const skipped = results[0]?.skippedEdges ?? 0;
|
|
1228
|
+
if (skipped)
|
|
1229
|
+
info(color.dim(`${skipped} connection(s) from nodes outside the copy were left out`));
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
801
1232
|
canvas
|
|
802
1233
|
.command('nodes')
|
|
803
1234
|
.description('Nodes with references and a content summary (* = generate node)')
|
|
@@ -1102,58 +1533,127 @@ export function canvasCommand(globals) {
|
|
|
1102
1533
|
.action(async (id, opts) => {
|
|
1103
1534
|
const c = client();
|
|
1104
1535
|
const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
const
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1536
|
+
await submitGenerate(c, cv, requireNode(cv, id), opts);
|
|
1537
|
+
});
|
|
1538
|
+
canvas
|
|
1539
|
+
.command('upscale <nodeId>')
|
|
1540
|
+
.description('Upscale a Seedance 2.5 draft clip (480p) to the finished 1080p video — adds an upscale node beside it and generates it (costs credits)')
|
|
1541
|
+
.option('-c, --canvas <id>', 'canvas id (defaults to the linked canvas)')
|
|
1542
|
+
.option('-n, --name <name>', 'name of the upscale node (default: "<source name>-1080p")')
|
|
1543
|
+
.option('--at <x,y>', 'position of the upscale node; default is right of the draft clip')
|
|
1544
|
+
.option('--no-gen', 'only create the upscale node; generate it later with `wji canvas gen`')
|
|
1545
|
+
.option('-y, --yes', 'skip the cost confirmation')
|
|
1546
|
+
.option('-w, --wait', 'wait for the result')
|
|
1547
|
+
.option('--timeout <sec>', 'with --wait, give up after this many seconds', '1200')
|
|
1548
|
+
.option('--interval <sec>', 'with --wait, poll every N seconds', '5')
|
|
1549
|
+
.action(async (id, opts) => {
|
|
1550
|
+
const c = client();
|
|
1551
|
+
const cv = await loadCanvas(c, await resolveCanvasId(c, opts.canvas));
|
|
1552
|
+
const source = requireNode(cv, id);
|
|
1553
|
+
const asset = source.file ? cv.assets.get(source.file) : undefined;
|
|
1554
|
+
if (source.kind !== 'video' || !asset?.url)
|
|
1555
|
+
throw new Error(`${id} is not a video node with a finished clip`);
|
|
1556
|
+
const draft = seedanceDraftSource(asset);
|
|
1557
|
+
if (!isSeedance25(draft.model))
|
|
1558
|
+
throw new Error(`${id} was generated with ${draft.model || 'an unknown model'} — only Seedance 2.5 drafts can be upscaled`);
|
|
1559
|
+
if (!draft.taskId)
|
|
1560
|
+
throw new Error(`${id} has no draft task id (asset ${asset.id} carries no BytePlus task) — it cannot be upscaled`);
|
|
1561
|
+
if (draft.expired)
|
|
1562
|
+
throw new Error(`The draft task of ${id} expired ${draft.expiresAt ? `on ${draft.expiresAt.toISOString().slice(0, 10)}` : ''} (drafts last 7 days) — regenerate the clip instead`);
|
|
1563
|
+
if (!draft.flagged)
|
|
1564
|
+
throw new Error(`${id} is not a Seedance draft (asset ${asset.id} has no config.values.draft) — only draft clips can be upscaled`);
|
|
1565
|
+
const model = await findModel(c, 'video', draft.model, cv.workspaceId);
|
|
1566
|
+
const configDuration = Number(asset.config?.duration);
|
|
1567
|
+
const duration = configDuration > 0 ? configDuration : Number(asset.duration) > 0 ? Math.round(Number(asset.duration)) : undefined;
|
|
1568
|
+
const ratio = String(asset.config?.aspectRatio || '').includes(':') ? asset.config.aspectRatio : asset.aspectRatio;
|
|
1569
|
+
const content = {
|
|
1570
|
+
mode: 'generate',
|
|
1571
|
+
generate: {
|
|
1572
|
+
prompt: 'N/A',
|
|
1573
|
+
model: model.nickname || model.name,
|
|
1574
|
+
resolution: '1080p',
|
|
1575
|
+
ratio,
|
|
1576
|
+
duration,
|
|
1577
|
+
values: { mode: 'video', resolution: '1080p', draft: false, draftTaskId: draft.taskId },
|
|
1578
|
+
},
|
|
1579
|
+
};
|
|
1580
|
+
const body = {
|
|
1581
|
+
name: opts.name || `${source.name || 'video'}-1080p`,
|
|
1582
|
+
type: 'file',
|
|
1583
|
+
mimetype: `${NODE_PREFIX}video`,
|
|
1584
|
+
parent: cv.id,
|
|
1585
|
+
sort: 0,
|
|
1586
|
+
};
|
|
1587
|
+
if (source.content.width)
|
|
1588
|
+
Object.assign(content, { width: source.content.width, height: source.content.height });
|
|
1589
|
+
content.position = opts.at
|
|
1590
|
+
? parsePoint(opts.at, '--at')
|
|
1591
|
+
: placeNear(cv, [source], estimateSize(cv, { id: '', kind: 'video', name: '', sort: 0, file: undefined, content, raw: null }));
|
|
1592
|
+
const raw = await c.post('/fsnode/create', { ...body, content });
|
|
1593
|
+
const node = { id: raw.id, kind: 'video', name: raw.name, sort: 0, file: idOf(raw.file), content, raw };
|
|
1594
|
+
cv.nodes.push(node);
|
|
1595
|
+
cv.byId.set(node.id, node);
|
|
1596
|
+
// The web draws this edge so the panel shows the draft; the submit itself reads only draftTaskId.
|
|
1597
|
+
await createEdge(c, cv, source.id, node.id, { content: { referenceRole: 'video', referenceLabel: 'video1' } });
|
|
1598
|
+
success(`Created upscale node ${node.id} from ${id} (draft task ${draft.taskId})`);
|
|
1599
|
+
if (!opts.gen)
|
|
1600
|
+
return info(color.dim(`Generate it with: wji canvas gen ${node.id} -c ${cv.id}`));
|
|
1601
|
+
await submitGenerate(c, cv, node, opts);
|
|
1157
1602
|
});
|
|
1158
1603
|
return canvas;
|
|
1159
1604
|
}
|
|
1605
|
+
/** Price, confirm and submit one generate node — what the canvas Generate button does. */
|
|
1606
|
+
async function submitGenerate(c, cv, node, opts) {
|
|
1607
|
+
const id = node.id;
|
|
1608
|
+
if (!MEDIA_KINDS.has(node.kind) || node.content.mode !== 'generate') {
|
|
1609
|
+
throw new Error(`${id} is not a generate node. Create one with \`wji canvas add ${MEDIA_KINDS.has(node.kind) ? node.kind : 'image'} -m … -p …\`.`);
|
|
1610
|
+
}
|
|
1611
|
+
if (describe(cv, node).status === 'generating')
|
|
1612
|
+
throw new Error(`${id} is already generating`);
|
|
1613
|
+
const g = node.content.generate || {};
|
|
1614
|
+
if (!g.model)
|
|
1615
|
+
throw new Error(`${id} has no model — set one with \`wji canvas set ${id} -m …\``);
|
|
1616
|
+
const model = await findModel(c, node.kind, g.model, cv.workspaceId);
|
|
1617
|
+
const body = buildSubmit(cv, node, model);
|
|
1618
|
+
const cost = await c.post('/ai/assets/preComputeCost', {
|
|
1619
|
+
type: node.kind,
|
|
1620
|
+
model: body.model,
|
|
1621
|
+
n: body.n,
|
|
1622
|
+
prompt: body.prompt,
|
|
1623
|
+
// Auto-duration submits -1; price the node's configured length instead.
|
|
1624
|
+
duration: body.duration && body.duration > 0 ? body.duration : g.duration,
|
|
1625
|
+
aspectRatio: body.aspectRatio,
|
|
1626
|
+
referenceImageCount: body.referenceImages?.length,
|
|
1627
|
+
values: body.values,
|
|
1628
|
+
});
|
|
1629
|
+
const credits = typeof cost === 'object' ? cost?.cost ?? cost?.credits ?? cost?.total : cost;
|
|
1630
|
+
const refs = referencesOf(cv, node).map((r) => r.label).join(', ') || 'none';
|
|
1631
|
+
const auto = body.duration === -1 ? ` (auto duration — estimated for ${g.duration ?? '?'}s, final cost follows the real length)` : '';
|
|
1632
|
+
const question = `Generate ${node.kind} "${node.name}" with ${body.model} (refs: ${refs}) for ${credits ?? '?'} credits${auto}?`;
|
|
1633
|
+
if (!(await confirm(question, opts.yes)))
|
|
1634
|
+
return info('Aborted');
|
|
1635
|
+
const history = await c.post(`/ai/assets/history/${node.kind}/generations`, body);
|
|
1636
|
+
const assetId = idOf((history?.images || [])[0]);
|
|
1637
|
+
const nextGenerate = { ...g, historyId: String(history.id), pendingAssetId: assetId };
|
|
1638
|
+
await patchNode(c, node, {
|
|
1639
|
+
...(assetId ? { file: assetId } : {}),
|
|
1640
|
+
content: { ...node.content, generate: nextGenerate },
|
|
1641
|
+
});
|
|
1642
|
+
if (!opts.wait) {
|
|
1643
|
+
if (isJsonMode())
|
|
1644
|
+
return printJson(history);
|
|
1645
|
+
success(`Submitted ${node.kind} generation for node ${id}`);
|
|
1646
|
+
reportHistory(history, await resolveAssets(c, history));
|
|
1647
|
+
info(color.dim(`The canvas shows progress. Poll with: wji canvas get ${id} or wji history get ${history.id} --wait`));
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
info(color.dim(`Submitted ${history.id}, waiting…`));
|
|
1651
|
+
const done = await pollHistory(c, history.id, {
|
|
1652
|
+
timeoutMs: Number(opts.timeout) * 1000,
|
|
1653
|
+
intervalMs: Number(opts.interval) * 1000,
|
|
1654
|
+
});
|
|
1655
|
+
if (isJsonMode())
|
|
1656
|
+
return printJson({ ...done.history, images: done.assets });
|
|
1657
|
+
success(`Node ${id} generated`);
|
|
1658
|
+
reportHistory(done.history, done.assets);
|
|
1659
|
+
}
|