short-drama-write
编写或修订可拍摄的中文短剧、漫剧单集 Markdown 剧本,也负责保留作者原文地规范化现成剧本。用户提出“写/改一集短剧”“把大纲写成剧本”“优化场景/对白”“去模板感”“去 AI 味润色”“续写下一集”或提供剧本要求进入后续制作时使用;不负责资产、分镜、媒体提示词或终审。
Install
npx skills add https://github.com/zenstory-ai/drama-skills/tree/main/skills/short-drama-write
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zenstory-ai-drama-skills@llmmart
git clone https://github.com/zenstory-ai/drama-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zenstory-ai/drama-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
短剧写作
把单集意图写成可表演、可拍摄、会改变故事状态的 剧集/<EP>/剧本.md。
Quick Start
只维护一份剧本 Markdown,不另建 episode card、beats、block index、录音表、QA 或接受记录。
一级标题使用 # EP001 集名,场景标题使用 ## EP001-SC001 内 · 地点 · 时间/天气。
对白写 角色(可选表演提示):台词;生产标签使用 [VO]、[OS]、[SFX]、[画面文字]、
[连续性]、[转场]。不要把含冒号的动作叙述误写成对白。
入口
- 有分集规划:读取本集进入状态、目标、转折、回报和交接事实。
- 只有想法/大纲:在上下文形成最小单集契约和因果节拍,再直接写剧本。
- 已有剧本:保留作者语言,只做用户点名的定点修订。
- 非规范文本要进入制作:保留原文,只做必要的场景、动作、对白和生产标签规范化。
开发大纲和长篇分析都是可选上游;当前材料足够时直接写。
工作流
- 锁定本集承诺的观看体验、谁现在要什么、阻力、当集兑现和退出状态。
- 只识别本集实际存在的发动机:若主打艰难选择,检查两边价值;若判断转折依赖争议证据,检查证明边界;若前景化精确死线,检查动作容量;若题面限定次数、轮次、呼吸或节拍,检查每个单位是否在边界内完成所需状态。没有时不强行补齐;输入已经以它为主发动机时,要让它真正改变行动、关系或结果,不只在台词中点名。
- 在上下文排“因为—行动—结果—下一股压力”,不落盘节拍表;让物件、对白、沉默、空间或声音按本集题材承担必要工作,不预设某一种载体。
- 逐场确定不可替代的功能、可见变化与场尾状态;由争取或冲突组织的场景再明确焦点议程与阻力。承担主要戏剧功能的配角要有自己的策略,事务性或环境性角色保持简洁即可。
- 把动作、对白、画外音、声音事实和画面文字写入同一份剧本。
- 用户要求整集就写完整集,场景批次自动续跑。
- 交稿前静默反查:只看剧本能否接收到本集承诺、人物行动是否有因果、题材语气是否一致;再对本集确实使用的选择、证据、有限单位或精确死线做专项检查。若自检让剧本长出输入没有要求的机关,撤掉机关而不是替它找理由。
- 局部修订保留无关段落;明确问题直接修正,只把真实剧情分叉交给用户。
写作要求
- 先服从输入已经给出的事实、
必须/禁止、题材观看契约和退出状态;本技能的审查项与手艺默认只帮助实现这些要求,不能反过来补造题面没有的精确数字、记录、资源、程序、关系或机关。 - 每场改变信息、权力、关系、情绪、物理状态或风险;无变化的场删掉或合并。核心阻力的转向要有可追溯的事实、筹码、权限、代价或人物行动,不能只靠对手停止反应。
- 对白必须在争取、回避、试探、逼迫或重新定义关系;用可见、可听、可触的行为表现状态,不用解释替代行动,也不把已经演清的机制再说一遍。
- 观众理解当前行动所必需的赌注要进入剧本,但载体服从题材;选择、证据、有限单位、精确死线等专项规则只在输入真正以它为发动机时启用,并按需读取剧作手艺,普通决定、普通物件和模糊时间压力不升级成同一套机关。
- 压缩过程时只保留会改变策略、关系、风险或结果的节点。资源与外部反应必须来自输入或前文建立,且不能替人物完成最难的戏剧行动。
- 承担主要冲突的角色要保持相称的目标、判断或策略;事务性、环境性角色保持简洁,不为检查表硬加秘密、反转或人物弧。
- 已建立的代价、不可逆后果、关键持物、空间和知识状态要进入可追溯的退出状态;结尾可推进、留白、反讽、安静收束或形成余韵,不强制追加外部事件。
[连续性]只交接由前文动作已经建立、又容易在下游丢失的状态;不能用作者标签证明镜头外从未发生某事,也不复述正文已清楚呈现的结果。- 交稿前静默做一次经济性检查:是否增加了输入没有要求的精确数字或记录链;是否用标签/对白重复动作;是否在回报后堆了多个同方向后果。没有新作用的内容删掉,knowhow 本身不删。
- 输入给出目标时长时按真实表演、动作和停顿通读压稿,先删重复验证、解释和同义收尾;不用统一字数、镜头数或节拍数填模板。
- 用户原文优先;“去 AI 味”是定点修订,不抹平作者个性。
按需知识
默认只读本 SKILL 和直接输入。出现下列明确信号时,在写正文前读取对应知识;同一输入有多个不同问题 可以分别读取,但不遍历无关引用:
- 阶段边界与规则分级:阶段契约
- 场景标题、对白和生产标签语法,或把创作者带来的现成剧本规范化:剧本格式
- 项目特有的制作稿方言:制作格式方言
- 输入含核心选择、有限次数/轮次/呼吸/节拍、精确倒计时或会改变人物判断的关键物证:剧作手艺
- 过去关系必须通过对白进入,或关键人物容易只剩说明功能:对白手艺
- 声源、留白与 sound bridge:场景声音戏剧
- 同一故事义务的不同可拍实现:可替代实现
- 长单集跨上下文时的最小交接:场景交接胶囊,只留在上下文
时长估算(按需)
用户问时长时才把索引写入系统临时目录。先用 Python 查询跨平台临时目录,把第一条命令打印的完整
路径原样替换进后两条命令的引号内。以下每条都是一行完整命令,不依赖 shell 变量或续行符;Windows
没有 python3 命令时使用 py -3。--speaker 的示例值必须替换为本集实际说话者。
--project short-drama.json 只加在第三条命令上(duration_estimate.py 才接受它,
screenplay_index.py 没有这个参数,加上去会直接报 unrecognized arguments),且只在项目
配置确实存在时加:
python3 -c "from pathlib import Path; import tempfile, uuid; print(Path(tempfile.gettempdir()) / ('short-drama-' + uuid.uuid4().hex + '.jsonl'))"
python3 "{技能目录}/scripts/screenplay_index.py" "剧集/EP001/剧本.md" --output "粘贴第一条命令输出的完整路径" --speaker "本集角色一" --speaker "本集角色二"
python3 "{技能目录}/scripts/duration_estimate.py" "剧集/EP001/剧本.md" --index "粘贴第一条命令输出的完整路径"
改稿后重跑:第二条命令写过一次的输出路径不能直接再写一次,会以 already holds an index 失败。
要保留原有块 ID 就加 --previous-index <同一路径>,要从头重编号就加 --no-previous;
或者干脆回到第一条命令另取一个临时路径。
估时前区分计数口径:dialogue_characters 包含标点,视频提示词中的参考语速按可发声字数计算。
按语速换算时排除标点,并按实际读法处理数字和缩写;不把两个计数混用。
估算是参考,不是门禁;没有语速或动作段速率时只报告可数事实,不猜秒数。但任务已给目标时长时, 写作者仍须用真实朗读和动作通读判断能否容纳,并直接压缩重复节拍;“不能精确估秒”不是忽略目标的理由。
完成
点名范围已写入、场景因果连贯、对白与行动可表演、承诺有兑现或有意延迟、结尾完成本集预定的戏剧或情绪功能,即完成。最后一拍可以推进,也可以安静收束、形成余韵或让既有动作变义;判断它是否有效,不用“是否又发生一件事”代替。
资产、分镜、审查与生产只有用户点名时开始。
五份创作文档齐备后,可转 $short-drama 对跨文档结构做一次机械核对;内容质量仍由创作者审查。
安装维护
只有安装、升级或排障时运行 python3 scripts/selftest.py。
Files (drama-skills)
-
agents
-
openai.yaml 270 B
interface: display_name: "短剧写作" short_description: "创作因果清楚、人物声音鲜明、动作可表演的中文短剧剧本" default_prompt: "使用 $short-drama-write 创作或修订我的单集卡、因果节拍和中文 Markdown 短剧剧本。"
-
-
assets
-
beats.jsonl 1.4 KB · in bundle
-
episode-card-standalone.json 3.2 KB
{ "episode_id": "EP001", "contract_authority": { "mode": "write_standalone", "source_ref": null }, "owned_contract": { "incoming_state": { "knowledge": [ "__FILL_OR_REMOVE__" ], "power": [ "__FILL_OR_REMOVE__" ], "relationship": [ "__FILL_OR_REMOVE__" ], "physical": [ "__FILL_OR_REMOVE__" ], "active_action": [ "__FILL_OR_REMOVE__" ] }, "active_pressure": "__FILL__", "objective": { "character": "__FILL__", "desired_change": "__FILL__", "why_now": "__FILL__" }, "opposition": { "actor_or_constraint": "__FILL__", "goal": "__FILL__", "leverage": "__FILL__" }, "causal_escalation": [ { "because_of": "__FILL__", "choice": "__FILL__", "countermove": "__FILL__", "state_change": "__FILL__", "next_pressure": "__FILL__" } ], "turn": { "trigger": "__FILL__", "old_plan_that_stops_working": "__FILL__", "new_direction": "__FILL__" }, "local_dramatic_result": { "goal_outcome": "__SUCCESS_FAILURE_OR_REDIRECTION__", "state_change": "__FILL__", "cost_paid": "__FILL__" }, "promised_payoff": "__FILL__", "character_progression": [ { "character_id": "__FILL__", "strategy_before": "__FILL__", "pressure_test": "__FILL__", "choice_or_retreat": "__FILL__", "visible_strategy_after": "__FILL__" } ], "information_release": [ { "fact_id": "__FILL__", "audience_before": "__KNOWN_SUSPECTED_OR_UNKNOWN__", "character_permissions_before": { "__CHARACTER_ID__": "__KNOWN_MISTAKEN_OR_UNKNOWN__" }, "visible_carrier": "__ACTION_OBJECT_DIALOGUE_OR_CONSEQUENCE__", "supported_claim": "__STRONGEST_CLAIM_DIRECTLY_SUPPORTED_BY_CARRIER__", "unresolved_inference": "__UNPROVED_IDENTITY_CAUSE_MOTIVE_OR_MECHANISM_OR_NONE__", "audience_after": "__KNOWN_SUSPECTED_OR_UNKNOWN__", "character_permissions_after": { "__CHARACTER_ID__": "__KNOWN_MISTAKEN_OR_UNKNOWN__" } } ], "rhythm_plan": { "external_pressure": "__LOW_MEDIUM_HIGH_WITH_REASON__", "emotional_load": "__LOW_MEDIUM_HIGH_WITH_REASON__", "relationship_to_previous": "__CONTINUE_CONTRAST_OR_LAND__", "landing_space": "__WHERE_CONSEQUENCE_IS_PROCESSED_OR_NONE_WITH_REASON__" }, "outgoing_pressure": { "decision_danger_or_question_already_in_motion": "__FILL__", "caused_by_this_episode": "__FILL__" }, "handoff_state": { "knowledge": [ "__FILL_OR_REMOVE__" ], "power": [ "__FILL_OR_REMOVE__" ], "relationship": [ "__FILL_OR_REMOVE__" ], "physical": [ "__FILL_OR_REMOVE__" ], "active_action": [ "__FILL_OR_REMOVE__" ] }, "setup_refs": [], "payoff_refs": [] }, "execution_plan": { "viewpoint_and_audience_alignment": "__FILL__", "opening_action_carrier": "__FILL__", "scene_jobs": [], "production_requirements": [] }, "source_refs": [], "open_questions": [], "creator_status": "draft" } -
episode-card.json 1.3 KB
{ "episode_id": "EP001", "sources": { "episode-map": { "owner": "short-drama-develop", "artifact": "项目开发/episode-map.jsonl" } }, "contract_authority": { "mode": "development_projection", "source_ref": { "src": "episode-map", "record_id": "EP001" }, "projected_fields": [ "/incoming_state", "/hook", "/objective", "/opposition", "/turn", "/local_dramatic_result", "/information_release", "/premise_device_disclosure", "/outgoing_pressure", "/handoff_state", "/setup_ids", "/payoff_ids" ] }, "execution_plan": { "viewpoint_and_audience_alignment": "__FILL__", "opening_action_carrier": "__FILL__", "turn_carrier": "__FILL__", "payoff_carrier": "__FILL__", "outgoing_pressure_carrier": "__FILL__", "scene_jobs": [ { "scene_id": "SC001", "agenda_collision": "__FILL__", "dramatic_result": "__FILL__", "external_pressure": "__LOW_MEDIUM_HIGH_WITH_REASON__", "emotional_load": "__LOW_MEDIUM_HIGH_WITH_REASON__", "visible_change": "__FILL__", "source_contract_fields": [ "/objective", "/opposition" ] } ], "production_requirements": [] }, "source_refs": [], "open_questions": [], "creator_status": "draft" } -
screenplay.md 1.6 KB
# EP001 【填写集名】 <!-- 复制到 剧集/EP001/screenplay.md 后填写并删除本注释。 场景标题:## 场景ID 内/外/内外 · 地点 · 时间/天气 动作写现在时、可见且可表演的行为。对白格式:角色(可执行提示):台词。 只保留剧情必需的生产标签;未使用的示意行全部删除。 不要在这里写镜头、资产全集、图片/视频提示词或终审结论。 --> ## EP001-SC001 内 · 【地点】 · 【时间/天气】 【写压力已经活跃时的可见行动;说明人物位置、持物或关键空间关系,只保留影响剧情的细节。】 【角色名】(【可选:一个可执行的策略/变化提示】):【台词】 【让对方通过行动、证据或另一种策略回应;不要用作者说明代替关系变化。】 【角色名】:【台词】 <!-- 以下标签只在故事需要时取消注释并填写。 --> <!-- [VO] 【角色】:【内容】 --> <!-- [OS] 【角色】:【内容】 --> <!-- [SFX] 【会改变行动或信息的关键声音】 --> <!-- [画面文字] 【载体】:【必须读清的精确内容】 --> <!-- [连续性] 【容易在后续丢失的明确状态变化】 --> <!-- [转场] 【有叙事意义的转场要求】 --> ## EP001-SC002 外 · 【地点】 · 【时间/天气】 【以上一场退出状态为进入事实。写新的目标、反对与方向性变化;不需要的示例场景直接删除。】 【角色名】:【台词】 【以可执行的决定、后果或未稳定状态结束,并与 episode-card.json 指向的权威 handoff_state(或 `write_standalone` 自有契约)对齐。】 -
voice-record-sheet.jsonl.md 5.4 KB
# `voice-record-sheet.jsonl` 填写模板 每行一条待录台词。这份表是**剧本的投影,不是第二份台词权威**:`line_text` 必须逐字 等于 `source_ref` 指向的剧本块,需要改词就改剧本再重新投影,不在这里改。示例值不是 默认答案;不适用的字段删掉,不要添加媒体任务、供应商或接口。 录音的顺序几乎从不是剧情顺序——通常按人物集中录,所以**配音者失去的正是上下文**。 本表存在的理由就是把上下文补回去:他此刻知道什么、对谁说、上一句是谁说的、这一句要 达成什么。缺了这些,剩下的只是一串没有处境的句子。 第一行声明本表引用的上游快照,一个快照只写一次: ```json {"record_type": "sources", "schema_version": "1.0.0", "sources": {"screenplay-index": {"owner": "short-drama-write", "artifact": "剧集/EP001/screenplay-index.jsonl"}}} ``` 其后每行一条待录台词,`source_ref` 用 `src` 指向上面声明的快照,加 `record_id` 指向其中的剧本块: ```json { "line_id": "VLINE-EP001-SC001-003", "episode_id": "EP001", "scene_id": "EP001-SC001", "speaker": "CHAR-<id>", "speaker_display": "<剧本里逐字写的那个名字>", "line_text": "<逐字等于剧本块原文的冒号之后部分>", "source_ref": { "src": "screenplay-index", "record_id": "BLK-EP001-SC001-D03" }, "channel": "sync | dubbed | VO | OS", "lip_sync_constrained": true, "addressed_to": ["CHAR-<id>"], "preceding_line_id": "VLINE-EP001-SC001-002", "speaker_knows_now": "<此刻他知道什么、还不知道什么>", "tactic": "<质问 | 试探 | 收回 | 交换 | 拖延……:这一句要达成什么>", "pronunciation_notes": [ { "surface": "<多音字、生僻字、专名或数字>", "reading": "<确定读法>", "decided_by": "creator | <role>:<stable-id>" } ], "target_seconds": null, "unresolved": [] } ``` ## 字段为什么是这些 | 字段 | 不写会怎样 | |---|---| | `source_ref` | 剧本改一句而表没跟上,录出来的是旧词;绑定了块 ID 才能切出剧本原字节逐字比对 | | `speaker` 与 `speaker_display` | 前者是资产身份用于绑定,后者是剧本里逐字写的名字;只留一个就必然有一处对不上 | | `channel` 与 `lip_sync_constrained` | 同期与配音、画内与 VO 的可改余地完全不同,混在一起就只能按最严的来。`channel` 必须与它投影的块一致:`[VO]` / `[OS]` 块只能记为同名声道或 `dubbed`,画内对白块不能记成 VO/OS——校验器判定,诊断码 `VOICE_CHANNEL_DISAGREES_WITH_BLOCK` | | `addressed_to` / `preceding_line_id` | 集中录制时配音者不知道在对谁说、接谁的话,语气只能靠猜 | | `speaker_knows_now` | 同一句话在"已经知道"和"还不知道"下是两种读法,这是最常见的重录原因 | | `tactic` | 情绪词("愤怒")不可执行;策略可执行。见对白工艺的策略库 | | `pronunciation_notes` | 专名与多音字在录音棚里是最贵的中断;决定要在进棚前做完并留痕 | | `target_seconds` | 有画面时长约束的行要提前知道,不要在混录时才发现塞不下 | ## 诊断码 `voice_sheet_check.py` 报出的全部代码。它只判逐字投影与结构,不判配音表演。 | 代码 | 分级 | 判定者 | 含义 | |---|---|---|---| | VOICE_LINE_HAS_NO_ID | structural_invariant | validator | 配音行没有 `line_id` | | VOICE_LINE_ID_REPEATS | structural_invariant | validator | `line_id` 重复 | | VOICE_CHANNEL_INVALID | structural_invariant | validator | `channel` 不是 sync / dubbed / VO / OS | | VOICE_CHANNEL_DISAGREES_WITH_BLOCK | structural_invariant | validator | `channel` 与它投影的块不符:画外音块记成画内,或画内对白记成 VO/OS | | VOICE_SOURCE_REF_MISSING | structural_invariant | validator | `source_ref` 没有指向任何上游快照 | | VOICE_SOURCE_REF_UNDECLARED | structural_invariant | validator | `src` 在本文件 `sources` 里没有对应条目 | | VOICE_SOURCE_REF_UNRESOLVABLE | structural_invariant | validator | `record_id` 在剧本索引里找不到 | | VOICE_SOURCE_IS_NOT_DIALOGUE | structural_invariant | validator | 配音行投影的块不是台词,也不是 `[VO]`/`[OS]` | | VOICE_BLOCK_SPAN_INVALID | structural_invariant | validator | 索引记的块跨度装不进这份剧本,或落在半个字符上(索引已过期) | | VOICE_BLOCK_IS_UNPARSEABLE | structural_invariant | validator | 台词块不符合文档写明的行语法 | | VOICE_LINE_TEXT_DIVERGED | structural_invariant | validator | `line_text` 与剧本原字节不一致——**以剧本为准** | | VOICE_SPEAKER_DIVERGED | structural_invariant | validator | `speaker_display` 与剧本里写的名字不一致 | | SOURCE_ENTRY_IS_INCOMPLETE | structural_invariant | validator | `sources` 里的条目缺 `owner` 或 `artifact` | ## 边界 - **本表不拥有台词文字、说话人和信息权限**,它们属于剧本;本表也不拥有逐镜的音频实现 (空间化、层级、与画面的对位),那属于视频提示词环节,本表只引用不复制。 - 表里出现与剧本不一致的文字时,**剧本为准**,把差异作为 `unresolved` 记下来交给 负责人,不要就地"顺一下"。 - 本表不生成音频、不调用任何语音服务,也不从文字判断成品音质;实际 TTS 交给 `$short-drama-produce`,并须在看到本次任务预览后明确确认。
-
-
references
-
dialogue-craft.md 21.3 KB
# 短剧对白方法 ## 目录 1. 对白的工作定义与规则分级 2. 先写议程,再写句子 3. 台词是一种行动 4. 潜台词来自已知差与风险 5. 信息如何进入冲突 6. 人物声音如何区分 7. 让对话改变权力与关系 8. 动作、停顿、打断与沉默 9. 高密度对白场景 10. 对白修订流程 11. 合成示例 12. 失败征兆与审查问题 ## 1. 对白的工作定义与规则分级 对白不是角色把剧情资料读给观众。每一句都发生在具体关系中:说话者试图改变对方的行动、判断、情绪位置或公开形象,对方用自己的目标回应。 四类规则: - **`structural_invariant`**:说话者、场景、画外/画内边界与精确文字要求;校验器可检查。 - **`reviewed_invariant`**:对白是否有议程、是否回应上一动作、是否改变局面、人物声音是否可信;审查者引用文本判断。 - **`craft_default`**:让信息通过争取、回避、交换和后果出现;创作者可说明理由覆盖。 - **`taste_option`**:口语程度、方言、停顿、沉默、直白、诗性、打断和句式长短;遵从人物与作品风格。 对白比例、金句数量、句长和口癖数量不能证明质量。 ## 2. 先写议程,再写句子 写一轮对话前,为每个主要说话者回答: | 问题 | 角色甲 | 角色乙 | |---|---|---| | 此刻想让对方做什么? | | | | 最怕对方看见/说出什么? | | | | 手里有什么筹码? | | | | 哪件事不能公开说?为什么? | | | | 认为对方想要什么? | | | | 先用什么策略?失败后怎样换? | | | | 哪条底线受到压力才会越过? | | | 两人可以表面谈同一件事,实际争不同结果。例如都在谈“交钥匙”:一人要结束控制,一人要迫使对方承认责任。 **`reviewed_invariant · SCR-04`**:重要对白应能识别说话者议程、关系位置、潜在风险以及台词造成的变化。审查者引用前后动作,而不是给“自然度”打空泛分数。 **`craft_default`**:先写“这句话想对对方做什么”,再决定字面表达。没有行动目标的句子通常可以压缩或转为动作。 ## 3. 台词是一种行动 同一句事实可以执行不同动作: 事实:“门在九点锁。” - **催促**:“你还有三分钟。” - **推责**:“九点以后,钥匙不归我管。” - **试探**:“你是不是以为九点以后没人进得来?” - **威胁**:“九点一到,里面是谁都不重要了。” - **保护**:“九点前走,我就当没见过你。” 字面信息相近,关系动作完全不同。 ### 3.1 常用策略库 不是模板轮换,只在符合人物目标时选用: - 要求、命令、请求、交换; - 试探、诱导、套话、验证; - 重新命名问题、缩小/扩大责任; - 先承认一部分以保护更大秘密; - 借第三人或公共规则施压; - 假装无所谓、拖延、转移; - 把选择交还给对方; - 暴露自己愿付的代价; - 拒绝旧称呼或旧关系位置; - 用沉默迫使对方继续说。 ### 3.2 策略必须会受阻 若一方每句话都成功,对话会变成演讲。让另一方: - 看穿并点破策略; - 接受字面条件却拒绝隐含关系; - 提供反证; - 改变公开范围; - 把对方的词反过来使用; - 拿走说话所依赖的时间、物件或身份; - 不给预期反应。 **`craft_default`**:一轮对话中,策略受阻后应当换策略、付代价或退出;重复同一句要求只会延长时长。 **`taste_option`**:有意的重复可以表现仪式、创伤、压迫或喜剧,只要重复本身改变效果或关系。 ### 3.3 每种方法的反向条件 本文给出的每种方法都有它不成立的场合。只写"该怎么做"而不写"什么时候不该",结果是 方法被当成质量指标全场铺开——**对白最常见的坏味道不是方法用得少,是方法用得到处都是**。 | 方法 | 它失效的条件 | 失效后读起来是什么样 | |---|---|---| | 写潜台词 | 人物没有理由隐瞒 | 该说的话被作者按住不说,人物显得莫名其妙地躲闪 | | 把说明改成有利益的传递 | 这条信息本来就中立 | 为了制造冲突而硬派立场,观众看得出争的是假的 | | 打断 | 被打断的那句带着观众此后拿不到的信息 | 信息断供,后面的推断失去依据 | | 停顿与沉默 | 沉默没有可见对象 | 观众读不出在权衡什么,只读到空档 | | 把选择交还给对方 | 交出者本来就无权扣住它 | 不是让步,是虚张声势 | | 借第三人或公共规则施压 | 这条规则观众此前不知道 | 像临时发明的挡箭牌 | | 先承认一部分 | 观众还不知道有更大的秘密 | 部分承认被当成全部真相,后面揭露时观众觉得被耍 | | 区分人物声音 | 差异只落在口头禅上 | 标签化:换掉那个词人物就没了区别 | | 用动作断开长发言 | 这段发言本来就没有内部转折 | 插入的动作是填充,该做的是缩短(`SCR-09`) | | 让每轮承担不同工作 | 场景本来只有一件事要办 | 为了不重复而制造支线,冲淡了唯一的目标 | 配重的用法不是"用之前先查表",而是**修订时反查**:一场对白读着别扭却说不出哪里错, 多半是某个方法用在了它的反向条件上。先找方法,再看条件是否成立。 ## 4. 潜台词来自已知差与风险 潜台词不是人人说谜语,而是“真正想做的事不能/不愿直接说”。先找出不能直说的原因: - 说出会暴露秘密; - 说出会失去体面或关系; - 现场有第三人; - 说话者尚未确认对方知道多少; - 直接要求会显得自己没有筹码; - 角色连自己都不愿承认真实需要。 ### 4.1 三层写法 可用三层检查,不要求每句都复杂: 1. **字面层**:角色实际上说了什么; 2. **行动层**:想让对方做什么; 3. **风险层**:为什么不能直接说。 例: > “你把窗户关上吧,外面的人都听得见。” - 字面:要求关窗; - 行动:把对话从公开变私下,夺回解释权; - 风险:说话者害怕某个事实被第三人听见。 若现场没有外人、秘密或权力差,这句就只是生活指令,不自动具有潜台词。 ### 4.2 让观众有推断依据 潜台词需要可观察线索: - 角色避开一个具体词; - 对方触碰/藏起相关物件; - 称呼改变; - 先回答旁枝,拒绝核心问题; - 台词与可见证据矛盾; - 第三人进入后语言突然改变。 **`reviewed_invariant`**:潜台词不能完全依赖作者事后解释。审查者要指出观众能依据哪些台词、动作与已知事实推断。 **`taste_option · SCR-06`**:有些场景需要直白承认,有些需要高度含蓄;潜台词不是每句必备装饰。 ## 5. 信息如何进入冲突 ### 5.1 把说明改成有利益的传递 信息更有戏时,至少一方对它有态度或利益: - **争夺**:谁有权读、说、保存或解释; - **验证**:证据是否可信,来源是否暴露; - **交换**:信息换取行动、沉默、时间或关系承诺; - **指控**:说出事实会把责任落到某人身上; - **纠正**:新事实迫使对方重新选择; - **回避**:不回答本身暴露风险; - **公开**:同一句话在私下与众人面前代价不同。 弱: > “我们这家店是母亲二十年前开的,她把合同放在纸柜里。” 带议程: > “你要真觉得这只是家店,就别拿母亲那把纸柜钥匙。” 后者不必一次交代所有背景,却把历史、所有权和当下动作绑在一起。 ### 5.2 说明量由现场认知决定 人物不会为了观众重复彼此都熟知的事实。可以通过: - 对事实的不同解释; - 对过去承诺的选择性引用; - 第三人不知道而必须被说服; - 某人故意把私事说成公开规则; - 物证迫使人物说出最少必要部分。 **`craft_default`**:让说明带着争取目标进入;能由动作或证据表达的部分,不再由两人完整复述。 动作已经让观众看懂机制时,台词优先承担新的工作:改变关系、收窄结论、隐藏态度或迫使下一步。若一句 话只是逐项复述观众刚看到的因果,删掉后画面仍完整,就让人物回应结果而不是给画面配答案。 过去事件只说当前动作需要的最小片段。若一名角色能不受阻地一口气交代时间、关系与结果,先检查这些 信息能否被对方打断、质疑、回避或拿来交换;让背景参与当下争取,而不是暂停剧情播放资料。需要完整 宣读、证词或仪式性讲述时可以保留长段,但其现场作用要清楚。 **`taste_option`**:仪式宣读、审讯、教学、公开声明等场景可以容纳长信息,但仍要有听者反应和权力后果。 ## 6. 人物声音如何区分 “一个总说成语,一个总说网络词”只是表面。更稳定的声音来自人物看世界和处理关系的方式。 ### 6.1 五个声音维度 1. **注意什么**:流程、感受、风险、体面、具体物件、长期后果; 2. **怎样证明**:举证、引用规则、讲关系、诉诸经验、逼对方表态; 3. **怎样控制距离**:直呼名字、使用称谓、说“我们”、避免代词、公开/私下说; 4. **怎样隐藏**:多解释、反问、开玩笑、沉默、改话题、过度精确; 5. **压力下怎样变形**:更正式、更短、更礼貌、更粗暴、开始重复或突然坦白。 再加入关系特异性:同一人物面对上级、家人、陌生人和需要保护的人,不会使用完全相同的语言策略。 ### 6.2 建立轻量声音卡 ```text 角色: 默认策略: 认定什么才算证据: 最常回避的主题: 建立亲近/权威的方式: 压力下的变化: 对角色 A 的称呼与禁区: 绝不会自然说出的表达: ``` 不要把声音卡变成强制口癖表。成品中偶尔偏离也可能体现关系或状态变化。 **`craft_default`**:用世界观、关系策略和压力变化区分声音,再用词汇与节奏微调。 **`reviewed_invariant`**:如果交换说话者后台词仍完全成立,审查者应引用片段并说明缺失的是目标、关系还是认知差;不能仅凭常用词判定“同声”。 **`taste_option`**:人物可以极简、健谈、书面、地方化、诗性或笨拙;不要用统一“自然口语”抹平风格。 ## 7. 让对话改变权力与关系 ### 7.1 权力不是音量 现场权力可能来自: - 谁能结束谈话或打开门; - 谁持有可验证证据; - 谁能让话语公开; - 谁承担更少退出成本; - 谁能定义问题与可接受答案; - 谁愿意付更高代价; - 谁掌握第三人的信任。 台词改变权力时,应有后续动作证明:对方交出物件、改称呼、停止解释、转向第三人、撤回条件或离开原位置。 ### 7.2 关系动作 关系变化不必由“我不再信任你”宣布。可通过: - 从私名称呼改为正式称谓; - 把共同物品变成个人物品; - 不再替对方撒谎; - 首次在第三人面前支持/反对; - 接受条件但拒绝情感解释; - 把选择权还给一直被保护的人。 **`reviewed_invariant · SCR-04`**:关键对白结束后,至少能指出信息、权力、关系、情绪行动或物理状态的一项变化;否则审查者应说明该轮是否只是重复。 **`taste_option`**:关系也可以有意维持僵局;僵局要增加成本、暴露策略或改变观众理解,而不是静止。 ## 8. 动作、停顿、打断与沉默 ### 8.1 动作不要成为填充物 避免在每句间插入“看了看、笑了笑、皱眉”。对话间动作应完成: - 操作筹码或证据; - 改变距离和公开范围; - 显示话语与行为不一致; - 给对方一个必须回应的新事实; - 记录连续性变化。 ### 8.2 停顿要有对象 不要只写“沉默片刻”。写清停顿期间人物在避免、等待或判断什么: > 岑放没有回答。他把纸柜钥匙从钥匙圈上拆下来,却没有递过去。 这段沉默包含选择,下一句会因此不同。 ### 8.3 打断的权力 打断可以: - 阻止一个危险词被说完; - 抢走问题定义权; - 保护第三人; - 暴露说话者已经知道答案; - 让本来私密的内容进入公开空间。 打断后要处理未说完内容的后果;否则只是制造表面节奏。 ### 8.4 长发言用动作断开,而不是连排台词 (`SCR-09`) 一段发言长到需要分两口气说完时,有三种处理:连排成两条同角色台词、压成一条长台词、 或者在中间插一条可见动作行把它断开。第三种通常最划算,因为它同时解决下游两个问题: - **给分镜一个有依据的切点**:断开处是一次可见变化,分镜可以在这里换景别或换被摄主体, 而不必自己在一条长台词中间猜该切哪里——猜出来的切点没有来源,审查也无从核对。 - **给表演和配音一个换气口**:说话者在断开处做了一件事,这个停顿有对象(见 8.2), 不是为了凑节奏的空拍。 **`craft_default`**:**只有当发言内部存在议程转折时**,才用一条改变策略的动作行把它断开; 内部没有转折的长发言应当**缩短**,不是拆开。插入的动作必须满足 8.1 的标准(操作证据、 改变距离、暴露言行不一、给出必须回应的新事实、或记录连续性变化),不能是“看了看、 顿了顿”这类填充。断不开就说明这段话本身在替人物做说明,回到第 5 节重写信息的进入方式。 判据是转折,不是字数——多长算长由议程决定。 反例与修法——原稿把动作塞进台词括号里,两者都被削弱: ```text 【角色甲】:这事跟我没关系。再说,(看了角色乙一眼)你要找的人今晚也不会来。 ``` 括号里的动作不会进入分镜的动作序列,台词也被一个括号打断了语气。改成三行,动作成为 真正的一拍,台词各自完整: ```text 【角色甲】(轻描淡写):这事跟我没关系。 ▲角色甲抬手把话题挡回去,随即转头看向角色乙,把手里的名册翻过一页,压住了其中一行。 【角色甲】:再说,你要找的人今晚也不会来。 ``` 注意括注里只剩语气(`轻描淡写`)。"抬手挡回去"是可见动作,必须待在 ▲ 行里——把它塞进 括注就又回到了上面那个反例,只是换了个位置。括注负责怎么说,动作行负责做了什么。 断开点的选择服从议程转折,不服从字数:上例断在“推开话题”转向“反过来施压”的位置。 连排本身也不是错误—— 连续两条同角色台词在人物抢话、自我修正或对方拒绝回应时都成立;下面 SCR-06 的范围同样 覆盖单条台词长度与断开频率,不设统一字数或比例门槛。 **`craft_default`**:只在动作、停顿或打断改变当前策略时写出它们。 **`taste_option · SCR-06`**:留白长度、标点、重叠对白和沉默密度属于表演与风格选择,不设统一比例。 ## 9. 高密度对白场景 一室对谈可以有强烈运动,关键是让对话不断改变“谁能做什么”。 ### 9.1 设计可见锚点 - 一份可逐步露出内容的文件; - 一个会改变公开范围的门/电话/录音; - 一件所有权正在争夺的物品; - 一个有时间限制的现场任务; - 第三人的进入、离开或保持沉默; - 人物位置变化影响谁能读、听、拿到。 锚点服务权力变化,不是为了强行“画面丰富”。 ### 9.2 让每轮承担不同工作 可在修订时标记每轮主要功能: - 定义问题; - 试探知识边界; - 提出条件; - 交出/拒绝证据; - 重新定义关系; - 迫使选择; - 承担后果。 如果连续多轮功能相同,压缩或让反对方换策略。无需追求固定轮数。 ### 9.3 给听者行动 听不是空白。听者可以验证、隐藏、拖延、记录、改变站位、让第三人看见或不给预期反应。这样对白不会变成单人独白加人名。 **`reviewed_invariant`**:对白密集场景仍需可追踪的目标、反制、转向与退出状态;“台词好听”不能替代场景运动。 ## 10. 对白修订流程 ### 10.1 标注行动 在每句旁用动词标记它正在做什么:逼问、遮掩、交换、缓和、划界、验证、转移……无法标注且不承担必要信息的句子,考虑删除或改成动作。 ### 10.2 检查回应关系 每句是否: - 回答字面问题; - 拒绝回答但暴露风险; - 改写问题; - 利用对方刚说的词; - 用行动造成更强回应。 角色可以答非所问,但必须有议程,而非作者忘了上一句。 ### 10.3 删除共同已知说明 标出人物彼此都知道的背景。保留有争议的解释、责任与关系含义,删除只为观众复述的百科句。 ### 10.4 检查策略变化 给每个角色画策略序列: ```text 试探 → 证据受阻 → 借第三人施压 → 暴露自己代价 → 退出/决定 ``` 这只是记录实际序列,不是预设模板。若一人从头到尾只重复一种策略,检查是否需要压缩。 ### 10.5 交换说话者测试 暂时交换两人的台词: - 若情节和关系几乎不变,补入各自的目标、证据观和关系风险; - 若只是词汇不合但行动完全通用,表面口癖过多、人物策略过少; - 若交换后明显违背谁愿意付代价、谁能公开、谁回避什么,声音基础较稳。 ### 10.6 朗读与可表演性 朗读检查: - 句子是否能在当前动作中说出; - 表演提示是否与台词/动作重复; - 呼吸、打断和沉默是否有行动原因; - 长说明是否可以被对方打断、质疑或要求证明; - 重要话语之后是否有行为后果。 ### 10.7 保护风格 修订只解决已指出的问题。不要把方言、笨拙表达、重复、长句或含混一律“润色”为同一种利落金句。 ## 11. 合成示例 情境:岑予想从弟弟岑放手里拿到纸柜钥匙。两人都知道母亲留下过合同,但岑放已答应房东不让姐姐接触原件。 ### 11.1 信息朗读版 ```text 岑予:妈妈把合同原件放在纸柜里,你把钥匙给我。 岑放:我不能给你,因为我已经答应房东把原件交给他。 岑予:可是我是你的姐姐,我有权看合同。 岑放:我们关系不好,所以我不相信你。 ``` 问题:双方直接讲完背景,却没有利用筹码、试探知识或改变现场。 ### 11.2 让台词行动 ```md 岑予把卷帘门遥控器装进口袋。 岑予:钥匙。 岑放:你先把门开了。 岑予:你要真把原件交出去了,还怕我看柜子? 岑放捏住钥匙圈,不让那把铜钥匙垂下来。 岑放:房东只要一份合同。 岑予:他要的是哪一份? 岑放:你连母亲最后签的是哪一页都不知道。 岑予把手从遥控器上移开。 岑予:所以你还没交。 ``` 对白动作: - 岑予先用门交换钥匙; - 岑放反向设条件; - 岑予用“怕看柜子”试探; - 岑放为了压制她,泄露自己知道合同有不同版本; - 岑予据此改判,撤掉原交换条件。 关系与信息通过每轮策略变化显现,没有人物宣布“我们互不信任”。这个版本仍可按创作者选择写得更生活化、更克制或更激烈。 ### 11.3 同一事实的不同人物声音 事实:原件仍在店里。 - 重证据的岑予:“房东收到的那页没有骑缝章。原件没出这道门。” - 重承诺的岑放:“我答应交,不等于已经交。” - 重体面的房东:“我只确认收到文件,不参与分辨你们家的哪一份算原件。” 区别来自人物如何证明和保护自己,不只来自句尾口气。 ## 12. 失败征兆与审查问题 ### 失败征兆 - 人物轮流复述彼此都知道的背景; - 每句都直接说出真实意图,且没有这样做的关系理由; - 所有人用同一种证据观、句式和价值判断; - 一方持续发问,另一方持续完整回答; - 对方已经反制,角色却重复原要求; - 台词很“狠”或很“金”,但没有让任何人改变行动; - 每句后都配无功能的表情动作; - 沉默、打断只用于制造节奏,不留下后果; - 为了含蓄而让观众没有任何推断依据; - 为了利落而抹掉角色的笨拙、地方性或关系差异; - 用对白长度或比例判断好坏; - 写作所有者把自己的偏好当成终审结论。 ### 审查问题 审查者引用具体台词与相邻动作回答: 1. 每个主要人物此刻想让对方做什么? 2. 说话者使用了什么策略,对方怎样反制? 3. 哪句话改变了信息、权力、关系或下一动作? 4. 潜台词的风险是什么,观众有什么推断依据? 5. 必要信息为何必须在此刻、以这种关系动作说出? 6. 交换说话者后,哪些台词会失去人物逻辑? 7. 动作、停顿与打断是否操作筹码或改变公开范围? 8. 修订建议是在解决证据明确的问题,还是把个人口味冒充规则? 所有者根据证据修改对白;需要 verdict 时另起审查动作并记录证据。审查可以由未参与写作的 reviewer 完成,也可以诚实标注为自检;所有者修改本身不自动等于通过。 -
production-format-dialect.md 18.3 KB
# 生产剧本格式与写作工艺 ## 目录 - [格式语法](#一格式语法) - [拍的写法](#二拍beat的写法) - [画外音](#三画外音系统) - [指令](#四指令系统) - [叙事工艺](#五叙事工艺决策规则) - [项目选择](#六项目选择craft_default) - [自检](#七自检清单) - [范例](#八范例) - [反模式](#九反模式定性案例中暴露的失效机制) - [扩写](#十扩写工作流) 来自对不同短剧格式怎样流入资产与分镜的定性案例阅读,示例均为合成改写。本套件的 规范格式见 [screenplay-format.md](screenplay-format.md);本文档服务三个场景: 1. **规范化入口**:创作者交来的现成剧本可能使用这种格式,识别它能减少内容对应错误; 2. **交付偏好**:创作者要求按行业习惯交付时的输出格式(`taste_option`); 3. **写作工艺**:逐案验证可迁移的节拍作用、对峙压力、系统信息与钩子工艺。 **与规则分级的关系**:旧稿中的拍数、字数和格式习惯只用于识别来源格式,不是新稿 质量公式。数字只有被创作者纳入已接受的项目方案后才能成为验收约束;“台词在下游 逐字保留”“规范化不改台词”属于套件硬规则,不因格式不同而放松。 核心思想:**剧本要把故事意义写成可表演、可听见或可制作的内容,而不是只给文学性 解释**。优先用行动、证据、空间压力和对白策略承载;VO/OS、屏显文字与表演括注是 创作者选择的表达方式,不能自动装入所有心理、设定和情绪。若暂留非拍摄性背景说明,应将 它明确标成不进入制作稿的内容,而不是与动作行混写(SCR-03)。 ## 一、格式语法 以下是创作者选择这种格式时的一种结构,不是所有项目的标准答案: ```text <集标题(仅项目使用集标题时)> 【场景 1:地点 - 内/外(日/夜)】 【人物:甲、乙、丙】 △ 环境陈述或空镜(景物、氛围、光线) ▲ 人物动作(主体+单一动作+受体+可见结果) 【角色名】(表演括注):台词 … △ 空镜:<只有转场需要可见画面时才写> 【场景 2:…】 ``` 符号分工(同一项目内恒一致): - `△` **只用于环境陈述与空镜**:`△红烛高燃,喜堂内一片死寂。` - `▲` **只用于人物动作**:`▲李威一把捏住随从甲的手腕,将密奏强行抽走,随从甲手腕被拧得发红。` - 台词行:`【角色名】(表演括注):台词`,冒号全角,括注可省略。 时段槽位词表:日、夜、晨、黄昏、傍晚、深夜、凌晨、幽暗(光线当时段用)、时间不明; 其中日与夜是绝大多数旧稿实际只用到的两个值,其余是补充。 群演写法:`村民甲/乙/丙`(首选)、`员工A/B/C`、`修士若干`、`观众*N`。群演之所以概括 而不点名,是因为**被点名的人物会在下游产生资产**:一个有名字的角色要建身份、要有参考图、 要在每次出现时保持一致。只承担人数、声势或阻挡作用的人不需要这些,点名反而会凭空增加 一整条资产与连续性负担。反过来,一旦某个群演获得了台词、被追踪或在后续集回归,他就已经 是角色了,应当从概括写法里提出来单独建立身份,而不是继续用“壮汉丙”指代。 ### 兼容变体(读旧稿须识别,写新稿不用) 规范化入口面对的旧稿远比一份变体清单杂。**不要假定存在一种行业标准形式**,也不要因为 来稿不符合上面的规范格式就判它有问题——先识别,再对应,格式差异本身不是缺陷。按槽位 逐项识别,比匹配整行模板可靠: | 槽位 | 常见形态 | 较少见形态 | 识别要点 | |---|---|---|---| | 场景头 | 场次编号 + 地点/时段/内外三槽以空格分隔,三槽顺序不固定 | 逗号或斜杠分隔;`场:N-N`+`景:…` 双行式;`【场次】`+`【场景】` 键值块 | 先认出哪几个词属于哪个槽,不要依赖槽的先后顺序 | | 场次编号 | `集号-场序` 写在场景头行首 | 单序号 `N.`;无编号;编号并入括号或冒号 | 先把编号切出来,再分清哪一段是集号、哪一段是场序 | | 时段 | 单字 `日`/`夜` | 晨、黄昏、傍晚、深夜、凌晨等细分词 | 光线词有时被当时段用 | | 内外 | 单字 `内`/`外` | `内景`/`外景` | 与地点连写时需切分 | | 人物行 | `人物:A、B、C`,顿号分隔 | `出场人物:`、`人:`、`角色:`;空格或逗号分隔;整行缺失 | 常紧跟场景头,但并非总在第二行 | | 动作行 | 行首三角符号,**不编号**;或无任何符号、动作直接写成段落(约每六份来稿一份) | 其他符号(`*`、`◆`/`◇`);逐拍编号 `1▲…15▲`,是个别项目的私有习惯,不要用它做位置引用 | 有人物行或 `名:台词` 行时,剩下的段落就是动作行;找不到符号不等于这份稿子没有结构 | | 台词行 | `角色名:台词`(无括注)与 `角色名(情绪):台词` 都很常见 | `角色名:(情绪)台词`(括注在冒号后);`【角色名】` 方括号形式 | 括注在冒号前后都要能吃;很大一部分台词根本没有括注,不能把缺括注当成信息缺失 | | 独白标记 | 角色名后紧跟 `OS`/`VO`,括号可有可无——约六成不带括号 | 小写 `os`/`vo`;带点 `V.O.`/`O.S.`;`(画外音)`、`(内心)`;句尾标记;与情绪词逗号并列写在同一括注里 | 先把名字末尾的 OS/VO 剥掉再比对本场人物——不分大小写、不论有无括号;剥掉后能对上的(`邬砚青os` → 邬砚青)是同一角色的画外通道,不是新角色 | 场次编号、时段、内外这三槽各有约六分之一的来稿整槽缺失;缺哪一槽就空着哪一槽, 不要按上下文补——补出来的时段会直接变成整场的布光依据。 三角符号的具体分工(△ 环境、▲ 人物)是**本套件写新稿的约定**,不是旧稿的普遍规律: 旧稿常常整篇只用一种符号承担全部动作行。识别时按内容判断这一行写的是环境还是人物, 不要按符号反推。 情绪括注不要按封闭词表校验。旧稿里的括注既有一两个字的情绪词,也有整句的微表情与调度 描写,两者都是有效输入;把它当自由文本读,只在它无法对应任何可演内容时才提修订。 ### 集标题(仅项目需要时) 标题应指向本集已确认的行动、结果或新压力,不按题材自动套对仗、反问或“打脸”句式。 例如,某一集同时确认“纸人睁眼”和“活人隐瞒身份”这两个事实时,可写 `纸人睁眼,活人藏脸`;若本集没有这两个事实,就不能借用该句式制造不存在的承诺。 ## 二、拍(beat)的写法 拍(beat)是当前格式中最小的可读行动、反应、信息或状态变化。主体、动作、受体和 可见结果是诊断动作是否清楚的候选问题,不要求每拍四项齐全;一句沉默拒绝、画外声 触发或保持不动的选择也可以成立: ```text ▲李威将密奏撕开,碎片落到王怀安面前。 ← 道具归属与结果清楚 ▲王怀安没有去捡,仍把手放在扶手上。 ← 保持也可表达策略 ``` 避免把心理解释伪装成动作事实(“他心想”应改为有依据的行为或证据,或在创作者选择时 使用 OS);多个独立行动按可读性与制作需要拆拍,不按固定动作数; 形容词若不能写成可表演或可制作的内容,应补证据或删除。 **单词行当拍用**是悬疑/惊悚段落的停顿控制手法: ```text △ 嗒。手电筒打开。 △ 光柱停住了。 △ 画面静止。 ``` 台词与动作按戏剧职责交替:谈判可以连续说话,只要议程、权力或关系持续变化;追逐、 搜寻或沉默对抗也可以连续无台词,只要每拍改变位置、信息、风险或选择。不要为凑比例 插入无后果动作,也不要为缩短文本删除必要的表演处理。 表演括注不只写情绪,也可以写动作(冷笑、点头、低声、打断、挑眉、一字一顿、摆手)、 微动作(`(上前握住她的手)`)或语音 方式(`(声音洪亮,带着回音)`、`(方言)`)。括注只保留能与本句同时完成的表演提示; 若包含独立动作、道具接触或新的状态结果,拆为独立 ▲ 拍,而不是按字数机械截断。 台词中段可插动作括号:`回国?……(抬眼看向身旁)这三百年……`(SCR-08)。 ## 三、画外音系统 | 记法 | 语义 | 用法 | |---|---|---| | `角色(OS):` | 上游或创作者明确的内在声音 | 只在选择该通道时使用;记录非对口型意图,不把所有心理解释自动改成 OS | | `角色 VO:` | 上游或创作者明确的非现场或叙述声音 | 声源、时空和与画面的关系要清楚;电话、回忆或解释不因类别自动归入 VO | | `规则VO:` | 已接受的系统或世界声音身份 | 只有题材和声音方案已建立该身份时登记;也可选择角色可见界面、环境反馈或无系统声 | 同项目内记法和声源关系要一致;通道边界由创作者及已接受的声音方案定义。双语项目 如何呈现原文、翻译或字幕同样读取已接受的文字与声音方案,不自动附中文对照。 ## 四、【】指令系统 `【特写】`、`【音效:砰!砰!砰!】`、`【字幕:…】`(人名/地名/等级标注)、 `【闪回】…【闪回结束】`(本人记忆)、`【闪入:场景 内 夜】…【闪入结束】` (他人往事插叙)、`【转场】`、`【空镜:…】`、`【卡点】/【钩子】`(集尾定格)。 **系统信息的声画选择**(按已接受的文字与声音方案决定,不强制重复): - **声音身份**:若已接受方案把规则设为可听角色,人物或声音表中登记其来源与 声画关系,不用“系统信息”标签自动添加播报。 - **可见文字**:只有观众必须读取且资产允许 `exact-readable` 时才建立屏显;纯声音、 `no-text` 或无批准显示位置时不得自动补字幕。 - **分阶段展示**:首次和后续分别展示什么由当前剧情需要与观众已知决定,不预设面板 栏目、展示次数或更新方式。 ## 五、叙事工艺决策规则 1. **开场切入**:在既有压力迫使人物行动的窗口进入,并尽早让观众看见本剧承诺的 具体画面。异常、反差奇观、关系越线或有后果的日常选择都可以承担开场;前史只给 当前行动所需部分,不按固定拍数或 VO 句数验收。题材与钩子的选择属于开发阶段, 本阶段按已接受的题材与钩子取向执行,不在剧本里重新分类。 2. **对峙升级**:筹码、策略、距离、信息或关系位置发生实际变化时,对峙才算升级。 口头挑衅、道具动作、身体压迫、权力亮出与败方独处只是候选手段;是否使用及如何 收尾取决于人物选择和局部结果,不能机械套成固定阶梯。 3. **对白攻防回合**:每轮应有可辨的议程与策略变化;逼问、抵赖、加码、让步或反将 可以组合,只要人物能表演、对方能接收且局部结果清楚。句长和语气来自人物策略, 不把“掌控者一定更短更平静”当成权力公式。 4. **爽点之后的反应**:主角亮出能力或证据后,让受到权力、关系或信息变化影响的人作出可见 反应。群像可以分出不同立场,也可以用一个代表性反应或全场状态变化;不按群演 台词数量制造热闹。 5. **用做法表现人物立场**:角色怎样索取、回避代价、定义他人或使用礼貌,往往比 自报立场更有效;表面客气、直言威胁、沉默施压或口是心非都只是人物写法的选项。 6. **闪回**只在过去事实会改变当前选择、理解或情绪后果时插入:前有可见触发,后有 当前时空的处理与回收;信息量与长度由当前行动需要决定。 7. **集尾钩子要由本集结果引出**:先确认本集已完成什么局部结果,再问该结果启动了谁的 决定、危险、误解或代价,以及下集首场能否接住。新威胁、身份信息、宣战、静止、 切黑或末拍重叠都是候选手段,不预设句式、镜头或重复方式。 8. **打戏**:动作写清部位、路径与物理后果;重复招式只有在空间、资源、策略或关系 持续变化时保留。是否让主角受伤、让谁出声、何时加入新变量由本场承诺与人物代价 决定,不按循环次数填充。 9. **拆集边界**:先兑现本集局部结果,再留下由该结果启动的决定、危险或问题。边界 可以落在悬念顶点、关系落点或一次行动完成之后;不能只因为场景切换或目标字数到达而截断。 ## 六、项目选择(`craft_default`) | 决策 | 如何确定 | |---|---| | 单集规模 | 从目标成片时长、表演语速、动作路径、停顿和转场反推;不按通用字数填充。 | | 场景数量 | 使用完成本集目标、阻力、转向与局部结果所需的最少场景;地点变化本身不创造戏。 | | 拍数与密度 | 每拍承担可见行动、反应、信息或状态变化;重复职责应合并,过载职责应拆分。 | | 台词负载 | 结合说话目的、语速、口型与同时发生的动作试听;放不下时删减、拆拍或请求修订。 | | 表演括注 | 保留能与台词同步完成的提示;独立动作与状态结果进入动作拍。 | | VO/OS/字幕 | 只承载当前必须知道且不能由更合适的可见行动表达的信息,并服从已接受的文字与声音方案。 | 规范化旧稿时保留其已接受的项目方案;偏离任何示例数量都不能单独判为问题。 ## 七、自检清单 只检查与当前剧本格式和项目选择有关的项目;不要为通过清单补写无来源内容。 1. 每个动作行删掉后是否丢失一个**可拍摄画面**?(丢形容词→删对了;丢画面→写对了) 2. 非拍摄性解释是否与制作稿分开?需要进入本集的信息是否选择了最合适的行动、证据、 对白、声音或文字呈现方式,而不是自动塞进 OS、VO 或字幕? 3. 开场是否已经有压力、行动或本剧承诺的具体画面?集尾是否先有局部结果,再产生新的压力? 4. 每轮升级是否改变筹码、策略、距离、信息或关系?承担后果的人有可见接收吗? 5. 台词、动作与反应是否都承担职责,并能在目标时长和语速内执行? 6. 抽象情绪是否已落为可表演行为、语音或选择,而不是只堆形容词? 7. 编号拍是否连号无重号?格式全集恒一致? 8. 系统信息的声音、字幕与面板是否服从已接受的文字与声音方案,且没有无来源重复? ## 八、范例 ### 范例 A:一种对峙场面的格式写法(合成案例,不是升级链模板) ```text 11-1 边关监军大帐 夜/内 人物:王怀安 随从甲 李威 △油灯跳动,王怀安坐在虎皮交椅上,手指有节奏地敲击木制扶手,发出笃笃声响。 △随从甲双手捧着明黄色绢帛,躬身退后。 王怀安:本监军的密奏,三日内必须送到长安。 随从甲:大人放心,卑职这就去驿站。 △随从甲刚转过身,营帐门帘被猛地掀开,寒风灌入。 △李威身披染血战甲大步踏入,铁靴踩在地面发出沉重声响。 李威(猛然伸手):慢着! 随从甲(身体一颤):李,李将军? △李威一把捏住随从甲的手腕,将密奏强行抽走,随从甲手腕被拧得发红。 李威(撕开密奏扫视):大敌当前,监军大人还有闲心写密奏? 王怀安(脸色阴沉):李将军,这是本监军呈给圣上的机密,你无权... △李威双手用力,将密奏撕成碎片,碎纸片如雪花般飘落。 △李威抓起一把碎纸,重重砸向王怀安脸上。 李威(俯身逼近):边关数万将士的命都在我手上!你再敢搞这些阴私勾当,别怪本将军不给你体面! 王怀安(身体颤抖,后退半步):你,你敢对本监军... 李威(拔出腰刀,刀尖指向王怀安):老子在战场上砍了二十年人头,还怕你这张嘴? △王怀安瞳孔收缩,额头渗出汗珠。 △李威收刀入鞘,拂袖转身,大步离开,帐门帘剧烈摆动。 △王怀安双手紧握扶手,指节发白,牙齿咬得咯咯作响。 王怀安(低声,眼中闪过狠毒):好,好得很。 ``` ### 范例 B:规范化最小对照(只动结构与表演层,不改台词) 原稿: ```text △礼成后,宾客散去,苏卿润走到两人面前,拍了拍萧尘渊的肩膀。 苏卿润:好好待她。 萧尘渊:放心,我此生定不负她。 ``` 规范稿: ```text ▲礼成后,宾客散去,苏卿润走到两人面前,拍了拍萧尘渊的肩膀。 【苏卿润】:好好待她。 【萧尘渊】(目光坚定):放心,我此生定不负她。 ``` (变换:人物动作 △→▲;台词加【】归属;补微表演括注;台词逐字保留。) ## 九、反模式(定性案例中暴露的失效机制) 1. 小说原文直贴当剧本(含"他笑得合不拢嘴"类不可拍心理叙述)。 2. 换行破碎/全角空格污染(PDF 粘贴事故,"灰 雾 降 临")。 3. 场景头、人物行、动作行挤成一个巨型段落。 4. 同质拍只替换人物或道具名,没有改变筹码、策略、信息、空间或关系状态。 5. 只有“镜头/画面/台词”栏目、缺少场景边界与可执行内容的空骨架。 6. 编号重号/漏符号(逐拍编号应机器校验连号)。 7. 括注同时承担独立行动、道具接触或新状态结果;此时按职责拆拍,而不是按字符数。 ## 十、扩写工作流 生产中可以在不改原意的前提下补写制作信息:可补原文已经要求但尚未写成可见行动、 反应或表演依据的内容,不能仅为达到字数重复同类拍。新情节、新信息或新角色要交给 原文归属方修订,不属于格式扩写;扩写稿仍以已接受原文为准,并记录语义变化。 -
scene-handoff-capsule.md 1.5 KB
# 单集跨上下文交接胶囊 长单集因为上下文限制需要分批续写时,可以在**当前会话上下文**维护一份最小交接摘要。它不落盘、 不成为第六份创作文档,也不替代 `剧本.md`。 ## 只记恢复下一段真正需要的内容 - 当前集号和最后完成的场景 ID; - 上一场结束时仍会影响下一场的地点、在场人物、知识、关系、伤势、持物与风险; - 已建立但尚未兑现的 setup/payoff; - 下一场已确定的功能与 exit state;若它由争取或冲突组织,再记 agenda、opposition 与 turn; - 本批仍未解决、会改变下一段写法的真实创作分叉。 只记录变化和下一步依赖,不复制整段正文、角色小传、视觉设定或全部旧节拍。任何信息都要能回到 当前 `剧本.md` 的场景 ID 核对;胶囊与正文冲突时以正文为准。 ## 恢复步骤 1. 重新读取当前 `剧本.md` 的最后一个已完成场景及其相邻上下文; 2. 对照胶囊中的结束状态、未兑现义务和下一场目标,丢弃已经过期的摘要; 3. 只读取下一场直接依赖的分集规划与连续性事实,不预加载无关 reference; 4. 写下一场前重新回答它的必要功能与 exit state;冲突场景再回答 agenda、opposition 与 turn,不能把候选想法当既定剧情; 5. 场景写入正文后刷新上下文胶囊;整集完成后删除它。 胶囊只帮助恢复注意力。它不代表创作者确认、审查通过或交付状态。 -
scene-sound-dramaturgy.md 2.4 KB
# 场次级声音戏剧设计 剧本阶段只写**故事需要听见、撤掉或留白的声音事实**,不替逐镜提示词做混音。声音要改变观众 注意、空间感或人物压力,而不是每场复制一份环境音清单。 ## 先问五件事 1. 场次开始时观众先听见什么,它怎样建立空间或画外角色存在; 2. 哪个声音代表当前秩序、人物习惯或权力; 3. 哪句台词、哪个证据或哪个选择前需要**主动留白**,留白让路给什么; 4. 环境声何时撤出、恢复或改变距离,变化由什么可见事件触发; 5. 是否用 `sound bridge` 把本场结果带入下一场。 对白、VO、OS、SFX 和必须可辨的声源继续用项目制作标签写入剧本并进入索引。没有剧情职责的 空调、车流、鸟鸣不必逐项登记;真正承担证据、威胁、空间或转折的动作声音不能只留在后期想象。 ## 表演与留白 声音不替代表演。身份揭示不默认加冲击音;人物认出证据后,可以让餐具声逐渐停下,只保留底噪 与一次吞咽,让停顿和选择获得空间。配乐也不替代场景转向;若把音乐说明删掉后人物选择不成立, 应先修场景。 多人场景中,群体声音可以分层而不是同步“安静”:靠近的人先停话,远处声源后知后觉,某个角色 继续制造噪声以掩饰紧张。差异必须来自立场、距离或行动,不用情绪形容词解释。 ## 交接边界 剧本拥有逐字对白、关键声源与声音事件的故事事实;`scene_visual_plan.sound_strategy` 决定整场 注意如何运动;video-prompts 再把已接受事实投影到逐镜进入、退出、duck、距离与时机。后两层都 不能改写台词或凭空新增证据声。 留白要写明对象与恢复条件,不等于漏写音轨。相邻镜与相邻场的声源若连续,说明是同一空间的持续、 距离变化还是主动 cut;若用画外声提前建立人物,也要服从剧本的信息权限。 ## 失效检查 - 每镜都列环境音,但整场声音没有开始、撤出、恢复或转向; - 所有冲突点都用同一种冲击音或配乐抬高; - 留白没有对象,只是没有写声音; - `sound bridge` 提前泄露下一场不该知道的身份或地点; - 声音策略要求一个剧本与资产中都不存在的声源。 这些是 agent 结合场次意图审查的语义问题,不用关键词、音效数量或静音秒数写死。 -
screenplay-format.md 10.4 KB
# 中文短剧 Markdown 剧本格式 ## 目录 1. 设计目标与事实所有权 2. 文件、集与场景结构 3. 动作、对白与生产标签 4. 注释与未知内容 5. 完整格式示例 6. 稳定 ID、临时索引与修订 7. 现有文本的规范化入口 8. 剧本中不应出现的内容 9. 完成前检查 ## 1. 设计目标与事实所有权 `剧本.md` 要能在普通 Markdown 编辑器中直接阅读,同时让下游准确引用场景、动作、对白与生产要求。 - 它是场景边界、动作、对白和生产指令的**唯一可编辑剧本源**。 - 需要时生成的临时索引只帮助识别场景、标签和时长,不得反向改写正文,也不作为项目交付。 - 卡片和节拍是写作推理材料,不是第二份正文。 - 资产、分镜、图片/视频提示词与审查结论引用剧本,不在剧本中取得所有权。 规则权限: - **`structural_invariant`**:标题语法、稳定 ID、受支持标签、来源范围与显式矛盾;校验器可阻断。 - **`reviewed_invariant`**:动作是否可表演、语义是否被规范化过程改变;审查者引用证据判断。 - **`craft_default`**:现在时、经济动作、可执行表演提示;可由创作者说明理由覆盖。 - **`taste_option`**:对白口语度、旁白、沉默、动作段落长短与风格化形式;只记录选择。 ## 2. 文件、集与场景结构 ### 2.1 集标题 ```md # EP001 【集名】 ``` `EP001` 是稳定集 ID。修改集名不改 ID。 ### 2.2 场景标题 ```text ## <场景ID> <内|外|内外> · <地点> · <时间/天气> ``` 示例: ```md ## EP001-SC003 内 · 渡口售票室 · 雨夜 ``` - 场景 ID 在同一项目中唯一且稳定; - “内/外/内外”说明主要行动空间; - 地点使用创作者可识别名称,不写资产内部编号; - 时间/天气只保留对表演、光线、行动或连续性有用的事实; - 修改显示名称不改 ID;真正新建场景才分配新 ID; - 拆分/合并后无法明确映射时,展示映射选择,不能静默猜测。 **`structural_invariant`**:生产场景必须有可解析的稳定场景 ID、空间性质、地点和时间/天气字段。 ## 3. 动作、对白与生产标签 ### 3.1 动作段落 普通段落表示现在发生、可见或可表演的行动: ```md 游森把湿透的末班票压在玻璃下,手指仍挡着日期。 ``` 写作要点: - **`craft_default`**:使用现在时与可执行动词;焦点行动、信息或空间关系变化时另起段落。 - **`reviewed_invariant · SCR-03`**:关键心理/认知变化要有行为、证据、空间后果或有意声音作为载体。 - **`taste_option`**:完整动作链、氛围或推理可以保留较长段落,不按统一长度切分。 - **`craft_default · SCR-18`**:动作段落**不要以「短语+全角冒号」开头**。 `第二格:一只手把杯子推过去。` 与 `屏幕上正在放的东西:一段没有声音的录像。` 这样的写法 和 3.2 的对白语法(`角色名:台词`)在字面上完全一样,索引器只能报 `ambiguous_dialogue_or_action` 交回本阶段判断,而它每次都要人来读一遍才能消掉。 改法是把冒号换成破折号、逗号或一个动词:`第二格换成一只手把杯子推过去。` `屏幕上正在放的是一段没有声音的录像。` 句中的冒号不受影响,只有**行首**这一处会撞。 不要在动作段落里写镜号、景别、镜头运动、焦段、生成参数或素材位。 ### 3.2 对白 ```text 角色名(可选的表演/策略提示):台词 ``` 示例: ```md 葛晴(不让门外的人听见):你卖的是明天的票。 游森:你先说,今天那班船去了哪里。 ``` - 角色名与说话者必须明确; - 建索引前由 write owner 语义确认本集说话者清单;确定性工具只核对清单,不从冒号前缀猜人物; - 提示描述一个可执行策略或变化,不堆叠同义情绪词; - 台词跨段确有必要时保持说话者清楚; - 现场不可见说话者使用 `[OS]`,非现场主观/叙述声音使用 `[VO]`。 **`reviewed_invariant · SCR-04`**:对白质量由议程、关系、潜台词与造成的变化判断,不由长度或比例判断。 ### 3.3 生产标签 只在事实对故事不可省略时使用: ```md [VO] 葛晴:我那时还不知道,票背面的孔不是验票留下的。 [OS] 船员:关窗,水进来了! [SFX] 远处传来一声短促汽笛。 [画面文字] 票面日期:11月6日 [连续性] 游森把黄铜钥匙交到葛晴左手;下一场仍由葛晴持有。 [转场] 汽笛声延续到清晨的空码头。 ``` 标签含义: | 标签 | 用途 | 不应用来做什么 | |---|---|---| | `[VO]` | 非现场主观/叙述声音 | 隐藏普通可见行动 | | `[OS]` | 同一场景中的画外对白 | 代替明确说话者 | | `[SFX]` | 改变注意、行动或信息的声音 | 罗列全部环境声 | | `[画面文字]` | 观众必须读清的载体与精确内容 | 复制一般道具说明 | | `[连续性]` | 易丢失且影响后续的状态变化 | 把整套人物设定贴入每场 | | `[转场]` | 有叙事意义的转场要求 | 编写剪辑方案全集 | **`structural_invariant · SCR-05`**:已存在的生产标签必须使用受支持、可闭合的语法, 并且 source reference 可解析;标签不得互相冒充。校验器只证明“已写标签是合法的”。 **`reviewed_invariant · SCR-07`**:reviewer 要对照剧情意义,检查必读文字、 VO/OS、改变行动/信息的关键声音、有叙事意义的转场与易丢连续性事实有没有 被遗漏。这是语义判断,不能由关键词扫描代替。 ## 4. 注释与未知内容 Markdown 注释可以保存创作者的非生产备注: ```md <!-- 待确认:渡口停运年份是否影响下一集时间线。 --> ``` - 注释原样保留; - 临时索引把注释标记为非生产内容; - 分镜覆盖不把注释当成必拍动作; - 生产关键事实不能只藏在注释中; - 未识别的 Markdown 原样保留并发出警告,不要擅自删除或“修复”。 **`structural_invariant`**:规范化 mapping 必须报告每个未映射 source span,不能静默丢字节。 该 span 是否生产关键,仍由 reviewer 根据 `SCR-07` 判断。 ## 5. 完整格式示例 以下为新写的格式示意,只展示语法与可见动作: ```md # EP001 明天的末班票 ## EP001-SC003 内 · 渡口售票室 · 雨夜 售票窗已经落锁。游森从退票口塞进一张湿透的船票,手指压住日期。 葛晴没有接票。她先把窗口旁的时刻牌翻到背面。 葛晴(不让门外的人听见):末班船半小时前就停了。 游森:所以这张票不是今晚的。 葛晴抽出票角。票背面有两个新打的圆孔。 [画面文字] 票面日期:11月6日 [SFX] 河面传来一声汽笛。 游森回头看向已经熄灯的码头,把黄铜钥匙留在退票口。 游森:你要答案,就替我开那道门。 [连续性] 黄铜钥匙留在售票窗内侧,由葛晴取得。 ``` 它遵循: - 压力在开场前已经发生; - 信息通过票、日期、时刻牌与人物行动出现; - 台词各自有议程; - 关键文字、声音与持物交接有明确边界; - 没有把摄影或提示词写进故事源。 ## 6. 稳定 ID、临时索引与修订 需要机器辅助检查时,临时索引应记录: - 块 ID 与类型(场景标题、动作、对白、生产标签、注释等); - 源文件范围与内容哈希; - 所属集/场景; - 上一版本映射与置信状态; - 生产标签类型; - 未映射或歧义说明。 临时索引不得: - 自动润色或规范化创作者文字; - 把显示名称变成另一份权威角色/资产事实; - 在拆分/合并歧义时重用错误块 ID; - 把注释当成生产动作; - 反向覆盖当前 `剧本.md`。 邻近文字修改且语义块映射明确时保留块身份。段落拆分、合并或整场重写无法确定时,提出显式重映射选择。 ## 7. 现有文本的规范化入口 创作者提供其他格式时: 1. 将原始字节保存为 `输入/original-script.<ext>`; 2. 记录原编码与来源说明; 3. 只提议场景标题、动作/对白边界、支持标签与索引所需的最小变化; 4. 展示原文和规范化预览; 5. 分开报告语义新增、删除、改写、未映射范围与低置信映射; 6. 让创作者选择接受、修订或取消; 7. 确认后才更新 `剧本.md`;索引若用于检查,只写临时目录。 **`structural_invariant`**:取消或拒绝不改变规范剧本;接受前不得把预览当成权威源。 **`reviewed_invariant`**:改变说话者、行动先后、事实、因果、态度或未说内容都属于语义变化,即使句子看似只被“整理”。 规范化入口不需要创作简报、故事引擎或新增节拍,也不得为了字段完整补造剧情。 ## 8. 剧本中不应出现的内容 - 人物、场景、道具的完整资产说明; - 镜头表、景别、镜头运动或关键帧构图; - 图片/视频提示词和生成参数; - 模型、任务或接口字段; - 审查结论和内部流程状态; - 原始参考材料位置; - 用统一数量、比例或篇幅冒充创作规律的说明。 故事真相留在剧本,其他所有者通过场景 ID、支持标签和必要短引文引用。 ## 9. 完成前检查 ### 可机械确认 - **`structural_invariant`**:集/场景 ID 唯一、标题可解析、受支持标签闭合、可见引用可解析; - **`structural_invariant`**:明确交接状态与单集卡不冲突; - **`structural_invariant`**:未知 Markdown 和注释被保留,未映射范围已报告; - **`structural_invariant`**:`剧本.md` 仍是唯一可编辑剧本源。 ### 需要审查 - **`reviewed_invariant`**:关键心理意义有可表演载体; - **`reviewed_invariant`**:场景有议程、反对、转向与退出; - **`reviewed_invariant`**:规范化没有隐藏语义变化。 ### 创作者选择 - **`craft_default`**:动作经济、现在时、生产事实不过度标记;可说明理由覆盖; - **`taste_option`**:旁白、沉默、语言风格、场景疏密和动作段落长度被保留。 写作所有者可做以上检查与修订;需要 verdict 时另起审查动作。未参与写作的 reviewer 更能 减少自证偏差,但诚实标注的自检也可记录任何有证据支持的结论。 -
script-craft.md 43.3 KB
# 中文短剧剧本方法 ## 目录 1. 剧本的任务与规则分级 2. 从单集契约到场景 3. 场景发动机:目标、反对、转向、退出 4. 把内在意义写成可表演行动 5. 用空间、物件与反应组织冲突 6. 节奏来自压力变化 7. 写动作段落与生产事实 8. 开场、收束与集间交接 9. 修订方法 10. 现有剧本的最小规范化 11. 合成示例 12. 失败征兆与审查问题 ## 1. 剧本的任务与规则分级 剧本不是故事说明书,也不是镜头提示词。它要让演员、导演和后续制作从同一份创作者文本中读出:谁在争取什么,采取什么可见行动,局面在哪里改变,哪些生产事实不能遗漏。 四类规则: | 分类 | 本层例子 | 执行方式 | |---|---|---| | `structural_invariant` | 场景 ID、来源引用、生产标签、明确连续性矛盾 | 本地校验器可阻断 | | `reviewed_invariant` | 场景是否真正转向、内心是否可表演、因果是否可信 | 审查者引用剧本证据 | | `craft_default` | 晚进早出、用选择和后果推进、动作经济 | 创作者可说明理由覆盖 | | `taste_option` | 静场、旁白、沉默、现实/风格化程度 | 保留创作者选择 | 长度、场景数量、对白比例和转折位置不属于质量证明。 ## 2. 从单集契约到场景 ### 2.1 先锁定不可丢失的事实 从单集卡与因果节拍提取: - 进入时人物知道/误信什么; - 当前目标和阻挡者; - 输入明确要求的选择与后果; - 当集承诺的兑现; - 结尾需要交给下一集的知识、关系、物理和行动状态; - 必须可读的文字、画外声音、关键音效、转场或连续性变化。 这些是写作边界,不是要求照抄卡片措辞。输入明确给出的事实、`必须/禁止`、题材观看契约与退出状态优先于本手册的审查项和手艺默认;手艺只能帮助兑现题面,不能以“更具体”“更严谨”为由补造题面没有的时间数字、记录、权限、资源、程序、关系或机关。需要新增具体事实时,它必须来自题面或前文已建立的因果,而不是来自规则本身。 上游事实对作者已知,不等于对观众已知。只把理解当前行动所必需的赌注带入现场,并选择符合题材的 载体:行为后果、关系态度、空间限制、对白、物件、声音或画面文字都可以。若人物的行动在没有任务说明 时完全失去动机,说明关键前提尚未进入剧本;反过来,观众能从行动读懂时,不必再加通知或解释。 当本集确实设计高代价选择时,相关价值与限制应在行动前可感知,后续事件可以让压力落地或升级,不能 临到决定才第一次发明“为什么只能二选一”。普通决定、直觉行动、习惯反应和单向追求不需要补成两难。 “只在存在时启用”是边界,不是降低强度。输入若已把某个机制设成主要看点,剧本就应让观众通过阻力、行动和后果 感受它,而不是只由人物把规则说出来。条件化防止每集长出同一套机关;它不允许已经承诺的机关只做背景。 ### 2.2 选择场景,而不是给节拍配房间 一个场景值得存在,通常因为它能让人物在同一时间与空间里完成至少一项不可替代工作: - 目标遭遇有筹码的反对; - 证据被获取、验证、争夺或毁坏; - 关系通过行动改变; - 人物作出带代价的选择; - 先前后果抵达并封住旧路径; - 必要的生产事实在剧情中自然显现。 若场景只把卡片内容说一遍,尝试把信息放进一个正在发生的争夺,或合并到真正会转向的场景。 **`craft_default · SCR-02`**:主要转向优先由人物选择及其后果推动;同时选择冲突最有表达力的场域——人物必须做事、空间会限制策略、物件能成为筹码的地方,而非默认坐下聊天。偶然事件可以开启问题,但不应替人物解决核心困境。 **`taste_option`**:单一场景也可承载整集;多场景也不自动更有节奏。形式由故事运动与制作边界决定。 ### 2.3 场景依赖检查 排序前问: - 人物是否已经获得本场使用的知识、道具和权限? - 前场结果是否让本场目标发生变化? - 如果对调两场,因果会不会毫无损失? - 场景退出状态是否能成为下一场进入状态? - 跳时、换地或主观段落是否有明确意图? **`structural_invariant`**:显式来源、场景与连续性引用必须解析;明确声明的前后状态不能无理由矛盾。 **`reviewed_invariant`**:即使字段可解析,场景仍可能只是时间相邻。审查者要引用前后行动说明是否存在真实因果。 ## 3. 冲突场景的发动机:目标、反对、转向、退出 当场景由主动争取或对抗组织时,写正文前可写一张临时工作卡(不必成为永久文件): ```text 场景必要性:删掉后会损失哪项选择/证据/关系变化? 进入状态:人物带着什么事实、行动和压力进来? 焦点人物目标:他想让谁在本场作出什么改变? 反对议程:另一方要什么,用什么筹码阻止? 行动场域:空间、任务或物件怎样让冲突必须通过行动发生? 策略变化:人物先怎样做,受阻后怎样改策? 方向性转向:哪项事实、权力或决定使原计划失效? 退出状态:谁取得/失去什么,下一场为何现在必须发生? ``` ### 3.1 目标要能作用于现场 “想获得尊重”“想弄清人生”太宽。把它落成可执行动作: - 让对方在众人面前承认记录被改过; - 在门关闭前拿到钥匙; - 阻止妹妹读出遗嘱附页; - 迫使合伙人选择签字或公开反对。 目标可在场景中失败,但人物必须有可表演的尝试。 ### 3.2 反对不是说“不” 反对方可以: - 重新定义问题; - 交换条件; - 拿走行动所需资源; - 质疑证据来源; - 让公开代价高于私下妥协; - 与第三人结盟; - 表面答应但改变执行条件。 有效反对会迫使焦点人物更换策略,而不是延长同一轮喊话。 反过来,焦点人物成功时也要改变反对方的筹码或选择。“他没再说话”“她收回手”“对方转身离开”可以是有力的反应,但前提是前一动作已迫使他们付出具体代价、失去某项权限、保留可追溯的反对或改变下一步。 若沉默只是作者不再让对手行动,转向尚未完成。 ### 3.3 转向要改变下一动作 可用测试:转向前人物准备做什么?转向后为什么不能照做? 如果答案只是“更生气”,还缺少新的事实、筹码、关系或决定。一次极小的转向也可有效,例如某人把钥匙放到桌子另一边,公开表明不再替同伴保管秘密。 **`reviewed_invariant · SCR-01`**:由主动争取或冲突组织的场景应有当前议程、对抗力量、方向性转向和退出状态;转向需有可追溯的事实、筹码、代价或选择使原策略失效,不以对手无因沉默或离场代替。审查者必须引用场景中的行动证据,不能仅检查是否填写了场景卡。 氛围、仪式、蒙太奇、过渡和后果消化场景不必虚构一个对手或策略失败来套这张工作卡。它们仍需完成 不可替代的变化,例如让知识、关系、压力、节奏或生产状态进入下一阶段;若删掉后这些都不损失,才是 场景必要性问题。静止、重复和停顿可以就是被设计的运动,而不是自动判为“没有转向”。 **`craft_default`**:在压力已活跃时进入,在有意义的变化后离开。若例行准备本身承载关系或危险,可有意保留。 **`taste_option`**:静场、犹豫和沉默可以成立,只要压力与变化仍可感知。 ### 3.4 巧合与误会:什么时候可以用 `SCR-02` 说重大转向优先用选择和后果,而不是巧合。这不等于巧合和误会不能用——短剧里 两者都是高频且有效的手段,问题从来不是"用没用",而是**用在哪一侧**。 **巧合有方向性**。 制造问题的巧合是古老且成立的(碰见不该碰见的人、快递送错门); 解决问题的巧合是欺骗,因为观众投入的是人物怎么应对,而不是世界怎么补偿他。判据四条: 1. **位置**:把人推进麻烦可以,把人从麻烦里捞出来不可以。困境靠巧合解除时,前面所有 挣扎都被追认为无关紧要。 2. **代价**:巧合送来的必须是负担,不是礼物。捡到关键证据本身不成立;捡到关键证据、 而拿着它就意味着必须解释自己为什么在那里,成立。 3. **世界是否建立过**:同一栋楼、同一条通勤线、同一个熟人圈里的偶遇是**设定**,前面 建立过就不算巧合;毫无铺垫的偶遇是作者伸手。 4. **只用一次**:同一条线上第二次巧合就成了模式,观众会停止相信这个世界的因果。 已建立的常规资源可以帮人物换策,但不能恰好替他付款。若道具、身份、权限或旁人的出现直接完成了原本最难的 一步,人物自己的策略、取舍或关系反应就会被掠走。资源可以改变问题的形状,解决仍应由人物完成。 旁人因为人物闯祸而进场时,他的反应通常应让人物更难维持原有策略,而不是无条件欣赏、宽恕或刚好送来可用资源。温情、荒诞或反常反应 当然可以成立,但需要是作品已经建立的世界与语气,不是作者为了收场突然放过人物。 **误会的失效点几乎总在解除的那一刻**。 最常见的写法是:误会产生 → 拖了几场 → 解释清楚 → 一切照旧。这样的误会整段可删,因为它没有留下任何东西。四条判据: 1. **人物有理由不解释**。一句话就能澄清而谁都没理由不说时,那是拖延不是戏。理由可以是 保护别人、承认代价太大、根本没有机会开口,但必须写出来。 2. **观众的位置要选定**。观众知道真相 → 悬念(看着人物走向错误);观众不知道 → 惊讶 (和人物一起被翻盘)。两种都成立,但要**选一种**并贯彻,摇摆的结果是两种效果都没有。 3. **误会必须产生行动**。只让人物"心里难过"的误会撑不起场次;它要让某人做出在真相下 绝不会做的选择。 4. **解除时必须有残余**。澄清之后关系回不到原点:有人看见了对方在压力下的选择,有人 欠下了道歉,有人失去了本可以不失去的东西。没有残余就说明这条线没有兑现。 **`craft_default`**:优先用选择和后果承担重大转向;巧合与误会用在制造压力的一侧, 并带着代价和残余。 **`taste_option`**:喜剧、闹剧与仪式性重复可以刻意堆叠巧合,只要堆叠本身是被看见的 风格选择,而不是因果链的替代品。 ### 3.5 把一拍写成艰难选择时,两个方向都要活着 本节只用于剧本主动承诺“艰难选择”的段落,不是每集模板。“人物作出选择”不等于剧本给出两个按钮。 如果人物在选择前已经说清一边没有价值或根本无法执行,剩下的方向只是顺序动作,不必硬包装成两难。 写核心选择前分别回答: - 选择 A 此刻保住什么,立刻失去什么? - 选择 B 此刻保住什么,立刻失去什么? - 为什么人物不能先后都做?时间、资源、承诺、身份或公开代价具体封住了哪条路? - 人物行动后,失败的一边留下什么可见残余? 代价可以是资源、关系、身份、时间、秘密、承诺或自我认知。关键是选择前双方仍有真实价值,选择后 只计算由该行动新增或加重的损失;原本无论如何都会发生的困境仍可施压,但不能重复记作这次选择的成本。 只关闭两条路径中真正互斥的部分,不为显得深刻而顺手丢掉可兼得的收益。可以做退出状态反事实:若走 另一条路,结尾的哪项损失不会发生?答不出来时,可能只有压力没有选择代价,也可能这本来就不是两难。 选择触发应来自已经活跃的价值、人物或因果,而不是一个从未建立过的便利条件恰好在犹豫瞬间出现并替 角色决定。偶然事件可以制造问题或提供信息;最终方向仍要由人物如何回应来定义。 **`reviewed_invariant · SCR-13`**:当一拍被写成重大选择时,至少两个仍可执行、仍有价值的方向保持到 人物行动;普通决定与单向追求不启用本条。审查者引用选择前被保住和被牺牲的价值,不能只检查有没有“还是”。 ## 4. 把内在意义写成可表演行动 ### 4.1 区分意义与载体 作者意图: > 她终于意识到父亲一直不相信她。 这不是不能写的意义,而是需要为剧本找到载体。可选: - **行为选择**:她停止解释,把准备交出的证件收回; - **证据反应**:父亲先核对陌生人的签名,却把她的证词推到一边; - **空间动作**:她从父亲身旁移到被质疑者一侧; - **关系动作**:她把一直替父亲保管的钥匙放下; - **明确声音**:有意使用 `[VO]`,让主观声音成为叙事选择。 载体不必把意义解释完。演员可执行、观众有依据推断即可。 **`reviewed_invariant · SCR-03`**:关键内在事实应通过行为、证据、空间后果或有意标记的声音表达。审查者引用具体句子并说明为何无法表演;禁止仅凭心理词匹配下结论。 **`taste_option · SCR-06`**:主观旁白、梦、记忆、直视镜头都可使用,只需清楚标出并服务创作者风格。 ### 4.2 用可表演动词 动作段落优先描述人物能做的事:压住、抽回、避开、递出、擦掉、停下、拆开、锁上、挡住、对齐、撕下、重新摆放。 不要为了“可见”堆满微表情。动作的价值在于改变空间、物件、注意力或关系,而不是记录每次眨眼。 **如果项目声明由生成模型承担画面**,动作还要多过一道:日常影像里罕见的精细动作 (在运动中截住某物、以厘米计的位移、三步以上的双手编排)在实拍里完全成立,在生成 管线里往往整镜不可用。此时优先让常见动作的**组合与时机**承担同一份戏剧信息。实拍 项目不受此限;形态未声明时不要预先裁剪。判据与改写方法由视频提示词环节拥有,本环节 只需知道这条约束存在,不必在剧本里提前降级。 弱: > 他很心虚,眼神复杂,内心充满挣扎。 更可执行: > 他把盖章页翻过去,只递出没有签名的那一面。门外的人伸手时,他仍按着纸角。 后者没有命名全部情绪,却给演员目标、阻力和持物状态。 ### 4.3 让反应承担后果 反应不是每句台词后的表情。值得写的反应至少完成一项: - 证明话语击中了对方; - 暴露对方早已知道; - 改变下一步策略; - 转移现场权力或注意; - 造成连续性事实。 **`craft_default`**:重大信息或关系动作后,给受影响者一个有后果的反应;普通往返不需要逐句配动作。 ## 5. 用空间、物件与反应组织冲突 ### 5.1 空间是可用关系 写清对剧情有用的地理: - 谁挡住出口或控制柜门; - 关键物件在谁可触达范围; - 谁能看见文字,谁只听到声音; - 公开与私密边界在哪里; - 人物改变站位后,联盟或威胁怎样显形。 不要在剧本中写镜头清单。空间事实让分镜有依据,但画面方案由后续技能决定。 ### 5.2 物件要进入行动链 道具不是装饰名词。问: - 谁拥有、持有、看见或误认它? - 它能证明、阻止、交换、隐藏或迫使什么? - 状态变化是什么——封口、破损、转交、露出文字、失效? - 变化后谁必须作新决定? **`craft_default`**:重要物件第一次出现时,让人物对它做事;后续状态变化以可见动作记录。 **`structural_invariant · SCR-05`**:剧情要求观众读清的文字、明确画外音/画外对白、关键声音、转场与易丢失连续性,必须使用支持的边界标签。校验器检查标签与字段,不判断台词好坏。 同一物件或动作可以重复出现,但每次必须有新工作:持有人变化、文字露出、用途改变、旧承诺被重新解释, 或人物对它采取了以前不会采取的行动。重复只为提醒观众时,优先删掉解释;重复后的状态变化本身就是 记忆。不要为了“丰富”不断引入一次性道具,让观众每场重新学习一套证据。 当过去关系推动现在的行动时,尽量让它影响人物此刻的策略,而不是暂停剧情讲完往事。可通过称呼、 回避、承诺、空间距离、习惯动作或输入已经支持的物件承载;物件只是选项之一。若一个细节只负责提示 “这里该感动”,可以删掉或让它承担更具体的关系工作,但不要求每段关系都拥有重复道具。 ### 5.3 证据先改变判断,再改变关系 本节只在人物的重大判断依赖**可争议证据**时启用。证据不是万能真相按钮,可以先拆开它可能涉及的四层: 1. **来源/真伪**:它是不是刚刚伪造、是否来自所声称的时间和地点; 2. **身份**:谁接触、写下、持有或留下它; 3. **原因/动机**:那个人为何这么做; 4. **完整真相**:事件究竟怎样发生。 先问当前转折究竟依赖哪层结论,再找仍然活跃的普通反解释。需要现场验证时,把反解释改写为可观察预测: 如果它为真,现场哪项结果应不同?测试使该预测失败,结论才可收紧;测试做不到,就保留不确定性。悬疑 可以故意延迟验证,喜剧可以让人物因性格而误判,主观叙事也可以让错误判断持续——关键是剧本知道这是 人物相信的事,而不是把它当世界真相。 人物只更新被证明的那层判断,结果命名也不超过实际展示的范围。真正的戏剧变化通常在下一行动:撤回 指控、改变合作方式、隐藏新发现、升级防备或承担风险,而不必统一落成一句“我信了”。 若结论依赖连接、封存、原位、时间或未被触碰等状态,按动作顺序检查谁改变过它;验证可以消耗证据, 但已被消耗的性质不能继续充当结论依据。相对日期只有在它驱动关键判断时才需要当前锚点,历史记录本身 只能证明记录内容,不能自动证明“现在”。 当证据是本集主要观看对象时,至少让一次观众可跟随的状态变化实际改变推断:它使某个解释更可能、更不可能,或明确暴露当下无法越过的证明边界, 并因此改变人物的下一动作。拍照、拿起、收袋、编号、登记等操作只有在改变接触连续性、可用性或调查方向时才是戏剧节点;否则它们只是程序布景。 一次验证已经改变本集需要改变的判断后就停止;只有仍然活跃的反解释会迫使人物采取不同下一动作时,才继续测试。不要为了显示严谨,把同一结论依次交给页面、原始文件、日志、时间戳和人物复述重复确认。 这不等于每个线索都要现场鉴定;“保留不确定”也能成为可拍结果,前提是观众看得见不确定的边界发生了什么变化。 **`reviewed_invariant · SCR-14`**:当重大判断依赖可争议证据时,结论不超过证据与已展示验证所支持的 范围;作品可以排除反解释、保留不确定性或有意延迟验证。普通事实、喜剧误会、主观叙事和以歧义为目标 的线索不强制增加取证程序。 ### 5.4 主要配角有策略,事务角色不硬加戏 承担主要戏剧功能的配角会占用观众注意力,应有自己的目标、判断或策略,能影响主角采取何种行动;否则 考虑合并人物或把信息交给更自然的载体。这里判断的是**角色承诺与篇幅是否匹配**,不是要求人人改变主线。 若一个人物在开场提供核心筹码、提出主要反对或承担选择的代价,转折发生后也要继续有一次相称的行动或反应;不必获胜,但不能 因为主角已经作出决定就突然变成可被拖走、略过或替代的功能物。 对手在开场控制的筹码不会因为主角完成一个动作就自动归零。若对手此后确实无法继续交易、阻拦或反制,用前面已建立的空间、力量、权限、公开关系或自身选择说清它如何失效, 而不是直接把他放到一边。 事务性或环境性角色可以只完成问路、交易、播报、见证等局部功能;群像、荒诞喜剧和仪式场景也可能故意 让个人退入集体节奏。不要为了通过检查给每个有对白者硬加秘密、反转或关系动作。 当配角承载过去关系时,只说当前争取需要的最小片段,再让对方的反应补足历史;若他只是中性传达背景, 可把说明压短或移入正在发生的冲突。删掉角色后主线完全不变并不自动是缺陷,先看作品是否承诺他是人物。 ### 5.5 人多的场次:先解决"谁是谁" 一场里出现七八个人时,最先崩掉的不是冲突,是**辨识**。观众分不清谁在说话、谁站哪边, 后面写得再有力也接收不到。竖屏与快节奏会放大这个问题:观众没有回看的习惯,错过一次 就一路错下去。 四种可组合的办法,按代价从低到高: 1. **减人**。与本场议程无关的人直接不出现。人物在场不等于有戏份,写进来却没有可执行 动作的人,只会分走注意。 2. **让人陆续进场**。不要一开场就把所有人摆好。先让一组产生冲突,冲突到某个位置时下 一组才进来。每次进场都是一次自然的介绍,也是一次压力升级。 3. **轮换本场主对手**。人确实必须同时在场时,把一场切成前后段,每段只让一个人真正与 焦点人物交手,其他人退为反应。观众每次只需要跟住一组关系。 4. **让人物互相递交**。切换注意力之前,先由当前说话者点名或看向下一个人,再把戏交过去。 没有交接的切换,读起来就是一群人轮流发言。 **`craft_default · SCR-12`**:多人场次先保证观众能分清谁在与谁交手——用减人、陆续进场、 分段轮换主对手或人物之间的明确递交完成,而不是靠动作段落逐一点名在场者。 仪式、群体压迫、闹剧式围攻这类场面本身就以"人多且乱"为效果时,可以有意违反本条; 此时要让混乱是被看见的选择,并至少保留一条观众能跟住的线索。 ### 5.6 主角不在场的场次 镜头可以整场不给主角,甚至整集不给。条件是这一场的**情绪、推进、戏剧性与期待**仍然 挂在主角身上:观众看反派上钩,看的是主角上一场埋的那一手;观众看旁人震惊,震惊的 对象是主角做过的事。 反过来说,如果一场戏删掉之后,主角的处境、观众对主角的期待都没有变化,那么它多半 是在替配角讲他们自己的故事——这在长篇里是支线,在短剧的篇幅里通常是失血。 **`craft_default`**:主角缺席的场次,要能说清它替主角推进了哪一项——已埋下的布置被 触发、观众对主角的期待被抬高、主角将要面对的威胁被具体化,或主角刚造成的后果落地。 ## 6. 节奏来自压力变化 节奏不是统一缩短句子或持续提高声量。它来自观众处理的任务变化: - 等待一个人决定; - 追踪谁知道什么; - 看一项动作能否完成; - 接收证据并重估旧事实; - 承受关系动作后的停顿; - 预见一个代价即将落地。 ### 6.1 加速 - 删除人物都知道的重复说明; - 让台词与动作同时争取目标; - 在前一后果还未消散时触发反制; - 把例行进入压缩到压力已经发生的位置; - 用清楚的物理任务替代抽象争论。 ### 6.2 留出呼吸 - 重大选择后让受影响者真正改变行为; - 让观众看清证据、位置或持物变化; - 在情绪转向时保留一个有内容的停顿; - 让人物承担胜利的代价,而不是立即转下一个事件。 **`craft_default`**:快慢应由信息密度、行动难度与情绪后果变化,不要让所有段落同样紧绷。 **`taste_option`**:作品可以高压、克制、荒诞、仪式化或生活流;节奏目标随风格改变。 任务明确给出目标时长时,先按真实表演通读:台词要说完,人物要移动、寻找、验证、反应,观众也要 看清关键文字。项目没有声明语速时不能伪造精确秒数,但仍可发现明显装不下的节拍;优先删重复日期、 第二次同义验证、已被动作说明的台词和回报后的同义收尾,而不是把所有动作默认为瞬间发生。 ### 6.3 倒计时先算动作,不要只写数字 本节只用于被前景化、可由观众逐项核对的**字面精确死线**。这类压力来自“来不及”,动作链要经得起 最粗的现实检查:移动、寻找、操作、等待和说话都占时间,每次显示新时间应与其间完成的工作大致相称。 蒙太奇、跳时、梦境、主观拉伸或明确风格化的计时不自动接受现实秒表审查,先按作品建立的约定判断。 “快点”“赶不上”“快迟到”“所剩不多”等定性压力不是精确死线;若题面没有给出可核对数字,不要擅自把它升级成分钟、秒数、时间戳、通知或系统记录。具体性应落在人物行动与后果上,不靠新增数字证明紧迫。 压缩专业过程时,保留会改变局面的节点,不保留操作说明书:诊断、受阻、换策、分工、取舍或可见结果 中只选本场真正需要的部分。可互换步骤可合并;能改变人物关系、风险或方案的步骤才值得展开。 短时长高潮通常承受不住连续多种同质障碍与多轮解释。优先保留最能改变策略的一项;观众已从动作看懂 机制后,让后续关系或行动承担结论,不再由对白逐步复述同一过程。 同一种压力出现多个计时或信息载体时,检查它们是否各自承担节奏、误导、反讽、主观体验或后果;只有 纯粹重复播报时才合并。关键验证也优先形成一条观众跟得上的连续动作,而不是为显得严谨重复展示。 若目标确实来不及,人物可以缩小目标、换熟悉方法、调用已建立资源或接受部分失败。新的解法仍须穿过 输入已经建立的核心阻力;临到高潮才新增此前无迹可循的资源、权限或能力直接抹掉困境,不是换策略。 **`reviewed_invariant · SCR-16`**:当作品前景化字面精确死线时,时间标记与动作容量大致一致;不一致时, 剧本让压缩约定、换策、目标变化或失败可读。蒙太奇、跳时、主观时间和明确风格化计时按既有约定判断。 时间事实还必须单调:`20:59:47` 后的十秒倒数只能在 `20:59:57` 左右结束;整点报时发生后,不再出现 `20:59:58`。宁可少写一个数字,也不要让精确数字互相拆台。 ### 6.4 有限单位按完成状态计数 题面若把“三次机会”“两轮演示”“倒数中的每一拍”等有限单位设为观看约束,先从要求的完成状态倒推每个单位必须留下什么可见结果。只有该单位结束时目标状态已经完成,才算消耗一个单位;命名单位、报数、提出请求、获得授权、开始准备或在边界之后补做,都不能冒充完成。作品可以故意让某个单位失败或只完成一部分,但要把失败/部分完成作为该单位的可读结果,而不是让所需完成动作悄悄移到计数之外。 有限单位不是新的模板:题面没有此类结构时不要主动分拍、报数或增加倒计时;题面使用诗性、主观或仪式化计数时,按它已经建立的完成约定判断,不擅自改成现实秒表。 ## 7. 写动作段落与生产事实 ### 7.1 动作段落 - 使用现在时; - 一个段落聚焦一个行动单位或注意力变化; - 写必要的空间与物件结果; - 不替演员规定每块肌肉; - 不写摄影参数与后续提示词; - 只有故事需要时才写精确文字、颜色、左右手或方向。 “短”不是硬规则。完整的证据获取或动作链可以稍长;混入多个焦点时再分段。 ### 7.2 表演提示 `角色(提示):台词` 中的提示最好描述一个可执行策略或变化: - 把威胁说成提醒; - 不让第三人听见; - 改口前先确认对方是否知道; - 放弃解释; - 第一次直呼对方名字。 少用同义情绪词堆叠,如“愤怒、激动、崩溃地”。台词与前后动作已经清楚时,可不写提示。 ### 7.3 生产标签 仅在故事确实需要时使用: - `[VO]`:不由现场可见说话者发出的主观/叙述声音; - `[OS]`:同场景中画外人物的对白; - `[SFX]`:会改变注意、行动或信息的关键声音; - `[画面文字]`:观众必须读清的载体与精确内容; - `[转场]`:有叙事意义的转场要求; - `[连续性]`:由前文可见动作已经建立、很容易在后续丢失且影响故事的明确状态变化;它只能交接状态,不能以作者断言证明镜头外“从未发生”“始终无人接触”等负面事实。 **`structural_invariant`**:生产关键事实不能只藏在作者注释或模糊形容里。标签内容应有明确边界,并与普通动作/对白区分。 **`craft_default`**:能通过自然动作清楚表达的事实,不要重复成标签。 ## 8. 开场、收束与集间交接 ### 8.1 开场接住进入状态 开场优先让观众看见: - 正在继续的行动; - 已落地的后果; - 当下必须处理的矛盾; - 与既有认知冲突的可见事实。 若环境本身携带威胁、限制或信息,它可以成为开场主体;不要无条件禁止氛围。 **`craft_default`**:在压力活跃处进入,并用具体行动证明进入状态,而非先由人物复述上集。 **`taste_option`**:冷开场、缓慢观察、先果后因、平静日常都可以选择;它们必须服务作品的观看契约。 ### 8.2 收束完成本集承诺 结尾可以让局部结果被看见,也可以让关系、情绪、喜剧节奏、恐惧或主题意象获得落点。连载需要交接时, 再写清下一集继承的行动、知识、关系或物理状态;不是所有单集都必须把新压力顶到最后一秒。 收束先回答本集承诺的功能是否完成:可以是局部结果、关系重新定义、认知变化、喜剧落点、恐惧余波、 讽刺反照或有意保持开放。连载作品通常还需要可交接状态,但不等于最后一拍必须追加外部事件。 局部回报不必清空代价。选择后仍存在的残余可留在人物行为、关系、空间、声音或物件状态里;主题已经 能从结果读出时,不再用抽象口号解释。重复动作、静止画面或环境声也能收束,只要它因前文获得新意义。 如果前文把某项代价当作行动的核心阻力,退出状态至少要保留它的一项实质影响:能力暂时缺口、身体损伤、资源消耗、 关系债务或必须处理的后续行动均可。不要在高潮后立即让系统、能力或局面自动复位,使原先的赌注只留一处轻微痕迹。 回报落下后,逐拍问“它在加深、转义、沉淀,还是重复说明?”多项后果可以并存,不强制只留一拍; 只有功能相同、观众已接收的内容才合并。安静余韵不是空转,机械复述才是。 人物放下、拿起、让路、留下或离开的动作已清楚表明了意向时,后面再写一次同向决定只会稀释第一次,除非中间有新阻力使第二次付出了不同代价。 笑点、情感或意象也一样:行动和画面已经完成的意义,不再用一句标语概括;一个生活细节已经形成隐喻时,不连续用多句人生道理翻译它。 已经声明不可逆的后果不应被后续动作无意写回轻易可挽回。作品若有意展示缓和、反转或仍存余地,让新的 因果清楚可读,而不是靠含混连续性同时声称“路径关闭”和“其实没关系”。 承担关系转折的主要人物应有相称反应,但反应可以是撤回、回避、沉默、策略变化或延迟爆发,不要求统一 改持物或立刻表态。结尾连续性则要求关键持物、空间和知识状态能从前文动作追溯;氛围性钩子、黑场、 声音和静止画面都可使用,只要它们完成作品的期待,而不是替代一个本该写出的结果。 **`craft_default · SCR-15`**:收束留下可读的结果或余效,并服从本集观看契约;推进、留白、反讽、 静止或开放状态都可成立,不用抽象口号替观众解释,也不为满足规则强造新事件。 ### 8.3 写精确出去状态 检查: - 谁在哪里,正要做什么; - 谁知道/不知道什么; - 谁持有什么,状态如何; - 关系与权力怎样变化; - 伤势、服装、光线、天气、时间是否延续; - 哪项动作已经不可撤回。 **`structural_invariant`**:剧本明确写出的出去事实必须与单集卡指向的 权威交接一致。若使用 development projection,“更好的结果”先路由给 develop owner 修订 episode map;若使用 `write_standalone`,才由 write 修订自有契约。 不可让两份权威并存。 ## 9. 修订方法 不要一遍同时改所有问题。每一遍只解决一种失败,并保护已接受选择。 ### 9.1 因果遍 在每个主要动作旁写: - 它因何发生? - 人物为何此刻选择它? - 对手如何回应? - 它改变了什么? - 下一动作为何由此被迫发生? 删除可互换顺序且无后果的重复。 ### 9.2 选择遍 仅当本集把某一拍写成艰难选择时启用。标出仍可执行的方向,暂时盖住任务说明,只读剧本,检查观众 能否指出各自价值、人物为何不能兼得,以及哪项损失由这次行动新增。若题面本来是单向追求或普通决定, 不要为了完成本遍而补出第二条路径。 ### 9.3 证据遍 仅当重大判断依赖可争议证据时启用。写“证明了什么 / 没证明什么 / 仍有哪些活跃解释”,再决定本集是 现场验证、保留不确定性还是有意延迟。不要为了完成本遍给普通事实加取证程序,也不要破坏喜剧误会、 主观叙事或悬疑线索有意保留的歧义。 ### 9.4 场景遍 标出每场目标、反对、策略变化、转向和退出。没有变化的场景: - 与相邻场合并; - 让证据/关系/决定在此真正改变; - 或删去,把必要信息移到正在发生的冲突。 ### 9.5 可表演遍 圈出只存在于作者说明中的认知和情绪。为关键项选择行为、证据、空间、关系动作或有意声音;不要为每个形容词补微表情。 ### 9.6 具体性遍 替换不产生行动的泛词: - “资料”究竟是登记页、录音还是钥匙卡? - “威胁”具体让谁失去什么? - “关系破裂”通过哪项承诺、称呼、持物或站位变化出现? 只补对识别、因果、表演或连续性有用的细节。 ### 9.7 生产事实遍 检查可读文字、声音、画外对白、转场和连续性标签。去掉模型、镜头和资产说明,把它们留给各自所有者。 ### 9.8 声音与潜台词遍 使用 [dialogue-craft.md](dialogue-craft.md)。朗读角色台词,检查删掉角色名后是否仍能凭策略、关系和措辞辨认,而不是靠口癖标签。 ### 9.9 扫读遍 前面几遍都是慢读——逐条检查因果、可表演性、具体性。这一遍相反:**用比正常阅读快得多 的速度把本集扫一遍**,尽量不停顿、不回看,然后只回答两个问题: 1. 有没有需要停下来才能想明白的地方?谁说的这句、刚才那个人做了什么、这个东西是哪来的; 2. 扫完之后,情绪上有没有起伏?还是只知道发生了一串事? 任何一处需要停顿,都记下位置——那通常意味着信息挤在一起、指代不清、或者一句台词同时 承担了太多任务。第二问答不上来,说明这一集可能每一场都成立,但合起来没有形成运动。 这一遍模拟的是真实观看条件:观众不会重看,不会推敲,注意力还被别的事分走。慢读能证明 剧本**讲得通**,扫读才能证明它**接收得到**。 **`craft_default`**:交付前至少做一遍扫读,把需要停顿的位置和"没有情绪起伏"的段落 记下来,再决定改不改。它是自检手段,不是质量门槛——刻意要求观众停下来思考的段落, 可以在说明理由后保留。 若作品使用可由观众逐项核对的字面精确死线,扫读后再做一次**可行性快查**:把时间标记之间的动作 顺序读出来。只能靠“默认很快”才能塞入时,按 SCR-16 让压缩约定、换策、目标变化或失败可读。蒙太奇、 跳时、主观时间和明确风格化计时不做现实秒表换算。 ### 9.10 经济性遍 最后静默扫掉四类规则展示:输入没有要求的精确数字或记录;为同一结论叠加的验证链;复述动作的标签或对白;回报后多个同方向决定、解释或后果。先问能否保留一个主要因果载体完成同样的戏,再删除没有新作用的载体。删的是冗余展示,不是选择、证据、时限、人物或连续性 knowhow。 ### 9.11 所有权遍 列出语义变化、未改部分与受影响下游。创作者确认后更新 `剧本.md`;所有者不能给自己签发终审通过。 ## 10. 现有剧本的最小规范化 创作者带来非规范文本时,目标是**建立可追踪入口,不是重写**。 流程: 1. 原始文件按字节保存到 `输入/`,记录编码与来源说明; 2. 识别明确的场景、动作、对白与生产要求; 3. 只补提议中的场景标题、块边界和必要标签; 4. 输出并列预览; 5. 分别列出语义新增、删除、改写、未映射段落和低置信映射; 6. 让创作者接受、修订或取消; 7. 只有确认后才更新 `剧本.md`;需要时临时生成索引做格式与时长检查,不把索引作为项目产物。 **`structural_invariant`**:拒绝或取消不得改变现有规范剧本;未映射的生产关键内容不得静默丢失。 **`reviewed_invariant`**:所谓“仅格式变化”若改变说话者、先后、因果、态度或事实,就必须作为语义改写展示。 **`craft_default`**:保留作者原句和段落边界,只有可追踪/生产需要时才提议重排。 ## 11. 合成示例 以下情境为新写示例:社区打印店闭店前,姐姐岑予要拿回母亲留下的合同附页,弟弟岑放负责看店并已答应把文件交给房东。 ### 11.1 只有说明,没有场景运动 > 岑予来到打印店。她和弟弟关系一直不好。两人谈起合同,都很生气。弟弟告诉她合同已经交给房东,她非常震惊。 问题:目标与筹码未进入行动,关系由作者宣布,信息没有争夺,转向只靠一句告知。 ### 11.2 把意义放进动作 ```md ## EP001-SC002 内 · 社区打印店 · 夜 岑放把碎纸箱推到取件台下。最上面露着半枚蓝色骑缝章。 岑予没有去抢。她把卷帘门遥控器装进口袋,门停在离地半米的位置。 岑予:附页给我,门就开。 岑放把手机翻过来。屏幕上是房东发来的收件确认。 岑放(把解释说成通知):你晚了十分钟。 岑予抽出碎纸箱里那一角。背面没有母亲的手写日期。 岑予:你交出去的是复印件。 岑放第一次看向上锁的纸柜。 [SFX] 卷帘门电机重新启动。 ``` 场景发动机: - 岑予要用关门权换回附页; - 岑放用“已经交出”封住路径; - 骑缝章与手写日期让证据可见; - 转向是两人发现原件仍在店里,目标从追房东变成争纸柜; - 岑放的视线与门启动形成下一动作,不需要解释“他心虚”。 例子不规定后续真相或结局,只展示目标、反对、证据与状态变化怎样进入可拍文本。 ## 12. 失败征兆与审查问题 ### 失败征兆 - 场景只传递信息,没有任何议程受阻或策略变化; - 角色轮流说出作者想让观众知道的内容; - 内心状态没有行为、证据、空间或声音载体; - 对手只会拒绝,主角只会重复要求; - 动作段落堆微表情,却没有改变物件、位置或选择; - 重大台词后人物行为完全不变; - 场景结尾恢复到进入状态; - 重要文字、声音或持物变化没有明确生产边界; - 剧本混入镜头、资产全集或媒体提示词; - 规范化时悄悄润色、补剧情或改变说话者; - 所有场景使用同一紧张度与句式; - 多人场次靠动作段落逐一点名在场者,观众仍分不清谁在与谁交手; - 主角缺席的场次替配角讲了他们自己的故事,主角的处境与观众的期待都没有变化; - 扫读时反复需要停下来确认谁说了什么、某样东西是哪来的; - 用篇幅或数量替代场景必要性判断。 - 题目并未承诺两难,却为了显得深刻硬加第二条路径;或反过来,声称是艰难选择却提前让一边失效; - 可争议证据让人物接受远超证明范围的完整真相,且作品未把它标成误判; - 事务性角色被硬加秘密、反转或关系动作,只为满足“每个人都要推动主线”; - 字面精确死线只能靠未说明的瞬间完成成立;或把蒙太奇、主观时间误审成实时连续; - 结尾用主题口号解释已清楚的意义,或为了“必须有钩子”硬加与本集无关的新事件。 ### 审查问题 审查者引用场景标题、动作或对白回答: 1. 本场谁想让什么发生,反对者用什么筹码阻止? 2. 哪个动作/事实改变方向,转向后下一动作为何不同? 3. 哪些关键内在意义没有可表演载体? 4. 空间和物件是否参与冲突,而非只作装饰? 5. 重大信息之后,受影响者的行为怎样改变? 6. 生产关键事实是否有正确边界,是否混入其他层内容? 7. 本集的进入与出去状态能否精确交接? 8. 修订是否保护了创作者的风格选择与未改部分? 9. 若本集承诺艰难选择,各方向在行动前是否仍有价值,哪项代价由选择新增?若没有此承诺,是否避免硬造两难? 10. 若重大判断依赖可争议证据,结论是否与证明范围相称;作品是验证、保留还是有意延迟? 11. 局部回报是否留下本集所需的结果或余效,同时没有用口号抹掉意义? 12. 若前景化字面精确时限,动作链是否可信;若采用蒙太奇、跳时或主观时间,约定是否清楚? 13. 私人动机通过何种符合题材的行为、关系、对白、沉默、空间或物件载体进入当前行动? 14. 若证明依赖连接、封存、原位、时间或无触碰状态,该性质是否被前序动作破坏? 15. 结尾形式是否服务本集观看契约,而不是机械满足“必须新增事件”或“必须安静”的单一配方? 写作所有者依据证据修订;审查动作负责记录结论,不能把修订产物本身冒充 verdict。 -
stage-contract.md 7.1 KB
# 剧本阶段契约 本阶段只拥有 `剧集/<EP>/剧本.md`:场景、动作、对白、画外音、声音事实、画面文字、连续性提示与转场。 它继承项目/分集决定,但不决定人物视觉身份、镜头构图、提示词或媒体生产。 所有来源都用文档中可见的集号与场景 ID 定位;不建立第二份节拍、block、审批或状态文件。格式标签 只负责区分生产语义,不借格式补造剧情。输入明确给出的事实、必须/禁止、题材观看契约与退出状态优先于本表;规则不能补造题面没有的精确数字、记录、资源、程序或机关。 ## 本阶段规则 ### `SCR` | ID | Class | Knowledge | |---|---|---| | SCR-01 | reviewed_invariant | When a scene is organized around active pursuit or conflict, it has a current agenda, opposing force, directional turn, and exit state. A traceable fact, leverage change, cost, authority, or choice makes the prior strategy fail; an opponent's unexplained silence, inaction, or departure does not substitute for the turn. Atmospheric, ritual, montage, transition, and consequence-processing scenes may instead justify themselves through a necessary change in knowledge, relationship, pressure, rhythm, or production state; they must not manufacture an opponent merely to satisfy this rule. | | SCR-02 | craft_default | Prefer choices and consequences over coincidence for major turns. | | SCR-03 | reviewed_invariant | Private thought is expressed through behavior, evidence, or deliberate VO/OS. | | SCR-04 | craft_default | Dialogue carries agenda, relationship, subtext, and a change—not only information. | | SCR-05 | structural_invariant | Existing production tags use supported, closed syntax and resolvable references. A continuity tag records a state established by shown action; it cannot establish that an unseen event or contact never occurred. | | SCR-06 | taste_option | Silence, slang, interruption, narration, and sentence rhythm remain character/style choices. | | SCR-07 | reviewed_invariant | Story-critical text, VO/OS, SFX, transition, and continuity requirements are not left indistinguishable from ordinary prose. | | SCR-08 | craft_default | When abstract emotion obscures performance, translate it into character-specific behavior, object handling, distance, silence, or delivery. Dialogue turn length and tactic follow the scene agenda rather than a universal attack-defense cadence. | | SCR-09 | craft_default | Break a long speech with a visible action beat that changes the speaker's tactic, giving downstream a sourced cut point and the performance a breath; a speech with no internal turn is shortened rather than split. | | SCR-10 | reviewed_invariant | When the creator marks a beat's realization as replaceable under later pressure, the record separates the dramatic function from the current depiction and names a fallback depiction that delivers the same function: same person proven or changed, downstream payoff refs and next-episode entry state still satisfied, cost not erased, no new setup required. Deleting the beat is never a fallback. An unmarked beat leaves the rule inactive—the suite carries no platform standard, predicts no outcome, and pre-emptive sanding is the more expensive mistake. | | SCR-11 | craft_default | When sound carries story information, spatial pressure, off-screen presence, a deliberate silence, or a scene bridge, the screenplay identifies the necessary source/event and its dramatic target; it does not prescribe per-shot mixing, add decorative sound to every scene, or use music to replace performance. | | SCR-12 | craft_default | A crowded scene first makes clear who is contending with whom—by cutting non-essential presence, staggering entrances, splitting the scene so one opponent holds the focus at a time, or handing attention from one character to the next—rather than naming every present character in action paragraphs. Deliberately chaotic ritual, siege, or farce may override this once the disorder is a visible choice and one followable thread remains. | | SCR-13 | reviewed_invariant | When a beat is framed as a consequential choice, at least two executable values remain live until the character acts, and the screenplay makes the relevant stakes and newly caused cost perceptible. This rule is inactive for ordinary decisions, habits, or single-path pursuits; it must not manufacture a dilemma the episode does not need. | | SCR-14 | reviewed_invariant | When a belief change hinges on contested evidence, the conclusion stays within what the evidence and any shown test support. If that evidence is a primary episode engine, at least one audience-followable state change changes which explanations remain live or changes the next action; routine photographing, bagging, numbering, or logging does not count by itself. The scene may test the strongest live counter-explanation, preserve uncertainty, or intentionally delay verification; this rule does not require a forensic procedure for uncontested facts, comic misunderstanding, subjective narration, or clues whose ambiguity is the point. | | SCR-15 | craft_default | A local payoff leaves a legible result or aftereffect without replacing the episode's meaning with a slogan. A cost framed as central pressure has a material exit-state effect rather than automatically resetting once the action succeeds. The result may be plot change, relationship response, irony, stillness, tonal afterimage, or an intentionally open state; a new external event is not mandatory. | | SCR-16 | reviewed_invariant | When a literal precise deadline is foregrounded and audience-auditable, its action chain fits the stated time and established resources, or the screenplay makes compression, failure, or a changed objective legible. Qualitative urgency is not upgraded into invented minutes, timestamps, notifications, or records. Montage, ellipsis, subjective time, and declared stylization follow their own established convention rather than an automatic real-time test. | | SCR-17 | reviewed_invariant | When the brief defines a limited number of attempts, turns, breaths, beats, or other units, each unit is counted by its visible end-state. A label, count, request, authorization, preparation step, or cleanup after the boundary does not substitute for the required completion; intentional partial completion or failure remains valid when it is the legible result of that unit. | | SCR-18 | craft_default | An action paragraph does not open with a noun phrase followed by a full-width colon. `第二格:一只手把杯子推过去。` is byte-for-byte the dialogue form `角色名:台词`, so the indexer can only return `ambiguous_dialogue_or_action` and hand it back for a human read; every such line costs one round trip for nothing. A dash, a comma, or a verb removes the collision. Colons inside a sentence are unaffected — only the start of the line collides. | 规则分级由高到低:`structural_invariant`(结构缺陷,阻断)、 `reviewed_invariant`(需证据判断)、`craft_default`(常用做法,可覆盖)、 `taste_option`(创作者选择,不作缺陷)。创作者已接受的事实优先于本表。 -
substitutable-realization.md 6.1 KB
# 可替换实现:让「要改」的时候改的是实现方式,不是戏 ## 目录 - [这条规则不做什么](#这条规则不做什么) - [真正的失效形状](#真正的失效形状) - [功能与实现分开写](#功能与实现分开写) - [什么样的备选算数](#什么样的备选算数) - [哪些位置值得看一眼](#哪些位置值得看一眼) - [两种更常见的错误做法](#两种更常见的错误做法) - [合成示例](#合成示例) - [自检](#自检) ## 这条规则不做什么 **本套件不内置任何平台的审核标准,不预测任何内容能否通过,也不给出题材禁令**。 标准随平台、地区、时间和分发方式变化,写进技能只会同时做到两件坏事:过时,以及把 创作者本可以拍的东西提前劝退。 它只做一件事:当某个兑现**只有一种实现方式**、而这种实现方式后来必须更换时,让更换 发生在**实现层**而不是**戏剧层**。要不要标、标哪些,由创作者判断;套件负责的是—— 标了之后,改动是有备而来的。 ## 真正的失效形状 昂贵的返工不是「某个镜头被要求换掉」,而是**换掉它之后这集不成立了**。 具体过程:某个兑现依赖一个特定呈现(一件证物上的字、一次身体接触的力度、一个场所的 性质、一段自白的具体内容)。这个呈现被下游一路消费——资产变体按它建、关键帧按它冻结、 运动规格按它写、相邻集的进入状态按它接。等到必须更换时,能改的只剩最后一环,于是 唯一可行的操作变成**把这场删了**。删掉之后,它承担的功能没有别处兑现,前面所有铺垫 变成空转。 改动窗口的关键性质是**它会关闭**:呈现刚写下时替换成本接近零,被下游消费之后成本 指数上升。所以标注要发生在剧本层,事后补标没有意义。 ## 功能与实现分开写 标注一个节拍时,把三件事分开: | 字段 | 回答什么 | 常见错误 | |---|---|---| | 功能 | 这个节拍在故事里**兑现了什么**:谁的什么被证明、谁的处境如何改变 | 写成情绪词("高潮""爽点"),无法据此判断备选是否等价 | | 当前实现 | 现在**用什么呈现**兑现它 | 与功能混写成一句,导致换实现就得换功能 | | 备选实现 | 换一种呈现,**同样兑现该功能** | 写成"改温和些",这是削弱不是替换 | 分开写本身就有诊断价值:如果一个节拍的功能写不出来,问题不在合规,在这个节拍本来 就没兑现什么。 ## 什么样的备选算数 备选必须**兑现同一个功能**,判据是: 1. **同一个人被证明或被改变**。换了受影响的人,就是另一个节拍。 2. **后续依赖仍然成立**。看这个节拍的 `payoff_refs` 与下一集的进入状态:备选执行后, 它们是否仍然被满足?有一条落空就不是备选。 3. **代价没有消失**。兑现通常带着代价(暴露、失去、欠下)。备选把代价抹掉时,后面的 压力也就没了来源。 4. **不需要新的铺垫**。备选如果要求一个前面没建立的事实,它不是备选,是改稿。 **「删掉这场」永远不是备选**——删除消掉的正是功能本身。可接受的写法是把功能移交给 另一个已经存在的节拍,并写清移交后那个节拍要多承担什么。 ## 哪些位置值得看一眼 按**机制**看,不按题材看。以下三种结构最容易在更换时连累戏剧层: - **兑现与呈现焊死**:这个兑现只有这一种拍法。焊死本身不是缺点(很多好戏正是如此), 但它意味着一旦要换就是重写,所以值得提前写下备选。 - **风险在链条上游**:被标注的呈现是后面多个节拍的依据。上游改一处,下游全线要重接, 此时备选必须连带说明下游怎么接。 - **窗口即将关闭**:该呈现马上要进入资产、关键帧或运动规格。这是最后一次低成本标注的 机会;再往后标注只剩记录价值。 ## 两种更常见的错误做法 **提前自我阉割**。 把所有可能敏感的地方统一磨平。代价是确定的(戏没了),收益是不确定的 (并不针对任何具体标准)。短剧的兑现本来就建立在越界、失衡和代价上,统一磨平之后剩下的 是一集没有人会看完的合格品。标注的意义恰恰是**保住当前实现**——先按最想要的拍法写, 备选只在真的需要时启用。 **假备选**。 备选栏写"视情况调整""改得含蓄一点""删减部分内容"。这类写法在需要时提供不了 任何可执行的东西,等于没标。备选必须具体到能直接改写成场景动作。 ## 合成示例 (虚构情境:社区食堂的排班员被指控偷改排班。) - **功能**:证明排班员确实改过记录,且是为保护一个不能公开的人;她的可信度与她的动机 同时暴露。 - **当前实现**:她当众把手写排班本摊开,指出被涂改的那一行,说出被保护者的名字。 - **备选实现**:她把排班本交给指控者本人翻,指控者读到那一行时停住不念——名字由**反应** 交代而不由台词交代。功能相同:改动被证明、动机被暴露、可信度同时受损;被保护者的 身份仍然进入后续依赖,只是载体从台词换成了他人的反应。 - **不算备选**:把这场改成她私下解释(受影响的人变了,公开暴露的代价消失);把这场 删掉(功能无处兑现)。 ## 自检 只在创作者标注过的节拍上执行;未标注时本节不生效,也不要因为没标注而提缺陷。 1. 功能是否写成了「谁的什么被证明或被改变」,而不是情绪词? 2. 备选执行后,本节拍的 `payoff_refs` 与下一集进入状态是否仍然成立? 3. 代价是否仍在?还是被备选一起抹掉了? 4. 备选是否需要一个前面没有建立的事实? 5. 备选是否具体到可以直接写成场景动作,而不是一句"酌情处理"? 6. 这个呈现是否已经被下游消费?若是,标注只剩记录价值,替换要走修订请求。
-
-
scripts
-
duration_estimate.py 12.1 KB
#!/usr/bin/env python3 """Estimate how long a screenplay runs, using the project's own declared rates. This reports a number; it never judges one. The suite carries no cross-project speech rate and no tolerance band, because a 90-second episode of dense argument and a 90-second episode of silent work do not convert at the same ratio. The creator declares the two rates their project actually uses, and this script applies them. Without declared rates the script still counts dialogue characters and action paragraphs -- those counts are facts about the text -- and says the seconds cannot be derived yet. That is the honest output, not a guess from a default. What counts as a line and what counts as a paragraph is decided by ``screenplay-index.jsonl``, never re-derived here. This script used to carry its own reader of the screenplay format, and a second reader of one format is a second set of answers: it timed ``[VO]`` at zero, billed a Markdown comment as speech, read ``他写下两个字:军宣。`` as dialogue because of the colon, and counted one multi-line action paragraph once per line. The index already classifies all four correctly. Reading it means this script cannot disagree with the artifact the rest of the pipeline cites. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable, Mapping # Creators run these scripts on whatever interpreter their machine provides, so # an unsupported version must say so instead of failing inside an import. MINIMUM_PYTHON = (3, 9) if sys.version_info < MINIMUM_PYTHON: raise SystemExit( "short-drama needs Python {}.{} or newer; this interpreter is {}.{}".format( *MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor ) ) # [VO] and [OS] are spoken off-camera. The format contract writes them as # ``[VO] 角色:台词`` -- a real line. Timing them at zero silently shortens every # episode that carries its interiority in voice-over, and the estimate then reads # as a deficit the writer pads to fill. VOICED_TAGS = {"VO", "OS"} VOICE_TAG_PREFIX = re.compile(r"^\[(?:VO|OS)\]\s*") # ``角色(提示):台词`` -- the speaker label may carry a parenthesised direction. # Only the spoken half is timed; the direction is a note to the performer. DIALOGUE = re.compile(r"^(?P<who>[^::((\[\]]{1,24})(?:([^)]*)|\([^)]*\))?[::](?P<line>.+)$") class StaleIndex(Exception): """The index does not describe the screenplay it was handed.""" def _spoken_characters(line: str) -> int: """Count what is actually voiced: no whitespace, no bracketed directions.""" stripped = re.sub(r"([^)]*)|\([^)]*\)", "", line) return len(re.sub(r"\s", "", stripped)) def _is_voiced(block: Mapping[str, Any]) -> bool: """Speech is a dialogue block, or a production tag that is spoken aloud.""" kind = block.get("kind") if kind == "dialogue": return True return kind == "production_tag" and block.get("tag") in VOICED_TAGS def _block_text(screenplay: bytes, block: Mapping[str, Any]) -> str | None: """Return the block's own bytes, or None when its span does not fit.""" start, end = block.get("byte_start"), block.get("byte_end") if ( not isinstance(start, int) or not isinstance(end, int) or not 0 <= start < end <= len(screenplay) ): return None try: return screenplay[start:end].decode("utf-8").strip() except UnicodeDecodeError: # A span that begins or ends mid-character is a stale offset, not a # crash: report it the same way as any other span that does not fit. return None def measure(screenplay: bytes, blocks: Iterable[Mapping[str, Any]]) -> dict[str, Any]: """Count the timed material, taking every classification from the index.""" dialogue_lines = 0 dialogue_characters = 0 action_paragraphs = 0 tag_lines = 0 unreadable_blocks: list[str] = [] for block in blocks: if block.get("record_type") != "block": continue kind = block.get("kind") if kind == "action": # One paragraph is one block however many lines it occupies. The # format contract lets an action paragraph run several lines. action_paragraphs += 1 continue if not _is_voiced(block): # Scene headings, comments and instruction-only tags carry no # performed duration. Comments are not production content at all. if kind == "production_tag": tag_lines += 1 continue text = _block_text(screenplay, block) if text is None: unreadable_blocks.append(str(block.get("block_id"))) continue spoken = DIALOGUE.match(VOICE_TAG_PREFIX.sub("", text)) if spoken is None: # The index recorded it as speech but it does not use the documented # line grammar. Guessing a duration here is how a wrong number gets # reported as a fact; the block is named instead. unreadable_blocks.append(str(block.get("block_id"))) continue dialogue_lines += 1 dialogue_characters += _spoken_characters(spoken.group("line")) counts: dict[str, Any] = { "dialogue_lines": dialogue_lines, "dialogue_characters": dialogue_characters, "action_paragraphs": action_paragraphs, "production_tag_lines": tag_lines, } if unreadable_blocks: counts["unreadable_blocks"] = sorted(unreadable_blocks) return counts def index_blocks( index: list[dict[str, Any]], screenplay: bytes ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """Return the index's blocks and its own review state. An index built from other bytes is refused rather than measured: its spans would land on text it never classified. """ meta = next( (row for row in index if row.get("record_type") == "screenplay_index_meta"), None, ) if meta is None: raise StaleIndex("the index carries no screenplay_index_meta record") declared = meta.get("source_byte_length") if not isinstance(declared, int): raise StaleIndex("the index declares no source_byte_length") if declared != len(screenplay): raise StaleIndex( "the index was built from {} bytes but this screenplay is {}; " "rebuild it with screenplay_index.py before estimating".format( declared, len(screenplay) ) ) review = { "review_status": meta.get("review_status"), "source_issue_count": meta.get("source_issue_count"), } return [row for row in index if row.get("record_type") == "block"], review def declared_rates(project: dict[str, Any] | None) -> dict[str, Any]: """Read the project's own pacing rates, or report that it declared none.""" pacing = ((project or {}).get("format") or {}).get("pacing") or {} per_second = pacing.get("spoken_characters_per_second") per_action = pacing.get("seconds_per_action_paragraph") usable = isinstance(per_second, (int, float)) and per_second > 0 and ( isinstance(per_action, (int, float)) and per_action >= 0 ) return { "declared": bool(usable), "spoken_characters_per_second": per_second if usable else None, "seconds_per_action_paragraph": per_action if usable else None, } def estimate( screenplay: bytes, blocks: Iterable[Mapping[str, Any]], project: dict[str, Any] | None = None, review: Mapping[str, Any] | None = None, ) -> dict[str, Any]: counts = measure(screenplay, blocks) rates = declared_rates(project) target = ((project or {}).get("format") or {}).get("target_seconds_per_episode") result: dict[str, Any] = {"counts": counts, "rates": rates, "seconds": None} result["target_seconds"] = target if isinstance(target, (int, float)) else None # Material the index could not classify is material this estimate did not # time. Reporting the seconds without that fact is how an incomplete number # gets read as a complete one. issues = (review or {}).get("source_issue_count") unreadable = counts.get("unreadable_blocks") or [] if isinstance(issues, int) and issues > 0 or unreadable: result["incomplete"] = { "index_review_status": (review or {}).get("review_status"), "source_issue_count": issues, "unreadable_blocks": unreadable, "note": ( "the index left some of this screenplay unclassified, so the " "counts below cover less than the whole text; resolve the index " "issues before reading these seconds as the episode's length" ), } if not rates["declared"]: result["note"] = ( "the project declares no format.pacing rates, so seconds cannot be " "derived; the counts above are still exact" if "incomplete" not in result else "the project declares no format.pacing rates, so seconds " "cannot be derived; the counts above are exact for the blocks the " "index classified, which is not all of this screenplay" ) return result seconds = ( counts["dialogue_characters"] / rates["spoken_characters_per_second"] + counts["action_paragraphs"] * rates["seconds_per_action_paragraph"] ) result["seconds"] = round(seconds, 1) if result["target_seconds"]: delta = seconds - result["target_seconds"] result["delta_seconds"] = round(delta, 1) result["delta_ratio"] = round(delta / result["target_seconds"], 3) result["note"] = ( "informational only: the suite sets no tolerance band, because the " "right spread depends on the project's own scenes" ) else: result["note"] = ( "no format.target_seconds_per_episode is declared, so there is " "nothing to compare the estimate against" ) return result def _load_jsonl(path: Path) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): if not line.strip(): continue try: row = json.loads(line) except json.JSONDecodeError as error: raise SystemExit("{}:{}: {}".format(path, number, error)) if isinstance(row, dict): rows.append(row) return rows def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Estimate screenplay duration from the project's declared rates." ) parser.add_argument("screenplay", type=Path) parser.add_argument( "--index", type=Path, required=True, help="screenplay-index.jsonl built from this screenplay by screenplay_index.py", ) parser.add_argument( "--project", type=Path, default=None, help="short-drama.json carrying format.pacing and target_seconds_per_episode", ) args = parser.parse_args(argv) if not args.screenplay.is_file(): raise SystemExit("screenplay not found: {}".format(args.screenplay)) if not args.index.is_file(): raise SystemExit("screenplay index not found: {}".format(args.index)) project = None if args.project is not None: if not args.project.is_file(): raise SystemExit("project file not found: {}".format(args.project)) project = json.loads(args.project.read_text(encoding="utf-8")) screenplay = args.screenplay.read_bytes() try: blocks, review = index_blocks(_load_jsonl(args.index), screenplay) except StaleIndex as error: raise SystemExit("{}: {}".format(args.index, error)) report = estimate(screenplay, blocks, project, review) print(json.dumps(report, ensure_ascii=True, indent=2, sort_keys=True)) # An estimate is never a verdict, so this exits successfully even when the # episode lands far from its target. Blocking here would turn a reported # number into the cross-project threshold this suite refuses to carry. return 0 if __name__ == "__main__": raise SystemExit(main()) -
screenplay_index.py 42.6 KB
#!/usr/bin/env python3 """Build a byte-accurate, derived index for a short-drama Markdown screenplay. The indexer recognizes only the screenplay grammar documented by the write skill. It never rewrites the screenplay and never guesses through a split/merge revision. """ from __future__ import annotations import argparse import json import os import re import sys import tempfile from collections import defaultdict from pathlib import Path from typing import Any, NamedTuple # Creators run these scripts on whatever interpreter their machine provides, so # an unsupported version must say so instead of failing inside an import. MINIMUM_PYTHON = (3, 9) if sys.version_info < MINIMUM_PYTHON: raise SystemExit( "short-drama needs Python {}.{} or newer; this interpreter is {}.{}".format( *MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor ) ) # --------------------------------------------------------------------------- # REFERENCE RESOLVER -- reference implementation. # # Each skill checker carries its own copy of this block. The suite has no shared # library on purpose: a skill must stay runnable after copying only its own # directory, so duplicating these few lines across skills is the correct shape. # Copy the block verbatim; do not import it. # --------------------------------------------------------------------------- SOURCES_RECORD_TYPE = "sources" SOURCES_SCHEMA_VERSION = "1.0.0" SOURCES_KEY = "sources" class ResolvedRef(NamedTuple): """An upstream reference with its snapshot resolved, whichever form it used.""" owner: str artifact: str record_id: str | None field: str | None authority: str | None class RefFinding(NamedTuple): """A structural defect in a reference object.""" code: str location: str detail: str def load_sources(document: Any) -> dict[str, dict[str, Any]]: """Return the ``sources`` declaration of a parsed file, or ``{}`` if absent. Accepts a parsed ``.json`` document (a dict) or the parsed record list of a ``.jsonl`` file, whose declaration lives on the first record. """ if isinstance(document, list): document = document[0] if document else None if not isinstance(document, dict): return {} declared = document.get("sources") if not isinstance(declared, dict): return {} return {key: value for key, value in declared.items() if isinstance(value, dict)} def resolve_ref( ref: Any, sources: dict[str, dict[str, Any]], location: str ) -> tuple[ResolvedRef | None, RefFinding | None]: """Resolve a reference object written in either the compact or expanded form.""" if not isinstance(ref, dict): return None, RefFinding("REF_IS_NOT_AN_OBJECT", location, f"got {type(ref).__name__}") src = ref.get("src") if isinstance(src, str): entry = sources.get(src) if entry is None: return None, RefFinding( "REF_SRC_IS_NOT_DECLARED", location, f"src {src!r} has no sources entry" ) owner, artifact = entry.get("owner"), entry.get("artifact") if not (isinstance(owner, str) and isinstance(artifact, str)): return None, RefFinding( "SOURCE_ENTRY_IS_INCOMPLETE", location, f"sources[{src!r}] needs owner/artifact", ) elif all(isinstance(ref.get(key), str) for key in ("owner", "artifact")): owner, artifact = ref["owner"], ref["artifact"] else: return None, RefFinding( "REF_HAS_NO_UPSTREAM_BINDING", location, "needs src, or owner+artifact" ) optional = { key: ref[key] for key in ("record_id", "field", "authority") if isinstance(ref.get(key), str) } return ( ResolvedRef( owner, artifact, optional.get("record_id"), optional.get("field"), optional.get("authority"), ), None, ) # --------------------------------------------------------------------------- # END REFERENCE RESOLVER # --------------------------------------------------------------------------- SCHEMA_VERSION = "1.0.0" # An index declares its upstream screenplay once, on the meta record, and every # reference in the file names it by key. A rebuild against a previous index adds # a second key only when the previous screenplay is a different snapshot. SCREENPLAY_SOURCE_KEY = "screenplay" PREVIOUS_SCREENPLAY_SOURCE_KEY = "previous-screenplay" SUPPORTED_TAGS = ("VO", "OS", "SFX", "画面文字", "连续性", "转场") KIND_CODES = { "scene_heading": "H", "action": "A", "dialogue": "D", "production_tag": "P", "comment": "C", } # Three digits through EP999, then unpadded — the same single spelling per # episode number the lifecycle tool enforces on `episodes/<EP>/`. A fixed three # digits would have made EP1000 a legal directory but an illegal heading. _EPISODE = r"EP(?:[0-9]{3}|[1-9][0-9]{3,})" EPISODE_HEADING_RE = re.compile(rf"^# (?P<episode>{_EPISODE})(?: .+)?$") SCENE_HEADING_RE = re.compile( rf"^## (?P<scene>{_EPISODE}-SC[0-9]{{3}}) " r"(?P<space>内外|内|外) · (?P<location>\S(?:.*\S)?) · " r"(?P<time>\S(?:.*\S)?)$" ) DIALOGUE_RE = re.compile( r"^(?P<speaker>[^\s:():\[\]#]{1,40})" r"(?:((?P<cue>[^()\r\n]+)))?:(?P<text>\S[\s\S]*)$" ) ASCII_DIALOGUE_RE = re.compile( r"^[^\s:():\[\]#]{1,40}(?:([^()\r\n]+))?:\s*\S" ) TAG_RE = re.compile(r"^\[(?P<tag>VO|OS|SFX|画面文字|连续性|转场)\]\s*(?P<body>\S[\s\S]*)$") ANY_TAG_RE = re.compile(r"^\[(?P<tag>[^\]\r\n]+)\]") # Production dialects write tags with full-width brackets (【特写】/【闪回】). # Without this the paragraph matches nothing and is emitted as a plain action # block with no issue, so the owner never learns the source needs # normalization. The block is still emitted (see below) because block IDs are # the durable cross-artifact reference; only the diagnosis is added. # The lookahead keeps dialect dialogue on the dialogue path. The dialect writes # the performance cue with ASCII parens and a full-width colon # (【角色名】(表演括注):台词, production-format-dialect.md), so both paren # styles must be exempted here. FULLWIDTH_TAG_RE = re.compile( r"^【(?P<tag>[^】\r\n]+)】" r"(?!\s*(?:([^()\r\n]*)|\([^()\r\n]*\))?\s*[::])" ) MALFORMED_TAG_RE = re.compile(r"^\[(?:VO|OS|SFX|画面文字|连续性|转场)(?:\s|:|:)") VOICE_TAG_BODY_RE = re.compile(r"^(?P<speaker>[^\s:():\[\]#]{1,40}):(?P<text>\S[\s\S]*)$") BLOCK_ID_RE = re.compile(r"^BLK-(?P<scope>.+)-(?P<code>[HADPC])(?P<number>\d+)$") def _newline_style(data: bytes) -> str: crlf = data.count(b"\r\n") bare_lf = data.count(b"\n") - crlf bare_cr = data.count(b"\r") - crlf styles = sum(bool(count) for count in (crlf, bare_lf, bare_cr)) if styles > 1: return "mixed" if crlf: return "crlf" if bare_lf: return "lf" if bare_cr: return "cr" return "none" def _line_table(data: bytes) -> list[dict[str, Any]]: raw_lines = data.splitlines(keepends=True) if not raw_lines and data == b"": return [] if raw_lines and sum(map(len, raw_lines)) < len(data): raw_lines.append(data[sum(map(len, raw_lines)) :]) lines: list[dict[str, Any]] = [] offset = 0 for number, raw in enumerate(raw_lines, 1): if raw.endswith(b"\r\n"): content = raw[:-2] elif raw.endswith((b"\n", b"\r")): content = raw[:-1] else: content = raw lines.append( { "number": number, "start": offset, "content_end": offset + len(content), "end": offset + len(raw), "raw": raw, "content": content, "text": content.decode("utf-8"), } ) offset += len(raw) return lines def _span( data: bytes, lines: list[dict[str, Any]], start_index: int, end_index: int, ) -> dict[str, Any]: start = lines[start_index]["start"] end = lines[end_index]["content_end"] raw = data[start:end] return { "byte_start": start, "byte_end": end, "line_start": lines[start_index]["number"], "line_end": lines[end_index]["number"], "_raw": raw, "_text": raw.decode("utf-8"), } def _issue( span: dict[str, Any], code: str, message: str, ) -> dict[str, Any]: return { **{key: span[key] for key in ("byte_start", "byte_end", "line_start", "line_end")}, "issue_code": code, "severity": "review_required", "message": message, } def _looks_like_markdown(text: str) -> bool: stripped = text.lstrip() return bool( stripped.startswith(("#", "```", "~~~", ">", "- ", "* ", "+ ")) or re.match(r"\d+[.)]\s", stripped) or re.fullmatch(r"[-*_]{3,}", stripped) ) def _parse_screenplay( data: bytes, speaker_names: frozenset[str], ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: # Decode once up front so invalid UTF-8 fails before any output is written. data.decode("utf-8") lines = _line_table(data) blocks: list[dict[str, Any]] = [] issues: list[dict[str, Any]] = [] episode_id: str | None = None scene_id: str | None = None index = 0 def append_block(kind: str, span: dict[str, Any], **fields: Any) -> None: blocks.append( { "kind": kind, "episode_id": episode_id, "scene_id": scene_id, **span, **fields, "_order": len(blocks), } ) while index < len(lines): text = lines[index]["text"] stripped = text.strip() if not stripped: index += 1 continue if stripped.startswith("<!--"): end_index = index while end_index < len(lines) and "-->" not in lines[end_index]["text"]: end_index += 1 if end_index >= len(lines): span = _span(data, lines, index, len(lines) - 1) issues.append(_issue(span, "malformed_comment", "Markdown 注释缺少闭合的 -->。")) break span = _span(data, lines, index, end_index) if not span["_text"].strip().endswith("-->"): issues.append( _issue(span, "mixed_comment_content", "注释闭合符后还有内容,不能机械分块。") ) else: append_block("comment", span, production=False) index = end_index + 1 continue if stripped.startswith("##"): span = _span(data, lines, index, index) match = SCENE_HEADING_RE.fullmatch(stripped) if not match: scene_id = None issues.append( _issue( span, "invalid_scene_heading", "场景标题必须为 ## <EP001-SC001> <内|外|内外> · <地点> · <时间/天气>。", ) ) else: scene_id = match.group("scene") heading_episode = scene_id.split("-", 1)[0] if episode_id and episode_id != heading_episode: issues.append( _issue(span, "episode_scene_mismatch", "场景 ID 与当前集标题的集 ID 不一致。") ) scene_id = None else: episode_id = heading_episode append_block( "scene_heading", span, space=match.group("space"), location=match.group("location"), time_weather=match.group("time"), ) index += 1 continue if stripped.startswith("#"): span = _span(data, lines, index, index) match = EPISODE_HEADING_RE.fullmatch(stripped) if match: episode_id = match.group("episode") scene_id = None else: scene_id = None issues.append(_issue(span, "invalid_episode_heading", "集标题必须以 # EP001 开头(EP 加三位数字,超过 EP999 后不补零)。")) index += 1 continue end_index = index while end_index + 1 < len(lines): next_stripped = lines[end_index + 1]["text"].strip() if not next_stripped or next_stripped.startswith(("#", "<!--")): break end_index += 1 span = _span(data, lines, index, end_index) paragraph = span["_text"].strip() if scene_id is None: issues.append( _issue(span, "content_outside_scene", "动作、对白和生产标签只能位于合法场景标题之后。") ) index = end_index + 1 continue paragraph_lines = [line.strip() for line in paragraph.splitlines() if line.strip()] if len(paragraph_lines) > 1: later_dialogues = [ match for line in paragraph_lines[1:] if (match := DIALOGUE_RE.fullmatch(line)) is not None ] if any(match.group("speaker") in speaker_names for match in later_dialogues) or any( ASCII_DIALOGUE_RE.match(line) or TAG_RE.fullmatch(line) or ANY_TAG_RE.match(line) or MALFORMED_TAG_RE.match(line) for line in paragraph_lines[1:] ): issues.append( _issue( span, "missing_block_separator", "动作、对白与生产标签之间需要空行,不能把不同语法块合成动作段落。", ) ) index = end_index + 1 continue if later_dialogues: issues.append( _issue( span, "ambiguous_dialogue_or_action", "冒号前缀不在本次说话者清单中;由 write owner 判断是动作、画面文字还是对白。", ) ) index = end_index + 1 continue tag_match = TAG_RE.fullmatch(paragraph) if tag_match: tag = tag_match.group("tag") body = tag_match.group("body") fields: dict[str, Any] = {"tag": tag, "production": True} if tag in {"VO", "OS"}: voice_match = VOICE_TAG_BODY_RE.fullmatch(body) if not voice_match: issues.append( _issue(span, "invalid_voice_tag_syntax", f"[{tag}] 必须明确说话者并使用全角冒号。") ) index = end_index + 1 continue fields["speaker"] = voice_match.group("speaker") append_block("production_tag", span, **fields) index = end_index + 1 continue any_tag = ANY_TAG_RE.match(paragraph) if any_tag: issues.append( _issue( span, "unsupported_production_tag", f"不支持生产标签 [{any_tag.group('tag')}];仅支持 {', '.join(SUPPORTED_TAGS)}。", ) ) index = end_index + 1 continue if MALFORMED_TAG_RE.match(paragraph): issues.append(_issue(span, "malformed_production_tag", "生产标签缺少闭合的 ]。")) index = end_index + 1 continue dialogue_match = DIALOGUE_RE.fullmatch(paragraph) if dialogue_match: if dialogue_match.group("speaker") not in speaker_names: issues.append( _issue( span, "ambiguous_dialogue_or_action", "冒号前缀不在本次说话者清单中;由 write owner 判断是动作、画面文字还是对白。", ) ) index = end_index + 1 continue append_block( "dialogue", span, speaker=dialogue_match.group("speaker"), performance_cue=dialogue_match.group("cue"), production=False, ) index = end_index + 1 continue if ASCII_DIALOGUE_RE.match(paragraph): issues.append( _issue(span, "invalid_dialogue_syntax", "对白说话者后必须使用全角冒号 :。") ) index = end_index + 1 continue if _looks_like_markdown(paragraph): issues.append( _issue(span, "unsupported_markdown", "该 Markdown 结构不属于受支持的剧本块。") ) index = end_index + 1 continue # Diagnosed last, so only paragraphs that would already be plain action # are reported. Anything a dialect writes with a colon (【音效:…】, # 【角色名】(括注):台词) has been claimed by the dialogue paths above # and keeps main's disposition, and the block is still emitted here, so # no block ID moves in either direction. fullwidth_tag = FULLWIDTH_TAG_RE.match(paragraph) if fullwidth_tag: issues.append( _issue( span, "unsupported_production_tag", f"【{fullwidth_tag.group('tag')}】 是生产方言写法,不是本套件的生产标签;" f"受支持的标签是半角 [ ] 加 {', '.join(SUPPORTED_TAGS)}。" "先经规范化入口判断它应当成为动作、画面文字还是转场,再发布。", ) ) append_block("action", span, production=False) index = end_index + 1 return blocks, issues def _read_jsonl(path: Path) -> list[dict[str, Any]]: records: list[dict[str, Any]] = [] for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): if not line.strip(): continue try: value = json.loads(line) except json.JSONDecodeError as error: raise ValueError(f"{path.name}:{line_number}: invalid JSONL: {error.msg}") from error if not isinstance(value, dict): raise ValueError(f"{path.name}:{line_number}: JSONL record must be an object") records.append(value) return records def _load_previous( records: list[dict[str, Any]], previous_source_path: Path, ) -> list[dict[str, Any]]: # Without the previous screenplay's bytes a block's identity is its content # digest and nothing more: exact-content reuse still holds, split/merge # detection does not. A rewritten body block then loses its ID instead of # exception by design — they are reused on a stable scene_id, so an edited # heading keeps its ID; the scene is the same scene, and the blocks under it # are what carry the content. source_data = previous_source_path.read_bytes() sources = load_sources(records) body = [record for record in records if record.get("record_type") != SOURCES_RECORD_TYPE] if not body or body[0].get("record_type") != "screenplay_index_meta": raise ValueError("previous index is missing screenplay_index_meta") # The previous index must name the screenplay it was built from. It no # longer carries that screenplay's bytes, so a resume can confirm identity # but not that the file is unchanged; a screenplay edited between runs is # caught by re-parsing below, not by a stored digest. resolved, _defect = resolve_ref(body[0].get("source_ref"), sources, "previous_index") if resolved is None: raise ValueError("previous index does not name its screenplay") previous_speakers = previous_index_speakers(records) parsed_blocks, _ = _parse_screenplay(source_data, previous_speakers) parsed_by_span = { (block["byte_start"], block["byte_end"], block["kind"]): block for block in parsed_blocks } previous: list[dict[str, Any]] = [] seen_ids: set[str] = set() for record in records: if record.get("record_type") != "block": continue block_id = record.get("block_id") if not isinstance(block_id, str) or not BLOCK_ID_RE.fullmatch(block_id): raise ValueError("previous index contains an invalid block_id") if block_id in seen_ids: raise ValueError("previous index contains duplicate block_id values") seen_ids.add(block_id) byte_start = record.get("byte_start") byte_end = record.get("byte_end") kind = record.get("kind") if ( not isinstance(byte_start, int) or not isinstance(byte_end, int) or isinstance(byte_start, bool) or isinstance(byte_end, bool) or not 0 <= byte_start < byte_end <= len(source_data) or kind not in KIND_CODES ): raise ValueError(f"previous block {block_id} does not resolve against previous source") parsed = parsed_by_span.get((byte_start, byte_end, kind)) if parsed is None: raise ValueError(f"previous block {block_id} does not resolve against previous source") previous.append({**record, "_text": parsed["_text"], "_order": parsed["_order"]}) return previous def previous_index_speakers(records: list[dict[str, Any]]) -> frozenset[str]: """Recover the dialogue speaker roster an earlier index already resolved.""" return frozenset( speaker.strip() for record in records if record.get("record_type") == "block" and record.get("kind") == "dialogue" and isinstance((speaker := record.get("speaker")), str) and speaker.strip() ) def _fingerprint(block: dict[str, Any]) -> tuple[Any, ...]: # Content identity comes from the prose, re-read from the previous screenplay. # No digest is stored in the index, so a rebuild without those bytes has # nothing to compare and renumbers by position. return ( block["kind"], block.get("episode_id"), block.get("scene_id"), _normalized(block["_text"]), ) def _normalized(text: str) -> str: return re.sub(r"\s+", "", text) def _mark_revision_mappings( current: list[dict[str, Any]], previous: list[dict[str, Any]], ) -> list[dict[str, Any]]: matched_current: set[int] = set() matched_previous: set[int] = set() old_headings: dict[tuple[str, str], list[int]] = defaultdict(list) new_headings: dict[tuple[str, str], list[int]] = defaultdict(list) for index, block in enumerate(previous): if block["kind"] == "scene_heading" and block.get("scene_id"): old_headings[(block["kind"], block["scene_id"])].append(index) for index, block in enumerate(current): if block["kind"] == "scene_heading" and block.get("scene_id"): new_headings[(block["kind"], block["scene_id"])].append(index) for key, new_indices in new_headings.items(): old_indices = old_headings.get(key, []) if len(old_indices) == len(new_indices) == 1: old_index, new_index = old_indices[0], new_indices[0] current[new_index]["block_id"] = previous[old_index]["block_id"] current[new_index]["mapping"] = { "status": "reused", "reason": "stable_scene_id", "previous_block_ids": [previous[old_index]["block_id"]], } matched_current.add(new_index) matched_previous.add(old_index) old_groups: dict[tuple[Any, ...], list[int]] = defaultdict(list) new_groups: dict[tuple[Any, ...], list[int]] = defaultdict(list) for index, block in enumerate(previous): if index not in matched_previous: old_groups[_fingerprint(block)].append(index) for index, block in enumerate(current): if index not in matched_current: new_groups[_fingerprint(block)].append(index) duplicate_requests: list[dict[str, Any]] = [] for fingerprint, new_indices in new_groups.items(): old_indices = old_groups.get(fingerprint, []) pairs: list[tuple[int, int]] = [] if len(old_indices) == len(new_indices) == 1: pairs = [(old_indices[0], new_indices[0])] elif len(old_indices) == len(new_indices) and old_indices: # Reuse repeated identical blocks only when their exact spans did not move. # Once a duplicate group moves, occurrence identity is ambiguous. by_start = {previous[old]["byte_start"]: old for old in old_indices} if all(current[new]["byte_start"] in by_start for new in new_indices): pairs = [(by_start[current[new]["byte_start"]], new) for new in new_indices] for old_index, new_index in pairs: current[new_index]["block_id"] = previous[old_index]["block_id"] current[new_index]["mapping"] = { "status": "reused", "reason": "exact_content", "previous_block_ids": [previous[old_index]["block_id"]], } matched_current.add(new_index) matched_previous.add(old_index) if old_indices and new_indices and not pairs: duplicate_requests.append( { "reason": "duplicate_exact_match", "previous_indices": old_indices, "current_indices": new_indices, } ) matched_current.update(new_indices) matched_previous.update(old_indices) return duplicate_requests + _find_split_merge_requests( current, previous, matched_current, matched_previous ) def _find_split_merge_requests( current: list[dict[str, Any]], previous: list[dict[str, Any]], matched_current: set[int], matched_previous: set[int], ) -> list[dict[str, Any]]: requests: list[dict[str, Any]] = [] claimed_new: set[int] = set() claimed_old: set[int] = set() def compatible(blocks: list[dict[str, Any]]) -> bool: return bool(blocks) and all( block["kind"] == blocks[0]["kind"] and block.get("scene_id") == blocks[0].get("scene_id") and block["kind"] in {"action", "dialogue", "production_tag", "comment"} for block in blocks ) for old_index, old in enumerate(previous): if old_index in matched_previous: continue old_text = _normalized(old["_text"]) for start in range(len(current)): new_indices: list[int] = [] new_parts: list[str] = [] for item in range(start, len(current)): if item in matched_current or item in claimed_new: break block = current[item] if not compatible([old, block]): break new_indices.append(item) new_parts.append(_normalized(block["_text"])) combined = "".join(new_parts) if not old_text.startswith(combined): break if len(new_indices) < 2: continue if combined != old_text: continue requests.append( { "reason": "split", "previous_indices": [old_index], "current_indices": new_indices, } ) claimed_old.add(old_index) claimed_new.update(new_indices) break if old_index in claimed_old: break for new_index, new in enumerate(current): if new_index in matched_current or new_index in claimed_new: continue new_text = _normalized(new["_text"]) for start in range(len(previous)): old_indices: list[int] = [] old_parts: list[str] = [] for item in range(start, len(previous)): if item in matched_previous or item in claimed_old: break block = previous[item] if not compatible([new, block]): break old_indices.append(item) old_parts.append(_normalized(block["_text"])) combined = "".join(old_parts) if not new_text.startswith(combined): break if len(old_indices) < 2: continue if combined != new_text: continue requests.append( { "reason": "merge", "previous_indices": old_indices, "current_indices": [new_index], } ) claimed_old.update(old_indices) claimed_new.add(new_index) break if new_index in claimed_new: break return requests def _scope(block: dict[str, Any]) -> str: if block.get("scene_id"): return str(block["scene_id"]) if block.get("episode_id"): return f"{block['episode_id']}-GLOBAL" return "GLOBAL" def _assign_new_ids( current: list[dict[str, Any]], previous: list[dict[str, Any]], high_water: dict[str, int] | None = None, ) -> dict[str, int]: """Give every unmatched block an ID no earlier revision has used. Retirement has to outlive the revision that retired an ID. Seeding the counter from the previous index alone makes it one generation deep: once every block of one kind in a scene is gone, the counter restarts at zero and the next new block is handed a retired ID. A downstream artifact still citing it resolves cleanly and silently means something else -- which is exactly what resolving cleanly is supposed to rule out. So the highest number ever issued per (scene, kind) rides along in the index meta and is carried forward here. """ reserved = {str(block["block_id"]) for block in previous} counters: dict[tuple[str, str], int] = defaultdict(int) for name, number in (high_water or {}).items(): scope, _, code = name.rpartition("|") if scope and isinstance(number, int): counters[(scope, code)] = max(counters[(scope, code)], number) for block_id in reserved: match = BLOCK_ID_RE.fullmatch(block_id) assert match key = (match.group("scope"), match.group("code")) counters[key] = max(counters[key], int(match.group("number"))) for block in current: if "block_id" in block: continue scope = _scope(block) code = KIND_CODES[block["kind"]] key = (scope, code) while True: counters[key] += 1 candidate = f"BLK-{scope}-{code}{counters[key]:02d}" if candidate not in reserved: break reserved.add(candidate) block["block_id"] = candidate block["mapping"] = { "status": "new", "reason": "no_previous_exact_match", "previous_block_ids": [], } # Every ID this revision kept also counts, so a block reused from the # previous index cannot be reissued after it later disappears. for block in current: match = BLOCK_ID_RE.fullmatch(str(block.get("block_id", ""))) if match: key = (match.group("scope"), match.group("code")) counters[key] = max(counters[key], int(match.group("number"))) return {f"{scope}|{code}": number for (scope, code), number in counters.items()} def _public_block(block: dict[str, Any], source_ref: dict[str, str]) -> dict[str, Any]: omitted = {"_raw", "_text", "_order"} return { "record_type": "block", "schema_version": SCHEMA_VERSION, **{key: value for key, value in block.items() if key not in omitted}, "source_ref": source_ref, } def _atomic_jsonl(path: Path, records: list[dict[str, Any]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) body = "".join( json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" for record in records ).encode("utf-8") descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temporary = Path(temporary_name) try: with os.fdopen(descriptor, "wb") as handle: handle.write(body) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def _portable_source_ref(source: Path, source_ref: str | None) -> str: value = source_ref or source.name candidate = Path(value) if candidate.is_absolute() or ".." in candidate.parts or "://" in value: raise ValueError("source_ref must be a portable project-relative path") return candidate.as_posix() def build_index( source_path: str | Path, output_path: str | Path, *, previous_index_path: str | Path | None = None, previous_source_path: str | Path | None = None, source_ref: str | None = None, authority: str = "accepted", speakers: list[str] | tuple[str, ...] | set[str] | frozenset[str] | None = None, no_previous: bool = False, ) -> dict[str, Any]: """Parse ``source_path`` and atomically publish its derived JSONL index.""" source = Path(source_path) output = Path(output_path) if source.resolve() == output.resolve(): raise ValueError("output path must not be the screenplay source path") if (previous_index_path is None) != (previous_source_path is None): raise ValueError("--previous-index and --previous-source must be supplied together") # Rebuilding over an existing index without naming a previous version # renumbers every block from scratch. Surviving text keeps whatever ID its # position now yields, so a rewritten block can reclaim the retired ID and # every downstream reference to it silently resolves to different content -- # the exact failure the content-stable path exists to prevent. Refuse, and # name the file, rather than produce that index quietly. if previous_index_path is None and not no_previous and output.exists(): raise ValueError( f"{output} already holds an index; pass --previous-index {output} " f"--previous-source <the screenplay before this revision> to keep block " f"IDs stable by content, or --no-previous to renumber from scratch" ) if authority not in {"accepted", "candidate"}: raise ValueError("authority must be accepted or candidate") raw_speakers = tuple(speakers or ()) if any(not isinstance(speaker, str) or not speaker.strip() for speaker in raw_speakers): raise ValueError("speaker names must be non-empty text") speaker_names = frozenset(speaker.strip() for speaker in raw_speakers) if any( len(speaker) > 40 or DIALOGUE_RE.fullmatch(f"{speaker}:占位") is None for speaker in speaker_names ): raise ValueError("speaker names must match the screenplay dialogue label grammar") source_data = source.read_bytes() declared_sources: dict[str, dict[str, str]] = { SCREENPLAY_SOURCE_KEY: { "owner": "short-drama-write", "artifact": _portable_source_ref(source, source_ref), } } source_artifact_ref: dict[str, str] = {"src": SCREENPLAY_SOURCE_KEY} if authority == "candidate": source_artifact_ref["authority"] = "candidate" previous_records: list[dict[str, Any]] = [] if previous_index_path is not None: previous_records = _read_jsonl(Path(previous_index_path)) # Union, not replacement: a reviser who adds one new speaker passes only # that name, and dropping the rest would turn every other dialogue line # back into an ambiguous block and change its ID. speaker_names |= previous_index_speakers(previous_records) current, source_issues = _parse_screenplay(source_data, speaker_names) previous: list[dict[str, Any]] = [] raw_requests: list[dict[str, Any]] = [] previous_source_ref: dict[str, str] | None = None # Carried from the previous index so a retired ID stays retired even after # every block that was using it has gone. previous_high_water: dict[str, int] = {} for record in previous_records: if record.get("record_type") == "screenplay_index_meta": declared = record.get("block_id_high_water") if isinstance(declared, dict): previous_high_water = { str(name): number for name, number in declared.items() if isinstance(number, int) and not isinstance(number, bool) } break if previous_index_path is not None and previous_source_path is not None: previous_source = Path(previous_source_path) previous_snapshot = { "owner": "short-drama-write", "artifact": _portable_source_ref(previous_source, source_ref), } previous_key = SCREENPLAY_SOURCE_KEY if previous_snapshot != declared_sources[SCREENPLAY_SOURCE_KEY]: previous_key = PREVIOUS_SCREENPLAY_SOURCE_KEY declared_sources[previous_key] = previous_snapshot previous_source_ref = {"src": previous_key} if authority == "candidate": previous_source_ref["authority"] = "candidate" previous = _load_previous(previous_records, previous_source) raw_requests = _mark_revision_mappings(current, previous) block_id_high_water = _assign_new_ids( current, previous, previous_high_water ) review_requests: list[dict[str, Any]] = [] for number, request in enumerate(raw_requests, 1): old_ids = [previous[index]["block_id"] for index in request["previous_indices"]] new_ids = [current[index]["block_id"] for index in request["current_indices"]] for index in request["current_indices"]: current[index]["mapping"] = { "status": "unresolved", "reason": request["reason"], "previous_block_ids": old_ids, } review_requests.append( { "record_type": "mapping_review_request", "schema_version": SCHEMA_VERSION, "request_id": f"MAP-REVIEW-{number:03d}", "reason": request["reason"], "status": "unresolved", "previous_block_ids": old_ids, "current_block_ids": new_ids, "instruction": "创作者或 write owner 必须显式选择重映射;索引器不会猜测。", } ) issue_records = [ { "record_type": "source_issue", "schema_version": SCHEMA_VERSION, "issue_id": f"SOURCE-ISSUE-{number:03d}", **issue, "source_ref": source_artifact_ref, } for number, issue in enumerate(source_issues, 1) ] review_status = "review_required" if issue_records or review_requests else "clean" meta = { "record_type": "screenplay_index_meta", "schema_version": SCHEMA_VERSION, "source_ref": source_artifact_ref, "source_byte_length": len(source_data), "newline_style": _newline_style(source_data), "previous_source_ref": previous_source_ref, "block_count": len(current), "source_issue_count": len(issue_records), "mapping_review_count": len(review_requests), "review_status": review_status, # The highest block number ever issued per scene and kind. Carried # forward so a retired ID stays retired after the blocks that were # using it are gone. "block_id_high_water": dict(sorted(block_id_high_water.items())), SOURCES_KEY: declared_sources, } records = [meta] records.extend(_public_block(block, source_artifact_ref) for block in current) records.extend(issue_records) records.extend(review_requests) _atomic_jsonl(output, records) return { "output": str(output), "block_count": len(current), "source_issue_count": len(issue_records), "mapping_review_count": len(review_requests), "review_status": review_status, } def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Build a byte-accurate derived screenplay-index.jsonl without editing screenplay.md." ) parser.add_argument("source", type=Path, help="UTF-8 Markdown screenplay source") parser.add_argument("--output", type=Path, help="output JSONL path") parser.add_argument("--previous-index", type=Path) parser.add_argument("--previous-source", type=Path) parser.add_argument( "--no-previous", action="store_true", help="renumber block IDs from scratch, abandoning the IDs downstream artifacts cite", ) parser.add_argument( "--source-ref", help="portable project-relative source reference; defaults to the source basename", ) parser.add_argument( "--authority", choices=("accepted", "candidate"), default="accepted", help="mark source refs candidate for a provisional normalization preview", ) parser.add_argument( "--speaker", action="append", default=[], help="repeat for each agent-resolved dialogue speaker label", ) parser.add_argument( "--fail-on-review", action="store_true", help="return exit code 2 after writing when source or mapping review is required", ) return parser def main(argv: list[str] | None = None) -> int: args = _parser().parse_args(argv) output = args.output or args.source.with_name("screenplay-index.jsonl") try: summary = build_index( args.source, output, previous_index_path=args.previous_index, previous_source_path=args.previous_source, source_ref=args.source_ref, authority=args.authority, speakers=args.speaker, no_previous=args.no_previous, ) except (OSError, UnicodeDecodeError, ValueError) as error: print(json.dumps({"error": str(error)}, ensure_ascii=True), file=sys.stderr) return 1 print(json.dumps(summary, ensure_ascii=True, sort_keys=True)) if args.fail_on_review and summary["review_status"] != "clean": return 2 return 0 if __name__ == "__main__": raise SystemExit(main()) -
selftest.py 8.7 KB
#!/usr/bin/env python3 """Offline self-test for screenplay indexing and voice-sheet projection.""" from __future__ import annotations import hashlib import json import sys import tempfile from pathlib import Path from duration_estimate import StaleIndex, estimate, index_blocks from screenplay_index import build_index from voice_sheet_check import check MINIMUM_PYTHON = (3, 9) if sys.version_info < MINIMUM_PYTHON: raise SystemExit("selftest.py requires Python 3.9 or newer") def require(condition: bool, message: str) -> None: if not condition: raise AssertionError(message) def codes(result: dict[str, object]) -> set[str]: findings = result["findings"] assert isinstance(findings, list) return {finding["code"] for finding in findings} def measure_text(body: str, project: dict[str, object] | None = None) -> dict[str, object]: """Index a screenplay the documented way, then estimate from that index. The estimate has no reader of its own. Going through ``build_index`` here is the point of the test, not overhead: it is what stops this script and the index from holding two opinions about the same line. """ with tempfile.TemporaryDirectory() as directory: root = Path(directory) screenplay = root / "screenplay.md" index_path = root / "screenplay-index.jsonl" screenplay.write_text( "# EP001\n\n## EP001-SC001 内 · 房间 · 日\n\n" + body, encoding="utf-8" ) build_index( screenplay, index_path, source_ref="剧集/EP001/screenplay.md", speakers=["甲"], ) records = [ json.loads(line) for line in index_path.read_text(encoding="utf-8").splitlines() if line.strip() ] source = screenplay.read_bytes() blocks, review = index_blocks(records, source) return estimate(source, blocks, project, review) def test_duration_reports_counts_without_rates() -> None: """No declared rates must yield no seconds -- never a default guess.""" report = measure_text("甲:一二三四五。\n\n他把门关上。\n") require(report["seconds"] is None, "seconds must stay unknown without rates") require(report["counts"]["dialogue_characters"] == 6, "spoken characters counted") require(report["counts"]["action_paragraphs"] == 1, "action paragraph counted") def test_duration_uses_the_projects_own_rates() -> None: project = { "format": { "target_seconds_per_episode": 10, "pacing": { "spoken_characters_per_second": 3.0, "seconds_per_action_paragraph": 2.0, }, } } report = measure_text("甲:一二三四五六\n\n他把门关上。\n", project) # Punctuation counts as spoken time because it stands for the pause it # creates; here the line is six bare characters: 6 / 3.0 + 1 * 2.0 == 4.0. require(report["seconds"] == 4.0, f"expected 4.0s, got {report['seconds']}") require(report["delta_seconds"] == -6.0, "delta is reported against the target") def test_production_tags_are_not_performed_time() -> None: report = measure_text("[画面文字] 账号后台:粉丝 2\n\n他把门关上。\n") require(report["counts"]["production_tag_lines"] == 1, "tag counted separately") require(report["counts"]["action_paragraphs"] == 1, "tag is not an action") def test_voice_over_is_performed_time() -> None: """[VO] is a line delivered off-camera, not an instruction to a later stage.""" report = measure_text("[VO] 甲:一二三四五。\n") require(report["counts"]["dialogue_lines"] == 1, "[VO] is a spoken line") require(report["counts"]["dialogue_characters"] == 6, "[VO] characters are timed") require(report["counts"]["production_tag_lines"] == 0, "[VO] is not a mute tag") def test_one_action_paragraph_is_one_paragraph_however_many_lines() -> None: """The format contract lets an action paragraph run several lines.""" report = measure_text("他站起来,\n走到窗边,\n把窗帘拉开。\n") require( report["counts"]["action_paragraphs"] == 1, f"expected 1 paragraph, got {report['counts']['action_paragraphs']}", ) def test_comments_are_not_production_content() -> None: """A Markdown comment is a note to the writer, not a line to perform.""" report = measure_text("<!-- 待确认:这一段的转场是否保留。 -->\n") require(report["counts"]["dialogue_lines"] == 0, "a comment is not dialogue") require(report["counts"]["dialogue_characters"] == 0, "a comment is not spoken") require(report["counts"]["action_paragraphs"] == 0, "a comment is not an action") def test_an_index_from_other_bytes_is_refused() -> None: """Spans from a stale index land on text it never classified.""" stale = [ { "record_type": "screenplay_index_meta", "source_byte_length": 999_999, } ] try: index_blocks(stale, b"short") except StaleIndex: return raise AssertionError("a stale index must be refused, not measured") def main() -> int: with tempfile.TemporaryDirectory() as directory: root = Path(directory) screenplay = root / "screenplay.md" index_path = root / "screenplay-index.jsonl" screenplay.write_text( "# EP001\n\n## EP001-SC001 内 · 客厅 · 夜\n\n陈予安推开门。\n\n陈予安:我回来了。\n", encoding="utf-8", ) summary = build_index( screenplay, index_path, source_ref="剧集/EP001/screenplay.md", speakers=["陈予安"], ) require(summary["review_status"] == "clean", "valid screenplay index") index_bytes = index_path.read_bytes() records = [ json.loads(line) for line in index_bytes.decode("utf-8").splitlines() ] dialogue = next(record for record in records if record.get("kind") == "dialogue") header = { "record_type": "sources", "schema_version": "1.0.0", "sources": { "screenplay-index": { "owner": "short-drama-write", "artifact": "剧集/EP001/screenplay-index.jsonl", "hash": hashlib.sha256(index_bytes).hexdigest(), } }, } line = { "line_id": "LINE-001", "channel": "sync", "speaker_display": "陈予安", "line_text": "我回来了。", "source_ref": {"src": "screenplay-index", "record_id": dialogue["block_id"]}, } sheet = [header, line] result = check(sheet, records, screenplay.read_bytes()) require(result["status"] == "pass", "faithful voice sheet") require(result["lines"] == 1, "the sources header is not a voice line") # A sheet written before the compact form still resolves; real projects # hold both spellings and neither is rewritten. expanded = [ dict( line, source_ref={ "owner": "short-drama-write", "artifact": "剧集/EP001/screenplay-index.jsonl", "hash": hashlib.sha256(index_bytes).hexdigest(), "record_id": dialogue["block_id"], }, ) ] require( check(expanded, records, screenplay.read_bytes())["status"] == "pass", "expanded voice sheet", ) undeclared = [header, dict(line, source_ref={"src": "screenplay", "record_id": "X"})] require( "VOICE_SOURCE_REF_UNDECLARED" in codes(check(undeclared, records, screenplay.read_bytes())), "src without a sources entry was not detected", ) unbound = [header, dict(line, source_ref={"record_id": dialogue["block_id"]})] require( "VOICE_SOURCE_REF_MISSING" in codes(check(unbound, records, screenplay.read_bytes())), "source_ref naming no upstream snapshot was not detected", ) changed = [header, dict(line, line_text="我走了。")] require( check(changed, records, screenplay.read_bytes())["status"] == "fail", "changed voice line was not detected", ) test_duration_reports_counts_without_rates() test_duration_uses_the_projects_own_rates() test_production_tags_are_not_performed_time() test_voice_over_is_performed_time() test_one_action_paragraph_is_one_paragraph_however_many_lines() test_comments_are_not_production_content() test_an_index_from_other_bytes_is_refused() print("14 self-tests passed") return 0 if __name__ == "__main__": raise SystemExit(main()) -
voice_sheet_check.py 15 KB
#!/usr/bin/env python3 """Prove a voice record sheet is still a projection of the screenplay. The sheet exists to be carried into a recording session, which is exactly the moment nobody can check it against the script. A line edited in the sheet, or a script revised after the sheet was built, both read as a perfectly ordinary sheet — so the comparison has to be mechanical: resolve each line's block in the derived index, slice those exact bytes out of the screenplay, and compare. The script reads accepted creator files and writes nothing. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from collections.abc import Mapping from typing import Any, NamedTuple # Creators run these scripts on whatever interpreter their machine provides, so # an unsupported version must say so instead of failing inside an import. MINIMUM_PYTHON = (3, 9) if sys.version_info < MINIMUM_PYTHON: raise SystemExit( "short-drama needs Python {}.{} or newer; this interpreter is {}.{}".format( *MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor ) ) # --------------------------------------------------------------------------- # REFERENCE RESOLVER -- reference implementation. # # Each skill checker carries its own copy of this block. The suite has no shared # library on purpose: a skill must stay runnable after copying only its own # directory, so duplicating these few lines across skills is the correct shape. # Copy the block verbatim; do not import it. # --------------------------------------------------------------------------- SOURCES_RECORD_TYPE = "sources" SOURCES_SCHEMA_VERSION = "1.0.0" class ResolvedRef(NamedTuple): """An upstream reference with its snapshot resolved, whichever form it used.""" owner: str artifact: str record_id: str | None field: str | None authority: str | None class RefFinding(NamedTuple): """A structural defect in a reference object.""" code: str location: str detail: str def load_sources(document: Any) -> dict[str, dict[str, Any]]: """Return the ``sources`` declaration of a parsed file, or ``{}`` if absent. Accepts a parsed ``.json`` document (a dict) or the parsed record list of a ``.jsonl`` file, whose declaration lives on the first record. """ if isinstance(document, list): document = document[0] if document else None if not isinstance(document, dict): return {} declared = document.get("sources") if not isinstance(declared, dict): return {} return {key: value for key, value in declared.items() if isinstance(value, dict)} def resolve_ref( ref: Any, sources: dict[str, dict[str, Any]], location: str ) -> tuple[ResolvedRef | None, RefFinding | None]: """Resolve a reference object written in either the compact or expanded form.""" if not isinstance(ref, dict): return None, RefFinding("REF_IS_NOT_AN_OBJECT", location, f"got {type(ref).__name__}") src = ref.get("src") if isinstance(src, str): entry = sources.get(src) if entry is None: return None, RefFinding( "REF_SRC_IS_NOT_DECLARED", location, f"src {src!r} has no sources entry" ) owner, artifact = entry.get("owner"), entry.get("artifact") if not (isinstance(owner, str) and isinstance(artifact, str)): return None, RefFinding( "SOURCE_ENTRY_IS_INCOMPLETE", location, f"sources[{src!r}] needs owner/artifact", ) elif all(isinstance(ref.get(key), str) for key in ("owner", "artifact")): owner, artifact = ref["owner"], ref["artifact"] else: return None, RefFinding( "REF_HAS_NO_UPSTREAM_BINDING", location, "needs src, or owner+artifact" ) optional = { key: ref[key] for key in ("record_id", "field", "authority") if isinstance(ref.get(key), str) } return ( ResolvedRef( owner, artifact, optional.get("record_id"), optional.get("field"), optional.get("authority"), ), None, ) # --------------------------------------------------------------------------- # END REFERENCE RESOLVER # --------------------------------------------------------------------------- SCHEMA_VERSION = "1.0.0" CHANNELS = {"sync", "dubbed", "VO", "OS"} # `[VO]` and `[OS]` are spoken lines that happen to be delivered off-camera, and # the index already records them with a `tag` and a `speaker`. Projecting only # `dialogue` blocks made the VO and OS channels above unreachable, so an episode # that carries its interiority in voice-over reported a clean sheet while the # recording list was missing every one of those lines. VOICED_TAGS = {"VO", "OS"} # `[VO] 角色:台词` — the tag is stripped before the line grammar is applied. VOICE_TAG_PREFIX = re.compile(r"^\[(?:VO|OS)\]\s*") def _is_voiced(block: Mapping[str, Any]) -> bool: kind = block.get("kind") if kind == "dialogue": return True return kind == "production_tag" and block.get("tag") in VOICED_TAGS # The resolver speaks the suite-wide reference vocabulary; this checker reports # in its own. REF_FINDING_CODES = { "REF_IS_NOT_AN_OBJECT": "VOICE_SOURCE_REF_MISSING", "REF_HAS_NO_UPSTREAM_BINDING": "VOICE_SOURCE_REF_MISSING", "REF_SRC_IS_NOT_DECLARED": "VOICE_SOURCE_REF_UNDECLARED", "SOURCE_ENTRY_IS_INCOMPLETE": "VOICE_SOURCE_REF_UNDECLARED", } # `角色(可表演提示):台词` — the cue is optional and never part of the line. DIALOGUE = re.compile( r"^(?P<speaker>[^((::]+)(?:[((](?P<cue>[^))]*)[))])?\s*[::]\s*(?P<line>.*)$", re.DOTALL, ) class CheckError(ValueError): """The inputs cannot be checked at all, as opposed to failing a check.""" def _load_jsonl(path: Path) -> list[dict[str, Any]]: records: list[dict[str, Any]] = [] try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeError) as error: raise CheckError(f"unreadable JSONL: {path}") from error for number, line in enumerate(text.splitlines(), start=1): if not line.strip(): continue try: record = json.loads(line) except json.JSONDecodeError as error: raise CheckError(f"invalid JSONL at {path.name}:{number}") from error if not isinstance(record, dict): raise CheckError(f"JSONL needs one object per line: {path.name}:{number}") records.append(record) return records def _finding(code: str, message: str, **detail: Any) -> dict[str, Any]: return {"code": code, "message": message, **detail} def _blocks_by_id(index: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: return { record["block_id"]: record for record in index if record.get("record_type") == "block" and isinstance(record.get("block_id"), str) } def check( sheet: list[dict[str, Any]], index: list[dict[str, Any]], screenplay: bytes, ) -> dict[str, Any]: findings: list[dict[str, Any]] = [] blocks = _blocks_by_id(index) seen: set[str] = set() # The first record declares the upstream snapshots this sheet references; the # rest are voice lines. sources = load_sources(sheet) lines = [record for record in sheet if record.get("record_type") != SOURCES_RECORD_TYPE] for record in lines: line_id = record.get("line_id") if not isinstance(line_id, str): findings.append(_finding("VOICE_LINE_HAS_NO_ID", "a line record has no id")) continue if line_id in seen: findings.append( _finding("VOICE_LINE_ID_REPEATS", "line_id must be unique", line_id=line_id) ) continue seen.add(line_id) channel = record.get("channel") if channel not in CHANNELS: findings.append( _finding( "VOICE_CHANNEL_INVALID", "channel must be sync, dubbed, VO, or OS", line_id=line_id, channel=channel, ) ) resolved, defect = resolve_ref(record.get("source_ref"), sources, line_id) if defect is not None: findings.append( _finding( REF_FINDING_CODES[defect.code], f"source_ref does not name an upstream snapshot: {defect.detail}", line_id=line_id, ) ) continue if resolved is None or resolved.record_id is None: findings.append( _finding( "VOICE_SOURCE_REF_MISSING", "a line must bind the screenplay block it projects", line_id=line_id, ) ) continue record_id = resolved.record_id block = blocks.get(record_id) if block is None: findings.append( _finding( "VOICE_SOURCE_REF_UNRESOLVABLE", "source_ref names a block that is not in the index", line_id=line_id, record_id=record_id, ) ) continue if not _is_voiced(block): findings.append( _finding( "VOICE_SOURCE_IS_NOT_DIALOGUE", "a voice line must project a spoken block: dialogue, [VO] or [OS]", line_id=line_id, record_id=record_id, kind=block.get("kind"), ) ) continue start, end = block.get("byte_start"), block.get("byte_end") if ( not isinstance(start, int) or not isinstance(end, int) or not 0 <= start < end <= len(screenplay) ): findings.append( _finding( "VOICE_BLOCK_SPAN_INVALID", "the indexed block span does not fit this screenplay", line_id=line_id, record_id=record_id, ) ) continue # The channel decides how the line is booked, and off-camera and # on-camera carry completely different room for change. It was checked # against the enum and never against the block it projects, so a [VO] # line could be booked `sync` and go to the booth under lip-sync # constraints. `_is_voiced` already reads the block's tag; this compares. expected = block.get("tag") if block.get("kind") == "production_tag" else "on_camera" if expected in VOICED_TAGS and channel not in {expected, "dubbed"}: findings.append( _finding( "VOICE_CHANNEL_DISAGREES_WITH_BLOCK", "an off-camera block must be booked on its own channel", line_id=line_id, record_id=record_id, channel=channel, tag=expected, ) ) elif expected == "on_camera" and channel in VOICED_TAGS: findings.append( _finding( "VOICE_CHANNEL_DISAGREES_WITH_BLOCK", "an on-camera dialogue block must not be booked as VO or OS", line_id=line_id, record_id=record_id, channel=channel, ) ) raw = screenplay[start:end] try: decoded = raw.decode("utf-8") except UnicodeDecodeError: # A span that begins or ends mid-character is a stale offset. It was # crashing the whole check instead of reporting the one line. findings.append( _finding( "VOICE_BLOCK_SPAN_INVALID", "the indexed block span does not start and end on characters", line_id=line_id, record_id=record_id, ) ) continue body = VOICE_TAG_PREFIX.sub("", decoded.strip()) match = DIALOGUE.match(body) if match is None: findings.append( _finding( "VOICE_BLOCK_IS_UNPARSEABLE", "the dialogue block does not use the documented line grammar", line_id=line_id, record_id=record_id, ) ) continue if record.get("line_text") != match.group("line"): findings.append( _finding( "VOICE_LINE_TEXT_DIVERGED", "line_text is not the screenplay wording; change the screenplay", line_id=line_id, record_id=record_id, ) ) indexed_speaker = block.get("speaker") if isinstance(indexed_speaker, str) and record.get("speaker_display") not in ( None, indexed_speaker, ): findings.append( _finding( "VOICE_SPEAKER_DIVERGED", "speaker_display disagrees with the indexed speaker", line_id=line_id, record_id=record_id, ) ) dialogue_blocks = { block_id for block_id, block in blocks.items() if _is_voiced(block) } projected: set[str] = set() for record in lines: covered, _defect = resolve_ref(record.get("source_ref"), sources, "") if covered is not None and covered.record_id is not None: projected.add(covered.record_id) # Reported, never a finding: a sheet may legitimately cover one actor or one # scene, so an incomplete sheet is a scope decision, not a defect. uncovered = sorted(dialogue_blocks - projected) return { "schema_version": SCHEMA_VERSION, "lines": len(lines), "dialogue_blocks": len(dialogue_blocks), "uncovered_dialogue_blocks": uncovered, "findings": findings, "status": "pass" if not findings else "fail", } def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Check a voice record sheet against its screenplay and index." ) parser.add_argument("sheet", type=Path, help="the voice record sheet JSONL") parser.add_argument("--index", type=Path, required=True) parser.add_argument("--screenplay", type=Path, required=True) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: result = check( _load_jsonl(args.sheet), _load_jsonl(args.index), args.screenplay.read_bytes(), ) except (CheckError, OSError) as error: print(f"{type(error).__name__}: {error}", file=sys.stderr) return 2 print(json.dumps(result, ensure_ascii=True, sort_keys=True)) return 0 if result["status"] == "pass" else 1 if __name__ == "__main__": raise SystemExit(main())
-
-
SKILL.md 9.2 KB
--- name: short-drama-write description: 编写或修订可拍摄的中文短剧、漫剧单集 Markdown 剧本,也负责保留作者原文地规范化现成剧本。用户提出“写/改一集短剧”“把大纲写成剧本”“优化场景/对白”“去模板感”“去 AI 味润色”“续写下一集”或提供剧本要求进入后续制作时使用;不负责资产、分镜、媒体提示词或终审。 license: MIT --- # 短剧写作 把单集意图写成可表演、可拍摄、会改变故事状态的 `剧集/<EP>/剧本.md`。 ## Quick Start 只维护一份剧本 Markdown,不另建 episode card、beats、block index、录音表、QA 或接受记录。 一级标题使用 `# EP001 集名`,场景标题使用 `## EP001-SC001 内 · 地点 · 时间/天气`。 对白写 `角色(可选表演提示):台词`;生产标签使用 `[VO]`、`[OS]`、`[SFX]`、`[画面文字]`、 `[连续性]`、`[转场]`。不要把含冒号的动作叙述误写成对白。 ## 入口 - 有分集规划:读取本集进入状态、目标、转折、回报和交接事实。 - 只有想法/大纲:在上下文形成最小单集契约和因果节拍,再直接写剧本。 - 已有剧本:保留作者语言,只做用户点名的定点修订。 - 非规范文本要进入制作:保留原文,只做必要的场景、动作、对白和生产标签规范化。 开发大纲和长篇分析都是可选上游;当前材料足够时直接写。 ## 工作流 1. 锁定本集承诺的观看体验、谁现在要什么、阻力、当集兑现和退出状态。 2. 只识别本集实际存在的发动机:若主打艰难选择,检查两边价值;若判断转折依赖争议证据,检查证明边界;若前景化精确死线,检查动作容量;若题面限定次数、轮次、呼吸或节拍,检查每个单位是否在边界内完成所需状态。没有时不强行补齐;输入已经以它为主发动机时,要让它真正改变行动、关系或结果,不只在台词中点名。 3. 在上下文排“因为—行动—结果—下一股压力”,不落盘节拍表;让物件、对白、沉默、空间或声音按本集题材承担必要工作,不预设某一种载体。 4. 逐场确定不可替代的功能、可见变化与场尾状态;由争取或冲突组织的场景再明确焦点议程与阻力。承担主要戏剧功能的配角要有自己的策略,事务性或环境性角色保持简洁即可。 5. 把动作、对白、画外音、声音事实和画面文字写入同一份剧本。 6. 用户要求整集就写完整集,场景批次自动续跑。 7. 交稿前静默反查:只看剧本能否接收到本集承诺、人物行动是否有因果、题材语气是否一致;再对本集确实使用的选择、证据、有限单位或精确死线做专项检查。若自检让剧本长出输入没有要求的机关,撤掉机关而不是替它找理由。 8. 局部修订保留无关段落;明确问题直接修正,只把真实剧情分叉交给用户。 ## 写作要求 - 先服从输入已经给出的事实、`必须/禁止`、题材观看契约和退出状态;本技能的审查项与手艺默认只帮助实现这些要求,不能反过来补造题面没有的精确数字、记录、资源、程序、关系或机关。 - 每场改变信息、权力、关系、情绪、物理状态或风险;无变化的场删掉或合并。核心阻力的转向要有可追溯的事实、筹码、权限、代价或人物行动,不能只靠对手停止反应。 - 对白必须在争取、回避、试探、逼迫或重新定义关系;用可见、可听、可触的行为表现状态,不用解释替代行动,也不把已经演清的机制再说一遍。 - 观众理解当前行动所必需的赌注要进入剧本,但载体服从题材;选择、证据、有限单位、精确死线等专项规则只在输入真正以它为发动机时启用,并按需读取[剧作手艺](references/script-craft.md),普通决定、普通物件和模糊时间压力不升级成同一套机关。 - 压缩过程时只保留会改变策略、关系、风险或结果的节点。资源与外部反应必须来自输入或前文建立,且不能替人物完成最难的戏剧行动。 - 承担主要冲突的角色要保持相称的目标、判断或策略;事务性、环境性角色保持简洁,不为检查表硬加秘密、反转或人物弧。 - 已建立的代价、不可逆后果、关键持物、空间和知识状态要进入可追溯的退出状态;结尾可推进、留白、反讽、安静收束或形成余韵,不强制追加外部事件。 - `[连续性]` 只交接由前文动作已经建立、又容易在下游丢失的状态;不能用作者标签证明镜头外从未发生某事,也不复述正文已清楚呈现的结果。 - 交稿前静默做一次经济性检查:是否增加了输入没有要求的精确数字或记录链;是否用标签/对白重复动作;是否在回报后堆了多个同方向后果。没有新作用的内容删掉,knowhow 本身不删。 - 输入给出目标时长时按真实表演、动作和停顿通读压稿,先删重复验证、解释和同义收尾;不用统一字数、镜头数或节拍数填模板。 - 用户原文优先;“去 AI 味”是定点修订,不抹平作者个性。 ## 按需知识 默认只读本 SKILL 和直接输入。出现下列明确信号时,在写正文前读取对应知识;同一输入有多个不同问题 可以分别读取,但不遍历无关引用: - 阶段边界与规则分级:[阶段契约](references/stage-contract.md) - 场景标题、对白和生产标签语法,或把创作者带来的现成剧本[规范化](references/screenplay-format.md#7-现有文本的规范化入口):[剧本格式](references/screenplay-format.md) - 项目特有的制作稿方言:[制作格式方言](references/production-format-dialect.md) - 输入含[核心选择](references/script-craft.md#35-把一拍写成艰难选择时两个方向都要活着)、[有限次数/轮次/呼吸/节拍](references/script-craft.md#64-有限单位按完成状态计数)、[精确倒计时](references/script-craft.md#63-倒计时先算动作不要只写数字)或[会改变人物判断的关键物证](references/script-craft.md#53-证据先改变判断再改变关系):[剧作手艺](references/script-craft.md) - 过去关系必须通过对白进入,或关键人物容易只剩说明功能:[对白手艺](references/dialogue-craft.md) - 声源、留白与 sound bridge:[场景声音戏剧](references/scene-sound-dramaturgy.md) - 同一故事义务的不同可拍实现:[可替代实现](references/substitutable-realization.md) - 长单集跨上下文时的最小交接:[场景交接胶囊](references/scene-handoff-capsule.md),只留在上下文 ## 时长估算(按需) 用户问时长时才把索引写入系统临时目录。先用 Python 查询跨平台临时目录,把第一条命令打印的完整 路径原样替换进后两条命令的引号内。以下每条都是一行完整命令,不依赖 shell 变量或续行符;Windows 没有 `python3` 命令时使用 `py -3`。`--speaker` 的示例值必须替换为本集实际说话者。 `--project short-drama.json` **只加在第三条命令上**(`duration_estimate.py` 才接受它, `screenplay_index.py` 没有这个参数,加上去会直接报 `unrecognized arguments`),且只在项目 配置确实存在时加: ```text python3 -c "from pathlib import Path; import tempfile, uuid; print(Path(tempfile.gettempdir()) / ('short-drama-' + uuid.uuid4().hex + '.jsonl'))" python3 "{技能目录}/scripts/screenplay_index.py" "剧集/EP001/剧本.md" --output "粘贴第一条命令输出的完整路径" --speaker "本集角色一" --speaker "本集角色二" python3 "{技能目录}/scripts/duration_estimate.py" "剧集/EP001/剧本.md" --index "粘贴第一条命令输出的完整路径" ``` **改稿后重跑**:第二条命令写过一次的输出路径不能直接再写一次,会以 `already holds an index` 失败。 要保留原有块 ID 就加 `--previous-index <同一路径>`,要从头重编号就加 `--no-previous`; 或者干脆回到第一条命令另取一个临时路径。 估时前区分计数口径:`dialogue_characters` 包含标点,视频提示词中的参考语速按可发声字数计算。 按语速换算时排除标点,并按实际读法处理数字和缩写;不把两个计数混用。 估算是参考,不是门禁;没有语速或动作段速率时只报告可数事实,不猜秒数。但任务已给目标时长时, 写作者仍须用真实朗读和动作通读判断能否容纳,并直接压缩重复节拍;“不能精确估秒”不是忽略目标的理由。 ## 完成 点名范围已写入、场景因果连贯、对白与行动可表演、承诺有兑现或有意延迟、结尾完成本集预定的戏剧或情绪功能,即完成。最后一拍可以推进,也可以安静收束、形成余韵或让既有动作变义;判断它是否有效,不用“是否又发生一件事”代替。 资产、分镜、审查与生产只有用户点名时开始。 五份创作文档齐备后,可转 `$short-drama` 对跨文档结构做一次机械核对;内容质量仍由创作者审查。 ## 安装维护 只有安装、升级或排障时运行 `python3 scripts/selftest.py`。
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.