@oh-story/dsh 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +14 -14
  2. package/lib/client.js +147 -96
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +77 -247
  5. package/lib/drama/skills/short-drama/SKILL.md +9 -1
  6. package/lib/drama/skills/short-drama/references/creator-documents.md +51 -23
  7. package/lib/drama/skills/short-drama/references/reference-roles.md +1 -1
  8. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +442 -129
  9. package/lib/drama/skills/short-drama/scripts/project_tool.py +54 -1
  10. package/lib/drama/skills/short-drama-assets/SKILL.md +2 -6
  11. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +9 -3
  12. package/lib/drama/skills/short-drama-assets/references/era-anchors.md +3 -3
  13. package/lib/drama/skills/short-drama-assets/references/location-and-view.md +4 -0
  14. package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +2 -2
  15. package/lib/drama/skills/short-drama-develop/references/episode-design.md +19 -4
  16. package/lib/drama/skills/short-drama-develop/references/genre-cards//347/263/273/347/273/237/346/265/201.md +1 -1
  17. package/lib/drama/skills/short-drama-edit/SKILL.md +1 -1
  18. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +28 -6
  19. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +1 -1
  20. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +1 -5
  21. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
  22. package/lib/drama/skills/short-drama-produce/SKILL.md +2 -0
  23. package/lib/drama/skills/short-drama-review/SKILL.md +0 -4
  24. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +1 -1
  25. package/lib/drama/skills/short-drama-storyboard/SKILL.md +9 -9
  26. package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +43 -13
  27. package/lib/drama/skills/short-drama-storyboard/references/keyframe-craft.md +5 -5
  28. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +5 -5
  29. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +27 -3
  30. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -1
  31. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +6 -8
  32. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +16 -1
  33. package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +23 -6
  34. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +2 -2
  35. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +8 -1
  36. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +41 -2
  37. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +1 -1
  38. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -1
  39. package/lib/drama/skills/short-drama-write/SKILL.md +7 -3
  40. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +1 -1
  41. package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +94 -1
  42. package/lib/drama/skills/short-drama-write/scripts/screenplay_index.py +13 -4
  43. package/lib/drama/skills/short-drama-write/scripts/selftest.py +3 -77
  44. package/lib/index.js +18 -14
  45. package/lib/index.js.map +2 -2
  46. package/lib/novel-to-game/README.md +20 -34
  47. package/lib/novel-to-game/README_ZH.md +20 -34
  48. package/lib/novel-to-game/VERSION +1 -1
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +2 -6
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +33 -39
  51. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +2 -2
  52. package/lib/novel-to-game/manifest.json +46 -46
  53. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +1 -1
  54. package/lib/novel-to-game/skills/game-build/references/baked-sprite-workflow.md +2 -3
  55. package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +6 -7
  56. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +2 -2
  57. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +1 -4
  58. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +1 -3
  59. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +1 -3
  60. package/lib/novel-to-game/skills/game-concept/SKILL.md +1 -1
  61. package/lib/novel-to-game/skills/game-qa/SKILL.md +3 -4
  62. package/lib/novel-to-game/skills/game-qa/references/qa-contract.md +1 -1
  63. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +3 -2
  64. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +13 -14
  65. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +1 -2
  66. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +4 -4
  67. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +2 -3
  68. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +0 -8
  69. package/lib/oh-story/manifest.json +49 -49
  70. package/lib/oh-story/skills/story/SKILL.md +2 -2
  71. package/lib/oh-story/skills/story/VERSION +1 -1
  72. package/lib/oh-story/skills/story-deslop/SKILL.md +2 -2
  73. package/lib/oh-story/skills/story-import/SKILL.md +5 -5
  74. package/lib/oh-story/skills/story-import/references/deep-analysis.md +1 -1
  75. package/lib/oh-story/skills/story-import/references/import-report.md +1 -1
  76. package/lib/oh-story/skills/story-import/references/import-tracking.md +1 -1
  77. package/lib/oh-story/skills/story-import/references/outline-reverse.md +1 -1
  78. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +6 -1
  79. package/lib/oh-story/skills/story-long-analyze/SKILL.md +2 -2
  80. package/lib/oh-story/skills/story-long-write/SKILL.md +2 -2
  81. package/lib/oh-story/skills/story-long-write/references/agent-calls.md +3 -3
  82. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -2
  83. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +6 -1
  84. package/lib/oh-story/skills/story-review/SKILL.md +8 -7
  85. package/lib/oh-story/skills/story-review/references/agent-prompts.md +7 -7
  86. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -2
  87. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +6 -1
  88. package/lib/oh-story/skills/story-setup/SKILL.md +6 -6
  89. package/lib/oh-story/skills/story-short-write/SKILL.md +2 -2
  90. package/lib/oh-story/skills/story-short-write/references/agent-calls.md +3 -3
  91. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +2 -2
  92. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +1 -1
  93. package/lib/types/client/game-preview-state.d.ts +34 -0
  94. package/lib/types/client/game-preview-state.d.ts.map +1 -0
  95. package/lib/types/client/index.d.ts.map +1 -1
  96. package/lib/types/client/video-studio.d.ts +24 -0
  97. package/lib/types/client/video-studio.d.ts.map +1 -1
  98. package/lib/types/host-python.d.ts +10 -3
  99. package/lib/types/host-python.d.ts.map +1 -1
  100. package/lib/types/workspace-route.d.ts +6 -0
  101. package/lib/types/workspace-route.d.ts.map +1 -1
  102. package/package.json +21 -21
  103. package/lib/drama/skills/short-drama-assets/assets/character-look.example.jsonl +0 -3
  104. package/lib/drama/skills/short-drama-assets/assets/continuity.example.jsonl +0 -4
  105. package/lib/drama/skills/short-drama-assets/assets/decisions.example.jsonl +0 -5
  106. package/lib/drama/skills/short-drama-assets/assets/location-view.example.jsonl +0 -3
  107. package/lib/drama/skills/short-drama-assets/assets/occurrences.example.jsonl +0 -5
  108. package/lib/drama/skills/short-drama-assets/assets/prop-state.example.jsonl +0 -3
  109. package/lib/drama/skills/short-drama-assets/examples/minimal/characters.jsonl +0 -2
  110. package/lib/drama/skills/short-drama-assets/examples/minimal/looks.jsonl +0 -2
  111. package/lib/drama/skills/short-drama-assets/scripts/asset_check.py +0 -267
  112. package/lib/drama/skills/short-drama-assets/scripts/selftest.py +0 -89
  113. package/lib/drama/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +0 -2
  114. package/lib/drama/skills/short-drama-image-prompts/scripts/image_prompt_check.py +0 -443
  115. package/lib/drama/skills/short-drama-image-prompts/scripts/selftest.py +0 -95
  116. package/lib/drama/skills/short-drama-review/assets/finding-template.jsonl +0 -2
  117. package/lib/drama/skills/short-drama-review/assets/supersession-decision.example.json +0 -42
  118. package/lib/drama/skills/short-drama-review/assets/verdict-template.json +0 -32
  119. package/lib/drama/skills/short-drama-review/examples/minimal-findings.jsonl +0 -2
  120. package/lib/drama/skills/short-drama-review/examples/minimal-verdict.json +0 -34
  121. package/lib/drama/skills/short-drama-review/scripts/review_check.py +0 -325
  122. package/lib/drama/skills/short-drama-review/scripts/selftest.py +0 -87
  123. package/lib/drama/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +0 -2
  124. package/lib/drama/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +0 -12
  125. package/lib/drama/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +0 -2
  126. package/lib/drama/skills/short-drama-storyboard/scripts/selftest.py +0 -191
  127. package/lib/drama/skills/short-drama-storyboard/scripts/storyboard_check.py +0 -824
  128. package/lib/drama/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +0 -21
  129. package/lib/drama/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +0 -2
  130. package/lib/drama/skills/short-drama-video-prompts/assets/performance.fragment.json +0 -38
  131. package/lib/drama/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +0 -2
  132. package/lib/drama/skills/short-drama-video-prompts/scripts/container_check.py +0 -329
  133. package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +0 -451
  134. package/lib/drama/skills/short-drama-video-prompts/scripts/music_spec_check.py +0 -327
  135. package/lib/drama/skills/short-drama-video-prompts/scripts/selftest.py +0 -82
  136. package/lib/drama/skills/short-drama-write/scripts/voice_sheet_check.py +0 -416
