@wji.ai/cli 0.1.1 → 0.2.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 +10 -1
- package/dist/commands/canvas.js +91 -12
- package/dist/commands/fsnode.js +30 -1
- package/dist/commands/workspace.js +6 -2
- package/package.json +1 -1
- package/skill/short-drama/SKILL.md +305 -0
- package/skill/short-drama/examples/night-shift/episodes/E01.json +1883 -0
- package/skill/short-drama/examples/night-shift/project.json +774 -0
- package/skill/short-drama/reference/canvas-recipes.md +199 -0
- package/skill/short-drama/reference/craft-motion.md +116 -0
- package/skill/short-drama/reference/craft-shots.md +177 -0
- package/skill/short-drama/reference/craft-story.md +188 -0
- package/skill/short-drama/reference/dialogue-craft.md +120 -0
- package/skill/short-drama/reference/schema.md +435 -0
- package/skill/short-drama/reference/stage-art.md +56 -0
- package/skill/short-drama/reference/stage-characters.md +206 -0
- package/skill/short-drama/reference/stage-outline.md +103 -0
- package/skill/short-drama/reference/stage-script.md +113 -0
- package/skill/short-drama/reference/stage-storyboard.md +465 -0
- package/skill/short-drama/reference/workspace-layout.md +118 -0
- package/skill/short-drama/scripts/selftest.mjs +1411 -0
- package/skill/short-drama/scripts/short-drama.mjs +3371 -0
- package/skill/wji/SKILL.md +33 -15
- package/skill/wji/reference/commands.md +4 -3
package/README.md
CHANGED
|
@@ -165,7 +165,9 @@ wji canvas gen <node> --yes --wait # spends credits
|
|
|
165
165
|
```
|
|
166
166
|
|
|
167
167
|
Changes appear live in the open canvas. Pass `--canvas <id>` to work on a
|
|
168
|
-
canvas that is not linked (`wji canvas list`)
|
|
168
|
+
canvas that is not linked (`wji canvas list`); `wji canvas create <name>` adds
|
|
169
|
+
an empty canvas, `wji canvas add image --asset <id>` shows an existing asset
|
|
170
|
+
and `wji canvas add dataset --dataset <id>` places a dataset card.
|
|
169
171
|
|
|
170
172
|
## Skills
|
|
171
173
|
|
|
@@ -240,3 +242,10 @@ cp -r "$(npm root -g)/@wji.ai/cli/skill/wji" ~/.claude/skills/wji
|
|
|
240
242
|
|
|
241
243
|
From a source checkout: `cp -r skill/wji ~/.claude/skills/wji`. Re-copy after
|
|
242
244
|
upgrading the CLI so the skill matches the commands.
|
|
245
|
+
|
|
246
|
+
`skill/short-drama/` is a second skill built on top of it: an AI short-drama
|
|
247
|
+
pipeline (outline → characters → scenes/props → script → storyboard) whose
|
|
248
|
+
stages are checked by `skill/short-drama/scripts/short-drama.mjs validate`,
|
|
249
|
+
with the data stored as workspace datasets and the work laid out on canvases.
|
|
250
|
+
Install it the same way (`cp -r skill/short-drama ~/.claude/skills/short-drama`)
|
|
251
|
+
next to `wji`; `node skill/short-drama/scripts/selftest.mjs` runs its self-test.
|
package/dist/commands/canvas.js
CHANGED
|
@@ -23,7 +23,8 @@ const NODE_PREFIX = 'canvas/node/';
|
|
|
23
23
|
const EDGE_MIMETYPE = 'canvas/edge';
|
|
24
24
|
const CANVAS_MIMETYPE = 'workspace-v2/canvas';
|
|
25
25
|
const CANVASES_MIMETYPE = 'workspace-v2/canvases';
|
|
26
|
-
const
|
|
26
|
+
const DATASET_MIMETYPE = 'workspace/dataset';
|
|
27
|
+
const CREATABLE_KINDS = ['text', 'title', 'comment', 'image', 'video', 'audio', 'dataset'];
|
|
27
28
|
const MEDIA_KINDS = new Set(['image', 'video', 'audio']);
|
|
28
29
|
const TEXT_KINDS = new Set(['text', 'title', 'comment']);
|
|
29
30
|
/** Default names the canvas UI shows localized. */
|
|
@@ -34,6 +35,12 @@ const DEFAULT_NAMES = {
|
|
|
34
35
|
image: '图片',
|
|
35
36
|
video: '视频',
|
|
36
37
|
audio: '音频',
|
|
38
|
+
dataset: '数据集',
|
|
39
|
+
};
|
|
40
|
+
/** What the web writes into a brand-new canvas folder (CanvasAppFileSystem.createCanvas). */
|
|
41
|
+
const INITIAL_CANVAS_CONTENT = {
|
|
42
|
+
data: { viewport: { x: 0, y: 0, zoom: 1 }, backgroundMode: 'dots' },
|
|
43
|
+
metadata: {},
|
|
37
44
|
};
|
|
38
45
|
/** The canvas lays media out on a 250px short side; text nodes are wider. */
|
|
39
46
|
const MEDIA_BASE = 250;
|
|
@@ -105,6 +112,19 @@ async function loadCanvas(client, canvasId) {
|
|
|
105
112
|
assets,
|
|
106
113
|
};
|
|
107
114
|
}
|
|
115
|
+
/** The `canvases` folder of every InfiniteCanvas app in a workspace. */
|
|
116
|
+
async function canvasesFolders(client, wsId) {
|
|
117
|
+
const ws = await client.get(`/workspace/list/${wsId}`);
|
|
118
|
+
const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const app of apps) {
|
|
121
|
+
const tree = await client.get(`/workspace/list/${wsId}/fs/${app.id}`);
|
|
122
|
+
const folder = (tree?.children || []).find((n) => n.mimetype === CANVASES_MIMETYPE);
|
|
123
|
+
if (folder)
|
|
124
|
+
out.push({ appId: String(app.id), folderId: String(folder.id) });
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
108
128
|
function requireNode(canvas, id) {
|
|
109
129
|
const node = canvas.byId.get(id);
|
|
110
130
|
if (!node)
|
|
@@ -144,6 +164,9 @@ function describe(canvas, node) {
|
|
|
144
164
|
assetId: node.file,
|
|
145
165
|
url: asset?.url || undefined,
|
|
146
166
|
assetType: asset?.type,
|
|
167
|
+
dataset: node.kind === 'dataset' && node.content.datasetId
|
|
168
|
+
? { id: String(node.content.datasetId), name: node.content.datasetName, type: node.content.datasetType, folder: node.content.datasetParentName }
|
|
169
|
+
: undefined,
|
|
147
170
|
status: generate?.historyId ? (pending ? 'generating' : asset?.error ? 'failed' : asset?.url ? 'done' : 'pending') : undefined,
|
|
148
171
|
error: asset?.error?.message || asset?.error || undefined,
|
|
149
172
|
generate,
|
|
@@ -152,12 +175,16 @@ function describe(canvas, node) {
|
|
|
152
175
|
};
|
|
153
176
|
}
|
|
154
177
|
// ── models ──────────────────────────────────────────────────────────────────
|
|
178
|
+
/** An audio node is fed by either an `audio` or a `tts` model — the backend tags voice models `tts`. */
|
|
179
|
+
const KIND_FEATURES = { audio: ['audio', 'tts'] };
|
|
155
180
|
async function findModel(client, kind, name, workspaceId) {
|
|
156
|
-
const
|
|
181
|
+
const features = KIND_FEATURES[kind] ?? [kind];
|
|
182
|
+
const rows = (await Promise.all(features.map((f) => fetchModels(client, { features: [f], workspace: workspaceId })))).flat();
|
|
157
183
|
const usable = rows.filter((m) => m.enabled !== false);
|
|
158
184
|
const model = usable.find((m) => m.nickname === name) ?? usable.find((m) => m.name === name);
|
|
159
185
|
if (!model) {
|
|
160
|
-
|
|
186
|
+
const hint = features.map((f) => `--feature ${f}`).join(' / ');
|
|
187
|
+
throw new Error(`No ${kind} model "${name}". See \`wji models list ${hint}\`.`);
|
|
161
188
|
}
|
|
162
189
|
return model;
|
|
163
190
|
}
|
|
@@ -243,6 +270,8 @@ function applyModelDefaults(model, kind, generate, opts) {
|
|
|
243
270
|
// ── writes ──────────────────────────────────────────────────────────────────
|
|
244
271
|
/** Short side 250, like the canvas; text-ish nodes use a typical card size. */
|
|
245
272
|
function estimateSize(canvas, node) {
|
|
273
|
+
if (node.kind === 'dataset')
|
|
274
|
+
return { width: 480, height: 220 };
|
|
246
275
|
if (!MEDIA_KINDS.has(node.kind))
|
|
247
276
|
return { width: 320, height: 200 };
|
|
248
277
|
const ratioText = node.content.mode === 'generate' ? node.content.generate?.ratio : undefined;
|
|
@@ -335,6 +364,8 @@ async function patchNode(client, node, body) {
|
|
|
335
364
|
function summary(n) {
|
|
336
365
|
if (n.text !== undefined)
|
|
337
366
|
return String(n.text).replace(/\s+/g, ' ');
|
|
367
|
+
if (n.dataset)
|
|
368
|
+
return [n.dataset.type, n.dataset.name, color.dim(n.dataset.id)].filter(Boolean).join(' ');
|
|
338
369
|
if (n.mode === 'generate') {
|
|
339
370
|
const g = n.generate || {};
|
|
340
371
|
const state = n.status === 'done' ? color.green('done') : n.status === 'failed' ? color.red('failed') : n.status ? color.yellow(n.status) : '';
|
|
@@ -361,6 +392,7 @@ function printNode(n) {
|
|
|
361
392
|
['name', n.name],
|
|
362
393
|
['position', n.position && `${Math.round(n.position.x)},${Math.round(n.position.y)}`],
|
|
363
394
|
['text', n.text],
|
|
395
|
+
['dataset', n.dataset && `${n.dataset.type || 'dataset'} "${n.dataset.name || ''}" ${color.dim(n.dataset.id)}${n.dataset.folder ? ` in ${n.dataset.folder}` : ''}`],
|
|
364
396
|
['asset', n.assetId],
|
|
365
397
|
['url', n.url],
|
|
366
398
|
['status', n.status],
|
|
@@ -603,17 +635,11 @@ export function canvasCommand(globals) {
|
|
|
603
635
|
const c = client();
|
|
604
636
|
const linked = opts.workspace ? null : await linkedState(c);
|
|
605
637
|
const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
|
|
606
|
-
const ws = await c.get(`/workspace/list/${wsId}`);
|
|
607
|
-
const apps = (ws.apps || []).filter((a) => a.app === 'InfiniteCanvas' || a.type === 'INFINITE_CANVAS');
|
|
608
638
|
const rows = [];
|
|
609
|
-
for (const
|
|
610
|
-
const
|
|
611
|
-
const folder = (tree?.children || []).find((n) => n.mimetype === CANVASES_MIMETYPE);
|
|
612
|
-
if (!folder)
|
|
613
|
-
continue;
|
|
614
|
-
const canvases = toList(await c.getFresh(`/fsnode/list/${folder.id}/children`, { limit: -1 }));
|
|
639
|
+
for (const { appId, folderId } of await canvasesFolders(c, wsId)) {
|
|
640
|
+
const canvases = toList(await c.getFresh(`/fsnode/list/${folderId}/children`, { limit: -1 }));
|
|
615
641
|
for (const it of canvases.filter((n) => n.mimetype === CANVAS_MIMETYPE)) {
|
|
616
|
-
rows.push({ id: it.id, name: it.name, app:
|
|
642
|
+
rows.push({ id: it.id, name: it.name, app: appId, linked: it.id === linked?.canvasId, updatedAt: it.updatedAt });
|
|
617
643
|
}
|
|
618
644
|
}
|
|
619
645
|
render(rows, () => table(rows, [
|
|
@@ -623,6 +649,36 @@ export function canvasCommand(globals) {
|
|
|
623
649
|
{ header: 'app', value: (r) => r.app },
|
|
624
650
|
]));
|
|
625
651
|
});
|
|
652
|
+
canvas
|
|
653
|
+
.command('create <name>')
|
|
654
|
+
.description('Create an empty canvas in a workspace (its InfiniteCanvas app)')
|
|
655
|
+
.option('-w, --workspace <id>', 'workspace (defaults to the linked one, then the profile default)')
|
|
656
|
+
.option('--app <appId>', 'InfiniteCanvas app when the workspace has several')
|
|
657
|
+
.action(async (name, opts) => {
|
|
658
|
+
const c = client();
|
|
659
|
+
const linked = opts.workspace ? null : await linkedState(c);
|
|
660
|
+
const wsId = opts.workspace || linked?.workspaceId || c.requireWorkspace();
|
|
661
|
+
const folders = await canvasesFolders(c, wsId);
|
|
662
|
+
if (!folders.length)
|
|
663
|
+
throw new Error(`Workspace ${wsId} has no InfiniteCanvas app. Add one with \`wji ws app add canvas\`.`);
|
|
664
|
+
const target = opts.app ? folders.find((f) => f.appId === opts.app) : folders[0];
|
|
665
|
+
if (!target)
|
|
666
|
+
throw new Error(`No InfiniteCanvas app ${opts.app} in workspace ${wsId}`);
|
|
667
|
+
if (!opts.app && folders.length > 1)
|
|
668
|
+
warn(`Several InfiniteCanvas apps — using ${target.appId}; pass --app to choose`);
|
|
669
|
+
const raw = await c.post('/fsnode/create', {
|
|
670
|
+
name,
|
|
671
|
+
type: 'folder',
|
|
672
|
+
mimetype: CANVAS_MIMETYPE,
|
|
673
|
+
parent: target.folderId,
|
|
674
|
+
content: INITIAL_CANVAS_CONTENT,
|
|
675
|
+
});
|
|
676
|
+
const out = { id: raw.id, name: raw.name, app: target.appId, workspaceId: wsId };
|
|
677
|
+
render(out, () => {
|
|
678
|
+
success(`Created canvas "${out.name}" ${color.dim(out.id)}`);
|
|
679
|
+
info(color.dim(`Target it with --canvas ${out.id}, or open it in the browser and press link.`));
|
|
680
|
+
});
|
|
681
|
+
});
|
|
626
682
|
canvas
|
|
627
683
|
.command('nodes')
|
|
628
684
|
.description('Nodes with references and a content summary (* = generate node)')
|
|
@@ -671,6 +727,7 @@ export function canvasCommand(globals) {
|
|
|
671
727
|
.option('-t, --text <text>', 'text for text/title/comment (- reads stdin)')
|
|
672
728
|
.option('--text-file <path>', 'read the text from a file')
|
|
673
729
|
.option('--asset <assetId>', 'show an existing asset (display node)')
|
|
730
|
+
.option('--dataset <fsnodeId>', 'for `add dataset`: the workspace dataset node to show on the canvas')
|
|
674
731
|
.option('--from <nodeIds...>', 'connect these nodes in as references, in order (@image1, @image2…)')
|
|
675
732
|
.option('--at <x,y>', 'position; default is right of the --from nodes')).action(async (kind, opts) => {
|
|
676
733
|
if (!CREATABLE_KINDS.includes(kind)) {
|
|
@@ -694,6 +751,28 @@ export function canvasCommand(globals) {
|
|
|
694
751
|
throw new Error(`${kind} nodes need --text`);
|
|
695
752
|
content.text = text;
|
|
696
753
|
}
|
|
754
|
+
else if (kind === 'dataset') {
|
|
755
|
+
// A dataset node is a card bound to a workspace dataset (CanvasAppFileSystem.createDatasetNodeFromDataset).
|
|
756
|
+
if (!opts.dataset)
|
|
757
|
+
throw new Error('dataset nodes need --dataset <fsnodeId>');
|
|
758
|
+
if (from.length)
|
|
759
|
+
throw new Error('dataset nodes take no references; connect them out to a generate node instead');
|
|
760
|
+
const ds = await c.getFresh(`/fsnode/list/${opts.dataset}`);
|
|
761
|
+
if (ds?.mimetype !== DATASET_MIMETYPE) {
|
|
762
|
+
throw new Error(`${opts.dataset} is not a dataset (mimetype ${ds?.mimetype || 'none'})`);
|
|
763
|
+
}
|
|
764
|
+
const parent = ds.parent ? await c.get(`/fsnode/list/${idOf(ds.parent)}`).catch(() => null) : null;
|
|
765
|
+
const dsName = ds.nickname || ds.name || DEFAULT_NAMES.dataset;
|
|
766
|
+
if (!opts.name)
|
|
767
|
+
body.name = dsName;
|
|
768
|
+
Object.assign(content, {
|
|
769
|
+
datasetId: String(ds.id),
|
|
770
|
+
datasetName: dsName,
|
|
771
|
+
datasetType: String(ds.content?.type || ''),
|
|
772
|
+
datasetParentName: parent ? parent.nickname || parent.name || '' : '',
|
|
773
|
+
description: String(ds.content?.description || ''),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
697
776
|
else {
|
|
698
777
|
const { patch, explicit } = await generateOptions(opts);
|
|
699
778
|
const wantsGenerate = Object.keys(patch).length > 0;
|
package/dist/commands/fsnode.js
CHANGED
|
@@ -3,6 +3,15 @@ import { WjiClient } from '../core/client.js';
|
|
|
3
3
|
import { confirm, parseKeyValues, readJson, readValue } from '../core/prompt.js';
|
|
4
4
|
import { color, detail, info, relativeTime, render, success, table, truncate } from '../core/output.js';
|
|
5
5
|
import { printTree } from './workspace.js';
|
|
6
|
+
/** Nested plain objects merge; arrays and scalars are replaced. */
|
|
7
|
+
function deepMerge(base, patch) {
|
|
8
|
+
const out = { ...base };
|
|
9
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
10
|
+
const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
11
|
+
out[key] = isObject(value) && isObject(out[key]) ? deepMerge(out[key], value) : value;
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
6
15
|
export function fsCommand(globals) {
|
|
7
16
|
const fs = new Command('fs')
|
|
8
17
|
.aliases(['fsnode'])
|
|
@@ -106,7 +115,9 @@ export function fsCommand(globals) {
|
|
|
106
115
|
.option('--icon <assetId>', 'set the icon asset')
|
|
107
116
|
.option('--asset <assetId>', 'set the attached file asset (use "null" to clear)')
|
|
108
117
|
.option('--sort <n>', 'sort order')
|
|
109
|
-
.option('--set <key=value...>', 'arbitrary field, dotted keys allowed')
|
|
118
|
+
.option('--set <key=value...>', 'arbitrary top-level field, dotted keys allowed (content.* would REPLACE content — use --merge)')
|
|
119
|
+
.option('--merge <key=value...>', 'set fields inside content, merged with the current content; values accept @file')
|
|
120
|
+
.option('--add-images <assetId...>', 'append to the image asset list instead of replacing it')
|
|
110
121
|
.action(async (id, opts) => {
|
|
111
122
|
const client = new WjiClient(globals());
|
|
112
123
|
const body = {
|
|
@@ -120,6 +131,24 @@ export function fsCommand(globals) {
|
|
|
120
131
|
sort: opts.sort != null ? Number(opts.sort) : undefined,
|
|
121
132
|
...parseKeyValues(opts.set),
|
|
122
133
|
};
|
|
134
|
+
if (opts.merge?.length || opts.addImages?.length) {
|
|
135
|
+
// PATCH replaces `content` and `images` whole, so read first and merge.
|
|
136
|
+
const node = await client.getFresh(`/fsnode/list/${id}`);
|
|
137
|
+
if (opts.merge?.length) {
|
|
138
|
+
const pairs = [];
|
|
139
|
+
for (const pair of opts.merge) {
|
|
140
|
+
const index = pair.indexOf('=');
|
|
141
|
+
if (index === -1)
|
|
142
|
+
throw new Error(`Expected key=value, got "${pair}"`);
|
|
143
|
+
pairs.push(`${pair.slice(0, index)}=${await readValue(pair.slice(index + 1))}`);
|
|
144
|
+
}
|
|
145
|
+
body.content = deepMerge(body.content ?? node.content ?? {}, parseKeyValues(pairs));
|
|
146
|
+
}
|
|
147
|
+
if (opts.addImages?.length) {
|
|
148
|
+
const current = (opts.images ?? node.images ?? []).map((it) => (typeof it === 'string' ? it : it.id));
|
|
149
|
+
body.images = [...current, ...opts.addImages.filter((it) => !current.includes(it))];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
123
152
|
const updated = await client.patch(`/fsnode/list/${id}`, body);
|
|
124
153
|
render(updated, (n) => success(`Updated ${n?.id || id}`));
|
|
125
154
|
});
|
|
@@ -16,6 +16,8 @@ const APPS = {
|
|
|
16
16
|
batch: 'Batch',
|
|
17
17
|
digitalhost: 'DigitalHost',
|
|
18
18
|
};
|
|
19
|
+
/** Apps every new workspace gets unless `-a` or `--no-apps` says otherwise — what the drama and canvas workflows need. */
|
|
20
|
+
const DEFAULT_APPS = ['dataset', 'datasetimagegeneration', 'canvas'];
|
|
19
21
|
const APP_TYPES = {
|
|
20
22
|
Dataset: 'DATASET',
|
|
21
23
|
DatasetImageGeneration: 'DATASET_IMAGE_GENERATION',
|
|
@@ -103,10 +105,12 @@ export function workspaceCommand(globals) {
|
|
|
103
105
|
.option('-r, --aspect-ratio <ratio>', '16:9 | 9:16 | 21:9', '16:9')
|
|
104
106
|
.option('-l, --language <lang>', 'dialogue language', 'auto')
|
|
105
107
|
.option('-t, --type <type>', 'workspaceType')
|
|
106
|
-
.option('-a, --app <app...>',
|
|
108
|
+
.option('-a, --app <app...>', `apps to create (default: ${DEFAULT_APPS.join(' ')})`)
|
|
109
|
+
.option('--no-apps', 'create an empty workspace with no apps')
|
|
107
110
|
.action(async (name, opts) => {
|
|
108
111
|
const client = new WjiClient(globals());
|
|
109
|
-
const
|
|
112
|
+
const tokens = opts.app?.length ? opts.app : opts.apps ? DEFAULT_APPS : [];
|
|
113
|
+
const apps = tokens.map((token) => {
|
|
110
114
|
const { app, type } = resolveApp(token);
|
|
111
115
|
return { app, type, name: app };
|
|
112
116
|
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: short-drama
|
|
3
|
+
description: Produce an AI short drama (短劇) on the WJI platform end to end — write it the way a commercial 短剧 is written (创作简报, 创作指引, 人物圣经, 分集目录 with 爽点 and 钩子, the 15-second hook and 30-second turns, 谈话+行动, the right 语体), adapt a story or novel into an outline, character / scene / prop bibles, per-episode scripts and storyboards, then generate the sheets, keyframes and video clips through the `wji` CLI, with data stored as workspace datasets and the work laid out on canvases (characters, scenes, one per episode). Every stage is gated by `scripts/short-drama.mjs validate`. Use whenever a task mentions short drama, 短劇 / 短剧, 分集 / 分镜 / 剧本 for AI video, or producing a serialised video story on WJI. Works together with the `wji` skill.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Short drama on WJI
|
|
7
|
+
|
|
8
|
+
Five stages, one project directory, one workspace. The **model writes the design fields**; the
|
|
9
|
+
**script checks them** (`validate`), derives what can be derived (`seed`, `prompt`) and keeps the
|
|
10
|
+
local ↔ WJI bindings (`manifest`, `bind`). Nothing is generated until its stage is green.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
outline ──▶ characters ──▶ art ──▶ script (per episode) ──▶ storyboard (per episode)
|
|
14
|
+
│ │ │ │ │
|
|
15
|
+
story/ character/ location/ story/ shot/E01/ datasets
|
|
16
|
+
dataset datasets + prop/ screenplay + E01 canvas: keyframes → video
|
|
17
|
+
`characters` datasets + dataset
|
|
18
|
+
canvas `scenes` canvas
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`{baseDir}` = this directory. Script: `node {baseDir}/scripts/short-drama.mjs <cmd> …` (zero
|
|
22
|
+
dependencies). Read `{baseDir}/reference/schema.md` before writing any JSON and
|
|
23
|
+
`{baseDir}/reference/workspace-layout.md` before touching WJI. The `wji` skill governs every CLI
|
|
24
|
+
call (auth, confirmations, spending policy, model choice) — this skill only says what to build.
|
|
25
|
+
|
|
26
|
+
**How the drama is written** comes from `{baseDir}/reference/craft-story.md` (the 短剧 method:
|
|
27
|
+
brief, three phases, 分集目录, 爽点 / 钩子 libraries, the episode formula, the character bible) and
|
|
28
|
+
`{baseDir}/reference/dialogue-craft.md` (lines as action, 语体 rules, the polish pass). Read the
|
|
29
|
+
first before stage 1 and the second before stage 4. The gates encode what those two can check.
|
|
30
|
+
|
|
31
|
+
**Re-read the guideline.** The 创作指引 (`render guideline`) is the drama's top rule document.
|
|
32
|
+
Print it before writing any character, any episode and any storyboard, and print the speakers'
|
|
33
|
+
`render character` before writing their lines — a script that drifts from the guideline is wrong
|
|
34
|
+
even when every gate is green. When the user changes a core requirement (genre, register, length,
|
|
35
|
+
ending), change the guideline first, then the episodes.
|
|
36
|
+
|
|
37
|
+
## Before anything else
|
|
38
|
+
|
|
39
|
+
1. **Preflight WJI**: `wji auth whoami --json`, the version check from the `wji` skill,
|
|
40
|
+
`wji profile wallet` (a drama is many generations — know the balance).
|
|
41
|
+
2. **Scan the message for `-auto`** (spending waiver, see the `wji` skill). Without it: one image
|
|
42
|
+
runs, video and batches ask first.
|
|
43
|
+
3. **Ask the parameters once**, in one message: episodes × minutes per episode, genre, adapt
|
|
44
|
+
mode (原创 / 忠实 / 抽核 / 借壳), the 语体 (`register`: 粤语 / 大陆普通话 / 台湾国语 / 港式普通话 /
|
|
45
|
+
古装 / 年代 / 英文美式 / 英文英式 — it decides how the lines are written and the accent the video
|
|
46
|
+
model speaks), ratio (default 9:16). Offer the defaults you will use unless
|
|
47
|
+
told otherwise: `video: h3` (MiniMax H3 ref), `style: realistic`, **`references: sheets`** (no
|
|
48
|
+
storyboard images) and **`videoDuration: auto`**. Do not start Stage 1 without the first two.
|
|
49
|
+
|
|
50
|
+
**The video mode decides how the plot is cut up.** A segment is one video generation, so its
|
|
51
|
+
length is whatever the provider accepts — and that differs by a factor of two:
|
|
52
|
+
|
|
53
|
+
| `--video` | model | segment | images | voices | auto duration | prompt |
|
|
54
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
55
|
+
| `h3` | `minimax-h3-ref` | **5–15 s** | 9 | 3 | ✗ — always `fixed` | English, keyframe-pinned |
|
|
56
|
+
| `seedance` | `seedance-2.5-ref` | **4–30 s** | 30 | 10 | ✓ | English, `@imageN` |
|
|
57
|
+
| `seedance2` | `seedance-2.0-ref` | **4–15 s** | 9 | 3 | ✓ | English, `@imageN` |
|
|
58
|
+
| `seedance2fast` | `seedance-2.0-fast-ref` | **4–15 s** | 9 | 3 | ✓ | English, `@imageN` |
|
|
59
|
+
| `wan` | `wan-3.0-ref` | **2–30 s** | 10 | 5 | ✓ | Chinese prose, **speaks the dialogue itself** |
|
|
60
|
+
|
|
61
|
+
All five take **whole seconds only**, and each has its own resolution tiers (h3: 480P / 768P /
|
|
62
|
+
2K / 4K — note there is no 720P; the Seedance pair is lowercase; wan: 480P / 720P / 1080P).
|
|
63
|
+
`validate` checks the spelling against the model. Pick the mode **before** the storyboard: the
|
|
64
|
+
same 90-second episode is 7 generations on a 15 s model and 3 on a 30 s one, and
|
|
65
|
+
`seed storyboard` computes that split for you. With `wan`, set `speechAccent` (e.g.
|
|
66
|
+
台湾口音国语) because the model speaks the lines.
|
|
67
|
+
|
|
68
|
+
**Style**: `photoreal` for a live-action drama, `realistic` for semi-realistic painterly,
|
|
69
|
+
`ghibli` for hand-painted anime. **References** (default `sheets`): the video node references
|
|
70
|
+
the character/scene sheets directly and no storyboard images are generated — fewer images to
|
|
71
|
+
pay for and approve, and continuity comes from the sheets. Choose `keyframes` (one generated
|
|
72
|
+
image per cut) only when a shot has to be composed exactly; h3 needs it for its keyframe-pinned
|
|
73
|
+
prompt. **Duration** (default `auto`): the model matches the clip to the prompt; `fixed` sends
|
|
74
|
+
`-d` = Σ cuts and is forced on models without an auto duration.
|
|
75
|
+
|
|
76
|
+
**The camera package** is `params.camera` — body, lens family and working stop, default
|
|
77
|
+
`ARRI ALEXA Mini LF` / `Zeiss Supreme Prime` / `T1.5`. Every shot prompt names it verbatim, so
|
|
78
|
+
the drama looks shot on one rig; a cut can override the focal and the stop. A look the user asks
|
|
79
|
+
for on top of that (大光圈, 背景虛化, 前景模糊…) goes in `lookPhrase`.
|
|
80
|
+
4. **Locate or create the project directory** (the user's choice, else next to the source):
|
|
81
|
+
```bash
|
|
82
|
+
node {baseDir}/scripts/short-drama.mjs init <dir> --title "<title>" --episodes N --minutes M --genre "<genre>" --register 台湾国语 [--adapt 抽核] [--ratio 9:16] [--video h3|seedance|seedance2|seedance2fast|wan] [--resolution 480P|…] [--accent "台湾口音国语"] [--style photoreal|realistic|ghibli] [--references sheets|keyframes] [--duration auto|fixed] [--look "<lens phrase>"] [--lang zh]
|
|
83
|
+
# params.camera (body / lens / aperture) is edited in project.json
|
|
84
|
+
```
|
|
85
|
+
An existing `project.json` means a resumed run: `validate` and `manifest` tell you where it stands.
|
|
86
|
+
5. **Workspace**: reuse the one bound in `project.json`; otherwise create it and bind the ids
|
|
87
|
+
(`wji ws create "<title>" -r <ratio>` → `wji ws use` → find the semantic
|
|
88
|
+
folders → `bind`). Details in `reference/workspace-layout.md`.
|
|
89
|
+
|
|
90
|
+
## The loop every stage follows
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
write JSON → validate --stage <stage> → fix until green → render → sync datasets → build canvas → generate → bind ids
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **Validate before spending.** `validate` exits 1 with the failing gates; fix each one, re-run.
|
|
97
|
+
Never generate from a red stage.
|
|
98
|
+
- **Sync = publish.** `render` produces the markdown for the dataset's `content.content`; the
|
|
99
|
+
entity JSON goes to `metadata.shortDrama`; prompts to `image_prompt` / `video_prompt` /
|
|
100
|
+
`audio_prompt` (a character's voice brief). Use
|
|
101
|
+
`wji fs set --merge …` (never `--content`), `--add-images` (never `--images` on an existing node).
|
|
102
|
+
- **Bind immediately.** Every id `wji` returns goes into `project.json` / `episodes/E<nn>.json`
|
|
103
|
+
with `bind` before the next command. `manifest` lists what is still unbound; run it before creating
|
|
104
|
+
anything so a resumed run never duplicates.
|
|
105
|
+
- **Look at what you generated.** `wji asset download <id> -o <dir>` and open the image before
|
|
106
|
+
approving a sheet or moving to the next segment. The reference-repo lesson: one wrong sheet
|
|
107
|
+
corrupts every shot after it.
|
|
108
|
+
|
|
109
|
+
## Stage 1 — Outline
|
|
110
|
+
|
|
111
|
+
Read `{baseDir}/reference/stage-outline.md`.
|
|
112
|
+
|
|
113
|
+
1. **Brief first**: `outline.brief` — the six judgements (logline, audience, 爽点 engine, main
|
|
114
|
+
hook, the pressures on both sides, emotional line, promise). If the user gave one line, ask
|
|
115
|
+
the focusing questions; do not invent a genre.
|
|
116
|
+
2. **Skeleton**: `outline.core/keep/cut/merge/risks`, `characters[]` (id/name/tier/role/arc/from),
|
|
117
|
+
`scenes[]` (id/name/primary/reusePlan), `props[]` (id/name/function/beatIds), `beats[]`,
|
|
118
|
+
`outline.structure` (golden / escalation / climax with episode ranges) and `outline.guideline`
|
|
119
|
+
(tone, intensity, dialogue style, visual style, limits, ≥ 3 forbidden, 3–5 priorities).
|
|
120
|
+
`validate --stage outline` will still fail on `episodes` — that is expected at this point; read
|
|
121
|
+
the other gates.
|
|
122
|
+
3. **Sign-off ⛔**: show the user the brief, what was cut, who was merged, where the major beats
|
|
123
|
+
land (`cutNote` / `mergeNote`) and the guideline's priorities. Do not write episode synopses
|
|
124
|
+
before they agree — a wrong skeleton wastes every downstream stage.
|
|
125
|
+
4. **Episodes** in batches of ≤10 — design before prose: `function` / `task` / `change` /
|
|
126
|
+
`satisfaction` / `hookType` (the 分集目录; episode 1 grabs, 2 proves the 爽点, 3 widens the line,
|
|
127
|
+
the finale settles), then `synopsis` / `hook` / `suspense` / ids / `crowdPlan` / `warnings`;
|
|
128
|
+
`validate --stage outline` until green.
|
|
129
|
+
5. Sync: `render outline` → `story/` dataset (type `outline`, `viewMode=content`), bind
|
|
130
|
+
`wji.outlineDatasetId`; `render guideline` → its own `story/` dataset `<title> 创作指引`
|
|
131
|
+
(type `outline`), bind `wji.guidelineDatasetId`.
|
|
132
|
+
|
|
133
|
+
## Stage 2 — Characters
|
|
134
|
+
|
|
135
|
+
Read `{baseDir}/reference/stage-characters.md`.
|
|
136
|
+
|
|
137
|
+
1. Fill `oneLiner`, `profile` (with the 关系网 in `relationships`: surface / real / power / secret),
|
|
138
|
+
**`bible`** (剧情功能, 一句话人物钩子, Want vs Need, 核心误信, 核心矛盾, 受压反应, `speech` — and for leads
|
|
139
|
+
the wound, past choices, forbidden / mustFace and the arc; `craft-story.md` §7), `identityEn`,
|
|
140
|
+
`image` (portrait prompt, negative, 16:9 sheet prompt, tags) and `voice` for every character;
|
|
141
|
+
`validate --stage characters`. Two of those exist purely so a video prompt can talk about the
|
|
142
|
+
person: **`identityEn`** is the name-free way to refer to them (`the woman in her mid thirties
|
|
143
|
+
in headphones`) and **`voice.videoDirection`** is one English clause on how they speak, pasted
|
|
144
|
+
beside every line they say in every clip — written from `bible.speech`.
|
|
145
|
+
2. Sync each character to a `character/` dataset (description = oneLiner, content = `render
|
|
146
|
+
character C01`, `image_prompt` = the sheet prompt, `audio_prompt` = `prompt voice C01`,
|
|
147
|
+
metadata = the JSON).
|
|
148
|
+
3. `characters` canvas (`wji canvas create characters` once): per character a title node, a
|
|
149
|
+
**headshot** image node (1:1, portrait prompt), a **sheet** image node (16:9, `--from` the headshot,
|
|
150
|
+
sheet prompt prefixed with `@image1 is the face to match`), and a **voice** audio node
|
|
151
|
+
(`seed-audio-1.0`, `--from` the headshot — the model reads the face and invents a voice for it;
|
|
152
|
+
the composed prompt asks for a dry voice, because the same model also makes rooms, scores and
|
|
153
|
+
sound effects — that is what its stage-5 soundtrack nodes are for;
|
|
154
|
+
`prompt voice C01` composes the text: a parenthetical carrying age, identity, personality,
|
|
155
|
+
timbre, pitch, pace, accent and emotion, then the acted line).
|
|
156
|
+
Recipes in `reference/canvas-recipes.md`.
|
|
157
|
+
4. Generate headshots first (single images run without asking; a batch of them is a batch — quote
|
|
158
|
+
the total), inspect, then sheets and voices from the approved headshots. Listen to each voice.
|
|
159
|
+
Attach all three assets — the asset list takes audio too:
|
|
160
|
+
`fs set <dataset> --add-images <headshot> <sheet> <voice> --icon <headshot> --set primaryAudio=<voice>`; bind node and asset ids.
|
|
161
|
+
|
|
162
|
+
## Stage 3 — Art (scenes + props)
|
|
163
|
+
|
|
164
|
+
Read `{baseDir}/reference/stage-art.md`.
|
|
165
|
+
|
|
166
|
+
1. Scenes: `summary`, 3–5 `anchors`, `lighting` states derived from the episodes the scene appears
|
|
167
|
+
in, empty-scene `image.*`; variants via `variantOf` + `changes`. Props (only narrative ones):
|
|
168
|
+
`scale`, `anchors`, `states`, white-background `image.*`. `validate --stage art`.
|
|
169
|
+
2. Sync to `location/` and `prop/` datasets; `scenes` canvas: one sheet node per scene / prop
|
|
170
|
+
(16:9), variant and state nodes `--from` the parent sheet. Generate, inspect (no people in a
|
|
171
|
+
scene, no hands on a prop, one space across all panels), attach as `icon`, bind.
|
|
172
|
+
|
|
173
|
+
## Stage 4 — Script (per episode, batches of ≤3)
|
|
174
|
+
|
|
175
|
+
Read `{baseDir}/reference/stage-script.md`.
|
|
176
|
+
|
|
177
|
+
1. `render guideline` and the speakers' `render character` first. Then `seed script --ep N`
|
|
178
|
+
writes `episodes/E0N.json` with target seconds, hook/cliff, claimed beats, the episode design
|
|
179
|
+
(`function` / `task` / `change` / `hookType`, `satisfactionBeat` to set) and a `seedNote`
|
|
180
|
+
(synopsis, the 爽点 to realise, register, guideline priorities, every speaker's `bible.speech`,
|
|
181
|
+
ids, previous cliff, budget). Fill `scenes[].flow` — action beats and line beats alternating,
|
|
182
|
+
**common actions only**, hook image inside the first 3 beats and by 15 s, a `turn: true` beat
|
|
183
|
+
at least every 30 s, the 爽点 on an action (`satisfactionBeat`), cliff as the last beat; lines
|
|
184
|
+
in the register, from each speaker's `speech` (`dialogue-craft.md`). Delete `seedNote` when done.
|
|
185
|
+
2. `validate --stage script --ep N` — duration math, line length, speakers, hook window, the
|
|
186
|
+
rhythm, the design, filmability, upstream ids; the register gate only advises. Over budget: cut
|
|
187
|
+
actions first; under: add conflict, not small talk. When dialogue is the weakness, run the
|
|
188
|
+
polish pass: `render dialogue --ep N` → fill the 逐句校对表 → apply → re-validate.
|
|
189
|
+
3. Sync: `render script --ep N` (the 短剧 episode format with the 创作自检 table) → `story/` dataset
|
|
190
|
+
`E0N 剧本` (type `screenplay`, `viewMode=content`); bind `wji.screenplayDatasetId` with `--ep N`.
|
|
191
|
+
|
|
192
|
+
## Stage 5 — Storyboard (per episode)
|
|
193
|
+
|
|
194
|
+
Read `{baseDir}/reference/stage-storyboard.md`.
|
|
195
|
+
|
|
196
|
+
Every cut is a set-up, so besides its beats and framing it carries the **lens** (`focal`, or let the
|
|
197
|
+
shot size pick it), the **key light relative to the camera** (`light.direction` from the vocabulary
|
|
198
|
+
plus `light.note` saying what it picks out, what fills the shadow side and how it changes over the
|
|
199
|
+
cut) and the **foreground** to throw out of focus. All three are gated and all three go into the
|
|
200
|
+
prompt, alongside the camera package and each speaker's delivery.
|
|
201
|
+
|
|
202
|
+
**And every cut is a shot with a job** — read `{baseDir}/reference/craft-shots.md` and
|
|
203
|
+
`{baseDir}/reference/craft-motion.md` before the first storyboard. In both reference modes a cut
|
|
204
|
+
carries its `purpose` (kind + what changes), its three framing axes (`angle`, `height`, `axisSide`
|
|
205
|
+
against the segment's declared `axis`), its `start` / `end` boundaries (positions, facing, gaze,
|
|
206
|
+
hands, held props per person — the next cut and the next segment begin where these end), its
|
|
207
|
+
`motion` (cue → one visible action → landing) and `cameraNote` (a lock-off says what still moves, a
|
|
208
|
+
move says why and where it ends); the segment carries its `sound` (ambience chained across the
|
|
209
|
+
scene, sfx per cut, music intent) and the 爽点 / cliff scenes a `visualPlan`. `frame` stays the
|
|
210
|
+
frozen start instant. `prompt segment` composes all of it into the prompt, and the prompt is
|
|
211
|
+
delivery text: no ids, files or notes, subtitles off, the other person's lips closed while one
|
|
212
|
+
speaks (H3), everyone bound by `@imageK` in their own shot (sheets).
|
|
213
|
+
|
|
214
|
+
**Everything the node references is addressed by its label, everywhere it applies.** WJI numbers
|
|
215
|
+
connected references per type, each series from 1: `@image1…` the sheets or keyframes, `@video1…`
|
|
216
|
+
the previous clip when `params.videoReferences: previous` is on (continuity of grade and camera
|
|
217
|
+
language — opt-in), `@audio1…` the voice samples of whoever speaks. The legend says what each one
|
|
218
|
+
is, and then each label is used where it belongs — `@imageK` in the shot that anchors on it,
|
|
219
|
+
`@videoK` in the sentence saying what to carry over, and **every line a character speaks names
|
|
220
|
+
their `@audioN`**, at each line rather than once in the legend, because the model picks a voice per
|
|
221
|
+
line. All of it is gated. `prompt segment` prints the references in the exact order the video node
|
|
222
|
+
must be connected: pictures, then clips, then voices.
|
|
223
|
+
|
|
224
|
+
1. `seed storyboard --ep N` adds `storyboard.plan`, `storyboard.seedScenes` (every beat with its
|
|
225
|
+
computed seconds, an axis hint per scene) and `storyboard.visualPlans` (the worksheet for the
|
|
226
|
+
爽点 scene and the cliff scene — fill it before cutting them). **Follow the plan**: it reads the chosen model's duration range and says how
|
|
227
|
+
many segments each scene needs and how long each should be — the split follows the model, not
|
|
228
|
+
the story's own rhythm, because a segment is one generation and cannot outlast what the provider
|
|
229
|
+
accepts. Then cut the episode into those **segments** (one scene each,
|
|
230
|
+
`minSegmentSeconds`–`maxSegmentSeconds`, whole seconds) of **cuts** (2–5 s, claiming consecutive
|
|
231
|
+
beats). Per cut: `purpose`, `angle` / `height` / `axisSide`, `start` (first cut) and `end`,
|
|
232
|
+
`motion`, `cameraNote`, light / foreground / `background`, then the `frame` (the start instant);
|
|
233
|
+
per segment `axis` and `sound`. Then the segment `prompt` — take the skeleton from
|
|
234
|
+
`prompt <project> segment E0N-01` and fill in the descriptions. Delete `seedScenes` and `plan`
|
|
235
|
+
when done. `validate --stage storyboard --ep N` (the advisory `storyboard-advice` only notes).
|
|
236
|
+
2. Sync: shot folder `shot/E0N`, one `shot` dataset per segment (`video_prompt` = the prompt,
|
|
237
|
+
content = `render segment E0N-01` — the 分镜 with one `### SHOT k` block per cut).
|
|
238
|
+
3. `E0N` canvas (`wji canvas create E0N`): display nodes for every sheet the episode uses
|
|
239
|
+
(`canvas add image --asset …`, keyed in `wji.displayNodes`). Then, in the default `sheets` mode,
|
|
240
|
+
the video node per segment straight from those display nodes (`--from` them in the order
|
|
241
|
+
`prompt segment` prints the legend — the order is the legend, so a reconnect must redo the whole
|
|
242
|
+
set). With `references: keyframes`, first a keyframe node per cut (`--from` the display nodes,
|
|
243
|
+
ratio = `params.ratio`), then the video node `--from` the keyframes in cut order.
|
|
244
|
+
Either way the video node takes the mode's model, `--resolution params.videoResolution`,
|
|
245
|
+
`--prompt-file`, and `-d` = Σ cut seconds **only in `fixed` duration mode** — in `auto` leave
|
|
246
|
+
`-d` off and let the model match the clip to the prompt.
|
|
247
|
+
4. In `sheets` mode go straight to generating the clips. With `keyframes`, **generate the first
|
|
248
|
+
segment's keyframes only**, download, check: same world as the scene sheet, same faces as the
|
|
249
|
+
character sheets, the right shot size, nobody extra in frame. Regenerate what is off, then
|
|
250
|
+
continue segment by segment. Keyframes for one segment are a batch (quote the total); each video
|
|
251
|
+
is a video (ask, or `-auto`). Stay under the queue cap (`wji` skill *Limits*): submit up to the
|
|
252
|
+
cap, `wji history get <id> --wait`, continue.
|
|
253
|
+
5. **Soundtrack node** per segment (`params.audioTrack`, default `bed`): `prompt audio E0N-01`
|
|
254
|
+
derives a Seed Audio prompt from `segment.sound` — timecoded `[start s:end s]` ambience, sfx on
|
|
255
|
+
their cuts, music that enters / swells / ducks / falls silent at the cues you wrote on
|
|
256
|
+
`sound.music.cues` — and `canvas add audio -m seed-audio-1.0 --prompt-file …` makes it (~0.15
|
|
257
|
+
credits a minute, no `--from`; `--full` adds the lines in the connected `@Audio1..3` voices).
|
|
258
|
+
Seed Audio is the voice-sample model, but it is an audio-scene generator: one prompt is a whole
|
|
259
|
+
track. Attach the asset to the shot dataset; bind `wji.nodes.track` / `wji.assets.track`.
|
|
260
|
+
6. After each clip: `fs set <shot dataset> --add-images <keyframes…> <track> --set primaryVideo=<clip>`;
|
|
261
|
+
bind asset ids. `manifest --ep N` must come back clean before you call the episode done.
|
|
262
|
+
|
|
263
|
+
- **A failed generation is not always a bad prompt.** `wan-3.0-ref` rejections from DashScope's
|
|
264
|
+
content filter (`DataInspectionFailed`) charge nothing and are partly random — retry the same node
|
|
265
|
+
8–12 times, then **shorten** the prompt (that, not rewording, is what clears a stuck one).
|
|
266
|
+
|
|
267
|
+
## Reporting
|
|
268
|
+
|
|
269
|
+
One line per stage when you stop: what exists (counts), what was generated and the credits
|
|
270
|
+
actually charged (the `credits` line of each result, not the estimate), what `manifest` still
|
|
271
|
+
lists, and any gate you had to fix. If a generation failed, say which and why — never resubmit a
|
|
272
|
+
node whose status is `generating`.
|
|
273
|
+
|
|
274
|
+
## Rules specific to this skill
|
|
275
|
+
|
|
276
|
+
- **The user signs off the brief + outline skeleton and the first segment's keyframes.**
|
|
277
|
+
Everything else runs on the `wji` spending table.
|
|
278
|
+
- **The guideline outranks a good idea.** A single episode's idea that conflicts with
|
|
279
|
+
`outline.guideline` is either dropped or the guideline is changed with the user first.
|
|
280
|
+
- **Ids are stable.** Never renumber `C01` / `S01` / `P01` / `B01` / `E01-01` after a dataset or node
|
|
281
|
+
is bound; add new ids at the end.
|
|
282
|
+
- **Prompts never carry names.** Faces come from the sheet references; `validate` rejects a name
|
|
283
|
+
or alias in any English prompt.
|
|
284
|
+
- **The voice sample is a reference, not a deliverable.** 3–5 s, acted, generated from the
|
|
285
|
+
headshot; it connects to later video nodes as `@audio1` to hold the timbre the way the sheet
|
|
286
|
+
holds the face.
|
|
287
|
+
- **In `wan` mode the dialogue audio also comes from the video model** — the video model speaks the lines from the segment
|
|
288
|
+
prompt's 音频 line. Fill the `voice` fields anyway; they are the register, and the gate wants them.
|
|
289
|
+
- **One style for the whole drama** (`params.style`). Changing it later means regenerating every
|
|
290
|
+
sheet — say so before doing it.
|
|
291
|
+
- **Keyframes at `params.ratio`, sheets at 16:9.** A 9:16 drama still gets landscape sheets.
|
|
292
|
+
- **Don't fight a gate by loosening `params`** unless the user asks; the thresholds encode
|
|
293
|
+
production limits (H3 15 s, 2–5 s attention span, one breath per line).
|
|
294
|
+
|
|
295
|
+
## Self-test
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
node {baseDir}/scripts/selftest.mjs # no model calls, every gate has a breaking case
|
|
299
|
+
node {baseDir}/scripts/short-drama.mjs validate {baseDir}/examples/night-shift
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`examples/night-shift` — 《夜班便利店》, 3 × 1 min, three characters, two scenes, one prop, episode 1
|
|
303
|
+
scripted and storyboarded in H3 mode — passes every gate (the register gate is advisory and clean)
|
|
304
|
+
and is the reference for the shape of every field, the brief, the guideline and the bibles
|
|
305
|
+
included.
|