@wji.ai/cli 0.8.0 → 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.
@@ -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,14 +27,18 @@
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
35
+ // dashboard <project> [--output dashboard.html] [--force]
36
+ // copy the static dashboard next to the project; it loads project.json
32
37
  // manifest <project> [--stage …] [--ep N] unbound WJI objects, in execution order
33
38
  // bind <project> [--ep N] <path> <value> write a WJI id back (path segments may be entity ids)
34
39
  // slug <name>
35
40
 
36
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
41
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
37
42
  import { basename, dirname, join, resolve } from 'node:path';
38
43
  import { fileURLToPath } from 'node:url';
39
44
 
@@ -85,6 +90,9 @@ export const DEFAULT_PARAMS = Object.freeze({
85
90
  format: 'short',
86
91
  // Where it is released (MARKETS): mainland holds the supernatural to a rational explanation.
87
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',
88
96
  minCutSeconds: 2,
89
97
  maxCutSeconds: 5,
90
98
  maxOnScreen: 3,
@@ -106,6 +114,8 @@ export const DEFAULT_PARAMS = Object.freeze({
106
114
  audioTrack: 'post',
107
115
  // Frame rate the post spotting sheet writes SMPTE timecode at (conform it to the edit's real fps).
108
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,
109
119
  thresholds: { maxLeads: 5, maxSupport: 10, maxFunctional: 10, maxBeatGap: 3, maxProps: 8 },
110
120
  });
111
121
 
@@ -201,6 +211,7 @@ export const DURATION_MODES = ['fixed', 'auto'];
201
211
  * move carry over instead of being re-invented per clip. `none` leaves the sheets to do that alone.
202
212
  */
203
213
  export const VIDEO_REFERENCE_MODES = ['none', 'previous'];
214
+ export const IMAGE_PROMPT_LANGS = ['en', 'zh'];
204
215
 
205
216
  // ── screenplay craft (from the w-short-drama-editor / character-creator / dialogue-editor method) ──
206
217
 
@@ -283,6 +294,7 @@ export const NO_SUBTITLES = { en: 'no subtitles, no captions, no on-screen dialo
283
294
  export const LIPS_CLOSED = 'lips remain completely closed';
284
295
  export const WARNINGS = ['雨戏', '肢体接触', '人群', '手部特写'];
285
296
  export const PROP_SCALES = { handheld: 'handheld scale', tabletop: 'tabletop scale', furniture: 'furniture scale' };
297
+ export const PROP_SCALES_ZH = { handheld: '手持尺寸', tabletop: '桌面尺寸', furniture: '大件尺寸' };
286
298
  export const SIZES = {
287
299
  'extreme-wide': 'extreme wide shot',
288
300
  wide: 'wide shot',
@@ -469,6 +481,10 @@ export const STYLE_PRESETS = {
469
481
  negativeMustNot: ['photorealistic', '3d render'],
470
482
  negativeMust: [],
471
483
  tags: ['semi-realistic', 'painterly', 'cinematic'],
484
+ renderZh: '半写实角色插画,绘画质感,笔触柔和融合并带可见的笔刷肌理,解剖结构扎实',
485
+ framePhraseZh: '电影剧照',
486
+ negativeMustNotZh: ['照片级', '真人', '3d渲染'],
487
+ negativeMustZh: [],
472
488
  },
473
489
  photoreal: {
474
490
  label: 'photographic live-action',
@@ -478,6 +494,10 @@ export const STYLE_PRESETS = {
478
494
  negativeMustNot: ['photorealistic', 'film grain', 'skin texture'],
479
495
  negativeMust: ['illustration', 'anime', 'cartoon'],
480
496
  tags: ['photoreal', 'live action', 'cinematic'],
497
+ renderZh: '真人实拍参考摄影,皮肤可见毛孔与细绒毛,布料有真实织纹与磨损,中性电影调色',
498
+ framePhraseZh: '照片级写实电影剧照',
499
+ negativeMustNotZh: ['照片级', '胶片颗粒', '皮肤纹理'],
500
+ negativeMustZh: ['插画', '动漫', '卡通'],
481
501
  },
482
502
  soft3d: {
483
503
  label: 'soft clay-toy 3D animation',
@@ -487,6 +507,10 @@ export const STYLE_PRESETS = {
487
507
  negativeMustNot: ['3d render', 'cgi', 'stylized', 'clay', 'matte', 'toy', 'simplified'],
488
508
  negativeMust: ['photorealistic'],
489
509
  tags: ['3d animation', 'clay render', 'soft toy style', 'kids tv'],
510
+ renderZh: '柔和哑光的黏土玩具风格三维渲染,表面光滑无毛孔,发型是一整块圆润的造型,五官极简,四肢圆胖,大头身比,柔和全局光',
511
+ framePhraseZh: '黏土动画电影剧照',
512
+ negativeMustNotZh: ['3d渲染', 'cg', '风格化', '黏土', '哑光', '玩具', '简化'],
513
+ negativeMustZh: ['照片级'],
490
514
  },
491
515
  disney3d: {
492
516
  label: 'Disney/Pixar-style 3D animation',
@@ -496,6 +520,10 @@ export const STYLE_PRESETS = {
496
520
  negativeMustNot: ['3d render', 'cgi', 'stylized', 'cartoon'],
497
521
  negativeMust: ['photorealistic'],
498
522
  tags: ['3d animation', 'disney pixar style', 'family', 'cinematic'],
523
+ renderZh: '迪士尼与皮克斯长片风格的三维动画,圆润讨喜的造型语言,大而传神的眼睛,次表面散射皮肤,温暖的电影主光与轮廓光',
524
+ framePhraseZh: '三维动画电影剧照',
525
+ negativeMustNotZh: ['3d渲染', 'cg', '风格化', '卡通'],
526
+ negativeMustZh: ['照片级'],
499
527
  },
500
528
  ghibli: {
501
529
  label: 'hand-painted anime',
@@ -505,9 +533,67 @@ export const STYLE_PRESETS = {
505
533
  negativeMustNot: [],
506
534
  negativeMust: ['photorealistic'],
507
535
  tags: ['anime', 'cel shading', 'hand-painted'],
536
+ renderZh: '日本经典动画电影风格的手绘赛璐璐动画,干净线稿,平涂色彩配柔和水彩背景',
537
+ framePhraseZh: '手绘动画电影剧照',
538
+ negativeMustNotZh: [],
539
+ negativeMustZh: ['照片级'],
508
540
  },
509
541
  };
510
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
+
511
597
  /**
512
598
  * The drama's lighting and composition, orthogonal to the render style (`params.style`). Each
513
599
  * preset has a guide in reference/cinematography-<key>.md; the gates read only the fields below.
@@ -1164,7 +1250,7 @@ export function langTag(lang) {
1164
1250
  export function forbiddenNames(project) {
1165
1251
  const out = [];
1166
1252
  for (const c of project.characters || []) {
1167
- 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());
1168
1254
  }
1169
1255
  return uniq(out);
1170
1256
  }
@@ -1175,9 +1261,18 @@ export function containsName(text, name) {
1175
1261
  return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text);
1176
1262
  }
1177
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
+
1178
1273
  export function jaccard(a, b) {
1179
- const wa = new Set(String(a).toLowerCase().match(/[a-z0-9']+/g) || []);
1180
- const wb = new Set(String(b).toLowerCase().match(/[a-z0-9']+/g) || []);
1274
+ const wa = promptTokens(a);
1275
+ const wb = promptTokens(b);
1181
1276
  if (wa.size < 8 || wb.size < 8) return 0;
1182
1277
  let inter = 0;
1183
1278
  for (const w of wa) if (wb.has(w)) inter += 1;
@@ -1198,6 +1293,18 @@ function sceneById(project, id) {
1198
1293
  function characterById(project, id) {
1199
1294
  return (project.characters || []).find((c) => c.id === id);
1200
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
+ }
1201
1308
  function propById(project, id) {
1202
1309
  return (project.props || []).find((p) => p.id === id);
1203
1310
  }
@@ -1535,13 +1642,14 @@ export const GATES = [
1535
1642
  else {
1536
1643
  const [lo, hi] = FORMATS[params.format].minutes;
1537
1644
  const m = Number(params.minutesPerEpisode);
1538
- if (m && (m < lo || m > hi)) out.push(`params.minutesPerEpisode ${m} is outside the ${params.format} range ${lo}–${hi} minutes — pick the format that fits (short 1–3, medium 4–15, movie parts 3–8)`);
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)`);
1539
1646
  }
1540
1647
  if (!MARKETS.includes(params.market)) out.push(`params.market must be one of ${MARKETS.join(' / ')}`);
1541
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(' / ')}`);
1542
1649
  if (!Object.hasOwn(COMPOSITION_STYLES, params.composition)) out.push(`params.composition must be one of ${Object.keys(COMPOSITION_STYLES).join(' / ')}`);
1543
1650
  if (!Object.hasOwn(COMEDY_STYLES, params.comedy)) out.push(`params.comedy must be one of ${Object.keys(COMEDY_STYLES).join(' / ')}`);
1544
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)`);
1545
1653
  if (!/^\d+:\d+$/.test(String(params.ratio))) out.push('params.ratio must look like 9:16');
1546
1654
  if (!project.title) out.push('title is required');
1547
1655
  return out;
@@ -1574,6 +1682,13 @@ export const GATES = [
1574
1682
  if (!c.role) out.push(`${c.id}: role is required`);
1575
1683
  if (['lead', 'support'].includes(c.tier) && !c.arc) out.push(`${c.id}: ${c.tier} characters need an arc`);
1576
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
+ }
1577
1692
  }
1578
1693
  if (count.lead < 1 || count.lead > t.maxLeads) out.push(`lead characters: ${count.lead}, must be 1–${t.maxLeads}`);
1579
1694
  if (count.support > t.maxSupport) out.push(`support characters: ${count.support}, max ${t.maxSupport}`);
@@ -1700,15 +1815,20 @@ export const GATES = [
1700
1815
  const phases = project.outline?.structure;
1701
1816
  const n = Number(params.episodes) || 0;
1702
1817
  if (!Array.isArray(phases) || !phases.length) return ['outline.structure is missing — three phases (golden / escalation / climax) with episode ranges'];
1703
- const expect = n >= 3 ? PHASES : PHASES.filter((p) => p !== 'escalation');
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');
1704
1820
  const got = phases.map((p) => p.phase);
1705
1821
  if (got.join(',') !== expect.join(',')) out.push(`outline.structure phases must be ${expect.join(' → ')} in that order (got ${got.join(', ') || 'none'})`);
1706
1822
  let cursor = 1;
1707
1823
  phases.forEach((p, i) => {
1708
1824
  const [a, b] = Array.isArray(p.episodes) ? p.episodes.map(Number) : [NaN, NaN];
1709
1825
  if (!(a >= 1 && b >= a && b <= n)) { out.push(`outline.structure[${i}] (${p.phase}): episodes must be [from, to] inside 1–${n}`); return; }
1710
- if (a !== cursor) out.push(`outline.structure[${i}] (${p.phase}) starts at ${a}, expected ${cursor} — the phases must tile the episodes without gaps`);
1711
- cursor = b + 1;
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
+ }
1712
1832
  for (const key of ['task', 'satisfactionPlan', 'hookPlan']) if (!String(p[key] || '').trim()) out.push(`outline.structure[${i}] (${p.phase}): ${key} is empty`);
1713
1833
  if (p.phase === 'golden') {
1714
1834
  // 黄金窗口 is the first ~20 % — and never less than the first three episodes of a long run.
@@ -1717,7 +1837,7 @@ export const GATES = [
1717
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`);
1718
1838
  }
1719
1839
  });
1720
- 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}`);
1721
1841
  return out;
1722
1842
  }),
1723
1843
  gate('guideline', 'outline', '创作指引: tone, dialogue, visuals, limits, ≥3 forbidden, 3–5 priorities', ({ project }) => {
@@ -1799,26 +1919,26 @@ export const GATES = [
1799
1919
  }
1800
1920
  return out;
1801
1921
  }),
1802
- 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 }) => {
1803
1923
  const out = [];
1804
1924
  const names = forbiddenNames(project);
1805
- const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
1925
+ const kit = imagePromptKit(params);
1806
1926
  for (const c of project.characters || []) {
1807
1927
  const img = c.image || {};
1808
1928
  for (const key of ['prompt', 'negativePrompt', 'sheet']) {
1809
1929
  const text = String(img[key] || '');
1810
1930
  if (!text.trim()) { out.push(`${c.id}: image.${key} is empty`); continue; }
1811
- if (!isEnglish(text)) out.push(`${c.id}: image.${key} must be English`);
1931
+ if (!kit.inLang(text)) out.push(`${c.id}: image.${key} must be ${kit.label} (params.imagePromptLang = ${params.imagePromptLang})`);
1812
1932
  for (const n of names) if (containsName(text, n)) out.push(`${c.id}: image.${key} contains the name "${n}" — describe, don't name`);
1813
1933
  }
1814
1934
  if (!Array.isArray(img.tags) || !img.tags.length) out.push(`${c.id}: image.tags is empty`);
1815
- if (img.prompt && !ETHNICITY_RE.test(img.prompt)) out.push(`${c.id}: image.prompt must state ethnicity / facial heritage`);
1816
- if (img.prompt && !ERA_RE.test(img.prompt)) out.push(`${c.id}: image.prompt must state the era`);
1817
- if (img.sheet && !/16:9/.test(img.sheet)) out.push(`${c.id}: image.sheet must specify the 16:9 canvas`);
1818
- if (img.sheet && !img.sheet.toLowerCase().includes(preset.render.slice(0, 30).toLowerCase())) out.push(`${c.id}: image.sheet must contain the ${params.style} render phrase (see STYLE_PRESETS)`);
1819
- const neg = String(img.negativePrompt || '').toLowerCase();
1820
- for (const word of preset.negativeMustNot) if (neg.includes(word)) out.push(`${c.id}: style ${params.style} must not ban "${word}" in the negative prompt`);
1821
- for (const word of preset.negativeMust) if (!neg.includes(word)) out.push(`${c.id}: style ${params.style} must ban "${word}" in the negative prompt`);
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`);
1822
1942
  }
1823
1943
  return out;
1824
1944
  }),
@@ -1826,7 +1946,13 @@ export const GATES = [
1826
1946
  const out = [];
1827
1947
  for (const c of project.characters || []) {
1828
1948
  const v = c.voice || {};
1829
- for (const key of ['timbre', 'pitch', 'pace', 'accent', 'emotion']) if (!String(v[key] || '').trim()) out.push(`${c.id}: voice.${key} is empty`);
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
+ }
1830
1956
  if (params.voiceSamples !== false) {
1831
1957
  const sample = v.sample || {};
1832
1958
  const line = String(sample.line || '').trim();
@@ -1839,6 +1965,10 @@ export const GATES = [
1839
1965
  if (QUOTE_RE.test(line)) out.push(`${c.id}: voice.sample.line must not carry quote marks — the engine reads them`);
1840
1966
  }
1841
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
+ }
1842
1972
  }
1843
1973
  // The compact form the video prompt puts beside every line this character speaks.
1844
1974
  const vd = String(v.videoDirection || '').trim();
@@ -1855,6 +1985,8 @@ export const GATES = [
1855
1985
  if (!isEnglish(prompt)) out.push(`${c.id}: voice.prompt must be English`);
1856
1986
  if (prompt.length > 400) out.push(`${c.id}: voice.prompt is ${prompt.length} chars, max 400 — write a parameter string, not prose`);
1857
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}"`);
1858
1990
  }
1859
1991
  }
1860
1992
  return out;
@@ -2524,9 +2656,12 @@ export const GATES = [
2524
2656
  return out;
2525
2657
  }, { advisory: true }),
2526
2658
  // ── art ───────────────────────────────────────────────────────────────
2527
- gate('scene-design', 'art', 'scenes: intent, 3–5 anchors, ≥1 English lighting state (in the cinematography preset)', ({ project, params }) => {
2659
+ gate('scene-design', 'art', 'scenes: intent, 3–5 anchors, ≥1 lighting state in params.imagePromptLang (in the cinematography preset)', ({ project, params }) => {
2528
2660
  const out = [];
2529
- const look = cinematographyOf(params);
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);
2530
2665
  for (const s of project.scenes || []) {
2531
2666
  if (!String(s.summary || '').trim()) out.push(`${s.id}: summary (design intent) is empty`);
2532
2667
  const anchors = s.anchors || [];
@@ -2536,7 +2671,7 @@ export const GATES = [
2536
2671
  if (!lighting.length) out.push(`${s.id}: at least one lighting state is required`);
2537
2672
  for (const l of lighting) {
2538
2673
  if (!l.state || !l.prompt) out.push(`${s.id}: every lighting state needs state + prompt`);
2539
- else if (!isEnglish(l.prompt)) out.push(`${s.id}: lighting "${l.state}" prompt must be English`);
2674
+ else if (!kit.inLang(l.prompt)) out.push(`${s.id}: lighting "${l.state}" prompt must be ${kit.label}`);
2540
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})`);
2541
2676
  }
2542
2677
  if (s.variantOf) {
@@ -2546,28 +2681,29 @@ export const GATES = [
2546
2681
  }
2547
2682
  return out;
2548
2683
  }),
2549
- gate('scene-prompts', 'art', 'scene prompts English, empty of people, no names, style-consistent', ({ project, params }) => {
2684
+ gate('scene-prompts', 'art', 'scene prompts in params.imagePromptLang, empty of people, no names, style-consistent', ({ project, params }) => {
2550
2685
  const out = [];
2551
2686
  const names = forbiddenNames(project);
2552
- const preset = STYLE_PRESETS[params.style] || STYLE_PRESETS.realistic;
2687
+ const kit = imagePromptKit(params);
2553
2688
  for (const s of project.scenes || []) {
2554
2689
  const img = s.image || {};
2555
2690
  for (const key of ['prompt', 'negativePrompt', 'sheet']) {
2556
2691
  const text = String(img[key] || '');
2557
2692
  if (!text.trim()) { out.push(`${s.id}: image.${key} is empty`); continue; }
2558
- if (!isEnglish(text)) out.push(`${s.id}: image.${key} must be English`);
2693
+ if (!kit.inLang(text)) out.push(`${s.id}: image.${key} must be ${kit.label}`);
2559
2694
  for (const n of names) if (containsName(text, n)) out.push(`${s.id}: image.${key} contains the character name "${n}"`);
2560
2695
  }
2561
- const neg = String(img.negativePrompt || '').toLowerCase();
2562
- if (neg && !/(people|person|figure|human|man|woman|crowd)/.test(neg)) out.push(`${s.id}: negativePrompt must ban people — environments are a separate asset layer`);
2563
- if (img.prompt && !/(no people|empty|unpopulated|nobody|no one|no human)/i.test(img.prompt)) out.push(`${s.id}: image.prompt must say the scene is empty (no people)`);
2564
- if (img.sheet && !img.sheet.toLowerCase().includes(preset.render.slice(0, 30).toLowerCase())) out.push(`${s.id}: image.sheet must contain the ${params.style} render phrase`);
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}…"`);
2565
2700
  }
2566
2701
  return out;
2567
2702
  }),
2568
- 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 }) => {
2569
2704
  const out = [];
2570
2705
  const names = forbiddenNames(project);
2706
+ const kit = imagePromptKit(params);
2571
2707
  for (const p of project.props || []) {
2572
2708
  if (!PROP_SCALES[p.scale]) out.push(`${p.id}: scale must be one of ${Object.keys(PROP_SCALES).join(' / ')}`);
2573
2709
  if (!String(p.summary || '').trim()) out.push(`${p.id}: summary (dramatic function) is empty`);
@@ -2577,24 +2713,24 @@ export const GATES = [
2577
2713
  if (!states.length) out.push(`${p.id}: at least one state is required`);
2578
2714
  for (const st of states) {
2579
2715
  if (!st.state || !st.prompt) out.push(`${p.id}: every state needs state + prompt`);
2580
- else if (!isEnglish(st.prompt)) out.push(`${p.id}: state "${st.state}" prompt must be English`);
2716
+ else if (!kit.inLang(st.prompt)) out.push(`${p.id}: state "${st.state}" prompt must be ${kit.label}`);
2581
2717
  }
2582
2718
  for (const id of p.relatedScenes || []) if (!sceneById(project, id)) out.push(`${p.id}: relatedScenes references unknown scene ${id}`);
2583
2719
  const img = p.image || {};
2584
2720
  for (const key of ['prompt', 'negativePrompt', 'sheet']) {
2585
2721
  const text = String(img[key] || '');
2586
2722
  if (!text.trim()) { out.push(`${p.id}: image.${key} is empty`); continue; }
2587
- if (!isEnglish(text)) out.push(`${p.id}: image.${key} must be English`);
2723
+ if (!kit.inLang(text)) out.push(`${p.id}: image.${key} must be ${kit.label}`);
2588
2724
  for (const n of names) if (containsName(text, n)) out.push(`${p.id}: image.${key} contains the character name "${n}"`);
2589
2725
  }
2590
- const phrase = PROP_SCALES[p.scale];
2726
+ const phrase = kit.scalePhrase(p.scale);
2591
2727
  if (phrase) {
2592
- for (const key of ['prompt', 'sheet']) if (img[key] && !img[key].toLowerCase().includes(phrase)) out.push(`${p.id}: image.${key} must contain "${phrase}"`);
2728
+ for (const key of ['prompt', 'sheet']) if (img[key] && !kit.has(img[key], phrase)) out.push(`${p.id}: image.${key} must contain "${phrase}"`);
2593
2729
  }
2594
- const neg = String(img.negativePrompt || '').toLowerCase();
2595
- if (neg && !/(hand|finger)/.test(neg)) out.push(`${p.id}: negativePrompt must ban hands — a hand holding the prop is the usual contamination`);
2596
- if (neg && !/(people|person|figure|human)/.test(neg)) out.push(`${p.id}: negativePrompt must ban people`);
2597
- for (const key of ['prompt', 'sheet']) if (img[key] && !/white background/i.test(img[key])) out.push(`${p.id}: image.${key} must specify a pure white background`);
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 ? '(纯白背景)' : ''}`);
2598
2734
  }
2599
2735
  return out;
2600
2736
  }),
@@ -2606,7 +2742,7 @@ export const GATES = [
2606
2742
  for (const [ep, e] of ctx.episodes) {
2607
2743
  if (Number(e.ep) !== Number(ep)) out.push(`E${pad2(ep)}: ep field is ${e.ep}`);
2608
2744
  if (!(Number(e.targetSeconds) > 0)) out.push(`E${pad2(ep)}: targetSeconds missing (run seed script)`);
2609
- 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)`);
2610
2746
  if (!String(e.cliff || '').trim()) out.push(`E${pad2(ep)}: cliff is empty`);
2611
2747
  if (!Array.isArray(e.beatsClaimed)) out.push(`E${pad2(ep)}: beatsClaimed must be an array`);
2612
2748
  if (!Array.isArray(e.scenes) || !e.scenes.length) { out.push(`E${pad2(ep)}: scenes is empty`); continue; }
@@ -2622,9 +2758,11 @@ export const GATES = [
2622
2758
  }
2623
2759
  return out;
2624
2760
  }),
2625
- gate('beat-flow', 'script', 'beats: action ⇄ line, one kind each, no quotes in actions, speakers legal, line ≤ N chars', (ctx) => {
2626
- 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;
2627
2763
  const out = [];
2764
+ const names = forbiddenNames(project);
2765
+ const promptLang = videoModel(params).promptLang;
2628
2766
  for (const [ep, e] of ctx.episodes) {
2629
2767
  (e.scenes || []).forEach((sc, i) => {
2630
2768
  const where = `E${pad2(ep)} scene ${i + 1}`;
@@ -2643,6 +2781,13 @@ export const GATES = [
2643
2781
  if (!String(b.line || '').trim()) out.push(`${where} beat ${k + 1}: line is empty`);
2644
2782
  if (nonWhitespace(b.line) > params.maxLineChars) out.push(`${where} beat ${k + 1}: line has ${nonWhitespace(b.line)} chars, max ${params.maxLineChars}`);
2645
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
+ }
2646
2791
  }
2647
2792
  });
2648
2793
  if ((sc.flow || []).length && actions === 0) out.push(`${where}: no action beat — a scene of pure dialogue is a radio play`);
@@ -3092,6 +3237,75 @@ export const GATES = [
3092
3237
  }
3093
3238
  return out;
3094
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 }),
3095
3309
  gate('framing-axis', 'storyboard', '角度 / 机位高度 / 轴线: three separate fields; a two-person segment declares its axis; reverse shots face opposite ways', (ctx) => {
3096
3310
  const out = [];
3097
3311
  for (const [, e] of ctx.episodes) {
@@ -3320,7 +3534,21 @@ export const GATES = [
3320
3534
  }
3321
3535
  const texts = shotTexts(prompt, mode.family, params.references);
3322
3536
  const refs = segmentReferences(project, e, seg);
3323
- const sheetOf = params.references === 'sheets' ? new Map(refs.filter((r) => r.kind === 'image' && r.asset === 'sheet').map((r) => [r.entity, r.label])) : null;
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;
3324
3552
  (seg.cuts || []).forEach((c, k) => {
3325
3553
  const text = texts.get(k + 1);
3326
3554
  if (!text) return; // the dialect gate reports the missing shot
@@ -3920,7 +4148,7 @@ export function seedScript(project, ep) {
3920
4148
  for (const id of outlineEp.characterIds || []) {
3921
4149
  const c = characterById(project, id);
3922
4150
  const sp = c?.bible?.speech || {};
3923
- if (c) speech[id] = { name: c.name, rhythm: sp.rhythm || '', silence: sp.silence || '', catchphrase: sp.catchphrase || '', forbiddenWords: sp.forbiddenWords || [], register: sp.register || '', voice: c.voice?.pace || '' };
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 || '' };
3924
4152
  }
3925
4153
  return {
3926
4154
  ep: Number(ep),
@@ -3930,14 +4158,16 @@ export function seedScript(project, ep) {
3930
4158
  task: isObject(outlineEp.task) ? { ...outlineEp.task } : { goal: '', obstacle: '', stake: '' },
3931
4159
  change: outlineEp.change || '',
3932
4160
  hookType: outlineEp.hookType || '',
3933
- hook: outlineEp.hook || '',
3934
- cliff: outlineEp.suspense || '',
4161
+ hook: '',
4162
+ cliff: outlineEp.hook || '',
3935
4163
  beatsClaimed: beats.map((b) => b.id),
3936
4164
  hookBeat: [1, 1],
3937
4165
  satisfactionBeat: null,
3938
4166
  scenes: [],
3939
4167
  seedNote: {
3940
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 || '',
3941
4171
  satisfaction: isObject(outlineEp.satisfaction) ? { ...outlineEp.satisfaction } : { type: '', action: '' },
3942
4172
  register: params.register,
3943
4173
  guidelinePriorities: asList(project.outline?.guideline?.priorities),
@@ -3950,7 +4180,7 @@ export function seedScript(project, ep) {
3950
4180
  crowdPlan: outlineEp.crowdPlan || '',
3951
4181
  warnings: outlineEp.warnings || [],
3952
4182
  beats: beats.map((b) => ({ id: b.id, type: b.type, weight: b.weight || 'minor', setup: b.setup, payoff: b.payoff })),
3953
- previousCliff: prev?.suspense || '',
4183
+ previousCliff: prev?.hook || '',
3954
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`,
3955
4185
  },
3956
4186
  storyboard: { segments: [] },
@@ -4031,6 +4261,50 @@ export function segmentPlan(project, episode) {
4031
4261
  * Measured on Seed Audio 1.0: the parenthetical is obeyed, not spoken (a 12-character line ran
4032
4262
  * 5.17 s plain and 4.88 s with the direction), so it costs nothing in the sample's length.
4033
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
+
4034
4308
  export function voiceSamplePrompt(character, params = {}) {
4035
4309
  const v = character?.voice || {};
4036
4310
  const pf = character?.profile || {};
@@ -4065,14 +4339,29 @@ export function voiceSampleSeconds(character, params) {
4065
4339
  export function segmentReferences(project, episode, seg) {
4066
4340
  const scene = (episode.scenes || [])[Number(seg.sceneIndex) - 1] || {};
4067
4341
  const sceneEntity = sceneById(project, scene.sceneId) || {};
4068
- const refs = [];
4069
- 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: '环境,严格还原布局、材质与旧痕' });
4070
- 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: '同一场景的光线状态' });
4071
- const seen = new Set();
4072
4342
  const params = paramsOf(project);
4073
- 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: '人物三视图,严格还原面容、发型与服装' }); }
4074
- 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: '需要严格还原的道具' }); }
4075
- refs.forEach((r) => (r.kind = 'image'));
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
+ }
4076
4365
  // The clip before this one, when the project asks for it: the cheapest continuity there is — the
4077
4366
  // model sees what the last generation actually produced, not a description of it.
4078
4367
  if (params.videoReferences === 'previous') {
@@ -4108,7 +4397,19 @@ export function segmentReferences(project, episode, seg) {
4108
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`;
4109
4398
  }
4110
4399
  const sheetOf = params.references === 'sheets'
4111
- ? new Map(refs.filter((r) => r.kind === 'image' && r.asset === 'sheet').map((r) => [r.entity, r.label]))
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
+ })()
4112
4413
  : new Map();
4113
4414
  for (const r of refs) {
4114
4415
  if (r.kind !== 'audio') continue;
@@ -4169,7 +4470,11 @@ export function promptSkeleton(project, episode, segmentId) {
4169
4470
  described.add(l.speaker);
4170
4471
  // A line is attributed by the reference that carries the voice — that is what the model has to
4171
4472
  // read it in. The sheet label rides along so the picture and the voice are tied to one person.
4172
- const who = [voice, sheet].filter(Boolean).join(' / ') || identityOf(ch);
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));
4173
4478
  return {
4174
4479
  ...l,
4175
4480
  cut: k + 1,
@@ -4245,7 +4550,14 @@ export function promptSkeleton(project, episode, segmentId) {
4245
4550
  } else if (params.references === 'sheets') {
4246
4551
  const legend = allRefs.map((r) => `${r.label} is ${r.use}.`);
4247
4552
  const shots = cuts.map((c, k) => {
4248
- const talk = dialogue[k].map((l) => `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`).join(' ');
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(' ');
4249
4561
  // The frame already carries the lens, the light and the foreground — do not repeat them.
4250
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();
4251
4563
  });
@@ -4256,7 +4568,13 @@ export function promptSkeleton(project, episode, segmentId) {
4256
4568
  const shotOf = (c) => `${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}; ${lightPhrase(c)}${foregroundPhrase(c)}`;
4257
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)}).`));
4258
4570
  const script = cuts.map((c, k) => {
4259
- const talk = dialogue[k].map((l) => `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`).join(' ');
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(' ');
4260
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();
4261
4579
  });
4262
4580
  const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `from @image${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
@@ -4445,7 +4763,7 @@ export function renderDialogue(project, episode) {
4445
4763
  if (b.line === undefined) return;
4446
4764
  const ch = characterById(project, b.speaker);
4447
4765
  const sp = ch?.bible?.speech || {};
4448
- const who = b.speaker === 'VO' ? 'VO' : ch?.name || b.speaker;
4766
+ const who = b.speaker === 'VO' ? 'VO' : displayName(project, b.speaker, episode.ep);
4449
4767
  const hint = [sp.rhythm, sp.silence ? `沉默:${sp.silence}` : '', (sp.forbiddenWords || []).length ? `禁用:${sp.forbiddenWords.join('、')}` : ''].filter(Boolean).join(';');
4450
4768
  rows.push([`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`, '', '', '', hint]);
4451
4769
  });
@@ -4540,12 +4858,12 @@ export function renderScript(project, episode) {
4540
4858
  const satRef = Array.isArray(episode.satisfactionBeat) ? episode.satisfactionBeat.map(Number) : null;
4541
4859
  (episode.scenes || []).forEach((sc, i) => {
4542
4860
  const scene = sceneById(project, sc.sceneId);
4543
- lines.push(`## ${i + 1}. ${scene?.name || sc.sceneId}${sc.lighting ? ` · ${sc.lighting}` : ''}`, '', `人物:${(sc.characters || []).map((id) => characterById(project, id)?.name || id).join('、')}${(sc.props || []).length ? ` · 道具:${sc.props.map((id) => propById(project, id)?.name || id).join('、')}` : ''}`, '');
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('、')}` : ''}`, '');
4544
4862
  (sc.flow || []).forEach((b, k) => {
4545
4863
  const o = at(i + 1, k + 1);
4546
4864
  const span = o ? `(${Math.round(o.start)}-${Math.round(o.end)}秒)` : '';
4547
4865
  if (b.line !== undefined) {
4548
- const who = b.speaker === 'VO' ? 'VO' : characterById(project, b.speaker)?.name || b.speaker;
4866
+ const who = b.speaker === 'VO' ? 'VO' : displayName(project, b.speaker, episode.ep);
4549
4867
  lines.push(`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`);
4550
4868
  } else lines.push(`△${span}${b.action}${b.turn ? ' ⟲' : ''}`);
4551
4869
  if (satRef && satRef[0] === i + 1 && satRef[1] === k + 1) lines.push('', `【爽点标记】${sat.action || sat.type || ''}`);
@@ -4566,7 +4884,7 @@ export function renderSegment(project, episode, segmentId) {
4566
4884
  const cuts = seg.cuts || [];
4567
4885
  const starts = cutStarts(cuts);
4568
4886
  const total = cuts.reduce((s, c) => s + (Number(c.seconds) || 0), 0);
4569
- const nameOf = (id) => characterById(project, id)?.name || id;
4887
+ const nameOf = (id) => displayName(project, id, episode.ep);
4570
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(' + ')}`, ''];
4571
4889
  const kit = params.camera || DEFAULT_PARAMS.camera;
4572
4890
  lines.push(`镜头:${kit.body} + ${kit.lens}`, '');
@@ -4779,6 +5097,7 @@ export function initProject(opts) {
4779
5097
  videoDuration: opts.duration || (mode.autoDuration ? DEFAULT_PARAMS.videoDuration : 'fixed'),
4780
5098
  lookPhrase: opts.look || DEFAULT_PARAMS.lookPhrase,
4781
5099
  style: opts.style || DEFAULT_PARAMS.style,
5100
+ imagePromptLang: opts.imageLang || DEFAULT_PARAMS.imagePromptLang,
4782
5101
  // Picked from the genre unless named; `--cinematography none` opts out.
4783
5102
  cinematography: cine,
4784
5103
  // Named, or read from the genre only when it names an action style outright.
@@ -4916,6 +5235,16 @@ function main(argv) {
4916
5235
  return 0;
4917
5236
  }
4918
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
+
4919
5248
  if (cmd === 'init') {
4920
5249
  const dir = pos[0];
4921
5250
  if (!dir) throw new Error('usage: init <dir> --title … --episodes N --minutes M --genre G');
@@ -4924,14 +5253,17 @@ function main(argv) {
4924
5253
  adapt: flag(rest, '--adapt'), ratio: flag(rest, '--ratio'), video: flag(rest, '--video'), style: flag(rest, '--style'), lang: flag(rest, '--lang'),
4925
5254
  resolution: flag(rest, '--resolution'), accent: flag(rest, '--accent'),
4926
5255
  references: flag(rest, '--references'), duration: flag(rest, '--duration'), look: flag(rest, '--look'),
4927
- register: flag(rest, '--register'), 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'),
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'),
4928
5258
  source: flag(rest, '--source'), sourcePath: flag(rest, '--source-path'),
4929
5259
  });
4930
5260
  const paths = projectPaths(dir);
4931
5261
  if (existsSync(paths.project) && !flag(rest, '--force')) throw new Error(`${paths.project} exists (pass --force to overwrite)`);
4932
5262
  writeJsonFile(paths.project, project);
4933
5263
  mkdirSync(paths.episodes, { recursive: true });
5264
+ const dashboard = installDashboard(paths.dir);
4934
5265
  console.log(`✓ ${paths.project}`);
5266
+ console.log(`✓ ${dashboard.output}`);
4935
5267
  return 0;
4936
5268
  }
4937
5269
 
@@ -4972,6 +5304,17 @@ function main(argv) {
4972
5304
  return 0;
4973
5305
  }
4974
5306
 
5307
+ if (cmd === 'dashboard') {
5308
+ const loaded = loadProject(input);
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')),
5313
+ });
5314
+ console.log(installed.copied ? `✓ ${installed.output}` : `✓ ${installed.output} (already beside project.json)`);
5315
+ return 0;
5316
+ }
5317
+
4975
5318
  if (cmd === 'render') {
4976
5319
  const what = pos[1];
4977
5320
  const { project, episodes } = loadProject(input);
@@ -5036,6 +5379,15 @@ function main(argv) {
5036
5379
  return 0;
5037
5380
  }
5038
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
+
5039
5391
  if (cmd === 'prompt' && pos[1] === 'voice') {
5040
5392
  const { project } = loadProject(input);
5041
5393
  const c = characterById(project, pos[2]);
@@ -5106,7 +5458,7 @@ function main(argv) {
5106
5458
  throw new Error(`unknown command "${cmd}" — run with --help`);
5107
5459
  }
5108
5460
 
5109
- 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));
5110
5462
  if (isMain) {
5111
5463
  try {
5112
5464
  process.exitCode = main(process.argv.slice(2));