@@ -188,7 +188,7 @@
188
188
  固定是这张表的最后一行,不是起点:前面几行都不成立时才固定。写法是
189
189
  「运镜:<行为>,<从什么时候开始、停在哪里>;<为什么>」,固定写「运镜:固定;<为什么>」。
190
190
  固定也要写出来:这一行空着,动不动就改由视频提示词阶段替你选。只写「运镜:固定」
191
- 说不出是上表哪一行,等于没有选。例如「运镜:缓推,他说出『给我五天』时开始,停在眼睛;这是他第一次把赌注
191
+ 说不出是上表哪一行,等于没有选。例如「运镜:缓推,他说出『这单我接了』时开始,停在眼睛;这是他第一次把赌注
192
192
  压在自己身上」「运镜:固定;插入镜只要看清数字」「运镜:固定;这句话没有落到谁身上」。
193
193
 
194
194
  强调手段包括短促推近、定格、闪白,只留给情绪爆发和重大反转,用在普通台词上,真正的峰值就
@@ -211,6 +211,20 @@
211
211
  时长要给动作、表演、对白和反应留出可读空间。明确的对白长度和计时动作可以作为证据;
212
212
  一般动作量是否过多,应由审查者结合本镜判断,不能用通用的每秒字数阻断。
213
213
 
214
+ **塞多了不会被删,会被压扁**。视频模型拿到一串动作时,通常不是丢掉后面几件,而是把每件都压成
215
+ 一闪而过:一组 Seedance 2.0 小样本里,5 秒镜头写 6 个连续动作,3 次都“做完”了,但从第三件起
216
+ 每件只占约半秒,起身是跳过去的,走开只剩一帧,前后动作叠在一起;写 4 个时多数停在第三件;
217
+ 写 2 个时动作从容但起得晚。这组数字只来自 5 秒镜头,不是通用的动作数上限;要做的是在分镜阶段就数一下
218
+ 这一镜要观众**看清**的事,按镜长判断每件有没有足够的时间,而不是等视频提示词阶段才发现。下面这些是超载的信号:
219
+
220
+ - 一个人要在几秒里依次完成好几件需要看清的事(拿起、放下、再拿起另一件、起身……),平均到每件不足一秒多;
221
+ - 几个角色各有独立的大动作,而这一镜的目的只需要一次揭示;
222
+ - 长对白、情绪转折、精细手部动作和复杂运镜挤在同一镜;
223
+ - 终点没有留出落住的时间,只能写“最后到达”。
224
+
225
+ 修法依次是:删掉没有故事功能的动作;把能连贯的合成一个;选一个注意中心,其余人只留必要反应;
226
+ 仍然放不下,就拆镜或延长,而不是在提示词里加“快速”“依次”。
227
+
214
228
  在变化处切镜:新信息、新目光、新决定、新空间关系、新威胁、新证据或节奏变化。
215
229
  不要只为换角度而重复同一个动作。给反应留下足够时间,让观众读懂后果。
216
230
 
@@ -308,8 +322,18 @@
308
322
  `### 收尾关键帧提示词`。它只描述终点,不混入起点或中间动作。
309
323
  若起终状态相同,或变化发生在画外,通常无需另做尾帧。
310
324
 
311
- Seedance 2.5 的一个镜头对照中,绑定尾帧改善了末帧落位;这不是所有模型和动作的保证。
312
- 生成后仍检查中间动作、节奏和终点,不能只比较最后一帧。
325
+ 尾帧管的是最后一格的构图,不管中间的动作做没做完。Seedance 2.5 的一个镜头对照中,绑定尾帧改善了
326
+ 末帧落位;Seedance 2.0 的一组递文件夹小样本里,首尾帧模式与“参考模式里写明图片作首尾帧”的末帧都
327
+ 贴近尾帧图(相似度 0.92–0.99),但 6 次里有 4 次文件夹没打开到位;只给首帧、终点写在正文里的 3 次
328
+ 反而都做完,动作也最顺。所以镜内的状态变化默认只出首帧,终点写进分镜;尾帧留给构图必须准确落位的
329
+ 地方,最常见的是下面的转场。生成后仍检查中间动作、节奏和终点,不能只比较最后一帧。
330
+
331
+ **跨镜转场**:相邻两镜要无缝接上时,先看生成路线。同一连续时空、目标档案支持续接时,默认从上一段
332
+ 的真实结果续接,下一镜的开场就是上一镜真实的尾帧,不需要另做图。创作者明确选择逐镜独立生成时,
333
+ 两镜在剪接点必须对上的那个状态用同一张已接受的图承载:先出下一镜的首帧并接受,再把它作为上一镜的
334
+ 尾帧;两镜分镜里的终点与起点写成同一状态。这只适用于剪接点两边构图相同的接法(同机位连续、同一
335
+ 构图里接动作);换景别或换角度的动作剪接只要求动作状态连续,两镜各用自己的机位,不共用一张图。
336
+ 两镜之间本来就有时间或空间跳变时,硬切即可。
313
337
 
