@wji.ai/cli 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -9
- package/skill/short-drama/SKILL.md +44 -23
- package/skill/short-drama/dashboard.html +863 -0
- package/skill/short-drama/examples/night-shift/episodes/E01.json +1 -1
- package/skill/short-drama/examples/night-shift/project.json +5 -4
- package/skill/short-drama/reference/canvas-recipes.md +1 -1
- package/skill/short-drama/reference/cinematography-low-key.md +5 -6
- package/skill/short-drama/reference/cinematography.md +1 -1
- package/skill/short-drama/reference/craft-motion.md +21 -1
- package/skill/short-drama/reference/craft-shots.md +24 -0
- package/skill/short-drama/reference/edit-tools.md +65 -0
- package/skill/short-drama/reference/mix-over-video.md +5 -1
- package/skill/short-drama/reference/operations-cli.md +3 -3
- package/skill/short-drama/reference/schema.md +18 -10
- package/skill/short-drama/reference/stage-characters.md +26 -16
- package/skill/short-drama/reference/stage-storyboard.md +34 -7
- package/skill/short-drama/reference/workspace-layout.md +1 -1
- package/skill/short-drama/scripts/selftest.mjs +440 -33
- package/skill/short-drama/scripts/short-drama.mjs +644 -107
- package/skill/short-drama/scripts/short-drama-dashboard.mjs +0 -459
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// [--market mainland|overseas] (mainland holds the supernatural to a rational explanation)
|
|
20
20
|
// [--format short|medium|movie] (短剧 1–3 min, 中剧 4–15 min, 电影 in ~5-minute parts — see FORMATS)
|
|
21
21
|
// [--register 台湾国语] (语体 — decides how lines are written and the accent spoken)
|
|
22
|
+
// [--foreground low|medium|high] (how often cuts shoot past a foreground — see FOREGROUND_LEVELS)
|
|
23
|
+
// [--image-lang en|zh] (language of the character / scene / prop image prompts)
|
|
22
24
|
// validate <project> [--stage outline|characters|art|script|storyboard] [--ep 1-3]
|
|
23
25
|
// checkup <project> [--stage …] [--ep …] every gate with ✓/✗, exit 0
|
|
24
26
|
// seed <project> script --ep N [--force] episodes/E0N.json skeleton from the outline
|
|
@@ -26,19 +28,20 @@
|
|
|
26
28
|
// render <project> outline | guideline | character C01 | scene S01 | prop P01 | script --ep N | dialogue --ep N | segment E01-01
|
|
27
29
|
// prompt <project> segment E01-01 derived video-prompt skeleton + keyframe reference plan
|
|
28
30
|
// prompt <project> voice C01 the acted sample text for the voice node
|
|
31
|
+
// prompt <project> image C01|S01|P01 [--asset headshot|sheet|lighting:<state>|state:<state>] [--json]
|
|
32
|
+
// the image node's prompt, in params.imagePromptLang (+ negative, ratio, reference)
|
|
29
33
|
// prompt <project> audio E01-01 [--full] the segment's Seed Audio soundtrack prompt (timecoded ambience / sfx / music, + lines with --full)
|
|
30
34
|
// prompt <project> score E01 [--fps 24] the episode's spotting sheet for the post soundtrack (audioTrack post): planned
|
|
31
35
|
// timecodes of scenes, lines, music cues and post sfx, and the ≤120 s Seed Audio sections
|
|
32
|
-
// dashboard <project> [--output dashboard.html] [--
|
|
33
|
-
// static
|
|
36
|
+
// dashboard <project> [--output dashboard.html] [--force]
|
|
37
|
+
// copy the static dashboard next to the project; it loads project.json
|
|
34
38
|
// manifest <project> [--stage …] [--ep N] unbound WJI objects, in execution order
|
|
35
39
|
// bind <project> [--ep N] <path> <value> write a WJI id back (path segments may be entity ids)
|
|
36
40
|
// slug <name>
|
|
37
41
|
|
|
38
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
42
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
39
43
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
40
44
|
import { fileURLToPath } from 'node:url';
|
|
41
|
-
import { renderDashboard } from './short-drama-dashboard.mjs';
|
|
42
45
|
|
|
43
46
|
// ── constants ───────────────────────────────────────────────────────────────
|
|
44
47
|
|
|
@@ -88,6 +91,9 @@ export const DEFAULT_PARAMS = Object.freeze({
|
|
|
88
91
|
format: 'short',
|
|
89
92
|
// Where it is released (MARKETS): mainland holds the supernatural to a rational explanation.
|
|
90
93
|
market: 'mainland',
|
|
94
|
+
// The language of the character / scene / prop image prompts (sheets, headshots, lighting and
|
|
95
|
+
// prop states): `en`, or `zh` when the image model takes Chinese and the crew wants to read them.
|
|
96
|
+
imagePromptLang: 'en',
|
|
91
97
|
minCutSeconds: 2,
|
|
92
98
|
maxCutSeconds: 5,
|
|
93
99
|
maxOnScreen: 3,
|
|
@@ -109,6 +115,8 @@ export const DEFAULT_PARAMS = Object.freeze({
|
|
|
109
115
|
audioTrack: 'post',
|
|
110
116
|
// Frame rate the post spotting sheet writes SMPTE timecode at (conform it to the edit's real fps).
|
|
111
117
|
scoreFps: 24,
|
|
118
|
+
// Scene-boundary contrast: last K cuts of scene N vs first K of scene N+1 (gate boundary-contrast).
|
|
119
|
+
boundaryContrastCuts: 2,
|
|
112
120
|
thresholds: { maxLeads: 5, maxSupport: 10, maxFunctional: 10, maxBeatGap: 3, maxProps: 8 },
|
|
113
121
|
});
|
|
114
122
|
|
|
@@ -192,6 +200,28 @@ export const AUDIO_MAX_REFS = 3;
|
|
|
192
200
|
* `bed` (ambience + sfx + music) / `full` (the bed plus the lines): a Seed Audio node per segment.
|
|
193
201
|
*/
|
|
194
202
|
export const AUDIO_TRACK_MODES = ['post', 'none', 'bed', 'full'];
|
|
203
|
+
// Share of an episode's cuts that may carry a foreground. Measured per episode, not per segment, so a
|
|
204
|
+
// siege can be full of them and a quiet confession can have none.
|
|
205
|
+
export const FOREGROUND_LEVELS = Object.freeze({
|
|
206
|
+
low: { min: 0, max: 0.25 },
|
|
207
|
+
medium: { min: 0.25, max: 0.5 },
|
|
208
|
+
high: { min: 0.5, max: 1 },
|
|
209
|
+
});
|
|
210
|
+
/**
|
|
211
|
+
* The level a project gets when it does not name one: `high` for 唯美 or 美感优先 — the art-first
|
|
212
|
+
* composition, the two 唯美 cinematography presets, or a genre that says so — `medium` otherwise.
|
|
213
|
+
*/
|
|
214
|
+
export function foregroundForLook(params) {
|
|
215
|
+
const aesthetic = params?.composition === 'art-first'
|
|
216
|
+
|| ['sweet-romance', 'costume-idol'].includes(params?.cinematography)
|
|
217
|
+
|| /唯美|美感/.test(String(params?.genre || ''));
|
|
218
|
+
return aesthetic ? 'high' : 'medium';
|
|
219
|
+
}
|
|
220
|
+
/** The foreground cut count a level allows in an episode of `cuts` cuts. */
|
|
221
|
+
export function foregroundBand(level, cuts) {
|
|
222
|
+
const band = FOREGROUND_LEVELS[level];
|
|
223
|
+
return band ? { min: Math.floor(band.min * cuts), max: Math.ceil(band.max * cuts) } : null;
|
|
224
|
+
}
|
|
195
225
|
/** No per-segment audio node in these modes. */
|
|
196
226
|
export const NO_SEGMENT_TRACK = ['post', 'none'];
|
|
197
227
|
/** What the music does at a cue point. */
|
|
@@ -204,6 +234,7 @@ export const DURATION_MODES = ['fixed', 'auto'];
|
|
|
204
234
|
* move carry over instead of being re-invented per clip. `none` leaves the sheets to do that alone.
|
|
205
235
|
*/
|
|
206
236
|
export const VIDEO_REFERENCE_MODES = ['none', 'previous'];
|
|
237
|
+
export const IMAGE_PROMPT_LANGS = ['en', 'zh'];
|
|
207
238
|
|
|
208
239
|
// ── screenplay craft (from the w-short-drama-editor / character-creator / dialogue-editor method) ──
|
|
209
240
|
|
|
@@ -286,6 +317,7 @@ export const NO_SUBTITLES = { en: 'no subtitles, no captions, no on-screen dialo
|
|
|
286
317
|
export const LIPS_CLOSED = 'lips remain completely closed';
|
|
287
318
|
export const WARNINGS = ['雨戏', '肢体接触', '人群', '手部特写'];
|
|
288
319
|
export const PROP_SCALES = { handheld: 'handheld scale', tabletop: 'tabletop scale', furniture: 'furniture scale' };
|
|
320
|
+
export const PROP_SCALES_ZH = { handheld: '手持尺寸', tabletop: '桌面尺寸', furniture: '大件尺寸' };
|
|
289
321
|
export const SIZES = {
|
|
290
322
|
'extreme-wide': 'extreme wide shot',
|
|
291
323
|
wide: 'wide shot',
|
|
@@ -404,10 +436,58 @@ export function speechDirection(character, delivery, lang = 'en') {
|
|
|
404
436
|
return bits.join(lang === 'zh' ? '\uff0c' : ', ');
|
|
405
437
|
}
|
|
406
438
|
|
|
439
|
+
// Two people on screen. A model puts into the shot everyone the shot names, and "looking at him" gives
|
|
440
|
+
// it no direction, so sheets-mode prompts say who is in frame, which way each person looks and which
|
|
441
|
+
// side each stands on (gate two-person-framing).
|
|
442
|
+
export const EYELINE_DIRECTIONS = ['screen-left', 'screen-right'];
|
|
443
|
+
export const EYELINE_HEIGHTS = ['level', 'up', 'down'];
|
|
444
|
+
/** "screen-right down" · "away screen-left" · "task" (eyes on what the hands are doing). */
|
|
445
|
+
export function parseEyeline(value) {
|
|
446
|
+
const words = String(value || '').trim().split(/\s+/).filter(Boolean);
|
|
447
|
+
if (words[0] === 'task') return { task: true };
|
|
448
|
+
const away = words[0] === 'away';
|
|
449
|
+
const [to, height = 'level'] = away ? words.slice(1) : words;
|
|
450
|
+
return { to, height, away };
|
|
451
|
+
}
|
|
452
|
+
export function eyelineValid(value) {
|
|
453
|
+
const e = parseEyeline(value);
|
|
454
|
+
return Boolean(e.task) || (EYELINE_DIRECTIONS.includes(e.to) && EYELINE_HEIGHTS.includes(e.height));
|
|
455
|
+
}
|
|
456
|
+
export function eyelinePhrase(value, who, lang = 'en') {
|
|
457
|
+
const e = parseEyeline(value);
|
|
458
|
+
const zh = lang === 'zh';
|
|
459
|
+
if (e.task) return zh ? `${who}的视线落在手上的活上。` : `${who} keeps their eyes on what their hands are doing.`;
|
|
460
|
+
const side = zh ? (e.to === 'screen-left' ? '左' : '右') : e.to;
|
|
461
|
+
if (zh) return `${who}的视线${e.away ? '移开,' : ''}朝画面${side}侧${e.height === 'up' ? '上方' : e.height === 'down' ? '下方' : '平视'}。`;
|
|
462
|
+
return `${who} ${e.away ? 'looks away toward' : 'looks toward'} ${side}${e.height === 'up' ? ' and up' : e.height === 'down' ? ' and down' : ' at eye level'}.`;
|
|
463
|
+
}
|
|
464
|
+
export function sidesLine(axis, whoOf, lang = 'en') {
|
|
465
|
+
const left = whoOf(axis.screenLeft), right = whoOf(axis.screenRight);
|
|
466
|
+
return lang === 'zh' ? `站位:${left}始终在画面左侧,${right}始终在画面右侧。` : `Sides: ${left} stays on screen left and ${right} on screen right in every shot.`;
|
|
467
|
+
}
|
|
468
|
+
export function onlyInFramePhrase(who, lang = 'en') {
|
|
469
|
+
return lang === 'zh' ? `画面中只有${who}。` : `Only ${who} in frame.`;
|
|
470
|
+
}
|
|
471
|
+
export function overShoulderPhrase(who, side, lang = 'en') {
|
|
472
|
+
return lang === 'zh'
|
|
473
|
+
? `越过${who}的肩拍摄:其肩与后脑虚化在画面${side === 'left' ? '左' : '右'}下方前景,看不到其脸。`
|
|
474
|
+
: `Over-the-shoulder from behind ${who}: their shoulder and the back of their head soft in the lower-${side} foreground, their face not visible.`;
|
|
475
|
+
}
|
|
476
|
+
/** What a shot opens with in a two-person segment: who is in frame, then where each person looks. */
|
|
477
|
+
export function shotFraming(cut, seg, whoOf, lang = 'en') {
|
|
478
|
+
if (segmentCharacters(seg).length < 2) return '';
|
|
479
|
+
const chars = cut.characters || [];
|
|
480
|
+
const parts = [];
|
|
481
|
+
if (cut.ots) parts.push(overShoulderPhrase(whoOf(cut.ots), seg.axis?.screenLeft === cut.ots ? 'left' : 'right', lang));
|
|
482
|
+
else if (chars.length === 1) parts.push(onlyInFramePhrase(whoOf(chars[0]), lang));
|
|
483
|
+
for (const id of chars) if (id !== cut.ots && cut.eyeline?.[id]) parts.push(eyelinePhrase(cut.eyeline[id], whoOf(id), lang));
|
|
484
|
+
return parts.length ? `${parts.join(' ')} ` : '';
|
|
485
|
+
}
|
|
486
|
+
|
|
407
487
|
/**
|
|
408
488
|
* The blurred thing between the lens and the subject. Depth needs something to be shallow against,
|
|
409
|
-
* so a cut
|
|
410
|
-
* clean frame
|
|
489
|
+
* so a cut that needs one names it and the prompt says it is out of focus; an empty `foreground` is a
|
|
490
|
+
* deliberate clean frame. params.foregroundLevel sets how many of each an episode has.
|
|
411
491
|
*/
|
|
412
492
|
export function foregroundPhrase(cut, lang = 'en') {
|
|
413
493
|
const fg = String(cut?.foreground || '').trim();
|
|
@@ -426,6 +506,23 @@ export function motionSentence(cut, lang = 'en') {
|
|
|
426
506
|
return `Cue: ${t}. Action: ${a}. End: ${r}.`;
|
|
427
507
|
}
|
|
428
508
|
|
|
509
|
+
// Everyone on screen is alive. A model freezes whoever the prompt gives no motion, so each person other
|
|
510
|
+
// than the one carrying the cut's action gets a small motion of their own (gate idle-motion).
|
|
511
|
+
export const IDLE_STILL = 'still';
|
|
512
|
+
// A line, a mouth that moves or a change of place is a cut's action, not an idle.
|
|
513
|
+
export const IDLE_TOO_BIG = /\b(says?|speaks?|talks?|whispers?|shouts?|mouths?|chews?|walks?|crosses|leaves|enters|runs?|sits? down|stands? up)\b|[说說讲講喊嚼]|走向|离开|離開|坐下|站起/i;
|
|
514
|
+
export const IDLE_MAX_CHARS = 90;
|
|
515
|
+
export function idlePhrase(idle, who, lang = 'en') {
|
|
516
|
+
const text = String(idle || '').trim().replace(/[.。]+$/, '');
|
|
517
|
+
if (text === IDLE_STILL) return lang === 'zh' ? `${who}保持不动,只有呼吸与眨眼。` : `${who} holds still, only breathing and blinking.`;
|
|
518
|
+
return lang === 'zh' ? `${who}${text}。` : `${who} ${text}.`;
|
|
519
|
+
}
|
|
520
|
+
/** The idle motions of a cut, in on-screen order. */
|
|
521
|
+
export function idleSentence(cut, whoOf, lang = 'en') {
|
|
522
|
+
const parts = (cut.characters || []).filter((id) => cut.idle?.[id]).map((id) => idlePhrase(cut.idle[id], whoOf(id), lang));
|
|
523
|
+
return parts.length ? `${parts.join(' ')} ` : '';
|
|
524
|
+
}
|
|
525
|
+
|
|
429
526
|
/**
|
|
430
527
|
* A lock-off is written (what still moves, held), a move is written (why, when, along what, ending
|
|
431
528
|
* where) — an empty camera line is read by the model as permission to drift.
|
|
@@ -472,6 +569,10 @@ export const STYLE_PRESETS = {
|
|
|
472
569
|
negativeMustNot: ['photorealistic', '3d render'],
|
|
473
570
|
negativeMust: [],
|
|
474
571
|
tags: ['semi-realistic', 'painterly', 'cinematic'],
|
|
572
|
+
renderZh: '半写实角色插画,绘画质感,笔触柔和融合并带可见的笔刷肌理,解剖结构扎实',
|
|
573
|
+
framePhraseZh: '电影剧照',
|
|
574
|
+
negativeMustNotZh: ['照片级', '真人', '3d渲染'],
|
|
575
|
+
negativeMustZh: [],
|
|
475
576
|
},
|
|
476
577
|
photoreal: {
|
|
477
578
|
label: 'photographic live-action',
|
|
@@ -481,6 +582,10 @@ export const STYLE_PRESETS = {
|
|
|
481
582
|
negativeMustNot: ['photorealistic', 'film grain', 'skin texture'],
|
|
482
583
|
negativeMust: ['illustration', 'anime', 'cartoon'],
|
|
483
584
|
tags: ['photoreal', 'live action', 'cinematic'],
|
|
585
|
+
renderZh: '真人实拍参考摄影,皮肤可见毛孔与细绒毛,布料有真实织纹与磨损,中性电影调色',
|
|
586
|
+
framePhraseZh: '照片级写实电影剧照',
|
|
587
|
+
negativeMustNotZh: ['照片级', '胶片颗粒', '皮肤纹理'],
|
|
588
|
+
negativeMustZh: ['插画', '动漫', '卡通'],
|
|
484
589
|
},
|
|
485
590
|
soft3d: {
|
|
486
591
|
label: 'soft clay-toy 3D animation',
|
|
@@ -490,6 +595,10 @@ export const STYLE_PRESETS = {
|
|
|
490
595
|
negativeMustNot: ['3d render', 'cgi', 'stylized', 'clay', 'matte', 'toy', 'simplified'],
|
|
491
596
|
negativeMust: ['photorealistic'],
|
|
492
597
|
tags: ['3d animation', 'clay render', 'soft toy style', 'kids tv'],
|
|
598
|
+
renderZh: '柔和哑光的黏土玩具风格三维渲染,表面光滑无毛孔,发型是一整块圆润的造型,五官极简,四肢圆胖,大头身比,柔和全局光',
|
|
599
|
+
framePhraseZh: '黏土动画电影剧照',
|
|
600
|
+
negativeMustNotZh: ['3d渲染', 'cg', '风格化', '黏土', '哑光', '玩具', '简化'],
|
|
601
|
+
negativeMustZh: ['照片级'],
|
|
493
602
|
},
|
|
494
603
|
disney3d: {
|
|
495
604
|
label: 'Disney/Pixar-style 3D animation',
|
|
@@ -499,6 +608,10 @@ export const STYLE_PRESETS = {
|
|
|
499
608
|
negativeMustNot: ['3d render', 'cgi', 'stylized', 'cartoon'],
|
|
500
609
|
negativeMust: ['photorealistic'],
|
|
501
610
|
tags: ['3d animation', 'disney pixar style', 'family', 'cinematic'],
|
|
611
|
+
renderZh: '迪士尼与皮克斯长片风格的三维动画,圆润讨喜的造型语言,大而传神的眼睛,次表面散射皮肤,温暖的电影主光与轮廓光',
|
|
612
|
+
framePhraseZh: '三维动画电影剧照',
|
|
613
|
+
negativeMustNotZh: ['3d渲染', 'cg', '风格化', '卡通'],
|
|
614
|
+
negativeMustZh: ['照片级'],
|
|
502
615
|
},
|
|
503
616
|
ghibli: {
|
|
504
617
|
label: 'hand-painted anime',
|
|
@@ -508,9 +621,67 @@ export const STYLE_PRESETS = {
|
|
|
508
621
|
negativeMustNot: [],
|
|
509
622
|
negativeMust: ['photorealistic'],
|
|
510
623
|
tags: ['anime', 'cel shading', 'hand-painted'],
|
|
624
|
+
renderZh: '日本经典动画电影风格的手绘赛璐璐动画,干净线稿,平涂色彩配柔和水彩背景',
|
|
625
|
+
framePhraseZh: '手绘动画电影剧照',
|
|
626
|
+
negativeMustNotZh: [],
|
|
627
|
+
negativeMustZh: ['照片级'],
|
|
511
628
|
},
|
|
512
629
|
};
|
|
513
630
|
|
|
631
|
+
// Traditional → simplified for the characters the Chinese image-prompt rules look for, so a crew
|
|
632
|
+
// writing 繁體 (真人實拍參考攝影, 純白背景, 東亞) passes the same checks as one writing 简体.
|
|
633
|
+
const ZH_T = '實參攝寫繪畫質膚絨織紋損調級動風簡賽顆東亞漢華國韓歐維紀時當現戰無場沒純圖臉須髮還間構變燈樣與體條機啞寬細電影剛劇個們來這為後從歲種頭於將兩裡進遠擊衛萬處滿點眾邊師張長門開關問員陽陰雲書幾話鐵號專業輕壓環題顏鏡區鬥創標誌劃會戲網際聲氣態網雜淺濃暗餘濕黃綠藍紅寫臨團鄉樓廳櫃燈廢廠館護殘獨帶膠鮮爾歷羅陸屬樂傳麗貴壯結線淨';
|
|
634
|
+
const ZH_S = '实参摄写绘画质肤绒织纹损调级动风简赛颗东亚汉华国韩欧维纪时当现战无场没纯图脸须发还间构变灯样与体条机哑宽细电影刚剧个们来这为后从岁种头于将两里进远击卫万处满点众边师张长门开关问员阳阴云书几话铁号专业轻压环题颜镜区斗创标志划会戏网际声气态网杂浅浓暗余湿黄绿蓝红写临团乡楼厅柜灯废厂馆护残独带胶鲜尔历罗陆属乐传丽贵壮结线净';
|
|
635
|
+
const ZH_FOLD = new Map([...ZH_T].map((ch, i) => [ch, ZH_S[i]]));
|
|
636
|
+
const ZH_HANT = new Map([...ZH_S].map((ch, i) => [ch, [...ZH_T][i]]));
|
|
637
|
+
/** True when the text is written in 繁體 (it changes under folding). */
|
|
638
|
+
export function isHant(text) {
|
|
639
|
+
const t = String(text ?? '');
|
|
640
|
+
return [...t].some((ch) => ZH_FOLD.has(ch));
|
|
641
|
+
}
|
|
642
|
+
/** Simplified → traditional for the engine's own fixed phrases (not a general converter). */
|
|
643
|
+
export function hantPhrase(text) {
|
|
644
|
+
return [...String(text ?? '').replace(/干净/g, '乾淨')].map((ch) => ZH_HANT.get(ch) || ch).join('');
|
|
645
|
+
}
|
|
646
|
+
export function foldZh(text) {
|
|
647
|
+
return [...String(text ?? '')].map((ch) => ZH_FOLD.get(ch) || ch).join('').toLowerCase();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const ZH_ETHNICITY_RE = /(东亚|亚洲|亚裔|汉族|华人|中国|日本|韩国|朝鲜|欧洲|白人|黑人|非洲|拉丁|中东|阿拉伯|南亚|东南亚|混血|蒙古|藏族|维吾尔|鲜卑|匈奴|胡人|西域|印度)/;
|
|
651
|
+
// whole era words only — a bare 明 / 清 / 元 would match 明亮 / 清晰 / 元素
|
|
652
|
+
const ZH_ERA_RE = /(年代|世纪|时期|当代|现代|古代|近代|公元|未来|中世纪|维多利亚|民国|春秋|战国|先秦|秦朝|秦代|秦末|西汉|东汉|汉朝|汉代|汉初|汉末|三国|西晋|东晋|晋朝|南北朝|北魏|北齐|北周|南朝|隋朝|隋代|隋末|唐朝|唐代|初唐|盛唐|晚唐|五代|宋朝|宋代|北宋|南宋|元朝|元代|明朝|明代|明末|清朝|清代|晚清)/;
|
|
653
|
+
|
|
654
|
+
// the checked part of a Chinese render phrase: up to its first comma, at most 8 characters
|
|
655
|
+
const renderKeyZh = (preset) => preset.renderZh.split(',')[0].slice(0, 8);
|
|
656
|
+
|
|
657
|
+
/** The rules the image-prompt gates apply, in the project's image-prompt language. */
|
|
658
|
+
export function imagePromptKit(params) {
|
|
659
|
+
const zh = params.imagePromptLang === 'zh';
|
|
660
|
+
const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
|
|
661
|
+
const has = (text, word) => (zh ? foldZh(text).includes(foldZh(word)) : String(text ?? '').toLowerCase().includes(String(word).toLowerCase()));
|
|
662
|
+
return {
|
|
663
|
+
zh,
|
|
664
|
+
label: zh ? 'Chinese' : 'English',
|
|
665
|
+
inLang: (text) => (zh ? CJK_RE.test(String(text ?? '')) : isEnglish(text)),
|
|
666
|
+
ethnicity: (text) => (zh ? ZH_ETHNICITY_RE.test(foldZh(text)) : ETHNICITY_RE.test(String(text ?? ''))),
|
|
667
|
+
era: (text) => (zh ? ZH_ERA_RE.test(foldZh(text)) : ERA_RE.test(String(text ?? ''))),
|
|
668
|
+
sheetRatio: (text) => /16\s*[::]\s*9/.test(String(text ?? '')),
|
|
669
|
+
// the style's render phrase — its first 30 characters in English, up to 8 before the first comma in Chinese
|
|
670
|
+
render: zh ? preset.renderZh : preset.render,
|
|
671
|
+
renderKey: zh ? renderKeyZh(preset) : preset.render.slice(0, 30),
|
|
672
|
+
hasRender: (text) => has(text, zh ? renderKeyZh(preset) : preset.render.slice(0, 30)),
|
|
673
|
+
framePhrase: zh ? preset.framePhraseZh : preset.framePhrase,
|
|
674
|
+
negativeMust: zh ? preset.negativeMustZh : preset.negativeMust,
|
|
675
|
+
negativeMustNot: zh ? preset.negativeMustNotZh : preset.negativeMustNot,
|
|
676
|
+
has,
|
|
677
|
+
bansPeople: (neg) => (zh ? /人/.test(foldZh(neg)) : /(people|person|figure|human|man|woman|crowd)/.test(String(neg).toLowerCase())),
|
|
678
|
+
bansHands: (neg) => (zh ? /手/.test(foldZh(neg)) : /(hand|finger)/.test(String(neg).toLowerCase())),
|
|
679
|
+
saysEmpty: (text) => (zh ? /(空场景|空无一人|没有人|没有任何人|无人|空景)/.test(foldZh(text)) : /(no people|empty|unpopulated|nobody|no one|no human)/i.test(String(text))),
|
|
680
|
+
whiteBackground: (text) => (zh ? /(纯白背景|白色背景|白底)/.test(foldZh(text)) : /white background/i.test(String(text))),
|
|
681
|
+
scalePhrase: (scale) => (zh ? PROP_SCALES_ZH[scale] : PROP_SCALES[scale]),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
514
685
|
/**
|
|
515
686
|
* The drama's lighting and composition, orthogonal to the render style (`params.style`). Each
|
|
516
687
|
* preset has a guide in reference/cinematography-<key>.md; the gates read only the fields below.
|
|
@@ -531,8 +702,8 @@ export const CINEMATOGRAPHY_PRESETS = {
|
|
|
531
702
|
'low-key': {
|
|
532
703
|
label: 'low-key, practical-lit, negative space, wide open',
|
|
533
704
|
framePhrase: 'low-key practical lighting',
|
|
534
|
-
videoPhrase: 'low-key cinematography lit only by practical sources in the scene, faces held in shadow and shaped by side, back and rim light with no flat fill, compositions with generous negative space
|
|
535
|
-
videoPhraseZh: '
|
|
705
|
+
videoPhrase: 'low-key cinematography lit only by practical sources in the scene, faces held in shadow and shaped by side, back and rim light with no flat fill, compositions with generous negative space, shallow depth of field',
|
|
706
|
+
videoPhraseZh: '低调光影,光线只来自场景内的现场光源,人物处于阴影中,以侧光、逆光、轮廓光勾勒,不打平光补光,构图留白,大光圈浅景深',
|
|
536
707
|
sourceHint: 'the practical in or just outside the frame that the key comes from (a lamp, a window, a screen, a sign), not a film light',
|
|
537
708
|
// 包围光 / 世界光: light from the lens axis or from nowhere flattens the face and kills the shadow side.
|
|
538
709
|
bannedLight: ['frontal', 'ambient'],
|
|
@@ -547,8 +718,8 @@ export const CINEMATOGRAPHY_PRESETS = {
|
|
|
547
718
|
'sweet-romance': {
|
|
548
719
|
label: 'sweet romance: soft high-key glow, backlit halo, pastel, dreamy bokeh',
|
|
549
720
|
framePhrase: 'soft romantic lighting',
|
|
550
|
-
videoPhrase: 'soft romantic cinematography of a modern idol romance drama, bright airy high-key light from soft motivated sources, faces softly and flatteringly lit with a warm backlight haloing the hair, gentle low-contrast pastel grade with creamy highlights and lifted shadows, dreamy diffusion and soft halation, shot wide open with an extremely shallow depth of field, dreamy oversized creamy bokeh balls
|
|
551
|
-
videoPhraseZh: '
|
|
721
|
+
videoPhrase: 'soft romantic cinematography of a modern idol romance drama, bright airy high-key light from soft motivated sources, faces softly and flatteringly lit with a warm backlight haloing the hair, gentle low-contrast pastel grade with creamy highlights and lifted shadows, dreamy diffusion and soft halation, shot wide open with an extremely shallow depth of field, dreamy oversized creamy bokeh balls',
|
|
722
|
+
videoPhraseZh: '唯美浪漫偶像剧质感,明亮通透的柔光,光线来自窗光、夕阳、串灯等柔和光源,人物面部柔和明亮,暖色逆光在发丝上形成光晕,低反差粉嫩柔和色调,高光奶油般柔和,暗部通透不死黑,柔焦朦胧,全开大光圈极浅景深,梦幻的大颗奶油光斑散景',
|
|
552
723
|
sourceHint: 'the soft source in or just outside the frame that the light comes from (a big window, the low sun, overcast sky, string lights, a café lamp), not a film light',
|
|
553
724
|
// 底光 / 顶光: the horror and interrogation lights — they carve eye sockets and chins.
|
|
554
725
|
bannedLight: ['underlight', 'top'],
|
|
@@ -602,8 +773,8 @@ export const CINEMATOGRAPHY_PRESETS = {
|
|
|
602
773
|
'costume-dark': {
|
|
603
774
|
label: 'dark historical realism: firelight and lattice windows, faces in shadow, smoke, earthy texture',
|
|
604
775
|
framePhrase: 'low-key firelit period lighting',
|
|
605
|
-
videoPhrase: 'gritty low-key historical cinematography with realistic texture, lit only by candles, oil lamps, braziers, torches and daylight or moonlight through lattice windows, faces carved by side and back light and falling into deep shadow, smoke and dust hanging in shafts of light, desaturated earthy palette of ochre, umber and cold steel blue, worn fabric, weathered wood and stone, sweeping wide and extreme-wide shots with vast negative space of darkness, smoke and sky, compositions with negative space
|
|
606
|
-
videoPhraseZh: '
|
|
776
|
+
videoPhrase: 'gritty low-key historical cinematography with realistic texture, lit only by candles, oil lamps, braziers, torches and daylight or moonlight through lattice windows, faces carved by side and back light and falling into deep shadow, smoke and dust hanging in shafts of light, desaturated earthy palette of ochre, umber and cold steel blue, worn fabric, weathered wood and stone, sweeping wide and extreme-wide shots with vast negative space of darkness, smoke and sky, compositions with negative space, shallow depth of field',
|
|
777
|
+
videoPhraseZh: '写实质感的暗黑古装影调,光线只来自蜡烛、油灯、火盆、火把,以及透过窗棂的日光或月光,人物面部被侧光与逆光雕刻,隐入浓重阴影,烟尘在光束中悬浮,低饱和的赭石、深棕与冷铁蓝色调,布料磨旧,木石斑驳,大量全景与大全景,大面积留白于黑暗、烟尘与天空之中,构图留白,大光圈浅景深',
|
|
607
778
|
sourceHint: 'the period source the key comes from (a candle, an oil lamp, a brazier, a torch, daylight or moonlight through a lattice window), never an electric light',
|
|
608
779
|
bannedLight: ['frontal', 'ambient'],
|
|
609
780
|
bannedWhy: 'floods the face (包围光 / 世界光) — stage the person so the flame or the lattice window sits beside or behind them',
|
|
@@ -1138,6 +1309,8 @@ export function slug(name) {
|
|
|
1138
1309
|
export function paramsOf(project) {
|
|
1139
1310
|
const p = { ...DEFAULT_PARAMS, ...(project.params || {}) };
|
|
1140
1311
|
p.thresholds = { ...DEFAULT_PARAMS.thresholds, ...(project.params?.thresholds || {}) };
|
|
1312
|
+
// Named, or read from the look, so a later change of look carries the level with it.
|
|
1313
|
+
if (project.params?.foregroundLevel == null) p.foregroundLevel = foregroundForLook(p);
|
|
1141
1314
|
// The model decides how long a segment may be; an explicit param still wins, but it is then gated.
|
|
1142
1315
|
const mode = videoModel(p);
|
|
1143
1316
|
if (project.params?.maxSegmentSeconds == null) p.maxSegmentSeconds = mode.maxSegmentSeconds;
|
|
@@ -1167,7 +1340,7 @@ export function langTag(lang) {
|
|
|
1167
1340
|
export function forbiddenNames(project) {
|
|
1168
1341
|
const out = [];
|
|
1169
1342
|
for (const c of project.characters || []) {
|
|
1170
|
-
for (const n of [c.name, ...(c.aliases || [])]) if (n && String(n).trim().length >= 2) out.push(String(n).trim());
|
|
1343
|
+
for (const n of [c.name, ...(c.aliases || []), c.disguise?.name]) if (n && String(n).trim().length >= 2) out.push(String(n).trim());
|
|
1171
1344
|
}
|
|
1172
1345
|
return uniq(out);
|
|
1173
1346
|
}
|
|
@@ -1178,9 +1351,18 @@ export function containsName(text, name) {
|
|
|
1178
1351
|
return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text);
|
|
1179
1352
|
}
|
|
1180
1353
|
|
|
1354
|
+
function promptTokens(text) {
|
|
1355
|
+
const t = foldZh(text);
|
|
1356
|
+
const words = t.match(/[a-z0-9']+/g) || [];
|
|
1357
|
+
// Chinese has no spaces: compare character bigrams inside each run of CJK characters.
|
|
1358
|
+
const bigrams = [];
|
|
1359
|
+
for (const run of t.match(/[\u3400-\u9fff]+/g) || []) for (let i = 0; i + 1 < run.length; i++) bigrams.push(run.slice(i, i + 2));
|
|
1360
|
+
return new Set([...words, ...bigrams]);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1181
1363
|
export function jaccard(a, b) {
|
|
1182
|
-
const wa =
|
|
1183
|
-
const wb =
|
|
1364
|
+
const wa = promptTokens(a);
|
|
1365
|
+
const wb = promptTokens(b);
|
|
1184
1366
|
if (wa.size < 8 || wb.size < 8) return 0;
|
|
1185
1367
|
let inter = 0;
|
|
1186
1368
|
for (const w of wa) if (wb.has(w)) inter += 1;
|
|
@@ -1201,6 +1383,18 @@ function sceneById(project, id) {
|
|
|
1201
1383
|
function characterById(project, id) {
|
|
1202
1384
|
return (project.characters || []).find((c) => c.id === id);
|
|
1203
1385
|
}
|
|
1386
|
+
/**
|
|
1387
|
+
* The name the drama's own documents use for a character in episode `ep`. A character with
|
|
1388
|
+
* `disguise: {name, revealEpisode}` goes by the disguise name before the reveal, so an episode's
|
|
1389
|
+
* script, polish sheet and segment sheet never spoil who they are. Without `ep` it is the real name.
|
|
1390
|
+
*/
|
|
1391
|
+
export function displayName(project, id, ep) {
|
|
1392
|
+
const c = characterById(project, id);
|
|
1393
|
+
if (!c) return id;
|
|
1394
|
+
const d = c.disguise;
|
|
1395
|
+
if (isObject(d) && String(d.name || '').trim() && ep != null && Number(ep) < Number(d.revealEpisode)) return String(d.name).trim();
|
|
1396
|
+
return c.name || id;
|
|
1397
|
+
}
|
|
1204
1398
|
function propById(project, id) {
|
|
1205
1399
|
return (project.props || []).find((p) => p.id === id);
|
|
1206
1400
|
}
|
|
@@ -1515,6 +1709,7 @@ export const GATES = [
|
|
|
1515
1709
|
if (!ADAPT_MODES.includes(params.adaptMode)) out.push(`params.adaptMode must be one of ${ADAPT_MODES.join(' / ')}`);
|
|
1516
1710
|
if (!VIDEO_MODES.includes(params.video)) out.push(`params.video must be one of ${VIDEO_MODES.join(' / ')}`);
|
|
1517
1711
|
if (!AUDIO_TRACK_MODES.includes(params.audioTrack)) out.push(`params.audioTrack must be one of ${AUDIO_TRACK_MODES.join(' / ')}`);
|
|
1712
|
+
if (!FOREGROUND_LEVELS[params.foregroundLevel]) out.push(`params.foregroundLevel must be one of ${Object.keys(FOREGROUND_LEVELS).join(' / ')}`);
|
|
1518
1713
|
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`);
|
|
1519
1714
|
if (!REFERENCE_MODES.includes(params.references)) out.push(`params.references must be one of ${REFERENCE_MODES.join(' / ')}`);
|
|
1520
1715
|
if (!DURATION_MODES.includes(params.videoDuration)) out.push(`params.videoDuration must be one of ${DURATION_MODES.join(' / ')}`);
|
|
@@ -1538,13 +1733,14 @@ export const GATES = [
|
|
|
1538
1733
|
else {
|
|
1539
1734
|
const [lo, hi] = FORMATS[params.format].minutes;
|
|
1540
1735
|
const m = Number(params.minutesPerEpisode);
|
|
1541
|
-
if (m && (m < lo || m > hi)) out.push(`params.minutesPerEpisode ${m} is outside the ${params.format} range ${lo}
|
|
1736
|
+
if (m && (m < lo || m > hi)) out.push(`params.minutesPerEpisode ${m} is outside the ${params.format} range ${lo}\u2013${hi} minutes \u2014 pick the format that fits (short 1\u20133, medium 4\u201315, movie parts 3\u20138)`);
|
|
1542
1737
|
}
|
|
1543
1738
|
if (!MARKETS.includes(params.market)) out.push(`params.market must be one of ${MARKETS.join(' / ')}`);
|
|
1544
1739
|
for (const k of asList(params.story)) if (!Object.hasOwn(STORY_GENRES, k)) out.push(`params.story "${k}" is not one of ${Object.keys(STORY_GENRES).join(' / ')}`);
|
|
1545
1740
|
if (!Object.hasOwn(COMPOSITION_STYLES, params.composition)) out.push(`params.composition must be one of ${Object.keys(COMPOSITION_STYLES).join(' / ')}`);
|
|
1546
1741
|
if (!Object.hasOwn(COMEDY_STYLES, params.comedy)) out.push(`params.comedy must be one of ${Object.keys(COMEDY_STYLES).join(' / ')}`);
|
|
1547
1742
|
if (!Object.hasOwn(CINEMATOGRAPHY_PRESETS, params.cinematography)) out.push(`params.cinematography must be one of ${Object.keys(CINEMATOGRAPHY_PRESETS).join(' / ')}`);
|
|
1743
|
+
if (!IMAGE_PROMPT_LANGS.includes(params.imagePromptLang)) out.push(`params.imagePromptLang must be one of ${IMAGE_PROMPT_LANGS.join(' / ')} (the language of the character / scene / prop image prompts)`);
|
|
1548
1744
|
if (!/^\d+:\d+$/.test(String(params.ratio))) out.push('params.ratio must look like 9:16');
|
|
1549
1745
|
if (!project.title) out.push('title is required');
|
|
1550
1746
|
return out;
|
|
@@ -1577,6 +1773,13 @@ export const GATES = [
|
|
|
1577
1773
|
if (!c.role) out.push(`${c.id}: role is required`);
|
|
1578
1774
|
if (['lead', 'support'].includes(c.tier) && !c.arc) out.push(`${c.id}: ${c.tier} characters need an arc`);
|
|
1579
1775
|
if (!Array.isArray(c.from) || !c.from.length) out.push(`${c.id}: from[] must record where the character comes from (or ["原创"])`);
|
|
1776
|
+
if (c.disguise !== undefined) {
|
|
1777
|
+
const d = isObject(c.disguise) ? c.disguise : {};
|
|
1778
|
+
const rev = Number(d.revealEpisode);
|
|
1779
|
+
if (!String(d.name || '').trim()) out.push(`${c.id}: disguise.name is empty — the name this character goes by before the reveal`);
|
|
1780
|
+
else if (String(d.name).trim() === c.name) out.push(`${c.id}: disguise.name equals the real name`);
|
|
1781
|
+
if (!(Number.isInteger(rev) && rev >= 2 && rev <= Number(params.episodes))) out.push(`${c.id}: disguise.revealEpisode must be an episode in 2–${params.episodes} (the first episode that uses the real name)`);
|
|
1782
|
+
}
|
|
1580
1783
|
}
|
|
1581
1784
|
if (count.lead < 1 || count.lead > t.maxLeads) out.push(`lead characters: ${count.lead}, must be 1–${t.maxLeads}`);
|
|
1582
1785
|
if (count.support > t.maxSupport) out.push(`support characters: ${count.support}, max ${t.maxSupport}`);
|
|
@@ -1703,15 +1906,20 @@ export const GATES = [
|
|
|
1703
1906
|
const phases = project.outline?.structure;
|
|
1704
1907
|
const n = Number(params.episodes) || 0;
|
|
1705
1908
|
if (!Array.isArray(phases) || !phases.length) return ['outline.structure is missing — three phases (golden / escalation / climax) with episode ranges'];
|
|
1706
|
-
|
|
1909
|
+
// Single-episode dramas: golden + climax both cover ep 1 (retention and payoff in the same 集).
|
|
1910
|
+
const expect = n === 1 ? ['golden', 'climax'] : n >= 3 ? PHASES : PHASES.filter((p) => p !== 'escalation');
|
|
1707
1911
|
const got = phases.map((p) => p.phase);
|
|
1708
1912
|
if (got.join(',') !== expect.join(',')) out.push(`outline.structure phases must be ${expect.join(' → ')} in that order (got ${got.join(', ') || 'none'})`);
|
|
1709
1913
|
let cursor = 1;
|
|
1710
1914
|
phases.forEach((p, i) => {
|
|
1711
1915
|
const [a, b] = Array.isArray(p.episodes) ? p.episodes.map(Number) : [NaN, NaN];
|
|
1712
1916
|
if (!(a >= 1 && b >= a && b <= n)) { out.push(`outline.structure[${i}] (${p.phase}): episodes must be [from, to] inside 1–${n}`); return; }
|
|
1713
|
-
if (
|
|
1714
|
-
|
|
1917
|
+
if (n === 1) {
|
|
1918
|
+
if (!(a === 1 && b === 1)) out.push(`outline.structure[${i}] (${p.phase}): for a 1-episode drama both phases must be [1, 1]`);
|
|
1919
|
+
} else {
|
|
1920
|
+
if (a !== cursor) out.push(`outline.structure[${i}] (${p.phase}) starts at ${a}, expected ${cursor} — the phases must tile the episodes without gaps`);
|
|
1921
|
+
cursor = b + 1;
|
|
1922
|
+
}
|
|
1715
1923
|
for (const key of ['task', 'satisfactionPlan', 'hookPlan']) if (!String(p[key] || '').trim()) out.push(`outline.structure[${i}] (${p.phase}): ${key} is empty`);
|
|
1716
1924
|
if (p.phase === 'golden') {
|
|
1717
1925
|
// 黄金窗口 is the first ~20 % — and never less than the first three episodes of a long run.
|
|
@@ -1720,7 +1928,7 @@ export const GATES = [
|
|
|
1720
1928
|
if (n >= 6 && b < 3) out.push(`golden window ends at episode ${b}; with ${n} episodes the first three episodes are the retention window`);
|
|
1721
1929
|
}
|
|
1722
1930
|
});
|
|
1723
|
-
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}`);
|
|
1931
|
+
if (n !== 1 && 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}`);
|
|
1724
1932
|
return out;
|
|
1725
1933
|
}),
|
|
1726
1934
|
gate('guideline', 'outline', '创作指引: tone, dialogue, visuals, limits, ≥3 forbidden, 3–5 priorities', ({ project }) => {
|
|
@@ -1802,26 +2010,26 @@ export const GATES = [
|
|
|
1802
2010
|
}
|
|
1803
2011
|
return out;
|
|
1804
2012
|
}),
|
|
1805
|
-
gate('char-prompts', 'characters', 'image prompts English, no names, ethnicity + era stated', ({ project, params }) => {
|
|
2013
|
+
gate('char-prompts', 'characters', 'image prompts in params.imagePromptLang (English by default), no names, ethnicity + era stated', ({ project, params }) => {
|
|
1806
2014
|
const out = [];
|
|
1807
2015
|
const names = forbiddenNames(project);
|
|
1808
|
-
const
|
|
2016
|
+
const kit = imagePromptKit(params);
|
|
1809
2017
|
for (const c of project.characters || []) {
|
|
1810
2018
|
const img = c.image || {};
|
|
1811
2019
|
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
1812
2020
|
const text = String(img[key] || '');
|
|
1813
2021
|
if (!text.trim()) { out.push(`${c.id}: image.${key} is empty`); continue; }
|
|
1814
|
-
if (!
|
|
2022
|
+
if (!kit.inLang(text)) out.push(`${c.id}: image.${key} must be ${kit.label} (params.imagePromptLang = ${params.imagePromptLang})`);
|
|
1815
2023
|
for (const n of names) if (containsName(text, n)) out.push(`${c.id}: image.${key} contains the name "${n}" — describe, don't name`);
|
|
1816
2024
|
}
|
|
1817
2025
|
if (!Array.isArray(img.tags) || !img.tags.length) out.push(`${c.id}: image.tags is empty`);
|
|
1818
|
-
if (img.prompt && !
|
|
1819
|
-
if (img.prompt && !
|
|
1820
|
-
if (img.sheet &&
|
|
1821
|
-
if (img.sheet && !img.sheet
|
|
1822
|
-
const neg = String(img.negativePrompt || '')
|
|
1823
|
-
for (const word of
|
|
1824
|
-
for (const word of
|
|
2026
|
+
if (img.prompt && !kit.ethnicity(img.prompt)) out.push(`${c.id}: image.prompt must state ethnicity / facial heritage${kit.zh ? '(如 东亚、汉族)' : ''}`);
|
|
2027
|
+
if (img.prompt && !kit.era(img.prompt)) out.push(`${c.id}: image.prompt must state the era${kit.zh ? '(如 当代、秦末汉初、唐代)' : ''}`);
|
|
2028
|
+
if (img.sheet && !kit.sheetRatio(img.sheet)) out.push(`${c.id}: image.sheet must specify the 16:9 canvas`);
|
|
2029
|
+
if (img.sheet && !kit.hasRender(img.sheet)) out.push(`${c.id}: image.sheet must contain the ${params.style} render phrase "${kit.renderKey}…" (see STYLE_PRESETS${kit.zh ? '.renderZh' : ''})`);
|
|
2030
|
+
const neg = String(img.negativePrompt || '');
|
|
2031
|
+
for (const word of kit.negativeMustNot) if (kit.has(neg, word)) out.push(`${c.id}: style ${params.style} must not ban "${word}" in the negative prompt`);
|
|
2032
|
+
for (const word of kit.negativeMust) if (!kit.has(neg, word)) out.push(`${c.id}: style ${params.style} must ban "${word}" in the negative prompt`);
|
|
1825
2033
|
}
|
|
1826
2034
|
return out;
|
|
1827
2035
|
}),
|
|
@@ -1829,7 +2037,13 @@ export const GATES = [
|
|
|
1829
2037
|
const out = [];
|
|
1830
2038
|
for (const c of project.characters || []) {
|
|
1831
2039
|
const v = c.voice || {};
|
|
1832
|
-
for (const key of ['timbre', 'pitch', 'pace', 'accent', 'emotion'])
|
|
2040
|
+
for (const key of ['timbre', 'pitch', 'pace', 'accent', 'emotion']) {
|
|
2041
|
+
const text = String(v[key] || '');
|
|
2042
|
+
if (!text.trim()) { out.push(`${c.id}: voice.${key} is empty`); continue; }
|
|
2043
|
+
// pasted verbatim beside the character's first line in every Chinese segment prompt, and into the voice-sample prompt
|
|
2044
|
+
const nm = forbiddenNames(project).find((n) => containsName(text, n));
|
|
2045
|
+
if (nm) out.push(`${c.id}: voice.${key} contains the name "${nm}" — it is pasted into prompts; say 对她 / 对他 or describe the person`);
|
|
2046
|
+
}
|
|
1833
2047
|
if (params.voiceSamples !== false) {
|
|
1834
2048
|
const sample = v.sample || {};
|
|
1835
2049
|
const line = String(sample.line || '').trim();
|
|
@@ -1842,6 +2056,10 @@ export const GATES = [
|
|
|
1842
2056
|
if (QUOTE_RE.test(line)) out.push(`${c.id}: voice.sample.line must not carry quote marks — the engine reads them`);
|
|
1843
2057
|
}
|
|
1844
2058
|
if (!direction) out.push(`${c.id}: voice.sample.direction is empty — say how it is acted; it becomes the parenthetical the model obeys`);
|
|
2059
|
+
else {
|
|
2060
|
+
const nm = forbiddenNames(project).find((n) => containsName(direction, n));
|
|
2061
|
+
if (nm) out.push(`${c.id}: voice.sample.direction contains the name "${nm}" — it goes into the voice-sample prompt`);
|
|
2062
|
+
}
|
|
1845
2063
|
}
|
|
1846
2064
|
// The compact form the video prompt puts beside every line this character speaks.
|
|
1847
2065
|
const vd = String(v.videoDirection || '').trim();
|
|
@@ -1858,6 +2076,8 @@ export const GATES = [
|
|
|
1858
2076
|
if (!isEnglish(prompt)) out.push(`${c.id}: voice.prompt must be English`);
|
|
1859
2077
|
if (prompt.length > 400) out.push(`${c.id}: voice.prompt is ${prompt.length} chars, max 400 — write a parameter string, not prose`);
|
|
1860
2078
|
if (QUOTED_LINE_RE.test(prompt)) out.push(`${c.id}: voice.prompt contains a quoted line — engines read it aloud`);
|
|
2079
|
+
const nm = forbiddenNames(project).find((n) => containsName(prompt, n));
|
|
2080
|
+
if (nm) out.push(`${c.id}: voice.prompt contains the name "${nm}"`);
|
|
1861
2081
|
}
|
|
1862
2082
|
}
|
|
1863
2083
|
return out;
|
|
@@ -2527,9 +2747,12 @@ export const GATES = [
|
|
|
2527
2747
|
return out;
|
|
2528
2748
|
}, { advisory: true }),
|
|
2529
2749
|
// ── art ───────────────────────────────────────────────────────────────
|
|
2530
|
-
gate('scene-design', 'art', 'scenes: intent, 3–5 anchors, ≥1
|
|
2750
|
+
gate('scene-design', 'art', 'scenes: intent, 3–5 anchors, ≥1 lighting state in params.imagePromptLang (in the cinematography preset)', ({ project, params }) => {
|
|
2531
2751
|
const out = [];
|
|
2532
|
-
const
|
|
2752
|
+
const kit = imagePromptKit(params);
|
|
2753
|
+
// The cinematography presets carry an English framePhrase only, so a Chinese
|
|
2754
|
+
// image prompt (imagePromptLang zh) has nothing to match — skip the check there.
|
|
2755
|
+
const look = kit.zh ? null : cinematographyOf(params);
|
|
2533
2756
|
for (const s of project.scenes || []) {
|
|
2534
2757
|
if (!String(s.summary || '').trim()) out.push(`${s.id}: summary (design intent) is empty`);
|
|
2535
2758
|
const anchors = s.anchors || [];
|
|
@@ -2539,7 +2762,7 @@ export const GATES = [
|
|
|
2539
2762
|
if (!lighting.length) out.push(`${s.id}: at least one lighting state is required`);
|
|
2540
2763
|
for (const l of lighting) {
|
|
2541
2764
|
if (!l.state || !l.prompt) out.push(`${s.id}: every lighting state needs state + prompt`);
|
|
2542
|
-
else if (!
|
|
2765
|
+
else if (!kit.inLang(l.prompt)) out.push(`${s.id}: lighting "${l.state}" prompt must be ${kit.label}`);
|
|
2543
2766
|
else if (look && !l.prompt.toLowerCase().includes(look.framePhrase)) out.push(`${s.id}: lighting "${l.state}" prompt must contain "${look.framePhrase}" and name the practical sources that light the space (params.cinematography ${params.cinematography})`);
|
|
2544
2767
|
}
|
|
2545
2768
|
if (s.variantOf) {
|
|
@@ -2549,28 +2772,29 @@ export const GATES = [
|
|
|
2549
2772
|
}
|
|
2550
2773
|
return out;
|
|
2551
2774
|
}),
|
|
2552
|
-
gate('scene-prompts', 'art', 'scene prompts
|
|
2775
|
+
gate('scene-prompts', 'art', 'scene prompts in params.imagePromptLang, empty of people, no names, style-consistent', ({ project, params }) => {
|
|
2553
2776
|
const out = [];
|
|
2554
2777
|
const names = forbiddenNames(project);
|
|
2555
|
-
const
|
|
2778
|
+
const kit = imagePromptKit(params);
|
|
2556
2779
|
for (const s of project.scenes || []) {
|
|
2557
2780
|
const img = s.image || {};
|
|
2558
2781
|
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
2559
2782
|
const text = String(img[key] || '');
|
|
2560
2783
|
if (!text.trim()) { out.push(`${s.id}: image.${key} is empty`); continue; }
|
|
2561
|
-
if (!
|
|
2784
|
+
if (!kit.inLang(text)) out.push(`${s.id}: image.${key} must be ${kit.label}`);
|
|
2562
2785
|
for (const n of names) if (containsName(text, n)) out.push(`${s.id}: image.${key} contains the character name "${n}"`);
|
|
2563
2786
|
}
|
|
2564
|
-
const neg = String(img.negativePrompt || '')
|
|
2565
|
-
if (neg &&
|
|
2566
|
-
if (img.prompt &&
|
|
2567
|
-
if (img.sheet && !img.sheet
|
|
2787
|
+
const neg = String(img.negativePrompt || '');
|
|
2788
|
+
if (neg && !kit.bansPeople(neg)) out.push(`${s.id}: negativePrompt must ban people — environments are a separate asset layer`);
|
|
2789
|
+
if (img.prompt && !kit.saysEmpty(img.prompt)) out.push(`${s.id}: image.prompt must say the scene is empty (${kit.zh ? '空场景,没有人' : 'no people'})`);
|
|
2790
|
+
if (img.sheet && !kit.hasRender(img.sheet)) out.push(`${s.id}: image.sheet must contain the ${params.style} render phrase "${kit.renderKey}…"`);
|
|
2568
2791
|
}
|
|
2569
2792
|
return out;
|
|
2570
2793
|
}),
|
|
2571
|
-
gate('prop-design', 'art', 'props: scale phrase, 3–5 anchors, ≥1 state, white background, no hands', ({ project }) => {
|
|
2794
|
+
gate('prop-design', 'art', 'props: scale phrase, 3–5 anchors, ≥1 state, white background, no hands', ({ project, params }) => {
|
|
2572
2795
|
const out = [];
|
|
2573
2796
|
const names = forbiddenNames(project);
|
|
2797
|
+
const kit = imagePromptKit(params);
|
|
2574
2798
|
for (const p of project.props || []) {
|
|
2575
2799
|
if (!PROP_SCALES[p.scale]) out.push(`${p.id}: scale must be one of ${Object.keys(PROP_SCALES).join(' / ')}`);
|
|
2576
2800
|
if (!String(p.summary || '').trim()) out.push(`${p.id}: summary (dramatic function) is empty`);
|
|
@@ -2580,24 +2804,24 @@ export const GATES = [
|
|
|
2580
2804
|
if (!states.length) out.push(`${p.id}: at least one state is required`);
|
|
2581
2805
|
for (const st of states) {
|
|
2582
2806
|
if (!st.state || !st.prompt) out.push(`${p.id}: every state needs state + prompt`);
|
|
2583
|
-
else if (!
|
|
2807
|
+
else if (!kit.inLang(st.prompt)) out.push(`${p.id}: state "${st.state}" prompt must be ${kit.label}`);
|
|
2584
2808
|
}
|
|
2585
2809
|
for (const id of p.relatedScenes || []) if (!sceneById(project, id)) out.push(`${p.id}: relatedScenes references unknown scene ${id}`);
|
|
2586
2810
|
const img = p.image || {};
|
|
2587
2811
|
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
2588
2812
|
const text = String(img[key] || '');
|
|
2589
2813
|
if (!text.trim()) { out.push(`${p.id}: image.${key} is empty`); continue; }
|
|
2590
|
-
if (!
|
|
2814
|
+
if (!kit.inLang(text)) out.push(`${p.id}: image.${key} must be ${kit.label}`);
|
|
2591
2815
|
for (const n of names) if (containsName(text, n)) out.push(`${p.id}: image.${key} contains the character name "${n}"`);
|
|
2592
2816
|
}
|
|
2593
|
-
const phrase =
|
|
2817
|
+
const phrase = kit.scalePhrase(p.scale);
|
|
2594
2818
|
if (phrase) {
|
|
2595
|
-
for (const key of ['prompt', 'sheet']) if (img[key] && !img[key]
|
|
2819
|
+
for (const key of ['prompt', 'sheet']) if (img[key] && !kit.has(img[key], phrase)) out.push(`${p.id}: image.${key} must contain "${phrase}"`);
|
|
2596
2820
|
}
|
|
2597
|
-
const neg = String(img.negativePrompt || '')
|
|
2598
|
-
if (neg &&
|
|
2599
|
-
if (neg &&
|
|
2600
|
-
for (const key of ['prompt', 'sheet']) if (img[key] &&
|
|
2821
|
+
const neg = String(img.negativePrompt || '');
|
|
2822
|
+
if (neg && !kit.bansHands(neg)) out.push(`${p.id}: negativePrompt must ban hands — a hand holding the prop is the usual contamination`);
|
|
2823
|
+
if (neg && !(kit.zh ? kit.bansPeople(neg) : /(people|person|figure|human)/.test(neg.toLowerCase()))) out.push(`${p.id}: negativePrompt must ban people`);
|
|
2824
|
+
for (const key of ['prompt', 'sheet']) if (img[key] && !kit.whiteBackground(img[key])) out.push(`${p.id}: image.${key} must specify a pure white background${kit.zh ? '(纯白背景)' : ''}`);
|
|
2601
2825
|
}
|
|
2602
2826
|
return out;
|
|
2603
2827
|
}),
|
|
@@ -2609,7 +2833,7 @@ export const GATES = [
|
|
|
2609
2833
|
for (const [ep, e] of ctx.episodes) {
|
|
2610
2834
|
if (Number(e.ep) !== Number(ep)) out.push(`E${pad2(ep)}: ep field is ${e.ep}`);
|
|
2611
2835
|
if (!(Number(e.targetSeconds) > 0)) out.push(`E${pad2(ep)}: targetSeconds missing (run seed script)`);
|
|
2612
|
-
if (!String(e.hook || '').trim()) out.push(`E${pad2(ep)}: hook is empty`);
|
|
2836
|
+
if (!String(e.hook || '').trim()) out.push(`E${pad2(ep)}: hook is empty — describe the opening image (seed leaves it for you; the outline's hook is the cliff)`);
|
|
2613
2837
|
if (!String(e.cliff || '').trim()) out.push(`E${pad2(ep)}: cliff is empty`);
|
|
2614
2838
|
if (!Array.isArray(e.beatsClaimed)) out.push(`E${pad2(ep)}: beatsClaimed must be an array`);
|
|
2615
2839
|
if (!Array.isArray(e.scenes) || !e.scenes.length) { out.push(`E${pad2(ep)}: scenes is empty`); continue; }
|
|
@@ -2625,9 +2849,11 @@ export const GATES = [
|
|
|
2625
2849
|
}
|
|
2626
2850
|
return out;
|
|
2627
2851
|
}),
|
|
2628
|
-
gate('beat-flow', 'script', 'beats: action ⇄ line, one kind each, no quotes in actions, speakers legal, line ≤ N chars', (ctx) => {
|
|
2629
|
-
const { params } = ctx;
|
|
2852
|
+
gate('beat-flow', 'script', 'beats: action ⇄ line, one kind each, no quotes in actions, speakers legal, line ≤ N chars, no names in a delivery the video prompt carries', (ctx) => {
|
|
2853
|
+
const { project, params } = ctx;
|
|
2630
2854
|
const out = [];
|
|
2855
|
+
const names = forbiddenNames(project);
|
|
2856
|
+
const promptLang = videoModel(params).promptLang;
|
|
2631
2857
|
for (const [ep, e] of ctx.episodes) {
|
|
2632
2858
|
(e.scenes || []).forEach((sc, i) => {
|
|
2633
2859
|
const where = `E${pad2(ep)} scene ${i + 1}`;
|
|
@@ -2646,6 +2872,13 @@ export const GATES = [
|
|
|
2646
2872
|
if (!String(b.line || '').trim()) out.push(`${where} beat ${k + 1}: line is empty`);
|
|
2647
2873
|
if (nonWhitespace(b.line) > params.maxLineChars) out.push(`${where} beat ${k + 1}: line has ${nonWhitespace(b.line)} chars, max ${params.maxLineChars}`);
|
|
2648
2874
|
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)`);
|
|
2875
|
+
// speechDirection pastes the delivery beside the line in a Chinese (wan) prompt, and an English
|
|
2876
|
+
// delivery into any prompt — where a name fails the storyboard's no-names check much later.
|
|
2877
|
+
const delivery = String(b.delivery || '');
|
|
2878
|
+
if (delivery && beatDelivery(delivery, promptLang)) {
|
|
2879
|
+
const nm = names.find((n) => containsName(delivery, n));
|
|
2880
|
+
if (nm) out.push(`${where} beat ${k + 1}: delivery names "${nm}" — it goes into the video prompt beside the line; say 她 / 他 / 对方 or describe them`);
|
|
2881
|
+
}
|
|
2649
2882
|
}
|
|
2650
2883
|
});
|
|
2651
2884
|
if ((sc.flow || []).length && actions === 0) out.push(`${where}: no action beat — a scene of pure dialogue is a radio play`);
|
|
@@ -2958,15 +3191,16 @@ export const GATES = [
|
|
|
2958
3191
|
}
|
|
2959
3192
|
return out;
|
|
2960
3193
|
}),
|
|
2961
|
-
gate('photography', 'storyboard', 'every cut names its lens and stop, its key-light direction, and
|
|
3194
|
+
gate('photography', 'storyboard', 'every cut names its lens and stop, its key-light direction, and a foreground share within params.foregroundLevel; the cinematography preset (named source, preferred light, wide open)', (ctx) => {
|
|
2962
3195
|
const { project, params } = ctx;
|
|
2963
3196
|
const out = [];
|
|
2964
3197
|
const kit = params.camera || DEFAULT_PARAMS.camera;
|
|
2965
3198
|
const look = cinematographyOf(params);
|
|
2966
|
-
for (const [, e] of ctx.episodes) {
|
|
3199
|
+
for (const [epId, e] of ctx.episodes) {
|
|
3200
|
+
let epCuts = 0, epForeground = 0;
|
|
2967
3201
|
for (const seg of e.storyboard?.segments || []) {
|
|
2968
3202
|
const cuts = seg.cuts || [];
|
|
2969
|
-
|
|
3203
|
+
epCuts += cuts.length;
|
|
2970
3204
|
let withPeople = 0, shaped = 0, withBokeh = 0, wides = 0;
|
|
2971
3205
|
cuts.forEach((c, k) => {
|
|
2972
3206
|
const where = `${seg.id} cut ${k + 1}`;
|
|
@@ -2990,10 +3224,11 @@ export const GATES = [
|
|
|
2990
3224
|
// Foreground.
|
|
2991
3225
|
const fg = String(c.foreground || '').trim();
|
|
2992
3226
|
if (fg) {
|
|
2993
|
-
|
|
3227
|
+
epForeground += 1;
|
|
2994
3228
|
if (frame.trim() && !frame.includes(fg)) out.push(`${where}: frame prompt must name the foreground "${fg}"`);
|
|
2995
3229
|
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`);
|
|
2996
3230
|
}
|
|
3231
|
+
else if (c.ots) epForeground += 1; // the near shoulder is the foreground
|
|
2997
3232
|
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`);
|
|
2998
3233
|
if (!look) return;
|
|
2999
3234
|
// The cinematography preset: a named source, no banned light on a person, shot wide open.
|
|
@@ -3019,8 +3254,10 @@ export const GATES = [
|
|
|
3019
3254
|
if (look && look.bokehShare && cuts.length && withBokeh < look.bokehShare * cuts.length) out.push(`${seg.id}: only ${withBokeh} of ${cuts.length} frames name their bokeh — at least ${Math.round(look.bokehShare * 100)} % must say what melts into bokeh behind or in front of the subject (params.cinematography ${params.cinematography})`);
|
|
3020
3255
|
if (look && look.widePerSegment && cuts.length && wides < look.widePerSegment) out.push(`${seg.id}: no wide or extreme-wide cut — ${params.cinematography} needs at least ${look.widePerSegment} per segment, a 全景 / 大全景 with vast negative space`);
|
|
3021
3256
|
if (look && withPeople && shaped < look.preferredShare * withPeople) out.push(`${seg.id}: only ${shaped} of ${withPeople} cuts with people are ${look.preferredWhat} (${look.preferredLight.join(' / ')}) — at least ${Math.round(look.preferredShare * 100)} % must (params.cinematography ${params.cinematography}); re-stage the person against the source or move the camera`);
|
|
3022
|
-
|
|
3023
|
-
|
|
3257
|
+
}
|
|
3258
|
+
const band = foregroundBand(params.foregroundLevel, epCuts);
|
|
3259
|
+
if (band && (epForeground < band.min || epForeground > band.max)) {
|
|
3260
|
+
out.push(`E${pad2(epId)}: ${epForeground} of ${epCuts} cuts have a foreground — params.foregroundLevel ${params.foregroundLevel} allows ${band.min}–${band.max}. Change which cuts carry one (story first), or change the level`);
|
|
3024
3261
|
}
|
|
3025
3262
|
}
|
|
3026
3263
|
return out;
|
|
@@ -3095,6 +3332,75 @@ export const GATES = [
|
|
|
3095
3332
|
}
|
|
3096
3333
|
return out;
|
|
3097
3334
|
}),
|
|
3335
|
+
gate('boundary-contrast', 'storyboard', '跨场边界: last K cuts of scene N must not match first K of scene N+1 in size+angle+people (params.boundaryContrastCuts, default 2); note "match-cut" to exempt', (ctx) => {
|
|
3336
|
+
const out = [];
|
|
3337
|
+
const Kraw = Number(ctx.params.boundaryContrastCuts);
|
|
3338
|
+
const K = Number.isFinite(Kraw) && Kraw >= 1 ? Math.min(3, Math.floor(Kraw)) : 2;
|
|
3339
|
+
const isMatchCut = (c) => /match[\s-]?cut/i.test(String(c?.note || ''));
|
|
3340
|
+
const signature = (c) => `${c?.size || ''}|${c?.angle || ''}|${[...(c?.characters || [])].map(String).sort().join(',')}`;
|
|
3341
|
+
for (const [, e] of ctx.episodes) {
|
|
3342
|
+
const segs = e.storyboard?.segments || [];
|
|
3343
|
+
const byScene = new Map();
|
|
3344
|
+
for (const seg of segs) {
|
|
3345
|
+
const si = Number(seg.sceneIndex);
|
|
3346
|
+
if (!Number.isFinite(si)) continue;
|
|
3347
|
+
if (!byScene.has(si)) byScene.set(si, []);
|
|
3348
|
+
byScene.get(si).push(seg);
|
|
3349
|
+
}
|
|
3350
|
+
const sceneIds = [...byScene.keys()].sort((a, b) => a - b);
|
|
3351
|
+
for (let i = 0; i < sceneIds.length - 1; i++) {
|
|
3352
|
+
const aSegs = byScene.get(sceneIds[i]);
|
|
3353
|
+
const bSegs = byScene.get(sceneIds[i + 1]);
|
|
3354
|
+
const lastSeg = aSegs[aSegs.length - 1];
|
|
3355
|
+
const firstSeg = bSegs[0];
|
|
3356
|
+
const aCuts = lastSeg.cuts || [];
|
|
3357
|
+
const bCuts = firstSeg.cuts || [];
|
|
3358
|
+
if (!aCuts.length || !bCuts.length) continue;
|
|
3359
|
+
const tail = aCuts.slice(-K);
|
|
3360
|
+
const head = bCuts.slice(0, K);
|
|
3361
|
+
tail.forEach((tc, ti) => {
|
|
3362
|
+
head.forEach((hc, hi) => {
|
|
3363
|
+
if (isMatchCut(tc) || isMatchCut(hc)) return;
|
|
3364
|
+
if (signature(tc) !== signature(hc)) return;
|
|
3365
|
+
const aIdx = aCuts.length - tail.length + ti + 1;
|
|
3366
|
+
out.push(`${firstSeg.id} cut ${hi + 1}: same size, angle and people as ${lastSeg.id} cut ${aIdx} across the scene ${sceneIds[i]}→${sceneIds[i + 1]} boundary — change at least one, or mark note "match-cut" if intentional`);
|
|
3367
|
+
});
|
|
3368
|
+
});
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
return out;
|
|
3372
|
+
}),
|
|
3373
|
+
gate('boundary-contrast-episode', 'storyboard', '跨集边界 (advisory): last K cuts of episode E should not match first K of E+1 in size+angle+people', (ctx) => {
|
|
3374
|
+
const out = [];
|
|
3375
|
+
const Kraw = Number(ctx.params.boundaryContrastCuts);
|
|
3376
|
+
const K = Number.isFinite(Kraw) && Kraw >= 1 ? Math.min(3, Math.floor(Kraw)) : 2;
|
|
3377
|
+
const isMatchCut = (c) => /match[\s-]?cut/i.test(String(c?.note || ''));
|
|
3378
|
+
const signature = (c) => `${c?.size || ''}|${c?.angle || ''}|${[...(c?.characters || [])].map(String).sort().join(',')}`;
|
|
3379
|
+
const eps = [...ctx.episodes.entries()].sort((a, b) => Number(a[0]) - Number(b[0]));
|
|
3380
|
+
for (let i = 0; i < eps.length - 1; i++) {
|
|
3381
|
+
const [epA, eA] = eps[i];
|
|
3382
|
+
const [epB, eB] = eps[i + 1];
|
|
3383
|
+
const segsA = eA.storyboard?.segments || [];
|
|
3384
|
+
const segsB = eB.storyboard?.segments || [];
|
|
3385
|
+
if (!segsA.length || !segsB.length) continue;
|
|
3386
|
+
const lastSeg = segsA[segsA.length - 1];
|
|
3387
|
+
const firstSeg = segsB[0];
|
|
3388
|
+
const aCuts = lastSeg.cuts || [];
|
|
3389
|
+
const bCuts = firstSeg.cuts || [];
|
|
3390
|
+
if (!aCuts.length || !bCuts.length) continue;
|
|
3391
|
+
const tail = aCuts.slice(-K);
|
|
3392
|
+
const head = bCuts.slice(0, K);
|
|
3393
|
+
tail.forEach((tc, ti) => {
|
|
3394
|
+
head.forEach((hc, hi) => {
|
|
3395
|
+
if (isMatchCut(tc) || isMatchCut(hc)) return;
|
|
3396
|
+
if (signature(tc) !== signature(hc)) return;
|
|
3397
|
+
const aIdx = aCuts.length - tail.length + ti + 1;
|
|
3398
|
+
out.push(`${firstSeg.id} cut ${hi + 1}: same size, angle and people as ${lastSeg.id} cut ${aIdx} across episode E${String(epA).padStart(2, '0')}→E${String(epB).padStart(2, '0')} — change at least one for a fresher open, or mark note "match-cut" if the echo is intentional`);
|
|
3399
|
+
});
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
return out;
|
|
3403
|
+
}, { advisory: true }),
|
|
3098
3404
|
gate('framing-axis', 'storyboard', '角度 / 机位高度 / 轴线: three separate fields; a two-person segment declares its axis; reverse shots face opposite ways', (ctx) => {
|
|
3099
3405
|
const out = [];
|
|
3100
3406
|
for (const [, e] of ctx.episodes) {
|
|
@@ -3323,12 +3629,26 @@ export const GATES = [
|
|
|
3323
3629
|
}
|
|
3324
3630
|
const texts = shotTexts(prompt, mode.family, params.references);
|
|
3325
3631
|
const refs = segmentReferences(project, e, seg);
|
|
3326
|
-
const sheetOf = params.references === 'sheets'
|
|
3632
|
+
const sheetOf = params.references === 'sheets'
|
|
3633
|
+
? (() => {
|
|
3634
|
+
const m = new Map();
|
|
3635
|
+
for (const r of refs.filter((x) => x.kind === 'image')) {
|
|
3636
|
+
const who = r.faceOf || (r.asset === 'sheet' || r.asset === 'headshot' ? r.entity : null);
|
|
3637
|
+
if (who && !m.has(who)) m.set(who, r.label);
|
|
3638
|
+
}
|
|
3639
|
+
// Prefer explicit headshot / faceOf over later body sheets
|
|
3640
|
+
for (const r of refs.filter((x) => x.kind === 'image' && (x.asset === 'headshot' || x.faceOf))) {
|
|
3641
|
+
const who = r.faceOf || r.entity;
|
|
3642
|
+
m.set(who, r.label);
|
|
3643
|
+
}
|
|
3644
|
+
return m;
|
|
3645
|
+
})()
|
|
3646
|
+
: null;
|
|
3327
3647
|
(seg.cuts || []).forEach((c, k) => {
|
|
3328
3648
|
const text = texts.get(k + 1);
|
|
3329
3649
|
if (!text) return; // the dialect gate reports the missing shot
|
|
3330
3650
|
const lines = claimedLines(scene, [c]);
|
|
3331
|
-
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)`);
|
|
3651
|
+
if (mode.family === 'h3' && lines.length && (c.characters || []).length >= 2 && !c.ots && !/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)`);
|
|
3332
3652
|
if (sheetOf) for (const id of c.characters || []) {
|
|
3333
3653
|
const label = sheetOf.get(id);
|
|
3334
3654
|
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`);
|
|
@@ -3339,6 +3659,93 @@ export const GATES = [
|
|
|
3339
3659
|
}
|
|
3340
3660
|
return out;
|
|
3341
3661
|
}),
|
|
3662
|
+
gate('two-person-framing', 'storyboard', 'sheets mode, two-person segments: a single names only its person, every look has a screen direction toward the other, the sides are locked, over-the-shoulder is said', (ctx) => {
|
|
3663
|
+
const { project, params } = ctx;
|
|
3664
|
+
const out = [];
|
|
3665
|
+
if (params.references !== 'sheets') return out;
|
|
3666
|
+
const lang = videoModel(params).promptLang === 'zh' ? 'zh' : 'en';
|
|
3667
|
+
for (const [, e] of ctx.episodes) {
|
|
3668
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
3669
|
+
const people = segmentCharacters(seg);
|
|
3670
|
+
if (people.length < 2) continue;
|
|
3671
|
+
const cuts = seg.cuts || [];
|
|
3672
|
+
const axis = isObject(seg.axis) ? seg.axis : null;
|
|
3673
|
+
const labels = characterLabels(segmentReferences(project, e, seg));
|
|
3674
|
+
const whoOf = (id) => labels.get(id) || identityOf(characterById(project, id));
|
|
3675
|
+
const prompt = String(seg.prompt || '');
|
|
3676
|
+
const texts = prompt.trim() ? shotTexts(prompt, videoModel(params).family, params.references) : new Map();
|
|
3677
|
+
if (prompt.trim() && axis?.screenLeft && axis?.screenRight && !cuts.some((c) => c.axisSide === 'cross')) {
|
|
3678
|
+
const line = sidesLine(axis, whoOf, lang);
|
|
3679
|
+
if (!prompt.includes(line)) out.push(`${seg.id}: the prompt must lock the sides — add "${line}"`);
|
|
3680
|
+
}
|
|
3681
|
+
cuts.forEach((c, k) => {
|
|
3682
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
3683
|
+
const chars = c.characters || [];
|
|
3684
|
+
const text = texts.get(k + 1);
|
|
3685
|
+
if (c.ots) {
|
|
3686
|
+
if (!chars.includes(c.ots) || chars.length !== 2) out.push(`${where}: ots must be one of exactly two people on screen — the one whose shoulder is near the lens`);
|
|
3687
|
+
else if (text && !text.includes(overShoulderPhrase(whoOf(c.ots), axis?.screenLeft === c.ots ? 'left' : 'right', lang))) out.push(`${where}: the shot must say the over-the-shoulder — paste "${overShoulderPhrase(whoOf(c.ots), axis?.screenLeft === c.ots ? 'left' : 'right', lang)}"`);
|
|
3688
|
+
} else if (chars.length === 1 && text) {
|
|
3689
|
+
const phrase = onlyInFramePhrase(whoOf(chars[0]), lang);
|
|
3690
|
+
if (!text.includes(phrase)) out.push(`${where}: a single must open with "${phrase}"`);
|
|
3691
|
+
for (const other of people.filter((id) => id !== chars[0])) {
|
|
3692
|
+
const label = labels.get(other);
|
|
3693
|
+
const identity = String(characterById(project, other)?.identityEn || '').trim();
|
|
3694
|
+
if ((label && new RegExp(`${label}(?!\\d)`).test(text)) || (identity && text.includes(identity))) out.push(`${where}: a single on ${chars[0]} must not describe ${other} — whoever a shot names ends up on screen`);
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
for (const id of chars) {
|
|
3698
|
+
if (id === c.ots) continue;
|
|
3699
|
+
const value = c.eyeline?.[id];
|
|
3700
|
+
if (!value) { out.push(`${where}: eyeline.${id} is required — "screen-left|screen-right level|up|down", "away screen-left" or "task"`); continue; }
|
|
3701
|
+
if (!eyelineValid(value)) { out.push(`${where}: eyeline.${id} "${value}" is not valid — "screen-left|screen-right level|up|down", "away screen-left" or "task"`); continue; }
|
|
3702
|
+
const look = parseEyeline(value);
|
|
3703
|
+
if (axis && !look.task && !look.away && c.axisSide !== 'cross' && (id === axis.screenLeft || id === axis.screenRight)) {
|
|
3704
|
+
const toward = id === axis.screenLeft ? 'screen-right' : 'screen-left';
|
|
3705
|
+
if (look.to !== toward) out.push(`${where}: ${id} stands on ${id === axis.screenLeft ? 'screen left' : 'screen right'}, so looking at the other person is ${toward}, not ${look.to} — or say "away ${look.to}"`);
|
|
3706
|
+
}
|
|
3707
|
+
if (text && !text.includes(eyelinePhrase(value, whoOf(id), lang))) out.push(`${where}: the shot must state the eyeline — paste "${eyelinePhrase(value, whoOf(id), lang)}"`);
|
|
3708
|
+
}
|
|
3709
|
+
});
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
return out;
|
|
3713
|
+
}),
|
|
3714
|
+
gate('idle-motion', 'storyboard', 'sheets mode: in a cut with two or more visible faces, everyone but the one carrying the action has a small motion of their own, written into the shot', (ctx) => {
|
|
3715
|
+
const { project, params } = ctx;
|
|
3716
|
+
const out = [];
|
|
3717
|
+
if (params.references !== 'sheets') return out;
|
|
3718
|
+
const lang = videoModel(params).promptLang === 'zh' ? 'zh' : 'en';
|
|
3719
|
+
for (const [, e] of ctx.episodes) {
|
|
3720
|
+
for (const seg of e.storyboard?.segments || []) {
|
|
3721
|
+
const labels = characterLabels(segmentReferences(project, e, seg));
|
|
3722
|
+
const whoOf = (id) => labels.get(id) || identityOf(characterById(project, id));
|
|
3723
|
+
const prompt = String(seg.prompt || '');
|
|
3724
|
+
const texts = prompt.trim() ? shotTexts(prompt, videoModel(params).family, params.references) : new Map();
|
|
3725
|
+
(seg.cuts || []).forEach((c, k) => {
|
|
3726
|
+
const where = `${seg.id} cut ${k + 1}`;
|
|
3727
|
+
const visible = (c.characters || []).filter((id) => id !== c.ots);
|
|
3728
|
+
for (const [id, idle] of Object.entries(isObject(c.idle) ? c.idle : {})) {
|
|
3729
|
+
if (!(c.characters || []).includes(id)) out.push(`${where}: idle.${id} — that person is not on screen`);
|
|
3730
|
+
const text = String(idle || '').trim();
|
|
3731
|
+
if (text.length > IDLE_MAX_CHARS) out.push(`${where}: idle.${id} is ${text.length} chars, max ${IDLE_MAX_CHARS} — one small motion`);
|
|
3732
|
+
if (IDLE_TOO_BIG.test(text)) out.push(`${where}: idle.${id} "${text}" is too big for an idle — a line, a moving mouth or a change of place is a cut's action`);
|
|
3733
|
+
const phrase = idlePhrase(idle, whoOf(id), lang);
|
|
3734
|
+
const shot = texts.get(k + 1);
|
|
3735
|
+
if (shot && !shot.includes(phrase)) out.push(`${where}: the shot must carry the idle — paste "${phrase}"`);
|
|
3736
|
+
}
|
|
3737
|
+
if (visible.length < 2) return;
|
|
3738
|
+
const idled = visible.filter((id) => String(c.idle?.[id] || '').trim());
|
|
3739
|
+
if (idled.length < visible.length - 1) {
|
|
3740
|
+
const missing = visible.filter((id) => !idled.includes(id));
|
|
3741
|
+
out.push(`${where}: ${visible.length} people are visible but only ${idled.length} have an idle — give all but the one carrying the action a small motion in cut.idle (${missing.join(', ')}), or "${IDLE_STILL}" when keeping still is the performance`);
|
|
3742
|
+
}
|
|
3743
|
+
if (/^\s*(only|nothing but)\b|只有|只剩/i.test(String(c.cameraNote?.hold || ''))) out.push(`${where}: cameraNote.hold says only "${c.cameraNote.hold}" moves, but the people in frame move too — drop "only"`);
|
|
3744
|
+
});
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
return out;
|
|
3748
|
+
}),
|
|
3342
3749
|
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) => {
|
|
3343
3750
|
const { project, params } = ctx;
|
|
3344
3751
|
const out = [];
|
|
@@ -3374,6 +3781,9 @@ export const GATES = [
|
|
|
3374
3781
|
const action = String(c.motion?.action || '');
|
|
3375
3782
|
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)`);
|
|
3376
3783
|
});
|
|
3784
|
+
const scene = (e.scenes || [])[Number(seg.sceneIndex) - 1];
|
|
3785
|
+
const twoFaceLines = scene ? cuts.filter((c) => (c.characters || []).length >= 2 && !c.ots && claimedLines(scene, [c]).length).length : 0;
|
|
3786
|
+
if (twoFaceLines >= 2) out.push(`${seg.id}: ${twoFaceLines} lines are played with both faces in frame — try the pattern in craft-shots.md: a two-shot once, over-the-shoulder for the exchange, a clean single for the punchline`);
|
|
3377
3787
|
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`);
|
|
3378
3788
|
}
|
|
3379
3789
|
}
|
|
@@ -3774,11 +4184,24 @@ function checkSpeechDirections(seg, prompt, lines, project, lang, refs = []) {
|
|
|
3774
4184
|
return out;
|
|
3775
4185
|
}
|
|
3776
4186
|
|
|
4187
|
+
/** Which image reference carries each person's face in a segment. */
|
|
4188
|
+
export function characterLabels(refs) {
|
|
4189
|
+
const images = refs.filter((r) => r.kind === 'image');
|
|
4190
|
+
const labels = new Map();
|
|
4191
|
+
for (const r of images) {
|
|
4192
|
+
const who = r.faceOf || (r.asset === 'sheet' || r.asset === 'headshot' ? r.entity : null);
|
|
4193
|
+
if (who && !labels.has(who)) labels.set(who, r.label);
|
|
4194
|
+
}
|
|
4195
|
+
for (const r of images.filter((x) => x.asset === 'headshot' || x.faceOf)) labels.set(r.faceOf || r.entity, r.label);
|
|
4196
|
+
return labels;
|
|
4197
|
+
}
|
|
4198
|
+
|
|
3777
4199
|
function shotParagraphs(prompt) {
|
|
3778
4200
|
const map = new Map();
|
|
3779
4201
|
const re = /\[Shot (\d+)\]\s*([\s\S]*?)(?=\n\[Shot \d+\]|\n\s*\n|$)/g;
|
|
3780
4202
|
let m;
|
|
3781
|
-
|
|
4203
|
+
// The soundscape line also says "at [Shot 1]": the shot's own paragraph comes first and is the one kept.
|
|
4204
|
+
while ((m = re.exec(prompt))) if (!map.has(Number(m[1]))) map.set(Number(m[1]), m[2].trim());
|
|
3782
4205
|
return map;
|
|
3783
4206
|
}
|
|
3784
4207
|
|
|
@@ -3923,7 +4346,7 @@ export function seedScript(project, ep) {
|
|
|
3923
4346
|
for (const id of outlineEp.characterIds || []) {
|
|
3924
4347
|
const c = characterById(project, id);
|
|
3925
4348
|
const sp = c?.bible?.speech || {};
|
|
3926
|
-
if (c) speech[id] = { name:
|
|
4349
|
+
if (c) speech[id] = { name: displayName(project, id, ep), rhythm: sp.rhythm || '', silence: sp.silence || '', catchphrase: sp.catchphrase || '', forbiddenWords: sp.forbiddenWords || [], register: sp.register || '', voice: c.voice?.pace || '' };
|
|
3927
4350
|
}
|
|
3928
4351
|
return {
|
|
3929
4352
|
ep: Number(ep),
|
|
@@ -3933,14 +4356,16 @@ export function seedScript(project, ep) {
|
|
|
3933
4356
|
task: isObject(outlineEp.task) ? { ...outlineEp.task } : { goal: '', obstacle: '', stake: '' },
|
|
3934
4357
|
change: outlineEp.change || '',
|
|
3935
4358
|
hookType: outlineEp.hookType || '',
|
|
3936
|
-
hook:
|
|
3937
|
-
cliff: outlineEp.
|
|
4359
|
+
hook: '',
|
|
4360
|
+
cliff: outlineEp.hook || '',
|
|
3938
4361
|
beatsClaimed: beats.map((b) => b.id),
|
|
3939
4362
|
hookBeat: [1, 1],
|
|
3940
4363
|
satisfactionBeat: null,
|
|
3941
4364
|
scenes: [],
|
|
3942
4365
|
seedNote: {
|
|
3943
4366
|
synopsis: outlineEp.synopsis || '',
|
|
4367
|
+
openingHook: `write \`hook\` yourself: the concrete image, in motion, that opens the episode (on screen within the first ${params.hookWindow} beats and by ${params.hookSeconds} s). \`cliff\` is seeded from the outline's hook — the question that makes them tap the next episode`,
|
|
4368
|
+
suspense: outlineEp.suspense || '',
|
|
3944
4369
|
satisfaction: isObject(outlineEp.satisfaction) ? { ...outlineEp.satisfaction } : { type: '', action: '' },
|
|
3945
4370
|
register: params.register,
|
|
3946
4371
|
guidelinePriorities: asList(project.outline?.guideline?.priorities),
|
|
@@ -3953,7 +4378,7 @@ export function seedScript(project, ep) {
|
|
|
3953
4378
|
crowdPlan: outlineEp.crowdPlan || '',
|
|
3954
4379
|
warnings: outlineEp.warnings || [],
|
|
3955
4380
|
beats: beats.map((b) => ({ id: b.id, type: b.type, weight: b.weight || 'minor', setup: b.setup, payoff: b.payoff })),
|
|
3956
|
-
previousCliff: prev?.
|
|
4381
|
+
previousCliff: prev?.hook || '',
|
|
3957
4382
|
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`,
|
|
3958
4383
|
},
|
|
3959
4384
|
storyboard: { segments: [] },
|
|
@@ -3988,7 +4413,7 @@ export function seedStoryboard(project, episode) {
|
|
|
3988
4413
|
});
|
|
3989
4414
|
return {
|
|
3990
4415
|
...episode,
|
|
3991
|
-
storyboard: { plan: segmentPlan(project, episode), seedScenes, visualPlans, segments: [] },
|
|
4416
|
+
storyboard: { plan: { ...segmentPlan(project, episode), foregroundLevel: paramsOf(project).foregroundLevel }, seedScenes, visualPlans, segments: [] },
|
|
3992
4417
|
};
|
|
3993
4418
|
}
|
|
3994
4419
|
|
|
@@ -4034,6 +4459,50 @@ export function segmentPlan(project, episode) {
|
|
|
4034
4459
|
* Measured on Seed Audio 1.0: the parenthetical is obeyed, not spoken (a 12-character line ran
|
|
4035
4460
|
* 5.17 s plain and 4.88 s with the direction), so it costs nothing in the sample's length.
|
|
4036
4461
|
*/
|
|
4462
|
+
/**
|
|
4463
|
+
* The full prompt of one image node on the characters / scenes / props canvas, in the project's
|
|
4464
|
+
* image-prompt language: the headshot (1:1), the sheet (16:9; a character's references its headshot
|
|
4465
|
+
* as @image1), a scene's lighting variant and a prop's state (both reference the sheet as @image1).
|
|
4466
|
+
* The Operator pastes `prompt` and `negative` as they are — no hand-assembled prefixes.
|
|
4467
|
+
*/
|
|
4468
|
+
export function imagePrompt(project, id, asset) {
|
|
4469
|
+
const params = paramsOf(project);
|
|
4470
|
+
const kit = imagePromptKit(params);
|
|
4471
|
+
const zh = kit.zh;
|
|
4472
|
+
const c = characterById(project, id), sc = sceneById(project, id), pr = propById(project, id);
|
|
4473
|
+
const entity = c || sc || pr;
|
|
4474
|
+
if (!entity) throw new Error(`no character / scene / prop ${id}`);
|
|
4475
|
+
const img = entity.image || {};
|
|
4476
|
+
// the fixed phrases follow the script the prompts are written in (繁體 or 简体)
|
|
4477
|
+
const hant = zh && isHant(`${img.prompt || ''}${img.sheet || ''}`);
|
|
4478
|
+
const t = (phrase) => (hant ? hantPhrase(phrase) : phrase);
|
|
4479
|
+
const out = (prompt, ratio, from) => ({ entity: id, asset, lang: params.imagePromptLang, prompt, negative: String(img.negativePrompt || ''), ratio, from });
|
|
4480
|
+
const kind = asset || 'sheet';
|
|
4481
|
+
if (c) {
|
|
4482
|
+
if (kind === 'headshot') return out(zh ? `${img.prompt}${t(',正面干净人像,纯色中性背景')}` : `${img.prompt}, clean front-facing portrait, plain neutral background`, '1:1', null);
|
|
4483
|
+
if (kind === 'sheet') return out(`${zh ? t('@image1 是必须完全一致的脸——五官与发型相同。') : '@image1 is the face to match exactly — same features, same hairstyle. '}${img.sheet}`, '16:9', 'headshot');
|
|
4484
|
+
throw new Error(`a character has --asset headshot | sheet, not ${kind}`);
|
|
4485
|
+
}
|
|
4486
|
+
if (kind === 'sheet') return out(String(img.sheet || ''), '16:9', null);
|
|
4487
|
+
if (sc && kind.startsWith('lighting:')) {
|
|
4488
|
+
const state = kind.slice('lighting:'.length);
|
|
4489
|
+
const l = (sc.lighting || []).find((x) => x.state === state);
|
|
4490
|
+
if (!l) throw new Error(`${id} has no lighting state "${state}" (${(sc.lighting || []).map((x) => x.state).join(' / ')})`);
|
|
4491
|
+
return out(zh
|
|
4492
|
+
? `${t('@image1 是同一个空间;结构、材质与磨损完全一致,只改变光线:')}${l.prompt}${t(`。空场景,没有任何人,${kit.framePhrase}`)}`
|
|
4493
|
+
: `@image1 is this exact space; keep structure, materials and wear identical; change only the lighting: ${l.prompt}. Empty scene, no people, ${kit.framePhrase}`, '16:9', 'sheet');
|
|
4494
|
+
}
|
|
4495
|
+
if (pr && kind.startsWith('state:')) {
|
|
4496
|
+
const state = kind.slice('state:'.length);
|
|
4497
|
+
const st = (pr.states || []).find((x) => x.state === state);
|
|
4498
|
+
if (!st) throw new Error(`${id} has no state "${state}" (${(pr.states || []).map((x) => x.state).join(' / ')})`);
|
|
4499
|
+
return out(zh
|
|
4500
|
+
? `${t('@image1 是要完全还原的同一件道具。')}${st.prompt}${t('。纯白背景,没有人,没有手')}`
|
|
4501
|
+
: `@image1 is the prop to reproduce exactly. ${st.prompt}, pure white background, no people, no hands`, '16:9', 'sheet');
|
|
4502
|
+
}
|
|
4503
|
+
throw new Error(sc ? `a scene has --asset sheet | lighting:<state>` : `a prop has --asset sheet | state:<state>`);
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4037
4506
|
export function voiceSamplePrompt(character, params = {}) {
|
|
4038
4507
|
const v = character?.voice || {};
|
|
4039
4508
|
const pf = character?.profile || {};
|
|
@@ -4068,14 +4537,29 @@ export function voiceSampleSeconds(character, params) {
|
|
|
4068
4537
|
export function segmentReferences(project, episode, seg) {
|
|
4069
4538
|
const scene = (episode.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
4070
4539
|
const sceneEntity = sceneById(project, scene.sceneId) || {};
|
|
4071
|
-
const refs = [];
|
|
4072
|
-
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: '环境,严格还原布局、材质与旧痕' });
|
|
4073
|
-
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: '同一场景的光线状态' });
|
|
4074
|
-
const seen = new Set();
|
|
4075
4540
|
const params = paramsOf(project);
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4541
|
+
const refs = [];
|
|
4542
|
+
// Optional per-segment image ref plan (Director Strategy B etc.) — pictures only; voices/video still appended below.
|
|
4543
|
+
if (Array.isArray(seg.refOverride) && seg.refOverride.length) {
|
|
4544
|
+
for (const r of seg.refOverride) {
|
|
4545
|
+
refs.push({
|
|
4546
|
+
entity: r.entity,
|
|
4547
|
+
asset: r.asset || 'sheet',
|
|
4548
|
+
why: r.why || `${r.entity}:${r.asset || 'sheet'}`,
|
|
4549
|
+
use: r.use || 'a reference image for this segment',
|
|
4550
|
+
zh: r.zh || '',
|
|
4551
|
+
kind: 'image',
|
|
4552
|
+
faceOf: r.faceOf || null,
|
|
4553
|
+
});
|
|
4554
|
+
}
|
|
4555
|
+
} else {
|
|
4556
|
+
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: '环境,严格还原布局、材质与旧痕' });
|
|
4557
|
+
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: '同一场景的光线状态' });
|
|
4558
|
+
const seen = new Set();
|
|
4559
|
+
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: '人物三视图,严格还原面容、发型与服装' }); }
|
|
4560
|
+
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: '需要严格还原的道具' }); }
|
|
4561
|
+
refs.forEach((r) => (r.kind = 'image'));
|
|
4562
|
+
}
|
|
4079
4563
|
// The clip before this one, when the project asks for it: the cheapest continuity there is — the
|
|
4080
4564
|
// model sees what the last generation actually produced, not a description of it.
|
|
4081
4565
|
if (params.videoReferences === 'previous') {
|
|
@@ -4111,7 +4595,19 @@ export function segmentReferences(project, episode, seg) {
|
|
|
4111
4595
|
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`;
|
|
4112
4596
|
}
|
|
4113
4597
|
const sheetOf = params.references === 'sheets'
|
|
4114
|
-
?
|
|
4598
|
+
? (() => {
|
|
4599
|
+
const m = new Map();
|
|
4600
|
+
for (const r of refs.filter((x) => x.kind === 'image')) {
|
|
4601
|
+
const who = r.faceOf || (r.asset === 'sheet' || r.asset === 'headshot' ? r.entity : null);
|
|
4602
|
+
if (who && !m.has(who)) m.set(who, r.label);
|
|
4603
|
+
}
|
|
4604
|
+
// Prefer explicit headshot / faceOf over later body sheets
|
|
4605
|
+
for (const r of refs.filter((x) => x.kind === 'image' && (x.asset === 'headshot' || x.faceOf))) {
|
|
4606
|
+
const who = r.faceOf || r.entity;
|
|
4607
|
+
m.set(who, r.label);
|
|
4608
|
+
}
|
|
4609
|
+
return m;
|
|
4610
|
+
})()
|
|
4115
4611
|
: new Map();
|
|
4116
4612
|
for (const r of refs) {
|
|
4117
4613
|
if (r.kind !== 'audio') continue;
|
|
@@ -4172,7 +4668,11 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4172
4668
|
described.add(l.speaker);
|
|
4173
4669
|
// A line is attributed by the reference that carries the voice — that is what the model has to
|
|
4174
4670
|
// read it in. The sheet label rides along so the picture and the voice are tied to one person.
|
|
4175
|
-
|
|
4671
|
+
// VO has no character / no @audio: never fall through identityOf(null) → "the person in this shot"
|
|
4672
|
+
// (that wrongly lip-syncs the line onto whoever is on camera).
|
|
4673
|
+
const who = l.speaker === 'VO'
|
|
4674
|
+
? 'an off-screen booth-edge friend voiceover'
|
|
4675
|
+
: ([voice, sheet].filter(Boolean).join(' / ') || identityOf(ch));
|
|
4176
4676
|
return {
|
|
4177
4677
|
...l,
|
|
4178
4678
|
cut: k + 1,
|
|
@@ -4201,7 +4701,17 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4201
4701
|
const clipMusic = params.audioTrack === 'post' ? '' : musicOf(sound).intent;
|
|
4202
4702
|
// Who else is on screen while `speaker` has a line: the H3 idiom keeps the lip-sync off them.
|
|
4203
4703
|
// In keyframes mode the @imageN labels are frames, so a person is named by identity instead.
|
|
4204
|
-
const
|
|
4704
|
+
const framingLabels = characterLabels(allRefs);
|
|
4705
|
+
const whoOf = (id) => framingLabels.get(id) || identityOf(characterById(project, id));
|
|
4706
|
+
const framingLang = videoModel(params).promptLang === 'zh' ? 'zh' : 'en';
|
|
4707
|
+
const idleOf = (c) => (params.references === 'sheets' ? idleSentence(c, whoOf, framingLang) : '');
|
|
4708
|
+
const framingOf = (c) => (params.references === 'sheets' ? shotFraming(c, seg, whoOf, framingLang) : '');
|
|
4709
|
+
const sidesOf = () => {
|
|
4710
|
+
const axis = isObject(seg.axis) ? seg.axis : null;
|
|
4711
|
+
if (params.references !== 'sheets' || !axis?.screenLeft || !axis?.screenRight || cuts.some((c) => c.axisSide === 'cross')) return [];
|
|
4712
|
+
return [sidesLine(axis, whoOf, framingLang)];
|
|
4713
|
+
};
|
|
4714
|
+
const othersOnScreen = (c, speaker) => (c.characters || []).filter((id) => id !== speaker && id !== c.ots).map((id) => (params.references === 'sheets' && sheetLabels.get(id) ? `the person in ${sheetLabels.get(id)}` : identityOf(characterById(project, id))));
|
|
4205
4715
|
let video;
|
|
4206
4716
|
if (mode.family === 'h3') {
|
|
4207
4717
|
const sheets = params.references === 'sheets';
|
|
@@ -4217,14 +4727,14 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4217
4727
|
// In sheets mode the cut's own `frame` goes into the prompt and already carries the lens, the
|
|
4218
4728
|
// light and the foreground (gated there), so only the shot size and camera move are added.
|
|
4219
4729
|
const body = sheets
|
|
4220
|
-
? `${c.frame || '…'} ${SIZES[c.size] || c.size}, ${c.camera}.`
|
|
4730
|
+
? `${framingOf(c)}${c.frame || '…'} ${SIZES[c.size] || c.size}, ${c.camera}.`
|
|
4221
4731
|
: `… anchor on @image${k + 1} · ${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}. ${lightPhrase(c)}.${foregroundPhrase(c)}`;
|
|
4222
|
-
return `${head}${body} ${motionSentence(c)} ${cameraClause(c)} ${d}${lips}`.replace(/\s+/g, ' ').trim();
|
|
4732
|
+
return `${head}${body} ${motionSentence(c)} ${idleOf(c)}${cameraClause(c)} ${d}${lips}`.replace(/\s+/g, ' ').trim();
|
|
4223
4733
|
});
|
|
4224
4734
|
// Keyframes are aligned by the alignment line; the voices are named either way.
|
|
4225
4735
|
const voiceLegend = allRefs.filter((r) => r.kind !== 'image').map((r) => `${r.label} is ${r.use}.`);
|
|
4226
4736
|
const lead = sheets
|
|
4227
|
-
? allRefs.map((r) => `${r.label} is ${r.use}.`)
|
|
4737
|
+
? [...allRefs.map((r) => `${r.label} is ${r.use}.`), ...sidesOf()]
|
|
4228
4738
|
: [h3AlignmentLine(cuts), ...voiceLegend];
|
|
4229
4739
|
const look = sheets ? [`Look: ${lookOf()}.`, ''] : [];
|
|
4230
4740
|
const soundscape = [sound.ambience || '…', ...cuts.map((c, k) => (sfxOf(k) ? `at [Shot ${k + 1}] ${sfxOf(k)}` : '')).filter(Boolean)].join('; ');
|
|
@@ -4235,22 +4745,29 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4235
4745
|
// Wan's prompt is Chinese: the lens and the light direction translate, the note and the
|
|
4236
4746
|
// foreground are the author's English prose, so they are left as blanks to write in Chinese.
|
|
4237
4747
|
const shot = `${cameraPhrase(c, params, 'zh')}。${LIGHT_DIRECTIONS_ZH[c.light?.direction] || '…'},…。前景:${c.foreground ? '…(虚化)' : '无'}。`;
|
|
4238
|
-
return [`${wanShotHeader(cuts, k)}…${shot}${motionSentence(c, 'zh')}${cameraClause(c, 'zh')}`, ...talk].join('\n');
|
|
4748
|
+
return [`${wanShotHeader(cuts, k)}${framingOf(c)}…${shot}${motionSentence(c, 'zh')}${idleOf(c)}${cameraClause(c, 'zh')}`, ...talk].join('\n');
|
|
4239
4749
|
});
|
|
4240
4750
|
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `镜头${k + 1}:${sfxOf(k)}` : '')).filter(Boolean).join(';');
|
|
4241
4751
|
video = [
|
|
4242
|
-
params.references === 'sheets' ? wanSheetLegendLine(allRefs) : wanLegendLine(cuts, allRefs), '',
|
|
4752
|
+
params.references === 'sheets' ? wanSheetLegendLine(allRefs) : wanLegendLine(cuts, allRefs), ...sidesOf(), '',
|
|
4243
4753
|
...blocks.flatMap((b) => [b, '']),
|
|
4244
4754
|
...(cine && params.references === 'sheets' ? [`画面:${cine.videoPhraseZh}${params.lookPhraseZh ? `,${params.lookPhraseZh}` : ''}。`, ''] : []),
|
|
4245
4755
|
...(grammarLines(seg, params, 'zh').length ? [...grammarLines(seg, params, 'zh'), ''] : []),
|
|
4246
4756
|
`音频:对白为${accent || '…'},环境音${sound.ambience || '…'}${sfxAll ? `;${sfxAll}` : ''}${clipMusic ? `;配乐:${clipMusic}` : params.audioTrack === 'post' ? ';无配乐' : ''}。${params.subtitles ? '' : `${NO_SUBTITLES.zh}。`}`,
|
|
4247
4757
|
].join('\n');
|
|
4248
4758
|
} else if (params.references === 'sheets') {
|
|
4249
|
-
const legend = allRefs.map((r) => `${r.label} is ${r.use}.`);
|
|
4759
|
+
const legend = [...allRefs.map((r) => `${r.label} is ${r.use}.`), ...sidesOf()];
|
|
4250
4760
|
const shots = cuts.map((c, k) => {
|
|
4251
|
-
const talk = dialogue[k].map((l) =>
|
|
4761
|
+
const talk = dialogue[k].map((l) => {
|
|
4762
|
+
const base = `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`;
|
|
4763
|
+
// VO is off-screen: keep every on-screen mouth closed so the cheer never lands on the visible face.
|
|
4764
|
+
if (l.speaker !== 'VO') return base;
|
|
4765
|
+
const others = othersOnScreen(c, l.speaker);
|
|
4766
|
+
if (!others.length) return base;
|
|
4767
|
+
return `${base} while ${others.join(' and ')} ${others.length > 1 ? 'do' : 'does'} not speak, ${LIPS_CLOSED}`;
|
|
4768
|
+
}).join(' ');
|
|
4252
4769
|
// The frame already carries the lens, the light and the foreground — do not repeat them.
|
|
4253
|
-
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();
|
|
4770
|
+
return `Shot ${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s, ${SIZES[c.size] || c.size}, ${c.camera}): ${framingOf(c)}${c.frame || '…'} ${motionSentence(c)} ${idleOf(c)}${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
|
|
4254
4771
|
});
|
|
4255
4772
|
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `shot ${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
|
|
4256
4773
|
video = [...legend, '', ...shots, '', ...actionLine, `Look: ${lookOf()}. 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}.` : ''}${clipMusic ? ` Music: ${clipMusic}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
|
|
@@ -4259,7 +4776,13 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4259
4776
|
const shotOf = (c) => `${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}; ${lightPhrase(c)}${foregroundPhrase(c)}`;
|
|
4260
4777
|
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)}).`));
|
|
4261
4778
|
const script = cuts.map((c, k) => {
|
|
4262
|
-
const talk = dialogue[k].map((l) =>
|
|
4779
|
+
const talk = dialogue[k].map((l) => {
|
|
4780
|
+
const base = `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`;
|
|
4781
|
+
if (l.speaker !== 'VO') return base;
|
|
4782
|
+
const others = othersOnScreen(c, l.speaker);
|
|
4783
|
+
if (!others.length) return base;
|
|
4784
|
+
return `${base} while ${others.join(' and ')} ${others.length > 1 ? 'do' : 'does'} not speak, ${LIPS_CLOSED}`;
|
|
4785
|
+
}).join(' ');
|
|
4263
4786
|
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();
|
|
4264
4787
|
});
|
|
4265
4788
|
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `from @image${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
|
|
@@ -4448,7 +4971,7 @@ export function renderDialogue(project, episode) {
|
|
|
4448
4971
|
if (b.line === undefined) return;
|
|
4449
4972
|
const ch = characterById(project, b.speaker);
|
|
4450
4973
|
const sp = ch?.bible?.speech || {};
|
|
4451
|
-
const who = b.speaker === 'VO' ? 'VO' :
|
|
4974
|
+
const who = b.speaker === 'VO' ? 'VO' : displayName(project, b.speaker, episode.ep);
|
|
4452
4975
|
const hint = [sp.rhythm, sp.silence ? `沉默:${sp.silence}` : '', (sp.forbiddenWords || []).length ? `禁用:${sp.forbiddenWords.join('、')}` : ''].filter(Boolean).join(';');
|
|
4453
4976
|
rows.push([`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`, '', '', '', hint]);
|
|
4454
4977
|
});
|
|
@@ -4543,12 +5066,12 @@ export function renderScript(project, episode) {
|
|
|
4543
5066
|
const satRef = Array.isArray(episode.satisfactionBeat) ? episode.satisfactionBeat.map(Number) : null;
|
|
4544
5067
|
(episode.scenes || []).forEach((sc, i) => {
|
|
4545
5068
|
const scene = sceneById(project, sc.sceneId);
|
|
4546
|
-
lines.push(`## ${i + 1}. ${scene?.name || sc.sceneId}${sc.lighting ? ` · ${sc.lighting}` : ''}`, '', `人物:${(sc.characters || []).map((id) =>
|
|
5069
|
+
lines.push(`## ${i + 1}. ${scene?.name || sc.sceneId}${sc.lighting ? ` · ${sc.lighting}` : ''}`, '', `人物:${(sc.characters || []).map((id) => displayName(project, id, episode.ep)).join('、')}${(sc.props || []).length ? ` · 道具:${sc.props.map((id) => propById(project, id)?.name || id).join('、')}` : ''}`, '');
|
|
4547
5070
|
(sc.flow || []).forEach((b, k) => {
|
|
4548
5071
|
const o = at(i + 1, k + 1);
|
|
4549
5072
|
const span = o ? `(${Math.round(o.start)}-${Math.round(o.end)}秒)` : '';
|
|
4550
5073
|
if (b.line !== undefined) {
|
|
4551
|
-
const who = b.speaker === 'VO' ? 'VO' :
|
|
5074
|
+
const who = b.speaker === 'VO' ? 'VO' : displayName(project, b.speaker, episode.ep);
|
|
4552
5075
|
lines.push(`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`);
|
|
4553
5076
|
} else lines.push(`△${span}${b.action}${b.turn ? ' ⟲' : ''}`);
|
|
4554
5077
|
if (satRef && satRef[0] === i + 1 && satRef[1] === k + 1) lines.push('', `【爽点标记】${sat.action || sat.type || ''}`);
|
|
@@ -4569,7 +5092,7 @@ export function renderSegment(project, episode, segmentId) {
|
|
|
4569
5092
|
const cuts = seg.cuts || [];
|
|
4570
5093
|
const starts = cutStarts(cuts);
|
|
4571
5094
|
const total = cuts.reduce((s, c) => s + (Number(c.seconds) || 0), 0);
|
|
4572
|
-
const nameOf = (id) =>
|
|
5095
|
+
const nameOf = (id) => displayName(project, id, episode.ep);
|
|
4573
5096
|
const lines = [`# ${seg.id}`, '', `${sceneById(project, scene.sceneId)?.name || scene.sceneId || ''}${scene.lighting ? ` · ${scene.lighting}` : ''} · ${round2(total)} s · ${params.video} · ${grammarsOf(seg, params).join(' + ')}`, ''];
|
|
4574
5097
|
const kit = params.camera || DEFAULT_PARAMS.camera;
|
|
4575
5098
|
lines.push(`镜头:${kit.body} + ${kit.lens}`, '');
|
|
@@ -4782,6 +5305,8 @@ export function initProject(opts) {
|
|
|
4782
5305
|
videoDuration: opts.duration || (mode.autoDuration ? DEFAULT_PARAMS.videoDuration : 'fixed'),
|
|
4783
5306
|
lookPhrase: opts.look || DEFAULT_PARAMS.lookPhrase,
|
|
4784
5307
|
style: opts.style || DEFAULT_PARAMS.style,
|
|
5308
|
+
imagePromptLang: opts.imageLang || DEFAULT_PARAMS.imagePromptLang,
|
|
5309
|
+
...(opts.foreground ? { foregroundLevel: opts.foreground } : {}),
|
|
4785
5310
|
// Picked from the genre unless named; `--cinematography none` opts out.
|
|
4786
5311
|
cinematography: cine,
|
|
4787
5312
|
// Named, or read from the genre only when it names an action style outright.
|
|
@@ -4919,6 +5444,16 @@ function main(argv) {
|
|
|
4919
5444
|
return 0;
|
|
4920
5445
|
}
|
|
4921
5446
|
|
|
5447
|
+
function installDashboard(projectDir, opts = {}) {
|
|
5448
|
+
const output = resolve(opts.output || join(projectDir, 'dashboard.html'));
|
|
5449
|
+
const source = join(dirname(fileURLToPath(import.meta.url)), '..', 'dashboard.html');
|
|
5450
|
+
if (!existsSync(source)) throw new Error(`static dashboard missing: ${source}`);
|
|
5451
|
+
if (existsSync(output) && !opts.force) return { output, copied: false };
|
|
5452
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
5453
|
+
copyFileSync(source, output);
|
|
5454
|
+
return { output, copied: true };
|
|
5455
|
+
}
|
|
5456
|
+
|
|
4922
5457
|
if (cmd === 'init') {
|
|
4923
5458
|
const dir = pos[0];
|
|
4924
5459
|
if (!dir) throw new Error('usage: init <dir> --title … --episodes N --minutes M --genre G');
|
|
@@ -4927,14 +5462,17 @@ function main(argv) {
|
|
|
4927
5462
|
adapt: flag(rest, '--adapt'), ratio: flag(rest, '--ratio'), video: flag(rest, '--video'), style: flag(rest, '--style'), lang: flag(rest, '--lang'),
|
|
4928
5463
|
resolution: flag(rest, '--resolution'), accent: flag(rest, '--accent'),
|
|
4929
5464
|
references: flag(rest, '--references'), duration: flag(rest, '--duration'), look: flag(rest, '--look'),
|
|
4930
|
-
register: flag(rest, '--register'),
|
|
5465
|
+
register: flag(rest, '--register'), imageLang: flag(rest, '--image-lang'), foreground: flag(rest, '--foreground'),
|
|
5466
|
+
cinematography: flag(rest, '--cinematography'), action: flag(rest, '--action'), violence: flag(rest, '--violence'), grammar: flag(rest, '--grammar'), comedy: flag(rest, '--comedy'), composition: flag(rest, '--composition'), story: flag(rest, '--story'), market: flag(rest, '--market'), format: flag(rest, '--format'),
|
|
4931
5467
|
source: flag(rest, '--source'), sourcePath: flag(rest, '--source-path'),
|
|
4932
5468
|
});
|
|
4933
5469
|
const paths = projectPaths(dir);
|
|
4934
5470
|
if (existsSync(paths.project) && !flag(rest, '--force')) throw new Error(`${paths.project} exists (pass --force to overwrite)`);
|
|
4935
5471
|
writeJsonFile(paths.project, project);
|
|
4936
5472
|
mkdirSync(paths.episodes, { recursive: true });
|
|
5473
|
+
const dashboard = installDashboard(paths.dir);
|
|
4937
5474
|
console.log(`✓ ${paths.project}`);
|
|
5475
|
+
console.log(`✓ ${dashboard.output}`);
|
|
4938
5476
|
return 0;
|
|
4939
5477
|
}
|
|
4940
5478
|
|
|
@@ -4977,22 +5515,12 @@ function main(argv) {
|
|
|
4977
5515
|
|
|
4978
5516
|
if (cmd === 'dashboard') {
|
|
4979
5517
|
const loaded = loadProject(input);
|
|
4980
|
-
const
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
const gateResults = runGates(makeContext(loaded.project, loaded.episodes));
|
|
4985
|
-
const html = renderDashboard({
|
|
4986
|
-
project: loaded.project,
|
|
4987
|
-
episodes: loaded.episodes,
|
|
4988
|
-
gateResults,
|
|
4989
|
-
projectDir: loaded.paths.dir,
|
|
4990
|
-
assetsDir: flag(rest, '--assets'),
|
|
4991
|
-
outputPath: output,
|
|
5518
|
+
const outputFlag = flag(rest, '--output');
|
|
5519
|
+
const installed = installDashboard(loaded.paths.dir, {
|
|
5520
|
+
output: typeof outputFlag === 'string' ? outputFlag : undefined,
|
|
5521
|
+
force: Boolean(flag(rest, '--force')),
|
|
4992
5522
|
});
|
|
4993
|
-
|
|
4994
|
-
writeFileSync(output, html, 'utf8');
|
|
4995
|
-
console.log(`✓ ${output}`);
|
|
5523
|
+
console.log(installed.copied ? `✓ ${installed.output}` : `✓ ${installed.output} (already beside project.json)`);
|
|
4996
5524
|
return 0;
|
|
4997
5525
|
}
|
|
4998
5526
|
|
|
@@ -5060,6 +5588,15 @@ function main(argv) {
|
|
|
5060
5588
|
return 0;
|
|
5061
5589
|
}
|
|
5062
5590
|
|
|
5591
|
+
if (cmd === 'prompt' && pos[1] === 'image') {
|
|
5592
|
+
const { project } = loadProject(input);
|
|
5593
|
+
if (!pos[2]) throw new Error('usage: prompt <project> image C01|S01|P01 [--asset headshot|sheet|lighting:<state>|state:<state>] [--json]');
|
|
5594
|
+
const out = imagePrompt(project, pos[2], flag(rest, '--asset') || 'sheet');
|
|
5595
|
+
if (flag(rest, '--json')) console.log(JSON.stringify(out, null, 2));
|
|
5596
|
+
else console.log(out.prompt);
|
|
5597
|
+
return 0;
|
|
5598
|
+
}
|
|
5599
|
+
|
|
5063
5600
|
if (cmd === 'prompt' && pos[1] === 'voice') {
|
|
5064
5601
|
const { project } = loadProject(input);
|
|
5065
5602
|
const c = characterById(project, pos[2]);
|
|
@@ -5130,7 +5667,7 @@ function main(argv) {
|
|
|
5130
5667
|
throw new Error(`unknown command "${cmd}" — run with --help`);
|
|
5131
5668
|
}
|
|
5132
5669
|
|
|
5133
|
-
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
5670
|
+
const isMain = process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url));
|
|
5134
5671
|
if (isMain) {
|
|
5135
5672
|
try {
|
|
5136
5673
|
process.exitCode = main(process.argv.slice(2));
|