@oh-story/dsh 0.1.10 → 0.1.12

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 (444) hide show
  1. package/README.md +11 -11
  2. package/lib/client.js +7 -3
  3. package/lib/client.js.map +2 -2
  4. package/lib/drama/manifest.json +212 -142
  5. package/lib/drama/skills/short-drama/SKILL.md +14 -2
  6. package/lib/drama/skills/short-drama/assets/project-template/short-drama.json +3 -0
  7. package/lib/drama/skills/short-drama/references/creator-documents.md +3 -0
  8. package/lib/drama/skills/short-drama/references/knowhow-index.md +15 -0
  9. package/lib/drama/skills/short-drama/scripts/project_tool.py +131 -0
  10. package/lib/drama/skills/short-drama-assets/SKILL.md +2 -1
  11. package/lib/drama/skills/short-drama-assets/references/asset-review-checklist.md +2 -0
  12. package/lib/drama/skills/short-drama-assets/references/era-anchors.md +37 -0
  13. package/lib/drama/skills/short-drama-assets/references/stage-contract.md +2 -0
  14. package/lib/drama/skills/short-drama-develop/SKILL.md +10 -4
  15. package/lib/drama/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -1
  16. package/lib/drama/skills/short-drama-develop/assets/creative-brief.md +8 -0
  17. package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +80 -13
  18. package/lib/drama/skills/short-drama-develop/references/episode-design.md +95 -12
  19. package/lib/drama/skills/short-drama-develop/references/genre-and-hook-playbook.md +9 -5
  20. package/lib/drama/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +1 -1
  21. package/lib/drama/skills/short-drama-develop/references/genre-cards//347/263/273/347/273/237/346/265/201.md +51 -0
  22. package/lib/drama/skills/short-drama-develop/references/genre-cards//351/200/206/350/242/255.md +51 -0
  23. package/lib/drama/skills/short-drama-develop/references/genre-cards//351/207/215/347/224/237/347/251/277/350/266/212.md +51 -0
  24. package/lib/drama/skills/short-drama-develop/references/genre-cards.md +10 -7
  25. package/lib/drama/skills/short-drama-develop/references/premise-devices.md +15 -9
  26. package/lib/drama/skills/short-drama-develop/references/serial-character-and-memory.md +3 -2
  27. package/lib/drama/skills/short-drama-develop/references/stage-contract.md +14 -7
  28. package/lib/drama/skills/short-drama-develop/references/story-craft.md +7 -3
  29. package/lib/drama/skills/short-drama-edit/SKILL.md +43 -20
  30. package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +49 -13
  31. package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +4 -2
  32. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Overlay.tsx +31 -0
  33. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +5 -5
  34. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +68 -69
  35. package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +39 -29
  36. package/lib/drama/skills/short-drama-edit/assets/remotion/src/rules.mjs +188 -0
  37. package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +26 -13
  38. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/Card.tsx +72 -0
  39. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/CornerChip.tsx +39 -0
  40. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/ScreenText.tsx +36 -0
  41. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/SystemPanel.tsx +44 -0
  42. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/TaskPanel.tsx +100 -0
  43. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/chrome.tsx +197 -0
  44. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/tokens.ts +62 -0
  45. package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +1 -0
  46. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +4 -2
  47. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +3 -1
  48. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +6 -2
  49. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +12 -6
  50. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +13 -8
  51. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +2 -0
  52. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1153 -121
  53. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +49 -9
  54. package/lib/drama/skills/short-drama-novel-analyze/SKILL.md +3 -3
  55. package/lib/drama/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +2 -2
  56. package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-triage.md +8 -4
  57. package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-value.md +10 -3
  58. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +25 -1
  59. package/lib/drama/skills/short-drama-novel-analyze/references/stage-contract.md +1 -0
  60. package/lib/drama/skills/short-drama-review/SKILL.md +2 -2
  61. package/lib/drama/skills/short-drama-review/references/review-method.md +6 -1
  62. package/lib/drama/skills/short-drama-review/references/rubric-assets-prompts.md +11 -0
  63. package/lib/drama/skills/short-drama-review/references/rubric-story-script.md +77 -3
  64. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +15 -0
  65. package/lib/drama/skills/short-drama-review/references/stage-contract.md +3 -0
  66. package/lib/drama/skills/short-drama-storyboard/SKILL.md +13 -5
  67. package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +1 -1
  68. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +2 -2
  69. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +82 -14
  70. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +4 -2
  71. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +5 -3
  72. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +12 -0
  73. package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +4 -5
  74. package/lib/drama/skills/short-drama-video-prompts/references/generability.md +4 -2
  75. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +40 -1
  76. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +20 -14
  77. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +1 -1
  78. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +8 -7
  79. package/lib/drama/skills/short-drama-video-prompts/references/review-and-fixtures.md +1 -1
  80. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
  81. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +8 -1
  82. package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +103 -0
  83. package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +8 -7
  84. package/lib/drama/skills/short-drama-write/SKILL.md +8 -3
  85. package/lib/drama/skills/short-drama-write/references/dialogue-craft.md +1 -1
  86. package/lib/drama/skills/short-drama-write/references/script-craft.md +56 -3
  87. package/lib/drama/skills/short-drama-write/references/stage-contract.md +4 -0
  88. package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +48 -2
  89. package/lib/index.js +71 -21
  90. package/lib/index.js.map +3 -3
  91. package/lib/oh-story/manifest.json +545 -310
  92. package/lib/oh-story/roles/chapter-extractor.md +11 -11
  93. package/lib/oh-story/roles/character-designer.md +1 -2
  94. package/lib/oh-story/roles/consistency-checker.md +11 -9
  95. package/lib/oh-story/roles/narrative-writer.md +24 -25
  96. package/lib/oh-story/roles/story-architect.md +9 -61
  97. package/lib/oh-story/roles/story-explorer.md +20 -125
  98. package/lib/oh-story/roles/story-researcher.md +0 -1
  99. package/lib/oh-story/skills/story/SKILL.md +6 -6
  100. package/lib/oh-story/skills/story/VERSION +1 -1
  101. package/lib/oh-story/skills/story/references/author-memory-maintenance.md +75 -0
  102. package/lib/oh-story/skills/story/references/author-memory.md +40 -118
  103. package/lib/oh-story/skills/story/scripts/author_memory_commit.py +3 -3
  104. package/lib/oh-story/skills/story-deslop/SKILL.md +24 -25
  105. package/lib/oh-story/skills/story-deslop/references/agent-calls.md +5 -0
  106. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +1 -1
  107. package/lib/oh-story/skills/story-deslop/references/author-memory-maintenance.md +75 -0
  108. package/lib/oh-story/skills/story-deslop/references/author-memory.md +40 -118
  109. package/lib/oh-story/skills/story-deslop/references/banned-words.md +2 -2
  110. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +21 -4
  111. package/lib/oh-story/skills/story-deslop/references/style-resolution.md +4 -4
  112. package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +3 -3
  113. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +155 -8
  114. package/lib/oh-story/skills/story-import/SKILL.md +63 -579
  115. package/lib/oh-story/skills/story-import/references/deep-analysis.md +47 -0
  116. package/lib/oh-story/skills/story-import/references/format-and-structure.md +1 -1
  117. package/lib/oh-story/skills/story-import/references/import-report.md +64 -0
  118. package/lib/oh-story/skills/story-import/references/import-special-cases.md +34 -0
  119. package/lib/oh-story/skills/story-import/references/import-tracking.md +95 -0
  120. package/lib/oh-story/skills/story-import/references/length-routing.md +1 -1
  121. package/lib/oh-story/skills/story-import/references/outline-reverse-rules.md +79 -0
  122. package/lib/oh-story/skills/story-import/references/outline-reverse.md +24 -0
  123. package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +95 -91
  124. package/lib/oh-story/skills/story-import/references/structure-mapping-short.md +24 -12
  125. package/lib/oh-story/skills/story-import/references/tracking-initialization.md +1 -1
  126. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +11 -9
  127. package/lib/oh-story/skills/story-import/scripts/build_outline_brief.py +332 -0
  128. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +101 -14
  129. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +278 -23
  130. package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +179 -23
  131. package/lib/oh-story/skills/story-long-analyze/SKILL.md +28 -50
  132. package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +12 -5
  133. package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +1 -1
  134. package/lib/oh-story/skills/story-long-analyze/references/final-checks.md +19 -0
  135. package/lib/oh-story/skills/story-long-analyze/references/index-rebuild.md +11 -0
  136. package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +21 -55
  137. package/lib/oh-story/skills/story-long-analyze/references/stage1-golden-chapters.md +128 -0
  138. package/lib/oh-story/skills/story-long-analyze/references/stage2-extraction.md +229 -0
  139. package/lib/oh-story/skills/story-long-analyze/references/stage3-plot-rhythm.md +480 -0
  140. package/lib/oh-story/skills/story-long-analyze/references/stage4-characters-settings.md +195 -0
  141. package/lib/oh-story/skills/story-long-analyze/references/stage5-report.md +33 -0
  142. package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +17 -0
  143. package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +1 -1
  144. package/lib/oh-story/skills/story-long-analyze/references/synthesis-inputs.md +39 -0
  145. package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +47 -8
  146. package/lib/oh-story/skills/story-long-scan/SKILL.md +41 -193
  147. package/lib/oh-story/skills/story-long-scan/references/platform-ciweimao.md +19 -0
  148. package/lib/oh-story/skills/story-long-scan/references/platform-fanqie.md +45 -0
  149. package/lib/oh-story/skills/story-long-scan/references/platform-jjwxc.md +28 -0
  150. package/lib/oh-story/skills/story-long-scan/references/platform-qidian.md +35 -0
  151. package/lib/oh-story/skills/story-long-scan/references/platform-qimao.md +27 -0
  152. package/lib/oh-story/skills/story-long-scan/references/topic-decision.md +6 -2
  153. package/lib/oh-story/skills/story-long-write/SKILL.md +32 -73
  154. package/lib/oh-story/skills/story-long-write/references/agent-calls.md +47 -0
  155. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +1 -1
  156. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +18 -201
  157. package/lib/oh-story/skills/story-long-write/references/author-memory-maintenance.md +75 -0
  158. package/lib/oh-story/skills/story-long-write/references/author-memory.md +40 -118
  159. package/lib/oh-story/skills/story-long-write/references/banned-words.md +2 -2
  160. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +6 -10
  161. package/lib/oh-story/skills/story-long-write/references/character-relations.md +49 -1
  162. package/lib/oh-story/skills/story-long-write/references/deslop-gates.md +171 -0
  163. package/lib/oh-story/skills/story-long-write/references/emotion-on-page.md +116 -0
  164. package/lib/oh-story/skills/story-long-write/references/emotional-arc-design.md +1 -114
  165. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards.md +1 -1
  166. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +3 -2
  167. package/lib/oh-story/skills/story-long-write/references/long-format.md +0 -8
  168. package/lib/oh-story/skills/story-long-write/references/long-genre-catalog.md +1 -1
  169. package/lib/oh-story/skills/story-long-write/references/long-genre-mechanics.md +3 -3
  170. package/lib/oh-story/skills/story-long-write/references/opening-design.md +2 -2
  171. package/lib/oh-story/skills/story-long-write/references/outline-conflict.md +4 -4
  172. package/lib/oh-story/skills/story-long-write/references/outline-methods.md +7 -7
  173. package/lib/oh-story/skills/story-long-write/references/outline-rhythm.md +3 -3
  174. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +14 -69
  175. package/lib/oh-story/skills/story-long-write/references/plot-frameworks.md +1 -1
  176. package/lib/oh-story/skills/story-long-write/references/plot-special-topics.md +2 -2
  177. package/lib/oh-story/skills/story-long-write/references/project-files.md +10 -6
  178. package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +1 -1
  179. package/lib/oh-story/skills/story-long-write/references/reference-index.md +4 -4
  180. package/lib/oh-story/skills/story-long-write/references/stop-and-fix.md +9 -0
  181. package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -58
  182. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
  183. package/lib/oh-story/skills/story-long-write/references/style-resolution.md +4 -4
  184. package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +1 -1
  185. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +11 -9
  186. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +33 -44
  187. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +18 -51
  188. package/lib/oh-story/skills/story-long-write/references/workflow-outline.md +157 -0
  189. package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +53 -67
  190. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +112 -251
  191. package/lib/oh-story/skills/story-long-write/references/workflow-volume.md +142 -0
  192. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +5 -3
  193. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +18 -56
  194. package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +3 -3
  195. package/lib/oh-story/skills/story-long-write/scripts/build_architect_brief.py +260 -0
  196. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +96 -16
  197. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +155 -8
  198. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +101 -14
  199. package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +7 -2
  200. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +2 -1
  201. package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +250 -50
  202. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +278 -23
  203. package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +179 -23
  204. package/lib/oh-story/skills/story-review/SKILL.md +19 -273
  205. package/lib/oh-story/skills/story-review/references/agent-prompts.md +184 -0
  206. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +1 -1
  207. package/lib/oh-story/skills/story-review/references/author-memory-maintenance.md +75 -0
  208. package/lib/oh-story/skills/story-review/references/author-memory.md +40 -118
  209. package/lib/oh-story/skills/story-review/references/banned-words.md +2 -2
  210. package/lib/oh-story/skills/story-review/references/batch-review.md +19 -0
  211. package/lib/oh-story/skills/story-review/references/character-relations.md +49 -1
  212. package/lib/oh-story/skills/story-review/references/review-quality.md +2 -1
  213. package/lib/oh-story/skills/story-review/references/review-tracking.md +10 -0
  214. package/lib/oh-story/skills/story-review/references/solo.md +52 -0
  215. package/lib/oh-story/skills/story-review/references/style-resolution.md +4 -4
  216. package/lib/oh-story/skills/story-review/references/tracking-initialization.md +1 -1
  217. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +11 -9
  218. package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +3 -3
  219. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +155 -8
  220. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +278 -23
  221. package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +179 -23
  222. package/lib/oh-story/skills/story-setup/SKILL.md +69 -458
  223. package/lib/oh-story/skills/story-setup/references/agent-references/agent-quality.md +4 -4
  224. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +1 -1
  225. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +2 -2
  226. package/lib/oh-story/skills/story-setup/references/agent-references/benchmark-style-load.md +107 -0
  227. package/lib/oh-story/skills/story-setup/references/agent-references/character-relations.md +49 -1
  228. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +21 -4
  229. package/lib/oh-story/skills/story-setup/references/agent-references/emotion-on-page.md +116 -0
  230. package/lib/oh-story/skills/story-setup/references/agent-references/emotional-arc-design.md +1 -114
  231. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +1 -1
  232. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards.md +1 -1
  233. package/lib/oh-story/skills/story-setup/references/agent-references/long-genre-catalog.md +1 -1
  234. package/lib/oh-story/skills/story-setup/references/agent-references/long-genre-mechanics.md +3 -3
  235. package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +7 -5
  236. package/lib/oh-story/skills/story-setup/references/agent-references/opening-design.md +2 -2
  237. package/lib/oh-story/skills/story-setup/references/agent-references/outline-conflict.md +4 -4
  238. package/lib/oh-story/skills/story-setup/references/agent-references/outline-methods.md +7 -7
  239. package/lib/oh-story/skills/story-setup/references/agent-references/outline-rhythm.md +3 -3
  240. package/lib/oh-story/skills/story-setup/references/agent-references/short-emotional-methods.md +1 -1
  241. package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +0 -15
  242. package/lib/oh-story/skills/story-setup/references/agent-references/short-reversal.md +1 -1
  243. package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -58
  244. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
  245. package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +4 -4
  246. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +18 -56
  247. package/lib/oh-story/skills/story-setup/references/diagnostics.md +3 -3
  248. package/lib/oh-story/skills/story-short-analyze/SKILL.md +37 -110
  249. package/lib/oh-story/skills/story-short-analyze/references/analysis-character-relations.md +1 -1
  250. package/lib/oh-story/skills/story-short-analyze/references/analysis-method.md +42 -0
  251. package/lib/oh-story/skills/story-short-analyze/references/optional-modules.md +97 -0
  252. package/lib/oh-story/skills/story-short-analyze/references/output-contract.md +9 -47
  253. package/lib/oh-story/skills/story-short-analyze/references/quality-checklist.md +80 -0
  254. package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +4 -4
  255. package/lib/oh-story/skills/story-short-analyze/references/stage2-3-structure-emotion.md +308 -0
  256. package/lib/oh-story/skills/story-short-analyze/references/{material-decomposition.md → stage4-6-reversal-summary.md} +303 -374
  257. package/lib/oh-story/skills/story-short-scan/SKILL.md +30 -64
  258. package/lib/oh-story/skills/story-short-scan/references/manual-rank-input.md +36 -0
  259. package/lib/oh-story/skills/story-short-scan/references/platform-dianzhong.md +24 -0
  260. package/lib/oh-story/skills/story-short-scan/references/platform-heiyan.md +21 -0
  261. package/lib/oh-story/skills/story-short-scan/references/topic-match.md +38 -0
  262. package/lib/oh-story/skills/story-short-write/SKILL.md +23 -34
  263. package/lib/oh-story/skills/story-short-write/references/agent-calls.md +13 -0
  264. package/lib/oh-story/skills/story-short-write/references/author-memory-maintenance.md +75 -0
  265. package/lib/oh-story/skills/story-short-write/references/author-memory.md +40 -118
  266. package/lib/oh-story/skills/story-short-write/references/banned-words.md +2 -2
  267. package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +1 -21
  268. package/lib/oh-story/skills/story-short-write/references/emotional-methods.md +1 -1
  269. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +0 -15
  270. package/lib/oh-story/skills/story-short-write/references/output-contract.md +9 -47
  271. package/lib/oh-story/skills/story-short-write/references/reference-index.md +17 -49
  272. package/lib/oh-story/skills/story-short-write/references/scene-craft.md +40 -0
  273. package/lib/oh-story/skills/story-short-write/references/short-craft.md +7 -43
  274. package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -3
  275. package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -1
  276. package/lib/oh-story/skills/story-short-write/references/short-reversal.md +1 -1
  277. package/lib/oh-story/skills/story-short-write/references/style-resolution.md +4 -4
  278. package/lib/oh-story/skills/story-short-write/references/submission-craft.md +1 -1
  279. package/lib/oh-story/skills/story-short-write/references/villain-and-reveal.md +5 -10
  280. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +19 -12
  281. package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +22 -32
  282. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +84 -17
  283. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +7 -119
  284. package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +3 -3
  285. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +155 -8
  286. package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +7 -2
  287. package/lib/types/client/video-studio.d.ts.map +1 -1
  288. package/lib/types/native-hooks.d.ts +2 -2
  289. package/lib/types/native-hooks.d.ts.map +1 -1
  290. package/lib/types/role-tool.d.ts.map +1 -1
  291. package/lib/types/skill-provider.d.ts.map +1 -1
  292. package/lib/types/video-project.d.ts.map +1 -1
  293. package/lib/types/workspace-route.d.ts.map +1 -1
  294. package/lib/video-recap/CHANGELOG.md +60 -1
  295. package/lib/video-recap/README.en.md +348 -137
  296. package/lib/video-recap/README.md +340 -136
  297. package/lib/video-recap/manifest.json +406 -251
  298. package/lib/video-recap/skills/video-assemble/SKILL.md +30 -15
  299. package/lib/video-recap/skills/video-assemble/references/audio-modes.md +63 -0
  300. package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +75 -0
  301. package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +118 -0
  302. package/lib/video-recap/skills/video-assemble/references/narration-adoption.md +103 -0
  303. package/lib/video-recap/skills/video-assemble/references/packaging.md +45 -0
  304. package/lib/video-recap/skills/video-assemble/references/pair-media.md +74 -0
  305. package/lib/video-recap/skills/video-assemble/references/source-score.md +147 -0
  306. package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +202 -0
  307. package/lib/video-recap/skills/video-assemble/scripts/adoption/__init__.py +1 -0
  308. package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +279 -0
  309. package/lib/video-recap/skills/video-assemble/scripts/adoption/frozen_audio.py +187 -0
  310. package/lib/video-recap/skills/video-assemble/scripts/adoption/narration_binding.py +323 -0
  311. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_inputs.py +109 -0
  312. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +130 -0
  313. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +10 -46
  314. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +378 -119
  315. package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +29 -12
  316. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +95 -44
  317. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +75 -32
  318. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +11 -11
  319. package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +293 -0
  320. package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +71 -78
  321. package/lib/video-recap/skills/video-assemble/scripts/jianying/__init__.py +1 -0
  322. package/lib/video-recap/skills/video-assemble/scripts/{jianying_builders.py → jianying/builders.py} +39 -67
  323. package/lib/video-recap/skills/video-assemble/scripts/{jianying_model.py → jianying/model.py} +3 -11
  324. package/lib/video-recap/skills/video-assemble/scripts/{jianying_optional.py → jianying/optional.py} +1 -1
  325. package/lib/video-recap/skills/video-assemble/scripts/jianying/schema.py +53 -0
  326. package/lib/video-recap/skills/video-assemble/scripts/{jianying_templates.py → jianying/templates.py} +1 -1
  327. package/lib/video-recap/skills/video-assemble/scripts/{jianying_timeline_contract.py → jianying/timeline_contract.py} +34 -13
  328. package/lib/video-recap/skills/video-assemble/scripts/{jianying_writer.py → jianying/writer.py} +7 -17
  329. package/lib/video-recap/skills/video-assemble/scripts/lib.py +37 -4
  330. package/lib/video-recap/skills/video-assemble/scripts/media.py +12 -21
  331. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +64 -34
  332. package/lib/video-recap/skills/video-assemble/scripts/packaging.py +122 -0
  333. package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +216 -0
  334. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +1 -3
  335. package/lib/video-recap/skills/video-assemble/scripts/source_score.py +469 -0
  336. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +25 -9
  337. package/lib/video-recap/skills/video-assemble/scripts/subtitles/__init__.py +1 -0
  338. package/lib/video-recap/skills/video-assemble/scripts/{subtitle_core.py → subtitles/core.py} +13 -6
  339. package/lib/video-recap/skills/video-assemble/scripts/{subtitle_render.py → subtitles/render.py} +5 -4
  340. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +324 -0
  341. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +200 -0
  342. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +30 -46
  343. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +91 -7
  344. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +22 -8
  345. package/lib/video-recap/skills/video-cut/SKILL.md +34 -16
  346. package/lib/video-recap/skills/video-cut/references/shot-review.md +50 -0
  347. package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -28
  348. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +58 -62
  349. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +21 -106
  350. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +14 -9
  351. package/lib/video-recap/skills/video-cut/scripts/lib.py +39 -0
  352. package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +2 -155
  353. package/lib/video-recap/skills/video-cut/scripts/narrative_selection.py +244 -0
  354. package/lib/video-recap/skills/video-cut/scripts/shot_review.py +357 -0
  355. package/lib/video-recap/skills/video-recap/SKILL.md +86 -42
  356. package/lib/video-recap/skills/video-recap/references/audio-routing.md +126 -0
  357. package/lib/video-recap/skills/video-recap/references/config-playbook.md +12 -8
  358. package/lib/video-recap/skills/video-recap/references/data-schema.md +44 -33
  359. package/lib/video-recap/skills/video-recap/references/resource-library.md +151 -0
  360. package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +1 -1
  361. package/lib/video-recap/skills/video-recap/scripts/doctor.py +123 -30
  362. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +22 -75
  363. package/lib/video-recap/skills/video-recap/scripts/lib.py +3 -3
  364. package/lib/video-recap/skills/video-recap/scripts/library.py +509 -0
  365. package/lib/video-recap/skills/video-recap/scripts/materials.py +63 -91
  366. package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +8 -70
  367. package/lib/video-recap/skills/video-recap/scripts/project_binding.py +286 -0
  368. package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +1 -0
  369. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_client.py → qc/mimo_client.py} +1 -1
  370. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_evidence.py → qc/mimo_evidence.py} +33 -66
  371. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_observations.py → qc/mimo_observations.py} +3 -11
  372. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_payload.py → qc/mimo_payload.py} +4 -7
  373. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_report.py → qc/mimo_report.py} +17 -28
  374. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_runner.py → qc/mimo_runner.py} +2 -2
  375. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -19
  376. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +119 -50
  377. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +32 -114
  378. package/lib/video-recap/skills/video-recap/scripts/recap_review.py +9 -23
  379. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +387 -334
  380. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +23 -23
  381. package/lib/video-recap/skills/video-recap/scripts/recap_source.py +278 -0
  382. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +25 -12
  383. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +59 -52
  384. package/lib/video-recap/skills/video-recap/scripts/resource_lock.py +162 -0
  385. package/lib/video-recap/skills/video-script/SKILL.md +16 -13
  386. package/lib/video-recap/skills/video-script/references/creative-editing-playbook.md +9 -5
  387. package/lib/video-recap/skills/video-script/references/promotional-copy.md +68 -0
  388. package/lib/video-recap/skills/video-script/references/research-guide.md +1 -1
  389. package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +24 -76
  390. package/lib/video-recap/skills/video-script/scripts/lib.py +10 -87
  391. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +63 -39
  392. package/lib/video-recap/skills/video-script/scripts/review.py +0 -2
  393. package/lib/video-recap/skills/video-script/scripts/review_grounding.py +35 -59
  394. package/lib/video-recap/skills/video-script/scripts/review_response.py +66 -128
  395. package/lib/video-recap/skills/video-script/scripts/review_runner.py +12 -20
  396. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +4 -4
  397. package/lib/video-recap/skills/video-script/scripts/validate.py +53 -42
  398. package/lib/video-recap/skills/video-understanding/SKILL.md +6 -4
  399. package/lib/video-recap/skills/video-understanding/references/data-schema.md +50 -156
  400. package/lib/video-recap/skills/video-understanding/references/research-guide.md +4 -4
  401. package/lib/video-recap/skills/video-understanding/scripts/asr.py +85 -65
  402. package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +270 -0
  403. package/lib/video-recap/skills/video-understanding/scripts/brief.py +2 -5
  404. package/lib/video-recap/skills/video-understanding/scripts/briefing/__init__.py +1 -0
  405. package/lib/video-recap/skills/video-understanding/scripts/{agent_brief.py → briefing/builder.py} +38 -35
  406. package/lib/video-recap/skills/{video-script/scripts/brief_context.py → video-understanding/scripts/briefing/context.py} +4 -35
  407. package/lib/video-recap/skills/{video-script/scripts/brief_inputs.py → video-understanding/scripts/briefing/inputs.py} +9 -31
  408. package/lib/video-recap/skills/video-understanding/scripts/{brief_timeline.py → briefing/timeline.py} +5 -6
  409. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +65 -116
  410. package/lib/video-recap/skills/video-understanding/scripts/detect.py +31 -40
  411. package/lib/video-recap/skills/video-understanding/scripts/lib.py +30 -86
  412. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +63 -39
  413. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +4 -4
  414. package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +17 -79
  415. package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +14 -40
  416. package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +64 -46
  417. package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +31 -11
  418. package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +19 -14
  419. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +80 -108
  420. package/lib/video-recap/skills/video-voiceover/SKILL.md +29 -19
  421. package/lib/video-recap/skills/video-voiceover/references/index-tts.md +31 -0
  422. package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +123 -0
  423. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +51 -59
  424. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +8 -44
  425. package/lib/video-recap/skills/video-voiceover/scripts/providers/__init__.py +1 -0
  426. package/lib/video-recap/skills/video-voiceover/scripts/providers/index_tts.py +184 -0
  427. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +212 -134
  428. package/package.json +41 -41
  429. package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +0 -538
  430. package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +0 -603
  431. package/lib/oh-story/skills/story-long-scan/references/scan-output-format.md +0 -207
  432. package/lib/oh-story/skills/story-short-analyze/references/output-templates.md +0 -561
  433. package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +0 -98
  434. package/lib/video-recap/skills/video-recap/references/creative-editing-playbook.md +0 -225
  435. package/lib/video-recap/skills/video-recap/references/research-guide.md +0 -74
  436. package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +0 -250
  437. package/lib/video-recap/skills/video-script/scripts/agent_brief.py +0 -399
  438. package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +0 -327
  439. package/lib/video-recap/skills/video-script/scripts/narration.py +0 -12
  440. package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +0 -348
  441. package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +0 -290
  442. /package/lib/video-recap/skills/video-assemble/scripts/{jianying_tracks.py → jianying/tracks.py} +0 -0
  443. /package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_contract.py → qc/mimo_contract.py} +0 -0
  444. /package/lib/video-recap/skills/video-voiceover/scripts/{fish_audio.py → providers/fish_audio.py} +0 -0