314
338
  当前生产工具从 `分镜.md` 的 `SHOT-...` 只提取「冻结关键帧提示词」,即首帧。
315
339
  要在套件内生产尾帧,交图片提示词阶段建立单独的 `IMG-...` 条目,注明来自本 SHOT 的
@@ -28,7 +28,7 @@
28
28
  | 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. |
29
29
  | 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. |
30
30
  | 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. |
31
- | 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. |
31
+ | 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 the shot's `终点` field, 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. |
32
32
  | 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. |
33
33
  | 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. |
34
34
  | 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. |
@@ -41,6 +41,7 @@
41
41
  | 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. |
42
42
  | SHT-28 | craft_default | Shot durations come from the scene's sound-event timeline together with the accepted `target_avg_shot_seconds`: the timeline sets scene length, the target sets roughly how many cuts it holds, and the average is a scene-level target rather than a per-shot quota. Durations are never folded from screenplay word or paragraph counts. Under an accepted rhythm profile, close-range shots, as the profile field defines them, make up at least `close_shot_share_min` of the episode's shots. A shot shorter than the model's native minimum is designed at its narrative length, and the storyboard proposes to the creator a multi-shot container (多镜容器) of adjacent same-scene shots, declared in the delivery profile and noting that the target dialect may require an accepted keyframe for every cut; until the creator declares one, or where the dialect cannot carry it, the shot runs to the native minimum and holds its end state. A missing or proposed profile leaves the numeric checks inactive. |
43
43
  | SHT-29 | craft_default | Dialogue coverage starts from one line per shot, a listener or reaction shot where a line lands, shot/reverse-shot for exchanges, an establishing shot that carries people and action, medium framings for the talk, and close-ups only where a line must land; shots with neither line nor action are cut, and inserts (a phone screen, a hand, an object) are used as cheap rhythm tools. A long line is split across speaker and listener shots rather than sped up. |
44
+ | SHT-30 | craft_default | Blocking is recorded in two layers. The storyboard's 场景站位 block and each shot's 起点/终点 place people against fixed set anchors and each other (behind the desk, facing the door, across the table from her), which survives a camera change; a scene filmed in more than one shot keeps one 场景站位 block at the head of 分镜.md—fixed anchors, each character's position, the axis and working side, and the shots where positions change—and each shot is checked against it rather than only against the previous shot. Keyframe and video prompts translate that into what this camera sees: where the camera is, who is in foreground or background, who faces or turns from the lens, what lies between them, and screen side; an abstract relation alone ("across the desk") is not relied on to set depth. Gaze names what it lands on, an off-screen target keeping its screen side as well so reverse shots match eyelines. Under image-to-video the start frame carries position. |
44
45
 
45
46
  ### `CON`
46
47
 
@@ -101,9 +101,11 @@ license: MIT
101
101
  可以保留;不按否定句数量判错。确认不跨下一镜、不要求互斥动作、不让人物或道具瞬移变形。
102
102
 
103
103
  14. 用户要整集就完成整集,镜头组只是内部批次。
104
- 15. 同一连续时空中相邻正式生成段、且目标档案支持续接时,默认选择“从上一段生成结果续接”;
105
- 一镜一生成只决定每次装一个镜头,不等于各镜独立文生。只有创作者明确要求独立重生成,或上游
106
- 已接受时间/地点跳变时才重新起链。选择续接后,这就是该链唯一正式执行路由。第二段起同时绑定上一段
104
+ 15. 同一连续时空中相邻正式生成段、且目标档案支持续接时,默认选择“从上一段生成结果续接”。续接的前提是下一段
105
+ 从上一段最后一格接着拍:两段之间是剪接(换景别、换机位、正反打)时,上一段的尾帧不是下一段的开场,
106
+ 这两段各自从已接受起点生成,不进同一条续接链;创作者选择文生视频只说明不挂参考图,不等于放弃续接;
107
+ 一镜一生成只决定每次装一个镜头,不等于各镜独立文生。两段之间是剪接、创作者明确要求独立重生成,
108
+ 或上游已接受时间/地点跳变时重新起链。选择续接后,这就是该链唯一正式执行路由。第二段起同时绑定上一段
107
109
  **实际视频**和从该视频取得的
108
110
  **实际尾帧**:视频负责动作、节奏和声音连续,尾帧负责下一段的可见起点。具体素材 role 和续写措辞
109
111
  由命中的模型方言决定,不能把通用职责直接翻译成供应商字段。未取得真实产物时,后续段在
@@ -159,8 +161,4 @@ license: MIT
159
161
 
160
162
  每个点名镜头都有明确起点、主变化、时序、运镜/声音边界和可验证终点,且与相邻镜头连续,即完成。
161
163
  实际生成转 `$short-drama-produce`,先展示精确 job 并取得显式确认;本技能不调用外部服务。
162
- 五份创作文档齐备后,可转 `$short-drama` 对跨文档结构做一次机械核对;内容质量仍由创作者审查。
163
-
164
- ## 安装维护
165
-
166
- 只有安装、升级或排障时运行 `python3 scripts/selftest.py`。
164
+ 写完本阶段文档后,可转 `$short-drama` 做局部机械核对(只核对已有文档);五份齐备后再做完整核对。内容质量仍由创作者审查。
@@ -249,9 +249,24 @@ ID 或网址。即使引用齐全,本套件也只证明文本绑定一致,
249
249
  「路径本身是戏」的镜头(夺取过程、跟随移动、逐步逼近)不适合交给两端夹逼。
250
250
  - 运动规格照旧要写清动作顺序、对白落点与摄影机行为,**不因为有了尾帧就省略**。
251
251
  尾帧说明「到哪」,运动规格说明「怎么到、中途观众必须看到什么」,两者不重复也不互替。
252
- - 终点报告仍然逐字段对照分镜的 `end_boundary`,**不对照尾帧**。尾帧只是该边界的投影,
252
+ - 终点报告仍然逐字段对照分镜的「终点」,**不对照尾帧**。尾帧只是该边界的投影,
253
253
  画面对上了不等于终点事实到达了——尤其是持物归属和目光对象,两者最容易只在构图上成立。
