@wji.ai/cli 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/dist/commands/backreel.js +260 -0
- package/dist/commands/canvas.js +588 -88
- package/dist/commands/class.js +395 -0
- package/dist/commands/npc.js +413 -0
- package/dist/commands/workspace.js +109 -24
- package/dist/core/members.js +54 -0
- package/dist/core/npc.js +108 -0
- package/dist/core/output.js +4 -2
- package/dist/index.js +8 -0
- package/package.json +1 -1
- package/skill/ads/SKILL.md +176 -0
- package/skill/ads/reference/ai-consistency.md +109 -0
- package/skill/ads/reference/brief-template.md +103 -0
- package/skill/ads/reference/compliance.md +66 -0
- package/skill/ads/reference/formats-image.md +108 -0
- package/skill/ads/reference/formats-video.md +121 -0
- package/skill/ads/reference/models.md +90 -0
- package/skill/ads/reference/product-photography.md +119 -0
- package/skill/ads/reference/styles.md +70 -0
- package/skill/short-drama/SKILL.md +90 -8
- package/skill/short-drama/reference/action-bullet-time.md +47 -0
- package/skill/short-drama/reference/action-car-chase.md +51 -0
- package/skill/short-drama/reference/action-gun-fu.md +52 -0
- package/skill/short-drama/reference/action-historical-realist.md +50 -0
- package/skill/short-drama/reference/action-hk-kungfu.md +54 -0
- package/skill/short-drama/reference/action-street-brawl.md +49 -0
- package/skill/short-drama/reference/action-superhero.md +50 -0
- package/skill/short-drama/reference/action-wuxia-fantasy.md +51 -0
- package/skill/short-drama/reference/action.md +94 -0
- package/skill/short-drama/reference/cinematography-costume-dark.md +117 -0
- package/skill/short-drama/reference/cinematography-costume-idol.md +126 -0
- package/skill/short-drama/reference/cinematography-low-key.md +115 -0
- package/skill/short-drama/reference/cinematography-period-nostalgia.md +106 -0
- package/skill/short-drama/reference/cinematography-scifi-hard.md +63 -0
- package/skill/short-drama/reference/cinematography-scifi-soft.md +66 -0
- package/skill/short-drama/reference/cinematography-sweet-romance.md +175 -0
- package/skill/short-drama/reference/cinematography.md +83 -0
- package/skill/short-drama/reference/composition-art-first.md +74 -0
- package/skill/short-drama/reference/craft-shots.md +3 -1
- package/skill/short-drama/reference/craft-story.md +4 -1
- package/skill/short-drama/reference/format.md +94 -0
- package/skill/short-drama/reference/grammar-comedy.md +105 -0
- package/skill/short-drama/reference/grammar-faceslap.md +67 -0
- package/skill/short-drama/reference/grammar.md +103 -0
- package/skill/short-drama/reference/operations-cli.md +1 -1
- package/skill/short-drama/reference/schema.md +34 -3
- package/skill/short-drama/reference/stage-art.md +1 -1
- package/skill/short-drama/reference/stage-outline.md +21 -0
- package/skill/short-drama/reference/stage-script.md +10 -0
- package/skill/short-drama/reference/stage-storyboard.md +15 -0
- package/skill/short-drama/reference/story-angst.md +117 -0
- package/skill/short-drama/reference/story-comedy.md +136 -0
- package/skill/short-drama/reference/story-cultivation.md +109 -0
- package/skill/short-drama/reference/story-family.md +119 -0
- package/skill/short-drama/reference/story-horror.md +124 -0
- package/skill/short-drama/reference/story-mystery.md +127 -0
- package/skill/short-drama/reference/story-palace.md +103 -0
- package/skill/short-drama/reference/story-revenge.md +124 -0
- package/skill/short-drama/reference/story-scifi.md +112 -0
- package/skill/short-drama/reference/story-sweet.md +128 -0
- package/skill/short-drama/reference/story.md +127 -0
- package/skill/short-drama/scripts/selftest.mjs +809 -1
- package/skill/short-drama/scripts/short-drama.mjs +1608 -16
- package/skill/social-media/SKILL.md +152 -0
- package/skill/social-media/reference/content-craft.md +124 -0
- package/skill/social-media/reference/plan-template.md +171 -0
- package/skill/social-media/reference/platforms-china.md +94 -0
- package/skill/social-media/reference/platforms-global.md +122 -0
- package/skill/social-media/reference/ratio-map.md +100 -0
- package/skill/social-media/reference/repurpose.md +119 -0
- package/skill/wji/SKILL.md +66 -2
- package/skill/wji/reference/commands.md +76 -15
- package/skill/wji-classroom/SKILL.md +161 -0
|
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
|
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import {
|
|
11
|
-
CAMERAS, GATES, STAGES, STYLE_PRESETS,
|
|
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,
|
|
12
12
|
bindPath, claimedLines, containsName, cutStarts, dialogueSeconds, episodeSeconds,
|
|
13
13
|
forbiddenNames, h3AlignmentLine, initProject, jaccard, lineSeconds, loadProject, makeContext, manifest,
|
|
14
14
|
paramsOf, parseEpRange, promptSkeleton, renderCharacter, renderOutline, renderProp, renderScene, renderScript,
|
|
@@ -26,6 +26,7 @@ const FIXTURE = join(here, '../examples/night-shift');
|
|
|
26
26
|
const PROJECT = JSON.parse(readFileSync(join(FIXTURE, 'project.json'), 'utf8'));
|
|
27
27
|
const E01 = JSON.parse(readFileSync(join(FIXTURE, 'episodes/E01.json'), 'utf8'));
|
|
28
28
|
const clone = (x) => structuredClone(x);
|
|
29
|
+
const ETHNICITY_WORDS = /\b(asian|chinese|japanese|korean|k-drama|caucasian|european|western)\b/i;
|
|
29
30
|
|
|
30
31
|
let passed = 0;
|
|
31
32
|
const ok = (cond, label) => { assert.ok(cond, label); passed += 1; };
|
|
@@ -825,6 +826,158 @@ const breaks = (gateId, project, episode, needle) => {
|
|
|
825
826
|
breaks('photography', PROJECT, unsaidFg, 'must name the foreground');
|
|
826
827
|
}
|
|
827
828
|
|
|
829
|
+
{
|
|
830
|
+
// The house cinematography (params.cinematography low-key): practical sources, no flat light on a
|
|
831
|
+
// person, most cuts keyed from the side or behind, shot wide open; the look rides every prompt.
|
|
832
|
+
const look = CINEMATOGRAPHY_PRESETS['low-key'];
|
|
833
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g' }).params.cinematography, 'low-key', 'init gives new projects the house look');
|
|
834
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: '甜宠恋爱' }).params.cinematography, 'sweet-romance', 'a romance genre gets the sweet look');
|
|
835
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: '甜宠', cinematography: 'low-key' }).params.cinematography, 'low-key', 'an explicit --cinematography wins over the genre');
|
|
836
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', cinematography: 'none' }).params.cinematography, 'none', 'init --cinematography none opts out');
|
|
837
|
+
assert.throws(() => initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', cinematography: 'noir' }), /--cinematography/, 'init rejects an unknown cinematography');
|
|
838
|
+
eq(paramsOf(PROJECT).cinematography, 'none', 'a project without the key keeps its old rules');
|
|
839
|
+
eq(cinematographyOf(paramsOf(PROJECT)), null, 'none means no preset');
|
|
840
|
+
eq(stopNumber('T1.5'), 1.5, 'T-stop parses'); eq(stopNumber('f/2'), 2, 'f-stop parses');
|
|
841
|
+
const lk = clone(PROJECT); lk.params.cinematography = 'low-key';
|
|
842
|
+
const bogus = clone(PROJECT); bogus.params.cinematography = 'noir';
|
|
843
|
+
breaks('params', bogus, E01, 'params.cinematography');
|
|
844
|
+
// Scene lighting states are written in the look.
|
|
845
|
+
breaks('scene-design', lk, null, look.framePhrase);
|
|
846
|
+
const lkLit = clone(lk); lkLit.scenes.forEach((sc) => sc.lighting.forEach((l) => { l.prompt = `${look.framePhrase} from the shop's own fixtures, ${l.prompt}`; }));
|
|
847
|
+
passes('scene-design', lkLit, null);
|
|
848
|
+
// A storyboard that follows it: every cut side-keyed by a named practical.
|
|
849
|
+
const source = 'the flickering oden light box';
|
|
850
|
+
const lit = clone(E01);
|
|
851
|
+
for (const seg of lit.storyboard.segments) for (const c of seg.cuts) {
|
|
852
|
+
c.frame = c.frame.replace(LIGHT_DIRECTIONS[c.light.direction], LIGHT_DIRECTIONS['side-left']) + ` Low-key practical lighting from ${source}.`;
|
|
853
|
+
c.light = { ...c.light, direction: 'side-left', source };
|
|
854
|
+
}
|
|
855
|
+
passes('photography', lk, lit);
|
|
856
|
+
breaks('photography', lk, E01, 'light.source is required');
|
|
857
|
+
const noPhrase = clone(lit); noPhrase.storyboard.segments[0].cuts[0].frame = noPhrase.storyboard.segments[0].cuts[0].frame.replace('Low-key practical lighting', 'Lighting');
|
|
858
|
+
breaks('photography', lk, noPhrase, `must contain "${look.framePhrase}"`);
|
|
859
|
+
const unnamed = clone(lit); unnamed.storyboard.segments[0].cuts[0].frame = unnamed.storyboard.segments[0].cuts[0].frame.replace(source, 'a lamp');
|
|
860
|
+
breaks('photography', lk, unnamed, 'must name the light source');
|
|
861
|
+
const flat = clone(lit); const fc = flat.storyboard.segments[0].cuts.find((c) => (c.characters || []).length);
|
|
862
|
+
fc.frame = fc.frame.replace(LIGHT_DIRECTIONS['side-left'], LIGHT_DIRECTIONS.frontal); fc.light.direction = 'frontal';
|
|
863
|
+
breaks('photography', lk, flat, '包围光');
|
|
864
|
+
passes('photography', PROJECT, flat);
|
|
865
|
+
const slow = clone(lit); slow.storyboard.segments[0].cuts[0].aperture = 'T4';
|
|
866
|
+
slow.storyboard.segments[0].cuts[0].frame = slow.storyboard.segments[0].cuts[0].frame.replace('T1.5', 'T4');
|
|
867
|
+
breaks('photography', lk, slow, 'too slow');
|
|
868
|
+
const frontish = clone(lit);
|
|
869
|
+
for (const c of frontish.storyboard.segments[0].cuts) { c.frame = c.frame.replace(LIGHT_DIRECTIONS['side-left'], LIGHT_DIRECTIONS['front-left']); c.light.direction = 'front-left'; }
|
|
870
|
+
breaks('photography', lk, frontish, 'at least 50 %');
|
|
871
|
+
// The Look line carries the phrase when no keyframes do, and the gate asks for it.
|
|
872
|
+
const sheets = clone(lk); sheets.params.references = 'sheets';
|
|
873
|
+
ok(promptSkeleton(sheets, lit, 'E01-04').video.prompt.includes(look.videoPhrase), 'the sheets skeleton Look line carries the cinematography');
|
|
874
|
+
ok(failures('video-prompt', sheets, lit).some((m) => m.includes('cinematography phrase')), 'video-prompt asks for the cinematography phrase');
|
|
875
|
+
const wanSheets = clone(sheets); wanSheets.params.video = 'wan'; wanSheets.params.videoResolution = '480P';
|
|
876
|
+
ok(promptSkeleton(wanSheets, lit, 'E01-04').video.prompt.includes(look.videoPhraseZh), 'the wan skeleton carries the Chinese cinematography');
|
|
877
|
+
ok(!failures('video-prompt', clone(PROJECT), E01).some((m) => m.includes('cinematography')), 'no cinematography demand without the preset');
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
{
|
|
881
|
+
// sweet-romance: the same machinery with the opposite light — soft front keys and halos welcome,
|
|
882
|
+
// under- and top light on a face refused, and no nationality in the phrases.
|
|
883
|
+
const look = CINEMATOGRAPHY_PRESETS['sweet-romance'];
|
|
884
|
+
const sr = clone(PROJECT); sr.params.cinematography = 'sweet-romance';
|
|
885
|
+
passes('params', sr, E01);
|
|
886
|
+
for (const k of Object.keys(CINEMATOGRAPHY_PRESETS)) {
|
|
887
|
+
const pr = CINEMATOGRAPHY_PRESETS[k];
|
|
888
|
+
if (pr) ok(!ETHNICITY_WORDS.test(`${pr.videoPhrase} ${pr.framePhrase}`) && !/韩|韓|日系|欧美/.test(pr.videoPhraseZh), `${k} phrases name no nationality`);
|
|
889
|
+
}
|
|
890
|
+
breaks('scene-design', sr, null, look.framePhrase);
|
|
891
|
+
const source = 'the big café window behind them';
|
|
892
|
+
const soft = clone(E01);
|
|
893
|
+
for (const seg of soft.storyboard.segments) for (const c of seg.cuts) {
|
|
894
|
+
c.frame = c.frame.replace(LIGHT_DIRECTIONS[c.light.direction], LIGHT_DIRECTIONS['front-left']) + ` Soft romantic lighting from ${source}, the fairy lights behind melting into creamy bokeh.`;
|
|
895
|
+
c.light = { ...c.light, direction: 'front-left', source };
|
|
896
|
+
}
|
|
897
|
+
passes('photography', sr, soft);
|
|
898
|
+
const lk = clone(PROJECT); lk.params.cinematography = 'low-key';
|
|
899
|
+
breaks('photography', lk, soft, 'at least 50 %');
|
|
900
|
+
eq(cinematographyForGenre('都市悬疑'), 'low-key', 'a thriller genre reads low-key');
|
|
901
|
+
eq(cinematographyForGenre('韓風浪漫'), 'sweet-romance', 'traditional spellings read sweet');
|
|
902
|
+
eq(cinematographyForGenre('古装甜宠'), 'costume-idol', 'a costume romance is a costume drama first');
|
|
903
|
+
eq(cinematographyForGenre('仙侠'), 'costume-idol', 'xianxia reads idol');
|
|
904
|
+
eq(cinematographyForGenre('古裝權謀'), 'costume-dark', 'court intrigue reads dark');
|
|
905
|
+
eq(cinematographyForGenre('武侠复仇'), 'costume-dark', 'wuxia revenge reads dark');
|
|
906
|
+
eq(cinematographyForGenre('古装'), 'costume-idol', 'a bare 古装 defaults to idol');
|
|
907
|
+
eq(cinematographyForGenre('八十年代怀旧'), 'period-nostalgia', 'an era reads nostalgia');
|
|
908
|
+
eq(cinematographyForGenre('年代剧'), 'period-nostalgia', '年代剧 reads nostalgia');
|
|
909
|
+
const under = clone(soft); const uc = under.storyboard.segments[0].cuts.find((c) => (c.characters || []).length);
|
|
910
|
+
uc.frame = uc.frame.replace(LIGHT_DIRECTIONS['front-left'], LIGHT_DIRECTIONS.underlight); uc.light.direction = 'underlight';
|
|
911
|
+
breaks('photography', sr, under, '底光');
|
|
912
|
+
const noHint = clone(soft); delete noHint.storyboard.segments[0].cuts[0].light.source;
|
|
913
|
+
breaks('photography', sr, noHint, 'string lights');
|
|
914
|
+
const sheets = clone(sr); sheets.params.references = 'sheets';
|
|
915
|
+
ok(promptSkeleton(sheets, soft, 'E01-04').video.prompt.includes(look.videoPhrase), 'the sheets skeleton carries the romance look');
|
|
916
|
+
// The dreamiest bokeh: a faster package from init, T2 at the slowest, half the frames name it.
|
|
917
|
+
const init = initProject({ title: 't', episodes: 1, minutes: 1, genre: '甜宠' });
|
|
918
|
+
eq(init.params.camera.aperture, 'T1.3', 'sweet-romance brings a T1.3 package');
|
|
919
|
+
eq(init.params.camera.body, DEFAULT_PARAMS.camera.body, 'the kit keeps the default body');
|
|
920
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: '悬疑' }).params.camera, undefined, 'low-key keeps the default package');
|
|
921
|
+
const t28 = clone(soft); t28.storyboard.segments[0].cuts[0].aperture = 'T2.8';
|
|
922
|
+
t28.storyboard.segments[0].cuts[0].frame = t28.storyboard.segments[0].cuts[0].frame.replace('T1.5', 'T2.8');
|
|
923
|
+
breaks('photography', sr, t28, 'T2 or faster');
|
|
924
|
+
const flatBg = clone(soft); for (const c of flatBg.storyboard.segments[0].cuts) c.frame = c.frame.replace(/bokeh/gi, 'blur');
|
|
925
|
+
breaks('photography', sr, flatBg, 'name their bokeh');
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
{
|
|
929
|
+
// period and costume presets: each one's own storyboard passes, and a source from the wrong era fails.
|
|
930
|
+
const cases = [
|
|
931
|
+
['period-nostalgia', 'side-left', 'the bare tungsten bulb over the table', 'the LED strip'],
|
|
932
|
+
['costume-idol', 'backlight', 'the sun through the paper window', 'the street light'],
|
|
933
|
+
['costume-dark', 'side-right', 'the oil lamp on the desk', 'an electric bulb'],
|
|
934
|
+
];
|
|
935
|
+
for (const [key, dir, source, wrong] of cases) {
|
|
936
|
+
const look = CINEMATOGRAPHY_PRESETS[key];
|
|
937
|
+
const p = clone(PROJECT); p.params.cinematography = key;
|
|
938
|
+
passes('params', p, E01);
|
|
939
|
+
const e = clone(E01);
|
|
940
|
+
for (const seg of e.storyboard.segments) seg.cuts.forEach((c, k) => {
|
|
941
|
+
c.frame = c.frame.replace(LIGHT_DIRECTIONS[c.light.direction], LIGHT_DIRECTIONS[dir]) + ` ${look.framePhrase[0].toUpperCase()}${look.framePhrase.slice(1)} from ${source}, lanterns behind melting into creamy bokeh.`;
|
|
942
|
+
c.light = { ...c.light, direction: dir, source };
|
|
943
|
+
// costume-idol steps back once per segment: the first cut becomes a wide with negative space.
|
|
944
|
+
if (look.widePerSegment && k === 0 && c.size !== 'wide' && c.size !== 'extreme-wide') { c.focal = focalOf(c); c.size = 'wide'; c.frame += ' Vast negative space of mist above.'; }
|
|
945
|
+
else if (look.widePerSegment && (c.size === 'wide' || c.size === 'extreme-wide')) c.frame += ' Vast negative space of mist above.';
|
|
946
|
+
});
|
|
947
|
+
passes('photography', p, e);
|
|
948
|
+
const bad = clone(e); const c0 = bad.storyboard.segments[0].cuts[0];
|
|
949
|
+
c0.frame = c0.frame.replace(source, wrong); c0.light.source = wrong;
|
|
950
|
+
breaks('photography', p, bad, 'does not exist in this era');
|
|
951
|
+
const banned = clone(e); const bc = banned.storyboard.segments[0].cuts.find((c) => (c.characters || []).length);
|
|
952
|
+
bc.frame = bc.frame.replace(LIGHT_DIRECTIONS[dir], LIGHT_DIRECTIONS[look.bannedLight[0]]); bc.light.direction = look.bannedLight[0];
|
|
953
|
+
breaks('photography', p, banned, look.bannedWhy.slice(0, 20));
|
|
954
|
+
breaks('scene-design', p, null, look.framePhrase);
|
|
955
|
+
}
|
|
956
|
+
ok(!CINEMATOGRAPHY_PRESETS['costume-dark'].anachronisms.test('the oil lamp'), 'an oil lamp is not an electric lamp');
|
|
957
|
+
// costume-idol: a 全景 / 大全景 in every segment, built on negative space.
|
|
958
|
+
{
|
|
959
|
+
const p = clone(PROJECT); p.params.cinematography = 'costume-idol';
|
|
960
|
+
const e = clone(E01);
|
|
961
|
+
for (const seg of e.storyboard.segments) seg.cuts.forEach((c, k) => {
|
|
962
|
+
c.frame = c.frame.replace(LIGHT_DIRECTIONS[c.light.direction], LIGHT_DIRECTIONS.backlight) + ' Ethereal costume drama lighting from the paper window, lanterns melting into creamy bokeh.';
|
|
963
|
+
c.light = { ...c.light, direction: 'backlight', source: 'the paper window' };
|
|
964
|
+
if (c.size === 'wide' || c.size === 'extreme-wide') c.frame += ' Vast negative space.';
|
|
965
|
+
});
|
|
966
|
+
const noWide = clone(e);
|
|
967
|
+
for (const seg of noWide.storyboard.segments) for (const c of seg.cuts) if (c.size === 'wide' || c.size === 'extreme-wide') { c.focal = focalOf(c); c.size = 'medium'; }
|
|
968
|
+
breaks('photography', p, noWide, 'no wide or extreme-wide cut');
|
|
969
|
+
const full = clone(noWide); const c0 = full.storyboard.segments[0].cuts[0];
|
|
970
|
+
c0.focal = focalOf(c0); c0.size = 'extreme-wide';
|
|
971
|
+
breaks('photography', p, full, 'built on negative space');
|
|
972
|
+
ok(!failures('photography', PROJECT, noWide).some((m) => m.includes('wide or extreme-wide')), 'other presets do not ask for wides');
|
|
973
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: '仙侠' }).params.camera.aperture, 'T1.3', 'costume-idol brings a T1.3 package');
|
|
974
|
+
const dark = clone(PROJECT); dark.params.cinematography = 'costume-dark';
|
|
975
|
+
breaks('photography', dark, noWide, 'no wide or extreme-wide cut');
|
|
976
|
+
breaks('photography', dark, full, 'built on negative space');
|
|
977
|
+
}
|
|
978
|
+
ok(CINEMATOGRAPHY_PRESETS['period-nostalgia'].maxStop === 4, 'nostalgia may stop down to T4 for the period detail');
|
|
979
|
+
}
|
|
980
|
+
|
|
828
981
|
{
|
|
829
982
|
// Every reference is addressed the way WJI labels it: @image1 / @video1 / @audio1.
|
|
830
983
|
ok(h3AlignmentLine([{ seconds: 3 }, { seconds: 2 }]).includes('@image1'), 'the h3 alignment line uses @image1');
|
|
@@ -1386,6 +1539,661 @@ const breaks = (gateId, project, episode, needle) => {
|
|
|
1386
1539
|
ok(!staged.some((i) => i.stage === 'characters'), 'manifest --stage limits the stages');
|
|
1387
1540
|
}
|
|
1388
1541
|
|
|
1542
|
+
{
|
|
1543
|
+
// Action styles: fight segments (segment.action) open wide, take one move per cut, answer every
|
|
1544
|
+
// impact with a reaction, never ask for provider-refused gore, and carry the style phrase.
|
|
1545
|
+
eq(paramsOf(PROJECT).action, 'none', 'no action style unless chosen');
|
|
1546
|
+
eq(paramsOf(PROJECT).violence, 'blood', 'blood is allowed by default');
|
|
1547
|
+
eq(actionForGenre('港式功夫喜剧'), 'hk-kungfu', 'kung fu reads hk-kungfu');
|
|
1548
|
+
eq(actionForGenre('都市杀手枪战'), 'gun-fu', 'a gunfight reads gun-fu');
|
|
1549
|
+
eq(actionForGenre('仙侠动作'), 'wuxia-fantasy', 'xianxia + action reads wuxia-fantasy');
|
|
1550
|
+
eq(actionForGenre('仙侠甜宠'), 'none', 'xianxia romance has no action style by itself');
|
|
1551
|
+
eq(actionForGenre('古装冷兵器战争'), 'historical-realist', 'cold weapons read historical-realist');
|
|
1552
|
+
eq(actionForGenre('超级英雄'), 'superhero', 'superhero');
|
|
1553
|
+
eq(actionForGenre('飞车追逐'), 'car-chase', 'car chase');
|
|
1554
|
+
eq(actionForGenre('都市悬疑'), 'none', 'a thriller is not an action style by itself');
|
|
1555
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: '功夫' }).params.action, 'hk-kungfu', 'init reads the action style from the genre');
|
|
1556
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: '悬疑', action: 'street-brawl', violence: 'implied' }).params.violence, 'implied', 'init takes --violence');
|
|
1557
|
+
assert.throws(() => initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', action: 'kungfu' }), /--action/, 'init rejects an unknown action style');
|
|
1558
|
+
for (const k of Object.keys(ACTION_STYLES)) {
|
|
1559
|
+
const a = ACTION_STYLES[k];
|
|
1560
|
+
if (!a) continue;
|
|
1561
|
+
ok(!IP_NAMES_RE.test(`${a.videoPhrase} ${a.videoPhraseZh} ${a.label}`), `${k} names no film or performer`);
|
|
1562
|
+
ok(Array.isArray(a.cutSeconds) && a.needs.length > 0, `${k} has a rhythm and signature shots`);
|
|
1563
|
+
}
|
|
1564
|
+
const p = clone(PROJECT); p.params.action = 'hk-kungfu';
|
|
1565
|
+
const bogus = clone(PROJECT); bogus.params.action = 'kungfu';
|
|
1566
|
+
breaks('params', bogus, E01, 'params.action');
|
|
1567
|
+
// A fight: E01-04 rebuilt as wide establish → strike (impact) → reaction → block.
|
|
1568
|
+
const fight = () => {
|
|
1569
|
+
const e = clone(E01);
|
|
1570
|
+
const seg = e.storyboard.segments[3];
|
|
1571
|
+
seg.action = true;
|
|
1572
|
+
const [c0, c1, c2, c3] = seg.cuts;
|
|
1573
|
+
c0.motion = { trigger: 'the door bangs', action: 'he steps off the counter into the aisle', result: 'they face each other' };
|
|
1574
|
+
c1.motion = { trigger: 'she moves', action: 'he punches at her jaw', result: 'the fist lands' }; c1.move = { kind: 'strike', impact: true };
|
|
1575
|
+
c2.purpose = { kind: 'reaction', change: 'she is hurt' }; c2.motion = { trigger: 'the blow lands', action: 'she staggers back against the shelf', result: 'cans fall' };
|
|
1576
|
+
c3.motion = { trigger: 'he comes again', action: 'she blocks with a tray', result: 'the tray rings' }; c3.move = { kind: 'block' };
|
|
1577
|
+
seg.prompt = `${seg.prompt}\nAction: ${ACTION_STYLES['hk-kungfu'].videoPhrase}.`;
|
|
1578
|
+
return e;
|
|
1579
|
+
};
|
|
1580
|
+
passes('action', p, fight());
|
|
1581
|
+
const unmarked = clone(fight()); breaks('grammar', PROJECT, unmarked, 'params.action is none');
|
|
1582
|
+
const narrow = fight(); const n0 = narrow.storyboard.segments[3].cuts[0]; n0.focal = focalOf(n0); n0.size = 'medium';
|
|
1583
|
+
breaks('action', p, narrow, 'opens on a wide');
|
|
1584
|
+
const waived = clone(narrow); waived.storyboard.segments[3].actionExceptions = [{ rule: 'open-wide', reason: 'user asked to open on the fist' }];
|
|
1585
|
+
passes('action', p, waived);
|
|
1586
|
+
const noReason = clone(narrow); noReason.storyboard.segments[3].actionExceptions = [{ rule: 'open-wide', reason: '' }];
|
|
1587
|
+
breaks('grammar', p, noReason, 'needs a reason');
|
|
1588
|
+
const combo = fight(); combo.storyboard.segments[3].cuts[1].motion.action = 'he punches at her jaw and kicks her knee';
|
|
1589
|
+
breaks('action', p, combo, '2 fight moves');
|
|
1590
|
+
const chain = fight(); chain.storyboard.segments[3].cuts[1].motion.action = 'he punches at her jaw, then steps in';
|
|
1591
|
+
breaks('action', p, chain, 'chains moves');
|
|
1592
|
+
const zh = fight(); zh.storyboard.segments[3].cuts[1].motion.action = '他出拳,接着扫腿';
|
|
1593
|
+
breaks('action', p, zh, 'fight moves in one cut');
|
|
1594
|
+
const noReact = fight(); noReact.storyboard.segments[3].cuts[2].purpose = { kind: 'pressure', change: 'x' };
|
|
1595
|
+
breaks('action', p, noReact, 'follow a blow with a reaction');
|
|
1596
|
+
const selfShown = clone(noReact); selfShown.storyboard.segments[3].cuts[1].purpose = { kind: 'consequence', change: 'she is hit' };
|
|
1597
|
+
ok(!failures('action', p, selfShown).some((m) => m.includes('follow a blow')), 'an impact that shows its own result needs no reaction cut');
|
|
1598
|
+
const gore = fight(); gore.storyboard.segments[3].cuts[2].motion.result = 'his severed arm drops';
|
|
1599
|
+
breaks('action', p, gore, 'providers refuse');
|
|
1600
|
+
const blood = fight(); blood.storyboard.segments[3].cuts[2].motion.result = 'blood on her lip';
|
|
1601
|
+
ok(!failures('action', p, blood).some((m) => m.includes('blood')), 'blood is fine by default');
|
|
1602
|
+
const implied = clone(p); implied.params.violence = 'implied';
|
|
1603
|
+
breaks('action', implied, blood, 'violence is implied');
|
|
1604
|
+
const noPhrase = fight(); noPhrase.storyboard.segments[3].prompt = E01.storyboard.segments[3].prompt;
|
|
1605
|
+
breaks('grammar', p, noPhrase, 'must carry "Action:');
|
|
1606
|
+
const badRule = fight(); badRule.storyboard.segments[3].actionExceptions = [{ rule: 'no-wide', reason: 'x' }];
|
|
1607
|
+
breaks('grammar', p, badRule, 'is not one of');
|
|
1608
|
+
eq(ACTION_RULES.join(' '), 'open-wide one-move impact-reaction no-graphic', 'the waivable rules');
|
|
1609
|
+
// Advisory: rhythm and signature shots; skeleton carries the Action line; IP names are refused.
|
|
1610
|
+
const adv = failures('action-advice', clone(Object.assign(clone(PROJECT), { params: { ...PROJECT.params, action: 'bullet-time' } })), fight());
|
|
1611
|
+
ok(adv.some((m) => m.includes('Arc Shot')), 'bullet-time asks for an Arc Shot');
|
|
1612
|
+
ok(GATES.find((g) => g.id === 'action-advice').advisory, 'action-advice never fails validate');
|
|
1613
|
+
ok(promptSkeleton(p, fight(), 'E01-04').video.prompt.includes(`Action: ${ACTION_STYLES['hk-kungfu'].videoPhrase}.`), 'the skeleton writes the Action line on a fight segment');
|
|
1614
|
+
ok(!promptSkeleton(p, fight(), 'E01-03').video.prompt.includes(ACTION_STYLES['hk-kungfu'].videoPhrase), 'no Action line on a talk segment');
|
|
1615
|
+
const wan = clone(p); wan.params.video = 'wan'; wan.params.videoResolution = '480P';
|
|
1616
|
+
ok(promptSkeleton(wan, fight(), 'E01-04').video.prompt.includes(`动作:${ACTION_STYLES['hk-kungfu'].videoPhraseZh}`), 'wan writes 动作:');
|
|
1617
|
+
const ip = clone(E01); ip.storyboard.segments[0].prompt += ' Shot like John Wick.';
|
|
1618
|
+
breaks('no-ip-names', PROJECT, ip, 'John Wick');
|
|
1619
|
+
const ipf = clone(E01); ipf.storyboard.segments[0].cuts[0].frame += ' 李小龙 stance.';
|
|
1620
|
+
breaks('no-ip-names', PROJECT, ipf, '李小龙');
|
|
1621
|
+
passes('no-ip-names', PROJECT, E01);
|
|
1622
|
+
ok(!IP_NAMES_RE.test('the neon sign'), 'neon is not Neo');
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
{
|
|
1626
|
+
// Grammars: each segment is cut in its own grammar(s), else the drama's default; they combine.
|
|
1627
|
+
eq(GRAMMARS.join(' '), 'drama action faceslap comedy', 'four grammars');
|
|
1628
|
+
eq(GRAMMAR_RULES.length, 11, 'eleven waivable rules across the grammars and the composition');
|
|
1629
|
+
const base = paramsOf(PROJECT);
|
|
1630
|
+
eq(base.grammar, 'drama', 'drama is the default grammar');
|
|
1631
|
+
eq(grammarsOf({}, base).join(), 'drama', 'a segment without its own grammar takes the default');
|
|
1632
|
+
eq(grammarsOf({ action: true }, base).join(), 'drama,action', 'action: true still means action');
|
|
1633
|
+
eq(grammarsOf({ grammar: ['action', 'comedy'] }, base).join(), 'action,comedy', 'grammars combine');
|
|
1634
|
+
eq(grammarsOf({}, { ...base, grammar: 'comedy' }).join(), 'comedy', 'a comedy drama cuts every segment as comedy');
|
|
1635
|
+
eq(grammarForGenre('古装喜剧'), 'comedy', '古装喜剧 is a comedy');
|
|
1636
|
+
eq(comedyForGenre('古装喜剧'), 'slapstick', 'a plain comedy is slapstick');
|
|
1637
|
+
eq(comedyForGenre('冷面处境喜剧'), 'deadpan', 'deadpan');
|
|
1638
|
+
eq(comedyForGenre('甜宠喜剧'), 'romcom', 'romcom');
|
|
1639
|
+
eq(comedyForGenre('都市悬疑'), 'none', 'no comedy');
|
|
1640
|
+
eq(actionForGenre('未来科幻动作'), 'bullet-time', 'future action reads bullet-time');
|
|
1641
|
+
const gi = initProject({ title: 't', episodes: 1, minutes: 1, genre: '古装喜剧' }).params;
|
|
1642
|
+
eq(`${gi.cinematography} ${gi.grammar} ${gi.comedy} ${gi.action}`, 'costume-idol comedy slapstick none', 'init composes 古装喜剧 from its axes');
|
|
1643
|
+
const fi = initProject({ title: 't', episodes: 1, minutes: 1, genre: '未来动作' }).params;
|
|
1644
|
+
eq(`${fi.grammar} ${fi.action}`, 'drama bullet-time', 'init composes 未来动作');
|
|
1645
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', grammar: 'comedy' }).params.comedy, 'slapstick', '--grammar comedy without a comedy genre defaults to slapstick');
|
|
1646
|
+
assert.throws(() => initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', grammar: 'farce' }), /--grammar/, 'init rejects an unknown grammar');
|
|
1647
|
+
for (const k of Object.keys(COMEDY_STYLES)) if (COMEDY_STYLES[k]) ok(!IP_NAMES_RE.test(`${COMEDY_STYLES[k].videoPhrase} ${COMEDY_STYLES[k].videoPhraseZh}`), `${k} names no film or performer`);
|
|
1648
|
+
ok(!IP_NAMES_RE.test(`${FACESLAP.videoPhrase} ${FACESLAP.videoPhraseZh}`), 'faceslap names no film');
|
|
1649
|
+
|
|
1650
|
+
// grammar gate: known grammars, the style chosen, exceptions with reasons, the phrases in the prompt.
|
|
1651
|
+
const bad = clone(E01); bad.storyboard.segments[0].grammar = 'farce';
|
|
1652
|
+
breaks('grammar', PROJECT, bad, 'not one of');
|
|
1653
|
+
const noStyle = clone(E01); noStyle.storyboard.segments[0].grammar = 'comedy';
|
|
1654
|
+
breaks('grammar', PROJECT, noStyle, 'params.comedy is none');
|
|
1655
|
+
const pc = clone(PROJECT); pc.params.comedy = 'slapstick'; pc.params.action = 'hk-kungfu';
|
|
1656
|
+
const mixed = clone(E01); const ms = mixed.storyboard.segments[3]; ms.grammar = ['action', 'comedy'];
|
|
1657
|
+
const lines = grammarLines(ms, paramsOf(pc));
|
|
1658
|
+
eq(lines.length, 2, 'a comic fight carries two lines');
|
|
1659
|
+
ok(promptSkeleton(pc, mixed, 'E01-04').video.prompt.includes(lines[1]), 'the skeleton writes the Comedy line');
|
|
1660
|
+
breaks('grammar', pc, mixed, 'must carry "Comedy:');
|
|
1661
|
+
ms.prompt += `\n${lines.join('\n')}`;
|
|
1662
|
+
ok(!failures('grammar', pc, mixed).some((m) => m.includes('must carry')), 'both lines present');
|
|
1663
|
+
const wanP = clone(pc); wanP.params.video = 'wan'; wanP.params.videoResolution = '480P';
|
|
1664
|
+
ok(promptSkeleton(wanP, mixed, 'E01-04').video.prompt.includes('喜剧:'), 'wan writes 喜剧:');
|
|
1665
|
+
|
|
1666
|
+
// faceslap: the proof on screen, then at least two reactions (into the next segment of the scene).
|
|
1667
|
+
const slap = () => {
|
|
1668
|
+
const e = clone(E01); const seg = e.storyboard.segments[3]; seg.grammar = 'faceslap';
|
|
1669
|
+
const [c0, c1, c2, c3] = seg.cuts;
|
|
1670
|
+
c0.purpose = { kind: 'pressure', change: 'he sneers' }; c1.purpose = { kind: 'evidence', change: 'the badge' };
|
|
1671
|
+
c2.purpose = { kind: 'reaction', change: 'he pales' }; c3.purpose = { kind: 'reaction', change: 'she smiles' };
|
|
1672
|
+
return e;
|
|
1673
|
+
};
|
|
1674
|
+
passes('faceslap', PROJECT, slap());
|
|
1675
|
+
const noProof = slap(); noProof.storyboard.segments[3].cuts[1].purpose = { kind: 'decision', change: 'x' };
|
|
1676
|
+
breaks('faceslap', PROJECT, noProof, 'shows the proof');
|
|
1677
|
+
const oneFace = slap(); oneFace.storyboard.segments[3].cuts[3].purpose = { kind: 'decision', change: 'x' };
|
|
1678
|
+
breaks('faceslap', PROJECT, oneFace, 'at least 2');
|
|
1679
|
+
const carried = clone(oneFace); carried.storyboard.segments[4].cuts[0].purpose = { kind: 'reaction', change: 'x' };
|
|
1680
|
+
passes('faceslap', PROJECT, carried);
|
|
1681
|
+
const waivedSlap = clone(oneFace); waivedSlap.storyboard.segments[3].exceptions = [{ rule: 'reaction-chain', reason: 'user: one cold stare is enough' }];
|
|
1682
|
+
passes('faceslap', PROJECT, waivedSlap);
|
|
1683
|
+
|
|
1684
|
+
// comedy: a setup before the punchline, laugh room after it.
|
|
1685
|
+
const gag = () => {
|
|
1686
|
+
const e = clone(E01); const seg = e.storyboard.segments[3]; seg.grammar = 'comedy';
|
|
1687
|
+
const [c0, c1, c2] = seg.cuts;
|
|
1688
|
+
c0.gag = 'setup'; c1.gag = 'punchline'; c1.seconds = 2; c2.purpose = { kind: 'reaction', change: 'she gapes' }; c2.seconds = 3;
|
|
1689
|
+
return e;
|
|
1690
|
+
};
|
|
1691
|
+
passes('comedy', pc, gag());
|
|
1692
|
+
const noSetup = gag(); delete noSetup.storyboard.segments[3].cuts[0].gag;
|
|
1693
|
+
breaks('comedy', pc, noSetup, 'no setup before it');
|
|
1694
|
+
const setupEarlier = clone(noSetup); setupEarlier.storyboard.segments[2].cuts[0].gag = 'setup';
|
|
1695
|
+
ok(!failures('comedy', pc, setupEarlier).some((m) => m.includes('no setup')), 'a setup earlier in the scene counts');
|
|
1696
|
+
const noRoom = gag(); noRoom.storyboard.segments[3].cuts[2].purpose = { kind: 'decision', change: 'x' };
|
|
1697
|
+
breaks('comedy', pc, noRoom, 'no laugh room');
|
|
1698
|
+
const held = clone(noRoom); const hc = held.storyboard.segments[3].cuts[1]; hc.camera = 'Static Shot'; hc.seconds = 3;
|
|
1699
|
+
ok(!failures('comedy', pc, held).some((m) => m.includes('laugh room')), 'a punchline held long enough is its own laugh room');
|
|
1700
|
+
const dp = clone(pc); dp.params.comedy = 'deadpan';
|
|
1701
|
+
breaks('comedy', dp, held, 'no laugh room');
|
|
1702
|
+
const badGag = gag(); badGag.storyboard.segments[3].cuts[0].gag = 'joke';
|
|
1703
|
+
breaks('grammar', pc, badGag, 'gag must be one of');
|
|
1704
|
+
|
|
1705
|
+
// Advice: the 爽点 of a 打脸-type episode is cut as faceslap; a 打脸 has pressure, a low angle, a music hit.
|
|
1706
|
+
const adv = failures('grammar-advice', PROJECT, E01);
|
|
1707
|
+
ok(adv.some((m) => m.startsWith('E01-03') && m.includes('faceslap')), 'the 智商碾压 爽点 segment is asked to be a 打脸');
|
|
1708
|
+
const bare = slap(); const bs = bare.storyboard.segments[3];
|
|
1709
|
+
bs.cuts.forEach((c) => { c.height = 'eye'; }); bs.sound.music = 'N/A';
|
|
1710
|
+
const advSlap = failures('grammar-advice', PROJECT, bare);
|
|
1711
|
+
ok(!failures('grammar-advice', PROJECT, slap()).some((m) => m.startsWith('E01-04') && (m.includes('from below') || m.includes('music hit'))), 'a 打脸 with a low angle and a music enter is fine');
|
|
1712
|
+
ok(advSlap.some((m) => m.includes('from below')), 'a 打脸 without a low angle is noted');
|
|
1713
|
+
ok(advSlap.some((m) => m.includes('music hit')), 'a 打脸 without a music hit is noted');
|
|
1714
|
+
ok(GATES.find((g) => g.id === 'grammar-advice').advisory, 'grammar-advice never fails validate');
|
|
1715
|
+
const flat = clone(E01); flat.storyboard.segments[3].grammar = 'comedy';
|
|
1716
|
+
ok(failures('grammar-advice', pc, flat).some((m) => m.includes('no punchline')), 'a comedy segment without a punchline is noted');
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
{
|
|
1720
|
+
// Sci-fi looks: hard (engineered, no magic or floating holograms) and soft (holograms, neon).
|
|
1721
|
+
eq(cinematographyForGenre('硬科幻'), 'scifi-hard', '硬科幻 reads scifi-hard');
|
|
1722
|
+
eq(cinematographyForGenre('赛博朋克'), 'scifi-soft', 'cyberpunk reads scifi-soft');
|
|
1723
|
+
eq(cinematographyForGenre('科幻'), 'scifi-soft', 'a bare 科幻 reads soft');
|
|
1724
|
+
eq(cinematographyForGenre('未来动作'), 'scifi-soft', '未来动作 now has a sci-fi look');
|
|
1725
|
+
const hard = CINEMATOGRAPHY_PRESETS['scifi-hard'];
|
|
1726
|
+
ok(hard.anachronisms.test('the hologram over the table') && !hard.anachronisms.test('the instrument panel'), 'hard sci-fi refuses holograms, keeps panels');
|
|
1727
|
+
const ph = clone(PROJECT); ph.params.cinematography = 'scifi-hard';
|
|
1728
|
+
const e = clone(E01);
|
|
1729
|
+
for (const seg of e.storyboard.segments) seg.cuts.forEach((c) => {
|
|
1730
|
+
c.frame = c.frame.replace(LIGHT_DIRECTIONS[c.light.direction], LIGHT_DIRECTIONS['side-left']) + ' Grounded hard science fiction lighting from the instrument panel.';
|
|
1731
|
+
c.light = { ...c.light, direction: 'side-left', source: 'the instrument panel' };
|
|
1732
|
+
});
|
|
1733
|
+
passes('photography', ph, e);
|
|
1734
|
+
const magic = clone(e); const m0 = magic.storyboard.segments[0].cuts[0];
|
|
1735
|
+
m0.frame = m0.frame.replace('the instrument panel', 'a floating hologram'); m0.light.source = 'a floating hologram';
|
|
1736
|
+
breaks('photography', ph, magic, 'does not belong in hard science fiction');
|
|
1737
|
+
|
|
1738
|
+
// Art-first composition, in any genre: devices named and in the frame, the signature share, a wide.
|
|
1739
|
+
eq(paramsOf(PROJECT).composition, 'standard', 'standard framing by default');
|
|
1740
|
+
eq(compositionForGenre('艺术古装'), 'art-first', '艺术 in the genre reads art-first');
|
|
1741
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: '文艺科幻' }).params.composition, 'art-first', 'init reads art-first from the genre');
|
|
1742
|
+
assert.throws(() => initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', composition: 'arty' }), /--composition/, 'init rejects an unknown composition');
|
|
1743
|
+
const pa = clone(PROJECT); pa.params.composition = 'art-first';
|
|
1744
|
+
eq(compositionOf({}, paramsOf(pa)), COMPOSITION_STYLES['art-first'], 'the drama default applies');
|
|
1745
|
+
eq(compositionOf({ composition: 'standard' }, paramsOf(pa)), null, 'a segment may step out of art-first');
|
|
1746
|
+
eq(compositionOf({ composition: 'art-first' }, paramsOf(PROJECT)), COMPOSITION_STYLES['art-first'], 'a segment may step into art-first');
|
|
1747
|
+
const art = () => {
|
|
1748
|
+
const x = clone(E01);
|
|
1749
|
+
for (const seg of x.storyboard.segments) seg.cuts.forEach((c, k) => {
|
|
1750
|
+
const d = k % 2 ? 'symmetry' : 'negative-space';
|
|
1751
|
+
c.composition = [d]; c.frame += ` ${COMPOSITION_DEVICES[d]}.`;
|
|
1752
|
+
if (k === 0 && !['wide', 'extreme-wide'].includes(c.size)) { c.focal = focalOf(c); c.size = 'wide'; }
|
|
1753
|
+
});
|
|
1754
|
+
return x;
|
|
1755
|
+
};
|
|
1756
|
+
passes('composition', pa, art());
|
|
1757
|
+
breaks('composition', pa, E01, 'composition is required');
|
|
1758
|
+
const missing = art(); const a0 = missing.storyboard.segments[0].cuts[0]; a0.frame = a0.frame.replace(COMPOSITION_DEVICES['negative-space'], '');
|
|
1759
|
+
breaks('composition', pa, missing, 'must contain "vast negative space"');
|
|
1760
|
+
const unknown = art(); unknown.storyboard.segments[0].cuts[0].composition = ['dutch'];
|
|
1761
|
+
breaks('composition', pa, unknown, 'is not one of');
|
|
1762
|
+
const plain = art(); plain.storyboard.segments[0].cuts.forEach((c) => { c.composition = ['thirds']; c.frame += ` ${COMPOSITION_DEVICES.thirds}.`; });
|
|
1763
|
+
breaks('composition', pa, plain, 'art-first needs at least 50 %');
|
|
1764
|
+
const noWide = art(); noWide.storyboard.segments[0].cuts.forEach((c) => { if (['wide', 'extreme-wide'].includes(c.size)) { c.focal = focalOf(c); c.size = 'medium'; } });
|
|
1765
|
+
breaks('composition', pa, noWide, 'steps back to the whole composition');
|
|
1766
|
+
const waived = clone(noWide); waived.storyboard.segments[0].exceptions = [{ rule: 'art-wide', reason: 'user: all close for this one' }];
|
|
1767
|
+
ok(!failures('composition', pa, waived).some((m) => m.startsWith('E01-01') && m.includes('whole composition')), 'art-wide waivable');
|
|
1768
|
+
passes('composition', PROJECT, E01);
|
|
1769
|
+
ok(grammarLines({}, paramsOf(pa)).some((l) => l.startsWith('Composition: ')), 'art-first adds a Composition line');
|
|
1770
|
+
ok(promptSkeleton(pa, art(), 'E01-01').video.prompt.includes(COMPOSITION_STYLES['art-first'].videoPhrase), 'the skeleton writes it');
|
|
1771
|
+
breaks('grammar', pa, art(), 'must carry "Composition:');
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
{
|
|
1775
|
+
// Story genres: outline ledgers and script markers, per genre, combinable.
|
|
1776
|
+
eq(storyForGenre('战神逆袭甜宠').join(), 'revenge,sweet', 'genres combine');
|
|
1777
|
+
eq(storyForGenre('悬疑推理').join(), 'mystery', 'mystery');
|
|
1778
|
+
eq(storyForGenre('古装喜剧').join(), 'comedy', 'comedy');
|
|
1779
|
+
eq(storyForGenre('都市').length, 0, 'no story genre by default');
|
|
1780
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: '重生复仇' }).params.story.join(), 'revenge', 'init reads the story genre');
|
|
1781
|
+
eq(initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', story: 'mystery,comedy' }).params.story.join(), 'mystery,comedy', 'init takes --story');
|
|
1782
|
+
assert.throws(() => initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', story: 'zombie' }), /--story/, 'init rejects an unknown story genre');
|
|
1783
|
+
eq(storyOf({ story: ['revenge', 'bogus'] }).join(), 'revenge', 'storyOf keeps known genres');
|
|
1784
|
+
const bogus = clone(PROJECT); bogus.params.story = ['zombie'];
|
|
1785
|
+
breaks('params', bogus, E01, 'params.story');
|
|
1786
|
+
for (const g of ['story-revenge', 'story-sweet', 'story-mystery', 'story-comedy']) passes(g, PROJECT, null);
|
|
1787
|
+
|
|
1788
|
+
// A 30-episode outline: golden 1–4, escalation 5–24, climax 25–30.
|
|
1789
|
+
const synth = (story, typeOf) => {
|
|
1790
|
+
const p = clone(PROJECT);
|
|
1791
|
+
p.params.episodes = 30; p.params.story = story;
|
|
1792
|
+
p.outline.structure = [{ phase: 'golden', episodes: [1, 4] }, { phase: 'escalation', episodes: [5, 24] }, { phase: 'climax', episodes: [25, 30] }];
|
|
1793
|
+
const hooks = ['身份', '危机', '结果', '情感', '反转', '道具'];
|
|
1794
|
+
p.outline.episodes = Array.from({ length: 30 }, (_, i) => ({ ep: i + 1, satisfaction: { type: typeOf(i + 1), action: 'x' }, hookType: hooks[i % 6] }));
|
|
1795
|
+
return p;
|
|
1796
|
+
};
|
|
1797
|
+
|
|
1798
|
+
// 复仇: payback ladder.
|
|
1799
|
+
const rv = () => {
|
|
1800
|
+
const p = synth(['revenge'], (ep) => ([5, 6, 7].includes(ep) ? '情感释放' : '打脸'));
|
|
1801
|
+
p.characters.push(
|
|
1802
|
+
{ id: 'A1', name: '舅妈', tier: 'support', antagonist: { tier: 1, firstPressEp: 1, motive: '看不起穷亲戚' } },
|
|
1803
|
+
{ id: 'A2', name: '二少', tier: 'support', antagonist: { tier: 2, firstPressEp: 3, motive: '争家产' } },
|
|
1804
|
+
{ id: 'A3', name: '幕后', tier: 'support', antagonist: { tier: 3, firstPressEp: 2, motive: '当年的旧债' } },
|
|
1805
|
+
);
|
|
1806
|
+
p.outline.paybacks = [
|
|
1807
|
+
{ ep: 2, target: 'A1', scale: 'small', by: 'C01', how: '当众拆穿' }, { ep: 4, target: 'A1', scale: 'final', by: 'C01', how: '被赶出家门' },
|
|
1808
|
+
{ ep: 8, target: 'A2', scale: 'big', by: 'C01', how: '合约作废' }, { ep: 14, target: 'A2', scale: 'final', by: 'C01', how: '身败名裂' },
|
|
1809
|
+
{ ep: 20, target: 'A3', scale: 'big', by: 'C01', how: '证据曝光' }, { ep: 28, target: 'A3', scale: 'final', by: 'C01', how: '伏法' },
|
|
1810
|
+
];
|
|
1811
|
+
return p;
|
|
1812
|
+
};
|
|
1813
|
+
passes('story-revenge', rv(), null);
|
|
1814
|
+
const late = rv(); for (const e of late.outline.episodes) if (e.ep <= 3) e.satisfaction.type = '情感释放';
|
|
1815
|
+
breaks('story-revenge', late, null, 'units 1–3');
|
|
1816
|
+
const gap = rv(); for (const e of gap.outline.episodes) if (e.ep >= 10 && e.ep <= 13) e.satisfaction.type = '强宠';
|
|
1817
|
+
breaks('story-revenge', gap, null, '4 episodes in a row');
|
|
1818
|
+
const mixedGap = clone(gap); mixedGap.params.story = ['revenge', 'sweet'];
|
|
1819
|
+
ok(!failures('story-revenge', mixedGap, null).some((m) => m.includes('in a row')), 'a mixed drama allows a longer gap');
|
|
1820
|
+
const noMotive = rv(); noMotive.characters.find((c) => c.id === 'A2').antagonist.motive = '';
|
|
1821
|
+
breaks('story-revenge', noMotive, null, '极端反派');
|
|
1822
|
+
const twice = rv(); twice.outline.paybacks.push({ ep: 29, target: 'A3', scale: 'final', by: 'C01' });
|
|
1823
|
+
breaks('story-revenge', twice, null, '2 final paybacks');
|
|
1824
|
+
const rushed = rv(); rushed.outline.paybacks[2].ep = 4;
|
|
1825
|
+
breaks('story-revenge', rushed, null, 'a tier 2 needs 3');
|
|
1826
|
+
const upside = rv(); upside.outline.paybacks[3].ep = 3; upside.outline.paybacks[2].ep = 3; upside.characters.find((c) => c.id === 'A2').antagonist.firstPressEp = 0;
|
|
1827
|
+
breaks('story-revenge', upside, null, 'lower tiers fall first');
|
|
1828
|
+
const earlyBoss = rv(); earlyBoss.outline.paybacks[5].ep = 22;
|
|
1829
|
+
breaks('story-revenge', earlyBoss, null, 'finished in the climax');
|
|
1830
|
+
const lull = rv(); lull.outline.paybacks[3].scale = 'small'; lull.outline.paybacks[4].scale = 'small'; lull.outline.paybacks.push({ ep: 26, target: 'A2', scale: 'final', by: 'C01' });
|
|
1831
|
+
breaks('story-revenge', lull, null, 'without a big payback');
|
|
1832
|
+
const backer = rv(); backer.outline.paybacks[5].by = 'C02';
|
|
1833
|
+
breaks('story-revenge', backer, null, 'never land the last blow');
|
|
1834
|
+
const waivedBacker = clone(backer); waivedBacker.outline.storyExceptions = [{ rule: 'hero-delivers', reason: 'user: the old general finishes him' }];
|
|
1835
|
+
passes('story-revenge', waivedBacker, null);
|
|
1836
|
+
|
|
1837
|
+
// 甜宠: sugar share and gap, milestones.
|
|
1838
|
+
const sw = () => {
|
|
1839
|
+
const p = synth(['sweet'], (ep) => (ep % 2 === 0 ? '强宠' : '打脸'));
|
|
1840
|
+
p.outline.romance = [
|
|
1841
|
+
{ ep: 1, milestone: 'meet' }, { ep: 2, milestone: 'bind' }, { ep: 4, milestone: 'touch' }, { ep: 10, milestone: 'kiss' },
|
|
1842
|
+
{ ep: 14, milestone: 'confession' }, { ep: 16, milestone: 'misunderstanding', until: 18 }, { ep: 24, milestone: 'black-moment' },
|
|
1843
|
+
{ ep: 26, milestone: 'reconcile' }, { ep: 27, milestone: 'public' },
|
|
1844
|
+
];
|
|
1845
|
+
return p;
|
|
1846
|
+
};
|
|
1847
|
+
passes('story-sweet', sw(), null);
|
|
1848
|
+
const dry = sw(); for (const e of dry.outline.episodes) if (e.ep % 4 === 0) e.satisfaction.type = '打脸';
|
|
1849
|
+
breaks('story-sweet', dry, null, 'at least 40 %');
|
|
1850
|
+
const drought = sw(); for (const e of drought.outline.episodes) if (e.ep >= 6 && e.ep <= 10) e.satisfaction.type = '打脸';
|
|
1851
|
+
breaks('story-sweet', drought, null, 'without sugar');
|
|
1852
|
+
const unbound = sw(); unbound.outline.romance[1].ep = 5;
|
|
1853
|
+
breaks('story-sweet', unbound, null, 'nothing binds');
|
|
1854
|
+
const cold = sw(); cold.outline.romance[2].ep = 8; cold.outline.romance[3].ep = 8;
|
|
1855
|
+
breaks('story-sweet', cold, null, 'no physical closeness');
|
|
1856
|
+
const noConf = sw(); noConf.outline.romance[4].ep = 22;
|
|
1857
|
+
breaks('story-sweet', noConf, null, 'midpoint of love');
|
|
1858
|
+
const longMis = sw(); longMis.outline.romance[5].until = 20;
|
|
1859
|
+
breaks('story-sweet', longMis, null, 'lasts 5 episodes');
|
|
1860
|
+
const noUntil = sw(); delete noUntil.outline.romance[5].until;
|
|
1861
|
+
breaks('story-sweet', noUntil, null, 'until');
|
|
1862
|
+
const noMend = sw(); noMend.outline.romance[7].ep = 30;
|
|
1863
|
+
breaks('story-sweet', noMend, null, 'nothing reconciles');
|
|
1864
|
+
const sad = sw(); for (const e of sad.outline.episodes) if (e.ep > 27) e.satisfaction.type = '打脸';
|
|
1865
|
+
breaks('story-sweet', sad, null, 'happy ending');
|
|
1866
|
+
const badMs = sw(); badMs.outline.romance.push({ ep: 3, milestone: 'wink' });
|
|
1867
|
+
breaks('story-sweet', badMs, null, 'milestone must be one of');
|
|
1868
|
+
|
|
1869
|
+
// 悬疑: questions and the clue ledger.
|
|
1870
|
+
const my = () => {
|
|
1871
|
+
const p = synth(['mystery'], () => '智商碾压');
|
|
1872
|
+
p.characters.push({ id: 'X1', name: '房东', tier: 'support', mysteryRole: 'culprit', firstEp: 2 });
|
|
1873
|
+
p.outline.questions = [
|
|
1874
|
+
{ id: 'Q1', layer: 1, question: '三年前谁拿走了硬盘', openEp: 1, answerEp: 28, clues: ['K1', 'K2', 'K3'] },
|
|
1875
|
+
{ id: 'Q2', layer: 2, question: '老陈为什么知道雨衣', openEp: 5, answerEp: 12, clues: ['K4', 'K5'] },
|
|
1876
|
+
];
|
|
1877
|
+
p.outline.clues = [
|
|
1878
|
+
{ id: 'K1', kind: 'true', what: '门口的雨衣', plantEp: 2, echoEps: [20] }, { id: 'K2', kind: 'true', what: '监控时间码', plantEp: 3, echoEps: [22] },
|
|
1879
|
+
{ id: 'K3', kind: 'true', what: '钥匙串', plantEp: 10 }, { id: 'K4', kind: 'true', what: '碗里的筷子', plantEp: 6 }, { id: 'K5', kind: 'true', what: '旧工牌', plantEp: 8 },
|
|
1880
|
+
{ id: 'R1', kind: 'red-herring', what: '店长的借据', plantEp: 5, dismissedEp: 12 },
|
|
1881
|
+
];
|
|
1882
|
+
return p;
|
|
1883
|
+
};
|
|
1884
|
+
passes('story-mystery', my(), null);
|
|
1885
|
+
const twoCentral = my(); twoCentral.outline.questions[1].layer = 1;
|
|
1886
|
+
breaks('story-mystery', twoCentral, null, '2 layer-1 questions');
|
|
1887
|
+
const lateAsk = my(); lateAsk.outline.questions[0].openEp = 6;
|
|
1888
|
+
breaks('story-mystery', lateAsk, null, 'ask it by E03');
|
|
1889
|
+
const earlyTruth = my(); earlyTruth.outline.questions[0].answerEp = 15;
|
|
1890
|
+
breaks('story-mystery', earlyTruth, null, 'truth lands in the climax');
|
|
1891
|
+
const lateCulprit = my(); lateCulprit.characters.find((c) => c.id === 'X1').firstEp = 12;
|
|
1892
|
+
breaks('story-mystery', lateCulprit, null, 'the culprit first appears');
|
|
1893
|
+
const inverted = clone(lateCulprit); inverted.outline.mysteryMode = 'thriller';
|
|
1894
|
+
ok(!failures('story-mystery', inverted, null).some((m) => m.includes('culprit')), 'a thriller has no culprit rule');
|
|
1895
|
+
const thin = my(); thin.outline.questions[0].clues = ['K1', 'K2'];
|
|
1896
|
+
breaks('story-mystery', thin, null, 'at least 3');
|
|
1897
|
+
const rushedClue = my(); rushedClue.outline.clues[4].plantEp = 11;
|
|
1898
|
+
breaks('story-mystery', rushedClue, null, 'at least 3');
|
|
1899
|
+
const forgotten = my(); delete forgotten.outline.clues[0].echoEps;
|
|
1900
|
+
breaks('story-mystery', forgotten, null, 'echo it');
|
|
1901
|
+
const herrings = my(); herrings.outline.clues.push(...[2, 3, 4, 5, 6].map((n) => ({ id: `R${n}`, kind: 'red-herring', what: 'x', plantEp: 4, dismissedEp: 9 })));
|
|
1902
|
+
breaks('story-mystery', herrings, null, 'fewer false trails');
|
|
1903
|
+
const undismissed = my(); delete undismissed.outline.clues[5].dismissedEp;
|
|
1904
|
+
breaks('story-mystery', undismissed, null, 'dismissedEp');
|
|
1905
|
+
const crowded = my(); crowded.outline.questions.push(...[3, 4, 5, 6, 7].map((n) => ({ id: `Q${n}`, layer: 3, question: 'x', openEp: 2 })));
|
|
1906
|
+
breaks('story-mystery', crowded, null, 'questions are open');
|
|
1907
|
+
const noProp = my(); noProp.outline.clues[0].prop = 'P99';
|
|
1908
|
+
breaks('story-mystery', noProp, null, 'P99 does not exist');
|
|
1909
|
+
|
|
1910
|
+
// 喜剧: premise, cast, running gags.
|
|
1911
|
+
const co = () => {
|
|
1912
|
+
const p = synth(['comedy'], () => '打脸');
|
|
1913
|
+
p.outline.brief.comicPremise = '十八岁的身体里住着太奶奶';
|
|
1914
|
+
for (const c of p.characters) c.comic = { flaw: `${c.id} 的怪癖`, role: c.id === 'C02' ? '捧' : '逗' };
|
|
1915
|
+
p.outline.runningGags = [{ id: 'G1', premise: '一紧张就背菜谱', eps: [2, 8, 26] }, { id: 'G2', premise: '永远找不到钥匙', eps: [5, 15, 29] }];
|
|
1916
|
+
return p;
|
|
1917
|
+
};
|
|
1918
|
+
passes('story-comedy', co(), null);
|
|
1919
|
+
const noPremise = co(); noPremise.outline.brief.comicPremise = '';
|
|
1920
|
+
breaks('story-comedy', noPremise, null, 'comicPremise');
|
|
1921
|
+
const noFlaw = co(); noFlaw.characters[0].comic.flaw = '';
|
|
1922
|
+
breaks('story-comedy', noFlaw, null, 'comic.flaw is empty');
|
|
1923
|
+
const allZany = co(); for (const c of allZany.characters) c.comic.role = '逗';
|
|
1924
|
+
breaks('story-comedy', allZany, null, 'nobody plays it straight');
|
|
1925
|
+
const oneGag = co(); oneGag.outline.runningGags.pop();
|
|
1926
|
+
breaks('story-comedy', oneGag, null, 'at least 2');
|
|
1927
|
+
const twice2 = co(); twice2.outline.runningGags[0].eps = [2, 26];
|
|
1928
|
+
breaks('story-comedy', twice2, null, 'at least 3');
|
|
1929
|
+
const bunched = co(); bunched.outline.runningGags[0].eps = [2, 3, 26];
|
|
1930
|
+
breaks('story-comedy', bunched, null, 'too close');
|
|
1931
|
+
const noPayoff = co(); noPayoff.outline.runningGags[0].eps = [2, 8, 14];
|
|
1932
|
+
breaks('story-comedy', noPayoff, null, 'never returns in the climax');
|
|
1933
|
+
|
|
1934
|
+
// Script markers: clues on screen, gags per episode with setups and short punchlines.
|
|
1935
|
+
const pm = clone(PROJECT); pm.params.story = ['mystery']; pm.outline.clues = [{ id: 'K1', kind: 'true', what: '雨衣', plantEp: 1 }];
|
|
1936
|
+
breaks('story-script', pm, E01, 'no beat shows it');
|
|
1937
|
+
const shown = clone(E01); shown.scenes[0].flow[0].clue = 'K1';
|
|
1938
|
+
passes('story-script', pm, shown);
|
|
1939
|
+
const pc = clone(PROJECT); pc.params.story = ['comedy'];
|
|
1940
|
+
breaks('story-script', pc, E01, 'punchline beat(s)');
|
|
1941
|
+
const funny = clone(E01);
|
|
1942
|
+
funny.scenes[1].flow[2].gag = 'setup'; funny.scenes[1].flow[4].gag = 'punchline'; funny.scenes[1].flow[9].gag = 'punchline';
|
|
1943
|
+
passes('story-script', pc, funny);
|
|
1944
|
+
const noSetup = clone(funny); delete noSetup.scenes[1].flow[2].gag;
|
|
1945
|
+
breaks('story-script', pc, noSetup, 'no setup before it');
|
|
1946
|
+
const wordy = clone(funny); wordy.scenes[1].flow[4].line = '老了,睡不着,你知道吗我年轻的时候可是一夜不睡都行的那种人';
|
|
1947
|
+
breaks('story-script', pc, wordy, 'at most 20');
|
|
1948
|
+
const badGag = clone(funny); badGag.scenes[1].flow[0].gag = 'joke';
|
|
1949
|
+
breaks('story-script', pc, badGag, 'gag must be one of');
|
|
1950
|
+
|
|
1951
|
+
// Advice: hook variety, money-only 强宠, suspects.
|
|
1952
|
+
const samey = rv(); for (const e of samey.outline.episodes) e.hookType = '危机';
|
|
1953
|
+
ok(failures('story-advice', samey, null).some((m) => m.includes('vary the cliffs')), 'a drama ending every episode the same way is noted');
|
|
1954
|
+
ok(GATES.find((g) => g.id === 'story-advice').advisory, 'story-advice never fails validate');
|
|
1955
|
+
eq(STORY_RULES.length, Object.values(STORY_GENRES).reduce((n, g) => n + g.rules.length, 0), 'every rule belongs to a genre');
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
{
|
|
1959
|
+
// Six more story genres: 虐恋, 家庭伦理, 宫斗, 修仙, 科幻, 恐怖.
|
|
1960
|
+
eq(storyForGenre('追妻火葬场').join(), 'angst', '追妻 reads angst');
|
|
1961
|
+
eq(storyForGenre('萌宝认亲').join(), 'family', '萌宝认亲 reads family');
|
|
1962
|
+
eq(storyForGenre('宫斗权谋').join(), 'palace', 'palace');
|
|
1963
|
+
eq(storyForGenre('都市修仙系统').join(), 'cultivation', 'cultivation');
|
|
1964
|
+
eq(storyForGenre('末世囤货').join(), 'scifi', 'scifi');
|
|
1965
|
+
eq(storyForGenre('规则怪谈').join(), 'horror', 'horror');
|
|
1966
|
+
eq(paramsOf(PROJECT).market, 'mainland', 'mainland by default');
|
|
1967
|
+
const badMarket = clone(PROJECT); badMarket.params.market = 'moon';
|
|
1968
|
+
breaks('params', badMarket, E01, 'params.market');
|
|
1969
|
+
assert.throws(() => initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', market: 'moon' }), /--market/, 'init rejects an unknown market');
|
|
1970
|
+
const synth = (story, typeOf) => {
|
|
1971
|
+
const p = clone(PROJECT);
|
|
1972
|
+
p.params.episodes = 30; p.params.story = story;
|
|
1973
|
+
p.outline.structure = [{ phase: 'golden', episodes: [1, 4] }, { phase: 'escalation', episodes: [5, 24] }, { phase: 'climax', episodes: [25, 30] }];
|
|
1974
|
+
const hooks = ['身份', '危机', '结果', '情感', '反转', '道具'];
|
|
1975
|
+
p.outline.episodes = Array.from({ length: 30 }, (_, i) => ({ ep: i + 1, satisfaction: { type: typeOf(i + 1), action: 'x' }, hookType: hooks[i % 6] }));
|
|
1976
|
+
return p;
|
|
1977
|
+
};
|
|
1978
|
+
const tweak = (make, fn) => { const p = make(); fn(p); return p; };
|
|
1979
|
+
|
|
1980
|
+
// 虐恋
|
|
1981
|
+
const an = () => {
|
|
1982
|
+
const p = synth(['angst'], () => '情感释放');
|
|
1983
|
+
p.outline.brief.redLines = ['动手', '下药'];
|
|
1984
|
+
p.outline.angst = {
|
|
1985
|
+
ending: 'HE', turnEp: 10, exitEp: 8, reconcileEp: 27,
|
|
1986
|
+
misbelief: { belief: '她推了白月光', engineeredBy: 'C02', evidence: '监控片段', audienceRevealEp: 2, heroRevealEp: 15 },
|
|
1987
|
+
beats: [
|
|
1988
|
+
{ ep: 2, kind: '虐', releaseEp: 3 }, { ep: 4, kind: '虐', releaseEp: 5 }, { ep: 6, kind: '虐', releaseEp: 7 },
|
|
1989
|
+
{ ep: 11, kind: '悔', cost: '失眠' }, { ep: 13, kind: '悔', cost: '当众认错' }, { ep: 15, kind: '悔', cost: '与母亲决裂' },
|
|
1990
|
+
{ ep: 17, kind: '追', cost: '放下公司' }, { ep: 21, kind: '追', cost: '雨中等候', public: true }, { ep: 25, kind: '追', cost: '交出股份' },
|
|
1991
|
+
{ ep: 28, kind: '糖' },
|
|
1992
|
+
],
|
|
1993
|
+
};
|
|
1994
|
+
return p;
|
|
1995
|
+
};
|
|
1996
|
+
passes('story-angst', an(), null);
|
|
1997
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.ending = 'sad'; }), null, 'ending must be one of');
|
|
1998
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.turnEp = 13; p.outline.angst.beats.push({ ep: 12, kind: '虐', releaseEp: 13 }); }), null, 'the pain runs to E12');
|
|
1999
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.turnEp = 20; }), null, 'turnEp');
|
|
2000
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.beats = p.outline.angst.beats.filter((b) => b.kind !== '悔'); }), null, 'outweighs');
|
|
2001
|
+
breaks('story-angst', tweak(an, (p) => { delete p.outline.angst.beats[0].releaseEp; }), null, 'not released within 2');
|
|
2002
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.misbelief.engineeredBy = 'C01'; }), null, 'engineered by a non-lead');
|
|
2003
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.misbelief.audienceRevealEp = 6; }), null, 'by E03');
|
|
2004
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.exitEp = 12; }), null, 'leaves by her own choice');
|
|
2005
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.beats[4].cost = ''; }), null, 'costs him nothing');
|
|
2006
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.beats[7].public = false; }), null, 'before witnesses');
|
|
2007
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.reconcileEp = 20; }), null, 'reconciles late');
|
|
2008
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.brief.redLines = []; }), null, 'redLines is empty');
|
|
2009
|
+
breaks('story-angst', tweak(an, (p) => { p.outline.angst.beats[1].inflictor = 'C01'; p.outline.angst.beats[1].what = '他扇耳光'; }), null, 'crosses a red line');
|
|
2010
|
+
passes('story-angst', tweak(an, (p) => { p.outline.angst.beats[1].inflictor = 'C02'; p.outline.angst.beats[1].what = '她扇耳光'; }), null);
|
|
2011
|
+
|
|
2012
|
+
// 家庭伦理
|
|
2013
|
+
const fm = () => {
|
|
2014
|
+
const p = synth(['family'], (ep) => (ep % 3 === 0 ? '情感释放' : '打脸'));
|
|
2015
|
+
const f = (id, name, tier, generation, extra = {}) => ({ id, name, tier, family: { generation, role: 'x', endState: '和解', ...extra } });
|
|
2016
|
+
p.characters = [
|
|
2017
|
+
{ ...p.characters[0], family: { generation: 1, role: '媳婿', endState: '清醒切割' } },
|
|
2018
|
+
{ ...p.characters[1], family: { generation: 0, role: '长辈', endState: '和解' } },
|
|
2019
|
+
f('F1', '大哥', 'support', 1), f('F2', '侄女', 'functional', 2), f('F3', '小宝', 'functional', 2, { minor: true }),
|
|
2020
|
+
];
|
|
2021
|
+
p.outline.vindications = [{ char: 'C01', ep: 15, venue: '寿宴', witnesses: ['C02', 'F1', 'F2'] }, { char: 'C01', ep: 27, venue: '分家', witnesses: ['C02', 'F1', 'F2'] }];
|
|
2022
|
+
p.outline.secrets = [{ id: 'S1', content: '抱错的孩子', audienceKnowsEp: 2, plantEp: 1, hintEps: [5, 10], revealEp: 20 }];
|
|
2023
|
+
p.outline.reconciliations = [{ pair: ['C01', 'C02'], woundEp: 2, triggerEp: 18, turnEp: 22, endState: '和解' }];
|
|
2024
|
+
return p;
|
|
2025
|
+
};
|
|
2026
|
+
passes('story-family', fm(), null);
|
|
2027
|
+
breaks('story-family', tweak(fm, (p) => { p.characters.pop(); }), null, 'at least five members');
|
|
2028
|
+
breaks('story-family', tweak(fm, (p) => { delete p.characters[2].family.endState; }), null, 'family.endState');
|
|
2029
|
+
breaks('story-family', tweak(fm, (p) => { for (const e of p.outline.episodes) if (e.ep >= 5 && e.ep <= 14) e.satisfaction.type = '打脸'; }), null, 'without a tear');
|
|
2030
|
+
breaks('story-family', tweak(fm, (p) => { p.outline.vindications[0].witnesses = ['C02']; }), null, 'three or more family witnesses');
|
|
2031
|
+
breaks('story-family', tweak(fm, (p) => { p.outline.vindications.pop(); }), null, 'larger vindication in the climax');
|
|
2032
|
+
breaks('story-family', tweak(fm, (p) => { p.outline.secrets[0].hintEps = [5]; }), null, 'at least two');
|
|
2033
|
+
breaks('story-family', tweak(fm, (p) => { p.outline.secrets[0].audienceKnowsEp = 8; }), null, '信息差');
|
|
2034
|
+
breaks('story-family', tweak(fm, (p) => { p.outline.secrets[0].revealEp = 12; }), null, 'room for the fallout');
|
|
2035
|
+
breaks('story-family', tweak(fm, (p) => { Object.assign(p.outline.reconciliations[0], { triggerEp: 3, turnEp: 7 }); }), null, 'at least 10');
|
|
2036
|
+
breaks('story-family', tweak(fm, (p) => { p.outline.episodes[0].satisfaction = { type: '打脸', action: '小宝当众揭穿舅妈' }; }), null, 'never schemers');
|
|
2037
|
+
|
|
2038
|
+
// 宫斗
|
|
2039
|
+
const pl = () => {
|
|
2040
|
+
const p = synth(['palace'], () => '智商碾压');
|
|
2041
|
+
p.outline.rankLadder = { track: '后宫', levels: ['答应', '贵人', '嫔', '妃', '皇后'] };
|
|
2042
|
+
p.outline.rankEvents = [{ char: 'C01', ep: 3, to: '贵人' }, { char: 'C01', ep: 8, to: '嫔' }, { char: 'C01', ep: 14, to: '贵人' }, { char: 'C01', ep: 20, to: '妃' }, { char: 'C01', ep: 28, to: '皇后' }];
|
|
2043
|
+
p.outline.schemes = [
|
|
2044
|
+
{ id: 'X1', schemer: 'C02', target: 'C01', stakes: 'minor', setupEp: 2, revealEp: 5, outcome: 'success' },
|
|
2045
|
+
{ id: 'X2', schemer: 'C02', target: 'C01', stakes: 'major', setupEp: 6, revealEp: 12, outcome: 'countered', counteredBy: { type: 'prop', ref: '香囊', plantEp: 9 }, flaw: '急于求成' },
|
|
2046
|
+
{ id: 'X3', schemer: 'C01', target: 'C02', stakes: 'master', setupEp: 4, revealEp: 28, outcome: 'success' },
|
|
2047
|
+
];
|
|
2048
|
+
p.outline.factions = [{ id: 'A', leader: 'C01', members: ['C01'] }, { id: 'B', leader: 'C02', members: ['C02', 'C03'] }];
|
|
2049
|
+
p.outline.allegiance = [{ char: 'C03', ep: 20, from: 'B', to: 'A', motive: '主子弃她', foreshadowEp: 12 }];
|
|
2050
|
+
return p;
|
|
2051
|
+
};
|
|
2052
|
+
passes('story-palace', pl(), null);
|
|
2053
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.schemes[1].setupEp = 10; }), null, 'a major scheme needs 5');
|
|
2054
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.schemes[2].stakes = 'major'; p.outline.schemes[2].setupEp = 16; }), null, 'no master scheme');
|
|
2055
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.schemes[1].counteredBy.plantEp = 13; }), null, 'out of nowhere');
|
|
2056
|
+
breaks('story-palace', tweak(pl, (p) => { delete p.outline.schemes[1].flaw; }), null, 'no flaw recorded');
|
|
2057
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.schemes.push({ id: 'X4', schemer: 'C02', target: 'C01', stakes: 'minor', setupEp: 13, revealEp: 16, outcome: 'countered', counteredBy: { type: 'authority', plantEp: 14 }, flaw: 'x' }, { id: 'X5', schemer: 'C02', target: 'C01', stakes: 'minor', setupEp: 15, revealEp: 18, outcome: 'countered', counteredBy: { type: 'authority', plantEp: 16 }, flaw: 'x' }); }), null, 'rescue button');
|
|
2058
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.schemes[0].outcome = 'countered'; p.outline.schemes[0].counteredBy = { type: 'prop', plantEp: 3 }; p.outline.schemes[0].flaw = 'x'; }), null, 'strong enough villain');
|
|
2059
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.rankEvents = p.outline.rankEvents.slice(2); }), null, 'first rank event');
|
|
2060
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.rankEvents[2].to = '妃'; }), null, 'no low point');
|
|
2061
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.rankEvents.pop(); }), null, 'top rung');
|
|
2062
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.factions.pop(); }), null, 'factions');
|
|
2063
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.allegiance[0].foreshadowEp = 18; }), null, 'foreshadowing');
|
|
2064
|
+
breaks('story-palace', tweak(pl, (p) => { p.outline.schemes.push({ id: 'X6', schemer: 'C01', target: 'C03', stakes: 'minor', setupEp: 7, revealEp: 10, outcome: 'success' }); }), null, 'moral capital');
|
|
2065
|
+
|
|
2066
|
+
// 修仙
|
|
2067
|
+
const cu = () => {
|
|
2068
|
+
const p = synth(['cultivation'], () => '打脸');
|
|
2069
|
+
p.outline.powerSystem = { realms: ['R1', 'R2', 'R3', 'R4', 'R5'].map((id) => ({ id, name: id, visual: 'glow' })) };
|
|
2070
|
+
p.outline.goldenFinger = { type: '系统', name: '签到系统', activationEp: 1, rules: ['每日签到'], costs: ['寿元'] };
|
|
2071
|
+
p.outline.startPower = '废柴';
|
|
2072
|
+
p.outline.upgrades = [3, 10, 18, 27].map((ep, i) => ({ ep, char: 'C01', to: `R${i + 2}`, cause: '丹药', cost: '重伤' }));
|
|
2073
|
+
p.outline.fights = [{ ep: 9, foe: 'C02', heroRealm: 'R2', foeRealm: 'R3', foeRevealedEp: 8, edge: '爆元丹' }];
|
|
2074
|
+
return p;
|
|
2075
|
+
};
|
|
2076
|
+
passes('story-cultivation', cu(), null);
|
|
2077
|
+
breaks('story-cultivation', tweak(cu, (p) => { p.outline.goldenFinger.activationEp = 3; }), null, 'by E02');
|
|
2078
|
+
breaks('story-cultivation', tweak(cu, (p) => { p.outline.goldenFinger.costs = []; }), null, 'rules and costs');
|
|
2079
|
+
breaks('story-cultivation', tweak(cu, (p) => { p.outline.upgrades[1].ep = 16; p.outline.upgrades[2].ep = 17; }), null, 'more than 12 units');
|
|
2080
|
+
breaks('story-cultivation', tweak(cu, (p) => { p.outline.upgrades[1].to = 'R4'; }), null, 'one realm at a time');
|
|
2081
|
+
breaks('story-cultivation', tweak(cu, (p) => { p.outline.upgrades[3].ep = 21; }), null, 'hold it for the last 15 %');
|
|
2082
|
+
breaks('story-cultivation', tweak(cu, (p) => { p.outline.upgrades[0].cost = ''; }), null, 'a cause and a cost');
|
|
2083
|
+
breaks('story-cultivation', tweak(cu, (p) => { p.outline.fights[0].foeRevealedEp = 10; }), null, 'revealed after the fight');
|
|
2084
|
+
breaks('story-cultivation', tweak(cu, (p) => { p.outline.fights[0].foeRealm = 'R4'; }), null, 'at most one');
|
|
2085
|
+
breaks('story-cultivation', tweak(cu, (p) => { delete p.outline.fights[0].edge; }), null, 'needs an edge');
|
|
2086
|
+
const hidden = tweak(cu, (p) => { p.outline.startPower = '隐藏大佬'; p.outline.upgrades = []; });
|
|
2087
|
+
breaks('story-cultivation', hidden, null, 'layer of 身份揭露');
|
|
2088
|
+
const revealing = clone(hidden); for (const e of revealing.outline.episodes) if (e.ep % 8 === 0) e.satisfaction.type = '身份揭露';
|
|
2089
|
+
passes('story-cultivation', revealing, null);
|
|
2090
|
+
|
|
2091
|
+
// 科幻
|
|
2092
|
+
const sf = () => {
|
|
2093
|
+
const p = synth(['scifi'], () => '智商碾压');
|
|
2094
|
+
p.outline.brief.novum = { statement: '三十天后寒潮降临', category: '末世', firstEp: 1 };
|
|
2095
|
+
p.outline.worldRules = [
|
|
2096
|
+
{ id: 'W1', rule: '空间可存物资', kind: 'power', shownEp: 1, decidesEps: [5] },
|
|
2097
|
+
{ id: 'W2', rule: '空间每天只能开一次', kind: 'limit', shownEp: 2 },
|
|
2098
|
+
{ id: 'W3', rule: '寒潮后空间会冻结', kind: 'exception', shownEp: 14, decidesEps: [28] },
|
|
2099
|
+
];
|
|
2100
|
+
p.outline.countdown = { label: '寒潮倒计时', zeroEp: 15, log: Array.from({ length: 24 }, (_, i) => ({ ep: i + 1, value: Math.max(0, 30 - i * 2) })) };
|
|
2101
|
+
p.outline.foreknowledge = [{ event: '邻居抢粮', plantedEp: 3, payoffEp: 8 }];
|
|
2102
|
+
return p;
|
|
2103
|
+
};
|
|
2104
|
+
passes('story-scifi', sf(), null);
|
|
2105
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.brief.novum.firstEp = 2; }), null, 'on screen in E01');
|
|
2106
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.secondaryNovums = [{ id: 'a' }, { id: 'b' }]; }), null, "Wells' law");
|
|
2107
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.worldRules[1].kind = 'power'; }), null, 'limit or cost per power');
|
|
2108
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.worldRules[0].shownEp = 6; }), null, 'before it decides');
|
|
2109
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.worldRules[2].shownEp = 24; }), null, 'before the climax');
|
|
2110
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.worldRules[1].brokenEp = 9; }), null, 'set-up exception');
|
|
2111
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.countdown.log[5].value = 99; }), null, 'only counts down');
|
|
2112
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.countdown.log = p.outline.countdown.log.slice(0, 5); }), null, '60 %');
|
|
2113
|
+
breaks('story-scifi', tweak(sf, (p) => { delete p.outline.countdown; }), null, 'runs on a countdown');
|
|
2114
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.loops = [{ n: 1, ep: 3, newInfo: '' }]; }), null, 'brings nothing new');
|
|
2115
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.loops = [{ n: 1, ep: 3, newInfo: 'a', changed: false }, { n: 2, ep: 4, newInfo: 'b', changed: false }]; }), null, 'changes nothing');
|
|
2116
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.foreknowledge[0].payoffEp = 20; }), null, 'within 10');
|
|
2117
|
+
breaks('story-scifi', tweak(sf, (p) => { p.outline.worldRules[2].shownEp = 8; p.outline.countdown.zeroEp = 22; }), null, 'no midpoint turn');
|
|
2118
|
+
|
|
2119
|
+
// 恐怖
|
|
2120
|
+
const hr = () => {
|
|
2121
|
+
const p = synth(['horror'], () => '智商碾压');
|
|
2122
|
+
for (const e of p.outline.episodes) e.scares = e.ep === 7 ? ['假警报', '惊吓'] : ['惊吓'];
|
|
2123
|
+
p.outline.horror = { subgenre: '规则怪谈', threat: { nature: '夜班规则', firstSignEp: 1, glimpseEps: [5, 10], revealEp: 20, explanation: { type: '人为', revealEp: 26, clueEps: [8, 15] } } };
|
|
2124
|
+
p.outline.horrorRules = [
|
|
2125
|
+
{ id: 'H1', text: '零点后不要回头', isTrue: true, shownEp: 1 }, { id: 'H2', text: '红衣店员不存在', isTrue: true, shownEp: 2, brokenEp: 6 },
|
|
2126
|
+
{ id: 'H3', text: '不要接三点的电话', isTrue: true, shownEp: 3 }, { id: 'H4', text: '冰柜第三层是空的', isTrue: true, shownEp: 4 },
|
|
2127
|
+
{ id: 'H5', text: '店长的话都可信', isTrue: false, shownEp: 12 },
|
|
2128
|
+
];
|
|
2129
|
+
return p;
|
|
2130
|
+
};
|
|
2131
|
+
passes('story-horror', hr(), null);
|
|
2132
|
+
breaks('story-horror', tweak(hr, (p) => { for (const e of p.outline.episodes) if (e.ep <= 8) e.scares = ['氛围']; }), null, 'at least 80 %');
|
|
2133
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.episodes[6].scares = ['假警报']; }), null, 'set up the real one');
|
|
2134
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.episodes[7].scares = ['假警报', '惊吓']; }), null, 'two episodes running');
|
|
2135
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.episodes[0].scares = ['鬼']; }), null, 'is not one of');
|
|
2136
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horror.threat.firstSignEp = 2; }), null, 'first sign of the threat');
|
|
2137
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horror.threat.revealEp = 10; }), null, 'hold it past the half');
|
|
2138
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horror.threat.glimpseEps = [5]; }), null, 'two glimpses');
|
|
2139
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horrorRules[1].shownEp = 7; }), null, 'before the audience has seen it');
|
|
2140
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horrorRules.pop(); }), null, 'at least five');
|
|
2141
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horrorRules.push({ id: 'H6', text: 'x', isTrue: true, shownEp: 6 }); p.outline.horrorRules[4].isTrue = true; }), null, 'contaminated');
|
|
2142
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horror.threat.explanation.type = '超自然'; }), null, 'red lines');
|
|
2143
|
+
passes('story-horror', tweak(hr, (p) => { p.params.market = 'overseas'; p.outline.horror.threat.explanation.type = '超自然'; }), null);
|
|
2144
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horror.threat.explanation.revealEp = 20; }), null, 'keep the dread');
|
|
2145
|
+
breaks('story-horror', tweak(hr, (p) => { p.outline.horror.threat.explanation.clueEps = [25]; }), null, 'at least two clues');
|
|
2146
|
+
eq(STORY_RULES.length, Object.values(STORY_GENRES).reduce((n, g) => n + g.rules.length, 0), 'every rule belongs to one genre');
|
|
2147
|
+
eq(new Set(STORY_RULES).size, STORY_RULES.length, 'rule ids are unique across genres');
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
{
|
|
2151
|
+
// Formats: short 短剧, medium 中剧, movie 电影 in parts — the same pipeline, the rhythm and the counts scaled.
|
|
2152
|
+
eq(paramsOf(PROJECT).format, 'short', 'short by default');
|
|
2153
|
+
eq(formatForGenre('悬疑电影', 5), 'movie', '电影 reads movie');
|
|
2154
|
+
eq(formatForGenre('都市', 5), 'medium', 'a 5-minute episode reads medium');
|
|
2155
|
+
eq(formatForGenre('都市', 2), 'short', 'a 2-minute episode reads short');
|
|
2156
|
+
const mi = initProject({ title: 't', episodes: 18, minutes: 5, genre: '复仇电影' }).params;
|
|
2157
|
+
eq(`${mi.format} ${mi.ratio}`, 'movie 16:9', 'init: a movie is 16:9 by default');
|
|
2158
|
+
eq(initProject({ title: 't', episodes: 30, minutes: 1, genre: 'g' }).params.ratio, '9:16', 'init: a short drama is 9:16');
|
|
2159
|
+
assert.throws(() => initProject({ title: 't', episodes: 1, minutes: 1, genre: 'g', format: 'tv' }), /--format/, 'init rejects an unknown format');
|
|
2160
|
+
const short = { format: 'short', episodes: 80, minutesPerEpisode: 1.5 };
|
|
2161
|
+
const medium = { format: 'medium', episodes: 24, minutesPerEpisode: 5 };
|
|
2162
|
+
const movie = { format: 'movie', episodes: 18, minutesPerEpisode: 5 };
|
|
2163
|
+
eq(span(short, 3, 0.2), 3, 'short keeps the count');
|
|
2164
|
+
eq(span(medium, 3, 0.2), 1, 'medium converts by runtime (3 × 1.5 min ≈ one 5-minute episode)');
|
|
2165
|
+
eq(span(medium, 12, 0.3), 4, 'medium: 12 short episodes ≈ 4 medium ones');
|
|
2166
|
+
eq(span(movie, 3, 0.2), 4, 'movie converts by share of the film');
|
|
2167
|
+
eq(span(movie, 3), 1, 'movie without a share falls back to runtime');
|
|
2168
|
+
ok(runsAtLeast(movie, 20) && !runsAtLeast({ format: 'short', episodes: 12 }, 20), 'run length by runtime outside short');
|
|
2169
|
+
const pm = clone(PROJECT); Object.assign(pm.params, { format: 'movie', minutesPerEpisode: 5 });
|
|
2170
|
+
const rm = paramsOf(pm);
|
|
2171
|
+
eq(`${rm.hookSeconds} ${rm.turnSeconds} ${rm.maxConsecutiveLines}`, '60 90 5', 'a movie takes the movie rhythm');
|
|
2172
|
+
const own = clone(pm); own.params.turnSeconds = 45;
|
|
2173
|
+
eq(paramsOf(own).turnSeconds, 45, 'an explicit param still wins');
|
|
2174
|
+
const off = clone(PROJECT); off.params.minutesPerEpisode = 5;
|
|
2175
|
+
breaks('params', off, E01, 'outside the short range');
|
|
2176
|
+
const badFmt = clone(PROJECT); badFmt.params.format = 'tv';
|
|
2177
|
+
breaks('params', badFmt, E01, 'params.format');
|
|
2178
|
+
// Movie parts: only the first part needs the cold-open hook.
|
|
2179
|
+
const late = clone(E01); late.ep = 2; late.hookBeat = [2, 5];
|
|
2180
|
+
const hookGate = GATES.find((g) => g.id === 'hook-cliff');
|
|
2181
|
+
ok(hookGate.run(makeContext(PROJECT, { 2: late })).some((m) => m.includes('cold open')), 'a short episode needs the cold open');
|
|
2182
|
+
ok(!hookGate.run(makeContext(pm, { 2: late })).some((m) => m.includes('cold open')), 'a later movie part does not');
|
|
2183
|
+
// A revenge movie of 18 parts: the first payback may come by part 4 (20 %), not part 3.
|
|
2184
|
+
const rv = clone(PROJECT);
|
|
2185
|
+
Object.assign(rv.params, { format: 'movie', episodes: 18, minutesPerEpisode: 5, story: ['revenge'] });
|
|
2186
|
+
rv.outline.structure = [{ phase: 'golden', episodes: [1, 4] }, { phase: 'escalation', episodes: [5, 14] }, { phase: 'climax', episodes: [15, 18] }];
|
|
2187
|
+
rv.outline.episodes = Array.from({ length: 18 }, (_, i) => ({ ep: i + 1, satisfaction: { type: i + 1 === 4 || i + 1 >= 8 ? '打脸' : '情感释放', action: 'x' }, hookType: '危机' }));
|
|
2188
|
+
ok(!failures('story-revenge', rv, null).some((m) => m.includes('units 1–')), 'a movie may land its first payback at 20 %');
|
|
2189
|
+
rv.outline.episodes[3].satisfaction.type = '情感释放';
|
|
2190
|
+
ok(failures('story-revenge', rv, null).some((m) => m.includes('units 1–4')), 'but not later');
|
|
2191
|
+
// Comedy density by the minute outside short.
|
|
2192
|
+
const pc = clone(PROJECT); Object.assign(pc.params, { story: ['comedy'], comedy: 'slapstick', format: 'medium', minutesPerEpisode: 5 });
|
|
2193
|
+
const two = clone(E01); two.scenes[1].flow[2].gag = 'setup'; two.scenes[1].flow[4].gag = 'punchline'; two.scenes[1].flow[9].gag = 'punchline';
|
|
2194
|
+
ok(failures('story-script', pc, two).some((m) => m.includes('at least 4')), 'a 5-minute slapstick episode lands at least 4');
|
|
2195
|
+
}
|
|
2196
|
+
|
|
1389
2197
|
// ── init & file IO ──────────────────────────────────────────────────────────
|
|
1390
2198
|
{
|
|
1391
2199
|
const p = initProject({ title: 't', episodes: 4, minutes: 2, genre: 'g' });
|