@@ -5,7 +5,8 @@ Three subcommands, deliberately separated so a report can never borrow one's
5
5
  evidence for another's claim:
6
6
 
7
7
  ``check`` parse and cross-check ``剪辑单.md`` against the project. No rendering.
8
- ``render`` cut, join, burn subtitles and normalize loudness into 制作成果/成片/.
8
+ ``render`` cut, join, mix sound effects, burn subtitles and screen text, and
9
+ normalize loudness into 制作成果/成片/.
9
10
  ``verify`` measure an already-rendered film and print the numbers.
10
11
 
11
12
  ``verify`` prints measurements, never verdicts. Whether the film is any good is
@@ -16,13 +17,14 @@ from __future__ import annotations
16
17
 
17
18
  import argparse
18
19
  import json
20
+ import operator
19
21
  import re
20
22
  import shutil
21
23
  import subprocess
22
24
  import sys
23
25
  import tempfile
24
26
  from pathlib import Path
25
- from typing import Any, NamedTuple, Optional, Sequence
27
+ from typing import Any, Callable, NamedTuple, Optional, Sequence, Union
26
28
 
27
29
  MINIMUM_PYTHON = (3, 9)
28
30
  if sys.version_info < MINIMUM_PYTHON:
@@ -53,11 +55,20 @@ REMOTION_SOURCE_FILES = (
53
55
  "src/index.ts",
54
56
  "src/schema.ts",
55
57
  "src/Root.tsx",
58
+ "src/Overlay.tsx",
56
59
  "src/Subtitles.tsx",
57
60
  "src/font.ts",
61
+ "src/rules.mjs",
62
+ "src/screen/ScreenText.tsx",
63
+ "src/screen/tokens.ts",
64
+ "src/screen/chrome.tsx",
65
+ "src/screen/Card.tsx",
66
+ "src/screen/SystemPanel.tsx",
67
+ "src/screen/TaskPanel.tsx",
68
+ "src/screen/CornerChip.tsx",
58
69
  )