254
254
 
255
+ 一组 Seedance 2.0 小样本正好说明了最后一条:首尾帧模式下末帧与尾帧图的相似度到 0.97,但 3 次里
256
+ 有 2 次文件夹只掀开一角,没有到达分镜写的“摊开在桌上”。同一镜只给首帧、终点写在正文里的 3 次都
257
+ 到达了,动作也最顺。镜内动作默认只交首帧。
258
+
259
+ ### 跨镜转场:尾帧与下一镜首帧是同一张图
260
+
261
+ 这条只用于创作者明确选择逐镜独立生成的相邻镜头。同一连续时空、目标档案支持续接时,默认仍按
262
+ “从上一段生成结果续接”的路线,用上一段的真实视频和真实尾帧,不用计划图代替。
263
+
264
+ 独立生成且分镜为两镜指定“同一张图接”时,上一镜按首尾帧交付,尾帧就是下一镜已接受的首帧;下一镜
265
+ 照常从这张图起。正文里上一镜的终点与下一镜的起点逐字对应,不在任何一边追加图上没有的变化。分镜只会
266
+ 在剪接点两边构图相同时这样指定;换景别、换角度的动作剪接只对齐动作状态,各镜用自己的首帧。
267
+ Seedance 2.0 的首帧、首尾帧与全模态参考三种方式互斥:上一镜还要挂人物或道具参考时,改用参考模式
268
+ 并在正文写明哪张图作首帧、哪张作尾帧;同一组样本里这样写,末帧与尾帧图的相似度为 0.92–0.99。
269
+
255
270
  ### 修订请求
256
271
 
257
272
  ```markdown
@@ -83,9 +83,11 @@
83
83
  `视频提示词.md` 只标一个正式执行路由;其他粒度的正文若因可审查性而保留,要明确标成成员依据。
84
84
 
85
85
  这里的“逐镜 / 多镜容器”是打包粒度,不自动表示独立生成。同一场次、时间连续、地点与资产绑定链
86
- 不变的相邻正式段,在目标档案支持续接时默认组成实际结果接力链:第一段从已接受起点生成,第二段起
87
- 等待上一段实际视频和实际尾帧。只有创作者明确选择独立重生成,或已接受内容发生时间/地点跳变时才
88
- 重新起链;不能仅凭 `one-shot-per-generation` 把每镜都解释成互不相干的文生任务。
86
+ 不变、且两段之间没有剪接(下一段从上一段最后一格接着拍)的相邻正式段,在目标档案支持续接时默认组成
87
+ 实际结果接力链:第一段从已接受起点生成,第二段起等待上一段实际视频和实际尾帧。以下情况重新起链,
88
+ 各段从已接受起点生成:两段之间是换景别、换机位或正反打的剪接(上一段尾帧不是下一段的开场);创作者
89
+ 明确选择独立重生成;已接受内容发生时间/地点跳变。不能仅凭 `one-shot-per-generation` 把每镜都解释成
90
+ 互不相干的文生任务。
89
91
 
90
92
  ## 二、容器算术(唯一允许的数字规则)
91
93
 
@@ -106,9 +108,24 @@
106
108
  **`structural_invariant`**:容器只能承载已接受镜头;容器时长等于其成员镜头已接受时长
107
109
  之和;镜头边界、镜头目的与逐镜独立可审查性不因打包发生变化(VID-13)。
108
110
 
109
- 这条不变式必须**可本地核对**,所以需要多镜打包时,在 `视频提示词.md` 增加“交付分组”章节:
110
- 用稳定组 ID 依次列出成员 `MOTION-...` / `SHOT-...`、各镜时长、容器总时长,以及同场、连续、
111
- 同一地理/资产链的理由。单镜交付不需要额外章节。
111
+ 这条不变式必须**可本地核对**,所以需要多镜打包时,在 `视频提示词.md` 里为每个容器写一个
112
+ `GROUP-...` 条目。它的 `### 可复制提示词` 就是真正提交给执行端的那一条正文;成员 `MOTION-...`
113
+ 仍按逐镜写,作为各成员的依据和逐镜审查对象。单镜交付不需要这个条目。
114
+
115
+ ```markdown
116
+ ## GROUP-EP001-01 · 门外到门内
117
+ - 成员:MOTION-EP001-001、MOTION-EP001-002
118
+ - 时长:8s
119
+ - 理由:同场连续,同一走廊地理与人物造型
120
+
121
+ ### 可复制提示词
122
+ > 镜头1:…… 镜头2:……
123
+ ```
124
+
125
+ 检查器核对:成员都是已有的 `MOTION-...`,按分镜顺序相邻、不跨场次;一个镜头最多进一个分组;
126
+ `时长` 等于成员时长之和;正文里的引文能在上游找到;生效范围覆盖任一成员的连续性锁,锁面也在
127
+ 分组正文里。同一地理/资产链与蒙太奇的判断仍由审查核对。目标模型只收整数秒时,分组时长也要是
128
+ 整数秒。套件内置的生产工具目前只提交逐镜 `MOTION-...`,分组正文由创作者复制到自己的生成工具里提交。
112
129
 
113
130
  ### 双向和不变式
114
131
 
@@ -85,7 +85,7 @@ reviewer 结合完整故事义务决定,不能让未承担内容随更短提
85
85
  镜头目的:观众的注意、信息或情绪要怎样改变
86
86
  时长:已确认时长
87
87
  起点:姿态 / 重心 / 目光 / 双手 / 持物 / 空间关系
88
- 终点:同一组字段的已确认 `end_boundary`
88
+ 终点:同一组字段的已确认「终点」
89
89
  对白/声音:准确来源引用 + 已确认的表演或声源要求
90
90
  持续关系:限制本镜动作空间的身体—物件—空间关系及有效范围
91
91
  下一镜:下一镜起点引用(只用于比较)
@@ -228,7 +228,7 @@ reviewer 结合完整故事义务决定,不能让未承担内容随更短提
228
228
  ### 3.7 终点核对
229
229
 
230
230
  在同一 `MOTION-...` 条目核对姿态、位置、目光、双手、持物和可见状态变化。
231
- 逐项对照分镜的 `end_boundary`:
231
+ 逐项对照分镜的「终点」:
232
232
 
