@wji.ai/cli 0.8.2 → 0.9.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.
@@ -73,7 +73,7 @@ the headshot + sheet as image references and the voice as audio.
73
73
  ```
74
74
 
75
75
  - headshot: `canvas add image -n "C01 headshot" -m <image model> -r 1:1 -p "<image.prompt>"`
76
- - sheet: `canvas add image -n "C01 sheet" --from <headshotNode> -m <image model> -r 16:9 -p "@image1 is the face to match. <image.sheet>"`
76
+ - sheet: `canvas add image -n "C01 sheet" --from <headshotNode> -m <image model> -r 16:9 -p "@image1 is the face to match exactly. <image.sheet>"`
77
77
  - voice: `canvas add audio -n "C01 voice" -m <audio/tts model> -p "<voice.prompt>"` (optional; skip when no voice model is enabled)
78
78
  - title: `canvas add title -t "C01 林小夏" --at <x>,<y>` — one per row, rows 700 px apart
79
79
 
@@ -14,7 +14,7 @@ import {
14
14
  paramsOf, parseEpRange, promptSkeleton, renderCharacter, renderOutline, renderProp, renderScene, renderScript,
15
15
  renderSegment, runGates, seedScript, seedStoryboard, seedanceAnchors, slug, timestamp, writeJsonFile,
16
16
  wanLegendLine, wanSheetLegendLine, wanShotHeader, segmentReferences, imageReferences, audioReferences, voiceSamplePrompt, voiceSampleSeconds, nonWhitespace,
17
- 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,
18
18
  renderGuideline, renderDialogue, scriptSelfCheck, beatOffsets, turnPoints, parseAge, talkAndAction, normalizeRegister, REGISTERS, REGISTER_ACCENT,
19
19
  cutStart, segmentCharacters, keyScenes, shotTexts, motionSentence, cameraClause, PURPOSE_KINDS, ANGLES, HEIGHTS, NO_SUBTITLES, LIPS_CLOSED,
20
20
  audioPrompt, musicOf, cueSeconds, AUDIO_MODEL, AUDIO_MAX_CHARS, AUDIO_MAX_SECONDS, scoreSheet, smpte,
@@ -850,18 +850,127 @@ const breaks = (gateId, project, episode, needle) => {
850
850
  breaks('photography', PROJECT, noNote, 'light.note is required');
851
851
  const wrongDir = clone(E01); wrongDir.storyboard.segments[0].cuts[0].light.direction = 'top';
852
852
  breaks('photography', PROJECT, wrongDir, 'keyed from directly above');
853
- // 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.
854
854
  ok(foregroundPhrase(cut).includes('out of focus'), 'the foreground phrase says it is defocused');
855
855
  eq(foregroundPhrase({ foreground: '' }), '', 'a clean frame adds nothing');
856
856
  ok(foregroundPhrase({ foreground: '\u6536\u94f6\u53f0' }, 'zh').includes('\u524d\u666f'), 'the Chinese foreground phrase says 前景');
857
- const noFg = clone(E01); noFg.storyboard.segments[0].cuts.forEach((c) => { c.foreground = ''; });
858
- 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`);
859
881
  const missingFg = clone(E01); delete missingFg.storyboard.segments[0].cuts[0].foreground;
860
882
  breaks('photography', PROJECT, missingFg, 'foreground is required');
861
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');
862
884
  breaks('photography', PROJECT, unsaidFg, 'must name the foreground');
863
885
  }
864
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
+
865
974
  {
866
975
  // The house cinematography (params.cinematography low-key): practical sources, no flat light on a
867
976
  // person, most cuts keyed from the side or behind, shot wide open; the look rides every prompt.
@@ -2307,8 +2416,8 @@ const breaks = (gateId, project, episode, needle) => {
2307
2416
 
2308
2417
  // ── gate inventory (crew engine) ────────────────────────────────────────────
2309
2418
  {
2310
- eq(GATES.length, 67, 'gate count');
2311
- eq(GATES.filter((g) => !g.advisory).length, 61, 'hard gate count');
2419
+ eq(GATES.length, 69, 'gate count');
2420
+ eq(GATES.filter((g) => !g.advisory).length, 63, 'hard gate count');
2312
2421
  eq(GATES.filter((g) => g.advisory).length, 6, 'advisory gate count');
2313
2422
  ok(!GATES.find((g) => g.id === 'boundary-contrast').advisory, 'boundary-contrast is hard');
2314
2423
  ok(GATES.find((g) => g.id === 'boundary-contrast-episode').advisory, 'boundary-contrast-episode is advisory');
@@ -2430,8 +2539,8 @@ const breaks = (gateId, project, episode, needle) => {
2430
2539
  const results = runGates(makeContext(PROJECT, { 1: E01 }));
2431
2540
  const rep = formatGates(results);
2432
2541
  eq(Object.keys(rep).sort().join(','), 'advisory,failed,hard,lines,notes,summary,text', 'formatGates shape');
2433
- eq(rep.summary, '✓ 61 gate(s) passed, 6 advisory (1 note)', 'fixture summary');
2434
- eq(rep.failed, 0, 'nothing failed'); eq(rep.hard, 61, 'hard count'); eq(rep.advisory, 6, 'advisory count');
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');
2435
2544
  // The fixture now raises one advisory (E01-03 carries the 爽点 and is not cut as faceslap),
2436
2545
  // exactly as it does in the wji-cli copy — advisory lines print, failures still must not.
2437
2546
  eq(rep.lines.filter((l) => l.includes('✗')).length, 0, 'a green run prints no failed gates');