@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.
@@ -0,0 +1,1411 @@
1
+ #!/usr/bin/env node
2
+ // short-drama selftest: no model calls, no credits. Every gate gets a breaking
3
+ // case — a gate that cannot fail is not a gate.
4
+
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import {
11
+ CAMERAS, GATES, STAGES, STYLE_PRESETS,
12
+ bindPath, claimedLines, containsName, cutStarts, dialogueSeconds, episodeSeconds,
13
+ forbiddenNames, h3AlignmentLine, initProject, jaccard, lineSeconds, loadProject, makeContext, manifest,
14
+ paramsOf, parseEpRange, promptSkeleton, renderCharacter, renderOutline, renderProp, renderScene, renderScript,
15
+ renderSegment, runGates, seedScript, seedStoryboard, seedanceAnchors, slug, timestamp, writeJsonFile,
16
+ wanLegendLine, wanSheetLegendLine, wanShotHeader, segmentReferences, imageReferences, audioReferences, voiceSamplePrompt, voiceSampleSeconds, nonWhitespace,
17
+ speechDirection, cameraPhrase, lightPhrase, foregroundPhrase, focalOf, LIGHT_DIRECTIONS, identityOf, isEnglish,
18
+ renderGuideline, renderDialogue, scriptSelfCheck, beatOffsets, turnPoints, parseAge, talkAndAction, normalizeRegister, REGISTERS, REGISTER_ACCENT,
19
+ cutStart, segmentCharacters, keyScenes, shotTexts, motionSentence, cameraClause, PURPOSE_KINDS, ANGLES, HEIGHTS, NO_SUBTITLES, LIPS_CLOSED,
20
+ audioPrompt, musicOf, cueSeconds, AUDIO_MODEL, AUDIO_MAX_CHARS,
21
+ VIDEO_MODELS, VIDEO_MODES, DEFAULT_PARAMS, videoModel, videoResolutionFor, nearestResolution, segmentPlan, videoReferences,
22
+ } from './short-drama.mjs';
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const FIXTURE = join(here, '../examples/night-shift');
26
+ const PROJECT = JSON.parse(readFileSync(join(FIXTURE, 'project.json'), 'utf8'));
27
+ const E01 = JSON.parse(readFileSync(join(FIXTURE, 'episodes/E01.json'), 'utf8'));
28
+ const clone = (x) => structuredClone(x);
29
+
30
+ let passed = 0;
31
+ const ok = (cond, label) => { assert.ok(cond, label); passed += 1; };
32
+ const eq = (a, b, label) => { assert.equal(a, b, `${label} — expected ${b}, got ${a}`); passed += 1; };
33
+
34
+ /** Run one gate on a (project, episode) pair and return its failures. */
35
+ function failures(gateId, project = PROJECT, episode = E01) {
36
+ const g = GATES.find((x) => x.id === gateId);
37
+ assert.ok(g, `gate ${gateId} exists`);
38
+ return g.run(makeContext(project, episode ? { 1: episode } : {}));
39
+ }
40
+ const passes = (gateId, project, episode) => ok(failures(gateId, project, episode).length === 0, `${gateId} passes`);
41
+ const breaks = (gateId, project, episode, needle) => {
42
+ const f = failures(gateId, project, episode);
43
+ ok(f.length > 0, `${gateId} breaks`);
44
+ if (needle) ok(f.some((m) => m.includes(needle)), `${gateId} names the problem (${needle}): ${f.join(' | ')}`);
45
+ };
46
+
47
+ // ── fixture is green ────────────────────────────────────────────────────────
48
+ {
49
+ const results = runGates(makeContext(PROJECT, { 1: E01 }));
50
+ const red = results.filter((r) => r.failures.length);
51
+ ok(red.length === 0, `fixture passes every gate: ${red.map((r) => `${r.id}: ${r.failures.join('; ')}`).join('\n')}`);
52
+ eq(results.filter((r) => !r.skipped).length, GATES.length, 'every gate ran');
53
+ ok(GATES.every((g) => STAGES.includes(g.stage)), 'every gate belongs to a stage');
54
+ const staged = runGates(makeContext(PROJECT, {}), { stage: 'art' });
55
+ ok(staged.every((r) => ['outline', 'characters', 'art'].includes(r.stage)), '--stage art runs outline..art only');
56
+ const noEp = runGates(makeContext(PROJECT, {}));
57
+ ok(noEp.filter((r) => r.stage === 'script').every((r) => r.skipped), 'script gates skip without episode files');
58
+ }
59
+
60
+ // ── params ──────────────────────────────────────────────────────────────────
61
+ {
62
+ const p = paramsOf(PROJECT);
63
+ eq(p.thresholds.maxPrimaryScenes, 5, 'primary scene cap for 3 episodes is 5 (clamped)');
64
+ eq(paramsOf({ params: { episodes: 60 } }).thresholds.maxPrimaryScenes, 10, 'primary scene cap for 60 episodes is 10');
65
+ eq(paramsOf({ params: { episodes: 200 } }).thresholds.maxPrimaryScenes, 15, 'primary scene cap clamps at 15');
66
+ eq(paramsOf({ params: { episodes: 60, thresholds: { maxPrimaryScenes: 3 } } }).thresholds.maxPrimaryScenes, 3, 'explicit cap wins');
67
+ const bad = clone(PROJECT); bad.params.video = 'sora';
68
+ breaks('params', bad, null, 'params.video');
69
+ const bad2 = clone(PROJECT); bad2.params.adaptMode = '随便';
70
+ breaks('params', bad2, null, 'adaptMode');
71
+ }
72
+
73
+ // ── outline gates ───────────────────────────────────────────────────────────
74
+ {
75
+ const p = clone(PROJECT); p.outline.core = '';
76
+ breaks('adaptation', p, null, 'core');
77
+ const p2 = clone(PROJECT); p2.params.adaptMode = '抽核';
78
+ breaks('adaptation', p2, null, 'cut');
79
+ p2.outline.cut = [{ what: 'x', why: 'y' }]; p2.outline.keep = [{ what: 'a', why: 'b' }];
80
+ passes('adaptation', p2, null);
81
+ const p3 = clone(PROJECT); p3.outline.cutNote = ' ';
82
+ breaks('adaptation', p3, null, 'cutNote');
83
+ }
84
+ {
85
+ const p = clone(PROJECT); p.characters[0].tier = 'support';
86
+ breaks('tiers', p, null, 'lead characters: 0');
87
+ const p2 = clone(PROJECT);
88
+ for (let i = 0; i < 6; i++) p2.characters.push({ id: `C1${i}`, name: `x${i}`, tier: 'lead', role: 'r', arc: 'a', from: ['原创'] });
89
+ breaks('tiers', p2, null, 'must be 1–5');
90
+ const p3 = clone(PROJECT); delete p3.characters[1].arc;
91
+ breaks('tiers', p3, null, 'need an arc');
92
+ const p4 = clone(PROJECT); p4.characters[0].from = [];
93
+ breaks('tiers', p4, null, 'from[]');
94
+ const p5 = clone(PROJECT); p5.characters[1].id = 'C01';
95
+ breaks('tiers', p5, null, 'duplicate');
96
+ }
97
+ {
98
+ const p = clone(PROJECT);
99
+ for (let i = 0; i < 5; i++) { p.scenes.push({ id: `S1${i}`, name: `s${i}`, primary: true }); p.outline.episodes[0].sceneIds.push(`S1${i}`); }
100
+ breaks('scenes', p, null, 'primary scenes: 7');
101
+ const p2 = clone(PROJECT);
102
+ p2.scenes.push({ id: 'S03', name: 'once', primary: false }); p2.outline.episodes[1].sceneIds.push('S03');
103
+ breaks('scenes', p2, null, 'reusePlan');
104
+ p2.scenes[2].reusePlan = 'reuse S01 at dawn';
105
+ passes('scenes', p2, null);
106
+ p2.scenes[2].reusePlan = ''; p2.scenes[2].variantOf = 'S01';
107
+ passes('scenes', p2, null);
108
+ }
109
+ {
110
+ const p = clone(PROJECT); p.props[0].function = '';
111
+ breaks('props-cap', p, null, 'function');
112
+ const p2 = clone(PROJECT);
113
+ for (let i = 0; i < 8; i++) p2.props.push({ id: `P1${i}`, name: `p${i}`, function: 'f' });
114
+ breaks('props-cap', p2, null, 'max 8');
115
+ const p3 = clone(PROJECT); p3.props[0].beatIds = ['B99'];
116
+ breaks('props-cap', p3, null, 'B99');
117
+ }
118
+ {
119
+ const p = clone(PROJECT); p.outline.beats = [];
120
+ breaks('beats', p, null, 'empty');
121
+ const p2 = clone(PROJECT); p2.outline.beats[0].episode = 2;
122
+ breaks('beats', p2, null, 'episode 1');
123
+ const p3 = clone(PROJECT); p3.params.episodes = 6; p3.outline.beats[1].episode = 5; p3.outline.beats[2].episode = 6;
124
+ breaks('beats', p3, null, 'beat gap');
125
+ const p4 = clone(PROJECT); p4.outline.beats[1].weight = 'minor'; p4.outline.beats[2].weight = 'major';
126
+ breaks('beats', p4, null, 'last episode');
127
+ const p5 = clone(PROJECT); p5.outline.beats.forEach((b) => (b.weight = 'minor'));
128
+ breaks('beats', p5, null, 'major');
129
+ const p6 = clone(PROJECT); p6.outline.beats[0].episode = 1; p6.outline.beats[1].episode = 1; p6.outline.beats[2].episode = 1;
130
+ breaks('beats', p6, null, 'finale');
131
+ }
132
+ {
133
+ const p = clone(PROJECT); p.outline.episodes[0].synopsis = '她说:「你是谁」';
134
+ breaks('episodes', p, null, 'dialogue quotes');
135
+ const p2 = clone(PROJECT); p2.outline.episodes[1].hook = '';
136
+ breaks('episodes', p2, null, 'hook is empty');
137
+ const p3 = clone(PROJECT); delete p3.outline.episodes[0].crowdPlan;
138
+ breaks('episodes', p3, null, 'crowdPlan');
139
+ const p4 = clone(PROJECT); p4.outline.episodes.pop();
140
+ breaks('episodes', p4, null, '2 entries');
141
+ const p5 = clone(PROJECT); p5.outline.episodes[0].warnings = ['爆炸'];
142
+ breaks('episodes', p5, null, 'unknown warning');
143
+ }
144
+ {
145
+ const p = clone(PROJECT); p.outline.episodes[0].characterIds.push('C77');
146
+ breaks('refs', p, null, 'unknown character C77');
147
+ const p2 = clone(PROJECT); p2.characters.push({ id: 'C09', name: 'idle', tier: 'functional', role: 'r', from: ['原创'] });
148
+ breaks('refs', p2, null, 'appears in no episode');
149
+ const p3 = clone(PROJECT); p3.outline.episodes.forEach((e) => (e.propIds = []));
150
+ breaks('refs', p3, null, 'prop P01');
151
+ }
152
+
153
+ // ── character gates ─────────────────────────────────────────────────────────
154
+ {
155
+ const p = clone(PROJECT); p.characters[0].profile.personality = ['a'];
156
+ breaks('profile', p, null, '3–5');
157
+ const p2 = clone(PROJECT); p2.characters[2].oneLiner = '';
158
+ breaks('profile', p2, null, 'oneLiner');
159
+ }
160
+ {
161
+ const p = clone(PROJECT); p.characters[0].image.prompt += ' 林小夏';
162
+ breaks('char-prompts', p, null, 'name');
163
+ const p2 = clone(PROJECT); p2.characters[0].image.prompt = p2.characters[0].image.prompt.replace('East Asian ', '').replace('Chinese ', '');
164
+ breaks('char-prompts', p2, null, 'ethnicity');
165
+ const p3 = clone(PROJECT); p3.characters[0].image.prompt = p3.characters[0].image.prompt.replace('contemporary 2020s ', '');
166
+ breaks('char-prompts', p3, null, 'era');
167
+ const p4 = clone(PROJECT); p4.characters[0].image.negativePrompt += ', photorealistic';
168
+ breaks('char-prompts', p4, null, 'must not ban');
169
+ const p5 = clone(PROJECT); p5.params.style = 'ghibli';
170
+ breaks('char-prompts', p5, null, 'must ban');
171
+ const p6 = clone(PROJECT); p6.characters[1].image.sheet = 'a sheet without the render phrase, 16:9';
172
+ breaks('char-prompts', p6, null, 'render phrase');
173
+ const p7 = clone(PROJECT); p7.characters[1].image.prompt = '一个老人的画像 portrait';
174
+ breaks('char-prompts', p7, null, 'English');
175
+ ok(containsName('the old man', 'man'), 'ascii name match is word-bounded');
176
+ ok(!containsName('the woman', 'man'), 'ascii name does not match inside a word');
177
+ ok(containsName('画面里的小夏', '小夏'), 'cjk name matches as substring');
178
+ eq(forbiddenNames(PROJECT).includes('陈叔'), true, 'aliases are forbidden too');
179
+ }
180
+ {
181
+ const p = clone(PROJECT); p.characters[0].voice.prompt = 'x'.repeat(401);
182
+ breaks('voice', p, null, 'max 400');
183
+ const p2 = clone(PROJECT); p2.characters[0].voice.prompt += ' She says "lock the door".';
184
+ breaks('voice', p2, null, 'quoted line');
185
+ const p3 = clone(PROJECT); p3.characters[0].voice.timbre = '';
186
+ breaks('voice', p3, null, 'timbre');
187
+ }
188
+ {
189
+ const p = clone(PROJECT); p.characters[1].image.prompt = p.characters[0].image.prompt.replace('mid-twenties', 'thirties');
190
+ breaks('distinct', p, null, 'alike');
191
+ ok(jaccard('one two three four five six seven eight', 'one two three four five six seven nine') > 0.7, 'jaccard of near-identical sets is high');
192
+ eq(jaccard('short', 'short'), 0, 'short prompts do not count');
193
+ }
194
+
195
+ // ── art gates ───────────────────────────────────────────────────────────────
196
+ {
197
+ const p = clone(PROJECT); p.scenes[0].anchors.pop(); p.scenes[0].anchors.pop();
198
+ breaks('scene-design', p, null, 'need 3–5');
199
+ const p2 = clone(PROJECT); p2.scenes[0].lighting = [];
200
+ breaks('scene-design', p2, null, 'lighting');
201
+ const p3 = clone(PROJECT); p3.scenes[1].variantOf = 'S09';
202
+ breaks('scene-design', p3, null, 'does not exist');
203
+ const p4 = clone(PROJECT); p4.scenes[1].variantOf = 'S01'; p4.scenes[1].changes = '';
204
+ breaks('scene-design', p4, null, 'changes');
205
+ const p5 = clone(PROJECT); p5.scenes[0].lighting[0].prompt = '冷白光';
206
+ breaks('scene-design', p5, null, 'English');
207
+ }
208
+ {
209
+ const p = clone(PROJECT); p.scenes[0].image.negativePrompt = 'warped perspective';
210
+ breaks('scene-prompts', p, null, 'ban people');
211
+ const p2 = clone(PROJECT); p2.scenes[0].image.prompt = 'Interior of a store at night, cinematic film still';
212
+ breaks('scene-prompts', p2, null, 'empty');
213
+ const p3 = clone(PROJECT); p3.scenes[0].image.prompt += ' 老陈';
214
+ breaks('scene-prompts', p3, null, 'name');
215
+ }
216
+ {
217
+ const p = clone(PROJECT); p.props[0].scale = 'huge';
218
+ breaks('prop-design', p, null, 'scale');
219
+ const p2 = clone(PROJECT); p2.props[0].image.prompt = p2.props[0].image.prompt.replace('handheld scale', 'small');
220
+ breaks('prop-design', p2, null, 'handheld scale');
221
+ const p3 = clone(PROJECT); p3.props[0].image.negativePrompt = 'people, text';
222
+ breaks('prop-design', p3, null, 'hands');
223
+ const p4 = clone(PROJECT); p4.props[0].image.sheet = p4.props[0].image.sheet.replace(/white background/gi, 'grey background');
224
+ breaks('prop-design', p4, null, 'white background');
225
+ const p5 = clone(PROJECT); p5.props[0].states = [];
226
+ breaks('prop-design', p5, null, 'state');
227
+ }
228
+
229
+ // ── script gates ────────────────────────────────────────────────────────────
230
+ {
231
+ const params = paramsOf(PROJECT);
232
+ eq(lineSeconds('又是你值夜班。', params), 7 / 4.5, 'line seconds count non-whitespace chars incl. punctuation');
233
+ const est = episodeSeconds(E01, params);
234
+ ok(est > 51 && est < 69, `E01 estimate ${est} within ±15% of 60`);
235
+ const e = clone(E01); e.scenes[1].lighting = '正午';
236
+ breaks('script-shape', PROJECT, e, 'not a state');
237
+ const e2 = clone(E01); e2.scenes[0].sceneId = 'S99';
238
+ breaks('script-shape', PROJECT, e2, 'unknown scene');
239
+ const e3 = clone(E01); e3.scenes[0].characters.push('C88');
240
+ breaks('script-shape', PROJECT, e3, 'unknown character');
241
+ const e4 = clone(E01); e4.cliff = '';
242
+ breaks('script-shape', PROJECT, e4, 'cliff');
243
+ }
244
+ {
245
+ const e = clone(E01); e.scenes[0].flow[0] = { action: '她说「你是谁」' };
246
+ breaks('beat-flow', PROJECT, e, 'quotes');
247
+ const e2 = clone(E01); e2.scenes[0].flow[1].speaker = 'C02';
248
+ breaks('beat-flow', PROJECT, e2, 'not in this scene');
249
+ const e3 = clone(E01); e3.scenes[0].flow[1].line = '这句话实在是太长了太长了太长了太长了太长了太长了太长了太长了太长了太长了。';
250
+ breaks('beat-flow', PROJECT, e3, 'max 35');
251
+ const e4 = clone(E01); e4.scenes[0].flow = e4.scenes[0].flow.filter((b) => b.line !== undefined);
252
+ breaks('beat-flow', PROJECT, e4, 'radio play');
253
+ const e5 = clone(E01); e5.scenes[0].flow[0] = { action: 'x', line: 'y', speaker: 'C01' };
254
+ breaks('beat-flow', PROJECT, e5, 'not both');
255
+ const e6 = clone(E01); e6.scenes[1].flow[16].speaker = 'VO';
256
+ passes('beat-flow', PROJECT, e6);
257
+ }
258
+ {
259
+ const e = clone(E01); e.targetSeconds = 120;
260
+ breaks('duration', PROJECT, e, 'target 120');
261
+ const e2 = clone(E01); e2.scenes[1].flow.push(...Array(10).fill({ action: 'padding' }));
262
+ breaks('duration', PROJECT, e2, 'estimated');
263
+ }
264
+ {
265
+ const e = clone(E01); e.hookBeat = [1, 4];
266
+ breaks('hook-cliff', PROJECT, e, 'first 3');
267
+ const e2 = clone(E01); e2.hookBeat = [2, 1];
268
+ breaks('hook-cliff', PROJECT, e2, 'beat 6');
269
+ const e3 = clone(E01); e3.hookBeat = [9, 9];
270
+ breaks('hook-cliff', PROJECT, e3, 'does not point');
271
+ }
272
+ {
273
+ const e = clone(E01); e.beatsClaimed = [];
274
+ breaks('beats-claimed', PROJECT, e, 'not claimed: B01');
275
+ const e2 = clone(E01); e2.beatsClaimed = ['B01', 'B02'];
276
+ breaks('beats-claimed', PROJECT, e2, 'not in this episode');
277
+ }
278
+
279
+ // ── screenplay craft gates ──────────────────────────────────────────────────
280
+ {
281
+ // params.register drives the accent in the prompt language
282
+ eq(paramsOf(PROJECT).speechAccent, 'standard Mandarin', 'register derives an English accent for an English-prompt model');
283
+ const w = clone(PROJECT); w.params.video = 'wan';
284
+ eq(paramsOf(w).speechAccent, '标准普通话', 'register derives a Chinese accent for wan');
285
+ const explicit = clone(PROJECT); explicit.params.speechAccent = 'Beijing Mandarin';
286
+ eq(paramsOf(explicit).speechAccent, 'Beijing Mandarin', 'an explicit speechAccent wins');
287
+ eq(normalizeRegister('港式普通話'), '港式普通话', 'traditional spellings normalise');
288
+ const bad = clone(PROJECT); bad.params.register = '火星话';
289
+ breaks('params', bad, null, 'register');
290
+ const none = clone(PROJECT); delete none.params.register;
291
+ breaks('params', none, null, 'register');
292
+ ok(REGISTERS.every((r) => REGISTER_ACCENT[r]), 'every register has an accent');
293
+ }
294
+ {
295
+ const p = clone(PROJECT); delete p.outline.brief;
296
+ breaks('brief', p, null, 'brief is missing');
297
+ const p2 = clone(PROJECT); p2.outline.brief.mainHook = '';
298
+ breaks('brief', p2, null, 'mainHook');
299
+ const p3 = clone(PROJECT); p3.outline.brief.logline = '她说:「我不管」';
300
+ breaks('brief', p3, null, 'quotes');
301
+ const p4 = clone(PROJECT); p4.outline.brief.satisfactionEngine = '很爽';
302
+ breaks('brief', p4, null, 'satisfactionEngine');
303
+ }
304
+ {
305
+ const p = clone(PROJECT); p.outline.structure = [];
306
+ breaks('structure', p, null, 'structure is missing');
307
+ const p2 = clone(PROJECT); p2.outline.structure[1].episodes = [3, 3]; p2.outline.structure[2].episodes = [3, 3];
308
+ breaks('structure', p2, null, 'expected 2');
309
+ const p3 = clone(PROJECT); p3.outline.structure[0].episodes = [1, 2]; p3.outline.structure[1].episodes = [3, 3];
310
+ breaks('structure', p3, null, 'golden window ends at episode 2');
311
+ const p4 = clone(PROJECT); p4.outline.structure[0].task = '';
312
+ breaks('structure', p4, null, 'task is empty');
313
+ const p5 = clone(PROJECT); p5.outline.structure = [p5.outline.structure[2], p5.outline.structure[1], p5.outline.structure[0]];
314
+ breaks('structure', p5, null, 'in that order');
315
+ const long = clone(PROJECT); long.params.episodes = 10;
316
+ long.outline.structure = [{ phase: 'golden', episodes: [1, 2], task: 't', satisfactionPlan: 's', hookPlan: 'h' }, { phase: 'escalation', episodes: [3, 8], task: 't', satisfactionPlan: 's', hookPlan: 'h' }, { phase: 'climax', episodes: [9, 10], task: 't', satisfactionPlan: 's', hookPlan: 'h' }];
317
+ breaks('structure', long, null, 'first three episodes');
318
+ long.outline.structure[0].episodes = [1, 3]; long.outline.structure[1].episodes = [4, 8];
319
+ passes('structure', long, null);
320
+ }
321
+ {
322
+ const p = clone(PROJECT); delete p.outline.guideline;
323
+ breaks('guideline', p, null, 'guideline is missing');
324
+ const p2 = clone(PROJECT); p2.outline.guideline.forbidden = ['一条'];
325
+ breaks('guideline', p2, null, 'forbidden');
326
+ const p3 = clone(PROJECT); p3.outline.guideline.priorities = ['a', 'b', 'c', 'd', 'e', 'f'];
327
+ breaks('guideline', p3, null, 'priorities');
328
+ const p4 = clone(PROJECT); p4.outline.guideline.tone = '{{整体风格}}';
329
+ breaks('guideline', p4, null, 'placeholders');
330
+ }
331
+ {
332
+ const p = clone(PROJECT); p.outline.episodes[0].function = [];
333
+ breaks('episode-design', p, null, 'function is empty');
334
+ const p2 = clone(PROJECT); p2.outline.episodes[0].function = ['情感推进'];
335
+ breaks('episode-design', p2, null, '开局钩子');
336
+ const p3 = clone(PROJECT); p3.outline.episodes[1].satisfaction.type = '强宠';
337
+ breaks('episode-design', p3, null, 'episode two proves');
338
+ const p4 = clone(PROJECT); p4.outline.episodes[2].function = ['情感推进'];
339
+ breaks('episode-design', p4, null, 'episode three');
340
+ const p5 = clone(PROJECT); p5.outline.episodes[1].task.stake = '';
341
+ breaks('episode-design', p5, null, 'task.stake');
342
+ const p6 = clone(PROJECT); p6.outline.episodes[1].change = '';
343
+ breaks('episode-design', p6, null, 'change is empty');
344
+ const p7 = clone(PROJECT); p7.outline.episodes[1].hookType = '悬念';
345
+ breaks('episode-design', p7, null, 'hookType');
346
+ const p8 = clone(PROJECT); p8.outline.episodes[1].satisfaction.action = '';
347
+ breaks('episode-design', p8, null, 'satisfaction.action');
348
+ }
349
+ {
350
+ const p = clone(PROJECT); delete p.characters[0].bible;
351
+ breaks('bible', p, null, 'bible is missing');
352
+ const p2 = clone(PROJECT); p2.characters[0].bible.need = p2.characters[0].bible.want;
353
+ breaks('bible', p2, null, 'same sentence');
354
+ const p3 = clone(PROJECT); p3.characters[0].bible.forbidden.pop();
355
+ breaks('bible', p3, null, 'exactly 3');
356
+ const p4 = clone(PROJECT); p4.characters[0].bible.arc = p4.characters[0].bible.arc.filter((st) => st.stage !== '高潮选择');
357
+ breaks('bible', p4, null, '高潮选择');
358
+ const p5 = clone(PROJECT); p5.characters[1].bible.speech.rhythm = '';
359
+ breaks('bible', p5, null, 'speech.rhythm');
360
+ const p6 = clone(PROJECT); p6.characters[2].bible = { function: '路人' };
361
+ breaks('bible', p6, null, 'bible.function');
362
+ const p7 = clone(PROJECT); p7.characters[2].bible = { function: '反派' };
363
+ passes('bible', p7, null);
364
+ const p8 = clone(PROJECT); p8.characters[0].bible.pastChoices.pop();
365
+ breaks('bible', p8, null, 'pastChoices');
366
+ const p9 = clone(PROJECT); p9.characters[0].bible.arc[0].episode = 9;
367
+ breaks('bible', p9, null, 'outside 1–3');
368
+ const p10 = clone(PROJECT); p10.characters[1].bible.speech.register = '粵語';
369
+ passes('bible', p10, null);
370
+ }
371
+ {
372
+ const p = clone(PROJECT); p.characters[0].profile.relationships = [];
373
+ breaks('relationships', p, null, 'a lead needs');
374
+ const p2 = clone(PROJECT); p2.characters.forEach((c) => c.profile.relationships.forEach((r) => (r.secret = '')));
375
+ breaks('relationships', p2, null, 'secret');
376
+ const p3 = clone(PROJECT); p3.characters[0].profile.relationships[0].id = 'C77';
377
+ breaks('relationships', p3, null, 'C77');
378
+ const p4 = clone(PROJECT); p4.characters[0].profile.relationships[0].turnEpisode = 8;
379
+ breaks('relationships', p4, null, 'turnEpisode');
380
+ }
381
+ {
382
+ const e = clone(E01); e.function = [];
383
+ breaks('script-design', PROJECT, e, 'function is empty');
384
+ const e2 = clone(E01); e2.function = ['大反转'];
385
+ breaks('script-design', PROJECT, e2, 'differs from the outline');
386
+ const e3 = clone(E01); e3.satisfactionBeat = null;
387
+ breaks('script-design', PROJECT, e3, 'satisfactionBeat');
388
+ const e4 = clone(E01); e4.satisfactionBeat = [2, 4]; // a line between two lines
389
+ breaks('script-design', PROJECT, e4, 'no action beat beside it');
390
+ const e5 = clone(E01); e5.satisfactionBeat = [2, 8]; // a line next to an action
391
+ passes('script-design', PROJECT, e5);
392
+ const e6 = clone(E01); e6.task.goal = '';
393
+ breaks('script-design', PROJECT, e6, 'task.goal');
394
+ const e7 = clone(E01); e7.hookType = '危机';
395
+ breaks('script-design', PROJECT, e7, 'differs from the outline');
396
+ }
397
+ {
398
+ const params = paramsOf(PROJECT);
399
+ const offsets = beatOffsets(E01, params);
400
+ eq(offsets.length, 24, 'beatOffsets lists every beat');
401
+ ok(Math.abs(offsets[5].start - 12.6) < 0.05, 'beat offsets accumulate across scenes');
402
+ const points = turnPoints(E01, params);
403
+ eq(points[0].what, 'hook', 'turn points start at the hook');
404
+ eq(points[points.length - 1].what, 'cliff', 'turn points end at the cliff');
405
+ const e = clone(E01); e.scenes[1].flow.forEach((b) => delete b.turn);
406
+ breaks('rhythm', PROJECT, e, 'no turn');
407
+ const e2 = clone(E01); e2.hookBeat = [2, 3]; e2.scenes[0].flow.forEach((b) => delete b.turn);
408
+ breaks('rhythm', PROJECT, e2, 'hook image lands');
409
+ const e3 = clone(E01); e3.scenes[1].flow.splice(5, 1); // remove the action between b5 and b7 → 5 lines in a row
410
+ breaks('rhythm', PROJECT, e3, 'in a row');
411
+ const e4 = clone(E01); e4.satisfactionBeat = [2, 19];
412
+ breaks('rhythm', PROJECT, e4, 'last beat is the cliff');
413
+ const e5 = clone(E01); e5.satisfactionBeat = [1, 1];
414
+ breaks('rhythm', PROJECT, e5, 'before or on the hook');
415
+ const e6 = clone(E01); e6.scenes.forEach((sc) => (sc.flow = sc.flow.filter((b, i) => b.line !== undefined || i === 0)));
416
+ const loose = clone(PROJECT); loose.params.maxConsecutiveLines = 10;
417
+ breaks('rhythm', loose, e6, '%');
418
+ const ta = talkAndAction(E01);
419
+ eq(ta.longestLineRun, 3, 'longest run of lines in the fixture is 3');
420
+ eq(parseAge('六十岁出头'), 60, 'parseAge reads Chinese numerals');
421
+ eq(parseAge('24 岁左右'), 24, 'parseAge reads digits');
422
+ eq(parseAge('四十上下'), 40, 'parseAge reads 四十');
423
+ eq(parseAge('中年'), null, 'parseAge gives null when there is no number');
424
+ }
425
+ {
426
+ const e = clone(E01); e.scenes[1].flow[5].action = '老陈心想,这袋子不简单。';
427
+ breaks('filmable', PROJECT, e, 'novel prose');
428
+ const e2 = clone(E01); e2.scenes[1].flow[5].action = '老陈似乎认出了那个袋子。';
429
+ breaks('filmable', PROJECT, e2, 'inner state');
430
+ const e3 = clone(E01); e3.scenes[1].flow[8].action = '老陈拿起碗,放下筷子,递过纸巾,接过袋子,握住她的手。';
431
+ breaks('filmable', PROJECT, e3, 'hand verbs');
432
+ const e4 = clone(E01); e4.scenes[1].flow[8].action = '老陈把碗放下,伸手想拿袋子。';
433
+ passes('filmable', PROJECT, e4);
434
+ }
435
+ {
436
+ // dialogue-register is advisory: it reports, validate still passes
437
+ const g = GATES.find((x) => x.id === 'dialogue-register');
438
+ ok(g.advisory === true, 'dialogue-register is advisory');
439
+ const e = clone(E01); e.scenes[1].flow[2].line = '你又嚟值夜班。';
440
+ const notes = failures('dialogue-register', PROJECT, e);
441
+ ok(notes.some((m) => m.includes('Cantonese')), 'a Cantonese marker in a 大陆普通话 line is noted');
442
+ const results = runGates(makeContext(PROJECT, { 1: e }));
443
+ const adv = results.find((r) => r.id === 'dialogue-register');
444
+ ok(adv.advisory && adv.failures.length === 0 && adv.warnings.length > 0, 'advisory findings come back as warnings, not failures');
445
+ ok(results.filter((r) => !r.advisory && r.stage !== 'storyboard').every((r) => !r.failures.length), 'a register note never fails validate (the storyboard is stale on purpose here)');
446
+ const p2 = clone(PROJECT); p2.params.register = '粤语';
447
+ ok(failures('dialogue-register', p2, E01).some((m) => m.includes('书面中文硬转')), 'a 粤语 drama with written-Chinese lines is noted');
448
+ const p3 = clone(PROJECT); p3.params.register = '古装';
449
+ const e3 = clone(E01); e3.scenes[1].flow[2].line = 'OK,又是你值夜班。';
450
+ ok(failures('dialogue-register', p3, e3).some((m) => m.includes('modern speech')), 'a modern word in 古装 is noted');
451
+ const e4 = clone(E01); e4.scenes[1].flow[2].line = '绝了,又是你值夜班。'; // C02 is 60
452
+ ok(failures('dialogue-register', PROJECT, e4).some((m) => m.includes('网络用语')), 'net slang on a sixty-year-old is noted');
453
+ const e5 = clone(E01); e5.scenes[1].flow[3].line = '绝了,陈叔你怎么天天来。'; // C01 is 24
454
+ ok(!failures('dialogue-register', PROJECT, e5).some((m) => m.includes('网络用语')), 'net slang on a twenty-four-year-old is fine');
455
+ const p6 = clone(PROJECT); p6.params.register = '英文美式';
456
+ const e6 = clone(E01); e6.scenes[1].flow[2].line = 'I am telling you right now that this is absolutely not something I am going to keep doing every single night.';
457
+ const en = failures('dialogue-register', p6, e6);
458
+ ok(en.some((m) => m.includes('words')), 'an over-long English line is noted');
459
+ ok(en.some((m) => m.includes('uncontracted')), 'uncontracted English is noted');
460
+ const e7 = clone(E01); e7.scenes[1].flow[3].delivery = '生气';
461
+ ok(failures('dialogue-register', PROJECT, e7).some((m) => m.includes('emotion')), 'an emotion-only delivery is noted');
462
+ const p8 = clone(PROJECT); p8.characters[1].bible.speech.register = '粤语';
463
+ const e8 = clone(E01); e8.scenes[1].flow[2].line = '你又嚟值夜班。';
464
+ ok(!failures('dialogue-register', p8, e8).some((m) => m.includes('Cantonese')), 'a per-character register override allows Cantonese');
465
+ }
466
+
467
+ // ── storyboard gates ────────────────────────────────────────────────────────
468
+ {
469
+ const e = clone(E01); e.storyboard.segments[1].id = 'E01-09';
470
+ breaks('segments', PROJECT, e, 'expected id E01-02');
471
+ const e2 = clone(E01); e2.storyboard.segments[0].cuts[0].beats = [1, 2];
472
+ breaks('segments', PROJECT, e2, 'claimed twice');
473
+ const e3 = clone(E01); e3.storyboard.segments[0].cuts.pop();
474
+ breaks('segments', PROJECT, e3, 'unclaimed');
475
+ const e4 = clone(E01); e4.storyboard.segments[0].sceneIndex = 5;
476
+ breaks('segments', PROJECT, e4, 'does not exist');
477
+ const e5 = clone(E01); e5.storyboard.segments = [];
478
+ breaks('segments', PROJECT, e5, 'no segments');
479
+ }
480
+ {
481
+ const e = clone(E01); e.storyboard.segments[0].cuts[0].seconds = 6;
482
+ breaks('timing', PROJECT, e, '2–5');
483
+ const e2 = clone(E01); e2.storyboard.segments[1].cuts[2].seconds = 2;
484
+ breaks('timing', PROJECT, e2, 'does not fit');
485
+ const e3 = clone(E01); e3.storyboard.segments[0].cuts.forEach((c) => (c.seconds = 5));
486
+ breaks('timing', PROJECT, e3, 'must be 5–15 s (minimax-h3-ref)');
487
+ // Under the model's floor is a failure too, not only over its ceiling.
488
+ const e3b = clone(E01); e3b.storyboard.segments[0].cuts = [{ ...e3b.storyboard.segments[0].cuts[0], seconds: 2, beats: [1] }];
489
+ breaks('timing', PROJECT, e3b, 'must be 5–15 s');
490
+ const e4 = clone(E01); e4.targetSeconds = 30;
491
+ breaks('timing', PROJECT, e4, 'storyboard totals');
492
+ const sc = E01.scenes[1];
493
+ ok(Math.abs(dialogueSeconds(sc, [3, 4], paramsOf(PROJECT)) - (7 + 12) / 4.5) < 1e-9, 'dialogue seconds sum the claimed lines');
494
+ eq(cutStarts([{ seconds: 3 }, { seconds: 2 }, { seconds: 5 }]).join(','), '0,3,5', 'cut starts are cumulative');
495
+ eq(timestamp(63.5), '01:03.500', 'timestamp format mm:ss.mmm');
496
+ }
497
+ {
498
+ const e = clone(E01); e.storyboard.segments[0].cuts[0].size = 'huge';
499
+ breaks('cut-content', PROJECT, e, 'size');
500
+ const e2 = clone(E01); e2.storyboard.segments[0].cuts[0].camera = 'Dolly Zoom';
501
+ breaks('cut-content', PROJECT, e2, 'vocabulary');
502
+ ok(CAMERAS.length === 20, '20 camera words');
503
+ const e3 = clone(E01); e3.storyboard.segments[0].cuts[0].characters = ['C02'];
504
+ breaks('cut-content', PROJECT, e3, 'not in this scene');
505
+ const e4 = clone(E01); e4.storyboard.segments[1].cuts[0].characters = ['C01', 'C02', 'C01', 'C02'];
506
+ breaks('cut-content', PROJECT, e4, 'on screen');
507
+ const e5 = clone(E01); e5.storyboard.segments[0].cuts[0].frame = e5.storyboard.segments[0].cuts[0].frame.replace('medium shot', 'shot');
508
+ breaks('cut-content', PROJECT, e5, 'medium shot');
509
+ const e6 = clone(E01); e6.storyboard.segments[0].cuts[0].frame = e6.storyboard.segments[0].cuts[0].frame.replace('cinematic film still', 'photo');
510
+ breaks('cut-content', PROJECT, e6, 'cinematic film still');
511
+ const e7 = clone(E01); e7.storyboard.segments[0].cuts[0].frame += ' 林小夏';
512
+ breaks('cut-content', PROJECT, e7, 'name');
513
+ const p = clone(PROJECT); p.params.style = 'ghibli';
514
+ breaks('cut-content', p, E01, STYLE_PRESETS.ghibli.framePhrase);
515
+ }
516
+ {
517
+ // H3 mode
518
+ const seg = E01.storyboard.segments[0];
519
+ eq(h3AlignmentLine(seg.cuts), seg.prompt.split('\n')[0], 'alignment line is derived from the cuts');
520
+ eq(h3AlignmentLine([{ seconds: 4 }]), 'For the target video, at 0.00 seconds into the target video, @image1 (from [Shot 1]) is fully referenced.', 'single-cut alignment sentence');
521
+ const e = clone(E01); e.storyboard.segments[0].cuts[1].seconds = 4; e.storyboard.segments[0].cuts[2].seconds = 2;
522
+ breaks('video-prompt', PROJECT, e, 'alignment line');
523
+ const e2 = clone(E01); e2.storyboard.segments[0].prompt = e2.storyboard.segments[0].prompt.replace('<d>[Chinese] 帮我收着。明天有人来拿。</d>', 'he asks her to keep it');
524
+ breaks('video-prompt', PROJECT, e2, 'verbatim');
525
+ const e3 = clone(E01); e3.storyboard.segments[0].prompt = e3.storyboard.segments[0].prompt.replace('[Shot 3] At 00:07.000,', '[Shot 3] At 00:08.000,');
526
+ breaks('video-prompt', PROJECT, e3, 'At 00:07.000');
527
+ const e4 = clone(E01); e4.storyboard.segments[0].prompt = e4.storyboard.segments[0].prompt.replace('overall_soundscape:', 'sounds:');
528
+ breaks('video-prompt', PROJECT, e4, 'overall_soundscape');
529
+ const e5 = clone(E01); e5.storyboard.segments[0].prompt = e5.storyboard.segments[0].prompt.replace('a tracking shot follows', 'the camera follows');
530
+ breaks('video-prompt', PROJECT, e5, 'Tracking Shot');
531
+ const e6 = clone(E01); e6.storyboard.segments[0].prompt = e6.storyboard.segments[0].prompt.replace('a young night-shift clerk', '林小夏');
532
+ breaks('video-prompt', PROJECT, e6, 'name');
533
+ const e7 = clone(E01); e7.storyboard.segments[0].prompt = '';
534
+ breaks('video-prompt', PROJECT, e7, 'empty');
535
+ const lines = claimedLines(E01.scenes[0], seg.cuts);
536
+ eq(lines.length, 2, 'segment 1 claims two lines');
537
+ eq(lines[0].speaker, 'C03', 'first claimed line is the stranger');
538
+ }
539
+ {
540
+ // Seedance mode
541
+ const p = clone(PROJECT); p.params.video = 'seedance'; p.params.videoResolution = '720p';
542
+ const e = clone(E01);
543
+ const seg = e.storyboard.segments[3]; // 4 cuts
544
+ const lines = claimedLines(e.scenes[1], seg.cuts).map((l) => l.line);
545
+ const C02_DIRECTION = PROJECT.characters.find((c) => c.id === 'C02').voice.videoDirection;
546
+ seg.prompt = [...seedanceAnchors(4), 'This first frame defines the clerk behind the counter and the older man facing the doors, a wide shot with a static shot.', '@image2 defines the second keyframe: a close-up from behind him with a static shot.', '@image3 defines the third keyframe: a slow pan left as he turns, medium shot.', 'This last frame defines his grave face in close-up, a slow push in.', '@audio1 is the voice of the stocky man in his sixties.',
547
+ `@audio1, ${C02_DIRECTION}, says: ${lines[0]} Then @audio1 says: ${lines[1]}`, 'Sound: all dialogue in standard Mandarin, no subtitles.'].join('\n');
548
+ const ep = { 1: e };
549
+ const f = GATES.find((g) => g.id === 'video-prompt').run(makeContext(p, ep));
550
+ const segFails = f.filter((m) => m.startsWith('E01-04'));
551
+ ok(segFails.length === 0, `seedance prompt passes: ${segFails.join(' | ')}`);
552
+ eq(seedanceAnchors(1).length, 1, 'single keyframe only needs the first-frame sentence');
553
+ const e2 = clone(e); e2.storyboard.segments[3].prompt = e2.storyboard.segments[3].prompt.replace('Use @image4 as the last frame.', '');
554
+ breaks('video-prompt', p, e2, 'Use @image4 as the last frame.');
555
+ const e3 = clone(e); e3.storyboard.segments[3].prompt += '\n@image7 is unused.';
556
+ breaks('video-prompt', p, e3, '@image7');
557
+ const e4 = clone(e); e4.storyboard.segments[3].prompt = e4.storyboard.segments[3].prompt.replace(lines[1], 'something else');
558
+ breaks('video-prompt', p, e4, 'verbatim');
559
+ const e5 = clone(e); e5.storyboard.segments[3].prompt = e5.storyboard.segments[3].prompt.replace('slow pan left', 'camera swings');
560
+ breaks('video-prompt', p, e5, 'Pan Left');
561
+ }
562
+
563
+ {
564
+ // Wan 3.0 Ref mode — Chinese prompt, 图N legend, integer duration
565
+ const p = clone(PROJECT); p.params.video = 'wan'; p.params.speechAccent = '台湾口音国语'; p.params.videoResolution = '480P';
566
+ const e = clone(E01);
567
+ const seg = e.storyboard.segments[3]; // 4 cuts, 12 s
568
+ const lines = claimedLines(e.scenes[1], seg.cuts).map((l) => l.line);
569
+ const C02_ZH = speechDirection(PROJECT.characters.find((c) => c.id === 'C02'), '', 'zh');
570
+ seg.prompt = [
571
+ wanLegendLine(seg.cuts, segmentReferences(p, e, seg)), '',
572
+ `${wanShotHeader(seg.cuts, 0)}店内全景,年轻店员站在收银台后,一名年长男子背对镜头。`,
573
+ `台词(@audio1,即@image1中的年长男子,${C02_ZH}):「${lines[0]}」`, '',
574
+ `${wanShotHeader(seg.cuts, 1)}他的手推过一个纸袋。`, '',
575
+ `${wanShotHeader(seg.cuts, 2)}他转过身来。`, '',
576
+ `${wanShotHeader(seg.cuts, 3)}他的脸靠近。`,
577
+ `台词(@audio1,即@image4中的年长男子,${C02_ZH}):「${lines[1]}」`, '',
578
+ '整体:凌晨便利店,冷白灯光。',
579
+ '音频:对白为台湾口音国语,冷气柜嗡嗡声。',
580
+ ].join('\n');
581
+ const ep = { 1: e };
582
+ const fails = GATES.find((g) => g.id === 'video-prompt').run(makeContext(p, ep)).filter((m) => m.startsWith('E01-04'));
583
+ ok(fails.length === 0, `wan prompt passes: ${fails.join(' | ')}`);
584
+ eq(paramsOf(p).maxSegmentSeconds, 30, 'wan raises the segment cap to 30 s');
585
+ const e2 = clone(e); e2.storyboard.segments[3].prompt = e2.storyboard.segments[3].prompt.replace(wanLegendLine(seg.cuts, segmentReferences(p, e, seg)), '参考图说明:随便用。');
586
+ breaks('video-prompt', p, e2, 'reference legend');
587
+ const e3 = clone(e); e3.storyboard.segments[3].prompt = e3.storyboard.segments[3].prompt.replace(wanShotHeader(seg.cuts, 2), '镜头3:');
588
+ breaks('video-prompt', p, e3, wanShotHeader(seg.cuts, 2));
589
+ const e4 = clone(e); e4.storyboard.segments[3].prompt = e4.storyboard.segments[3].prompt.replace(lines[1], '随便说点别的。');
590
+ breaks('video-prompt', p, e4, 'verbatim');
591
+ const e5 = clone(e); e5.storyboard.segments[3].prompt = e5.storyboard.segments[3].prompt.replace('音频:', '声音:');
592
+ breaks('video-prompt', p, e5, '音频');
593
+ const e6 = clone(e); e6.storyboard.segments[3].prompt = e6.storyboard.segments[3].prompt.replace('台湾口音国语', '普通话');
594
+ breaks('video-prompt', p, e6, 'accent');
595
+ const e7 = clone(e); e7.storyboard.segments[3].prompt = e7.storyboard.segments[3].prompt.replace('年长男子背对镜头', '老陈背对镜头');
596
+ breaks('video-prompt', p, e7, 'name');
597
+ // Fractional seconds only matter when a duration is actually sent; `auto` lets the model pick.
598
+ const pFixed = clone(p); pFixed.params.videoDuration = 'fixed';
599
+ const e8 = clone(e); e8.storyboard.segments[3].cuts[0].seconds = 3.5;
600
+ breaks('timing', pFixed, e8, 'whole seconds');
601
+ const autoFails = GATES.find((g) => g.id === 'timing').run(makeContext(p, { 1: e8 })).filter((m) => m.includes('whole seconds'));
602
+ ok(autoFails.length === 0, 'auto duration does not demand whole seconds');
603
+ const sk = promptSkeleton(p, e, 'E01-04');
604
+ eq(sk.video.model, 'wan-3.0-ref', 'wan model');
605
+ eq(sk.video.resolution, '480P', 'skeleton carries the output resolution');
606
+ ok(sk.video.prompt.startsWith(wanLegendLine(seg.cuts, segmentReferences(p, e, seg))), 'wan skeleton starts with the legend, voices included');
607
+ ok(sk.video.prompt.includes('@audio1'), 'and the wan legend carries the voice reference');
608
+ ok(sk.video.prompt.includes('台湾口音国语'), 'wan skeleton names the accent');
609
+ }
610
+
611
+ {
612
+ // Style presets are self-consistent: a preset never demands and forbids the same word
613
+ for (const [name, preset] of Object.entries(STYLE_PRESETS)) {
614
+ ok(preset.render && preset.framePhrase, `${name}: render + framePhrase present`);
615
+ for (const w of preset.negativeMust) ok(!preset.negativeMustNot.includes(w), `${name}: "${w}" is not both required and forbidden`);
616
+ }
617
+ eq(STYLE_PRESETS.photoreal.framePhrase, 'photorealistic cinematic film still', 'photoreal frame phrase');
618
+ }
619
+
620
+
621
+ {
622
+ // Sheets reference mode: no keyframes, the video node references the sheets, duration auto
623
+ const p = clone(PROJECT); p.params.video = 'seedance'; p.params.videoResolution = '720p'; p.params.references = 'sheets'; p.params.videoDuration = 'auto'; p.params.lookPhrase = 'shallow depth of field';
624
+ const e = clone(E01);
625
+ const seg = e.storyboard.segments[3];
626
+ const refs = segmentReferences(p, e, seg);
627
+ ok(refs.length >= 2, 'segment references at least the scene and one person');
628
+ eq(refs[0].entity, 'S01', 'first sheet reference is the environment');
629
+ eq(refs[0].label, '@image1', 'sheet references are labelled @imageN');
630
+ const lines = claimedLines(e.scenes[1], seg.cuts).map((l) => l.line);
631
+ const legend = refs.map((r) => `${r.label} is ${r.use}.`).join('\n');
632
+ const dirC02 = speechDirection(PROJECT.characters.find((c) => c.id === 'C02'), '', 'en');
633
+ seg.prompt = [legend, 'Shot 1: a wide shot with a static shot of the clerk and the older man.', 'Shot 2: a close-up, static shot, from behind him.', 'Shot 3: a slow pan left as he turns, medium shot.', 'Shot 4: a slow push in on his grave face.', '@audio1 is the voice of the person in @image3.',
634
+ `@audio1 / @image3, ${dirC02}, says: ${lines[0]} Then @audio1 says: ${lines[1]}`, 'Look: semi-realistic painterly cinematic footage, shallow depth of field.', 'Sound: all dialogue in standard Mandarin.'].join('\n');
635
+ const ep = { 1: e };
636
+ const fails = GATES.find((g) => g.id === 'video-prompt').run(makeContext(p, ep)).filter((m) => m.startsWith('E01-04'));
637
+ ok(fails.length === 0, `sheets-mode prompt passes: ${fails.join(' | ')}`);
638
+ const e2 = clone(e); e2.storyboard.segments[3].prompt = e2.storyboard.segments[3].prompt.replace('@image1 is', '@image9 is');
639
+ breaks('video-prompt', p, e2, '@image1');
640
+ const e3 = clone(e); e3.storyboard.segments[3].prompt += '\nUse @image1 as the first frame.';
641
+ breaks('video-prompt', p, e3, 'keyframe anchor');
642
+ const e4 = clone(e); e4.storyboard.segments[3].prompt = e4.storyboard.segments[3].prompt.replace('shallow depth of field', 'deep focus');
643
+ breaks('video-prompt', p, e4, 'look phrase');
644
+ const e5 = clone(e); e5.storyboard.segments[3].prompt = e5.storyboard.segments[3].prompt.replace('semi-realistic painterly cinematic footage', 'footage');
645
+ breaks('video-prompt', p, e5, 'semi-realistic painterly cinematic footage');
646
+ const p2 = clone(p); p2.params.video = 'h3'; p2.params.videoResolution = '768P';
647
+ breaks('params', p2, e, 'auto duration'); // h3 accepts sheets but not auto duration
648
+ const sk = promptSkeleton(p, e, 'E01-04');
649
+ eq(sk.keyframes.length, 0, 'sheets mode makes no keyframes');
650
+ eq(sk.video.duration, null, 'auto duration passes no -d');
651
+ eq(sk.video.plannedSeconds, 12, 'planned seconds still reported');
652
+ ok(sk.video.references[0] === 'S01:sheet', 'video references are sheet keys');
653
+ ok(sk.video.prompt.includes('@image1 is the environment'), 'skeleton legend describes the sheets');
654
+ ok(sk.video.prompt.includes('shallow depth of field'), 'skeleton carries the look phrase');
655
+ const cutFails = GATES.find((g) => g.id === 'cut-content').run(makeContext(p, ep));
656
+ ok(cutFails.length === 0, `frame phrase not demanded in sheets mode: ${cutFails.join(' | ')}`);
657
+ const m = manifest(p, ep, { ep: 1 });
658
+ ok(!m.some((it) => it.item.includes('keyframes')), 'manifest lists no keyframes in sheets mode');
659
+ ok(m.some((it) => it.hint.includes('sheet display nodes')), 'manifest points the video node at the sheet display nodes');
660
+ }
661
+
662
+
663
+ {
664
+ // Provider policy: Seedance refuses photoreal people as references
665
+ const p = clone(PROJECT); p.params.video = 'seedance'; p.params.videoResolution = '720p'; p.params.style = 'photoreal';
666
+ breaks('params', p, E01, 'real-person');
667
+ p.params.references = 'sheets'; p.params.humanReferences = false;
668
+ const f = GATES.find((g) => g.id === 'params').run(makeContext(p, { 1: E01 }));
669
+ ok(f.length === 0, `seedance + photoreal passes with sheets and no human references: ${f.join(' | ')}`);
670
+ const p3 = clone(p); p3.params.humanReferences = true;
671
+ ok(GATES.find((g) => g.id === 'params').run(makeContext(p3, { 1: E01 })).length === 0, 'an explicit humanReferences: true (permission enabled) also passes');
672
+ const refs = segmentReferences(p, E01, E01.storyboard.segments[3]);
673
+ // humanReferences is about PICTURES of people — the provider's real-person check looks at images.
674
+ // The voices are audio references and are unaffected.
675
+ ok(refs.filter((r) => r.kind === 'image').every((r) => !/^C\d/.test(r.entity)), 'no character sheet is referenced when humanReferences is false');
676
+ ok(refs.some((r) => r.kind === 'audio' && /^C\d/.test(r.entity)), 'the speakers keep their voice references');
677
+ eq(refs[0].entity, 'S01', 'environment still first');
678
+ }
679
+
680
+
681
+ {
682
+ // H3 in sheets mode: "Image k" legend instead of the alignment line, no <Picture k> anchors
683
+ const p = clone(PROJECT); p.params.references = 'sheets'; p.params.lookPhrase = 'shallow depth of field';
684
+ const e = clone(E01);
685
+ const seg = e.storyboard.segments[3];
686
+ const n = segmentReferences(p, e, seg).length;
687
+ const lines = claimedLines(e.scenes[1], seg.cuts).map((l) => l.line);
688
+ const legend = [...Array.from({ length: n }, (_, i) => `@image${i + 1} is a reference sheet.`), '@audio1 is the voice of the person in @image3.'].join('\n');
689
+ const dir = speechDirection(PROJECT.characters.find((c) => c.id === 'C02'), '', 'en');
690
+ seg.prompt = [legend, '', 'Look: semi-realistic painterly cinematic footage, shallow depth of field.', '', 'integrated_multimodal_description:',
691
+ `[Shot 1] the clerk and the older man, a wide shot, static shot. @audio1 / @image3, ${dir}, says <d>[Chinese] ${lines[0]}</d>`,
692
+ '[Shot 2] At 00:02.000, from behind him, close-up, static shot.',
693
+ '[Shot 3] At 00:06.000, he turns, a slow pan left, medium shot.',
694
+ `[Shot 4] At 00:09.000, his grave face, a slow push in. @audio1 says <d>[Chinese] ${lines[1]}</d>`, '', 'overall_soundscape: night store hum, dialogue in standard Mandarin', '', 'non_diegetic_music: N/A'].join('\n');
695
+ const ep = { 1: e };
696
+ const fails = GATES.find((g) => g.id === 'video-prompt').run(makeContext(p, ep)).filter((m) => m.startsWith('E01-04'));
697
+ ok(fails.length === 0, `h3 sheets-mode prompt passes: ${fails.join(' | ')}`);
698
+ const e2 = clone(e); e2.storyboard.segments[3].prompt = e2.storyboard.segments[3].prompt.replace('@image1 is', '@image7 is');
699
+ breaks('video-prompt', p, e2, '@image1');
700
+ const e3 = clone(e); e3.storyboard.segments[3].prompt = h3AlignmentLine(seg.cuts) + '\n' + e3.storyboard.segments[3].prompt;
701
+ breaks('video-prompt', p, e3, 'alignment wording');
702
+ const p2 = clone(p); p2.params.videoDuration = 'auto';
703
+ breaks('params', p2, e, 'auto duration');
704
+ const sk = promptSkeleton(p, e, 'E01-04');
705
+ ok(sk.video.prompt.startsWith('@image1 is'), 'h3 sheets skeleton opens with the @image legend');
706
+ ok(!/<Picture|\bPicture \d/.test(sk.video.prompt), 'h3 sheets skeleton has no picture anchors');
707
+ ok(sk.video.prompt.includes('[Shot 2] At 00:02.000,'), 'h3 sheets skeleton keeps the shot timestamps');
708
+ }
709
+
710
+
711
+ {
712
+ // resolution spelling follows the model form; the wan skeleton stays short
713
+ const p = clone(PROJECT); p.params.video = 'seedance'; p.params.videoResolution = '480P';
714
+ eq(promptSkeleton(p, E01, 'E01-04').video.resolution, '480p', 'seedance wants lowercase resolution');
715
+ const p2 = clone(PROJECT); p2.params.video = 'wan'; p2.params.videoResolution = '480P';
716
+ const sk = promptSkeleton(p2, E01, 'E01-04');
717
+ eq(sk.video.resolution, '480P', 'wan keeps uppercase resolution');
718
+ ok(!sk.video.prompt.includes('\u6574\u4f53\uff1a'), 'wan skeleton has no 整体 paragraph');
719
+ }
720
+
721
+
722
+ {
723
+ // Per-model duration: the model decides how long a segment may be, and the plot is cut to fit it.
724
+ eq(VIDEO_MODES.join(' '), 'h3 seedance seedance2 seedance2fast wan', 'five ref modes');
725
+ for (const [key, m] of Object.entries(VIDEO_MODELS)) {
726
+ ok(m.model && m.family && m.promptLang, `${key} is fully described`);
727
+ ok(m.minSegmentSeconds >= 2 && m.minSegmentSeconds < m.maxSegmentSeconds, `${key} has a sane duration range`);
728
+ ok(Array.isArray(m.resolutions) && m.resolutions.length, `${key} lists its resolution tiers`);
729
+ }
730
+ eq(VIDEO_MODELS.wan.maxSegmentSeconds, 30, 'wan-3.0-ref: 30 s');
731
+ eq(VIDEO_MODELS.seedance.maxSegmentSeconds, 30, 'seedance-2.5-ref: 30 s');
732
+ eq(VIDEO_MODELS.h3.maxSegmentSeconds, 15, 'minimax-h3-ref: 15 s');
733
+ eq(VIDEO_MODELS.seedance2.maxSegmentSeconds, 15, 'seedance-2.0-ref: 15 s');
734
+ eq(VIDEO_MODELS.seedance2fast.maxSegmentSeconds, 15, 'seedance-2.0-fast-ref: 15 s');
735
+ eq(VIDEO_MODELS.seedance2.model, 'seedance-2.0-ref', 'seedance2 maps to the 2.0 ref model');
736
+ eq(VIDEO_MODELS.seedance2fast.model, 'seedance-2.0-fast-ref', 'seedance2fast maps to the 2.0 fast ref model');
737
+ eq(VIDEO_MODELS.seedance2.family, 'seedance', '2.0 shares the Seedance prompt dialect');
738
+ // Defaults: auto duration and no storyboard keyframes.
739
+ eq(DEFAULT_PARAMS.videoDuration, 'auto', 'auto duration is the default');
740
+ eq(DEFAULT_PARAMS.references, 'sheets', 'sheets (no keyframe images) is the default');
741
+ // … except where the model has no autoDuration parameter, where it silently becomes fixed.
742
+ const h3p = clone(PROJECT); delete h3p.params.videoDuration;
743
+ eq(paramsOf(h3p).videoDuration, 'fixed', 'h3 falls back to a fixed duration');
744
+ const wanp = clone(PROJECT); wanp.params.video = 'wan'; wanp.params.videoResolution = '480P'; delete wanp.params.videoDuration;
745
+ eq(paramsOf(wanp).videoDuration, 'auto', 'wan keeps auto');
746
+ eq(paramsOf(wanp).maxSegmentSeconds, 30, 'wan raises the cap');
747
+ eq(paramsOf(wanp).minSegmentSeconds, 2, 'wan lowers the floor');
748
+ // An explicit `auto` on a model without it is an error, not a silent downgrade.
749
+ const h3auto = clone(PROJECT); h3auto.params.videoDuration = 'auto';
750
+ breaks('params', h3auto, E01, 'has no auto duration');
751
+ // Resolution tiers are per model: h3 has 768P and no 720P, the Seedance pair is lowercase.
752
+ eq(videoResolutionFor(VIDEO_MODELS.h3, '720P'), null, 'h3 has no 720P tier');
753
+ eq(videoResolutionFor(VIDEO_MODELS.h3, '768p'), '768P', 'h3 spells it 768P');
754
+ eq(videoResolutionFor(VIDEO_MODELS.seedance2, '480P'), '480p', 'seedance 2.0 spells it lowercase');
755
+ eq(videoResolutionFor(VIDEO_MODELS.seedance2fast, '1080p'), null, 'seedance 2.0 fast tops out at 720p');
756
+ const badRes = clone(PROJECT); badRes.params.videoResolution = '720P';
757
+ breaks('params', badRes, E01, 'is not a tier of minimax-h3-ref');
758
+ // init never writes an invalid tier: it lands on the nearest one the model has, in the model's spelling.
759
+ eq(nearestResolution(VIDEO_MODELS.h3, '720P'), '768P', 'h3 lands on 768P, not on the cheapest tier');
760
+ eq(nearestResolution(VIDEO_MODELS.seedance2, '720P'), '720p', 'the Seedance spelling is lowercase');
761
+ for (const key of VIDEO_MODES) {
762
+ const dir = mkdtempSync(join(tmpdir(), 'sd-init-'));
763
+ try {
764
+ const p = initProject({ title: 'T', episodes: 3, minutes: 1, genre: 'G', video: key });
765
+ const fails = GATES.find((g) => g.id === 'params').run(makeContext({ ...p, title: 'T' }, {}));
766
+ eq(fails.filter((m) => /videoResolution|auto duration/.test(m)).length, 0, `init ${key} is self-consistent`);
767
+ } finally { rmSync(dir, { recursive: true, force: true }); }
768
+ }
769
+ // Segment caps cannot be widened past the model.
770
+ const wide = clone(PROJECT); wide.params.maxSegmentSeconds = 30;
771
+ breaks('params', wide, E01, 'minimax-h3-ref takes 5–15 s');
772
+ }
773
+
774
+ {
775
+ // The seed's plan splits each scene by the model's ceiling, so the same episode is cut differently per model.
776
+ const h3plan = segmentPlan(PROJECT, E01);
777
+ eq(h3plan.model, 'minimax-h3-ref', 'plan names the model');
778
+ eq(h3plan.segmentSeconds.join('–'), '5–15', 'plan carries the model duration range');
779
+ ok(h3plan.wholeSecondsOnly, 'every ref model takes whole seconds only');
780
+ const wanProject = clone(PROJECT); wanProject.params.video = 'wan'; wanProject.params.videoResolution = '480P';
781
+ const wanPlan = segmentPlan(wanProject, E01);
782
+ ok(wanPlan.segments < h3plan.segments, 'a 30 s model needs fewer generations than a 15 s one');
783
+ for (const sc of h3plan.scenes) ok(sc.secondsPerSegment <= 15 + 1e-9, 'no planned segment exceeds the h3 ceiling');
784
+ for (const sc of wanPlan.scenes) ok(sc.secondsPerSegment <= 30 + 1e-9, 'no planned segment exceeds the wan ceiling');
785
+ ok(seedStoryboard(PROJECT, E01).storyboard.plan.model === 'minimax-h3-ref', 'seed storyboard carries the plan');
786
+ // Seedance 2.0 is a 15 s model, so it plans like h3 while speaking Seedance's prompt dialect.
787
+ const sd2 = clone(PROJECT); sd2.params.video = 'seedance2'; sd2.params.videoResolution = '720p';
788
+ eq(segmentPlan(sd2, E01).segments, h3plan.segments, 'seedance 2.0 splits like the other 15 s model');
789
+ eq(videoModel(paramsOf(sd2)).family, 'seedance', 'and prompts like seedance');
790
+ }
791
+
792
+ {
793
+ // Photography: lens + stop, key light relative to the camera, and foreground on most cuts.
794
+ const seg = E01.storyboard.segments[0];
795
+ const cut = seg.cuts[0];
796
+ eq(focalOf(cut), '40mm', 'a medium shot defaults to 40mm');
797
+ eq(focalOf({ size: 'close' }), '75mm', 'a close-up defaults to 75mm');
798
+ eq(focalOf({ size: 'close', focal: '135mm' }), '135mm', 'the cut may name its own focal');
799
+ const phrase = cameraPhrase(cut, paramsOf(PROJECT));
800
+ ok(phrase.includes('ARRI ALEXA Mini LF') && phrase.includes('40mm') && phrase.includes('T1.5'), 'camera phrase carries body, focal and stop');
801
+ ok(cut.frame.includes(phrase), 'the fixture frame pastes the camera phrase verbatim');
802
+ eq(cameraPhrase({ size: 'close', aperture: 'T2.8' }, paramsOf(PROJECT)).endsWith('at T2.8'), true, 'a cut may stop down on its own');
803
+ ok(cameraPhrase(cut, paramsOf(PROJECT), 'zh').includes('\u5149\u5708'), 'the Chinese phrase says 光圈');
804
+ // Light direction is camera-relative and gated against the vocabulary.
805
+ ok(cut.frame.includes(LIGHT_DIRECTIONS[cut.light.direction]), 'the frame carries the light-direction phrase');
806
+ const noLens = clone(E01); noLens.storyboard.segments[0].cuts[0].frame = noLens.storyboard.segments[0].cuts[0].frame.replace('40mm', '');
807
+ breaks('photography', PROJECT, noLens, 'must name "40mm"');
808
+ const badDir = clone(E01); badDir.storyboard.segments[0].cuts[0].light.direction = 'from the window';
809
+ breaks('photography', PROJECT, badDir, 'not in the vocabulary');
810
+ const noDir = clone(E01); delete noDir.storyboard.segments[0].cuts[0].light.direction;
811
+ breaks('photography', PROJECT, noDir, 'light.direction is required');
812
+ const noNote = clone(E01); noNote.storyboard.segments[0].cuts[0].light.note = '';
813
+ breaks('photography', PROJECT, noNote, 'light.note is required');
814
+ const wrongDir = clone(E01); wrongDir.storyboard.segments[0].cuts[0].light.direction = 'top';
815
+ breaks('photography', PROJECT, wrongDir, 'keyed from directly above');
816
+ // Foreground: named in the frame, said to be out of focus, and present on at least half the cuts.
817
+ ok(foregroundPhrase(cut).includes('out of focus'), 'the foreground phrase says it is defocused');
818
+ eq(foregroundPhrase({ foreground: '' }), '', 'a clean frame adds nothing');
819
+ ok(foregroundPhrase({ foreground: '\u6536\u94f6\u53f0' }, 'zh').includes('\u524d\u666f'), 'the Chinese foreground phrase says 前景');
820
+ const noFg = clone(E01); noFg.storyboard.segments[0].cuts.forEach((c) => { c.foreground = ''; });
821
+ breaks('photography', PROJECT, noFg, 'no depth');
822
+ const missingFg = clone(E01); delete missingFg.storyboard.segments[0].cuts[0].foreground;
823
+ breaks('photography', PROJECT, missingFg, 'foreground is required');
824
+ const unsaidFg = clone(E01); unsaidFg.storyboard.segments[0].cuts[0].frame = unsaidFg.storyboard.segments[0].cuts[0].frame.replace('the dripping edge of the striped awning', 'a wall');
825
+ breaks('photography', PROJECT, unsaidFg, 'must name the foreground');
826
+ }
827
+
828
+ {
829
+ // Every reference is addressed the way WJI labels it: @image1 / @video1 / @audio1.
830
+ ok(h3AlignmentLine([{ seconds: 3 }, { seconds: 2 }]).includes('@image1'), 'the h3 alignment line uses @image1');
831
+ ok(!/Picture/.test(h3AlignmentLine([{ seconds: 3 }, { seconds: 2 }])), 'and never <Picture k>');
832
+ ok(h3AlignmentLine([{ seconds: 3 }]).includes('@image1'), 'single-cut alignment too');
833
+ ok(seedanceAnchors(3).every((l) => !l.includes('@Image')), 'seedance anchors are lowercase');
834
+ ok(wanLegendLine([{ seconds: 3 }]).includes('@image1'), 'the wan legend uses @image1, not 图1');
835
+ for (const p of [PROJECT, { ...PROJECT, params: { ...PROJECT.params, video: 'wan', videoResolution: '480P' } }]) {
836
+ const sk = promptSkeleton(p, E01, 'E01-01');
837
+ ok(!/@Image|<Picture|\bPicture \d/.test(sk.video.prompt), 'no legacy reference token in the skeleton');
838
+ }
839
+ // Numbering restarts per type, so the voices are @audio1… and do not continue the image series.
840
+ const refs0 = segmentReferences(PROJECT, E01, E01.storyboard.segments[0]);
841
+ for (const r of refs0.filter((x) => x.kind === 'image')) ok(/^@image\d+$/.test(r.label), `sheet label ${r.label} is @imageN`);
842
+ for (const r of refs0.filter((x) => x.kind === 'audio')) ok(/^@audio\d+$/.test(r.label), `voice label ${r.label} is @audioN`);
843
+ eq(refs0.filter((x) => x.kind === 'audio')[0].label, '@audio1', 'the first voice is @audio1 whatever the image count');
844
+ eq(imageReferences(PROJECT, E01, E01.storyboard.segments[0]).length + audioReferences(PROJECT, E01, E01.storyboard.segments[0]).length, refs0.length, 'images + audio = every reference');
845
+ // Wan in sheets mode: the legend describes sheets, in Chinese, and the gate expects that one.
846
+ const wanSheets = clone(PROJECT); wanSheets.params.video = 'wan'; wanSheets.params.videoResolution = '480P'; wanSheets.params.references = 'sheets';
847
+ const refs = segmentReferences(wanSheets, E01, E01.storyboard.segments[0]);
848
+ const legend = wanSheetLegendLine(refs);
849
+ ok(legend.includes('@image1='), 'the wan sheets legend numbers the sheets');
850
+ ok(!legend.includes('\u5173\u952e\u5e27'), 'and does not call a sheet a keyframe');
851
+ ok(refs.every((r) => r.zh), 'every sheet reference has a Chinese label for it');
852
+ const wanSk = promptSkeleton(wanSheets, E01, 'E01-01');
853
+ ok(wanSk.video.prompt.startsWith(legend), 'the wan sheets skeleton opens with it');
854
+ ok(!/[a-z]{4,} [a-z]{4,} [a-z]{4,}/.test(wanSk.video.prompt.split('\n')[0]), 'the wan legend is Chinese, not English prose');
855
+ }
856
+
857
+ {
858
+ // Dialogue is delivered the way the character bible says, and never attributed by name.
859
+ const c02 = PROJECT.characters.find((c) => c.id === 'C02');
860
+ eq(speechDirection(c02, '', 'en'), c02.voice.videoDirection, 'the English direction is voice.videoDirection');
861
+ ok(speechDirection(c02, 'almost whispering', 'en').endsWith('almost whispering'), "the beat's own delivery is appended");
862
+ ok(speechDirection(c02, '', 'zh').includes(c02.voice.pace), 'the Chinese direction is built from the voice fields');
863
+ eq(identityOf(c02), c02.identityEn, 'identityOf is the name-free identity');
864
+ // The skeleton must hand the agent something that validates: no names in it.
865
+ const names = forbiddenNames(PROJECT);
866
+ for (const mode of ['h3', 'seedance', 'wan']) {
867
+ const p = clone(PROJECT); p.params.video = mode; p.params.videoResolution = VIDEO_MODELS[mode].resolutions[0];
868
+ const sk = promptSkeleton(p, E01, 'E01-01');
869
+ const spoken = claimedLines(E01.scenes[0], E01.storyboard.segments[0].cuts).map((l) => l.line);
870
+ const outside = spoken.reduce((t, line) => t.split(line).join(''), sk.video.prompt);
871
+ for (const n of names) ok(!containsName(outside, n), `${mode} skeleton does not attribute a line to "${n}"`);
872
+ ok(sk.video.prompt.includes(speechDirection(PROJECT.characters.find((c) => c.id === 'C03'), '', videoModel(paramsOf(p)).promptLang)), `${mode} skeleton carries the speaker's delivery`);
873
+ }
874
+ // A prompt that drops a speaker's delivery fails.
875
+ const stripped = clone(E01);
876
+ stripped.storyboard.segments[0].prompt = stripped.storyboard.segments[0].prompt.split(c02.voice.videoDirection).join('');
877
+ const e2 = clone(E01);
878
+ e2.storyboard.segments[1].prompt = e2.storyboard.segments[1].prompt.split(c02.voice.videoDirection).join('flatly');
879
+ breaks('video-prompt', PROJECT, e2, 'must carry their delivery verbatim');
880
+ // The character fields behind it are required.
881
+ const noDirection = clone(PROJECT); delete noDirection.characters[1].voice.videoDirection;
882
+ breaks('voice', noDirection, E01, 'voice.videoDirection is empty');
883
+ const noIdentity = clone(PROJECT); noIdentity.characters[1].identityEn = '';
884
+ breaks('profile', noIdentity, E01, 'identityEn is empty');
885
+ const sameIdentity = clone(PROJECT); sameIdentity.characters[1].identityEn = sameIdentity.characters[0].identityEn;
886
+ breaks('profile', sameIdentity, E01, 'same as');
887
+ const namedIdentity = clone(PROJECT); namedIdentity.characters[1].identityEn = 'the man called \u8001\u9648';
888
+ breaks('profile', namedIdentity, E01, 'contains the name');
889
+ }
890
+
891
+ {
892
+ // The voice references: every line points at the @audioN it must be read in.
893
+ const seg = E01.storyboard.segments[0];
894
+ const audio = audioReferences(PROJECT, E01, seg);
895
+ eq(audio.length, 2, 'both speakers in the segment bring a voice reference');
896
+ eq(audio[0].entity, 'C03', 'voices are numbered in speaking order, not cast order');
897
+ ok(audio[0].use.includes('the voice of'), 'the legend line says whose voice it is');
898
+ // In sheets mode a voice points at its sheet; in keyframes mode @imageN are frames, so it describes.
899
+ const sheetsP = clone(PROJECT); sheetsP.params.references = 'sheets';
900
+ ok(audioReferences(sheetsP, E01, seg)[0].use.includes('@image'), 'sheets mode ties the voice to a sheet label');
901
+ ok(!audio[0].use.includes('@image'), 'keyframes mode does not, because @imageN are the frames');
902
+ ok(audio[0].use.includes(PROJECT.characters.find((c) => c.id === 'C03').identityEn), 'it names them instead');
903
+ // Dropping one line's attribution fails, even though the label is still in the legend.
904
+ const line = claimedLines(E01.scenes[0], seg.cuts)[0].line;
905
+ const e = clone(E01);
906
+ const at = e.storyboard.segments[0].prompt.indexOf(line);
907
+ const lead = e.storyboard.segments[0].prompt.slice(Math.max(0, at - 400), at);
908
+ e.storyboard.segments[0].prompt = e.storyboard.segments[0].prompt.replace(lead, lead.split('@audio1').join('the man'));
909
+ breaks('video-prompt', PROJECT, e, 'is not attributed to @audio1');
910
+ // A label with no reference behind it fails too.
911
+ const e2 = clone(E01); e2.storyboard.segments[0].prompt += '\n@audio7 says something.';
912
+ breaks('video-prompt', PROJECT, e2, '@audio7 but only 2 voice reference(s)');
913
+ // A connected voice that the prompt never uses fails.
914
+ const e3 = clone(E01); e3.storyboard.segments[0].prompt = e3.storyboard.segments[0].prompt.split('@audio2').join('the clerk');
915
+ breaks('video-prompt', PROJECT, e3, '@audio2');
916
+ // Turning voice samples off removes the references entirely — nothing to point at.
917
+ const noVoice = clone(PROJECT); noVoice.params.voiceSamples = false;
918
+ eq(audioReferences(noVoice, E01, seg).length, 0, 'no voice samples, no audio references');
919
+ // Every model states how many voices it takes.
920
+ for (const [key, m] of Object.entries(VIDEO_MODELS)) ok(m.maxAudioRefs >= 3, `${key} states its audio reference cap`);
921
+ eq(VIDEO_MODELS.seedance.maxAudioRefs, 10, 'seedance 2.5 takes 10 audio references');
922
+ eq(VIDEO_MODELS.h3.maxAudioRefs, 3, 'h3 takes 3');
923
+ eq(VIDEO_MODELS.wan.maxAudioRefs, 5, 'wan takes 5');
924
+ eq(paramsOf(PROJECT).maxAudioRefs, 3, 'the cap follows the model unless set');
925
+ // A beat's acting note is script prose in the drama's language: it belongs in a prompt written in
926
+ // that language, never inside an English one — the skeleton must not hand over something the
927
+ // English-only gate would reject.
928
+ const zhBeat = PROJECT.characters[0];
929
+ ok(!speechDirection(zhBeat, '\u538b\u7740\u55d3\u5b50', 'en').includes('\u538b\u7740\u55d3\u5b50'), 'an English prompt drops a Chinese beat delivery');
930
+ ok(speechDirection(zhBeat, '\u538b\u7740\u55d3\u5b50', 'zh').includes('\u538b\u7740\u55d3\u5b50'), 'a Chinese prompt keeps it');
931
+ ok(speechDirection(zhBeat, 'almost whispering', 'en').includes('almost whispering'), 'an English beat delivery survives either way');
932
+ eq(speechDirection(zhBeat, 'almost whispering', null), 'almost whispering', 'after the first line only the beat note is added');
933
+ for (const mode of ['h3', 'seedance']) {
934
+ const p = clone(PROJECT); p.params.video = mode; p.params.videoResolution = VIDEO_MODELS[mode].resolutions[0];
935
+ const sk = promptSkeleton(p, E01, 'E01-01');
936
+ const spoken = claimedLines(E01.scenes[0], E01.storyboard.segments[0].cuts).map((l) => l.line);
937
+ const outside = spoken.reduce((t, line) => t.split(line).join(''), sk.video.prompt);
938
+ ok(isEnglish(outside), `the ${mode} skeleton is English outside the dialogue`);
939
+ }
940
+ }
941
+
942
+ {
943
+ eq(1, 1, 'scope');
944
+ // The full voice description lands on a character's first line only; later lines carry the label.
945
+ const twoLines = promptSkeleton(PROJECT, E01, 'E01-02').video.prompt;
946
+ const c01 = PROJECT.characters.find((c) => c.id === 'C01');
947
+ eq(twoLines.split(c01.voice.videoDirection).length - 1, 1, 'the delivery is spelled out once per character per segment');
948
+ // A Chinese prompt takes the Chinese look phrase, not the English one.
949
+ const wanLook = clone(PROJECT);
950
+ wanLook.params.video = 'wan'; wanLook.params.videoResolution = '480P';
951
+ wanLook.params.lookPhrase = 'shallow depth of field'; wanLook.params.lookPhraseZh = '\u5927\u5149\u5708\u6DFA\u666F\u6DF1';
952
+ const wanE = clone(E01);
953
+ const wanFails = GATES.find((g) => g.id === 'video-prompt').run(makeContext(wanLook, { 1: wanE })).filter((m) => m.includes('look phrase'));
954
+ ok(wanFails.every((m) => m.includes('lookPhraseZh')), 'wan is asked for lookPhraseZh, never the English one');
955
+ const enFails = GATES.find((g) => g.id === 'video-prompt').run(makeContext({ ...PROJECT, params: { ...PROJECT.params, lookPhrase: 'shallow depth of field' } }, { 1: E01 })).filter((m) => m.includes('look phrase'));
956
+ ok(enFails.every((m) => m.includes('params.lookPhrase)')), 'an English prompt is asked for lookPhrase');
957
+ // manifest asks for the voice display node of everyone who speaks in the episode.
958
+ const items = manifest(PROJECT, { 1: E01 }, { stage: 'storyboard' }).map((it) => it.item);
959
+ ok(items.some((it) => it === 'wji.displayNodes.C01:voice'), 'manifest wants the speakers\u2019 voices on the canvas');
960
+ ok(items.find((it) => it === 'wji.displayNodes.C01:voice') !== undefined, 'keyed like the sheets');
961
+ const quiet = clone(PROJECT); quiet.params.voiceSamples = false;
962
+ ok(!manifest(quiet, { 1: E01 }, { stage: 'storyboard' }).some((it) => it.item.endsWith(':voice')), 'and not when voice samples are off');
963
+ // More speakers than the model takes voices for is a failure, not a silent drop.
964
+ const tight = clone(PROJECT); tight.params.maxAudioRefs = 1;
965
+ breaks('timing', tight, E01, 'people speak, so 2 voice references, but minimax-h3-ref takes at most 1');
966
+ }
967
+
968
+ {
969
+ // @videoN — the previous clip, when the project asks for continuity that way.
970
+ eq(DEFAULT_PARAMS.videoReferences, 'none', 'clip references are opt-in');
971
+ eq(videoReferences(PROJECT, E01, E01.storyboard.segments[1]).length, 0, 'and off by default');
972
+ const p = clone(PROJECT); p.params.videoReferences = 'previous';
973
+ eq(videoReferences(p, E01, E01.storyboard.segments[0]).length, 0, 'the first segment has nothing before it');
974
+ const v = videoReferences(p, E01, E01.storyboard.segments[1]);
975
+ eq(v.length, 1, 'every later segment references the one before it');
976
+ eq(v[0].label, '@video1', 'clips are their own @videoN series');
977
+ eq(v[0].entity, 'E01-01', 'and it is the previous segment');
978
+ ok(v[0].use.includes('continue its look'), 'the legend says what to carry over');
979
+ // Numbering stays per type: images keep theirs, voices keep theirs.
980
+ const refs = segmentReferences(p, E01, E01.storyboard.segments[1]);
981
+ eq(refs.filter((r) => r.kind === 'image')[0].label, '@image1', 'images still start at 1');
982
+ eq(refs.filter((r) => r.kind === 'audio')[0].label, '@audio1', 'voices still start at 1');
983
+ // The skeleton names it, and lists it between the pictures and the voices — the connection order.
984
+ const sk = promptSkeleton(p, E01, 'E01-02');
985
+ ok(sk.video.prompt.includes('@video1 is the clip'), 'the skeleton legends the clip');
986
+ const order = sk.video.references;
987
+ ok(order.indexOf('E01-01:video') > 0 && order.indexOf('E01-01:video') < order.indexOf('C01:voice'), 'pictures, then the clip, then the voices');
988
+ // Gated both ways.
989
+ const dropped = clone(E01);
990
+ dropped.storyboard.segments[1].prompt = dropped.storyboard.segments[1].prompt.split('@video1').join('');
991
+ breaks('video-prompt', p, dropped, '@video1 (the clip before this one (E01-01)) is connected');
992
+ const overNumbered = clone(E01);
993
+ overNumbered.storyboard.segments[1].prompt += '\n@video1 continues. @video4 too.';
994
+ breaks('video-prompt', p, overNumbered, '@video4 but only 1 clip reference');
995
+ const bad = clone(PROJECT); bad.params.videoReferences = 'sometimes';
996
+ breaks('params', bad, E01, 'params.videoReferences must be one of');
997
+ // A reference video has its own duration cap — h3 takes 15 s, and its segments are 15 s.
998
+ for (const [key, m] of Object.entries(VIDEO_MODELS)) {
999
+ ok(m.maxVideoRefs >= 3 && m.maxRefVideoSeconds >= 15, `${key} states its clip reference limits`);
1000
+ ok(m.maxRefVideoSeconds <= m.maxSegmentSeconds || key === 'h3', `${key}'s reference cap is consistent with its output`);
1001
+ }
1002
+ eq(VIDEO_MODELS.seedance.maxVideoRefs, 10, 'seedance 2.5 takes 10 clips');
1003
+ eq(VIDEO_MODELS.wan.maxRefVideoSeconds, 15, 'wan reference videos are 15 s even though it outputs 30');
1004
+ // A 30 s seedance segment cannot be fed to wan as a reference.
1005
+ const long = clone(PROJECT);
1006
+ long.params.video = 'wan'; long.params.videoResolution = '480P'; long.params.videoReferences = 'previous';
1007
+ long.params.maxSegmentSeconds = 30; long.params.minSegmentSeconds = 2;
1008
+ const longE = clone(E01);
1009
+ longE.storyboard.segments[0].cuts = [...longE.storyboard.segments[0].cuts, ...longE.storyboard.segments[0].cuts].map((c, i) => ({ ...c, beats: [i + 1, i + 1] }));
1010
+ const fails = GATES.find((g) => g.id === 'timing').run(makeContext(long, { 1: longE })).filter((m) => m.includes('reference videos of at most'));
1011
+ ok(fails.length > 0, 'a clip longer than the reference cap is reported');
1012
+ }
1013
+
1014
+ {
1015
+ // @imageN is used per shot, not just declared once.
1016
+ const dropped = clone(E01);
1017
+ dropped.storyboard.segments[0].prompt = dropped.storyboard.segments[0].prompt.replace('@image2:', 'the keyframe:');
1018
+ breaks('video-prompt', PROJECT, dropped, '[Shot 2] must anchor on @image2');
1019
+ // Seedance keyframes: the middle frames need their own state sentence, not only first and last.
1020
+ const p = clone(PROJECT); p.params.video = 'seedance'; p.params.videoResolution = '720p';
1021
+ const e = clone(E01);
1022
+ const seg = e.storyboard.segments[3];
1023
+ const lines = claimedLines(e.scenes[1], seg.cuts).map((l) => l.line);
1024
+ const dir = speechDirection(PROJECT.characters.find((c) => c.id === 'C02'), '', 'en');
1025
+ seg.prompt = [...seedanceAnchors(4),
1026
+ '@audio1 is the voice of the stocky man in his sixties.',
1027
+ 'This first frame defines the room, a wide shot with a static shot.',
1028
+ 'This last frame defines his grave face in close-up, a slow push in.',
1029
+ `@audio1, ${dir}, says: ${lines[0]} Then @audio1 says: ${lines[1]}`].join('\n');
1030
+ breaks('video-prompt', p, e, '@image2 (keyframe 2) is never mentioned');
1031
+ }
1032
+
1033
+ {
1034
+ // Voice sample: 3–5 s at the TTS speech rate, an acting direction that becomes a parenthetical
1035
+ const p = clone(PROJECT);
1036
+ const c = p.characters[0];
1037
+ const vp = voiceSamplePrompt(c, paramsOf(p));
1038
+ ok(vp.endsWith(`)${c.voice.sample.line}`), 'sample prompt ends with the bare line');
1039
+ const brief = vp.slice(1, vp.indexOf(')'));
1040
+ for (const part of [c.profile.ageRange, c.profile.identity, c.voice.timbre, c.voice.pitch, c.voice.pace, c.voice.accent, c.voice.emotion, c.voice.sample.direction]) {
1041
+ ok(brief.includes(part), `voice brief carries "${part}"`);
1042
+ }
1043
+ for (const w of c.profile.personality) ok(brief.includes(w), `voice brief carries the trait "${w}"`);
1044
+ ok(!/[「」『』“”]/.test(brief), 'quote marks are stripped from the brief — they would be read aloud');
1045
+ const noAccent = clone(p); noAccent.params.speechAccent = 'Taiwanese-accented Mandarin';
1046
+ ok(voiceSamplePrompt(noAccent.characters[0], paramsOf(noAccent)).includes(c.voice.accent), 'voice.accent wins over params.speechAccent');
1047
+ delete noAccent.characters[0].voice.accent;
1048
+ ok(voiceSamplePrompt(noAccent.characters[0], paramsOf(noAccent)).includes('Taiwanese-accented Mandarin'), 'speechAccent fills the gap when voice.accent is empty');
1049
+ ok(Math.abs(voiceSampleSeconds(c, paramsOf(p)) - nonWhitespace(c.voice.sample.line) / 2.4) < 0.01, 'sample seconds use the speech rate, not the reading rate');
1050
+ const long = clone(p); long.characters[0].voice.sample.line = '今晚也是我值班,你要的東西我幫你收在櫃檯下面了。';
1051
+ breaks('voice', long, E01, '3–5 s');
1052
+ const bare = clone(p); delete bare.characters[0].voice.sample.direction;
1053
+ breaks('voice', bare, E01, 'direction is empty');
1054
+ const quoted = clone(p); quoted.characters[0].voice.sample.line = '「今晚也是我值班。」';
1055
+ breaks('voice', quoted, E01, 'quote marks');
1056
+ const off = clone(p); off.params.voiceSamples = false;
1057
+ delete off.characters[0].voice.sample;
1058
+ ok(GATES.find((g) => g.id === 'voice').run(makeContext(off, { 1: E01 })).length === 0, 'voiceSamples false drops the sample requirement');
1059
+ const m = manifest(p, { 1: E01 }, { stage: 'characters' });
1060
+ ok(m.some((it) => it.item.endsWith('wji.nodes.voice') && it.hint.includes('seed-audio-1.0')), 'manifest demands the voice node on the headshot');
1061
+ ok(!manifest(off, { 1: E01 }, { stage: 'characters' }).some((it) => it.item.endsWith('wji.nodes.voice')), 'and drops it when voiceSamples is false');
1062
+ }
1063
+
1064
+
1065
+ // ── shot-language gates ─────────────────────────────────────────────────────
1066
+ {
1067
+ const seg = () => E01.storyboard.segments[2]; // E01-03: the 爽点 segment
1068
+ eq(cutStart(seg(), 0).positions.C02, seg().cuts[0].start.positions.C02, 'the first cut starts on its own start boundary');
1069
+ eq(cutStart(seg(), 1).positions.C02, seg().cuts[0].end.positions.C02, 'later cuts start where the previous cut ended');
1070
+ eq(segmentCharacters(seg()).join(','), 'C02,C01', 'segment characters in first-appearance order');
1071
+ eq(keyScenes(E01).join(','), '2', 'the key scenes are the 爽点 scene and the cliff scene');
1072
+ ok(motionSentence(seg().cuts[2]).startsWith('Cue: he sets the bowl down'), 'motion sentence is cue / action / end');
1073
+ ok(cameraClause(seg().cuts[0]).startsWith('The camera holds;'), 'a static shot writes its hold');
1074
+ ok(cameraClause(seg().cuts[4]).startsWith('Push In (pressure), as he starts the question:'), 'a move writes motivation, trigger, path and end framing');
1075
+ ok(motionSentence(seg().cuts[2], 'zh').startsWith('触发:'), 'motion sentence has a Chinese form for wan');
1076
+ }
1077
+ {
1078
+ const e = clone(E01); e.storyboard.segments[2].cuts[0].purpose = { kind: 'pretty', change: 'x' };
1079
+ breaks('shot-purpose', PROJECT, e, 'purpose.kind');
1080
+ const e2 = clone(E01); e2.storyboard.segments[2].cuts[0].purpose.change = '';
1081
+ breaks('shot-purpose', PROJECT, e2, 'purpose.change');
1082
+ const e3 = clone(E01); const c = e3.storyboard.segments[2].cuts; c[1].size = c[0].size; c[1].angle = c[0].angle; c[1].characters = [...c[0].characters];
1083
+ breaks('shot-purpose', PROJECT, e3, 'same size, angle and people');
1084
+ const e4 = clone(E01); e4.storyboard.segments[2].cuts[2].purpose.kind = 'performance'; // the 爽点 cut
1085
+ breaks('shot-purpose', PROJECT, e4, 'claims a turn / 爽点');
1086
+ const e5 = clone(E01); e5.storyboard.segments[2].cuts[2].purpose.kind = 'performance'; e5.storyboard.segments[2].cuts[3].purpose.kind = 'reaction';
1087
+ passes('shot-purpose', PROJECT, e5); // the next cut reads her reaction
1088
+ const e6 = clone(E01); e6.storyboard.segments[4].cuts[3].purpose.kind = 'establish'; // the cliff cut
1089
+ breaks('shot-purpose', PROJECT, e6, 'cliff');
1090
+ }
1091
+ {
1092
+ const e = clone(E01); e.storyboard.segments[2].cuts[0].angle = 'dutch';
1093
+ breaks('framing-axis', PROJECT, e, 'angle');
1094
+ const e2 = clone(E01); e2.storyboard.segments[2].cuts[0].height = 'medium';
1095
+ breaks('framing-axis', PROJECT, e2, 'height');
1096
+ const e3 = clone(E01); e3.storyboard.segments[2].cuts[0].axisSide = 'cross';
1097
+ breaks('framing-axis', PROJECT, e3, 'crossing the axis needs a note');
1098
+ const e4 = clone(E01); delete e4.storyboard.segments[2].axis;
1099
+ breaks('framing-axis', PROJECT, e4, 'declare axis');
1100
+ const e5 = clone(E01); e5.storyboard.segments[2].axis.screenRight = 'C02';
1101
+ breaks('framing-axis', PROJECT, e5, 'screenLeft / screenRight');
1102
+ const e6 = clone(E01); e6.storyboard.segments[2].cuts[0].start.facing.C02 = 'screen-left'; // now both singles face screen-left
1103
+ breaks('framing-axis', PROJECT, e6, 'reverse shots');
1104
+ const e7 = clone(E01); e7.storyboard.segments[2].axis.between = ['C01', 'C03'];
1105
+ breaks('framing-axis', PROJECT, e7, 'two characters of this scene');
1106
+ }
1107
+ {
1108
+ const e = clone(E01); delete e.storyboard.segments[2].cuts[0].start;
1109
+ breaks('continuity', PROJECT, e, 'start boundary is required');
1110
+ const e2 = clone(E01); e2.storyboard.segments[2].cuts[1].start = { positions: {} };
1111
+ breaks('continuity', PROJECT, e2, 'only the first cut');
1112
+ const e3 = clone(E01); delete e3.storyboard.segments[2].cuts[1].end.hands;
1113
+ breaks('continuity', PROJECT, e3, 'end.hands.C01');
1114
+ const e4 = clone(E01); e4.storyboard.segments[2].cuts[0].end.facing.C02 = 'left';
1115
+ breaks('continuity', PROJECT, e4, 'facing.C02 must be');
1116
+ const e5 = clone(E01); e5.storyboard.segments[2].cuts[3].end.heldProps.C01 = []; // the bag leaves her hands on a line-only cut
1117
+ breaks('continuity', PROJECT, e5, 'a hand-over is an action');
1118
+ const e6 = clone(E01); e6.storyboard.segments[2].cuts[0].end.heldProps.C02 = ['P09'];
1119
+ breaks('continuity', PROJECT, e6, 'not a prop of this scene');
1120
+ const e7 = clone(E01); e7.storyboard.segments[3].cuts[0].start.positions.C02 = 'sitting on the stool'; // E01-03 left him standing
1121
+ breaks('continuity', PROJECT, e7, 'boundary carries over');
1122
+ const e8 = clone(E01); e8.storyboard.segments[2].cuts[2].frame += ' the bowl set down on the counter'; // an end-only state
1123
+ breaks('continuity', PROJECT, e8, 'only exists at the end');
1124
+ const e9 = clone(E01); e9.storyboard.segments[2].cuts[0].frame += ' then he looks up';
1125
+ breaks('continuity', PROJECT, e9, 'one freezeable instant');
1126
+ const e10 = clone(E01); e10.storyboard.segments[3].cuts[0].start.heldProps.C02 = ['P01']; // E01-03 ended with his hands empty
1127
+ breaks('continuity', PROJECT, e10, 'starts holding');
1128
+ }
1129
+ {
1130
+ const e = clone(E01); e.storyboard.segments[2].cuts[0].motion.trigger = '';
1131
+ breaks('motion', PROJECT, e, 'motion.trigger');
1132
+ const e2 = clone(E01); e2.storyboard.segments[2].cuts[0].motion.action = '他问这是什么';
1133
+ breaks('motion', PROJECT, e2, 'must be English');
1134
+ const e3 = clone(E01); e3.storyboard.segments[2].cuts[0].motion.action = '林小夏 asks what it is';
1135
+ breaks('motion', PROJECT, e3, 'name');
1136
+ const e4 = clone(E01); e4.storyboard.segments[2].cuts[0].motion.action = 'he seems to recognise the bag';
1137
+ breaks('motion', PROJECT, e4, 'inner state');
1138
+ const e5 = clone(E01); e5.storyboard.segments[2].cuts[0].motion.action = 'he grabs the bowl, takes the bag, hands her the chopsticks and pulls the drawer';
1139
+ breaks('motion', PROJECT, e5, 'hand verbs');
1140
+ const e6 = clone(E01); delete e6.storyboard.segments[2].cuts[4].cameraNote.motivation;
1141
+ breaks('motion', PROJECT, e6, 'cameraNote.motivation');
1142
+ const e7 = clone(E01); delete e7.storyboard.segments[2].cuts[4].cameraNote.endFraming;
1143
+ breaks('motion', PROJECT, e7, 'endFraming');
1144
+ const e8 = clone(E01); e8.storyboard.segments[2].cuts[0].cameraNote = {};
1145
+ breaks('motion', PROJECT, e8, 'cameraNote.hold');
1146
+ const w = clone(PROJECT); w.params.video = 'wan'; w.params.videoResolution = '480P';
1147
+ const e9 = clone(E01); e9.storyboard.segments[2].cuts[0].motion.action = '他只动嘴,问这是什么';
1148
+ ok(!failures('motion', w, e9).some((m) => m.includes('must be English')), 'a wan project may write the motion in Chinese');
1149
+ }
1150
+ {
1151
+ const e = clone(E01); delete e.storyboard.segments[2].sound;
1152
+ breaks('sound', PROJECT, e, 'sound {');
1153
+ const e2 = clone(E01); e2.storyboard.segments[2].sound.ambience = '';
1154
+ breaks('sound', PROJECT, e2, 'ambience is empty');
1155
+ const e3 = clone(E01); e3.storyboard.segments[2].sound.sfx = [{ cut: 9, event: 'x' }];
1156
+ breaks('sound', PROJECT, e3, 'cut number');
1157
+ const e4 = clone(E01); e4.storyboard.segments[2].sound.music = '';
1158
+ breaks('sound', PROJECT, e4, 'music');
1159
+ const e5 = clone(E01); e5.storyboard.segments[3].sound.ambience = 'a different room tone';
1160
+ breaks('sound', PROJECT, e5, 'ambience differs');
1161
+ }
1162
+ {
1163
+ const e = clone(E01); e.storyboard.visualPlans = [];
1164
+ breaks('visual-plan', PROJECT, e, 'no visual plan');
1165
+ const e2 = clone(E01); e2.storyboard.visualPlans[0].landing = '';
1166
+ breaks('visual-plan', PROJECT, e2, 'visualPlan.landing');
1167
+ const e3 = clone(E01); e3.storyboard.visualPlans[0].turn = '她说「锁上门」';
1168
+ breaks('visual-plan', PROJECT, e3, 'quotes');
1169
+ }
1170
+ {
1171
+ const p = clone(PROJECT); p.props[0].textPolicy = { mode: 'readable', text: '3' };
1172
+ const e = clone(E01);
1173
+ ok(failures('text-policy', p, e).some((m) => m.includes('must be in the frame verbatim')), 'a readable prop\'s text must be in every frame that shows it');
1174
+ const p2 = clone(PROJECT); p2.props[0].textPolicy = { mode: 'readable', text: 'hard drive' };
1175
+ ok(!failures('text-policy', p2, E01).some((m) => m.includes('E01-01 cut 5')), 'a frame that carries the text passes');
1176
+ const p3 = clone(PROJECT); p3.props[0].textPolicy = { mode: 'readable' };
1177
+ breaks('text-policy', p3, E01, 'needs the exact text');
1178
+ const p4 = clone(PROJECT); p4.props[0].textPolicy = { mode: 'blank' };
1179
+ const e4 = clone(E01); e4.storyboard.segments[0].cuts[4].frame += ' a label reading "DO NOT OPEN"';
1180
+ breaks('text-policy', p4, e4, 'quotes text');
1181
+ const p5 = clone(PROJECT); p5.props[0].textPolicy = { mode: 'loud' };
1182
+ breaks('text-policy', p5, E01, 'textPolicy.mode');
1183
+ }
1184
+ {
1185
+ const e = clone(E01); e.storyboard.segments[2].prompt = e.storyboard.segments[2].prompt.replace('overall_soundscape: ', 'overall_soundscape: … ');
1186
+ breaks('prompt-hygiene', PROJECT, e, 'unfilled');
1187
+ const e2 = clone(E01); e2.storyboard.segments[2].prompt += '\nC01 stays behind the counter.';
1188
+ breaks('prompt-hygiene', PROJECT, e2, 'ids');
1189
+ const e3 = clone(E01); e3.storyboard.segments[2].prompt += '\nreference: sheet_v3.png';
1190
+ breaks('prompt-hygiene', PROJECT, e3, 'file name');
1191
+ const e4 = clone(E01); e4.storyboard.segments[2].prompt += '\nTODO fix hands';
1192
+ breaks('prompt-hygiene', PROJECT, e4, 'workflow note');
1193
+ const e5 = clone(E01); e5.storyboard.segments[2].prompt = e5.storyboard.segments[2].prompt.replace('No subtitles, no captions, no on-screen dialogue text.', '');
1194
+ breaks('prompt-hygiene', PROJECT, e5, NO_SUBTITLES.en);
1195
+ const p5 = clone(PROJECT); p5.params.subtitles = true;
1196
+ passes('prompt-hygiene', p5, e5);
1197
+ const e6 = clone(E01); e6.storyboard.segments[1].prompt = e6.storyboard.segments[1].prompt.replace('while she does not speak and her lips remain completely closed; then', 'and then').replace('while his lips remain completely closed', '');
1198
+ breaks('prompt-hygiene', PROJECT, e6, 'does not speak');
1199
+ const e7 = clone(E01); e7.storyboard.segments[2].prompt = e7.storyboard.segments[2].prompt.replace('<d>[Chinese] 这是什么?</d>', '<d>[Chinese] 这是什么?(轻声)</d>');
1200
+ breaks('prompt-hygiene', PROJECT, e7, 'parenthetical');
1201
+ // Sheets mode: a person on screen is bound by their @imageK inside their own shot.
1202
+ const p = clone(PROJECT); p.params.video = 'seedance'; p.params.videoResolution = '720p'; p.params.references = 'sheets'; p.params.videoDuration = 'auto';
1203
+ const e8 = clone(E01);
1204
+ const seg = e8.storyboard.segments[3];
1205
+ const refs = segmentReferences(p, e8, seg);
1206
+ const legend = refs.map((r) => `${r.label} is ${r.use}.`).join('\n');
1207
+ const labelOf = (id) => refs.find((r) => r.entity === id && r.kind === 'image').label;
1208
+ const shots = seg.cuts.map((c, k) => `Shot ${k + 1} (${k}.0–${k + 1}.0 s, ${c.size}, ${c.camera}): ${c.characters.map(labelOf).join(' and ')} in frame.`);
1209
+ seg.prompt = [legend, ...shots, 'Look: x. Sound: hum. No subtitles, no captions, no on-screen dialogue text.'].join('\n');
1210
+ ok(!failures('prompt-hygiene', p, e8).some((m) => m.includes('never binds')), 'every on-screen person bound in their own shot passes');
1211
+ const e9 = clone(e8); e9.storyboard.segments[3].prompt = e9.storyboard.segments[3].prompt.replace(`Shot 3 (2.0–3.0 s, ${seg.cuts[2].size}, ${seg.cuts[2].camera}): ${seg.cuts[2].characters.map(labelOf).join(' and ')} in frame.`, `Shot 3 (2.0–3.0 s, ${seg.cuts[2].size}, ${seg.cuts[2].camera}): the two of them in frame.`);
1212
+ breaks('prompt-hygiene', p, e9, 'never binds');
1213
+ const texts = shotTexts(e8.storyboard.segments[3].prompt, 'seedance', 'sheets');
1214
+ eq(texts.size, 4, 'shotTexts splits a sheets-mode prompt into its shots');
1215
+ eq(shotTexts(E01.storyboard.segments[0].prompt, 'h3').size, 5, 'shotTexts uses the H3 paragraphs');
1216
+ }
1217
+ {
1218
+ const g = GATES.find((x) => x.id === 'storyboard-advice');
1219
+ ok(g.advisory === true, 'storyboard-advice is advisory');
1220
+ eq(failures('storyboard-advice', PROJECT, E01).length, 0, 'fixture has no storyboard advice');
1221
+ const e = clone(E01); e.storyboard.segments[0].cuts[3].background = ''; e.storyboard.segments[0].cuts[3].note = '';
1222
+ ok(failures('storyboard-advice', PROJECT, e).some((m) => m.includes('9:16')), 'a bare wide on 9:16 is noted');
1223
+ const e2 = clone(E01); e2.storyboard.segments[0].cuts[4].note = '';
1224
+ ok(failures('storyboard-advice', PROJECT, e2).some((m) => m.includes('size steps')), 'a three-step size jump is noted');
1225
+ const e3 = clone(E01); e3.storyboard.segments[2].cuts[0].motion.action = 'he does not answer';
1226
+ ok(failures('storyboard-advice', PROJECT, e3).some((m) => m.includes('absence')), 'a negative-form action is noted');
1227
+ const e4 = clone(E01); e4.storyboard.segments[2].cuts[0].frame = 'x'.repeat(950);
1228
+ ok(failures('storyboard-advice', PROJECT, e4).some((m) => m.includes('padding')), 'an over-long frame is noted');
1229
+ }
1230
+ {
1231
+ const e = clone(E01); e.storyboard.segments[2].cuts[1].seconds = 4; // 4 s of dialogue + lead-in
1232
+ breaks('timing', PROJECT, e, 'lead-in');
1233
+ }
1234
+
1235
+ // ── Seed Audio soundtrack ───────────────────────────────────────────────────
1236
+ {
1237
+ const seg5 = E01.storyboard.segments[4];
1238
+ eq(musicOf({ music: 'N/A' }).intent, '', 'N/A is no music');
1239
+ eq(musicOf({ music: 'a cello' }).intent, 'a cello', 'a string is an intent with no cues');
1240
+ eq(musicOf(seg5.sound).cues.length, 3, 'an object carries its cues');
1241
+ eq(cueSeconds(3, seg5.cuts), 6, 'a cue at a cut number is that cut\'s start');
1242
+ eq(cueSeconds('10.5s', seg5.cuts), 10.5, 'a cue in seconds is read as seconds');
1243
+ eq(cueSeconds('later', seg5.cuts), null, 'a cue that is neither is null');
1244
+ const bed = audioPrompt(PROJECT, E01, seg5);
1245
+ eq(bed.mode, 'bed', 'the default track is a bed');
1246
+ eq(bed.model, AUDIO_MODEL, 'the soundtrack node is Seed Audio');
1247
+ ok(bed.prompt.startsWith('[0.0s:11.0s] Ambience, continuous: the low hum'), 'the bed opens with the timecoded ambience');
1248
+ ok(bed.prompt.includes('[6.0s:9.0s] SFX: a single ring of the brass wind chimes'), 'sfx are timecoded to their cut');
1249
+ ok(bed.prompt.includes('[6.0s:10.5s] the music swells — on the chime.') && bed.prompt.includes('[10.5s:11.0s] the music falls silent'), 'music cues become timecoded states');
1250
+ ok(bed.prompt.includes('No dialogue, no vocals, no narration.'), 'a bed carries no voices');
1251
+ eq(bed.references.length, 0, 'a bed references no voices');
1252
+ const full = audioPrompt(PROJECT, E01, E01.storyboard.segments[1], { mode: 'full' });
1253
+ eq(full.references.map((r) => r.label).join(','), '@Audio1,@Audio2', 'full mode references the speakers as @AudioN');
1254
+ ok(full.prompt.includes('[5.5s:7.1s] @Audio1 (the stocky man in his sixties with a grey crew cut), slow and gravelly'), 'a line is timecoded into its cut after the lead-in, in its voice and direction');
1255
+ ok(full.prompt.includes('says: 又是你值夜班。'), 'the line is verbatim');
1256
+ const noMusic = audioPrompt(PROJECT, E01, E01.storyboard.segments[0]);
1257
+ ok(noMusic.prompt.includes('No music.'), 'N/A music is stated');
1258
+ const md = renderSegment(PROJECT, E01, 'E01-05');
1259
+ ok(md.includes('## 音轨提示词(seed-audio-1.0 · bed · 11 s)') && md.includes('配乐:the low cello note(镜1 enter,镜3 swell,10.5s silence)'), 'segment markdown carries the soundtrack prompt and the cues');
1260
+ // gates
1261
+ const e = clone(E01); e.storyboard.segments[4].sound.music = { intent: '', cues: [] };
1262
+ breaks('sound', PROJECT, e, 'music.intent');
1263
+ const e2 = clone(E01); e2.storyboard.segments[4].sound.music.cues[0].do = 'explode';
1264
+ breaks('sound', PROJECT, e2, 'cues[0].do');
1265
+ const e3 = clone(E01); e3.storyboard.segments[4].sound.music.cues[0].at = '40s';
1266
+ breaks('sound', PROJECT, e3, 'inside the segment');
1267
+ const e4 = clone(E01); e4.storyboard.segments[4].sound.ambience = 'x'.repeat(AUDIO_MAX_CHARS);
1268
+ breaks('audio-track', PROJECT, e4, 'max 2048');
1269
+ const p = clone(PROJECT); p.params.audioTrack = 'full';
1270
+ p.characters.push({ id: 'C04', name: '路人', tier: 'functional', role: 'r', from: ['原创'], identityEn: 'a passer-by in a wet coat', voice: { videoDirection: 'flat' } });
1271
+ const e5 = clone(E01); e5.scenes[1].characters.push('C03', 'C04');
1272
+ e5.scenes[1].flow[9].speaker = 'C03'; e5.scenes[1].flow[10].speaker = 'C04'; // E01-03 now has four speakers
1273
+ ok(failures('audio-track', p, e5).some((m) => m.includes('at most 3 reference voices')), 'full mode refuses more than three speakers');
1274
+ const p2 = clone(PROJECT); p2.params.audioTrack = 'none';
1275
+ eq(failures('audio-track', p2, E01).length, 0, 'audioTrack none skips the gate');
1276
+ const p3 = clone(PROJECT); p3.params.audioTrack = 'stems';
1277
+ breaks('params', p3, null, 'audioTrack');
1278
+ const items = manifest(PROJECT, { 1: E01 });
1279
+ ok(items.some((i) => i.item === 'storyboard.segments.E01-01.wji.nodes.track'), 'manifest asks for the soundtrack node');
1280
+ ok(!manifest(p2, { 1: E01 }).some((i) => i.item.includes('.nodes.track')), 'audioTrack none asks for no soundtrack node');
1281
+ ok(voiceSamplePrompt(PROJECT.characters[0], paramsOf(PROJECT)).includes('干声,无配乐无音效'), 'the voice sample is asked for dry');
1282
+ }
1283
+
1284
+ // ── seeds, skeletons, renders ───────────────────────────────────────────────
1285
+ {
1286
+ const s = seedScript(PROJECT, 2);
1287
+ eq(s.targetSeconds, 60, 'seed target seconds from params');
1288
+ eq(s.beatsClaimed.join(','), 'B02', 'seed claims the episode beats');
1289
+ eq(s.seedNote.previousCliff, PROJECT.outline.episodes[0].suspense, 'seed carries the previous cliff');
1290
+ eq(s.scenes.length, 0, 'seed leaves scenes empty');
1291
+ assert.throws(() => seedScript(PROJECT, 9), /no episode 9/, 'seed refuses unknown episode');
1292
+ const sb = seedStoryboard(PROJECT, E01);
1293
+ eq(sb.storyboard.seedScenes.length, 2, 'storyboard seed lists both scenes');
1294
+ eq(sb.storyboard.seedScenes[1].beats.length, 19, 'storyboard seed lists every beat');
1295
+ eq(sb.storyboard.seedScenes[1].beats[2].kind, 'line', 'seed marks lines');
1296
+ ok(Math.abs(sb.storyboard.seedScenes[1].beats[2].seconds - 1.56) < 0.01, 'seed times each beat');
1297
+ eq(sb.storyboard.segments.length, 0, 'storyboard seed resets segments');
1298
+ eq(sb.storyboard.visualPlans.length, 1, 'storyboard seed adds a visual-plan worksheet for the key scenes');
1299
+ eq(sb.storyboard.visualPlans[0].sceneIndex, 2, 'the worksheet is for the 爽点 / cliff scene');
1300
+ ok(sb.storyboard.seedScenes[1].axisHint.includes('C01 ↔ C02'), 'storyboard seed hints the axis pairs');
1301
+ passed += 0;
1302
+ }
1303
+ {
1304
+ const sk = promptSkeleton(PROJECT, E01, 'E01-01');
1305
+ eq(sk.video.duration, 15, 'skeleton duration is the sum of cuts');
1306
+ eq(sk.video.model, 'minimax-h3-ref', 'h3 model');
1307
+ ok(sk.video.prompt.startsWith(h3AlignmentLine(E01.storyboard.segments[0].cuts)), 'skeleton starts with the alignment line');
1308
+ ok(sk.video.prompt.includes('[Shot 2] At 00:03.000,'), 'skeleton has shot timestamps');
1309
+ ok(sk.video.prompt.includes('<d>[Chinese] 帮我收着。明天有人来拿。</d>'), 'skeleton carries the dialogue');
1310
+ ok(sk.video.prompt.includes('Cue: the door swings shut behind her. Action:'), 'skeleton carries the motion sentence');
1311
+ ok(sk.video.prompt.includes('Tracking Shot (pressure), as she steps out:'), 'skeleton carries the camera clause');
1312
+ ok(sk.video.prompt.includes('No subtitles, no captions, no on-screen dialogue text.'), 'skeleton turns subtitles off');
1313
+ ok(sk.video.prompt.includes('overall_soundscape: heavy rain on the fabric awning'), 'skeleton fills the soundscape from segment.sound');
1314
+ const sk3 = promptSkeleton(PROJECT, E01, 'E01-02');
1315
+ ok(sk3.video.prompt.includes(`while the young woman in the faded navy jacket does not speak, ${LIPS_CLOSED}`), 'skeleton keeps the other person\'s lips closed on a line');
1316
+ eq(sk.keyframes.length, 5, 'one keyframe per cut');
1317
+ eq(sk.keyframes[0].references[0].entity, 'S02', 'first reference is the scene sheet');
1318
+ ok(sk.keyframes[0].references.some((r) => r.entity === 'C03'), 'people in the cut are referenced');
1319
+ ok(sk.keyframes[0].references.some((r) => r.entity === 'P01'), 'props in the cut are referenced');
1320
+ ok(sk.keyframes[0].references.every((r) => /^@image\d+$/.test(r.label)), 'references are labelled @imageN');
1321
+ for (const n of forbiddenNames(PROJECT)) ok(!sk.keyframes[0].prompt.includes(n), `keyframe prompt has no name (${n})`);
1322
+ const p = clone(PROJECT); p.params.video = 'seedance'; p.params.videoResolution = '720p';
1323
+ const sk2 = promptSkeleton(p, E01, 'E01-04');
1324
+ eq(sk2.video.model, 'seedance-2.5-ref', 'seedance model');
1325
+ ok(sk2.video.prompt.startsWith('Use @image1 through @image4 as keyframes in that order.'), 'seedance skeleton starts with the keyframe order');
1326
+ assert.throws(() => promptSkeleton(PROJECT, E01, 'E01-99'), /no segment/, 'skeleton refuses unknown segment');
1327
+ }
1328
+ {
1329
+ const md = renderOutline(PROJECT);
1330
+ ok(md.includes('# 夜班便利店') && md.includes('## 爽点表') && md.includes('第 3 集'), 'outline markdown has title, beats, episodes');
1331
+ ok(renderCharacter(PROJECT, 'C01').includes(PROJECT.characters[0].voice.prompt), 'character markdown carries the voice prompt');
1332
+ ok(renderScene(PROJECT, 'S01').includes('关东煮灯箱'), 'scene markdown lists anchors');
1333
+ ok(renderProp(PROJECT, 'P01').includes('handheld'), 'prop markdown shows the scale');
1334
+ const script = renderScript(PROJECT, E01);
1335
+ ok(script.includes('穿雨衣的男人(') && script.includes('VO'), 'script markdown names speakers and VO');
1336
+ ok(script.includes('△(0-3秒)') && script.includes('【爽点标记】') && script.includes('【钩子标记】'), 'script markdown is in the 短剧 episode format with derived time marks');
1337
+ ok(script.includes('## 本集功能') && script.includes('## 本集任务') && script.includes('## 创作自检') && script.includes('16/16'), 'script markdown carries the episode design and a full self-check');
1338
+ const guide = renderGuideline(PROJECT);
1339
+ ok(guide.includes('创作指引') && guide.includes('## 9. 本项目绝对要注意的重点') && !guide.includes('{{'), 'guideline markdown is complete with no placeholders');
1340
+ ok(guide.includes(PROJECT.outline.brief.logline), 'guideline carries the logline');
1341
+ const dlg = renderDialogue(PROJECT, E01);
1342
+ eq((dlg.match(/^\| [^|]*:/gm) || []).length, 13, 'dialogue worksheet has one row per line');
1343
+ ok(dlg.includes('禁用:其实、我觉得'), 'dialogue worksheet carries the speaker\'s speech rules');
1344
+ const c1 = renderCharacter(PROJECT, 'C01');
1345
+ ok(c1.includes('## 人物圣经') && c1.includes('### 过去的关键选择') && c1.includes('## 人物关系网') && c1.includes('### 写作备忘'), 'character markdown has the bible sections');
1346
+ const out = renderOutline(PROJECT);
1347
+ ok(out.includes('## 创作简报') && out.includes('## 全剧结构') && out.includes('## 分集目录'), 'outline markdown has brief, structure and episode index');
1348
+ const check = scriptSelfCheck(PROJECT, E01);
1349
+ eq(check.score, 16, 'fixture episode scores 16/16');
1350
+ const segMd = renderSegment(PROJECT, E01, 'E01-02');
1351
+ ok(segMd.includes('| 00:02.000 |') && segMd.includes('integrated_multimodal_description'), 'segment markdown has cut table and prompt');
1352
+ ok(segMd.includes('### SHOT 1 · establish') && segMd.includes('| 唯一动作 |') && segMd.includes('| 视觉依据 |') && segMd.includes('轴线:'), 'segment markdown has the SHOT blocks, the axis and the sound line');
1353
+ ok(renderSegment(PROJECT, E01, 'E01-03').includes('入画(上一镜不在画内)'), 'a person entering a cut is marked as entering');
1354
+ assert.throws(() => renderCharacter(PROJECT, 'C99'), /no character/, 'render refuses unknown ids');
1355
+ }
1356
+
1357
+ // ── manifest & bind ─────────────────────────────────────────────────────────
1358
+ {
1359
+ const items = manifest(PROJECT, { 1: E01 });
1360
+ ok(items.some((i) => i.item === 'wji.workspaceId'), 'manifest asks for the workspace first');
1361
+ ok(items.some((i) => i.item === 'characters.C01.wji.datasetId'), 'manifest lists character datasets');
1362
+ ok(items.some((i) => i.item === 'wji.displayNodes.S02:sheet'), 'manifest lists display nodes the episode needs');
1363
+ ok(items.some((i) => i.item === 'storyboard.segments.E01-01.wji.nodes.keyframes.4'), 'manifest lists every keyframe node');
1364
+ ok(items.some((i) => i.item === 'storyboard.segments.E01-05.wji.nodes.video'), 'manifest lists video nodes');
1365
+ ok(!items.some((i) => i.item.includes('wji.assets.keyframes')), 'assets are not requested before nodes exist');
1366
+ const bound = clone(PROJECT);
1367
+ bindPath(bound, 'wji.workspaceId', 'w1');
1368
+ bindPath(bound, 'characters.C02.wji.datasetId', 'd2');
1369
+ bindPath(bound, 'characters.C02.wji.nodes.headshot', 'n2');
1370
+ eq(bound.wji.workspaceId, 'w1', 'bind writes top-level ids');
1371
+ eq(bound.characters[1].wji.datasetId, 'd2', 'bind resolves array elements by id');
1372
+ const items2 = manifest(bound, {});
1373
+ ok(!items2.some((i) => i.item === 'characters.C02.wji.datasetId'), 'bound dataset no longer listed');
1374
+ ok(items2.some((i) => i.item === 'characters.C02.wji.assets.headshot'), 'a bound node without an asset asks for the asset');
1375
+ const e = clone(E01);
1376
+ bindPath(e, 'storyboard.segments.E01-01.wji.nodes.keyframes.0', 'k1');
1377
+ eq(e.storyboard.segments[0].wji.nodes.keyframes[0], 'k1', 'bind writes into arrays by index');
1378
+ bindPath(e, 'storyboard.segments.E01-02.wji.nodes.keyframes.2', 'k3');
1379
+ eq(e.storyboard.segments[1].wji.nodes.keyframes[2], 'k3', 'bind grows arrays');
1380
+ assert.throws(() => bindPath(clone(PROJECT), 'characters.C99.wji.datasetId', 'x'), /no element/, 'bind refuses unknown ids');
1381
+ const staged = manifest(PROJECT, { 1: E01 }, { stage: 'outline' });
1382
+ ok(!staged.some((i) => i.stage === 'characters'), 'manifest --stage limits the stages');
1383
+ }
1384
+
1385
+ // ── init & file IO ──────────────────────────────────────────────────────────
1386
+ {
1387
+ const p = initProject({ title: 't', episodes: 4, minutes: 2, genre: 'g' });
1388
+ eq(p.params.episodes, 4, 'init stores numbers');
1389
+ eq(p.params.video, 'h3', 'init defaults video to h3');
1390
+ eq(p.params.register, '', 'init leaves the register empty (gated) unless given');
1391
+ eq(initProject({ title: 't', episodes: 4, minutes: 2, genre: 'g', register: '台灣國語' }).params.register, '台湾国语', 'init normalises a traditional register spelling');
1392
+ ok(p.outline.brief && p.outline.guideline && Array.isArray(p.outline.structure), 'init writes the brief / structure / guideline shapes');
1393
+ assert.throws(() => initProject({ title: 't' }), /--episodes/, 'init requires episodes');
1394
+ const dir = mkdtempSync(join(tmpdir(), 'sd-'));
1395
+ try {
1396
+ writeJsonFile(join(dir, 'project.json'), p);
1397
+ writeJsonFile(join(dir, 'episodes/E02.json'), { ep: 2 });
1398
+ const loaded = loadProject(dir);
1399
+ eq(loaded.project.title, 't', 'loadProject reads project.json');
1400
+ eq(Object.keys(loaded.episodes).join(','), '2', 'loadProject picks up episode files');
1401
+ eq(Object.keys(loadProject(join(dir, 'project.json'), { eps: [1] }).episodes).length, 0, 'loadProject honours --ep');
1402
+ } finally {
1403
+ rmSync(dir, { recursive: true, force: true });
1404
+ }
1405
+ eq(parseEpRange('1-3').join(','), '1,2,3', 'ep range');
1406
+ eq(parseEpRange('2,5').join(','), '2,5', 'ep list');
1407
+ assert.throws(() => parseEpRange('x'), /bad --ep/, 'ep range rejects junk');
1408
+ eq(slug('E01-01 便利店 / 夜班'), 'e01-01-便利店-夜班', 'slug keeps CJK, drops separators');
1409
+ }
1410
+
1411
+ console.log(`✓ ${passed} assertions passed`);