233
233
  ```text
234
234
  reported_end == source end:match
@@ -101,7 +101,14 @@
101
101
 
102
102
  ### 空间连续
103
103
 
104
- - 位置写**相对参照**("病床右侧""右肩上"),不写绝对坐标;
104
+ - 位置从分镜的场景站位与起点出发,翻成本镜机位下看得见的样子:机位在哪、谁在前景谁在后景、
105
+ 谁面对谁背对镜头、两人之间隔着什么、各自在画面哪一侧。只写"隔着桌子相对""在他身后"这类
106
+ 抽象关系时,模型常把人放错深度;简单的画面左右可以照写;
107
+ - **视线写看向谁或什么**("看着赵经理""盯着桌上的工牌"),不只写"看向画右""目光落在画左后方"。
108
+ 目标在画外时写明是画外的谁,并保留分镜给的方向("看向画外左侧的赵经理"),正反打的视线才接得上;
109
+ 文生视频里还要写明对方不入画,免得模型把人补进画面;
110
+ - 图生视频时,站位由起始帧承载;正文只写本镜动作需要的关系与位移,不再重申起始帧已经画出的站位,
111
+ 免得文字与画面打架;
105
112
  - **裸写的"左/右"一律指画面左右**(观众看到的左右);指人物自身的左右必须带主体
106
113
  ("他的左手""她的右肩")。写作和审查时都要核对这两种参照,避免正反打方向翻转;
107
114
  - 场景资产用方位变体逐镜选用(`探视室(北)`/`篝火(近)`),正反打双方各绑
@@ -8,7 +8,7 @@
8
8
  "video_prompt_dialect": "seedance-2.0",
9
9
  "video_prompt_language": "zh-CN",
10
10
  "native_duration_seconds": {"min": 4, "max": 15},
11
- "supported_generation_modes": ["text", "reference", "extend"],
11
+ "supported_generation_modes": ["text", "first_frame", "first_last_frame", "reference", "extend"],
12
12
  "audio_generation": "same_pass"
13
13
  }
14
14
  ```
@@ -28,6 +28,14 @@
28
28
  - 每个音效事件写 `<声音>`,音乐写 `(音乐)`。字幕只有创作者明确开启时才用 `【字幕】`;默认禁字幕时正文
29
29
  明写“全程保持无字幕,不生成对白文字叠加”,并且不出现任何 `【】` 字幕指令。画内获准文字仍逐字说明承载面和位置。
30
30
 
31
+ ## 站位与视线
32
+
33
+ - 人物的前后关系按机位写:机位在哪、谁在前景谁在后景、谁背对镜头、两人之间隔着什么。
34
+ 小样本里,“走到桌前、隔着桌子与他面对面”这种只写关系的说法九次都被放成了同侧;改写成
35
+ “机位在桌前,她走进前景、背对镜头停在桌子这一边,桌子在她和他之间”后三次都对。
36
+ - 简单的画面左右(“男人在画面左侧”“她从右侧门口进来”)在同一组样本里都被遵循,可以写。
37
+ - 视线写看着谁;对方在画外时写明在画外、不入画,免得模型把人补进画面。
38
+
31
39
  ## 参考与续写
32
40
 
33
41
  - 每次提及素材都重复绑定:`人物 @图片1`、`参考 @视频1 的运镜`、`参考 @音频1 的音色`,不要只在开头列素材清单。
@@ -35,6 +43,37 @@
35
43
  `@图片1` 指向该视频的实际尾帧,说明它是新段开场的可见状态;不要再把任务写成“参考上一段视频”。
36
44
  - 实际视频与实际尾帧缺一时不宣称连续生成。素材序号来自当次 job 的真实绑定,不沿用文档里的 REF ID 猜序号。
37
45
 
