Claude Skill

short-drama-storyboard

把中文短剧剧本和视觉设定写成有戏剧职责、连续性边界与冻结关键帧提示词的分镜 Markdown。用户提出“拆分镜/设计镜头/做镜头表”“场次视觉计划/调度故事板”“比较导演方案”“写首帧/关键帧提示词”或检查轴线、站位、视线、持物连续性时使用;不生成媒体。

LLM Mart · 0 points · 12 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zenstory-ai-drama-skills-skills_short-drama-storyboard-9e78a57.zip · 79 KB
Part of zenstory-ai/drama-skills — 11 skills

Install

skills CLI npx skills add https://github.com/zenstory-ai/drama-skills/tree/main/skills/short-drama-storyboard
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zenstory-ai-drama-skills@llmmart
Git 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。 每镜使用二级标题 ## SHOT-...,同镜下用 ### 冻结关键帧提示词 写起始帧正文; 需要时另加 ### 收尾关键帧提示词 写终点正文。

Quick Start

用 $short-drama-storyboard 完成 EP001 的正式分镜和每镜冻结关键帧

入口

当前剧本加必要视觉事实即可直接进入。资产图片提示词与分镜是兄弟分支,不互相等待。只有关键场次 真的存在多个成立导演方案时,才在上下文比较覆盖方案;普通场次直接设计。

创作者可读说明跟随项目语言;冻结关键帧的可复制正文跟随 short-drama.json#/format/prompt_language。没有 short-drama.json 时正文默认使用 en。 分配镜头时长前读取 short-drama.json#/creator_authority/production_profile/choices/native_duration_seconds。已声明目标模型但 没有原生时长时先补这一个选择;目标模型也未声明时照常按叙事节奏设计,不猜供应商限制。

冻结关键帧写完之前,谁会出现在这一格画面里还没有定下来。所以三条依据都在关键帧之后回填, 读的是成稿画面,不是镜头意图。

用户没有手工点名参考图,不等于选择文生视频。冻结关键帧后先根据本镜可见人物、地点、关键道具与 起始构图判断一致性需求,再检查项目中已有的真实图片。只有当前可读、内容与用途可核对的图片才能自动绑定; 文件名像某个角色但内容未核对时,仍视为未准备好。

项目里一张都没有时,创作者有三条路,一次把三条都说清楚再让他选,不要只给「先生成参考图」和 「改成文生视频」两个:把手上已有的图片放进项目再绑成 REF-...;在自己的工具里出图、本轮先用 PLAN-... 写出挂图计划;或者明确改走文生视频。用本套件的生产技能出图只是第一条路的一种做法, 需要项目外的 adapter 与凭据,不是唯一入口。

开始分镜前读 剧集/<EP>/剧本.md 的当前内容。创作者说「剧本已确认采用」指的是那个文件, 不是对话里更早的草稿;两者不一致时以文件为准,并把差异说出来。

工作流

  1. 读取已接受的目标模型原生时长区间,再确认每个场景动作、对白、声音、画面文字和转折由哪些镜头承载。

  2. 确定观众何时知道什么、对齐谁、空间如何揭示、转折落在哪个可见动作。 剧本或 Brief 用“前半段 / 后半段 / 前三分之一 / 最后 N 秒”等相对时间限定首次出现、释放或禁止时, 先用已接受集长换成明确时间边界再分镜;例如 60 秒作品的“后半段首次出现”不得早于 30 秒。

  3. 每镜写清唯一职责、来源、时长、景别/机位、起点、唯一主要动作、终点和声音;用 起点 → 唯一动作 → 终点 直接说明人物、双手与持物怎样完成本镜状态转换。三条依据留到第 6 步。 有对白、VO 或 OS 时,先按原文和人物说话方式估算完整发声时间,再安排不能重叠的动作、停顿与 末尾反应;把估时依据简写在本镜「声音」里。具体做法见 对白估时。

  4. 锁定人物朝向、位置、视线、持物、出入口、屏幕方向和必要状态;上游已有会限制动作空间的持续关系时,把关系投影进本镜起点与终点。

  5. 冻结关键帧只投影镜头起点;删除终点才出现的文字、动作、姿态或道具状态。 镜头的职责落在收尾那一下时,另写 ### 收尾关键帧提示词 投影终点——同一格画面的 另一端,规则完全对称:删除起点才有、终点已经不在的东西。它是可选的,写法与取舍见 首尾成对。

  6. 关键帧成稿后,先从成稿正文读出这一格里需要保持身份、造型或地理的人物、地点和道具, 再用同一份清单一次填满三条依据:

    • 视觉依据:清单里每一项写成《视觉设定.md》的条目引用,这是必写字段;
    • 图片提示词项:清单里已有合适 IMG-... 条目的写上,没有的留「无」;
    • 输入参考图:按清单在用户提供的输入、剧集/<EP>/制作成果/ 和文档已指向的其他可见媒体中查找匹配图片; 命中就写入 REF-...。一张都没命中时写「无(待补参考图:<本镜缺失的起始帧、人物、地点、道具>)」; 只命中一部分时,在已有 REF-... 后追加「;待补参考图:<仍缺的图片>」,缺口之间只用 、 分隔。 创作者说图片由他自己在别处准备时,把这份清单写成 PLAN-... 槽位,本镜即告就绪; 只有创作者明确表示不用图时,才写「无(创作者已明确选择文生视频)」。

    三条依据读的是同一格画面,所以不能互相矛盾:关键帧里点名的人物或道具,一定出现在视觉依据里。

  7. 整集请求完成整集,末端一次报告覆盖、节奏和真实未决选择。

镜头要求

  • 每镜有唯一职责、明确来源、可见起止状态和合理时长。「来源」以《剧本.md》里真实存在的场景 ID 开头, ID 之后可以跟必要短引文,跨场次的镜头用 、 连写多条;creator_markdown_check.py 会核对每个 ID 都解析得到。

  • 「来源」短引文应对应本镜承担的动作或声音。正文提到画外人物时,可在视觉依据写 ;画外:<类别>「<名称>」;这只声明其不在画面,不能代替必要动作的落实。 需要看见的动作应另有镜头承载,或说明为何省略。

  • 剧本里的每个场景都要有镜头承载。确实决定不拍某一场时,在《分镜.md》正文开头写一行 - 未拍场次:<场景 ID>(理由:……),多项用 ; 连接。SHT-01 一直允许带理由的省略, 但省略和漏拍在成稿文档里长得一模一样,所以理由要写下来,而不是留在对话里。

  • 已声明原生时长时,每个一镜一生成的镜头都落在其最短与最长秒数内。短于下限的叙事动作在合法 镜长内完成后保持终点;长于上限的动作按可见状态闭合点拆镜,拆出的每镜仍各有唯一职责与可信交界。

  • 「唯一动作」写从本镜起点到终点的那条可见状态链;涉及交接、换手、放置或取回时,把接触者、 接触方式和物件落点写在同一链里,不能让合法终点之间靠镜外瞬移衔接。

  • 切镜必须带来信息、权力、情绪、空间或节奏变化;同义重复镜头删掉。

  • 相对时间词约束的是事件首次可见或可听的时刻,不因前置动作已经完成就提前释放;分镜时间轴直接 写出该边界,不把“稍后”“交接后”等叙事顺序当成数值时间的替代。

  • 同一动作不跨镜重复发生;终点应能成为下一镜可信起点。

  • 关键信息的可读性优先于装饰性运镜,竖屏构图优先保证主体和反应可见。

  • 复杂群戏先锁空间锚点和屏幕方向,再决定景别变化。

  • 起终边界除了逐人姿态,还要写清本镜依赖的身体—物件—空间关系。关系在镜内不变时两端都保留;确需改变时,过程与终点必须有剧本依据。

  • 冻结关键帧只写该瞬间能看见的内容;文字、手指、反射和遮挡要可生成。

  • 《视觉设定.md》的连续性锁在生效镜头上原样写进冻结关键帧正文;锁面不因“参考图会交代”而省略。

  • 检查发现遗漏就直接修正文档,不生成覆盖表来证明自己检查过。镜头块里的「视觉依据」是本镜的依据字段,不是这里说的覆盖表。

视觉依据与图片

  • IMG-... 是《图片提示词.md》里的提示词条目 ID,不代表图片已生成、已提供或可用。
  • 已有《图片提示词.md》且本镜需要其中条目时,写「图片提示词项」:ID 必须匹配该文档的可见二级标题,同时写中文名称和它控制的身份、造型、地理、构图或其他边界。ID 只用于查找,不让英文 slug 承担含义。
  • 没有《图片提示词.md》或没有合适条目时,写「图片提示词项:无」,再以中文指向《视觉设定.md》的人物、地点或道具项及控制范围。这是正常直接入口;不为继续分镜而编造 ID,也不补造另一个阶段。
  • 「视觉依据」是每镜必写字段:冻结关键帧成稿后按实际画面反查,需要保持身份、造型或地理的可见人物、地点和道具必须逐项写成 《视觉设定.md》·<人物|造型|地点|道具>「<名称>」(控制:<范围>),多项用 ; 连接,名称必须能在《视觉设定.md》里找到同名标题。 小比例或背景人物只要需要认出身份仍在范围内,画外人声和不可识别局部不算;本镜确实没有这类条目时写「视觉依据:无」。
  • 关键帧正文出现了某个条目的名字,但它本镜确实不在画面里(物主已离场、名字只出现在屏幕文字上、和某个词同形), 在「视觉依据」末尾追加 ;画外:<类别>「<名称>」。这是如实记录,不是把不在场的人写成在场。
  • 某个条目的名字在正文里根本不可靠(道具「手机」 会撞上「手机店」,人物「小雨」 会撞上「下着小雨」), 在《视觉设定.md》该条目写 画面代称:无,机械核对就不再按名字找它;这一镜是否画到它仍由「视觉依据」如实回答。
  • 三条依据都描述同一格画面,不是本镜的资产花名册。「控制」只写这一格承担得起的范围:手背特写不写「本集造型」, 只出现屏幕和一根手指的镜头不写「体态」。上一镜的绑定不因为“角色还在这场戏里”就顺延到下一镜。
  • 关键帧正文点名的人物、地点或道具(《视觉设定.md》条目名称或它声明的「画面代称」),必须出现在本镜「视觉依据」里; creator_markdown_check.py 会机械核对这一条。它只抓被名字点到的那一类:正文只描述不点名 ("a boy in a hooded jacket")时机器不发声,画面里有谁仍然要靠这一步自己读出来,不能等报错。
  • 本镜需要保持一致、但《视觉设定.md》里还没有条目的可见物件(跨镜出现的道具尤其常见),不能因为 「写不进视觉依据」就当它不需要一致性。四镜都出现的一支笔和一件外套是同一类问题。做两件事: 正文里逐镜用完全相同的描述把它钉住,并在本轮末尾报告里点名它、建议 $short-drama-assets 补条目。 不自行新增《视觉设定.md》条目,也不把它塞进「视觉依据」冒充已有条目。
  • 「输入参考图」是独立的另一条轴:创作者已提供,或经确认生产且当前可读的实际图片,用不同于 IMG-... 的稳定 REF-... 槽位引用。每个槽位同时写显式顺序、仅用 / 分隔的项目相对路径、中文名称、「用途」、「控制」和「不得控制」范围;没有时必须区分「待补参考图」和「创作者已明确选择文生视频」,只准备了一部分时则保留已有 REF-... 并追加「;待补参考图:……」。不得把提示词条目、还没生成的图或对话中的描述写成 REF-...——REF-... 的意思是「文件此刻在项目里」。 创作者会在生成时自己挂载的图片走下面的 PLAN-...:它同样不声称文件存在,只是把这一镜要挂哪几张写清楚。
  • 每个 REF-... 必须逐字遵循唯一格式:REF-<slot>(顺序:<n>)· <项目相对路径>《<中文名称>》(用途:<用途>;控制:<范围>;不得控制:<范围>)。注意「顺序:」后的全角冒号和路径前的中点 ·;不得用 / 代替字段分隔符。多图只用中文分号 ; 连接,部分缺图则在最后一个完整槽位后接 ;待补参考图:<缺口>,缺口之间用 、 分隔。写完后按此格式回读一次再交付。
  • 用途 只能取 身份、造型状态、地理、构图、尺度、效果、起始帧、结束帧、风格 之一;逐条能控制什么见 $short-drama 的《参考角色》。 本镜的起始画面图写 用途:起始帧,人物图写 用途:身份 或 造型状态,场景图写 用途:地理,道具状态图按它决定的那一件写。 一张图承担两件事就拆成两个槽位;一镜最多一张 起始帧 和一张 结束帧,写 结束帧 时必须同时有 起始帧。 下游视频提示词靠 用途 说清「本镜送哪一张分镜起始帧、哪些人物/道具/场景图」,所以缺 用途 的槽位不算准备好。
  • 本镜还没有起始画面图时有两条路。创作者用本套件生产:用途:起始帧 那一张由 $short-drama-produce 用本镜的冻结关键帧正文生成,job 的 source 写 剧集/<EP>/分镜.md、source_entry 写本镜 SHOT-..., 产出落在 剧集/<EP>/制作成果/images/,拿到真实文件后再绑成 REF-...。创作者在自己的工具里生产: 把冻结关键帧正文复制过去出图,再把文件放进项目绑成 REF-...,或者本轮先写成 PLAN-SHOT-START(顺序:<n>)· <本镜 SHOT-...>《本镜冻结关键帧》(用途:起始帧;……)。 两条路都没走时,它仍然只是「待补参考图」里的一项。
  • 创作者在项目外出图、图片不进项目时,用 PLAN-... 槽位:语法与 REF-... 逐字相同,只把项目相对路径 换成 IMG-... 或 SHOT-... 条目 ID,IMG-... 的中文名称必须与《图片提示词.md》标题一致。 它声明的是「这张图由创作者在生成时按这个顺序挂载」,不声称项目里有文件,因此不是「待补」, 也不能拿来投产。真实文件进了项目就改写成 REF-...。两种槽位可以混用,顺序 统一连续编号。
  • 自动绑定不是把所有图都塞进一镜。只选本镜可见、且身份、造型、地理、关键道具状态或起始构图确实需要稳定的图;逐张写清它的用途、能控制什么、不得控制什么。
  • 两条轴可以独立组合:有合法的图片提示词项时,输入参考图仍可为「待补」或创作者明确选择的文生视频;创作者给了真实参考图时,图片提示词项也可为「无」。

按需知识

默认只读本 SKILL、当前剧本和必要视觉事实。遇到对应问题时只打开一份:

修订

保留未受影响的镜头 ID。拆镜、并镜或改变镜头职责时,说明哪些下游视频提示词需要刷新,但不自动 重写用户没点名的文件。

完成

点名范围的原文都有镜头落实,每镜职责不重复、空间与状态连续、起止边界可制作、关键帧可冻结, 每镜「来源」解析到《剧本.md》的真实场景、剧本里每个场景都有镜头承载或已记入「未拍场次」, 且每镜关键帧里的可见人物、地点、道具都在本镜「视觉依据」里,即完成。画面需要、但《视觉设定.md》 还没有条目的可见物件,在末尾报告里逐项点名。本次请求同时修改 《图片提示词.md》与《分镜.md》时,当轮刷新受影响镜头的引用;不另建覆盖表。 视频提示词、生产和审查只有用户点名时开始。 五份创作文档齐备后,可转 $short-drama 对跨文档结构做一次机械核对;内容质量仍由创作者审查。

安装维护

只有安装、升级或排障时运行 python3 scripts/selftest.py。

