@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 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.
@@ -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 CREATABLE_KINDS = ['text', 'title', 'comment', 'image', 'video', 'audio'];
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 rows = await fetchModels(client, { features: [kind], workspace: workspaceId });
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
- throw new Error(`No ${kind} model "${name}". See \`wji models list --feature ${kind}\`.`);
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 app of apps) {
610
- const tree = await c.get(`/workspace/list/${wsId}/fs/${app.id}`);
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: app.id, linked: it.id === linked?.canvasId, updatedAt: it.updatedAt });
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;
@@ -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...>', 'apps to create, e.g. dataset files canvas')
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 apps = (opts.app || []).map((token) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wji.ai/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -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.