46
+ ## 三种起点互斥
47
+
48
+ 官方把首帧、首尾帧和全模态参考列为三种互斥场景,同一次生成不能混用:
49
+
50
+ - **首帧**:1 张图作为开场。
51
+ - **首尾帧**:2 张图,分别标为首帧和尾帧。
52
+ - **全模态参考**:参考图 0–9 张、参考视频最多 3 段、参考音频最多 3 段;视频与音频每段 2–15 秒,
53
+ 各自合计不超过 15 秒;音频不能单独输入,至少要有 1 张图或 1 段视频。
54
+
55
+ 所以“起始帧 + 人物图 + 地点图”只能走全模态参考,在正文里写明“@图片1 作为首帧”。官方说明这是间接
56
+ 实现,要求开场或收尾严格对上某张图时用首帧或首尾帧模式,此时人物和地点只能靠那张图本身承载。
57
+ 分镜同时绑了起始帧和其他参考时,按上面的取舍写清这一镜走哪条路,不把两条路的素材混进一次请求。
58
+
59
+ ## 正文写多长
60
+
61
+ 官方建议中文提示词不超过 500 字、英文不超过 1000 词,并说明字数过多时模型可能只抓重点、漏掉部分元素。
62
+ 这是建议,不是会被拒绝的硬上限。一组小样本里(同一镜头分别写约 100、460、800 字,各 3 次),放在
63
+ 后段的三个可核对细节并没有因为长而明显丢失;800 字那组的问题是多写的背景都想进画面:正文里随手
64
+ 提到的猫、出租车被画了出来,还有一次从街角硬切到正文里描写的公交站。所以控制长度的办法是只写这一镜
65
+ 需要被看见的东西,不靠删字凑数;超过 500 字时先删不进画面的环境铺陈,连续性锁的锁面和逐字对白不删。
66
+ 文生视频没有参考图承载外观,人物、服装和锁面都要写在正文里,多镜容器的组正文更会超过 500 字;
67
+ 这是这条路线的代价,按上面的顺序删到只剩画面需要的事实即可,不要为了字数删掉身份锚点。
68
+
69
+ ## 参考图挂多少
70
+
71
+ 全模态参考最多 9 张图,这是上限,不是目标。每张图都要在本镜有具体职责:起始帧、正在说话或动作的
72
+ 人物身份、必须认得出的道具、地点。一组递文件夹的小样本里,挂 7 张(起始帧、两张人物、地点、文件夹、
73
+ 台灯、定调图)与挂 3 张(起始帧、两张人物)相比,没有看到身份或画面变差;倒是没挂文件夹的那组,
74
+ 3 次里有 2 次文件夹在交接中变成了夹板。必须认得出的道具要挂图,不能只靠文字。超过上限时的取舍顺序
75
+ 见 [目标模型能力档案](target-model-profile.md#参考条件方式)。
76
+
38
77
  ## 时长与边界
39
78
 
40
79
  Seedance 2.0 手动指定的整数时长是 4–15 秒。镜头进入本阶段前就应落在区间内。短于 4 秒的叙事动作放进
@@ -46,5 +85,5 @@ Seedance 2.0 手动指定的整数时长是 4–15 秒。镜头进入本阶段
46
85
  固定镜长交付仍传已接受的正整数;采用自动时长时说明它是自适应生成,返回后按实际视频重新核对剪辑时长、
47
86
  逐字完整性和语速。自动选择不会突破 15 秒上限,也不能替代超长对白的拆镜。
48
87
 
49
- 依据(2026-09-07 核对):[视频生成 API](https://docs.volcengine.com/docs/82379/1520757)、
88
+ 依据(2026-09-07 核对;三种起点互斥、素材上限与正文长度建议 2026-10-20 核对):[视频生成 API](https://docs.volcengine.com/docs/82379/1520757)、
50
89
  [Seedance 2.5 指南中的 2.0/2.5 时间控制差异](https://docs.volcengine.com/docs/82379/2607689)。
@@ -55,7 +55,7 @@
55
55
  ### 音色参考
56
56
 
57
57
  目标接口支持 `reference_audio` 时,可用已接受的角色参考音频辅助约束声音:在本镜写一行
58
- `参考音频`(写法见本技能 SKILL 的字段说明),正文照 `顺序` 写「江晨说话时音色参考 @音频1」。
58
+ `参考音频`(写法见本技能 SKILL 的字段说明),正文照 `顺序` 写「沈一舟说话时音色参考 @音频1」。
59
59
  参考可以来自预置音色合成或已授权录音,选型由视觉设定记录,生成后回听确认。
60
60
  样片档实测:绑一段该角色另一句话的合成做参考,青年角色 6 镜里 5 镜的声音明显靠近参考,中年角色 3 镜没有变化;
61
61
  把本句台词的合成挂上去,模型仍重新发声,不会原样播放。所以绑定只是提高命中,逐镜回听不能省。
@@ -84,9 +84,13 @@ Brief、剧本或声音方向若已经点名排除外语前导、语气词、额
84
84
  | 无参考 | 文生视频:静态视觉锚点必须自足,写清人物、造型、地点、构图与光线中本镜必要的事实 |
85
85
  | 首帧 | 只写从该起点开始的变化;静态外观由参考帧与视觉设定共同拥有 |
86
86
  | 首尾帧 | 终点帧只能投影 `SHOT-...` 已写明的终点,不得发明新结果 |
87
- | 多槽参考 | 每张按 `REF-...` 槽位记账,绑定顺序、路径、中文名、用途与控制/不得控制范围;槽位数不超过声明上限,超出时按重要性取舍并写明放弃了哪些 |
87
+ | 多槽参考 | 每张按 `REF-...` 槽位记账,绑定顺序、路径、中文名、用途与控制/不得控制范围;槽位数不超过声明上限,超出时按下面的顺序取舍并写明放弃了哪些 |
88
88
  | 多槽参考(创作者自备) | 同上,但图片不在项目里,槽位改用 `PLAN-...`、定位符写 `IMG-...` 或 `SHOT-...`;正文写法与上一行完全相同,`顺序` 就是创作者在生成界面里挂图的次序。这一行与目标模型无关,任何支持多槽参考的执行端都适用 |
89
89
 
90
+ 超出槽位上限时,按本镜失去它的代价从高到低保留:起始帧;正在说话或做主要动作的人物身份;
91
+ 本镜必须认得出的道具(交接、证物、持物);地点或地理;画面里但不说话不动作的人物身份;定调图与
92
+ 风格图。先放弃的那些,把必要事实写进正文和静态视觉锚点。
93
+
90
94
  无论哪一种,《视觉设定.md》连续性锁的锁面都留在正文里:参考帧只约束它自己画到的部分,
91
95
  跨镜不变的颜色、材质与形制仍然要逐镜带走。
92
96
 
@@ -86,9 +86,13 @@ python3 "{技能目录}/scripts/duration_estimate.py" "剧集/EP001/剧本.md" -
86
86
  `--previous-index <同一路径>` 和 `--previous-source <旧剧本快照路径>`。没有旧快照或要从头重编号时,
87
87
  加 `--no-previous`,也可以回到第一条命令另取一个临时路径。
88
88
 
89
- 估时前区分计数口径:`dialogue_characters` 包含标点,视频提示词中的参考语速按可发声字数计算。
90
- 按语速换算时排除标点,并按实际读法处理数字和缩写;不把两个计数混用。
89
+ 估时前区分计数口径:`dialogue_characters` 包含标点,用于 `[VO]` 占比;`voiced_characters` 去掉标点,
90
+ 脚本按它和语速换算秒数。数字和缩写仍按实际读法另行估计,不把两个计数混用。
91
91
  项目已接受节奏档案、上限有效且本集有台词时,输出增加 `vo_share`:`[VO]` 字数占全部台词字数的比例、档案上限,以及是否超过。`[OS]` 计入全部台词,不计入 `[VO]`。
92
+ `[画面文字]`、`[SFX]`、`[转场]` 占用画面时间,但不在秒数里,输出的 `untimed` 列出它们的行数。
93
+ 项目在 `format.pacing` 里另声明了 `on_screen_text_characters_per_second`(观众每秒读几个字)时,
94
+ `[画面文字]` 标签后的全部文字(含载体名)按它计入秒数(`on_screen_text_seconds`)。唱段按说话语速计时,实际通常更长。
95
+ 画面文字多、唱段长的集,估算明显低于成片,要按这几项另加读字和停留时间再对照目标。
92
96
 
93
97
  估算是参考,不是门禁;没有语速或动作段速率时只报告可数事实,不猜秒数。但任务已给目标时长时,
94
98
  写作者仍须用真实朗读和动作通读判断能否容纳,并直接压缩重复节拍。
@@ -97,7 +101,7 @@ python3 "{技能目录}/scripts/duration_estimate.py" "剧集/EP001/剧本.md" -
97
101
 
98
102
  用户点名的范围已写入,场景因果连贯,对白与行动可表演,承诺得到兑现或有意延迟,结尾完成本集预定的戏剧或情绪功能,即完成。最后一拍按本集结尾功能判断,不以新增事件作为完成条件。
99
103
  资产、分镜、审查与生产只有用户点名时开始。
100
- 五份创作文档齐备后,可转 `$short-drama` 对跨文档结构做一次机械核对;内容质量仍由创作者审查。
104
+ 写完本阶段文档后,可转 `$short-drama` 做局部机械核对(只核对已有文档);五份齐备后再做完整核对。内容质量仍由创作者审查。
101
105
 
102
106
  ## 安装维护
103
107
 
@@ -101,7 +101,7 @@
101
101
  - 建索引前由写作所有者确认本集说话者清单;工具只核对清单,不从冒号前缀猜人物;
102
102
  - 提示描述一个可执行策略或变化,不堆叠同义情绪词;
103
103
  - 台词跨段确有必要时保持说话者清楚;
104
- - 现场不可见说话者使用 `[OS]`,非现场主观/叙述声音使用 `[VO]`。
104
+ - 现场不可见说话者使用 `[OS]`,非现场主观/叙述声音使用 `[VO]`;两者同样可带提示:`[OS] 船员(隔着门喊):关窗!`。
105
105
 
106
106
  **`reviewed_invariant · SCR-04`**:对白质量由议程、关系、潜台词与造成的变化判断,不由长度或比例判断。
107
107
 
@@ -27,6 +27,7 @@ import argparse
27
27
  import json
28
28
  import math
29
29
  import re
30
+ import unicodedata
30
31
  import sys
31
32
  from pathlib import Path
32
33
  from typing import Any, Iterable, Mapping
@@ -54,6 +55,14 @@ VOICE_TAG_PREFIX = re.compile(r"^\[(?:VO|OS)\]\s*")
54
55
  DIALOGUE = re.compile(r"^(?P<who>[^::((\[\]]{1,24})(?:([^)]*)|\([^)]*\))?[::](?P<line>.+)$")
55
56
 
56
57
 
58
+ # Tags that occupy screen time without being spoken. Their seconds depend on how
59
+ # the cut holds them, so they are counted and named rather than timed -- unless
60
+ # the project declares how fast its audience reads on-screen text.
61
+ ON_SCREEN_TEXT_TAG = "画面文字"
62
+ UNTIMED_TAGS = (ON_SCREEN_TEXT_TAG, "SFX", "转场")
63
+ TAG_PREFIX = re.compile(r"^\[[^\]]+\]\s*")
64
+
65
+
57
66
  class StaleIndex(Exception):
58
67
  """The index does not describe the screenplay it was handed."""
59
68
 
@@ -64,6 +73,37 @@ def _spoken_characters(line: str) -> int:
64
73
  return len(re.sub(r"\s", "", stripped))
65
74
 
66
75
 
76
+ def _voiced_characters(line: str) -> int:
77
+ """What a speaker actually pronounces: the spoken count without punctuation.
78
+
79
+ Speech rates are measured in pronounced characters, so seconds come from
80
+ this count; the punctuation-inclusive count stays for the VO share, whose
81
+ ceiling is defined on it.
82
+ """
83
+ stripped = re.sub(r"([^)]*)|\([^)]*\)", "", line)
84
+ return sum(
85
+ 1
86
+ for character in re.sub(r"\s", "", stripped)
87
+ if not unicodedata.category(character).startswith("P")
88
+ )
89
+
90
+
91
+ def _readable_characters(body: str) -> int:
92
+ """Characters a viewer reads in an on-screen text line.
93
+
94
+ The whole tag body counts, brackets and any text before a colon included.
95
+ ``已授予:演唱`` puts displayed words before its colon and ``暂停营业(东门)``
96
+ displays its parentheses, so neither can be stripped as a carrier name or a
97
+ performer's note; a line that does name its carrier is over-read by a few
98
+ characters rather than having visible words silently dropped.
99
+ """
100
+ return sum(
101
+ 1
102
+ for character in re.sub(r"\s", "", body)
103
+ if not unicodedata.category(character).startswith("P")
104
+ )
105
+
106
+
67
107
  def _is_voiced(block: Mapping[str, Any]) -> bool:
68
108
  """Speech is a dialogue block, or a production tag that is spoken aloud."""
69
109
  kind = block.get("kind")
@@ -93,9 +133,12 @@ def measure(screenplay: bytes, blocks: Iterable[Mapping[str, Any]]) -> dict[str,
93
133
  """Count the timed material, taking every classification from the index."""
94
134
  dialogue_lines = 0
95
135
  dialogue_characters = 0
136
+ voiced_characters = 0
96
137
  voiceover_characters = 0
97
138
  action_paragraphs = 0
98
139
  tag_lines = 0
140
+ untimed_lines = {tag: 0 for tag in UNTIMED_TAGS}
141
+ on_screen_text_characters = 0
99
142
  unreadable_blocks: list[str] = []
100
143
 
101
144
  for block in blocks:
@@ -114,6 +157,17 @@ def measure(screenplay: bytes, blocks: Iterable[Mapping[str, Any]]) -> dict[str,
114
157
  # performed duration. Comments are not production content at all.
115
158
  if kind == "production_tag":
116
159
  tag_lines += 1
160
+ tag = block.get("tag")
161
+ if tag in untimed_lines:
162
+ untimed_lines[tag] += 1
163
+ if tag == ON_SCREEN_TEXT_TAG:
164
+ text = _block_text(screenplay, block)
165
+ if text is None:
166
+ unreadable_blocks.append(str(block.get("block_id")))
167
+ else:
168
+ on_screen_text_characters += _readable_characters(
169
+ TAG_PREFIX.sub("", text)
170
+ )
117
171
  continue
118
172
 
119
173
  text = _block_text(screenplay, block)
@@ -130,16 +184,24 @@ def measure(screenplay: bytes, blocks: Iterable[Mapping[str, Any]]) -> dict[str,
130
184
  dialogue_lines += 1
131
185
  characters = _spoken_characters(spoken.group("line"))
132
186
  dialogue_characters += characters
187
+ voiced_characters += _voiced_characters(spoken.group("line"))
133
188
  if block.get("tag") == "VO":
134
189
  voiceover_characters += characters
135
190
 
136
191
  counts: dict[str, Any] = {
137
192
  "dialogue_lines": dialogue_lines,
138
193
  "dialogue_characters": dialogue_characters,
194
+ "voiced_characters": voiced_characters,
139
195
  "voiceover_characters": voiceover_characters,
140
196
  "action_paragraphs": action_paragraphs,
141
197
  "production_tag_lines": tag_lines,
142
198
  }
199
+ if any(untimed_lines.values()):
200
+ counts["untimed_tag_lines"] = {
201
+ tag: number for tag, number in untimed_lines.items() if number
202
+ }
203
+ if untimed_lines[ON_SCREEN_TEXT_TAG]:
204
+ counts["on_screen_text_characters"] = on_screen_text_characters
143
205
  if unreadable_blocks:
144
206
  counts["unreadable_blocks"] = sorted(unreadable_blocks)
145
207
  return counts
@@ -184,10 +246,20 @@ def declared_rates(project: dict[str, Any] | None) -> dict[str, Any]:
184
246
  usable = isinstance(per_second, (int, float)) and per_second > 0 and (
185
247
  isinstance(per_action, (int, float)) and per_action >= 0
186
248
  )
249
+ reading = pacing.get("on_screen_text_characters_per_second")
250
+ reading_usable = (
251
+ isinstance(reading, (int, float))
252
+ and not isinstance(reading, bool)
253
+ and math.isfinite(reading)
254
+ and reading > 0
255
+ )
187
256
  return {
188
257
  "declared": bool(usable),
189
258
  "spoken_characters_per_second": per_second if usable else None,
190
259
  "seconds_per_action_paragraph": per_action if usable else None,
260
+ # Optional: a project that holds text on screen as its own beat can
261
+ # declare how fast it is read. Without it the text stays untimed.
262
+ "on_screen_text_characters_per_second": reading if reading_usable else None,
191
263
  }
192
264
 
193
265
 
@@ -258,6 +330,22 @@ def estimate(
258
330
  ),
259
331
  }
260
332
 
333
+ untimed = dict(counts.get("untimed_tag_lines") or {})
334
+ if rates["on_screen_text_characters_per_second"] is not None:
335
+ untimed.pop(ON_SCREEN_TEXT_TAG, None)
336
+ if untimed:
337
+ # These lines take screen time this estimate cannot see. A storyboard
338
+ # that holds each one long enough to read lands well above the number
339
+ # below, so the gap is named here instead of surfacing two stages later.
340
+ result["untimed"] = {
341
+ "tag_lines": untimed,
342
+ "note": (
343
+ "these production tags take screen time that is not in the "
344
+ "seconds; declare format.pacing.on_screen_text_characters_per_second "
345
+ "to time on-screen text by its readable characters"
346
+ ),
347
+ }
348
+
261
349
  if not rates["declared"]:
262
350
  result["note"] = (
263
351
  "the project declares no format.pacing rates, so seconds cannot be "
@@ -270,9 +358,14 @@ def estimate(
270
358
  return result
271
359
 
272
360
  seconds = (
273
- counts["dialogue_characters"] / rates["spoken_characters_per_second"]
361
+ counts["voiced_characters"] / rates["spoken_characters_per_second"]
274
362
  + counts["action_paragraphs"] * rates["seconds_per_action_paragraph"]
275
363
  )
364
+ reading = rates["on_screen_text_characters_per_second"]
365
+ if reading is not None and counts.get("on_screen_text_characters"):
366
+ reading_seconds = counts["on_screen_text_characters"] / reading
367
+ result["on_screen_text_seconds"] = round(reading_seconds, 1)
368
+ seconds += reading_seconds
276
369
  result["seconds"] = round(seconds, 1)
277
370
  if result["target_seconds"]:
278
371
  delta = seconds - result["target_seconds"]
@@ -146,8 +146,10 @@ KIND_CODES = {
146
146
  # digits would have made EP1000 a legal directory but an illegal heading.
147
147
  _EPISODE = r"EP(?:[0-9]{3}|[1-9][0-9]{3,})"
148
148
  EPISODE_HEADING_RE = re.compile(rf"^# (?P<episode>{_EPISODE})(?: .+)?$")
149
+ # A project that scopes ids by season writes `S01-EP001-SC001`; the episode is
150
+ # still the `EP` segment, and the cross-document checker accepts the same form.
149
151
  SCENE_HEADING_RE = re.compile(
150
- rf"^## (?P<scene>{_EPISODE}-SC[0-9]{{3}}) "
152
+ rf"^## (?P<scene>(?:[A-Za-z0-9]+-)*?(?P<episode>{_EPISODE})-SC[0-9]{{3}}) "
151
153
  r"(?P<space>内外|内|外) · (?P<location>\S(?:.*\S)?) · "
152
154
  r"(?P<time>\S(?:.*\S)?)$"
153
155
  )
@@ -174,7 +176,12 @@ FULLWIDTH_TAG_RE = re.compile(
174
176
  r"(?!\s*(?:([^()\r\n]*)|\([^()\r\n]*\))?\s*[::])"
175
177
  )
176
178
  MALFORMED_TAG_RE = re.compile(r"^\[(?:VO|OS|SFX|画面文字|连续性|转场)(?:\s|:|:)")
177
- VOICE_TAG_BODY_RE = re.compile(r"^(?P<speaker>[^\s:():\[\]#]{1,40}):(?P<text>\S[\s\S]*)$")
179
+ # A voiced tag carries the same optional performance cue as plain dialogue
180
+ # (``[OS] 角色(提示):台词``); the duration estimate already times it that way.
181
+ VOICE_TAG_BODY_RE = re.compile(
182
+ r"^(?P<speaker>[^\s:():\[\]#]{1,40})"
183
+ r"(?:((?P<cue>[^()\r\n]+)))?:(?P<text>\S[\s\S]*)$"
184
+ )
178
185
  BLOCK_ID_RE = re.compile(r"^BLK-(?P<scope>.+)-(?P<code>[HADPC])(?P<number>\d+)$")
179
186
 
180
187
 
@@ -329,7 +336,7 @@ def _parse_screenplay(
329
336
  )
330
337
  else:
331
338
  scene_id = match.group("scene")
332
- heading_episode = scene_id.split("-", 1)[0]
339
+ heading_episode = match.group("episode")
333
340
  if episode_id and episode_id != heading_episode:
334
341
  issues.append(
335
342
  _issue(span, "episode_scene_mismatch", "场景 ID 与当前集标题的集 ID 不一致。")
@@ -418,11 +425,13 @@ def _parse_screenplay(
418
425
  voice_match = VOICE_TAG_BODY_RE.fullmatch(body)
419
426
  if not voice_match:
420
427
  issues.append(
421
- _issue(span, "invalid_voice_tag_syntax", f"[{tag}] 必须明确说话者并使用全角冒号。")
428
+ _issue(span, "invalid_voice_tag_syntax", f"[{tag}] 写成「[{tag}] 角色(可选提示):台词」:要有说话者、全角冒号,提示用全角括号。")
422
429
  )
423
430
  index = end_index + 1
424
431
  continue
425
432
  fields["speaker"] = voice_match.group("speaker")
433
+ if voice_match.group("cue"):
434
+ fields["performance_cue"] = voice_match.group("cue")
426
435
  append_block("production_tag", span, **fields)
427
436
  index = end_index + 1
428
437
  continue