59
70
  DEFAULT_REMOTION_WORKSPACE = Path.home() / ".cache" / "short-drama-edit" / "remotion"
60
- REMOTION_COMPOSITION = "Subtitles"
71
+ REMOTION_COMPOSITION = "Overlay"
61
72
  # Remotion defaults its worker count to the machine's core count, and each worker
62
73
  # is a browser holding a full frame. On a 10-core / 8 GB laptop that took the
63
74
  # machine down mid-render. Raise this only after measuring the host.
@@ -77,6 +88,54 @@ SUBTITLE_WINDOW = re.compile(r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s*$")
77
88
  # Numbered fields keep them ordered and let each one state its own window.
78
89
  SUBTITLE_FIELD = re.compile(r"^字幕(?:\s*(\d+))?$")
79
90
  SUBTITLE_CUE = re.compile(r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s+(.+?)\s*$")
91
+ # Burned subtitles follow GY/T 288 and short-drama practice: no closing
92
+ # punctuation, a pause inside a line shows as a full-width space, and ? ! stay
93
+ # because they change how the line reads. Only the burned text changes: the cut
94
+ # list keeps the screenplay's punctuation, so the line is still checked against
95
+ # 剧本.md as written. ASCII marks are left alone mid-line: they sit inside
96
+ # numbers such as 1,000.
97
+ SUBTITLE_PAUSE_MARKS = ",、;:。"
98
+ SUBTITLE_KEPT_END_MARKS = "?!?!"
99
+ SUBTITLE_END_MARKS = "".join(
100
+ mark for mark in ",、;:。…—~!?,.;:!?~" if mark not in SUBTITLE_KEPT_END_MARKS
101
+ )
102
+ # One line on screen: a longer line is split at its pauses into cues shown one
103
+ # after another, each given a share of the window by its character count.
104
+ SUBTITLE_MAX_VISIBLE = 12
105
+ # 「字幕:… (重点:词|词)」 marks words to highlight; they must occur in the line.
106
+ SUBTITLE_KEYWORDS = re.compile(r"[((]重点[::]\s*(.+?)\s*[))]\s*$")
107
+ # Colour by who is speaking, read from 剧本.md rather than written again here:
108
+ # `[VO] 系统:` is the system voice, any other `[VO]` an inner or narrating voice.
109
+ SPOKEN_LINE = re.compile(
110
+ r"^\s*(?:\[(VO|OS)\]\s*)?([^\s::\[\]((]+)(?:[((][^))]*[))])?\s*[::]\s*(.+?)\s*$"
111
+ )
112
+ SYSTEM_SPEAKER = "系统"
113
+ # The same values as the Remotion layer's `src/screen/tokens.ts`.
114
+ SUBTITLE_COLOURS = {"line": "#FFFFFF", "vo": "#9FE8FF", "system": "#3FE0FF"}
115
+ SUBTITLE_KEYWORD_COLOUR = "#FFD400"
116
+ # Text the picture had to leave blank -- a system panel, a countdown, follower
117
+ # counts on a monitor -- because video models garble it. It is drawn here, over
118
+ # the finished picture, and every item must come from a [画面文字] line.
119
+ SCREEN_TEXT_FIELD = re.compile(r"^画面文字(?:\s*(\d+))?$")
120
+ SCREEN_TEXT_STYLES = {"卡片": "card", "系统面板": "system", "任务面板": "task", "角标": "corner"}
121
+ SCREEN_TEXT = re.compile(
122
+ r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s+(" + "|".join(SCREEN_TEXT_STYLES) + r")\s+(.+?)\s*$"
123
+ )
124
+ SCREEN_TEXT_ITEM_SEPARATOR = "|"
125
+ COUNTDOWN = re.compile(r"[((]倒计时[::]\s*([0-9]+(?:\.[0-9]+)?|接续)\s*[))]\s*$")
126
+ COUNTDOWN_STYLES = {"任务面板", "角标"}
127
+ # 「现金 10 万(传说)」: an item's rarity sets its colour. Unmarked items are 稀有.
128
+ RARITY = re.compile(r"[((](传说|史诗|稀有)[))]$")
129
+ RARITY_STYLES = {"系统面板", "任务面板"}
130
+ # The three panels share the top of the frame; the chip has its own corner.
131
+ SCREEN_TEXT_SLOTS = {"卡片": "面板", "系统面板": "面板", "任务面板": "面板", "角标": "角标"}
132
+ SCREENPLAY_SCREEN_TEXT = re.compile(r"^\s*\[画面文字\]\s*(.+?)\s*$", re.MULTILINE)
133
+ # Two pieces of the same screen text that meet at a cut are one piece on
134
+ # screen: a chip that persists across five shots must not re-enter five times.
135
+ SCREEN_TEXT_JOIN_GAP = 0.05
136
+ SOUND_EFFECT_FIELD = re.compile(r"^音效(?:\s*(\d+))?$")
137
+ GAIN = re.compile(r"[((]增益[::]\s*([+-]?[0-9]+(?:\.[0-9]+)?)\s*(?:dB)?\s*[))]\s*$", re.I)
138
+ GAIN_LIMITS = (-40.0, 6.0)
80
139
  # A shot-match is three numbers and nothing else. Anything richer belongs in a
81
140
  # grading tool, and anything implicit belongs nowhere: a correction the cut list
82
141
  # does not state is a correction no reviewer can see.
@@ -90,6 +149,35 @@ PICTURE_LIMITS = {
90
149
  "saturation": (0.0, 2.0),
91
150
  "warmth": (-30.0, 30.0),
92
151
  }
152
+ # 「画面:不校」 keeps a cut exactly as generated: no stated correction and no
153
+ # automatic one. 「画面:无」 only says nothing is stated.
154
+ PICTURE_UNTOUCHED = "不校"
155
+ # Clips are generated one by one and come back up to ~35 luma apart at a cut,
156
+ # which reads as a flash. Within one scene, render pulls each cut this far
157
+ # toward the scene's median per-channel mean and spread: close enough to stop
158
+ # the flash, short of flattening a deliberate change inside the scene.
159
+ SHOT_MATCH_STRENGTH = 0.7
160
+ # A near-flat clip (a black insert) has almost no spread; stretching it to the
161
+ # scene's would amplify noise, so the gain is held to a plausible grade.
162
+ SHOT_MATCH_GAIN_LIMITS = (0.6, 1.6)
163
+ SHOT_MATCH_SAMPLE = "fps=4,scale=64:-2"
164
+ STORYBOARD_DOCUMENT = "分镜.md"
165
+ SHOT_HEADING = re.compile(r"^##\s+(SHOT-[^\s·]+)")
166
+ SHOT_REFERENCE = re.compile(r"SHOT-[A-Za-z0-9-]+")
167
+ SCENE_ID = re.compile(r"[A-Za-z]+[0-9]+-SC[0-9]+")
168
+ # verify reads the delivered film at this size in grey, one value per pixel.
169
+ FRAME_PROBE_SIZE = (90, 160)
170
+ # A frame is suspect when it differs from both neighbours by more than this
171
+ # (mean absolute difference, 0-255) while the neighbours differ from each other
172
+ # by less than this share of it: a picture that belongs to neither side, such
173
+ # as a strip of another shot decoded into the top of the frame.
174
+ FLASH_FRAME_DIFFERENCE = 8.0
175
+ FLASH_NEIGHBOUR_SHARE = 0.5
176
+ # A panel entering over 2-3 frames of glitch trips the detector too; a suspect
177
+ # frame this soon after a screen text starts is labelled as that.
178
+ SCREEN_TEXT_ENTRANCE = 0.15
179
+ # Mean-luma change across a cut (0-255) above which verify asks for a look.
180
+ CUT_JUMP_NOTICE = 20.0
93
181
  TOLERANCE = 0.005
94
182
  # ffmpeg's `noise` strength runs to 100, which is snow, not grain. The useful
95
183
  # band for a finished film is single digits; the cap keeps a typo from shipping
@@ -101,6 +189,30 @@ class EditError(Exception):
101
189
  """A defect in the cut list or its inputs, reported rather than raised through."""
102
190
 
103
191
 
192
+ class ScreenText(NamedTuple):
193
+ start: float
194
+ end: float
195
+ style: str
196
+ items: tuple[str, ...]
197
+ # Seconds left at `start`; None when nothing counts down.
198
+ countdown: Optional[float] = None
199
+ # 「倒计时:接续」 carries on from the latest earlier countdown in the film.
200
+ resume: bool = False
201
+ # One per item: 传说 / 史诗 / 稀有, or None where the item names none.
202
+ rarities: tuple[Optional[str], ...] = ()
203
+
204
+
205
+ # (start, end, text as in 剧本.md, words to highlight); times relative to the cut.
206
+ Subtitle = tuple[Optional[float], Optional[float], str, tuple[str, ...]]
207
+
208
+
209
+ class SoundEffect(NamedTuple):
210
+ start: float
211
+ end: float
212
+ path: str
213
+ gain_db: float = 0.0
214
+
215
+
104
216
  class Cut(NamedTuple):
105
217
  cut_id: str
106
218
  title: str
@@ -109,9 +221,13 @@ class Cut(NamedTuple):
109
221
  start: float
110
222
  end: float
111
223
  declared: float
112
- subtitles: tuple[tuple[Optional[float], Optional[float], str], ...]
224
+ subtitles: tuple[Subtitle, ...]
113
225
  picture: dict[str, float]
114
226
  line_number: int
227
+ screen_texts: tuple[ScreenText, ...] = ()
228
+ sound_effects: tuple[SoundEffect, ...] = ()
229
+ # 「画面:不校」: neither a stated nor an automatic correction.
230
+ untouched: bool = False
115
231
 
116
232
 
117
233
  class Delivery(NamedTuple):
@@ -121,6 +237,22 @@ class Delivery(NamedTuple):
121
237
  frame_size: Optional[tuple[int, int]]
122
238
  fps: Optional[float]
123
239
  grain: Optional[float]
240
+ # 「接镜匹配:无」 turns the automatic within-scene match off.
241
+ shot_match: bool = True
242
+
243
+
244
+ class ChannelStats(NamedTuple):
245
+ """Per-channel (R, G, B) mean and standard deviation, 0-255."""
246
+
247
+ mean: tuple[float, ...]
248
+ spread: tuple[float, ...]
249
+
250
+
251
+ class ShotMatch(NamedTuple):
252
+ """Per-channel `value * gain + offset`, applied to one cut."""
253
+
254
+ gains: tuple[float, ...]
255
+ offsets: tuple[float, ...]
124
256
 
125
257
 
126
258
  def _seconds(raw: str, *, field: str, line: int) -> float:
@@ -137,6 +269,7 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
137
269
  frame_size: Optional[tuple[int, int]] = None
138
270
  fps: Optional[float] = None
139
271
  grain: Optional[float] = None
272
+ shot_match = True
140
273
  for raw in lines:
141
274
  match = FIELD.match(raw)
142
275
  if not match:
@@ -169,6 +302,8 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
169
302
  "这一档以上不再像胶片,像信号故障"
170
303
  )
171
304
  grain = amount or None
305
+ elif name == "接镜匹配":
306
+ shot_match = value.strip() not in {"无", "关"}
172
307
  elif name == "画幅与帧率":
173
308
  size = re.search(r"([0-9]{2,5})\s*[×x*]\s*([0-9]{2,5})", value)
174
309
  if size:
@@ -176,7 +311,7 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
176
311
  rate = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*fps", value, re.I)
177
312
  if rate:
178
313
  fps = float(rate.group(1))
179
- return Delivery(target, loudness, burn, frame_size, fps, grain)
314
+ return Delivery(target, loudness, burn, frame_size, fps, grain, shot_match)
180
315
 
181
316
 
182
317
  def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
@@ -220,11 +355,26 @@ def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
220
355
  continue
221
356
  field = FIELD.match(raw)
222
357
  if field:
223
- current["fields"][field.group(1).strip()] = (field.group(2).strip(), number)
358
+ name = field.group(1).strip()
359
+ if name in current["fields"]:
360
+ raise EditError(_repeated_field(name, current["cut_id"], number))
361
+ current["fields"][name] = (field.group(2).strip(), number)
224
362
  close(current)
225
363
  return _parse_delivery(preamble), cuts, unused
226
364
 
227
365
 
366
+ def _repeated_field(name: str, cut_id: str, line: int) -> str:
367
+ """A field written twice in one cut: the second would silently replace the first."""
368
+
369
+ where = f"{CUT_LIST_NAME}:{line}: {cut_id} 的「{name}」写了两遍"
370
+ for pattern, label in (
371
+ (SUBTITLE_FIELD, "字幕"), (SCREEN_TEXT_FIELD, "画面文字"), (SOUND_EFFECT_FIELD, "音效"),
372
+ ):
373
+ if pattern.match(name):
374
+ return f"{where};一段有多条{label}时全部编号,且编号不重复:「{label} 1」「{label} 2」…"
375
+ return f"{where};这一项一段只写一次"
376
+
377
+
228
378
  def _finish_cut(pending: dict[str, Any]) -> Cut:
229
379
  fields: dict[str, tuple[str, int]] = pending["fields"]
230
380
  cut_id: str = pending["cut_id"]
@@ -248,9 +398,10 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
248
398
  subtitles = _parse_subtitles(fields, cut_id=cut_id, line=line)
249
399
  picture: dict[str, float] = {}
250
400
  picture_raw = fields.get("画面")
401
+ untouched = picture_raw is not None and picture_raw[0].strip() == PICTURE_UNTOUCHED
251
402
  if picture_raw is not None:
252
403
  text, where = picture_raw
253
- if text.strip() not in {"", "无", "不校"}:
404
+ if text.strip() not in {"", "无", PICTURE_UNTOUCHED}:
254
405
  for label, value in PICTURE_TERM.findall(text):
255
406
  key = PICTURE_KEYS[label]
256
407
  number = float(value)
@@ -264,7 +415,8 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
264
415
  if not picture:
265
416
  raise EditError(
266
417
  f"{CUT_LIST_NAME}:{where}: {cut_id} 的画面写了内容但没有可执行的项;"
267
- "只认「亮度 <数>」「饱和 <数>」「色温 <数>」,不需要校正时写「无」"
418
+ "只认「亮度 <数>」「饱和 <数>」「色温 <数>」,不需要校正时写「无」,"
419
+ f"连自动接镜也不要时写「{PICTURE_UNTOUCHED}」"
268
420
  )
269
421
  return Cut(
270
422
  cut_id=cut_id,
@@ -277,24 +429,30 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
277
429
  subtitles=subtitles,
278
430
  picture=picture,
279
431
  line_number=line,
432
+ screen_texts=_parse_screen_texts(fields, cut_id=cut_id, line=line),
433
+ sound_effects=_parse_sound_effects(fields, cut_id=cut_id, line=line),
434
+ untouched=untouched,
280
435
  )
281
436
 
282
437
 
283
- def _parse_subtitles(
284
- fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
285
- ) -> tuple[tuple[Optional[float], Optional[float], str], ...]:
286
- """Read every subtitle a cut declares, in written order.
438
+ def _field_entries(
439
+ fields: dict[str, tuple[str, int]],
440
+ pattern: re.Pattern[str],
441
+ label: str,
442
+ *,
443
+ cut_id: str,
444
+ line: int,
445
+ ) -> tuple[bool, list[tuple[int, str, int]]]:
446
+ """Collect one kind of field, written once plain or as 「<label> 1..N」.
287
447
 
288
- A shot is not one line. The exchange "就是什么 / 就是少了点东西 / 少了什么" is a
289
- single over-shoulder shot carrying three, and a cut list that can hold only
290
- one of them silently drops the other two — the film then plays lines that
291
- never reach the screen, and nothing reports it.
448
+ Returns whether the numbered form was used, and (number, value, line) rows
449
+ in number order. The plain form comes back as a single row numbered 1.
292
450
  """
293
451
 
294
452
  numbered: list[tuple[int, str, int]] = []
295
453
  plain: Optional[tuple[str, int]] = None
296
454
  for key, (value, where) in fields.items():
297
- match = SUBTITLE_FIELD.match(key.strip())
455
+ match = pattern.match(key.strip())
298
456
  if not match:
299
457
  continue
300
458
  if match.group(1) is None:
@@ -304,27 +462,162 @@ def _parse_subtitles(
304
462
 
305
463
  if numbered and plain is not None:
306
464
  raise EditError(
307
- f"{CUT_LIST_NAME}:{line}: {cut_id} 同时写了「字幕」和「字幕 N」;"
308
- "一段用一种写法——一句用「字幕」,多句全部编号"
465
+ f"{CUT_LIST_NAME}:{line}: {cut_id} 同时写了「{label}」和「{label} N」;"
466
+ f"一段用一种写法——一条用「{label}」,多条全部编号"
309
467
  )
468
+ if not numbered:
469
+ return False, [] if plain is None else [(1, plain[0], plain[1])]
470
+ numbered.sort()
471
+ expected = list(range(1, len(numbered) + 1))
472
+ if [item[0] for item in numbered] != expected:
473
+ raise EditError(
474
+ f"{CUT_LIST_NAME}:{line}: {cut_id} 的{label}编号要从 1 连续排到 "
475
+ f"{len(numbered)},当前是 {[item[0] for item in numbered]}"
476
+ )
477
+ return True, numbered
478
+
479
+
480
+ def _parse_screen_texts(
481
+ fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
482
+ ) -> tuple[ScreenText, ...]:
483
+ """Read 「画面文字 N:<起>-<止> <样式> <项>|<项>…(倒计时:<秒>|接续)」 lines."""
484
+
485
+ _, entries = _field_entries(fields, SCREEN_TEXT_FIELD, "画面文字", cut_id=cut_id, line=line)
486
+ texts: list[ScreenText] = []
487
+ for index, value, where in entries:
488
+ if value.strip() == "无":
489
+ continue
490
+ countdown: Optional[float] = None
491
+ resume = False
492
+ counted = COUNTDOWN.search(value)
493
+ if counted:
494
+ value = value[: counted.start()]
495
+ if counted.group(1) == "接续":
496
+ resume = True
497
+ else:
498
+ countdown = float(counted.group(1))
499
+ found = SCREEN_TEXT.match(value)
500
+ if not found:
501
+ raise EditError(
502
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「画面文字 {index}」要写成"
503
+ f"「<起>-<止> <样式> <文字>」,样式是 {'/'.join(SCREEN_TEXT_STYLES)}"
504
+ )
505
+ style = found.group(3)
506
+ items: list[str] = []
507
+ rarities: list[Optional[str]] = []
508
+ for raw in found.group(4).split(SCREEN_TEXT_ITEM_SEPARATOR):
509
+ item = raw.strip()
510
+ rare = RARITY.search(item)
511
+ if rare and style not in RARITY_STYLES:
512
+ raise EditError(
513
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「画面文字 {index}」是{style},"
514
+ f"稀有度只用在{'或'.join(sorted(RARITY_STYLES))}上"
515
+ )
516
+ items.append(item[: rare.start()].strip() if rare else item)
517
+ rarities.append(rare.group(1) if rare else None)
518
+ if not all(items):
519
+ raise EditError(
520
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「画面文字 {index}」有空项;"
521
+ f"「{SCREEN_TEXT_ITEM_SEPARATOR}」两边都要有文字"
522
+ )
523
+ if counted and style not in COUNTDOWN_STYLES:
524
+ raise EditError(
525
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「画面文字 {index}」是{style},"
526
+ f"倒计时只放在{'或'.join(sorted(COUNTDOWN_STYLES))}上"
527
+ )
528
+ texts.append(ScreenText(
529
+ float(found.group(1)), float(found.group(2)), style, tuple(items),
530
+ countdown, resume, tuple(rarities),
531
+ ))
532
+
533
+ by_slot: dict[str, list[ScreenText]] = {}
534
+ for text in texts:
535
+ by_slot.setdefault(SCREEN_TEXT_SLOTS[text.style], []).append(text)
536
+ for slot, group in by_slot.items():
537
+ ordered = sorted(group, key=lambda item: item.start)
538
+ for earlier, later in zip(ordered, ordered[1:]):
539
+ if later.start < earlier.end - TOLERANCE:
540
+ raise EditError(
541
+ f"{CUT_LIST_NAME}:{line}: {cut_id} 的画面文字时间重叠:同一位置({slot})"
542
+ f"{earlier.start:g}-{earlier.end:g} 与 {later.start:g}-{later.end:g}"
543
+ )
544
+ return tuple(texts)
545
+
546
+
547
+ def _parse_sound_effects(
548
+ fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
549
+ ) -> tuple[SoundEffect, ...]:
550
+ """Read 「音效 N:<起>-<止> <文件>(增益:<dB>)」 lines."""
310
551
 
311
- if numbered:
312
- numbered.sort()
313
- expected = list(range(1, len(numbered) + 1))
314
- if [item[0] for item in numbered] != expected:
552
+ _, entries = _field_entries(fields, SOUND_EFFECT_FIELD, "音效", cut_id=cut_id, line=line)
553
+ effects: list[SoundEffect] = []
554
+ for index, value, where in entries:
555
+ if value.strip() == "无":
556
+ continue
557
+ gain = 0.0
558
+ stated = GAIN.search(value)
559
+ if stated:
560
+ value = value[: stated.start()]
561
+ gain = float(stated.group(1))
562
+ low, high = GAIN_LIMITS
563
+ if not low <= gain <= high:
564
+ raise EditError(
565
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」增益 {gain:g} dB "
566
+ f"超出 {low:g} 到 {high:g}"
567
+ )
568
+ found = SUBTITLE_CUE.match(value)
569
+ if not found:
315
570
  raise EditError(
316
- f"{CUT_LIST_NAME}:{line}: {cut_id} 的字幕编号要从 1 连续排到 "
317
- f"{len(numbered)},当前是 {[item[0] for item in numbered]}"
571
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」要写成"
572
+ "「<起>-<止> <项目相对路径>」"
318
573
  )
319
- cues: list[tuple[Optional[float], Optional[float], str]] = []
320
- for index, value, where in numbered:
574
+ effects.append(SoundEffect(float(found.group(1)), float(found.group(2)), found.group(3), gain))
575
+ return tuple(effects)
576
+
577
+
578
+ def _parse_subtitles(
579
+ fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
580
+ ) -> tuple[Subtitle, ...]:
581
+ """Read every subtitle a cut declares, in written order.
582
+
583
+ A shot is not one line. The exchange "就是什么 / 就是少了点东西 / 少了什么" is a
584
+ single over-shoulder shot carrying three, and a cut list that can hold only
585
+ one of them silently drops the other two — the film then plays lines that
586
+ never reach the screen, and nothing reports it.
587
+ """
588
+
589
+ is_numbered, entries = _field_entries(
590
+ fields, SUBTITLE_FIELD, "字幕", cut_id=cut_id, line=line
591
+ )
592
+
593
+ def keywords(value: str, where: int) -> tuple[str, tuple[str, ...]]:
594
+ marked = SUBTITLE_KEYWORDS.search(value)
595
+ if not marked:
596
+ return value.strip(), ()
597
+ text = value[: marked.start()].strip()
598
+ words = tuple(
599
+ word.strip() for word in marked.group(1).split(SCREEN_TEXT_ITEM_SEPARATOR)
600
+ )
601
+ stray = [
602
+ word for word in words if word not in text or not _display_line(word)
603
+ ]
604
+ if stray:
605
+ raise EditError(
606
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的字幕重点词不在这句里或只有标点: {stray}"
607
+ )
608
+ return text, words
609
+
610
+ if is_numbered:
611
+ cues: list[Subtitle] = []
612
+ for index, value, where in entries:
321
613
  found = SUBTITLE_CUE.match(value)
322
614
  if not found:
323
615
  raise EditError(
324
616
  f"{CUT_LIST_NAME}:{where}: {cut_id} 的「字幕 {index}」要写成"
325
617
  "「<起>-<止> <台词>」,多句必须各自带时间"
326
618
  )
327
- cues.append((float(found.group(1)), float(found.group(2)), found.group(3)))
619
+ text, words = keywords(found.group(3), where)
620
+ cues.append((float(found.group(1)), float(found.group(2)), text, words))
328
621
  for earlier, later in zip(cues, cues[1:]):
329
622
  if later[0] is not None and earlier[1] is not None and later[0] < earlier[1]:
330
623
  raise EditError(
@@ -332,17 +625,18 @@ def _parse_subtitles(
332
625
  )
333
626
  return tuple(cues)
334
627
 
335
- if plain is None or plain[0].strip() == "无":
628
+ if not entries or entries[0][1].strip() == "无":
336
629
  return ()
630
+ text, words = keywords(entries[0][1], entries[0][2])
337
631
  window_raw = fields.get("字幕时间")
338
632
  if window_raw is None:
339
- return ((None, None, plain[0].strip()),)
633
+ return ((None, None, text, words),)
340
634
  found = SUBTITLE_WINDOW.match(window_raw[0])
341
635
  if not found:
342
636
  raise EditError(
343
637
  f"{CUT_LIST_NAME}:{window_raw[1]}: {cut_id} 的字幕时间要写成「起-止」秒数"
344
638
  )
345
- return ((float(found.group(1)), float(found.group(2)), plain[0].strip()),)
639
+ return ((float(found.group(1)), float(found.group(2)), text, words),)
346
640
 
347
641
 
348
642
  def _which(name: str) -> Optional[str]:
@@ -494,7 +788,7 @@ def check_cuts(
494
788
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 {cut.end:.2f} "
495
789
  f"超过素材实际时长 {available:.2f}"
496
790
  )
497
- for window_start, window_end, text in cut.subtitles:
791
+ for window_start, window_end, text, _ in cut.subtitles:
498
792
  if screenplay and _normalize(text) not in _normalize(screenplay):
499
793
  findings.append(
500
794
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕在《"
@@ -507,12 +801,57 @@ def check_cuts(
507
801
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间超出本段区间"
508
802
  f": {window_start}-{window_end}"
509
803
  )
804
+ for effect in cut.sound_effects:
805
+ if not 0 <= effect.start < effect.end <= span + TOLERANCE:
806
+ findings.append(
807
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效时间超出本段区间"
808
+ f": {effect.start:g}-{effect.end:g}"
809
+ )
810
+ if _resolve_media(episode, project_root, effect.path) is None:
811
+ findings.append(
812
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效文件不存在: "
813
+ f"{effect.path}"
814
+ )
510
815
 
816
+ findings.extend(_screen_text_findings(cuts, screenplay))
511
817
  findings.extend(_overlap_findings(cuts))
512
818
  findings.extend(_stale_window_findings(episode, project_root, cuts))
513
819
  return findings
514
820
 
515
821
 
822
+ def _screen_text_findings(cuts: Sequence[Cut], screenplay: str) -> list[str]:
823
+ """Screen text is held to 剧本.md's [画面文字] lines, the way subtitles are to dialogue.
824
+
825
+ Without a screenplay the check is skipped exactly as the subtitle one is;
826
+ with one, a screenplay that declares no screen text traces nothing.
827
+ """
828
+
829
+ findings: list[str] = []
830
+ sources = [_normalize(body) for body in SCREENPLAY_SCREEN_TEXT.findall(screenplay)]
831
+ counting = False
832
+ for cut in cuts:
833
+ span = cut.end - cut.start
834
+ for text in sorted(cut.screen_texts, key=lambda item: item.start):
835
+ where = f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id}"
836
+ if not 0 <= text.start < text.end <= span + TOLERANCE:
837
+ findings.append(
838
+ f"{where} 的画面文字时间超出本段区间: {text.start:g}-{text.end:g}"
839
+ )
840
+ if screenplay:
841
+ for item in text.items:
842
+ if not any(_normalize(item) in source for source in sources):
843
+ findings.append(
844
+ f"{where} 的画面文字在《{SCREENPLAY_DOCUMENT}》的"
845
+ f"「[画面文字]」行里找不到: {item}"
846
+ )
847
+ if text.resume and not counting:
848
+ findings.append(
849
+ f"{where} 的画面文字写了「倒计时:接续」,但成片里在它之前没有倒计时"
850
+ )
851
+ counting = counting or text.countdown is not None or text.resume
852
+ return findings
853
+
854
+
516
855
  def _stale_window_findings(
517
856
  episode: Path, project_root: Path, cuts: Sequence[Cut]
518
857
  ) -> list[str]:
@@ -533,7 +872,12 @@ def _stale_window_findings(
533
872
  return []
534
873
  findings: list[str] = []
535
874
  for cut in cuts:
536
- if not any(start is not None for start, _, _ in cut.subtitles):
875
+ timed = (
876
+ any(subtitle[0] is not None for subtitle in cut.subtitles)
877
+ or cut.screen_texts
878
+ or cut.sound_effects
879
+ )
880
+ if not timed:
537
881
  continue
538
882
  media = _resolve_media(episode, project_root, cut.media)
539
883
  if media is None:
@@ -545,7 +889,7 @@ def _stale_window_findings(
545
889
  if changed > authored + 1.0:
546
890
  findings.append(
547
891
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材比剪辑单新"
548
- f"({cut.media});这一段的字幕时间是按旧素材的发声区间反推的,"
892
+ f"({cut.media});这一段的字幕、画面文字与音效时间是按旧素材反推的,"
549
893
  "重出之后必须重测再改,不能沿用"
550
894
  )
551
895
  return findings
@@ -600,9 +944,16 @@ def render(
600
944
  segments_root = output_root / SEGMENT_DIRECTORY
601
945
  segments_root.mkdir(parents=True, exist_ok=True)
602
946
 
947
+ def measure(cut: Cut) -> Optional[ChannelStats]:
948
+ media = _resolve_media(episode, project_root, cut.media)
949
+ return None if media is None else _channel_stats(ffmpeg, media, cut.start, cut.end - cut.start)
950
+
951
+ scenes = _scene_keys(episode, cuts)
952
+ pictures, auto = _picture_plan(cuts, scenes, measure, enabled=delivery.shot_match)
953
+
603
954
  segments: list[Path] = []
604
955
  spans: list[float] = []
605
- for cut in cuts:
956
+ for cut, match in zip(cuts, pictures):
606
957
  media = _resolve_media(episode, project_root, cut.media)
607
958
  if media is None:
608
959
  raise EditError(f"{cut.cut_id} 的素材不存在: {cut.media}")
@@ -611,7 +962,6 @@ def render(
611
962
  ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
612
963
  "-ss", f"{cut.start:.3f}", "-t", f"{cut.end - cut.start:.3f}", "-i", str(media),
613
964
  ]
614
- match = _shot_match_filter(cut)
615
965
  if match:
616
966
  command += ["-vf", match]
617
967
  command += [
@@ -631,30 +981,53 @@ def render(
631
981
  _run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
632
982
  "-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", str(joined)])
633
983
 
984
+ effects: list[tuple[float, float, Path, float]] = []
985
+ for start, duration, written, gain in _placed_sound_effects(cuts, spans):
986
+ resolved = _resolve_media(episode, project_root, written)
987
+ if resolved is None:
988
+ raise EditError(f"音效文件不存在: {written}")
989
+ effects.append((start, duration, resolved, gain))
990
+ if effects:
991
+ # Mixed into its own file first, so the loudness pass below measures
992
+ # the film the audience hears rather than the one before the chimes.
993
+ mixed = output_root / "成片-含音效.mp4"
994
+ _run(_sound_effect_command(ffmpeg, joined, effects, mixed))
995
+ joined = mixed
996
+
634
997
  filters: list[str] = []
635
998
  subtitle_path = None
636
999
  styled_path = None
637
1000
  overlay_path = None
638
- if burn_subtitles and any(cut.subtitles for cut in cuts):
639
- canvas = probe_stream(segments[0])
640
- cues = _subtitle_cues(cuts, spans)
1001
+ cues = (
1002
+ _display_cues(_subtitle_cues(cuts, spans), _screenplay_text(episode))
1003
+ if burn_subtitles and any(cut.subtitles for cut in cuts)
1004
+ else []
1005
+ )
1006
+ layers = _screen_text_layers(cuts, spans)
1007
+ canvas = probe_stream(segments[0]) if cues or layers else {}
1008
+ if cues:
641
1009
  subtitle_path = output_root / "字幕.srt"
642
1010
  subtitle_path.write_text(_build_srt(cues), encoding="utf-8")
643
- if renderer == "remotion":
644
- overlay_path = _render_remotion_overlay(
645
- output_root, cues, canvas, sum(spans), remotion_workspace,
646
- concurrency=remotion_concurrency,
647
- )
648
- else:
649
- styled = styled_path = output_root / "字幕.ass"
650
- styled.write_text(
651
- _build_ass(cues, canvas["width"] or 1080, canvas["height"] or 1920),
652
- encoding="utf-8",
653
- )
654
- escaped = (
655
- str(styled).replace("\\", "/").replace(":", r"\:").replace("'", r"\'")
656
- )
657
- filters.append(f"ass='{escaped}'")
1011
+ # Screen text has no ffmpeg route: panels, glow and a live countdown are
1012
+ # layout, not subtitle styling. So it always goes through the Remotion
1013
+ # pass, and on the ffmpeg subtitle route that pass carries no cues.
1014
+ remotion_cues = cues if renderer == "remotion" else []
1015
+ if remotion_cues or layers:
1016
+ overlay_path = _render_remotion_overlay(
1017
+ output_root, remotion_cues, layers, canvas, sum(spans), remotion_workspace,
1018
+ concurrency=remotion_concurrency,
1019
+ needed_for="画面文字" if layers else "字幕",
1020
+ )
1021
+ if cues and renderer != "remotion":
1022
+ styled = styled_path = output_root / "字幕.ass"
1023
+ styled.write_text(
1024
+ _build_ass(cues, canvas["width"] or 1080, canvas["height"] or 1920),
1025
+ encoding="utf-8",
1026
+ )
1027
+ escaped = (
1028
+ str(styled).replace("\\", "/").replace(":", r"\:").replace("'", r"\'")
1029
+ )
1030
+ filters.append(f"ass='{escaped}'")
658
1031
 
659
1032
  # Grain goes on last, over the whole assembled film, so one texture sits
660
1033
  # across every cut. `t` makes it move frame to frame — static noise reads
@@ -674,12 +1047,9 @@ def render(
674
1047
  # to be named: the default one drops it and the overlay arrives as
675
1048
  # an opaque black rectangle.
676
1049
  command += ["-c:v", "libvpx", "-i", str(overlay_path)]
677
- # The overlay branch owns its own chain, so grain is spliced in
678
- # after the composite rather than left in `filters`, which this
679
- # branch never reads.
680
- chain = "[0:v][1:v]overlay=0:0:format=auto"
681
- if grain is not None:
682
- chain += f",{grain}"
1050
+ # ffmpeg-route subtitles and grain follow the composite, so the
1051
+ # subtitle sits above the screen text and one grain covers both.
1052
+ chain = ",".join(["[0:v][1:v]overlay=0:0:format=auto", *filters])
683
1053
  command += [
684
1054
  "-filter_complex",
685
1055
  f"{chain},format=yuv420p[v]",
@@ -704,22 +1074,18 @@ def render(
704
1074
  "分段": [str(segment) for segment in segments],
705
1075
  "字幕": str(subtitle_path) if subtitle_path else None,
706
1076
  "压制字幕": str(styled_path) if styled_path else None,
707
- "字幕叠层": str(overlay_path) if overlay_path else None,
1077
+ "叠层": str(overlay_path) if overlay_path else None,
708
1078
  "字幕渲染": renderer if subtitle_path else None,
1079
+ "画面文字": len(layers),
1080
+ "音效": len(effects),
1081
+ "自动接镜": _shot_match_report(auto, scenes, enabled=delivery.shot_match),
709
1082
  "段数": len(segments),
710
1083
  "各段时长之和": round(sum(cut.end - cut.start for cut in cuts), 2),
711
1084
  }
712
1085
 
713
1086
 
714
1087
  def _shot_match_filter(cut: Cut) -> str:
715
- """Match one cut to its neighbours, using only what the cut list declared.
716
-
717
- Generated shots drift: two takes of the same person at the same table come
718
- back a stop apart and half a step of white balance away from each other, and
719
- the join reads as a mistake rather than a cut. The correction stays a stated
720
- creator decision — the tool never measures a clip and adjusts it on its own,
721
- because a correction nobody wrote down is one nobody can review.
722
- """
1088
+ """The correction a cut states in 「画面:」. It replaces the automatic match."""
723
1089
 
724
1090
  stages: list[str] = []
725
1091
  eq = [
@@ -737,6 +1103,192 @@ def _shot_match_filter(cut: Cut) -> str:
737
1103
  return ",".join(stages)
738
1104
 
739
1105
 
1106
+ def _heading_fields(path: Path, heading: re.Pattern[str], field: str) -> dict[str, str]:
1107
+ """`{ID: value}` for one `- <field>:` line under each matching `##` heading."""
1108
+
1109
+ if not path.is_file():
1110
+ return {}
1111
+ found: dict[str, str] = {}
1112
+ current: Optional[str] = None
1113
+ for raw in path.read_text(encoding="utf-8").splitlines():
1114
+ match = heading.match(raw)
1115
+ if match:
1116
+ current = match.group(1)
1117
+ continue
1118
+ if raw.startswith("## "):
1119
+ current = None
1120
+ continue
1121
+ line = FIELD.match(raw)
1122
+ if current and line and line.group(1).strip() == field:
1123
+ found.setdefault(current, line.group(2).strip())
1124
+ return found
1125
+
1126
+
1127
+ def _scene_keys(episode: Path, cuts: Sequence[Cut]) -> list[Optional[tuple[str, ...]]]:
1128
+ """Each cut's scene: MOTION → its 分镜 SHOT → the scene IDs that SHOT's 来源 names.
1129
+
1130
+ A shot drawing on two scenes keys on both, so it matches neither neighbour.
1131
+ None where the chain breaks; such a cut is never matched.
1132
+ """
1133
+
1134
+ shots = _heading_fields(episode / MOTION_DOCUMENT, MOTION_HEADING, "分镜")
1135
+ sources = _heading_fields(episode / STORYBOARD_DOCUMENT, SHOT_HEADING, "来源")
1136
+ keys: list[Optional[tuple[str, ...]]] = []
1137
+ for cut in cuts:
1138
+ shot = SHOT_REFERENCE.search(shots.get(cut.motion, ""))
1139
+ scenes = tuple(SCENE_ID.findall(sources.get(shot.group(0), ""))) if shot else ()
1140
+ keys.append(scenes or None)
1141
+ return keys
1142
+
1143
+
1144
+ def _scene_runs(scenes: Sequence[Optional[tuple[str, ...]]]) -> list[list[int]]:
1145
+ """Indexes of consecutive cuts from one scene. Intercut scenes form separate runs."""
1146
+
1147
+ runs: list[list[int]] = []
1148
+ for index, scene in enumerate(scenes):
1149
+ if scene is None:
1150
+ continue
1151
+ if runs and scenes[index - 1] == scene:
1152
+ runs[-1].append(index)
1153
+ else:
1154
+ runs.append([index])
1155
+ return runs
1156
+
1157
+
1158
+ def _median(values: Sequence[float]) -> float:
1159
+ ordered = sorted(values)
1160
+ middle = len(ordered) // 2
1161
+ return ordered[middle] if len(ordered) % 2 else (ordered[middle - 1] + ordered[middle]) / 2
1162
+
1163
+
1164
+ def _match_toward(
1165
+ stats: ChannelStats, reference: ChannelStats, strength: float = SHOT_MATCH_STRENGTH
1166
+ ) -> ShotMatch:
1167
+ """Gain and offset per channel moving mean and spread `strength` of the way to `reference`."""
1168
+
1169
+ low, high = SHOT_MATCH_GAIN_LIMITS
1170
+ gains: list[float] = []
1171
+ offsets: list[float] = []
1172
+ for mean, spread, target_mean, target_spread in zip(
1173
+ stats.mean, stats.spread, reference.mean, reference.spread
1174
+ ):
1175
+ mean_to = mean + strength * (target_mean - mean)
1176
+ spread_to = spread + strength * (target_spread - spread)
1177
+ gain = min(high, max(low, spread_to / spread)) if spread > 1e-6 else 1.0
1178
+ gains.append(gain)
1179
+ offsets.append(mean_to - gain * mean)
1180
+ return ShotMatch(tuple(gains), tuple(offsets))
1181
+
1182
+
1183
+ def _plan_shot_match(
1184
+ cuts: Sequence[Cut],
1185
+ scenes: Sequence[Optional[tuple[str, ...]]],
1186
+ measure: Callable[[Cut], Optional[ChannelStats]],
1187
+ strength: float = SHOT_MATCH_STRENGTH,
1188
+ ) -> dict[str, ShotMatch]:
1189
+ """The automatic match for every cut it applies to, by CUT ID.
1190
+
1191
+ Within each run of one scene, the cuts that state no correction are pulled
1192
+ toward the median of those same cuts. A cut with 「画面:<校正>」 or
1193
+ 「画面:不校」 is neither corrected nor counted in the median. `measure(cut)`
1194
+ returns ChannelStats, or None when the clip cannot be read.
1195
+ """
1196
+
1197
+ plan: dict[str, ShotMatch] = {}
1198
+ for run in _scene_runs(scenes):
1199
+ eligible = [cuts[i] for i in run if not cuts[i].picture and not cuts[i].untouched]
1200
+ measured = [(cut, measure(cut)) for cut in eligible] if len(eligible) > 1 else []
1201
+ known = [(cut, stats) for cut, stats in measured if stats is not None]
1202
+ if len(known) < 2:
1203
+ continue
1204
+ reference = ChannelStats(
1205
+ tuple(_median([stats.mean[c] for _, stats in known]) for c in range(3)),
1206
+ tuple(_median([stats.spread[c] for _, stats in known]) for c in range(3)),
1207
+ )
1208
+ for cut, stats in known:
1209
+ plan[cut.cut_id] = _match_toward(stats, reference, strength)
1210
+ return plan
1211
+
1212
+
1213
+ def _auto_match_filter(match: ShotMatch) -> str:
1214
+ """One `lutrgb` stage on 8-bit RGB; the lookup clips to 0-255 itself.
1215
+
1216
+ The numbers were measured on a 0-255 scale. Left to negotiate, a 10-bit
1217
+ source runs the lookup at 16 bits a channel and the offsets barely move it.
1218
+ """
1219
+
1220
+ return "format=rgb24,lutrgb=" + ":".join(
1221
+ f"{channel}='val*{gain:.4f}{offset:+.3f}'"
1222
+ for channel, gain, offset in zip("rgb", match.gains, match.offsets)
1223
+ )
1224
+
1225
+
1226
+ def _picture_plan(
1227
+ cuts: Sequence[Cut],
1228
+ scenes: Sequence[Optional[tuple[str, ...]]],
1229
+ measure: Callable[[Cut], Optional[ChannelStats]],
1230
+ *,
1231
+ enabled: bool,
1232
+ ) -> tuple[list[str], dict[str, ShotMatch]]:
1233
+ """Each cut's picture filter: its stated correction, else the automatic match, else none."""
1234
+
1235
+ auto = _plan_shot_match(cuts, scenes, measure) if enabled else {}
1236
+ filters = [
1237
+ _shot_match_filter(cut) if cut.picture
1238
+ else _auto_match_filter(auto[cut.cut_id]) if cut.cut_id in auto
1239
+ else ""
1240
+ for cut in cuts
1241
+ ]
1242
+ return filters, auto
1243
+
1244
+
1245
+ def _shot_match_report(
1246
+ auto: dict[str, ShotMatch], scenes: Sequence[Optional[tuple[str, ...]]], *, enabled: bool
1247
+ ) -> Any:
1248
+ """What the automatic match did, so the correction is on the record."""
1249
+
1250
+ if not enabled:
1251
+ return "关(剪辑单写了「接镜匹配:无」)"
1252
+ if not any(scenes):
1253
+ return f"未执行(按《{MOTION_DOCUMENT}》与《{STORYBOARD_DOCUMENT}》找不到各段所属场景)"
1254
+ return {
1255
+ cut_id: {
1256
+ "增益": [round(gain, 3) for gain in match.gains],
1257
+ "偏移": [round(offset, 1) for offset in match.offsets],
1258
+ }
1259
+ for cut_id, match in auto.items()
1260
+ }
1261
+
1262
+
1263
+ def _channel_stats(
1264
+ ffmpeg: str, media: Path, start: float, duration: float
1265
+ ) -> Optional[ChannelStats]:
1266
+ """Per-channel mean and spread of one cut's source range, sampled small."""
1267
+
1268
+ result = subprocess.run(
1269
+ [ffmpeg, "-v", "error", "-ss", f"{start:.3f}", "-t", f"{duration:.3f}",
1270
+ "-i", str(media), "-vf", SHOT_MATCH_SAMPLE,
1271
+ "-pix_fmt", "rgb24", "-f", "rawvideo", "-"],
1272
+ capture_output=True, check=False,
1273
+ )
1274
+ if result.returncode != 0 or len(result.stdout) < 3:
1275
+ return None
1276
+ return _rgb_stats(result.stdout)
1277
+
1278
+
1279
+ def _rgb_stats(raw: bytes) -> ChannelStats:
1280
+ means: list[float] = []
1281
+ spreads: list[float] = []
1282
+ for channel in range(3):
1283
+ values = raw[channel::3]
1284
+ count = len(values)
1285
+ mean = sum(values) / count
1286
+ square = sum(map(operator.mul, values, values)) / count
1287
+ means.append(mean)
1288
+ spreads.append(max(0.0, square - mean * mean) ** 0.5)
1289
+ return ChannelStats(tuple(means), tuple(spreads))
1290
+
1291
+
740
1292
  def _loudnorm_filter(ffmpeg: str, media: Path, target: float) -> str:
741
1293
  """Measure EBU R128 before normalization; verify the encoded output afterward.
742
1294
 
@@ -775,24 +1327,76 @@ def _sync_remotion_workspace(workspace: Path) -> Path:
775
1327
  """
776
1328
 
777
1329
  workspace = workspace.expanduser().resolve()
778
- (workspace / "src").mkdir(parents=True, exist_ok=True)
779
1330
  for name in REMOTION_SOURCE_FILES:
780
1331
  source = REMOTION_SOURCE / name
781
1332
  if not source.is_file():
782
1333
  raise EditError(f"技能里缺少 Remotion 源文件: {name}")
1334
+ (workspace / name).parent.mkdir(parents=True, exist_ok=True)
783
1335
  shutil.copyfile(source, workspace / name)
784
1336
  return workspace
785
1337
 
786
1338
 
1339
+ def _overlay_props(
1340
+ cues: Sequence[DisplayCue],
1341
+ layers: Sequence[dict[str, Any]],
1342
+ canvas: dict[str, Any],
1343
+ duration: float,
1344
+ ) -> dict[str, Any]:
1345
+ """The whole contract with the composition, in output seconds.
1346
+
1347
+ Typography -- faces, sizes, colours -- is the composition's own; only what
1348
+ to draw and when crosses this boundary.
1349
+ """
1350
+
1351
+ return {
1352
+ "cues": [
1353
+ {"start": round(cue.start, 3), "end": round(cue.end, 3), "text": cue.text,
1354
+ "kind": cue.kind, "keys": list(cue.keys)}
1355
+ for cue in cues
1356
+ ],
1357
+ "screenTexts": [
1358
+ {
1359
+ **layer,
1360
+ "start": round(layer["start"], 3),
1361
+ "end": round(layer["end"], 3),
1362
+ "countdown": (
1363
+ None if layer["countdown"] is None else round(layer["countdown"], 3)
1364
+ ),
1365
+ }
1366
+ for layer in layers
1367
+ ],
1368
+ "width": canvas.get("width") or 1080,
1369
+ "height": canvas.get("height") or 1920,
1370
+ "fps": canvas.get("fps") or 24,
1371
+ "durationInSeconds": round(duration, 3),
1372
+ }
1373
+
1374
+
1375
+ def _missing_remotion_packages(workspace: Path) -> list[str]:
1376
+ """Dependencies the synced package.json names that the workspace has not installed.
1377
+
1378
+ The fonts are packages too: a workspace installed before they were added
1379
+ has `node_modules` but no faces, and the bundle would fail halfway in.
1380
+ """
1381
+
1382
+ manifest = json.loads((workspace / "package.json").read_text(encoding="utf-8"))
1383
+ return sorted(
1384
+ name for name in manifest.get("dependencies", {})
1385
+ if not (workspace / "node_modules" / name / "package.json").is_file()
1386
+ )
1387
+
1388
+
787
1389
  def _render_remotion_overlay(
788
1390
  output_root: Path,
789
- cues: Sequence[tuple[float, float, str]],
1391
+ cues: Sequence[DisplayCue],
1392
+ layers: Sequence[dict[str, Any]],
790
1393
  canvas: dict[str, Any],
791
1394
  duration: float,
792
1395
  workspace_root: Path,
793
1396
  concurrency: int = DEFAULT_REMOTION_CONCURRENCY,
1397
+ needed_for: str = "字幕",
794
1398
  ) -> Path:
795
- """Render the subtitle layer as a transparent video with Remotion.
1399
+ """Render subtitles and screen text as one transparent video with Remotion.
796
1400
 
797
1401
  The picture is never re-drawn by the browser: only the type is, onto an
798
1402
  empty frame, and ffmpeg composites that over untouched footage. So the
@@ -806,44 +1410,33 @@ def _render_remotion_overlay(
806
1410
  """
807
1411
 
808
1412
  workspace = _sync_remotion_workspace(workspace_root)
809
- if not (workspace / "node_modules").is_dir():
1413
+ # Screen text has no other route, so the way out named here depends on why
1414
+ # the pass is running: dropping a flag cannot draw a system panel.
1415
+ alternative = (
1416
+ "剪辑单里有画面文字,它只能由这条路线绘制;不装就把画面文字行删掉或写「无」。"
1417
+ if needed_for == "画面文字"
1418
+ else "或改用默认的 ffmpeg 字幕(去掉 --subtitles remotion)。"
1419
+ )
1420
+ missing = _missing_remotion_packages(workspace)
1421
+ if missing:
810
1422
  raise EditError(
811
- f"Remotion 还没安装。先运行一次:\n"
1423
+ f"Remotion 叠层的依赖没装全(缺 {'、'.join(missing)}),{needed_for}需要它们。先运行一次:\n"
812
1424
  f" cd {workspace} && npm install\n"
813
- "或改用默认的 ffmpeg 字幕(去掉 --subtitles remotion)。\n"
1425
+ f"{alternative}\n"
814
1426
  "注意 Remotion 有自己的许可证:个人与小团队免费,超出规模需要商业授权;"
815
1427
  "本工具不会替你安装它。"
816
1428
  )
817
1429
  npx = _which("npx")
818
1430
  if npx is None:
819
1431
  raise EditError(
820
- "PATH 上没有 npx,无法运行 Remotion;装好 Node.js,或改用默认的 ffmpeg 字幕。"
1432
+ f"PATH 上没有 npx,无法运行 Remotion 绘制{needed_for};先装好 Node.js。{alternative}"
821
1433
  )
822
- props = output_root / "字幕.props.json"
1434
+ props = output_root / "叠层.props.json"
823
1435
  props.write_text(
824
- json.dumps(
825
- {
826
- "cues": [
827
- {"start": round(start, 3), "end": round(end, 3), "text": text}
828
- for start, end, text in cues
829
- ],
830
- "width": canvas["width"] or 1080,
831
- "height": canvas["height"] or 1920,
832
- "fps": canvas["fps"] or 24,
833
- "durationInSeconds": round(duration, 3),
834
- "fontScale": 0.034,
835
- "bottomScale": 0.055,
836
- "fontFamily": (
837
- '"PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC", '
838
- '"Microsoft YaHei", sans-serif'
839
- ),
840
- },
841
- ensure_ascii=False,
842
- indent=2,
843
- ),
1436
+ json.dumps(_overlay_props(cues, layers, canvas, duration), ensure_ascii=False, indent=2),
844
1437
  encoding="utf-8",
845
1438
  )
846
- overlay = output_root / "字幕叠层.webm"
1439
+ overlay = output_root / "叠层.webm"
847
1440
  result = subprocess.run(
848
1441
  [npx, "remotion", "render", REMOTION_COMPOSITION, str(overlay),
849
1442
  f"--props={props}", "--log=error",
@@ -864,7 +1457,7 @@ def _frame_geometry(media: Path) -> str:
864
1457
 
865
1458
  def _subtitle_cues(
866
1459
  cuts: Sequence[Cut], spans: Sequence[float]
867
- ) -> list[tuple[float, float, str]]:
1460
+ ) -> list[tuple[float, float, str, tuple[str, ...]]]:
868
1461
  """Place each line in output time, measuring the segments that were written.
869
1462
 
870
1463
  A segment lands on a frame boundary, so it is a few milliseconds longer than
@@ -873,33 +1466,308 @@ def _subtitle_cues(
873
1466
  before the actor stops speaking. The rendered files are the timeline.
874
1467
  """
875
1468
 
876
- cues: list[tuple[float, float, str]] = []
877
- cursor = 0.0
878
- for cut, span in zip(cuts, spans):
1469
+ cues: list[tuple[float, float, str, tuple[str, ...]]] = []
1470
+ for cut, cursor, scale in _timeline(cuts, spans):
879
1471
  declared = cut.end - cut.start
880
- # The windows were authored against the declared span; hold them in
881
- # place proportionally rather than letting the tail slip out.
882
- scale = span / declared if declared > 0 else 1.0
883
- for window_start, window_end, text in cut.subtitles:
1472
+ for window_start, window_end, text, words in cut.subtitles:
884
1473
  start = 0.0 if window_start is None else window_start
885
1474
  end = declared if window_end is None else window_end
886
- cues.append((cursor + start * scale, cursor + end * scale, text))
887
- cursor += span
1475
+ cues.append((cursor + start * scale, cursor + end * scale, text, words))
888
1476
  return cues
889
1477
 
890
1478
 
891
- def _build_srt(cues: Sequence[tuple[float, float, str]]) -> str:
892
- """The text is the screenplay's, verbatim; only the timing is ours."""
1479
+ def _timeline(
1480
+ cuts: Sequence[Cut], spans: Sequence[float]
1481
+ ) -> list[tuple[Cut, float, float]]:
1482
+ """Each cut with its output start and the factor from declared to rendered time.
1483
+
1484
+ Windows were authored against the declared span; scaling holds them in
1485
+ place proportionally rather than letting the tail slip out.
1486
+ """
1487
+
1488
+ placed: list[tuple[Cut, float, float]] = []
1489
+ cursor = 0.0
1490
+ for cut, span in zip(cuts, spans):
1491
+ declared = cut.end - cut.start
1492
+ placed.append((cut, cursor, span / declared if declared > 0 else 1.0))
1493
+ cursor += span
1494
+ return placed
1495
+
1496
+
1497
+ class DisplayCue(NamedTuple):
1498
+ """One subtitle as burned: output seconds, display text, speaker kind, highlights."""
1499
+
1500
+ start: float
1501
+ end: float
1502
+ text: str
1503
+ kind: str = "line"
1504
+ keys: tuple[str, ...] = ()
1505
+
1506
+
1507
+ def _display_line(text: str) -> str:
1508
+ """The burned form of a line: no closing punctuation, pauses as full-width spaces."""
1509
+
1510
+ shown = text.strip().rstrip(SUBTITLE_END_MARKS + "  ")
1511
+ shown = re.sub(f"[{SUBTITLE_PAUSE_MARKS}]+[  ]*", " ", shown)
1512
+ return re.sub(r"[  ]{2,}", " ", shown).strip("  ")
1513
+
1514
+
1515
+ def _visible(text: str) -> int:
1516
+ return len(re.sub(r"[  ]", "", text))
1517
+
1518
+
1519
+ def _split_display(
1520
+ text: str, limit: int = SUBTITLE_MAX_VISIBLE, keep: Sequence[str] = ()
1521
+ ) -> list[str]:
1522
+ """Break a displayed line into one-line pieces of at most `limit` characters.
1523
+
1524
+ Pieces break at the line's own pauses (the full-width spaces, and after ?!)
1525
+ and are packed back together while they fit. A single phrase longer than
1526
+ the limit is cut into near-equal parts, which is the only place a break
1527
+ falls where the screenplay has no pause. No break falls inside a word in
1528
+ `keep`: a highlight is looked up in the piece it lands in, and half a word
1529
+ matches nothing.
1530
+ """
1531
+
1532
+ if _visible(text) <= limit:
1533
+ return [text]
1534
+ held = {
1535
+ at
1536
+ for word in keep if word
1537
+ for found in re.finditer(re.escape(word), text)
1538
+ for at in range(found.start() + 1, found.end())
1539
+ }
1540
+ phrases: list[tuple[int, int]] = []
1541
+ for found in re.finditer(r"[^ ?!?!]+[?!?!]*|[?!?!]+", text):
1542
+ start, end = found.span()
1543
+ if phrases and any(at in held for at in range(phrases[-1][1], start + 1)):
1544
+ phrases[-1] = (phrases[-1][0], end)
1545
+ else:
1546
+ phrases.append((start, end))
1547
+
1548
+ def even_parts(start: int, end: int) -> list[tuple[int, int]]:
1549
+ count = _visible(text[start:end])
1550
+ if count <= limit:
1551
+ return [(start, end)]
1552
+ free = [at for at in range(start + 1, end) if at not in held]
1553
+ first: Optional[list[tuple[int, int]]] = None
1554
+ for parts in range(-(-count // limit), count + 1):
1555
+ size = -(-count // parts)
1556
+ cuts = [start]
1557
+ for index in range(1, parts):
1558
+ ideal = start + index * size
1559
+ later = [at for at in free if at > cuts[-1]]
1560
+ if not later:
1561
+ break
1562
+ cuts.append(min(later, key=lambda at: (abs(at - ideal), at)))
1563
+ spans = list(zip(cuts, cuts[1:] + [end]))
1564
+ if all(_visible(text[a:b]) <= limit for a, b in spans):
1565
+ return spans
1566
+ first = first or spans
1567
+ return first or [(start, end)]
1568
+
1569
+ packed: list[tuple[int, int]] = []
1570
+ for start, end in (span for phrase in phrases for span in even_parts(*phrase)):
1571
+ # Joining by slicing the line keeps whatever stood between the pieces.
1572
+ if packed and _visible(text[packed[-1][0]:end]) <= limit:
1573
+ packed[-1] = (packed[-1][0], end)
1574
+ else:
1575
+ packed.append((start, end))
1576
+ return [text[start:end].strip("  ") for start, end in packed]
1577
+
1578
+
1579
+ def _screenplay_text(episode: Path) -> str:
1580
+ path = episode / SCREENPLAY_DOCUMENT
1581
+ return path.read_text(encoding="utf-8") if path.is_file() else ""
1582
+
1583
+
1584
+ def _line_kind(text: str, screenplay: str) -> str:
1585
+ """`system`, `vo` or `line`, from the 剧本.md line the subtitle quotes."""
1586
+
1587
+ needle = _normalize(text)
1588
+ for raw in screenplay.splitlines():
1589
+ spoken = SPOKEN_LINE.match(raw)
1590
+ if not spoken or needle not in _normalize(spoken.group(3)):
1591
+ continue
1592
+ if spoken.group(1) == "VO":
1593
+ return "system" if SYSTEM_SPEAKER in spoken.group(2) else "vo"
1594
+ return "line"
1595
+ return "line"
1596
+
1597
+
1598
+ def _display_cues(
1599
+ cues: Sequence[tuple[float, float, str, tuple[str, ...]]],
1600
+ screenplay: str = "",
1601
+ ) -> list[DisplayCue]:
1602
+ """Turn placed lines into what is burned: display text, split, coloured by speaker.
1603
+
1604
+ A split line shares its window by character count, so each piece stays up
1605
+ about as long as it takes to read.
1606
+ """
1607
+
1608
+ shown: list[DisplayCue] = []
1609
+ for start, end, text, words in cues:
1610
+ kind = _line_kind(text, screenplay)
1611
+ # Highlights are matched in their burned form: 「十万,现金」 shows as 「十万 现金」.
1612
+ burned = tuple(_display_line(word) for word in words)
1613
+ pieces = _split_display(_display_line(text), keep=burned)
1614
+ # A line that is all punctuation ("……") has nothing left to show.
1615
+ pieces = [piece for piece in pieces if _visible(piece)]
1616
+ lost = [word for word in burned if not word or not any(word in p for p in pieces)]
1617
+ if lost:
1618
+ raise EditError(f"字幕「{text}」的重点词在压制后的字幕里找不到: {lost}")
1619
+ total = sum(_visible(piece) for piece in pieces)
1620
+ cursor = start
1621
+ for piece in pieces:
1622
+ length = (end - start) * _visible(piece) / total
1623
+ keys = tuple(word for word in burned if word in piece)
1624
+ shown.append(DisplayCue(cursor, cursor + length, piece, kind, keys))
1625
+ cursor += length
1626
+ return shown
1627
+
1628
+
1629
+ def _screen_text_layers(
1630
+ cuts: Sequence[Cut], spans: Sequence[float]
1631
+ ) -> list[dict[str, Any]]:
1632
+ """Place screen text in output time and resolve every countdown to a number.
1633
+
1634
+ 「接续」 continues the latest earlier countdown: its value here is that one's
1635
+ value minus the output time between them, so a chip picks up exactly where
1636
+ the task panel left off however long the cuts in between came out. A piece
1637
+ that meets an identical piece at a cut is extended instead of re-entering.
1638
+ """
1639
+
1640
+ layers: list[dict[str, Any]] = []
1641
+ latest: Optional[dict[str, Any]] = None
1642
+ for cut, cursor, scale in _timeline(cuts, spans):
1643
+ for text in sorted(cut.screen_texts, key=lambda item: item.start):
1644
+ start = cursor + text.start * scale
1645
+ end = cursor + text.end * scale
1646
+ countdown = text.countdown
1647
+ if text.resume:
1648
+ if latest is None:
1649
+ raise EditError(
1650
+ f"{cut.cut_id} 的画面文字写了「倒计时:接续」,但在它之前没有倒计时"
1651
+ )
1652
+ countdown = latest["countdown"] - (start - latest["start"])
1653
+ style = SCREEN_TEXT_STYLES[text.style]
1654
+ items = [
1655
+ {"text": item, "rarity": rarity}
1656
+ for item, rarity in zip(text.items, text.rarities or (None,) * len(text.items))
1657
+ ]
1658
+ previous = next((layer for layer in reversed(layers) if layer["style"] == style), None)
1659
+ if (
1660
+ previous is not None
1661
+ and previous["items"] == items
1662
+ and abs(previous["end"] - start) <= SCREEN_TEXT_JOIN_GAP
1663
+ and (
1664
+ (text.resume and previous is latest)
1665
+ or (countdown is None and previous["countdown"] is None)
1666
+ )
1667
+ ):
1668
+ previous["end"] = end
1669
+ continue
1670
+ layer = {
1671
+ "start": start,
1672
+ "end": end,
1673
+ "style": style,
1674
+ "items": items,
1675
+ "countdown": countdown,
1676
+ }
1677
+ layers.append(layer)
1678
+ if countdown is not None:
1679
+ latest = layer
1680
+ return layers
1681
+
1682
+
1683
+ def _placed_sound_effects(
1684
+ cuts: Sequence[Cut], spans: Sequence[float]
1685
+ ) -> list[tuple[float, float, str, float]]:
1686
+ """Each effect as (output start, duration, path as written, gain dB)."""
1687
+
1688
+ return [
1689
+ (cursor + effect.start * scale, (effect.end - effect.start) * scale,
1690
+ effect.path, effect.gain_db)
1691
+ for cut, cursor, scale in _timeline(cuts, spans)
1692
+ for effect in cut.sound_effects
1693
+ ]
1694
+
1695
+
1696
+ def _sound_effect_command(
1697
+ ffmpeg: str,
1698
+ film: Path,
1699
+ effects: Sequence[tuple[float, float, Path, float]],
1700
+ output: Path,
1701
+ ) -> list[str]:
1702
+ """Lay each effect into the film's own audio at its output time.
1703
+
1704
+ `normalize=0` keeps the dialogue at its level: amix's default divides every
1705
+ input by the input count, and the film would get quieter each time a chime
1706
+ is added. A short fade closes each effect so a trimmed tail does not click.
1707
+ """
1708
+
1709
+ command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(film)]
1710
+ # The film's channel layout is left as it is: forcing mono dialogue up to
1711
+ # stereo costs it 3 dB, and the effects are converted to match instead.
1712
+ stages = ["[0:a]aformat=sample_rates=48000[a0]"]
1713
+ labels = ["[a0]"]
1714
+ for index, (start, duration, media, gain) in enumerate(effects, start=1):
1715
+ command += ["-i", str(media)]
1716
+ fade = min(0.05, duration / 2)
1717
+ stages.append(
1718
+ f"[{index}:a]atrim=0:{duration:.3f},asetpts=PTS-STARTPTS,"
1719
+ f"afade=t=out:st={duration - fade:.3f}:d={fade:.3f},volume={gain:g}dB,"
1720
+ "aformat=sample_rates=48000,"
1721
+ f"adelay={round(start * 1000)}:all=1[s{index}]"
1722
+ )
1723
+ labels.append(f"[s{index}]")
1724
+ stages.append(
1725
+ f"{''.join(labels)}amix=inputs={len(labels)}:duration=first:"
1726
+ "dropout_transition=0:normalize=0[a]"
1727
+ )
1728
+ return command + [
1729
+ "-filter_complex", ";".join(stages),
1730
+ "-map", "0:v", "-map", "[a]",
1731
+ "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", str(output),
1732
+ ]
1733
+
1734
+
1735
+ def _build_srt(cues: Sequence[DisplayCue]) -> str:
1736
+ """The screenplay's words in their burned form; only the timing is ours."""
893
1737
 
894
1738
  return "\n".join(
895
- f"{index}\n{_timecode(start)} --> {_timecode(end)}\n{text}\n"
896
- for index, (start, end, text) in enumerate(cues, start=1)
1739
+ f"{index}\n{_timecode(cue.start)} --> {_timecode(cue.end)}\n{cue.text}\n"
1740
+ for index, cue in enumerate(cues, start=1)
897
1741
  )
898
1742
 
899
1743
 
900
- def _build_ass(
901
- cues: Sequence[tuple[float, float, str]], width: int, height: int
902
- ) -> str:
1744
+ def _ass_colour(hex_rgb: str) -> str:
1745
+ """`#RRGGBB` as ASS writes it: blue, green, red."""
1746
+
1747
+ red, green, blue = hex_rgb[1:3], hex_rgb[3:5], hex_rgb[5:7]
1748
+ return f"&H00{blue}{green}{red}&".upper()
1749
+
1750
+
1751
+ def _ass_line(cue: DisplayCue) -> str:
1752
+ """Escaped text with the speaker's colour and the highlighted words in yellow."""
1753
+
1754
+ base = _ass_colour(SUBTITLE_COLOURS[cue.kind])
1755
+ marked = "{\\c" + base + "}" if cue.kind != "line" else ""
1756
+ rest = cue.text
1757
+ while rest:
1758
+ hits = [(rest.find(word), word) for word in cue.keys if word in rest]
1759
+ if not hits:
1760
+ marked += _ass_text(rest)
1761
+ break
1762
+ at, word = min(hits)
1763
+ marked += _ass_text(rest[:at])
1764
+ marked += "{\\c" + _ass_colour(SUBTITLE_KEYWORD_COLOUR) + "}" + _ass_text(word)
1765
+ marked += "{\\c" + base + "}"
1766
+ rest = rest[at + len(word):]
1767
+ return marked
1768
+
1769
+
1770
+ def _build_ass(cues: Sequence[DisplayCue], width: int, height: int) -> str:
903
1771
  """Author the ASS directly so the type size is stated in frame pixels.
904
1772
 
905
1773
  Letting ffmpeg convert the SRT hands libass a 384-high canvas, and every
@@ -910,9 +1778,11 @@ def _build_ass(
910
1778
  PlayRes as the frame removes the conversion, so one unit is one pixel.
911
1779
  """
912
1780
 
913
- font_size = max(18, round(height * 0.034))
914
- outline = max(2, round(height * 0.0022))
915
- margin_v = max(24, round(height * 0.055))
1781
+ # The same measures as the Remotion layer: type 4.3% of frame height, a
1782
+ # 0.42% dark rim, and the baseline 24% up, clear of the platform's own UI.
1783
+ font_size = max(18, round(height * 0.043))
1784
+ outline = max(2, round(height * 0.0042))
1785
+ margin_v = max(24, round(height * 0.24))
916
1786
  margin_h = max(24, round(width * 0.06))
917
1787
  header = (
918
1788
  "[Script Info]\n"
@@ -927,15 +1797,15 @@ def _build_ass(
927
1797
  "ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
928
1798
  "MarginL, MarginR, MarginV, Encoding\n"
929
1799
  f"Style: 正片,{SUBTITLE_FONT},{font_size},&H00FFFFFF,&H00FFFFFF,"
930
- f"&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,{outline},1,2,"
1800
+ f"&H00120D0B,&H80000000,-1,0,0,0,100,100,0,0,1,{outline},1,2,"
931
1801
  f"{margin_h},{margin_h},{margin_v},1\n"
932
1802
  "\n[Events]\n"
933
1803
  "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, "
934
1804
  "Effect, Text\n"
935
1805
  )
936
1806
  lines = [
937
- f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},正片,,0,0,0,,{_ass_text(text)}"
938
- for start, end, text in cues
1807
+ f"Dialogue: 0,{_ass_time(cue.start)},{_ass_time(cue.end)},正片,,0,0,0,,{_ass_line(cue)}"
1808
+ for cue in cues
939
1809
  ]
940
1810
  return header + "\n".join(lines) + "\n"
941
1811
 
@@ -1043,11 +1913,173 @@ def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str,
1043
1913
  measurements["画内可读文字"] = (
1044
1914
  "未测(抽有画内文字的帧,逐字对《剧本.md》的「画面文字」与提示词声明的内容)"
1045
1915
  )
1916
+ segments = [episode / OUTPUT_DIRECTORY / SEGMENT_DIRECTORY / f"{cut.cut_id}.mp4" for cut in cuts]
1917
+ spans = (
1918
+ [probe_duration(segment) for segment in segments]
1919
+ if all(segment.is_file() for segment in segments)
1920
+ else None
1921
+ )
1922
+ if any(cut.screen_texts or cut.sound_effects for cut in cuts):
1923
+ measurements.update(_placements_for_sampling(cuts, spans))
1046
1924
  measurements["台词完整性"] = "未测(本工具不做转写;在成片上转写后逐句对《剧本.md》原文)"
1925
+ measurements.update(_frame_report(
1926
+ _grey_frames(ffmpeg, final), stream["fps"], cuts, spans, _scene_keys(episode, cuts)
1927
+ ))
1047
1928
  measurements["边界帧"] = "未测(抽剪辑点前后各一帧目视核对黑场/白场/半渲染帧)"
1048
1929
  return measurements
1049
1930
 
1050
1931
 
1932
+ def _grey_frames(ffmpeg: str, media: Path) -> Optional[list[bytes]]:
1933
+ """Every frame of the film, grey, at FRAME_PROBE_SIZE."""
1934
+
1935
+ width, height = FRAME_PROBE_SIZE
1936
+ result = subprocess.run(
1937
+ [ffmpeg, "-v", "error", "-i", str(media), "-vf", f"scale={width}:{height},format=gray",
1938
+ "-f", "rawvideo", "-"],
1939
+ capture_output=True, check=False,
1940
+ )
1941
+ size = width * height
1942
+ if result.returncode != 0 or len(result.stdout) < size:
1943
+ return None
1944
+ raw = result.stdout
1945
+ return [raw[at:at + size] for at in range(0, len(raw) - size + 1, size)]
1946
+
1947
+
1948
+ def _mean_difference(first: bytes, second: bytes) -> float:
1949
+ return sum(map(abs, map(operator.sub, first, second))) / len(first)
1950
+
1951
+
1952
+ def _suspect_frames(frames: Sequence[bytes]) -> list[tuple[int, float, float, float]]:
1953
+ """Frames unlike both neighbours while the neighbours are alike: (index, prev, next, across).
1954
+
1955
+ A hard cut differs from one side only; fast motion differs from both, but
1956
+ its neighbours differ from each other even more.
1957
+ """
1958
+
1959
+ steps = [_mean_difference(a, b) for a, b in zip(frames, frames[1:])]
1960
+ suspects: list[tuple[int, float, float, float]] = []
1961
+ for index in range(1, len(frames) - 1):
1962
+ before, after = steps[index - 1], steps[index]
1963
+ if min(before, after) <= FLASH_FRAME_DIFFERENCE:
1964
+ continue
1965
+ across = _mean_difference(frames[index - 1], frames[index + 1])
1966
+ if across < FLASH_NEIGHBOUR_SHARE * min(before, after):
1967
+ suspects.append((index, before, after, across))
1968
+ return suspects
1969
+
1970
+
1971
+ def _cut_jumps(
1972
+ means: Sequence[float],
1973
+ fps: float,
1974
+ cuts: Sequence[Cut],
1975
+ spans: Sequence[float],
1976
+ scenes: Sequence[Optional[tuple[str, ...]]],
1977
+ ) -> list[dict[str, Any]]:
1978
+ """Mean-luma change from the last frame before each cut to the first after it."""
1979
+
1980
+ rows: list[dict[str, Any]] = []
1981
+ cursor = 0.0
1982
+ for index in range(1, len(cuts)):
1983
+ cursor += spans[index - 1]
1984
+ frame = round(cursor * fps)
1985
+ if not 0 < frame < len(means):
1986
+ continue
1987
+ before, after = scenes[index - 1], scenes[index]
1988
+ rows.append({
1989
+ "切点秒": round(cursor, 2),
1990
+ "前段": cuts[index - 1].cut_id,
1991
+ "后段": cuts[index].cut_id,
1992
+ "亮度变化": round(means[frame] - means[frame - 1], 1),
1993
+ "场景": "未知" if before is None or after is None
1994
+ else "同场" if before == after else "换场",
1995
+ })
1996
+ return rows
1997
+
1998
+
1999
+ def _frame_report(
2000
+ frames: Optional[Sequence[bytes]],
2001
+ fps: float,
2002
+ cuts: Sequence[Cut],
2003
+ spans: Optional[Sequence[float]],
2004
+ scenes: Sequence[Optional[tuple[str, ...]]],
2005
+ ) -> dict[str, Any]:
2006
+ """Luma jumps at cuts and suspect frames, with what to look at. Nothing here blocks."""
2007
+
2008
+ if frames is None or not fps:
2009
+ reason = "未测(读不出成片的逐帧画面)"
2010
+ return {"切点亮度变化": reason, "疑似坏帧": reason}
2011
+ notes: list[str] = []
2012
+ report: dict[str, Any] = {}
2013
+ if spans is None:
2014
+ report["切点亮度变化"] = "未测(分段缺失,无法换算切点时间)"
2015
+ entrances: list[float] = []
2016
+ else:
2017
+ jumps = _cut_jumps([sum(frame) / len(frame) for frame in frames], fps, cuts, spans, scenes)
2018
+ report["切点亮度变化"] = jumps
2019
+ notes.extend(
2020
+ f"{row['切点秒']:.2f} 秒 {row['前段']} → {row['后段']}({row['场景']})亮度变化 "
2021
+ f"{row['亮度变化']:+.1f}:看是否像闪了一下"
2022
+ for row in jumps if abs(row["亮度变化"]) > CUT_JUMP_NOTICE
2023
+ )
2024
+ placed = _placed_layers(cuts, spans)
2025
+ entrances = [] if isinstance(placed, str) else [layer["start"] for layer in placed]
2026
+ suspects = []
2027
+ for index, before, after, across in _suspect_frames(frames):
2028
+ seconds = index / fps
2029
+ entering = any(0 <= seconds - start <= SCREEN_TEXT_ENTRANCE for start in entrances)
2030
+ suspects.append({
2031
+ "秒": round(seconds, 3), "帧": index,
2032
+ "与前帧差": round(before, 1), "与后帧差": round(after, 1), "前后帧互差": round(across, 1),
2033
+ "画面文字入场": entering,
2034
+ })
2035
+ notes.append(
2036
+ f"{seconds:.3f} 秒(第 {index} 帧)与前后两帧都不像:"
2037
+ + ("画面文字正在入场,确认是入场效果" if entering else "逐帧看是否有错位或串入别的画面")
2038
+ )
2039
+ report["疑似坏帧"] = suspects
2040
+ report["请逐帧查看"] = notes
2041
+ return report
2042
+
2043
+
2044
+ def _placed_layers(cuts: Sequence[Cut], spans: Sequence[float]) -> Union[list[dict[str, Any]], str]:
2045
+ """The screen text layers, or why they cannot be placed: verify reports, it does not stop."""
2046
+
2047
+ try:
2048
+ return _screen_text_layers(cuts, spans)
2049
+ except EditError as error:
2050
+ return f"未测({error})"
2051
+
2052
+
2053
+ def _placements_for_sampling(cuts: Sequence[Cut], spans: Optional[Sequence[float]]) -> dict[str, Any]:
2054
+ """Where the screen text and effects landed in the film, from the rendered segments.
2055
+
2056
+ These are the frames and moments to sample; whether the text reads and the
2057
+ chime sits right is still for someone to look and listen.
2058
+ """
2059
+
2060
+ if spans is None:
2061
+ reason = "未测(分段缺失,无法换算成片时间)"
2062
+ return {"画面文字落点": reason, "音效落点": reason}
2063
+ placed = _placed_layers(cuts, spans)
2064
+ return {
2065
+ "画面文字落点": placed if isinstance(placed, str) else [
2066
+ {
2067
+ "起": round(layer["start"], 2),
2068
+ "止": round(layer["end"], 2),
2069
+ "样式": next(
2070
+ label for label, key in SCREEN_TEXT_STYLES.items() if key == layer["style"]
2071
+ ),
2072
+ "文字": SCREEN_TEXT_ITEM_SEPARATOR.join(item["text"] for item in layer["items"]),
2073
+ }
2074
+ for layer in placed
2075
+ ],
2076
+ "音效落点": [
2077
+ {"起": round(start, 2), "止": round(start + duration, 2), "文件": written}
2078
+ for start, duration, written, _ in _placed_sound_effects(cuts, spans)
2079
+ ],
2080
+ }
2081
+
2082
+
1051
2083
  def _matches_frame_size(delivery: Delivery, stream: dict[str, Any]) -> Any:
1052
2084
  if delivery.frame_size is None:
1053
2085
  return "未测(剪辑单没有声明画幅)"