Files (drama-skills)
  • agents
    • openai.yaml 273 B
      interface:
        display_name: "短剧分镜"
        short_description: "把剧本与资产拆成完整coverage、有动机镜头和冻结关键帧"
        default_prompt: "使用 $short-drama-storyboard 把已接受剧本和资产拆成有动机的镜头与冻结关键帧提示词。"
      
  • assets
    • coverage-audition.example.jsonl 1.9 KB · in bundle
    • coverage-template.json 1.2 KB
      {
        "sources": {
          "shots": {
            "owner": "short-drama-storyboard",
            "artifact": "剧集/EP001/storyboard/shots.jsonl"
          },
          "screenplay-index": {
            "owner": "short-drama-write",
            "artifact": "剧集/EP001/screenplay-index.jsonl"
          },
          "short-drama": {
            "owner": "short-drama",
            "artifact": "short-drama.json"
          }
        },
        "episode_id": "EP001",
        "dispositions": [
          {
            "block_id": "BLK-EXAMPLE",
            "status": "covered",
            "reason": "Primary action and reaction remain continuous.",
            "shot_refs": [
              {
                "src": "shots",
                "record_id": "SHOT-EXAMPLE",
                "authority": "candidate"
              }
            ]
          }
        ],
        "episode_duration": {
          "shot_seconds_total": null,
          "counted_shot_ids": [
            "SHOT-EXAMPLE"
          ],
          "unresolved_durations": [],
          "target_ref": {
            "src": "short-drama",
            "field": "/format/target_seconds_per_episode"
          },
          "target_seconds": null,
          "delta_seconds": null,
          "disposition": "no_target_declared | within_creator_tolerance | creator_accepted_overrun | to_revise"
        },
        "screenplay_ref": {
          "src": "screenplay-index"
        },
        "status": "candidate"
      }
      
    • keyframe-prompts.md 1.8 KB
      # `keyframe-prompts.md` 可复制输出模板
      
      这是由已接受的 `keyframes.jsonl` 生成的文本,不是镜头边界或资产事实来源。
      每次导出都记下配方版本;文本被手改时,先预览恢复、
      采用或合并的结果。
      
      **这份模板属于结构化管线**(`keyframes.jsonl` 一路)。creator-first 项目的《分镜.md》骨架
      以 `SKILL.md` 为准:`## SHOT-... · 中文名`,关键帧正文标题是 `### 冻结关键帧提示词`。
      不要把这里的加粗字段名、`KEY-...` 或 `### 可复制通用提示词` 搬进 creator-first 文档。
      
      ```markdown
      # EP<编号> · 冻结关键帧提示词
      
      > 来源:`keyframes.jsonl` 的已接受记录
      > 配方:`keyframe-generic@<version>`
      > 范围:单一静止瞬间;不生成图片,不写时间动作
      
      ## `SHOT-<id>` · `KEY-<id>`
      
      - **镜头目的**:<观众此刻必须看见什么>
      - **观众可见性**:逐项列出 `<来源 · 展示或暂缓 · 声画载体 · 揭示条件 · 保护方法>`
      - **边界来源**:`SHOT-<id>/start_boundary`
      - **资产绑定**:`<准确人物与造型 · 地点与观察视角 · 道具与状态>`
      - **文字处理**:`<已接受的来源文字政策 -> 本帧呈现方法>`
      
      ### 可复制通用提示词
      
      > <焦点主体与识别锚点>。<构图层级、人物/道具/空间锚点关系>。摄影机<景别、角度、镜头意图>。
      > <一个可冻结的身体朝向、姿态、目光、双手、持物与可见表演状态>。<继承的光向、时段、天气与必要气氛>。
      > <文字处理>。保持<边界与身份事实>;排除<本帧高风险漂移>。
      
      ---
      ```
      
      若一句话必须靠“先、再、随后、最终”才能成立,就交给运动提示词环节;若要改变镜头
      开始边界,先向分镜负责人提出修订请求,不要在派生文本中偷偷改。
      
    • keyframe-template.jsonl 2.3 KB · in bundle
    • revision-lineage.fragment.json 277 B
      {
        "revision_lineage": {
          "change": "split | merge | restore",
          "predecessor_shot_ids": [
            "SHOT-<same-file-prior-id>"
          ],
          "retired_shot_ids": [
            "SHOT-<same-file-retired-id>"
          ],
          "reason": "<导演职责怎样被拆开、合并或恢复>"
        }
      }
      
    • scene-visual-plan.example.jsonl 2.8 KB · in bundle
    • shot-template.jsonl 3.5 KB · in bundle
  • references
    • blocking-playbooks.md 12.3 KB
      # 常见场面的分镜方法:竖屏、单房、证据与群体轴线
      
      ## 目录
      
      - [竖屏多人](#1-竖屏多人调度)
      - [单房对白](#2-单房对白)
      - [证据揭示](#3-证据揭示)
      - [群体与轴线](#4-群体与轴线)
      - [动态对象与竞赛](#5-动态对象与竞赛)
      - [观察视角与边界](#6-观察视角与边界)
      - [规则分级](#7-规则分级)
      
      先使用镜头设计资料中的原文落实、镜头目的和场景地理方法。只有遇到下列场面时才读
      本文;这些是思考步骤,不是镜头数量模板。
      
      ## 1. 竖屏多人调度
      
      竖屏不是把所有人排成上下两层。先画出人物能怎样通行,再决定纵向画幅怎样表现关系与权力:
      
      - 用前景、中景、后景、门框、台阶和桌沿建立深度。
      - 明确谁能接近证据、出口或权力座位。
      - 头像高低只在空间真实且有意义时表达地位。
      - 两人对话时,仍要保留第三人的监听、反应或阻挡位置。
      - 先保证进出方向和视线连续,再用近景强化选择。
      
      可以按以下顺序设计:空间锚点与入口 → 每人的行动目标和筹码 → 群体注意中心 →
      关键动作前后的站位 → 哪些同时反应必须留在全景中 → 只有丢失信息时才切插入镜头或近景。
      
      常见问题:每句台词都切一个头像近景;站位只为填满画面;背景人物在切镜后无原因换边;
      重要人物一直被前景遮住,但剧情并未利用这种遮挡。
      
      ### 交付面遮挡 (`SHT-15`)
      
      竖屏画面在播放时通常不是完整可见的:播放界面会在画幅上叠一层东西——字幕条、播放控件、
      进度条、侧边的互动控件、片头片尾的角标。**画幅是满的,可读区不是**。 这与拍摄无关,
      是交付面的事实,所以它约束的是构图决定,而不是画面内容。
      
      **`reviewed_invariant`**:当创作者已声明交付面的遮挡区时,本镜必须被读到的内容(脸与
      眼神、证据上的可读文字、决定性的手部动作)不能只落在这些区域里。遮挡区、是否常驻及其
      来源引用都取自已接受的交付方案;**没有声明就没有这条约束**。
      
      - **未声明时保持未解决,不要猜**。 记为待创作者决定,照常按画面本身的可读性构图,
        **不因为猜测的区域改变构图**——猜出来的避让同样是一个通用安全框,只是没写在纸上。
        需要时向创作者提一次问:这次交付的播放面会在画幅哪些位置叠加什么、是否常驻。
      
      声明由**创作者拥有**,写在 `视觉设定.md` 的“交付面”章节,与项目视觉方向和制作形态并列。
      它回答三件事:**占用哪里、是否常驻、依据来自哪**。每个区域使用稳定 ID,并写:
      
      - 当前画幅;
      - 用画幅比例或可命名边带描述的占用范围;
      - 常驻、间歇或条件出现;
      - 承载字幕、控件、角标还是其他内容;
      - 创作者提供的平台模板、截图或文字说明。
      
      没有声明时本规则不生效:镜头照常按画面本身的可读性构图,把问题留为待创作者决定。已经声明时,
      受影响镜头在 `分镜.md` 指名交付面区域 ID;交付方案改版后重新核对,不沿用上一版避让结论。
      已声明遮挡区后,按下面的优先级判断哪些内容必须挪开:
      
      - **可读文字最敏感**:证据类道具上必须被读出的字,一旦落在字幕条位置,观众永远读不到。
        这类镜头要么把文字挪到中部可读区,要么改用更近的景别单独给一次。
      - **脸与眼神次之**:竖屏习惯把人物放得很高,容易压到顶部角标区;也不宜为了避让而把人
        一律推到正中,那会让全片构图变平。判断依据是"这一镜要被读到的是什么",不是固定安全框。
      - **一侧的互动控件**是竖屏特有的:如果本镜的关键动作发生在被占用的那一侧,把动作调到
        另一侧,或用镜像的机位关系解决——不要靠缩小主体来避让,那会同时削弱表演。
      - **不要把避让写成通用禁区**。它是本项目交付方案下的构图约束;换交付面就要重新确认,
        也不能反过来要求剧本改动作发生的位置。
      
      失效方式是安静的:单帧看构图很好,只有放到实际播放面上才发现关键信息被压住,而这时
      关键帧与视频提示词都已经做完了。所以这个判断属于分镜阶段,不属于交付前检查。
      
      ## 1b. 轴线、屏幕方向与正反打
      
      ### 左右的唯一读法
      
      **`structural_invariant`**:本套件所有产物里,**裸写的"左/右"一律指画面左右**(观众看到的
      左右)。指人物自身的左右时必须带主体——"他的左手""她的右肩"。两种读法混用是正反打
      最常见的失效来源,因为单看每一句都成立。
      
      - ✅ `沈一舟站在画面左三分之一处,赵经理占画面右侧`
      - ✅ `秘书把工牌递到他的右手边`(带主体,指人物右手)
      - ❌ `秘书从右侧接近`(谁的右?下游只能猜)
      
      ### 轴线怎么记
      
      轴线不是一个形容词,是**两个可命名锚点的连线**。记法要落成可比较的断言,让下一镜能
      逐字核对:
      
      ```text
      轴线:沈一舟 ↔ 赵经理(两人连线)
      working side:摄影机常驻红毯一侧
      屏幕方向:沈一舟固定占画面左,赵经理固定占画面右
      ```
      
      写成 `<working side>` 这类占位等于没写。判据是:换一个人来拍下一镜,他能不能只读这
      三行就把机位放对。
      
      ### 正反打的配对规则
      
      - **屏幕方向必须互补**:A 镜里甲在画面左看向右,B 镜里乙就必须在画面右看向左。两镜
        人物都望向同一侧时,观众读到的不是对话,是两人在看同一个画外的第三者。
      - **视线高度按实际身高差匹配**:一方明显高时,A 镜的俯角与 B 镜的仰角要对应;两镜都
        平视会把身高差抹平,也会让"逼近"失去压迫感。
      - **过肩镜写明前景是谁的肩、占画多少**:只写"过肩"不够,前景肩的归属决定了这一镜属于
        谁的视角。
      - **A/B 两镜是否对称是主动选择**:机位高度与景别对称表示势均力敌,不对称表示权力倾斜。
        选了不对称就写清理由,别让它变成随手的结果。
      
      ### 越轴之后要重新记账
      
      越轴的合法手段见本文上一节。但越完之后,**新的 working side 必须重新声明并被后续镜头
      继承**——否则后面每一镜都在各自猜轴线在哪。越轴那一镜本身也要标明它是越轴,而不是让
      下游从方向矛盾里反推。
      
      ## 2. 单房对白
      
      单房戏的运动来自人物策略、物件和注意变化,不来自无理由走动。先画出会造成压力的
      空间关系:
      
      - 出口、座位、桌面或证物、电话或电梯的声源。
      - 谁先占据或控制哪个位置。
      - 谁能触碰、打开、藏起或拒绝某件物。
      - 哪个声音会增加时间压力,但不引入新人物。
      
      每轮可以按以下关系检查:
      
      ~~~text
      说话或沉默的策略 -> 对方怎样接住或拒绝 -> 物件、目光或空间发生什么 -> 谁暂时掌权
      ~~~
      
      同一个双人镜头可以承载多轮对话。只有注意、解释或权力真的改变时才需要切镜。
      反应不必总拍脸,也可以是不碰纸袋、把钥匙推过边界、手从门把移开,或让沉默继续。
      
      不要为了“画面丰富”让人物轮流起身、倒水、看窗。动作若不改变议程或证据条件,反而会削弱表演。
      
      ## 3. 证据揭示
      
      先写清观众知道了什么,再选镜头:
      
      1. 证据在哪里,由谁控制。
      2. 揭示前观众能读到什么。
      3. 构成证据的是形状、状态、精确文字,还是人物与物件的关系。
      4. 谁先看见,谁又看见对方已经看见。
      5. 得知后,哪个行动发生变化。
      
      镜头可以负责建立方位、获得证据、展示证据、表现接收者反应或交代后果,不要求固定
      顺序。若精确文字就是证据,镜头必须绑定来源文字政策和本帧呈现方法;不要用一闪而过的
      插入镜头假装观众能读完。若证据只是纹样或断角,提示词不得临时造字。
      
      ## 4. 群体与轴线
      
      把群体按作用分区,不要给每个人单建一条轴线:
      
      - 主行动者与对手的主要轴线。
      - 见证者或队列所在区域。
      - 入口、出口与权力位置。
      - 群体注意方向的变化。
      
      公开揭示或家庭冲突中,再按行动能力区分知情者、能作决定的人、利益相关者和普通
      旁观者。只有新事实改变某组人能做什么时,才给这组人新的动作:作证、拦住、让路、
      退出或保持沉默。不要让所有人轮流说同一种惊叹,也不要把每个背景人物都建成独立资产。
      
      **`SHT-13 · reviewed_invariant`**:多人对峙先画一张**有方向的关系投影**:谁此刻保护/协作/施压/观察谁,这条关系由哪个
      剧本动作或信息状态支持,再把它翻成站位、身体朝向、视线、距离和行动线。关系投影只对
      本镜有效;同一人物可在剧情转向后换边,但一帧内不能既与甲背靠背结盟、又从对立行动线
      朝甲施压,除非镜头明确表现转向发生的边界。逐句核对人物、手持物和阵营,避免自然语言
      提示词因重复段落把同一角色写进两个互斥位置。
      
      跨越轴线时,可以让人物在同一镜内穿过轴线,用中性过渡镜头,或用新的全景重新建立
      场景方位;也可以由创作者明确选择主观错乱。群体反应不需齐刷刷给特写;选择一个会
      改变风险的代表反应,或用全景展示沉默、退让、围拢和让路。
      
      ## 5. 动态对象与竞赛
      
      **`SHT-14 · reviewed_invariant`**:球、车辆、武器、证物、接力物或争夺中的工具会把控制权和空间关系带过切镜。设计前先写
      当前时间/回合、区域、控制者、运动方向、目标路线与可用反制;若某项不适用就省略,不为
      填表虚构数据。
      
      一次关键行动至少要让观众理解:动作从什么可见状态开始、控制权怎样被争夺或转移、方向
      或战术为何改变、结束时对象与人物分别在哪里。镜头数量由力学和注意变化决定;慢动作、
      特写、群众反应或解说不能替代丢失的空间阶段。
      
      - 切镜后动态对象应从上一镜结束位置和方向继续,除非明确发生跳时或省略;
      - 比分、计时和阵形只有在改变选择时进入画面;若精确文字不可可靠呈现,标记为后期图形
        或声音信息,不让提示词临时造字;
      - 对手的战术调整应能追溯到此前看见的信息,避免每轮都重置成同一站位;
      - 反应镜只在接收者的新判断会改变下一动作时承担职责,不能用欢呼遮住控制权转移。
      
      ## 6. 观察视角与边界
      
      **`SHT-09 · reviewed_invariant`**: 准确的地点和观察视角,其方向和可见锚点必须与
      摄影机实际所在一侧一致。
      
      地点记录整体地理;观察视角记录一个可复用的方向、时段和光线状态。镜头在门外朝内时,
      不能只因为“仍是同一城门”,就绑定门内朝外的视角。选择前逐项比较摄影机所在一侧、
      可见锚点、朝向、时段、天气和光线;缺少合适视角时,向资产技能请求新版本。
      
      每次切镜前后比较:
      
      - 位置、面向和目光。
      - 左右手与持有道具。
      - 伤势、造型和可见状态。
      - 轴线与屏幕运动方向。
      - 门、灯、天气和文字状态。
      - 谁知道什么;这一项只引用剧本来源。
      
      镜头结束边界是事实来源;关键帧只投影镜头开始边界;运动规格只实现这段过程。
      不能把本镜动作结果提前写进起始关键帧,也不能在相邻两镜重复完整动作。若 SHOT-02
      的结束状态是“右手刚开始伸出”,SHOT-03 才能完成取物;若 SHOT-02 已完成取物,
      SHOT-03 就必须从“已经持有”继续。
      
      ## 7. 规则分级
      
      - `structural_invariant`:原文落实、产物引用、已接受绑定、起止边界和相邻关系都能解析。
      - `reviewed_invariant`:切镜保留剧本原意,观察视角匹配摄影机一侧,证据有足够可读时间和接收反应。
      - `craft_default`:先建立地理、按注意变化切镜、在单房中用策略和物件制造运动;有理由时可以调整。
      - `taste_option`:镜头数量、景别、手持或固定、留白和反应方式由已接受的视觉方向决定。
      
    • comic-keyframe-lexicon.md 11 KB
      # 漫剧关键帧视觉词表
      
      ## 目录
      
      - [这份词表解决什么问题](#这份词表解决什么问题)
      - [规则分级与语言政策](#规则分级与语言政策)
      - [三层组织方式](#三层组织方式)
      - [项目级共享画风基底](#项目级共享画风基底)
      - [镜头类型一:叙事关键帧](#镜头类型一叙事关键帧)
      - [镜头类型二:对话镜头(中近景)](#镜头类型二对话镜头中近景)
      - [镜头类型三:动作 / 特效帧](#镜头类型三动作--特效帧)
      - [组合方法:选镜头类型,填主体](#组合方法选镜头类型填主体)
      - [完整示例(合成改写)](#完整示例合成改写)
      - [反模式](#反模式)
      
      ## 这份词表解决什么问题
      
      项目视觉方向接受为漫剧 / 二维漫画形态后,写关键帧 `generic_prompt` 的人会撞上同一个
      问题:身份、地理、边界事实都绑好了,但“这一帧怎样投影已接受的画风、当前镜头要防什么
      可读性风险”每次都要重新发明措辞,同一个项目里不同集的提示词各说各话。这份词表把
      项目级画风基底与镜头级可选词分开,让同一项目复用一份视觉语言,又不把镜头差异抹平。
      
      它是 [关键帧手艺](keyframe-craft.md) 的补充,不是替代:主体、边界瞬间、构图、机位、光线、
      连续性与排除项仍先写清,本词表只负责画风投影、光色倾向和当前帧的可读性约束。
      事实先于审美词的优先级不变——词表里的任何词都不能顶替身份、地理、构图与连续性事实。
      
      ## 规则分级与语言政策
      
      - 本词表中的画风与镜头级选词都是 `taste_option`:它们供比较,不是质量门槛。创作者选定方向后,
        把可观察的稳定项写进 `视觉设定.md` 的“项目视觉方向”;在那之前,审查不得只因没用某个词判问题。
      - 不用量、词数或“必须包含某词”设门槛。项目级基底读取一套已接受的方向(可以是创作者明确
        接受的兼容组合);镜头级词只取能解释当前叙事职责或可见风险的部分。
      - 提示词正文语言跟随 `prompt_language`(没有项目配置时默认 `en`)。本词表给中文词根,写入
        prompt 时译为当前任务声明的语言;改了描述语言不等于
        改了画面里的可读文字,可读文字仍由已接受的文字政策决定。
      - 正文里不写镜头 / 记录 ID、规则编号与状态词;这延续 `SHT-21`。下表的“镜头类型”只是
        选词入口,不进提示词正文。
      
      ## 三层组织方式
      
      建议按三层组织;重点是事实先于审美词,不把段落顺序本身当成机械门槛:
      
      1. **场景 / 主体事实**:这一帧拍谁、在哪、处于哪个边界瞬间——来自已接受的资产、镜头边界与
         [keyframe-craft.md](keyframe-craft.md) 的十二项。本词表不提供这一层,也不允许用风格词冒充它。
      2. **视觉语言投影**:项目级共享画风基底 + 当前镜头需要的光色或表现手段。先从已接受的
         “项目视觉方向”读取稳定项,再按镜头目的补局部变化;不能每集回表重新选一套。
      3. **可读性 / 风险约束**:只写当前帧确实可能失去的可见信息,例如目光、持物、特效起点或
         前中后景层级。分辨率、供应商质量标签和固定负面词尾不是这一层。
      
      ## 项目级共享画风基底
      
      画风基底属于项目,不属于某一种镜头。分镜阶段从 `视觉设定.md` 的“项目视觉方向”读取
      “选择 + 可观察写法”;尚未决定时回到 Look Development 比较,不在单镜里代选。叙事、对话和
      动作帧都读取同一组稳定项。
      
      | 选择入口 | 写进可观察视觉方向的投影 |
      |---|---|
      | 赛璐璐动画帧 | 清晰轮廓线、平整色块、层数克制的硬边阴影 |
      | 厚涂漫画 | 可见笔触、以明暗塑造体积、软硬边随材质变化 |
      | 平涂漫画 | 形体概括、有限色阶、弱化表面纹理 |
      | 国风条漫 | 墨线粗细变化、分层设色、留白参与空间组织 |
      | 日系二次元漫剧 | 简化面部体块、干净线稿、赛璐璐式表面分区 |
      | 复古港漫 | 密集轮廓与排线、高反差色块、印刷颗粒 |
      | 水彩叙事 | 纸面纹理、透明色层、轮廓局部晕开 |
      | 极简叙事漫画 | 克制线条、大面积留白、低数量色层 |
      
      表里的名称只是选择入口。若删掉名称后,线条、色层、阴影边缘、材质和空间写法都没有变化,
      这个方向仍只是标签,应先回到 Look Development 把它写成可观察选择。
      
      ## 镜头类型一:叙事关键帧
      
      用于承担剧情转向、揭示或场尾落点的关键画面,是漫剧分镜的默认帧型。
      
      | 分组 | 可选词根 | 何时使用 |
      |---|---|---|
      | 构图表现 | 强透视、前景遮挡、特写叙事、画面重心偏置 | 透视、遮挡或不平衡构图确实承担压力 / 揭示时 |
      | 光影与色调 | 暗调叙事、冷暖分区、轮廓逆光、局部高反差、低饱和色层 | 已接受方向允许该变量,且它帮助观众找到叙事重点时 |
      | 可读性约束 | 视觉重点不被氛围层遮住、身份锚点可辨、前中后景层级分明 | 当前帧有雨雾、群像、暗部或复杂景深等具体风险时 |
      
      “动态张力”不能代替冻结姿态;要写清身体朝向、重心、持物和画面边界。“电影运镜感”不进
      关键帧,摄影机随时间的变化仍由 `$short-drama-video-prompts` 负责。
      
      ## 镜头类型二:对话镜头(中近景)
      
      用于以台词和神态为主的镜头,风险集中在面部、目光、持物与背景竞争。
      
      | 分组 | 可选词根 | 何时使用 |
      |---|---|---|
      | 构图表现 | 人物半身构图、肩后前景、反应特写、对话框留白 | 构图需要交代关系,或交付方案确实要叠加对话框时 |
      | 光影与情绪 | 柔和侧光、单侧硬光、低饱和色层、冷暖肤色分离、背景降对比 | 光线 / 色层能让表情或双方权力差变得可见时 |
      | 可读性约束 | 面部识别点与目光可辨、关键手势 / 持物不被裁掉、背景不争夺注意中心 | 当前镜头的台词落点依赖对应信息时 |
      
      “对话框留白”是交付面上的构图决定,不是每个对话镜头的默认词。角色没有关键手势或持物时,
      也不添加相应排除项。
      
      ## 镜头类型三:动作 / 特效帧
      
      用于打斗、施法、爆发瞬间。下表描述的是**冻结帧里可见的处理**(方向性拖影、粒子、光刃
      凝固在一个边界瞬间),不是动作过程——“先、再、最后”与运镜过程仍属于
      `$short-drama-video-prompts`。
      
      | 分组 | 可选词根 | 何时使用 |
      |---|---|---|
      | 冻结动态表现 | 方向性拖影、张力透视、流光残迹、碎片定格、冲击点形变 | 这些可见痕迹能说明当前边界瞬间,而不是暗示一段过程时 |
      | 特效体系(按题材选一系) | 仙侠剑气 / 水墨飞白;赛博能量束 / 霓虹光刃;魔法辉光 / 冰晶;火焰 / 金属火花 / 电弧 / 沙尘 | 已接受题材与视觉方向允许该体系时;不同体系不为热闹而混用 |
      | 层次与可读性 | 特效起点和受力点可辨、武器与肢体轮廓分离、粒子不遮住身份锚点、前后层特效有明确遮挡关系 | 当前帧确实存在特效遮挡或轮廓粘连风险时 |
      
      不要按“动作帧”类别固定追加“无肢体畸变、运动轨迹清晰”。前者只有在当前构图造成具体
      轮廓风险时才需要改写成可见约束;后者涉及时间变化,应交给视频运动规格。
      
      ## 组合方法:选镜头类型,填主体
      
      1. 先按 [关键帧手艺](keyframe-craft.md) 写清谁、在哪、边界瞬间的姿态/目光/持物、构图、机位、
         光线、连续性和排除项。这一步不碰本词表。
      2. 读取 `视觉设定.md` 的“项目视觉方向”。若基底仍只有名称或尚未决定,就在当前任务完成最小
         Look Development 比较;选定结果直接写回该章节。
      3. 判断这一帧的类型:叙事关键帧 / 对话镜头 / 动作特效帧。类型由镜头目的决定,不由
         “想要好看的特效”决定。
      4. 投影项目级画风的稳定项,再从对应镜头表挑当前需要的局部变化。可读性约束必须能指向
         当前帧的具体风险;写不出风险就不加。
      5. 按“事实 → 视觉语言投影 → 可读性约束”的顺序写入 prompt,并译为项目声明的提示词语言。
      
      ## 完整示例(合成改写)
      
      竖屏 9:16 漫剧,已接受视觉方向为“国风条漫:墨线粗细变化、分层设色、留白参与空间组织;
      暗调场景允许局部冷暖分区”。场:雨夜天台对峙,叙事关键帧。十二项事实按
      `keyframe-craft.md` 写满后,视觉投影与可读性约束这样接在事实之后:
      
      ```text
      竖屏 9:16 中近景。机位在轴线左侧、平视略低约五度,标准焦段。前景是湿透的栏杆扶手
      (占下缘约六分之一);中景两人对峙;背景是虚焦的城市灯斑与雨幕。三分构图,她落右三分线。
      她在栏杆内侧,双手握栏杆,身体朝向正前,视线落在对方胸口,无持物。人物乙站在她正前方
      约两步、略偏画面左,一手插袋,身体朝向她。光自画面右上高杆灯斜下为硬主光,左侧留暗。
      视觉重点在她握栏杆的双手。位置与持物与上一镜相同。
      视觉语言:国风条漫,墨线粗细分明,前中后景分层设色;暗调叙事,右上硬光形成局部冷暖分区。
      可读性约束:雨幕不遮住两人的面部识别点与她握栏杆的双手;栏杆、人物和城市背景层级分明。
      排除:不出现动作过程;不新增道具或可读文字。
      ```
      
      事实段独立承担身份、地理和边界;视觉段改变这些事实如何被看见,可读性段保护雨夜构图里
      最容易丢失的信息。三者职责不同,因此不会用审美标签掩盖一条缺失的事实。
      
      ## 反模式
      
      1. **项目级画风漂移**:同一项目临时从“赛璐璐动画帧”换到“厚涂漫画”,或只记名称、不记
         可观察投影。项目基底应由创作者接受并写入 `视觉设定.md` 的“项目视觉方向”;单镜只使用
         已允许的局部变化。
      2. **特效体系混用**:仙侠剑气与赛博能量束为热闹而同框。题材外的词再好看也不进本项目。
      3. **质量标签冒充事实**:用泛化分辨率、“电影级”或“大师杰作”顶替“谁在谁正前方约两步”。
         分辨率属于制作配置,泛化质量标签不进入通用关键帧提示词。
      4. **万能排除清单**:每帧都带一长串防畸变禁词。约束只防当前帧的真实风险,否则正文被
         补丁稀释(与 [production-shot-grammar.md](production-shot-grammar.md) 的反模式同源)。
      5. **把镜头类型写进正文**:“叙事关键帧、对话镜头”是选词入口,不是画面内容;正文不写
         分类名、编号与状态词(`SHT-21`)。
      6. **词表当质量门槛**:审查时只因“没用词表词汇”打回。词汇被创作者接受进视觉方向之前,
         它只是 `taste_option`。
      
    • coverage-audition.md 2.1 KB
      # 关键场景 Coverage Audition
      
      Coverage audition 在正式 shots 前比较真正不同的导演方案,避免第一种合理做法直接成为唯一答案。
      它不是固定镜头数、固定宫格或自动变体器;创作者选择之后才投影为正式场次视觉计划和 shots。
      
      ## 什么时候比较
      
      只用于人物首次亮相、身份/证据揭示、关系反转、高潮与结尾、复杂多人调度、悬念延迟或视觉母题。
      普通场景不做。判断标准不是“场次重要”四个字,而是不同观看位置、信息时机或表演所有权会不会
      显著改变观众体验。
      
      ## 什么叫真正不同
      
      方案必须至少改变一个导演命题,而不是只换景别名称:
      
      - **表演中心**:延迟证据特写,让停顿、撤步和旁观者让路完成权力变化;
      - **证据中心**:先给局部,再给听者反应,最后允许观众确认;
      - **主观错位**:揭示前后更换观众所站的一侧,让原观察者变成被观察者;
      - **空间中心**:用入口、距离、阻挡和通道归属完成转向;
      - **声音中心**:让画外声、环境撤出或留白先改变权力,再决定何时给画面确认。
      
      这些是比较方向,不是每场必须生成的套餐。若两项方案的观众知情时机、对齐对象、表演空间、
      最强画面和结尾落点都相同,它们仍是同一个方案。
      
      ## 比较方式
      
      在上下文中用真实场景 ID 绑定当前剧本、项目视觉方向、制作形态、地点视图与参与资产;逐方案回答:
      观众何时知道什么、
      站在哪一边、谁拥有表演空间、最强画面与最终落点、可能丢失的空间/反应/信息、与制作形态是否
      相容。保留反对理由,不替创作者选择。
      
      不得固定四宫格、九宫格、二十五宫格或景别比例。宫格只是人工比较界面,不是镜头数量公式。
      展示各方案的取舍后,由创作者选择;只把选中的观看位置、信息时机、空间与声音策略投影进正式
      `分镜.md`。未选择时所有方案都只是上下文候选,不能据此写正式镜头。
      
    • keyframe-craft.md 10 KB
      # Frozen Keyframe Craft
      
      ## 目录
      
      - [Boundary and instant tests](#boundary-and-instant-tests)
      - [Purpose](#purpose)
      - [Ordered recipe](#ordered-recipe)
      - [尾帧:什么时候可以有,以及它换来什么代价](#尾帧什么时候可以有以及它换来什么代价sht-17)
      - [Start-only drafting discipline](#start-only-drafting-discipline)
      - [Static test](#static-test)
      
      ## Boundary and instant tests
      
      - `SHT-05` — Project exactly one accepted shot boundary and bind the exact
        Character/Look, Location/View, and Prop/State variants visible there.
      - `SHT-06` — Describe one freezeable instant. Ordered action, expression arcs,
        camera movement, and transforming weather belong to motion, not the keyframe.
      
      ## Purpose
      
      Describe one frame that can exist at a single instant and accurately anchors the
      accepted shot. A keyframe is not a compressed video prompt.
      
      ## Ordered recipe
      
      1. **Purpose:** what the audience must notice now.
      2. **Focal subject:** exact asset and variant IDs.
      3. **Frame:** shot size, angle, lens intent, aspect-aware composition.
      4. **Geography:** Location/View, fixed anchors, foreground/background zones.
      5. **Boundary projection:** exact start position, body facing, pose, gaze, hands,
         held props. Give body facing for every person — it fixes which screen side they
         hold and which side the next reverse cuts from; `gaze` does not cover it. Add
         head facing only when it differs from body facing.
      6. **Performance instant:** one visible expression/tension state, not an arc.
         Pick a channel the current shot size can actually read — gaze, breath, body
         set, object handling, or a held decision. A wide shot cannot carry an eyelid;
         a close-up cannot carry a full-body retreat. Naming only the emotion ("愤怒")
         leaves the translation to a downstream stage, and the keyframe is the first
         frame — a mistranslation there is wrong from frame one.
      7. **Light and atmosphere:** inherit accepted direction/time/weather; add only
         frame-relevant detail.
      8. **Text policy:** readable/symbolic/blank/postproduction for visible surfaces.
      9. **Exclusions:** contradictions or drift likely for this frame.
      
      Identity and boundary facts remain source references. The keyframe owns focal,
      composition, camera/lens, frame-only staging, and exclusions.
      
      ### 写完之后过一遍可渲染检查
      
      判据是一句话:**接手的人能不能照着画出来,而不用自己发明任何事实**。逐条对照,
      缺哪条补哪条——这些不是可选修辞,缺一条执行端就得替你决定一次:
      
      | 必须写出来 | 缺了会发生什么 |
      |---|---|
      | 每个人在哪、彼此距离多少("站在他正前方约两步") | 人物间距逐镜漂移,观众读成换了场地 |
      | 前景、中景、背景各有什么 | 纵深塌成一层,主体贴在背景上 |
      | 视线该落在哪(本镜的视觉重点) | 画面平均用力,观众不知道该看谁 |
      | 构图方式与景别 | 同一场每镜构图随机,剪不到一起 |
      | 机位在轴线哪一侧、俯仰角度、焦段意图 | 越轴;正反打方向对不上 |
      | 光从哪来、什么质感 | 同场光位逐镜跳变 |
      | 每个在场者的姿态、朝向、手部、持物 | 下一镜接不住,道具凭空易手 |
      | 哪些与上一镜相同 | 执行端把"没写"读成"可以变" |
      
      最后一条最容易漏,也最便宜:与上一镜一致的部分**写一句"与上一镜相同"即可**,
      不必重述细节;真正变了的才展开写。这一句是连续性的承载点。
      
      **写紧**:一件事只说一次,能并进一句就不另起一句。上表十二项写满大约五百字就够;
      写到八百字往上,多出来的通常是连接词、铺垫句和把同一件事换个说法再说一遍——它们不增加
      可渲染的事实,却按字数付费。检查办法:删掉任意一句,如果画面没有变得更不确定,那句就是
      多的。
      
      写出来是这样(合成材料,四百余字覆盖上表全部十二项):
      
      ```text
      竖屏 9:16 中近景。机位在轴线左侧、高约一米二,平视略低约五度,中焦 50mm。前景是长凳
      扶手与半张湿报纸(占下缘约六分之一);中景是两人对峙;背景是虚焦的候车牌。三分构图,
      她落在右三分线上。她:坐长凳右端,双手交叠压膝,身体朝向正前,视线落在对方鞋尖,
      无持物。人物乙:站她正前方约两步、略偏画面左,双手插袋,身体朝向她,头略偏向出口。
      光自画面右上顶棚缝隙斜下为主光(硬),左侧留暗,无补光。视觉重点在她压膝的双手——
      本镜唯一不动的东西。位置与持物与上一镜相同,只有她的视线从水洼移到对方鞋尖。
      排除:无动作过程,无雨丝拖影。
      ```
      
      Every visible entry the finished frame carries is listed in the shot's required
      `视觉依据` field, resolving to a real `视觉设定.md` entry; an `IMG-...` prompt item
      in `图片提示词.md` is a separate axis and does not stand in for it, and
      `视觉设定.md` itself does not define `IMG-...` IDs. Write that list from the
      finished frame, not from the shot's intent — the subject that gets missed is the
      one that entered the composition while the frame was being written. A frozen frame may determine whether the surface
      is legible in this composition; it may not replace the exact wording or policy
      with an untraceable prose instruction. If the policy is still undecided, say so
      instead of inventing a hidden candidate state.
      
      ## 尾帧:什么时候可以有,以及它换来什么代价(`SHT-17`)
      
      默认每镜一张关键帧,冻结的是 start。但首尾帧接续是 AI 视频里最常用的工作流之一:把首帧
      和尾帧一起交给执行端,中间由它补。套件此前没有尾帧的位置,于是这条路要么走不通,要么被
      私自绕过——后者更糟,因为绕过时尾帧往往是**独立画出来的**,它就成了第二个终点权威,与
      镜头 `end_boundary` 各说各话。
      
      **`structural_invariant`**:关键帧记录必须声明 `boundary_role`(`start` 或 `end`),
      `boundary_ref` 指向同一镜头对应的那个边界字段。**尾帧是 `end_boundary` 的投影,
      不是新的终点事实**——它与首帧对 `start_boundary` 的关系完全一样:可以决定这一帧怎么构图、
      用什么景别镜头、光怎么落,不能决定人在哪、手里有什么、看着谁。尾帧与镜头终点不一致时,
      错的是尾帧。
      
      每镜的关键帧数量**不是固定的**。默认一张首帧;只有当交付工作流真的要消费尾帧时才加一张,
      不为凑齐而画。补尾帧不改变 `SHT-10`:首帧仍然只能写 start 事实,尾帧只能写 end 事实,
      两张各自守自己的边界,不互相借用。
      
      **代价要说清楚:交出一对首尾帧,等于把两帧之间的运动交给了执行端插值**。 而运动路径本来
      是运动规格拥有的东西。所以选了首尾帧接续的镜头,其运动规格不是"照旧再写一遍",而是被
      两端夹住了:它仍然要写清中间必须发生什么(动作顺序、对白落点、摄影机行为),但要意识到
      执行端会优先满足两端的画面一致性。因此——
      
      - 中间必须被看到的动作**不要只靠插值兑现**。如果一个动作是本镜存在的理由,两端之间没有
        任何东西保证它会发生,就该拆镜,或者让该动作落在其中一端。
      - 两端差异越大,插值越自由,中间越不可控。首尾帧接续适合"状态改变清楚、路径无所谓"的
        镜头(转身、递交完成、坐下),不适合"路径本身是戏"的镜头。
      - 终点报告仍然对照镜头 `end_boundary`,不对照尾帧。尾帧只是它的投影,不能自证到达。
      
      ## Start-only drafting discipline
      
      **`SHT-10 · reviewed_invariant`**: rendered keyframe prose may contain only facts
      from the boundary that frame declares. 首帧写进任何由运动或终点首次产生的事实是漂移;
      尾帧写进在它之前就已经消耗掉的事实同样是漂移。两帧各守各的边界,不互相借用。
      
      Keyframe 默认是 shot start,不是“本镜最有戏的时刻”。为避免把 end 提前:
      
      1. 先明确本帧冻结 `SHOT-...` 的起点,草拟正文时暂不读终点与运动描述;
      2. 只填 start 已成立的 position/pose/gaze/hands/held props/visible state;
      3. 再与 end 做“新出现事实”差集;差集中的事实不得出现在 keyframe prompt;
      4. 写完 Markdown 后从自然语言反向提取手位、持物、目光和可见状态,与镜头起点再比一次;
         不能只看条目说明而忽略真正交付的正文。
      
      反例:start 是“右手空置、看对方”,end 是“右手握铃绳、看门”。冻结帧写
      “手已握铃绳”即使很好看,也属于 boundary drift。
      
      ## Static test
      
      Ask: could a still photographer capture every described fact at once?
      
      Move these to motion:
      
      - ordered verbs (先、再、随后、最终);
      - expression changing from A to B;
      - camera push/pan/track over time;
      - entering/leaving/turning/reaching sequences;
      - dialogue delivery arc or sound progression;
      - light/weather transforming during the shot.
      
      A single held pose may imply tension, but it must not require several moments.
      
      ## Prompt economy
      
      Do not restate full character or location 设定集s. Bind accepted variants and
      repeat only facts the frame needs to prevent ambiguity: distinguishing anchor,
      current Look, crucial spatial anchor, held prop, light direction, text state.
      
      Generic “cinematic, 8K, masterpiece” language cannot replace subject identity,
      geography, composition, or continuity.
      
      ## Failure examples
      
      - incompatible Looks appear in the same frame;
      - “turns, runs, then looks back” appears in a still;
      - the start hand/prop differs from the shot boundary;
      - background changes location identity or orientation;
      - light direction resets without source change;
      - readable evidence is paired with no-text;
      - prompt lists subjects but not their spatial relationship;
      - framing has no focal hierarchy.
      - structured projection matches start but rendered prompt describes a fact that
        first appears in the shot end or motion.
      
    • lighting-craft.md 2.1 KB
      # 光的设计
      
      ## 分工
      
      - 《视觉设定.md》的地点条目记录光源、窗户位置和已经确定的光线状态。
      - 分镜选择本镜的注意中心,说明哪些脸、动作或证据必须可读。
      - 提示词把这些选择写成可观察的画面结果。
      
      开灯、停电和时段变化会改变场景事实,应来自剧本或已接受的视觉设定。
      
      ## 按镜头职责设计
      
      先确定观众需要看清什么,再选择亮暗关系、光比与背景分离程度。
      需要读表情时保留相关面部细节;需要隐藏身份时可以用剪影或遮暗。
      关注点可以靠亮度、色彩、轮廓或局部对比突出,不要求它总是全画面最亮的部分。
      
      竖屏按实际构图检查人物与背景是否分得开。近景中的脸部光线、多人纵深中的背景光,
      各自承担不同信息;不要仅按画幅省略环境或默认加轮廓光。
      
      ## 把光位与画面效果一起写清
      
      光位术语后补充它在画面上的结果,并明确左右指画面还是人物自身。例如:
      
      > 主光来自画面左侧;画面左侧脸颊较亮,另一侧留柔和阴影,眼睛仍可辨认。
      
      仅写「电影感、氛围感强」不足以决定画面。一次图片模型对照中,描述受光结果比只写
      光位更稳定;这可以作为起步写法,不能据此保证其他模型或视频同样执行。
      暗部深度、边缘亮光和背景亮度按本镜需要写,不把例子中的效果全部照搬。
      
      ## 参考板与跨镜连续性
      
      参考板的均匀照明用于看清形制,不自动成为每个镜头的打光方案。
      绑定角色板时说明保留身份与造型;本镜的光仍依据场景事实。
      
      同一连续场景维持光源关系与既定色调;视角、取景范围和人物位置变化可以带来正常的
      画面亮度差异。不要要求所有镜头的平均 RGB 相同。
      
      生成后对比相邻镜头中可比较的脸、衣物或场景表面。曝光和白平衡的小幅偏差交给剪辑
      校正;光源方向或剧情光态错误回上游修订。测量用于定位差异,是否有问题仍结合画面判断。
      
    • production-shot-grammar.md 20.6 KB
      # 生产分镜工艺:把剧本事实编成可审查镜头
      
      ## 目录
      
      - [贯穿原则](#一贯穿原则)
      - [编号与文本](#二编号与文本分工)
      - [拆分](#三拍镜拆分规则)
      - [动作顺序交接](#四动作顺序交接给视频提示词环节的输入)
      - [镜头叙事](#五镜头叙事学摄影决策问题)
      - [戏型诊断](#六场景戏型诊断问题)
      - [项目选择](#七项目选择craft_default)
      - [单集时长加总](#七之二单集时长加总sht-16)
      - [人物反应](#八人物反应sht-11)
      - [自检](#九自检清单)
      - [范例](#十范例合成决策记录不是镜头配额)
      - [反模式](#十一反模式)
      
      来自对剧本拍、分镜、关键帧与视频提示词如何衔接的定性案例阅读,示例均为合成改写。
      方法论见 [shot-craft.md](shot-craft.md) 与 [keyframe-craft.md](keyframe-craft.md);
      本文档是生产端的转换规则和判断依据。出现的数字只有被创作者纳入项目方案
      (`craft_default`)后才是验收条件,不能只因偏离示例就判为问题。
      
      核心认知:**分镜先说明剪辑覆盖范围和可审查的起止边界,不是下游视频提示词**。
      它说明本镜为何存在、观众能知道什么、从哪一状态到哪一状态;只有已接受的制作方案
      需要时才补充时间段意图,完整动作措辞由视频提示词环节编写。`start_boundary` 可以是
      静止关键帧,也可以是上游明确的运动中瞬间,不能把“静帧输入”误写成故事必须静止。
      
      ## 一、贯穿原则
      
      1. **一镜一职责**(SHT-03 的生产表述):每镜有一个主导叙事职责。台词、反应与动作
         可同镜存在,只要镜头时长、表演负荷和注意顺序可执行;过载时按行动转向、听者后果或
         力学阶段拆镜,不按句号机械切镜。
      2. **先后顺序守住上游内容**:镜头记录必要动作与反应的相对顺序,不代写完整运动提示词。
      3. **几何要可比较**:方向、距离、占比或停留只在能消除当前歧义时量化;数值来自
         已接受的项目方案,不是每镜必填。
      4. **摄影行为有职责**(SHT-04):运镜或有意固定机位都应能说明它如何服务注意力、
         压力、观众立场、揭示或节奏,而不是给每镜强加运镜。
      5. **连续性靠已确认边界比较**:精确绑定 `Location/View`、`start_boundary` 与
         `end_boundary`;下游只
         重复执行必需的局部状态,不逐镜复制整份状态表。
      
      ## 二、编号与文本分工
      
      - 分镜号 = `场序-镜序`。**场序按 clip 内"地点组"顺位**:换地点才递增,同地点的
        多个剧本场共用前缀;剧本自带集号时用三段式 `64-1-1`。
      - 文本分工:保留该镜覆盖的剧本原文;用简短中文纲要说明谁做什么以及镜头职责,供
        人工审阅;另记录镜头目的、起止边界和摄影意图。完整时间过程由视频提示词环节编写。
        **纲要写差 = 人审索引
        失效**——"开场"两个字的纲要是真实事故案例。
      
      ## 三、拍→镜拆分规则
      
      | 剧本形态 | 拆法 |
      |---|---|
      | 一拍 = 一句台词 + 一个动作 | 若同一镜头目的和边界可承担,可保留一镜;不是等式 |
      | 一拍含多次行动转向或听者后果 | 按议程变化、听者反应与可执行语速拆镜;不使用固定句数/字数配额 |
      | 一拍是爆发动作 | 若单镜过载,可按起势/接触/结果等力学阶段拆,切点绑定精确状态 |
      | 一拍是纯反应/沉默 | 只有承担反应或选择后果时独立成镜;停留由后果与节奏决定 |
      
      **建立镜头要自己挣到位置**。新地点确实可能需要让观众定位人物、入口或冲突区域,但
      “先给一个空景全景说明这是哪儿”是长片的开场习惯:它把最前面的时间花在观众还没有理由
      在意的信息上。竖屏快节奏交付里默认反过来——**地理在动作内部到达**:人物已经在做的事、
      已经在说的话带出空间,观众边看边拼。这与剧本环节“在压力活跃处进入、用具体行动证明进入
      状态”的默认是同一条,不要在分镜层把它撤销。
      
      交代空间的镜头在这两种情况下成立:空间事实本身承担本镜因果(距离决定来不来得及、出口
      决定能不能走、谁站在门口决定谁看见了),或后续镜头的方位判断依赖它而动作本身给不出。
      成立时它也不必是空景全景——带着人物和动作的取景通常能一并完成,还省一个镜头。
      
      若前镜已经建立地理,或上游有意让观众先迷失/延迟识别,同样不为凑“第一镜全景”自增。
      任何新增镜头仍须解释它覆盖的内容与信息职责。
      
      **`craft_default`**:以上是快节奏竖屏短剧的默认取向。项目声明了不同的观看契约(长篇、
      剧场感、慢节奏、以空间为主角)时,创作者说明理由即可覆盖,审查者不得单据此阻断交付。
      
      ## 四、动作顺序交接(给视频提示词环节的输入)
      
      需要把复杂动作交给视频提示词环节时,可用中性顺序草图:
      
      ```text
      起点:<已接受镜头边界,可静止也可在运动中>
      唯一动作:<触发> → <主体完成一条可见状态转换;交接时写清接触与落点> → <必要反应>
      终点:<已接受镜头边界;逐项接住位置、双手、持物与可见状态>
      摄影意图:<固定/移动及其叙事职责>
      ```
      
      - 起点逐字段写入已接受的 `start_boundary`,不把静态关键帧的媒介性质改成“人物必须静止”;
      - 结束状态由分镜文件记录,下一镜通过引用比较,不靠复制长篇说明获得权威;
      - 「唯一动作」是本镜的主状态链,不是限制镜内只能发生一个原子动作;它把必要步骤放在同一条
        因果线上,确保上一个可见状态确实走到下一个可见状态。
      - 并行槽只放有来源的变化:他人反应、环境压力或持续运动锚点可以
        帮助画面成立,但没有叙事/物理依据时宁可保持静止,不为满足配额发明微动态;
      - 只有已接受的时长或制作方案要求时才写秒数、角度或位移;它们是当前镜头选择,
        不是跨项目动作质量公式;
      - 台词保持精确引用与相对动作顺序;是否把动作展开成更多阶段取决于时长和表演负荷。
      
      **质量问题**:不改动台词和上游顺序时,这个镜头是否仍明确谁承载信息、谁接收
      后果、画面从什么边界到什么边界,以及为什么此处需要切镜?
      
      ## 五、镜头叙事学(摄影决策问题)
      
      | 需要解决的问题 | 先核对 | 可选实现,不是映射答案 |
      |---|---|---|
      | 信息必须被读到 | 通过脸、手、道具文字、空间关系还是声音呈现?何时允许看见? | 固定机位、跟随信息所在位置、改变焦点或切到接收者 |
      | 观众立场改变 | 观众此前与谁同知、此刻为何要靠近或疏离? | 保持原视点、换主观侧、拉开关系或有意拒绝切换 |
      | 空间压力改变 | 谁控制入口、距离、路径或画外威胁?地理是否已经建立? | 调整机位、跟位移、用遮挡,或保持固定让调度自己改变关系 |
      | 力学动作需要理解 | 起势、接触、结果中哪些必须连续看清,哪些切点已有精确状态? | 单镜保持、按力学阶段拆、补细节镜头或反应镜头 |
      | 情绪后果需要落地 | 哪个可见处理或选择证明人物接收了后果? | 留在同镜、切反应、延迟到后镜;景别由信息或表演所在位置决定 |
      
      一场通常比它真正要演的景别先开宽一档,第二镜就落到本场的**工作景别**,之后多数切镜在
      这一档上下一格来回;宽景和特写是**离开**工作景别的标记事件,用过就回来,场尾略微放开是
      常见收法。例如两人在客厅摊牌:第 1 镜全景交代谁堵着门口,第 2 镜落到中景这个工作景别,
      之后在中景与中近景之间对切,只有她把信封推过茶几时切一次特写,随即回到中近景,场尾退回
      中景收住。一路单调收紧的场次到中段就没有余量了。也可以用细节、背影、负空间或不切镜保持
      压力;顶点后的拉远同样只是选项。选择必须回应本场注意力、观众立场和信息揭示变化,不从
      “情绪顶点=大特写”“结束=上帝视角”套模板。正反打、过肩和轴线策略服从已建立地理
      与有意的视角变化。
      
      ### 切点必须带来可见变化
      
      上一段拒绝的是“按情绪套景别”,不是“景别可以原地不动”。两者的分界线是**这里到底切
      不切**:
      
      - **不切镜**:同一景别持续本身就是选择。压力累积、人物在同一构图里自己改变距离、
        观众被迫一直看着不肯变的画面,都是有效理由。
      - **已经切镜**:新镜必须在景别、机位角度或人物关系里至少改变一项。相邻两镜景别相同、
        角度相同、关系也没变时,观众读到的不是节奏,是跳切或穿帮——这个切点没有存在理由。
        修法只有三种:让收放跟着注意重点走(最常见是沿阶梯相邻一格一收一放:
        中景↔中近景↔近景↔特写),换轴线侧或视点,或者干脆并成一镜、用调度在镜内改变关系。
      - **新空间的第一镜**通常先给得开一些,让观众定位后再逐步收进去;但前镜已经建立地理,
        或上游有意延迟识别时,不为“第一镜要宽”自增镜头(见第三节)。
      
      这条规则只在已经决定切镜时生效。它不是为景别多样性增加切点的理由:切多少镜仍由动作
      边界、听者反应和信息变化解释,收放幅度也要能说出注意力或观众立场变化在哪里。
      
      变焦推拉(Dolly Zoom)、荷兰角或甩镜是点睛手段;只有视角、失衡或注意力突然改变时才使用。
      重复使用会让每个节点失去差异,但不能靠固定次数判断(SHT-07)。
      
      ### 角度、机位高度与焦段:三件常被写成一件的事
      
      景别有受控词表,角度长期只有一句"有动机的角度",结果三种彼此独立的选择被压进同一个词。
      最常见的具体后果:写"俯拍"时其实想要的是"从她背后越过肩膀"(那是**水平角度**,不是高度),
      下游按字面给了一个从上往下的机位,权力关系当场反了。
      
      分三栏写,缺哪栏就说明哪栏无所谓:
      
      | 维度 | 词表 | 它在故事里回答什么 | 记在哪 |
      |---|---|---|---|
      | **水平角度** | `正面 / 斜侧(四分之三)/ 正侧 / 后斜侧 / 背面` | 观众被允许读到多少表情,以及站在谁的一侧 | 镜头 `framing.angle`,同时说明在轴线哪一侧 |
      | **机位高度** | `地平 / 低(仰)/ 平视 / 高(俯)/ 顶视` | 谁在这一刻大于谁;平视是**主动**表示势均力敌,不是"没选" | 镜头 `framing.camera_height` |
      | **焦段意图** | `广(空间关系优先)/ 标准(接近肉眼)/ 长(压缩纵深、隔离主体)` | 人与人的距离该显得更远还是更近 | 关键帧 `camera.lens_intent`(**帧级选择**) |
      
      前两栏是镜头拥有的调度事实,关键帧只投影不覆盖;焦段意图是关键帧自己的选择,因为它
      决定的是这一帧怎么被看,不改变人在哪、谁大于谁。不要在镜头记录里再写一次焦段——
      同一事实两处各写一份,改动时必然只改一处。
      
      三者互不推导,写死一栏不代表另两栏有了答案:
      
      - 仰拍不等于正面。从背后仰拍是"我们跟在他身后而他正在变大",从正面仰拍是"他正压向我们"。
      - 长焦不等于特写。用长焦拍全景会把纵深压平,让两个人看起来贴在一起——这可能正是要的,
        也可能是把"隔着半个房间对峙"拍成了"面对面"。
      - 焦段意图属于 `taste_option`(`SHT-07`):写的是**要什么效果**,不是器材参数。写"长焦"
        是意图,写具体毫米数是把创作者的器材决定替他做了。
      
      竖屏还有一条不能忽略:**高度变化在竖屏里比横屏更贵**。9:16 的画面纵向本来就长,俯仰会
      迅速吃掉人物所在的可用高度,同样的俯角在竖屏里读起来比横屏强得多。要靠角度表达权力时,
      先看水平角度和距离能不能承担,再动高度。
      
      ## 六、场景戏型诊断问题
      
      - **对峙戏**:双方议程和可用筹码是什么?哪次行动真正改变距离、证据、关系或退出
        能力?谁必须接收改变?只有这些转向需要新镜头。
      - **动作戏**:观众必须理解哪条路径、接触和结果?人物选择与代价落在哪里?先保证
        地理和道具归属,再决定连续保持、拆力学阶段或切反应。
      - **情绪戏**:触发是什么,人物如何可见地处理,它促成什么选择或抑制?沉默、对白、
        手部、空间距离和不切镜都可承载,不把情绪名自动匹配到特写或推拉摇移。
      - **悬疑/恐怖**:观众、角色与威胁分别知道什么?`show_now/withhold_now/reveal_trigger`
        分别由什么画面或声音呈现?遮挡既不能提前泄露,也不能藏掉本镜负责交付的证据。
      - **喜剧**:铺垫事实、误读和纠正由谁看到?笑点可能来自节奏时机、构图内并存或切换,
        不要求固定反应次数,也不让反应镜解释已经成立的笑点。
      
      ## 七、项目选择(`craft_default`)
      
      | 决策 | 如何确定 |
      |---|---|
      | 拍如何拆镜 | 由动作边界、听者反应、空间变化与可执行时长共同决定,不追求固定比值。 |
      | 台词如何分配 | 先保证一次可表演语气与口型负载;过长时按行动转向拆分,而非机械按字数。 |
      | 单镜还是镜头组 | 先把每个镜头写成可单独审查的记录,再按目标交付方式编组;编组不改变镜头身份。本环节只负责镜头本身:定清每个镜头的目的、起止边界与已接受时长。编组之后由谁承担时长加总与成员资格,属于交付环节的事,不在这里决定,也不因为将来会编组就提前合并镜头。 |
      | 子镜密度 | 9:16 信息流上的注意力是按切镜买的:`craft_default` 是可看的切镜密度大致每 4–8 秒一次,爽点更紧、情感处理更松在这条带宽内浮动,每次切换仍要有注意、压力或状态理由。项目把生成单元固定在更长的一档时,这条带宽由镜内调度承担——一镜里要有景别或视点的实际变化、或一次完整的动作与反应交接,否则无论单帧多好,观众读到的都是幻灯片。 |
      | 单元时长 | 先从必须保留的动作、反应、对白与最终状态反推这一镜需要多久;放不下就删减、拆镜或请求改边界。项目已经定下可用的生成长度档位时,取容得下它的最短一档;没定就按反推出的秒数写,并把用到的档位报给创作者。超过约五到八秒后,一张冻结关键帧只描述了镜头开头,其余部分实际上没有规格:更长的镜头要么按精确状态边界拆开,要么由创作者明确接受它是一个宽规格镜头。 |
      
      ## 七之二、单集时长加总(`SHT-16`)
      
      创作者在项目里写下的每集目标时长,如果全流程没有一处消费它,它就只是一个愿望。
      真实的失效是这样发生的:分镜逐镜看都合理,每一镜的时长也都是有理由的,直到全集镜头
      装到一起才发现比目标长出三分之一——而此时台词、资产、关键帧提示词都已经按这些镜头
      做完了,唯一还能改的只剩硬删。
      
      `structural_invariant`:覆盖记录带一个 `episode_duration`,它的 `shot_seconds_total`
      必须是本集镜头 `duration_seconds` 的**算术和**,且覆盖里列出的每个镜头要么贡献一个数字,
      要么出现在 `unresolved_durations` 里。这条只管账目对不对:**不允许有镜头无声地不进总和**。
      时长未定的镜头显式挂起是正常状态,静默漏掉不是。
      
      项目声明了 `target_seconds_per_episode` 时,记录还要绑定该字段并写出带符号的
      `delta_seconds` 与一个 `disposition`:
      
      | disposition | 含义 |
      |---|---|
      | `no_target_declared` | 项目没有声明目标,本项只留总和 |
      | `within_creator_tolerance` | 差值在创作者可接受范围内 |
      | `creator_accepted_overrun` | 超出但创作者明确接受,附理由 |
      | `to_revise` | 需要回到内容层处理 |
      
      `craft_default`:差值本身**不阻断交付**。目标时长是计划,不是质量门槛;`to_revise` 的
      处理手段是回到内容层(合并同质节拍、把说明改成传递、拆集),不是按数字均匀删镜头或
      砍台词——后者砍掉的往往正是兑现。
      
      这条与分集阶段的量级估算是两件事,不要互相代替:估算用项目已接受集的比值预测**还没写的
      集**,第一集因为没有基准而跳过;加总用**已经存在的镜头**做测量,第一集同样成立。第一集
      拿不到估算,但拿得到测量。
      
      ## 八、人物反应(SHT-11)
      
      对白覆盖存在不等于后果被接收。关键信息改变听者的权力、关系、知识或选择时,必须
      在本镜或后续独立镜中给出可见处理与最终状态;景别、时长和是否切镜由后果重量、表演动作
      及项目节奏决定。群像可用全场与个体两级反应,但这只是表现选项,不是固定组合。
      
      ## 九、自检清单
      
      只检查与当前镜头有关的项目;不要为通过清单补写无来源动作、镜头或文字。
      
      1. 台词、行动、证据或沉默由什么可见/可听载体承担?有没有把以对白为主的合法镜头
         误判成必须添加动作?
      2. `start_boundary` 与 `end_boundary` 是否逐字段可比较?运动中边界、停顿和静止都与上游内容相容吗?
      3. 摄影选择或有意固定机位能回答它怎样服务注意力、观众立场、信息揭示、
         空间或节奏吗?
      4. 并行微动态有上游或环境依据吗?没有时是否避免为“画面活”而硬加?
      5. 表演状态的变化或保持是否符合触发、人物策略与局部结果?克制不变是否正是选择?
      6. 当前理解若依赖新地理,是否交代了必要锚点?若有意延迟识别,是否记录观众可见边界?
      7. 台词与反应在所选时长/语速内可执行吗?对照剧本是否遗漏或改变相对动作顺序?
      8. 镜头数量能由覆盖内容、动作边界、听者反应和时长解释吗?本集时长总和是否已加出来,
         时长未定的镜头是否显式挂起而不是漏掉?
      9. 每镜人物站位与上一镜衔接吗?(位置漂移是高频退回原因)
      10. 手部与持物能否逐步执行?放下、换手、双手动作、递交/取回是否有可见来源?
      11. 上游 `exact_readable` 字段是否有显示位置和处理方式,且没有被通用 `no_text` 覆盖?
      
      ## 十、范例(合成决策记录,不是镜头配额)
      
      ### 对峙中的证据递交
      
      - **上游必保内容**:甲把账页交给乙;乙读到签名后停止否认。
      - **为何切镜**:递交改变道具归属,乙的停顿证明信息被接收;两项可同镜完成时不拆。
      - **观众可见边界**:账页内容只需让乙可读,观众通过签名位置和乙的选择理解,不自动补
        大特写或屏显全文。
      - **摄影意图**:若同一构图能同时保住交接与反应则固定机位;若手部遮掉签名位置,
        才调整焦点或切细节镜头。选择由信息所在位置决定,不由“对峙戏”标签决定。
      
      ### 动作是否拆镜
      
      - **上游必保内容**:角色越过桌面夺走钥匙并退到门边。
      - **单镜成立条件**:路径、接触、道具归属和退路都可读,且已接受时长足以完成。
      - **拆镜条件**:遮挡使钥匙易手不可读,或退到门边需要新的空间边界;切点绑定钥匙
        在谁手中、双方位置和朝向,而不是套“起势—发力—结果”固定链。
      
      ### 保持不动也可能是有效表演
      
      - **上游必保内容**:威胁落下后,角色拒绝给对方可利用的反应。
      - **实现判断**:稳定目光、没有退让和有意固定机位可以是人物策略;不为通过
        “情绪必须变化”检查添加吞咽、握拳或无来源微动作。
      
      ## 十一、反模式
      
      1. 旁白切片当分镜(小说旁白按逗号切进纲要,视频提示词全是“嘴唇开合说话”空壳)。
      2. 资产管理名泄漏进画面文本(占位文件名出现在视频提示词正文里)。
      3. 图片引用标记侵入台词内文。
      4. 无动机运镜("镜头静止。"单独出现不解释)。
      5. 台词标点+句式句号叠加("血口喷人!。"——收尾句号只加在无标点台词后)。
      6. 负面约束堆砌淹没内容(防缺陷补丁头尾重复 150+ 字,正文动作被稀释——补丁
         只在目标模型确有该缺陷时使用)。
      
    • review-and-fixtures.md 3.2 KB
      # 分镜完成前检查
      
      这份清单只检查 `分镜.md` 能直接证明的结构事实,把审查注意力留给真正需要读懂戏的判断。
      检查发现问题就修正文档,不另建 coverage、QA 或校验结果文件。
      
      ## 可直接核对
      
      - 每个 `SHOT-...` ID 唯一,来源场景 ID 能在 `剧本.md` 找到;
      - 每个制作相关的动作、对白、VO/OS、SFX、画面文字与连续性要求都由镜头承载,或有明确创作理由;
      - 同一来源动作只有一个主要落实镜头,其他镜头只增加反应、细节或重新解释;
      - 每镜时长是正数,全剧时长等于可见镜头时长之和;目标时长只用于报告差值,不是质量门槛;
      - 起点和终点都写绝对事实,不使用“同上”“保持不变”代替;
      - 相邻镜头的站位、朝向、视线、持物、伤势、光态与可读文字连续;
      - 每镜都有「视觉依据」字段,条目名称能在《视觉设定.md》找到同名标题;关键帧正文点名的人物、地点、道具全部在列,「控制」不超过这一格画面承担得起的范围;
      - 已用的 `IMG-...` 必须是《图片提示词.md》的可见标题,并带中文名称和控制范围;没有合适条目时明写「无」,不伪造 ID;
      - 提示词条目与真实输入图片分开记录;只有创作者已提供或经确认生产、且当前可读的实际图片才能声称为「输入参考图」,并用独立稳定的 `REF-...` 槽位、显式顺序、仅用 `/` 分隔的项目相对路径、中文名称、用途、控制与不得控制范围定位;每个槽位的 `用途` 取自封闭词表,一镜最多一张起始帧与一张结束帧;图片由创作者在项目外准备、生成时自行挂载时,改用 `PLAN-...` 槽位,定位符写它对应的 `IMG-...` 或 `SHOT-...` 条目,其余字段与 `REF-...` 相同——它同样不声称项目里有文件;
        一张都没有、只准备了一部分、已有真实文件、由创作者自备和创作者明确选择文生视频必须如实区分;
      - 冻结关键帧只投影所声明的边界:默认起点;外部工作流确实需要时才增加终点帧;
      - 可复制关键帧正文不含 ID、文件路径、工作流状态或工艺说明。
      
      结构检查失败时点名文件、场景或镜头 ID、冲突事实和负责 owner,不回显整段私有文本。
      
      ## 需要语义审查
      
      下列问题不能靠字段数量或脚本替代:
      
      - 镜头是否守住来源原意和观众知情时机;
      - 重复覆盖是否真的增加新的反应、证据或理解;
      - 景别、机位、运动和切点是否由注意、压力、关系、空间或节奏变化驱动;
      - 多人调度是否让主要关系和注意交接可读;
      - 证据文字、决定性手部动作、脸与眼神是否会被遮挡;
      - 关键帧是否既可冻结,又保留镜头下一步需要发生的动作空间;
      - 画面里没有被名字点到、但仍需要认出身份的小比例或背景人物,是否也进了「视觉依据」——机械核对只抓被点名的那一类。
      
      关键帧数量、镜头数量、景别比例和目标时长差值都不是通用质量门槛。审查 finding 必须给出位置、
      证据、影响和必须达到的修订结果,不能只说“镜头不够电影感”或“看起来像 AI”。
      
    • scene-visual-plan.md 1.8 KB
      # 场次视觉戏剧计划
      
      场次视觉计划是正式分镜前的可选思考层,回答整场怎样改变观众位置、空间压力、观看节奏和声音注意。
      它不是第二份剧本,也不拥有镜头边界。
      
      ## 什么时候使用
      
      只在导演选择明显影响体验时使用:主要人物亮相、身份/证据揭示、关系反转、高潮或结尾、复杂多人
      调度、延迟信息的悬念场、承担视觉母题的场景。普通场景直接写镜头,不增加表单。
      
      ## 在上下文比较什么
      
      使用场景标题 ID,例如 `EP001-SC003`,并结合当前视觉设定比较:
      
      1. **戏剧转向**:哪一种公开关系、信息或选择不可逆地改变;
      2. **观众立场**:开场站在哪一方,结束被移到哪里;
      3. **空间压力**:入口、距离、占位、遮挡、让路或退路怎样承载人物策略;
      4. **视觉推进**:什么时候让信息、反应或空间先于特写落地;
      5. **摄影节奏**:固定、移动、切近和停留分别由什么变化触发;
      6. **反应落点**:结尾画面由谁的选择或空间后果承担;
      7. **声音策略**:主导声源、主动留白、距离变化、撤出/恢复和 sound bridge。
      
      只有真正不同的方案才比较;不固定方案数、宫格、景别或镜头数。选定后直接把结果投影进
      `分镜.md` 的镜头职责、景别/机位、起止边界与声音,不另存 audition、plan 或接受记录。
      
      ## 检查
      
      不按字段完整度打分。检查观众立场、知情时机、空间压力或节奏是否发生有意变化;有意保持同一观看
      位置也可以成立,但必须说明它怎样制造观察、困住感或重新解释同构画面。再检查空间、摄影和声音是否
      共同承担同一转向,而不是重复“中近景—反打—推镜”。
      
    • screenplay-to-keyframe-example.md 7.7 KB
      # 合成正例:从剧本段到冻结关键帧
      
      ## 目录
      
      - [剧本段](#剧本段)
      - [原文落实](#原文落实)
      - [SHOT-01](#shot-01拒绝交牌)
      - [SHOT-02](#shot-02声音引导注意)
      - [SHOT-03](#shot-03证据细节)
      - [KEY-03](#key-03冻结瞬间)
      - [连续性交接](#连续性交接)
      - [(情绪)交接](#情绪交接)
      
      本例只演示剧本内容怎样逐步变成镜头、关键帧和准确产物引用,不是镜头数量答案。
      素材为全新合成。
      
      ## 剧本段
      
      ~~~md
      ## EP004-SC002 内 · 社区洗衣房 · 清晨
      
      烘干机还在转。沈禾把一张写着“17”的取衣牌压在掌心,没有递给柜台后的蒋姨。
      
      蒋姨:你不是来取衣服的。
      
      [SFX] 烘干机停转。
      
      沈禾摊开右手。取衣牌背面粘着一小片带血的蓝线头。
      ~~~
      
      假设索引已经建立 H01(场景标题)、A01(持牌动作)、D01(判断)、P01(停机声)和
      A02(摊手与蓝线头)。
      
      ## 原文落实
      
      | 剧本段 | 落实方式 | 理由 |
      |---|---|---|
      | H01 | 由 SHOT-01 落实 | 建立门、柜台、烘干机和双方位置 |
      | A01 + D01 | 由 SHOT-01 落实 | 持牌不递与台词属于同一次策略对撞 |
      | P01 | 由 SHOT-02 落实 | 声音改变注意并制造停顿 |
      | A02 | 由 SHOT-02 和 SHOT-03 落实 | 前一镜交代谁摊手并守住手位,后一镜让观众读清证据;重复带来新信息 |
      
      这里没有“一段一镜”的公式。A02 在两镜中出现,是因为第一次说明谁摊手,第二次才让
      观众看清蓝线头。
      
      ## SHOT-01:拒绝交牌
      
      **镜头目的**: 建立蒋姨控制柜台、沈禾控制证物的关系,让台词成为判断,而不是说明。
      
      - 开始:沈禾在柜台外,右拳握牌;蒋姨在柜台内;烘干机转动。
      - 动作:沈禾把拳压在台沿但不越过边界,蒋姨不伸手。
      - 结束:手位不变,蒋姨已经公开表示“你另有目的”。
      - 构图:纵向双人中景,柜台横线分隔双方。
      - 摄影机:固定机位,让“不递”这个选择持续存在。
      
      来源:`剧本.md / EP004-SC002` 中“沈禾把拳压在台沿但不递牌”的动作和对应对白。正式镜头在
      `分镜.md` 直接写这个场景 ID 与必要短引文。
      
      若为了增加动作而让沈禾把取衣牌交出去,就改写了故事,必须拒绝这种改动。
      
      ## SHOT-02:声音引导注意
      
      **镜头目的**: 停机声让原本被机器掩盖的沉默显出来,并把注意从两人脸上引到沈禾右手。
      
      - 开始状态准确继承 SHOT-01 的结束状态。
      - 音效准确引用 P01。
      - 沈禾听见停机后先不看机器,而是决定摊开右手。
      - 结束:右掌摊开,牌正面仍贴掌,背面朝向蒋姨和观众;蓝线头存在,但本镜不要求读清。
      - 摄影机:从固定双人镜头有目的地下移,结束构图仍保留蒋姨的视线。
      
      这次摄影机移动不是为了“节奏感”,而是因为观众注意从人物判断转到证据。
      
      ## SHOT-03:证据细节
      
      **镜头目的**: 让观众确认牌背粘着带血的蓝线头,同时保留蒋姨已经看见的关系。
      
      - 开始状态准确继承 SHOT-02 的结束状态。
      - 细节镜头只拍右掌、牌背和蓝线头;后景保留蒋姨眼睛和视线的模糊位置。
      - 文字政策:数字“17”在牌正面,本镜展示背面,因此不得临时翻牌制造可读文字。
      - 结束状态与开始状态相同;观众获得了新信息,但物理状态没有变化。
      - 固定机位细节镜头;停留到观众能识别颜色、血迹和附着关系。
      
      “信息变化而物理状态不变”仍然可以成为有效的镜头目的。
      
      ## KEY-03:冻结瞬间
      
      ~~~text
      社区洗衣房柜台外,沈禾摊开的右掌位于画面焦点;取衣牌背面朝上,
      一小片带暗红血迹的蓝线头粘在牌背边缘。右手与牌完全静止,柜台横线
      仍分隔双方,后景保留蒋姨已经落在证物上的目光。清晨冷白顶光,已经
      停转的烘干机只是空间锚点。画面不展示牌正面的“17”,不加入递交、
      翻牌、推镜或表情变化过程。
      ~~~
      
      冻结检查:所有事实能在同一时刻存在。摊开右手是 SHOT-02 的动作过程;KEY-03 只写
      “已经摊开”。
      
      ## KEY-03 之后:从成稿画面回填三条依据
      
      关键帧写完才知道这一格里到底有谁、有什么。按成稿正文逐项读出来,再写进 SHOT-03 的字段:
      
      ```markdown
      - 图片提示词项:IMG-SHENHE-SHEET《沈禾角色板》(控制:身份、手部特征);IMG-CLAIM-TAG《取衣牌道具板》(控制:牌形、编号面、边缘磨损)
      - 输入参考图:无(待补参考图:沈禾手部、取衣牌背面、本镜起始帧)
      - 视觉依据:《视觉设定.md》·人物「沈禾」(控制:身份、右手特征);人物「蒋姨」(控制:身份、视线方向);道具「取衣牌」(控制:牌形、背面材质、蓝线头位置);地点「社区洗衣房」(控制:柜台横线、冷白顶光)。
      ```
      
      三处值得注意:
      
      - 蒋姨只在后景留一双模糊的眼睛,但观众要认出是她,所以她在列;
      - 牌正面的“17”本镜看不到,所以「控制」写的是「背面材质」而不是「编号面」;
      - 沈禾整身造型不在这一格里,所以不写「本集造型」——上一镜绑过不等于这一镜也该绑。
      
      ## 连续性交接
      
      | 字段 | SHOT-01 结束 | SHOT-02 开始与结束 | SHOT-03 开始 |
      |---|---|---|---|
      | 沈禾右手 | 握牌 | 握牌 → 摊掌 | 摊掌 |
      | 牌朝向 | 背面不可见 | 背面转向蒋姨 | 背面可见 |
      | 蒋姨目光 | 沈禾脸 | 右手 | 牌背 |
      | 烘干机 | 转 | 停 | 停 |
      | 观众知道什么 | 她拒绝交牌 | 她决定展示 | 看见带血蓝线头 |
      
      运动规格只能实现表中的箭头,不能让蒋姨夺牌、让牌正面突然朝向摄影机,或让机器重新启动。
      
      ## (情绪)交接
      
      剧本对白的表演括注是**写一次、投影两处**的来源:本技能把它投影成镜头里可见的表演
      状态,视频提示词把它投影成 `delivery`。两处绑定同一条剧本记录,谁都不另起情绪名。
      下面是另一段全新合成的示例。
      
      ~~~md
      ## EP002-SC004 内 · 老楼梯间 · 夜
      
      角色甲:(压着嗓子)你现在放回去,我就当没看见。
      
      角色乙:(笑)你确定还找得回来?
      ~~~
      
      | 位置 | 负责人 | 写什么 | 不能写什么 |
      |---|---|---|---|
      | 剧本括注 `(压着嗓子)` | 编剧 | 唯一情绪来源,随该对白块一起发布 | — |
      | 本镜表演状态 | 本技能 | 边界里可见的收敛:下颌收紧、身体不前倾、音量压在楼道回声之下 | 不把“压着嗓子”升级成“低吼”或“怒斥” |
      | 下游 `delivery` | 视频提示词 | 由同一条来源取到的语气,再落成呼吸、停顿和音量走向 | 不新起情绪词,也不与本镜表演状态相反 |
      
      两处引用同一条来源:`剧本.md / EP002-SC004 / 角色甲对白“你现在放回去……”`。分镜与视频提示词
      都保留这个场景 ID 与准确台词,不另造情绪来源。
      
      - 角色乙的 `(笑)` 同样同源投影:本镜写“嘴角上扬、眼睛不动、重心后靠”,下游写
        “句尾带气声的短笑”,两处解释的是同一条括注,不是两次独立发明。
      - 若下游认为该情绪不成立——例如楼梯间的回声让“压着嗓子”传不出信息——修订回到剧本:
        编剧改括注后,本技能与视频提示词再各自重新投影一次。**不能**在镜头写
        “克制”、在运动规格写“爆发”,让同一句台词挂着两个互相冲突的情绪来源。
      - 情绪来源只决定表演状态,不自动决定景别或运镜。`(压着嗓子)` 不等于必须切特写;
        本镜给什么景别,仍由观众此刻必须读到的信息决定。
      
    • shot-craft.md 16.2 KB
      # 镜头设计方法
      
      ## 规则分级
      
      - `structural_invariant`:可以直接检查的结构要求,例如每段原文都有处理结果、引用可以解析;格式错误可以阻断。
      - `reviewed_invariant`:必须守住、但要结合证据判断的内容要求,例如镜头没有改写剧本动作。
      - `craft_default`:通常有效的起步方法;有明确的戏剧理由时可以调整。
      - `taste_option`:创作者或视觉方向已经接受的风格选择,不能仅因不同于示例就判错。
      
      ## 目录
      
      1. [先落实原文,再追求画面](#先落实原文再追求画面)
      2. [镜头目的](#镜头目的)
      3. [场面调度与地理](#场面调度与地理)
      4. [景别与摄影机动机](#景别与摄影机动机)
      5. [时长与切镜](#时长与切镜)
      6. [视觉方向选择](#视觉方向选择-sht-07)
      7. [相连边界](#相连边界-con-01)
      8. [首尾成对](#首尾成对)
      9. [一镜到底还是切开](#一镜到底还是切开)
      10. [常见问题](#常见问题)
      11. [审查问题](#审查问题)
      
      ## 先落实原文,再追求画面
      
      `SHT-01` 要求每个与制作有关的剧本段都有明确处理:由镜头落实、有理由地省略,或因
      新的观众体验而有意重复。
      
      先把剧本段与镜头对应起来,再设计镜头。原文落实表是责任清单,不是“一段一镜”公式。
      连续表演和空间关系能守住原意时,几段原文可以合在一镜;揭示、反应、证据细节或空间
      变化很重要时,一段原文也可以由几镜承担。
      
      原文落实直接写在 `分镜.md`:每个制作相关的动作、对白、声音和画面文字都指向承担它的
      `SHOT-...`;镜头条目反向写场景 ID 与必要短引文。来源改变后逐场复核对应镜头,不依赖隐藏引用。
      
      重复拍同一段内容,只有在第二次带来新的观众体验时才成立,例如新增反应、矛盾、
      此前保留的证据或对前镜的新理解;不能因为第一次对应关系被忘了就重复。
      
      ### 动作落实表
      
      **`SHT-08 · reviewed_invariant`**: 每个有权威来源的动作只有一个主要落实镜头;其他
      镜头若再次涉及该动作,必须增加反应、细节或对原信息的新理解。
      
      原文落实数量只能证明“某段原文被引用”,不能证明动作没有提前、重复或遗漏。对转手、
      开门、受伤、揭面、拿出证物等动作,另记:
      
      | 来源动作 | 主要落实镜头 | 动作前事实 | 动作后事实 | 其他镜头的作用 |
      |---|---|---|---|---|
      | 右手从衣内取令牌 | SHOT-02 | 令牌在衣内 | 右手持令牌 | SHOT-03 只展示纹样和接收者反应 |
      
      - 只有主要落实镜头可以把状态从动作前改成动作后。
      - 前一镜的结束状态不得在没有来源动作时提前变成动作后状态。
      - 后一镜已经从动作后状态开始时,运动规格不得再完整表演一次该动作。
      - 其他镜头要写清自己负责细节、反应还是重新解释,不能成为第二个主要落实镜头。
      
      ## 镜头目的
      
      `SHT-02` 要求镜头守住来源原意,并说明观众或人物在本镜结束时发生了什么变化。
      只有漂亮画面、没有戏剧职责的镜头,不能通过内容审查。
      
      按以下顺序设计:
      
      1. 找出来源中的关键动作:行动、台词、发现、拒绝、转手或揭示。
      2. 说明观众或人物因此发生什么变化。
      3. 决定本镜主要从谁的体验组织画面。
      4. 确定开始和结束时必须看见什么。
      5. 最后才选择景别、摄影机行为和切镜点。
      
      镜头可以负责建立空间、让观众站到某个人物一边、展示证据、暂缓信息、转移权力、
      表现反应,或让后果无法回避。“中景展示人物说话”不算足够的镜头目的。
      
      ### 承受者的反应
      
      **`SHT-27 · reviewed_invariant`**:打击、否定或翻盘需要观众理解其后果时,
      分镜应安排可读的承受者反应或其他后果载体。
      可以用独立反应镜、双人镜内的调度、清楚的群像反应、声音或物件状态承载。
      现有画面已经清楚就不必补镜;需要保留反应或承受者不在场时,注明叙事安排。
      审查按后果能否被理解判断,不按人数、景别或反应镜数量判断。
      
      ### 观众可见性 (`SHT-12`)
      
      构图同时决定观众此刻能知道什么。每个事实单独写一条观众可见性判断:绑定准确场景 ID,说明
      现在展示还是暂时扣住,写明可见或可听的载体,并分别写出
      揭示条件、保护方法和理由。同一镜中的不同事实可以在不同时间放出。
      
      背影、只露腿部的入场、门缝、前景遮挡、延迟对焦,以及**逆光成剪影或压暗到只剩轮廓**,
      都可以用于保护剧本保留的揭示。用光保护时不改光源身份与方向,只声明人物落在明区还是
      暗区,并把该选择写进本镜排除项防止后续漂移——否则人物正面入场却要求不给身份时,
      本环节没有合法写法,执行端会直接给一张清晰正脸。
      如果它们提前暴露身份或证据,就是失败。反过来,本镜若负责展示证据或听者的关键反应,
      就不能为了画面好看而遮住其载体。神秘感不是默认加分项;审查者同时核对剧本允许展示
      什么,以及本镜必须交代什么。
      
      ## 场面调度与地理
      
      把场景写成可以行动的空间:
      
      - 入口、出口和固定锚点。
      - 前景、中景、后景区域。
      - 谁能看见或接近什么。
      - 人物面向与视线关系。
      - 轴线工作侧和屏幕运动方向。
      - 双手、道具归属和转手路径。
      
      场面调度表现人物策略。靠近可以施压或寻求结盟;转身可以拒绝;占住出口可以控制;
      碰触证据可以暴露知情。不要只为增加运动而让人物无理由走动。
      
      跨轴本身不是错误;没有动机又让空间难以理解才是问题。跨轴时可以使用中性过渡镜头、
      人物在同一镜内穿轴、有意制造主观错乱,或重新建立空间。
      
      画面文字必须服从 `视觉设定.md` 中已经写明的文字政策。镜头和关键帧直接引用对应标题或
      `IMG-...`,可以决定文字是否可见、大小、角度和对焦,但不能静默编造文案,也不能改写
      “逐字可读”“只保留图形”或“无可读文字”等当前决定。政策仍未决定时要明确标为未决,不能
      假装已经可以交付。
      
      ## 景别与摄影机动机
      
      `SHT-04` 要求景别或摄影机行为因注意、压力、观众立场、揭示或节奏变化而改变,
      不能为了装饰每一拍而变化。
      
      - **远景**:交代地理、人物与环境的力量对比,或让人物被空间吞掉;信息靠轮廓、方位和
        距离读出,不要指望这一档承担表情。
      - **全景**:建立空间、孤立感、群体权力或同时发生的后果。
      - **中景**:表现互动、手势、双人策略和清楚的调度。
      - **中近景**:对话距离的双人,或仍然保留手部的单人:比中景少一层环境,比近景多留出
        调度余地;一场停在工作景别时的对切多数落在这一档。
      - **近景**:表现决定、证据、被藏住的反应或无法撤回的认识。
      - **特写**:把一张脸、一只手或一处伤单独提出来,让某个反应、破绽或细微动作成为本镜
        唯一事实;代价是暂时切掉空间参照,用它就要接受观众此刻读不到周围发生了什么。
      - **大特写**:只留眼睛、嘴、指节或物件上的一处细节;用于观众必须看清、而近景会淹没的
        单一证据或生理反应。它切掉的信息最多,因此要有明确的“非看不可”理由。
      - **细节镜头**:展示已经建立意义的关键物件或动作。
      
      近景和特写是中文分镜里最常用的两档,不是留给高潮的稀缺资源;同样,大特写也不是被
      禁止的景别。要防的是**把景别当情绪音量旋钮**:情绪词一升级就换更紧的景别,本镜到底
      要观众读到什么反而没人回答。判断标准始终是必须被读到的信息存在于哪个尺度——证据在
      指节上就给大特写,后果在两人距离上就不该切近。
      
      ### 竖屏把可用的景别收窄了
      
      上面那张表是横平竖直的电影景别谱系。**竖屏不是把它裁窄,是让其中两档变得难用**。
      
      机制很简单:远景和全景靠的是**横向铺开的信息**——谁站在谁旁边、队伍有多长、这个人
      离出口有多远。9:16 的横向只有 16:9 的三分之一强,同样的信息铺进去要么挤成一条,
      要么小到读不出。所以竖屏短剧的实际用法以中景、近景、特写为主,远景与全景出现得少。
      
      但这**不是禁令,是换一种排法**:竖屏的空间要靠**纵深**而不是横向来交代——前景、中景、
      后景分层,用门框、桌沿、台阶、人的前后遮挡把关系立起来(见
      [竖屏多人调度](blocking-playbooks.md#1-竖屏多人调度))。做到了这一点,竖屏一样能给全景;
      做不到就不要用这一档去承担交代空间的职责,改用一个更近的镜头单独给一次。
      
      **竖屏的横向视野窄,观众的视线本来就被逼到画面中央**,这既是小景别好用的原因,
      也是大景别失效的原因——同一件事的两面。
      
      只有注意或关系发生变化时才改变景别和角度。场面声音变大,不等于镜头必须从全景一路
      推到大特写。反过来,一旦决定切镜,新镜就要在景别、角度或人物关系里至少改变一项;
      相邻两镜完全同景别、同角度、同关系时,这次切镜没有职责,应当改变收放或并回一镜。
      
      一个时间段内选择一套连贯的摄影机行为:
      
      - 固定机位用于观察、紧张、仪式或受困。
      - 摇镜或俯仰用于揭示信息或转移注意。
      - 前移或后移用于改变亲近、压力或人物认识。
      - 跟拍用于人物移动本身带有策略意义的场面。
      - 环绕用于关系或空间在原地被重新评估:绕过去才看得到的另一面、被围住的处境、
        两人相对位置在不移动的情况下改变意义;没有这层理由时它只是装饰性运动。
      - 升降(摇臂)用于高度本身携带意义:升起交出全局或结局,落下把观众接进人物所在的
        高度;不为“开场要大气”而升降。
      - 手持用于创作者已经接受的身体不稳或现场感。
      
      没有明确切换时,不要在同一时间段同时要求固定和移动。
      
      ## 时长与切镜
      
      ### 短镜头的动作量 (`SHT-03`)
      
      作为 `craft_default`,可以先从一个主要动作开始,再保留让观众读懂后果所需的反应。
      只有连续表演、空间、时长和镜头目的都仍然清楚时才合并更多内容。这是起步方法,
      不是固定动作数量检查。
      
      时长要给动作、表演、对白和反应留出可读空间。明确的对白长度和计时动作可以作为证据;
      一般动作量是否过多,应由审查者结合本镜判断,不能用通用的每秒字数阻断。
      
      在变化处切镜:新信息、新目光、新决定、新空间关系、新威胁、新证据或节奏变化。
      不要只为换角度而重复同一个动作。给反应留下足够时间,让观众读懂后果。
      
      ### 对白估时
      
      **`craft_default`**:先排完整声音事件,再确定镜长。不要先选一个短镜长,再要求人物加快语速把台词塞进去。
      
      - 有同一句、同语言、同表演方式的录音或配音时,使用实际发声区间;文件首尾静默与句内停顿分开核对,
        不把整个文件长度当作纯说话时间。没有音频时按原文、人物语速、情绪和换气估一个区间,明确标为
        「文本估计」,不要声称已实测。项目已校准的语速可以辅助估算;数字、缩写按实际读法考虑,不能只数可见字符。
      - 顺序说话的多句或多人对白累加,句间停顿与听者反应按实际顺序加入。边说边抬眼等确能并行的动作
        与对白共用时间;必须等话说完才发生的动作另占时间。不要重复计算已包含在录音或估计里的停顿。
      - 在本镜「声音」中简写依据,例如「对白按克制语气文本估计 8–10 秒(含句内停顿);开口前 0.5 秒,
        说完后的反应 1 秒;抬眼与对白并行」。此例按估计上沿需要 11.5 秒;若目标是 H3,向上取到 12 秒,
        余下 0.5 秒安排在已有终点停留。数字只是此例的预算,不是统一余量或官方语速。
      - 取容得下时间线的合法时长;超过模型上限时按语义和可见状态闭合点拆镜,并检查整集总时长。
        已接受镜长或集长固定且放不下时,说明冲突并提出延长、拆镜或剧本修订;不擅自删词或把正常对白改成快读。
      
      文本估计是规划依据,实际生成后还需听完整性、语速和尾音。没有录音、配音或生成结果时,不把估计写成
      秒级准确的声音控制承诺;纯动作镜无需补对白预算。
      
      ## 视觉方向选择 (`SHT-07`)
      
      焦段语言、节奏、固定机位、手持或形式化调度属于 `taste_option`,读取创作者已接受的
      视觉方向。审查它是否服务本镜,不强制所有项目使用同一套风格。
      
      ## 相连边界 (`CON-01`)
      
      本镜已接受的结束边界必须与下一镜已接受的开始边界一致;若不一致,负责产物必须声明
      修订或过渡。不得用摄影机描述掩盖边界冲突。
      
      ## 常见问题
      
      - 镜头表只是改写剧本句子,没有说明观众怎样观看或获得什么变化。
      - 用匿名“人物”代替准确的已接受资产。
      - 一镜跨越地点或时间,却没有蒙太奇或省略依据。
      - 保留了对白,却删掉承担后果的反应。
      - 人物或道具在切镜后无原因换位。
      - 每句情绪台词都变成大特写(问题在“情绪升级=景别收紧”的条件反射,不在大特写本身)。
      - 相邻两镜同景别、同角度、同关系,切了等于没切。
      - 摄影机指令冲突,或运动没有注意变化的理由。
      - 套话式布光替代真实空间锚点。
      - 把外部批量制作单位误当成剪辑镜头本身。
      
      ## 审查问题
      
      - 本镜落实、省略或重复了哪些剧本段?
      - 本镜结束时改变了什么?
      - 为什么选择这个景别和摄影机行为?
      - 每个人物能否在当前空间中合理站立、看见和移动?
      - 是否绑定了准确的造型、观察视角、道具状态、双手状态和文字政策?
      - 结束边界是否为下一镜提供清楚的开始状态?
      - 删除或合并本镜,会不会丢失故事意义?
      
      ## 首尾成对
      
      终点的可见状态对本镜重要,且目标模型支持尾帧时,可以补写
      `### 收尾关键帧提示词`。它只描述终点,不混入起点或中间动作。
      若起终状态相同,或变化发生在画外,通常无需另做尾帧。
      
      Seedance 2.5 的一个镜头对照中,绑定尾帧改善了末帧落位;这不是所有模型和动作的保证。
      生成后仍检查中间动作、节奏和终点,不能只比较最后一帧。
      
      当前生产工具从 `分镜.md` 的 `SHOT-...` 只提取「冻结关键帧提示词」,即首帧。
      要在套件内生产尾帧,交图片提示词阶段建立单独的 `IMG-...` 条目,注明来自本 SHOT 的
      终点、保留已确认的视觉依据,再以该条目作为 image job 的来源。不得用首帧选择器提交尾帧正文。
      实际图片生成后按目标方言绑定 `用途:结束帧`。
      
      ## 一镜到底还是切开
      
      先看这场戏需要什么,再考虑生成与修订成本。
      
      - 连续等待、沉默、空间调度或完整表演,可考虑一镜到底。
      - 需要选择不同表演、突出反应或精确安排切点,可考虑分开生成再剪。
      - 长段生成一处失败可能需要重出更多素材;分开生成则需要额外处理跨镜一致性。
      
      一次 Seedance 2.5 对白场次实测出现过长镜头发声时间过早的问题。
      有严格落点时应检查目标模型的时序能力并试听,不能保证分成镜头就能锁定每句发声时间。
      长镜头也可以在剪辑中截取,是否保留完整时长由叙事决定。
      
      多镜容器是在一次生成里安排多个切口;一镜到底则不切镜。
      容器可减少提交次数,但不自动保证跨镜一致性,也仍可在生成后剪辑。
      各镜合并时重新整理参考素材与编号,并核对结果中的实际切点。
      
    • shot-revision-identity.md 1.7 KB
      # 镜头修订与稳定身份
      
      镜头 ID 表示同一个编辑/导演决定的连续身份,不等于列表位置,也不等于外部制作任务编号。
      修订时先判断镜头的叙事职责是否仍是同一个,再决定保留、创建或 retire。
      
      ## 身份规则
      
      - **重排**:只改变顺序,镜头目的与边界职责不变时保留原 ID;刷新依赖顺序与相邻边界。
      - **插入**:新增一个独立注意或戏剧职责时创建新 ID;不要给后续所有镜头重新编号。
      - **内容修订**:同一镜头目的下调整构图、blocking 或边界,保留 ID 并更新当前文档。
      - **拆分**:原镜头被两个或更多独立职责取代时,停用旧 ID,为每个新镜头创建新 ID;在修订说明中
        写清前身,不把旧 ID 偷给其中一个子镜头。
      - **合并**:多个旧镜头被一个新决定取代时,停用全部旧 ID,创建新 ID,并列出来源集合。
      - **恢复**:停用的 ID 不复用于无关镜头;需要恢复同一职责时显式说明关系。
      
      普通新镜只需要稳定 `SHOT-...`。拆分、合并或恢复旧职责时,在 `分镜.md` 的修订说明中列出前身、
      后继与停用 ID;没有关系时不写空模板。
      
      ## 每次拆分、合并或重排后的对账
      
      重新核对原文落实、场次视觉计划、关键帧、`MOTION-...`、多镜分组、相邻起止边界、总时长与审查
      结论。旧 ID 只在修订说明中解释历史,不能继续被当前镜头或交付分组引用。
      
      脚本可以检查引用、集合和算术;“是否仍是同一个导演决定”由 agent 根据镜头目的、信息变化、
      表演所有权和边界职责判断,不能用文本相似度或数组位置硬判。
      
    • stage-contract.md 9.9 KB
      # 分镜阶段契约
      
      本阶段只拥有 `剧集/<EP>/分镜.md` 中的 `SHOT-...`、镜头职责、来源、时长、起止边界、声音、视觉依据和
      冻结关键帧。它继承剧本事实与视觉设定,不改写剧情或资产身份。
      
      覆盖比较、场次视觉计划和调度推演都留在上下文;只有最终镜头与关键决定进入 `分镜.md`。来源使用
      场景 ID。已存在的图片提示词条目可用 `IMG-...` 可见标题引用;它不是已生成图片的证明,也不是分镜的前置门禁。没有条目时直接引用《视觉设定.md》,不建立 coverage、shots、keyframes、audition 或接受记录。
      
      ## 本阶段规则
      
      ### `SHT`
      
      | ID | Class | Knowledge |
      |---|---|---|
      | SHT-01 | structural_invariant | Every production-relevant scene action, line, sound and on-screen text is carried by a shot or explicitly omitted with a creative reason. Each shot's 来源 names scene ids that exist in `剧本.md`, and every scene is either claimed by a shot or recorded with its reason on the storyboard's `未拍场次` line. |
      | SHT-02 | reviewed_invariant | Each shot has a dramatic/viewing purpose and preserves its source meaning. |
      | SHT-03 | craft_default | Keep a short shot focused on the smallest action/reaction unit that carries its purpose; combine or split it according to performance, information, and continuity rather than a fixed count. |
      | SHT-04 | craft_default | Change framing/camera because attention, pressure, alignment, reveal, or rhythm changes. |
      | SHT-05 | structural_invariant | A keyframe projects one shot boundary and the shot's declared visual basis. Any `IMG-...` used is an existing image-prompt heading, not a claim that generated media exists; when none is suitable, the shot names the relevant textual visual-setting entries without inventing an ID. |
      | SHT-06 | reviewed_invariant | A keyframe is one freezeable instant, not an ordered action chain. |
      | SHT-07 | taste_option | Lens vocabulary, tempo, and locked/handheld/formal style follow visual direction. |
      | SHT-08 | reviewed_invariant | Each authoritative source action is realized once; repeated coverage adds reaction/detail/recontextualization rather than replaying it. |
      | SHT-09 | reviewed_invariant | Exact Location/View orientation and visible anchors match the camera side used by the shot. |
      | SHT-10 | reviewed_invariant | Rendered keyframe prose contains only facts from the boundary that keyframe declares. A start frame carries no state first created by the shot's motion or end; an end frame carries no state already spent before it. Neither frame borrows the other's facts. |
      | SHT-11 | craft_default | When information changes another person's power, relationship, knowledge, or choice, preserve that reception visibly; shot count, framing, and duration follow the consequence and project profile. |
      | SHT-12 | reviewed_invariant | Each audience-visibility fact binds its exact source, carrier, permission, trigger, and protection method; framing neither reveals that fact early nor hides the carrier this shot must communicate. |
      | SHT-13 | reviewed_invariant | Multi-character blocking projects sourced, directed relationships into compatible positions, gaze, distance, and action lines for the current boundary. |
      | SHT-14 | reviewed_invariant | A contested moving object preserves ownership, trajectory, direction, time/round state, and end location across cuts unless an authorized ellipsis says otherwise. |
      | SHT-15 | reviewed_invariant | When the creator has declared delivery-surface overlay regions with their permanence and source, what a shot must be read for—face and gaze, readable evidence text, the decisive hand action—does not sit only inside those regions, and shots bind the declared version. An undeclared surface leaves the rule inactive: record it as unresolved and do not restage against a guessed region. |
      | SHT-16 | structural_invariant | The episode duration is the arithmetic sum of visible shot durations; an unresolved duration stays visibly unresolved, and a target delta is reported rather than used as a universal quality gate. |
      | SHT-17 | structural_invariant | A keyframe declares which boundary it freezes and binds that shot's matching boundary field. An end keyframe is a projection of `end_boundary`, never a second end-state authority, and per-shot keyframe count stays open: one start frame by default, an end frame only when the delivery workflow consumes it. Handing over a start/end pair delegates the motion between them to interpolation, so an action the shot exists for cannot rest on that gap alone. |
      | SHT-18 | craft_default | For a scene where directing choice materially changes audience knowledge, alignment, spatial pressure, performance ownership, or the landing, an accepted scene visual plan may bridge project direction and shots; it binds exact screenplay blocks, direction/profile, Location/View and relevant asset states, ordinary scenes skip it, and it never owns screenplay facts or shot boundaries. |
      | SHT-19 | reviewed_invariant | When a coverage audition is used, its approaches genuinely differ by knowledge timing, alignment, performance space, strongest image, landing, losses, or production fit; it uses no fixed option, grid, framing, or shot-count formula, and the selected approach is stated before formal shots are written. |
      | SHT-20 | reviewed_invariant | Shot revision identity follows directing responsibility rather than array position or text similarity: reorder preserves IDs, insertion creates one, split/merge retires replaced IDs and creates successors, and active coverage plus downstream refs are reconciled before delivery. |
      | SHT-21 | reviewed_invariant | A keyframe's copyable text carries only what will be visible; IDs, workflow notes, file paths and craft commentary stay outside the prompt. Two prompts that differ only by identifiers are a template, not two frames. |
      | SHT-22 | structural_invariant | Each shot carries a required `视觉依据` field, written after the frozen keyframe, listing every visible character, place, or prop whose identity, Look, or geography must remain recognizable; each item resolves to an existing `视觉设定.md` entry. Whatever the keyframe text calls by name — an entry name or its declared `画面代称` — appears there. Small/background subjects remain in scope when recognition matters; offscreen voices and unidentifiable fragments do not. |
      | SHT-23 | structural_invariant | An omitted reference choice is not consent to text-to-video. After reference discovery, each shot either binds all verified real images as `REF-...`, declares creator-supplied pictures as `PLAN-...` slots that locate an existing `IMG-...` board or `SHOT-...` keyframe, preserves partial bindings while naming the still-missing start/identity/geography/prop images, or records the creator's explicit text-to-video choice. A prompt entry, plausible filename, or text description never counts as a real image, and a `PLAN-...` slot never claims one: it states that the picture is supplied outside the project at generation time, which is why it cannot be sent to production. |
      | SHT-24 | reviewed_invariant | A visual basis describes this frame, not the episode's cast list. Each declared 控制 scope stays within what this frame can carry, and a binding is not carried forward merely because the subject remains in the scene. |
      | SHT-25 | structural_invariant | Every real reference image a shot binds declares one 用途 from the closed set 身份/造型状态/地理/构图/尺度/效果/起始帧/结束帧/风格, so the shot can say which start frame and which identity/geography/prop pictures a downstream job sends. At most one 起始帧 and one 结束帧 per shot, and 结束帧 requires 起始帧. |
      | SHT-26 | structural_invariant | A shot's 来源 is its claim on the screenplay: whatever it quotes, this shot is the one that films it. Every 视觉设定 entry the quote calls by name is therefore either in this shot's 视觉依据 or declared 画外 there. Quoting an action performed by someone the frame never shows takes that action off every list — no other shot claims it and nothing reports it missing, so it simply never gets filmed, and the gap only surfaces when a person watches the finished film. The check matches entry names rather than 画面代称, because a 来源 quote comes from 剧本.md; an entry that opted out with `画面代称:无` stays out of this check too. |
      | SHT-27 | reviewed_invariant | When understanding a beat requires seeing its effect on a character, provide a readable reaction or consequence. A separate shot, staging within a shared shot, sound, or an object may carry it. Judge legibility against the intended reveal; do not require a shot count or exclude group shots by default. |
      
      ### `CON`
      
      | ID | Class | Knowledge |
      |---|---|---|
      | CON-01 | structural_invariant | Linked end and next start states match or have an explicit owner revision. |
      | CON-02 | reviewed_invariant | Knowledge, injury, ownership, weather, light, or physical state does not teleport/regress without story cause. |
      | CON-03 | craft_default | Track downstream-relevant deltas, not the whole 设定集 in every shot. |
      | CON-04 | structural_invariant | A continuity change states before, after, cause/source scene, effective range, and affected visible IDs. |
      | CON-05 | taste_option | Declared montage, ellipsis, dream, or subjective imagery may intentionally break ordinary continuity. |
      | CON-06 | structural_invariant | A continuity change names every existing downstream document it affects; future work is described, not pre-created. |
      | CON-07 | structural_invariant | A continuity lock declared in `视觉设定.md` fixes one verbatim surface plus its shot and image scope; every in-scope frozen keyframe, motion prompt, and named image prompt carries that surface unchanged. |
      
      规则分级由高到低:`structural_invariant`(结构缺陷,阻断)、
      `reviewed_invariant`(需证据判断)、`craft_default`(常用做法,可覆盖)、
      `taste_option`(创作者选择,不作缺陷)。创作者已接受的事实优先于本表。
      
  • scripts
    • selftest.py 6.8 KB
      #!/usr/bin/env python3
      """Offline self-test for storyboard bookkeeping checks."""
      
      from __future__ import annotations
      
      import copy
      import sys
      import tempfile
      from pathlib import Path
      from typing import Any
      
      from storyboard_check import (
          check_boundary_entries,
          check_episode_duration,
          check_keyframe_boundaries,
          check_screenplay_coverage,
      )
      
      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(findings: list[dict[str, Any]]) -> set[Any]:
          return {item["code"] for item in findings}
      
      
      SHOT: dict[str, Any] = {
          "shot_id": "SHOT-001",
          "duration_seconds": 5,
          "start_boundary": "closed door",
          "end_boundary": "open door",
      }
      BOUNDED_SHOT: dict[str, Any] = {
          "shot_id": "SHOT-002",
          "duration_seconds": 5,
          "start_boundary": {
              "positions": ["站在柜台东侧(与上一镜相同)"],
              "facing": ["面向门口"],
          },
          "end_boundary": {"positions": ["退到门内一步"], "facing": ["面向柜台"]},
      }
      SHOTS_SOURCE: dict[str, Any] = {
          "owner": "short-drama-storyboard",
          "artifact": "剧集/EP001/storyboard/shots.jsonl",
          "hash": "a" * 64,
      }
      COVERAGE: dict[str, Any] = {
          "sources": {"shots": SHOTS_SOURCE},
          "dispositions": [{"shot_refs": [{"src": "shots", "record_id": "SHOT-001"}]}],
          "episode_duration": {
              "counted_shot_ids": ["SHOT-001"],
              "unresolved_durations": [],
              "shot_seconds_total": 5,
              "delta_seconds": 0,
              "disposition": "within_creator_tolerance",
          },
      }
      KEYFRAME_SOURCES: dict[str, Any] = {"shots": SHOTS_SOURCE}
      KEYFRAME: dict[str, Any] = {
          "keyframe_id": "KF-001-END",
          "boundary_role": "end",
          "boundary_ref": {"src": "shots", "record_id": "SHOT-001", "field": "/end_boundary"},
      }
      # A reference may also carry its whole snapshot inline instead of naming a
      # sources key; the checker resolves both to the same upstream binding.
      EXPANDED_COVERAGE: dict[str, Any] = {
          "dispositions": [
              {"shot_refs": [{**SHOTS_SOURCE, "record_id": "SHOT-001", "authority": "accepted"}]}
          ],
          "episode_duration": COVERAGE["episode_duration"],
      }
      EXPANDED_KEYFRAME: dict[str, Any] = {
          "keyframe_id": "KF-001-END",
          "boundary_role": "end",
          "boundary_ref": {**SHOTS_SOURCE, "record_id": "SHOT-001", "field": "/end_boundary"},
      }
      
      
      def test_screenplay_coverage_flags_gaps_and_double_claims() -> None:
          """Every screenplay block must be claimed by exactly one shot.
      
          Shots bind to the screenplay through the index, so coverage is measured in
          block IDs. Measuring it in prose lines instead reported every line of a
          correctly covered episode as unfilmed.
          """
          with tempfile.TemporaryDirectory() as directory:
              index = Path(directory) / "screenplay-index.jsonl"
              index.write_text(
                  '{"record_type":"block","block_id":"BLK-A"}\n'
                  '{"record_type":"block","block_id":"BLK-B"}\n'
                  '{"record_type":"block","block_id":"BLK-C"}\n',
                  encoding="utf-8",
              )
              sources = {
                  "screenplay-index": {
                      "owner": "short-drama-write",
                      "artifact": "剧集/EP001/screenplay-index.jsonl",
                  }
              }
              shots = [
                  {"shot_id": "SH001", "source_refs": [{"src": "screenplay-index", "record_id": "BLK-A"}]},
                  {"shot_id": "SH002", "source_refs": [{"src": "screenplay-index", "record_id": "BLK-B"}]},
                  {"shot_id": "SH003", "source_refs": [{"src": "screenplay-index", "record_id": "BLK-B"}]},
                  {"shot_id": "SH004", "source_refs": [{"src": "screenplay-index", "record_id": "BLK-Z"}]},
              ]
              found = {f["code"] for f in check_screenplay_coverage(shots, sources, index)}
          require("SHT01_BLOCK_UNCLAIMED" in found, "an unfilmed block must be reported")
          require("SHT01_BLOCK_CLAIMED_TWICE" in found, "a doubly claimed block must be reported")
          require(
              "SHT01_BLOCK_NOT_IN_SCREENPLAY" in found,
              "a shot claiming an absent block must be reported",
          )
      
      
      def test_screenplay_coverage_is_skipped_when_not_supplied() -> None:
          require(check_screenplay_coverage([], {}, None) == [], "no index means no claim")
      
      
      def main() -> int:
          require(check_episode_duration(COVERAGE, [SHOT], 5) == [], "valid duration")
          require(
              check_keyframe_boundaries([KEYFRAME], [SHOT], KEYFRAME_SOURCES) == [],
              "valid boundary",
          )
          require(check_episode_duration(EXPANDED_COVERAGE, [SHOT], 5) == [], "expanded duration")
          require(
              check_keyframe_boundaries([EXPANDED_KEYFRAME], [SHOT], {}) == [],
              "expanded boundary",
          )
      
          wrong_total = copy.deepcopy(COVERAGE)
          wrong_total["episode_duration"]["shot_seconds_total"] = 4
          require(
              "SHT16_TOTAL_IS_NOT_THE_SUM" in codes(check_episode_duration(wrong_total, [SHOT], 5)),
              "wrong duration total was not detected",
          )
      
          wrong_boundary = copy.deepcopy(KEYFRAME)
          wrong_boundary["boundary_ref"]["field"] = "/start_boundary"
          require(
              "SHT17_BOUNDARY_REF_DISAGREES_WITH_ROLE"
              in codes(check_keyframe_boundaries([wrong_boundary], [SHOT], KEYFRAME_SOURCES)),
              "wrong keyframe boundary was not detected",
          )
      
          undeclared = copy.deepcopy(COVERAGE)
          undeclared["sources"] = {}
          require(
              "REF_SRC_IS_NOT_DECLARED" in codes(check_episode_duration(undeclared, [SHOT], 5)),
              "a src without a sources entry was not detected",
          )
          require(
              "REF_SRC_IS_NOT_DECLARED"
              in codes(check_keyframe_boundaries([KEYFRAME], [SHOT], {})),
              "a keyframe src without a sources entry was not detected",
          )
      
          unbound = copy.deepcopy(COVERAGE)
          unbound["dispositions"][0]["shot_refs"] = [{"record_id": "SHOT-001"}]
          require(
              "REF_HAS_NO_UPSTREAM_BINDING" in codes(check_episode_duration(unbound, [SHOT], 5)),
              "a reference binding no snapshot was not detected",
          )
      
          # A boundary entry that only says "same as before" reads as written but tells
          # the next stage nothing; an absolute fact that mentions the previous shot in
          # passing is fine and must not be flagged.
          require(check_boundary_entries([BOUNDED_SHOT]) == [], "absolute boundary entries")
          relative = copy.deepcopy(BOUNDED_SHOT)
          relative["end_boundary"]["positions"] = ["(位置不变)"]
          require(
              "SHT05_BOUNDARY_ENTRY_IS_A_BACK_REFERENCE"
              in codes(check_boundary_entries([relative])),
              "a boundary entry that only points back was not detected",
          )
      
          test_screenplay_coverage_flags_gaps_and_double_claims()
          test_screenplay_coverage_is_skipped_when_not_supplied()
      
          print("13 self-tests passed")
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • storyboard_check.py 30.4 KB
      #!/usr/bin/env python3
      """Check the storyboard invariants that are pure bookkeeping.
      
      `SHT-16` is arithmetic over shot durations, `SHT-17` is a structural claim about
      which boundary a keyframe freezes, and a boundary entry that only points back at
      an earlier shot states no fact at all. None of these needs a reading of the
      drama, so leaving them to a reviewer spends judgment on work a script does
      exactly. Everything requiring judgment stays in the reference documents.
      
      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 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"
      BOUNDARY_FIELD = {"start": "/start_boundary", "end": "/end_boundary"}
      PLACEHOLDER = re.compile(r"^<.*>$")
      
      # A boundary entry is read on its own by whoever owns the next stage, so an
      # entry whose whole content points back at another shot leaves that field empty
      # in practice. Only a wholly referential entry is a defect: "站在柜台东侧(与上一
      # 镜相同)" still says where the character is.
      BACK_REFERENCE = re.compile(
          r"^(同上一?镜?|同前一?镜?|同上镜|与上一?镜相同|照旧"
          r"|(位置|朝向|目光|手部|状态|持物)?(保持)?不变|无变化"
          r"|same as (above|before|previous( shot)?)|unchanged|no change)$",
          re.IGNORECASE,
      )
      BACK_REFERENCE_TRIM = " \t 。,、;:()()【】〔〕「」『』\"'·-—~…!!??"
      
      
      class CheckError(ValueError):
          """The inputs cannot be checked at all, as opposed to failing a check."""
      
      
      def _load_json(path: Path) -> Any:
          try:
              return json.loads(path.read_text(encoding="utf-8"))
          except (OSError, UnicodeError, json.JSONDecodeError) as error:
              raise CheckError(f"unreadable JSON: {path}") from error
      
      
      def _load_jsonl(path: Path) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]]]:
          """Return the file's ``sources`` declaration and its data records.
      
          A leading ``{"record_type": "sources"}`` header declares the upstream
          snapshots of the whole file; it is a declaration, not a data record, so it
          is kept out of the returned list.
          """
      
          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)
          sources = load_sources(records)
          if records and records[0].get("record_type") == SOURCES_RECORD_TYPE:
              records = records[1:]
          return sources, records
      
      
      def _is_template(value: Any) -> bool:
          """Template files ship placeholder strings; they are not project data."""
      
          return isinstance(value, str) and bool(PLACEHOLDER.match(value.strip()))
      
      
      def _finding(code: str, message: str, **detail: Any) -> dict[str, Any]:
          return {"code": code, "message": message, **detail}
      
      
      def _duration_of(shot: dict[str, Any]) -> float | None:
          value = shot.get("duration_seconds")
          if isinstance(value, bool) or not isinstance(value, (int, float)):
              return None
          return float(value)
      
      
      def _ref_finding(finding: RefFinding, **detail: Any) -> dict[str, Any]:
          return _finding(finding.code, finding.detail, location=finding.location, **detail)
      
      
      def _covered_shot_ids(coverage: dict[str, Any]) -> tuple[list[str], list[dict[str, Any]]]:
          """Return the shot IDs the coverage claims, and any unusable reference."""
      
          ids: list[str] = []
          findings: list[dict[str, Any]] = []
          sources = load_sources(coverage)
          dispositions = coverage.get("dispositions")
          if not isinstance(dispositions, list):
              return ids, findings
          for index, disposition in enumerate(dispositions):
              if not isinstance(disposition, dict):
                  continue
              refs = disposition.get("shot_refs")
              if not isinstance(refs, list):
                  continue
              for position, ref in enumerate(refs):
                  resolved, finding = resolve_ref(
                      ref, sources, f"/dispositions/{index}/shot_refs/{position}"
                  )
                  if finding is not None:
                      findings.append(_ref_finding(finding))
                      continue
                  if resolved is not None and resolved.record_id is not None:
                      ids.append(resolved.record_id)
          return ids, findings
      
      
      def check_episode_duration(
          coverage: dict[str, Any],
          shots: list[dict[str, Any]],
          target_seconds: float | None,
      ) -> list[dict[str, Any]]:
          """SHT-16: the total is arithmetic, and no shot may leave it silently."""
      
          covered_ids, findings = _covered_shot_ids(coverage)
          duration = coverage.get("episode_duration")
          if not isinstance(duration, dict):
              findings.append(_finding("SHT16_RECORD_MISSING", "coverage carries no episode_duration"))
              return findings
      
          by_id = {
              shot["shot_id"]: shot
              for shot in shots
              if isinstance(shot.get("shot_id"), str)
          }
          counted = duration.get("counted_shot_ids")
          unresolved = duration.get("unresolved_durations")
          if not isinstance(counted, list) or not isinstance(unresolved, list):
              findings.append(
                  _finding(
                      "SHT16_RECORD_INCOMPLETE",
                      "episode_duration needs counted_shot_ids and unresolved_durations",
                  )
              )
              return findings
          counted_ids = [value for value in counted if isinstance(value, str)]
          unresolved_ids = [value for value in unresolved if isinstance(value, str)]
      
          overlap = sorted(set(counted_ids) & set(unresolved_ids))
          if overlap:
              findings.append(
                  _finding(
                      "SHT16_SHOT_COUNTED_AND_UNRESOLVED",
                      "a shot cannot be both counted and unresolved",
                      shot_ids=overlap,
                  )
              )
      
          # A shot listed twice is summed twice, and set arithmetic above never sees
          # it. One copy-paste inflates the episode by a whole shot, and the checker
          # then confirms the inflated figure in the coverage file as correct.
          duplicates = sorted(
              {shot_id for shot_id in counted_ids + unresolved_ids
               if (counted_ids + unresolved_ids).count(shot_id) > 1}
          )
          if duplicates:
              findings.append(
                  _finding(
                      "SHT16_SHOT_LISTED_TWICE",
                      "episode_duration names the same shot more than once",
                      shot_ids=duplicates,
                  )
              )
      
          accounted = set(counted_ids) | set(unresolved_ids)
          missing = sorted(set(covered_ids) - accounted)
          if missing:
              findings.append(
                  _finding(
                      "SHT16_SHOT_LEFT_THE_TOTAL",
                      "coverage lists shots that neither contribute nor are suspended",
                      shot_ids=missing,
                  )
              )
          # `covered_ids` comes from the coverage document itself, so the check above
          # only asks whether coverage agrees with coverage. The episode's real shot
          # list is `shots.jsonl`; a shot dropped from both the dispositions and the
          # total is invisible to every check that reads only the one file.
          unaccounted = sorted(set(by_id) - accounted)
          if unaccounted:
              findings.append(
                  _finding(
                      "SHT16_EPISODE_SHOT_LEFT_THE_TOTAL",
                      "the shot file carries shots that episode_duration neither "
                      "counts nor suspends",
                      shot_ids=unaccounted,
                  )
              )
          unknown = sorted(accounted - set(by_id))
          if unknown:
              findings.append(
                  _finding(
                      "SHT16_SHOT_UNRESOLVABLE",
                      "episode_duration names shots that are not in the shot file",
                      shot_ids=unknown,
                  )
              )
      
          total = 0.0
          for shot_id in sorted(set(counted_ids)):
              shot = by_id.get(shot_id)
              if shot is None:
                  continue
              seconds = _duration_of(shot)
              if seconds is None:
                  findings.append(
                      _finding(
                          "SHT16_COUNTED_SHOT_HAS_NO_DURATION",
                          "a counted shot carries no numeric duration_seconds",
                          shot_id=shot_id,
                      )
                  )
                  continue
              total += seconds
          for shot_id in unresolved_ids:
              shot = by_id.get(shot_id)
              if shot is not None and _duration_of(shot) is not None:
                  findings.append(
                      _finding(
                          "SHT16_SUSPENDED_SHOT_HAS_A_DURATION",
                          "a shot listed as unresolved already carries a duration",
                          shot_id=shot_id,
                      )
                  )
      
          stated = duration.get("shot_seconds_total")
          if isinstance(stated, bool) or not isinstance(stated, (int, float)):
              findings.append(
                  _finding("SHT16_TOTAL_MISSING", "shot_seconds_total is not a number")
              )
          elif abs(float(stated) - total) > 1e-6:
              findings.append(
                  _finding(
                      "SHT16_TOTAL_IS_NOT_THE_SUM",
                      "shot_seconds_total does not equal the sum of the counted shots",
                      stated=float(stated),
                      computed=round(total, 6),
                  )
              )
      
          if target_seconds is None:
              if duration.get("disposition") not in (None, "no_target_declared"):
                  findings.append(
                      _finding(
                          "SHT16_DISPOSITION_CLAIMS_A_TARGET",
                          "no target is declared, so the disposition cannot judge one; "
                          "leave it unset or use no_target_declared",
                      )
                  )
              return findings
      
          delta = duration.get("delta_seconds")
          expected = total - target_seconds
          if isinstance(delta, bool) or not isinstance(delta, (int, float)):
              findings.append(
                  _finding("SHT16_DELTA_MISSING", "a declared target needs a signed delta")
              )
          elif abs(float(delta) - expected) > 1e-6:
              findings.append(
                  _finding(
                      "SHT16_DELTA_IS_WRONG",
                      "delta_seconds does not equal total minus target",
                      stated=float(delta),
                      computed=round(expected, 6),
                  )
              )
          allowed = ("within_creator_tolerance", "creator_accepted_overrun", "to_revise")
          if duration.get("disposition") not in set(allowed):
              findings.append(
                  _finding(
                      "SHT16_DISPOSITION_MISSING",
                      "a declared target needs a disposition for its delta; "
                      f"use one of {', '.join(allowed)}",
                      stated=duration.get("disposition"),
                  )
              )
          return findings
      
      
      def check_keyframe_boundaries(
          keyframes: list[dict[str, Any]],
          shots: list[dict[str, Any]],
          sources: dict[str, dict[str, Any]] | None = None,
      ) -> list[dict[str, Any]]:
          """SHT-17: say which boundary is frozen, and bind that shot's field."""
      
          declared = sources or {}
          findings: list[dict[str, Any]] = []
          shot_ids = {
              shot["shot_id"] for shot in shots if isinstance(shot.get("shot_id"), str)
          }
          seen: dict[tuple[str, str], str] = {}
          for keyframe in keyframes:
              keyframe_id = keyframe.get("keyframe_id")
              if not isinstance(keyframe_id, str):
                  findings.append(
                      _finding("SHT17_KEYFRAME_HAS_NO_ID", "a keyframe record has no id")
                  )
                  continue
              role = keyframe.get("boundary_role")
              if role not in BOUNDARY_FIELD:
                  findings.append(
                      _finding(
                          "SHT17_BOUNDARY_ROLE_MISSING",
                          "boundary_role must be start or end",
                          keyframe_id=keyframe_id,
                      )
                  )
                  continue
              ref = keyframe.get("boundary_ref")
              if not isinstance(ref, dict):
                  findings.append(
                      _finding(
                          "SHT17_BOUNDARY_REF_MISSING",
                          "a keyframe must bind the boundary it projects",
                          keyframe_id=keyframe_id,
                      )
                  )
                  continue
              resolved, ref_finding = resolve_ref(ref, declared, f"{keyframe_id}/boundary_ref")
              if ref_finding is not None or resolved is None:
                  if ref_finding is not None:
                      findings.append(_ref_finding(ref_finding, keyframe_id=keyframe_id))
                  continue
              if resolved.field != BOUNDARY_FIELD[role]:
                  findings.append(
                      _finding(
                          "SHT17_BOUNDARY_REF_DISAGREES_WITH_ROLE",
                          "boundary_ref.field does not match the declared role",
                          keyframe_id=keyframe_id,
                          role=role,
                          field=resolved.field,
                      )
                  )
              shot_id = resolved.record_id
              if shot_id is None or shot_id not in shot_ids:
                  findings.append(
                      _finding(
                          "SHT17_BOUNDARY_REF_UNRESOLVABLE",
                          "boundary_ref does not resolve to a shot in the shot file",
                          keyframe_id=keyframe_id,
                          record_id=shot_id,
                      )
                  )
                  continue
              previous = seen.get((shot_id, role))
              if previous is not None:
                  findings.append(
                      _finding(
                          "SHT17_DUPLICATE_BOUNDARY_KEYFRAME",
                          "one shot boundary cannot have two keyframes",
                          shot_id=shot_id,
                          role=role,
                          keyframe_ids=[previous, keyframe_id],
                      )
                  )
                  continue
              seen[(shot_id, role)] = keyframe_id
          return findings
      
      
      def check_boundary_entries(shots: list[dict[str, Any]]) -> list[dict[str, Any]]:
          """SHT-05: every boundary entry states a fact readable without the last shot."""
      
          findings: list[dict[str, Any]] = []
          for shot in shots:
              shot_id = shot.get("shot_id")
              for boundary in ("start_boundary", "end_boundary"):
                  fields = shot.get(boundary)
                  if isinstance(fields, str):
                      # A boundary written as one string carries the same defect. The
                      # empty key keeps the reported pointer at /<boundary>/0.
                      fields = {"": [fields]}
                  if not isinstance(fields, dict):
                      continue
                  for name, entries in sorted(fields.items()):
                      if isinstance(entries, str):
                          entries = [entries]
                      if not isinstance(entries, list):
                          continue
                      for index, entry in enumerate(entries):
                          if not isinstance(entry, str) or _is_template(entry):
                              continue
                          if BACK_REFERENCE.match(entry.strip(BACK_REFERENCE_TRIM)):
                              findings.append(
                                  _finding(
                                      "SHT05_BOUNDARY_ENTRY_IS_A_BACK_REFERENCE",
                                      "a boundary entry points back instead of stating the fact",
                                      shot_id=shot_id,
                                      location=f"/{boundary}/{name}/{index}" if name else f"/{boundary}/{index}",
                                      entry=entry,
                                  )
                              )
          return findings
      
      
      def _declared_target(project: Path | None) -> float | None:
          if project is None:
              return None
          document = _load_json(project)
          if not isinstance(document, dict):
              raise CheckError("project file must be a JSON object")
          value = document.get("format", {}).get("target_seconds_per_episode")
          if value is None or _is_template(value):
              return None
          if isinstance(value, bool) or not isinstance(value, (int, float)):
              raise CheckError("target_seconds_per_episode must be a number or null")
          return float(value)
      
      
      # A screenplay block that no shot claims is a block nobody will film. A block
      # two shots claim is a block the edit will show twice. Shots bind to the
      # screenplay through the index -- stable block IDs -- not through the prose,
      # because the prose is edited constantly and the IDs survive that.
      def screenplay_block_ids(index_path: Path) -> tuple[list[str], int]:
          """The indexed blocks, and how much of the screenplay stayed unclassified.
      
          A line the index could not classify is not a block, so it is invisible to
          every check below: no shot can claim it, and nothing reports that it is
          missing. Coverage measured against a partial index is not coverage of the
          screenplay, so the count comes back with the IDs.
          """
      
          _sources, records = _load_jsonl(index_path)
          blocks = [
              str(record["block_id"])
              for record in records
              if record.get("record_type") == "block" and isinstance(record.get("block_id"), str)
          ]
          issues = sum(
              1 for record in records if record.get("record_type") == "source_issue"
          )
          return blocks, issues
      
      
      # SKILL.md gives four dispositions, and three of them are not "one shot claims
      # this block": material can be intentionally repeated, omitted for a reason, or
      # present only so the reader understands the scene. A claim check that knows
      # nothing about them can only ever demand exactly one shot per block, which is
      # why marking a block `nonvisual_context` as the workflow instructs used to fail.
      DISPOSITIONS = {
          "covered",
          "intentional_repeat",
          "omitted_with_reason",
          "nonvisual_context",
      }
      # Two of them are a decision, not an observation, so they have to carry the
      # reason the decision was made.
      DISPOSITIONS_NEEDING_A_REASON = {"intentional_repeat", "omitted_with_reason"}
      # Only these expect shots. The other two are records of material that is
      # deliberately not on screen.
      DISPOSITIONS_EXPECTING_SHOTS = {"covered", "intentional_repeat"}
      
      
      def block_dispositions(
          coverage: dict[str, Any], wanted: set[str]
      ) -> tuple[dict[str, str], list[dict[str, Any]]]:
          """Read the disposition table, and check it against the screenplay index.
      
          Nothing validated this table before: rows could be dropped, given a status
          no rule defines, or point at blocks that do not exist, and the file still
          passed. Coverage and `shots.jsonl` were two independent claims about the
          same fact with no reconciliation between them.
          """
      
          findings: list[dict[str, Any]] = []
          rows = coverage.get("dispositions")
          if not isinstance(rows, list):
              return {}, [
                  _finding("SHT01_DISPOSITIONS_MISSING", "coverage carries no dispositions")
              ]
      
          status_of: dict[str, str] = {}
          for index, row in enumerate(rows):
              if not isinstance(row, dict):
                  findings.append(
                      _finding(
                          "SHT01_DISPOSITION_MALFORMED",
                          "a disposition row is not an object",
                          position=index,
                      )
                  )
                  continue
              block_id = row.get("block_id")
              status = row.get("status")
              if not isinstance(block_id, str) or not block_id:
                  findings.append(
                      _finding(
                          "SHT01_DISPOSITION_MALFORMED",
                          "a disposition row names no block",
                          position=index,
                      )
                  )
                  continue
              if status not in DISPOSITIONS:
                  findings.append(
                      _finding(
                          "SHT01_DISPOSITION_UNKNOWN",
                          "a disposition must be one of the four the workflow defines",
                          block_id=block_id,
                          status=status,
                      )
                  )
                  continue
              if block_id in status_of:
                  findings.append(
                      _finding(
                          "SHT01_DISPOSITION_REPEATED",
                          "a block carries more than one disposition",
                          block_id=block_id,
                      )
                  )
                  continue
              if block_id not in wanted:
                  findings.append(
                      _finding(
                          "SHT01_DISPOSITION_NOT_IN_SCREENPLAY",
                          "a disposition names a block that is not in the screenplay index",
                          block_id=block_id,
                      )
                  )
                  continue
              if status in DISPOSITIONS_NEEDING_A_REASON and not str(
                  row.get("reason") or ""
              ).strip():
                  findings.append(
                      _finding(
                          "SHT01_DISPOSITION_HAS_NO_REASON",
                          "repeating or omitting material is a decision and must say why",
                          block_id=block_id,
                          status=status,
                      )
                  )
              status_of[block_id] = status
      
          undecided = sorted(wanted - set(status_of))
          if undecided:
              findings.append(
                  _finding(
                      "SHT01_BLOCK_HAS_NO_DISPOSITION",
                      "every production-relevant block must carry a disposition",
                      block_ids=undecided,
                  )
              )
          return status_of, findings
      
      
      def check_screenplay_coverage(
          shots: list[dict[str, Any]],
          shot_sources: dict[str, Any],
          index_path: Path | None,
          coverage: dict[str, Any] | None = None,
      ) -> list[dict[str, Any]]:
          if index_path is None:
              return []
          try:
              wanted, unclassified = screenplay_block_ids(index_path)
          except OSError as error:
              raise CheckError(f"screenplay index cannot be read: {error}") from error
      
          status_of: dict[str, str] = {}
          disposition_findings: list[dict[str, Any]] = []
          if unclassified:
              disposition_findings.append(
                  _finding(
                      "SHT01_SCREENPLAY_IS_NOT_FULLY_INDEXED",
                      "the screenplay index left lines unclassified, so this coverage "
                      "check cannot see all of the screenplay",
                      source_issue_count=unclassified,
                  )
              )
          if coverage is not None:
              status_of, disposition_findings2 = block_dispositions(coverage, set(wanted))
              disposition_findings.extend(disposition_findings2)
      
          claims: dict[str, list[str]] = {block_id: [] for block_id in wanted}
          unknown: list[dict[str, Any]] = []
          for shot in shots:
              shot_id = shot.get("shot_id")
              for reference in shot.get("source_refs") or []:
                  resolved, _defect = resolve_ref(reference, shot_sources, "source_refs")
                  if resolved is None or resolved.record_id is None:
                      continue
                  record_id = str(resolved.record_id)
                  if record_id in claims:
                      claims[record_id].append(shot_id)
                  elif resolved.artifact.endswith("screenplay-index.jsonl"):
                      unknown.append({"shot_id": shot_id, "block_id": record_id})
      
          findings = list(disposition_findings)
          for block_id, owners in claims.items():
              # With no coverage file to read, every block is treated as `covered`:
              # that is the old behaviour, and the only safe assumption when the
              # creator has not said otherwise.
              status = status_of.get(block_id, "covered") if coverage is not None else "covered"
              if not owners:
                  if status in DISPOSITIONS_EXPECTING_SHOTS:
                      findings.append(
                          _finding(
                              "SHT01_BLOCK_UNCLAIMED",
                              "no shot claims this screenplay block",
                              block_id=block_id,
                              status=status,
                          )
                      )
              elif status not in DISPOSITIONS_EXPECTING_SHOTS:
                  findings.append(
                      _finding(
                          "SHT01_BLOCK_IS_ON_SCREEN_ANYWAY",
                          "a block recorded as not filmed is claimed by a shot",
                          block_id=block_id,
                          status=status,
                          shot_ids=owners,
                      )
                  )
              elif len(owners) > 1 and status != "intentional_repeat":
                  findings.append(
                      _finding(
                          "SHT01_BLOCK_CLAIMED_TWICE",
                          "more than one shot claims the same screenplay block",
                          block_id=block_id,
                          shot_ids=owners,
                      )
                  )
          for stray in unknown:
              findings.append(
                  _finding(
                      "SHT01_BLOCK_NOT_IN_SCREENPLAY",
                      "a shot claims a block that is not in the screenplay index",
                      **stray,
                  )
              )
          return findings
      
      
      def check(
          coverage_path: Path,
          shots_path: Path,
          keyframes_path: Path | None,
          project_path: Path | None,
          screenplay_index_path: Path | None = None,
      ) -> dict[str, Any]:
          coverage = _load_json(coverage_path)
          if not isinstance(coverage, dict):
              raise CheckError("coverage must be a JSON object")
          shot_sources, shots = _load_jsonl(shots_path)
          findings = check_episode_duration(coverage, shots, _declared_target(project_path))
          findings.extend(check_boundary_entries(shots))
          findings.extend(
              check_screenplay_coverage(
                  shots, shot_sources, screenplay_index_path, coverage
              )
          )
          keyframes: list[dict[str, Any]] | None = None
          if keyframes_path is not None:
              keyframe_sources, keyframes = _load_jsonl(keyframes_path)
              findings.extend(check_keyframe_boundaries(keyframes, shots, keyframe_sources))
          return {
              "schema_version": SCHEMA_VERSION,
              "episode_id": coverage.get("episode_id"),
              "checked": {
                  "shots": len(shots),
                  "keyframes": None if keyframes is None else len(keyframes),
              },
              "findings": findings,
              "status": "pass" if not findings else "fail",
          }
      
      
      def build_parser() -> argparse.ArgumentParser:
          parser = argparse.ArgumentParser(
              description="Check storyboard duration arithmetic and keyframe boundary roles."
          )
          parser.add_argument("coverage", type=Path, help="the episode coverage JSON")
          parser.add_argument("--shots", type=Path, required=True)
          parser.add_argument("--keyframes", type=Path)
          parser.add_argument(
              "--screenplay-index",
              type=Path,
              help="screenplay-index.jsonl, so every block is claimed by exactly one shot",
          )
          parser.add_argument(
              "--project",
              type=Path,
              help="short-drama.json, so a declared per-episode target is compared",
          )
          return parser
      
      
      def main(argv: list[str] | None = None) -> int:
          args = build_parser().parse_args(argv)
          try:
              result = check(
                  args.coverage, args.shots, args.keyframes, args.project, args.screenplay_index
              )
          except CheckError 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 17.2 KB
    ---
    name: short-drama-storyboard
    description: 把中文短剧剧本和视觉设定写成有戏剧职责、连续性边界与冻结关键帧提示词的分镜 Markdown。用户提出“拆分镜/设计镜头/做镜头表”“场次视觉计划/调度故事板”“比较导演方案”“写首帧/关键帧提示词”或检查轴线、站位、视线、持物连续性时使用;不生成媒体。
    license: MIT
    ---
    
    # 短剧分镜与冻结关键帧
    
    把剧本和视觉事实转成有镜头职责、空间连续性和可冻结起点的 `剧集/<EP>/分镜.md`。
    每镜使用二级标题 `## SHOT-...`,同镜下用 `### 冻结关键帧提示词` 写起始帧正文;
    需要时另加 `### 收尾关键帧提示词` 写终点正文。
    
    ## Quick Start
    
    ```text
    用 $short-drama-storyboard 完成 EP001 的正式分镜和每镜冻结关键帧
    ```
    
    ## 入口
    
    当前剧本加必要视觉事实即可直接进入。资产图片提示词与分镜是兄弟分支,不互相等待。只有关键场次
    真的存在多个成立导演方案时,才在上下文比较覆盖方案;普通场次直接设计。
    
    创作者可读说明跟随项目语言;冻结关键帧的可复制正文跟随
    `short-drama.json#/format/prompt_language`。没有 `short-drama.json` 时正文默认使用 `en`。
    分配镜头时长前读取
    `short-drama.json#/creator_authority/production_profile/choices/native_duration_seconds`。已声明目标模型但
    没有原生时长时先补这一个选择;目标模型也未声明时照常按叙事节奏设计,不猜供应商限制。
    
    冻结关键帧写完之前,谁会出现在这一格画面里还没有定下来。所以三条依据都在关键帧之后回填,
    读的是成稿画面,不是镜头意图。
    
    用户没有手工点名参考图,不等于选择文生视频。冻结关键帧后先根据本镜可见人物、地点、关键道具与
    起始构图判断一致性需求,再检查项目中已有的真实图片。只有当前可读、内容与用途可核对的图片才能自动绑定;
    文件名像某个角色但内容未核对时,仍视为未准备好。
    
    项目里一张都没有时,创作者有三条路,一次把三条都说清楚再让他选,不要只给「先生成参考图」和
    「改成文生视频」两个:把手上已有的图片放进项目再绑成 `REF-...`;在自己的工具里出图、本轮先用
    `PLAN-...` 写出挂图计划;或者明确改走文生视频。用本套件的生产技能出图只是第一条路的一种做法,
    需要项目外的 adapter 与凭据,不是唯一入口。
    
    开始分镜前读 `剧集/<EP>/剧本.md` 的当前内容。创作者说「剧本已确认采用」指的是那个文件,
    不是对话里更早的草稿;两者不一致时以文件为准,并把差异说出来。
    
    ## 工作流
    
    1. 读取已接受的目标模型原生时长区间,再确认每个场景动作、对白、声音、画面文字和转折由哪些镜头承载。
    2. 确定观众何时知道什么、对齐谁、空间如何揭示、转折落在哪个可见动作。
       剧本或 Brief 用“前半段 / 后半段 / 前三分之一 / 最后 N 秒”等相对时间限定首次出现、释放或禁止时,
       先用已接受集长换成明确时间边界再分镜;例如 60 秒作品的“后半段首次出现”不得早于 30 秒。
    3. 每镜写清唯一职责、来源、时长、景别/机位、起点、唯一主要动作、终点和声音;用
       `起点 → 唯一动作 → 终点` 直接说明人物、双手与持物怎样完成本镜状态转换。三条依据留到第 6 步。
       有对白、VO 或 OS 时,先按原文和人物说话方式估算完整发声时间,再安排不能重叠的动作、停顿与
       末尾反应;把估时依据简写在本镜「声音」里。具体做法见 [对白估时](references/shot-craft.md#对白估时)。
    4. 锁定人物朝向、位置、视线、持物、出入口、屏幕方向和必要状态;上游已有会限制动作空间的持续关系时,把关系投影进本镜起点与终点。
    5. 冻结关键帧只投影镜头起点;删除终点才出现的文字、动作、姿态或道具状态。
       镜头的职责落在收尾那一下时,另写 `### 收尾关键帧提示词` 投影**终点**——同一格画面的
       另一端,规则完全对称:删除起点才有、终点已经不在的东西。它是可选的,写法与取舍见
       [首尾成对](references/shot-craft.md#首尾成对)。
    6. 关键帧成稿后,先从成稿正文读出这一格里需要保持身份、造型或地理的人物、地点和道具,
       再用同一份清单一次填满三条依据:
    
       - **视觉依据**:清单里每一项写成《视觉设定.md》的条目引用,这是必写字段;
       - **图片提示词项**:清单里已有合适 `IMG-...` 条目的写上,没有的留「无」;
       - **输入参考图**:按清单在用户提供的输入、`剧集/<EP>/制作成果/` 和文档已指向的其他可见媒体中查找匹配图片;
         命中就写入 `REF-...`。一张都没命中时写「无(待补参考图:<本镜缺失的起始帧、人物、地点、道具>)」;
         只命中一部分时,在已有 `REF-...` 后追加「;待补参考图:<仍缺的图片>」,缺口之间只用 `、` 分隔。
         创作者说图片由他自己在别处准备时,把这份清单写成 `PLAN-...` 槽位,本镜即告就绪;
         只有创作者明确表示不用图时,才写「无(创作者已明确选择文生视频)」。
    
       三条依据读的是同一格画面,所以不能互相矛盾:关键帧里点名的人物或道具,一定出现在视觉依据里。
    7. 整集请求完成整集,末端一次报告覆盖、节奏和真实未决选择。
    
    ## 镜头要求
    
    - 每镜有唯一职责、明确来源、可见起止状态和合理时长。「来源」以《剧本.md》里真实存在的场景 ID 开头,
      ID 之后可以跟必要短引文,跨场次的镜头用 `、` 连写多条;`creator_markdown_check.py` 会核对每个 ID 都解析得到。
    - 「来源」短引文应对应本镜承担的动作或声音。正文提到画外人物时,可在视觉依据写
      `;画外:<类别>「<名称>」`;这只声明其不在画面,不能代替必要动作的落实。
      需要看见的动作应另有镜头承载,或说明为何省略。
    
    - 剧本里的每个场景都要有镜头承载。确实决定不拍某一场时,在《分镜.md》正文开头写一行
      `- 未拍场次:<场景 ID>(理由:……)`,多项用 `;` 连接。SHT-01 一直允许带理由的省略,
      但省略和漏拍在成稿文档里长得一模一样,所以理由要写下来,而不是留在对话里。
    - 已声明原生时长时,每个一镜一生成的镜头都落在其最短与最长秒数内。短于下限的叙事动作在合法
      镜长内完成后保持终点;长于上限的动作按可见状态闭合点拆镜,拆出的每镜仍各有唯一职责与可信交界。
    - 「唯一动作」写从本镜起点到终点的那条可见状态链;涉及交接、换手、放置或取回时,把接触者、
      接触方式和物件落点写在同一链里,不能让合法终点之间靠镜外瞬移衔接。
    - 切镜必须带来信息、权力、情绪、空间或节奏变化;同义重复镜头删掉。
    - 相对时间词约束的是事件首次可见或可听的时刻,不因前置动作已经完成就提前释放;分镜时间轴直接
      写出该边界,不把“稍后”“交接后”等叙事顺序当成数值时间的替代。
    - 同一动作不跨镜重复发生;终点应能成为下一镜可信起点。
    - 关键信息的可读性优先于装饰性运镜,竖屏构图优先保证主体和反应可见。
    - 复杂群戏先锁空间锚点和屏幕方向,再决定景别变化。
    - 起终边界除了逐人姿态,还要写清本镜依赖的身体—物件—空间关系。关系在镜内不变时两端都保留;确需改变时,过程与终点必须有剧本依据。
    - 冻结关键帧只写该瞬间能看见的内容;文字、手指、反射和遮挡要可生成。
    - 《视觉设定.md》的连续性锁在生效镜头上原样写进冻结关键帧正文;锁面不因“参考图会交代”而省略。
    - 检查发现遗漏就直接修正文档,不生成覆盖表来证明自己检查过。镜头块里的「视觉依据」是本镜的依据字段,不是这里说的覆盖表。
    
    ## 视觉依据与图片
    
    - `IMG-...` 是《图片提示词.md》里的**提示词条目 ID**,不代表图片已生成、已提供或可用。
    - 已有《图片提示词.md》且本镜需要其中条目时,写「图片提示词项」:ID 必须匹配该文档的可见二级标题,同时写中文名称和它控制的身份、造型、地理、构图或其他边界。ID 只用于查找,不让英文 slug 承担含义。
    - 没有《图片提示词.md》或没有合适条目时,写「图片提示词项:无」,再以中文指向《视觉设定.md》的人物、地点或道具项及控制范围。这是正常直接入口;不为继续分镜而编造 ID,也不补造另一个阶段。
    - 「视觉依据」是每镜必写字段:冻结关键帧成稿后按实际画面反查,需要保持身份、造型或地理的可见人物、地点和道具**必须**逐项写成
      `《视觉设定.md》·<人物|造型|地点|道具>「<名称>」(控制:<范围>)`,多项用 `;` 连接,名称必须能在《视觉设定.md》里找到同名标题。
      小比例或背景人物只要需要认出身份仍在范围内,画外人声和不可识别局部不算;本镜确实没有这类条目时写「视觉依据:无」。
    - 关键帧正文出现了某个条目的名字,但它本镜确实不在画面里(物主已离场、名字只出现在屏幕文字上、和某个词同形),
      在「视觉依据」末尾追加 `;画外:<类别>「<名称>」`。这是如实记录,不是把不在场的人写成在场。
    - 某个条目的名字在正文里根本不可靠(`道具「手机」` 会撞上「手机店」,`人物「小雨」` 会撞上「下着小雨」),
      在《视觉设定.md》该条目写 `画面代称:无`,机械核对就不再按名字找它;这一镜是否画到它仍由「视觉依据」如实回答。
    - 三条依据都描述同一格画面,不是本镜的资产花名册。「控制」只写这一格承担得起的范围:手背特写不写「本集造型」,
      只出现屏幕和一根手指的镜头不写「体态」。上一镜的绑定不因为“角色还在这场戏里”就顺延到下一镜。
    - 关键帧正文点名的人物、地点或道具(《视觉设定.md》条目名称或它声明的「画面代称」),必须出现在本镜「视觉依据」里;
      `creator_markdown_check.py` 会机械核对这一条。它只抓**被名字点到**的那一类:正文只描述不点名
      ("a boy in a hooded jacket")时机器不发声,画面里有谁仍然要靠这一步自己读出来,不能等报错。
    - 本镜需要保持一致、但《视觉设定.md》里还没有条目的可见物件(跨镜出现的道具尤其常见),不能因为
      「写不进视觉依据」就当它不需要一致性。四镜都出现的一支笔和一件外套是同一类问题。做两件事:
      正文里逐镜用完全相同的描述把它钉住,并在本轮末尾报告里点名它、建议 `$short-drama-assets` 补条目。
      不自行新增《视觉设定.md》条目,也不把它塞进「视觉依据」冒充已有条目。
    - 「输入参考图」是独立的另一条轴:创作者已提供,或经确认生产且当前可读的实际图片,用不同于 `IMG-...` 的稳定 `REF-...` 槽位引用。每个槽位同时写显式顺序、仅用 `/` 分隔的项目相对路径、中文名称、「用途」、「控制」和「不得控制」范围;没有时必须区分「待补参考图」和「创作者已明确选择文生视频」,只准备了一部分时则保留已有 `REF-...` 并追加「;待补参考图:……」。不得把提示词条目、还没生成的图或对话中的描述写成 `REF-...`——`REF-...` 的意思是「文件此刻在项目里」。
      创作者会在生成时自己挂载的图片走下面的 `PLAN-...`:它同样不声称文件存在,只是把这一镜要挂哪几张写清楚。
    - 每个 `REF-...` 必须逐字遵循唯一格式:`REF-<slot>(顺序:<n>)· <项目相对路径>《<中文名称>》(用途:<用途>;控制:<范围>;不得控制:<范围>)`。注意「顺序:」后的全角冒号和路径前的中点 `·`;不得用 `/` 代替字段分隔符。多图只用中文分号 `;` 连接,部分缺图则在最后一个完整槽位后接 `;待补参考图:<缺口>`,缺口之间用 `、` 分隔。写完后按此格式回读一次再交付。
    - `用途` 只能取 `身份`、`造型状态`、`地理`、`构图`、`尺度`、`效果`、`起始帧`、`结束帧`、`风格` 之一;逐条能控制什么见 `$short-drama` 的《参考角色》。
      本镜的起始画面图写 `用途:起始帧`,人物图写 `用途:身份` 或 `造型状态`,场景图写 `用途:地理`,道具状态图按它决定的那一件写。
      一张图承担两件事就拆成两个槽位;一镜最多一张 `起始帧` 和一张 `结束帧`,写 `结束帧` 时必须同时有 `起始帧`。
      下游视频提示词靠 `用途` 说清「本镜送哪一张分镜起始帧、哪些人物/道具/场景图」,所以缺 `用途` 的槽位不算准备好。
    - 本镜还没有起始画面图时有两条路。创作者用本套件生产:`用途:起始帧` 那一张由 `$short-drama-produce`
      用本镜的冻结关键帧正文生成,job 的 `source` 写 `剧集/<EP>/分镜.md`、`source_entry` 写本镜 `SHOT-...`,
      产出落在 `剧集/<EP>/制作成果/images/`,拿到真实文件后再绑成 `REF-...`。创作者在自己的工具里生产:
      把冻结关键帧正文复制过去出图,再把文件放进项目绑成 `REF-...`,或者本轮先写成
      `PLAN-SHOT-START(顺序:<n>)· <本镜 SHOT-...>《本镜冻结关键帧》(用途:起始帧;……)`。
      两条路都没走时,它仍然只是「待补参考图」里的一项。
    - 创作者在项目外出图、图片不进项目时,用 `PLAN-...` 槽位:语法与 `REF-...` 逐字相同,只把项目相对路径
      换成 `IMG-...` 或 `SHOT-...` 条目 ID,`IMG-...` 的中文名称必须与《图片提示词.md》标题一致。
      它声明的是「这张图由创作者在生成时按这个顺序挂载」,不声称项目里有文件,因此不是「待补」,
      也不能拿来投产。真实文件进了项目就改写成 `REF-...`。两种槽位可以混用,`顺序` 统一连续编号。
    - 自动绑定不是把所有图都塞进一镜。只选本镜可见、且身份、造型、地理、关键道具状态或起始构图确实需要稳定的图;逐张写清它的用途、能控制什么、不得控制什么。
    - 两条轴可以独立组合:有合法的图片提示词项时,输入参考图仍可为「待补」或创作者明确选择的文生视频;创作者给了真实参考图时,图片提示词项也可为「无」。
    
    ## 按需知识
    
    默认只读本 SKILL、当前剧本和必要视觉事实。遇到对应问题时只打开一份:
    
    - 阶段边界与规则分级:[阶段契约](references/stage-contract.md)
    - 镜头职责、切镜理由和边界:[镜头手艺](references/shot-craft.md)
    - 实际制作所需的镜头语法:[生产镜头语法](references/production-shot-grammar.md)
    - 关键场次比较导演方案:[场次视觉计划](references/scene-visual-plan.md)
    - 检查原文是否都有画面承载:[Coverage Audition](references/coverage-audition.md)
    - 多人、群体和复杂空间调度:[调度手册](references/blocking-playbooks.md)
    - 谁被照亮、怎么写才执行得出:[光的设计](references/lighting-craft.md)
    - 冻结瞬间、注意中心和可读性:[关键帧手艺](references/keyframe-craft.md)
    - 漫剧/二维漫画形态的共享画风词汇:[漫剧关键帧词表](references/comic-keyframe-lexicon.md)
    - 剧本事实到关键帧的完整示例:[剧本到关键帧](references/screenplay-to-keyframe-example.md)
    - 拆镜、并镜和稳定镜头 ID:[镜头修订身份](references/shot-revision-identity.md)
    - 完成前的连续性检查:[审查与示例](references/review-and-fixtures.md)
    
    ## 修订
    
    保留未受影响的镜头 ID。拆镜、并镜或改变镜头职责时,说明哪些下游视频提示词需要刷新,但不自动
    重写用户没点名的文件。
    
    ## 完成
    
    点名范围的原文都有镜头落实,每镜职责不重复、空间与状态连续、起止边界可制作、关键帧可冻结,
    每镜「来源」解析到《剧本.md》的真实场景、剧本里每个场景都有镜头承载或已记入「未拍场次」,
    且每镜关键帧里的可见人物、地点、道具都在本镜「视觉依据」里,即完成。画面需要、但《视觉设定.md》
    还没有条目的可见物件,在末尾报告里逐项点名。本次请求同时修改
    《图片提示词.md》与《分镜.md》时,当轮刷新受影响镜头的引用;不另建覆盖表。
    视频提示词、生产和审查只有用户点名时开始。
    五份创作文档齐备后,可转 `$short-drama` 对跨文档结构做一次机械核对;内容质量仍由创作者审查。
    
    ## 安装维护
    
    只有安装、升级或排障时运行 `python3 scripts/selftest.py`。
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related