@wji.ai/cli 0.9.0 → 0.9.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.
Files changed (70) hide show
  1. package/README.md +7 -1
  2. package/dist/index.js +156 -11
  3. package/package.json +1 -1
  4. package/skill/backreel/SKILL.md +6 -2
  5. package/skill/backreel/reference/episodes.md +45 -7
  6. package/skill/short-drama/SKILL.md +27 -4
  7. package/skill/short-drama/examples/night-shift/episodes/E01.json +173 -1
  8. package/skill/short-drama/examples/night-shift/project.json +6 -0
  9. package/skill/short-drama/reference/dialogue-craft.md +16 -11
  10. package/skill/short-drama/reference/prompt-audit.mjs +308 -0
  11. package/skill/short-drama/reference/reference-binding.md +84 -0
  12. package/skill/short-drama/reference/schema.md +14 -5
  13. package/skill/short-drama/reference/stage-characters.md +1 -1
  14. package/skill/short-drama/reference/stage-script.md +54 -7
  15. package/skill/short-drama/reference/stage-storyboard.md +29 -24
  16. package/skill/short-drama/scripts/selftest.mjs +154 -9
  17. package/skill/short-drama/scripts/short-drama.mjs +467 -23
  18. package/skill/w-film-dialogue-editor/CHANGELOG.md +44 -0
  19. package/skill/w-film-dialogue-editor/SKILL.md +124 -0
  20. package/skill/w-film-dialogue-editor/references/data/README.md +222 -0
  21. package/skill/w-film-dialogue-editor/references/data/en/core.json +1666 -0
  22. package/skill/w-film-dialogue-editor/references/data/en/slang.json +1602 -0
  23. package/skill/w-film-dialogue-editor/references/data/en/work.json +1722 -0
  24. package/skill/w-film-dialogue-editor/references/data/en-sentences.csv +134 -0
  25. package/skill/w-film-dialogue-editor/references/data/en-sentences.md +146 -0
  26. package/skill/w-film-dialogue-editor/references/data/ja/core.json +1978 -0
  27. package/skill/w-film-dialogue-editor/references/data/ja/slang.json +1927 -0
  28. package/skill/w-film-dialogue-editor/references/data/ja/work.json +1972 -0
  29. package/skill/w-film-dialogue-editor/references/data/ja-sentences.csv +134 -0
  30. package/skill/w-film-dialogue-editor/references/data/ja-sentences.md +146 -0
  31. package/skill/w-film-dialogue-editor/references/data/ko/core.json +1839 -0
  32. package/skill/w-film-dialogue-editor/references/data/ko/slang.json +1897 -0
  33. package/skill/w-film-dialogue-editor/references/data/ko/work.json +1829 -0
  34. package/skill/w-film-dialogue-editor/references/data/ko-sentences.csv +134 -0
  35. package/skill/w-film-dialogue-editor/references/data/ko-sentences.md +146 -0
  36. package/skill/w-film-dialogue-editor/references/data/zh-cn/core.json +1794 -0
  37. package/skill/w-film-dialogue-editor/references/data/zh-cn/slang.json +1609 -0
  38. package/skill/w-film-dialogue-editor/references/data/zh-cn/work.json +1648 -0
  39. package/skill/w-film-dialogue-editor/references/data/zh-cn-sentences.csv +134 -0
  40. package/skill/w-film-dialogue-editor/references/data/zh-cn-sentences.md +146 -0
  41. package/skill/w-film-dialogue-editor/references/data/zh-hk/core.json +2476 -0
  42. package/skill/w-film-dialogue-editor/references/data/zh-hk/gen-z.json +3263 -0
  43. package/skill/w-film-dialogue-editor/references/data/zh-hk/older.json +3632 -0
  44. package/skill/w-film-dialogue-editor/references/data/zh-hk/rough.json +1668 -0
  45. package/skill/w-film-dialogue-editor/references/data/zh-hk/workplace.json +3697 -0
  46. package/skill/w-film-dialogue-editor/references/data/zh-hk-sentences.csv +373 -0
  47. package/skill/w-film-dialogue-editor/references/data/zh-hk-sentences.md +391 -0
  48. package/skill/w-film-dialogue-editor/references/data/zh-tw/core.json +1640 -0
  49. package/skill/w-film-dialogue-editor/references/data/zh-tw/slang.json +1537 -0
  50. package/skill/w-film-dialogue-editor/references/data/zh-tw/work.json +1699 -0
  51. package/skill/w-film-dialogue-editor/references/data/zh-tw-sentences.csv +132 -0
  52. package/skill/w-film-dialogue-editor/references/data/zh-tw-sentences.md +144 -0
  53. package/skill/w-film-dialogue-editor/references/dialogue_revision_framework.md +84 -0
  54. package/skill/w-film-dialogue-editor/references/en-index.md +265 -0
  55. package/skill/w-film-dialogue-editor/references/ja-index.md +633 -0
  56. package/skill/w-film-dialogue-editor/references/ko-index.md +498 -0
  57. package/skill/w-film-dialogue-editor/references/lang-en.md +242 -0
  58. package/skill/w-film-dialogue-editor/references/lang-ja.md +312 -0
  59. package/skill/w-film-dialogue-editor/references/lang-ko.md +290 -0
  60. package/skill/w-film-dialogue-editor/references/lang-zh-cn.md +60 -0
  61. package/skill/w-film-dialogue-editor/references/lang-zh-hk-mandarin.md +34 -0
  62. package/skill/w-film-dialogue-editor/references/lang-zh-hk.md +306 -0
  63. package/skill/w-film-dialogue-editor/references/lang-zh-period.md +38 -0
  64. package/skill/w-film-dialogue-editor/references/lang-zh-tw.md +57 -0
  65. package/skill/w-film-dialogue-editor/references/zh-cn-index.md +636 -0
  66. package/skill/w-film-dialogue-editor/references/zh-hk-index.md +1450 -0
  67. package/skill/w-film-dialogue-editor/references/zh-tw-index.md +457 -0
  68. package/skill/w-film-dialogue-editor/scripts/rebuild.sh +13 -0
  69. package/skill/w-film-dialogue-editor/scripts/ref.mjs +539 -0
  70. package/skill/w-film-dialogue-editor/templates/dialogue_revision_report.md +43 -0
@@ -24,6 +24,8 @@
24
24
  // validate <project> [--stage outline|characters|art|script|storyboard] [--ep 1-3]
25
25
  // checkup <project> [--stage …] [--ep …] every gate with ✓/✗, exit 0
26
26
  // seed <project> script --ep N [--force] episodes/E0N.json skeleton from the outline
27
+ // seed <project> dialogue --ep N [--force] 对白校对 record from the current lines (filled rows for unchanged lines are kept)
28
+ // apply <project> dialogue --ep N write the reviewed lines (and deliveries) back into the script
27
29
  // seed <project> storyboard --ep N [--force] beat worksheet, visual-plan worksheet + empty segments from the script
28
30
  // render <project> outline | guideline | character C01 | scene S01 | prop P01 | script --ep N | dialogue --ep N | segment E01-01
29
31
  // prompt <project> segment E01-01 derived video-prompt skeleton + keyframe reference plan
@@ -42,6 +44,7 @@
42
44
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
43
45
  import { basename, dirname, join, resolve } from 'node:path';
44
46
  import { fileURLToPath } from 'node:url';
47
+ import { homedir } from 'node:os';
45
48
 
46
49
  // ── constants ───────────────────────────────────────────────────────────────
47
50
 
