@wji.ai/cli 0.1.1 → 0.3.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.3.0",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -0,0 +1,322 @@
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` + `props` canvases
19
+ ```
20
+
21
+ `{baseDir}` = this directory. Read `{baseDir}/reference/schema.md` before writing any JSON and
22
+ `{baseDir}/reference/workspace-layout.md` before touching WJI.
23
+
24
+ **First, decide which surface you are on — it decides how every operation below is spelled.**
25
+
26
+ - `short_drama_validate` is among your tools → you are in **WJI chat**. Read
27
+ `{baseDir}/reference/operations-wji.md`. Every `node {baseDir}/scripts/short-drama.mjs <cmd>` and
28
+ `wji <cmd>` line in this skill is the CLI spelling of an operation — translate it with the tables
29
+ there. There is no shell and no project directory.
30
+ - Otherwise you are in **Claude Code with the `wji` CLI**. Read
31
+ `{baseDir}/reference/operations-cli.md`; the commands in this skill are literal. The script is
32
+ `node {baseDir}/scripts/short-drama.mjs <cmd> …` (zero dependencies), and the `wji` skill governs
33
+ every CLI call (auth, confirmations, spending policy, model choice).
34
+
35
+ Either way, this skill only says **what to build**; the operations doc says how to say it.
36
+
37
+ **How the drama is written** comes from `{baseDir}/reference/craft-story.md` (the 短剧 method:
38
+ brief, three phases, 分集目录, 爽点 / 钩子 libraries, the episode formula, the character bible) and
39
+ `{baseDir}/reference/dialogue-craft.md` (lines as action, 语体 rules, the polish pass). Read the
40
+ first before stage 1 and the second before stage 4. The gates encode what those two can check.
41
+
42
+ **Re-read the guideline.** The 创作指引 (`render guideline`) is the drama's top rule document.
43
+ Print it before writing any character, any episode and any storyboard, and print the speakers'
44
+ `render character` before writing their lines — a script that drifts from the guideline is wrong
45
+ even when every gate is green. When the user changes a core requirement (genre, register, length,
46
+ ending), change the guideline first, then the episodes.
47
+
48
+ ## Before anything else
49
+
50
+ 1. **Preflight WJI**: `wji auth whoami --json`, the version check from the `wji` skill,
51
+ `wji profile wallet` (a drama is many generations — know the balance).
52
+ 2. **Scan the message for `-auto`** (spending waiver, see the `wji` skill). Without it: one image
53
+ runs, video and batches ask first.
54
+ 3. **Ask the parameters once**, in one message: episodes × minutes per episode, genre, adapt
55
+ mode (原创 / 忠实 / 抽核 / 借壳), the 语体 (`register`: 粤语 / 大陆普通话 / 台湾国语 / 港式普通话 /
56
+ 古装 / 年代 / 英文美式 / 英文英式 — it decides how the lines are written and the accent the video
57
+ model speaks), ratio (default 9:16). Offer the defaults you will use unless
58
+ told otherwise: `video: h3` (MiniMax H3 ref), `style: realistic`, **`references: sheets`** (no
59
+ storyboard images) and **`videoDuration: auto`**. Do not start Stage 1 without the first two.
60
+
61
+ **The video mode decides how the plot is cut up.** A segment is one video generation, so its
62
+ length is whatever the provider accepts — and that differs by a factor of two:
63
+
64
+ | `--video` | model | segment | images | voices | auto duration | prompt |
65
+ | --- | --- | --- | --- | --- | --- | --- |
66
+ | `h3` | `minimax-h3-ref` | **5–15 s** | 9 | 3 | ✗ — always `fixed` | English, keyframe-pinned |
67
+ | `seedance` | `seedance-2.5-ref` | **4–30 s** | 30 | 10 | ✓ | English, `@imageN` |
68
+ | `seedance2` | `seedance-2.0-ref` | **4–15 s** | 9 | 3 | ✓ | English, `@imageN` |
69
+ | `seedance2fast` | `seedance-2.0-fast-ref` | **4–15 s** | 9 | 3 | ✓ | English, `@imageN` |
70
+ | `wan` | `wan-3.0-ref` | **2–30 s** | 10 | 5 | ✓ | Chinese prose, **speaks the dialogue itself** |
71
+
72
+ All five take **whole seconds only**, and each has its own resolution tiers (h3: 480P / 768P /
73
+ 2K / 4K — note there is no 720P; the Seedance pair is lowercase; wan: 480P / 720P / 1080P).
74
+ `validate` checks the spelling against the model. Pick the mode **before** the storyboard: the
75
+ same 90-second episode is 7 generations on a 15 s model and 3 on a 30 s one, and
76
+ `seed storyboard` computes that split for you. With `wan`, set `speechAccent` (e.g.
77
+ 台湾口音国语) because the model speaks the lines.
78
+
79
+ **Style**: `photoreal` for a live-action drama, `realistic` for semi-realistic painterly,
80
+ `ghibli` for hand-painted anime. **References** (default `sheets`): the video node references
81
+ the character/scene sheets directly and no storyboard images are generated — fewer images to
82
+ pay for and approve, and continuity comes from the sheets. Every mode works this way, h3
83
+ included (its sheets-mode prompt opens with an `@imageK is …` legend instead of the alignment
84
+ line). Choose `keyframes` (one generated image per cut) only when the user asks for it or a shot
85
+ has to be composed exactly — never switch a project to it on your own. **Duration** (default `auto`): the model matches the clip to the prompt; `fixed` sends
86
+ `-d` = Σ cuts and is forced on models without an auto duration.
87
+
88
+ **The camera package** is `params.camera` — body, lens family and working stop, default
89
+ `ARRI ALEXA Mini LF` / `Zeiss Supreme Prime` / `T1.5`. Every shot prompt names it verbatim, so
90
+ the drama looks shot on one rig; a cut can override the focal and the stop. A look the user asks
91
+ for on top of that (大光圈, 背景虛化, 前景模糊…) goes in `lookPhrase`.
92
+ 4. **Locate or create the project directory** (the user's choice, else next to the source):
93
+ ```bash
94
+ 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]
95
+ # params.camera (body / lens / aperture) is edited in project.json
96
+ ```
97
+ An existing `project.json` means a resumed run: `validate` and `manifest` tell you where it stands.
98
+ 5. **Workspace**: reuse the one bound in `project.json`; otherwise create it and bind the ids
99
+ (`wji ws create "<title>" -r <ratio>` → `wji ws use` → find the semantic
100
+ folders → `bind`). Details in `reference/workspace-layout.md`.
101
+
102
+ ## The loop every stage follows
103
+
104
+ ```
105
+ write JSON → validate --stage <stage> → fix until green → render → sync datasets → build canvas → generate → bind ids
106
+ ```
107
+
108
+ - **Validate before spending.** `validate` exits 1 with the failing gates; fix each one, re-run.
109
+ Never generate from a red stage.
110
+ - **Sync = publish.** `render` produces the markdown for the dataset's `content.content`; the
111
+ entity JSON goes to `metadata.shortDrama`; prompts to `image_prompt` / `video_prompt` /
112
+ `audio_prompt` (a character's voice brief). Use
113
+ `wji fs set --merge …` (never `--content`), `--add-images` (never `--images` on an existing node).
114
+ - **Bind immediately.** Every id `wji` returns goes into `project.json` / `episodes/E<nn>.json`
115
+ with `bind` before the next command. `manifest` lists what is still unbound; run it before creating
116
+ anything so a resumed run never duplicates.
117
+ - **Look at what you generated.** `wji asset download <id> -o <dir>` and open the image before
118
+ approving a sheet or moving to the next segment. The reference-repo lesson: one wrong sheet
119
+ corrupts every shot after it.
120
+
121
+ ## Stage 1 — Outline
122
+
123
+ Read `{baseDir}/reference/stage-outline.md`.
124
+
125
+ 1. **Brief first**: `outline.brief` — the six judgements (logline, audience, 爽点 engine, main
126
+ hook, the pressures on both sides, emotional line, promise). If the user gave one line, ask
127
+ the focusing questions; do not invent a genre.
128
+ 2. **Skeleton**: `outline.core/keep/cut/merge/risks`, `characters[]` (id/name/tier/role/arc/from),
129
+ `scenes[]` (id/name/primary/reusePlan), `props[]` (id/name/function/beatIds), `beats[]`,
130
+ `outline.structure` (golden / escalation / climax with episode ranges) and `outline.guideline`
131
+ (tone, intensity, dialogue style, visual style, limits, ≥ 3 forbidden, 3–5 priorities).
132
+ `validate --stage outline` will still fail on `episodes` — that is expected at this point; read
133
+ the other gates.
134
+ 3. **Sign-off ⛔**: show the user the brief, what was cut, who was merged, where the major beats
135
+ land (`cutNote` / `mergeNote`) and the guideline's priorities. Do not write episode synopses
136
+ before they agree — a wrong skeleton wastes every downstream stage.
137
+ 4. **Episodes** in batches of ≤10 — design before prose: `function` / `task` / `change` /
138
+ `satisfaction` / `hookType` (the 分集目录; episode 1 grabs, 2 proves the 爽点, 3 widens the line,
139
+ the finale settles), then `synopsis` / `hook` / `suspense` / ids / `crowdPlan` / `warnings`;
140
+ `validate --stage outline` until green.
141
+ 5. Sync: `render outline` → `story/` dataset (type `outline`, `viewMode=content`), bind
142
+ `wji.outlineDatasetId`; `render guideline` → its own `story/` dataset `<title> 创作指引`
143
+ (type `outline`), bind `wji.guidelineDatasetId`.
144
+
145
+ ## Stage 2 — Characters
146
+
147
+ Read `{baseDir}/reference/stage-characters.md`.
148
+
149
+ 1. Fill `oneLiner`, `profile` (with the 关系网 in `relationships`: surface / real / power / secret),
150
+ **`bible`** (剧情功能, 一句话人物钩子, Want vs Need, 核心误信, 核心矛盾, 受压反应, `speech` — and for leads
151
+ the wound, past choices, forbidden / mustFace and the arc; `craft-story.md` §7), `identityEn`,
152
+ `image` (portrait prompt, negative, 16:9 sheet prompt, tags) and `voice` for every character;
153
+ `validate --stage characters`. Two of those exist purely so a video prompt can talk about the
154
+ person: **`identityEn`** is the name-free way to refer to them (`the woman in her mid thirties
155
+ in headphones`) and **`voice.videoDirection`** is one English clause on how they speak, pasted
156
+ beside every line they say in every clip — written from `bible.speech`.
157
+ 2. Sync each character to a `character/` dataset (description = oneLiner, content = `render
158
+ character C01`, `image_prompt` = the sheet prompt, `audio_prompt` = `prompt voice C01`,
159
+ metadata = the JSON).
160
+ 3. `characters` canvas (`wji canvas create characters` once): per character a title node, a
161
+ **headshot** image node (1:1, portrait prompt), a **sheet** image node (16:9, `--from` the headshot,
162
+ sheet prompt prefixed with `@image1 is the face to match`), and a **voice** audio node
163
+ (`seed-audio-1.0`, `--from` the headshot — the model reads the face and invents a voice for it;
164
+ the composed prompt asks for a dry voice, because the same model also makes rooms, scores and
165
+ sound effects — that is what its stage-5 soundtrack nodes are for;
166
+ `prompt voice C01` composes the text: a parenthetical carrying age, identity, personality,
167
+ timbre, pitch, pace, accent and emotion, then the acted line).
168
+ Recipes in `reference/canvas-recipes.md`.
169
+ 4. Generate headshots first (single images run without asking; a batch of them is a batch — quote
170
+ the total), inspect, then sheets and voices from the approved headshots. Listen to each voice.
171
+ Attach all three assets — the asset list takes audio too:
172
+ `fs set <dataset> --add-images <headshot> <sheet> <voice> --icon <headshot> --set primaryAudio=<voice>`; bind node and asset ids.
173
+
174
+ ## Stage 3 — Art (scenes + props)
175
+
176
+ Read `{baseDir}/reference/stage-art.md`.
177
+
178
+ 1. Scenes: `summary`, 3–5 `anchors`, `lighting` states derived from the episodes the scene appears
179
+ in, empty-scene `image.*`; variants via `variantOf` + `changes`. Props (only narrative ones):
180
+ `scale`, `anchors`, `states`, white-background `image.*`. `validate --stage art`.
181
+ 2. Sync to `location/` and `prop/` datasets; `scenes` canvas: one sheet node per scene (16:9),
182
+ lighting / variant nodes `--from` the parent sheet; `props` canvas (`wji canvas create props`):
183
+ one white-background sheet node per prop, state nodes `--from` it. Generate, inspect (no people in a
184
+ scene, no hands on a prop, one space across all panels), attach as `icon`, bind.
185
+
186
+ ## Stage 4 — Script (per episode, batches of ≤3)
187
+
188
+ Read `{baseDir}/reference/stage-script.md`.
189
+
190
+ 1. `render guideline` and the speakers' `render character` first. Then `seed script --ep N`
191
+ writes `episodes/E0N.json` with target seconds, hook/cliff, claimed beats, the episode design
192
+ (`function` / `task` / `change` / `hookType`, `satisfactionBeat` to set) and a `seedNote`
193
+ (synopsis, the 爽点 to realise, register, guideline priorities, every speaker's `bible.speech`,
194
+ ids, previous cliff, budget). Fill `scenes[].flow` — action beats and line beats alternating,
195
+ **common actions only**, hook image inside the first 3 beats and by 15 s, a `turn: true` beat
196
+ at least every 30 s, the 爽点 on an action (`satisfactionBeat`), cliff as the last beat; lines
197
+ in the register, from each speaker's `speech` (`dialogue-craft.md`). Delete `seedNote` when done.
198
+ 2. `validate --stage script --ep N` — duration math, line length, speakers, hook window, the
199
+ rhythm, the design, filmability, upstream ids; the register gate only advises. Over budget: cut
200
+ actions first; under: add conflict, not small talk. When dialogue is the weakness, run the
201
+ polish pass: `render dialogue --ep N` → fill the 逐句校对表 → apply → re-validate.
202
+ 3. Sync: `render script --ep N` (the 短剧 episode format with the 创作自检 table) → `story/` dataset
203
+ `E0N 剧本` (type `screenplay`, `viewMode=content`); bind `wji.screenplayDatasetId` with `--ep N`.
204
+
205
+ ## Stage 5 — Storyboard (per episode)
206
+
207
+ Read `{baseDir}/reference/stage-storyboard.md`.
208
+
209
+ Every cut is a set-up, so besides its beats and framing it carries the **lens** (`focal`, or let the
210
+ shot size pick it), the **key light relative to the camera** (`light.direction` from the vocabulary
211
+ plus `light.note` saying what it picks out, what fills the shadow side and how it changes over the
212
+ cut) and the **foreground** to throw out of focus. All three are gated and all three go into the
213
+ prompt, alongside the camera package and each speaker's delivery.
214
+
215
+ **And every cut is a shot with a job** — read `{baseDir}/reference/craft-shots.md` and
216
+ `{baseDir}/reference/craft-motion.md` before the first storyboard. In both reference modes a cut
217
+ carries its `purpose` (kind + what changes), its three framing axes (`angle`, `height`, `axisSide`
218
+ against the segment's declared `axis`), its `start` / `end` boundaries (positions, facing, gaze,
219
+ hands, held props per person — the next cut and the next segment begin where these end), its
220
+ `motion` (cue → one visible action → landing) and `cameraNote` (a lock-off says what still moves, a
221
+ move says why and where it ends); the segment carries its `sound` (ambience chained across the
222
+ scene, sfx per cut, music intent) and the 爽点 / cliff scenes a `visualPlan`. `frame` stays the
223
+ frozen start instant. `prompt segment` composes all of it into the prompt, and the prompt is
224
+ delivery text: no ids, files or notes, subtitles off, the other person's lips closed while one
225
+ speaks (H3), everyone bound by `@imageK` in their own shot (sheets).
226
+
227
+ **Everything the node references is addressed by its label, everywhere it applies.** WJI numbers
228
+ connected references per type, each series from 1: `@image1…` the sheets or keyframes, `@video1…`
229
+ the previous clip when `params.videoReferences: previous` is on (continuity of grade and camera
230
+ language — opt-in), `@audio1…` the voice samples of whoever speaks. The legend says what each one
231
+ is, and then each label is used where it belongs — `@imageK` in the shot that anchors on it,
232
+ `@videoK` in the sentence saying what to carry over, and **every line a character speaks names
233
+ their `@audioN`**, at each line rather than once in the legend, because the model picks a voice per
234
+ line. All of it is gated. `prompt segment` prints the references in the exact order the video node
235
+ must be connected: pictures, then clips, then voices.
236
+
237
+ 1. `seed storyboard --ep N` adds `storyboard.plan`, `storyboard.seedScenes` (every beat with its
238
+ computed seconds, an axis hint per scene) and `storyboard.visualPlans` (the worksheet for the
239
+ 爽点 scene and the cliff scene — fill it before cutting them). **Follow the plan**: it reads the chosen model's duration range and says how
240
+ many segments each scene needs and how long each should be — the split follows the model, not
241
+ the story's own rhythm, because a segment is one generation and cannot outlast what the provider
242
+ accepts. Then cut the episode into those **segments** (one scene each,
243
+ `minSegmentSeconds`–`maxSegmentSeconds`, whole seconds) of **cuts** (2–5 s, claiming consecutive
244
+ beats). Per cut: `purpose`, `angle` / `height` / `axisSide`, `start` (first cut) and `end`,
245
+ `motion`, `cameraNote`, light / foreground / `background`, then the `frame` (the start instant);
246
+ per segment `axis` and `sound`. Then the segment `prompt` — take the skeleton from
247
+ `prompt <project> segment E0N-01` and fill in the descriptions. Delete `seedScenes` and `plan`
248
+ when done. `validate --stage storyboard --ep N` (the advisory `storyboard-advice` only notes).
249
+ 2. Sync: shot folder `shot/E0N`, one `shot` dataset per segment (`video_prompt` = the prompt,
250
+ content = `render segment E0N-01` — the 分镜 with one `### SHOT k` block per cut).
251
+ 3. `E0N` canvas (`wji canvas create E0N`): display nodes for every sheet the episode uses
252
+ (`canvas add image --asset …`, keyed in `wji.displayNodes`), plus the voice display nodes of
253
+ whoever speaks. **No storyboard images are generated**: the video node per segment references
254
+ the sheets directly — `--from` the scene / character / prop display nodes then the voices, in
255
+ the order `prompt segment` prints the legend (the order *is* the numbering, so a reconnect must
256
+ redo the whole set). The video node takes the mode's model, `--resolution
257
+ params.videoResolution`, `--prompt-file`, and `-d` = Σ cut seconds **only in `fixed` duration
258
+ mode** — in `auto` leave `-d` off and let the model match the clip to the prompt.
259
+ 4. Generate the clips segment by segment — each video is a video (ask, or `-auto`); download and
260
+ check the first one against the sheets (same world, same faces, right size, nobody extra)
261
+ before submitting the rest. Stay under the queue cap (`wji` skill *Limits*): submit up to the
262
+ cap, `wji history get <id> --wait`, continue.
263
+
264
+ *Only if the user set `references: keyframes`* (a shot that must be composed exactly): a keyframe
265
+ image node per cut first (`--from` the display nodes, ratio = `params.ratio`, prompt from
266
+ `prompt segment`), the video node `--from` the keyframes in cut order; generate the first
267
+ segment's keyframes only, inspect, then continue. Keyframes for one segment are a batch (quote
268
+ the total). Do not switch a project to keyframes on your own — it doubles the images to pay for
269
+ and approve.
270
+ 5. **Soundtrack node** per segment (`params.audioTrack`, default `bed`): `prompt audio E0N-01`
271
+ derives a Seed Audio prompt from `segment.sound` — timecoded `[start s:end s]` ambience, sfx on
272
+ their cuts, music that enters / swells / ducks / falls silent at the cues you wrote on
273
+ `sound.music.cues` — and `canvas add audio -m seed-audio-1.0 --prompt-file …` makes it (~0.15
274
+ credits a minute, no `--from`; `--full` adds the lines in the connected `@Audio1..3` voices).
275
+ Seed Audio is the voice-sample model, but it is an audio-scene generator: one prompt is a whole
276
+ track. Attach the asset to the shot dataset; bind `wji.nodes.track` / `wji.assets.track`.
277
+ 6. After each clip: `fs set <shot dataset> --add-images <track> [<keyframes…>] --set primaryVideo=<clip>`;
278
+ bind asset ids. `manifest --ep N` must come back clean before you call the episode done.
279
+
280
+ - **A failed generation is not always a bad prompt.** `wan-3.0-ref` rejections from DashScope's
281
+ content filter (`DataInspectionFailed`) charge nothing and are partly random — retry the same node
282
+ 8–12 times, then **shorten** the prompt (that, not rewording, is what clears a stuck one).
283
+
284
+ ## Reporting
285
+
286
+ One line per stage when you stop: what exists (counts), what was generated and the credits
287
+ actually charged (the `credits` line of each result, not the estimate), what `manifest` still
288
+ lists, and any gate you had to fix. If a generation failed, say which and why — never resubmit a
289
+ node whose status is `generating`.
290
+
291
+ ## Rules specific to this skill
292
+
293
+ - **The user signs off the brief + outline skeleton and the first segment's keyframes.**
294
+ Everything else runs on the `wji` spending table.
295
+ - **The guideline outranks a good idea.** A single episode's idea that conflicts with
296
+ `outline.guideline` is either dropped or the guideline is changed with the user first.
297
+ - **Ids are stable.** Never renumber `C01` / `S01` / `P01` / `B01` / `E01-01` after a dataset or node
298
+ is bound; add new ids at the end.
299
+ - **Prompts never carry names.** Faces come from the sheet references; `validate` rejects a name
300
+ or alias in any English prompt.
301
+ - **The voice sample is a reference, not a deliverable.** 3–5 s, acted, generated from the
302
+ headshot; it connects to later video nodes as `@audio1` to hold the timbre the way the sheet
303
+ holds the face.
304
+ - **In `wan` mode the dialogue audio also comes from the video model** — the video model speaks the lines from the segment
305
+ prompt's 音频 line. Fill the `voice` fields anyway; they are the register, and the gate wants them.
306
+ - **One style for the whole drama** (`params.style`). Changing it later means regenerating every
307
+ sheet — say so before doing it.
308
+ - **Keyframes at `params.ratio`, sheets at 16:9.** A 9:16 drama still gets landscape sheets.
309
+ - **Don't fight a gate by loosening `params`** unless the user asks; the thresholds encode
310
+ production limits (H3 15 s, 2–5 s attention span, one breath per line).
311
+
312
+ ## Self-test
313
+
314
+ ```bash
315
+ node {baseDir}/scripts/selftest.mjs # no model calls, every gate has a breaking case
316
+ node {baseDir}/scripts/short-drama.mjs validate {baseDir}/examples/night-shift
317
+ ```
318
+
319
+ `examples/night-shift` — 《夜班便利店》, 3 × 1 min, three characters, two scenes, one prop, episode 1
320
+ scripted and storyboarded in H3 mode — passes every gate (the register gate is advisory and clean)
321
+ and is the reference for the shape of every field, the brief, the guideline and the bibles
322
+ included.