@wji.ai/cli 0.9.1 → 0.9.4
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 +6 -1
- package/dist/index.js +109 -32
- package/package.json +1 -1
- package/skill/backreel/SKILL.md +29 -15
- package/skill/backreel/reference/crowdfunding.md +61 -30
- package/skill/backreel/reference/episodes.md +7 -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
|
@@ -36,8 +36,11 @@ Either way, this skill only says **what to build**; the operations doc says how
|
|
|
36
36
|
|
|
37
37
|
**How the drama is written** comes from `{baseDir}/reference/craft-story.md` (the 短剧 method:
|
|
38
38
|
brief, three phases, 分集目录, 爽点 / 钩子 libraries, the episode formula, the character bible) and
|
|
39
|
-
`{baseDir}/reference/dialogue-craft.md` (lines as action, 语体 rules, the
|
|
39
|
+
`{baseDir}/reference/dialogue-craft.md` (lines as action, 语体 rules, the dialogue review). Read the
|
|
40
40
|
first before stage 1 and the second before stage 4. The gates encode what those two can check.
|
|
41
|
+
The full dialogue method is the user's **`w-film-dialogue-editor`** skill, installed beside this
|
|
42
|
+
one: its language guides and corpus are read while the lines are written and again in the required
|
|
43
|
+
dialogue review after the script.
|
|
41
44
|
|
|
42
45
|
**Re-read the guideline.** The 创作指引 (`render guideline`) is the drama's top rule document.
|
|
43
46
|
Print it before writing any character, any episode and any storyboard, and print the speakers'
|
|
@@ -277,6 +280,7 @@ Read `{baseDir}/reference/stage-script.md`.
|
|
|
277
280
|
writes `episodes/E0N.json` with target seconds, hook/cliff, claimed beats, the episode design
|
|
278
281
|
(`function` / `task` / `change` / `hookType`, `satisfactionBeat` to set) and a `seedNote`
|
|
279
282
|
(synopsis, the 爽点 to realise, register, guideline priorities, every speaker's `bible.speech`,
|
|
283
|
+
the dialogue skill's guides and per-speaker corpus queries in `dialogue`,
|
|
280
284
|
ids, previous cliff, budget). Fill `scenes[].flow` — action beats and line beats alternating,
|
|
281
285
|
**common actions only**, hook image inside the first 3 beats and by 15 s, a `turn: true` beat
|
|
282
286
|
at least every 30 s, the 爽点 on an action (`satisfactionBeat`), cliff as the last beat; lines
|
|
@@ -286,9 +290,13 @@ Read `{baseDir}/reference/stage-script.md`.
|
|
|
286
290
|
Delete `seedNote` when done.
|
|
287
291
|
2. `validate --stage script --ep N` — duration math, line length, speakers, hook window, the
|
|
288
292
|
rhythm, the design, filmability, upstream ids; the register gate only advises. Over budget: cut
|
|
289
|
-
actions first; under: add conflict, not small talk.
|
|
290
|
-
|
|
291
|
-
|
|
293
|
+
actions first; under: add conflict, not small talk.
|
|
294
|
+
3. **Dialogue review** (required, gate `dialogue-review`): `seed dialogue --ep N` → read the
|
|
295
|
+
`w-film-dialogue-editor` framework and the guide of each 语体 it prints, query the corpus per
|
|
296
|
+
speaker → fill the diagnosis, level, one row per line (issue / revised / reason / playable
|
|
297
|
+
note) and the guide's 自检 → `apply dialogue --ep N` → re-validate. A line edited later is
|
|
298
|
+
reviewed again. Details in `stage-script.md`.
|
|
299
|
+
4. Sync: `render script --ep N` (the 短剧 episode format with the 创作自检 table) → `story/` dataset
|
|
292
300
|
`E0N 剧本` (type `screenplay`, `viewMode=content`); bind `wji.screenplayDatasetId` with `--ep N`.
|
|
293
301
|
|
|
294
302
|
## Stage 5 — Storyboard (per episode)
|
|
@@ -307,6 +315,21 @@ prompt, alongside the camera package and each speaker's delivery.
|
|
|
307
315
|
|
|
308
316
|
**Two people talking** (gate `two-person-framing`, sheets mode): a two-shot once, then over-the-shoulder (`ots`) for the exchange, a clean single for the punchline. A single names only its person; every look states a screen direction (`eyeline`); the sides stay locked. See `reference/craft-shots.md`.
|
|
309
317
|
|
|
318
|
+
**Every picture says what it is, and what NOT to copy from it** (gate `ref-binding`, sheets mode).
|
|
319
|
+
The model reads one string: a legend line that only says "a character sheet" for two different
|
|
320
|
+
people is how faces get swapped, and a shot that never names its `@imageK` is a shot where the
|
|
321
|
+
model invents the face or the prop. So — one legend line per picture, naming a person by age /
|
|
322
|
+
hair / clothes from the character bible, the room by its name, a prop by its name; every picture
|
|
323
|
+
that is **not** a person also says what may not be copied from it (*take only its light ratios,
|
|
324
|
+
contrast and colour temperature — never its composition or its people*); the room is named once
|
|
325
|
+
for the whole segment (`Every shot stays inside @image1 …`); every person and every prop is bound
|
|
326
|
+
by its label again **inside its own shot**; no connected picture may go unused; and a segment may
|
|
327
|
+
not carry more pictures than the model takes (`refs` cap). Wording patterns and the six roles:
|
|
328
|
+
`reference/reference-binding.md`. A prompt that was written or edited by hand (or copied from a
|
|
329
|
+
production run) can be proofread before any image is spent:
|
|
330
|
+
`node {baseDir}/reference/prompt-audit.mjs --project <project> --ep 1 --segment E0N-k --prompt <file>`
|
|
331
|
+
— it reports per-shot unbound people and props, indistinguishable legend lines and unused pictures.
|
|
332
|
+
|
|
310
333
|
**Every cut is lit and composed in the preset's look.** Read the preset's guide
|
|
311
334
|
(`{baseDir}/reference/cinematography-<preset>.md`) before the first storyboard. Name the source
|
|
312
335
|
in `light.source`, stage the person against it, and choose the camera side that gives the
|
|
@@ -1879,5 +1879,177 @@
|
|
|
1879
1879
|
"canvasId": "",
|
|
1880
1880
|
"shotFolderId": "",
|
|
1881
1881
|
"displayNodes": {}
|
|
1882
|
+
},
|
|
1883
|
+
"dialogueReview": {
|
|
1884
|
+
"level": "轻修",
|
|
1885
|
+
"guides": [
|
|
1886
|
+
"lang-zh-cn.md"
|
|
1887
|
+
],
|
|
1888
|
+
"diagnosis": {
|
|
1889
|
+
"goal": "小夏想守住别人托付的袋子;老陈想弄清袋子从哪来",
|
|
1890
|
+
"conflict": "老陈步步试探,小夏用「别人的东西」挡住",
|
|
1891
|
+
"subtext": "老陈认得穿雨衣的人,却不说破;小夏开始怀疑他",
|
|
1892
|
+
"voice": "小夏短句、收得急;老陈慢、一字一顿,遮住名字也分得出",
|
|
1893
|
+
"rhythm": "问答都短,每两三句就被动作打断,节奏成立"
|
|
1894
|
+
},
|
|
1895
|
+
"rows": [
|
|
1896
|
+
{
|
|
1897
|
+
"scene": 1,
|
|
1898
|
+
"beat": 2,
|
|
1899
|
+
"speaker": "C03",
|
|
1900
|
+
"original": "帮我收着。明天有人来拿。",
|
|
1901
|
+
"issue": "无:两个短句,交代托付又不解释",
|
|
1902
|
+
"revised": "帮我收着。明天有人来拿。",
|
|
1903
|
+
"reason": "",
|
|
1904
|
+
"note": "压着嗓子,你不是请求,是把麻烦塞给她"
|
|
1905
|
+
},
|
|
1906
|
+
{
|
|
1907
|
+
"scene": 1,
|
|
1908
|
+
"beat": 3,
|
|
1909
|
+
"speaker": "C01",
|
|
1910
|
+
"original": "你是谁?我们不代收——",
|
|
1911
|
+
"issue": "无:被打断的半句,正好是人已走远",
|
|
1912
|
+
"revised": "你是谁?我们不代收——",
|
|
1913
|
+
"reason": "",
|
|
1914
|
+
"note": "追问,话到一半发现对方根本不听"
|
|
1915
|
+
},
|
|
1916
|
+
{
|
|
1917
|
+
"scene": 2,
|
|
1918
|
+
"beat": 3,
|
|
1919
|
+
"speaker": "C02",
|
|
1920
|
+
"original": "又是你值夜班。",
|
|
1921
|
+
"issue": "无:熟人寒暄,带着她天天值夜的信息",
|
|
1922
|
+
"revised": "又是你值夜班。",
|
|
1923
|
+
"reason": "",
|
|
1924
|
+
"note": "先吃一口再说,你在装作若无其事"
|
|
1925
|
+
},
|
|
1926
|
+
{
|
|
1927
|
+
"scene": 2,
|
|
1928
|
+
"beat": 4,
|
|
1929
|
+
"speaker": "C01",
|
|
1930
|
+
"original": "陈叔,你怎么天天半夜来。",
|
|
1931
|
+
"issue": "无:顺口一问,眼睛在袋子上,潜台词在动作里",
|
|
1932
|
+
"revised": "陈叔,你怎么天天半夜来。",
|
|
1933
|
+
"reason": "",
|
|
1934
|
+
"note": "嘴上问他,心思全在袋子上,别看他"
|
|
1935
|
+
},
|
|
1936
|
+
{
|
|
1937
|
+
"scene": 2,
|
|
1938
|
+
"beat": 5,
|
|
1939
|
+
"speaker": "C02",
|
|
1940
|
+
"original": "老了,睡不着。",
|
|
1941
|
+
"issue": "无:一句带过,把真正的原因藏住",
|
|
1942
|
+
"revised": "老了,睡不着。",
|
|
1943
|
+
"reason": "",
|
|
1944
|
+
"note": "笑着挡回去,不让她再问"
|
|
1945
|
+
},
|
|
1946
|
+
{
|
|
1947
|
+
"scene": 2,
|
|
1948
|
+
"beat": 7,
|
|
1949
|
+
"speaker": "C02",
|
|
1950
|
+
"original": "这是什么?",
|
|
1951
|
+
"issue": "无:最短的问题,压力在筷子停住",
|
|
1952
|
+
"revised": "这是什么?",
|
|
1953
|
+
"reason": "",
|
|
1954
|
+
"note": "声音不变,你在试探,不是好奇"
|
|
1955
|
+
},
|
|
1956
|
+
{
|
|
1957
|
+
"scene": 2,
|
|
1958
|
+
"beat": 8,
|
|
1959
|
+
"speaker": "C01",
|
|
1960
|
+
"original": "刚才一个人放这儿的,说明天有人来拿。",
|
|
1961
|
+
"issue": "无:耸肩一句,转述托付人的话,不加解释",
|
|
1962
|
+
"revised": "刚才一个人放这儿的,说明天有人来拿。",
|
|
1963
|
+
"reason": "",
|
|
1964
|
+
"note": "耸肩,你在敷衍,想把话题带过"
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
"scene": 2,
|
|
1968
|
+
"beat": 10,
|
|
1969
|
+
"speaker": "C01",
|
|
1970
|
+
"original": "别人的东西。",
|
|
1971
|
+
"issue": "无:三个字划界线",
|
|
1972
|
+
"revised": "别人的东西。",
|
|
1973
|
+
"reason": "",
|
|
1974
|
+
"note": "手按住抽屉,你在拒绝,不用抬高声音"
|
|
1975
|
+
},
|
|
1976
|
+
{
|
|
1977
|
+
"scene": 2,
|
|
1978
|
+
"beat": 11,
|
|
1979
|
+
"speaker": "C02",
|
|
1980
|
+
"original": "小夏,那个人穿雨衣?",
|
|
1981
|
+
"issue": "无:叫名字一次,用在试探的关键处",
|
|
1982
|
+
"revised": "小夏,那个人穿雨衣?",
|
|
1983
|
+
"reason": "",
|
|
1984
|
+
"note": "很轻,你在确认一件你早就知道的事"
|
|
1985
|
+
},
|
|
1986
|
+
{
|
|
1987
|
+
"scene": 2,
|
|
1988
|
+
"beat": 13,
|
|
1989
|
+
"speaker": "C02",
|
|
1990
|
+
"original": "三年前,这店里丢过一样东西。",
|
|
1991
|
+
"issue": "无:只给半句往事,留钩子",
|
|
1992
|
+
"revised": "三年前,这店里丢过一样东西。",
|
|
1993
|
+
"reason": "",
|
|
1994
|
+
"note": "背对她说,你在决定要说多少"
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
"scene": 2,
|
|
1998
|
+
"beat": 15,
|
|
1999
|
+
"speaker": "C02",
|
|
2000
|
+
"original": "别把它交给任何人。",
|
|
2001
|
+
"issue": "无:命令句,第一次正眼看她",
|
|
2002
|
+
"revised": "别把它交给任何人。",
|
|
2003
|
+
"reason": "",
|
|
2004
|
+
"note": "一字一顿,你在把责任压到她身上"
|
|
2005
|
+
},
|
|
2006
|
+
{
|
|
2007
|
+
"scene": 2,
|
|
2008
|
+
"beat": 17,
|
|
2009
|
+
"speaker": "VO",
|
|
2010
|
+
"original": "他怎么知道那人穿雨衣。",
|
|
2011
|
+
"issue": "无:心声一句,把怀疑交给观众",
|
|
2012
|
+
"revised": "他怎么知道那人穿雨衣。",
|
|
2013
|
+
"reason": "",
|
|
2014
|
+
"note": "压得很低,像自己不敢承认这个念头"
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
"scene": 2,
|
|
2018
|
+
"beat": 19,
|
|
2019
|
+
"speaker": "C02",
|
|
2020
|
+
"original": "锁上门。",
|
|
2021
|
+
"issue": "无:三个字收尾,命令里有恐惧",
|
|
2022
|
+
"revised": "锁上门。",
|
|
2023
|
+
"reason": "",
|
|
2024
|
+
"note": "不回头,你在逼她立刻照做"
|
|
2025
|
+
}
|
|
2026
|
+
],
|
|
2027
|
+
"selfCheck": [
|
|
2028
|
+
{
|
|
2029
|
+
"item": "读出声:有没有播音腔的句子?",
|
|
2030
|
+
"ok": true,
|
|
2031
|
+
"note": "全部短句口语,无书面长句"
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
"item": "语气词密度:一句最多一个,没有粤语残留(啦/喇/啫)",
|
|
2035
|
+
"ok": true,
|
|
2036
|
+
"note": "几乎不用语气词"
|
|
2037
|
+
},
|
|
2038
|
+
{
|
|
2039
|
+
"item": "网络用语符合年龄:长辈角色不用",
|
|
2040
|
+
"ok": true,
|
|
2041
|
+
"note": "老陈六十多岁,无网络用语"
|
|
2042
|
+
},
|
|
2043
|
+
{
|
|
2044
|
+
"item": "地域口音只给少数角色",
|
|
2045
|
+
"ok": true,
|
|
2046
|
+
"note": "全员标准普通话"
|
|
2047
|
+
},
|
|
2048
|
+
{
|
|
2049
|
+
"item": "英语夹杂符合行业身份",
|
|
2050
|
+
"ok": true,
|
|
2051
|
+
"note": "无英语夹杂,便利店夜班不需要"
|
|
2052
|
+
}
|
|
2053
|
+
]
|
|
1882
2054
|
}
|
|
1883
|
-
}
|
|
2055
|
+
}
|
|
@@ -588,6 +588,8 @@
|
|
|
588
588
|
"primary": true,
|
|
589
589
|
"reusePlan": "",
|
|
590
590
|
"summary": "凌晨两点的便利店:亮得刺眼、空得发冷,收银台是全店唯一有人气的地方,所有的戏都在那一米柜台前后发生。",
|
|
591
|
+
"condition": "lived-in",
|
|
592
|
+
"conditionReason": "这家便利店每晚都在营业:柜台和抽屉有日常使用的痕迹,不是残破,也不是刚搭好的布景。",
|
|
591
593
|
"anchors": [
|
|
592
594
|
{
|
|
593
595
|
"name": "关东煮灯箱",
|
|
@@ -647,6 +649,8 @@
|
|
|
647
649
|
"primary": true,
|
|
648
650
|
"reusePlan": "",
|
|
649
651
|
"summary": "店门外一米宽的雨棚:里面是灯,外面是雨,站在这里的人一半亮一半黑——所有来历不明的人都从这里进出。",
|
|
652
|
+
"condition": "lived-in",
|
|
653
|
+
"conditionReason": "雨棚在雨里站了很多年,红白条已经褪色,但店还在开,不是新搭的布景。",
|
|
650
654
|
"anchors": [
|
|
651
655
|
{
|
|
652
656
|
"name": "褪色的红白雨棚",
|
|
@@ -709,6 +713,8 @@
|
|
|
709
713
|
],
|
|
710
714
|
"scale": "handheld",
|
|
711
715
|
"summary": "一块旧的 3.5 寸机械硬盘,装在防静电袋里,标签被撕掉一半。",
|
|
716
|
+
"condition": "worn",
|
|
717
|
+
"conditionReason": "这是三年前的监控硬盘:标签撕掉一半,接口生锈,不是主办方刚发的新道具。",
|
|
712
718
|
"anchors": [
|
|
713
719
|
{
|
|
714
720
|
"name": "撕掉一半的标签",
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# Dialogue craft — lines as action, in the right 语体
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
A summary of the user's `w-film-dialogue-editor` skill, which is installed beside this one and is
|
|
4
|
+
the source of truth: its revision framework, one language guide per 语体 and a corpus of real
|
|
5
|
+
usage queried with `scripts/ref.mjs`. This page is the short version to keep in mind; the guide of
|
|
6
|
+
the drama's 语体 is read in full before writing lines and again in the dialogue review (§3). Two
|
|
7
|
+
gates: the **advisory** `dialogue-register` notes lines that break the register (a 穿越 character
|
|
8
|
+
speaking 古装 in a modern drama is right, not wrong), and the **required** `dialogue-review` holds
|
|
9
|
+
the storyboard until every line has been reviewed.
|
|
7
10
|
|
|
8
11
|
## 1. A line is an action under pressure
|
|
9
12
|
|
|
@@ -94,14 +97,16 @@ a 6–18-month half-life: two or three signature words per character, no more. P
|
|
|
94
97
|
vertical short drama allows damn / hell / ass / limited bitch, avoids the f-word; the first three
|
|
95
98
|
seconds are clean.
|
|
96
99
|
|
|
97
|
-
## 3. The
|
|
100
|
+
## 3. The dialogue review — `seed dialogue` → fill → `apply dialogue`
|
|
98
101
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
Required after every script (`stage-script.md` § Dialogue review). `seed dialogue --ep N` writes
|
|
103
|
+
`episode.dialogueReview` — one row per line with 原句 filled — and prints the guide and the corpus
|
|
104
|
+
query for each speaker. Fill it the way the dialogue skill reviews a scene: the diagnosis first,
|
|
105
|
+
then per line 问题 / 修改句 / 修改理由 / 导演/演员提示, then the guide's 自检清单. Levels: 轻修 (keep
|
|
106
|
+
the sentence, fix tone, rhythm, filler), 中修 (restructure, cut explanation, sharpen the attack and
|
|
107
|
+
defence), 重修 (re-design the beats and strategies, keep the story intent). `apply dialogue` writes
|
|
108
|
+
the revised lines into the script; `render dialogue` prints the filled table, which is appended to
|
|
109
|
+
the screenplay dataset under `## 对白校对`.
|
|
105
110
|
|
|
106
111
|
| 原句 | 问题 | 修改句 | 修改理由 |
|
|
107
112
|
| --- | --- | --- | --- |
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* prompt-audit.mjs — reference-binding proofreader for AI short-drama video prompts.
|
|
4
|
+
*
|
|
5
|
+
* It does NOT rewrite the prompt. It reads the prompt as TEXT, compares it with what the
|
|
6
|
+
* project says must be on screen (cuts → characters / props / scene), and reports every place
|
|
7
|
+
* where the model could mistake which @imageN is a person, which is the room, which is a prop,
|
|
8
|
+
* or where a shot simply never mentions the reference it depends on.
|
|
9
|
+
*
|
|
10
|
+
* usage:
|
|
11
|
+
* node prompt-audit.mjs --project <dir> --ep 1 --segment E01-02 --prompt <file> [--json]
|
|
12
|
+
*
|
|
13
|
+
* exit code: 0 clean (warnings allowed), 1 errors found.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
// ── args ────────────────────────────────────────────────────────────────────
|
|
20
|
+
const argv = process.argv.slice(2);
|
|
21
|
+
const arg = (name, fallback = null) => {
|
|
22
|
+
const i = argv.indexOf(`--${name}`);
|
|
23
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;
|
|
24
|
+
};
|
|
25
|
+
const PROJECT = arg('project');
|
|
26
|
+
const EP = Number(arg('ep', 1));
|
|
27
|
+
const SEGMENT = arg('segment');
|
|
28
|
+
const PROMPT_FILE = arg('prompt');
|
|
29
|
+
const AS_JSON = argv.includes('--json');
|
|
30
|
+
if (!PROJECT || !SEGMENT || !PROMPT_FILE) {
|
|
31
|
+
console.error('usage: node prompt-audit.mjs --project <dir> --ep 1 --segment E01-02 --prompt <file> [--json]');
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const project = JSON.parse(readFileSync(resolve(PROJECT, 'project.json'), 'utf8'));
|
|
36
|
+
const epFile = join(resolve(PROJECT, 'episodes'), `E${String(EP).padStart(2, '0')}.json`);
|
|
37
|
+
const episode = JSON.parse(readFileSync(epFile, 'utf8'));
|
|
38
|
+
const prompt = readFileSync(resolve(PROMPT_FILE), 'utf8')
|
|
39
|
+
.split('\n')
|
|
40
|
+
.filter((l) => !l.startsWith('#')) // drop `prompt` command's header comment line
|
|
41
|
+
.join('\n')
|
|
42
|
+
.trim();
|
|
43
|
+
|
|
44
|
+
const seg = (episode.storyboard?.segments || []).find((s) => s.id === SEGMENT);
|
|
45
|
+
if (!seg) {
|
|
46
|
+
console.error(`no segment ${SEGMENT} in ${epFile}`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
const scene = (episode.scenes || [])[Number(seg.sceneIndex) - 1] || {};
|
|
50
|
+
const params = project.params || {};
|
|
51
|
+
const charById = (id) => (project.characters || []).find((c) => c.id === id);
|
|
52
|
+
const sceneById = (id) => (project.scenes || []).find((s) => s.id === id);
|
|
53
|
+
const propById = (id) => (project.props || []).find((p) => p.id === id);
|
|
54
|
+
|
|
55
|
+
// ── what the project says the segment needs ────────────────────────────────
|
|
56
|
+
/** Reference plan in connection order, the same way the engine derives it (env → people → props → voices). */
|
|
57
|
+
function planOf(seg) {
|
|
58
|
+
const imgs = [];
|
|
59
|
+
if (Array.isArray(seg.refOverride) && seg.refOverride.length) {
|
|
60
|
+
for (const r of seg.refOverride) imgs.push({ entity: r.entity, asset: r.asset || 'sheet', declared: r.role || null });
|
|
61
|
+
} else {
|
|
62
|
+
if (scene.sceneId) {
|
|
63
|
+
imgs.push({ entity: scene.sceneId, asset: 'sheet', declared: 'scene' });
|
|
64
|
+
const se = sceneById(scene.sceneId);
|
|
65
|
+
if (scene.lighting && se?.wji?.assets?.lighting?.[scene.lighting]) imgs.push({ entity: scene.sceneId, asset: `lighting:${scene.lighting}`, declared: 'lighting' });
|
|
66
|
+
}
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const c of seg.cuts || []) for (const id of c.characters || []) if (!seen.has(id)) { seen.add(id); imgs.push({ entity: id, asset: 'sheet', declared: 'character' }); }
|
|
69
|
+
for (const c of seg.cuts || []) for (const id of c.props || []) if (!seen.has(id)) { seen.add(id); imgs.push({ entity: id, asset: 'sheet', declared: 'prop' }); }
|
|
70
|
+
}
|
|
71
|
+
imgs.forEach((r, i) => (r.label = `@image${i + 1}`));
|
|
72
|
+
const speakers = [];
|
|
73
|
+
for (const c of seg.cuts || []) for (const id of c.characters || []) if (id !== 'VO' && !speakers.includes(id)) speakers.push(id);
|
|
74
|
+
const voices = speakers.map((id, i) => ({ entity: id, label: `@audio${i + 1}`, kind: 'audio' }));
|
|
75
|
+
return { imgs, voices };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const roleOfEntity = (id, asset, declared) => {
|
|
79
|
+
if (declared) return declared;
|
|
80
|
+
if (/^lighting:/.test(String(asset))) return 'lighting';
|
|
81
|
+
if (/^S/i.test(id)) return 'scene';
|
|
82
|
+
if (/^P/i.test(id)) return 'prop';
|
|
83
|
+
if (/^C/i.test(id)) return 'character';
|
|
84
|
+
return 'unknown';
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const plan = planOf(seg);
|
|
88
|
+
for (const r of plan.imgs) r.role = roleOfEntity(r.entity, r.asset, r.declared);
|
|
89
|
+
|
|
90
|
+
// ── identity tokens: how a legend can prove WHICH person a picture is ──────
|
|
91
|
+
const CN_STOP = new Set(['男的', '女的', '一個', '一个', 'the', 'and', 'with', 'her', 'his', 'man', 'woman']);
|
|
92
|
+
function tokensOf(ch) {
|
|
93
|
+
const t = new Set();
|
|
94
|
+
const add = (s) => {
|
|
95
|
+
for (const w of String(s || '').toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/)) if (w.length > 2 && !CN_STOP.has(w)) t.add(w);
|
|
96
|
+
for (const run of String(s || '').match(/[\u4e00-\u9fff]+/g) || []) {
|
|
97
|
+
if (run.length <= 3) t.add(run);
|
|
98
|
+
for (let i = 0; i + 1 < run.length && run.length > 3; i++) t.add(run.slice(i, i + 2));
|
|
99
|
+
for (let i = 0; i + 2 < run.length; i++) t.add(run.slice(i, i + 3));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
add(ch.identityEn);
|
|
103
|
+
add(ch.profile?.appearance);
|
|
104
|
+
add(ch.profile?.identity);
|
|
105
|
+
const g = ch.profile?.gender;
|
|
106
|
+
if (g) t.add(g === '男' ? '男' : '女');
|
|
107
|
+
return t;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── prompt parsing ─────────────────────────────────────────────────────────
|
|
111
|
+
const SHOT_RE = /(?:^|\n)[ \t]*(?:镜头|鏡頭|Shot)[ \t]*(\d+)/g;
|
|
112
|
+
const shotMarks = [];
|
|
113
|
+
for (let m; (m = SHOT_RE.exec(prompt)); ) shotMarks.push({ k: Number(m[1]), at: m.index + (m[0].startsWith('\n') ? 1 : 0) });
|
|
114
|
+
const firstShotAt = shotMarks.length ? shotMarks[0].at : prompt.length;
|
|
115
|
+
const head = prompt.slice(0, firstShotAt); // legend + global blocks
|
|
116
|
+
const shots = shotMarks.map((s, i) => ({
|
|
117
|
+
k: s.k,
|
|
118
|
+
text: prompt.slice(s.at, i + 1 < shotMarks.length ? shotMarks[i + 1].at : prompt.length),
|
|
119
|
+
}));
|
|
120
|
+
const legend = head.split('\n').filter((l) => /@image\d+/.test(l)).join('\n');
|
|
121
|
+
|
|
122
|
+
const IMG_RE = /@image(\d+)/g;
|
|
123
|
+
const labelPositions = new Map(); // label -> { first, count, span }
|
|
124
|
+
const legendLines = head.split('\n').filter((l) => /^@image\d+\b/.test(l.trim()));
|
|
125
|
+
{
|
|
126
|
+
const all = [...head.matchAll(IMG_RE)];
|
|
127
|
+
for (let i = 0; i < all.length; i++) {
|
|
128
|
+
const label = `@image${all[i][1]}`;
|
|
129
|
+
const from = all[i].index;
|
|
130
|
+
const to = i + 1 < all.length ? all[i + 1].index : head.length;
|
|
131
|
+
const prev = labelPositions.get(label);
|
|
132
|
+
const mine = legendLines.filter((l) => l.trim().startsWith(label));
|
|
133
|
+
const lines = mine.length;
|
|
134
|
+
const span = lines ? mine[0].trim().slice(label.length) : head.slice(from, to);
|
|
135
|
+
labelPositions.set(label, { first: from, count: (prev?.count || 0) + 1, lines, span });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const countIn = (text, label) => (text.match(new RegExp(`${label}(?!\\d)`, 'g')) || []).length;
|
|
140
|
+
|
|
141
|
+
// ── checks ─────────────────────────────────────────────────────────────────
|
|
142
|
+
const issues = [];
|
|
143
|
+
const add = (level, code, msg) => issues.push({ level, code, msg });
|
|
144
|
+
const ROLE_EN = /\b(scene|environment|character|person|prop|lighting|light reference|moment|action reference|style)\b/i;
|
|
145
|
+
const ROLE_ZH = /(場景|场景|環境|环境|人物|角色|道具|光影|光線|光线|瞬間|瞬间|撞擊|撞击|動作參考|动作参考|風格|风格|車廂|车厢)/;
|
|
146
|
+
const BIND_EN = /\b(match|as in|from|same as|use)\b/i;
|
|
147
|
+
const BIND_ZH = /(照|參照|参照|跟|用|依|係|是)/;
|
|
148
|
+
const NEG_EN = /\b(not|only|do not|don't)\b/i;
|
|
149
|
+
const NEG_ZH = /(唔照|不要照|唔好照|只照|只取|唔要|不要|只係|只是|只)/;
|
|
150
|
+
const hasCJK = (t) => /[\u4e00-\u9fff]/.test(String(t || ''));
|
|
151
|
+
const ROLE_WORDS = { test: (t) => (hasCJK(t) ? ROLE_ZH : ROLE_EN).test(t) };
|
|
152
|
+
const BIND_WORDS = { test: (t) => (hasCJK(t) ? BIND_ZH : BIND_EN).test(t) };
|
|
153
|
+
const NEG_WORDS = { test: (t) => (hasCJK(t) ? NEG_ZH : NEG_EN).test(t) };
|
|
154
|
+
|
|
155
|
+
// R1 legend exists
|
|
156
|
+
if (!/@image\d+/.test(head)) add('error', 'R1-legend-missing', 'prompt 首段冇 @imageN legend:模型唔知邊張圖係咩');
|
|
157
|
+
|
|
158
|
+
// R2 every planned image is in the legend, exactly once
|
|
159
|
+
for (const r of plan.imgs) {
|
|
160
|
+
const p = labelPositions.get(r.label);
|
|
161
|
+
if (!p) add('error', 'R2-legend-missing-label', `${r.label}(${r.role}:${r.entity})已接但 legend 冇提`);
|
|
162
|
+
else if (p.lines > 1) add('warn', 'R2-legend-duplicate', `${r.label} 有 ${p.lines} 條 legend 行 — 一張圖一行,重覆會令模型重新解讀角色`);
|
|
163
|
+
else if (p.lines === 0) add('warn', 'R2-legend-inline', `${r.label} 夾在一句裡面,冇自己一行 — 一張圖一行,「@image2、@image3 是人物圖」係模型撈亂嘅溫床`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// R3 prompt uses labels the plan does not have (extra hand-connected refs)
|
|
167
|
+
const maxPlanned = plan.imgs.length;
|
|
168
|
+
for (const label of labelPositions.keys()) {
|
|
169
|
+
const n = Number(label.slice(6));
|
|
170
|
+
if (n > maxPlanned) add('warn', 'R3-extra-ref', `${label} 唔在項目派生嘅引用次序(只有 ${maxPlanned} 張圖)— 額外接嘅圖要寫入 segment.refOverride,否則重跑 prompt 就對唔上`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// R4 role words per label + R5 negative instruction on non-character refs
|
|
174
|
+
for (const r of plan.imgs) {
|
|
175
|
+
const p = labelPositions.get(r.label);
|
|
176
|
+
if (!p) continue;
|
|
177
|
+
if (!ROLE_WORDS.test(p.span)) add('warn', 'R4-role-unsaid', `${r.label} 冇講明係咩角色(場景/人物/道具/光影/瞬間)— 模型只會當「一張參考圖」`);
|
|
178
|
+
if (r.role !== 'character' && !NEG_WORDS.test(p.span)) add('warn', 'R5-no-negative', `${r.label}(${r.role})冇寫「只照…唔照…」:非人物圖一定要講明唔好抄佢嘅構圖/人物,唔係模型會連人一齊抄`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// R6 legend must let the model tell one person from another
|
|
182
|
+
const segChars = [];
|
|
183
|
+
for (const c of seg.cuts || []) for (const id of c.characters || []) if (id !== 'VO' && !segChars.includes(id)) segChars.push(id);
|
|
184
|
+
const charImgs = plan.imgs.filter((r) => r.role === 'character');
|
|
185
|
+
if (segChars.length >= 2 && charImgs.length >= 2) {
|
|
186
|
+
const total = new Map();
|
|
187
|
+
for (const id of segChars) for (const t of tokensOf(charById(id) || {})) total.set(t, (total.get(t) || 0) + 1);
|
|
188
|
+
const assigned = new Map();
|
|
189
|
+
for (const r of charImgs) {
|
|
190
|
+
const span = (labelPositions.get(r.label)?.span || '').toLowerCase();
|
|
191
|
+
let best = null, bestScore = 0;
|
|
192
|
+
for (const id of segChars) {
|
|
193
|
+
if (assigned.has(id)) continue;
|
|
194
|
+
let score = 0;
|
|
195
|
+
for (const t of tokensOf(charById(id) || {})) if (span.includes(t) && (total.get(t) || 0) <= 1) score += 2;
|
|
196
|
+
const g = charById(id)?.profile?.gender;
|
|
197
|
+
if (g && span.includes(g) && segChars.filter((x) => charById(x)?.profile?.gender === g).length === 1) score += 1;
|
|
198
|
+
if (score > bestScore) { best = id; bestScore = score; }
|
|
199
|
+
}
|
|
200
|
+
if (best && bestScore >= 2) assigned.set(best, r.label);
|
|
201
|
+
}
|
|
202
|
+
if (assigned.size < segChars.length) {
|
|
203
|
+
const missing = segChars.filter((id) => !assigned.has(id)).map((id) => charById(id)?.name || id);
|
|
204
|
+
add('error', 'R6-identity-unresolvable', `legend 冇寫到分辨特徵,無法對應邊張圖係邊個人(對唔到:${missing.join('、')})。` +
|
|
205
|
+
`人物圖必須逐張寫「係邊個」——性別、年齡、髮型、衣著各一句;寫成「@image2、@image3 是人物參考圖」模型就會互換面孔`);
|
|
206
|
+
}
|
|
207
|
+
// two people described in one legend sentence, with no per-picture role
|
|
208
|
+
for (const line of legendLines) {
|
|
209
|
+
const labels = line.match(/@image\d+/g) || [];
|
|
210
|
+
const people = labels.filter((l) => charImgs.some((r) => r.label === l));
|
|
211
|
+
if (people.length >= 2 && !/not its|never its|只照|不要照/i.test(line.replace(/@image\d+/g, ''))) {
|
|
212
|
+
add('warn', 'R6b-shared-clause', `legend 一句寫咗幾個人(${people.join('、')})又冇逐個講明 — 一張圖一句,否則模型會互換`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// R7 per-shot binding: person / prop / scene
|
|
218
|
+
const cuts = seg.cuts || [];
|
|
219
|
+
if (shots.length !== cuts.length) add('warn', 'R0-shot-count', `prompt 有 ${shots.length} 個鏡頭,分鏡有 ${cuts.length} 個 cut — 冇法逐鏡核對(人名/道具綁定會漏)`);
|
|
220
|
+
const deriveOrder = (() => {
|
|
221
|
+
// when the legend is ambiguous, fall back to the engine's derivation order to still give per-shot advice
|
|
222
|
+
const m = new Map();
|
|
223
|
+
for (const r of plan.imgs) if (r.role === 'character') m.set(r.entity, r.label);
|
|
224
|
+
return m;
|
|
225
|
+
})();
|
|
226
|
+
const unboundChar = new Map(); // entity -> {label, shots: []}
|
|
227
|
+
for (let i = 0; i < cuts.length && i < shots.length; i++) {
|
|
228
|
+
const c = cuts[i];
|
|
229
|
+
const s = shots[i];
|
|
230
|
+
for (const id of c.characters || []) {
|
|
231
|
+
const label = deriveOrder.get(id);
|
|
232
|
+
if (!label) continue;
|
|
233
|
+
if (!countIn(s.text, label)) {
|
|
234
|
+
if (!unboundChar.has(id)) unboundChar.set(id, { label, shots: [] });
|
|
235
|
+
unboundChar.get(id).shots.push(s.k);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const id of c.props || []) {
|
|
239
|
+
const label = plan.imgs.find((r) => r.entity === id)?.label;
|
|
240
|
+
if (label && !countIn(s.text, label)) add('error', 'R7-prop', `鏡頭 ${s.k}:道具 ${propById(id)?.name || id} 在畫面內,但鏡頭文冇 ${label} — 道具外觀會由模型自創`);
|
|
241
|
+
}
|
|
242
|
+
if (i === 0) {
|
|
243
|
+
const sceneLabel = plan.imgs.find((r) => r.role === 'scene')?.label;
|
|
244
|
+
if (sceneLabel && !countIn(head + '\n' + shots.map((x) => x.text).join('\n'), sceneLabel) && !countIn(prompt.slice(firstShotAt), sceneLabel)) {
|
|
245
|
+
add('warn', 'R7-scene', `${sceneLabel} 只在 legend 出現,冇任何鏡頭講明照佢嘅空間/材質 — 每個 segment 至少講一次「整段都在 ${sceneLabel} 的空間裡」`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const [id, bad] of unboundChar) {
|
|
251
|
+
add('error', 'R7-char', `${charById(id)?.name || id} 在鏡頭 ${bad.shots.join('、')} 畫面內,但鏡頭文冇 ${bad.label}` +
|
|
252
|
+
` — 模型會沿用上一鏡嘅樣或者自己編。每個有佢嘅鏡頭都要寫一次 ${bad.label}`);
|
|
253
|
+
}
|
|
254
|
+
// R8 every image ref is actually used somewhere outside the legend (people are covered by R7)
|
|
255
|
+
const bodyText = prompt.slice(firstShotAt);
|
|
256
|
+
for (const r of plan.imgs) {
|
|
257
|
+
if (r.role === 'character' || r.role === 'scene') continue;
|
|
258
|
+
if (!countIn(bodyText, r.label)) add('error', 'R8-unused-ref', `${r.label}(${r.role}:${r.entity})接咗但鏡頭文一次都冇用 — 等於冇參考`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// R9 dialogue still carries its voice label (mirrors the engine's own rule)
|
|
262
|
+
const dialogues = [];
|
|
263
|
+
for (const c of cuts) {
|
|
264
|
+
for (let b = (c.beats?.[0] || 1) - 1; b <= (c.beats?.[1] || 1) - 1; b++) {
|
|
265
|
+
const beat = (scene.flow || [])[b];
|
|
266
|
+
if (beat?.speaker) dialogues.push({ speaker: beat.speaker, line: beat.line });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const norm = (t) => String(t).replace(/[…⋯]/g, '...').replace(/[“”「」『』"]/g, '').replace(/\s+/g, '');
|
|
270
|
+
const flat = norm(prompt);
|
|
271
|
+
for (const l of dialogues) {
|
|
272
|
+
const needle = norm(l.line);
|
|
273
|
+
const at = flat.indexOf(needle);
|
|
274
|
+
if (at < 0) { add('error', 'R9-dialogue-missing', `台詞「${l.line}」逐字唔見(引擎逐字對,標點差異都算唔同)`); continue; }
|
|
275
|
+
const around = flat.slice(Math.max(0, at - 160), at + needle.length + 60);
|
|
276
|
+
if (!/@audio\d/.test(around)) add('error', 'R9-voice-unbound', `台詞「${l.line}」附近冇 @audioN — 模型會用預設聲`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// R10 hygiene: no names, subtitles off
|
|
280
|
+
for (const ch of project.characters || []) if (ch.name && prompt.includes(ch.name)) add('warn', 'R10-name-leak', `prompt 出現角色名「${ch.name}」(引擎 prompt-hygiene 會當錯;描述性名字可以留,但要預期被擋)`);
|
|
281
|
+
if (!params.subtitles && !/(全程无字幕|no subtitles)/i.test(prompt)) add('warn', 'R10-subtitles', 'prompt 冇寫「無字幕/no subtitles」');
|
|
282
|
+
|
|
283
|
+
// R11 ambiguity sweep: a whole sentence that names several pictures without saying what each is for
|
|
284
|
+
for (const s of shots) {
|
|
285
|
+
for (const sentence of s.text.split(/[。;;\n]/)) {
|
|
286
|
+
const body = sentence.replace(/^\s*(?:Shot|镜头|鏡頭)\s*\d+\s*\([^)]*\)\s*:?/, '');
|
|
287
|
+
const labels = body.match(/@image\d+/g) || [];
|
|
288
|
+
if (labels.length >= 2 && !BIND_WORDS.test(body.replace(/@image\d+/g, '')) && body.length > 40) {
|
|
289
|
+
add('warn', 'R11-ambiguous-clause', `鏡頭 ${s.k}「${body.trim().slice(0, 44)}…」一句有 ${labels.length} 張圖但冇逐張講用途 — 拆開寫`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── report ─────────────────────────────────────────────────────────────────
|
|
295
|
+
const errors = issues.filter((x) => x.level === 'error');
|
|
296
|
+
const warns = issues.filter((x) => x.level === 'warn');
|
|
297
|
+
if (AS_JSON) {
|
|
298
|
+
console.log(JSON.stringify({ segment: SEGMENT, shots: shots.length, cuts: cuts.length, plan: plan.imgs, issues }, null, 2));
|
|
299
|
+
} else {
|
|
300
|
+
console.log(`\n== prompt-audit · ${SEGMENT} ==`);
|
|
301
|
+
console.log(`refs (derived order): ${plan.imgs.map((r) => `${r.label}=${r.role}:${r.entity}`).join(' ')}`);
|
|
302
|
+
console.log(`shots in prompt: ${shots.length} · cuts in storyboard: ${cuts.length} · images declared: ${labelPositions.size}\n`);
|
|
303
|
+
for (const x of errors) console.log(`❌ [${x.code}] ${x.msg}`);
|
|
304
|
+
for (const x of warns) console.log(`⚠️ [${x.code}] ${x.msg}`);
|
|
305
|
+
if (!issues.length) console.log('✅ 冇發現引用綁定問題');
|
|
306
|
+
console.log(`\n${errors.length} error(s), ${warns.length} warning(s)`);
|
|
307
|
+
}
|
|
308
|
+
process.exit(errors.length ? 1 : 0);
|