@@ -104,6 +107,9 @@ export const DEFAULT_PARAMS = Object.freeze({
104
107
  maxConsecutiveLines: 3,
105
108
  minActionRatio: 0.35,
106
109
  maxNameCallsPerScene: 2,
110
+ // The 对白校对 pass after the script (`seed dialogue` → fill → `apply dialogue`): `required` blocks the
111
+ // storyboard until every line is reviewed against the 语体 guide; `off` skips it.
112
+ dialogueReview: 'required',
107
113
  maxLineWords: 20,
108
114
  // Shot language.
109
115
  speechLeadSeconds: 0.5,
@@ -256,6 +262,42 @@ export function normalizeRegister(value) {
256
262
  const v = String(value || '').trim();
257
263
  return REGISTER_ALIASES[v] || v;
258
264
  }
265
+ /**
266
+ * 语体 → the user's `w-film-dialogue-editor` skill: which language guide to read and which corpus
267
+ * `scripts/ref.mjs` can query. A register without a corpus still has its guide.
268
+ */
269
+ export const DIALOGUE_GUIDES = {
270
+ '粤语': { lang: 'zh-hk', guide: 'lang-zh-hk.md', corpus: true },
271
+ '大陆普通话': { lang: 'zh-cn', guide: 'lang-zh-cn.md', corpus: true },
272
+ '台湾国语': { lang: 'zh-tw', guide: 'lang-zh-tw.md', corpus: true },
273
+ '港式普通话': { lang: 'zh-hk-mandarin', guide: 'lang-zh-hk-mandarin.md', corpus: false },
274
+ '古装': { lang: 'zh-period', guide: 'lang-zh-period.md', corpus: false },
275
+ '年代': { lang: 'zh-period', guide: 'lang-zh-period.md', corpus: false },
276
+ '英文美式': { lang: 'en', guide: 'lang-en.md', corpus: true },
277
+ '英文英式': { lang: 'en', guide: 'lang-en.md', corpus: true },
278
+ };
279
+ export const DIALOGUE_SKILL = 'w-film-dialogue-editor';
280
+ export const DIALOGUE_LEVELS = ['轻修', '中修', '重修'];
281
+ /** The scene-level diagnosis the review must answer before any line is touched. */
282
+ export const DIALOGUE_DIAGNOSIS = { goal: '场面目标', conflict: '主要冲突', subtext: '潜台词核心', voice: '角色声音问题', rhythm: '节奏问题' };
283
+ /** Director notes that are not notes. */
284
+ export const VAGUE_NOTES = ['自然一点', '自然一點', '自然些', '激动一点', '激動一點', '伤心一点', '傷心一點', '大声一点', '大聲一點', 'more natural', 'be natural'];
285
+ /** The corpus's age bands (ref.mjs ENUMS.age). */
286
+ export function corpusAge(age) {
287
+ if (age == null) return null;
288
+ if (age <= 12) return 'kid';
289
+ if (age <= 19) return 'teen';
290
+ if (age <= 29) return 'young';
291
+ if (age <= 44) return 'mid';
292
+ if (age <= 59) return 'senior';
293
+ return 'elder';
294
+ }
295
+ /** Where the dialogue skill is: beside this skill (npm package, crew install), else the user's Claude skills. */
296
+ export function dialogueSkillDir() {
297
+ const here = dirname(fileURLToPath(import.meta.url));
298
+ const candidates = [join(here, '..', '..', DIALOGUE_SKILL), join(homedir(), '.claude', 'skills', DIALOGUE_SKILL)];
299
+ return candidates.find((d) => existsSync(join(d, 'SKILL.md'))) || null;
300
+ }
259
301
  /** 本集功能 — what an episode is for in the run of the drama. */
260
302
  export const EPISODE_FUNCTIONS = ['开局钩子', '冲突升级', '身份揭露', '情感推进', '大反转', '清算收束'];
261
303
  /** 爽点类型库 — a 爽点 is done by a visible action of one of these kinds. */
@@ -543,6 +585,53 @@ export function identityOf(character) {
543
585
  return String(character?.identityEn || '').trim() || 'the person in this shot';
544
586
  }
545
587
 
588
+ /** The drama-language identity, for a Chinese legend: two people must never read the same line. */
589
+ export function identityZh(character) {
590
+ const p = character?.profile || {};
591
+ const clean = (v) => (v && !/^(不詳|不详|未知|n\/a|unknown)$/i.test(String(v).trim()) ? String(v).trim() : '');
592
+ const bits = [clean(p.ageRange), clean(p.gender), clean(p.appearance || p.identity)].filter(Boolean);
593
+ return String(bits.join(',') || '').trim();
594
+ }
595
+
596
+ /**
597
+ * What a picture IS, said in the legend, so the model never has to guess whether @image3 is the
598
+ * room, a person or a prop — and, for a reference that is not a person, what NOT to copy from it.
599
+ * One legend line per picture; nothing else in the prompt repeats it.
600
+ */
601
+ /** A short English handle from an English art prompt, so an English legend can name a room or a prop
602
+ * without breaking the "prompt must be English apart from dialogue" rule (and without using an id). */
603
+ function plainPhrase(text, maxWords = 10) {
604
+ const s = String(text || '').trim();
605
+ if (!s || /[\u4e00-\u9fff]/.test(s)) return '';
606
+ const head = s.replace(/^(product photograph of|a|an|the)\s+/i, '').split(/[,.;(]/)[0].trim();
607
+ return head.split(/\s+/).filter(Boolean).slice(0, maxWords).join(' ');
608
+ }
609
+
610
+ export function sheetUse(ref, project) {
611
+ const id = String(ref.entity || '');
612
+ const ch = characterById(project, id);
613
+ const pr = propById(project, id);
614
+ const sc = sceneById(project, id);
615
+ if (ref.asset && /^lighting:/.test(ref.asset)) return 'the same environment in the lighting state to use \u2014 take only its light ratios, contrast and colour temperature, never its composition or its people';
616
+ if (/^C\d/.test(id) && ch) return `the character sheet of ${identityOf(ch)} \u2014 match face, hair and clothes exactly, and nothing else in the frame`;
617
+ if (/^P\d/.test(id) && pr) return `the prop to reproduce exactly${plainPhrase(pr.image?.prompt || pr.states?.[0]?.prompt) ? `: ${plainPhrase(pr.image?.prompt || pr.states?.[0]?.prompt)}` : ''} \u2014 only this object, not the surface it sits on`;
618
+ if (sc) return `the environment${plainPhrase(sc.lighting?.[0]?.prompt) ? `: ${plainPhrase(sc.lighting[0].prompt)}` : ''} \u2014 match its layout, materials and wear exactly; take no person from it, not ever`;
619
+ if (ref.asset === 'headshot') return 'the headshot that supplies one person\u2019s face \u2014 match face and hair only';
620
+ return 'a reference image for this segment';
621
+ }
622
+ export function sheetZh(ref, project) {
623
+ const id = String(ref.entity || '');
624
+ const ch = characterById(project, id);
625
+ const pr = propById(project, id);
626
+ const sc = sceneById(project, id);
627
+ if (ref.asset && /^lighting:/.test(ref.asset)) return '同一场景的光线状态:只照它的光比、反差与色温,不要照它的构图与人物';
628
+ if (/^C\d/.test(id) && ch) return `人物三视图(${identityZh(ch)}):严格还原面容、发型与服装`;
629
+ if (/^P\d/.test(id) && pr) return `需要严格还原的道具:${pr.name || id}`;
630
+ if (sc) return `环境(${sc.name || id}),严格还原布局、材质与旧痕;这张图里没有人,不要照它的人物`;
631
+ if (ref.asset === 'headshot') return '一张人头特写,只照面容与发型';
632
+ return '本段参考图';
633
+ }
634
+
546
635
  export const CAMERAS = [
547
636
  'Static Shot', 'Push In', 'Pull Out', 'Zoom In', 'Zoom Out', 'Pan Left', 'Pan Right',
548
637
  'Truck Left', 'Truck Right', 'Tilt Up', 'Tilt Down', 'Pedestal Up', 'Pedestal Down',
@@ -1699,6 +1788,90 @@ function gate(id, stage, label, run, opts = {}) {
1699
1788
  return { id, stage, label, run, advisory: opts.advisory === true };
1700
1789
  }
1701
1790
 
1791
+ const ASSET_CONDITIONS = ['new', 'lived-in', 'worn'];
1792
+ // Whole English words, so "stainless" is not "stain". Chinese marks are the wear words themselves.
1793
+ const WORN_CONDITION_RE = /\b(worn|rust|rusty|rusted|faded|chipped|torn|frayed|peeling|stain|stained|stains)\b|殘舊|残旧|生鏽|生锈|褪色|剝落|剥落/i;
1794
+ const CLEAN_BAN_RE = /showroom[-\s]?clean|brand[-\s]?new/i;
1795
+
1796
+ function assetConditionFailures(entity, texts) {
1797
+ const out = [];
1798
+ if (!ASSET_CONDITIONS.includes(entity.condition)) out.push(`${entity.id}: condition must be new, lived-in or worn`);
1799
+ if (!String(entity.conditionReason || '').trim()) out.push(`${entity.id}: conditionReason must say why the story gives this condition`);
1800
+ if (entity.condition === 'new') {
1801
+ for (const [where, text] of texts) {
1802
+ if (WORN_CONDITION_RE.test(String(text || ''))) out.push(`${entity.id}: condition is new but ${where} describes wear`);
1803
+ }
1804
+ }
1805
+ if (entity.condition && entity.condition !== 'worn' && CLEAN_BAN_RE.test(String(entity.image?.negativePrompt || ''))) {
1806
+ out.push(`${entity.id}: only condition worn may ban brand-new or showroom-clean`);
1807
+ }
1808
+ return out;
1809
+ }
1810
+
1811
+ function sceneConditionTexts(scene) {
1812
+ const texts = [];
1813
+ for (const a of scene.anchors || []) texts.push([`anchor "${a.name || ''}"`, `${a.name || ''} ${a.desc || ''}`]);
1814
+ const img = scene.image || {};
1815
+ for (const key of ['prompt', 'sheet']) if (img[key]) texts.push([`image.${key}`, img[key]]);
1816
+ for (const l of scene.lighting || []) if (l.prompt) texts.push([`lighting "${l.state || ''}"`, l.prompt]);
1817
+ return texts;
1818
+ }
1819
+
1820
+ function propConditionTexts(prop) {
1821
+ const texts = [];
1822
+ for (const a of prop.anchors || []) texts.push([`anchor "${a.name || ''}"`, `${a.name || ''} ${a.desc || ''}`]);
1823
+ const img = prop.image || {};
1824
+ for (const key of ['prompt', 'sheet']) if (img[key]) texts.push([`image.${key}`, img[key]]);
1825
+ for (const st of prop.states || []) if (st.prompt) texts.push([`state "${st.state || ''}"`, st.prompt]);
1826
+ return texts;
1827
+ }
1828
+
1829
+ // ── reference binding helpers ───────────────────────────────────────────────
1830
+ // The storyboard's picture plan has to survive into the delivery text. The model reads ONE string:
1831
+ // a shot that never names its @imageK falls back to whatever it last saw, and two pictures the
1832
+ // legend describes in the same words are two pictures the model will swap.
1833
+ const ID_STOPWORDS = new Set(['the', 'and', 'with', 'her', 'his', 'one', 'man', 'woman', 'from', 'this']);
1834
+ function identityTokens(ch) {
1835
+ const out = new Set();
1836
+ const add = (text) => {
1837
+ for (const w of String(text || '').toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/)) if (w.length > 2 && !ID_STOPWORDS.has(w)) out.add(w);
1838
+ for (const run of String(text || '').match(/[\u4e00-\u9fff]+/g) || []) {
1839
+ if (run.length <= 3) out.add(run);
1840
+ else for (let i = 0; i + 1 < run.length; i++) out.add(run.slice(i, i + 2));
1841
+ }
1842
+ };
1843
+ add(ch?.identityEn);
1844
+ add(ch?.profile?.appearance);
1845
+ add(ch?.profile?.identity);
1846
+ const g = ch?.profile?.gender;
1847
+ if (g) out.add(g === '男' ? '男' : '女');
1848
+ return out;
1849
+ }
1850
+ /** The legend text that belongs to one label: from its `@imageN` to the next label. */
1851
+ function legendSpanOf(head, label) {
1852
+ const m = new RegExp(`${label}(?!\\d)`).exec(head);
1853
+ if (!m) return '';
1854
+ const rest = head.slice(m.index + label.length);
1855
+ const next = rest.search(/@image\d+/);
1856
+ return (next < 0 ? rest : rest.slice(0, next)).trim();
1857
+ }
1858
+ /** Per-shot text for every family: h3 / wan / seedance sheets have a parser, keyframes are read here. */
1859
+ function referenceShotTexts(prompt, mode, params) {
1860
+ const texts = shotTexts(prompt, mode.family, params.references);
1861
+ if (texts.size) return texts;
1862
+ const map = new Map();
1863
+ const re = /(?:^|\n)\s*From @image(\d+)[^\n]*([\s\S]*?)(?=\n\s*From @image\d+|\n\s*\n|$)/g;
1864
+ for (let m; (m = re.exec(prompt)); ) map.set(Number(m[1]), m[0]);
1865
+ return map;
1866
+ }
1867
+ /** label per sheet entity (people, props and the room), for the binding checks. */
1868
+ function sheetLabelsOf(images) {
1869
+ const m = new Map();
1870
+ for (const r of images) if (r.asset === 'sheet' || r.asset === 'headshot') if (!m.has(r.entity)) m.set(r.entity, r.label);
1871
+ return m;
1872
+ }
1873
+ const NEGATIVE_REF = /not its|never its|only its|no person|take no person|not the composition|只照|唔照|不要照|只取|这张图里没有人|不要照它的人物/i;
1874
+
1702
1875
  export const GATES = [
1703
1876
  // ── outline ───────────────────────────────────────────────────────────
1704
1877
  gate('params', 'outline', 'params complete and valid', ({ project, params }) => {
@@ -2755,6 +2928,7 @@ export const GATES = [
2755
2928
  const look = kit.zh ? null : cinematographyOf(params);
2756
2929
  for (const s of project.scenes || []) {
2757
2930
  if (!String(s.summary || '').trim()) out.push(`${s.id}: summary (design intent) is empty`);
2931
+ out.push(...assetConditionFailures(s, sceneConditionTexts(s)));
2758
2932
  const anchors = s.anchors || [];
2759
2933
  if (anchors.length < 3 || anchors.length > 5) out.push(`${s.id}: ${anchors.length} anchors, need 3–5`);
2760
2934
  for (const a of anchors) if (!a.name || !a.desc) out.push(`${s.id}: every anchor needs name + desc`);
@@ -2798,6 +2972,7 @@ export const GATES = [
2798
2972
  for (const p of project.props || []) {
2799
2973
  if (!PROP_SCALES[p.scale]) out.push(`${p.id}: scale must be one of ${Object.keys(PROP_SCALES).join(' / ')}`);
2800
2974
  if (!String(p.summary || '').trim()) out.push(`${p.id}: summary (dramatic function) is empty`);
2975
+ out.push(...assetConditionFailures(p, propConditionTexts(p)));
2801
2976
  const anchors = p.anchors || [];
2802
2977
  if (anchors.length < 3 || anchors.length > 5) out.push(`${p.id}: ${anchors.length} anchors, need 3–5`);
2803
2978
  const states = p.states || [];
@@ -3082,6 +3257,49 @@ export const GATES = [
3082
3257
  }
3083
3258
  return out;
3084
3259
  }, { advisory: true }),
3260
+ gate('dialogue-review', 'script', '对白校对: every line reviewed against its 语体 guide (w-film-dialogue-editor) — diagnosis, a row per line, the revision applied, playable director notes, the guide’s 自检', (ctx) => {
3261
+ const { params } = ctx;
3262
+ if (params.dialogueReview === 'off') return [];
3263
+ const out = [];
3264
+ for (const [ep, e] of ctx.episodes) {
3265
+ const E = `E${pad2(ep)}`;
3266
+ const lines = lineBeats(e);
3267
+ if (!lines.length) continue;
3268
+ const rv = e.dialogueReview;
3269
+ if (!isObject(rv)) { out.push(`${E}: no dialogueReview — run \`seed <project> dialogue --ep ${ep}\`, read the 语体 guide, fill every row, then \`apply <project> dialogue --ep ${ep}\``); continue; }
3270
+ if (!DIALOGUE_LEVELS.includes(rv.level)) out.push(`${E}: dialogueReview.level must be ${DIALOGUE_LEVELS.join(' / ')}`);
3271
+ for (const [k, label] of Object.entries(DIALOGUE_DIAGNOSIS)) if (!String(rv.diagnosis?.[k] || '').trim()) out.push(`${E}: dialogueReview.diagnosis.${k} (${label}) is empty — diagnose the scene before touching a line`);
3272
+ const want = new Set(dialogueRefs(ctx.project, e, { skillDir: null }).speakers.map((x) => DIALOGUE_GUIDES[x.register]?.guide).filter(Boolean));
3273
+ const read = new Set(asList(rv.guides).map((g) => basename(String(g))));
3274
+ for (const g of want) if (!read.has(g)) out.push(`${E}: dialogueReview.guides is missing ${g} — a speaker in this episode speaks that 语体`);
3275
+ const rows = asList(rv.rows);
3276
+ const byPos = new Map();
3277
+ for (const r of rows) {
3278
+ const key = `${r.scene}:${r.beat}`;
3279
+ if (byPos.has(key)) out.push(`${E} scene ${r.scene} beat ${r.beat}: reviewed twice`);
3280
+ byPos.set(key, r);
3281
+ }
3282
+ const isLine = new Set(lines.map((l) => `${l.scene}:${l.beat}`));
3283
+ for (const r of rows) if (!isLine.has(`${r.scene}:${r.beat}`)) out.push(`${E} scene ${r.scene} beat ${r.beat}: a review row for a beat that is no longer a line — re-seed the review`);
3284
+ for (const l of lines) {
3285
+ const where = `${E} scene ${l.scene} beat ${l.beat}`;
3286
+ const r = byPos.get(`${l.scene}:${l.beat}`);
3287
+ if (!r) { out.push(`${where}: "${truncateLine(l.line)}" has no review row — re-seed the review (filled rows are kept)`); continue; }
3288
+ if (r.speaker !== l.speaker) out.push(`${where}: the row is for ${r.speaker}, the line is ${l.speaker}'s — re-seed the review`);
3289
+ if (String(r.revised ?? '') !== l.line) { out.push(`${where}: the script line differs from the reviewed one ("${truncateLine(String(r.revised ?? ''))}") — \`apply <project> dialogue --ep ${ep}\`, or re-seed after an edit so the new line is reviewed`); continue; }
3290
+ if (!String(r.original || '').trim()) out.push(`${where}: original is empty`);
3291
+ if (!String(r.issue || '').trim()) out.push(`${where}: issue is empty — name the problem, or 无 when the line stands`);
3292
+ if (r.revised !== r.original && !String(r.reason || '').trim()) out.push(`${where}: changed without a reason`);
3293
+ const note = String(r.note || '').trim();
3294
+ if (!note) out.push(`${where}: no 导演/演员提示 — a playable action (逼 / 哄 / 避 / 试探 / 停半拍…)`);
3295
+ else if (EMOTION_ONLY.includes(note) || VAGUE_NOTES.some((w) => note.toLowerCase().includes(w.toLowerCase()))) out.push(`${where}: note "${note}" is a mood, not an action — say what the line does to the other person`);
3296
+ }
3297
+ const checks = asList(rv.selfCheck);
3298
+ if (checks.length < 3) out.push(`${E}: dialogueReview.selfCheck needs the guide's 自检清单 (at least 3 items), each { item, ok, note }`);
3299
+ for (const c of checks) if (!c.ok && !String(c.note || '').trim()) out.push(`${E}: 自检 "${truncateLine(String(c.item || ''))}" failed with no note — fix the lines or say why it stands`);
3300
+ }
3301
+ return out;
3302
+ }),
3085
3303
 
3086
3304
  // ── storyboard (per episode) ──────────────────────────────────────────
3087
3305
  gate('segments', 'storyboard', 'segments: ids sequential, one scene each, cuts claim every beat once in order', (ctx) => {
@@ -3659,6 +3877,70 @@ export const GATES = [
3659
3877
  }
3660
3878
  return out;
3661
3879
  }),
3880
+ gate('ref-binding', 'storyboard', '参考图绑定: every picture says in the legend WHAT it is (and what not to copy from it), everyone and everything on screen is bound by its @imageK inside that shot, and no connected picture goes unused', (ctx) => {
3881
+ const { project, params } = ctx;
3882
+ const out = [];
3883
+ if (params.references !== 'sheets') return out;
3884
+ const mode = videoModel(params);
3885
+ for (const [, e] of ctx.episodes) {
3886
+ for (const seg of e.storyboard?.segments || []) {
3887
+ const prompt = String(seg.prompt || '').trim();
3888
+ if (!prompt) continue;
3889
+ const refs = segmentReferences(project, e, seg);
3890
+ const images = refs.filter((r) => r.kind === 'image');
3891
+ if (!images.length) continue;
3892
+ if (images.length > mode.maxRefs) out.push(`${seg.id}: ${images.length} picture references, but ${mode.model} takes at most ${mode.maxRefs} — split the segment, or drop a picture that earns nothing`);
3893
+ const shotStart = prompt.search(/(?:^|\n)\s*(?:\[Shot\s*1\]|镜头\s*1|Shot\s*1)\b/);
3894
+ const head = shotStart < 0 ? prompt : prompt.slice(0, shotStart);
3895
+ // 1. one legend line per picture, and it says what the picture IS
3896
+ const legendLines = head.split('\n').filter((l) => /^@image\d+\b/.test(l.trim()));
3897
+ for (const r of images) {
3898
+ const mine = legendLines.filter((l) => l.trim().startsWith(r.label));
3899
+ if (!mine.length) out.push(`${seg.id}: ${r.label} (${r.why}) is connected but the legend never gives it a line — the model cannot know the picture exists`);
3900
+ else if (mine.length > 1) out.push(`${seg.id}: ${r.label} gets ${mine.length} legend lines — one line per picture, or the model reads them as different things`);
3901
+ if (mine.length && /^(@image\d+ is\s*)?a reference image/i.test(mine[0].trim().replace(/^@image\d+ is\s*/, ''))) out.push(`${seg.id}: ${r.label} says only "a reference image" — say whether it is the place, a person, the prop, or a light / moment to copy`);
3902
+ }
3903
+ // 2. a picture that is not a person must say what NOT to copy from it
3904
+ for (const r of images) {
3905
+ if (/^C\d/.test(String(r.entity))) continue;
3906
+ const span = legendSpanOf(head, r.label);
3907
+ if (span && !NEGATIVE_REF.test(span)) out.push(`${seg.id}: ${r.label} (${r.role || 'not a person'}) never says what NOT to take from it — a light or moment reference must say "only its light ratios / not its composition or people", or the model copies its faces`);
3908
+ }
3909
+ // 3. two people must not read the same in the legend
3910
+ const chars = uniq((seg.cuts || []).flatMap((c) => c.characters || [])).filter((id) => id !== 'VO');
3911
+ const byEntity = new Map(images.map((r) => [r.entity, r]));
3912
+ const seenSpans = new Map();
3913
+ for (const id of chars) {
3914
+ const r = byEntity.get(id);
3915
+ if (!r) { out.push(`${seg.id}: ${characterById(project, id)?.name || id} is on screen, but no picture supplies them`); continue; }
3916
+ const span = legendSpanOf(head, r.label).toLowerCase();
3917
+ const key = span.replace(/\s+/g, ' ').trim();
3918
+ if (key && seenSpans.has(key)) out.push(`${seg.id}: ${r.label} and ${seenSpans.get(key)} read exactly the same in the legend — two people described in the same words are two people the model swaps`);
3919
+ else if (key) seenSpans.set(key, r.label);
3920
+ const hits = [...identityTokens(characterById(project, id))].filter((t) => span.includes(t)).length;
3921
+ if (span && hits < 2) out.push(`${seg.id}: ${r.label} never says WHICH person it is (no age / hair / clothes from the character bible) — "@image2, @image3 are character sheets" is how faces get swapped`);
3922
+ }
3923
+ // 4. what is in shot is bound inside that shot
3924
+ const texts = referenceShotTexts(prompt, mode, params);
3925
+ const labels = sheetLabelsOf(images);
3926
+ (seg.cuts || []).forEach((c, k) => {
3927
+ const text = texts.get(k + 1) || '';
3928
+ if (!text) return;
3929
+ for (const id of c.props || []) {
3930
+ const label = labels.get(id);
3931
+ if (label && !text.includes(label)) out.push(`${seg.id} shot ${k + 1}: ${propById(project, id)?.name || id} is in shot but the shot never binds ${label} — the prop is invented on screen`);
3932
+ }
3933
+ });
3934
+ // 5. a picture connected but never used outside the legend earns nothing
3935
+ const body = shotStart < 0 ? prompt : prompt.slice(shotStart);
3936
+ for (const r of images) {
3937
+ if (r.role === 'environment' || r.role === 'lighting') continue;
3938
+ if (!body.includes(r.label)) out.push(`${seg.id}: ${r.label} (${r.why}) is connected but used in no shot — that is a picture the model will not look at`);
3939
+ }
3940
+ }
3941
+ }
3942
+ return out;
3943
+ }),
3662
3944
  gate('two-person-framing', 'storyboard', 'sheets mode, two-person segments: a single names only its person, every look has a screen direction toward the other, the sides are locked, over-the-shoulder is said', (ctx) => {
3663
3945
  const { project, params } = ctx;
3664
3946
  const out = [];
@@ -4348,7 +4630,7 @@ export function seedScript(project, ep) {
4348
4630
  const sp = c?.bible?.speech || {};
4349
4631
  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 || '' };
4350
4632
  }
4351
- return {
4633
+ const seeded = {
4352
4634
  ep: Number(ep),
4353
4635
  targetSeconds: Math.round(Number(params.minutesPerEpisode) * 60),
4354
4636
  // The episode design from the outline's 分集目录 — gated against it in the script stage.
@@ -4371,6 +4653,7 @@ export function seedScript(project, ep) {
4371
4653
  guidelinePriorities: asList(project.outline?.guideline?.priorities),
4372
4654
  guidelineForbidden: asList(project.outline?.guideline?.forbidden),
4373
4655
  speech,
4656
+ dialogue: null,
4374
4657
  rhythm: `hook image by ${params.hookSeconds} s · a turn (new information / obstacle / counter / choice, \`turn: true\`) at least every ${params.turnSeconds} s · 爽点 (satisfactionBeat) on a visible action after the hook and before the cliff · no more than ${params.maxConsecutiveLines} lines in a row`,
4375
4658
  sceneIds: outlineEp.sceneIds || [],
4376
4659
  characterIds: outlineEp.characterIds || [],
@@ -4384,6 +4667,10 @@ export function seedScript(project, ep) {
4384
4667
  storyboard: { segments: [] },
4385
4668
  wji: { screenplayDatasetId: '', canvasId: '', shotFolderId: '', displayNodes: {} },
4386
4669
  };
4670
+ // The dialogue skill for everyone who speaks here — read while writing, not only in the review.
4671
+ const pseudo = { ep: Number(ep), scenes: [{ characters: outlineEp.characterIds || [], flow: [] }] };
4672
+ seeded.seedNote.dialogue = dialogueRefs(project, pseudo);
4673
+ return seeded;
4387
4674
  }
4388
4675
 
4389
4676
  export function seedStoryboard(project, episode) {
@@ -4489,8 +4776,8 @@ export function imagePrompt(project, id, asset) {
4489
4776
  const l = (sc.lighting || []).find((x) => x.state === state);
4490
4777
  if (!l) throw new Error(`${id} has no lighting state "${state}" (${(sc.lighting || []).map((x) => x.state).join(' / ')})`);
4491
4778
  return out(zh
4492
- ? `${t('@image1 是同一个空间;结构、材质与磨损完全一致,只改变光线:')}${l.prompt}${t(`。空场景,没有任何人,${kit.framePhrase}`)}`
4493
- : `@image1 is this exact space; keep structure, materials and wear identical; change only the lighting: ${l.prompt}. Empty scene, no people, ${kit.framePhrase}`, '16:9', 'sheet');
4779
+ ? `${t('@image1 是同一个空间;结构、材质与状态完全一致,只改变光线:')}${l.prompt}${t(`。空场景,没有任何人,${kit.framePhrase}`)}`
4780
+ : `@image1 is this exact space; keep structure, materials and condition identical; change only the lighting: ${l.prompt}. Empty scene, no people, ${kit.framePhrase}`, '16:9', 'sheet');
4494
4781
  }
4495
4782
  if (pr && kind.startsWith('state:')) {
4496
4783
  const state = kind.slice('state:'.length);
@@ -4542,22 +4829,25 @@ export function segmentReferences(project, episode, seg) {
4542
4829
  // Optional per-segment image ref plan (Director Strategy B etc.) — pictures only; voices/video still appended below.
4543
4830
  if (Array.isArray(seg.refOverride) && seg.refOverride.length) {
4544
4831
  for (const r of seg.refOverride) {
4832
+ const pinned = { entity: r.entity, asset: r.asset || 'sheet' };
4833
+ const notTake = r.notTake ? ` \u2014 take only what it is for, not ${r.notTake}` : '';
4545
4834
  refs.push({
4546
4835
  entity: r.entity,
4547
4836
  asset: r.asset || 'sheet',
4548
4837
  why: r.why || `${r.entity}:${r.asset || 'sheet'}`,
4549
- use: r.use || 'a reference image for this segment',
4550
- zh: r.zh || '',
4838
+ use: r.use || r.take || `${sheetUse(pinned, project)}${notTake}`,
4839
+ zh: r.zh || r.takeZh || sheetZh(pinned, project),
4840
+ role: r.role || null,
4551
4841
  kind: 'image',
4552
4842
  faceOf: r.faceOf || null,
4553
4843
  });
4554
4844
  }
4555
4845
  } else {
4556
- refs.push({ entity: scene.sceneId, asset: 'sheet', why: `environment: ${sceneEntity.name || scene.sceneId}${scene.lighting ? ` (${scene.lighting})` : ''}`, use: 'the environment \u2014 match its layout, materials and wear exactly', zh: '环境,严格还原布局、材质与旧痕' });
4557
- if (scene.lighting && sceneEntity.wji?.assets?.lighting?.[scene.lighting]) refs.push({ entity: scene.sceneId, asset: `lighting:${scene.lighting}`, why: `lighting state ${scene.lighting}`, use: 'the same environment in the lighting state to use', zh: '同一场景的光线状态' });
4846
+ refs.push({ entity: scene.sceneId, asset: 'sheet', role: 'environment', why: `environment: ${sceneEntity.name || scene.sceneId}${scene.lighting ? ` (${scene.lighting})` : ''}`, use: sheetUse({ entity: scene.sceneId, asset: 'sheet' }, project), zh: sheetZh({ entity: scene.sceneId, asset: 'sheet' }, project) });
4847
+ if (scene.lighting && sceneEntity.wji?.assets?.lighting?.[scene.lighting]) refs.push({ entity: scene.sceneId, asset: `lighting:${scene.lighting}`, role: 'lighting', why: `lighting state ${scene.lighting}`, use: sheetUse({ entity: scene.sceneId, asset: `lighting:${scene.lighting}` }, project), zh: sheetZh({ entity: scene.sceneId, asset: `lighting:${scene.lighting}` }, project) });
4558
4848
  const seen = new Set();
4559
- if (params.humanReferences !== false) for (const c of seg.cuts || []) for (const id of c.characters || []) if (!seen.has(id)) { seen.add(id); refs.push({ entity: id, asset: 'sheet', why: `face and clothes of ${characterById(project, id)?.name || id}`, use: 'the character sheet of one person in this segment \u2014 match face, hair and clothes exactly', zh: '人物三视图,严格还原面容、发型与服装' }); }
4560
- for (const c of seg.cuts || []) for (const id of c.props || []) if (!seen.has(id)) { seen.add(id); refs.push({ entity: id, asset: 'sheet', why: `prop ${propById(project, id)?.name || id}`, use: 'the prop to reproduce exactly', zh: '需要严格还原的道具' }); }
4849
+ 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', role: 'character', why: `face and clothes of ${characterById(project, id)?.name || id}`, use: sheetUse({ entity: id, asset: 'sheet' }, project), zh: sheetZh({ entity: id, asset: 'sheet' }, project) }); }
4850
+ 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', role: 'prop', why: `prop ${propById(project, id)?.name || id}`, use: sheetUse({ entity: id, asset: 'sheet' }, project), zh: sheetZh({ entity: id, asset: 'sheet' }, project) }); }
4561
4851
  refs.forEach((r) => (r.kind = 'image'));
4562
4852
  }
4563
4853
  // The clip before this one, when the project asks for it: the cheapest continuity there is — the
@@ -4712,6 +5002,19 @@ export function promptSkeleton(project, episode, segmentId) {
4712
5002
  return [sidesLine(axis, whoOf, framingLang)];
4713
5003
  };
4714
5004
  const othersOnScreen = (c, speaker) => (c.characters || []).filter((id) => id !== speaker && id !== c.ots).map((id) => (params.references === 'sheets' && sheetLabels.get(id) ? `the person in ${sheetLabels.get(id)}` : identityOf(characterById(project, id))));
5005
+ // A prop on screen is bound by its label INSIDE that shot — a prop named only in the legend is a
5006
+ // prop the model designs itself the moment it appears. Same rule people already had.
5007
+ const propOf = (c, lang = 'en') => (c.props || []).map((id) => sheetLabels.get(id)).filter(Boolean)
5008
+ .map((label) => (lang === 'zh' ? `道具严格照 ${label};` : `the prop is ${label}, reproduced exactly; `)).join('');
5009
+ // The environment is one picture for the whole segment: say once that it does not change, so the
5010
+ // model does not re-invent the room between shots.
5011
+ const sceneLine = (lang = 'en') => {
5012
+ const label = sheetLabels.get(scene?.sceneId);
5013
+ if (!label || params.references !== 'sheets') return '';
5014
+ return lang === 'zh'
5015
+ ? `整段都在 ${label} 的空间里:布局、材质与旧痕不变,不要重新设计场景。`
5016
+ : `Every shot stays inside ${label} \u2014 its layout, materials and wear do not change; do not redesign the room.`;
5017
+ };
4715
5018
  let video;
4716
5019
  if (mode.family === 'h3') {
4717
5020
  const sheets = params.references === 'sheets';
@@ -4727,14 +5030,14 @@ export function promptSkeleton(project, episode, segmentId) {
4727
5030
  // In sheets mode the cut's own `frame` goes into the prompt and already carries the lens, the
4728
5031
  // light and the foreground (gated there), so only the shot size and camera move are added.
4729
5032
  const body = sheets
4730
- ? `${framingOf(c)}${c.frame || '…'} ${SIZES[c.size] || c.size}, ${c.camera}.`
5033
+ ? `${framingOf(c)}${c.frame || '…'} ${SIZES[c.size] || c.size}, ${c.camera}. ${propOf(c, framingLang)}`
4731
5034
  : `… anchor on @image${k + 1} · ${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}. ${lightPhrase(c)}.${foregroundPhrase(c)}`;
4732
5035
  return `${head}${body} ${motionSentence(c)} ${idleOf(c)}${cameraClause(c)} ${d}${lips}`.replace(/\s+/g, ' ').trim();
4733
5036
  });
4734
5037
  // Keyframes are aligned by the alignment line; the voices are named either way.
4735
5038
  const voiceLegend = allRefs.filter((r) => r.kind !== 'image').map((r) => `${r.label} is ${r.use}.`);
4736
5039
  const lead = sheets
4737
- ? [...allRefs.map((r) => `${r.label} is ${r.use}.`), ...sidesOf()]
5040
+ ? [...allRefs.map((r) => `${r.label} is ${r.use}.`), ...[sceneLine(framingLang)].filter(Boolean), ...sidesOf()]
4738
5041
  : [h3AlignmentLine(cuts), ...voiceLegend];
4739
5042
  const look = sheets ? [`Look: ${lookOf()}.`, ''] : [];
4740
5043
  const soundscape = [sound.ambience || '…', ...cuts.map((c, k) => (sfxOf(k) ? `at [Shot ${k + 1}] ${sfxOf(k)}` : '')).filter(Boolean)].join('; ');
@@ -4745,18 +5048,18 @@ export function promptSkeleton(project, episode, segmentId) {
4745
5048
  // Wan's prompt is Chinese: the lens and the light direction translate, the note and the
4746
5049
  // foreground are the author's English prose, so they are left as blanks to write in Chinese.
4747
5050
  const shot = `${cameraPhrase(c, params, 'zh')}。${LIGHT_DIRECTIONS_ZH[c.light?.direction] || '…'},…。前景:${c.foreground ? '…(虚化)' : '无'}。`;
4748
- return [`${wanShotHeader(cuts, k)}${framingOf(c)}…${shot}${motionSentence(c, 'zh')}${idleOf(c)}${cameraClause(c, 'zh')}`, ...talk].join('\n');
5051
+ return [`${wanShotHeader(cuts, k)}${framingOf(c)}…${shot}${motionSentence(c, 'zh')}${idleOf(c)}${propOf(c, 'zh')}${cameraClause(c, 'zh')}`, ...talk].join('\n');
4749
5052
  });
4750
5053
  const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `镜头${k + 1}:${sfxOf(k)}` : '')).filter(Boolean).join(';');
4751
5054
  video = [
4752
- params.references === 'sheets' ? wanSheetLegendLine(allRefs) : wanLegendLine(cuts, allRefs), ...sidesOf(), '',
5055
+ params.references === 'sheets' ? wanSheetLegendLine(allRefs) : wanLegendLine(cuts, allRefs), ...[sceneLine('zh')].filter(Boolean), ...sidesOf(), '',
4753
5056
  ...blocks.flatMap((b) => [b, '']),
4754
5057
  ...(cine && params.references === 'sheets' ? [`画面:${cine.videoPhraseZh}${params.lookPhraseZh ? `,${params.lookPhraseZh}` : ''}。`, ''] : []),
4755
5058
  ...(grammarLines(seg, params, 'zh').length ? [...grammarLines(seg, params, 'zh'), ''] : []),
4756
5059
  `音频:对白为${accent || '…'},环境音${sound.ambience || '…'}${sfxAll ? `;${sfxAll}` : ''}${clipMusic ? `;配乐:${clipMusic}` : params.audioTrack === 'post' ? ';无配乐' : ''}。${params.subtitles ? '' : `${NO_SUBTITLES.zh}。`}`,
4757
5060
  ].join('\n');
4758
5061
  } else if (params.references === 'sheets') {
4759
- const legend = [...allRefs.map((r) => `${r.label} is ${r.use}.`), ...sidesOf()];
5062
+ const legend = [...allRefs.map((r) => `${r.label} is ${r.use}.`), ...[sceneLine(framingLang)].filter(Boolean), ...sidesOf()];
4760
5063
  const shots = cuts.map((c, k) => {
4761
5064
  const talk = dialogue[k].map((l) => {
4762
5065
  const base = `${l.speakerRef}${l.direction ? `, ${l.direction},` : ''} says: ${l.line}`;
@@ -4767,7 +5070,7 @@ export function promptSkeleton(project, episode, segmentId) {
4767
5070
  return `${base} while ${others.join(' and ')} ${others.length > 1 ? 'do' : 'does'} not speak, ${LIPS_CLOSED}`;
4768
5071
  }).join(' ');
4769
5072
  // The frame already carries the lens, the light and the foreground — do not repeat them.
4770
- return `Shot ${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s, ${SIZES[c.size] || c.size}, ${c.camera}): ${framingOf(c)}${c.frame || '…'} ${motionSentence(c)} ${idleOf(c)}${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
5073
+ return `Shot ${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s, ${SIZES[c.size] || c.size}, ${c.camera}): ${framingOf(c)}${c.frame || '…'} ${motionSentence(c)} ${idleOf(c)}${propOf(c, framingLang)}${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
4771
5074
  });
4772
5075
  const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `shot ${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
4773
5076
  video = [...legend, '', ...shots, '', ...actionLine, `Look: ${lookOf()}. Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis across every shot.`, `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${clipMusic ? ` Music: ${clipMusic}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
@@ -4783,7 +5086,7 @@ export function promptSkeleton(project, episode, segmentId) {
4783
5086
  if (!others.length) return base;
4784
5087
  return `${base} while ${others.join(' and ')} ${others.length > 1 ? 'do' : 'does'} not speak, ${LIPS_CLOSED}`;
4785
5088
  }).join(' ');
4786
- return `From @image${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s): ${motionSentence(c)} ${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
5089
+ return `From @image${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s): ${motionSentence(c)} ${propOf(c, framingLang)}${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
4787
5090
  });
4788
5091
  const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `from @image${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
4789
5092
  video = [...anchors, ...states, '', ...script, '', ...actionLine, 'Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis.', `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${clipMusic ? ` Music: ${clipMusic}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
@@ -4953,31 +5256,144 @@ export function scriptSelfCheck(project, episode) {
4953
5256
  ['结尾是否有明确【钩子标记】?', !!String(episode.cliff || '').trim() && !!episode.hookType, `${episode.hookType || ''}:${episode.cliff || ''}`],
4954
5257
  ['对白是否符合「谈话 + 行动」?', ta.longestLineRun <= params.maxConsecutiveLines && (ta.lines < 5 || ta.ratio >= params.minActionRatio), `${ta.actions} 动作 / ${ta.lines} 台词,最长连续 ${ta.longestLineRun} 句`],
4955
5258
  ['人物行为是否可拍摄,没有小说式心理描写?', !novel, novel ? '动作里有心理词' : ''],
5259
+ ...(params.dialogueReview === 'off' ? [] : [['对白是否已按语体 guide 逐句校对?', dialogueReviewDone(episode), dialogueReviewNote(episode)]]),
4956
5260
  ];
4957
5261
  return { rows, score: rows.filter((r) => r[1]).length * 2, max: rows.length * 2 };
4958
5262
  }
4959
5263
 
4960
- /** The 对白校对 worksheet: one row per line, 原句 filled, the rest for the polish pass. */
5264
+ /** True when every current line has a filled, applied review row. */
5265
+ export function dialogueReviewDone(episode) {
5266
+ const rv = episode.dialogueReview;
5267
+ if (!isObject(rv) || !DIALOGUE_LEVELS.includes(rv.level)) return false;
5268
+ const byPos = new Map(asList(rv.rows).map((r) => [`${r.scene}:${r.beat}`, r]));
5269
+ return lineBeats(episode).every((l) => { const r = byPos.get(`${l.scene}:${l.beat}`); return r && r.revised === l.line && String(r.issue || '').trim() && String(r.note || '').trim(); });
5270
+ }
5271
+ function dialogueReviewNote(episode) {
5272
+ const rv = episode.dialogueReview;
5273
+ if (!isObject(rv)) return '未校对';
5274
+ const rows = asList(rv.rows);
5275
+ const changed = rows.filter((r) => r.revised !== r.original).length;
5276
+ return `${rv.level || '?'} · ${asList(rv.guides).join('、')} · 改 ${changed} / ${rows.length} 句`;
5277
+ }
5278
+
5279
+ /** Every line beat of an episode, in order, with its scene / beat position (1-based). */
5280
+ export function lineBeats(episode) {
5281
+ const out = [];
5282
+ (episode.scenes || []).forEach((sc, i) => (sc.flow || []).forEach((b, k) => {
5283
+ if (b.line !== undefined) out.push({ scene: i + 1, beat: k + 1, speaker: b.speaker, line: String(b.line), delivery: b.delivery || '' });
5284
+ }));
5285
+ return out;
5286
+ }
5287
+
5288
+ /**
5289
+ * The dialogue skill per speaker of this episode: the 语体 (their bible's override, else the drama's),
5290
+ * the language guide to read, and — where a corpus exists — the `ref.mjs` query that pulls real
5291
+ * phrasing for someone of their age, so lines are drawn from the corpus instead of invented.
5292
+ */
5293
+ export function dialogueRefs(project, episode, opts = {}) {
5294
+ const params = paramsOf(project);
5295
+ const dir = opts.skillDir !== undefined ? opts.skillDir : dialogueSkillDir();
5296
+ const root = dir || `<skills>/${DIALOGUE_SKILL}`;
5297
+ const ids = [];
5298
+ for (const l of lineBeats(episode)) if (l.speaker !== 'VO' && !ids.includes(l.speaker)) ids.push(l.speaker);
5299
+ if (!ids.length) for (const sc of episode.scenes || []) for (const id of sc.characters || []) if (!ids.includes(id)) ids.push(id);
5300
+ const speakers = ids.map((id) => {
5301
+ const ch = characterById(project, id);
5302
+ const register = normalizeRegister(ch?.bible?.speech?.register || params.register);
5303
+ const g = DIALOGUE_GUIDES[register];
5304
+ const age = corpusAge(parseAge(ch?.profile?.ageRange));
5305
+ const query = g?.corpus ? `node ${root}/scripts/ref.mjs --lang ${g.lang} list${age ? ` --age ${age}` : ''} --limit 30` : '';
5306
+ return { id, name: displayName(project, id, episode.ep), register, guide: g ? `${root}/references/${g.guide}` : '', lang: g?.lang || '', age: age || '', query };
5307
+ });
5308
+ const guides = [...new Set(speakers.map((x) => x.guide).filter(Boolean))];
5309
+ return {
5310
+ skill: DIALOGUE_SKILL,
5311
+ installed: !!dir,
5312
+ framework: `${root}/references/dialogue_revision_framework.md`,
5313
+ guides,
5314
+ speakers,
5315
+ rule: 'read the guide of every 语体 in this episode before writing a line; pull phrasing from each speaker\'s corpus query (`search <字>` for a word); never invent slang or particles; finish with the guide\'s 自检清单',
5316
+ };
5317
+ }
5318
+
5319
+ /**
5320
+ * The 对白校对 record (`episode.dialogueReview`), seeded from the current lines. Rows already filled
5321
+ * for a line that has not changed since are kept, so a re-seed after editing the script only asks
5322
+ * for the lines that moved.
5323
+ */
5324
+ export function seedDialogue(project, episode, opts = {}) {
5325
+ const refs = dialogueRefs(project, episode, opts);
5326
+ const prev = opts.force ? null : (isObject(episode.dialogueReview) ? episode.dialogueReview : null);
5327
+ const kept = new Map();
5328
+ for (const r of asList(prev?.rows)) kept.set(`${r.scene}:${r.beat}:${r.speaker}:${r.revised}`, r);
5329
+ const rows = lineBeats(episode).map((l) => kept.get(`${l.scene}:${l.beat}:${l.speaker}:${l.line}`) || {
5330
+ scene: l.scene, beat: l.beat, speaker: l.speaker, original: l.line, issue: '', revised: l.line, reason: '', note: '',
5331
+ });
5332
+ const diagnosis = {};
5333
+ for (const k of Object.keys(DIALOGUE_DIAGNOSIS)) diagnosis[k] = prev?.diagnosis?.[k] || '';
5334
+ return {
5335
+ level: prev?.level || '',
5336
+ guides: refs.guides.map((g) => basename(g)),
5337
+ diagnosis,
5338
+ rows,
5339
+ selfCheck: asList(prev?.selfCheck),
5340
+ };
5341
+ }
5342
+
5343
+ /** Write each row's `revised` (and `delivery`, when the row sets one) back into the script's flow. */
5344
+ export function applyDialogue(episode) {
5345
+ const review = episode.dialogueReview;
5346
+ if (!isObject(review)) throw new Error('no dialogueReview — run `seed <project> dialogue --ep N` first');
5347
+ let changed = 0;
5348
+ for (const r of asList(review.rows)) {
5349
+ const b = episode.scenes?.[Number(r.scene) - 1]?.flow?.[Number(r.beat) - 1];
5350
+ if (!b || b.line === undefined) throw new Error(`row scene ${r.scene} beat ${r.beat} is not a line — re-seed the review`);
5351
+ if (b.speaker !== r.speaker) throw new Error(`row scene ${r.scene} beat ${r.beat}: speaker ${r.speaker} but the script has ${b.speaker} — re-seed the review`);
5352
+ const revised = String(r.revised ?? '').trim();
5353
+ if (!revised) throw new Error(`row scene ${r.scene} beat ${r.beat}: revised is empty — a line to cut is removed from the flow, then the review re-seeded`);
5354
+ if (b.line !== revised) { b.line = revised; changed += 1; }
5355
+ if (r.delivery !== undefined && String(r.delivery).trim() && b.delivery !== r.delivery) { b.delivery = r.delivery; changed += 1; }
5356
+ }
5357
+ return changed;
5358
+ }
5359
+
5360
+ /** The 对白校对 sheet: the filled review when there is one, otherwise the blank worksheet. */
4961
5361
  export function renderDialogue(project, episode) {
4962
5362
  const params = paramsOf(project);
5363
+ const review = isObject(episode.dialogueReview) ? episode.dialogueReview : null;
5364
+ const refs = dialogueRefs(project, episode);
5365
+ const d = review?.diagnosis || {};
4963
5366
  const lines = [`# ${project.title} · 第 ${episode.ep} 集对白校对`, '', mdTable(['项目', '判断'], [
4964
- ['语体', params.register || ''], ['场面目标', ''], ['主要冲突', ''], ['潜台词核心', ''], ['角色声音问题', ''], ['节奏问题', ''], ['建议修改幅度', '轻修/中修/重修'],
5367
+ ['语体', params.register || ''],
5368
+ ...Object.entries(DIALOGUE_DIAGNOSIS).map(([k, label]) => [label, d[k] || '']),
5369
+ ['修改幅度', review?.level || '轻修/中修/重修'],
4965
5370
  ]), ''];
5371
+ if (!review) {
5372
+ lines.push('## 语体 guide 与语料库', '', `技能 \`${refs.skill}\`${refs.installed ? '' : '(未安装 — 把它放在本技能旁或 ~/.claude/skills)'};先读 \`${refs.framework}\`。`, '');
5373
+ lines.push(mdTable(['角色', '语体', 'guide', '语料库查询'], refs.speakers.map((x) => [x.name, x.register, x.guide ? `\`${x.guide}\`` : '—', x.query ? `\`${x.query}\`` : '(此语体无语料库,只按 guide)'])), '');
5374
+ }
5375
+ const byPos = new Map(asList(review?.rows).map((r) => [`${r.scene}:${r.beat}`, r]));
4966
5376
  (episode.scenes || []).forEach((sc, i) => {
4967
5377
  const scene = sceneById(project, sc.sceneId);
4968
5378
  lines.push(`## ${i + 1}. ${scene?.name || sc.sceneId}`, '');
4969
5379
  const rows = [];
4970
- (sc.flow || []).forEach((b) => {
5380
+ (sc.flow || []).forEach((b, k) => {
4971
5381
  if (b.line === undefined) return;
4972
5382
  const ch = characterById(project, b.speaker);
4973
5383
  const sp = ch?.bible?.speech || {};
4974
5384
  const who = b.speaker === 'VO' ? 'VO' : displayName(project, b.speaker, episode.ep);
4975
- const hint = [sp.rhythm, sp.silence ? `沉默:${sp.silence}` : '', (sp.forbiddenWords || []).length ? `禁用:${sp.forbiddenWords.join('、')}` : ''].filter(Boolean).join(';');
4976
- rows.push([`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`, '', '', '', hint]);
5385
+ const r = byPos.get(`${i + 1}:${k + 1}`);
5386
+ if (r) {
5387
+ rows.push([`${who}${b.delivery ? `(${b.delivery})` : ''}:${r.original}`, r.issue || '', r.revised === r.original ? '(保留)' : r.revised, r.reason || '', r.note || '']);
5388
+ } else {
5389
+ const hint = [sp.rhythm, sp.silence ? `沉默:${sp.silence}` : '', (sp.forbiddenWords || []).length ? `禁用:${sp.forbiddenWords.join('、')}` : ''].filter(Boolean).join(';');
5390
+ rows.push([`${who}${b.delivery ? `(${b.delivery})` : ''}:${b.line}`, '', '', '', hint]);
5391
+ }
4977
5392
  });
4978
5393
  lines.push(mdTable(['原句', '问题', '修改句', '修改理由', '导演/演员提示'], rows), '');
4979
5394
  });
4980
- lines.push('> 每句检查:有没有戏剧功能?遮住名字还认得出是谁在说?真正想说的有没有藏起来?熟人有没有互相解释已知的事?导演提示用可演动词(逼、哄、避、试探),不用情绪形容词。', '');
5395
+ if (asList(review?.selfCheck).length) lines.push('## 自检', '', mdTable(['检查项', '结论', '说明'], asList(review.selfCheck).map((c) => [c.item || '', c.ok ? '是' : '否', c.note || ''])), '');
5396
+ if (!review) lines.push('> 每句检查:有没有戏剧功能?遮住名字还认得出是谁在说?真正想说的有没有藏起来?熟人有没有互相解释已知的事?导演提示用可演动词(逼、哄、避、试探),不用情绪形容词。', '');
4981
5397
  return lines.join('\n');
4982
5398
  }
4983
5399
 
@@ -5494,9 +5910,23 @@ function main(argv) {
5494
5910
  if (cmd === 'seed') {
5495
5911
  const what = pos[1];
5496
5912
  const ep = Number(flag(rest, '--ep'));
5497
- if (!['script', 'storyboard'].includes(what) || !ep) throw new Error('usage: seed <project> script|storyboard --ep N [--force]');
5913
+ if (!['script', 'storyboard', 'dialogue'].includes(what) || !ep) throw new Error('usage: seed <project> script|dialogue|storyboard --ep N [--force]');
5498
5914
  const { paths, project, episodes } = loadProject(input);
5499
5915
  const file = join(paths.episodes, `E${pad2(ep)}.json`);
5916
+ if (what === 'dialogue') {
5917
+ const episode = episodes[ep];
5918
+ if (!episode) throw new Error(`${file} does not exist — seed and write the script first`);
5919
+ if (!lineBeats(episode).length) throw new Error(`${file} has no lines to review`);
5920
+ episode.dialogueReview = seedDialogue(project, episode, { force: Boolean(flag(rest, '--force')) });
5921
+ writeJsonFile(file, episode);
5922
+ const rv = episode.dialogueReview;
5923
+ const refs = dialogueRefs(project, episode);
5924
+ const open = rv.rows.filter((r) => !String(r.issue || '').trim()).length;
5925
+ console.log(`✓ ${file} · dialogueReview: ${rv.rows.length} lines (${open} to review) · read ${refs.framework}`);
5926
+ for (const x of refs.speakers) console.log(` ${x.name} · ${x.register} · ${x.guide || '(no guide)'}${x.query ? `\n ${x.query}` : ''}`);
5927
+ if (!refs.installed) console.log(` ! ${DIALOGUE_SKILL} is not installed beside this skill or in ~/.claude/skills`);
5928
+ return 0;
5929
+ }
5500
5930
  if (what === 'script') {
5501
5931
  if (existsSync(file) && !flag(rest, '--force')) throw new Error(`${file} exists (pass --force to overwrite)`);
5502
5932
  const seeded = seedScript(project, ep);
@@ -5513,6 +5943,20 @@ function main(argv) {
5513
5943
  return 0;
5514
5944
  }
5515
5945
 
5946
+ if (cmd === 'apply') {
5947
+ const what = pos[1];
5948
+ const ep = Number(flag(rest, '--ep'));
5949
+ if (what !== 'dialogue' || !ep) throw new Error('usage: apply <project> dialogue --ep N');
5950
+ const { paths, episodes } = loadProject(input);
5951
+ const file = join(paths.episodes, `E${pad2(ep)}.json`);
5952
+ const episode = episodes[ep];
5953
+ if (!episode) throw new Error(`${file} does not exist`);
5954
+ const changed = applyDialogue(episode);
5955
+ writeJsonFile(file, episode);
5956
+ console.log(`✓ ${file} · ${changed} change(s) written into the script — re-run validate --stage script --ep ${ep}`);
5957
+ return 0;
5958
+ }
5959
+
5516
5960
  if (cmd === 'dashboard') {
5517
5961
  const loaded = loadProject(input);
5518
5962
  const outputFlag = flag(rest, '--output');