@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.
- package/README.md +7 -1
- package/dist/index.js +156 -11
- package/package.json +1 -1
- package/skill/backreel/SKILL.md +6 -2
- package/skill/backreel/reference/episodes.md +45 -7
- package/skill/short-drama/SKILL.md +27 -4
- package/skill/short-drama/examples/night-shift/episodes/E01.json +173 -1
- package/skill/short-drama/examples/night-shift/project.json +6 -0
- package/skill/short-drama/reference/dialogue-craft.md +16 -11
- package/skill/short-drama/reference/prompt-audit.mjs +308 -0
- package/skill/short-drama/reference/reference-binding.md +84 -0
- package/skill/short-drama/reference/schema.md +14 -5
- package/skill/short-drama/reference/stage-characters.md +1 -1
- package/skill/short-drama/reference/stage-script.md +54 -7
- package/skill/short-drama/reference/stage-storyboard.md +29 -24
- package/skill/short-drama/scripts/selftest.mjs +154 -9
- package/skill/short-drama/scripts/short-drama.mjs +467 -23
- package/skill/w-film-dialogue-editor/CHANGELOG.md +44 -0
- package/skill/w-film-dialogue-editor/SKILL.md +124 -0
- package/skill/w-film-dialogue-editor/references/data/README.md +222 -0
- package/skill/w-film-dialogue-editor/references/data/en/core.json +1666 -0
- package/skill/w-film-dialogue-editor/references/data/en/slang.json +1602 -0
- package/skill/w-film-dialogue-editor/references/data/en/work.json +1722 -0
- package/skill/w-film-dialogue-editor/references/data/en-sentences.csv +134 -0
- package/skill/w-film-dialogue-editor/references/data/en-sentences.md +146 -0
- package/skill/w-film-dialogue-editor/references/data/ja/core.json +1978 -0
- package/skill/w-film-dialogue-editor/references/data/ja/slang.json +1927 -0
- package/skill/w-film-dialogue-editor/references/data/ja/work.json +1972 -0
- package/skill/w-film-dialogue-editor/references/data/ja-sentences.csv +134 -0
- package/skill/w-film-dialogue-editor/references/data/ja-sentences.md +146 -0
- package/skill/w-film-dialogue-editor/references/data/ko/core.json +1839 -0
- package/skill/w-film-dialogue-editor/references/data/ko/slang.json +1897 -0
- package/skill/w-film-dialogue-editor/references/data/ko/work.json +1829 -0
- package/skill/w-film-dialogue-editor/references/data/ko-sentences.csv +134 -0
- package/skill/w-film-dialogue-editor/references/data/ko-sentences.md +146 -0
- package/skill/w-film-dialogue-editor/references/data/zh-cn/core.json +1794 -0
- package/skill/w-film-dialogue-editor/references/data/zh-cn/slang.json +1609 -0
- package/skill/w-film-dialogue-editor/references/data/zh-cn/work.json +1648 -0
- package/skill/w-film-dialogue-editor/references/data/zh-cn-sentences.csv +134 -0
- package/skill/w-film-dialogue-editor/references/data/zh-cn-sentences.md +146 -0
- package/skill/w-film-dialogue-editor/references/data/zh-hk/core.json +2476 -0
- package/skill/w-film-dialogue-editor/references/data/zh-hk/gen-z.json +3263 -0
- package/skill/w-film-dialogue-editor/references/data/zh-hk/older.json +3632 -0
- package/skill/w-film-dialogue-editor/references/data/zh-hk/rough.json +1668 -0
- package/skill/w-film-dialogue-editor/references/data/zh-hk/workplace.json +3697 -0
- package/skill/w-film-dialogue-editor/references/data/zh-hk-sentences.csv +373 -0
- package/skill/w-film-dialogue-editor/references/data/zh-hk-sentences.md +391 -0
- package/skill/w-film-dialogue-editor/references/data/zh-tw/core.json +1640 -0
- package/skill/w-film-dialogue-editor/references/data/zh-tw/slang.json +1537 -0
- package/skill/w-film-dialogue-editor/references/data/zh-tw/work.json +1699 -0
- package/skill/w-film-dialogue-editor/references/data/zh-tw-sentences.csv +132 -0
- package/skill/w-film-dialogue-editor/references/data/zh-tw-sentences.md +144 -0
- package/skill/w-film-dialogue-editor/references/dialogue_revision_framework.md +84 -0
- package/skill/w-film-dialogue-editor/references/en-index.md +265 -0
- package/skill/w-film-dialogue-editor/references/ja-index.md +633 -0
- package/skill/w-film-dialogue-editor/references/ko-index.md +498 -0
- package/skill/w-film-dialogue-editor/references/lang-en.md +242 -0
- package/skill/w-film-dialogue-editor/references/lang-ja.md +312 -0
- package/skill/w-film-dialogue-editor/references/lang-ko.md +290 -0
- package/skill/w-film-dialogue-editor/references/lang-zh-cn.md +60 -0
- package/skill/w-film-dialogue-editor/references/lang-zh-hk-mandarin.md +34 -0
- package/skill/w-film-dialogue-editor/references/lang-zh-hk.md +306 -0
- package/skill/w-film-dialogue-editor/references/lang-zh-period.md +38 -0
- package/skill/w-film-dialogue-editor/references/lang-zh-tw.md +57 -0
- package/skill/w-film-dialogue-editor/references/zh-cn-index.md +636 -0
- package/skill/w-film-dialogue-editor/references/zh-hk-index.md +1450 -0
- package/skill/w-film-dialogue-editor/references/zh-tw-index.md +457 -0
- package/skill/w-film-dialogue-editor/scripts/rebuild.sh +13 -0
- package/skill/w-film-dialogue-editor/scripts/ref.mjs +539 -0
- 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
|
-
|
|
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
|
|
4493
|
-
: `@image1 is this exact space; keep structure, materials and
|
|
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 ||
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
4976
|
-
|
|
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');
|