@wji.ai/cli 0.8.1 → 0.8.3

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.
@@ -7,7 +7,6 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { dirname, join } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { renderDashboard } from './short-drama-dashboard.mjs';
11
10
  import {
12
11
  CAMERAS, GATES, STAGES, STYLE_PRESETS, CINEMATOGRAPHY_PRESETS, cinematographyForGenre, ACTION_STYLES, ACTION_RULES, actionForGenre, IP_NAMES_RE, GRAMMARS, GRAMMAR_RULES, COMEDY_STYLES, FACESLAP, grammarsOf, grammarLines, grammarForGenre, comedyForGenre, COMPOSITION_STYLES, COMPOSITION_DEVICES, compositionOf, compositionForGenre, STORY_GENRES, STORY_RULES, storyOf, storyForGenre, FORMATS, span, runsAtLeast, formatForGenre, cinematographyOf, stopNumber,
13
12
  bindPath, claimedLines, containsName, cutStarts, dialogueSeconds, episodeSeconds,
@@ -15,12 +14,15 @@ import {
15
14
  paramsOf, parseEpRange, promptSkeleton, renderCharacter, renderOutline, renderProp, renderScene, renderScript,
16
15
  renderSegment, runGates, seedScript, seedStoryboard, seedanceAnchors, slug, timestamp, writeJsonFile,
17
16
  wanLegendLine, wanSheetLegendLine, wanShotHeader, segmentReferences, imageReferences, audioReferences, voiceSamplePrompt, voiceSampleSeconds, nonWhitespace,
18
- speechDirection, cameraPhrase, lightPhrase, foregroundPhrase, focalOf, LIGHT_DIRECTIONS, identityOf, isEnglish,
17
+ speechDirection, cameraPhrase, lightPhrase, idlePhrase, idleSentence, IDLE_MAX_CHARS, eyelinePhrase, eyelineValid, onlyInFramePhrase, overShoulderPhrase, sidesLine, foregroundPhrase, foregroundBand, foregroundForLook, focalOf, LIGHT_DIRECTIONS, identityOf, isEnglish,
19
18
  renderGuideline, renderDialogue, scriptSelfCheck, beatOffsets, turnPoints, parseAge, talkAndAction, normalizeRegister, REGISTERS, REGISTER_ACCENT,
20
19
  cutStart, segmentCharacters, keyScenes, shotTexts, motionSentence, cameraClause, PURPOSE_KINDS, ANGLES, HEIGHTS, NO_SUBTITLES, LIPS_CLOSED,
21
20
  audioPrompt, musicOf, cueSeconds, AUDIO_MODEL, AUDIO_MAX_CHARS, AUDIO_MAX_SECONDS, scoreSheet, smpte,
22
21
  VIDEO_MODELS, VIDEO_MODES, DEFAULT_PARAMS, videoModel, videoResolutionFor, nearestResolution, segmentPlan, videoReferences,
22
+ formatGates, displayName, imagePrompt, imagePromptKit, foldZh, IMAGE_PROMPT_LANGS,
23
23
  } from './short-drama.mjs';
24
+ import { spawnSync } from 'node:child_process';
25
+ import { copyFileSync, existsSync, symlinkSync } from 'node:fs';
24
26
 
25
27
  const here = dirname(fileURLToPath(import.meta.url));
26
28
  const FIXTURE = join(here, '../examples/night-shift');
@@ -48,34 +50,35 @@ const breaks = (gateId, project, episode, needle) => {
48
50
 
49
51
  // ── dashboard ──────────────────────────────────────────────────────────────
50
52
  {
51
- const outputPath = join(tmpdir(), 'wji-short-drama-dashboard-test.html');
52
- const html = renderDashboard({
53
- project: PROJECT,
54
- episodes: { 1: E01 },
55
- gateResults: runGates(makeContext(PROJECT, { 1: E01 })),
56
- projectDir: FIXTURE,
57
- outputPath,
58
- });
53
+ const html = readFileSync(join(here, '../dashboard.html'), 'utf8');
59
54
  ok(html.startsWith('<!doctype html>'), 'dashboard is a standalone HTML document');
60
- ok(html.includes('夜班便利店') && html.includes('人物设定'), 'dashboard renders project sections');
61
- ok(html.includes('E01-01') && html.includes('制作总览'), 'dashboard renders episode storyboard');
62
- ok(html.includes('图片占位'), 'dashboard keeps an image placeholder when no media is bound');
63
-
64
- const mediaDir = mkdtempSync(join(tmpdir(), 'wji-dashboard-media-'));
65
- const localImage = join(mediaDir, 'lin.jpg');
66
- writeFileSync(localImage, '');
67
- const withMedia = clone(PROJECT);
68
- withMedia.characters[0].image.url = localImage;
69
- withMedia.characters[1].video = { url: 'https://cdn.example.com/chen.mp4', type: 'video' };
70
- const mediaHtml = renderDashboard({
71
- project: withMedia,
72
- episodes: { 1: E01 },
73
- projectDir: mediaDir,
74
- outputPath: join(mediaDir, 'dashboard.html'),
75
- });
76
- ok(mediaHtml.includes('lin.jpg') && mediaHtml.includes('<img'), 'dashboard uses a local image path');
77
- ok(mediaHtml.includes('<video src="https://cdn.example.com/chen.mp4"'), 'dashboard uses a WJI video URL');
78
- rmSync(mediaDir, { recursive: true, force: true });
55
+ ok(html.includes('project.json') && html.includes('episodes/E'), 'dashboard loads project.json and episode files');
56
+ ok(html.includes('/assets/check-status') && html.includes('/assets/list/'), 'dashboard resolves an asset id to its file url');
57
+ ok(html.includes('圖片佔位'), 'dashboard keeps an image placeholder when no media is bound');
58
+ ok(!html.includes('夜班便利店'), 'dashboard does not bake the fixture into the page');
59
+ ok(!html.includes('asset download'), 'dashboard does not download asset files');
60
+ ok(html.includes('data-target') && html.includes('scrollTo'), 'dashboard left nav scrolls the content column');
61
+ ok(html.includes('episode.scenes') && html.includes('△') && html.includes('<details class="script">'), 'dashboard folds the episode script from scenes[].flow');
62
+ ok(html.includes('localStorage.setItem') && html.includes('wji-short-drama-token') && html.includes('login-dialog'), 'dashboard saves the API key in localStorage');
63
+ ok(html.includes('https://app.wji.ai') && html.includes('InfiniteCanvas') && html.includes("app: 'Dataset'"), 'dashboard links to the WJI canvas and dataset pages');
64
+ ok(html.includes('id="viewer"') && html.includes('data-kind'), 'dashboard enlarges an image or video on click');
65
+
66
+ const dir = mkdtempSync(join(tmpdir(), 'sd-dash-copy-'));
67
+ try {
68
+ writeFileSync(join(dir, 'project.json'), JSON.stringify(PROJECT));
69
+ const engine = join(here, 'short-drama.mjs');
70
+ const first = spawnSync(process.execPath, [engine, 'dashboard', dir], { encoding: 'utf8' });
71
+ eq(first.status, 0, `dashboard copies a missing file: ${first.stderr}`);
72
+ ok(existsSync(join(dir, 'dashboard.html')), 'dashboard.html is copied beside project.json');
73
+ const stamped = join(dir, 'dashboard.html');
74
+ writeFileSync(stamped, 'keep');
75
+ const second = spawnSync(process.execPath, [engine, 'dashboard', dir], { encoding: 'utf8' });
76
+ eq(second.status, 0, 'dashboard leaves an existing file');
77
+ eq(readFileSync(stamped, 'utf8'), 'keep', 'an existing dashboard.html is not overwritten');
78
+ ok(second.stdout.includes('already beside project.json'), 'dashboard reports the file is already there');
79
+ } finally {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ }
79
82
  }
80
83
 
81
84
  // ── fixture is green ────────────────────────────────────────────────────────
@@ -847,18 +850,127 @@ const breaks = (gateId, project, episode, needle) => {
847
850
  breaks('photography', PROJECT, noNote, 'light.note is required');
848
851
  const wrongDir = clone(E01); wrongDir.storyboard.segments[0].cuts[0].light.direction = 'top';
849
852
  breaks('photography', PROJECT, wrongDir, 'keyed from directly above');
850
- // Foreground: named in the frame, said to be out of focus, and present on at least half the cuts.
853
+ // Foreground: named in the frame, said to be out of focus, and within params.foregroundLevel across the episode.
851
854
  ok(foregroundPhrase(cut).includes('out of focus'), 'the foreground phrase says it is defocused');
852
855
  eq(foregroundPhrase({ foreground: '' }), '', 'a clean frame adds nothing');
853
856
  ok(foregroundPhrase({ foreground: '\u6536\u94f6\u53f0' }, 'zh').includes('\u524d\u666f'), 'the Chinese foreground phrase says 前景');
854
- const noFg = clone(E01); noFg.storyboard.segments[0].cuts.forEach((c) => { c.foreground = ''; });
855
- breaks('photography', PROJECT, noFg, 'no depth');
857
+ const noFg = clone(E01); noFg.storyboard.segments.forEach((seg) => seg.cuts.forEach((c) => { c.foreground = ''; }));
858
+ // The fixture sits at `high`: no foreground at all is too few there, and fine at `low`.
859
+ breaks('photography', PROJECT, noFg, 'params.foregroundLevel high allows');
860
+ const asLevel = (level) => ({ ...PROJECT, params: { ...PROJECT.params, foregroundLevel: level } });
861
+ ok(!failures('photography', asLevel('low'), noFg).some((f) => f.includes('foregroundLevel')), 'low accepts an episode of clean frames');
862
+ breaks('photography', asLevel('low'), E01, 'params.foregroundLevel low allows');
863
+ breaks('photography', asLevel('medium'), E01, 'params.foregroundLevel medium allows');
864
+ breaks('params', asLevel('sometimes'), E01, 'params.foregroundLevel must be one of');
865
+ const levelFor = (params) => paramsOf({ params }).foregroundLevel;
866
+ eq(levelFor({}), 'medium', 'the default level is medium');
867
+ eq(levelFor({ composition: 'standard', cinematography: 'low-key', genre: '复仇' }), 'medium', 'a standard look stays medium');
868
+ eq(levelFor({ composition: 'art-first' }), 'high', '艺术优先 defaults to high');
869
+ eq(levelFor({ cinematography: 'sweet-romance' }), 'high', 'the 唯美 romance preset defaults to high');
870
+ eq(levelFor({ cinematography: 'costume-idol' }), 'high', 'the 唯美 costume preset defaults to high');
871
+ eq(levelFor({ genre: '唯美古风' }), 'high', 'a 唯美 genre defaults to high');
872
+ eq(levelFor({ genre: '美感優先' }), 'high', 'a 美感優先 genre defaults to high');
873
+ eq(levelFor({ composition: 'art-first', foregroundLevel: 'low' }), 'low', 'a named level beats the look');
874
+ eq(foregroundForLook({ cinematography: 'costume-dark' }), 'medium', 'the dark costume preset is not 唯美');
875
+ eq(foregroundBand('low', 40).max, 10, 'low allows a quarter of the cuts');
876
+ eq(foregroundBand('medium', 40).min, 10, 'medium starts at a quarter');
877
+ eq(foregroundBand('medium', 40).max, 20, 'medium stops at half');
878
+ eq(foregroundBand('high', 40).min, 20, 'high starts at half');
879
+ ok(foregroundBand('nope', 40) === null, 'an unknown level has no band');
880
+ for (const [k, pr] of Object.entries(CINEMATOGRAPHY_PRESETS)) if (pr) ok(!/foreground/i.test(pr.videoPhrase) && !/前景/.test(pr.videoPhraseZh), `${k} leaves the foreground to the level`);
856
881
  const missingFg = clone(E01); delete missingFg.storyboard.segments[0].cuts[0].foreground;
857
882
  breaks('photography', PROJECT, missingFg, 'foreground is required');
858
883
  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');
859
884
  breaks('photography', PROJECT, unsaidFg, 'must name the foreground');
860
885
  }
861
886
 
887
+ {
888
+ // Everyone on screen is alive: a model freezes whoever the prompt gives no motion.
889
+ eq(idlePhrase('shifts her weight', '@image3'), '@image3 shifts her weight.', 'an idle reads as a sentence');
890
+ eq(idlePhrase('still', '@image3'), '@image3 holds still, only breathing and blinking.', 'still keeps the breath');
891
+ ok(idlePhrase('轻轻换了一下重心', '@image3', 'zh').endsWith('轻轻换了一下重心。'), 'the Chinese idle');
892
+ eq(idleSentence({ characters: ['A', 'B'], idle: { B: 'taps a finger' } }, (id) => id), 'B taps a finger. ', 'only the people with an idle are written');
893
+ const p = clone(PROJECT); p.params.references = 'sheets';
894
+ const only = (f) => f.filter((m) => m.startsWith('E01-02'));
895
+ const build = (idle) => {
896
+ const ep = clone(E01); const seg = ep.storyboard.segments.find((s) => s.id === 'E01-02');
897
+ const two = seg.cuts[2]; // C01 + C02
898
+ if (idle) two.idle = idle;
899
+ seg.prompt = promptSkeleton(p, ep, seg.id).video.prompt;
900
+ return { ep, seg, two };
901
+ };
902
+ const bare = build();
903
+ ok(only(failures('idle-motion', p, bare.ep)).some((m) => m.includes('have an idle')), 'a second visible person with no idle fails');
904
+ const good = build({ C02: 'shifts her weight and picks at her sleeve' });
905
+ eq(only(failures('idle-motion', p, good.ep)).filter((m) => !m.includes('cameraNote.hold')).length, 0, 'one idle for the second person passes');
906
+ ok(good.seg.prompt.includes('shifts her weight and picks at her sleeve.'), 'the skeleton writes the idle');
907
+ const stillOne = build({ C02: 'still' });
908
+ ok(stillOne.seg.prompt.includes('holds still, only breathing and blinking.'), 'still is written with the breath');
909
+ eq(only(failures('idle-motion', p, stillOne.ep)).filter((m) => !m.includes('cameraNote.hold')).length, 0, 'keeping still is allowed when it is the performance');
910
+ ok(only(failures('idle-motion', p, build({ C02: 'walks to the door' }).ep)).some((m) => m.includes('too big')), 'a change of place is not an idle');
911
+ ok(only(failures('idle-motion', p, build({ C02: 'says something quietly' }).ep)).some((m) => m.includes('too big')), 'a line is not an idle');
912
+ ok(only(failures('idle-motion', p, build({ C02: 'x'.repeat(IDLE_MAX_CHARS + 1) }).ep)).some((m) => m.includes('one small motion')), 'an idle stays small');
913
+ const unwritten = build({ C02: 'taps a finger' }); unwritten.seg.prompt = unwritten.seg.prompt.replace(/taps a finger\.\s*/, '');
914
+ ok(only(failures('idle-motion', p, unwritten.ep)).some((m) => m.includes('must carry the idle')), 'an idle left out of the prompt fails');
915
+ const onlyHold = build({ C02: 'taps a finger' }); onlyHold.two.cameraNote = { ...onlyHold.two.cameraNote, hold: 'only the sign flickers' };
916
+ ok(only(failures('idle-motion', p, onlyHold.ep)).some((m) => m.includes('drop "only"')), '"only the sign" contradicts the people moving');
917
+ const offScreen = build({ C03: 'taps a finger' });
918
+ ok(only(failures('idle-motion', p, offScreen.ep)).some((m) => m.includes('not on screen')), 'an idle for someone off screen fails');
919
+ eq(failures('idle-motion', PROJECT, E01).length, 0, 'keyframes mode is exempt');
920
+ }
921
+
922
+ {
923
+ // Two people on screen, sheets mode: who is in frame, where they look, which side they stand on.
924
+ eq(eyelinePhrase('screen-right down', '@image3'), '@image3 looks toward screen-right and down.', 'an eyeline has a direction and a height');
925
+ eq(eyelinePhrase('task', '@image3'), '@image3 keeps their eyes on what their hands are doing.', 'task keeps the eyes on the work');
926
+ ok(eyelinePhrase('away screen-left', '@image3').includes('looks away'), 'away says so');
927
+ ok(eyelinePhrase('screen-left up', '@image3', 'zh').includes('左侧上方'), 'the Chinese eyeline');
928
+ ok(eyelineValid('screen-left level') && eyelineValid('task') && eyelineValid('away screen-right down'), 'valid eyelines');
929
+ ok(!eyelineValid('at him') && !eyelineValid('screen-right sideways'), 'an eyeline without a direction is refused');
930
+ const p = clone(PROJECT); p.params.references = 'sheets';
931
+ const framed = () => {
932
+ const ep = clone(E01);
933
+ const seg = ep.storyboard.segments.find((s) => s.id === 'E01-02'); // C01 | C02 | C01+C02 | C02 | C02, C02 screen left
934
+ const look = { C01: 'screen-left level', C02: 'screen-right level' };
935
+ seg.cuts.forEach((c) => { c.eyeline = Object.fromEntries((c.characters || []).map((id) => [id, look[id]])); });
936
+ seg.prompt = promptSkeleton(p, ep, seg.id).video.prompt;
937
+ return { ep, seg };
938
+ };
939
+ const only = (f) => f.filter((m) => m.startsWith('E01-02'));
940
+ const { ep: okEp, seg: okSeg } = framed();
941
+ eq(only(failures('two-person-framing', p, okEp)).length, 0, 'the skeleton writes everything the gate asks for');
942
+ ok(okSeg.prompt.includes('Sides: '), 'the skeleton locks the sides');
943
+ ok(okSeg.prompt.includes('Only @image'), 'the skeleton opens a single with who is in frame');
944
+ ok(okSeg.prompt.includes('looks toward screen-left'), 'the skeleton states the eyeline');
945
+ const noLook = framed(); delete noLook.seg.cuts[0].eyeline;
946
+ ok(only(failures('two-person-framing', p, noLook.ep)).some((m) => m.includes('is required')), 'an eyeline is required');
947
+ const vague = framed(); vague.seg.cuts[0].eyeline = { C01: 'at him' };
948
+ ok(only(failures('two-person-framing', p, vague.ep)).some((m) => m.includes('not valid')), '"at him" is refused');
949
+ const wrong = framed(); wrong.seg.cuts[0].eyeline = { C01: 'screen-right level' };
950
+ ok(only(failures('two-person-framing', p, wrong.ep)).some((m) => m.includes('so looking at the other person is screen-left')), 'looking away from the other person is caught');
951
+ const away = framed(); away.seg.cuts[0].eyeline = { C01: 'away screen-right' }; away.seg.prompt = promptSkeleton(p, away.ep, away.seg.id).video.prompt;
952
+ ok(!only(failures('two-person-framing', p, away.ep)).some((m) => m.includes('so looking')), 'a deliberate look away passes');
953
+ const noSides = framed(); noSides.seg.prompt = noSides.seg.prompt.replace(/Sides: [^\n]*\n/, '');
954
+ ok(only(failures('two-person-framing', p, noSides.ep)).some((m) => m.includes('must lock the sides')), 'a prompt without the sides line fails');
955
+ const leaked = framed();
956
+ const labelOf = /(@image\d+) looks toward screen-right/.exec(leaked.seg.prompt)[1];
957
+ leaked.seg.prompt = leaked.seg.prompt.replace(/(Shot 1 \([^)]*\): )/, `$1Behind him stands ${labelOf}. `).replace(/(\[Shot 1\] )/, `$1Behind him stands ${labelOf}. `);
958
+ ok(only(failures('two-person-framing', p, leaked.ep)).some((m) => m.includes('must not describe')), 'a single that names the other person fails');
959
+ const ots = framed(); const twoShot = ots.seg.cuts[2];
960
+ twoShot.ots = 'C01'; delete twoShot.eyeline.C01;
961
+ ots.seg.prompt = promptSkeleton(p, ots.ep, ots.seg.id).video.prompt;
962
+ ok(ots.seg.prompt.includes('their face not visible'), 'the skeleton writes the over-the-shoulder');
963
+ ok(ots.seg.prompt.includes('lower-right foreground'), 'the near shoulder sits on its own side of the axis');
964
+ eq(only(failures('two-person-framing', p, ots.ep)).length, 0, 'over-the-shoulder needs no eyeline from the shoulder');
965
+ const badOts = framed(); badOts.seg.cuts[0].ots = 'C02';
966
+ ok(only(failures('two-person-framing', p, badOts.ep)).some((m) => m.includes('ots must be one of')), 'ots must be on screen with one other');
967
+ eq(onlyInFramePhrase('@image3', 'zh'), '画面中只有@image3。', 'the Chinese single');
968
+ ok(overShoulderPhrase('@image3', 'left', 'zh').includes('左下方前景'), 'the Chinese over-the-shoulder');
969
+ ok(sidesLine({ screenLeft: 'A', screenRight: 'B' }, (id) => id, 'zh').includes('A始终在画面左侧'), 'the Chinese sides line');
970
+ // Keyframes carry the blocking in their pictures: the gate stays out of the way.
971
+ eq(failures('two-person-framing', PROJECT, E01).length, 0, 'keyframes mode is exempt');
972
+ }
973
+
862
974
  {
863
975
  // The house cinematography (params.cinematography low-key): practical sources, no flat light on a
864
976
  // person, most cuts keyed from the side or behind, shot wide open; the look rides every prompt.
@@ -1517,7 +1629,11 @@ const breaks = (gateId, project, episode, needle) => {
1517
1629
  const s = seedScript(PROJECT, 2);
1518
1630
  eq(s.targetSeconds, 60, 'seed target seconds from params');
1519
1631
  eq(s.beatsClaimed.join(','), 'B02', 'seed claims the episode beats');
1520
- eq(s.seedNote.previousCliff, PROJECT.outline.episodes[0].suspense, 'seed carries the previous cliff');
1632
+ eq(s.seedNote.previousCliff, PROJECT.outline.episodes[0].hook, 'seed carries the previous cliff (the outline hook of the episode before)');
1633
+ eq(s.hook, '', 'seed leaves the opening hook for the writer');
1634
+ eq(s.cliff, PROJECT.outline.episodes[1].hook, 'seed cliff is the outline hook (the question that makes them tap next)');
1635
+ eq(s.seedNote.suspense, PROJECT.outline.episodes[1].suspense, 'seed note carries the outline suspense');
1636
+ ok(s.seedNote.openingHook.includes('opens the episode'), 'seed note says the hook is the opening image');
1521
1637
  eq(s.scenes.length, 0, 'seed leaves scenes empty');
1522
1638
  assert.throws(() => seedScript(PROJECT, 9), /no episode 9/, 'seed refuses unknown episode');
1523
1639
  const sb = seedStoryboard(PROJECT, E01);
@@ -2298,4 +2414,295 @@ const breaks = (gateId, project, episode, needle) => {
2298
2414
  eq(slug('E01-01 便利店 / 夜班'), 'e01-01-便利店-夜班', 'slug keeps CJK, drops separators');
2299
2415
  }
2300
2416
 
2417
+ // ── gate inventory (crew engine) ────────────────────────────────────────────
2418
+ {
2419
+ eq(GATES.length, 69, 'gate count');
2420
+ eq(GATES.filter((g) => !g.advisory).length, 63, 'hard gate count');
2421
+ eq(GATES.filter((g) => g.advisory).length, 6, 'advisory gate count');
2422
+ ok(!GATES.find((g) => g.id === 'boundary-contrast').advisory, 'boundary-contrast is hard');
2423
+ ok(GATES.find((g) => g.id === 'boundary-contrast-episode').advisory, 'boundary-contrast-episode is advisory');
2424
+ eq(DEFAULT_PARAMS.boundaryContrastCuts, 2, 'boundaryContrastCuts default');
2425
+ }
2426
+
2427
+ // ── boundary-contrast (跨场边界) ─────────────────────────────────────────────
2428
+ {
2429
+ passes('boundary-contrast', PROJECT, E01);
2430
+ // E01-01 is scene 1, E01-02 opens scene 2: copy scene 1's last cut signature onto scene 2's first cut
2431
+ const last = E01.storyboard.segments[0].cuts.at(-1);
2432
+ const same = (c) => Object.assign(c, { size: last.size, angle: last.angle, characters: [...last.characters] });
2433
+ const e = clone(E01); same(e.storyboard.segments[1].cuts[0]);
2434
+ breaks('boundary-contrast', PROJECT, e, 'across the scene 1→2 boundary');
2435
+ const e2 = clone(e); e2.storyboard.segments[1].cuts[0].note = 'match-cut on her hand';
2436
+ passes('boundary-contrast', PROJECT, e2);
2437
+ const e3 = clone(e); e3.storyboard.segments[0].cuts.at(-1).note = 'Match Cut';
2438
+ passes('boundary-contrast', PROJECT, e3);
2439
+ // K decides how deep the comparison reaches: a match one cut in only counts when K ≥ 2
2440
+ const e4 = clone(E01); same(e4.storyboard.segments[1].cuts[1]);
2441
+ breaks('boundary-contrast', PROJECT, e4, 'cut 2');
2442
+ const p1 = clone(PROJECT); p1.params.boundaryContrastCuts = 1;
2443
+ passes('boundary-contrast', p1, e4);
2444
+ const p9 = clone(PROJECT); p9.params.boundaryContrastCuts = 9; // clamps to 3
2445
+ const e5 = clone(E01); same(e5.storyboard.segments[1].cuts[2]);
2446
+ breaks('boundary-contrast', p9, e5, 'cut 3');
2447
+ passes('boundary-contrast', PROJECT, e5);
2448
+ const pBad = clone(PROJECT); pBad.params.boundaryContrastCuts = 'lots'; // falls back to 2
2449
+ breaks('boundary-contrast', pBad, e4, 'cut 2');
2450
+ const e6 = clone(e); e6.storyboard.segments.forEach((s) => delete s.sceneIndex);
2451
+ passes('boundary-contrast', PROJECT, e6);
2452
+ }
2453
+
2454
+ // ── boundary-contrast-episode (跨集边界, advisory) ───────────────────────────
2455
+ {
2456
+ const e02 = clone(E01);
2457
+ e02.storyboard.segments.forEach((s, i) => { s.id = `E02-${String(i + 1).padStart(2, '0')}`; });
2458
+ const lastCut = E01.storyboard.segments.at(-1).cuts.at(-1);
2459
+ Object.assign(e02.storyboard.segments[0].cuts[0], { size: lastCut.size, angle: lastCut.angle, characters: [...lastCut.characters] });
2460
+ const g = GATES.find((x) => x.id === 'boundary-contrast-episode');
2461
+ const notes = g.run(makeContext(PROJECT, { 1: E01, 2: e02 }));
2462
+ ok(notes.length > 0 && notes.some((m) => m.includes('across episode E01→E02')), `episode boundary noted: ${notes.join(' | ')}`);
2463
+ const results = runGates(makeContext(PROJECT, { 1: E01, 2: e02 }), { only: ['storyboard'] });
2464
+ const r = results.find((x) => x.id === 'boundary-contrast-episode');
2465
+ ok(r.advisory && r.failures.length === 0 && r.warnings.length > 0, 'episode boundary is a warning, never a failure');
2466
+ e02.storyboard.segments[0].cuts[0].note = 'match-cut';
2467
+ eq(g.run(makeContext(PROJECT, { 1: E01, 2: e02 })).length, 0, 'match-cut exempts the episode boundary');
2468
+ eq(g.run(makeContext(PROJECT, { 1: E01 })).length, 0, 'one episode has no episode boundary');
2469
+ }
2470
+
2471
+ // ── refOverride / faceOf (Director pins the picture references) ──────────────
2472
+ {
2473
+ const sheets = clone(PROJECT); sheets.params.references = 'sheets';
2474
+ const e = clone(E01);
2475
+ const seg = e.storyboard.segments[1]; // S01 with C01 + C02 + P01
2476
+ const derived = segmentReferences(sheets, e, seg);
2477
+ eq(derived.filter((r) => r.kind === 'image').map((r) => r.entity).join(','), 'S01,C01,C02,P01', 'derived picture order: environment, people, props');
2478
+ seg.refOverride = [{ entity: 'C01', asset: 'headshot', faceOf: 'C01' }, { entity: 'S01' }];
2479
+ const refs = segmentReferences(sheets, e, seg);
2480
+ const pics = refs.filter((r) => r.kind === 'image');
2481
+ eq(pics.map((r) => `${r.entity}:${r.asset}:${r.label}`).join(' '), 'C01:headshot:@image1 S01:sheet:@image2', 'refOverride replaces the pictures, in its own order');
2482
+ eq(pics[0].faceOf, 'C01', 'faceOf rides along');
2483
+ eq(pics[1].why, 'S01:sheet', 'override defaults fill why');
2484
+ eq(refs.filter((r) => r.kind === 'audio').length, derived.filter((r) => r.kind === 'audio').length, 'voices are still appended after an override');
2485
+ const sk = promptSkeleton(sheets, e, 'E01-02');
2486
+ ok(sk.video.prompt.includes('the person in @image1'), 'the speaker binds to the headshot label');
2487
+ // A body sheet first, the headshot later: the headshot still wins the face binding
2488
+ seg.refOverride = [{ entity: 'C01', asset: 'sheet' }, { entity: 'C01', asset: 'headshot', faceOf: 'C01' }, { entity: 'S01' }];
2489
+ const sk2 = promptSkeleton(sheets, e, 'E01-02');
2490
+ ok(sk2.video.prompt.includes('the person in @image2'), 'headshot / faceOf beats an earlier body sheet');
2491
+ seg.refOverride = [];
2492
+ eq(segmentReferences(sheets, e, seg).filter((r) => r.kind === 'image')[0].entity, 'S01', 'an empty override falls back to the derived set');
2493
+ }
2494
+
2495
+ // ── VO lines: off-screen, no @audio, every visible mouth closed ───────────────
2496
+ {
2497
+ const vo = (project) => {
2498
+ const e = clone(E01);
2499
+ e.scenes[1].flow[2].speaker = 'VO'; // beat 3, claimed by E01-02 cut 3 where C01 and C02 are on screen
2500
+ const sk = promptSkeleton(project, e, 'E01-02');
2501
+ const line = sk.dialogue.flat().find((l) => l.speaker === 'VO');
2502
+ ok(line && /off-screen/.test(line.speakerRef), 'VO speaker is described as off-screen');
2503
+ ok(sk.video.prompt.includes('off-screen'), 'the prompt says the voice is off-screen');
2504
+ ok(sk.video.prompt.includes(`do not speak, ${LIPS_CLOSED}`), 'both visible people keep their lips closed under a VO');
2505
+ ok(audioReferences(project, e, e.storyboard.segments[1]).every((r) => r.entity !== 'VO'), 'VO gets no voice reference');
2506
+ eq(claimedLines(e.scenes[1], e.storyboard.segments[1].cuts).filter((l) => l.speaker === 'VO').length, 1, 'the VO line is still claimed by its cut');
2507
+ return e;
2508
+ };
2509
+ const eVo = vo(PROJECT); // h3 / keyframes branch
2510
+ const sheets = clone(PROJECT); sheets.params.references = 'sheets'; vo(sheets); // sheets branch
2511
+ const seedance = clone(PROJECT); seedance.params.video = 'seedance'; seedance.params.references = 'sheets'; vo(seedance);
2512
+ passes('beat-flow', PROJECT, eVo);
2513
+ const eBad = clone(E01); eBad.scenes[1].flow[2].speaker = 'C03';
2514
+ breaks('beat-flow', PROJECT, eBad, 'not in this scene (or VO)');
2515
+ }
2516
+
2517
+ // ── one-episode structure ───────────────────────────────────────────────────
2518
+ {
2519
+ const one = clone(PROJECT);
2520
+ one.params.episodes = 1;
2521
+ one.outline.episodes = one.outline.episodes.slice(0, 1);
2522
+ const golden = { ...one.outline.structure[0], episodes: [1, 1] };
2523
+ const climax = { ...one.outline.structure[2], episodes: [1, 1] };
2524
+ one.outline.structure = [golden, climax];
2525
+ passes('structure', one, null);
2526
+ const p2 = clone(one); p2.outline.structure = [golden];
2527
+ breaks('structure', p2, null, 'golden → climax');
2528
+ const p3 = clone(one); p3.outline.structure = [{ ...golden, episodes: [1, 2] }, climax];
2529
+ breaks('structure', p3, null, 'inside 1–1');
2530
+ const p4 = clone(one); p4.params.episodes = 2; p4.outline.episodes = PROJECT.outline.episodes.slice(0, 2);
2531
+ p4.outline.structure = [golden, { ...climax, episodes: [2, 2] }];
2532
+ passes('structure', p4, null);
2533
+ const p5 = clone(p4); p5.outline.structure = [{ ...golden, episodes: [1, 1] }, { ...climax, episodes: [1, 2] }];
2534
+ breaks('structure', p5, null, 'expected 2');
2535
+ }
2536
+
2537
+ // ── formatGates (what the CLI prints and what wji-backend embeds) ─────────────
2538
+ {
2539
+ const results = runGates(makeContext(PROJECT, { 1: E01 }));
2540
+ const rep = formatGates(results);
2541
+ eq(Object.keys(rep).sort().join(','), 'advisory,failed,hard,lines,notes,summary,text', 'formatGates shape');
2542
+ eq(rep.summary, '✓ 63 gate(s) passed, 6 advisory (1 note)', 'fixture summary');
2543
+ eq(rep.failed, 0, 'nothing failed'); eq(rep.hard, 63, 'hard count'); eq(rep.advisory, 6, 'advisory count');
2544
+ // The fixture now raises one advisory (E01-03 carries the 爽点 and is not cut as faceslap),
2545
+ // exactly as it does in the wji-cli copy — advisory lines print, failures still must not.
2546
+ eq(rep.lines.filter((l) => l.includes('✗')).length, 0, 'a green run prints no failed gates');
2547
+ eq(rep.lines.length, 2, 'a green run prints only the advisory and its note');
2548
+ ok(rep.text.endsWith(rep.summary), 'text ends with the summary');
2549
+ const staged = formatGates(runGates(makeContext(PROJECT, {}), { stage: 'art' }), { stage: 'art' });
2550
+ ok(staged.summary.includes('(through art)'), 'staged summary names the stage');
2551
+ const all = formatGates(results, { all: true });
2552
+ ok(all.lines.length >= 67 && all.lines.some((l) => l.includes('(advisory)')), '--all lists every gate');
2553
+ ok(formatGates(runGates(makeContext(PROJECT, {})), { all: true }).lines.some((l) => l.includes('(skipped: no episode files)')), '--all shows skipped gates');
2554
+ const bad = clone(PROJECT); bad.params.register = '';
2555
+ const red = formatGates(runGates(makeContext(bad, { 1: E01 })));
2556
+ ok(red.failed > 0 && red.summary.startsWith('✗') && red.text.includes('outline/params'), 'a broken register shows as a failed outline/params gate');
2557
+ }
2558
+
2559
+ // ── names in the prompt-bound voice / delivery text; disguised characters ─────
2560
+ {
2561
+ const p = clone(PROJECT);
2562
+ p.characters[1].voice.emotion += ',对小夏说话时放轻';
2563
+ breaks('voice', p, null, 'voice.emotion contains the name "小夏"');
2564
+ const p2 = clone(PROJECT);
2565
+ p2.characters[0].voice.sample.direction = '盯着老陈,压低声音';
2566
+ breaks('voice', p2, null, 'voice.sample.direction contains the name "老陈"');
2567
+ const p3 = clone(PROJECT);
2568
+ p3.characters[0].voice.prompt += ' Speaks softly to Chen.';
2569
+ passes('voice', p3); // an English word that is no name of the cast
2570
+ p3.characters[1].aliases.push('Uncle Chen');
2571
+ p3.characters[0].voice.prompt += ' Warm with Uncle Chen.';
2572
+ breaks('voice', p3, null, 'voice.prompt contains the name "Uncle Chen"');
2573
+
2574
+ const wan = clone(PROJECT); wan.params.video = 'wan';
2575
+ passes('beat-flow', wan, E01);
2576
+ const e = clone(E01);
2577
+ e.scenes[0].flow[1].delivery = '压着嗓子,盯着林小夏';
2578
+ breaks('beat-flow', wan, e, 'delivery names "林小夏"');
2579
+ passes('beat-flow', PROJECT, e); // an English (h3) prompt never carries a Chinese delivery
2580
+ const eEn = clone(E01);
2581
+ eEn.scenes[0].flow[1].delivery = 'hisses it at Uncle Chen';
2582
+ breaks('beat-flow', p3, eEn, 'delivery names "Uncle Chen"');
2583
+
2584
+ const d = clone(PROJECT);
2585
+ d.characters[2].disguise = { name: '过路人', revealEpisode: 3 };
2586
+ passes('tiers', d);
2587
+ eq(displayName(d, 'C03', 1), '过路人', 'before the reveal the disguise name is shown');
2588
+ eq(displayName(d, 'C03', 3), '穿雨衣的男人', 'from the reveal episode on the real name is shown');
2589
+ eq(displayName(d, 'C03'), '穿雨衣的男人', 'without an episode the real name is shown');
2590
+ eq(displayName(d, 'C01', 1), '林小夏', 'undisguised characters keep their name');
2591
+ ok(forbiddenNames(d).includes('过路人'), 'a disguise name is a forbidden prompt name');
2592
+ const script = renderScript(d, E01);
2593
+ ok(script.includes('过路人(') && !script.includes('穿雨衣的男人('), 'render script attributes lines to the disguise name');
2594
+ ok(/人物:[^\n]*过路人/.test(script), 'render script lists the disguise name in the scene cast');
2595
+ ok(renderDialogue(d, E01).includes('过路人(') && !renderDialogue(d, E01).includes('穿雨衣的男人('), 'render dialogue uses the disguise name');
2596
+ eq(seedScript(d, 1).seedNote.speech.C03.name, '过路人', 'seed note uses the disguise name');
2597
+ const late = clone(E01); late.ep = 3;
2598
+ ok(renderScript(d, late).includes('穿雨衣的男人('), 'after the reveal the script uses the real name');
2599
+ for (const [bad, needle] of [[{ name: '过路人', revealEpisode: 1 }, 'revealEpisode'], [{ name: '穿雨衣的男人', revealEpisode: 2 }, 'equals the real name'], [{ revealEpisode: 2 }, 'disguise.name is empty'], [{ name: '过路人', revealEpisode: 9 }, 'revealEpisode']]) {
2600
+ const x = clone(PROJECT); x.characters[2].disguise = bad;
2601
+ breaks('tiers', x, null, needle);
2602
+ }
2603
+ }
2604
+
2605
+ // ── Chinese image prompts (params.imagePromptLang = zh) ──────────────────────
2606
+ {
2607
+ // the fixture with its image prompts written in Chinese; C02 in 繁體 to prove the folding
2608
+ const zh = clone(PROJECT);
2609
+ zh.params.imagePromptLang = 'zh';
2610
+ const R = '半写实角色插画,绘画质感,笔触柔和融合并带可见的笔刷肌理,解剖结构扎实';
2611
+ const NEG = '眼神空洞、塑料皮肤、过度磨皮、完全对称的脸、死板发型、无纹理布料、标志、品牌名、文字、水印';
2612
+ const CH = {
2613
+ C01: ['二十四岁左右的东亚女性,当代中国,便利店夜班店员,短发齐耳,褪色的藏青夹克,眼下微青,四分之三侧身胸像,纯色中性背景,左上方柔和方向光',
2614
+ '单人角色设定图,一张 16:9 横版画布,纯白背景。' + R + '。左区一张正面胸像,右上三个全身像(正面、侧面、背面),右下四个细节特写;人物:二十四岁东亚女性,短发,藏青夹克。无文字、无水印。'],
2615
+ C02: ['六十歲出頭的東亞男性,當代中國,退休保安,矮壯微駝,花白平頭,洗舊的灰色夾克,指節粗大,四分之三側身胸像,純色中性背景,左上方柔和方向光',
2616
+ '單人角色設定圖,一張 16:9 橫版畫布,純白背景。半寫實角色插畫,繪畫質感,筆觸柔和。左區一張正面胸像,右上三個全身像,右下四個細節特寫;人物:六十歲東亞男性,花白平頭,灰夾克。無文字、無水印。'],
2617
+ C03: ['三十多岁的东亚男性,当代中国,穿深色连帽雨衣,帽檐压低,下巴有胡茬,身形瘦高,四分之三侧身胸像,纯色中性背景,右侧硬光',
2618
+ '单人角色设定图,一张 16:9 横版画布,纯白背景。' + R + '。左区一张正面胸像,右上三个全身像,右下四个细节特写;人物:三十多岁东亚男性,深色雨衣,瘦高。无文字、无水印。'],
2619
+ };
2620
+ for (const c of zh.characters) Object.assign(c.image, { prompt: CH[c.id][0], sheet: CH[c.id][1], negativePrompt: NEG });
2621
+ const SC = {
2622
+ S01: ['中国城市里一家小便利店的店内,空场景,没有人,从门口望向收银台,关东煮机的灯箱闪烁,绿色四格监控屏,夜晚冷白日光灯', { 凌晨冷白光: '凌晨,整片冷白日光灯,货架投下硬影', 监控回放绿: '只剩监控屏的绿光照亮收银台,其余沉在暗里' }],
2623
+ S02: ['便利店门口的条纹雨棚下,空场景,没有人,湿漉漉的地面反光,门口的垃圾桶,雨夜', { 雨夜冷白灯: '雨夜,雨棚下一盏冷白灯,雨丝在灯下发亮' }],
2624
+ };
2625
+ for (const sc of zh.scenes) {
2626
+ Object.assign(sc.image, { prompt: SC[sc.id][0], sheet: `环境设定图,一张 16:9 横版画布。${R}。主画面:${SC[sc.id][0]};右侧与底部为锚点细节特写,所有格子是同一个空间。无文字、无水印。`, negativePrompt: '人、人物、人影、人群、手、标志、品牌名、文字、水印' });
2627
+ for (const l of sc.lighting) l.prompt = SC[sc.id][1][l.state];
2628
+ }
2629
+ const pr = zh.props[0];
2630
+ Object.assign(pr.image, { prompt: '一个旧的三点五寸监控硬盘,手持尺寸,金属外壳有划痕,贴着褪色的标签(不可辨读),纯白背景,没有人,没有手,柔和均匀的棚拍光',
2631
+ sheet: `道具设定图,一张 16:9 横版画布,纯白背景。${R}。主画面:旧监控硬盘,手持尺寸,四分之三视角;右侧与底部为细节特写。没有人、没有手。无文字、无水印。`,
2632
+ negativePrompt: '人、人物、手、手指、杂乱背景、标志、品牌名、文字、水印' });
2633
+ pr.states[0].prompt = '装在半透明的防静电袋里,袋口折起';
2634
+ pr.states[1].prompt = '接上一根灰色数据线,指示灯亮着';
2635
+ for (const g of ['params', 'char-prompts', 'distinct', 'scene-design', 'scene-prompts', 'prop-design']) passes(g, zh);
2636
+ ok(IMAGE_PROMPT_LANGS.includes('zh') && imagePromptKit(zh.params).zh, 'zh is an image-prompt language');
2637
+ eq(foldZh('真人實拍參考攝影'), '真人实拍参考摄影', 'traditional folds to simplified');
2638
+
2639
+ const en = clone(PROJECT); en.params.imagePromptLang = 'zh';
2640
+ breaks('char-prompts', en, null, 'image.prompt must be Chinese');
2641
+ breaks('scene-prompts', en, null, 'must be Chinese');
2642
+ breaks('scene-design', en, null, 'prompt must be Chinese');
2643
+ breaks('prop-design', en, null, 'must be Chinese');
2644
+ const fr = clone(PROJECT); fr.params.imagePromptLang = 'fr';
2645
+ breaks('params', fr, null, 'imagePromptLang');
2646
+
2647
+ const x = clone(zh);
2648
+ x.characters[0].image.prompt = x.characters[0].image.prompt.replace('东亚女性,当代中国', '女性,当代');
2649
+ breaks('char-prompts', x, null, 'ethnicity');
2650
+ const bright = clone(zh);
2651
+ bright.characters[1].image.prompt = '东亚男性,退休保安,明亮清晰的元素,矮壮微驼'; // 明 / 清 / 元 alone are no era
2652
+ breaks('char-prompts', bright, null, 'era');
2653
+ x.characters[1].image.prompt = x.characters[1].image.prompt.replace('當代中國,', '');
2654
+ breaks('char-prompts', x, null, 'era');
2655
+ x.characters[2].image.sheet = x.characters[2].image.sheet.replace(R, '写实风格');
2656
+ breaks('char-prompts', x, null, 'render phrase "半写实角色插画…"');
2657
+ x.characters[0].image.negativePrompt += '、照片級';
2658
+ breaks('char-prompts', x, null, 'must not ban "照片级"');
2659
+ x.characters[0].image.sheet = x.characters[0].image.sheet.replace('16:9', '横版');
2660
+ breaks('char-prompts', x, null, '16:9');
2661
+ const y = clone(zh);
2662
+ y.scenes[0].image.prompt = y.scenes[0].image.prompt.replace('空场景,没有人,', '');
2663
+ breaks('scene-prompts', y, null, 'empty');
2664
+ y.scenes[1].image.negativePrompt = '标志、文字、水印';
2665
+ breaks('scene-prompts', y, null, 'ban people');
2666
+ const z = clone(zh);
2667
+ z.props[0].image.prompt = z.props[0].image.prompt.replace('手持尺寸,', '');
2668
+ breaks('prop-design', z, null, 'must contain "手持尺寸"');
2669
+ z.props[0].image.sheet = z.props[0].image.sheet.replace('纯白背景', '灰色背景');
2670
+ breaks('prop-design', z, null, 'white background');
2671
+ z.props[0].image.negativePrompt = '人、标志、文字';
2672
+ breaks('prop-design', z, null, 'ban hands');
2673
+ const d = clone(zh);
2674
+ d.characters[2].image.prompt = d.characters[0].image.prompt;
2675
+ breaks('distinct', d, null, 'image prompts are 100% alike'); // CJK bigrams, not English words
2676
+
2677
+ const head = imagePrompt(zh, 'C01', 'headshot');
2678
+ ok(head.prompt.endsWith('正面干净人像,纯色中性背景') && head.ratio === '1:1' && head.from === null && head.lang === 'zh', 'zh headshot prompt');
2679
+ const sheet = imagePrompt(zh, 'C01', 'sheet');
2680
+ ok(sheet.prompt.startsWith('@image1 是必须完全一致的脸') && sheet.ratio === '16:9' && sheet.from === 'headshot' && sheet.negative === NEG, 'zh character sheet references the headshot');
2681
+ const light = imagePrompt(zh, 'S01', 'lighting:监控回放绿');
2682
+ ok(light.prompt.includes('只改变光线:只剩监控屏的绿光') && light.prompt.endsWith('空场景,没有任何人,电影剧照') && light.from === 'sheet', 'zh lighting variant');
2683
+ ok(imagePrompt(zh, 'P01', 'state:接在主机上').prompt.startsWith('@image1 是要完全还原的同一件道具。接上一根灰色数据线'), 'zh prop state');
2684
+ eq(imagePrompt(zh, 'S02').prompt, zh.scenes[1].image.sheet, 'a scene sheet is its sheet prompt');
2685
+ ok(imagePrompt(PROJECT, 'C01', 'sheet').prompt.startsWith('@image1 is the face to match exactly'), 'en character sheet keeps the English prefix');
2686
+ ok(imagePrompt(zh, 'C02', 'sheet').prompt.startsWith('@image1 是必須完全一致的臉——五官與髮型相同。'), '繁體 prompts get the 繁體 prefix');
2687
+ ok(imagePrompt(zh, 'C02', 'headshot').prompt.endsWith(',正面乾淨人像,純色中性背景'), '繁體 headshot suffix');
2688
+ ok(imagePrompt(PROJECT, 'S01', 'lighting:凌晨冷白光').prompt.endsWith('Empty scene, no people, cinematic film still'), 'en lighting variant');
2689
+ assert.throws(() => imagePrompt(zh, 'S01', 'lighting:白天'), /no lighting state/, 'unknown lighting state refused');
2690
+ assert.throws(() => imagePrompt(zh, 'C01', 'lighting:x'), /headshot \| sheet/, 'a character has no lighting');
2691
+ assert.throws(() => imagePrompt(zh, 'X9'), /no character/, 'unknown id refused');
2692
+ }
2693
+
2694
+ // ── the CLI entry point survives a symlink (realpath isMain) ──────────────────
2695
+ {
2696
+ const dir = mkdtempSync(join(tmpdir(), 'sd-link-'));
2697
+ try {
2698
+ copyFileSync(join(here, 'short-drama.mjs'), join(dir, 'engine.mjs'));
2699
+ symlinkSync(join(dir, 'engine.mjs'), join(dir, 'sd'));
2700
+ const run = spawnSync(process.execPath, [join(dir, 'sd')], { encoding: 'utf8' });
2701
+ eq(run.status, 0, 'symlinked engine exits 0 with usage');
2702
+ ok(run.stdout.includes('short-drama — deterministic tooling'), 'symlinked engine prints usage (isMain resolved through realpath)');
2703
+ } finally {
2704
+ rmSync(dir, { recursive: true, force: true });
2705
+ }
2706
+ }
2707
+
2301
2708
  console.log(`✓ ${passed} assertions passed`);