@wji.ai/cli 0.8.1 → 0.8.2
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 +1 -1
- package/skill/short-drama/SKILL.md +35 -21
- package/skill/short-drama/dashboard.html +863 -0
- package/skill/short-drama/examples/night-shift/episodes/E01.json +1 -1
- package/skill/short-drama/reference/canvas-recipes.md +1 -1
- 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 +14 -10
- package/skill/short-drama/reference/stage-storyboard.md +3 -2
- package/skill/short-drama/scripts/selftest.mjs +327 -29
- package/skill/short-drama/scripts/short-drama.mjs +410 -82
- package/skill/short-drama/scripts/short-drama-dashboard.mjs +0 -459
|
@@ -19,6 +19,7 @@
|
|
|
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
|
+
// [--image-lang en|zh] (language of the character / scene / prop image prompts)
|
|
22
23
|
// validate <project> [--stage outline|characters|art|script|storyboard] [--ep 1-3]
|
|
23
24
|
// checkup <project> [--stage …] [--ep …] every gate with ✓/✗, exit 0
|
|
24
25
|
// seed <project> script --ep N [--force] episodes/E0N.json skeleton from the outline
|
|
@@ -26,19 +27,20 @@
|
|
|
26
27
|
// render <project> outline | guideline | character C01 | scene S01 | prop P01 | script --ep N | dialogue --ep N | segment E01-01
|
|
27
28
|
// prompt <project> segment E01-01 derived video-prompt skeleton + keyframe reference plan
|
|
28
29
|
// prompt <project> voice C01 the acted sample text for the voice node
|
|
30
|
+
// prompt <project> image C01|S01|P01 [--asset headshot|sheet|lighting:<state>|state:<state>] [--json]
|
|
31
|
+
// the image node's prompt, in params.imagePromptLang (+ negative, ratio, reference)
|
|
29
32
|
// prompt <project> audio E01-01 [--full] the segment's Seed Audio soundtrack prompt (timecoded ambience / sfx / music, + lines with --full)
|
|
30
33
|
// prompt <project> score E01 [--fps 24] the episode's spotting sheet for the post soundtrack (audioTrack post): planned
|
|
31
34
|
// timecodes of scenes, lines, music cues and post sfx, and the ≤120 s Seed Audio sections
|
|
32
|
-
// dashboard <project> [--output dashboard.html] [--
|
|
33
|
-
// static
|
|
35
|
+
// dashboard <project> [--output dashboard.html] [--force]
|
|
36
|
+
// copy the static dashboard next to the project; it loads project.json
|
|
34
37
|
// manifest <project> [--stage …] [--ep N] unbound WJI objects, in execution order
|
|
35
38
|
// bind <project> [--ep N] <path> <value> write a WJI id back (path segments may be entity ids)
|
|
36
39
|
// slug <name>
|
|
37
40
|
|
|
38
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
41
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
39
42
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
40
43
|
import { fileURLToPath } from 'node:url';
|
|
41
|
-
import { renderDashboard } from './short-drama-dashboard.mjs';
|
|
42
44
|
|
|
43
45
|
// ── constants ───────────────────────────────────────────────────────────────
|
|
44
46
|
|
|
@@ -88,6 +90,9 @@ export const DEFAULT_PARAMS = Object.freeze({
|
|
|
88
90
|
format: 'short',
|
|
89
91
|
// Where it is released (MARKETS): mainland holds the supernatural to a rational explanation.
|
|
90
92
|
market: 'mainland',
|
|
93
|
+
// The language of the character / scene / prop image prompts (sheets, headshots, lighting and
|
|
94
|
+
// prop states): `en`, or `zh` when the image model takes Chinese and the crew wants to read them.
|
|
95
|
+
imagePromptLang: 'en',
|
|
91
96
|
minCutSeconds: 2,
|
|
92
97
|
maxCutSeconds: 5,
|
|
93
98
|
maxOnScreen: 3,
|
|
@@ -109,6 +114,8 @@ export const DEFAULT_PARAMS = Object.freeze({
|
|
|
109
114
|
audioTrack: 'post',
|
|
110
115
|
// Frame rate the post spotting sheet writes SMPTE timecode at (conform it to the edit's real fps).
|
|
111
116
|
scoreFps: 24,
|
|
117
|
+
// Scene-boundary contrast: last K cuts of scene N vs first K of scene N+1 (gate boundary-contrast).
|
|
118
|
+
boundaryContrastCuts: 2,
|
|
112
119
|
thresholds: { maxLeads: 5, maxSupport: 10, maxFunctional: 10, maxBeatGap: 3, maxProps: 8 },
|
|
113
120
|
});
|
|
114
121
|
|
|
@@ -204,6 +211,7 @@ export const DURATION_MODES = ['fixed', 'auto'];
|
|
|
204
211
|
* move carry over instead of being re-invented per clip. `none` leaves the sheets to do that alone.
|
|
205
212
|
*/
|
|
206
213
|
export const VIDEO_REFERENCE_MODES = ['none', 'previous'];
|
|
214
|
+
export const IMAGE_PROMPT_LANGS = ['en', 'zh'];
|
|
207
215
|
|
|
208
216
|
// ── screenplay craft (from the w-short-drama-editor / character-creator / dialogue-editor method) ──
|
|
209
217
|
|
|
@@ -286,6 +294,7 @@ export const NO_SUBTITLES = { en: 'no subtitles, no captions, no on-screen dialo
|
|
|
286
294
|
export const LIPS_CLOSED = 'lips remain completely closed';
|
|
287
295
|
export const WARNINGS = ['雨戏', '肢体接触', '人群', '手部特写'];
|
|
288
296
|
export const PROP_SCALES = { handheld: 'handheld scale', tabletop: 'tabletop scale', furniture: 'furniture scale' };
|
|
297
|
+
export const PROP_SCALES_ZH = { handheld: '手持尺寸', tabletop: '桌面尺寸', furniture: '大件尺寸' };
|
|
289
298
|
export const SIZES = {
|
|
290
299
|
'extreme-wide': 'extreme wide shot',
|
|
291
300
|
wide: 'wide shot',
|
|
@@ -472,6 +481,10 @@ export const STYLE_PRESETS = {
|
|
|
472
481
|
negativeMustNot: ['photorealistic', '3d render'],
|
|
473
482
|
negativeMust: [],
|
|
474
483
|
tags: ['semi-realistic', 'painterly', 'cinematic'],
|
|
484
|
+
renderZh: '半写实角色插画,绘画质感,笔触柔和融合并带可见的笔刷肌理,解剖结构扎实',
|
|
485
|
+
framePhraseZh: '电影剧照',
|
|
486
|
+
negativeMustNotZh: ['照片级', '真人', '3d渲染'],
|
|
487
|
+
negativeMustZh: [],
|
|
475
488
|
},
|
|
476
489
|
photoreal: {
|
|
477
490
|
label: 'photographic live-action',
|
|
@@ -481,6 +494,10 @@ export const STYLE_PRESETS = {
|
|
|
481
494
|
negativeMustNot: ['photorealistic', 'film grain', 'skin texture'],
|
|
482
495
|
negativeMust: ['illustration', 'anime', 'cartoon'],
|
|
483
496
|
tags: ['photoreal', 'live action', 'cinematic'],
|
|
497
|
+
renderZh: '真人实拍参考摄影,皮肤可见毛孔与细绒毛,布料有真实织纹与磨损,中性电影调色',
|
|
498
|
+
framePhraseZh: '照片级写实电影剧照',
|
|
499
|
+
negativeMustNotZh: ['照片级', '胶片颗粒', '皮肤纹理'],
|
|
500
|
+
negativeMustZh: ['插画', '动漫', '卡通'],
|
|
484
501
|
},
|
|
485
502
|
soft3d: {
|
|
486
503
|
label: 'soft clay-toy 3D animation',
|
|
@@ -490,6 +507,10 @@ export const STYLE_PRESETS = {
|
|
|
490
507
|
negativeMustNot: ['3d render', 'cgi', 'stylized', 'clay', 'matte', 'toy', 'simplified'],
|
|
491
508
|
negativeMust: ['photorealistic'],
|
|
492
509
|
tags: ['3d animation', 'clay render', 'soft toy style', 'kids tv'],
|
|
510
|
+
renderZh: '柔和哑光的黏土玩具风格三维渲染,表面光滑无毛孔,发型是一整块圆润的造型,五官极简,四肢圆胖,大头身比,柔和全局光',
|
|
511
|
+
framePhraseZh: '黏土动画电影剧照',
|
|
512
|
+
negativeMustNotZh: ['3d渲染', 'cg', '风格化', '黏土', '哑光', '玩具', '简化'],
|
|
513
|
+
negativeMustZh: ['照片级'],
|
|
493
514
|
},
|
|
494
515
|
disney3d: {
|
|
495
516
|
label: 'Disney/Pixar-style 3D animation',
|
|
@@ -499,6 +520,10 @@ export const STYLE_PRESETS = {
|
|
|
499
520
|
negativeMustNot: ['3d render', 'cgi', 'stylized', 'cartoon'],
|
|
500
521
|
negativeMust: ['photorealistic'],
|
|
501
522
|
tags: ['3d animation', 'disney pixar style', 'family', 'cinematic'],
|
|
523
|
+
renderZh: '迪士尼与皮克斯长片风格的三维动画,圆润讨喜的造型语言,大而传神的眼睛,次表面散射皮肤,温暖的电影主光与轮廓光',
|
|
524
|
+
framePhraseZh: '三维动画电影剧照',
|
|
525
|
+
negativeMustNotZh: ['3d渲染', 'cg', '风格化', '卡通'],
|
|
526
|
+
negativeMustZh: ['照片级'],
|
|
502
527
|
},
|
|
503
528
|
ghibli: {
|
|
504
529
|
label: 'hand-painted anime',
|
|
@@ -508,9 +533,67 @@ export const STYLE_PRESETS = {
|
|
|
508
533
|
negativeMustNot: [],
|
|
509
534
|
negativeMust: ['photorealistic'],
|
|
510
535
|
tags: ['anime', 'cel shading', 'hand-painted'],
|
|
536
|
+
renderZh: '日本经典动画电影风格的手绘赛璐璐动画,干净线稿,平涂色彩配柔和水彩背景',
|
|
537
|
+
framePhraseZh: '手绘动画电影剧照',
|
|
538
|
+
negativeMustNotZh: [],
|
|
539
|
+
negativeMustZh: ['照片级'],
|
|
511
540
|
},
|
|
512
541
|
};
|
|
513
542
|
|
|
543
|
+
// Traditional → simplified for the characters the Chinese image-prompt rules look for, so a crew
|
|
544
|
+
// writing 繁體 (真人實拍參考攝影, 純白背景, 東亞) passes the same checks as one writing 简体.
|
|
545
|
+
const ZH_T = '實參攝寫繪畫質膚絨織紋損調級動風簡賽顆東亞漢華國韓歐維紀時當現戰無場沒純圖臉須髮還間構變燈樣與體條機啞寬細電影剛劇個們來這為後從歲種頭於將兩裡進遠擊衛萬處滿點眾邊師張長門開關問員陽陰雲書幾話鐵號專業輕壓環題顏鏡區鬥創標誌劃會戲網際聲氣態網雜淺濃暗餘濕黃綠藍紅寫臨團鄉樓廳櫃燈廢廠館護殘獨帶膠鮮爾歷羅陸屬樂傳麗貴壯結線淨';
|
|
546
|
+
const ZH_S = '实参摄写绘画质肤绒织纹损调级动风简赛颗东亚汉华国韩欧维纪时当现战无场没纯图脸须发还间构变灯样与体条机哑宽细电影刚剧个们来这为后从岁种头于将两里进远击卫万处满点众边师张长门开关问员阳阴云书几话铁号专业轻压环题颜镜区斗创标志划会戏网际声气态网杂浅浓暗余湿黄绿蓝红写临团乡楼厅柜灯废厂馆护残独带胶鲜尔历罗陆属乐传丽贵壮结线净';
|
|
547
|
+
const ZH_FOLD = new Map([...ZH_T].map((ch, i) => [ch, ZH_S[i]]));
|
|
548
|
+
const ZH_HANT = new Map([...ZH_S].map((ch, i) => [ch, [...ZH_T][i]]));
|
|
549
|
+
/** True when the text is written in 繁體 (it changes under folding). */
|
|
550
|
+
export function isHant(text) {
|
|
551
|
+
const t = String(text ?? '');
|
|
552
|
+
return [...t].some((ch) => ZH_FOLD.has(ch));
|
|
553
|
+
}
|
|
554
|
+
/** Simplified → traditional for the engine's own fixed phrases (not a general converter). */
|
|
555
|
+
export function hantPhrase(text) {
|
|
556
|
+
return [...String(text ?? '').replace(/干净/g, '乾淨')].map((ch) => ZH_HANT.get(ch) || ch).join('');
|
|
557
|
+
}
|
|
558
|
+
export function foldZh(text) {
|
|
559
|
+
return [...String(text ?? '')].map((ch) => ZH_FOLD.get(ch) || ch).join('').toLowerCase();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const ZH_ETHNICITY_RE = /(东亚|亚洲|亚裔|汉族|华人|中国|日本|韩国|朝鲜|欧洲|白人|黑人|非洲|拉丁|中东|阿拉伯|南亚|东南亚|混血|蒙古|藏族|维吾尔|鲜卑|匈奴|胡人|西域|印度)/;
|
|
563
|
+
// whole era words only — a bare 明 / 清 / 元 would match 明亮 / 清晰 / 元素
|
|
564
|
+
const ZH_ERA_RE = /(年代|世纪|时期|当代|现代|古代|近代|公元|未来|中世纪|维多利亚|民国|春秋|战国|先秦|秦朝|秦代|秦末|西汉|东汉|汉朝|汉代|汉初|汉末|三国|西晋|东晋|晋朝|南北朝|北魏|北齐|北周|南朝|隋朝|隋代|隋末|唐朝|唐代|初唐|盛唐|晚唐|五代|宋朝|宋代|北宋|南宋|元朝|元代|明朝|明代|明末|清朝|清代|晚清)/;
|
|
565
|
+
|
|
566
|
+
// the checked part of a Chinese render phrase: up to its first comma, at most 8 characters
|
|
567
|
+
const renderKeyZh = (preset) => preset.renderZh.split(',')[0].slice(0, 8);
|
|
568
|
+
|
|
569
|
+
/** The rules the image-prompt gates apply, in the project's image-prompt language. */
|
|
570
|
+
export function imagePromptKit(params) {
|
|
571
|
+
const zh = params.imagePromptLang === 'zh';
|
|
572
|
+
const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
|
|
573
|
+
const has = (text, word) => (zh ? foldZh(text).includes(foldZh(word)) : String(text ?? '').toLowerCase().includes(String(word).toLowerCase()));
|
|
574
|
+
return {
|
|
575
|
+
zh,
|
|
576
|
+
label: zh ? 'Chinese' : 'English',
|
|
577
|
+
inLang: (text) => (zh ? CJK_RE.test(String(text ?? '')) : isEnglish(text)),
|
|
578
|
+
ethnicity: (text) => (zh ? ZH_ETHNICITY_RE.test(foldZh(text)) : ETHNICITY_RE.test(String(text ?? ''))),
|
|
579
|
+
era: (text) => (zh ? ZH_ERA_RE.test(foldZh(text)) : ERA_RE.test(String(text ?? ''))),
|
|
580
|
+
sheetRatio: (text) => /16\s*[::]\s*9/.test(String(text ?? '')),
|
|
581
|
+
// the style's render phrase — its first 30 characters in English, up to 8 before the first comma in Chinese
|
|
582
|
+
render: zh ? preset.renderZh : preset.render,
|
|
583
|
+
renderKey: zh ? renderKeyZh(preset) : preset.render.slice(0, 30),
|
|
584
|
+
hasRender: (text) => has(text, zh ? renderKeyZh(preset) : preset.render.slice(0, 30)),
|
|
585
|
+
framePhrase: zh ? preset.framePhraseZh : preset.framePhrase,
|
|
586
|
+
negativeMust: zh ? preset.negativeMustZh : preset.negativeMust,
|
|
587
|
+
negativeMustNot: zh ? preset.negativeMustNotZh : preset.negativeMustNot,
|
|
588
|
+
has,
|
|
589
|
+
bansPeople: (neg) => (zh ? /人/.test(foldZh(neg)) : /(people|person|figure|human|man|woman|crowd)/.test(String(neg).toLowerCase())),
|
|
590
|
+
bansHands: (neg) => (zh ? /手/.test(foldZh(neg)) : /(hand|finger)/.test(String(neg).toLowerCase())),
|
|
591
|
+
saysEmpty: (text) => (zh ? /(空场景|空无一人|没有人|没有任何人|无人|空景)/.test(foldZh(text)) : /(no people|empty|unpopulated|nobody|no one|no human)/i.test(String(text))),
|
|
592
|
+
whiteBackground: (text) => (zh ? /(纯白背景|白色背景|白底)/.test(foldZh(text)) : /white background/i.test(String(text))),
|
|
593
|
+
scalePhrase: (scale) => (zh ? PROP_SCALES_ZH[scale] : PROP_SCALES[scale]),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
514
597
|
/**
|
|
515
598
|
* The drama's lighting and composition, orthogonal to the render style (`params.style`). Each
|
|
516
599
|
* preset has a guide in reference/cinematography-<key>.md; the gates read only the fields below.
|
|
@@ -1167,7 +1250,7 @@ export function langTag(lang) {
|
|
|
1167
1250
|
export function forbiddenNames(project) {
|
|
1168
1251
|
const out = [];
|
|
1169
1252
|
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());
|
|
1253
|
+
for (const n of [c.name, ...(c.aliases || []), c.disguise?.name]) if (n && String(n).trim().length >= 2) out.push(String(n).trim());
|
|
1171
1254
|
}
|
|
1172
1255
|
return uniq(out);
|
|
1173
1256
|
}
|
|
@@ -1178,9 +1261,18 @@ export function containsName(text, name) {
|
|
|
1178
1261
|
return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text);
|
|
1179
1262
|
}
|
|
1180
1263
|
|
|
1264
|
+
function promptTokens(text) {
|
|
1265
|
+
const t = foldZh(text);
|
|
1266
|
+
const words = t.match(/[a-z0-9']+/g) || [];
|
|
1267
|
+
// Chinese has no spaces: compare character bigrams inside each run of CJK characters.
|
|
1268
|
+
const bigrams = [];
|
|
1269
|
+
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));
|
|
1270
|
+
return new Set([...words, ...bigrams]);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1181
1273
|
export function jaccard(a, b) {
|
|
1182
|
-
const wa =
|
|
1183
|
-
const wb =
|
|
1274
|
+
const wa = promptTokens(a);
|
|
1275
|
+
const wb = promptTokens(b);
|
|
1184
1276
|
if (wa.size < 8 || wb.size < 8) return 0;
|
|
1185
1277
|
let inter = 0;
|
|
1186
1278
|
for (const w of wa) if (wb.has(w)) inter += 1;
|
|
@@ -1201,6 +1293,18 @@ function sceneById(project, id) {
|
|
|
1201
1293
|
function characterById(project, id) {
|
|
1202
1294
|
return (project.characters || []).find((c) => c.id === id);
|
|
1203
1295
|
}
|
|
1296
|
+
/**
|
|
1297
|
+
* The name the drama's own documents use for a character in episode `ep`. A character with
|
|
1298
|
+
* `disguise: {name, revealEpisode}` goes by the disguise name before the reveal, so an episode's
|
|
1299
|
+
* script, polish sheet and segment sheet never spoil who they are. Without `ep` it is the real name.
|
|
1300
|
+
*/
|
|
1301
|
+
export function displayName(project, id, ep) {
|
|
1302
|
+
const c = characterById(project, id);
|
|
1303
|
+
if (!c) return id;
|
|
1304
|
+
const d = c.disguise;
|
|
1305
|
+
if (isObject(d) && String(d.name || '').trim() && ep != null && Number(ep) < Number(d.revealEpisode)) return String(d.name).trim();
|
|
1306
|
+
return c.name || id;
|
|
1307
|
+
}
|
|
1204
1308
|
function propById(project, id) {
|
|
1205
1309
|
return (project.props || []).find((p) => p.id === id);
|
|
1206
1310
|
}
|
|
@@ -1538,13 +1642,14 @@ export const GATES = [
|
|
|
1538
1642
|
else {
|
|
1539
1643
|
const [lo, hi] = FORMATS[params.format].minutes;
|
|
1540
1644
|
const m = Number(params.minutesPerEpisode);
|
|
1541
|
-
if (m && (m < lo || m > hi)) out.push(`params.minutesPerEpisode ${m} is outside the ${params.format} range ${lo}
|
|
1645
|
+
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
1646
|
}
|
|
1543
1647
|
if (!MARKETS.includes(params.market)) out.push(`params.market must be one of ${MARKETS.join(' / ')}`);
|
|
1544
1648
|
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
1649
|
if (!Object.hasOwn(COMPOSITION_STYLES, params.composition)) out.push(`params.composition must be one of ${Object.keys(COMPOSITION_STYLES).join(' / ')}`);
|
|
1546
1650
|
if (!Object.hasOwn(COMEDY_STYLES, params.comedy)) out.push(`params.comedy must be one of ${Object.keys(COMEDY_STYLES).join(' / ')}`);
|
|
1547
1651
|
if (!Object.hasOwn(CINEMATOGRAPHY_PRESETS, params.cinematography)) out.push(`params.cinematography must be one of ${Object.keys(CINEMATOGRAPHY_PRESETS).join(' / ')}`);
|
|
1652
|
+
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
1653
|
if (!/^\d+:\d+$/.test(String(params.ratio))) out.push('params.ratio must look like 9:16');
|
|
1549
1654
|
if (!project.title) out.push('title is required');
|
|
1550
1655
|
return out;
|
|
@@ -1577,6 +1682,13 @@ export const GATES = [
|
|
|
1577
1682
|
if (!c.role) out.push(`${c.id}: role is required`);
|
|
1578
1683
|
if (['lead', 'support'].includes(c.tier) && !c.arc) out.push(`${c.id}: ${c.tier} characters need an arc`);
|
|
1579
1684
|
if (!Array.isArray(c.from) || !c.from.length) out.push(`${c.id}: from[] must record where the character comes from (or ["原创"])`);
|
|
1685
|
+
if (c.disguise !== undefined) {
|
|
1686
|
+
const d = isObject(c.disguise) ? c.disguise : {};
|
|
1687
|
+
const rev = Number(d.revealEpisode);
|
|
1688
|
+
if (!String(d.name || '').trim()) out.push(`${c.id}: disguise.name is empty — the name this character goes by before the reveal`);
|
|
1689
|
+
else if (String(d.name).trim() === c.name) out.push(`${c.id}: disguise.name equals the real name`);
|
|
1690
|
+
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)`);
|
|
1691
|
+
}
|
|
1580
1692
|
}
|
|
1581
1693
|
if (count.lead < 1 || count.lead > t.maxLeads) out.push(`lead characters: ${count.lead}, must be 1–${t.maxLeads}`);
|
|
1582
1694
|
if (count.support > t.maxSupport) out.push(`support characters: ${count.support}, max ${t.maxSupport}`);
|
|
@@ -1703,15 +1815,20 @@ export const GATES = [
|
|
|
1703
1815
|
const phases = project.outline?.structure;
|
|
1704
1816
|
const n = Number(params.episodes) || 0;
|
|
1705
1817
|
if (!Array.isArray(phases) || !phases.length) return ['outline.structure is missing — three phases (golden / escalation / climax) with episode ranges'];
|
|
1706
|
-
|
|
1818
|
+
// Single-episode dramas: golden + climax both cover ep 1 (retention and payoff in the same 集).
|
|
1819
|
+
const expect = n === 1 ? ['golden', 'climax'] : n >= 3 ? PHASES : PHASES.filter((p) => p !== 'escalation');
|
|
1707
1820
|
const got = phases.map((p) => p.phase);
|
|
1708
1821
|
if (got.join(',') !== expect.join(',')) out.push(`outline.structure phases must be ${expect.join(' → ')} in that order (got ${got.join(', ') || 'none'})`);
|
|
1709
1822
|
let cursor = 1;
|
|
1710
1823
|
phases.forEach((p, i) => {
|
|
1711
1824
|
const [a, b] = Array.isArray(p.episodes) ? p.episodes.map(Number) : [NaN, NaN];
|
|
1712
1825
|
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
|
-
|
|
1826
|
+
if (n === 1) {
|
|
1827
|
+
if (!(a === 1 && b === 1)) out.push(`outline.structure[${i}] (${p.phase}): for a 1-episode drama both phases must be [1, 1]`);
|
|
1828
|
+
} else {
|
|
1829
|
+
if (a !== cursor) out.push(`outline.structure[${i}] (${p.phase}) starts at ${a}, expected ${cursor} — the phases must tile the episodes without gaps`);
|
|
1830
|
+
cursor = b + 1;
|
|
1831
|
+
}
|
|
1715
1832
|
for (const key of ['task', 'satisfactionPlan', 'hookPlan']) if (!String(p[key] || '').trim()) out.push(`outline.structure[${i}] (${p.phase}): ${key} is empty`);
|
|
1716
1833
|
if (p.phase === 'golden') {
|
|
1717
1834
|
// 黄金窗口 is the first ~20 % — and never less than the first three episodes of a long run.
|
|
@@ -1720,7 +1837,7 @@ export const GATES = [
|
|
|
1720
1837
|
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
1838
|
}
|
|
1722
1839
|
});
|
|
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}`);
|
|
1840
|
+
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
1841
|
return out;
|
|
1725
1842
|
}),
|
|
1726
1843
|
gate('guideline', 'outline', '创作指引: tone, dialogue, visuals, limits, ≥3 forbidden, 3–5 priorities', ({ project }) => {
|
|
@@ -1802,26 +1919,26 @@ export const GATES = [
|
|
|
1802
1919
|
}
|
|
1803
1920
|
return out;
|
|
1804
1921
|
}),
|
|
1805
|
-
gate('char-prompts', 'characters', 'image prompts English, no names, ethnicity + era stated', ({ project, params }) => {
|
|
1922
|
+
gate('char-prompts', 'characters', 'image prompts in params.imagePromptLang (English by default), no names, ethnicity + era stated', ({ project, params }) => {
|
|
1806
1923
|
const out = [];
|
|
1807
1924
|
const names = forbiddenNames(project);
|
|
1808
|
-
const
|
|
1925
|
+
const kit = imagePromptKit(params);
|
|
1809
1926
|
for (const c of project.characters || []) {
|
|
1810
1927
|
const img = c.image || {};
|
|
1811
1928
|
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
1812
1929
|
const text = String(img[key] || '');
|
|
1813
1930
|
if (!text.trim()) { out.push(`${c.id}: image.${key} is empty`); continue; }
|
|
1814
|
-
if (!
|
|
1931
|
+
if (!kit.inLang(text)) out.push(`${c.id}: image.${key} must be ${kit.label} (params.imagePromptLang = ${params.imagePromptLang})`);
|
|
1815
1932
|
for (const n of names) if (containsName(text, n)) out.push(`${c.id}: image.${key} contains the name "${n}" — describe, don't name`);
|
|
1816
1933
|
}
|
|
1817
1934
|
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
|
|
1935
|
+
if (img.prompt && !kit.ethnicity(img.prompt)) out.push(`${c.id}: image.prompt must state ethnicity / facial heritage${kit.zh ? '(如 东亚、汉族)' : ''}`);
|
|
1936
|
+
if (img.prompt && !kit.era(img.prompt)) out.push(`${c.id}: image.prompt must state the era${kit.zh ? '(如 当代、秦末汉初、唐代)' : ''}`);
|
|
1937
|
+
if (img.sheet && !kit.sheetRatio(img.sheet)) out.push(`${c.id}: image.sheet must specify the 16:9 canvas`);
|
|
1938
|
+
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' : ''})`);
|
|
1939
|
+
const neg = String(img.negativePrompt || '');
|
|
1940
|
+
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`);
|
|
1941
|
+
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
1942
|
}
|
|
1826
1943
|
return out;
|
|
1827
1944
|
}),
|
|
@@ -1829,7 +1946,13 @@ export const GATES = [
|
|
|
1829
1946
|
const out = [];
|
|
1830
1947
|
for (const c of project.characters || []) {
|
|
1831
1948
|
const v = c.voice || {};
|
|
1832
|
-
for (const key of ['timbre', 'pitch', 'pace', 'accent', 'emotion'])
|
|
1949
|
+
for (const key of ['timbre', 'pitch', 'pace', 'accent', 'emotion']) {
|
|
1950
|
+
const text = String(v[key] || '');
|
|
1951
|
+
if (!text.trim()) { out.push(`${c.id}: voice.${key} is empty`); continue; }
|
|
1952
|
+
// pasted verbatim beside the character's first line in every Chinese segment prompt, and into the voice-sample prompt
|
|
1953
|
+
const nm = forbiddenNames(project).find((n) => containsName(text, n));
|
|
1954
|
+
if (nm) out.push(`${c.id}: voice.${key} contains the name "${nm}" — it is pasted into prompts; say 对她 / 对他 or describe the person`);
|
|
1955
|
+
}
|
|
1833
1956
|
if (params.voiceSamples !== false) {
|
|
1834
1957
|
const sample = v.sample || {};
|
|
1835
1958
|
const line = String(sample.line || '').trim();
|
|
@@ -1842,6 +1965,10 @@ export const GATES = [
|
|
|
1842
1965
|
if (QUOTE_RE.test(line)) out.push(`${c.id}: voice.sample.line must not carry quote marks — the engine reads them`);
|
|
1843
1966
|
}
|
|
1844
1967
|
if (!direction) out.push(`${c.id}: voice.sample.direction is empty — say how it is acted; it becomes the parenthetical the model obeys`);
|
|
1968
|
+
else {
|
|
1969
|
+
const nm = forbiddenNames(project).find((n) => containsName(direction, n));
|
|
1970
|
+
if (nm) out.push(`${c.id}: voice.sample.direction contains the name "${nm}" — it goes into the voice-sample prompt`);
|
|
1971
|
+
}
|
|
1845
1972
|
}
|
|
1846
1973
|
// The compact form the video prompt puts beside every line this character speaks.
|
|
1847
1974
|
const vd = String(v.videoDirection || '').trim();
|
|
@@ -1858,6 +1985,8 @@ export const GATES = [
|
|
|
1858
1985
|
if (!isEnglish(prompt)) out.push(`${c.id}: voice.prompt must be English`);
|
|
1859
1986
|
if (prompt.length > 400) out.push(`${c.id}: voice.prompt is ${prompt.length} chars, max 400 — write a parameter string, not prose`);
|
|
1860
1987
|
if (QUOTED_LINE_RE.test(prompt)) out.push(`${c.id}: voice.prompt contains a quoted line — engines read it aloud`);
|
|
1988
|
+
const nm = forbiddenNames(project).find((n) => containsName(prompt, n));
|
|
1989
|
+
if (nm) out.push(`${c.id}: voice.prompt contains the name "${nm}"`);
|
|
1861
1990
|
}
|
|
1862
1991
|
}
|
|
1863
1992
|
return out;
|
|
@@ -2527,9 +2656,12 @@ export const GATES = [
|
|
|
2527
2656
|
return out;
|
|
2528
2657
|
}, { advisory: true }),
|
|
2529
2658
|
// ── art ───────────────────────────────────────────────────────────────
|
|
2530
|
-
gate('scene-design', 'art', 'scenes: intent, 3–5 anchors, ≥1
|
|
2659
|
+
gate('scene-design', 'art', 'scenes: intent, 3–5 anchors, ≥1 lighting state in params.imagePromptLang (in the cinematography preset)', ({ project, params }) => {
|
|
2531
2660
|
const out = [];
|
|
2532
|
-
const
|
|
2661
|
+
const kit = imagePromptKit(params);
|
|
2662
|
+
// The cinematography presets carry an English framePhrase only, so a Chinese
|
|
2663
|
+
// image prompt (imagePromptLang zh) has nothing to match — skip the check there.
|
|
2664
|
+
const look = kit.zh ? null : cinematographyOf(params);
|
|
2533
2665
|
for (const s of project.scenes || []) {
|
|
2534
2666
|
if (!String(s.summary || '').trim()) out.push(`${s.id}: summary (design intent) is empty`);
|
|
2535
2667
|
const anchors = s.anchors || [];
|
|
@@ -2539,7 +2671,7 @@ export const GATES = [
|
|
|
2539
2671
|
if (!lighting.length) out.push(`${s.id}: at least one lighting state is required`);
|
|
2540
2672
|
for (const l of lighting) {
|
|
2541
2673
|
if (!l.state || !l.prompt) out.push(`${s.id}: every lighting state needs state + prompt`);
|
|
2542
|
-
else if (!
|
|
2674
|
+
else if (!kit.inLang(l.prompt)) out.push(`${s.id}: lighting "${l.state}" prompt must be ${kit.label}`);
|
|
2543
2675
|
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
2676
|
}
|
|
2545
2677
|
if (s.variantOf) {
|
|
@@ -2549,28 +2681,29 @@ export const GATES = [
|
|
|
2549
2681
|
}
|
|
2550
2682
|
return out;
|
|
2551
2683
|
}),
|
|
2552
|
-
gate('scene-prompts', 'art', 'scene prompts
|
|
2684
|
+
gate('scene-prompts', 'art', 'scene prompts in params.imagePromptLang, empty of people, no names, style-consistent', ({ project, params }) => {
|
|
2553
2685
|
const out = [];
|
|
2554
2686
|
const names = forbiddenNames(project);
|
|
2555
|
-
const
|
|
2687
|
+
const kit = imagePromptKit(params);
|
|
2556
2688
|
for (const s of project.scenes || []) {
|
|
2557
2689
|
const img = s.image || {};
|
|
2558
2690
|
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
2559
2691
|
const text = String(img[key] || '');
|
|
2560
2692
|
if (!text.trim()) { out.push(`${s.id}: image.${key} is empty`); continue; }
|
|
2561
|
-
if (!
|
|
2693
|
+
if (!kit.inLang(text)) out.push(`${s.id}: image.${key} must be ${kit.label}`);
|
|
2562
2694
|
for (const n of names) if (containsName(text, n)) out.push(`${s.id}: image.${key} contains the character name "${n}"`);
|
|
2563
2695
|
}
|
|
2564
|
-
const neg = String(img.negativePrompt || '')
|
|
2565
|
-
if (neg &&
|
|
2566
|
-
if (img.prompt &&
|
|
2567
|
-
if (img.sheet && !img.sheet
|
|
2696
|
+
const neg = String(img.negativePrompt || '');
|
|
2697
|
+
if (neg && !kit.bansPeople(neg)) out.push(`${s.id}: negativePrompt must ban people — environments are a separate asset layer`);
|
|
2698
|
+
if (img.prompt && !kit.saysEmpty(img.prompt)) out.push(`${s.id}: image.prompt must say the scene is empty (${kit.zh ? '空场景,没有人' : 'no people'})`);
|
|
2699
|
+
if (img.sheet && !kit.hasRender(img.sheet)) out.push(`${s.id}: image.sheet must contain the ${params.style} render phrase "${kit.renderKey}…"`);
|
|
2568
2700
|
}
|
|
2569
2701
|
return out;
|
|
2570
2702
|
}),
|
|
2571
|
-
gate('prop-design', 'art', 'props: scale phrase, 3–5 anchors, ≥1 state, white background, no hands', ({ project }) => {
|
|
2703
|
+
gate('prop-design', 'art', 'props: scale phrase, 3–5 anchors, ≥1 state, white background, no hands', ({ project, params }) => {
|
|
2572
2704
|
const out = [];
|
|
2573
2705
|
const names = forbiddenNames(project);
|
|
2706
|
+
const kit = imagePromptKit(params);
|
|
2574
2707
|
for (const p of project.props || []) {
|
|
2575
2708
|
if (!PROP_SCALES[p.scale]) out.push(`${p.id}: scale must be one of ${Object.keys(PROP_SCALES).join(' / ')}`);
|
|
2576
2709
|
if (!String(p.summary || '').trim()) out.push(`${p.id}: summary (dramatic function) is empty`);
|
|
@@ -2580,24 +2713,24 @@ export const GATES = [
|
|
|
2580
2713
|
if (!states.length) out.push(`${p.id}: at least one state is required`);
|
|
2581
2714
|
for (const st of states) {
|
|
2582
2715
|
if (!st.state || !st.prompt) out.push(`${p.id}: every state needs state + prompt`);
|
|
2583
|
-
else if (!
|
|
2716
|
+
else if (!kit.inLang(st.prompt)) out.push(`${p.id}: state "${st.state}" prompt must be ${kit.label}`);
|
|
2584
2717
|
}
|
|
2585
2718
|
for (const id of p.relatedScenes || []) if (!sceneById(project, id)) out.push(`${p.id}: relatedScenes references unknown scene ${id}`);
|
|
2586
2719
|
const img = p.image || {};
|
|
2587
2720
|
for (const key of ['prompt', 'negativePrompt', 'sheet']) {
|
|
2588
2721
|
const text = String(img[key] || '');
|
|
2589
2722
|
if (!text.trim()) { out.push(`${p.id}: image.${key} is empty`); continue; }
|
|
2590
|
-
if (!
|
|
2723
|
+
if (!kit.inLang(text)) out.push(`${p.id}: image.${key} must be ${kit.label}`);
|
|
2591
2724
|
for (const n of names) if (containsName(text, n)) out.push(`${p.id}: image.${key} contains the character name "${n}"`);
|
|
2592
2725
|
}
|
|
2593
|
-
const phrase =
|
|
2726
|
+
const phrase = kit.scalePhrase(p.scale);
|
|
2594
2727
|
if (phrase) {
|
|
2595
|
-
for (const key of ['prompt', 'sheet']) if (img[key] && !img[key]
|
|
2728
|
+
for (const key of ['prompt', 'sheet']) if (img[key] && !kit.has(img[key], phrase)) out.push(`${p.id}: image.${key} must contain "${phrase}"`);
|
|
2596
2729
|
}
|
|
2597
|
-
const neg = String(img.negativePrompt || '')
|
|
2598
|
-
if (neg &&
|
|
2599
|
-
if (neg &&
|
|
2600
|
-
for (const key of ['prompt', 'sheet']) if (img[key] &&
|
|
2730
|
+
const neg = String(img.negativePrompt || '');
|
|
2731
|
+
if (neg && !kit.bansHands(neg)) out.push(`${p.id}: negativePrompt must ban hands — a hand holding the prop is the usual contamination`);
|
|
2732
|
+
if (neg && !(kit.zh ? kit.bansPeople(neg) : /(people|person|figure|human)/.test(neg.toLowerCase()))) out.push(`${p.id}: negativePrompt must ban people`);
|
|
2733
|
+
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
2734
|
}
|
|
2602
2735
|
return out;
|
|
2603
2736
|
}),
|
|
@@ -2609,7 +2742,7 @@ export const GATES = [
|
|
|
2609
2742
|
for (const [ep, e] of ctx.episodes) {
|
|
2610
2743
|
if (Number(e.ep) !== Number(ep)) out.push(`E${pad2(ep)}: ep field is ${e.ep}`);
|
|
2611
2744
|
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`);
|
|
2745
|
+
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
2746
|
if (!String(e.cliff || '').trim()) out.push(`E${pad2(ep)}: cliff is empty`);
|
|
2614
2747
|
if (!Array.isArray(e.beatsClaimed)) out.push(`E${pad2(ep)}: beatsClaimed must be an array`);
|
|
2615
2748
|
if (!Array.isArray(e.scenes) || !e.scenes.length) { out.push(`E${pad2(ep)}: scenes is empty`); continue; }
|
|
@@ -2625,9 +2758,11 @@ export const GATES = [
|
|
|
2625
2758
|
}
|
|
2626
2759
|
return out;
|
|
2627
2760
|
}),
|
|
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;
|
|
2761
|
+
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) => {
|
|
2762
|
+
const { project, params } = ctx;
|
|
2630
2763
|
const out = [];
|
|
2764
|
+
const names = forbiddenNames(project);
|
|
2765
|
+
const promptLang = videoModel(params).promptLang;
|
|
2631
2766
|
for (const [ep, e] of ctx.episodes) {
|
|
2632
2767
|
(e.scenes || []).forEach((sc, i) => {
|
|
2633
2768
|
const where = `E${pad2(ep)} scene ${i + 1}`;
|
|
@@ -2646,6 +2781,13 @@ export const GATES = [
|
|
|
2646
2781
|
if (!String(b.line || '').trim()) out.push(`${where} beat ${k + 1}: line is empty`);
|
|
2647
2782
|
if (nonWhitespace(b.line) > params.maxLineChars) out.push(`${where} beat ${k + 1}: line has ${nonWhitespace(b.line)} chars, max ${params.maxLineChars}`);
|
|
2648
2783
|
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)`);
|
|
2784
|
+
// speechDirection pastes the delivery beside the line in a Chinese (wan) prompt, and an English
|
|
2785
|
+
// delivery into any prompt — where a name fails the storyboard's no-names check much later.
|
|
2786
|
+
const delivery = String(b.delivery || '');
|
|
2787
|
+
if (delivery && beatDelivery(delivery, promptLang)) {
|
|
2788
|
+
const nm = names.find((n) => containsName(delivery, n));
|
|
2789
|
+
if (nm) out.push(`${where} beat ${k + 1}: delivery names "${nm}" — it goes into the video prompt beside the line; say 她 / 他 / 对方 or describe them`);
|
|
2790
|
+
}
|
|
2649
2791
|
}
|
|
2650
2792
|
});
|
|
2651
2793
|
if ((sc.flow || []).length && actions === 0) out.push(`${where}: no action beat — a scene of pure dialogue is a radio play`);
|
|
@@ -3095,6 +3237,75 @@ export const GATES = [
|
|
|
3095
3237
|
}
|
|
3096
3238
|
return out;
|
|
3097
3239
|
}),
|
|
3240
|
+
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) => {
|
|
3241
|
+
const out = [];
|
|
3242
|
+
const Kraw = Number(ctx.params.boundaryContrastCuts);
|
|
3243
|
+
const K = Number.isFinite(Kraw) && Kraw >= 1 ? Math.min(3, Math.floor(Kraw)) : 2;
|
|
3244
|
+
const isMatchCut = (c) => /match[\s-]?cut/i.test(String(c?.note || ''));
|
|
3245
|
+
const signature = (c) => `${c?.size || ''}|${c?.angle || ''}|${[...(c?.characters || [])].map(String).sort().join(',')}`;
|
|
3246
|
+
for (const [, e] of ctx.episodes) {
|
|
3247
|
+
const segs = e.storyboard?.segments || [];
|
|
3248
|
+
const byScene = new Map();
|
|
3249
|
+
for (const seg of segs) {
|
|
3250
|
+
const si = Number(seg.sceneIndex);
|
|
3251
|
+
if (!Number.isFinite(si)) continue;
|
|
3252
|
+
if (!byScene.has(si)) byScene.set(si, []);
|
|
3253
|
+
byScene.get(si).push(seg);
|
|
3254
|
+
}
|
|
3255
|
+
const sceneIds = [...byScene.keys()].sort((a, b) => a - b);
|
|
3256
|
+
for (let i = 0; i < sceneIds.length - 1; i++) {
|
|
3257
|
+
const aSegs = byScene.get(sceneIds[i]);
|
|
3258
|
+
const bSegs = byScene.get(sceneIds[i + 1]);
|
|
3259
|
+
const lastSeg = aSegs[aSegs.length - 1];
|
|
3260
|
+
const firstSeg = bSegs[0];
|
|
3261
|
+
const aCuts = lastSeg.cuts || [];
|
|
3262
|
+
const bCuts = firstSeg.cuts || [];
|
|
3263
|
+
if (!aCuts.length || !bCuts.length) continue;
|
|
3264
|
+
const tail = aCuts.slice(-K);
|
|
3265
|
+
const head = bCuts.slice(0, K);
|
|
3266
|
+
tail.forEach((tc, ti) => {
|
|
3267
|
+
head.forEach((hc, hi) => {
|
|
3268
|
+
if (isMatchCut(tc) || isMatchCut(hc)) return;
|
|
3269
|
+
if (signature(tc) !== signature(hc)) return;
|
|
3270
|
+
const aIdx = aCuts.length - tail.length + ti + 1;
|
|
3271
|
+
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`);
|
|
3272
|
+
});
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
return out;
|
|
3277
|
+
}),
|
|
3278
|
+
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) => {
|
|
3279
|
+
const out = [];
|
|
3280
|
+
const Kraw = Number(ctx.params.boundaryContrastCuts);
|
|
3281
|
+
const K = Number.isFinite(Kraw) && Kraw >= 1 ? Math.min(3, Math.floor(Kraw)) : 2;
|
|
3282
|
+
const isMatchCut = (c) => /match[\s-]?cut/i.test(String(c?.note || ''));
|
|
3283
|
+
const signature = (c) => `${c?.size || ''}|${c?.angle || ''}|${[...(c?.characters || [])].map(String).sort().join(',')}`;
|
|
3284
|
+
const eps = [...ctx.episodes.entries()].sort((a, b) => Number(a[0]) - Number(b[0]));
|
|
3285
|
+
for (let i = 0; i < eps.length - 1; i++) {
|
|
3286
|
+
const [epA, eA] = eps[i];
|
|
3287
|
+
const [epB, eB] = eps[i + 1];
|
|
3288
|
+
const segsA = eA.storyboard?.segments || [];
|
|
3289
|
+
const segsB = eB.storyboard?.segments || [];
|
|
3290
|
+
if (!segsA.length || !segsB.length) continue;
|
|
3291
|
+
const lastSeg = segsA[segsA.length - 1];
|
|
3292
|
+
const firstSeg = segsB[0];
|
|
3293
|
+
const aCuts = lastSeg.cuts || [];
|
|
3294
|
+
const bCuts = firstSeg.cuts || [];
|
|
3295
|
+
if (!aCuts.length || !bCuts.length) continue;
|
|
3296
|
+
const tail = aCuts.slice(-K);
|
|
3297
|
+
const head = bCuts.slice(0, K);
|
|
3298
|
+
tail.forEach((tc, ti) => {
|
|
3299
|
+
head.forEach((hc, hi) => {
|
|
3300
|
+
if (isMatchCut(tc) || isMatchCut(hc)) return;
|
|
3301
|
+
if (signature(tc) !== signature(hc)) return;
|
|
3302
|
+
const aIdx = aCuts.length - tail.length + ti + 1;
|
|
3303
|
+
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`);
|
|
3304
|
+
});
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
return out;
|
|
3308
|
+
}, { advisory: true }),
|
|
3098
3309
|
gate('framing-axis', 'storyboard', '角度 / 机位高度 / 轴线: three separate fields; a two-person segment declares its axis; reverse shots face opposite ways', (ctx) => {
|
|
3099
3310
|
const out = [];
|
|
3100
3311
|
for (const [, e] of ctx.episodes) {
|
|
@@ -3323,7 +3534,21 @@ export const GATES = [
|
|
|
3323
3534
|
}
|
|
3324
3535
|
const texts = shotTexts(prompt, mode.family, params.references);
|
|
3325
3536
|
const refs = segmentReferences(project, e, seg);
|
|
3326
|
-
const sheetOf = params.references === 'sheets'
|
|
3537
|
+
const sheetOf = params.references === 'sheets'
|
|
3538
|
+
? (() => {
|
|
3539
|
+
const m = new Map();
|
|
3540
|
+
for (const r of refs.filter((x) => x.kind === 'image')) {
|
|
3541
|
+
const who = r.faceOf || (r.asset === 'sheet' || r.asset === 'headshot' ? r.entity : null);
|
|
3542
|
+
if (who && !m.has(who)) m.set(who, r.label);
|
|
3543
|
+
}
|
|
3544
|
+
// Prefer explicit headshot / faceOf over later body sheets
|
|
3545
|
+
for (const r of refs.filter((x) => x.kind === 'image' && (x.asset === 'headshot' || x.faceOf))) {
|
|
3546
|
+
const who = r.faceOf || r.entity;
|
|
3547
|
+
m.set(who, r.label);
|
|
3548
|
+
}
|
|
3549
|
+
return m;
|
|
3550
|
+
})()
|
|
3551
|
+
: null;
|
|
3327
3552
|
(seg.cuts || []).forEach((c, k) => {
|
|
3328
3553
|
const text = texts.get(k + 1);
|
|
3329
3554
|
if (!text) return; // the dialect gate reports the missing shot
|
|
@@ -3923,7 +4148,7 @@ export function seedScript(project, ep) {
|
|
|
3923
4148
|
for (const id of outlineEp.characterIds || []) {
|
|
3924
4149
|
const c = characterById(project, id);
|
|
3925
4150
|
const sp = c?.bible?.speech || {};
|
|
3926
|
-
if (c) speech[id] = { name:
|
|
4151
|
+
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
4152
|
}
|
|
3928
4153
|
return {
|
|
3929
4154
|
ep: Number(ep),
|
|
@@ -3933,14 +4158,16 @@ export function seedScript(project, ep) {
|
|
|
3933
4158
|
task: isObject(outlineEp.task) ? { ...outlineEp.task } : { goal: '', obstacle: '', stake: '' },
|
|
3934
4159
|
change: outlineEp.change || '',
|
|
3935
4160
|
hookType: outlineEp.hookType || '',
|
|
3936
|
-
hook:
|
|
3937
|
-
cliff: outlineEp.
|
|
4161
|
+
hook: '',
|
|
4162
|
+
cliff: outlineEp.hook || '',
|
|
3938
4163
|
beatsClaimed: beats.map((b) => b.id),
|
|
3939
4164
|
hookBeat: [1, 1],
|
|
3940
4165
|
satisfactionBeat: null,
|
|
3941
4166
|
scenes: [],
|
|
3942
4167
|
seedNote: {
|
|
3943
4168
|
synopsis: outlineEp.synopsis || '',
|
|
4169
|
+
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`,
|
|
4170
|
+
suspense: outlineEp.suspense || '',
|
|
3944
4171
|
satisfaction: isObject(outlineEp.satisfaction) ? { ...outlineEp.satisfaction } : { type: '', action: '' },
|
|
3945
4172
|
register: params.register,
|
|
3946
4173
|
guidelinePriorities: asList(project.outline?.guideline?.priorities),
|
|
@@ -3953,7 +4180,7 @@ export function seedScript(project, ep) {
|
|
|
3953
4180
|
crowdPlan: outlineEp.crowdPlan || '',
|
|
3954
4181
|
warnings: outlineEp.warnings || [],
|
|
3955
4182
|
beats: beats.map((b) => ({ id: b.id, type: b.type, weight: b.weight || 'minor', setup: b.setup, payoff: b.payoff })),
|
|
3956
|
-
previousCliff: prev?.
|
|
4183
|
+
previousCliff: prev?.hook || '',
|
|
3957
4184
|
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
4185
|
},
|
|
3959
4186
|
storyboard: { segments: [] },
|
|
@@ -4034,6 +4261,50 @@ export function segmentPlan(project, episode) {
|
|
|
4034
4261
|
* Measured on Seed Audio 1.0: the parenthetical is obeyed, not spoken (a 12-character line ran
|
|
4035
4262
|
* 5.17 s plain and 4.88 s with the direction), so it costs nothing in the sample's length.
|
|
4036
4263
|
*/
|
|
4264
|
+
/**
|
|
4265
|
+
* The full prompt of one image node on the characters / scenes / props canvas, in the project's
|
|
4266
|
+
* image-prompt language: the headshot (1:1), the sheet (16:9; a character's references its headshot
|
|
4267
|
+
* as @image1), a scene's lighting variant and a prop's state (both reference the sheet as @image1).
|
|
4268
|
+
* The Operator pastes `prompt` and `negative` as they are — no hand-assembled prefixes.
|
|
4269
|
+
*/
|
|
4270
|
+
export function imagePrompt(project, id, asset) {
|
|
4271
|
+
const params = paramsOf(project);
|
|
4272
|
+
const kit = imagePromptKit(params);
|
|
4273
|
+
const zh = kit.zh;
|
|
4274
|
+
const c = characterById(project, id), sc = sceneById(project, id), pr = propById(project, id);
|
|
4275
|
+
const entity = c || sc || pr;
|
|
4276
|
+
if (!entity) throw new Error(`no character / scene / prop ${id}`);
|
|
4277
|
+
const img = entity.image || {};
|
|
4278
|
+
// the fixed phrases follow the script the prompts are written in (繁體 or 简体)
|
|
4279
|
+
const hant = zh && isHant(`${img.prompt || ''}${img.sheet || ''}`);
|
|
4280
|
+
const t = (phrase) => (hant ? hantPhrase(phrase) : phrase);
|
|
4281
|
+
const out = (prompt, ratio, from) => ({ entity: id, asset, lang: params.imagePromptLang, prompt, negative: String(img.negativePrompt || ''), ratio, from });
|
|
4282
|
+
const kind = asset || 'sheet';
|
|
4283
|
+
if (c) {
|
|
4284
|
+
if (kind === 'headshot') return out(zh ? `${img.prompt}${t(',正面干净人像,纯色中性背景')}` : `${img.prompt}, clean front-facing portrait, plain neutral background`, '1:1', null);
|
|
4285
|
+
if (kind === 'sheet') return out(`${zh ? t('@image1 是必须完全一致的脸——五官与发型相同。') : '@image1 is the face to match exactly — same features, same hairstyle. '}${img.sheet}`, '16:9', 'headshot');
|
|
4286
|
+
throw new Error(`a character has --asset headshot | sheet, not ${kind}`);
|
|
4287
|
+
}
|
|
4288
|
+
if (kind === 'sheet') return out(String(img.sheet || ''), '16:9', null);
|
|
4289
|
+
if (sc && kind.startsWith('lighting:')) {
|
|
4290
|
+
const state = kind.slice('lighting:'.length);
|
|
4291
|
+
const l = (sc.lighting || []).find((x) => x.state === state);
|
|
4292
|
+
if (!l) throw new Error(`${id} has no lighting state "${state}" (${(sc.lighting || []).map((x) => x.state).join(' / ')})`);
|
|
4293
|
+
return out(zh
|
|
4294
|
+
? `${t('@image1 是同一个空间;结构、材质与磨损完全一致,只改变光线:')}${l.prompt}${t(`。空场景,没有任何人,${kit.framePhrase}`)}`
|
|
4295
|
+
: `@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');
|
|
4296
|
+
}
|
|
4297
|
+
if (pr && kind.startsWith('state:')) {
|
|
4298
|
+
const state = kind.slice('state:'.length);
|
|
4299
|
+
const st = (pr.states || []).find((x) => x.state === state);
|
|
4300
|
+
if (!st) throw new Error(`${id} has no state "${state}" (${(pr.states || []).map((x) => x.state).join(' / ')})`);
|
|
4301
|
+
return out(zh
|
|
4302
|
+
? `${t('@image1 是要完全还原的同一件道具。')}${st.prompt}${t('。纯白背景,没有人,没有手')}`
|
|
4303
|
+
: `@image1 is the prop to reproduce exactly. ${st.prompt}, pure white background, no people, no hands`, '16:9', 'sheet');
|
|
4304
|
+
}
|
|
4305
|
+
throw new Error(sc ? `a scene has --asset sheet | lighting:<state>` : `a prop has --asset sheet | state:<state>`);
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4037
4308
|
export function voiceSamplePrompt(character, params = {}) {
|
|
4038
4309
|
const v = character?.voice || {};
|
|
4039
4310
|
const pf = character?.profile || {};
|
|
@@ -4068,14 +4339,29 @@ export function voiceSampleSeconds(character, params) {
|
|
|
4068
4339
|
export function segmentReferences(project, episode, seg) {
|
|
4069
4340
|
const scene = (episode.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
4070
4341
|
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
4342
|
const params = paramsOf(project);
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4343
|
+
const refs = [];
|
|
4344
|
+
// Optional per-segment image ref plan (Director Strategy B etc.) — pictures only; voices/video still appended below.
|
|
4345
|
+
if (Array.isArray(seg.refOverride) && seg.refOverride.length) {
|
|
4346
|
+
for (const r of seg.refOverride) {
|
|
4347
|
+
refs.push({
|
|
4348
|
+
entity: r.entity,
|
|
4349
|
+
asset: r.asset || 'sheet',
|
|
4350
|
+
why: r.why || `${r.entity}:${r.asset || 'sheet'}`,
|
|
4351
|
+
use: r.use || 'a reference image for this segment',
|
|
4352
|
+
zh: r.zh || '',
|
|
4353
|
+
kind: 'image',
|
|
4354
|
+
faceOf: r.faceOf || null,
|
|
4355
|
+
});
|
|
4356
|
+
}
|
|
4357
|
+
} else {
|
|
4358
|
+
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: '环境,严格还原布局、材质与旧痕' });
|
|
4359
|
+
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: '同一场景的光线状态' });
|
|
4360
|
+
const seen = new Set();
|
|
4361
|
+
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: '人物三视图,严格还原面容、发型与服装' }); }
|
|
4362
|
+
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: '需要严格还原的道具' }); }
|
|
4363
|
+
refs.forEach((r) => (r.kind = 'image'));
|
|
4364
|
+
}
|
|
4079
4365
|
// The clip before this one, when the project asks for it: the cheapest continuity there is — the
|
|
4080
4366
|
// model sees what the last generation actually produced, not a description of it.
|
|
4081
4367
|
if (params.videoReferences === 'previous') {
|
|
@@ -4111,7 +4397,19 @@ export function segmentReferences(project, episode, seg) {
|
|
|
4111
4397
|
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
4398
|
}
|
|
4113
4399
|
const sheetOf = params.references === 'sheets'
|
|
4114
|
-
?
|
|
4400
|
+
? (() => {
|
|
4401
|
+
const m = new Map();
|
|
4402
|
+
for (const r of refs.filter((x) => x.kind === 'image')) {
|
|
4403
|
+
const who = r.faceOf || (r.asset === 'sheet' || r.asset === 'headshot' ? r.entity : null);
|
|
4404
|
+
if (who && !m.has(who)) m.set(who, r.label);
|
|
4405
|
+
}
|
|
4406
|
+
// Prefer explicit headshot / faceOf over later body sheets
|
|
4407
|
+
for (const r of refs.filter((x) => x.kind === 'image' && (x.asset === 'headshot' || x.faceOf))) {
|
|
4408
|
+
const who = r.faceOf || r.entity;
|
|
4409
|
+
m.set(who, r.label);
|
|
4410
|
+
}
|
|
4411
|
+
return m;
|
|
4412
|
+
})()
|
|
4115
4413
|
: new Map();
|
|
4116
4414
|
for (const r of refs) {
|
|
4117
4415
|
if (r.kind !== 'audio') continue;
|
|
@@ -4172,7 +4470,11 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4172
4470
|
described.add(l.speaker);
|
|
4173
4471
|
// A line is attributed by the reference that carries the voice — that is what the model has to
|
|
4174
4472
|
// read it in. The sheet label rides along so the picture and the voice are tied to one person.
|
|
4175
|
-
|
|
4473
|
+
// VO has no character / no @audio: never fall through identityOf(null) → "the person in this shot"
|
|
4474
|
+
// (that wrongly lip-syncs the line onto whoever is on camera).
|
|
4475
|
+
const who = l.speaker === 'VO'
|
|
4476
|
+
? 'an off-screen booth-edge friend voiceover'
|
|
4477
|
+
: ([voice, sheet].filter(Boolean).join(' / ') || identityOf(ch));
|
|
4176
4478
|
return {
|
|
4177
4479
|
...l,
|
|
4178
4480
|
cut: k + 1,
|
|
@@ -4248,7 +4550,14 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4248
4550
|
} else if (params.references === 'sheets') {
|
|
4249
4551
|
const legend = allRefs.map((r) => `${r.label} is ${r.use}.`);
|
|
4250
4552
|
const shots = cuts.map((c, k) => {
|
|
4251
|
-
const talk = dialogue[k].map((l) =>
|
|
4553
|
+
const talk = dialogue[k].map((l) => {
|
|
4554
|
+
const base = `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`;
|
|
4555
|
+
// VO is off-screen: keep every on-screen mouth closed so the cheer never lands on the visible face.
|
|
4556
|
+
if (l.speaker !== 'VO') return base;
|
|
4557
|
+
const others = othersOnScreen(c, l.speaker);
|
|
4558
|
+
if (!others.length) return base;
|
|
4559
|
+
return `${base} while ${others.join(' and ')} ${others.length > 1 ? 'do' : 'does'} not speak, ${LIPS_CLOSED}`;
|
|
4560
|
+
}).join(' ');
|
|
4252
4561
|
// The frame already carries the lens, the light and the foreground — do not repeat them.
|
|
4253
4562
|
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();
|
|
4254
4563
|
});
|
|
@@ -4259,7 +4568,13 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4259
4568
|
const shotOf = (c) => `${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}; ${lightPhrase(c)}${foregroundPhrase(c)}`;
|
|
4260
4569
|
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
4570
|
const script = cuts.map((c, k) => {
|
|
4262
|
-
const talk = dialogue[k].map((l) =>
|
|
4571
|
+
const talk = dialogue[k].map((l) => {
|
|
4572
|
+
const base = `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`;
|
|
4573
|
+
if (l.speaker !== 'VO') return base;
|
|
4574
|
+
const others = othersOnScreen(c, l.speaker);
|
|
4575
|
+
if (!others.length) return base;
|
|
4576
|
+
return `${base} while ${others.join(' and ')} ${others.length > 1 ? 'do' : 'does'} not speak, ${LIPS_CLOSED}`;
|
|
4577
|
+
}).join(' ');
|
|
4263
4578
|
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
4579
|
});
|
|
4265
4580
|
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `from @image${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
|
|
@@ -4448,7 +4763,7 @@ export function renderDialogue(project, episode) {
|
|
|
4448
4763
|
if (b.line === undefined) return;
|
|
4449
4764
|
const ch = characterById(project, b.speaker);
|
|
4450
4765
|
const sp = ch?.bible?.speech || {};
|
|
4451
|
-
const who = b.speaker === 'VO' ? 'VO' :
|
|
4766
|
+
const who = b.speaker === 'VO' ? 'VO' : displayName(project, b.speaker, episode.ep);
|
|
4452
4767
|
const hint = [sp.rhythm, sp.silence ? `沉默:${sp.silence}` : '', (sp.forbiddenWords || []).length ? `禁用:${sp.forbiddenWords.join('、')}` : ''].filter(Boolean).join(';');
|
|
4453
4768
|
rows.push([`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`, '', '', '', hint]);
|
|
4454
4769
|
});
|
|
@@ -4543,12 +4858,12 @@ export function renderScript(project, episode) {
|
|
|
4543
4858
|
const satRef = Array.isArray(episode.satisfactionBeat) ? episode.satisfactionBeat.map(Number) : null;
|
|
4544
4859
|
(episode.scenes || []).forEach((sc, i) => {
|
|
4545
4860
|
const scene = sceneById(project, sc.sceneId);
|
|
4546
|
-
lines.push(`## ${i + 1}. ${scene?.name || sc.sceneId}${sc.lighting ? ` · ${sc.lighting}` : ''}`, '', `人物:${(sc.characters || []).map((id) =>
|
|
4861
|
+
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
4862
|
(sc.flow || []).forEach((b, k) => {
|
|
4548
4863
|
const o = at(i + 1, k + 1);
|
|
4549
4864
|
const span = o ? `(${Math.round(o.start)}-${Math.round(o.end)}秒)` : '';
|
|
4550
4865
|
if (b.line !== undefined) {
|
|
4551
|
-
const who = b.speaker === 'VO' ? 'VO' :
|
|
4866
|
+
const who = b.speaker === 'VO' ? 'VO' : displayName(project, b.speaker, episode.ep);
|
|
4552
4867
|
lines.push(`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`);
|
|
4553
4868
|
} else lines.push(`△${span}${b.action}${b.turn ? ' ⟲' : ''}`);
|
|
4554
4869
|
if (satRef && satRef[0] === i + 1 && satRef[1] === k + 1) lines.push('', `【爽点标记】${sat.action || sat.type || ''}`);
|
|
@@ -4569,7 +4884,7 @@ export function renderSegment(project, episode, segmentId) {
|
|
|
4569
4884
|
const cuts = seg.cuts || [];
|
|
4570
4885
|
const starts = cutStarts(cuts);
|
|
4571
4886
|
const total = cuts.reduce((s, c) => s + (Number(c.seconds) || 0), 0);
|
|
4572
|
-
const nameOf = (id) =>
|
|
4887
|
+
const nameOf = (id) => displayName(project, id, episode.ep);
|
|
4573
4888
|
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
4889
|
const kit = params.camera || DEFAULT_PARAMS.camera;
|
|
4575
4890
|
lines.push(`镜头:${kit.body} + ${kit.lens}`, '');
|
|
@@ -4782,6 +5097,7 @@ export function initProject(opts) {
|
|
|
4782
5097
|
videoDuration: opts.duration || (mode.autoDuration ? DEFAULT_PARAMS.videoDuration : 'fixed'),
|
|
4783
5098
|
lookPhrase: opts.look || DEFAULT_PARAMS.lookPhrase,
|
|
4784
5099
|
style: opts.style || DEFAULT_PARAMS.style,
|
|
5100
|
+
imagePromptLang: opts.imageLang || DEFAULT_PARAMS.imagePromptLang,
|
|
4785
5101
|
// Picked from the genre unless named; `--cinematography none` opts out.
|
|
4786
5102
|
cinematography: cine,
|
|
4787
5103
|
// Named, or read from the genre only when it names an action style outright.
|
|
@@ -4919,6 +5235,16 @@ function main(argv) {
|
|
|
4919
5235
|
return 0;
|
|
4920
5236
|
}
|
|
4921
5237
|
|
|
5238
|
+
function installDashboard(projectDir, opts = {}) {
|
|
5239
|
+
const output = resolve(opts.output || join(projectDir, 'dashboard.html'));
|
|
5240
|
+
const source = join(dirname(fileURLToPath(import.meta.url)), '..', 'dashboard.html');
|
|
5241
|
+
if (!existsSync(source)) throw new Error(`static dashboard missing: ${source}`);
|
|
5242
|
+
if (existsSync(output) && !opts.force) return { output, copied: false };
|
|
5243
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
5244
|
+
copyFileSync(source, output);
|
|
5245
|
+
return { output, copied: true };
|
|
5246
|
+
}
|
|
5247
|
+
|
|
4922
5248
|
if (cmd === 'init') {
|
|
4923
5249
|
const dir = pos[0];
|
|
4924
5250
|
if (!dir) throw new Error('usage: init <dir> --title … --episodes N --minutes M --genre G');
|
|
@@ -4927,14 +5253,17 @@ function main(argv) {
|
|
|
4927
5253
|
adapt: flag(rest, '--adapt'), ratio: flag(rest, '--ratio'), video: flag(rest, '--video'), style: flag(rest, '--style'), lang: flag(rest, '--lang'),
|
|
4928
5254
|
resolution: flag(rest, '--resolution'), accent: flag(rest, '--accent'),
|
|
4929
5255
|
references: flag(rest, '--references'), duration: flag(rest, '--duration'), look: flag(rest, '--look'),
|
|
4930
|
-
register: flag(rest, '--register'),
|
|
5256
|
+
register: flag(rest, '--register'), imageLang: flag(rest, '--image-lang'),
|
|
5257
|
+
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
5258
|
source: flag(rest, '--source'), sourcePath: flag(rest, '--source-path'),
|
|
4932
5259
|
});
|
|
4933
5260
|
const paths = projectPaths(dir);
|
|
4934
5261
|
if (existsSync(paths.project) && !flag(rest, '--force')) throw new Error(`${paths.project} exists (pass --force to overwrite)`);
|
|
4935
5262
|
writeJsonFile(paths.project, project);
|
|
4936
5263
|
mkdirSync(paths.episodes, { recursive: true });
|
|
5264
|
+
const dashboard = installDashboard(paths.dir);
|
|
4937
5265
|
console.log(`✓ ${paths.project}`);
|
|
5266
|
+
console.log(`✓ ${dashboard.output}`);
|
|
4938
5267
|
return 0;
|
|
4939
5268
|
}
|
|
4940
5269
|
|
|
@@ -4977,22 +5306,12 @@ function main(argv) {
|
|
|
4977
5306
|
|
|
4978
5307
|
if (cmd === 'dashboard') {
|
|
4979
5308
|
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,
|
|
5309
|
+
const outputFlag = flag(rest, '--output');
|
|
5310
|
+
const installed = installDashboard(loaded.paths.dir, {
|
|
5311
|
+
output: typeof outputFlag === 'string' ? outputFlag : undefined,
|
|
5312
|
+
force: Boolean(flag(rest, '--force')),
|
|
4992
5313
|
});
|
|
4993
|
-
|
|
4994
|
-
writeFileSync(output, html, 'utf8');
|
|
4995
|
-
console.log(`✓ ${output}`);
|
|
5314
|
+
console.log(installed.copied ? `✓ ${installed.output}` : `✓ ${installed.output} (already beside project.json)`);
|
|
4996
5315
|
return 0;
|
|
4997
5316
|
}
|
|
4998
5317
|
|
|
@@ -5060,6 +5379,15 @@ function main(argv) {
|
|
|
5060
5379
|
return 0;
|
|
5061
5380
|
}
|
|
5062
5381
|
|
|
5382
|
+
if (cmd === 'prompt' && pos[1] === 'image') {
|
|
5383
|
+
const { project } = loadProject(input);
|
|
5384
|
+
if (!pos[2]) throw new Error('usage: prompt <project> image C01|S01|P01 [--asset headshot|sheet|lighting:<state>|state:<state>] [--json]');
|
|
5385
|
+
const out = imagePrompt(project, pos[2], flag(rest, '--asset') || 'sheet');
|
|
5386
|
+
if (flag(rest, '--json')) console.log(JSON.stringify(out, null, 2));
|
|
5387
|
+
else console.log(out.prompt);
|
|
5388
|
+
return 0;
|
|
5389
|
+
}
|
|
5390
|
+
|
|
5063
5391
|
if (cmd === 'prompt' && pos[1] === 'voice') {
|
|
5064
5392
|
const { project } = loadProject(input);
|
|
5065
5393
|
const c = characterById(project, pos[2]);
|
|
@@ -5130,7 +5458,7 @@ function main(argv) {
|
|
|
5130
5458
|
throw new Error(`unknown command "${cmd}" — run with --help`);
|
|
5131
5459
|
}
|
|
5132
5460
|
|
|
5133
|
-
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
5461
|
+
const isMain = process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url));
|
|
5134
5462
|
if (isMain) {
|
|
5135
5463
|
try {
|
|
5136
5464
|
process.exitCode = main(process.argv.slice(2));
|