@wji.ai/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/commands/canvas.js +91 -12
- package/dist/commands/fsnode.js +30 -1
- package/dist/commands/gen.js +4 -19
- package/dist/commands/workspace.js +6 -2
- package/dist/index.js +4 -1
- package/package.json +1 -1
- package/skill/short-drama/SKILL.md +305 -0
- package/skill/short-drama/examples/night-shift/episodes/E01.json +1883 -0
- package/skill/short-drama/examples/night-shift/project.json +774 -0
- package/skill/short-drama/reference/canvas-recipes.md +199 -0
- package/skill/short-drama/reference/craft-motion.md +116 -0
- package/skill/short-drama/reference/craft-shots.md +177 -0
- package/skill/short-drama/reference/craft-story.md +188 -0
- package/skill/short-drama/reference/dialogue-craft.md +120 -0
- package/skill/short-drama/reference/schema.md +435 -0
- package/skill/short-drama/reference/stage-art.md +56 -0
- package/skill/short-drama/reference/stage-characters.md +206 -0
- package/skill/short-drama/reference/stage-outline.md +103 -0
- package/skill/short-drama/reference/stage-script.md +113 -0
- package/skill/short-drama/reference/stage-storyboard.md +465 -0
- package/skill/short-drama/reference/workspace-layout.md +118 -0
- package/skill/short-drama/scripts/selftest.mjs +1411 -0
- package/skill/short-drama/scripts/short-drama.mjs +3371 -0
- package/skill/wji/SKILL.md +97 -22
- package/skill/wji/reference/canvas-recipes.md +16 -2
- package/skill/wji/reference/commands.md +15 -6
|
@@ -0,0 +1,3371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// short-drama — deterministic tooling for the WJI short-drama pipeline.
|
|
3
|
+
// Zero dependencies; Node >= 20. Every rule that can be checked by code lives
|
|
4
|
+
// here as a gate so the agent loops until green instead of trusting itself.
|
|
5
|
+
//
|
|
6
|
+
// init <dir> --title … --episodes N --minutes M --genre G [--ratio 9:16]
|
|
7
|
+
// [--video h3|seedance|seedance2|seedance2fast|wan] (decides the segment length — see VIDEO_MODELS)
|
|
8
|
+
// [--resolution 480P|…] [--accent "台灣口音國語"] [--style photoreal|realistic|ghibli]
|
|
9
|
+
// [--references sheets|keyframes] [--duration auto|fixed] [--look "shallow depth of field"] [--lang zh] [--adapt 原創]
|
|
10
|
+
// [--register 台湾国语] (语体 — decides how lines are written and the accent spoken)
|
|
11
|
+
// validate <project> [--stage outline|characters|art|script|storyboard] [--ep 1-3]
|
|
12
|
+
// checkup <project> [--stage …] [--ep …] every gate with ✓/✗, exit 0
|
|
13
|
+
// seed <project> script --ep N [--force] episodes/E0N.json skeleton from the outline
|
|
14
|
+
// seed <project> storyboard --ep N [--force] beat worksheet, visual-plan worksheet + empty segments from the script
|
|
15
|
+
// render <project> outline | guideline | character C01 | scene S01 | prop P01 | script --ep N | dialogue --ep N | segment E01-01
|
|
16
|
+
// prompt <project> segment E01-01 derived video-prompt skeleton + keyframe reference plan
|
|
17
|
+
// prompt <project> voice C01 the acted sample text for the voice node
|
|
18
|
+
// prompt <project> audio E01-01 [--full] the segment's Seed Audio soundtrack prompt (timecoded ambience / sfx / music, + lines with --full)
|
|
19
|
+
// manifest <project> [--stage …] [--ep N] unbound WJI objects, in execution order
|
|
20
|
+
// bind <project> [--ep N] <path> <value> write a WJI id back (path segments may be entity ids)
|
|
21
|
+
// slug <name>
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
// ── constants ───────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export const VERSION = 1;
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_PARAMS = Object.freeze({
|
|
32
|
+
ratio: '9:16',
|
|
33
|
+
video: 'h3',
|
|
34
|
+
style: 'realistic',
|
|
35
|
+
adaptMode: '原创',
|
|
36
|
+
charsPerSecond: 4.5,
|
|
37
|
+
speechCharsPerSecond: 2.4,
|
|
38
|
+
voiceSampleSeconds: [3, 5],
|
|
39
|
+
actionSeconds: 2.5,
|
|
40
|
+
tolerance: 0.15,
|
|
41
|
+
maxLineChars: 35,
|
|
42
|
+
hookWindow: 3,
|
|
43
|
+
minSegmentSeconds: 5,
|
|
44
|
+
maxSegmentSeconds: 15,
|
|
45
|
+
maxAudioRefs: 3,
|
|
46
|
+
maxVideoRefs: 3,
|
|
47
|
+
videoReferences: 'none',
|
|
48
|
+
videoResolution: '720P',
|
|
49
|
+
speechAccent: '',
|
|
50
|
+
// The production's camera package. Every frame prompt names it, so two shots cut together.
|
|
51
|
+
camera: { body: 'ARRI ALEXA Mini LF', lens: 'Zeiss Supreme Prime', aperture: 'T1.5' },
|
|
52
|
+
references: 'sheets',
|
|
53
|
+
humanReferences: true,
|
|
54
|
+
voiceSamples: true,
|
|
55
|
+
videoDuration: 'auto',
|
|
56
|
+
lookPhrase: '',
|
|
57
|
+
lookPhraseZh: '',
|
|
58
|
+
minCutSeconds: 2,
|
|
59
|
+
maxCutSeconds: 5,
|
|
60
|
+
maxOnScreen: 3,
|
|
61
|
+
// Screenplay craft: the rhythm a 短剧 episode is held to.
|
|
62
|
+
register: '',
|
|
63
|
+
hookSeconds: 15,
|
|
64
|
+
turnSeconds: 30,
|
|
65
|
+
maxConsecutiveLines: 3,
|
|
66
|
+
minActionRatio: 0.35,
|
|
67
|
+
maxNameCallsPerScene: 2,
|
|
68
|
+
maxLineWords: 20,
|
|
69
|
+
// Shot language.
|
|
70
|
+
speechLeadSeconds: 0.5,
|
|
71
|
+
subtitles: false,
|
|
72
|
+
maxFrameChars: 900,
|
|
73
|
+
// A Seed Audio node per segment: the soundtrack bed by default (ambience, sfx, timed music).
|
|
74
|
+
audioTrack: 'bed',
|
|
75
|
+
thresholds: { maxLeads: 5, maxSupport: 10, maxFunctional: 10, maxBeatGap: 3, maxProps: 8 },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const TIERS = ['lead', 'support', 'functional'];
|
|
79
|
+
export const ADAPT_MODES = ['原创', '忠实', '抽核', '借壳'];
|
|
80
|
+
export const VIDEO_MODES = ['h3', 'seedance', 'seedance2', 'seedance2fast', 'wan'];
|
|
81
|
+
/**
|
|
82
|
+
* Per-mode video model + the provider's hard limits, read off the backend model forms.
|
|
83
|
+
* `family` picks the prompt dialect (three of the five share Seedance's `@imageN` dialect), `min/maxSegmentSeconds`
|
|
84
|
+
* is the duration range the provider accepts — **it is what decides how long a segment may be, so the
|
|
85
|
+
* plot is cut to the model, not the other way round** — and every one of them takes whole seconds only.
|
|
86
|
+
*/
|
|
87
|
+
export const VIDEO_MODELS = {
|
|
88
|
+
h3: {
|
|
89
|
+
model: 'minimax-h3-ref', family: 'h3', promptLang: 'en',
|
|
90
|
+
minSegmentSeconds: 5, maxSegmentSeconds: 15, maxRefs: 9, maxAudioRefs: 3, maxVideoRefs: 3, maxRefVideoSeconds: 15, autoDuration: false,
|
|
91
|
+
resolutions: ['480P', '768P', '2K', '4K'],
|
|
92
|
+
},
|
|
93
|
+
seedance: {
|
|
94
|
+
model: 'seedance-2.5-ref', family: 'seedance', promptLang: 'en',
|
|
95
|
+
minSegmentSeconds: 4, maxSegmentSeconds: 30, maxRefs: 30, maxAudioRefs: 10, maxVideoRefs: 10, maxRefVideoSeconds: 30, autoDuration: true,
|
|
96
|
+
resolutions: ['480p', '720p', '1080p'],
|
|
97
|
+
},
|
|
98
|
+
seedance2: {
|
|
99
|
+
model: 'seedance-2.0-ref', family: 'seedance', promptLang: 'en',
|
|
100
|
+
minSegmentSeconds: 4, maxSegmentSeconds: 15, maxRefs: 9, maxAudioRefs: 3, maxVideoRefs: 3, maxRefVideoSeconds: 15, autoDuration: true,
|
|
101
|
+
resolutions: ['480p', '720p', '1080p', '4k'],
|
|
102
|
+
},
|
|
103
|
+
seedance2fast: {
|
|
104
|
+
model: 'seedance-2.0-fast-ref', family: 'seedance', promptLang: 'en',
|
|
105
|
+
minSegmentSeconds: 4, maxSegmentSeconds: 15, maxRefs: 9, maxAudioRefs: 3, maxVideoRefs: 3, maxRefVideoSeconds: 15, autoDuration: true,
|
|
106
|
+
resolutions: ['480p', '720p'],
|
|
107
|
+
},
|
|
108
|
+
wan: {
|
|
109
|
+
model: 'wan-3.0-ref', family: 'wan', promptLang: 'zh',
|
|
110
|
+
minSegmentSeconds: 2, maxSegmentSeconds: 30, maxRefs: 10, maxAudioRefs: 5, maxVideoRefs: 5, maxRefVideoSeconds: 15, autoDuration: true,
|
|
111
|
+
resolutions: ['480P', '720P', '1080P'],
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
/** The mode's own table, defaulting to h3's — every lookup goes through this. */
|
|
115
|
+
export function videoModel(params) {
|
|
116
|
+
return VIDEO_MODELS[params?.video] || VIDEO_MODELS.h3;
|
|
117
|
+
}
|
|
118
|
+
/** The provider's spelling of a resolution (480P vs 480p vs 4K), or null when the model has no such tier. */
|
|
119
|
+
export function videoResolutionFor(mode, wanted) {
|
|
120
|
+
const want = String(wanted || '').toLowerCase();
|
|
121
|
+
return (mode.resolutions || []).find((r) => r.toLowerCase() === want) || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The tier to start a project on: the wanted one when the model has it, else the closest by height
|
|
126
|
+
* (h3 has no 720P, and its 768P is far nearer than dropping to 480P). Named tiers like 2K/4K only
|
|
127
|
+
* win when nothing numeric is on offer.
|
|
128
|
+
*/
|
|
129
|
+
export function nearestResolution(mode, wanted) {
|
|
130
|
+
const exact = videoResolutionFor(mode, wanted);
|
|
131
|
+
if (exact) return exact;
|
|
132
|
+
const height = (r) => Number(String(r).match(/^(\d+)/)?.[1] ?? NaN);
|
|
133
|
+
const want = height(wanted);
|
|
134
|
+
const numeric = (mode.resolutions || []).filter((r) => Number.isFinite(height(r)));
|
|
135
|
+
if (!numeric.length || !Number.isFinite(want)) return (mode.resolutions || [])[0];
|
|
136
|
+
return numeric.reduce((best, r) => (Math.abs(height(r) - want) < Math.abs(height(best) - want) ? r : best));
|
|
137
|
+
}
|
|
138
|
+
/** What the video node references: one generated keyframe per cut, or the sheets themselves (no keyframe images). */
|
|
139
|
+
export const REFERENCE_MODES = ['keyframes', 'sheets'];
|
|
140
|
+
/** TTS model for the character voice sample; it takes the headshot as its one reference image. */
|
|
141
|
+
export const VOICE_MODEL = 'seed-audio-1.0';
|
|
142
|
+
/**
|
|
143
|
+
* The same model is a full audio-scene generator, not a TTS: one prompt gives speech, sound effects,
|
|
144
|
+
* ambience and music in one track, up to 120 s, and a `[start s:end s]` timecode in front of an
|
|
145
|
+
* element pins it to that interval (100 ms precision). Up to 3 reference audios (`@Audio1..3`) or one
|
|
146
|
+
* reference image — never both. Prompt ≤ 2048 characters.
|
|
147
|
+
*/
|
|
148
|
+
export const AUDIO_MODEL = VOICE_MODEL;
|
|
149
|
+
export const AUDIO_MAX_SECONDS = 120;
|
|
150
|
+
export const AUDIO_MAX_CHARS = 2048;
|
|
151
|
+
export const AUDIO_MAX_REFS = 3;
|
|
152
|
+
/** What the per-segment audio node generates: nothing, a bed (ambience + sfx + music) or the full track with the lines. */
|
|
153
|
+
export const AUDIO_TRACK_MODES = ['none', 'bed', 'full'];
|
|
154
|
+
/** What the music does at a cue point. */
|
|
155
|
+
export const MUSIC_CUES = ['enter', 'swell', 'duck', 'drop', 'silence', 'out'];
|
|
156
|
+
/** `fixed` passes `-d` = Σ cut seconds; `auto` leaves it off and lets the model match the prompt. */
|
|
157
|
+
export const DURATION_MODES = ['fixed', 'auto'];
|
|
158
|
+
/**
|
|
159
|
+
* What a segment references as `@videoN`. `previous` hands the model the clip before it, which is
|
|
160
|
+
* how a series of separate generations keeps one look: wardrobe, grade, lens feel and the way people
|
|
161
|
+
* move carry over instead of being re-invented per clip. `none` leaves the sheets to do that alone.
|
|
162
|
+
*/
|
|
163
|
+
export const VIDEO_REFERENCE_MODES = ['none', 'previous'];
|
|
164
|
+
|
|
165
|
+
// ── screenplay craft (from the w-short-drama-editor / character-creator / dialogue-editor method) ──
|
|
166
|
+
|
|
167
|
+
/** 語體 — the spoken register of the drama. Simplified spellings are canonical; the traditional ones are accepted. */
|
|
168
|
+
export const REGISTERS = ['粤语', '大陆普通话', '台湾国语', '港式普通话', '古装', '年代', '英文美式', '英文英式'];
|
|
169
|
+
const REGISTER_ALIASES = { '粵語': '粤语', '大陸普通話': '大陆普通话', '台灣國語': '台湾国语', '港式普通話': '港式普通话', '古裝': '古装' };
|
|
170
|
+
/** The accent a video model is told to speak in, when `params.speechAccent` is left empty. */
|
|
171
|
+
export const REGISTER_ACCENT = {
|
|
172
|
+
'粤语': { zh: '粤语', en: 'Cantonese' },
|
|
173
|
+
'大陆普通话': { zh: '标准普通话', en: 'standard Mandarin' },
|
|
174
|
+
'台湾国语': { zh: '台湾口音国语', en: 'Taiwanese-accented Mandarin' },
|
|
175
|
+
'港式普通话': { zh: '港式普通话', en: 'Hong Kong-accented Mandarin' },
|
|
176
|
+
'古装': { zh: '标准普通话', en: 'standard Mandarin' },
|
|
177
|
+
'年代': { zh: '标准普通话', en: 'standard Mandarin' },
|
|
178
|
+
'英文美式': { zh: '美式英语', en: 'American English' },
|
|
179
|
+
'英文英式': { zh: '英式英语', en: 'British English' },
|
|
180
|
+
};
|
|
181
|
+
export function normalizeRegister(value) {
|
|
182
|
+
const v = String(value || '').trim();
|
|
183
|
+
return REGISTER_ALIASES[v] || v;
|
|
184
|
+
}
|
|
185
|
+
/** 本集功能 — what an episode is for in the run of the drama. */
|
|
186
|
+
export const EPISODE_FUNCTIONS = ['开局钩子', '冲突升级', '身份揭露', '情感推进', '大反转', '清算收束'];
|
|
187
|
+
/** 爽点类型库 — a 爽点 is done by a visible action of one of these kinds. */
|
|
188
|
+
export const SATISFACTION_TYPES = ['打脸', '复仇', '强宠', '身份揭露', '智商碾压', '情感释放'];
|
|
189
|
+
/** 钩子类型库 — what the last beat pushes into the next episode. */
|
|
190
|
+
export const HOOK_TYPES = ['身份', '危机', '结果', '情感', '反转', '道具'];
|
|
191
|
+
/** 三段结构 — 黄金窗口 / 发展升级 / 高潮收束, in this order. */
|
|
192
|
+
export const PHASES = ['golden', 'escalation', 'climax'];
|
|
193
|
+
export const PHASE_LABELS = { golden: '黄金窗口期', escalation: '发展升级期', climax: '高潮收束期' };
|
|
194
|
+
/** 剧情功能 of a character in the story (not their tier). */
|
|
195
|
+
export const CHARACTER_FUNCTIONS = ['主角', '共同主角', '反派', '爱情线', '导师', '盟友', '镜像', '压力测试者', '喜剧调节'];
|
|
196
|
+
/** The six stages of a character arc. */
|
|
197
|
+
export const ARC_STAGES = ['开场', '第一转折', '中段', '低谷', '高潮选择', '结局'];
|
|
198
|
+
/** Prose that cannot be filmed: thoughts, memories, feelings without a body. */
|
|
199
|
+
export const NOVELISTIC_WORDS = ['心想', '心里想', '心裏想', '内心', '內心', '她知道自己', '他知道自己', '回忆起', '回憶起', '脑海', '腦海', '感到无比', '感到無比', '多年的委屈', '说不出的', '說不出的', '难以言喻', '難以言喻', '无法言喻', '無法言喻'];
|
|
200
|
+
/** An emotion word on its own is not a direction an actor can play. */
|
|
201
|
+
export const EMOTION_ONLY = ['生气', '生氣', '伤心', '傷心', '激动', '激動', '开心', '開心', '难过', '難過', '害怕', '愤怒', '憤怒', '悲伤', '悲傷'];
|
|
202
|
+
/** Characters that only occur in written Cantonese — a line with one of these is Cantonese, whatever the register says. */
|
|
203
|
+
export const CANTONESE_MARKERS = ['咗', '嘅', '唔', '冇', '哋', '喺', '嚟', '嗰', '啲', '咁', '乜', '佢', '睇', '諗', '咩', '梗', '畀'];
|
|
204
|
+
/** Words that break a 古装 / 年代 world in one line. */
|
|
205
|
+
export const MODERN_WORDS = ['OK', '没问题', '沒問題', '搞定', '紧张', '緊張', '压力', '壓力', '约会', '約會', '上班', '项目', '項目', '团队', '團隊', '套路', '打卡', '拉黑'];
|
|
206
|
+
/** 网络用语 — fine on a twenty-year-old, wrong on a sixty-year-old or in 古装. */
|
|
207
|
+
export const NETSLANG = ['卧槽', '臥槽', '绝了', '絕了', '破防', 'emo', '整活', '麻了', '家人们', '家人們', '芭比Q', '笑死', '栓Q', '躺平', '内卷', '內卷', 'yyds', '绝绝子', '絕絕子'];
|
|
208
|
+
/** Inferred inner state — a camera cannot film "seems to". */
|
|
209
|
+
export const INFERENCE_WORDS = ['像是', '似乎', '仿佛', '彷彿', '忽然明白', '意识到', '意識到', 'seems to', 'seem to', 'as if', 'appears to', 'appear to', 'realizes', 'realises', 'realising', 'realizing'];
|
|
210
|
+
/** Hand verbs — more than three in one beat is choreography a video model will not perform. */
|
|
211
|
+
export const HAND_VERBS_ZH = ['拿', '放', '递', '遞', '接', '握', '翻', '扣', '推', '拉', '撕', '摸', '抓', '掏', '塞', '举', '舉', '捏', '拧', '擰'];
|
|
212
|
+
export const HAND_VERBS_EN = ['grab', 'grabs', 'take', 'takes', 'hand', 'hands', 'pass', 'passes', 'hold', 'holds', 'flip', 'flips', 'press', 'presses', 'push', 'pushes', 'pull', 'pulls', 'tear', 'tears', 'pick', 'picks', 'clutch', 'clutches', 'twist', 'twists'];
|
|
213
|
+
|
|
214
|
+
// ── shot language (from the short-drama-storyboard / short-drama-video-prompts method) ──
|
|
215
|
+
|
|
216
|
+
/** 镜头职责 — why this cut exists; a cut that changes nothing for the audience is not a cut. */
|
|
217
|
+
export const PURPOSE_KINDS = ['establish', 'reveal', 'evidence', 'reaction', 'pressure', 'decision', 'consequence', 'performance', 'transition'];
|
|
218
|
+
/** The kinds that can carry a turn, a 爽点 or the cliff on their own. */
|
|
219
|
+
export const TURN_KINDS = ['reveal', 'evidence', 'decision', 'consequence'];
|
|
220
|
+
export const CLIFF_KINDS = ['reveal', 'consequence', 'reaction', 'pressure'];
|
|
221
|
+
/** Horizontal angle — how much of the face the audience is allowed to read, and whose side they stand on. */
|
|
222
|
+
export const ANGLES = ['front', 'three-quarter', 'profile', 'rear-three-quarter', 'back'];
|
|
223
|
+
export const ANGLES_ZH = { front: '正面', 'three-quarter': '斜侧', profile: '正侧', 'rear-three-quarter': '后斜侧', back: '背面' };
|
|
224
|
+
/** Camera height — who is bigger than whom in this instant; `eye` is a choice, not an unset field. */
|
|
225
|
+
export const HEIGHTS = ['ground', 'low', 'eye', 'high', 'top'];
|
|
226
|
+
export const HEIGHTS_ZH = { ground: '地平', low: '低(仰)', eye: '平视', high: '高(俯)', top: '顶视' };
|
|
227
|
+
/** Which side of the 轴线 the camera is on. */
|
|
228
|
+
export const AXIS_SIDES = ['working', 'cross', 'neutral'];
|
|
229
|
+
/** Where a person faces, in screen terms — bare left / right always means the screen's. */
|
|
230
|
+
export const FACINGS = ['screen-left', 'screen-right', 'camera', 'away'];
|
|
231
|
+
/** Why the camera moves — or deliberately does not. */
|
|
232
|
+
export const CAMERA_MOTIVATIONS = ['reveal', 'pressure', 'alignment', 'relationship', 'transition', 'stillness'];
|
|
233
|
+
/** The size ladder, for "how many steps did this cut jump". */
|
|
234
|
+
export const SIZE_ORDER = ['extreme-wide', 'wide', 'medium', 'close', 'extreme-close'];
|
|
235
|
+
/** Words that describe a sequence, which a frozen frame cannot hold. */
|
|
236
|
+
export const SEQUENCE_WORDS_EN = ['then', 'before', 'after that', 'afterwards', 'begins to', 'starts to', 'next,', 'finally', 'first,'];
|
|
237
|
+
export const SEQUENCE_WORDS_ZH = ['先', '再', '随后', '隨後', '然后', '然後', '最终', '最終', '接着', '接著', '之后', '之後'];
|
|
238
|
+
/** Boundary fields a cut's start / end must carry per on-screen character. */
|
|
239
|
+
export const BOUNDARY_FIELDS = ['positions', 'facing', 'hands', 'heldProps'];
|
|
240
|
+
/** The subtitles-off sentence per prompt language — same-pass models render captions unless told not to. */
|
|
241
|
+
export const NO_SUBTITLES = { en: 'no subtitles, no captions, no on-screen dialogue text', zh: '全程无字幕' };
|
|
242
|
+
/** The H3 idiom for the person on screen who is not the one speaking. */
|
|
243
|
+
export const LIPS_CLOSED = 'lips remain completely closed';
|
|
244
|
+
export const WARNINGS = ['雨戏', '肢体接触', '人群', '手部特写'];
|
|
245
|
+
export const PROP_SCALES = { handheld: 'handheld scale', tabletop: 'tabletop scale', furniture: 'furniture scale' };
|
|
246
|
+
export const SIZES = {
|
|
247
|
+
'extreme-wide': 'extreme wide shot',
|
|
248
|
+
wide: 'wide shot',
|
|
249
|
+
medium: 'medium shot',
|
|
250
|
+
close: 'close-up',
|
|
251
|
+
'extreme-close': 'extreme close-up',
|
|
252
|
+
};
|
|
253
|
+
/** Chinese shot-size words for `wan` prompts (Wan 3.0 is prompted in Chinese). */
|
|
254
|
+
export const SIZES_ZH = {
|
|
255
|
+
'extreme-wide': '大远景',
|
|
256
|
+
wide: '远景',
|
|
257
|
+
medium: '中景',
|
|
258
|
+
close: '特写',
|
|
259
|
+
'extreme-close': '大特写',
|
|
260
|
+
};
|
|
261
|
+
/** Focal length a shot size is normally covered with, used when a cut does not name one. */
|
|
262
|
+
export const FOCAL_BY_SIZE = {
|
|
263
|
+
'extreme-wide': '18mm',
|
|
264
|
+
wide: '25mm',
|
|
265
|
+
medium: '40mm',
|
|
266
|
+
close: '75mm',
|
|
267
|
+
'extreme-close': '100mm macro',
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Where the key light sits **relative to the camera**, not to the room — the same lamp is a rim
|
|
271
|
+
* light from one side of the axis and a key from the other, so it has to be named per shot.
|
|
272
|
+
*/
|
|
273
|
+
export const LIGHT_DIRECTIONS = {
|
|
274
|
+
frontal: 'keyed frontally from the camera axis',
|
|
275
|
+
'front-left': 'keyed three-quarter from camera left',
|
|
276
|
+
'front-right': 'keyed three-quarter from camera right',
|
|
277
|
+
'side-left': 'keyed from hard camera left, across the face',
|
|
278
|
+
'side-right': 'keyed from hard camera right, across the face',
|
|
279
|
+
'back-left': 'rim-lit from behind camera left',
|
|
280
|
+
'back-right': 'rim-lit from behind camera right',
|
|
281
|
+
backlight: 'backlit from directly behind the subject',
|
|
282
|
+
top: 'keyed from directly above',
|
|
283
|
+
underlight: 'lit from below the eyeline',
|
|
284
|
+
ambient: 'lit by soft ambient bounce with no single key',
|
|
285
|
+
};
|
|
286
|
+
export const LIGHT_DIRECTIONS_ZH = {
|
|
287
|
+
frontal: '\u987a\u5149\uff0c\u5149\u6e90\u5728\u955c\u5934\u8f74\u4e0a',
|
|
288
|
+
'front-left': '\u524d\u4fa7\u5149\uff0c\u6765\u81ea\u955c\u5934\u5de6\u524d\u65b9\u56db\u5341\u4e94\u5ea6',
|
|
289
|
+
'front-right': '\u524d\u4fa7\u5149\uff0c\u6765\u81ea\u955c\u5934\u53f3\u524d\u65b9\u56db\u5341\u4e94\u5ea6',
|
|
290
|
+
'side-left': '\u4fa7\u5149\uff0c\u6765\u81ea\u955c\u5934\u6b63\u5de6\u65b9',
|
|
291
|
+
'side-right': '\u4fa7\u5149\uff0c\u6765\u81ea\u955c\u5934\u6b63\u53f3\u65b9',
|
|
292
|
+
'back-left': '\u9006\u4fa7\u5149\uff0c\u6765\u81ea\u955c\u5934\u5de6\u540e\u65b9\u52fe\u8fb9',
|
|
293
|
+
'back-right': '\u9006\u4fa7\u5149\uff0c\u6765\u81ea\u955c\u5934\u53f3\u540e\u65b9\u52fe\u8fb9',
|
|
294
|
+
backlight: '\u6b63\u9006\u5149\uff0c\u5149\u6e90\u5728\u4e3b\u4f53\u6b63\u540e\u65b9',
|
|
295
|
+
top: '\u9876\u5149\uff0c\u6765\u81ea\u6b63\u4e0a\u65b9',
|
|
296
|
+
underlight: '\u5e95\u5149\uff0c\u4f4e\u4e8e\u89c6\u7ebf',
|
|
297
|
+
ambient: '\u73af\u5883\u6f2b\u5c04\u5149\uff0c\u6ca1\u6709\u5355\u4e00\u4e3b\u5149',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** The focal this cut is shot on — its own, or the one its shot size implies. */
|
|
301
|
+
export function focalOf(cut) {
|
|
302
|
+
return String(cut?.focal || FOCAL_BY_SIZE[cut?.size] || '').trim();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `shot on an ARRI ALEXA Mini LF with a Zeiss Supreme Prime 75mm at T1.5` — body, lens and stop in
|
|
307
|
+
* one phrase, gated verbatim in the cut's frame prompt. The stop is what buys the shallow focus the
|
|
308
|
+
* foreground blur depends on, so it travels with the lens rather than living in `lookPhrase`.
|
|
309
|
+
*/
|
|
310
|
+
export function cameraPhrase(cut, params, lang = 'en') {
|
|
311
|
+
const kit = (params && params.camera) || DEFAULT_PARAMS.camera;
|
|
312
|
+
const focal = focalOf(cut);
|
|
313
|
+
const stop = String(cut?.aperture || kit.aperture || '').trim();
|
|
314
|
+
if (lang === 'zh') return `${kit.body} \u914d ${kit.lens} ${focal}\uff0c${stop} \u5149\u5708`;
|
|
315
|
+
return `shot on ${article(kit.body)} ${kit.body} with ${article(kit.lens)} ${kit.lens} ${focal} at ${stop}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function article(word) {
|
|
319
|
+
return /^[aeiou]/i.test(String(word).trim()) ? 'an' : 'a';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** `keyed three-quarter from camera left` + what the light does over the cut. */
|
|
323
|
+
export function lightPhrase(cut, lang = 'en') {
|
|
324
|
+
const dir = cut?.light?.direction;
|
|
325
|
+
const table = lang === 'zh' ? LIGHT_DIRECTIONS_ZH : LIGHT_DIRECTIONS;
|
|
326
|
+
const head = table[dir] || '';
|
|
327
|
+
const note = String(cut?.light?.note || '').trim();
|
|
328
|
+
return [head, note].filter(Boolean).join(lang === 'zh' ? '\uff0c' : ', ');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* How this character speaks, for a video prompt's dialogue line: the compact English
|
|
333
|
+
* `voice.videoDirection` (or the drama-language voice fields for a Chinese prompt), plus the beat's
|
|
334
|
+
* own `delivery`. The video model gets the same performance note the voice sample was built from,
|
|
335
|
+
* so a clip's reading matches the character rather than the model's default newsreader.
|
|
336
|
+
*/
|
|
337
|
+
/** A beat's acting note, but only when it is written in the language the prompt is written in. */
|
|
338
|
+
function beatDelivery(delivery, promptLang) {
|
|
339
|
+
const text = String(delivery || '').trim();
|
|
340
|
+
if (!text) return '';
|
|
341
|
+
return promptLang === 'zh' || promptLang === 'zh-note' || isEnglish(text) ? text : '';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `lang` null means: the character has already been described in this segment, so only the beat's
|
|
346
|
+
* own note is added and the timbre is carried by the `@audioN` label.
|
|
347
|
+
*/
|
|
348
|
+
export function speechDirection(character, delivery, lang = 'en') {
|
|
349
|
+
const v = character?.voice || {};
|
|
350
|
+
const bits = [];
|
|
351
|
+
// `lang === null`: this character was already described in this segment, so only the beat's own
|
|
352
|
+
// note is added — the @audioN label carries the timbre.
|
|
353
|
+
if (lang === null || lang === 'zh-note') return beatDelivery(delivery, lang === 'zh-note' ? 'zh' : 'en');
|
|
354
|
+
if (lang === 'zh') {
|
|
355
|
+
for (const key of ['timbre', 'pace', 'accent', 'emotion']) if (v[key]) bits.push(String(v[key]).trim());
|
|
356
|
+
} else if (v.videoDirection) {
|
|
357
|
+
bits.push(String(v.videoDirection).trim());
|
|
358
|
+
}
|
|
359
|
+
const extra = beatDelivery(delivery, lang);
|
|
360
|
+
if (extra) bits.push(extra);
|
|
361
|
+
return bits.join(lang === 'zh' ? '\uff0c' : ', ');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The blurred thing between the lens and the subject. Depth needs something to be shallow against,
|
|
366
|
+
* so a cut names one and the prompt says it is out of focus; an empty `foreground` is a deliberate
|
|
367
|
+
* clean frame and the gate limits how many of those a segment may have.
|
|
368
|
+
*/
|
|
369
|
+
export function foregroundPhrase(cut, lang = 'en') {
|
|
370
|
+
const fg = String(cut?.foreground || '').trim();
|
|
371
|
+
if (!fg) return '';
|
|
372
|
+
return lang === 'zh' ? ` \u524d\u666f\u6709${fg}\uff0c\u865a\u5316\u6210\u6a21\u7cca\u7684\u524d\u666f\u906e\u6321\u3002` : ` ${fg} sits in the immediate foreground, thrown well out of focus.`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* `On <trigger>, <action>; <result>.` — the cut's 起点 → 唯一动作 → 终点 as one sentence, the part of
|
|
377
|
+
* the prompt that says what happens over the seconds (the frame only says how it starts).
|
|
378
|
+
*/
|
|
379
|
+
export function motionSentence(cut, lang = 'en') {
|
|
380
|
+
const m = isObject(cut?.motion) ? cut.motion : {};
|
|
381
|
+
const t = String(m.trigger || '…').trim(), a = String(m.action || '…').trim(), r = String(m.result || '…').trim();
|
|
382
|
+
if (lang === 'zh') return `触发:${t};动作:${a};终点:${r}。`;
|
|
383
|
+
return `Cue: ${t}. Action: ${a}. End: ${r}.`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A lock-off is written (what still moves, held), a move is written (why, when, along what, ending
|
|
388
|
+
* where) — an empty camera line is read by the model as permission to drift.
|
|
389
|
+
*/
|
|
390
|
+
export function cameraClause(cut, lang = 'en') {
|
|
391
|
+
const cn = isObject(cut?.cameraNote) ? cut.cameraNote : {};
|
|
392
|
+
const move = cut?.camera && cut.camera !== 'Static Shot';
|
|
393
|
+
if (lang === 'zh') {
|
|
394
|
+
if (!move) return `固定机位:${cn.hold || '…'}。`;
|
|
395
|
+
return `${CAMERAS_ZH[cut.camera] || cut.camera}:${cn.trigger || '…'}时${cn.path || '…'},止于${cn.endFraming || '…'}。`;
|
|
396
|
+
}
|
|
397
|
+
if (!move) return `The camera holds; ${cn.hold || '…'}.`;
|
|
398
|
+
return `${cut.camera} (${cn.motivation || '\u2026'}), ${cn.trigger || '\u2026'}: ${cn.path || '\u2026'}, ending on ${cn.endFraming || '\u2026'}.`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The name-free way a prompt refers to a person: their own short English identity. */
|
|
402
|
+
export function identityOf(character) {
|
|
403
|
+
return String(character?.identityEn || '').trim() || 'the person in this shot';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export const CAMERAS = [
|
|
407
|
+
'Static Shot', 'Push In', 'Pull Out', 'Zoom In', 'Zoom Out', 'Pan Left', 'Pan Right',
|
|
408
|
+
'Truck Left', 'Truck Right', 'Tilt Up', 'Tilt Down', 'Pedestal Up', 'Pedestal Down',
|
|
409
|
+
'Arc Shot', 'Tracking Shot', 'Shake Slightly', 'Shake Strongly', 'POV',
|
|
410
|
+
'Roll Clockwise', 'Roll Counterclockwise',
|
|
411
|
+
];
|
|
412
|
+
/** Chinese camera words for `wan` prompts — same 20 moves, one canonical translation each. */
|
|
413
|
+
export const CAMERAS_ZH = {
|
|
414
|
+
'Static Shot': '固定镜头', 'Push In': '推近', 'Pull Out': '拉远', 'Zoom In': '变焦推近',
|
|
415
|
+
'Zoom Out': '变焦拉远', 'Pan Left': '向左摇镜', 'Pan Right': '向右摇镜', 'Truck Left': '向左平移',
|
|
416
|
+
'Truck Right': '向右平移', 'Tilt Up': '上摇', 'Tilt Down': '下摇', 'Pedestal Up': '升镜',
|
|
417
|
+
'Pedestal Down': '降镜', 'Arc Shot': '环绕', 'Tracking Shot': '跟拍', 'Shake Slightly': '轻微手持晃动',
|
|
418
|
+
'Shake Strongly': '强烈晃动', 'POV': '主观视角', 'Roll Clockwise': '顺时针旋转',
|
|
419
|
+
'Roll Counterclockwise': '逆时针旋转',
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/** One style for the whole drama. `framePhrase` must be in every keyframe prompt; `render` in every sheet. */
|
|
423
|
+
export const STYLE_PRESETS = {
|
|
424
|
+
realistic: {
|
|
425
|
+
label: 'semi-realistic painterly',
|
|
426
|
+
render: 'semi-realistic character illustration, painterly rendering with soft blended edges and visible brush texture, anatomically grounded',
|
|
427
|
+
framePhrase: 'cinematic film still',
|
|
428
|
+
videoPhrase: 'semi-realistic painterly cinematic footage',
|
|
429
|
+
negativeMustNot: ['photorealistic', '3d render'],
|
|
430
|
+
negativeMust: [],
|
|
431
|
+
tags: ['semi-realistic', 'painterly', 'cinematic'],
|
|
432
|
+
},
|
|
433
|
+
photoreal: {
|
|
434
|
+
label: 'photographic live-action',
|
|
435
|
+
render: 'photorealistic live-action reference photography, natural skin texture with visible pores and fine hair, fabric with real weave and wear, neutral film colour grade',
|
|
436
|
+
framePhrase: 'photorealistic cinematic film still',
|
|
437
|
+
videoPhrase: 'photorealistic live-action cinematography',
|
|
438
|
+
negativeMustNot: ['photorealistic', 'film grain', 'skin texture'],
|
|
439
|
+
negativeMust: ['illustration', 'anime', 'cartoon'],
|
|
440
|
+
tags: ['photoreal', 'live action', 'cinematic'],
|
|
441
|
+
},
|
|
442
|
+
soft3d: {
|
|
443
|
+
label: 'soft clay-toy 3D animation',
|
|
444
|
+
render: 'soft matte clay-toy 3D render, smooth poreless surfaces with a gentle waxy subsurface glow, hair sculpted as one solid rounded mass with simple carved shapes rather than individual strands, very simplified facial features with small dark oval or closed curved-line eyes, a tiny nose with no nostrils and a small simple mouth line, round soft blush patches on the cheeks, chunky rounded simplified limbs and mitten-like hands, toy-like proportions with a large head, soft global illumination with gentle ambient occlusion and almost no hard shadows, muted warm palette on a plain cream background',
|
|
445
|
+
framePhrase: 'soft clay-toy 3d animated film still',
|
|
446
|
+
videoPhrase: 'soft matte clay-toy 3D animated footage',
|
|
447
|
+
negativeMustNot: ['3d render', 'cgi', 'stylized', 'clay', 'matte', 'toy', 'simplified'],
|
|
448
|
+
negativeMust: ['photorealistic'],
|
|
449
|
+
tags: ['3d animation', 'clay render', 'soft toy style', 'kids tv'],
|
|
450
|
+
},
|
|
451
|
+
disney3d: {
|
|
452
|
+
label: 'Disney/Pixar-style 3D animation',
|
|
453
|
+
render: 'stylized 3D CG animation rendered in the look of a modern Disney and Pixar feature film, appealing rounded shape language, large expressive eyes, subsurface-scattering skin, soft cloth and hair simulation, clean physically-based shading with warm cinematic key light and rim light',
|
|
454
|
+
framePhrase: 'stylized 3d animated film still',
|
|
455
|
+
videoPhrase: 'stylized Disney and Pixar style 3D animated footage',
|
|
456
|
+
negativeMustNot: ['3d render', 'cgi', 'stylized', 'cartoon'],
|
|
457
|
+
negativeMust: ['photorealistic'],
|
|
458
|
+
tags: ['3d animation', 'disney pixar style', 'family', 'cinematic'],
|
|
459
|
+
},
|
|
460
|
+
ghibli: {
|
|
461
|
+
label: 'hand-painted anime',
|
|
462
|
+
render: 'hand-painted cel animation in the style of a classic Japanese animation film, clean line art, flat colour with soft watercolour backgrounds',
|
|
463
|
+
framePhrase: 'hand-painted anime film still',
|
|
464
|
+
videoPhrase: 'hand-painted anime footage',
|
|
465
|
+
negativeMustNot: [],
|
|
466
|
+
negativeMust: ['photorealistic'],
|
|
467
|
+
tags: ['anime', 'cel shading', 'hand-painted'],
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const LANG_TAGS = { zh: 'Chinese', en: 'English', ja: 'Japanese', ko: 'Korean', fr: 'French', de: 'German', es: 'Spanish' };
|
|
472
|
+
const QUOTE_RE = /[「」『』“”]/;
|
|
473
|
+
const QUOTE_RE_G = /[「」『』“”"]/g;
|
|
474
|
+
const QUOTED_LINE_RE = /["“「『][^"”」』]{2,}["”」』]/;
|
|
475
|
+
const ETHNICITY_RE = /\b(asian|chinese|han|japanese|korean|caucasian|european|african|black|white|latino|latina|hispanic|middle[- ]eastern|arab|indian|south asian|southeast asian|mixed[- ]race|indigenous|slavic|nordic|mediterranean)\b/i;
|
|
476
|
+
const ERA_RE = /\b(1[0-9]{3}s?|20[0-9]{2}s?|century|era|contemporary|modern[- ]day|present[- ]day|republican|dynasty|medieval|victorian|edwardian|futuristic|ancient|post-war|prewar|wartime|1990s|2000s|2010s|2020s)\b/i;
|
|
477
|
+
const CJK_RE = /[㐀-鿿]/;
|
|
478
|
+
|
|
479
|
+
// ── small helpers ───────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
export const nonWhitespace = (s) => String(s ?? '').replace(/\s+/g, '').length;
|
|
482
|
+
export const lineSeconds = (line, params) => nonWhitespace(line) / params.charsPerSecond;
|
|
483
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
484
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
485
|
+
const uniq = (arr) => [...new Set(arr)];
|
|
486
|
+
const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
487
|
+
|
|
488
|
+
export function slug(name) {
|
|
489
|
+
return String(name ?? '')
|
|
490
|
+
.trim()
|
|
491
|
+
.replace(/[\\/:*?"<>|#%&{}$!'`@+=~^\s]+/g, '-')
|
|
492
|
+
.replace(/-+/g, '-')
|
|
493
|
+
.replace(/^-|-$/g, '')
|
|
494
|
+
.toLowerCase() || 'untitled';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function paramsOf(project) {
|
|
498
|
+
const p = { ...DEFAULT_PARAMS, ...(project.params || {}) };
|
|
499
|
+
p.thresholds = { ...DEFAULT_PARAMS.thresholds, ...(project.params?.thresholds || {}) };
|
|
500
|
+
// The model decides how long a segment may be; an explicit param still wins, but it is then gated.
|
|
501
|
+
const mode = videoModel(p);
|
|
502
|
+
if (project.params?.maxSegmentSeconds == null) p.maxSegmentSeconds = mode.maxSegmentSeconds;
|
|
503
|
+
if (project.params?.minSegmentSeconds == null) p.minSegmentSeconds = mode.minSegmentSeconds;
|
|
504
|
+
if (project.params?.maxAudioRefs == null) p.maxAudioRefs = mode.maxAudioRefs;
|
|
505
|
+
if (project.params?.maxVideoRefs == null) p.maxVideoRefs = mode.maxVideoRefs;
|
|
506
|
+
// `auto` is the default, but a model without an autoDuration parameter has to be told a duration.
|
|
507
|
+
if (project.params?.videoDuration == null && !mode.autoDuration) p.videoDuration = 'fixed';
|
|
508
|
+
// The register is the drama's spoken 语体; the accent the video model is told to speak follows it.
|
|
509
|
+
p.register = normalizeRegister(p.register);
|
|
510
|
+
if (!String(p.speechAccent || '').trim() && REGISTER_ACCENT[p.register]) p.speechAccent = REGISTER_ACCENT[p.register][mode.promptLang === 'zh' ? 'zh' : 'en'];
|
|
511
|
+
if (p.thresholds.maxPrimaryScenes == null) {
|
|
512
|
+
const eps = Number(p.episodes) || 0;
|
|
513
|
+
p.thresholds.maxPrimaryScenes = Math.min(15, Math.max(5, 4 + Math.ceil(eps / 10)));
|
|
514
|
+
}
|
|
515
|
+
return p;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function langTag(lang) {
|
|
519
|
+
return LANG_TAGS[String(lang || 'zh').toLowerCase()] || 'Chinese';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Names/aliases that must not appear in English prompts. */
|
|
523
|
+
export function forbiddenNames(project) {
|
|
524
|
+
const out = [];
|
|
525
|
+
for (const c of project.characters || []) {
|
|
526
|
+
for (const n of [c.name, ...(c.aliases || [])]) if (n && String(n).trim().length >= 2) out.push(String(n).trim());
|
|
527
|
+
}
|
|
528
|
+
return uniq(out);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function containsName(text, name) {
|
|
532
|
+
if (!text || !name) return false;
|
|
533
|
+
if (CJK_RE.test(name)) return text.includes(name);
|
|
534
|
+
return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function jaccard(a, b) {
|
|
538
|
+
const wa = new Set(String(a).toLowerCase().match(/[a-z0-9']+/g) || []);
|
|
539
|
+
const wb = new Set(String(b).toLowerCase().match(/[a-z0-9']+/g) || []);
|
|
540
|
+
if (wa.size < 8 || wb.size < 8) return 0;
|
|
541
|
+
let inter = 0;
|
|
542
|
+
for (const w of wa) if (wb.has(w)) inter += 1;
|
|
543
|
+
return inter / (wa.size + wb.size - inter);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function isEnglish(text) {
|
|
547
|
+
return !CJK_RE.test(String(text ?? ''));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function beatsOfEpisode(project, ep) {
|
|
551
|
+
return (project.outline?.beats || []).filter((b) => Number(b.episode) === Number(ep));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function sceneById(project, id) {
|
|
555
|
+
return (project.scenes || []).find((s) => s.id === id);
|
|
556
|
+
}
|
|
557
|
+
function characterById(project, id) {
|
|
558
|
+
return (project.characters || []).find((c) => c.id === id);
|
|
559
|
+
}
|
|
560
|
+
function propById(project, id) {
|
|
561
|
+
return (project.props || []).find((p) => p.id === id);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── screenplay craft helpers ────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
/** Every beat of the episode with its cumulative start/end second, in order. */
|
|
567
|
+
export function beatOffsets(episode, params) {
|
|
568
|
+
const out = [];
|
|
569
|
+
let t = 0;
|
|
570
|
+
(episode.scenes || []).forEach((sc, si) => {
|
|
571
|
+
(sc.flow || []).forEach((b, bi) => {
|
|
572
|
+
const secs = beatSeconds(b, params);
|
|
573
|
+
out.push({ si: si + 1, bi: bi + 1, start: t, end: t + secs, beat: b });
|
|
574
|
+
t += secs;
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The beat a `[sceneIndex, beatIndex]` pointer names, or null. */
|
|
581
|
+
export function beatAt(episode, ref) {
|
|
582
|
+
if (!Array.isArray(ref) || ref.length !== 2) return null;
|
|
583
|
+
const [si, bi] = ref.map(Number);
|
|
584
|
+
return ((episode.scenes || [])[si - 1]?.flow || [])[bi - 1] || null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Where the episode turns: the hook, every beat marked `turn`, the 爽点 and the cliff (the end).
|
|
589
|
+
* The 30-second rule is measured between consecutive points.
|
|
590
|
+
*/
|
|
591
|
+
export function turnPoints(episode, params) {
|
|
592
|
+
const offsets = beatOffsets(episode, params);
|
|
593
|
+
const points = [];
|
|
594
|
+
const find = (ref) => offsets.find((o) => Array.isArray(ref) && o.si === Number(ref[0]) && o.bi === Number(ref[1]));
|
|
595
|
+
const hook = find(episode.hookBeat);
|
|
596
|
+
if (hook) points.push({ at: hook.start, what: 'hook' });
|
|
597
|
+
for (const o of offsets) if (o.beat.turn === true) points.push({ at: o.start, what: `turn S${o.si} b${o.bi}` });
|
|
598
|
+
const sat = find(episode.satisfactionBeat);
|
|
599
|
+
if (sat) points.push({ at: sat.start, what: '爽点' });
|
|
600
|
+
if (offsets.length) points.push({ at: offsets[offsets.length - 1].end, what: 'cliff' });
|
|
601
|
+
return points.sort((a, b) => a.at - b.at);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const CN_DIGITS = { '零': 0, '〇': 0, '一': 1, '二': 2, '两': 2, '兩': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 };
|
|
605
|
+
/** A rough age from an `ageRange` string ("24 岁左右", "六十岁出头", "四十上下"); null when there is none. */
|
|
606
|
+
export function parseAge(text) {
|
|
607
|
+
const t = String(text || '');
|
|
608
|
+
const digits = t.match(/(\d{1,3})/);
|
|
609
|
+
if (digits) return Number(digits[1]);
|
|
610
|
+
const m = t.match(/([一二两兩三四五六七八九]?)十([一二三四五六七八九]?)|([一二两兩三四五六七八九])/);
|
|
611
|
+
if (!m) return null;
|
|
612
|
+
if (m[3] !== undefined) return CN_DIGITS[m[3]];
|
|
613
|
+
const tens = m[1] ? CN_DIGITS[m[1]] : 1;
|
|
614
|
+
const ones = m[2] ? CN_DIGITS[m[2]] : 0;
|
|
615
|
+
return tens * 10 + ones;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Action vs line beats over the episode, and the longest run of lines with no action between. */
|
|
619
|
+
export function talkAndAction(episode) {
|
|
620
|
+
let actions = 0, lines = 0, run = 0, longest = 0;
|
|
621
|
+
for (const sc of episode.scenes || []) {
|
|
622
|
+
run = 0;
|
|
623
|
+
for (const b of sc.flow || []) {
|
|
624
|
+
if (b.line !== undefined) { lines += 1; run += 1; longest = Math.max(longest, run); }
|
|
625
|
+
else { actions += 1; run = 0; }
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return { actions, lines, longestLineRun: longest, ratio: lines ? actions / lines : Infinity };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const wordCount = (text) => (String(text || '').match(/[A-Za-z']+/g) || []).length;
|
|
632
|
+
const asList = (v) => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v]);
|
|
633
|
+
const isEnglishRegister = (register) => /^英文/.test(String(register || ''));
|
|
634
|
+
|
|
635
|
+
// ── shot language helpers ────────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
const boundaryOf = (b) => (isObject(b) ? b : {});
|
|
638
|
+
const heldOf = (b, id) => asList(boundaryOf(b).heldProps?.[id]).map(String).sort();
|
|
639
|
+
/** A cut's start boundary: its own on a segment's first cut, else the previous cut's end. */
|
|
640
|
+
export function cutStart(seg, k) {
|
|
641
|
+
const cuts = seg?.cuts || [];
|
|
642
|
+
return k === 0 ? boundaryOf(cuts[0]?.start) : boundaryOf(cuts[k - 1]?.end);
|
|
643
|
+
}
|
|
644
|
+
/** Every character who appears in the segment, in first-appearance order. */
|
|
645
|
+
export function segmentCharacters(seg) {
|
|
646
|
+
const out = [];
|
|
647
|
+
for (const c of seg?.cuts || []) for (const id of c.characters || []) if (!out.includes(id)) out.push(id);
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
const sizeIndex = (size) => SIZE_ORDER.indexOf(size);
|
|
651
|
+
/** Does this cut claim a beat that carries `ref` ([sceneIndex, beatIndex]) — same scene, in range. */
|
|
652
|
+
function cutClaims(seg, cut, ref) {
|
|
653
|
+
if (!Array.isArray(ref) || ref.length !== 2 || !Array.isArray(cut.beats)) return false;
|
|
654
|
+
return Number(ref[0]) === Number(seg.sceneIndex) && Number(ref[1]) >= Number(cut.beats[0]) && Number(ref[1]) <= Number(cut.beats[1]);
|
|
655
|
+
}
|
|
656
|
+
/** The beats a cut claims, with their index. */
|
|
657
|
+
function cutBeats(scene, cut) {
|
|
658
|
+
const [from, to] = Array.isArray(cut.beats) ? cut.beats.map(Number) : [0, -1];
|
|
659
|
+
const out = [];
|
|
660
|
+
for (let i = from; i <= to; i++) { const b = (scene?.flow || [])[i - 1]; if (b) out.push({ bi: i, beat: b }); }
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
/** The scenes that get a visual plan: the one with the 爽点 and the one with the cliff (the last). */
|
|
664
|
+
export function keyScenes(episode) {
|
|
665
|
+
const out = new Set();
|
|
666
|
+
if (Array.isArray(episode.satisfactionBeat)) out.add(Number(episode.satisfactionBeat[0]));
|
|
667
|
+
if ((episode.scenes || []).length) out.add((episode.scenes || []).length);
|
|
668
|
+
return [...out].filter((n) => n >= 1).sort((a, b) => a - b);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Per-shot text of a prompt, keyed by cut number, for every dialect. */
|
|
672
|
+
export function shotTexts(prompt, family, references) {
|
|
673
|
+
const map = new Map();
|
|
674
|
+
let re;
|
|
675
|
+
if (family === 'h3') return shotParagraphs(prompt);
|
|
676
|
+
if (family === 'wan') re = /镜头(\d+)([\s\S]*?(?=\n镜头\d+(|\n\s*\n|$)/g;
|
|
677
|
+
else if (references === 'sheets') re = /^Shot (\d+) \(([\s\S]*?)(?=\nShot \d+ \(|\n\s*\n|$)/gm;
|
|
678
|
+
else return map;
|
|
679
|
+
let m;
|
|
680
|
+
while ((m = re.exec(prompt))) map.set(Number(m[1]), m[0].trim());
|
|
681
|
+
return map;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** `sound.music` as {intent, cues[]}: a string is an intent with no cues, `N/A` is no music. */
|
|
685
|
+
export function musicOf(sound) {
|
|
686
|
+
const m = isObject(sound) ? sound.music : undefined;
|
|
687
|
+
if (isObject(m)) return { intent: String(m.intent || '').trim(), cues: asList(m.cues) };
|
|
688
|
+
const text = String(m || '').trim();
|
|
689
|
+
return { intent: text === 'N/A' ? '' : text, cues: [] };
|
|
690
|
+
}
|
|
691
|
+
/** A cue's `at` — a cut number (its start) or "7.5s" — as seconds, or null. */
|
|
692
|
+
export function cueSeconds(at, cuts) {
|
|
693
|
+
const starts = cutStarts(cuts || []);
|
|
694
|
+
if (typeof at === 'number' && Number.isInteger(at) && at >= 1 && at <= starts.length) return starts[at - 1];
|
|
695
|
+
const m = String(at ?? '').match(/^(\d+(?:\.\d+)?)\s*s$/);
|
|
696
|
+
if (m) return Number(m[1]);
|
|
697
|
+
if (typeof at === 'number') return at;
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
const tc = (a, b) => `[${a.toFixed(1)}s:${b.toFixed(1)}s]`;
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The Seed Audio prompt for a segment's soundtrack, derived from `sound` and the cuts: every element
|
|
704
|
+
* carries a `[start s:end s]` timecode so the bed lines up with the clip. `bed` = ambience + sfx +
|
|
705
|
+
* music, no voices; `full` adds every line at its cut, read in the connected `@AudioN` voices.
|
|
706
|
+
*/
|
|
707
|
+
export function audioPrompt(project, episode, seg, opts = {}) {
|
|
708
|
+
const params = paramsOf(project);
|
|
709
|
+
const mode = opts.mode || params.audioTrack;
|
|
710
|
+
const scene = (episode.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
711
|
+
const cuts = seg.cuts || [];
|
|
712
|
+
const starts = cutStarts(cuts);
|
|
713
|
+
const total = cuts.reduce((sum, c) => sum + (Number(c.seconds) || 0), 0);
|
|
714
|
+
const sound = isObject(seg.sound) ? seg.sound : {};
|
|
715
|
+
const music = musicOf(sound);
|
|
716
|
+
const lines = [];
|
|
717
|
+
const references = [];
|
|
718
|
+
lines.push(`${tc(0, total)} Ambience, continuous: ${sound.ambience || '…'}.`);
|
|
719
|
+
for (const x of asList(sound.sfx)) {
|
|
720
|
+
const k = Number(x?.cut);
|
|
721
|
+
if (!(k >= 1 && k <= cuts.length)) continue;
|
|
722
|
+
lines.push(`${tc(starts[k - 1], starts[k - 1] + (Number(cuts[k - 1].seconds) || 0))} SFX: ${x.event}.`);
|
|
723
|
+
}
|
|
724
|
+
if (music.intent) {
|
|
725
|
+
// The cues cut the timeline into states: enter → (swell | duck | drop) → silence / out.
|
|
726
|
+
const cues = music.cues.map((c) => ({ at: cueSeconds(c?.at, cuts), do: c?.do, note: c?.note })).filter((c) => c.at != null).sort((a, b) => a.at - b.at);
|
|
727
|
+
if (!cues.length) lines.push(`${tc(0, total)} Music, instrumental, under everything: ${music.intent}.`);
|
|
728
|
+
else {
|
|
729
|
+
const verb = { enter: 'enters', swell: 'swells', duck: 'ducks under the voices', drop: 'drops low', silence: 'falls silent', out: 'fades out' };
|
|
730
|
+
lines.push(`Music, instrumental: ${music.intent}.`);
|
|
731
|
+
if (cues[0].at > 0) lines.push(`${tc(0, cues[0].at)} no music.`);
|
|
732
|
+
cues.forEach((c, i) => {
|
|
733
|
+
const end = i + 1 < cues.length ? cues[i + 1].at : total;
|
|
734
|
+
lines.push(`${tc(c.at, end)} the music ${verb[c.do] || c.do}${c.note ? ` — ${c.note}` : ''}.`);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
} else lines.push('No music.');
|
|
738
|
+
if (mode === 'full') {
|
|
739
|
+
const voices = audioReferences(project, episode, seg);
|
|
740
|
+
voices.forEach((r, i) => references.push({ label: `@Audio${i + 1}`, entity: r.entity, asset: r.asset, why: r.why }));
|
|
741
|
+
const labelOf = new Map(references.map((r) => [r.entity, r.label]));
|
|
742
|
+
cuts.forEach((c, k) => {
|
|
743
|
+
let t = starts[k] + (Number(params.speechLeadSeconds) || 0);
|
|
744
|
+
for (const l of claimedLines(scene, [c])) {
|
|
745
|
+
const ch = characterById(project, l.speaker);
|
|
746
|
+
const secs = lineSeconds(l.line, params);
|
|
747
|
+
const who = l.speaker === 'VO' ? 'an off-screen inner voice' : labelOf.get(l.speaker) ? `${labelOf.get(l.speaker)} (${identityOf(ch)})` : identityOf(ch);
|
|
748
|
+
const how = speechDirection(ch, l.delivery, 'en');
|
|
749
|
+
lines.push(`${tc(t, Math.min(total, t + secs))} ${who}${how ? `, ${how},` : ''} says: ${l.line}`);
|
|
750
|
+
t += secs;
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
lines.push('Dialogue only from the lines above; no other voices.');
|
|
754
|
+
} else lines.push('No dialogue, no vocals, no narration.');
|
|
755
|
+
const prompt = lines.join('\n');
|
|
756
|
+
return { segment: seg.id, mode, seconds: total, prompt, references, model: AUDIO_MODEL };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── gates ───────────────────────────────────────────────────────────────────
|
|
760
|
+
//
|
|
761
|
+
// A gate is {id, stage, label, run(ctx) -> string[]} where the strings are the
|
|
762
|
+
// failures. `ctx` = {project, params, episodes: Map<ep, episodeJson>, eps}.
|
|
763
|
+
|
|
764
|
+
// `advisory` gates print their findings as ⚠ and never fail `validate`: the rule is a strong default
|
|
765
|
+
// the writer may knowingly break (a 穿越 character speaking 古装 in a modern register is correct).
|
|
766
|
+
function gate(id, stage, label, run, opts = {}) {
|
|
767
|
+
return { id, stage, label, run, advisory: opts.advisory === true };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export const GATES = [
|
|
771
|
+
// ── outline ───────────────────────────────────────────────────────────
|
|
772
|
+
gate('params', 'outline', 'params complete and valid', ({ project, params }) => {
|
|
773
|
+
const out = [];
|
|
774
|
+
if (!(Number(params.episodes) >= 1)) out.push('params.episodes must be a positive integer');
|
|
775
|
+
if (!(Number(params.minutesPerEpisode) > 0)) out.push('params.minutesPerEpisode must be > 0');
|
|
776
|
+
if (!params.genre) out.push('params.genre is required — it decides the beat types');
|
|
777
|
+
if (!ADAPT_MODES.includes(params.adaptMode)) out.push(`params.adaptMode must be one of ${ADAPT_MODES.join(' / ')}`);
|
|
778
|
+
if (!VIDEO_MODES.includes(params.video)) out.push(`params.video must be one of ${VIDEO_MODES.join(' / ')}`);
|
|
779
|
+
if (!AUDIO_TRACK_MODES.includes(params.audioTrack)) out.push(`params.audioTrack must be one of ${AUDIO_TRACK_MODES.join(' / ')}`);
|
|
780
|
+
if (!REGISTERS.includes(params.register)) out.push(`params.register (语体) must be one of ${REGISTERS.join(' / ')} — it decides how the lines are written and the accent the model speaks`);
|
|
781
|
+
if (!REFERENCE_MODES.includes(params.references)) out.push(`params.references must be one of ${REFERENCE_MODES.join(' / ')}`);
|
|
782
|
+
if (!DURATION_MODES.includes(params.videoDuration)) out.push(`params.videoDuration must be one of ${DURATION_MODES.join(' / ')}`);
|
|
783
|
+
if (!VIDEO_REFERENCE_MODES.includes(params.videoReferences)) out.push(`params.videoReferences must be one of ${VIDEO_REFERENCE_MODES.join(' / ')}`);
|
|
784
|
+
const mode = videoModel(params);
|
|
785
|
+
if (params.videoDuration === 'auto' && !mode.autoDuration) out.push(`${mode.model} has no auto duration \u2014 set params.videoDuration = fixed and give every segment a whole-second total`);
|
|
786
|
+
if (!(Number(params.minSegmentSeconds) >= mode.minSegmentSeconds && Number(params.maxSegmentSeconds) <= mode.maxSegmentSeconds && params.minSegmentSeconds <= params.maxSegmentSeconds)) {
|
|
787
|
+
out.push(`params.min/maxSegmentSeconds = ${params.minSegmentSeconds}\u2013${params.maxSegmentSeconds} s, but ${mode.model} takes ${mode.minSegmentSeconds}\u2013${mode.maxSegmentSeconds} s \u2014 leave them out to follow the model`);
|
|
788
|
+
}
|
|
789
|
+
if (mode.family === 'seedance' && params.style === 'photoreal') {
|
|
790
|
+
// BytePlus rejects photorealistic people in reference images ("input image may contain real person", HTTP 400).
|
|
791
|
+
if (params.references !== 'sheets') out.push(`${mode.model} + photoreal: keyframes (photoreal people) are refused by the provider's real-person check \u2014 set params.references = sheets`);
|
|
792
|
+
if (project.params?.humanReferences === undefined) out.push(`${mode.model} + photoreal: the provider's real-person check refuses character sheets unless the account has the real-person permission \u2014 set params.humanReferences explicitly (false: describe people in the prompt; true: the permission is enabled)`);
|
|
793
|
+
}
|
|
794
|
+
if (params.videoResolution && !videoResolutionFor(mode, params.videoResolution)) out.push(`params.videoResolution ${params.videoResolution} is not a tier of ${mode.model} \u2014 it takes ${mode.resolutions.join(' / ')}`);
|
|
795
|
+
if (!STYLE_PRESETS[params.style]) out.push(`params.style must be one of ${Object.keys(STYLE_PRESETS).join(' / ')}`);
|
|
796
|
+
if (!/^\d+:\d+$/.test(String(params.ratio))) out.push('params.ratio must look like 9:16');
|
|
797
|
+
if (!project.title) out.push('title is required');
|
|
798
|
+
return out;
|
|
799
|
+
}),
|
|
800
|
+
gate('adaptation', 'outline', 'adaptation decisions recorded', ({ project, params }) => {
|
|
801
|
+
const o = project.outline || {};
|
|
802
|
+
const out = [];
|
|
803
|
+
if (!o.core) out.push('outline.core (one-sentence core) is required');
|
|
804
|
+
if (params.adaptMode !== '原创' && !(o.keep || []).length) out.push('outline.keep needs at least one entry when adapting a source');
|
|
805
|
+
if (!['原创', '忠实'].includes(params.adaptMode) && !(o.cut || []).length) out.push(`adaptMode ${params.adaptMode} must cut something — outline.cut is empty`);
|
|
806
|
+
for (const [key, list] of [['keep', o.keep], ['cut', o.cut], ['merge', o.merge]]) {
|
|
807
|
+
for (const it of list || []) if (!it.what || !it.why) out.push(`outline.${key}: every entry needs what + why`);
|
|
808
|
+
}
|
|
809
|
+
for (const it of o.risks || []) if (!it.what || !it.plan) out.push('outline.risks: every entry needs what + plan');
|
|
810
|
+
for (const key of ['cutNote', 'mergeNote']) if (key in o && !String(o[key]).trim()) out.push(`outline.${key} is present but empty`);
|
|
811
|
+
return out;
|
|
812
|
+
}),
|
|
813
|
+
gate('tiers', 'outline', 'character tiers within caps', ({ project, params }) => {
|
|
814
|
+
const t = params.thresholds;
|
|
815
|
+
const out = [];
|
|
816
|
+
const seen = new Set();
|
|
817
|
+
const count = { lead: 0, support: 0, functional: 0 };
|
|
818
|
+
for (const c of project.characters || []) {
|
|
819
|
+
if (!/^C\d{2,}$/.test(String(c.id))) out.push(`character id "${c.id}" must look like C01`);
|
|
820
|
+
if (seen.has(c.id)) out.push(`duplicate character id ${c.id}`);
|
|
821
|
+
seen.add(c.id);
|
|
822
|
+
if (!c.name) out.push(`${c.id}: name is required`);
|
|
823
|
+
if (!TIERS.includes(c.tier)) out.push(`${c.id}: tier must be one of ${TIERS.join(' / ')}`);
|
|
824
|
+
else count[c.tier] += 1;
|
|
825
|
+
if (!c.role) out.push(`${c.id}: role is required`);
|
|
826
|
+
if (['lead', 'support'].includes(c.tier) && !c.arc) out.push(`${c.id}: ${c.tier} characters need an arc`);
|
|
827
|
+
if (!Array.isArray(c.from) || !c.from.length) out.push(`${c.id}: from[] must record where the character comes from (or ["原创"])`);
|
|
828
|
+
}
|
|
829
|
+
if (count.lead < 1 || count.lead > t.maxLeads) out.push(`lead characters: ${count.lead}, must be 1–${t.maxLeads}`);
|
|
830
|
+
if (count.support > t.maxSupport) out.push(`support characters: ${count.support}, max ${t.maxSupport}`);
|
|
831
|
+
if (count.functional > t.maxFunctional) out.push(`functional characters: ${count.functional}, max ${t.maxFunctional}`);
|
|
832
|
+
return out;
|
|
833
|
+
}),
|
|
834
|
+
gate('scenes', 'outline', 'scenes registered, primary cap, reuse plans', ({ project, params }) => {
|
|
835
|
+
const out = [];
|
|
836
|
+
const seen = new Set();
|
|
837
|
+
let primary = 0;
|
|
838
|
+
const usage = new Map();
|
|
839
|
+
for (const e of project.outline?.episodes || []) for (const id of e.sceneIds || []) usage.set(id, (usage.get(id) || 0) + 1);
|
|
840
|
+
for (const s of project.scenes || []) {
|
|
841
|
+
if (!/^S\d{2,}$/.test(String(s.id))) out.push(`scene id "${s.id}" must look like S01`);
|
|
842
|
+
if (seen.has(s.id)) out.push(`duplicate scene id ${s.id}`);
|
|
843
|
+
seen.add(s.id);
|
|
844
|
+
if (!s.name) out.push(`${s.id}: name is required`);
|
|
845
|
+
if (typeof s.primary !== 'boolean') out.push(`${s.id}: primary must be true/false`);
|
|
846
|
+
if (s.primary) primary += 1;
|
|
847
|
+
if ((usage.get(s.id) || 0) === 1 && !s.reusePlan && !s.variantOf) out.push(`${s.id}: used in only one episode — add a reusePlan (or make it a variantOf)`);
|
|
848
|
+
}
|
|
849
|
+
if (primary > params.thresholds.maxPrimaryScenes) out.push(`primary scenes: ${primary}, max ${params.thresholds.maxPrimaryScenes} for ${params.episodes} episodes`);
|
|
850
|
+
return out;
|
|
851
|
+
}),
|
|
852
|
+
gate('props-cap', 'outline', 'narrative props ≤ cap, each with a function', ({ project, params }) => {
|
|
853
|
+
const out = [];
|
|
854
|
+
const props = project.props || [];
|
|
855
|
+
if (props.length > params.thresholds.maxProps) out.push(`props: ${props.length}, max ${params.thresholds.maxProps} — the rest is set dressing`);
|
|
856
|
+
const beatIds = new Set((project.outline?.beats || []).map((b) => b.id));
|
|
857
|
+
const seen = new Set();
|
|
858
|
+
for (const p of props) {
|
|
859
|
+
if (!/^P\d{2,}$/.test(String(p.id))) out.push(`prop id "${p.id}" must look like P01`);
|
|
860
|
+
if (seen.has(p.id)) out.push(`duplicate prop id ${p.id}`);
|
|
861
|
+
seen.add(p.id);
|
|
862
|
+
if (!p.name) out.push(`${p.id}: name is required`);
|
|
863
|
+
if (!p.function) out.push(`${p.id}: function is required — if you cannot say what it carries, it is not a narrative prop`);
|
|
864
|
+
for (const b of p.beatIds || []) if (!beatIds.has(b)) out.push(`${p.id}: beatIds references unknown beat ${b}`);
|
|
865
|
+
}
|
|
866
|
+
return out;
|
|
867
|
+
}),
|
|
868
|
+
gate('beats', 'outline', 'beat rhythm: gap ≤ N, first-episode hook, major timing', ({ project, params }) => {
|
|
869
|
+
const out = [];
|
|
870
|
+
const beats = project.outline?.beats || [];
|
|
871
|
+
const eps = Number(params.episodes) || 0;
|
|
872
|
+
if (!beats.length) return ['outline.beats is empty'];
|
|
873
|
+
const seen = new Set();
|
|
874
|
+
for (const b of beats) {
|
|
875
|
+
if (!/^B\d{2,}$/.test(String(b.id))) out.push(`beat id "${b.id}" must look like B01`);
|
|
876
|
+
if (seen.has(b.id)) out.push(`duplicate beat id ${b.id}`);
|
|
877
|
+
seen.add(b.id);
|
|
878
|
+
if (!b.type) out.push(`${b.id}: type is required`);
|
|
879
|
+
if (!['major', 'minor'].includes(b.weight || 'minor')) out.push(`${b.id}: weight must be major or minor`);
|
|
880
|
+
if (!(Number(b.episode) >= 1 && Number(b.episode) <= eps)) out.push(`${b.id}: episode ${b.episode} is outside 1–${eps}`);
|
|
881
|
+
if (!b.setup || !b.payoff) out.push(`${b.id}: setup and payoff are required`);
|
|
882
|
+
}
|
|
883
|
+
const positions = uniq(beats.map((b) => Number(b.episode)).filter((n) => n >= 1)).sort((a, b) => a - b);
|
|
884
|
+
if (positions.length) {
|
|
885
|
+
if (positions[0] > 1) out.push(`no beat in episode 1 — the first episode needs a hook beat`);
|
|
886
|
+
const gap = params.thresholds.maxBeatGap;
|
|
887
|
+
let prev = 0;
|
|
888
|
+
for (const p of positions) {
|
|
889
|
+
if (p - prev > gap) out.push(`beat gap: nothing between episode ${prev} and ${p} (max gap ${gap})`);
|
|
890
|
+
prev = p;
|
|
891
|
+
}
|
|
892
|
+
if (prev !== eps) out.push(`the finale (episode ${eps}) has no beat — the last beat sits on episode ${prev}`);
|
|
893
|
+
}
|
|
894
|
+
const majors = beats.filter((b) => b.weight === 'major').map((b) => Number(b.episode));
|
|
895
|
+
if (!majors.length) out.push('at least one major beat is required');
|
|
896
|
+
else if (Math.min(...majors) >= eps && eps > 1) out.push('the first major beat cannot sit on the last episode');
|
|
897
|
+
return out;
|
|
898
|
+
}),
|
|
899
|
+
gate('episodes', 'outline', 'every episode has synopsis / hook / suspense, narrative only', ({ project, params }) => {
|
|
900
|
+
const out = [];
|
|
901
|
+
const eps = project.outline?.episodes || [];
|
|
902
|
+
const n = Number(params.episodes) || 0;
|
|
903
|
+
if (eps.length !== n) out.push(`outline.episodes has ${eps.length} entries, params.episodes is ${n}`);
|
|
904
|
+
eps.forEach((e, i) => {
|
|
905
|
+
if (Number(e.ep) !== i + 1) out.push(`outline.episodes[${i}].ep should be ${i + 1}`);
|
|
906
|
+
for (const key of ['synopsis', 'hook', 'suspense']) {
|
|
907
|
+
if (!String(e[key] || '').trim()) out.push(`ep ${e.ep}: ${key} is empty`);
|
|
908
|
+
else if (QUOTE_RE.test(e[key])) out.push(`ep ${e.ep}: ${key} contains dialogue quotes — synopses are narrative`);
|
|
909
|
+
}
|
|
910
|
+
if ((e.characterIds || []).length >= 3 && !String(e.crowdPlan || '').trim()) out.push(`ep ${e.ep}: ${e.characterIds.length} characters need a crowdPlan (how the group is staged)`);
|
|
911
|
+
for (const w of e.warnings || []) if (!WARNINGS.includes(w)) out.push(`ep ${e.ep}: unknown warning "${w}" (use ${WARNINGS.join(' / ')})`);
|
|
912
|
+
});
|
|
913
|
+
return out;
|
|
914
|
+
}),
|
|
915
|
+
gate('refs', 'outline', 'episode references resolve; no idle character / scene / prop', ({ project }) => {
|
|
916
|
+
const out = [];
|
|
917
|
+
const chars = new Set((project.characters || []).map((c) => c.id));
|
|
918
|
+
const scenes = new Set((project.scenes || []).map((s) => s.id));
|
|
919
|
+
const props = new Set((project.props || []).map((p) => p.id));
|
|
920
|
+
const used = { c: new Set(), s: new Set(), p: new Set() };
|
|
921
|
+
for (const e of project.outline?.episodes || []) {
|
|
922
|
+
if (!(e.sceneIds || []).length) out.push(`ep ${e.ep}: sceneIds is empty`);
|
|
923
|
+
if (!(e.characterIds || []).length) out.push(`ep ${e.ep}: characterIds is empty`);
|
|
924
|
+
for (const id of e.sceneIds || []) (scenes.has(id) ? used.s.add(id) : out.push(`ep ${e.ep}: unknown scene ${id}`));
|
|
925
|
+
for (const id of e.characterIds || []) (chars.has(id) ? used.c.add(id) : out.push(`ep ${e.ep}: unknown character ${id}`));
|
|
926
|
+
for (const id of e.propIds || []) (props.has(id) ? used.p.add(id) : out.push(`ep ${e.ep}: unknown prop ${id}`));
|
|
927
|
+
}
|
|
928
|
+
if ((project.outline?.episodes || []).length) {
|
|
929
|
+
for (const id of chars) if (!used.c.has(id)) out.push(`character ${id} appears in no episode`);
|
|
930
|
+
for (const id of scenes) if (!used.s.has(id)) out.push(`scene ${id} is used in no episode`);
|
|
931
|
+
for (const id of props) if (!used.p.has(id)) out.push(`prop ${id} appears in no episode`);
|
|
932
|
+
}
|
|
933
|
+
return out;
|
|
934
|
+
}),
|
|
935
|
+
|
|
936
|
+
gate('brief', 'outline', '创作简报: the six judgements, in the drama language', ({ project }) => {
|
|
937
|
+
const out = [];
|
|
938
|
+
const b = project.outline?.brief;
|
|
939
|
+
if (!isObject(b)) return ['outline.brief is missing — the 创作简报 (logline, audience, satisfactionEngine, mainHook, protagonistPressure, antagonistPressure, emotionalLine, promise) comes before any episode'];
|
|
940
|
+
for (const key of ['logline', 'audience', 'satisfactionEngine', 'mainHook', 'protagonistPressure', 'antagonistPressure', 'emotionalLine', 'promise']) {
|
|
941
|
+
const v = String(b[key] || '').trim();
|
|
942
|
+
if (!v) out.push(`outline.brief.${key} is empty`);
|
|
943
|
+
else if (QUOTE_RE.test(v)) out.push(`outline.brief.${key} contains dialogue quotes — the brief is narrative`);
|
|
944
|
+
}
|
|
945
|
+
const engine = String(b.satisfactionEngine || '');
|
|
946
|
+
if (engine && !SATISFACTION_TYPES.some((t) => engine.includes(t))) out.push(`outline.brief.satisfactionEngine must name one of ${SATISFACTION_TYPES.join(' / ')} (it can add how it varies)`);
|
|
947
|
+
return out;
|
|
948
|
+
}),
|
|
949
|
+
gate('structure', 'outline', '三段结构: golden → escalation → climax tiling every episode', ({ project, params }) => {
|
|
950
|
+
const out = [];
|
|
951
|
+
const phases = project.outline?.structure;
|
|
952
|
+
const n = Number(params.episodes) || 0;
|
|
953
|
+
if (!Array.isArray(phases) || !phases.length) return ['outline.structure is missing — three phases (golden / escalation / climax) with episode ranges'];
|
|
954
|
+
const expect = n >= 3 ? PHASES : PHASES.filter((p) => p !== 'escalation');
|
|
955
|
+
const got = phases.map((p) => p.phase);
|
|
956
|
+
if (got.join(',') !== expect.join(',')) out.push(`outline.structure phases must be ${expect.join(' → ')} in that order (got ${got.join(', ') || 'none'})`);
|
|
957
|
+
let cursor = 1;
|
|
958
|
+
phases.forEach((p, i) => {
|
|
959
|
+
const [a, b] = Array.isArray(p.episodes) ? p.episodes.map(Number) : [NaN, NaN];
|
|
960
|
+
if (!(a >= 1 && b >= a && b <= n)) { out.push(`outline.structure[${i}] (${p.phase}): episodes must be [from, to] inside 1–${n}`); return; }
|
|
961
|
+
if (a !== cursor) out.push(`outline.structure[${i}] (${p.phase}) starts at ${a}, expected ${cursor} — the phases must tile the episodes without gaps`);
|
|
962
|
+
cursor = b + 1;
|
|
963
|
+
for (const key of ['task', 'satisfactionPlan', 'hookPlan']) if (!String(p[key] || '').trim()) out.push(`outline.structure[${i}] (${p.phase}): ${key} is empty`);
|
|
964
|
+
if (p.phase === 'golden') {
|
|
965
|
+
// 黄金窗口 is the first ~20 % — and never less than the first three episodes of a long run.
|
|
966
|
+
const cap = Math.max(Math.ceil(n * 0.2), n >= 6 ? 3 : 1);
|
|
967
|
+
if (b > cap) out.push(`golden window ends at episode ${b}; for ${n} episodes it must end by ${cap} (the first ~20 %)`);
|
|
968
|
+
if (n >= 6 && b < 3) out.push(`golden window ends at episode ${b}; with ${n} episodes the first three episodes are the retention window`);
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
if (phases.length && cursor - 1 !== n && !out.some((m) => m.includes('inside 1'))) out.push(`outline.structure ends at episode ${cursor - 1}, the drama has ${n}`);
|
|
972
|
+
return out;
|
|
973
|
+
}),
|
|
974
|
+
gate('guideline', 'outline', '创作指引: tone, dialogue, visuals, limits, ≥3 forbidden, 3–5 priorities', ({ project }) => {
|
|
975
|
+
const out = [];
|
|
976
|
+
const g = project.outline?.guideline;
|
|
977
|
+
if (!isObject(g)) return ['outline.guideline is missing — the 创作指引 is re-read before every stage'];
|
|
978
|
+
for (const key of ['tone', 'emotionalIntensity', 'dialogueStyle', 'visualStyle', 'productionLimits']) if (!String(g[key] || '').trim()) out.push(`outline.guideline.${key} is empty`);
|
|
979
|
+
const forbidden = asList(g.forbidden).filter((x) => String(x).trim());
|
|
980
|
+
if (forbidden.length < 3) out.push(`outline.guideline.forbidden has ${forbidden.length} rules, needs at least 3 (what this drama must never do)`);
|
|
981
|
+
const priorities = asList(g.priorities).filter((x) => String(x).trim());
|
|
982
|
+
if (priorities.length < 3 || priorities.length > 5) out.push(`outline.guideline.priorities has ${priorities.length} entries, needs 3–5 不可偏离重点`);
|
|
983
|
+
if (/\{\{|\}\}/.test(JSON.stringify(g))) out.push('outline.guideline still contains {{placeholders}}');
|
|
984
|
+
return out;
|
|
985
|
+
}),
|
|
986
|
+
gate('episode-design', 'outline', '分集目录: every episode has function / task / change / 爽点 / 钩子, and the first three build retention', ({ project, params }) => {
|
|
987
|
+
const out = [];
|
|
988
|
+
const eps = project.outline?.episodes || [];
|
|
989
|
+
const n = Number(params.episodes) || 0;
|
|
990
|
+
const engine = String(project.outline?.brief?.satisfactionEngine || '');
|
|
991
|
+
const fnOf = (e) => asList(e.function).map(String);
|
|
992
|
+
eps.forEach((e) => {
|
|
993
|
+
const where = `ep ${e.ep}`;
|
|
994
|
+
const fns = fnOf(e);
|
|
995
|
+
if (!fns.length) out.push(`${where}: function is empty (${EPISODE_FUNCTIONS.join(' / ')})`);
|
|
996
|
+
for (const f of fns) if (!EPISODE_FUNCTIONS.includes(f)) out.push(`${where}: function "${f}" is not one of ${EPISODE_FUNCTIONS.join(' / ')}`);
|
|
997
|
+
const task = isObject(e.task) ? e.task : {};
|
|
998
|
+
for (const key of ['goal', 'obstacle', 'stake']) if (!String(task[key] || '').trim()) out.push(`${where}: task.${key} is empty (本集任务: 目标 / 阻力 / 代价)`);
|
|
999
|
+
if (!String(e.change || '').trim()) out.push(`${where}: change is empty — 这一集改变了什么? if only a conversation happened, the episode is not strong enough`);
|
|
1000
|
+
const sat = isObject(e.satisfaction) ? e.satisfaction : {};
|
|
1001
|
+
if (!SATISFACTION_TYPES.includes(sat.type)) out.push(`${where}: satisfaction.type must be one of ${SATISFACTION_TYPES.join(' / ')}`);
|
|
1002
|
+
if (!String(sat.action || '').trim()) out.push(`${where}: satisfaction.action is empty — a 爽点 is done by a visible action, not a feeling`);
|
|
1003
|
+
if (!HOOK_TYPES.includes(e.hookType)) out.push(`${where}: hookType must be one of ${HOOK_TYPES.join(' / ')}`);
|
|
1004
|
+
for (const key of ['change']) if (QUOTE_RE.test(String(e[key] || ''))) out.push(`${where}: ${key} contains dialogue quotes`);
|
|
1005
|
+
});
|
|
1006
|
+
const byEp = (k) => eps.find((e) => Number(e.ep) === k);
|
|
1007
|
+
const e1 = byEp(1);
|
|
1008
|
+
if (e1 && fnOf(e1).length && !fnOf(e1).includes('开局钩子')) out.push('ep 1: function must include 开局钩子 — the first episode grabs');
|
|
1009
|
+
if (n >= 3) {
|
|
1010
|
+
const e2 = byEp(2), e3 = byEp(3);
|
|
1011
|
+
if (e2 && engine && e2.satisfaction?.type && !engine.includes(e2.satisfaction.type)) out.push(`ep 2: satisfaction.type "${e2.satisfaction.type}" must be the brief's engine (${engine}) — episode two proves the 爽点`);
|
|
1012
|
+
if (e3 && fnOf(e3).length && !fnOf(e3).some((f) => ['冲突升级', '身份揭露', '大反转'].includes(f))) out.push('ep 3: function must include 冲突升级 / 身份揭露 / 大反转 — episode three widens the main line');
|
|
1013
|
+
}
|
|
1014
|
+
const last = byEp(n);
|
|
1015
|
+
if (last && n > 1 && fnOf(last).length && !fnOf(last).some((f) => ['清算收束', '大反转'].includes(f))) out.push(`ep ${n}: the finale's function must include 清算收束 or 大反转`);
|
|
1016
|
+
let same = 1;
|
|
1017
|
+
for (let i = 1; i < eps.length; i++) {
|
|
1018
|
+
same = eps[i].hookType && eps[i].hookType === eps[i - 1].hookType ? same + 1 : 1;
|
|
1019
|
+
if (same === 4) out.push(`ep ${eps[i].ep}: the same hookType "${eps[i].hookType}" four episodes in a row — vary the hook`);
|
|
1020
|
+
}
|
|
1021
|
+
return out;
|
|
1022
|
+
}),
|
|
1023
|
+
|
|
1024
|
+
// ── characters ────────────────────────────────────────────────────────
|
|
1025
|
+
gate('profile', 'characters', 'profiles complete in the drama language, each with a distinct name-free English identity', ({ project }) => {
|
|
1026
|
+
const out = [];
|
|
1027
|
+
for (const c of project.characters || []) {
|
|
1028
|
+
if (!String(c.oneLiner || '').trim()) out.push(`${c.id}: oneLiner is empty`);
|
|
1029
|
+
const p = c.profile || {};
|
|
1030
|
+
for (const key of ['gender', 'ageRange', 'identity', 'appearance', 'temperament', 'motivation']) {
|
|
1031
|
+
if (!String(p[key] || '').trim()) out.push(`${c.id}: profile.${key} is empty`);
|
|
1032
|
+
}
|
|
1033
|
+
if (!Array.isArray(p.personality) || p.personality.length < 3 || p.personality.length > 5) out.push(`${c.id}: profile.personality needs 3–5 words`);
|
|
1034
|
+
if (!Array.isArray(p.evidence)) out.push(`${c.id}: profile.evidence must be an array (empty is fine)`);
|
|
1035
|
+
// How a prompt says "this person" without saying who they are.
|
|
1036
|
+
const identity = String(c.identityEn || '').trim();
|
|
1037
|
+
if (!identity) out.push(`${c.id}: identityEn is empty — a short English identity with no name ("the woman in her mid thirties"), used wherever a prompt has to attribute a line`);
|
|
1038
|
+
else {
|
|
1039
|
+
if (!isEnglish(identity)) out.push(`${c.id}: identityEn must be English`);
|
|
1040
|
+
if (identity.length > 80) out.push(`${c.id}: identityEn is ${identity.length} chars, max 80`);
|
|
1041
|
+
for (const nm of forbiddenNames(project)) if (containsName(identity, nm)) out.push(`${c.id}: identityEn contains the name "${nm}"`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const seenIdentity = new Map();
|
|
1045
|
+
for (const c of project.characters || []) {
|
|
1046
|
+
const key = String(c.identityEn || '').trim().toLowerCase();
|
|
1047
|
+
if (!key) continue;
|
|
1048
|
+
if (seenIdentity.has(key)) out.push(`${c.id}: identityEn is the same as ${seenIdentity.get(key)}'s — two people a prompt cannot tell apart`);
|
|
1049
|
+
else seenIdentity.set(key, c.id);
|
|
1050
|
+
}
|
|
1051
|
+
return out;
|
|
1052
|
+
}),
|
|
1053
|
+
gate('char-prompts', 'characters', 'image prompts English, no names, ethnicity + era stated', ({ project, params }) => {
|
|
1054
|
+
const out = [];
|
|
1055
|
+
const names = forbiddenNames(project);
|
|
1056
|
+
const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
|
|
1057
|
+
for (const c of project.characters || []) {
|
|
1058
|
+
const img = c.image || {};
|
|
1059
|
+
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
1060
|
+
const text = String(img[key] || '');
|
|
1061
|
+
if (!text.trim()) { out.push(`${c.id}: image.${key} is empty`); continue; }
|
|
1062
|
+
if (!isEnglish(text)) out.push(`${c.id}: image.${key} must be English`);
|
|
1063
|
+
for (const n of names) if (containsName(text, n)) out.push(`${c.id}: image.${key} contains the name "${n}" — describe, don't name`);
|
|
1064
|
+
}
|
|
1065
|
+
if (!Array.isArray(img.tags) || !img.tags.length) out.push(`${c.id}: image.tags is empty`);
|
|
1066
|
+
if (img.prompt && !ETHNICITY_RE.test(img.prompt)) out.push(`${c.id}: image.prompt must state ethnicity / facial heritage`);
|
|
1067
|
+
if (img.prompt && !ERA_RE.test(img.prompt)) out.push(`${c.id}: image.prompt must state the era`);
|
|
1068
|
+
if (img.sheet && !/16:9/.test(img.sheet)) out.push(`${c.id}: image.sheet must specify the 16:9 canvas`);
|
|
1069
|
+
if (img.sheet && !img.sheet.toLowerCase().includes(preset.render.slice(0, 30).toLowerCase())) out.push(`${c.id}: image.sheet must contain the ${params.style} render phrase (see STYLE_PRESETS)`);
|
|
1070
|
+
const neg = String(img.negativePrompt || '').toLowerCase();
|
|
1071
|
+
for (const word of preset.negativeMustNot) if (neg.includes(word)) out.push(`${c.id}: style ${params.style} must not ban "${word}" in the negative prompt`);
|
|
1072
|
+
for (const word of preset.negativeMust) if (!neg.includes(word)) out.push(`${c.id}: style ${params.style} must ban "${word}" in the negative prompt`);
|
|
1073
|
+
}
|
|
1074
|
+
return out;
|
|
1075
|
+
}),
|
|
1076
|
+
gate('voice', 'characters', 'voice: six local fields, a 3–5 s acted sample line, a compact English video direction, prompt ≤ 400 chars, no quoted lines', ({ project, params }) => {
|
|
1077
|
+
const out = [];
|
|
1078
|
+
for (const c of project.characters || []) {
|
|
1079
|
+
const v = c.voice || {};
|
|
1080
|
+
for (const key of ['timbre', 'pitch', 'pace', 'accent', 'emotion']) if (!String(v[key] || '').trim()) out.push(`${c.id}: voice.${key} is empty`);
|
|
1081
|
+
if (params.voiceSamples !== false) {
|
|
1082
|
+
const sample = v.sample || {};
|
|
1083
|
+
const line = String(sample.line || '').trim();
|
|
1084
|
+
const direction = String(sample.direction || '').trim();
|
|
1085
|
+
if (!line) out.push(`${c.id}: voice.sample.line is empty — one short acted line for the voice reference`);
|
|
1086
|
+
else {
|
|
1087
|
+
const [lo, hi] = params.voiceSampleSeconds || [3, 5];
|
|
1088
|
+
const secs = voiceSampleSeconds(c, params);
|
|
1089
|
+
if (secs < lo || secs > hi) out.push(`${c.id}: voice.sample.line runs ≈${round2(secs)} s at ${params.speechCharsPerSecond} chars/s, must be ${lo}–${hi} s (about ${Math.ceil(lo * params.speechCharsPerSecond)}–${Math.floor(hi * params.speechCharsPerSecond)} characters)`);
|
|
1090
|
+
if (QUOTE_RE.test(line)) out.push(`${c.id}: voice.sample.line must not carry quote marks — the engine reads them`);
|
|
1091
|
+
}
|
|
1092
|
+
if (!direction) out.push(`${c.id}: voice.sample.direction is empty — say how it is acted; it becomes the parenthetical the model obeys`);
|
|
1093
|
+
}
|
|
1094
|
+
// The compact form the video prompt puts beside every line this character speaks.
|
|
1095
|
+
const vd = String(v.videoDirection || '').trim();
|
|
1096
|
+
if (!vd) out.push(`${c.id}: voice.videoDirection is empty — one short English clause (how they speak: pace, where the voice sits, what it does at the end of a phrase, accent) that goes beside every line they say in a video prompt`);
|
|
1097
|
+
else {
|
|
1098
|
+
if (!isEnglish(vd)) out.push(`${c.id}: voice.videoDirection must be English — it goes into an English video prompt`);
|
|
1099
|
+
if (vd.length > 160) out.push(`${c.id}: voice.videoDirection is ${vd.length} chars, max 160 — it is repeated on every line, so compress voice.prompt`);
|
|
1100
|
+
if (QUOTED_LINE_RE.test(vd)) out.push(`${c.id}: voice.videoDirection contains a quoted line`);
|
|
1101
|
+
for (const nm of forbiddenNames(project)) if (containsName(vd, nm)) out.push(`${c.id}: voice.videoDirection contains the name "${nm}"`);
|
|
1102
|
+
}
|
|
1103
|
+
const prompt = String(v.prompt || '');
|
|
1104
|
+
if (!prompt.trim()) out.push(`${c.id}: voice.prompt is empty`);
|
|
1105
|
+
else {
|
|
1106
|
+
if (!isEnglish(prompt)) out.push(`${c.id}: voice.prompt must be English`);
|
|
1107
|
+
if (prompt.length > 400) out.push(`${c.id}: voice.prompt is ${prompt.length} chars, max 400 — write a parameter string, not prose`);
|
|
1108
|
+
if (QUOTED_LINE_RE.test(prompt)) out.push(`${c.id}: voice.prompt contains a quoted line — engines read it aloud`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return out;
|
|
1112
|
+
}),
|
|
1113
|
+
gate('distinct', 'characters', 'characters are distinguishable (prompt similarity < 75 %)', ({ project }) => {
|
|
1114
|
+
const out = [];
|
|
1115
|
+
const chars = project.characters || [];
|
|
1116
|
+
for (let i = 0; i < chars.length; i++) {
|
|
1117
|
+
for (let j = i + 1; j < chars.length; j++) {
|
|
1118
|
+
const a = chars[i], b = chars[j];
|
|
1119
|
+
const img = jaccard(a.image?.prompt, b.image?.prompt);
|
|
1120
|
+
const voice = jaccard(a.voice?.prompt, b.voice?.prompt);
|
|
1121
|
+
if (img > 0.75) out.push(`${a.id} and ${b.id}: image prompts are ${Math.round(img * 100)}% alike — differentiate face, build, clothing`);
|
|
1122
|
+
if (voice > 0.75) out.push(`${a.id} and ${b.id}: voice prompts are ${Math.round(voice * 100)}% alike`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return out;
|
|
1126
|
+
}),
|
|
1127
|
+
|
|
1128
|
+
gate('bible', 'characters', '人物圣经: function, hook line, want ≠ need, misbelief, contradiction, speech, arc — by tier', ({ project, params }) => {
|
|
1129
|
+
const out = [];
|
|
1130
|
+
const n = Number(params.episodes) || 0;
|
|
1131
|
+
for (const c of project.characters || []) {
|
|
1132
|
+
const b = isObject(c.bible) ? c.bible : null;
|
|
1133
|
+
if (!b) { out.push(`${c.id}: bible is missing (剧情功能 at least; the full 人物圣经 for leads and support)`); continue; }
|
|
1134
|
+
const need = (key, label = key) => { if (!String(b[key] || '').trim()) out.push(`${c.id}: bible.${label} is empty`); };
|
|
1135
|
+
if (!CHARACTER_FUNCTIONS.includes(b.function)) out.push(`${c.id}: bible.function must be one of ${CHARACTER_FUNCTIONS.join(' / ')}`);
|
|
1136
|
+
if (c.tier === 'functional') continue;
|
|
1137
|
+
for (const key of ['hookLine', 'want', 'need', 'misbelief', 'contradiction', 'pressureResponse']) need(key);
|
|
1138
|
+
const speech = isObject(b.speech) ? b.speech : {};
|
|
1139
|
+
for (const key of ['rhythm', 'silence']) if (!String(speech[key] || '').trim()) out.push(`${c.id}: bible.speech.${key} is empty (说话节奏 / 沉默方式 — the dialogue is written from this)`);
|
|
1140
|
+
if (speech.register && !REGISTERS.includes(normalizeRegister(speech.register))) out.push(`${c.id}: bible.speech.register "${speech.register}" is not one of ${REGISTERS.join(' / ')}`);
|
|
1141
|
+
const want = String(b.want || '').replace(/\s+/g, ''), needV = String(b.need || '').replace(/\s+/g, '');
|
|
1142
|
+
if (want && want === needV) out.push(`${c.id}: bible.want and bible.need are the same sentence — Want is the goal they chase, Need is the truth they must face`);
|
|
1143
|
+
if (c.tier !== 'lead') continue;
|
|
1144
|
+
for (const key of ['wound', 'firstImpression', 'finalUnderstanding']) need(key);
|
|
1145
|
+
const choices = asList(b.pastChoices);
|
|
1146
|
+
if (choices.length < 2) out.push(`${c.id}: bible.pastChoices needs at least 2 past choices (when / pressure / choice / gained / lost / residue)`);
|
|
1147
|
+
choices.forEach((ch, i) => { for (const key of ['when', 'pressure', 'choice', 'gained', 'lost', 'residue']) if (!String(ch?.[key] || '').trim()) out.push(`${c.id}: bible.pastChoices[${i}].${key} is empty`); });
|
|
1148
|
+
for (const key of ['forbidden', 'mustFace']) {
|
|
1149
|
+
const items = asList(b[key]).map((x) => String(x).trim());
|
|
1150
|
+
if (items.length !== 3) out.push(`${c.id}: bible.${key} needs exactly 3 entries (got ${items.length})`);
|
|
1151
|
+
for (const it of items) if (it.length < 6) out.push(`${c.id}: bible.${key} entry "${it}" is too short to be a rule`);
|
|
1152
|
+
}
|
|
1153
|
+
const arc = asList(b.arc);
|
|
1154
|
+
if (arc.length < 3) out.push(`${c.id}: bible.arc needs at least 3 stages (${ARC_STAGES.join(' / ')})`);
|
|
1155
|
+
arc.forEach((st, i) => {
|
|
1156
|
+
if (!ARC_STAGES.includes(st?.stage)) out.push(`${c.id}: bible.arc[${i}].stage must be one of ${ARC_STAGES.join(' / ')}`);
|
|
1157
|
+
if (!(Number(st?.episode) >= 1 && Number(st?.episode) <= n)) out.push(`${c.id}: bible.arc[${i}].episode ${st?.episode} is outside 1–${n}`);
|
|
1158
|
+
for (const key of ['state', 'change']) if (!String(st?.[key] || '').trim()) out.push(`${c.id}: bible.arc[${i}].${key} is empty`);
|
|
1159
|
+
});
|
|
1160
|
+
if (arc.length && !arc.some((st) => st?.stage === '高潮选择')) out.push(`${c.id}: bible.arc has no 高潮选择 stage — where Want and Need collide`);
|
|
1161
|
+
}
|
|
1162
|
+
return out;
|
|
1163
|
+
}),
|
|
1164
|
+
gate('relationships', 'characters', '人物关系网: leads have relationships with surface / real / power; a secret somewhere in the cast', ({ project, params }) => {
|
|
1165
|
+
const out = [];
|
|
1166
|
+
const n = Number(params.episodes) || 0;
|
|
1167
|
+
const ids = new Set((project.characters || []).map((c) => c.id));
|
|
1168
|
+
let secrets = 0;
|
|
1169
|
+
for (const c of project.characters || []) {
|
|
1170
|
+
const rels = asList(c.profile?.relationships);
|
|
1171
|
+
rels.forEach((r, i) => {
|
|
1172
|
+
if (r?.id && !ids.has(r.id)) out.push(`${c.id}: relationships[${i}].id ${r.id} is not a character`);
|
|
1173
|
+
if (r?.turnEpisode != null && !(Number(r.turnEpisode) >= 1 && Number(r.turnEpisode) <= n)) out.push(`${c.id}: relationships[${i}].turnEpisode ${r.turnEpisode} is outside 1–${n}`);
|
|
1174
|
+
if (String(r?.secret || '').trim()) secrets += 1;
|
|
1175
|
+
});
|
|
1176
|
+
if (c.tier !== 'lead') continue;
|
|
1177
|
+
const full = rels.filter((r) => r?.id && ids.has(r.id) && ['surface', 'real', 'power'].every((k) => String(r[k] || '').trim()));
|
|
1178
|
+
if (!full.length) out.push(`${c.id}: a lead needs at least one relationship with id + surface (表面关系) + real (真正关系) + power (权力差)`);
|
|
1179
|
+
}
|
|
1180
|
+
if ((project.characters || []).some((c) => c.tier === 'lead') && !secrets) out.push('no relationship in the cast carries a secret — 秘密与谎言 is where the reversals come from');
|
|
1181
|
+
return out;
|
|
1182
|
+
}),
|
|
1183
|
+
|
|
1184
|
+
// ── art ───────────────────────────────────────────────────────────────
|
|
1185
|
+
gate('scene-design', 'art', 'scenes: intent, 3–5 anchors, ≥1 English lighting state', ({ project }) => {
|
|
1186
|
+
const out = [];
|
|
1187
|
+
for (const s of project.scenes || []) {
|
|
1188
|
+
if (!String(s.summary || '').trim()) out.push(`${s.id}: summary (design intent) is empty`);
|
|
1189
|
+
const anchors = s.anchors || [];
|
|
1190
|
+
if (anchors.length < 3 || anchors.length > 5) out.push(`${s.id}: ${anchors.length} anchors, need 3–5`);
|
|
1191
|
+
for (const a of anchors) if (!a.name || !a.desc) out.push(`${s.id}: every anchor needs name + desc`);
|
|
1192
|
+
const lighting = s.lighting || [];
|
|
1193
|
+
if (!lighting.length) out.push(`${s.id}: at least one lighting state is required`);
|
|
1194
|
+
for (const l of lighting) {
|
|
1195
|
+
if (!l.state || !l.prompt) out.push(`${s.id}: every lighting state needs state + prompt`);
|
|
1196
|
+
else if (!isEnglish(l.prompt)) out.push(`${s.id}: lighting "${l.state}" prompt must be English`);
|
|
1197
|
+
}
|
|
1198
|
+
if (s.variantOf) {
|
|
1199
|
+
if (!sceneById(project, s.variantOf)) out.push(`${s.id}: variantOf ${s.variantOf} does not exist`);
|
|
1200
|
+
if (!String(s.changes || '').trim()) out.push(`${s.id}: variantOf needs changes (what differs from the parent)`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return out;
|
|
1204
|
+
}),
|
|
1205
|
+
gate('scene-prompts', 'art', 'scene prompts English, empty of people, no names, style-consistent', ({ project, params }) => {
|
|
1206
|
+
const out = [];
|
|
1207
|
+
const names = forbiddenNames(project);
|
|
1208
|
+
const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
|
|
1209
|
+
for (const s of project.scenes || []) {
|
|
1210
|
+
const img = s.image || {};
|
|
1211
|
+
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
1212
|
+
const text = String(img[key] || '');
|
|
1213
|
+
if (!text.trim()) { out.push(`${s.id}: image.${key} is empty`); continue; }
|
|
1214
|
+
if (!isEnglish(text)) out.push(`${s.id}: image.${key} must be English`);
|
|
1215
|
+
for (const n of names) if (containsName(text, n)) out.push(`${s.id}: image.${key} contains the character name "${n}"`);
|
|
1216
|
+
}
|
|
1217
|
+
const neg = String(img.negativePrompt || '').toLowerCase();
|
|
1218
|
+
if (neg && !/(people|person|figure|human|man|woman|crowd)/.test(neg)) out.push(`${s.id}: negativePrompt must ban people — environments are a separate asset layer`);
|
|
1219
|
+
if (img.prompt && !/(no people|empty|unpopulated|nobody|no one|no human)/i.test(img.prompt)) out.push(`${s.id}: image.prompt must say the scene is empty (no people)`);
|
|
1220
|
+
if (img.sheet && !img.sheet.toLowerCase().includes(preset.render.slice(0, 30).toLowerCase())) out.push(`${s.id}: image.sheet must contain the ${params.style} render phrase`);
|
|
1221
|
+
}
|
|
1222
|
+
return out;
|
|
1223
|
+
}),
|
|
1224
|
+
gate('prop-design', 'art', 'props: scale phrase, 3–5 anchors, ≥1 state, white background, no hands', ({ project }) => {
|
|
1225
|
+
const out = [];
|
|
1226
|
+
const names = forbiddenNames(project);
|
|
1227
|
+
for (const p of project.props || []) {
|
|
1228
|
+
if (!PROP_SCALES[p.scale]) out.push(`${p.id}: scale must be one of ${Object.keys(PROP_SCALES).join(' / ')}`);
|
|
1229
|
+
if (!String(p.summary || '').trim()) out.push(`${p.id}: summary (dramatic function) is empty`);
|
|
1230
|
+
const anchors = p.anchors || [];
|
|
1231
|
+
if (anchors.length < 3 || anchors.length > 5) out.push(`${p.id}: ${anchors.length} anchors, need 3–5`);
|
|
1232
|
+
const states = p.states || [];
|
|
1233
|
+
if (!states.length) out.push(`${p.id}: at least one state is required`);
|
|
1234
|
+
for (const st of states) {
|
|
1235
|
+
if (!st.state || !st.prompt) out.push(`${p.id}: every state needs state + prompt`);
|
|
1236
|
+
else if (!isEnglish(st.prompt)) out.push(`${p.id}: state "${st.state}" prompt must be English`);
|
|
1237
|
+
}
|
|
1238
|
+
for (const id of p.relatedScenes || []) if (!sceneById(project, id)) out.push(`${p.id}: relatedScenes references unknown scene ${id}`);
|
|
1239
|
+
const img = p.image || {};
|
|
1240
|
+
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
1241
|
+
const text = String(img[key] || '');
|
|
1242
|
+
if (!text.trim()) { out.push(`${p.id}: image.${key} is empty`); continue; }
|
|
1243
|
+
if (!isEnglish(text)) out.push(`${p.id}: image.${key} must be English`);
|
|
1244
|
+
for (const n of names) if (containsName(text, n)) out.push(`${p.id}: image.${key} contains the character name "${n}"`);
|
|
1245
|
+
}
|
|
1246
|
+
const phrase = PROP_SCALES[p.scale];
|
|
1247
|
+
if (phrase) {
|
|
1248
|
+
for (const key of ['prompt', 'sheet']) if (img[key] && !img[key].toLowerCase().includes(phrase)) out.push(`${p.id}: image.${key} must contain "${phrase}"`);
|
|
1249
|
+
}
|
|
1250
|
+
const neg = String(img.negativePrompt || '').toLowerCase();
|
|
1251
|
+
if (neg && !/(hand|finger)/.test(neg)) out.push(`${p.id}: negativePrompt must ban hands — a hand holding the prop is the usual contamination`);
|
|
1252
|
+
if (neg && !/(people|person|figure|human)/.test(neg)) out.push(`${p.id}: negativePrompt must ban people`);
|
|
1253
|
+
for (const key of ['prompt', 'sheet']) if (img[key] && !/white background/i.test(img[key])) out.push(`${p.id}: image.${key} must specify a pure white background`);
|
|
1254
|
+
}
|
|
1255
|
+
return out;
|
|
1256
|
+
}),
|
|
1257
|
+
|
|
1258
|
+
// ── script (per episode) ──────────────────────────────────────────────
|
|
1259
|
+
gate('script-shape', 'script', 'episode file shape and upstream references', (ctx) => {
|
|
1260
|
+
const { project } = ctx;
|
|
1261
|
+
const out = [];
|
|
1262
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1263
|
+
if (Number(e.ep) !== Number(ep)) out.push(`E${pad2(ep)}: ep field is ${e.ep}`);
|
|
1264
|
+
if (!(Number(e.targetSeconds) > 0)) out.push(`E${pad2(ep)}: targetSeconds missing (run seed script)`);
|
|
1265
|
+
if (!String(e.hook || '').trim()) out.push(`E${pad2(ep)}: hook is empty`);
|
|
1266
|
+
if (!String(e.cliff || '').trim()) out.push(`E${pad2(ep)}: cliff is empty`);
|
|
1267
|
+
if (!Array.isArray(e.beatsClaimed)) out.push(`E${pad2(ep)}: beatsClaimed must be an array`);
|
|
1268
|
+
if (!Array.isArray(e.scenes) || !e.scenes.length) { out.push(`E${pad2(ep)}: scenes is empty`); continue; }
|
|
1269
|
+
e.scenes.forEach((sc, i) => {
|
|
1270
|
+
const scene = sceneById(project, sc.sceneId);
|
|
1271
|
+
const where = `E${pad2(ep)} scene ${i + 1}`;
|
|
1272
|
+
if (!scene) out.push(`${where}: unknown scene ${sc.sceneId}`);
|
|
1273
|
+
else if (sc.lighting && !(scene.lighting || []).some((l) => l.state === sc.lighting)) out.push(`${where}: lighting "${sc.lighting}" is not a state of ${sc.sceneId} — add it to the scene first`);
|
|
1274
|
+
for (const id of sc.characters || []) if (!characterById(project, id)) out.push(`${where}: unknown character ${id}`);
|
|
1275
|
+
for (const id of sc.props || []) if (!propById(project, id)) out.push(`${where}: unknown prop ${id}`);
|
|
1276
|
+
if (!Array.isArray(sc.flow) || !sc.flow.length) out.push(`${where}: flow is empty`);
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
return out;
|
|
1280
|
+
}),
|
|
1281
|
+
gate('beat-flow', 'script', 'beats: action ⇄ line, one kind each, no quotes in actions, speakers legal, line ≤ N chars', (ctx) => {
|
|
1282
|
+
const { params } = ctx;
|
|
1283
|
+
const out = [];
|
|
1284
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1285
|
+
(e.scenes || []).forEach((sc, i) => {
|
|
1286
|
+
const where = `E${pad2(ep)} scene ${i + 1}`;
|
|
1287
|
+
let actions = 0;
|
|
1288
|
+
(sc.flow || []).forEach((b, k) => {
|
|
1289
|
+
const isAction = b.action !== undefined;
|
|
1290
|
+
const isLine = b.line !== undefined || b.speaker !== undefined;
|
|
1291
|
+
if (isAction && isLine) out.push(`${where} beat ${k + 1}: a beat is either an action or a line, not both`);
|
|
1292
|
+
if (!isAction && !isLine) out.push(`${where} beat ${k + 1}: empty beat`);
|
|
1293
|
+
if (isAction) {
|
|
1294
|
+
actions += 1;
|
|
1295
|
+
if (!String(b.action).trim()) out.push(`${where} beat ${k + 1}: action is empty`);
|
|
1296
|
+
if (QUOTE_RE.test(b.action)) out.push(`${where} beat ${k + 1}: action contains dialogue quotes — make it a line beat`);
|
|
1297
|
+
}
|
|
1298
|
+
if (isLine) {
|
|
1299
|
+
if (!String(b.line || '').trim()) out.push(`${where} beat ${k + 1}: line is empty`);
|
|
1300
|
+
if (nonWhitespace(b.line) > params.maxLineChars) out.push(`${where} beat ${k + 1}: line has ${nonWhitespace(b.line)} chars, max ${params.maxLineChars}`);
|
|
1301
|
+
if (b.speaker !== 'VO' && !(sc.characters || []).includes(b.speaker)) out.push(`${where} beat ${k + 1}: speaker ${b.speaker} is not in this scene (or VO)`);
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
if ((sc.flow || []).length && actions === 0) out.push(`${where}: no action beat — a scene of pure dialogue is a radio play`);
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
return out;
|
|
1308
|
+
}),
|
|
1309
|
+
gate('duration', 'script', 'episode duration within target ± tolerance', (ctx) => {
|
|
1310
|
+
const out = [];
|
|
1311
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1312
|
+
const est = episodeSeconds(e, ctx.params);
|
|
1313
|
+
const target = Number(e.targetSeconds) || 0;
|
|
1314
|
+
if (!target) continue;
|
|
1315
|
+
const lo = target * (1 - ctx.params.tolerance), hi = target * (1 + ctx.params.tolerance);
|
|
1316
|
+
if (est < lo || est > hi) out.push(`E${pad2(ep)}: estimated ${round2(est)} s, target ${target} s (allowed ${round2(lo)}–${round2(hi)})`);
|
|
1317
|
+
}
|
|
1318
|
+
return out;
|
|
1319
|
+
}),
|
|
1320
|
+
gate('hook-cliff', 'script', 'hook lands within the first N beats; last beat is the cliff', (ctx) => {
|
|
1321
|
+
const { params } = ctx;
|
|
1322
|
+
const out = [];
|
|
1323
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1324
|
+
const hb = e.hookBeat;
|
|
1325
|
+
if (!Array.isArray(hb) || hb.length !== 2) { out.push(`E${pad2(ep)}: hookBeat must be [sceneIndex, beatIndex]`); continue; }
|
|
1326
|
+
const [si, bi] = hb.map(Number);
|
|
1327
|
+
const scene = (e.scenes || [])[si - 1];
|
|
1328
|
+
if (!scene || !(scene.flow || [])[bi - 1]) { out.push(`E${pad2(ep)}: hookBeat [${si}, ${bi}] does not point at a beat`); continue; }
|
|
1329
|
+
let offset = 0;
|
|
1330
|
+
for (let i = 0; i < si - 1; i++) offset += ((e.scenes[i] || {}).flow || []).length;
|
|
1331
|
+
const position = offset + bi;
|
|
1332
|
+
if (position > params.hookWindow) out.push(`E${pad2(ep)}: hook lands at beat ${position}, must be within the first ${params.hookWindow} (cold open)`);
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
1335
|
+
}),
|
|
1336
|
+
gate('beats-claimed', 'script', 'claimed beats are exactly the outline beats of this episode', (ctx) => {
|
|
1337
|
+
const out = [];
|
|
1338
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1339
|
+
const expected = beatsOfEpisode(ctx.project, ep).map((b) => b.id).sort();
|
|
1340
|
+
const claimed = [...(e.beatsClaimed || [])].sort();
|
|
1341
|
+
const missing = expected.filter((id) => !claimed.includes(id));
|
|
1342
|
+
const extra = claimed.filter((id) => !expected.includes(id));
|
|
1343
|
+
if (missing.length) out.push(`E${pad2(ep)}: outline beats not claimed: ${missing.join(', ')}`);
|
|
1344
|
+
if (extra.length) out.push(`E${pad2(ep)}: beatsClaimed lists beats that are not in this episode: ${extra.join(', ')}`);
|
|
1345
|
+
}
|
|
1346
|
+
return out;
|
|
1347
|
+
}),
|
|
1348
|
+
|
|
1349
|
+
gate('script-design', 'script', 'episode design carried into the script: function / task / change / hookType match the outline, 爽点 lands on an action', (ctx) => {
|
|
1350
|
+
const { project } = ctx;
|
|
1351
|
+
const out = [];
|
|
1352
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1353
|
+
const E = `E${pad2(ep)}`;
|
|
1354
|
+
const outlineEp = (project.outline?.episodes || []).find((x) => Number(x.ep) === Number(ep)) || {};
|
|
1355
|
+
const fns = asList(e.function).map(String), want = asList(outlineEp.function).map(String);
|
|
1356
|
+
if (!fns.length) out.push(`${E}: function is empty (seed script copies it from the outline)`);
|
|
1357
|
+
else if (want.length && fns.join('|') !== want.join('|')) out.push(`${E}: function [${fns.join(', ')}] differs from the outline's [${want.join(', ')}] — change the outline first`);
|
|
1358
|
+
if (e.hookType && outlineEp.hookType && e.hookType !== outlineEp.hookType) out.push(`${E}: hookType ${e.hookType} differs from the outline's ${outlineEp.hookType}`);
|
|
1359
|
+
if (!HOOK_TYPES.includes(e.hookType)) out.push(`${E}: hookType must be one of ${HOOK_TYPES.join(' / ')}`);
|
|
1360
|
+
const task = isObject(e.task) ? e.task : {};
|
|
1361
|
+
for (const key of ['goal', 'obstacle', 'stake']) if (!String(task[key] || '').trim()) out.push(`${E}: task.${key} is empty`);
|
|
1362
|
+
if (!String(e.change || '').trim()) out.push(`${E}: change is empty — what is different when this episode ends?`);
|
|
1363
|
+
const sat = e.satisfactionBeat;
|
|
1364
|
+
const beat = beatAt(e, sat);
|
|
1365
|
+
if (!beat) { out.push(`${E}: satisfactionBeat must point at a beat [sceneIndex, beatIndex] — the 爽点 has to be on screen`); continue; }
|
|
1366
|
+
if (beat.line !== undefined) {
|
|
1367
|
+
const [si, bi] = sat.map(Number);
|
|
1368
|
+
const flow = (e.scenes[si - 1] || {}).flow || [];
|
|
1369
|
+
const near = [flow[bi - 2], flow[bi]].some((b) => b && b.action !== undefined);
|
|
1370
|
+
if (!near) out.push(`${E}: satisfactionBeat [${si}, ${bi}] is a line with no action beat beside it — a 爽点 is done by a visible action`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return out;
|
|
1374
|
+
}),
|
|
1375
|
+
gate('rhythm', 'script', '短剧节奏: hook by 15 s, a turn every 30 s, 爽点 between hook and cliff, no 4 lines in a row, action ≥ 35 % of lines', (ctx) => {
|
|
1376
|
+
const { params } = ctx;
|
|
1377
|
+
const out = [];
|
|
1378
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1379
|
+
const E = `E${pad2(ep)}`;
|
|
1380
|
+
const offsets = beatOffsets(e, params);
|
|
1381
|
+
if (!offsets.length) continue;
|
|
1382
|
+
const total = offsets[offsets.length - 1].end;
|
|
1383
|
+
const hook = offsets.find((o) => Array.isArray(e.hookBeat) && o.si === Number(e.hookBeat[0]) && o.bi === Number(e.hookBeat[1]));
|
|
1384
|
+
if (hook && hook.start > params.hookSeconds + 1e-9) out.push(`${E}: the hook image lands at ${round2(hook.start)} s, must be on screen by ${params.hookSeconds} s`);
|
|
1385
|
+
const points = turnPoints(e, params);
|
|
1386
|
+
for (let i = 1; i < points.length; i++) {
|
|
1387
|
+
const gap = points[i].at - points[i - 1].at;
|
|
1388
|
+
if (gap > params.turnSeconds + 1e-9) out.push(`${E}: ${round2(gap)} s from ${points[i - 1].what} (${round2(points[i - 1].at)} s) to ${points[i].what} (${round2(points[i].at)} s) with no turn — mark a beat that brings new information, an obstacle, a counter or a choice with \`turn: true\` (max ${params.turnSeconds} s)`);
|
|
1389
|
+
}
|
|
1390
|
+
const sat = offsets.find((o) => Array.isArray(e.satisfactionBeat) && o.si === Number(e.satisfactionBeat[0]) && o.bi === Number(e.satisfactionBeat[1]));
|
|
1391
|
+
if (sat) {
|
|
1392
|
+
if (hook && sat.start <= hook.start) out.push(`${E}: the 爽点 lands before or on the hook — the hook opens, the 爽点 pays`);
|
|
1393
|
+
if (sat.end >= total - 1e-9) out.push(`${E}: the 爽点 is the last beat — the last beat is the cliff`);
|
|
1394
|
+
}
|
|
1395
|
+
const ta = talkAndAction(e);
|
|
1396
|
+
if (ta.longestLineRun > params.maxConsecutiveLines) out.push(`${E}: ${ta.longestLineRun} line beats in a row — insert an action (谈话+行动) at least every ${params.maxConsecutiveLines} lines`);
|
|
1397
|
+
if (ta.lines >= 5 && ta.ratio < params.minActionRatio) out.push(`${E}: ${ta.actions} action beats to ${ta.lines} lines (${Math.round(ta.ratio * 100)} %) — dialogue needs visible action alongside it (≥ ${Math.round(params.minActionRatio * 100)} %)`);
|
|
1398
|
+
}
|
|
1399
|
+
return out;
|
|
1400
|
+
}),
|
|
1401
|
+
gate('filmable', 'script', '可拍摄: action beats carry no thoughts, memories or inferred states', (ctx) => {
|
|
1402
|
+
const out = [];
|
|
1403
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1404
|
+
(e.scenes || []).forEach((sc, i) => {
|
|
1405
|
+
(sc.flow || []).forEach((b, k) => {
|
|
1406
|
+
if (b.action === undefined) return;
|
|
1407
|
+
const where = `E${pad2(ep)} scene ${i + 1} beat ${k + 1}`;
|
|
1408
|
+
const text = String(b.action);
|
|
1409
|
+
const hits = NOVELISTIC_WORDS.filter((w) => text.includes(w));
|
|
1410
|
+
if (hits.length) out.push(`${where}: "${hits.join('、')}" is novel prose, not a picture — write what the camera sees`);
|
|
1411
|
+
const inferred = INFERENCE_WORDS.filter((w) => (CJK_RE.test(w) ? text.includes(w) : new RegExp(`\\b${w}\\b`, 'i').test(text)));
|
|
1412
|
+
if (inferred.length) out.push(`${where}: "${inferred.join(' / ')}" infers an inner state — replace it with a visible action`);
|
|
1413
|
+
const hands = CJK_RE.test(text) ? HAND_VERBS_ZH.filter((v) => text.includes(v)).length : (text.toLowerCase().match(/[a-z]+/g) || []).filter((w) => HAND_VERBS_EN.includes(w)).length;
|
|
1414
|
+
if (hands > 3) out.push(`${where}: ${hands} hand verbs in one beat — a video model performs one or two; split the beat or hand the change to an object`);
|
|
1415
|
+
});
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
return out;
|
|
1419
|
+
}),
|
|
1420
|
+
gate('dialogue-register', 'script', '语体: lines match the drama’s register (advisory — a 穿越 character may break it on purpose)', (ctx) => {
|
|
1421
|
+
const { project, params } = ctx;
|
|
1422
|
+
const out = [];
|
|
1423
|
+
const names = forbiddenNames(project);
|
|
1424
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1425
|
+
(e.scenes || []).forEach((sc, i) => {
|
|
1426
|
+
let cantonese = 0, total = 0;
|
|
1427
|
+
const calls = new Map();
|
|
1428
|
+
(sc.flow || []).forEach((b, k) => {
|
|
1429
|
+
if (b.line === undefined) return;
|
|
1430
|
+
const where = `E${pad2(ep)} scene ${i + 1} beat ${k + 1}`;
|
|
1431
|
+
const ch = characterById(project, b.speaker);
|
|
1432
|
+
const register = normalizeRegister(ch?.bible?.speech?.register || params.register);
|
|
1433
|
+
const line = String(b.line);
|
|
1434
|
+
total += 1;
|
|
1435
|
+
const marks = CANTONESE_MARKERS.filter((m) => line.includes(m));
|
|
1436
|
+
if (marks.length) cantonese += 1;
|
|
1437
|
+
if (register && register !== '粤语' && marks.length) out.push(`${where}: "${truncateLine(line)}" has Cantonese ${marks.join('')} in a ${register} drama`);
|
|
1438
|
+
if (['古装', '年代'].includes(register)) {
|
|
1439
|
+
const modern = MODERN_WORDS.filter((w) => line.includes(w));
|
|
1440
|
+
if (modern.length) out.push(`${where}: "${modern.join('、')}" is modern speech in a ${register} line`);
|
|
1441
|
+
}
|
|
1442
|
+
if (['古装', '年代', '大陆普通话', '台湾国语', '港式普通话'].includes(register)) {
|
|
1443
|
+
const slang = NETSLANG.filter((w) => line.toLowerCase().includes(w.toLowerCase()));
|
|
1444
|
+
const age = parseAge(ch?.profile?.ageRange);
|
|
1445
|
+
if (slang.length && (register === '古装' || register === '年代' || (age != null && age >= 40))) out.push(`${where}: 网络用语 "${slang.join('、')}" on ${ch?.name || b.speaker}${age != null ? ` (${age})` : ''} in ${register}`);
|
|
1446
|
+
}
|
|
1447
|
+
if (isEnglishRegister(register)) {
|
|
1448
|
+
const words = wordCount(line);
|
|
1449
|
+
if (words > params.maxLineWords) out.push(`${where}: ${words} words — English screen lines run 7–12, over ${params.maxLineWords} needs a reason`);
|
|
1450
|
+
if (/\b(I am|do not|cannot|going to|it is|you are|we are|they are|did not|is not|are not|will not)\b/.test(line)) out.push(`${where}: uncontracted "${line.match(/\b(I am|do not|cannot|going to|it is|you are|we are|they are|did not|is not|are not|will not)\b/)[0]}" — contractions are the default; leave it only for emphasis or threat`);
|
|
1451
|
+
for (const nm of names) if (nm !== ch?.name && containsName(line, nm)) calls.set(nm, (calls.get(nm) || 0) + 1);
|
|
1452
|
+
}
|
|
1453
|
+
const delivery = String(b.delivery || '').trim();
|
|
1454
|
+
if (delivery && EMOTION_ONLY.includes(delivery)) out.push(`${where}: delivery "${delivery}" is an emotion, not a playable action — 逼 / 哄 / 避 / 试探 / 压着嗓子`);
|
|
1455
|
+
});
|
|
1456
|
+
const register = normalizeRegister(params.register);
|
|
1457
|
+
if (register === '粤语' && total >= 4 && cantonese * 2 < total) out.push(`E${pad2(ep)} scene ${i + 1}: only ${cantonese} of ${total} lines read as Cantonese — 书面中文硬转 (written Chinese with a few 啦 is not 粤语)`);
|
|
1458
|
+
for (const [nm, count] of calls) if (count > params.maxNameCallsPerScene) out.push(`E${pad2(ep)} scene ${i + 1}: "${nm}" is addressed by name ${count} times — in English a name is a heavy word; ${params.maxNameCallsPerScene} per scene`);
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
return out;
|
|
1462
|
+
}, { advisory: true }),
|
|
1463
|
+
|
|
1464
|
+
// ── storyboard (per episode) ──────────────────────────────────────────
|
|
1465
|
+
gate('segments', 'storyboard', 'segments: ids sequential, one scene each, cuts claim every beat once in order', (ctx) => {
|
|
1466
|
+
const out = [];
|
|
1467
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1468
|
+
const segs = e.storyboard?.segments || [];
|
|
1469
|
+
if (!segs.length) { out.push(`E${pad2(ep)}: no segments`); continue; }
|
|
1470
|
+
const claims = new Map(); // sceneIndex -> [[from,to]...]
|
|
1471
|
+
segs.forEach((seg, i) => {
|
|
1472
|
+
const expectId = `E${pad2(ep)}-${pad2(i + 1)}`;
|
|
1473
|
+
if (seg.id !== expectId) out.push(`segment ${seg.id || '?'}: expected id ${expectId} (sequential in file order)`);
|
|
1474
|
+
const si = Number(seg.sceneIndex);
|
|
1475
|
+
const scene = (e.scenes || [])[si - 1];
|
|
1476
|
+
if (!scene) { out.push(`${seg.id}: sceneIndex ${seg.sceneIndex} does not exist`); return; }
|
|
1477
|
+
if (!Array.isArray(seg.cuts) || !seg.cuts.length) { out.push(`${seg.id}: cuts is empty`); return; }
|
|
1478
|
+
for (const c of seg.cuts) {
|
|
1479
|
+
if (!Array.isArray(c.beats) || c.beats.length !== 2) { out.push(`${seg.id}: every cut needs beats [from, to]`); continue; }
|
|
1480
|
+
if (!claims.has(si)) claims.set(si, []);
|
|
1481
|
+
claims.get(si).push([Number(c.beats[0]), Number(c.beats[1])]);
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
(e.scenes || []).forEach((sc, i) => {
|
|
1485
|
+
const n = (sc.flow || []).length;
|
|
1486
|
+
const ranges = claims.get(i + 1) || [];
|
|
1487
|
+
let expect = 1;
|
|
1488
|
+
for (const [from, to] of ranges) {
|
|
1489
|
+
if (from !== expect) { out.push(`E${pad2(ep)} scene ${i + 1}: beat ${expect} is ${from > expect ? 'unclaimed' : 'claimed twice'} (cut claims [${from}, ${to}])`); }
|
|
1490
|
+
if (to < from) out.push(`E${pad2(ep)} scene ${i + 1}: cut range [${from}, ${to}] is reversed`);
|
|
1491
|
+
expect = Math.max(expect, to + 1);
|
|
1492
|
+
}
|
|
1493
|
+
if (expect - 1 < n) out.push(`E${pad2(ep)} scene ${i + 1}: beats ${expect}–${n} are unclaimed`);
|
|
1494
|
+
if (expect - 1 > n) out.push(`E${pad2(ep)} scene ${i + 1}: claims run past the last beat (${n})`);
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
return out;
|
|
1498
|
+
}),
|
|
1499
|
+
gate('timing', 'storyboard', 'cut 2–5 s, dialogue fits, segment inside the model\u2019s duration range, reference caps, episode within target ± tolerance', (ctx) => {
|
|
1500
|
+
const { project, params } = ctx;
|
|
1501
|
+
const out = [];
|
|
1502
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1503
|
+
let total = 0;
|
|
1504
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1505
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1];
|
|
1506
|
+
let segSeconds = 0;
|
|
1507
|
+
(seg.cuts || []).forEach((c, k) => {
|
|
1508
|
+
const s = Number(c.seconds);
|
|
1509
|
+
if (!(s >= params.minCutSeconds && s <= params.maxCutSeconds)) out.push(`${seg.id} cut ${k + 1}: ${c.seconds} s, must be ${params.minCutSeconds}–${params.maxCutSeconds}`);
|
|
1510
|
+
segSeconds += s || 0;
|
|
1511
|
+
if (scene && Array.isArray(c.beats)) {
|
|
1512
|
+
const talk = dialogueSeconds(scene, c.beats, params);
|
|
1513
|
+
// A line needs a beat to open the mouth before it and lands after it; the lead is charged here.
|
|
1514
|
+
const lead = talk ? Number(params.speechLeadSeconds) || 0 : 0;
|
|
1515
|
+
if (talk + lead > s + 1e-9) out.push(`${seg.id} cut ${k + 1}: ${round2(talk)} s of dialogue plus a ${lead} s lead-in does not fit in ${s} s`);
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
const mode = videoModel(params);
|
|
1519
|
+
// The model's duration range is what a segment must fit — this is the rule that decides where the plot is cut.
|
|
1520
|
+
if (!(segSeconds >= params.minSegmentSeconds && segSeconds <= params.maxSegmentSeconds)) out.push(`${seg.id}: ${round2(segSeconds)} s total, must be ${params.minSegmentSeconds}–${params.maxSegmentSeconds} s (${mode.model})`);
|
|
1521
|
+
if (params.videoDuration === 'fixed' && !Number.isInteger(round2(segSeconds))) out.push(`${seg.id}: ${round2(segSeconds)} s \u2014 ${mode.model} only takes whole seconds; make the cuts sum to an integer`);
|
|
1522
|
+
if (params.references === 'keyframes' && (seg.cuts || []).length > mode.maxRefs) out.push(`${seg.id}: ${(seg.cuts || []).length} keyframes, but ${mode.model} takes at most ${mode.maxRefs} references`);
|
|
1523
|
+
// Voices are audio references and have their own, much smaller cap.
|
|
1524
|
+
const voices = audioReferences(project, e, seg).length;
|
|
1525
|
+
if (voices > params.maxAudioRefs) out.push(`${seg.id}: ${voices} people speak, so ${voices} voice references, but ${mode.model} takes at most ${params.maxAudioRefs} — split the segment or drop a speaker from it`);
|
|
1526
|
+
const clips = videoReferences(project, e, seg).length;
|
|
1527
|
+
if (clips > params.maxVideoRefs) out.push(`${seg.id}: ${clips} clip references, but ${mode.model} takes at most ${params.maxVideoRefs}`);
|
|
1528
|
+
// A reference video has its own length limit, and the previous segment is a whole clip.
|
|
1529
|
+
if (clips && params.videoReferences === 'previous') {
|
|
1530
|
+
const all = e.storyboard?.segments || [];
|
|
1531
|
+
const prev = all[all.findIndex((x) => x.id === seg.id) - 1];
|
|
1532
|
+
const prevSeconds = (prev?.cuts || []).reduce((sum, c) => sum + (Number(c.seconds) || 0), 0);
|
|
1533
|
+
if (prevSeconds > mode.maxRefVideoSeconds) out.push(`${seg.id}: ${prev.id} runs ${round2(prevSeconds)} s but ${mode.model} takes reference videos of at most ${mode.maxRefVideoSeconds} s — set params.videoReferences = none, or shorten ${prev.id}`);
|
|
1534
|
+
}
|
|
1535
|
+
total += segSeconds;
|
|
1536
|
+
}
|
|
1537
|
+
const target = Number(e.targetSeconds) || 0;
|
|
1538
|
+
if (target && (e.storyboard?.segments || []).length) {
|
|
1539
|
+
const lo = target * (1 - params.tolerance), hi = target * (1 + params.tolerance);
|
|
1540
|
+
if (total < lo || total > hi) out.push(`E${pad2(ep)}: storyboard totals ${round2(total)} s, target ${target} s (allowed ${round2(lo)}–${round2(hi)})`);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return out;
|
|
1544
|
+
}),
|
|
1545
|
+
gate('cut-content', 'storyboard', 'cuts: size + camera vocab, on-screen cap, people/props ⊆ scene, style phrase, no names', (ctx) => {
|
|
1546
|
+
const { project, params } = ctx;
|
|
1547
|
+
const out = [];
|
|
1548
|
+
const names = forbiddenNames(project);
|
|
1549
|
+
const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
|
|
1550
|
+
for (const [, e] of ctx.episodes) {
|
|
1551
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1552
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
1553
|
+
(seg.cuts || []).forEach((c, k) => {
|
|
1554
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
1555
|
+
if (!SIZES[c.size]) out.push(`${where}: size must be one of ${Object.keys(SIZES).join(' / ')}`);
|
|
1556
|
+
if (!CAMERAS.includes(c.camera)) out.push(`${where}: camera "${c.camera}" is not in the vocabulary`);
|
|
1557
|
+
const chars = c.characters || [];
|
|
1558
|
+
for (const id of chars) if (!(scene.characters || []).includes(id)) out.push(`${where}: ${id} is not in this scene`);
|
|
1559
|
+
for (const id of c.props || []) if (!(scene.props || []).includes(id)) out.push(`${where}: prop ${id} is not in this scene`);
|
|
1560
|
+
if (chars.length > params.maxOnScreen && !String(c.note || '').trim()) out.push(`${where}: ${chars.length} people on screen — add a note on how they are staged`);
|
|
1561
|
+
const frame = String(c.frame || '');
|
|
1562
|
+
if (!frame.trim()) { out.push(`${where}: frame prompt is empty`); return; }
|
|
1563
|
+
if (!isEnglish(frame)) out.push(`${where}: frame prompt must be English`);
|
|
1564
|
+
if (SIZES[c.size] && !frame.toLowerCase().includes(SIZES[c.size])) out.push(`${where}: frame prompt must contain "${SIZES[c.size]}"`);
|
|
1565
|
+
if (params.references === 'keyframes' && !frame.toLowerCase().includes(preset.framePhrase)) out.push(`${where}: frame prompt must contain "${preset.framePhrase}" (style ${params.style})`);
|
|
1566
|
+
for (const n of names) if (containsName(frame, n)) out.push(`${where}: frame prompt contains the name "${n}"`);
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
return out;
|
|
1571
|
+
}),
|
|
1572
|
+
gate('photography', 'storyboard', 'every cut names its lens and stop, its key-light direction, and enough foreground', (ctx) => {
|
|
1573
|
+
const { project, params } = ctx;
|
|
1574
|
+
const out = [];
|
|
1575
|
+
const kit = params.camera || DEFAULT_PARAMS.camera;
|
|
1576
|
+
for (const [, e] of ctx.episodes) {
|
|
1577
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1578
|
+
const cuts = seg.cuts || [];
|
|
1579
|
+
let withForeground = 0;
|
|
1580
|
+
cuts.forEach((c, k) => {
|
|
1581
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
1582
|
+
const frame = String(c.frame || '');
|
|
1583
|
+
// Lens and stop: the shot's own focal, or the one its size implies, plus the package's body.
|
|
1584
|
+
const focal = focalOf(c);
|
|
1585
|
+
if (!focal) out.push(`${where}: no focal length — set cut.focal, or a size that implies one`);
|
|
1586
|
+
const stop = String(c.aperture || kit.aperture || '').trim();
|
|
1587
|
+
if (!stop) out.push(`${where}: no aperture — set cut.aperture or params.camera.aperture`);
|
|
1588
|
+
if (frame.trim()) {
|
|
1589
|
+
for (const token of [kit.body, focal, stop].filter(Boolean)) {
|
|
1590
|
+
if (!frame.includes(token)) out.push(`${where}: frame prompt must name "${token}" — paste the whole phrase: ${cameraPhrase(c, params)}`);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
// Key light, named relative to the camera, and what it does over the cut.
|
|
1594
|
+
const dir = c.light?.direction;
|
|
1595
|
+
if (!dir) out.push(`${where}: light.direction is required — one of ${Object.keys(LIGHT_DIRECTIONS).join(' / ')} (relative to the camera, not the room)`);
|
|
1596
|
+
else if (!LIGHT_DIRECTIONS[dir]) out.push(`${where}: light.direction "${dir}" is not in the vocabulary (${Object.keys(LIGHT_DIRECTIONS).join(' / ')})`);
|
|
1597
|
+
if (!String(c.light?.note || '').trim()) out.push(`${where}: light.note is required — say what the light does over the cut (what it rims, what fills the shadow side, how it changes as the camera or subject moves)`);
|
|
1598
|
+
if (frame.trim() && dir && LIGHT_DIRECTIONS[dir] && !frame.includes(LIGHT_DIRECTIONS[dir])) out.push(`${where}: frame prompt must contain "${LIGHT_DIRECTIONS[dir]}"`);
|
|
1599
|
+
// Foreground.
|
|
1600
|
+
const fg = String(c.foreground || '').trim();
|
|
1601
|
+
if (fg) {
|
|
1602
|
+
withForeground += 1;
|
|
1603
|
+
if (frame.trim() && !frame.includes(fg)) out.push(`${where}: frame prompt must name the foreground "${fg}"`);
|
|
1604
|
+
if (frame.trim() && !/out of focus|foreground blur|defocus|bokeh|soft foreground/i.test(frame)) out.push(`${where}: the foreground is named but never said to be out of focus`);
|
|
1605
|
+
}
|
|
1606
|
+
if (c.foreground === undefined) out.push(`${where}: foreground is required — name what sits between the lens and the subject, or "" for a deliberately clean frame`);
|
|
1607
|
+
});
|
|
1608
|
+
// "Reasonably" means most of them: a segment of clean frames is flat.
|
|
1609
|
+
if (cuts.length && withForeground * 2 < cuts.length) out.push(`${seg.id}: only ${withForeground} of ${cuts.length} cuts have a foreground element — at least half must, or the segment has no depth`);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return out;
|
|
1613
|
+
}),
|
|
1614
|
+
gate('video-prompt', 'storyboard', 'video prompt matches the storyboard (H3 alignment / Seedance keyframes / Wan legend), dialogue points at its @audioN', (ctx) => {
|
|
1615
|
+
const { project, params } = ctx;
|
|
1616
|
+
const out = [];
|
|
1617
|
+
const names = forbiddenNames(project);
|
|
1618
|
+
for (const [, e] of ctx.episodes) {
|
|
1619
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1620
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1];
|
|
1621
|
+
if (!scene || !(seg.cuts || []).length) continue;
|
|
1622
|
+
const prompt = String(seg.prompt || '');
|
|
1623
|
+
if (!prompt.trim()) { out.push(`${seg.id}: prompt is empty (run \`prompt segment ${seg.id}\` for the skeleton)`); continue; }
|
|
1624
|
+
const lines = claimedLines(scene, seg.cuts);
|
|
1625
|
+
const family = videoModel(params).family;
|
|
1626
|
+
const refs = segmentReferences(project, e, seg);
|
|
1627
|
+
const sheetCount = refs.filter((r) => r.kind === 'image').length;
|
|
1628
|
+
if (family === 'h3') out.push(...checkH3Prompt(seg, prompt, lines, project, names, params, sheetCount, refs));
|
|
1629
|
+
else if (family === 'wan') out.push(...checkWanPrompt(seg, prompt, lines, names, params, project, refs));
|
|
1630
|
+
else out.push(...checkSeedancePrompt(seg, prompt, lines, names, params, sheetCount, project, refs));
|
|
1631
|
+
const accent = String(params.speechAccent || '').trim();
|
|
1632
|
+
if (accent && params.video !== 'wan' && lines.length && !prompt.toLowerCase().includes(accent.toLowerCase())) out.push(`${seg.id}: prompt must name the accent "${accent}" (params.speechAccent) — the model speaks the lines`);
|
|
1633
|
+
// `lookPhrase` is English; a Chinese prompt carries `lookPhraseZh` instead, when there is one.
|
|
1634
|
+
const look = String(videoModel(params).promptLang === 'zh' ? params.lookPhraseZh || '' : params.lookPhrase || '').trim();
|
|
1635
|
+
if (look && !prompt.toLowerCase().includes(look.toLowerCase())) out.push(`${seg.id}: prompt must carry the look phrase "${look}" (params.${videoModel(params).promptLang === 'zh' ? 'lookPhraseZh' : 'lookPhrase'})`);
|
|
1636
|
+
if (params.references === 'sheets' && params.video !== 'wan') {
|
|
1637
|
+
const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
|
|
1638
|
+
if (!prompt.toLowerCase().includes(preset.videoPhrase)) out.push(`${seg.id}: prompt must contain "${preset.videoPhrase}" (style ${params.style}) — there are no keyframes to carry the style`);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return out;
|
|
1643
|
+
}),
|
|
1644
|
+
|
|
1645
|
+
// ── shot language (per episode) ───────────────────────────────────────
|
|
1646
|
+
gate('shot-purpose', 'storyboard', '镜头职责: every cut says what it changes; adjacent cuts differ; a turn / 爽点 is a reveal-class cut or is followed by a reaction; the cliff cut leaves the audience hanging', (ctx) => {
|
|
1647
|
+
const out = [];
|
|
1648
|
+
for (const [, e] of ctx.episodes) {
|
|
1649
|
+
const segs = e.storyboard?.segments || [];
|
|
1650
|
+
const lastScene = (e.scenes || []).length;
|
|
1651
|
+
const lastBeat = ((e.scenes || [])[lastScene - 1]?.flow || []).length;
|
|
1652
|
+
for (const seg of segs) {
|
|
1653
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1];
|
|
1654
|
+
const cuts = seg.cuts || [];
|
|
1655
|
+
cuts.forEach((c, k) => {
|
|
1656
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
1657
|
+
const pk = c.purpose?.kind;
|
|
1658
|
+
if (!PURPOSE_KINDS.includes(pk)) out.push(`${where}: purpose.kind must be one of ${PURPOSE_KINDS.join(' / ')}`);
|
|
1659
|
+
if (!String(c.purpose?.change || '').trim()) out.push(`${where}: purpose.change is empty — what does the audience know, or who holds power, when this cut ends?`);
|
|
1660
|
+
if (k > 0) {
|
|
1661
|
+
const p = cuts[k - 1];
|
|
1662
|
+
const same = p.size === c.size && p.angle === c.angle && [...(p.characters || [])].sort().join() === [...(c.characters || [])].sort().join();
|
|
1663
|
+
if (same) out.push(`${where}: same size, angle and people as cut ${k} — a cut has to change at least one of them, or merge the two`);
|
|
1664
|
+
}
|
|
1665
|
+
// A turn or the 爽点 lands as a reveal-class cut, or the next cut reads the reaction to it.
|
|
1666
|
+
const turnBeats = cutBeats(scene, c).filter(({ bi, beat }) => beat.turn === true || cutClaims(seg, c, e.satisfactionBeat) && Number(e.satisfactionBeat[1]) === bi);
|
|
1667
|
+
if (turnBeats.length && !TURN_KINDS.includes(pk)) {
|
|
1668
|
+
const next = cuts[k + 1];
|
|
1669
|
+
const speakers = turnBeats.map(({ beat }) => beat.speaker).filter(Boolean);
|
|
1670
|
+
const reacts = next && next.purpose?.kind === 'reaction' && (next.characters || []).some((id) => !speakers.includes(id));
|
|
1671
|
+
if (!reacts) out.push(`${where}: claims a turn / 爽点 (beat ${turnBeats.map((t) => t.bi).join(', ')}) but is "${pk || '?'}" — make it reveal / evidence / decision / consequence, or follow it with a reaction cut on someone who did not speak it`);
|
|
1672
|
+
}
|
|
1673
|
+
if (Number(seg.sceneIndex) === lastScene && Array.isArray(c.beats) && Number(c.beats[1]) === lastBeat && lastBeat > 0 && !CLIFF_KINDS.includes(pk)) out.push(`${where}: holds the episode's last beat (the cliff) but is "${pk || '?'}" — the cliff cut is ${CLIFF_KINDS.join(' / ')}`);
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return out;
|
|
1678
|
+
}),
|
|
1679
|
+
gate('framing-axis', 'storyboard', '角度 / 机位高度 / 轴线: three separate fields; a two-person segment declares its axis; reverse shots face opposite ways', (ctx) => {
|
|
1680
|
+
const out = [];
|
|
1681
|
+
for (const [, e] of ctx.episodes) {
|
|
1682
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1683
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
1684
|
+
const cuts = seg.cuts || [];
|
|
1685
|
+
cuts.forEach((c, k) => {
|
|
1686
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
1687
|
+
if (!ANGLES.includes(c.angle)) out.push(`${where}: angle must be one of ${ANGLES.join(' / ')} (horizontal — how much face the audience reads)`);
|
|
1688
|
+
if (!HEIGHTS.includes(c.height)) out.push(`${where}: height must be one of ${HEIGHTS.join(' / ')} (eye is a choice, not an unset field)`);
|
|
1689
|
+
if (!AXIS_SIDES.includes(c.axisSide)) out.push(`${where}: axisSide must be one of ${AXIS_SIDES.join(' / ')}`);
|
|
1690
|
+
if (c.axisSide === 'cross' && !String(c.note || '').trim()) out.push(`${where}: crossing the axis needs a note — the neutral shot, the in-shot crossing or the re-established geography`);
|
|
1691
|
+
});
|
|
1692
|
+
const people = segmentCharacters(seg);
|
|
1693
|
+
const axis = isObject(seg.axis) ? seg.axis : null;
|
|
1694
|
+
if (people.length >= 2) {
|
|
1695
|
+
if (!axis) { out.push(`${seg.id}: ${people.length} people appear — declare axis { between: [a, b], workingSide, screenLeft, screenRight }`); continue; }
|
|
1696
|
+
const between = asList(axis.between);
|
|
1697
|
+
if (between.length !== 2 || between.some((id) => !(scene.characters || []).includes(id))) out.push(`${seg.id}: axis.between must name two characters of this scene`);
|
|
1698
|
+
if (!String(axis.workingSide || '').trim()) out.push(`${seg.id}: axis.workingSide is empty — where the camera lives (e.g. "the counter side, camera toward the aisle")`);
|
|
1699
|
+
if (!between.includes(axis.screenLeft) || !between.includes(axis.screenRight) || axis.screenLeft === axis.screenRight) out.push(`${seg.id}: axis.screenLeft / screenRight must be the two axis characters, one each`);
|
|
1700
|
+
// Reverse shots: a single on A then a single on B must face opposite screen directions.
|
|
1701
|
+
for (let k = 1; k < cuts.length; k++) {
|
|
1702
|
+
const a = cuts[k - 1], b = cuts[k];
|
|
1703
|
+
if ((a.characters || []).length !== 1 || (b.characters || []).length !== 1) continue;
|
|
1704
|
+
const ca = a.characters[0], cb = b.characters[0];
|
|
1705
|
+
if (ca === cb || !between.includes(ca) || !between.includes(cb)) continue;
|
|
1706
|
+
if (a.axisSide === 'cross' || b.axisSide === 'cross') continue;
|
|
1707
|
+
// A person who entered on this cut has no start facing; their end facing stands in.
|
|
1708
|
+
const facingOf = (cut, idx, id) => boundaryOf(cutStart(seg, idx)).facing?.[id] ?? boundaryOf(cut.end).facing?.[id];
|
|
1709
|
+
const fa = facingOf(a, k - 1, ca), fb = facingOf(b, k, cb);
|
|
1710
|
+
const lr = ['screen-left', 'screen-right'];
|
|
1711
|
+
if (lr.includes(fa) && lr.includes(fb) && fa === fb) out.push(`${seg.id} cuts ${k}–${k + 1}: reverse shots on ${ca} and ${cb} both face ${fa} — two people looking the same way read as looking at a third party; one faces screen-left, the other screen-right`);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
return out;
|
|
1717
|
+
}),
|
|
1718
|
+
gate('continuity', 'storyboard', '起点 → 终点: boundaries per on-screen person, end feeds the next start, props change hands only on an action, the frame is one frozen instant', (ctx) => {
|
|
1719
|
+
const { project } = ctx;
|
|
1720
|
+
const out = [];
|
|
1721
|
+
for (const [, e] of ctx.episodes) {
|
|
1722
|
+
const segs = e.storyboard?.segments || [];
|
|
1723
|
+
segs.forEach((seg, si) => {
|
|
1724
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1];
|
|
1725
|
+
const cuts = seg.cuts || [];
|
|
1726
|
+
const checkBoundary = (b, c, where, label) => {
|
|
1727
|
+
const bo = boundaryOf(b);
|
|
1728
|
+
for (const id of c.characters || []) {
|
|
1729
|
+
for (const f of BOUNDARY_FIELDS) {
|
|
1730
|
+
const v = bo[f]?.[id];
|
|
1731
|
+
if (f === 'heldProps' ? !Array.isArray(v) : !String(v || '').trim()) out.push(`${where}: ${label}.${f}.${id} is missing — every person on screen has a position, a facing, hands and held props at both ends`);
|
|
1732
|
+
}
|
|
1733
|
+
if (bo.facing?.[id] && !FACINGS.includes(bo.facing[id])) out.push(`${where}: ${label}.facing.${id} must be one of ${FACINGS.join(' / ')} (screen terms)`);
|
|
1734
|
+
for (const pid of asList(bo.heldProps?.[id])) if (!(scene?.props || []).includes(pid)) out.push(`${where}: ${label}.heldProps.${id} names ${pid}, which is not a prop of this scene`);
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
cuts.forEach((c, k) => {
|
|
1738
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
1739
|
+
if (k === 0) {
|
|
1740
|
+
if (!isObject(c.start)) out.push(`${where}: start boundary is required on a segment's first cut`);
|
|
1741
|
+
else checkBoundary(c.start, c, where, 'start');
|
|
1742
|
+
} else if (c.start !== undefined) out.push(`${where}: only the first cut of a segment carries a start — later cuts begin where the previous cut ended`);
|
|
1743
|
+
if (!isObject(c.end)) { out.push(`${where}: end boundary is required`); return; }
|
|
1744
|
+
checkBoundary(c.end, c, where, 'end');
|
|
1745
|
+
// A prop changing hands is an action; it cannot happen between two lines.
|
|
1746
|
+
const start = cutStart(seg, k);
|
|
1747
|
+
for (const id of c.characters || []) {
|
|
1748
|
+
if (start.heldProps?.[id] === undefined) continue; // entered this cut: no prior state to compare
|
|
1749
|
+
const a = heldOf(start, id), b = heldOf(c.end, id);
|
|
1750
|
+
if (a.join() !== b.join() && !cutBeats(scene, c).some(({ beat }) => beat.action !== undefined)) out.push(`${where}: ${id} holds [${a.join(', ')}] at the start and [${b.join(', ')}] at the end, but the cut claims no action beat — a hand-over is an action`);
|
|
1751
|
+
}
|
|
1752
|
+
// Keyframe drift: the frame freezes the start; a state that only exists at the end may not be in it.
|
|
1753
|
+
const frame = String(c.frame || '');
|
|
1754
|
+
const startStates = asList(start.state).map(String);
|
|
1755
|
+
for (const st of asList(c.end?.state).map(String)) if (st && !startStates.includes(st) && frame.includes(st)) out.push(`${where}: frame names "${st}", a state that only exists at the end of the cut — the frame is the start instant`);
|
|
1756
|
+
// Static test: a frame holds one instant, never a sequence.
|
|
1757
|
+
const seq = [...SEQUENCE_WORDS_EN.filter((w) => (w.endsWith(',') ? new RegExp(`\\b${w}`, 'i') : new RegExp(`\\b${w}\\b`, 'i')).test(frame)), ...SEQUENCE_WORDS_ZH.filter((w) => frame.includes(w))];
|
|
1758
|
+
if (seq.length) out.push(`${where}: frame contains "${seq.join(' / ')}" — a keyframe is one freezeable instant; ordered action belongs in motion`);
|
|
1759
|
+
});
|
|
1760
|
+
// Segment k+1 in the same scene starts where segment k ended.
|
|
1761
|
+
const prev = segs[si - 1];
|
|
1762
|
+
if (prev && Number(prev.sceneIndex) === Number(seg.sceneIndex) && cuts.length && (prev.cuts || []).length) {
|
|
1763
|
+
const a = boundaryOf(prev.cuts[prev.cuts.length - 1].end), b = boundaryOf(cuts[0].start);
|
|
1764
|
+
for (const id of cuts[0].characters || []) {
|
|
1765
|
+
if (!(prev.cuts[prev.cuts.length - 1].characters || []).includes(id)) continue;
|
|
1766
|
+
if (String(a.positions?.[id] || '') !== String(b.positions?.[id] || '')) out.push(`${seg.id} cut 1: ${id} starts at "${b.positions?.[id]}" but ${prev.id} left them at "${a.positions?.[id]}" — same scene, the boundary carries over`);
|
|
1767
|
+
if (heldOf(a, id).join() !== heldOf(b, id).join()) out.push(`${seg.id} cut 1: ${id} starts holding [${heldOf(b, id).join(', ')}] but ${prev.id} ended with [${heldOf(a, id).join(', ')}]`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
return out;
|
|
1773
|
+
}),
|
|
1774
|
+
gate('motion', 'storyboard', '唯一动作: trigger → action → result per cut; the camera says why it moves and where it ends, or what still moves while it holds', (ctx) => {
|
|
1775
|
+
const { project, params } = ctx;
|
|
1776
|
+
const out = [];
|
|
1777
|
+
const names = forbiddenNames(project);
|
|
1778
|
+
const zh = videoModel(params).promptLang === 'zh';
|
|
1779
|
+
for (const [, e] of ctx.episodes) {
|
|
1780
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1781
|
+
(seg.cuts || []).forEach((c, k) => {
|
|
1782
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
1783
|
+
const m = isObject(c.motion) ? c.motion : {};
|
|
1784
|
+
for (const key of ['trigger', 'action', 'result']) {
|
|
1785
|
+
const v = String(m[key] || '').trim();
|
|
1786
|
+
if (!v) out.push(`${where}: motion.${key} is empty (起点 → 唯一动作 → 终点: what cues it, the one visible state chain, where it lands — or "hold")`);
|
|
1787
|
+
else if (!zh && !isEnglish(v)) out.push(`${where}: motion.${key} must be English — it goes into the video prompt`);
|
|
1788
|
+
}
|
|
1789
|
+
const action = String(m.action || '');
|
|
1790
|
+
for (const nm of names) if (containsName(action, nm)) out.push(`${where}: motion.action contains the name "${nm}" — use the identity or the reference label`);
|
|
1791
|
+
const inferred = INFERENCE_WORDS.filter((w) => (CJK_RE.test(w) ? action.includes(w) : new RegExp(`\\b${w}\\b`, 'i').test(action)));
|
|
1792
|
+
if (inferred.length) out.push(`${where}: motion.action infers an inner state ("${inferred.join(' / ')}") — write the visible action`);
|
|
1793
|
+
const hands = CJK_RE.test(action) ? HAND_VERBS_ZH.filter((v) => action.includes(v)).length : (action.toLowerCase().match(/[a-z]+/g) || []).filter((w) => HAND_VERBS_EN.includes(w)).length;
|
|
1794
|
+
if (hands > 3) out.push(`${where}: motion.action has ${hands} hand verbs — choreography a video model will not perform; hand the change to an object, split the cut, or end on a hold`);
|
|
1795
|
+
const cn = isObject(c.cameraNote) ? c.cameraNote : {};
|
|
1796
|
+
if (c.camera && c.camera !== 'Static Shot') {
|
|
1797
|
+
if (!CAMERA_MOTIVATIONS.includes(cn.motivation)) out.push(`${where}: ${c.camera} needs cameraNote.motivation (${CAMERA_MOTIVATIONS.join(' / ')}) — a move without a reason is decoration`);
|
|
1798
|
+
if (!String(cn.endFraming || '').trim()) out.push(`${where}: ${c.camera} needs cameraNote.endFraming — where the move stops`);
|
|
1799
|
+
} else if (!String(cn.hold || '').trim()) out.push(`${where}: a Static Shot needs cameraNote.hold — what still moves in the frame while the camera does not (an empty camera line is read as "free to drift")`);
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return out;
|
|
1804
|
+
}),
|
|
1805
|
+
gate('sound', 'storyboard', '声音时间线: ambience per segment, chained across a scene; sfx pinned to a cut; music declared', (ctx) => {
|
|
1806
|
+
const out = [];
|
|
1807
|
+
for (const [, e] of ctx.episodes) {
|
|
1808
|
+
const segs = e.storyboard?.segments || [];
|
|
1809
|
+
segs.forEach((seg, i) => {
|
|
1810
|
+
const sd = isObject(seg.sound) ? seg.sound : null;
|
|
1811
|
+
if (!sd) { out.push(`${seg.id}: sound { ambience, sfx: [{cut, event}], music } is required — a same-pass model fills silence with sound of its own`); return; }
|
|
1812
|
+
if (!String(sd.ambience || '').trim()) out.push(`${seg.id}: sound.ambience is empty`);
|
|
1813
|
+
asList(sd.sfx).forEach((x, j) => {
|
|
1814
|
+
const cut = Number(x?.cut);
|
|
1815
|
+
if (!(cut >= 1 && cut <= (seg.cuts || []).length)) out.push(`${seg.id}: sound.sfx[${j}].cut must be a cut number of this segment`);
|
|
1816
|
+
if (!String(x?.event || '').trim()) out.push(`${seg.id}: sound.sfx[${j}].event is empty`);
|
|
1817
|
+
});
|
|
1818
|
+
const mu = sd.music;
|
|
1819
|
+
if (isObject(mu)) {
|
|
1820
|
+
if (!String(mu.intent || '').trim()) out.push(`${seg.id}: sound.music.intent is empty`);
|
|
1821
|
+
asList(mu.cues).forEach((c, j) => {
|
|
1822
|
+
if (!MUSIC_CUES.includes(c?.do)) out.push(`${seg.id}: sound.music.cues[${j}].do must be one of ${MUSIC_CUES.join(' / ')}`);
|
|
1823
|
+
const at = cueSeconds(c?.at, seg.cuts);
|
|
1824
|
+
const total = (seg.cuts || []).reduce((sum, x) => sum + (Number(x.seconds) || 0), 0);
|
|
1825
|
+
if (at == null || at < 0 || at > total + 1e-9) out.push(`${seg.id}: sound.music.cues[${j}].at must be a cut number or "12.5s" inside the segment`);
|
|
1826
|
+
});
|
|
1827
|
+
} else if (!String(mu || '').trim()) out.push(`${seg.id}: sound.music must be "N/A", the music's intent, or { intent, cues: [{ at, do }] }`);
|
|
1828
|
+
const prev = segs[i - 1];
|
|
1829
|
+
if (prev && Number(prev.sceneIndex) === Number(seg.sceneIndex) && isObject(prev.sound) && String(prev.sound.ambience || '').trim() && String(sd.ambience || '').trim() && prev.sound.ambience !== sd.ambience) out.push(`${seg.id}: ambience differs from ${prev.id} in the same scene — separately generated clips each invent their own room tone; keep the string identical across the scene`);
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
return out;
|
|
1833
|
+
}),
|
|
1834
|
+
gate('visual-plan', 'storyboard', '场次视觉计划 for the key scenes (the 爽点 scene and the cliff scene)', (ctx) => {
|
|
1835
|
+
const out = [];
|
|
1836
|
+
for (const [ep, e] of ctx.episodes) {
|
|
1837
|
+
const key = keyScenes(e);
|
|
1838
|
+
const plans = asList(e.storyboard?.visualPlans);
|
|
1839
|
+
for (const si of key) {
|
|
1840
|
+
const plan = plans.find((pl) => Number(pl?.sceneIndex) === si);
|
|
1841
|
+
if (!plan) { out.push(`E${pad2(ep)} scene ${si}: no visual plan — it carries the ${Array.isArray(e.satisfactionBeat) && Number(e.satisfactionBeat[0]) === si ? '爽点' : 'cliff'}; answer 戏剧转向 / 观众立场 / 空间压力 / 视觉推进 / 摄影节奏 / 反应落点 / 声音策略 before cutting it`); continue; }
|
|
1842
|
+
for (const key of ['turn', 'alignment', 'pressure', 'progression', 'rhythm', 'landing', 'sound']) {
|
|
1843
|
+
const v = String(plan[key] || '').trim();
|
|
1844
|
+
if (!v) out.push(`E${pad2(ep)} scene ${si}: visualPlan.${key} is empty`);
|
|
1845
|
+
else if (QUOTE_RE.test(v)) out.push(`E${pad2(ep)} scene ${si}: visualPlan.${key} contains dialogue quotes`);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
return out;
|
|
1850
|
+
}),
|
|
1851
|
+
gate('text-policy', 'storyboard', 'on-screen text: a readable prop’s text is exact in every frame that shows it; symbolic / blank props carry none', (ctx) => {
|
|
1852
|
+
const { project } = ctx;
|
|
1853
|
+
const out = [];
|
|
1854
|
+
const policies = new Map();
|
|
1855
|
+
for (const p of project.props || []) {
|
|
1856
|
+
if (!isObject(p.textPolicy)) continue;
|
|
1857
|
+
const tp = p.textPolicy;
|
|
1858
|
+
if (!['readable', 'symbolic', 'blank'].includes(tp.mode)) out.push(`${p.id}: textPolicy.mode must be readable / symbolic / blank`);
|
|
1859
|
+
if (tp.mode === 'readable' && !String(tp.text || '').trim()) out.push(`${p.id}: textPolicy.mode readable needs the exact text`);
|
|
1860
|
+
policies.set(p.id, tp);
|
|
1861
|
+
}
|
|
1862
|
+
if (!policies.size) return out;
|
|
1863
|
+
for (const [, e] of ctx.episodes) {
|
|
1864
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1865
|
+
const shown = new Set();
|
|
1866
|
+
(seg.cuts || []).forEach((c, k) => {
|
|
1867
|
+
const frame = String(c.frame || '');
|
|
1868
|
+
for (const pid of c.props || []) {
|
|
1869
|
+
const tp = policies.get(pid);
|
|
1870
|
+
if (!tp) continue;
|
|
1871
|
+
shown.add(pid);
|
|
1872
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
1873
|
+
if (tp.mode === 'readable') {
|
|
1874
|
+
if (!frame.includes(String(tp.text))) out.push(`${where}: shows ${pid}, whose text "${tp.text}" must be in the frame verbatim`);
|
|
1875
|
+
if (/\bno text\b/i.test(frame)) out.push(`${where}: says "no text" while showing readable ${pid} — the global no-text overrides the evidence`);
|
|
1876
|
+
} else if (QUOTED_LINE_RE.test(frame)) out.push(`${where}: ${pid} is ${tp.mode}, but the frame quotes text on it`);
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
for (const pid of shown) {
|
|
1880
|
+
const tp = policies.get(pid);
|
|
1881
|
+
if (tp.mode === 'readable' && String(seg.prompt || '').trim() && !String(seg.prompt).includes(String(tp.text))) out.push(`${seg.id}: prompt must carry the readable text "${tp.text}" of ${pid}`);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
return out;
|
|
1886
|
+
}),
|
|
1887
|
+
gate('prompt-hygiene', 'storyboard', 'delivery text: filled, no ids / paths / notes, subtitles off, every on-screen person bound in their own shot, the non-speaker’s lips closed (H3)', (ctx) => {
|
|
1888
|
+
const { project, params } = ctx;
|
|
1889
|
+
const out = [];
|
|
1890
|
+
const mode = videoModel(params);
|
|
1891
|
+
for (const [, e] of ctx.episodes) {
|
|
1892
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1893
|
+
const prompt = String(seg.prompt || '');
|
|
1894
|
+
if (!prompt.trim()) continue;
|
|
1895
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1];
|
|
1896
|
+
if (/…|…/.test(prompt)) out.push(`${seg.id}: prompt still has an unfilled "…" from the skeleton`);
|
|
1897
|
+
const ids = uniq([...prompt.matchAll(/\b([CSPB]\d{2,}|E\d{2}-\d{2})\b/g)].map((m) => m[1]));
|
|
1898
|
+
if (ids.length) out.push(`${seg.id}: prompt contains ids (${ids.join(', ')}) — the model renders them as text; describe, or use the @image label`);
|
|
1899
|
+
if (/\.(png|jpe?g|webp|mp4|mov|wav|mp3)\b/i.test(prompt)) out.push(`${seg.id}: prompt contains a file name — delivery text carries only what is to be filmed`);
|
|
1900
|
+
if (/\bTODO\b|\bnote:/i.test(prompt)) out.push(`${seg.id}: prompt contains a workflow note (TODO / note:) — keep it outside the prompt`);
|
|
1901
|
+
if (!params.subtitles) {
|
|
1902
|
+
const need = NO_SUBTITLES[mode.promptLang === 'zh' ? 'zh' : 'en'];
|
|
1903
|
+
if (!prompt.toLowerCase().includes(need.toLowerCase())) out.push(`${seg.id}: prompt must say "${need}" — same-pass models add captions unless told not to (params.subtitles: true to allow)`);
|
|
1904
|
+
}
|
|
1905
|
+
const texts = shotTexts(prompt, mode.family, params.references);
|
|
1906
|
+
const refs = segmentReferences(project, e, seg);
|
|
1907
|
+
const sheetOf = params.references === 'sheets' ? new Map(refs.filter((r) => r.kind === 'image' && r.asset === 'sheet').map((r) => [r.entity, r.label])) : null;
|
|
1908
|
+
(seg.cuts || []).forEach((c, k) => {
|
|
1909
|
+
const text = texts.get(k + 1);
|
|
1910
|
+
if (!text) return; // the dialect gate reports the missing shot
|
|
1911
|
+
const lines = claimedLines(scene, [c]);
|
|
1912
|
+
if (mode.family === 'h3' && lines.length && (c.characters || []).length >= 2 && !/does not speak|lips remain completely closed/.test(text)) out.push(`${seg.id} [Shot ${k + 1}]: two people on screen while one speaks — say the other "does not speak; ${LIPS_CLOSED}" or the lip-sync lands on the more visible face (measured 3/3 vs 2/3)`);
|
|
1913
|
+
if (sheetOf) for (const id of c.characters || []) {
|
|
1914
|
+
const label = sheetOf.get(id);
|
|
1915
|
+
if (label && !text.includes(label)) out.push(`${seg.id} shot ${k + 1}: ${characterById(project, id)?.identityEn || id} is on screen but the shot never binds ${label} — a person bound in one shot and unbound in the next becomes two people`);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
if (mode.family === 'h3') for (const m of prompt.matchAll(/<d>\[[^\]]+\]\s*([^<]*)<\/d>/g)) if (/[()()]/.test(m[1])) out.push(`${seg.id}: <d> holds only the line — "${truncateLine(m[1])}" carries a parenthetical; put the direction outside <d>`);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
return out;
|
|
1922
|
+
}),
|
|
1923
|
+
gate('audio-track', 'storyboard', 'Seed Audio node per segment: the derived prompt fits the model (≤ 120 s, ≤ 2048 chars, ≤ 3 voices in full mode)', (ctx) => {
|
|
1924
|
+
const { project, params } = ctx;
|
|
1925
|
+
const out = [];
|
|
1926
|
+
if (params.audioTrack === 'none') return out;
|
|
1927
|
+
for (const [, e] of ctx.episodes) {
|
|
1928
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1929
|
+
if (!(seg.cuts || []).length || !isObject(seg.sound)) continue;
|
|
1930
|
+
const a = audioPrompt(project, e, seg);
|
|
1931
|
+
if (a.seconds > AUDIO_MAX_SECONDS) out.push(`${seg.id}: ${round2(a.seconds)} s, but ${AUDIO_MODEL} generates at most ${AUDIO_MAX_SECONDS} s`);
|
|
1932
|
+
if (a.prompt.length > AUDIO_MAX_CHARS) out.push(`${seg.id}: the audio prompt is ${a.prompt.length} chars, max ${AUDIO_MAX_CHARS} — shorten sound.ambience / sfx / music.intent`);
|
|
1933
|
+
if (a.mode === 'full' && a.references.length > AUDIO_MAX_REFS) out.push(`${seg.id}: ${a.references.length} speakers, but ${AUDIO_MODEL} takes at most ${AUDIO_MAX_REFS} reference voices — set params.audioTrack = bed or split the segment`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return out;
|
|
1937
|
+
}),
|
|
1938
|
+
gate('storyboard-advice', 'storyboard', 'shot craft advice: 9:16 wides need depth, size jumps, a segment that only tightens, long frames, negative-form motion (advisory)', (ctx) => {
|
|
1939
|
+
const { params } = ctx;
|
|
1940
|
+
const out = [];
|
|
1941
|
+
const vertical = (() => { const [w, h] = String(params.ratio).split(':').map(Number); return h > w; })();
|
|
1942
|
+
for (const [, e] of ctx.episodes) {
|
|
1943
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
1944
|
+
const cuts = seg.cuts || [];
|
|
1945
|
+
let ups = 0, downs = 0;
|
|
1946
|
+
cuts.forEach((c, k) => {
|
|
1947
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
1948
|
+
if (vertical && ['wide', 'extreme-wide'].includes(c.size) && !String(c.background || '').trim() && !String(c.note || '').trim()) out.push(`${where}: a ${c.size} on 9:16 has a third of the width — carry the geography in depth (background) or say why the wide is needed (note)`);
|
|
1949
|
+
if (k > 0) {
|
|
1950
|
+
const d = sizeIndex(c.size) - sizeIndex(cuts[k - 1].size);
|
|
1951
|
+
if (Math.abs(d) > 2 && !String(c.note || '').trim()) out.push(`${where}: jumps ${Math.abs(d)} size steps from cut ${k} — the ladder moves a step or two; a bigger jump needs an attention reason (say it in note)`);
|
|
1952
|
+
if (d > 0) ups += 1; else if (d < 0) downs += 1;
|
|
1953
|
+
}
|
|
1954
|
+
if (String(c.frame || '').length > params.maxFrameChars) out.push(`${where}: frame is ${c.frame.length} chars — past ~${params.maxFrameChars} the rest is padding the model pays for; delete any sentence whose absence changes nothing`);
|
|
1955
|
+
const action = String(c.motion?.action || '');
|
|
1956
|
+
if (/\b(does not|doesn't|never)\b/i.test(action) || /没有|不去|不[看说动]/.test(action)) out.push(`${where}: motion.action is phrased as what does not happen — a model cannot film an absence; write the action that happens instead (a hold, a hand that stays)`);
|
|
1957
|
+
});
|
|
1958
|
+
if (cuts.length >= 4 && ups >= 2 && downs === 0) out.push(`${seg.id}: every cut tightens — by mid-segment there is nowhere left to go; step back out once or hold the working size`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return out;
|
|
1962
|
+
}, { advisory: true }),
|
|
1963
|
+
];
|
|
1964
|
+
|
|
1965
|
+
export const STAGES = ['outline', 'characters', 'art', 'script', 'storyboard'];
|
|
1966
|
+
|
|
1967
|
+
// ── derived values ──────────────────────────────────────────────────────────
|
|
1968
|
+
|
|
1969
|
+
export function episodeSeconds(episode, params) {
|
|
1970
|
+
let total = 0;
|
|
1971
|
+
for (const sc of episode.scenes || []) for (const b of sc.flow || []) total += beatSeconds(b, params);
|
|
1972
|
+
return total;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
export function beatSeconds(beat, params) {
|
|
1976
|
+
return beat.line !== undefined ? lineSeconds(beat.line, params) : params.actionSeconds;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
export function dialogueSeconds(scene, [from, to], params) {
|
|
1980
|
+
let total = 0;
|
|
1981
|
+
for (let i = from - 1; i < to; i++) {
|
|
1982
|
+
const b = (scene.flow || [])[i];
|
|
1983
|
+
if (b && b.line !== undefined) total += lineSeconds(b.line, params);
|
|
1984
|
+
}
|
|
1985
|
+
return total;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/** Dialogue lines claimed by a segment, in order. */
|
|
1989
|
+
export function claimedLines(scene, cuts) {
|
|
1990
|
+
const lines = [];
|
|
1991
|
+
for (const c of cuts) {
|
|
1992
|
+
const [from, to] = c.beats || [0, -1];
|
|
1993
|
+
for (let i = from - 1; i < to; i++) {
|
|
1994
|
+
const b = (scene.flow || [])[i];
|
|
1995
|
+
if (b && b.line !== undefined) lines.push({ speaker: b.speaker, line: String(b.line), delivery: b.delivery });
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return lines;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/** Cumulative start second of every cut. */
|
|
2002
|
+
export function cutStarts(cuts) {
|
|
2003
|
+
const starts = [];
|
|
2004
|
+
let t = 0;
|
|
2005
|
+
for (const c of cuts) {
|
|
2006
|
+
starts.push(t);
|
|
2007
|
+
t += Number(c.seconds) || 0;
|
|
2008
|
+
}
|
|
2009
|
+
return starts;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
export function timestamp(seconds) {
|
|
2013
|
+
const m = Math.floor(seconds / 60);
|
|
2014
|
+
const s = seconds - m * 60;
|
|
2015
|
+
return `${pad2(m)}:${s.toFixed(3).padStart(6, '0')}`;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
export function h3AlignmentLine(cuts) {
|
|
2019
|
+
if (cuts.length === 1) {
|
|
2020
|
+
return 'For the target video, at 0.00 seconds into the target video, @image1 (from [Shot 1]) is fully referenced.';
|
|
2021
|
+
}
|
|
2022
|
+
const starts = cutStarts(cuts);
|
|
2023
|
+
const parts = cuts.map((_, i) => `@image${i + 1} (from Shot ${i + 1}) aligns with the ${starts[i].toFixed(2)}-second mark of the target video`);
|
|
2024
|
+
return `How the reference pictures align with the target video — ${parts.join('; ')}.`;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
function checkH3Prompt(seg, prompt, lines, project, names, params = {}, sheetCount = 0, refs = []) {
|
|
2028
|
+
const out = [];
|
|
2029
|
+
const cuts = seg.cuts;
|
|
2030
|
+
const firstLine = prompt.split('\n')[0].trim();
|
|
2031
|
+
if (params.references === 'sheets') {
|
|
2032
|
+
// H3 ref addresses its references as "Image 1 / Image 2"; there are no keyframes to align.
|
|
2033
|
+
const imageNs = [...prompt.matchAll(/@image(\d+)/g)].map((m) => Number(m[1]));
|
|
2034
|
+
for (let k = 1; k <= sheetCount; k++) if (!imageNs.includes(k)) out.push(`${seg.id}: @image${k} (a sheet this segment references) is never mentioned — say what it is`);
|
|
2035
|
+
for (const r of uniq(refs)) if (r > sheetCount) out.push(`${seg.id}: @image${r} but the segment references ${sheetCount} sheet(s)`);
|
|
2036
|
+
if (/aligns with the|is fully referenced/.test(prompt)) out.push(`${seg.id}: keyframe alignment wording found — in sheets mode the references are sheets, not shots`);
|
|
2037
|
+
} else {
|
|
2038
|
+
const expected = h3AlignmentLine(cuts);
|
|
2039
|
+
if (firstLine !== expected) out.push(`${seg.id}: first line must be the alignment line derived from the cuts:\n ${expected}`);
|
|
2040
|
+
}
|
|
2041
|
+
const idx = ['integrated_multimodal_description:', 'overall_soundscape:', 'non_diegetic_music:'].map((f) => prompt.indexOf(f));
|
|
2042
|
+
if (idx.some((i) => i < 0)) out.push(`${seg.id}: prompt needs integrated_multimodal_description:, overall_soundscape:, non_diegetic_music:`);
|
|
2043
|
+
else if (!(idx[0] < idx[1] && idx[1] < idx[2])) out.push(`${seg.id}: the three fields must be in order`);
|
|
2044
|
+
const starts = cutStarts(cuts);
|
|
2045
|
+
const paragraphs = shotParagraphs(prompt);
|
|
2046
|
+
cuts.forEach((c, k) => {
|
|
2047
|
+
const para = paragraphs.get(k + 1);
|
|
2048
|
+
if (!para) { out.push(`${seg.id}: missing [Shot ${k + 1}] paragraph`); return; }
|
|
2049
|
+
if (k > 0) {
|
|
2050
|
+
const at = `At ${timestamp(starts[k])},`;
|
|
2051
|
+
if (!para.startsWith(at)) out.push(`${seg.id}: [Shot ${k + 1}] must start with "${at}" (cumulative cut seconds)`);
|
|
2052
|
+
}
|
|
2053
|
+
if (c.camera && !para.toLowerCase().includes(String(c.camera).toLowerCase())) out.push(`${seg.id}: [Shot ${k + 1}] must mention its camera move "${c.camera}"`);
|
|
2054
|
+
// Keyframe k belongs to shot k: the alignment line says so, and the paragraph has to use it.
|
|
2055
|
+
if (params.references !== 'sheets' && !para.includes(`@image${k + 1}`)) out.push(`${seg.id}: [Shot ${k + 1}] must anchor on @image${k + 1} — that is the keyframe aligned to it`);
|
|
2056
|
+
});
|
|
2057
|
+
const tag = langTag(project.lang);
|
|
2058
|
+
for (const l of lines) {
|
|
2059
|
+
if (!prompt.includes(`<d>[${tag}] ${l.line}</d>`)) out.push(`${seg.id}: dialogue must appear verbatim as <d>[${tag}] ${l.line}</d>`);
|
|
2060
|
+
}
|
|
2061
|
+
out.push(...checkSpeechDirections(seg, prompt, lines, project, 'en', refs));
|
|
2062
|
+
const outside = prompt.replace(/<d>[\s\S]*?<\/d>/g, '');
|
|
2063
|
+
for (const n of names) if (containsName(outside, n)) out.push(`${seg.id}: prompt contains the name "${n}" outside <d> — use a generic identity`);
|
|
2064
|
+
if (!isEnglish(outside)) out.push(`${seg.id}: prompt must be English outside <d> blocks`);
|
|
2065
|
+
return out;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Every character who speaks in this segment must have their delivery in the prompt: the video model
|
|
2070
|
+
* reads a line the way the prompt tells it to, and left alone it reads everyone the same. The
|
|
2071
|
+
* character bible already decided how this person sounds — the same words that built their voice
|
|
2072
|
+
* sample — so the clip inherits it instead of inventing a new reading per segment.
|
|
2073
|
+
*/
|
|
2074
|
+
/** How far before a line the prompt may name its voice reference — room for a sentence of action. */
|
|
2075
|
+
const ATTRIBUTION_WINDOW = 400;
|
|
2076
|
+
|
|
2077
|
+
function truncateLine(text, max = 24) {
|
|
2078
|
+
const t = String(text);
|
|
2079
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function checkSpeechDirections(seg, prompt, lines, project, lang, refs = []) {
|
|
2083
|
+
const out = [];
|
|
2084
|
+
const voiceOf = new Map(refs.filter((r) => r.kind === 'audio').map((r) => [r.entity, r.label]));
|
|
2085
|
+
const seen = new Set();
|
|
2086
|
+
for (const l of lines) {
|
|
2087
|
+
if (seen.has(l.speaker)) continue;
|
|
2088
|
+
seen.add(l.speaker);
|
|
2089
|
+
const ch = characterById(project, l.speaker);
|
|
2090
|
+
if (!ch) continue;
|
|
2091
|
+
const direction = speechDirection(ch, '', lang);
|
|
2092
|
+
if (direction && !prompt.includes(direction)) out.push(`${seg.id}: ${l.speaker} speaks here, so the prompt must carry their delivery verbatim: "${direction}"`);
|
|
2093
|
+
}
|
|
2094
|
+
// Every single line has to be attributed to its voice reference, not just mentioned once: the model
|
|
2095
|
+
// decides per line which voice to read in, and an unattributed line is read in a default voice.
|
|
2096
|
+
for (const l of lines) {
|
|
2097
|
+
const voice = voiceOf.get(l.speaker);
|
|
2098
|
+
if (!voice) continue;
|
|
2099
|
+
const at = prompt.indexOf(l.line);
|
|
2100
|
+
if (at < 0) continue; // the verbatim check reports a missing line
|
|
2101
|
+
const lead = prompt.slice(Math.max(0, at - ATTRIBUTION_WINDOW), at);
|
|
2102
|
+
if (!lead.includes(voice)) out.push(`${seg.id}: the line "${truncateLine(l.line)}" is not attributed to ${voice} — every line this character speaks must name their voice reference (\`${voice} says: …\`), not only the legend`);
|
|
2103
|
+
}
|
|
2104
|
+
const audio = refs.filter((r) => r.kind === 'audio');
|
|
2105
|
+
for (const r of audio) {
|
|
2106
|
+
if (!prompt.includes(r.label)) out.push(`${seg.id}: ${r.label} (${r.why}) is connected to the node but never mentioned`);
|
|
2107
|
+
}
|
|
2108
|
+
const used = uniq([...prompt.matchAll(/@audio(\d+)/g)].map((m) => Number(m[1])));
|
|
2109
|
+
for (const k of used) if (k > audio.length) out.push(`${seg.id}: @audio${k} but only ${audio.length} voice reference(s) are connected`);
|
|
2110
|
+
// Same rule for the clip references: connected means used, and used means connected.
|
|
2111
|
+
const clips = refs.filter((r) => r.kind === 'video');
|
|
2112
|
+
for (const r of clips) {
|
|
2113
|
+
if (!prompt.includes(r.label)) out.push(`${seg.id}: ${r.label} (${r.why}) is connected to the node but never mentioned — say what to carry over from it`);
|
|
2114
|
+
}
|
|
2115
|
+
const usedVideo = uniq([...prompt.matchAll(/@video(\d+)/g)].map((m) => Number(m[1])));
|
|
2116
|
+
for (const k of usedVideo) if (k > clips.length) out.push(`${seg.id}: @video${k} but only ${clips.length} clip reference(s) are connected`);
|
|
2117
|
+
return out;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function shotParagraphs(prompt) {
|
|
2121
|
+
const map = new Map();
|
|
2122
|
+
const re = /\[Shot (\d+)\]\s*([\s\S]*?)(?=\n\[Shot \d+\]|\n\s*\n|$)/g;
|
|
2123
|
+
let m;
|
|
2124
|
+
while ((m = re.exec(prompt))) map.set(Number(m[1]), m[2].trim());
|
|
2125
|
+
return map;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
export function seedanceAnchors(n) {
|
|
2129
|
+
const lines = [];
|
|
2130
|
+
if (n >= 2) lines.push(`Use @image1 through @image${n} as keyframes in that order.`);
|
|
2131
|
+
lines.push('Use @image1 as the first frame.');
|
|
2132
|
+
if (n >= 2) lines.push(`Use @image${n} as the last frame.`);
|
|
2133
|
+
return lines;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/** Wan 3.0 Ref is prompted in Chinese, but its references keep WJI's own @image1… labels. */
|
|
2137
|
+
/** Sheets mode: the references are sheets, not keyframes, so the legend says what each one is. */
|
|
2138
|
+
export function wanSheetLegendLine(refs) {
|
|
2139
|
+
const parts = refs.map((r) => `${r.label}=${r.zh || r.use}`);
|
|
2140
|
+
return `\u53c2\u8003\u56fe\u8bf4\u660e\uff1a${parts.join('\uff1b')}\u3002\u4e25\u683c\u6309\u53c2\u8003\u56fe\u8fd8\u539f\u573a\u666f\u3001\u4eba\u7269\u4e0e\u9053\u5177\u3002`;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
export function wanLegendLine(cuts, refs = []) {
|
|
2144
|
+
const parts = cuts.map((_, i) => `@image${i + 1}=\u955c\u5934${i + 1}\u5173\u952e\u5e27`);
|
|
2145
|
+
// The voices are their own @audioN series, appended to the same legend.
|
|
2146
|
+
for (const r of refs.filter((x) => x.kind !== 'image')) parts.push(`${r.label}=${r.zh || r.use}`);
|
|
2147
|
+
return `\u53c2\u8003\u56fe\u8bf4\u660e\uff1a${parts.join('\uff1b')}\u3002\u6309\u987a\u5e8f\u8fd8\u539f\u6bcf\u4e2a\u955c\u5934\u7684\u6784\u56fe\u3001\u4eba\u7269\u5916\u5f62\u4e0e\u573a\u666f\u3002`;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/** `镜头k(0.0–3.0秒,中景,推近):` — every part derived from the cut, so it is checkable. */
|
|
2151
|
+
export function wanShotHeader(cuts, k) {
|
|
2152
|
+
const starts = cutStarts(cuts);
|
|
2153
|
+
const c = cuts[k];
|
|
2154
|
+
const end = starts[k] + (Number(c.seconds) || 0);
|
|
2155
|
+
const size = SIZES_ZH[c.size] || c.size;
|
|
2156
|
+
const cam = CAMERAS_ZH[c.camera] || c.camera;
|
|
2157
|
+
return `\u955c\u5934${k + 1}\uff08${starts[k].toFixed(1)}\u2013${end.toFixed(1)}\u79d2\uff0c${size}\uff0c${cam}\uff09\uff1a`;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function checkWanPrompt(seg, prompt, lines, names, params, project, sheetRefs = []) {
|
|
2161
|
+
const out = [];
|
|
2162
|
+
const cuts = seg.cuts;
|
|
2163
|
+
const firstLine = prompt.split('\n')[0].trim();
|
|
2164
|
+
const expected = params.references === 'sheets' ? wanSheetLegendLine(sheetRefs) : wanLegendLine(cuts, sheetRefs);
|
|
2165
|
+
if (firstLine !== expected) out.push(`${seg.id}: first line must be the reference legend derived from the ${params.references === 'sheets' ? 'sheets' : 'cuts'}:\n ${expected}`);
|
|
2166
|
+
cuts.forEach((c, k) => {
|
|
2167
|
+
const head = wanShotHeader(cuts, k);
|
|
2168
|
+
if (!prompt.includes(head)) out.push(`${seg.id}: prompt must contain verbatim "${head}"`);
|
|
2169
|
+
});
|
|
2170
|
+
if (lines.length && !prompt.includes('\u53f0\u8bcd')) out.push(`${seg.id}: spoken cuts need 台词(图k中的…):「…」 blocks`);
|
|
2171
|
+
for (const l of lines) if (!prompt.includes(l.line)) out.push(`${seg.id}: dialogue "${l.line}" must appear verbatim`);
|
|
2172
|
+
if (!prompt.includes('\u97f3\u9891\uff1a')) out.push(`${seg.id}: prompt needs an 音频: line (Wan 3.0 generates the sound with the picture)`);
|
|
2173
|
+
const accent = String(params.speechAccent || '').trim();
|
|
2174
|
+
if (accent && !prompt.includes(accent)) out.push(`${seg.id}: 音频 line must name the accent "${accent}" (params.speechAccent)`);
|
|
2175
|
+
out.push(...checkSpeechDirections(seg, prompt, lines, project, 'zh', sheetRefs));
|
|
2176
|
+
const outside = lines.reduce((p, l) => p.split(l.line).join(''), prompt);
|
|
2177
|
+
for (const n of names) if (outside.includes(n)) out.push(`${seg.id}: prompt contains the name "${n}" outside dialogue — identify people by @imagek`);
|
|
2178
|
+
if (prompt.length > 20000) out.push(`${seg.id}: prompt is ${prompt.length} chars, max 20,000`);
|
|
2179
|
+
return out;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function checkSeedancePrompt(seg, prompt, lines, names, params = {}, sheetCount = 0, project = {}, refs = []) {
|
|
2183
|
+
const out = [];
|
|
2184
|
+
const sheets = params.references === 'sheets';
|
|
2185
|
+
const n = sheets ? sheetCount : seg.cuts.length;
|
|
2186
|
+
const imageNs = [...prompt.matchAll(/@image(\d+)/g)].map((m) => Number(m[1]));
|
|
2187
|
+
if (sheets) {
|
|
2188
|
+
for (let k = 1; k <= n; k++) if (!imageNs.includes(k)) out.push(`${seg.id}: @image${k} (a sheet this segment references) is never mentioned — say what it is`);
|
|
2189
|
+
for (const r of uniq(imageNs)) if (r > n) out.push(`${seg.id}: @image${r} but the segment references ${n} sheet(s)`);
|
|
2190
|
+
for (const sentence of seedanceAnchors(seg.cuts.length)) if (prompt.includes(sentence)) out.push(`${seg.id}: "${sentence}" is a keyframe anchor — in sheets mode the references are sheets, not frames`);
|
|
2191
|
+
} else {
|
|
2192
|
+
for (const sentence of seedanceAnchors(n)) if (!prompt.includes(sentence)) out.push(`${seg.id}: prompt must contain verbatim "${sentence}"`);
|
|
2193
|
+
for (const r of uniq(imageNs)) if (r > n) out.push(`${seg.id}: @image${r} but the segment has ${n} keyframe(s)`);
|
|
2194
|
+
// The anchors only name the first and the last; every keyframe in between needs its own state.
|
|
2195
|
+
for (let k = 1; k <= n; k++) if (!imageNs.includes(k)) out.push(`${seg.id}: @image${k} (keyframe ${k}) is never mentioned — say what that frame defines`);
|
|
2196
|
+
}
|
|
2197
|
+
for (const c of seg.cuts) if (c.camera && !prompt.toLowerCase().includes(String(c.camera).toLowerCase())) out.push(`${seg.id}: prompt must mention the camera move "${c.camera}"`);
|
|
2198
|
+
for (const l of lines) if (!prompt.includes(l.line)) out.push(`${seg.id}: dialogue "${l.line}" must appear verbatim`);
|
|
2199
|
+
out.push(...checkSpeechDirections(seg, prompt, lines, project, 'en', refs));
|
|
2200
|
+
const outsideLines = lines.reduce((p, l) => p.split(l.line).join(''), prompt);
|
|
2201
|
+
for (const nm of names) if (containsName(outsideLines, nm)) out.push(`${seg.id}: prompt contains the name "${nm}" — use a generic identity`);
|
|
2202
|
+
if (!isEnglish(outsideLines)) out.push(`${seg.id}: prompt must be English apart from dialogue`);
|
|
2203
|
+
if (prompt.length > 10000) out.push(`${seg.id}: prompt is ${prompt.length} chars, max 10,000`);
|
|
2204
|
+
return out;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// ── validation driver ───────────────────────────────────────────────────────
|
|
2208
|
+
|
|
2209
|
+
export function parseEpRange(text) {
|
|
2210
|
+
if (!text) return null;
|
|
2211
|
+
const out = new Set();
|
|
2212
|
+
for (const part of String(text).split(',')) {
|
|
2213
|
+
const m = part.trim().match(/^(\d+)(?:-(\d+))?$/);
|
|
2214
|
+
if (!m) throw new Error(`bad --ep "${text}" (use 1, 1-3 or 1,3)`);
|
|
2215
|
+
const a = Number(m[1]), b = Number(m[2] ?? m[1]);
|
|
2216
|
+
for (let i = a; i <= b; i++) out.add(i);
|
|
2217
|
+
}
|
|
2218
|
+
return [...out].sort((a, b) => a - b);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
export function makeContext(project, episodes, opts = {}) {
|
|
2222
|
+
const params = paramsOf(project);
|
|
2223
|
+
const map = new Map();
|
|
2224
|
+
const wanted = opts.eps ? new Set(opts.eps) : null;
|
|
2225
|
+
for (const [ep, data] of Object.entries(episodes || {})) {
|
|
2226
|
+
if (!wanted || wanted.has(Number(ep))) map.set(Number(ep), data);
|
|
2227
|
+
}
|
|
2228
|
+
return { project, params, episodes: map };
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
export function runGates(ctx, opts = {}) {
|
|
2232
|
+
const stage = opts.stage;
|
|
2233
|
+
const stages = stage ? STAGES.slice(0, STAGES.indexOf(stage) + 1) : STAGES;
|
|
2234
|
+
const only = opts.only ? new Set(opts.only) : null;
|
|
2235
|
+
const results = [];
|
|
2236
|
+
for (const g of GATES) {
|
|
2237
|
+
if (!stages.includes(g.stage)) continue;
|
|
2238
|
+
if (only && !only.has(g.stage)) continue;
|
|
2239
|
+
if (['script', 'storyboard'].includes(g.stage) && !ctx.episodes.size) {
|
|
2240
|
+
results.push({ id: g.id, stage: g.stage, label: g.label, skipped: 'no episode files', failures: [] });
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
let findings;
|
|
2244
|
+
try {
|
|
2245
|
+
findings = g.run(ctx);
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
findings = [`gate crashed: ${err.message}`];
|
|
2248
|
+
}
|
|
2249
|
+
if (g.advisory) results.push({ id: g.id, stage: g.stage, label: g.label, advisory: true, failures: [], warnings: findings });
|
|
2250
|
+
else results.push({ id: g.id, stage: g.stage, label: g.label, failures: findings });
|
|
2251
|
+
}
|
|
2252
|
+
return results;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// ── seed ────────────────────────────────────────────────────────────────────
|
|
2256
|
+
|
|
2257
|
+
export function seedScript(project, ep) {
|
|
2258
|
+
const params = paramsOf(project);
|
|
2259
|
+
const outlineEp = (project.outline?.episodes || []).find((e) => Number(e.ep) === Number(ep));
|
|
2260
|
+
if (!outlineEp) throw new Error(`outline has no episode ${ep}`);
|
|
2261
|
+
const beats = beatsOfEpisode(project, ep);
|
|
2262
|
+
const prev = (project.outline?.episodes || []).find((e) => Number(e.ep) === Number(ep) - 1);
|
|
2263
|
+
// Everyone in this episode, with the speech rules the bible set for them — the writer reads these
|
|
2264
|
+
// at the point of writing instead of paging back to the character datasets.
|
|
2265
|
+
const speech = {};
|
|
2266
|
+
for (const id of outlineEp.characterIds || []) {
|
|
2267
|
+
const c = characterById(project, id);
|
|
2268
|
+
const sp = c?.bible?.speech || {};
|
|
2269
|
+
if (c) speech[id] = { name: c.name, rhythm: sp.rhythm || '', silence: sp.silence || '', catchphrase: sp.catchphrase || '', forbiddenWords: sp.forbiddenWords || [], register: sp.register || '', voice: c.voice?.pace || '' };
|
|
2270
|
+
}
|
|
2271
|
+
return {
|
|
2272
|
+
ep: Number(ep),
|
|
2273
|
+
targetSeconds: Math.round(Number(params.minutesPerEpisode) * 60),
|
|
2274
|
+
// The episode design from the outline's 分集目录 — gated against it in the script stage.
|
|
2275
|
+
function: asList(outlineEp.function),
|
|
2276
|
+
task: isObject(outlineEp.task) ? { ...outlineEp.task } : { goal: '', obstacle: '', stake: '' },
|
|
2277
|
+
change: outlineEp.change || '',
|
|
2278
|
+
hookType: outlineEp.hookType || '',
|
|
2279
|
+
hook: outlineEp.hook || '',
|
|
2280
|
+
cliff: outlineEp.suspense || '',
|
|
2281
|
+
beatsClaimed: beats.map((b) => b.id),
|
|
2282
|
+
hookBeat: [1, 1],
|
|
2283
|
+
satisfactionBeat: null,
|
|
2284
|
+
scenes: [],
|
|
2285
|
+
seedNote: {
|
|
2286
|
+
synopsis: outlineEp.synopsis || '',
|
|
2287
|
+
satisfaction: isObject(outlineEp.satisfaction) ? { ...outlineEp.satisfaction } : { type: '', action: '' },
|
|
2288
|
+
register: params.register,
|
|
2289
|
+
guidelinePriorities: asList(project.outline?.guideline?.priorities),
|
|
2290
|
+
guidelineForbidden: asList(project.outline?.guideline?.forbidden),
|
|
2291
|
+
speech,
|
|
2292
|
+
rhythm: `hook image by ${params.hookSeconds} s · a turn (new information / obstacle / counter / choice, \`turn: true\`) at least every ${params.turnSeconds} s · 爽点 (satisfactionBeat) on a visible action after the hook and before the cliff · no more than ${params.maxConsecutiveLines} lines in a row`,
|
|
2293
|
+
sceneIds: outlineEp.sceneIds || [],
|
|
2294
|
+
characterIds: outlineEp.characterIds || [],
|
|
2295
|
+
propIds: outlineEp.propIds || [],
|
|
2296
|
+
crowdPlan: outlineEp.crowdPlan || '',
|
|
2297
|
+
warnings: outlineEp.warnings || [],
|
|
2298
|
+
beats: beats.map((b) => ({ id: b.id, type: b.type, weight: b.weight || 'minor', setup: b.setup, payoff: b.payoff })),
|
|
2299
|
+
previousCliff: prev?.suspense || '',
|
|
2300
|
+
budget: `${Math.round(Number(params.minutesPerEpisode) * 60)} s ≈ ${Math.round((Number(params.minutesPerEpisode) * 60) / 3.3)} beats; lines at ${params.charsPerSecond} chars/s, actions ${params.actionSeconds} s each`,
|
|
2301
|
+
},
|
|
2302
|
+
storyboard: { segments: [] },
|
|
2303
|
+
wji: { screenplayDatasetId: '', canvasId: '', shotFolderId: '', displayNodes: {} },
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
export function seedStoryboard(project, episode) {
|
|
2308
|
+
const params = paramsOf(project);
|
|
2309
|
+
const seedScenes = (episode.scenes || []).map((sc, i) => ({
|
|
2310
|
+
sceneIndex: i + 1,
|
|
2311
|
+
sceneId: sc.sceneId,
|
|
2312
|
+
lighting: sc.lighting || '',
|
|
2313
|
+
characters: sc.characters || [],
|
|
2314
|
+
props: sc.props || [],
|
|
2315
|
+
beats: (sc.flow || []).map((b, k) => ({
|
|
2316
|
+
n: k + 1,
|
|
2317
|
+
kind: b.line !== undefined ? 'line' : 'action',
|
|
2318
|
+
seconds: round2(beatSeconds(b, params)),
|
|
2319
|
+
speaker: b.speaker,
|
|
2320
|
+
text: b.line !== undefined ? b.line : b.action,
|
|
2321
|
+
})),
|
|
2322
|
+
}));
|
|
2323
|
+
// The key scenes (the 爽点 scene, the cliff scene) get a visual-plan worksheet; every scene an axis hint.
|
|
2324
|
+
const key = keyScenes(episode);
|
|
2325
|
+
const visualPlans = key.map((si) => ({ sceneIndex: si, turn: '', alignment: '', pressure: '', progression: '', rhythm: '', landing: '', sound: '' }));
|
|
2326
|
+
seedScenes.forEach((sc) => {
|
|
2327
|
+
const pairs = [];
|
|
2328
|
+
const chars = sc.characters || [];
|
|
2329
|
+
for (let i = 0; i < chars.length; i++) for (let j = i + 1; j < chars.length; j++) pairs.push([chars[i], chars[j]]);
|
|
2330
|
+
sc.axisHint = pairs.length ? `declare segment.axis for ${pairs.map((p) => p.join(' ↔ ')).join(', ')}: who is screen-left, who is screen-right, which side the camera lives on` : 'single person — no axis needed';
|
|
2331
|
+
});
|
|
2332
|
+
return {
|
|
2333
|
+
...episode,
|
|
2334
|
+
storyboard: { plan: segmentPlan(project, episode), seedScenes, visualPlans, segments: [] },
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* How the episode has to be cut up for THIS model. A segment is one video generation, so its length is
|
|
2340
|
+
* the provider's duration range — 15 s on minimax-h3-ref and the Seedance 2.0 pair, 30 s on
|
|
2341
|
+
* seedance-2.5-ref and wan-3.0-ref. The same 90-second episode is therefore 3 segments on one model and
|
|
2342
|
+
* 7 on another, and the split has to follow the model rather than the writer's instinct.
|
|
2343
|
+
* A segment never spans two scenes, so each scene is divided on its own.
|
|
2344
|
+
*/
|
|
2345
|
+
export function segmentPlan(project, episode) {
|
|
2346
|
+
const params = paramsOf(project);
|
|
2347
|
+
const mode = videoModel(params);
|
|
2348
|
+
const lo = params.minSegmentSeconds, hi = params.maxSegmentSeconds;
|
|
2349
|
+
const scenes = (episode.scenes || []).map((sc, i) => {
|
|
2350
|
+
const seconds = round2((sc.flow || []).reduce((sum, b) => sum + beatSeconds(b, params), 0));
|
|
2351
|
+
// As few generations as the model allows, then spread the seconds evenly so no segment is under the floor.
|
|
2352
|
+
const count = Math.max(1, Math.ceil(seconds / hi));
|
|
2353
|
+
const each = seconds / count;
|
|
2354
|
+
return {
|
|
2355
|
+
sceneIndex: i + 1,
|
|
2356
|
+
sceneId: sc.sceneId,
|
|
2357
|
+
seconds,
|
|
2358
|
+
segments: count,
|
|
2359
|
+
secondsPerSegment: round2(each),
|
|
2360
|
+
note: each < lo ? `under ${mode.model}'s ${lo} s floor — merge this scene with the next or lengthen it` : '',
|
|
2361
|
+
};
|
|
2362
|
+
});
|
|
2363
|
+
return {
|
|
2364
|
+
model: mode.model,
|
|
2365
|
+
segmentSeconds: [lo, hi],
|
|
2366
|
+
wholeSecondsOnly: true,
|
|
2367
|
+
duration: params.videoDuration,
|
|
2368
|
+
maxReferences: mode.maxRefs,
|
|
2369
|
+
episodeSeconds: round2(scenes.reduce((sum, sc) => sum + sc.seconds, 0)),
|
|
2370
|
+
segments: scenes.reduce((sum, sc) => sum + sc.segments, 0),
|
|
2371
|
+
scenes,
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* The text sent to the voice model: a parenthetical acting direction followed by the line.
|
|
2377
|
+
* Measured on Seed Audio 1.0: the parenthetical is obeyed, not spoken (a 12-character line ran
|
|
2378
|
+
* 5.17 s plain and 4.88 s with the direction), so it costs nothing in the sample's length.
|
|
2379
|
+
*/
|
|
2380
|
+
export function voiceSamplePrompt(character, params = {}) {
|
|
2381
|
+
const v = character?.voice || {};
|
|
2382
|
+
const pf = character?.profile || {};
|
|
2383
|
+
const sample = v.sample || {};
|
|
2384
|
+
const line = String(sample.line || '').trim();
|
|
2385
|
+
const bits = [];
|
|
2386
|
+
const who = [pf.ageRange, pf.gender].filter(Boolean).join('');
|
|
2387
|
+
if (who) bits.push(who);
|
|
2388
|
+
if (pf.identity) bits.push(String(pf.identity));
|
|
2389
|
+
const personality = (pf.personality || []).filter(Boolean).join('\u3001');
|
|
2390
|
+
if (personality) bits.push(`\u6027\u683c${personality}`);
|
|
2391
|
+
for (const key of ['timbre', 'pitch', 'pace', 'accent', 'emotion']) {
|
|
2392
|
+
if (v[key]) bits.push(String(v[key]).trim());
|
|
2393
|
+
}
|
|
2394
|
+
// `voice.accent` is the drama-language wording and wins; speechAccent only fills a gap.
|
|
2395
|
+
const accent = String(params.speechAccent || '').trim();
|
|
2396
|
+
if (accent && !v.accent) bits.push(accent);
|
|
2397
|
+
if (sample.direction) bits.push(`\u6b64\u523b${String(sample.direction).trim()}`);
|
|
2398
|
+
// Seed Audio will happily add a room, music and sfx; a timbre reference has to be a dry voice.
|
|
2399
|
+
bits.push('\u5e72\u58f0\uff0c\u65e0\u914d\u4e50\u65e0\u97f3\u6548');
|
|
2400
|
+
// Quote marks inside the direction get read aloud; the parenthetical is an instruction, not text.
|
|
2401
|
+
const brief = bits.join('\uff1b').replace(QUOTE_RE_G, '');
|
|
2402
|
+
return brief ? `\uff08${brief}\uff09${line}` : line;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
/** How long the voice model will take over the sample line (its speech rate, not the reading rate). */
|
|
2406
|
+
export function voiceSampleSeconds(character, params) {
|
|
2407
|
+
return nonWhitespace(character?.voice?.sample?.line) / (Number(params.speechCharsPerSecond) || 2.4);
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/** Sheets a segment needs, in reference order: environment [+ lighting state], each person (first appearance), each prop. */
|
|
2411
|
+
export function segmentReferences(project, episode, seg) {
|
|
2412
|
+
const scene = (episode.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
2413
|
+
const sceneEntity = sceneById(project, scene.sceneId) || {};
|
|
2414
|
+
const refs = [];
|
|
2415
|
+
refs.push({ entity: scene.sceneId, asset: 'sheet', why: `environment: ${sceneEntity.name || scene.sceneId}${scene.lighting ? ` (${scene.lighting})` : ''}`, use: 'the environment \u2014 match its layout, materials and wear exactly', zh: '环境,严格还原布局、材质与旧痕' });
|
|
2416
|
+
if (scene.lighting && sceneEntity.wji?.assets?.lighting?.[scene.lighting]) refs.push({ entity: scene.sceneId, asset: `lighting:${scene.lighting}`, why: `lighting state ${scene.lighting}`, use: 'the same environment in the lighting state to use', zh: '同一场景的光线状态' });
|
|
2417
|
+
const seen = new Set();
|
|
2418
|
+
const params = paramsOf(project);
|
|
2419
|
+
if (params.humanReferences !== false) for (const c of seg.cuts || []) for (const id of c.characters || []) if (!seen.has(id)) { seen.add(id); refs.push({ entity: id, asset: 'sheet', why: `face and clothes of ${characterById(project, id)?.name || id}`, use: 'the character sheet of one person in this segment \u2014 match face, hair and clothes exactly', zh: '人物三视图,严格还原面容、发型与服装' }); }
|
|
2420
|
+
for (const c of seg.cuts || []) for (const id of c.props || []) if (!seen.has(id)) { seen.add(id); refs.push({ entity: id, asset: 'sheet', why: `prop ${propById(project, id)?.name || id}`, use: 'the prop to reproduce exactly', zh: '需要严格还原的道具' }); }
|
|
2421
|
+
refs.forEach((r) => (r.kind = 'image'));
|
|
2422
|
+
// The clip before this one, when the project asks for it: the cheapest continuity there is — the
|
|
2423
|
+
// model sees what the last generation actually produced, not a description of it.
|
|
2424
|
+
if (params.videoReferences === 'previous') {
|
|
2425
|
+
const all = episode.storyboard?.segments || [];
|
|
2426
|
+
const at = all.findIndex((x) => x.id === seg.id);
|
|
2427
|
+
const prev = at > 0 ? all[at - 1] : null;
|
|
2428
|
+
if (prev) refs.push({ entity: prev.id, asset: 'video', kind: 'video', why: `the clip before this one (${prev.id})` });
|
|
2429
|
+
}
|
|
2430
|
+
// The voice of everyone who SPEAKS in this segment: an audio reference, so the clip is read in the
|
|
2431
|
+
// timbre the character bible fixed rather than whatever the video model invents per segment.
|
|
2432
|
+
if (params.voiceSamples !== false) {
|
|
2433
|
+
const speakers = [];
|
|
2434
|
+
for (const c of seg.cuts || []) {
|
|
2435
|
+
for (const l of claimedLines(scene, [c])) if (!speakers.includes(l.speaker)) speakers.push(l.speaker);
|
|
2436
|
+
}
|
|
2437
|
+
for (const id of speakers) {
|
|
2438
|
+
const ch = characterById(project, id);
|
|
2439
|
+
if (!ch) continue;
|
|
2440
|
+
refs.push({ entity: id, asset: 'voice', kind: 'audio', why: `voice of ${ch.name || id}` });
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
// WJI numbers connected references PER TYPE, in connection order — @image1…, @video1…, @audio1…
|
|
2444
|
+
// (frontend referenceOrder.ts) — so the voices restart at 1 rather than continuing the images.
|
|
2445
|
+
const next = { image: 0, video: 0, audio: 0 };
|
|
2446
|
+
refs.forEach((r) => (r.label = `@${r.kind}${(next[r.kind] += 1)}`));
|
|
2447
|
+
// A voice is only useful if the prompt says WHOSE it is — by their sheet label when one is
|
|
2448
|
+
// connected, otherwise by their name-free identity.
|
|
2449
|
+
// Only in sheets mode do the @imageN labels belong to the sheets; in keyframes mode they are the
|
|
2450
|
+
// keyframes, so a voice must be tied to its person by description instead.
|
|
2451
|
+
for (const r of refs) {
|
|
2452
|
+
if (r.kind !== 'video') continue;
|
|
2453
|
+
r.use = `the clip immediately before this one — continue its look, wardrobe, lighting and camera language, and pick up where it left off; do not repeat its action`;
|
|
2454
|
+
r.zh = `\u4e0a\u4e00\u6bb5\u7684\u6210\u7247\uff0c\u5ef6\u7eed\u5b83\u7684\u8d28\u611f\u3001\u670d\u88c5\u3001\u5149\u7ebf\u4e0e\u955c\u5934\u8bed\u8a00\uff0c\u63a5\u7740\u5b83\u5f80\u4e0b\u6f14\uff0c\u4e0d\u8981\u91cd\u590d\u5b83\u7684\u52a8\u4f5c`;
|
|
2455
|
+
}
|
|
2456
|
+
const sheetOf = params.references === 'sheets'
|
|
2457
|
+
? new Map(refs.filter((r) => r.kind === 'image' && r.asset === 'sheet').map((r) => [r.entity, r.label]))
|
|
2458
|
+
: new Map();
|
|
2459
|
+
for (const r of refs) {
|
|
2460
|
+
if (r.kind !== 'audio') continue;
|
|
2461
|
+
const ch = characterById(project, r.entity);
|
|
2462
|
+
const who = sheetOf.get(r.entity) ? `the person in ${sheetOf.get(r.entity)}` : identityOf(ch);
|
|
2463
|
+
// The Chinese legend uses the drama-language identity — an English phrase inside a wan prompt
|
|
2464
|
+
// is both ugly and one more thing for the content filter to chew on.
|
|
2465
|
+
const whoZh = sheetOf.get(r.entity) ? `${sheetOf.get(r.entity)}\u4e2d\u4eba\u7269` : (ch?.profile?.identity || identityOf(ch));
|
|
2466
|
+
r.use = `the voice of ${who} — speak their lines in it, matching its timbre, pitch, pace and accent`;
|
|
2467
|
+
r.zh = `${whoZh}\u7684\u58f0\u7ebf\uff0c\u53f0\u8bcd\u7528\u8fd9\u4e2a\u58f0\u97f3\u8bf4`;
|
|
2468
|
+
}
|
|
2469
|
+
return refs;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/** Just the picture references — what `@imageN` counts, whatever else is connected. */
|
|
2473
|
+
export function imageReferences(project, episode, seg) {
|
|
2474
|
+
return segmentReferences(project, episode, seg).filter((r) => r.kind === 'image');
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/** The voice references, in `@audioN` order. */
|
|
2478
|
+
export function audioReferences(project, episode, seg) {
|
|
2479
|
+
return segmentReferences(project, episode, seg).filter((r) => r.kind === 'audio');
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/** The clip references, in `@videoN` order. */
|
|
2483
|
+
export function videoReferences(project, episode, seg) {
|
|
2484
|
+
return segmentReferences(project, episode, seg).filter((r) => r.kind === 'video');
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// \u2500\u2500 prompt skeleton ─────────────────────────────────────────────────────────
|
|
2488
|
+
|
|
2489
|
+
export function promptSkeleton(project, episode, segmentId) {
|
|
2490
|
+
const params = paramsOf(project);
|
|
2491
|
+
const seg = (episode.storyboard?.segments || []).find((s) => s.id === segmentId);
|
|
2492
|
+
if (!seg) throw new Error(`no segment ${segmentId} in E${pad2(episode.ep)}`);
|
|
2493
|
+
const scene = (episode.scenes || [])[Number(seg.sceneIndex) - 1];
|
|
2494
|
+
if (!scene) throw new Error(`${segmentId}: sceneIndex ${seg.sceneIndex} does not exist`);
|
|
2495
|
+
const cuts = seg.cuts || [];
|
|
2496
|
+
const starts = cutStarts(cuts);
|
|
2497
|
+
const tag = langTag(project.lang);
|
|
2498
|
+
const total = cuts.reduce((s, c) => s + (Number(c.seconds) || 0), 0);
|
|
2499
|
+
|
|
2500
|
+
// A prompt never carries a name: the speaker is their reference label (sheets mode) or their
|
|
2501
|
+
// short generic identity, and it carries the delivery the character bible set for that voice.
|
|
2502
|
+
const allRefs = segmentReferences(project, episode, seg);
|
|
2503
|
+
const sheetLabels = new Map(allRefs.filter((r) => r.kind === 'image' && r.asset === 'sheet').map((r) => [r.entity, r.label]));
|
|
2504
|
+
const voiceLabels = new Map(allRefs.filter((r) => r.kind === 'audio').map((r) => [r.entity, r.label]));
|
|
2505
|
+
const promptLang = videoModel(params).promptLang;
|
|
2506
|
+
const described = new Set();
|
|
2507
|
+
const dialogue = cuts.map((c, k) => claimedLines(scene, [c]).map((l) => {
|
|
2508
|
+
const ch = characterById(project, l.speaker);
|
|
2509
|
+
const voice = voiceLabels.get(l.speaker);
|
|
2510
|
+
const sheet = params.references === 'sheets' ? sheetLabels.get(l.speaker) : undefined;
|
|
2511
|
+
// The full voice description goes on this character's FIRST line in the segment; after that the
|
|
2512
|
+
// @audioN label carries it, and only the beat's own delivery is added. Repeating 60 characters of
|
|
2513
|
+
// timbre on every line buys nothing and lengthens a prompt the content filter judges by length.
|
|
2514
|
+
const first = !described.has(l.speaker);
|
|
2515
|
+
described.add(l.speaker);
|
|
2516
|
+
// A line is attributed by the reference that carries the voice — that is what the model has to
|
|
2517
|
+
// read it in. The sheet label rides along so the picture and the voice are tied to one person.
|
|
2518
|
+
const who = [voice, sheet].filter(Boolean).join(' / ') || identityOf(ch);
|
|
2519
|
+
return {
|
|
2520
|
+
...l,
|
|
2521
|
+
cut: k + 1,
|
|
2522
|
+
name: ch?.name || l.speaker,
|
|
2523
|
+
speakerRef: who,
|
|
2524
|
+
voiceRef: voice,
|
|
2525
|
+
// The beat's own `delivery` is script prose in the drama's language; it only goes into a prompt
|
|
2526
|
+
// written in that language. An English prompt carries the character's English videoDirection,
|
|
2527
|
+
// and the author adds a per-line acting note in English if they want one.
|
|
2528
|
+
direction: speechDirection(ch, l.delivery, first ? promptLang : (promptLang === 'zh' ? 'zh-note' : null)),
|
|
2529
|
+
};
|
|
2530
|
+
}));
|
|
2531
|
+
|
|
2532
|
+
const mode = videoModel(params);
|
|
2533
|
+
const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
|
|
2534
|
+
const accent = String(params.speechAccent || '').trim();
|
|
2535
|
+
const sound = isObject(seg.sound) ? seg.sound : {};
|
|
2536
|
+
const sfxOf = (k) => asList(sound.sfx).filter((x) => Number(x?.cut) === k + 1).map((x) => String(x.event)).join('; ');
|
|
2537
|
+
// Who else is on screen while `speaker` has a line: the H3 idiom keeps the lip-sync off them.
|
|
2538
|
+
// In keyframes mode the @imageN labels are frames, so a person is named by identity instead.
|
|
2539
|
+
const othersOnScreen = (c, speaker) => (c.characters || []).filter((id) => id !== speaker).map((id) => (params.references === 'sheets' && sheetLabels.get(id) ? `the person in ${sheetLabels.get(id)}` : identityOf(characterById(project, id))));
|
|
2540
|
+
let video;
|
|
2541
|
+
if (mode.family === 'h3') {
|
|
2542
|
+
const sheets = params.references === 'sheets';
|
|
2543
|
+
const shots = cuts.map((c, k) => {
|
|
2544
|
+
// Every line names who else is on screen and keeps their lips closed — measured on H3: without
|
|
2545
|
+
// it the lip-sync lands on the more visible face.
|
|
2546
|
+
const d = dialogue[k].map((l) => {
|
|
2547
|
+
const others = othersOnScreen(c, l.speaker);
|
|
2548
|
+
return `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says <d>[${tag}] ${l.line}</d>${others.length ? ` while ${others.join(' and ')} ${others.length > 1 ? 'do' : 'does'} not speak, ${LIPS_CLOSED}` : ''}`;
|
|
2549
|
+
}).join(' ');
|
|
2550
|
+
const lips = '';
|
|
2551
|
+
const head = k === 0 ? `[Shot 1] ` : `[Shot ${k + 1}] At ${timestamp(starts[k])}, `;
|
|
2552
|
+
// In sheets mode the cut's own `frame` goes into the prompt and already carries the lens, the
|
|
2553
|
+
// light and the foreground (gated there), so only the shot size and camera move are added.
|
|
2554
|
+
const body = sheets
|
|
2555
|
+
? `${c.frame || '…'} ${SIZES[c.size] || c.size}, ${c.camera}.`
|
|
2556
|
+
: `… anchor on @image${k + 1} · ${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}. ${lightPhrase(c)}.${foregroundPhrase(c)}`;
|
|
2557
|
+
return `${head}${body} ${motionSentence(c)} ${cameraClause(c)} ${d}${lips}`.replace(/\s+/g, ' ').trim();
|
|
2558
|
+
});
|
|
2559
|
+
// Keyframes are aligned by the alignment line; the voices are named either way.
|
|
2560
|
+
const voiceLegend = allRefs.filter((r) => r.kind !== 'image').map((r) => `${r.label} is ${r.use}.`);
|
|
2561
|
+
const lead = sheets
|
|
2562
|
+
? allRefs.map((r) => `${r.label} is ${r.use}.`)
|
|
2563
|
+
: [h3AlignmentLine(cuts), ...voiceLegend];
|
|
2564
|
+
const look = sheets ? [`Look: ${preset.videoPhrase}${params.lookPhrase ? `, ${params.lookPhrase}` : ''}.`, ''] : [];
|
|
2565
|
+
const soundscape = [sound.ambience || '…', ...cuts.map((c, k) => (sfxOf(k) ? `at [Shot ${k + 1}] ${sfxOf(k)}` : '')).filter(Boolean)].join('; ');
|
|
2566
|
+
video = [...lead, '', ...look, 'integrated_multimodal_description:', ...shots, '', `overall_soundscape: ${soundscape}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`, '', `non_diegetic_music: ${musicOf(sound).intent || 'N/A'}`].join('\n');
|
|
2567
|
+
} else if (mode.family === 'wan') {
|
|
2568
|
+
const blocks = cuts.map((c, k) => {
|
|
2569
|
+
const talk = dialogue[k].map((l) => `台词(${l.speakerRef},${l.direction || '…'}):「${l.line}」`);
|
|
2570
|
+
// Wan's prompt is Chinese: the lens and the light direction translate, the note and the
|
|
2571
|
+
// foreground are the author's English prose, so they are left as blanks to write in Chinese.
|
|
2572
|
+
const shot = `${cameraPhrase(c, params, 'zh')}。${LIGHT_DIRECTIONS_ZH[c.light?.direction] || '…'},…。前景:${c.foreground ? '…(虚化)' : '无'}。`;
|
|
2573
|
+
return [`${wanShotHeader(cuts, k)}…${shot}${motionSentence(c, 'zh')}${cameraClause(c, 'zh')}`, ...talk].join('\n');
|
|
2574
|
+
});
|
|
2575
|
+
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `镜头${k + 1}:${sfxOf(k)}` : '')).filter(Boolean).join(';');
|
|
2576
|
+
video = [
|
|
2577
|
+
params.references === 'sheets' ? wanSheetLegendLine(allRefs) : wanLegendLine(cuts, allRefs), '',
|
|
2578
|
+
...blocks.flatMap((b) => [b, '']),
|
|
2579
|
+
`音频:对白为${accent || '…'},环境音${sound.ambience || '…'}${sfxAll ? `;${sfxAll}` : ''}${musicOf(sound).intent ? `;配乐:${musicOf(sound).intent}` : ''}。${params.subtitles ? '' : `${NO_SUBTITLES.zh}。`}`,
|
|
2580
|
+
].join('\n');
|
|
2581
|
+
} else if (params.references === 'sheets') {
|
|
2582
|
+
const legend = allRefs.map((r) => `${r.label} is ${r.use}.`);
|
|
2583
|
+
const shots = cuts.map((c, k) => {
|
|
2584
|
+
const talk = dialogue[k].map((l) => `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`).join(' ');
|
|
2585
|
+
// The frame already carries the lens, the light and the foreground — do not repeat them.
|
|
2586
|
+
return `Shot ${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s, ${SIZES[c.size] || c.size}, ${c.camera}): ${c.frame || '…'} ${motionSentence(c)} ${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
|
|
2587
|
+
});
|
|
2588
|
+
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `shot ${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
|
|
2589
|
+
video = [...legend, '', ...shots, '', `Look: ${preset.videoPhrase}${params.lookPhrase ? `, ${params.lookPhrase}` : ''}. Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis across every shot.`, `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${musicOf(sound).intent ? ` Music: ${musicOf(sound).intent}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
|
|
2590
|
+
} else {
|
|
2591
|
+
const anchors = [...seedanceAnchors(cuts.length), ...allRefs.filter((r) => r.kind !== 'image').map((r) => `${r.label} is ${r.use}.`)];
|
|
2592
|
+
const shotOf = (c) => `${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}; ${lightPhrase(c)}${foregroundPhrase(c)}`;
|
|
2593
|
+
const states = cuts.map((c, k) => (k === 0 ? `This first frame defines … (${shotOf(c)}).` : k === cuts.length - 1 && cuts.length >= 2 ? `This last frame defines … (${shotOf(c)}).` : `@image${k + 1} defines keyframe ${k + 1}: … (${shotOf(c)}).`));
|
|
2594
|
+
const script = cuts.map((c, k) => {
|
|
2595
|
+
const talk = dialogue[k].map((l) => `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`).join(' ');
|
|
2596
|
+
return `From @image${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s): ${motionSentence(c)} ${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
|
|
2597
|
+
});
|
|
2598
|
+
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `from @image${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
|
|
2599
|
+
video = [...anchors, ...states, '', ...script, '', 'Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis.', `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${musicOf(sound).intent ? ` Music: ${musicOf(sound).intent}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
const sceneEntity = sceneById(project, scene.sceneId) || {};
|
|
2603
|
+
const keyframes = params.references === 'sheets' ? [] : cuts.map((c, k) => {
|
|
2604
|
+
// `why` is for people (may carry names); `use` goes into the prompt and must stay generic.
|
|
2605
|
+
const refs = [];
|
|
2606
|
+
refs.push({ entity: scene.sceneId, asset: 'sheet', why: `environment: ${sceneEntity.name || scene.sceneId}${scene.lighting ? ` (${scene.lighting})` : ''}`, use: 'the environment — match its layout, materials and wear exactly', zh: '环境,严格还原布局、材质与旧痕' });
|
|
2607
|
+
if (scene.lighting && sceneEntity.wji?.assets?.lighting?.[scene.lighting]) refs.push({ entity: scene.sceneId, asset: `lighting:${scene.lighting}`, why: `lighting state ${scene.lighting}`, use: 'the same environment in the lighting state to use', zh: '同一场景的光线状态' });
|
|
2608
|
+
for (const id of c.characters || []) refs.push({ entity: id, asset: 'sheet', why: `face and clothes of ${characterById(project, id)?.name || id}`, use: 'the character sheet of one person in this frame — match face, hair and clothes exactly', zh: '人物三视图,严格还原面容、发型与服装' });
|
|
2609
|
+
for (const id of c.props || []) refs.push({ entity: id, asset: 'sheet', why: `prop ${propById(project, id)?.name || id}`, use: 'the prop to reproduce exactly', zh: '需要严格还原的道具' });
|
|
2610
|
+
refs.forEach((r, i) => (r.label = `@image${i + 1}`));
|
|
2611
|
+
const prefix = refs.map((r) => `${r.label} is ${r.use}`).join('; ');
|
|
2612
|
+
return { cut: k + 1, name: `${seg.id} f${k + 1}`, ratio: params.ratio, references: refs, prompt: `${prefix}. ${c.frame}` };
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
return {
|
|
2616
|
+
segment: seg.id,
|
|
2617
|
+
video: {
|
|
2618
|
+
model: mode.model, ratio: params.ratio,
|
|
2619
|
+
duration: params.videoDuration === 'auto' ? null : total, plannedSeconds: total,
|
|
2620
|
+
resolution: videoResolutionFor(mode, params.videoResolution) || params.videoResolution,
|
|
2621
|
+
// keyframes (or sheets) first, then the voices — the order the video node must be connected in.
|
|
2622
|
+
references: [
|
|
2623
|
+
...(params.references === 'sheets' ? allRefs.filter((r) => r.kind === 'image').map((r) => `${r.entity}:${r.asset}`) : keyframes.map((k) => k.name)),
|
|
2624
|
+
...allRefs.filter((r) => r.kind === 'video').map((r) => `${r.entity}:${r.asset}`),
|
|
2625
|
+
...allRefs.filter((r) => r.kind === 'audio').map((r) => `${r.entity}:${r.asset}`),
|
|
2626
|
+
],
|
|
2627
|
+
prompt: video,
|
|
2628
|
+
},
|
|
2629
|
+
keyframes,
|
|
2630
|
+
dialogue: dialogue.flat(),
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
// ── render (markdown for dataset content) ───────────────────────────────────
|
|
2635
|
+
|
|
2636
|
+
function mdTable(headers, rows) {
|
|
2637
|
+
const esc = (v) => String(v ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
2638
|
+
return [`| ${headers.join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |`, ...rows.map((r) => `| ${r.map(esc).join(' | ')} |`)].join('\n');
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
export function renderOutline(project) {
|
|
2642
|
+
const o = project.outline || {};
|
|
2643
|
+
const params = paramsOf(project);
|
|
2644
|
+
const lines = [`# ${project.title} · 改编大纲`, '', `${params.episodes} 集 × ${params.minutesPerEpisode} 分钟 · ${params.genre} · ${params.adaptMode}${params.register ? ` · ${params.register}` : ''}`, '', `**核心**:${o.core || ''}`, ''];
|
|
2645
|
+
const block = (title, items, fmt) => {
|
|
2646
|
+
if (!(items || []).length) return;
|
|
2647
|
+
lines.push(`## ${title}`, '');
|
|
2648
|
+
for (const it of items) lines.push(`- ${fmt(it)}`);
|
|
2649
|
+
lines.push('');
|
|
2650
|
+
};
|
|
2651
|
+
block('保留', o.keep, (it) => `**${it.what}** — ${it.why}${it.evidence ? `(原文:${it.evidence})` : ''}`);
|
|
2652
|
+
block('砍掉', o.cut, (it) => `**${it.what}** — ${it.why}`);
|
|
2653
|
+
if (o.cutNote) lines.push(`> ${o.cutNote}`, '');
|
|
2654
|
+
block('合并', o.merge, (it) => `**${it.what}** — ${it.why}`);
|
|
2655
|
+
if (o.mergeNote) lines.push(`> ${o.mergeNote}`, '');
|
|
2656
|
+
block('风险', o.risks, (it) => `**${it.what}** → ${it.plan}`);
|
|
2657
|
+
if (isObject(o.brief)) {
|
|
2658
|
+
const b = o.brief;
|
|
2659
|
+
lines.push('## 创作简报', '', mdTable(['项', '内容'], [
|
|
2660
|
+
['一句话卖点', b.logline], ['目标观众', b.audience], ['核心爽点引擎', b.satisfactionEngine], ['最大钩子', b.mainHook],
|
|
2661
|
+
['主角压力', b.protagonistPressure], ['反派压力', b.antagonistPressure], ['情感主线', b.emotionalLine], ['情绪承诺', b.promise],
|
|
2662
|
+
]), '');
|
|
2663
|
+
}
|
|
2664
|
+
if (Array.isArray(o.structure) && o.structure.length) {
|
|
2665
|
+
lines.push('## 全剧结构', '', mdTable(['阶段', '集数', '创作任务', '爽点策略', '钩子策略'], o.structure.map((ph) => [PHASE_LABELS[ph.phase] || ph.phase, `第 ${(ph.episodes || [])[0]}–${(ph.episodes || [])[1]} 集`, ph.task, ph.satisfactionPlan, ph.hookPlan])), '');
|
|
2666
|
+
}
|
|
2667
|
+
if ((o.episodes || []).some((e) => e.function || e.task)) {
|
|
2668
|
+
lines.push('## 分集目录', '', mdTable(['集', '功能', '任务(目标/阻力/代价)', '改变', '爽点', '钩子'], (o.episodes || []).map((e) => [
|
|
2669
|
+
e.ep, asList(e.function).join('、'), e.task ? `${e.task.goal || ''}/${e.task.obstacle || ''}/${e.task.stake || ''}` : '', e.change || '',
|
|
2670
|
+
e.satisfaction ? `${e.satisfaction.type || ''}:${e.satisfaction.action || ''}` : '', e.hookType || '',
|
|
2671
|
+
])), '');
|
|
2672
|
+
}
|
|
2673
|
+
lines.push('## 人物表', '', mdTable(['ID', '姓名', '档', '定位', '人物弧', '来源'], (project.characters || []).map((c) => [c.id, c.name, c.tier, c.role, c.arc || '', (c.from || []).join('、')])), '');
|
|
2674
|
+
lines.push('## 场景', '', mdTable(['ID', '场景', '主场景', '复用方案'], (project.scenes || []).map((s) => [s.id, s.name, s.primary ? '是' : '', s.variantOf ? `变体 ← ${s.variantOf}` : s.reusePlan || ''])), '');
|
|
2675
|
+
if ((project.props || []).length) lines.push('## 叙事道具', '', mdTable(['ID', '道具', '承载', '爽点'], project.props.map((p) => [p.id, p.name, p.function, (p.beatIds || []).join('、')])), '');
|
|
2676
|
+
lines.push('## 爽点表', '', mdTable(['ID', '集', '类型', '权重', '铺垫', '兑现'], (o.beats || []).map((b) => [b.id, b.episode, b.type, b.weight || 'minor', b.setup, b.payoff])), '');
|
|
2677
|
+
lines.push('## 分集梗概', '');
|
|
2678
|
+
for (const e of o.episodes || []) {
|
|
2679
|
+
lines.push(`### 第 ${e.ep} 集`, '', e.synopsis || '', '', `- **钩子**:${e.hook || ''}`, `- **悬念**:${e.suspense || ''}`, `- 场景:${(e.sceneIds || []).join('、')} · 人物:${(e.characterIds || []).join('、')}${(e.propIds || []).length ? ` · 道具:${e.propIds.join('、')}` : ''}`);
|
|
2680
|
+
if (e.crowdPlan) lines.push(`- 同框方案:${e.crowdPlan}`);
|
|
2681
|
+
if ((e.warnings || []).length) lines.push(`- ⚠️ ${e.warnings.join('、')}`);
|
|
2682
|
+
lines.push('');
|
|
2683
|
+
}
|
|
2684
|
+
return lines.join('\n');
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/**
|
|
2688
|
+
* The 创作指引 — the top-level rule document of the drama, in the reference skill's shape. It is the
|
|
2689
|
+
* thing to re-read before writing any character, episode or storyboard, so it is its own dataset.
|
|
2690
|
+
*/
|
|
2691
|
+
export function renderGuideline(project) {
|
|
2692
|
+
const o = project.outline || {};
|
|
2693
|
+
const params = paramsOf(project);
|
|
2694
|
+
const b = isObject(o.brief) ? o.brief : {};
|
|
2695
|
+
const g = isObject(o.guideline) ? o.guideline : {};
|
|
2696
|
+
const lines = [`# 《${project.title}》创作指引`, '', '> 本文件是本短剧项目的最高创作指引。每次分析、创作、修改、续写、重写、格式检查或输出前,必须先阅读本文件,再阅读相关数据集。若本文件与单集临时想法冲突,除非用户明确更新创作指引,否则以创作指引为准。', ''];
|
|
2697
|
+
lines.push('## 1. 项目基本信息', '', mdTable(['项目', '内容'], [
|
|
2698
|
+
['剧名', project.title], ['类型定位', params.genre], ['改编方式', params.adaptMode], ['语言与语体', `${langTag(project.lang)} · ${params.register || ''}${params.speechAccent ? `(${params.speechAccent})` : ''}`],
|
|
2699
|
+
['总集数', `${params.episodes} 集`], ['单集时长', `${params.minutesPerEpisode} 分钟(目标 ${Math.round(Number(params.minutesPerEpisode) * 60)} 秒,±${Math.round(params.tolerance * 100)}%)`],
|
|
2700
|
+
['画幅与模型', `${params.ratio} · ${videoModel(params).model}${params.videoResolution ? ` ${params.videoResolution}` : ''} · ${params.style}`],
|
|
2701
|
+
]), '');
|
|
2702
|
+
lines.push('## 2. 一句话卖点与核心观众承诺', '', `**一句话卖点**:${b.logline || ''}`, '', `**观众承诺**:${b.promise || ''}`, '', `**核心爽点引擎**:${b.satisfactionEngine || ''}`, '', `**最大钩子**:${b.mainHook || ''}`, '');
|
|
2703
|
+
lines.push('## 3. 风格、类型与语气规则', '', mdTable(['维度', '创作规则'], [
|
|
2704
|
+
['整体风格', g.tone], ['情绪强度', g.emotionalIntensity], ['对白风格', g.dialogueStyle], ['视觉风格', g.visualStyle], ['禁忌', asList(g.forbidden).join(';')],
|
|
2705
|
+
]), '');
|
|
2706
|
+
lines.push('## 4. 压力、情感线与制作边界', '', `**主角压力**:${b.protagonistPressure || ''}`, '', `**反派压力**:${b.antagonistPressure || ''}`, '', `**情感主线**:${b.emotionalLine || ''}`, '', `**制作边界**:${g.productionLimits || ''}`, '', `**单集节奏规格**:开头 ${params.hookSeconds} 秒内钩子画面上屏;每 ${params.turnSeconds} 秒一次推进(新信息/新阻碍/新反击/新选择);每集一个由可见行动完成的爽点;结尾停在钩子上;对白不超过 ${params.maxLineChars} 字一口气,连续对白不超过 ${params.maxConsecutiveLines} 句。`, '');
|
|
2707
|
+
lines.push('## 5. 角色创作与人物连续性重点', '', mdTable(['角色', '剧情功能', 'Want vs Need', '核心矛盾', '声音与行为规则', '禁止偏离'], (project.characters || []).map((c) => {
|
|
2708
|
+
const bb = isObject(c.bible) ? c.bible : {};
|
|
2709
|
+
const sp = isObject(bb.speech) ? bb.speech : {};
|
|
2710
|
+
return [c.name, bb.function || '', bb.want || bb.need ? `${bb.want || ''}/${bb.need || ''}` : '', bb.contradiction || '', [sp.rhythm, sp.silence, bb.pressureResponse].filter(Boolean).join(';'), asList(bb.forbidden).join(';')];
|
|
2711
|
+
})), '', '角色详细资料以各人物数据集为准;如创作指引与人物圣经不一致,先更新其中一份。', '');
|
|
2712
|
+
if (Array.isArray(o.structure) && o.structure.length) {
|
|
2713
|
+
lines.push('## 6. 主线结构、阶段任务与分集策略', '', mdTable(['阶段', '集数范围', '创作任务', '爽点策略', '钩子策略'], o.structure.map((ph) => [PHASE_LABELS[ph.phase] || ph.phase, `第 ${(ph.episodes || [])[0]}–${(ph.episodes || [])[1]} 集`, ph.task, ph.satisfactionPlan, ph.hookPlan])), '');
|
|
2714
|
+
}
|
|
2715
|
+
const beats = o.beats || [];
|
|
2716
|
+
lines.push('## 7. 爽点、钩子与反转设计规则', '', `**核心爽点库**:${uniq(beats.map((x) => x.type)).join('、') || SATISFACTION_TYPES.join('、')}`, '', `**核心钩子库**:${uniq((o.episodes || []).map((e) => e.hookType).filter(Boolean)).join('、') || HOOK_TYPES.join('、')}`, '', '**反转规则**:反转必须改变权力关系、信息关系或情感关系;不能只靠一句台词宣布,要有可见行动、道具、证据或人物选择支撑。', '');
|
|
2717
|
+
const w = project.wji || {};
|
|
2718
|
+
lines.push('## 8. 参考数据集', '', mdTable(['数据集', '位置', '使用时机'], [
|
|
2719
|
+
['故事大纲', w.outlineDatasetId ? `story/ · ${w.outlineDatasetId}` : 'story/ 大纲', '调整主线、阶段节奏、高潮或结局前必读'],
|
|
2720
|
+
['人物圣经', 'character/ 每人一个数据集', '涉及角色语气、关系、动机、秘密、道具或弧线时必读'],
|
|
2721
|
+
['场景与道具', 'location/ · prop/', '写分镜前确认锚点、光态与道具状态'],
|
|
2722
|
+
['分集剧本', 'story/ E0N 剧本', '写作或修改任何一集前重读前后集的钩子与悬念'],
|
|
2723
|
+
['分镜', 'shot/E0N/ 每段一个数据集', '生成前核对镜头职责、起止状态与提示词'],
|
|
2724
|
+
]), '');
|
|
2725
|
+
lines.push('## 9. 本项目绝对要注意的重点', '', ...asList(g.priorities).map((x, i) => `${i + 1}. ${x}`), '');
|
|
2726
|
+
return lines.join('\n');
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* The 创作自检 of one episode — the eight questions of the reference episode template, answered from
|
|
2731
|
+
* the same helpers the gates use, so a green episode scores 16/16 by construction.
|
|
2732
|
+
*/
|
|
2733
|
+
export function scriptSelfCheck(project, episode) {
|
|
2734
|
+
const params = paramsOf(project);
|
|
2735
|
+
const offsets = beatOffsets(episode, params);
|
|
2736
|
+
const total = offsets.length ? offsets[offsets.length - 1].end : 0;
|
|
2737
|
+
const find = (ref) => offsets.find((o) => Array.isArray(ref) && o.si === Number(ref[0]) && o.bi === Number(ref[1]));
|
|
2738
|
+
const hook = find(episode.hookBeat), sat = find(episode.satisfactionBeat);
|
|
2739
|
+
const points = turnPoints(episode, params);
|
|
2740
|
+
let worst = 0;
|
|
2741
|
+
for (let i = 1; i < points.length; i++) worst = Math.max(worst, points[i].at - points[i - 1].at);
|
|
2742
|
+
const ta = talkAndAction(episode);
|
|
2743
|
+
const task = isObject(episode.task) ? episode.task : {};
|
|
2744
|
+
const novel = (episode.scenes || []).some((sc) => (sc.flow || []).some((b) => b.action !== undefined && NOVELISTIC_WORDS.some((w) => String(b.action).includes(w))));
|
|
2745
|
+
const rows = [
|
|
2746
|
+
[`开头 ${params.hookSeconds} 秒是否有冲突、羞辱、危机、秘密或反转?`, !!hook && hook.start <= params.hookSeconds + 1e-9, hook ? `钩子画面在 ${round2(hook.start)} s` : '未标 hookBeat'],
|
|
2747
|
+
['本集任务、阻力与代价是否清楚?', ['goal', 'obstacle', 'stake'].every((k) => String(task[k] || '').trim()), [task.goal, task.obstacle, task.stake].filter(Boolean).join('/')],
|
|
2748
|
+
[`中段是否每 ${params.turnSeconds} 秒有新信息、新阻碍、新反击或新选择?`, points.length >= 2 && worst <= params.turnSeconds + 1e-9, `最长间隔 ${round2(worst)} s(${points.map((p) => p.what).join(' → ')})`],
|
|
2749
|
+
['本集结束时主线是否有实质改变?', !!String(episode.change || '').trim(), episode.change || ''],
|
|
2750
|
+
['是否有明确【爽点标记】?', !!sat && (!hook || sat.start > hook.start) && sat.end < total - 1e-9, sat ? `${round2(sat.start)} s` : '未标 satisfactionBeat'],
|
|
2751
|
+
['结尾是否有明确【钩子标记】?', !!String(episode.cliff || '').trim() && !!episode.hookType, `${episode.hookType || ''}:${episode.cliff || ''}`],
|
|
2752
|
+
['对白是否符合「谈话 + 行动」?', ta.longestLineRun <= params.maxConsecutiveLines && (ta.lines < 5 || ta.ratio >= params.minActionRatio), `${ta.actions} 动作 / ${ta.lines} 台词,最长连续 ${ta.longestLineRun} 句`],
|
|
2753
|
+
['人物行为是否可拍摄,没有小说式心理描写?', !novel, novel ? '动作里有心理词' : ''],
|
|
2754
|
+
];
|
|
2755
|
+
return { rows, score: rows.filter((r) => r[1]).length * 2, max: rows.length * 2 };
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/** The 对白校对 worksheet: one row per line, 原句 filled, the rest for the polish pass. */
|
|
2759
|
+
export function renderDialogue(project, episode) {
|
|
2760
|
+
const params = paramsOf(project);
|
|
2761
|
+
const lines = [`# ${project.title} · 第 ${episode.ep} 集对白校对`, '', mdTable(['项目', '判断'], [
|
|
2762
|
+
['语体', params.register || ''], ['场面目标', ''], ['主要冲突', ''], ['潜台词核心', ''], ['角色声音问题', ''], ['节奏问题', ''], ['建议修改幅度', '轻修/中修/重修'],
|
|
2763
|
+
]), ''];
|
|
2764
|
+
(episode.scenes || []).forEach((sc, i) => {
|
|
2765
|
+
const scene = sceneById(project, sc.sceneId);
|
|
2766
|
+
lines.push(`## ${i + 1}. ${scene?.name || sc.sceneId}`, '');
|
|
2767
|
+
const rows = [];
|
|
2768
|
+
(sc.flow || []).forEach((b) => {
|
|
2769
|
+
if (b.line === undefined) return;
|
|
2770
|
+
const ch = characterById(project, b.speaker);
|
|
2771
|
+
const sp = ch?.bible?.speech || {};
|
|
2772
|
+
const who = b.speaker === 'VO' ? 'VO' : ch?.name || b.speaker;
|
|
2773
|
+
const hint = [sp.rhythm, sp.silence ? `沉默:${sp.silence}` : '', (sp.forbiddenWords || []).length ? `禁用:${sp.forbiddenWords.join('、')}` : ''].filter(Boolean).join(';');
|
|
2774
|
+
rows.push([`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`, '', '', '', hint]);
|
|
2775
|
+
});
|
|
2776
|
+
lines.push(mdTable(['原句', '问题', '修改句', '修改理由', '导演/演员提示'], rows), '');
|
|
2777
|
+
});
|
|
2778
|
+
lines.push('> 每句检查:有没有戏剧功能?遮住名字还认得出是谁在说?真正想说的有没有藏起来?熟人有没有互相解释已知的事?导演提示用可演动词(逼、哄、避、试探),不用情绪形容词。', '');
|
|
2779
|
+
return lines.join('\n');
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
export function renderCharacter(project, id) {
|
|
2783
|
+
const c = characterById(project, id);
|
|
2784
|
+
if (!c) throw new Error(`no character ${id}`);
|
|
2785
|
+
const p = c.profile || {};
|
|
2786
|
+
const lines = [`# ${c.name}${(c.aliases || []).length ? `(${c.aliases.join('、')})` : ''}`, '', `${c.id} · ${c.tier} · ${c.role}`, '', c.oneLiner || '', ''];
|
|
2787
|
+
lines.push('## 画像', '', mdTable(['项', '内容'], [['性别', p.gender], ['年龄', p.ageRange], ['身份', p.identity], ['外貌', p.appearance], ['性格', (p.personality || []).join('、')], ['气质', p.temperament], ['动机', p.motivation], ['人物弧', c.arc || ''], ['提示词称呼(英,不带名字)', c.identityEn || '']]), '');
|
|
2788
|
+
const bb = isObject(c.bible) ? c.bible : null;
|
|
2789
|
+
if (bb) {
|
|
2790
|
+
const sp = isObject(bb.speech) ? bb.speech : {};
|
|
2791
|
+
lines.push('## 人物圣经', '', mdTable(['项', '内容'], [
|
|
2792
|
+
['剧情功能', bb.function], ['一句话人物钩子', bb.hookLine], ['外在想要 Want', bb.want], ['真正需要 Need', bb.need], ['核心误信', bb.misbelief], ['核心矛盾', bb.contradiction],
|
|
2793
|
+
['观众第一印象', bb.firstImpression], ['观众最后理解', bb.finalUnderstanding], ['受压反应', bb.pressureResponse],
|
|
2794
|
+
['说话节奏', sp.rhythm], ['口头禅', sp.catchphrase], ['沉默方式', sp.silence], ['禁用词', asList(sp.forbiddenWords).join('、')], ['语体', sp.register || ''],
|
|
2795
|
+
].filter((r) => String(r[1] ?? '').trim())), '');
|
|
2796
|
+
if (bb.wound) lines.push('### 核心创伤', '', bb.wound, '');
|
|
2797
|
+
if (asList(bb.pastChoices).length) lines.push('### 过去的关键选择', '', mdTable(['时间点', '情境压力', '当时的选择', '得到', '失去', '今日残留'], asList(bb.pastChoices).map((ch) => [ch.when, ch.pressure, ch.choice, ch.gained, ch.lost, ch.residue])), '');
|
|
2798
|
+
if (asList(bb.arc).length) lines.push('### 角色弧线', '', mdTable(['阶段', '集', '角色状态', '内在变化'], asList(bb.arc).map((st) => [st.stage, st.episode, st.state, st.change])), '');
|
|
2799
|
+
if (asList(bb.forbidden).length || asList(bb.mustFace).length) lines.push('### 写作备忘', '', ...asList(bb.forbidden).map((x) => `- 不能做:${x}`), ...asList(bb.mustFace).map((x) => `- 必须面对:${x}`), '');
|
|
2800
|
+
}
|
|
2801
|
+
if ((p.relationships || []).length) {
|
|
2802
|
+
const rich = p.relationships.some((r) => r.surface || r.real || r.power);
|
|
2803
|
+
if (rich) lines.push('## 人物关系网', '', mdTable(['对象', '表面关系', '真正关系', '权力差', '秘密/谎言', '转折集'], p.relationships.map((r) => [r.name || r.id, r.surface || r.relation || '', r.real || '', r.power || '', r.secret || '', r.turnEpisode ? `第 ${r.turnEpisode} 集` : ''])), '');
|
|
2804
|
+
else lines.push('## 关系', '', ...p.relationships.map((r) => `- **${r.name}**:${r.relation || r.surface || ''}`), '');
|
|
2805
|
+
}
|
|
2806
|
+
if ((p.evidence || []).length) lines.push('## 原文依据', '', ...p.evidence.map((q) => `> ${q}`), '');
|
|
2807
|
+
const v = c.voice || {};
|
|
2808
|
+
const params = paramsOf(project);
|
|
2809
|
+
const sample = v.sample || {};
|
|
2810
|
+
lines.push('## 声线', '', mdTable(['项', '内容'], [
|
|
2811
|
+
['音色', v.timbre], ['音高', v.pitch], ['语速', v.pace], ['口音', v.accent],
|
|
2812
|
+
['默认情绪', v.emotion], ['参考提示', v.referenceHint || ''],
|
|
2813
|
+
]), '');
|
|
2814
|
+
// The clause every video prompt pastes beside this character's lines.
|
|
2815
|
+
if (v.videoDirection) lines.push(`视频提示词讲话方式(英):${v.videoDirection}`, '');
|
|
2816
|
+
if (sample.line) {
|
|
2817
|
+
lines.push(`试音台词(${round2(voiceSampleSeconds(c, params))} s):${sample.line}`, '');
|
|
2818
|
+
if (sample.direction) lines.push(`表演指示:${sample.direction}`, '');
|
|
2819
|
+
lines.push(`声音提示词(${VOICE_MODEL},参考头像生成)`, '', '```', voiceSamplePrompt(c, params), '```', '');
|
|
2820
|
+
}
|
|
2821
|
+
if (v.prompt) lines.push('英文声线参数串(给其他引擎)', '', '```', v.prompt, '```', '');
|
|
2822
|
+
lines.push('## 出图提示词', '', '**Portrait**', '', '```', c.image?.prompt || '', '```', '', '**Negative**', '', '```', c.image?.negativePrompt || '', '```', '', '**Character sheet (16:9)**', '', '```', c.image?.sheet || '', '```', '');
|
|
2823
|
+
return lines.join('\n');
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
export function renderScene(project, id) {
|
|
2827
|
+
const s = sceneById(project, id);
|
|
2828
|
+
if (!s) throw new Error(`no scene ${id}`);
|
|
2829
|
+
const lines = [`# ${s.name}`, '', `${s.id}${s.primary ? ' · 主场景' : ''}${s.variantOf ? ` · 变体 ← ${s.variantOf}(${s.changes || ''})` : ''}`, '', s.summary || '', ''];
|
|
2830
|
+
lines.push('## 一致性锚点', '', ...(s.anchors || []).map((a) => `- **${a.name}**:${a.desc}`), '');
|
|
2831
|
+
lines.push('## 光照状态', '', mdTable(['状态', 'Prompt'], (s.lighting || []).map((l) => [l.state, l.prompt])), '');
|
|
2832
|
+
if (s.reusePlan) lines.push(`复用方案:${s.reusePlan}`, '');
|
|
2833
|
+
lines.push('## 出图提示词', '', '**Master view (empty scene)**', '', '```', s.image?.prompt || '', '```', '', '**Negative**', '', '```', s.image?.negativePrompt || '', '```', '', '**Environment sheet (16:9)**', '', '```', s.image?.sheet || '', '```', '');
|
|
2834
|
+
return lines.join('\n');
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
export function renderProp(project, id) {
|
|
2838
|
+
const p = propById(project, id);
|
|
2839
|
+
if (!p) throw new Error(`no prop ${id}`);
|
|
2840
|
+
const lines = [`# ${p.name}`, '', `${p.id} · ${p.scale || ''}${(p.carriedBy || []).length ? ` · 随身:${p.carriedBy.join('、')}` : ''}`, '', `**承载**:${p.function || ''}`, '', p.summary || '', ''];
|
|
2841
|
+
lines.push('## 锚点', '', ...(p.anchors || []).map((a) => `- **${a.name}**:${a.desc}`), '');
|
|
2842
|
+
lines.push('## 状态变体', '', mdTable(['状态', 'Prompt'], (p.states || []).map((s) => [s.state, s.prompt])), '');
|
|
2843
|
+
if ((p.relatedScenes || []).length) lines.push(`场景:${p.relatedScenes.join('、')}`, '');
|
|
2844
|
+
lines.push('## 出图提示词', '', '**Master view (white background)**', '', '```', p.image?.prompt || '', '```', '', '**Negative**', '', '```', p.image?.negativePrompt || '', '```', '', '**Prop sheet (16:9)**', '', '```', p.image?.sheet || '', '```', '');
|
|
2845
|
+
return lines.join('\n');
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/**
|
|
2849
|
+
* The screenplay in the reference 短剧 episode format: `△(from-to秒)` action lines with the seconds
|
|
2850
|
+
* derived from the beat timing, `角色(delivery):line`, 【爽点标记】 after the 爽点 beat, 【钩子标记】 after
|
|
2851
|
+
* the last beat, and the 创作自检 table filled from the gates.
|
|
2852
|
+
*/
|
|
2853
|
+
export function renderScript(project, episode) {
|
|
2854
|
+
const params = paramsOf(project);
|
|
2855
|
+
const est = episodeSeconds(episode, params);
|
|
2856
|
+
const offsets = beatOffsets(episode, params);
|
|
2857
|
+
const at = (si, bi) => offsets.find((o) => o.si === si && o.bi === bi);
|
|
2858
|
+
const task = isObject(episode.task) ? episode.task : {};
|
|
2859
|
+
const outlineEp = (project.outline?.episodes || []).find((x) => Number(x.ep) === Number(episode.ep)) || {};
|
|
2860
|
+
const sat = isObject(outlineEp.satisfaction) ? outlineEp.satisfaction : {};
|
|
2861
|
+
const lines = [`# 第 ${episode.ep} 集|${project.title}`, '', `目标 ${episode.targetSeconds} s · 预估 ${round2(est)} s${params.register ? ` · ${params.register}` : ''}`, ''];
|
|
2862
|
+
lines.push('## 本集功能', '', asList(episode.function).join('/') || '—', '');
|
|
2863
|
+
lines.push('## 本集任务', '', `- **目标**:${task.goal || ''}`, `- **阻力**:${task.obstacle || ''}`, `- **代价**:${task.stake || ''}`, `- **改变**:${episode.change || ''}`, `- **钩子**:${episode.hook || ''}`, `- **悬念**:${episode.cliff || ''}${episode.hookType ? `(${episode.hookType}钩子)` : ''}`, `- 爽点:${(episode.beatsClaimed || []).join('、') || '—'}${sat.type ? ` · ${sat.type}` : ''}`, '');
|
|
2864
|
+
const satRef = Array.isArray(episode.satisfactionBeat) ? episode.satisfactionBeat.map(Number) : null;
|
|
2865
|
+
(episode.scenes || []).forEach((sc, i) => {
|
|
2866
|
+
const scene = sceneById(project, sc.sceneId);
|
|
2867
|
+
lines.push(`## ${i + 1}. ${scene?.name || sc.sceneId}${sc.lighting ? ` · ${sc.lighting}` : ''}`, '', `人物:${(sc.characters || []).map((id) => characterById(project, id)?.name || id).join('、')}${(sc.props || []).length ? ` · 道具:${sc.props.map((id) => propById(project, id)?.name || id).join('、')}` : ''}`, '');
|
|
2868
|
+
(sc.flow || []).forEach((b, k) => {
|
|
2869
|
+
const o = at(i + 1, k + 1);
|
|
2870
|
+
const span = o ? `(${Math.round(o.start)}-${Math.round(o.end)}秒)` : '';
|
|
2871
|
+
if (b.line !== undefined) {
|
|
2872
|
+
const who = b.speaker === 'VO' ? 'VO' : characterById(project, b.speaker)?.name || b.speaker;
|
|
2873
|
+
lines.push(`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`);
|
|
2874
|
+
} else lines.push(`△${span}${b.action}${b.turn ? ' ⟲' : ''}`);
|
|
2875
|
+
if (satRef && satRef[0] === i + 1 && satRef[1] === k + 1) lines.push('', `【爽点标记】${sat.action || sat.type || ''}`);
|
|
2876
|
+
lines.push('');
|
|
2877
|
+
});
|
|
2878
|
+
});
|
|
2879
|
+
lines.push(`【钩子标记】${episode.cliff || ''}`, '');
|
|
2880
|
+
const check = scriptSelfCheck(project, episode);
|
|
2881
|
+
lines.push('## 创作自检', '', mdTable(['检查项', '结论', '依据'], check.rows.map((r) => [r[0], r[1] ? '是' : '否', r[2]])), '', `${check.score}/${check.max}${check.score >= 12 ? '' : ' — 未达 12,先修再交付'}`, '');
|
|
2882
|
+
return lines.join('\n');
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
export function renderSegment(project, episode, segmentId) {
|
|
2886
|
+
const seg = (episode.storyboard?.segments || []).find((s) => s.id === segmentId);
|
|
2887
|
+
if (!seg) throw new Error(`no segment ${segmentId}`);
|
|
2888
|
+
const scene = (episode.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
2889
|
+
const params = paramsOf(project);
|
|
2890
|
+
const cuts = seg.cuts || [];
|
|
2891
|
+
const starts = cutStarts(cuts);
|
|
2892
|
+
const total = cuts.reduce((s, c) => s + (Number(c.seconds) || 0), 0);
|
|
2893
|
+
const nameOf = (id) => characterById(project, id)?.name || id;
|
|
2894
|
+
const lines = [`# ${seg.id}`, '', `${sceneById(project, scene.sceneId)?.name || scene.sceneId || ''}${scene.lighting ? ` · ${scene.lighting}` : ''} · ${round2(total)} s · ${params.video}`, ''];
|
|
2895
|
+
const kit = params.camera || DEFAULT_PARAMS.camera;
|
|
2896
|
+
lines.push(`镜头:${kit.body} + ${kit.lens}`, '');
|
|
2897
|
+
if (isObject(seg.axis)) lines.push(`轴线:${asList(seg.axis.between).map(nameOf).join(' ↔ ')} · 机位常驻:${seg.axis.workingSide || ''} · 画面左 ${nameOf(seg.axis.screenLeft)} / 画面右 ${nameOf(seg.axis.screenRight)}`, '');
|
|
2898
|
+
if (isObject(seg.sound)) lines.push(`声音:${seg.sound.ambience || ''}${asList(seg.sound.sfx).length ? ` · 音效:${asList(seg.sound.sfx).map((x) => `镜${x.cut} ${x.event}`).join(';')}` : ''} · 配乐:${musicOf(seg.sound).intent || 'N/A'}${musicOf(seg.sound).cues.length ? `(${musicOf(seg.sound).cues.map((c) => `${typeof c.at === 'number' ? `镜${c.at}` : c.at} ${c.do}`).join(',')})` : ''}`, '');
|
|
2899
|
+
lines.push('## 分镜', '', mdTable(['#', '起', '秒', '职责', '景别', '角度·高度', '轴线', '运镜', '焦段/光圈', '光位', '前景', '人物', '节拍'], cuts.map((c, k) => [
|
|
2900
|
+
k + 1, timestamp(starts[k]), c.seconds, c.purpose?.kind || '', c.size, `${ANGLES_ZH[c.angle] || c.angle || ''}·${HEIGHTS_ZH[c.height] || c.height || ''}`, c.axisSide || '', c.camera,
|
|
2901
|
+
`${focalOf(c)} ${c.aperture || kit.aperture}`,
|
|
2902
|
+
[c.light?.direction, c.light?.note].filter(Boolean).join(' — '),
|
|
2903
|
+
c.foreground || '—',
|
|
2904
|
+
(c.characters || []).map(nameOf).join('、'),
|
|
2905
|
+
`${c.beats?.[0]}–${c.beats?.[1]}`,
|
|
2906
|
+
])), '');
|
|
2907
|
+
// One block per cut in the reference 分镜 shape: 职责 · 来源 · 时长 · 起点 · 唯一动作 · 终点 · 摄影机 · 声音 · 视觉依据.
|
|
2908
|
+
const refs = segmentReferences(project, episode, seg);
|
|
2909
|
+
const boundaryText = (b, c) => (c.characters || []).map((id) => {
|
|
2910
|
+
const bo = boundaryOf(b);
|
|
2911
|
+
if (bo.positions?.[id] === undefined) return `${nameOf(id)}:入画(上一镜不在画内)`;
|
|
2912
|
+
const held = asList(bo.heldProps?.[id]).map((pid) => propById(project, pid)?.name || pid);
|
|
2913
|
+
return `${nameOf(id)}:${bo.positions?.[id] || ''};朝向 ${bo.facing?.[id] || ''};视线 ${bo.gaze?.[id] || ''};手 ${bo.hands?.[id] || ''}${held.length ? `;持 ${held.join('、')}` : ''}`;
|
|
2914
|
+
}).concat(asList(boundaryOf(b).state).length ? [`状态:${asList(boundaryOf(b).state).join(';')}`] : []).join('<br>');
|
|
2915
|
+
cuts.forEach((c, k) => {
|
|
2916
|
+
const beats = cutBeats(scene, c);
|
|
2917
|
+
const source = beats.map(({ bi, beat }) => `${bi}. ${beat.line !== undefined ? `${nameOf(beat.speaker)}:${beat.line}` : beat.action}`).join('<br>');
|
|
2918
|
+
const cn = isObject(c.cameraNote) ? c.cameraNote : {};
|
|
2919
|
+
const cam = c.camera === 'Static Shot' ? `固定机位 — ${cn.hold || ''}` : `${c.camera}(${cn.motivation || ''})— ${cn.trigger || ''}:${cn.path || ''},止于 ${cn.endFraming || ''}`;
|
|
2920
|
+
const sfx = asList(seg.sound?.sfx).filter((x) => Number(x?.cut) === k + 1).map((x) => x.event).join(';');
|
|
2921
|
+
const dialogue = claimedLines(scene, [c]);
|
|
2922
|
+
const soundText = [dialogue.length ? `对白 ${dialogue.map((l) => `${nameOf(l.speaker)}「${l.line}」`).join(' ')}` : '无对白', sfx ? `音效 ${sfx}` : ''].filter(Boolean).join(';');
|
|
2923
|
+
const basis = refs.filter((r) => r.kind === 'image' && (r.entity === scene.sceneId || (c.characters || []).includes(r.entity) || (c.props || []).includes(r.entity))).map((r) => `${r.label} ${r.why}`).join(';');
|
|
2924
|
+
lines.push(`### SHOT ${k + 1} · ${c.purpose?.kind || ''}`, '', mdTable(['项', '内容'], [
|
|
2925
|
+
['职责', c.purpose?.change || ''],
|
|
2926
|
+
['来源', source],
|
|
2927
|
+
['时长', `${timestamp(starts[k])} 起 · ${c.seconds} s`],
|
|
2928
|
+
['景别/角度/高度/焦段', `${c.size} · ${ANGLES_ZH[c.angle] || c.angle || ''} · ${HEIGHTS_ZH[c.height] || c.height || ''} · ${focalOf(c)} ${c.aperture || kit.aperture} · 轴线 ${c.axisSide || ''}`],
|
|
2929
|
+
['起点', boundaryText(cutStart(seg, k), c)],
|
|
2930
|
+
['唯一动作', `${c.motion?.trigger || ''} → ${c.motion?.action || ''} → ${c.motion?.result || ''}`],
|
|
2931
|
+
['终点', boundaryText(c.end, c)],
|
|
2932
|
+
['摄影机', cam],
|
|
2933
|
+
['光与前景', `${[c.light?.direction, c.light?.note].filter(Boolean).join(' — ')}${c.foreground ? ` · 前景 ${c.foreground}` : ''}${c.background ? ` · 背景 ${c.background}` : ''}`],
|
|
2934
|
+
['声音', soundText],
|
|
2935
|
+
['视觉依据', basis || '无'],
|
|
2936
|
+
...(c.note ? [['备注', c.note]] : []),
|
|
2937
|
+
]), '');
|
|
2938
|
+
});
|
|
2939
|
+
lines.push('## 画面提示词(起始瞬间)', '', ...cuts.flatMap((c, k) => [`**${k + 1}.** ${c.frame || ''}`, '']));
|
|
2940
|
+
if (seg.note) lines.push(`> ${seg.note}`, '');
|
|
2941
|
+
lines.push('## 视频提示词', '', '```', seg.prompt || '', '```', '');
|
|
2942
|
+
if (params.audioTrack !== 'none' && isObject(seg.sound)) {
|
|
2943
|
+
const a = audioPrompt(project, episode, seg);
|
|
2944
|
+
lines.push(`## 音轨提示词(${AUDIO_MODEL} · ${a.mode} · ${round2(a.seconds)} s)`, '', '```', a.prompt, '```', '');
|
|
2945
|
+
}
|
|
2946
|
+
return lines.join('\n');
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
// ── manifest & bind ─────────────────────────────────────────────────────────
|
|
2950
|
+
|
|
2951
|
+
export function manifest(project, episodes, opts = {}) {
|
|
2952
|
+
const items = [];
|
|
2953
|
+
const w = project.wji || {};
|
|
2954
|
+
const add = (stage, entity, item, hint) => items.push({ stage, entity, item, hint });
|
|
2955
|
+
if (!w.workspaceId) add('setup', 'workspace', 'wji.workspaceId', `wji ws create "${project.title}" -a dataset -a canvas -r ${paramsOf(project).ratio}`);
|
|
2956
|
+
for (const key of ['story', 'character', 'location', 'prop', 'shot']) {
|
|
2957
|
+
if (!w.datasetFolders?.[key]) add('setup', 'datasetFolders', `wji.datasetFolders.${key}`, 'wji --json ws fs --app <Dataset app id> | jq … select(.content.semanticKey)');
|
|
2958
|
+
}
|
|
2959
|
+
const stages = opts.stage ? STAGES.slice(0, STAGES.indexOf(opts.stage) + 1) : STAGES;
|
|
2960
|
+
|
|
2961
|
+
if (stages.includes('outline') && !w.outlineDatasetId) add('outline', 'outline', 'wji.outlineDatasetId', 'wji dataset new <story> "<title> 大纲" -t outline');
|
|
2962
|
+
if (stages.includes('outline') && isObject(project.outline?.guideline) && !w.guidelineDatasetId) add('outline', 'guideline', 'wji.guidelineDatasetId', 'wji dataset new <story> "<title> 创作指引" -t outline (content = render guideline)');
|
|
2963
|
+
|
|
2964
|
+
if (stages.includes('characters') && (project.characters || []).length) {
|
|
2965
|
+
if (!w.canvases?.characters) add('characters', 'canvas', 'wji.canvases.characters', 'wji canvas create characters');
|
|
2966
|
+
for (const c of project.characters) {
|
|
2967
|
+
const cw = c.wji || {};
|
|
2968
|
+
if (!cw.datasetId) add('characters', c.id, `characters.${c.id}.wji.datasetId`, `wji dataset new <character> "${c.name}" -t character`);
|
|
2969
|
+
for (const key of ['headshot', 'sheet']) if (!cw.nodes?.[key]) add('characters', c.id, `characters.${c.id}.wji.nodes.${key}`, `wji canvas add image -c <characters> -n "${c.id} ${key}"`);
|
|
2970
|
+
for (const key of ['headshot', 'sheet']) if (cw.nodes?.[key] && !cw.assets?.[key]) add('characters', c.id, `characters.${c.id}.wji.assets.${key}`, `wji canvas gen ${cw.nodes[key]} → asset`);
|
|
2971
|
+
if (paramsOf(project).voiceSamples !== false) {
|
|
2972
|
+
if (!cw.nodes?.voice) add('characters', c.id, `characters.${c.id}.wji.nodes.voice`, `wji canvas add audio -c <characters> -n "${c.id} voice" --from <${c.id} headshot node> -m ${VOICE_MODEL}`);
|
|
2973
|
+
else if (!cw.assets?.voice) add('characters', c.id, `characters.${c.id}.wji.assets.voice`, `wji canvas gen ${cw.nodes.voice} → asset → fs set <dataset> --set primaryAudio=<asset>`);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
if (stages.includes('art')) {
|
|
2978
|
+
if ((project.scenes || []).length + (project.props || []).length && !w.canvases?.scenes) add('art', 'canvas', 'wji.canvases.scenes', 'wji canvas create scenes');
|
|
2979
|
+
for (const s of project.scenes || []) {
|
|
2980
|
+
const sw = s.wji || {};
|
|
2981
|
+
if (!sw.datasetId) add('art', s.id, `scenes.${s.id}.wji.datasetId`, `wji dataset new <location> "${s.name}" -t location`);
|
|
2982
|
+
if (!sw.nodes?.sheet) add('art', s.id, `scenes.${s.id}.wji.nodes.sheet`, `wji canvas add image -c <scenes> -n "${s.id} sheet" -r 16:9`);
|
|
2983
|
+
else if (!sw.assets?.sheet) add('art', s.id, `scenes.${s.id}.wji.assets.sheet`, `wji canvas gen ${sw.nodes.sheet} → asset`);
|
|
2984
|
+
}
|
|
2985
|
+
for (const p of project.props || []) {
|
|
2986
|
+
const pw = p.wji || {};
|
|
2987
|
+
if (!pw.datasetId) add('art', p.id, `props.${p.id}.wji.datasetId`, `wji dataset new <prop> "${p.name}" -t prop`);
|
|
2988
|
+
if (!pw.nodes?.sheet) add('art', p.id, `props.${p.id}.wji.nodes.sheet`, `wji canvas add image -c <scenes> -n "${p.id} sheet" -r 16:9`);
|
|
2989
|
+
else if (!pw.assets?.sheet) add('art', p.id, `props.${p.id}.wji.assets.sheet`, `wji canvas gen ${pw.nodes.sheet} → asset`);
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
for (const [ep, e] of Object.entries(episodes || {})) {
|
|
2993
|
+
if (opts.ep && Number(ep) !== Number(opts.ep)) continue;
|
|
2994
|
+
const ew = e.wji || {};
|
|
2995
|
+
const E = `E${pad2(ep)}`;
|
|
2996
|
+
if (stages.includes('script') && (e.scenes || []).length && !ew.screenplayDatasetId) add('script', E, `wji.screenplayDatasetId`, `wji dataset new <story> "${E} 剧本" -t screenplay`);
|
|
2997
|
+
if (!stages.includes('storyboard')) continue;
|
|
2998
|
+
const segs = e.storyboard?.segments || [];
|
|
2999
|
+
if (!segs.length) continue;
|
|
3000
|
+
if (!ew.canvasId) add('storyboard', E, 'wji.canvasId', `wji canvas create ${E}`);
|
|
3001
|
+
if (!ew.shotFolderId) add('storyboard', E, 'wji.shotFolderId', `wji fs mkdir <shot> ${E}`);
|
|
3002
|
+
const needed = new Set();
|
|
3003
|
+
const voices = new Set();
|
|
3004
|
+
for (const seg of segs) {
|
|
3005
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
3006
|
+
needed.add(`${scene.sceneId}:sheet`);
|
|
3007
|
+
for (const c of seg.cuts || []) {
|
|
3008
|
+
for (const id of c.characters || []) needed.add(`${id}:sheet`);
|
|
3009
|
+
for (const id of c.props || []) needed.add(`${id}:sheet`);
|
|
3010
|
+
}
|
|
3011
|
+
// Whoever speaks needs their voice on the canvas: it is the `@audioN` the prompt points at.
|
|
3012
|
+
for (const r of audioReferences(project, e, seg)) voices.add(`${r.entity}:voice`);
|
|
3013
|
+
}
|
|
3014
|
+
for (const key of needed) if (!ew.displayNodes?.[key]) add('storyboard', E, `wji.displayNodes.${key}`, `wji canvas add image -c <${E}> --asset <${key} asset> -n "${key}"`);
|
|
3015
|
+
for (const key of voices) if (!ew.displayNodes?.[key]) add('storyboard', E, `wji.displayNodes.${key}`, `wji canvas add audio -c <${E}> --asset <${key.split(':')[0]} voice asset> -n "${key}"`);
|
|
3016
|
+
for (const seg of segs) {
|
|
3017
|
+
const sw = seg.wji || {};
|
|
3018
|
+
const n = (seg.cuts || []).length;
|
|
3019
|
+
if (!sw.datasetId) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.datasetId`, `wji dataset new <shot/${E}> "${seg.id}" -t shot`);
|
|
3020
|
+
for (let k = 0; k < (paramsOf(project).references === 'sheets' ? 0 : n); k++) {
|
|
3021
|
+
if (!sw.nodes?.keyframes?.[k]) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.nodes.keyframes.${k}`, `wji canvas add image -c <${E}> -n "${seg.id} f${k + 1}" --from <display nodes>`);
|
|
3022
|
+
else if (!sw.assets?.keyframes?.[k]) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.assets.keyframes.${k}`, `wji canvas gen ${sw.nodes.keyframes[k]} → asset`);
|
|
3023
|
+
}
|
|
3024
|
+
if (paramsOf(project).audioTrack !== 'none' && isObject(seg.sound)) {
|
|
3025
|
+
const full = paramsOf(project).audioTrack === 'full';
|
|
3026
|
+
if (!sw.nodes?.track) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.nodes.track`, `wji canvas add audio -c <${E}> -n "${seg.id} track" -m ${AUDIO_MODEL}${full ? ' --from <voice display nodes, speaking order>' : ''} --prompt-file <prompt audio ${seg.id}>`);
|
|
3027
|
+
else if (!sw.assets?.track) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.assets.track`, `wji canvas gen ${sw.nodes.track} → asset → fs set <shot dataset> --add-images <asset>`);
|
|
3028
|
+
}
|
|
3029
|
+
if (!sw.nodes?.video) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.nodes.video`, `wji canvas add video -c <${E}> -n "${seg.id}" --from <${paramsOf(project).references === 'sheets' ? 'sheet display nodes' : 'keyframes'}>`);
|
|
3030
|
+
else if (!sw.assets?.video) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.assets.video`, `wji canvas gen ${sw.nodes.video} (video — ask first) → asset`);
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
return items;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
/** Resolve a dotted path; a segment that does not exist as a key may be an entity id inside an array. */
|
|
3037
|
+
export function bindPath(root, path, value) {
|
|
3038
|
+
const parts = String(path).split('.');
|
|
3039
|
+
let cursor = root;
|
|
3040
|
+
for (let i = 0; i < parts.length; i++) {
|
|
3041
|
+
const seg = parts[i];
|
|
3042
|
+
const last = i === parts.length - 1;
|
|
3043
|
+
if (Array.isArray(cursor)) {
|
|
3044
|
+
let idx = /^\d+$/.test(seg) ? Number(seg) : cursor.findIndex((it) => it && it.id === seg);
|
|
3045
|
+
if (idx === -1) throw new Error(`no element with id "${seg}" at ${parts.slice(0, i).join('.')}`);
|
|
3046
|
+
if (last) { cursor[idx] = value; return root; }
|
|
3047
|
+
if (cursor[idx] == null) cursor[idx] = /^\d+$/.test(parts[i + 1]) ? [] : {};
|
|
3048
|
+
cursor = cursor[idx];
|
|
3049
|
+
continue;
|
|
3050
|
+
}
|
|
3051
|
+
if (!isObject(cursor)) throw new Error(`cannot descend into "${parts.slice(0, i).join('.')}"`);
|
|
3052
|
+
if (last) { cursor[seg] = value; return root; }
|
|
3053
|
+
if (cursor[seg] == null) cursor[seg] = /^\d+$/.test(parts[i + 1]) ? [] : {};
|
|
3054
|
+
cursor = cursor[seg];
|
|
3055
|
+
}
|
|
3056
|
+
return root;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
// ── init ────────────────────────────────────────────────────────────────────
|
|
3060
|
+
|
|
3061
|
+
export function initProject(opts) {
|
|
3062
|
+
const required = ['title', 'episodes', 'minutes', 'genre'];
|
|
3063
|
+
for (const key of required) if (opts[key] == null || opts[key] === '') throw new Error(`--${key} is required`);
|
|
3064
|
+
const video = opts.video || DEFAULT_PARAMS.video;
|
|
3065
|
+
if (!VIDEO_MODELS[video]) throw new Error(`--video must be one of ${VIDEO_MODES.join(' / ')}`);
|
|
3066
|
+
const mode = VIDEO_MODELS[video];
|
|
3067
|
+
return {
|
|
3068
|
+
version: VERSION,
|
|
3069
|
+
title: String(opts.title),
|
|
3070
|
+
lang: opts.lang || 'zh',
|
|
3071
|
+
params: {
|
|
3072
|
+
episodes: Number(opts.episodes),
|
|
3073
|
+
minutesPerEpisode: Number(opts.minutes),
|
|
3074
|
+
genre: String(opts.genre),
|
|
3075
|
+
adaptMode: opts.adapt || DEFAULT_PARAMS.adaptMode,
|
|
3076
|
+
ratio: opts.ratio || DEFAULT_PARAMS.ratio,
|
|
3077
|
+
video,
|
|
3078
|
+
// Store the provider's own spelling, and fall back to the nearest tier this model has.
|
|
3079
|
+
videoResolution: nearestResolution(mode, opts.resolution || DEFAULT_PARAMS.videoResolution),
|
|
3080
|
+
speechAccent: opts.accent || DEFAULT_PARAMS.speechAccent,
|
|
3081
|
+
references: opts.references || DEFAULT_PARAMS.references,
|
|
3082
|
+
// `auto` unless this model has no autoDuration parameter.
|
|
3083
|
+
videoDuration: opts.duration || (mode.autoDuration ? DEFAULT_PARAMS.videoDuration : 'fixed'),
|
|
3084
|
+
lookPhrase: opts.look || DEFAULT_PARAMS.lookPhrase,
|
|
3085
|
+
style: opts.style || DEFAULT_PARAMS.style,
|
|
3086
|
+
register: normalizeRegister(opts.register || DEFAULT_PARAMS.register),
|
|
3087
|
+
},
|
|
3088
|
+
source: { kind: opts.source || 'original', path: opts.sourcePath || '', note: '' },
|
|
3089
|
+
outline: {
|
|
3090
|
+
core: '',
|
|
3091
|
+
brief: { logline: '', audience: '', satisfactionEngine: '', mainHook: '', protagonistPressure: '', antagonistPressure: '', emotionalLine: '', promise: '' },
|
|
3092
|
+
structure: [],
|
|
3093
|
+
guideline: { tone: '', emotionalIntensity: '', dialogueStyle: '', visualStyle: '', productionLimits: '', forbidden: [], priorities: [] },
|
|
3094
|
+
keep: [], cut: [], merge: [], risks: [], beats: [], episodes: [],
|
|
3095
|
+
},
|
|
3096
|
+
characters: [],
|
|
3097
|
+
scenes: [],
|
|
3098
|
+
props: [],
|
|
3099
|
+
wji: { workspaceId: '', datasetFolders: {}, canvases: { episodes: {} } },
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// ── file IO ─────────────────────────────────────────────────────────────────
|
|
3104
|
+
|
|
3105
|
+
export function projectPaths(input) {
|
|
3106
|
+
const abs = resolve(input);
|
|
3107
|
+
const dir = basename(abs) === 'project.json' ? dirname(abs) : abs;
|
|
3108
|
+
return { dir, project: join(dir, 'project.json'), episodes: join(dir, 'episodes') };
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
export function readJsonFile(path) {
|
|
3112
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
export function writeJsonFile(path, data) {
|
|
3116
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3117
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
export function loadProject(input, opts = {}) {
|
|
3121
|
+
const paths = projectPaths(input);
|
|
3122
|
+
if (!existsSync(paths.project)) throw new Error(`no project.json in ${paths.dir}`);
|
|
3123
|
+
const project = readJsonFile(paths.project);
|
|
3124
|
+
const episodes = {};
|
|
3125
|
+
const wanted = opts.eps ? new Set(opts.eps) : null;
|
|
3126
|
+
const n = Number(project.params?.episodes) || 0;
|
|
3127
|
+
for (let ep = 1; ep <= Math.max(n, 99); ep++) {
|
|
3128
|
+
if (wanted && !wanted.has(ep)) continue;
|
|
3129
|
+
const file = join(paths.episodes, `E${pad2(ep)}.json`);
|
|
3130
|
+
if (existsSync(file)) episodes[ep] = readJsonFile(file);
|
|
3131
|
+
else if (ep > n) break;
|
|
3132
|
+
}
|
|
3133
|
+
return { paths, project, episodes };
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
3137
|
+
|
|
3138
|
+
function flag(args, name, fallback) {
|
|
3139
|
+
const i = args.indexOf(name);
|
|
3140
|
+
if (i === -1) return fallback;
|
|
3141
|
+
const v = args[i + 1];
|
|
3142
|
+
if (v === undefined || v.startsWith('--')) return true;
|
|
3143
|
+
return v;
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
function positional(args) {
|
|
3147
|
+
const out = [];
|
|
3148
|
+
for (let i = 0; i < args.length; i++) {
|
|
3149
|
+
if (args[i].startsWith('--')) {
|
|
3150
|
+
const next = args[i + 1];
|
|
3151
|
+
if (next !== undefined && !next.startsWith('--')) i += 1;
|
|
3152
|
+
continue;
|
|
3153
|
+
}
|
|
3154
|
+
out.push(args[i]);
|
|
3155
|
+
}
|
|
3156
|
+
return out;
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
function printGates(results, { all }) {
|
|
3160
|
+
let failed = 0;
|
|
3161
|
+
let notes = 0;
|
|
3162
|
+
for (const r of results) {
|
|
3163
|
+
if (r.skipped) { if (all) console.log(` – ${r.stage}/${r.id} · ${r.label} (skipped: ${r.skipped})`); continue; }
|
|
3164
|
+
if (r.advisory) {
|
|
3165
|
+
const warnings = r.warnings || [];
|
|
3166
|
+
notes += warnings.length;
|
|
3167
|
+
if (all || warnings.length) console.log(` ${warnings.length ? '⚠' : '✓'} ${r.stage}/${r.id} · ${r.label} (advisory)`);
|
|
3168
|
+
for (const w of warnings) console.log(` ⚠ ${w}`);
|
|
3169
|
+
continue;
|
|
3170
|
+
}
|
|
3171
|
+
const ok = !r.failures.length;
|
|
3172
|
+
if (!ok) failed += 1;
|
|
3173
|
+
if (all || !ok) console.log(` ${ok ? '✓' : '✗'} ${r.stage}/${r.id} · ${r.label}`);
|
|
3174
|
+
for (const f of r.failures) console.log(` - ${f}`);
|
|
3175
|
+
}
|
|
3176
|
+
return { failed, notes };
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
function main(argv) {
|
|
3180
|
+
const [cmd, ...rest] = argv;
|
|
3181
|
+
const pos = positional(rest);
|
|
3182
|
+
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
3183
|
+
console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 16).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
|
|
3184
|
+
return 0;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
if (cmd === 'slug') {
|
|
3188
|
+
console.log(slug(pos[0]));
|
|
3189
|
+
return 0;
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
if (cmd === 'init') {
|
|
3193
|
+
const dir = pos[0];
|
|
3194
|
+
if (!dir) throw new Error('usage: init <dir> --title … --episodes N --minutes M --genre G');
|
|
3195
|
+
const project = initProject({
|
|
3196
|
+
title: flag(rest, '--title'), episodes: flag(rest, '--episodes'), minutes: flag(rest, '--minutes'), genre: flag(rest, '--genre'),
|
|
3197
|
+
adapt: flag(rest, '--adapt'), ratio: flag(rest, '--ratio'), video: flag(rest, '--video'), style: flag(rest, '--style'), lang: flag(rest, '--lang'),
|
|
3198
|
+
resolution: flag(rest, '--resolution'), accent: flag(rest, '--accent'),
|
|
3199
|
+
references: flag(rest, '--references'), duration: flag(rest, '--duration'), look: flag(rest, '--look'),
|
|
3200
|
+
register: flag(rest, '--register'),
|
|
3201
|
+
source: flag(rest, '--source'), sourcePath: flag(rest, '--source-path'),
|
|
3202
|
+
});
|
|
3203
|
+
const paths = projectPaths(dir);
|
|
3204
|
+
if (existsSync(paths.project) && !flag(rest, '--force')) throw new Error(`${paths.project} exists (pass --force to overwrite)`);
|
|
3205
|
+
writeJsonFile(paths.project, project);
|
|
3206
|
+
mkdirSync(paths.episodes, { recursive: true });
|
|
3207
|
+
console.log(`✓ ${paths.project}`);
|
|
3208
|
+
return 0;
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
const input = pos[0];
|
|
3212
|
+
if (!input) throw new Error(`usage: ${cmd} <project> …`);
|
|
3213
|
+
const eps = parseEpRange(flag(rest, '--ep'));
|
|
3214
|
+
|
|
3215
|
+
if (cmd === 'validate' || cmd === 'checkup') {
|
|
3216
|
+
const stage = flag(rest, '--stage');
|
|
3217
|
+
if (stage && !STAGES.includes(stage)) throw new Error(`--stage must be one of ${STAGES.join(' / ')}`);
|
|
3218
|
+
const { project, episodes } = loadProject(input, { eps });
|
|
3219
|
+
const ctx = makeContext(project, episodes, { eps });
|
|
3220
|
+
const results = runGates(ctx, { stage });
|
|
3221
|
+
const { failed, notes } = printGates(results, { all: cmd === 'checkup' });
|
|
3222
|
+
const hard = results.filter((r) => !r.skipped && !r.advisory).length;
|
|
3223
|
+
const advisory = results.filter((r) => !r.skipped && r.advisory).length;
|
|
3224
|
+
const tail = advisory ? `, ${advisory} advisory (${notes} note${notes === 1 ? '' : 's'})` : '';
|
|
3225
|
+
console.log(failed ? `✗ ${failed}/${hard} gate(s) failed${tail}` : `✓ ${hard} gate(s) passed${stage ? ` (through ${stage})` : ''}${tail}`);
|
|
3226
|
+
return cmd === 'validate' && failed ? 1 : 0;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
if (cmd === 'seed') {
|
|
3230
|
+
const what = pos[1];
|
|
3231
|
+
const ep = Number(flag(rest, '--ep'));
|
|
3232
|
+
if (!['script', 'storyboard'].includes(what) || !ep) throw new Error('usage: seed <project> script|storyboard --ep N [--force]');
|
|
3233
|
+
const { paths, project, episodes } = loadProject(input);
|
|
3234
|
+
const file = join(paths.episodes, `E${pad2(ep)}.json`);
|
|
3235
|
+
if (what === 'script') {
|
|
3236
|
+
if (existsSync(file) && !flag(rest, '--force')) throw new Error(`${file} exists (pass --force to overwrite)`);
|
|
3237
|
+
const seeded = seedScript(project, ep);
|
|
3238
|
+
// --force re-seeds the script, never the bindings: ids created on WJI stay bound.
|
|
3239
|
+
if (episodes[ep]?.wji) seeded.wji = { ...seeded.wji, ...episodes[ep].wji };
|
|
3240
|
+
writeJsonFile(file, seeded);
|
|
3241
|
+
} else {
|
|
3242
|
+
const episode = episodes[ep];
|
|
3243
|
+
if (!episode) throw new Error(`${file} does not exist — seed the script first`);
|
|
3244
|
+
if ((episode.storyboard?.segments || []).length && !flag(rest, '--force')) throw new Error(`${file} already has segments (pass --force to reset them)`);
|
|
3245
|
+
writeJsonFile(file, seedStoryboard(project, episode));
|
|
3246
|
+
}
|
|
3247
|
+
console.log(`✓ ${file}`);
|
|
3248
|
+
return 0;
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
if (cmd === 'render') {
|
|
3252
|
+
const what = pos[1];
|
|
3253
|
+
const { project, episodes } = loadProject(input);
|
|
3254
|
+
const needEp = () => {
|
|
3255
|
+
const ep = Number(flag(rest, '--ep'));
|
|
3256
|
+
if (!ep || !episodes[ep]) throw new Error(`--ep N with an existing episodes/E0N.json is required`);
|
|
3257
|
+
return episodes[ep];
|
|
3258
|
+
};
|
|
3259
|
+
if (what === 'outline') console.log(renderOutline(project));
|
|
3260
|
+
else if (what === 'guideline') console.log(renderGuideline(project));
|
|
3261
|
+
else if (what === 'dialogue') console.log(renderDialogue(project, needEp()));
|
|
3262
|
+
else if (what === 'character') console.log(renderCharacter(project, pos[2]));
|
|
3263
|
+
else if (what === 'scene') console.log(renderScene(project, pos[2]));
|
|
3264
|
+
else if (what === 'prop') console.log(renderProp(project, pos[2]));
|
|
3265
|
+
else if (what === 'script') console.log(renderScript(project, needEp()));
|
|
3266
|
+
else if (what === 'segment') {
|
|
3267
|
+
const id = pos[2];
|
|
3268
|
+
const ep = Number((id || '').match(/^E(\d+)-/)?.[1]);
|
|
3269
|
+
if (!ep || !episodes[ep]) throw new Error(`segment id like E01-01 whose episode file exists is required`);
|
|
3270
|
+
console.log(renderSegment(project, episodes[ep], id));
|
|
3271
|
+
} else throw new Error('usage: render <project> outline | guideline | character C01 | scene S01 | prop P01 | script --ep N | dialogue --ep N | segment E01-01');
|
|
3272
|
+
return 0;
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
if (cmd === 'prompt' && pos[1] === 'audio') {
|
|
3276
|
+
const id = pos[2];
|
|
3277
|
+
const ep = Number((id || '').match(/^E(\d+)-/)?.[1]);
|
|
3278
|
+
const { project, episodes } = loadProject(input);
|
|
3279
|
+
if (!ep || !episodes[ep]) throw new Error('usage: prompt <project> audio E01-01 [--full|--bed]');
|
|
3280
|
+
const seg = (episodes[ep].storyboard?.segments || []).find((x) => x.id === id);
|
|
3281
|
+
if (!seg) throw new Error(`no segment ${id}`);
|
|
3282
|
+
const mode = flag(rest, '--full') ? 'full' : flag(rest, '--bed') ? 'bed' : undefined;
|
|
3283
|
+
const out = audioPrompt(project, episodes[ep], seg, mode ? { mode } : {});
|
|
3284
|
+
if (flag(rest, '--json')) console.log(JSON.stringify(out, null, 2));
|
|
3285
|
+
else {
|
|
3286
|
+
console.log(`# ${out.segment} · audio ${out.model} · ${out.mode} · ${round2(out.seconds)}s${out.references.length ? ` · refs: ${out.references.map((r) => `${r.label} ← ${r.entity}:${r.asset}`).join(', ')}` : ''}`);
|
|
3287
|
+
console.log('');
|
|
3288
|
+
console.log(out.prompt);
|
|
3289
|
+
}
|
|
3290
|
+
return 0;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
if (cmd === 'prompt' && pos[1] === 'voice') {
|
|
3294
|
+
const { project } = loadProject(input);
|
|
3295
|
+
const c = characterById(project, pos[2]);
|
|
3296
|
+
if (!c) throw new Error(`no character ${pos[2]}`);
|
|
3297
|
+
console.log(voiceSamplePrompt(c, paramsOf(project)));
|
|
3298
|
+
return 0;
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
if (cmd === 'prompt') {
|
|
3302
|
+
const id = pos[2];
|
|
3303
|
+
const ep = Number((id || '').match(/^E(\d+)-/)?.[1]);
|
|
3304
|
+
const { project, episodes } = loadProject(input);
|
|
3305
|
+
if (pos[1] !== 'segment' || !ep || !episodes[ep]) throw new Error('usage: prompt <project> segment E01-01');
|
|
3306
|
+
const out = promptSkeleton(project, episodes[ep], id);
|
|
3307
|
+
if (flag(rest, '--json')) console.log(JSON.stringify(out, null, 2));
|
|
3308
|
+
else {
|
|
3309
|
+
// `duration: null` means auto — say so, and say what the clip was planned at.
|
|
3310
|
+
const dur = out.video.duration == null ? `auto (planned ${out.video.plannedSeconds}s — pass no -d)` : `${out.video.duration}s`;
|
|
3311
|
+
console.log(`# ${out.segment} · video ${out.video.model} · ${out.video.ratio} · ${out.video.resolution} · ${dur} · refs: ${out.video.references.join(', ')}`);
|
|
3312
|
+
console.log('');
|
|
3313
|
+
console.log(out.video.prompt);
|
|
3314
|
+
console.log('');
|
|
3315
|
+
for (const k of out.keyframes) {
|
|
3316
|
+
console.log(`## keyframe ${k.name} (${k.ratio})`);
|
|
3317
|
+
for (const r of k.references) console.log(` ${r.label} ← ${r.entity}:${r.asset} (${r.why})`);
|
|
3318
|
+
console.log(` ${k.prompt}`);
|
|
3319
|
+
console.log('');
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
return 0;
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
if (cmd === 'manifest') {
|
|
3326
|
+
const stage = flag(rest, '--stage');
|
|
3327
|
+
const { project, episodes } = loadProject(input);
|
|
3328
|
+
const items = manifest(project, episodes, { stage, ep: flag(rest, '--ep') });
|
|
3329
|
+
if (flag(rest, '--json')) console.log(JSON.stringify(items, null, 2));
|
|
3330
|
+
else if (!items.length) console.log('✓ nothing unbound');
|
|
3331
|
+
else {
|
|
3332
|
+
for (const it of items) console.log(` [${it.stage}] ${it.entity.padEnd(8)} ${it.item}\n ${it.hint}`);
|
|
3333
|
+
console.log(`${items.length} unbound item(s)`);
|
|
3334
|
+
}
|
|
3335
|
+
return items.length ? 2 : 0;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
if (cmd === 'bind') {
|
|
3339
|
+
const path = pos[1], value = pos[2];
|
|
3340
|
+
if (!path || value === undefined) throw new Error('usage: bind <project> [--ep N] <path> <value>');
|
|
3341
|
+
const { paths, project, episodes } = loadProject(input);
|
|
3342
|
+
const ep = Number(flag(rest, '--ep'));
|
|
3343
|
+
let target = project, file = paths.project;
|
|
3344
|
+
if (ep) {
|
|
3345
|
+
if (!episodes[ep]) throw new Error(`episodes/E${pad2(ep)}.json does not exist`);
|
|
3346
|
+
target = episodes[ep];
|
|
3347
|
+
file = join(paths.episodes, `E${pad2(ep)}.json`);
|
|
3348
|
+
}
|
|
3349
|
+
let v = value;
|
|
3350
|
+
if (v === 'null') v = null;
|
|
3351
|
+
else if (v === 'true') v = true;
|
|
3352
|
+
else if (v === 'false') v = false;
|
|
3353
|
+
else if (v.startsWith('{') || v.startsWith('[')) v = JSON.parse(v);
|
|
3354
|
+
bindPath(target, path, v);
|
|
3355
|
+
writeJsonFile(file, target);
|
|
3356
|
+
console.log(`✓ ${path} = ${typeof v === 'string' ? v : JSON.stringify(v)}${ep ? ` (E${pad2(ep)})` : ''}`);
|
|
3357
|
+
return 0;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
throw new Error(`unknown command "${cmd}" — run with --help`);
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
3364
|
+
if (isMain) {
|
|
3365
|
+
try {
|
|
3366
|
+
process.exitCode = main(process.argv.slice(2));
|
|
3367
|
+
} catch (err) {
|
|
3368
|
+
console.error(`✗ ${err.message}`);
|
|
3369
|
+
process.exitCode = 1;
|
|
3370
|
+
}
|
|
3371
|
+
}
|