@oh-story/dsh 0.1.9 → 0.1.11

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 (529) hide show
  1. package/README.md +14 -14
  2. package/lib/client.js +209 -73
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +248 -168
  5. package/lib/drama/skills/short-drama/SKILL.md +16 -4
  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/creator_markdown_check.py +14 -0
  10. package/lib/drama/skills/short-drama/scripts/project_tool.py +131 -0
  11. package/lib/drama/skills/short-drama-assets/SKILL.md +2 -1
  12. package/lib/drama/skills/short-drama-assets/references/asset-review-checklist.md +2 -0
  13. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +10 -0
  14. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +6 -9
  15. package/lib/drama/skills/short-drama-assets/references/era-anchors.md +37 -0
  16. package/lib/drama/skills/short-drama-assets/references/stage-contract.md +2 -0
  17. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +28 -0
  18. package/lib/drama/skills/short-drama-develop/SKILL.md +10 -4
  19. package/lib/drama/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -1
  20. package/lib/drama/skills/short-drama-develop/assets/creative-brief.md +8 -0
  21. package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +80 -13
  22. package/lib/drama/skills/short-drama-develop/references/episode-design.md +95 -12
  23. package/lib/drama/skills/short-drama-develop/references/genre-and-hook-playbook.md +9 -5
  24. 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
  25. package/lib/drama/skills/short-drama-develop/references/genre-cards//347/263/273/347/273/237/346/265/201.md +51 -0
  26. package/lib/drama/skills/short-drama-develop/references/genre-cards//351/200/206/350/242/255.md +51 -0
  27. 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
  28. package/lib/drama/skills/short-drama-develop/references/genre-cards.md +10 -7
  29. package/lib/drama/skills/short-drama-develop/references/premise-devices.md +15 -9
  30. package/lib/drama/skills/short-drama-develop/references/serial-character-and-memory.md +3 -2
  31. package/lib/drama/skills/short-drama-develop/references/stage-contract.md +14 -7
  32. package/lib/drama/skills/short-drama-develop/references/story-craft.md +7 -3
  33. package/lib/drama/skills/short-drama-edit/SKILL.md +65 -44
  34. package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +49 -13
  35. package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +4 -2
  36. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Overlay.tsx +31 -0
  37. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +5 -5
  38. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +68 -69
  39. package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +39 -29
  40. package/lib/drama/skills/short-drama-edit/assets/remotion/src/rules.mjs +188 -0
  41. package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +26 -13
  42. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/Card.tsx +72 -0
  43. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/CornerChip.tsx +39 -0
  44. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/ScreenText.tsx +36 -0
  45. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/SystemPanel.tsx +44 -0
  46. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/TaskPanel.tsx +100 -0
  47. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/chrome.tsx +197 -0
  48. package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/tokens.ts +62 -0
  49. package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +1 -0
  50. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +6 -4
  51. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +26 -67
  52. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +31 -56
  53. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +42 -78
  54. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +44 -92
  55. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +14 -16
  56. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1272 -125
  57. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +113 -9
  58. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +2 -0
  59. package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +11 -0
  60. package/lib/drama/skills/short-drama-novel-analyze/SKILL.md +3 -3
  61. package/lib/drama/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +2 -2
  62. package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-triage.md +8 -4
  63. package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-value.md +10 -3
  64. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +25 -1
  65. package/lib/drama/skills/short-drama-novel-analyze/references/stage-contract.md +1 -0
  66. package/lib/drama/skills/short-drama-produce/SKILL.md +5 -1
  67. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +14 -20
  68. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +8 -26
  69. package/lib/drama/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
  70. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +142 -1
  71. package/lib/drama/skills/short-drama-review/SKILL.md +4 -5
  72. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +6 -19
  73. package/lib/drama/skills/short-drama-review/references/review-method.md +6 -1
  74. package/lib/drama/skills/short-drama-review/references/rubric-assets-prompts.md +11 -0
  75. package/lib/drama/skills/short-drama-review/references/rubric-story-script.md +77 -3
  76. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +24 -35
  77. package/lib/drama/skills/short-drama-review/references/stage-contract.md +3 -0
  78. package/lib/drama/skills/short-drama-storyboard/SKILL.md +23 -11
  79. package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +1 -1
  80. package/lib/drama/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
  81. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +2 -2
  82. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +139 -34
  83. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +5 -3
  84. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +15 -12
  85. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +29 -0
  86. package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +4 -5
  87. package/lib/drama/skills/short-drama-video-prompts/references/generability.md +4 -2
  88. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +52 -39
  89. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +24 -21
  90. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +9 -23
  91. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +14 -26
  92. package/lib/drama/skills/short-drama-video-prompts/references/review-and-fixtures.md +1 -1
  93. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +2 -3
  94. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +34 -9
  95. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +3 -2
  96. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +8 -1
  97. package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +103 -0
  98. package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +8 -7
  99. package/lib/drama/skills/short-drama-write/SKILL.md +10 -7
  100. package/lib/drama/skills/short-drama-write/references/dialogue-craft.md +1 -1
  101. package/lib/drama/skills/short-drama-write/references/script-craft.md +56 -3
  102. package/lib/drama/skills/short-drama-write/references/stage-contract.md +4 -0
  103. package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +48 -2
  104. package/lib/index.js +346 -98
  105. package/lib/index.js.map +4 -4
  106. package/lib/novel-to-game/README.md +299 -59
  107. package/lib/novel-to-game/README_ZH.md +299 -56
  108. package/lib/novel-to-game/VERSION +1 -1
  109. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +3 -1
  110. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +11 -3
  111. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +397 -8
  112. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +0 -31
  113. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +71 -73
  114. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +78 -89
  115. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +37 -14
  116. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +5 -5
  117. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -1
  118. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +11 -16
  119. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +11 -11
  120. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +23 -0
  121. package/lib/novel-to-game/manifest.json +77 -87
  122. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +7 -12
  123. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +27 -106
  124. package/lib/novel-to-game/skills/game-build/SKILL.md +13 -14
  125. package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +24 -0
  126. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +45 -82
  127. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +14 -45
  128. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +30 -65
  129. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +13 -27
  130. package/lib/novel-to-game/skills/game-concept/SKILL.md +13 -15
  131. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +28 -75
  132. package/lib/novel-to-game/skills/game-qa/SKILL.md +6 -7
  133. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +9 -5
  134. package/lib/novel-to-game/skills/game-world-design/SKILL.md +12 -16
  135. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +23 -72
  136. package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +14 -48
  137. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +36 -103
  138. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +4 -10
  139. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +4 -4
  140. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +9 -21
  141. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +5 -4
  142. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +5 -8
  143. package/lib/oh-story/manifest.json +607 -327
  144. package/lib/oh-story/roles/chapter-extractor.md +177 -237
  145. package/lib/oh-story/roles/character-designer.md +1 -2
  146. package/lib/oh-story/roles/consistency-checker.md +15 -13
  147. package/lib/oh-story/roles/narrative-writer.md +36 -38
  148. package/lib/oh-story/roles/story-architect.md +9 -61
  149. package/lib/oh-story/roles/story-explorer.md +20 -125
  150. package/lib/oh-story/roles/story-researcher.md +0 -1
  151. package/lib/oh-story/skills/story/SKILL.md +15 -9
  152. package/lib/oh-story/skills/story/VERSION +1 -1
  153. package/lib/oh-story/skills/story/references/author-memory-maintenance.md +75 -0
  154. package/lib/oh-story/skills/story/references/author-memory.md +46 -94
  155. package/lib/oh-story/skills/story/scripts/author_memory_commit.py +934 -170
  156. package/lib/oh-story/skills/story-cover/SKILL.md +10 -1
  157. package/lib/oh-story/skills/story-deslop/SKILL.md +59 -73
  158. package/lib/oh-story/skills/story-deslop/references/agent-calls.md +5 -0
  159. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +12 -11
  160. package/lib/oh-story/skills/story-deslop/references/author-memory-maintenance.md +75 -0
  161. package/lib/oh-story/skills/story-deslop/references/author-memory.md +46 -94
  162. package/lib/oh-story/skills/story-deslop/references/banned-words.md +2 -2
  163. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +22 -5
  164. package/lib/oh-story/skills/story-deslop/references/style-resolution.md +4 -4
  165. package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +934 -170
  166. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +155 -8
  167. package/lib/oh-story/skills/story-import/SKILL.md +64 -584
  168. package/lib/oh-story/skills/story-import/references/character-state-reverse.md +1 -1
  169. package/lib/oh-story/skills/story-import/references/deep-analysis.md +47 -0
  170. package/lib/oh-story/skills/story-import/references/format-and-structure.md +1 -1
  171. package/lib/oh-story/skills/story-import/references/import-report.md +64 -0
  172. package/lib/oh-story/skills/story-import/references/import-special-cases.md +34 -0
  173. package/lib/oh-story/skills/story-import/references/import-tracking.md +95 -0
  174. package/lib/oh-story/skills/story-import/references/length-routing.md +1 -1
  175. package/lib/oh-story/skills/story-import/references/outline-reverse-rules.md +79 -0
  176. package/lib/oh-story/skills/story-import/references/outline-reverse.md +24 -0
  177. package/lib/oh-story/skills/story-import/references/state-tracking.md +4 -69
  178. package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +98 -92
  179. package/lib/oh-story/skills/story-import/references/structure-mapping-short.md +24 -12
  180. package/lib/oh-story/skills/story-import/references/tracking-initialization.md +1 -1
  181. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +15 -11
  182. package/lib/oh-story/skills/story-import/scripts/build_outline_brief.py +332 -0
  183. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +132 -27
  184. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +478 -15
  185. package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +182 -23
  186. package/lib/oh-story/skills/story-long-analyze/SKILL.md +81 -270
  187. package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +252 -0
  188. package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +3 -3
  189. package/lib/oh-story/skills/story-long-analyze/references/final-checks.md +19 -0
  190. package/lib/oh-story/skills/story-long-analyze/references/index-rebuild.md +11 -0
  191. package/lib/oh-story/skills/story-long-analyze/references/inspiration-library.md +105 -0
  192. package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +138 -64
  193. package/lib/oh-story/skills/story-long-analyze/references/semantic-acceptance-fixtures.md +84 -0
  194. package/lib/oh-story/skills/story-long-analyze/references/stage1-golden-chapters.md +128 -0
  195. package/lib/oh-story/skills/story-long-analyze/references/stage2-extraction.md +229 -0
  196. package/lib/oh-story/skills/story-long-analyze/references/stage3-plot-rhythm.md +480 -0
  197. package/lib/oh-story/skills/story-long-analyze/references/stage4-characters-settings.md +195 -0
  198. package/lib/oh-story/skills/story-long-analyze/references/stage5-report.md +33 -0
  199. package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +55 -128
  200. package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +6 -6
  201. package/lib/oh-story/skills/story-long-analyze/references/synthesis-inputs.md +39 -0
  202. package/lib/oh-story/skills/story-long-analyze/scripts/build_chapter_index.py +466 -0
  203. package/lib/oh-story/skills/story-long-analyze/scripts/inspect_existing_assets.py +702 -0
  204. package/lib/oh-story/skills/story-long-analyze/scripts/inspiration_index.py +864 -0
  205. package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +1145 -0
  206. package/lib/oh-story/skills/story-long-analyze/scripts/render_relation_chart.py +425 -0
  207. package/lib/oh-story/skills/story-long-scan/SKILL.md +45 -194
  208. package/lib/oh-story/skills/story-long-scan/references/platform-ciweimao.md +19 -0
  209. package/lib/oh-story/skills/story-long-scan/references/platform-fanqie.md +45 -0
  210. package/lib/oh-story/skills/story-long-scan/references/platform-jjwxc.md +28 -0
  211. package/lib/oh-story/skills/story-long-scan/references/platform-qidian.md +35 -0
  212. package/lib/oh-story/skills/story-long-scan/references/platform-qimao.md +27 -0
  213. package/lib/oh-story/skills/story-long-scan/references/topic-decision.md +6 -2
  214. package/lib/oh-story/skills/story-long-write/SKILL.md +49 -81
  215. package/lib/oh-story/skills/story-long-write/references/agent-calls.md +47 -0
  216. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +12 -11
  217. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +24 -202
  218. package/lib/oh-story/skills/story-long-write/references/author-memory-maintenance.md +75 -0
  219. package/lib/oh-story/skills/story-long-write/references/author-memory.md +46 -94
  220. package/lib/oh-story/skills/story-long-write/references/banned-words.md +2 -2
  221. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +7 -11
  222. package/lib/oh-story/skills/story-long-write/references/character-relations.md +49 -1
  223. package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +26 -0
  224. package/lib/oh-story/skills/story-long-write/references/deslop-gates.md +171 -0
  225. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +4 -0
  226. package/lib/oh-story/skills/story-long-write/references/emotion-on-page.md +116 -0
  227. package/lib/oh-story/skills/story-long-write/references/emotional-arc-design.md +1 -114
  228. package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +2 -2
  229. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards.md +1 -1
  230. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +7 -15
  231. package/lib/oh-story/skills/story-long-write/references/long-format.md +0 -8
  232. package/lib/oh-story/skills/story-long-write/references/long-genre-catalog.md +1 -1
  233. package/lib/oh-story/skills/story-long-write/references/long-genre-mechanics.md +3 -3
  234. package/lib/oh-story/skills/story-long-write/references/opening-design.md +2 -2
  235. package/lib/oh-story/skills/story-long-write/references/outline-conflict.md +4 -4
  236. package/lib/oh-story/skills/story-long-write/references/outline-methods.md +7 -7
  237. package/lib/oh-story/skills/story-long-write/references/outline-rhythm.md +3 -3
  238. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +14 -69
  239. package/lib/oh-story/skills/story-long-write/references/plot-frameworks.md +1 -1
  240. package/lib/oh-story/skills/story-long-write/references/plot-special-topics.md +2 -2
  241. package/lib/oh-story/skills/story-long-write/references/project-files.md +19 -13
  242. package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +13 -5
  243. package/lib/oh-story/skills/story-long-write/references/reference-index.md +5 -6
  244. package/lib/oh-story/skills/story-long-write/references/stop-and-fix.md +9 -0
  245. package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -58
  246. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
  247. package/lib/oh-story/skills/story-long-write/references/style-resolution.md +4 -4
  248. package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +1 -1
  249. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +15 -11
  250. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +81 -121
  251. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +41 -139
  252. package/lib/oh-story/skills/story-long-write/references/workflow-outline.md +157 -0
  253. package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +57 -67
  254. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +112 -253
  255. package/lib/oh-story/skills/story-long-write/references/workflow-volume.md +142 -0
  256. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +6 -4
  257. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +19 -65
  258. package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +934 -170
  259. package/lib/oh-story/skills/story-long-write/scripts/build_architect_brief.py +260 -0
  260. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +226 -25
  261. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +155 -8
  262. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +132 -27
  263. package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +7 -2
  264. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +7 -4
  265. package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +304 -46
  266. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +478 -15
  267. package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +182 -23
  268. package/lib/oh-story/skills/story-review/SKILL.md +31 -302
  269. package/lib/oh-story/skills/story-review/references/agent-prompts.md +184 -0
  270. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +12 -11
  271. package/lib/oh-story/skills/story-review/references/author-memory-maintenance.md +75 -0
  272. package/lib/oh-story/skills/story-review/references/author-memory.md +46 -94
  273. package/lib/oh-story/skills/story-review/references/banned-words.md +2 -2
  274. package/lib/oh-story/skills/story-review/references/batch-review.md +19 -0
  275. package/lib/oh-story/skills/story-review/references/character-relations.md +49 -1
  276. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +4 -0
  277. package/lib/oh-story/skills/story-review/references/review-quality.md +6 -11
  278. package/lib/oh-story/skills/story-review/references/review-tracking.md +10 -0
  279. package/lib/oh-story/skills/story-review/references/solo.md +52 -0
  280. package/lib/oh-story/skills/story-review/references/style-resolution.md +4 -4
  281. package/lib/oh-story/skills/story-review/references/tracking-initialization.md +1 -1
  282. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +15 -11
  283. package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +934 -170
  284. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +155 -8
  285. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +478 -15
  286. package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +182 -23
  287. package/lib/oh-story/skills/story-setup/SKILL.md +71 -452
  288. package/lib/oh-story/skills/story-setup/references/agent-references/agent-quality.md +4 -4
  289. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +12 -11
  290. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +2 -2
  291. package/lib/oh-story/skills/story-setup/references/agent-references/benchmark-style-load.md +107 -0
  292. package/lib/oh-story/skills/story-setup/references/agent-references/character-relations.md +49 -1
  293. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +22 -5
  294. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +4 -0
  295. package/lib/oh-story/skills/story-setup/references/agent-references/emotion-on-page.md +116 -0
  296. package/lib/oh-story/skills/story-setup/references/agent-references/emotional-arc-design.md +1 -114
  297. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +1 -1
  298. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards.md +1 -1
  299. package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +2 -2
  300. package/lib/oh-story/skills/story-setup/references/agent-references/long-genre-catalog.md +1 -1
  301. package/lib/oh-story/skills/story-setup/references/agent-references/long-genre-mechanics.md +3 -3
  302. package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +7 -5
  303. package/lib/oh-story/skills/story-setup/references/agent-references/opening-design.md +2 -2
  304. package/lib/oh-story/skills/story-setup/references/agent-references/outline-conflict.md +4 -4
  305. package/lib/oh-story/skills/story-setup/references/agent-references/outline-methods.md +7 -7
  306. package/lib/oh-story/skills/story-setup/references/agent-references/outline-rhythm.md +3 -3
  307. package/lib/oh-story/skills/story-setup/references/agent-references/short-emotional-methods.md +1 -1
  308. package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +0 -15
  309. package/lib/oh-story/skills/story-setup/references/agent-references/short-reversal.md +1 -1
  310. package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -58
  311. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
  312. package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +4 -4
  313. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +19 -65
  314. package/lib/oh-story/skills/story-setup/references/diagnostics.md +4 -4
  315. package/lib/oh-story/skills/story-short-analyze/SKILL.md +56 -110
  316. package/lib/oh-story/skills/story-short-analyze/references/analysis-character-relations.md +1 -1
  317. package/lib/oh-story/skills/story-short-analyze/references/analysis-method.md +42 -0
  318. package/lib/oh-story/skills/story-short-analyze/references/optional-modules.md +97 -0
  319. package/lib/oh-story/skills/story-short-analyze/references/output-contract.md +9 -47
  320. package/lib/oh-story/skills/story-short-analyze/references/quality-checklist.md +80 -0
  321. package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +4 -4
  322. package/lib/oh-story/skills/story-short-analyze/references/stage2-3-structure-emotion.md +308 -0
  323. package/lib/oh-story/skills/story-short-analyze/references/stage4-6-reversal-summary.md +611 -0
  324. package/lib/oh-story/skills/story-short-scan/SKILL.md +35 -65
  325. package/lib/oh-story/skills/story-short-scan/references/manual-rank-input.md +36 -0
  326. package/lib/oh-story/skills/story-short-scan/references/platform-dianzhong.md +24 -0
  327. package/lib/oh-story/skills/story-short-scan/references/platform-heiyan.md +21 -0
  328. package/lib/oh-story/skills/story-short-scan/references/topic-match.md +38 -0
  329. package/lib/oh-story/skills/story-short-write/SKILL.md +23 -175
  330. package/lib/oh-story/skills/story-short-write/references/agent-calls.md +13 -0
  331. package/lib/oh-story/skills/story-short-write/references/author-memory-maintenance.md +75 -0
  332. package/lib/oh-story/skills/story-short-write/references/author-memory.md +46 -94
  333. package/lib/oh-story/skills/story-short-write/references/banned-words.md +2 -2
  334. package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +1 -21
  335. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +4 -0
  336. package/lib/oh-story/skills/story-short-write/references/emotional-methods.md +1 -1
  337. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +0 -15
  338. package/lib/oh-story/skills/story-short-write/references/output-contract.md +9 -47
  339. package/lib/oh-story/skills/story-short-write/references/reference-index.md +17 -47
  340. package/lib/oh-story/skills/story-short-write/references/scene-craft.md +40 -0
  341. package/lib/oh-story/skills/story-short-write/references/short-craft.md +7 -43
  342. package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -3
  343. package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -1
  344. package/lib/oh-story/skills/story-short-write/references/short-reversal.md +1 -1
  345. package/lib/oh-story/skills/story-short-write/references/style-resolution.md +4 -4
  346. package/lib/oh-story/skills/story-short-write/references/submission-craft.md +1 -1
  347. package/lib/oh-story/skills/story-short-write/references/villain-and-reveal.md +5 -10
  348. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +21 -13
  349. package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +116 -0
  350. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +99 -0
  351. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +7 -119
  352. package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +934 -170
  353. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +155 -8
  354. package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +7 -2
  355. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  356. package/lib/types/client/file-activity.d.ts +19 -1
  357. package/lib/types/client/file-activity.d.ts.map +1 -1
  358. package/lib/types/client/index.d.ts.map +1 -1
  359. package/lib/types/client/production-prompts.d.ts +7 -0
  360. package/lib/types/client/production-prompts.d.ts.map +1 -1
  361. package/lib/types/client/production-runtime.d.ts +16 -0
  362. package/lib/types/client/production-runtime.d.ts.map +1 -1
  363. package/lib/types/client/video-studio.d.ts.map +1 -1
  364. package/lib/types/drama-adapters.d.ts +5 -3
  365. package/lib/types/drama-adapters.d.ts.map +1 -1
  366. package/lib/types/native-hooks.d.ts +53 -1
  367. package/lib/types/native-hooks.d.ts.map +1 -1
  368. package/lib/types/role-identity.d.ts +17 -0
  369. package/lib/types/role-identity.d.ts.map +1 -0
  370. package/lib/types/role-tool.d.ts.map +1 -1
  371. package/lib/types/skill-provider.d.ts.map +1 -1
  372. package/lib/types/video-project.d.ts.map +1 -1
  373. package/lib/types/workspace-route.d.ts.map +1 -1
  374. package/lib/video-recap/CHANGELOG.md +60 -1
  375. package/lib/video-recap/README.en.md +348 -137
  376. package/lib/video-recap/README.md +340 -136
  377. package/lib/video-recap/manifest.json +406 -251
  378. package/lib/video-recap/skills/video-assemble/SKILL.md +30 -15
  379. package/lib/video-recap/skills/video-assemble/references/audio-modes.md +63 -0
  380. package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +75 -0
  381. package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +118 -0
  382. package/lib/video-recap/skills/video-assemble/references/narration-adoption.md +103 -0
  383. package/lib/video-recap/skills/video-assemble/references/packaging.md +45 -0
  384. package/lib/video-recap/skills/video-assemble/references/pair-media.md +74 -0
  385. package/lib/video-recap/skills/video-assemble/references/source-score.md +147 -0
  386. package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +202 -0
  387. package/lib/video-recap/skills/video-assemble/scripts/adoption/__init__.py +1 -0
  388. package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +279 -0
  389. package/lib/video-recap/skills/video-assemble/scripts/adoption/frozen_audio.py +187 -0
  390. package/lib/video-recap/skills/video-assemble/scripts/adoption/narration_binding.py +323 -0
  391. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_inputs.py +109 -0
  392. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +130 -0
  393. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +10 -46
  394. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +378 -119
  395. package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +29 -12
  396. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +95 -44
  397. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +75 -32
  398. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +11 -11
  399. package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +293 -0
  400. package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +71 -78
  401. package/lib/video-recap/skills/video-assemble/scripts/jianying/__init__.py +1 -0
  402. package/lib/video-recap/skills/video-assemble/scripts/{jianying_builders.py → jianying/builders.py} +39 -67
  403. package/lib/video-recap/skills/video-assemble/scripts/{jianying_model.py → jianying/model.py} +3 -11
  404. package/lib/video-recap/skills/video-assemble/scripts/{jianying_optional.py → jianying/optional.py} +1 -1
  405. package/lib/video-recap/skills/video-assemble/scripts/jianying/schema.py +53 -0
  406. package/lib/video-recap/skills/video-assemble/scripts/{jianying_templates.py → jianying/templates.py} +1 -1
  407. package/lib/video-recap/skills/video-assemble/scripts/{jianying_timeline_contract.py → jianying/timeline_contract.py} +34 -13
  408. package/lib/video-recap/skills/video-assemble/scripts/{jianying_writer.py → jianying/writer.py} +7 -17
  409. package/lib/video-recap/skills/video-assemble/scripts/lib.py +37 -4
  410. package/lib/video-recap/skills/video-assemble/scripts/media.py +12 -21
  411. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +64 -34
  412. package/lib/video-recap/skills/video-assemble/scripts/packaging.py +122 -0
  413. package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +216 -0
  414. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +1 -3
  415. package/lib/video-recap/skills/video-assemble/scripts/source_score.py +469 -0
  416. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +25 -9
  417. package/lib/video-recap/skills/video-assemble/scripts/subtitles/__init__.py +1 -0
  418. package/lib/video-recap/skills/video-assemble/scripts/{subtitle_core.py → subtitles/core.py} +13 -6
  419. package/lib/video-recap/skills/video-assemble/scripts/{subtitle_render.py → subtitles/render.py} +5 -4
  420. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +324 -0
  421. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +200 -0
  422. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +30 -46
  423. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +91 -7
  424. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +22 -8
  425. package/lib/video-recap/skills/video-cut/SKILL.md +34 -16
  426. package/lib/video-recap/skills/video-cut/references/shot-review.md +50 -0
  427. package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -28
  428. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +58 -62
  429. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +21 -106
  430. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +14 -9
  431. package/lib/video-recap/skills/video-cut/scripts/lib.py +39 -0
  432. package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +2 -155
  433. package/lib/video-recap/skills/video-cut/scripts/narrative_selection.py +244 -0
  434. package/lib/video-recap/skills/video-cut/scripts/shot_review.py +357 -0
  435. package/lib/video-recap/skills/video-recap/SKILL.md +86 -42
  436. package/lib/video-recap/skills/video-recap/references/audio-routing.md +126 -0
  437. package/lib/video-recap/skills/video-recap/references/config-playbook.md +12 -8
  438. package/lib/video-recap/skills/video-recap/references/data-schema.md +44 -33
  439. package/lib/video-recap/skills/video-recap/references/resource-library.md +151 -0
  440. package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +1 -1
  441. package/lib/video-recap/skills/video-recap/scripts/doctor.py +123 -30
  442. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +22 -75
  443. package/lib/video-recap/skills/video-recap/scripts/lib.py +3 -3
  444. package/lib/video-recap/skills/video-recap/scripts/library.py +509 -0
  445. package/lib/video-recap/skills/video-recap/scripts/materials.py +63 -91
  446. package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +8 -70
  447. package/lib/video-recap/skills/video-recap/scripts/project_binding.py +286 -0
  448. package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +1 -0
  449. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_client.py → qc/mimo_client.py} +1 -1
  450. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_evidence.py → qc/mimo_evidence.py} +33 -66
  451. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_observations.py → qc/mimo_observations.py} +3 -11
  452. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_payload.py → qc/mimo_payload.py} +4 -7
  453. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_report.py → qc/mimo_report.py} +17 -28
  454. package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_runner.py → qc/mimo_runner.py} +2 -2
  455. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -19
  456. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +119 -50
  457. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +32 -114
  458. package/lib/video-recap/skills/video-recap/scripts/recap_review.py +9 -23
  459. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +387 -334
  460. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +23 -23
  461. package/lib/video-recap/skills/video-recap/scripts/recap_source.py +278 -0
  462. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +25 -12
  463. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +59 -52
  464. package/lib/video-recap/skills/video-recap/scripts/resource_lock.py +162 -0
  465. package/lib/video-recap/skills/video-script/SKILL.md +16 -13
  466. package/lib/video-recap/skills/video-script/references/creative-editing-playbook.md +9 -5
  467. package/lib/video-recap/skills/video-script/references/promotional-copy.md +68 -0
  468. package/lib/video-recap/skills/video-script/references/research-guide.md +1 -1
  469. package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +24 -76
  470. package/lib/video-recap/skills/video-script/scripts/lib.py +10 -87
  471. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +63 -39
  472. package/lib/video-recap/skills/video-script/scripts/review.py +0 -2
  473. package/lib/video-recap/skills/video-script/scripts/review_grounding.py +35 -59
  474. package/lib/video-recap/skills/video-script/scripts/review_response.py +66 -128
  475. package/lib/video-recap/skills/video-script/scripts/review_runner.py +12 -20
  476. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +4 -4
  477. package/lib/video-recap/skills/video-script/scripts/validate.py +53 -42
  478. package/lib/video-recap/skills/video-understanding/SKILL.md +6 -4
  479. package/lib/video-recap/skills/video-understanding/references/data-schema.md +50 -156
  480. package/lib/video-recap/skills/video-understanding/references/research-guide.md +4 -4
  481. package/lib/video-recap/skills/video-understanding/scripts/asr.py +85 -65
  482. package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +270 -0
  483. package/lib/video-recap/skills/video-understanding/scripts/brief.py +2 -5
  484. package/lib/video-recap/skills/video-understanding/scripts/briefing/__init__.py +1 -0
  485. package/lib/video-recap/skills/{video-script/scripts/agent_brief.py → video-understanding/scripts/briefing/builder.py} +38 -35
  486. package/lib/video-recap/skills/video-understanding/scripts/{brief_context.py → briefing/context.py} +4 -35
  487. package/lib/video-recap/skills/{video-script/scripts/brief_inputs.py → video-understanding/scripts/briefing/inputs.py} +9 -31
  488. package/lib/video-recap/skills/{video-script/scripts/brief_timeline.py → video-understanding/scripts/briefing/timeline.py} +5 -6
  489. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +65 -116
  490. package/lib/video-recap/skills/video-understanding/scripts/detect.py +31 -40
  491. package/lib/video-recap/skills/video-understanding/scripts/lib.py +30 -86
  492. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +63 -39
  493. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +4 -4
  494. package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +17 -79
  495. package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +14 -40
  496. package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +64 -46
  497. package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +31 -11
  498. package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +19 -14
  499. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +80 -108
  500. package/lib/video-recap/skills/video-voiceover/SKILL.md +29 -19
  501. package/lib/video-recap/skills/video-voiceover/references/index-tts.md +31 -0
  502. package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +123 -0
  503. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +51 -59
  504. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +8 -44
  505. package/lib/video-recap/skills/video-voiceover/scripts/providers/__init__.py +1 -0
  506. package/lib/video-recap/skills/video-voiceover/scripts/providers/index_tts.py +184 -0
  507. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +212 -134
  508. package/package.json +45 -45
  509. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +0 -58
  510. package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +0 -53
  511. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -38
  512. package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +0 -530
  513. package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +0 -559
  514. package/lib/oh-story/skills/story-long-scan/references/scan-output-format.md +0 -207
  515. package/lib/oh-story/skills/story-long-write/references/state-tracking.md +0 -108
  516. package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +0 -678
  517. package/lib/oh-story/skills/story-short-analyze/references/output-templates.md +0 -561
  518. package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +0 -98
  519. package/lib/video-recap/skills/video-recap/references/creative-editing-playbook.md +0 -225
  520. package/lib/video-recap/skills/video-recap/references/research-guide.md +0 -74
  521. package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +0 -250
  522. package/lib/video-recap/skills/video-script/scripts/brief_context.py +0 -348
  523. package/lib/video-recap/skills/video-script/scripts/narration.py +0 -12
  524. package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +0 -399
  525. package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +0 -290
  526. package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +0 -327
  527. /package/lib/video-recap/skills/video-assemble/scripts/{jianying_tracks.py → jianying/tracks.py} +0 -0
  528. /package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_contract.py → qc/mimo_contract.py} +0 -0
  529. /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,13 +149,70 @@ 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
182
+ # ffmpeg's `noise` strength runs to 100, which is snow, not grain. The useful
183
+ # band for a finished film is single digits; the cap keeps a typo from shipping
184
+ # a broken-signal look that measures perfectly fine.
185
+ GRAIN_LIMIT = 20.0
94
186
 
95
187
 
96
188
  class EditError(Exception):
97
189
  """A defect in the cut list or its inputs, reported rather than raised through."""
98
190
 
99
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
+
100
216
  class Cut(NamedTuple):
101
217
  cut_id: str
102
218
  title: str
@@ -105,9 +221,13 @@ class Cut(NamedTuple):
105
221
  start: float
106
222
  end: float
107
223
  declared: float
108
- subtitles: tuple[tuple[Optional[float], Optional[float], str], ...]
224
+ subtitles: tuple[Subtitle, ...]
109
225
  picture: dict[str, float]
110
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
111
231
 
112
232
 
113
233
  class Delivery(NamedTuple):
@@ -116,6 +236,23 @@ class Delivery(NamedTuple):
116
236
  burn_subtitles: bool
117
237
  frame_size: Optional[tuple[int, int]]
118
238
  fps: Optional[float]
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, ...]
119
256
 
120
257
 
121
258
  def _seconds(raw: str, *, field: str, line: int) -> float:
@@ -131,6 +268,8 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
131
268
  burn = True
132
269
  frame_size: Optional[tuple[int, int]] = None
133
270
  fps: Optional[float] = None
271
+ grain: Optional[float] = None
272
+ shot_match = True
134
273
  for raw in lines:
135
274
  match = FIELD.match(raw)
136
275
  if not match:
@@ -146,6 +285,25 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
146
285
  loudness = float(found.group(0))
147
286
  elif name == "字幕":
148
287
  burn = "无" != value.strip() and "不烧" not in value
288
+ elif name == "颗粒":
289
+ # Grain belongs to the delivery spec rather than to a cut, for the
290
+ # same reason loudness does: applied per cut it would become one
291
+ # more thing that differs between segments, which is the defect it
292
+ # is here to cover.
293
+ if value.strip() in {"无", "不加"}:
294
+ grain = None
295
+ else:
296
+ found = re.search(r"[0-9]+(?:\.[0-9]+)?", value)
297
+ if found:
298
+ amount = float(found.group(0))
299
+ if not 0.0 <= amount <= GRAIN_LIMIT:
300
+ raise EditError(
301
+ f"{CUT_LIST_NAME}: 颗粒 {amount} 超出 0–{GRAIN_LIMIT:g};"
302
+ "这一档以上不再像胶片,像信号故障"
303
+ )
304
+ grain = amount or None
305
+ elif name == "接镜匹配":
306
+ shot_match = value.strip() not in {"无", "关"}
149
307
  elif name == "画幅与帧率":
150
308
  size = re.search(r"([0-9]{2,5})\s*[×x*]\s*([0-9]{2,5})", value)
151
309
  if size:
@@ -153,7 +311,7 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
153
311
  rate = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*fps", value, re.I)
154
312
  if rate:
155
313
  fps = float(rate.group(1))
156
- return Delivery(target, loudness, burn, frame_size, fps)
314
+ return Delivery(target, loudness, burn, frame_size, fps, grain, shot_match)
157
315
 
158
316
 
159
317
  def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
@@ -197,11 +355,26 @@ def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
197
355
  continue
198
356
  field = FIELD.match(raw)
199
357
  if field:
200
- 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)
201
362
  close(current)
202
363
  return _parse_delivery(preamble), cuts, unused
203
364
 
204
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
+
205
378
  def _finish_cut(pending: dict[str, Any]) -> Cut:
206
379
  fields: dict[str, tuple[str, int]] = pending["fields"]
207
380
  cut_id: str = pending["cut_id"]
@@ -225,9 +398,10 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
225
398
  subtitles = _parse_subtitles(fields, cut_id=cut_id, line=line)
226
399
  picture: dict[str, float] = {}
227
400
  picture_raw = fields.get("画面")
401
+ untouched = picture_raw is not None and picture_raw[0].strip() == PICTURE_UNTOUCHED
228
402
  if picture_raw is not None:
229
403
  text, where = picture_raw
230
- if text.strip() not in {"", "无", "不校"}:
404
+ if text.strip() not in {"", "无", PICTURE_UNTOUCHED}:
231
405
  for label, value in PICTURE_TERM.findall(text):
232
406
  key = PICTURE_KEYS[label]
233
407
  number = float(value)
@@ -241,7 +415,8 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
241
415
  if not picture:
242
416
  raise EditError(
243
417
  f"{CUT_LIST_NAME}:{where}: {cut_id} 的画面写了内容但没有可执行的项;"
244
- "只认「亮度 <数>」「饱和 <数>」「色温 <数>」,不需要校正时写「无」"
418
+ "只认「亮度 <数>」「饱和 <数>」「色温 <数>」,不需要校正时写「无」,"
419
+ f"连自动接镜也不要时写「{PICTURE_UNTOUCHED}」"
245
420
  )
246
421
  return Cut(
247
422
  cut_id=cut_id,
@@ -254,24 +429,30 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
254
429
  subtitles=subtitles,
255
430
  picture=picture,
256
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,
257
435
  )
258
436
 
259
437
 
260
- def _parse_subtitles(
261
- fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
262
- ) -> tuple[tuple[Optional[float], Optional[float], str], ...]:
263
- """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」.
264
447
 
265
- A shot is not one line. The exchange "就是什么 / 就是少了点东西 / 少了什么" is a
266
- single over-shoulder shot carrying three, and a cut list that can hold only
267
- one of them silently drops the other two — the film then plays lines that
268
- 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.
269
450
  """
270
451
 
271
452
  numbered: list[tuple[int, str, int]] = []
272
453
  plain: Optional[tuple[str, int]] = None
273
454
  for key, (value, where) in fields.items():
274
- match = SUBTITLE_FIELD.match(key.strip())
455
+ match = pattern.match(key.strip())
275
456
  if not match:
276
457
  continue
277
458
  if match.group(1) is None:
@@ -281,27 +462,162 @@ def _parse_subtitles(
281
462
 
282
463
  if numbered and plain is not None:
283
464
  raise EditError(
284
- f"{CUT_LIST_NAME}:{line}: {cut_id} 同时写了「字幕」和「字幕 N」;"
285
- "一段用一种写法——一句用「字幕」,多句全部编号"
465
+ f"{CUT_LIST_NAME}:{line}: {cut_id} 同时写了「{label}」和「{label} N」;"
466
+ f"一段用一种写法——一条用「{label}」,多条全部编号"
286
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)
287
545
 
288
- if numbered:
289
- numbered.sort()
290
- expected = list(range(1, len(numbered) + 1))
291
- if [item[0] for item in numbered] != expected:
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."""
551
+
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:
292
570
  raise EditError(
293
- f"{CUT_LIST_NAME}:{line}: {cut_id} 的字幕编号要从 1 连续排到 "
294
- f"{len(numbered)},当前是 {[item[0] for item in numbered]}"
571
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」要写成"
572
+ "「<起>-<止> <项目相对路径>」"
295
573
  )
296
- cues: list[tuple[Optional[float], Optional[float], str]] = []
297
- 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:
298
613
  found = SUBTITLE_CUE.match(value)
299
614
  if not found:
300
615
  raise EditError(
301
616
  f"{CUT_LIST_NAME}:{where}: {cut_id} 的「字幕 {index}」要写成"
302
617
  "「<起>-<止> <台词>」,多句必须各自带时间"
303
618
  )
304
- 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))
305
621
  for earlier, later in zip(cues, cues[1:]):
306
622
  if later[0] is not None and earlier[1] is not None and later[0] < earlier[1]:
307
623
  raise EditError(
@@ -309,17 +625,18 @@ def _parse_subtitles(
309
625
  )
310
626
  return tuple(cues)
311
627
 
312
- if plain is None or plain[0].strip() == "无":
628
+ if not entries or entries[0][1].strip() == "无":
313
629
  return ()
630
+ text, words = keywords(entries[0][1], entries[0][2])
314
631
  window_raw = fields.get("字幕时间")
315
632
  if window_raw is None:
316
- return ((None, None, plain[0].strip()),)
633
+ return ((None, None, text, words),)
317
634
  found = SUBTITLE_WINDOW.match(window_raw[0])
318
635
  if not found:
319
636
  raise EditError(
320
637
  f"{CUT_LIST_NAME}:{window_raw[1]}: {cut_id} 的字幕时间要写成「起-止」秒数"
321
638
  )
322
- return ((float(found.group(1)), float(found.group(2)), plain[0].strip()),)
639
+ return ((float(found.group(1)), float(found.group(2)), text, words),)
323
640
 
324
641
 
325
642
  def _which(name: str) -> Optional[str]:
@@ -371,8 +688,31 @@ def probe_stream(media: Path) -> dict[str, Any]:
371
688
  }
372
689
 
373
690
 
691
+ def _unaccounted_shots(known: set[str], cuts: Sequence[Cut], unused: Sequence[str]) -> list[str]:
692
+ """Require each source to be used or explicitly omitted with a reason."""
693
+
694
+ used = {cut.motion for cut in cuts}
695
+ excused = set()
696
+ for note in unused:
697
+ match = re.fullmatch(r"(MOTION-[\w-]+)\s*[((]理由[::]\s*(.+?)[))]", note.strip())
698
+ if match and match.group(2).strip():
699
+ excused.add(match.group(1))
700
+ missing = sorted(known - used - excused)
701
+ if not missing:
702
+ return []
703
+ return [
704
+ f"{CUT_LIST_NAME}: 以下镜头未采用,且缺少「未采用镜头」及理由:"
705
+ + "、".join(missing)
706
+ ]
707
+
708
+
374
709
  def check_cuts(
375
- episode: Path, cuts: Sequence[Cut], project_root: Path, *, probe: bool
710
+ episode: Path,
711
+ cuts: Sequence[Cut],
712
+ project_root: Path,
713
+ *,
714
+ probe: bool,
715
+ unused: Sequence[str] = (),
376
716
  ) -> list[str]:
377
717
  """Every mechanical cross-check the cut list can be held to. Returns findings."""
378
718
 
@@ -403,6 +743,7 @@ def check_cuts(
403
743
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的来源 "
404
744
  f"{cut.motion} 不在《{MOTION_DOCUMENT}》中"
405
745
  )
746
+ findings.extend(_unaccounted_shots(known, cuts, unused))
406
747
  else:
407
748
  findings.append(f"没有 {motion_path},来源 MOTION 无法核对")
408
749
 
@@ -411,6 +752,7 @@ def check_cuts(
411
752
  if screenplay_path.is_file():
412
753
  screenplay = screenplay_path.read_text(encoding="utf-8")
413
754
 
755
+ media_format: Optional[tuple[Any, Any, float]] = None
414
756
  for cut in cuts:
415
757
  span = cut.end - cut.start
416
758
  if cut.start < 0:
@@ -430,13 +772,23 @@ def check_cuts(
430
772
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材不存在: {cut.media}"
431
773
  )
432
774
  elif probe:
433
- available = probe_duration(media)
775
+ stream = probe_stream(media)
776
+ available = stream["duration"]
777
+ current_format = (stream["width"], stream["height"], stream["fps"])
778
+ if media_format is None:
779
+ media_format = current_format
780
+ elif current_format != media_format:
781
+ findings.append(
782
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的画幅或帧率 "
783
+ f"{current_format} 与首段 {media_format} 不一致;"
784
+ "先在外部统一素材规格,再更新来源路径与入出点"
785
+ )
434
786
  if cut.end > available + TOLERANCE:
435
787
  findings.append(
436
788
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 {cut.end:.2f} "
437
789
  f"超过素材实际时长 {available:.2f}"
438
790
  )
439
- for window_start, window_end, text in cut.subtitles:
791
+ for window_start, window_end, text, _ in cut.subtitles:
440
792
  if screenplay and _normalize(text) not in _normalize(screenplay):
441
793
  findings.append(
442
794
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕在《"
@@ -449,12 +801,57 @@ def check_cuts(
449
801
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间超出本段区间"
450
802
  f": {window_start}-{window_end}"
451
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
+ )
452
815
 
816
+ findings.extend(_screen_text_findings(cuts, screenplay))
453
817
  findings.extend(_overlap_findings(cuts))
454
818
  findings.extend(_stale_window_findings(episode, project_root, cuts))
455
819
  return findings
456
820
 
457
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
+
458
855
  def _stale_window_findings(
459
856
  episode: Path, project_root: Path, cuts: Sequence[Cut]
460
857
  ) -> list[str]:
@@ -475,7 +872,12 @@ def _stale_window_findings(
475
872
  return []
476
873
  findings: list[str] = []
477
874
  for cut in cuts:
478
- 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:
479
881
  continue
480
882
  media = _resolve_media(episode, project_root, cut.media)
481
883
  if media is None:
@@ -487,7 +889,7 @@ def _stale_window_findings(
487
889
  if changed > authored + 1.0:
488
890
  findings.append(
489
891
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材比剪辑单新"
490
- f"({cut.media});这一段的字幕时间是按旧素材的发声区间反推的,"
892
+ f"({cut.media});这一段的字幕、画面文字与音效时间是按旧素材反推的,"
491
893
  "重出之后必须重测再改,不能沿用"
492
894
  )
493
895
  return findings
@@ -542,9 +944,16 @@ def render(
542
944
  segments_root = output_root / SEGMENT_DIRECTORY
543
945
  segments_root.mkdir(parents=True, exist_ok=True)
544
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
+
545
954
  segments: list[Path] = []
546
955
  spans: list[float] = []
547
- for cut in cuts:
956
+ for cut, match in zip(cuts, pictures):
548
957
  media = _resolve_media(episode, project_root, cut.media)
549
958
  if media is None:
550
959
  raise EditError(f"{cut.cut_id} 的素材不存在: {cut.media}")
@@ -553,7 +962,6 @@ def render(
553
962
  ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
554
963
  "-ss", f"{cut.start:.3f}", "-t", f"{cut.end - cut.start:.3f}", "-i", str(media),
555
964
  ]
556
- match = _shot_match_filter(cut)
557
965
  if match:
558
966
  command += ["-vf", match]
559
967
  command += [
@@ -573,30 +981,64 @@ def render(
573
981
  _run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
574
982
  "-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", str(joined)])
575
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
+
576
997
  filters: list[str] = []
577
998
  subtitle_path = None
578
999
  styled_path = None
579
1000
  overlay_path = None
580
- if burn_subtitles and any(cut.subtitles for cut in cuts):
581
- canvas = probe_stream(segments[0])
582
- 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:
583
1009
  subtitle_path = output_root / "字幕.srt"
584
1010
  subtitle_path.write_text(_build_srt(cues), encoding="utf-8")
585
- if renderer == "remotion":
586
- overlay_path = _render_remotion_overlay(
587
- output_root, cues, canvas, sum(spans), remotion_workspace,
588
- concurrency=remotion_concurrency,
589
- )
590
- else:
591
- styled = styled_path = output_root / "字幕.ass"
592
- styled.write_text(
593
- _build_ass(cues, canvas["width"] or 1080, canvas["height"] or 1920),
594
- encoding="utf-8",
595
- )
596
- escaped = (
597
- str(styled).replace("\\", "/").replace(":", r"\:").replace("'", r"\'")
598
- )
599
- 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}'")
1031
+
1032
+ # Grain goes on last, over the whole assembled film, so one texture sits
1033
+ # across every cut. `t` makes it move frame to frame — static noise reads
1034
+ # as dirt on the lens, not as film.
1035
+ grain = (
1036
+ f"noise=alls={delivery.grain:g}:allf=t+u"
1037
+ if delivery.grain is not None
1038
+ else None
1039
+ )
1040
+ if grain is not None:
1041
+ filters.append(grain)
600
1042
 
601
1043
  final = output_root / "成片.mp4"
602
1044
  command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(joined)]
@@ -605,9 +1047,12 @@ def render(
605
1047
  # to be named: the default one drops it and the overlay arrives as
606
1048
  # an opaque black rectangle.
607
1049
  command += ["-c:v", "libvpx", "-i", str(overlay_path)]
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])
608
1053
  command += [
609
1054
  "-filter_complex",
610
- "[0:v][1:v]overlay=0:0:format=auto,format=yuv420p[v]",
1055
+ f"{chain},format=yuv420p[v]",
611
1056
  "-map", "[v]", "-map", "0:a",
612
1057
  "-c:v", "libx264", "-preset", "medium", "-crf", "18",
613
1058
  ]
@@ -618,7 +1063,7 @@ def render(
618
1063
  command += ["-c:v", "copy"]
619
1064
  if delivery.loudness_lufs is not None:
620
1065
  command += ["-af", _loudnorm_filter(ffmpeg, joined, delivery.loudness_lufs)]
621
- command += ["-c:a", "aac", "-b:a", "192k"]
1066
+ command += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"]
622
1067
  else:
623
1068
  command += ["-c:a", "copy"]
624
1069
  command.append(str(final))
@@ -629,22 +1074,18 @@ def render(
629
1074
  "分段": [str(segment) for segment in segments],
630
1075
  "字幕": str(subtitle_path) if subtitle_path else None,
631
1076
  "压制字幕": str(styled_path) if styled_path else None,
632
- "字幕叠层": str(overlay_path) if overlay_path else None,
1077
+ "叠层": str(overlay_path) if overlay_path else None,
633
1078
  "字幕渲染": renderer if subtitle_path else None,
1079
+ "画面文字": len(layers),
1080
+ "音效": len(effects),
1081
+ "自动接镜": _shot_match_report(auto, scenes, enabled=delivery.shot_match),
634
1082
  "段数": len(segments),
635
1083
  "各段时长之和": round(sum(cut.end - cut.start for cut in cuts), 2),
636
1084
  }
637
1085
 
638
1086
 
639
1087
  def _shot_match_filter(cut: Cut) -> str:
640
- """Match one cut to its neighbours, using only what the cut list declared.
641
-
642
- Generated shots drift: two takes of the same person at the same table come
643
- back a stop apart and half a step of white balance away from each other, and
644
- the join reads as a mistake rather than a cut. The correction stays a stated
645
- creator decision — the tool never measures a clip and adjusts it on its own,
646
- because a correction nobody wrote down is one nobody can review.
647
- """
1088
+ """The correction a cut states in 「画面:」. It replaces the automatic match."""
648
1089
 
649
1090
  stages: list[str] = []
650
1091
  eq = [
@@ -662,12 +1103,197 @@ def _shot_match_filter(cut: Cut) -> str:
662
1103
  return ",".join(stages)
663
1104
 
664
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
+
665
1292
  def _loudnorm_filter(ffmpeg: str, media: Path, target: float) -> str:
666
- """Two-pass EBU R128.
1293
+ """Measure EBU R128 before normalization; verify the encoded output afterward.
667
1294
 
668
- One pass is a dynamic normalizer that lands several dB from the target; the
669
- delivered loudness would then be a number nobody chose. Measure first, feed
670
- the measurement back, and the second pass is linear.
1295
+ FFmpeg can fall back to dynamic processing when linear gain would exceed
1296
+ the peak or loudness-range target. Two passes do not guarantee target LUFS.
671
1297
  """
672
1298
 
673
1299
  common = f"I={target}:TP=-1.5:LRA=11"
@@ -701,24 +1327,76 @@ def _sync_remotion_workspace(workspace: Path) -> Path:
701
1327
  """
702
1328
 
703
1329
  workspace = workspace.expanduser().resolve()
704
- (workspace / "src").mkdir(parents=True, exist_ok=True)
705
1330
  for name in REMOTION_SOURCE_FILES:
706
1331
  source = REMOTION_SOURCE / name
707
1332
  if not source.is_file():
708
1333
  raise EditError(f"技能里缺少 Remotion 源文件: {name}")
1334
+ (workspace / name).parent.mkdir(parents=True, exist_ok=True)
709
1335
  shutil.copyfile(source, workspace / name)
710
1336
  return workspace
711
1337
 
712
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
+
713
1389
  def _render_remotion_overlay(
714
1390
  output_root: Path,
715
- cues: Sequence[tuple[float, float, str]],
1391
+ cues: Sequence[DisplayCue],
1392
+ layers: Sequence[dict[str, Any]],
716
1393
  canvas: dict[str, Any],
717
1394
  duration: float,
718
1395
  workspace_root: Path,
719
1396
  concurrency: int = DEFAULT_REMOTION_CONCURRENCY,
1397
+ needed_for: str = "字幕",
720
1398
  ) -> Path:
721
- """Render the subtitle layer as a transparent video with Remotion.
1399
+ """Render subtitles and screen text as one transparent video with Remotion.
722
1400
 
723
1401
  The picture is never re-drawn by the browser: only the type is, onto an
724
1402
  empty frame, and ffmpeg composites that over untouched footage. So the
@@ -732,44 +1410,33 @@ def _render_remotion_overlay(
732
1410
  """
733
1411
 
734
1412
  workspace = _sync_remotion_workspace(workspace_root)
735
- 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:
736
1422
  raise EditError(
737
- f"Remotion 还没安装。先运行一次:\n"
1423
+ f"Remotion 叠层的依赖没装全(缺 {'、'.join(missing)}),{needed_for}需要它们。先运行一次:\n"
738
1424
  f" cd {workspace} && npm install\n"
739
- "或改用默认的 ffmpeg 字幕(去掉 --subtitles remotion)。\n"
1425
+ f"{alternative}\n"
740
1426
  "注意 Remotion 有自己的许可证:个人与小团队免费,超出规模需要商业授权;"
741
1427
  "本工具不会替你安装它。"
742
1428
  )
743
1429
  npx = _which("npx")
744
1430
  if npx is None:
745
1431
  raise EditError(
746
- "PATH 上没有 npx,无法运行 Remotion;装好 Node.js,或改用默认的 ffmpeg 字幕。"
1432
+ f"PATH 上没有 npx,无法运行 Remotion 绘制{needed_for};先装好 Node.js。{alternative}"
747
1433
  )
748
- props = output_root / "字幕.props.json"
1434
+ props = output_root / "叠层.props.json"
749
1435
  props.write_text(
750
- json.dumps(
751
- {
752
- "cues": [
753
- {"start": round(start, 3), "end": round(end, 3), "text": text}
754
- for start, end, text in cues
755
- ],
756
- "width": canvas["width"] or 1080,
757
- "height": canvas["height"] or 1920,
758
- "fps": canvas["fps"] or 24,
759
- "durationInSeconds": round(duration, 3),
760
- "fontScale": 0.034,
761
- "bottomScale": 0.055,
762
- "fontFamily": (
763
- '"PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC", '
764
- '"Microsoft YaHei", sans-serif'
765
- ),
766
- },
767
- ensure_ascii=False,
768
- indent=2,
769
- ),
1436
+ json.dumps(_overlay_props(cues, layers, canvas, duration), ensure_ascii=False, indent=2),
770
1437
  encoding="utf-8",
771
1438
  )
772
- overlay = output_root / "字幕叠层.webm"
1439
+ overlay = output_root / "叠层.webm"
773
1440
  result = subprocess.run(
774
1441
  [npx, "remotion", "render", REMOTION_COMPOSITION, str(overlay),
775
1442
  f"--props={props}", "--log=error",
@@ -790,7 +1457,7 @@ def _frame_geometry(media: Path) -> str:
790
1457
 
791
1458
  def _subtitle_cues(
792
1459
  cuts: Sequence[Cut], spans: Sequence[float]
793
- ) -> list[tuple[float, float, str]]:
1460
+ ) -> list[tuple[float, float, str, tuple[str, ...]]]:
794
1461
  """Place each line in output time, measuring the segments that were written.
795
1462
 
796
1463
  A segment lands on a frame boundary, so it is a few milliseconds longer than
@@ -799,33 +1466,308 @@ def _subtitle_cues(
799
1466
  before the actor stops speaking. The rendered files are the timeline.
800
1467
  """
801
1468
 
802
- cues: list[tuple[float, float, str]] = []
803
- cursor = 0.0
804
- 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):
805
1471
  declared = cut.end - cut.start
806
- # The windows were authored against the declared span; hold them in
807
- # place proportionally rather than letting the tail slip out.
808
- scale = span / declared if declared > 0 else 1.0
809
- for window_start, window_end, text in cut.subtitles:
1472
+ for window_start, window_end, text, words in cut.subtitles:
810
1473
  start = 0.0 if window_start is None else window_start
811
1474
  end = declared if window_end is None else window_end
812
- cues.append((cursor + start * scale, cursor + end * scale, text))
813
- cursor += span
1475
+ cues.append((cursor + start * scale, cursor + end * scale, text, words))
814
1476
  return cues
815
1477
 
816
1478
 
817
- def _build_srt(cues: Sequence[tuple[float, float, str]]) -> str:
818
- """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."""
819
1737
 
820
1738
  return "\n".join(
821
- f"{index}\n{_timecode(start)} --> {_timecode(end)}\n{text}\n"
822
- 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)
823
1741
  )
824
1742
 
825
1743
 
826
- def _build_ass(
827
- cues: Sequence[tuple[float, float, str]], width: int, height: int
828
- ) -> 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:
829
1771
  """Author the ASS directly so the type size is stated in frame pixels.
830
1772
 
831
1773
  Letting ffmpeg convert the SRT hands libass a 384-high canvas, and every
@@ -836,9 +1778,11 @@ def _build_ass(
836
1778
  PlayRes as the frame removes the conversion, so one unit is one pixel.
837
1779
  """
838
1780
 
839
- font_size = max(18, round(height * 0.034))
840
- outline = max(2, round(height * 0.0022))
841
- 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))
842
1786
  margin_h = max(24, round(width * 0.06))
843
1787
  header = (
844
1788
  "[Script Info]\n"
@@ -853,15 +1797,15 @@ def _build_ass(
853
1797
  "ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
854
1798
  "MarginL, MarginR, MarginV, Encoding\n"
855
1799
  f"Style: 正片,{SUBTITLE_FONT},{font_size},&H00FFFFFF,&H00FFFFFF,"
856
- 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,"
857
1801
  f"{margin_h},{margin_h},{margin_v},1\n"
858
1802
  "\n[Events]\n"
859
1803
  "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, "
860
1804
  "Effect, Text\n"
861
1805
  )
862
1806
  lines = [
863
- f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},正片,,0,0,0,,{_ass_text(text)}"
864
- 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
865
1809
  ]
866
1810
  return header + "\n".join(lines) + "\n"
867
1811
 
@@ -894,6 +1838,40 @@ def _timecode(seconds: float) -> str:
894
1838
  return f"{hours:02d}:{minutes:02d}:{whole:02d},{milliseconds:03d}"
895
1839
 
896
1840
 
1841
+ def _segment_colour(ffmpeg: str, path: Path) -> Optional[tuple[float, float]]:
1842
+ """Mean luma and blue-red difference of one segment, on a 0-255 scale."""
1843
+
1844
+ result = subprocess.run(
1845
+ [ffmpeg, "-v", "error", "-i", str(path), "-vf", "fps=2,scale=96:-1",
1846
+ "-pix_fmt", "rgb24", "-f", "rawvideo", "-"],
1847
+ capture_output=True,
1848
+ )
1849
+ raw = result.stdout
1850
+ if result.returncode != 0 or len(raw) < 3:
1851
+ return None
1852
+ count = len(raw) // 3
1853
+ red = sum(raw[i * 3] for i in range(count)) / count
1854
+ green = sum(raw[i * 3 + 1] for i in range(count)) / count
1855
+ blue = sum(raw[i * 3 + 2] for i in range(count)) / count
1856
+ return 0.299 * red + 0.587 * green + 0.114 * blue, blue - red
1857
+
1858
+
1859
+ def _segment_colours(ffmpeg: str, output_root: Path, cuts: Sequence[Cut]) -> list[dict[str, Any]]:
1860
+ """Report current cut segments individually, without imposing a shared grade."""
1861
+
1862
+ rows: list[dict[str, Any]] = []
1863
+ for cut in cuts:
1864
+ path = output_root / SEGMENT_DIRECTORY / f"{cut.cut_id}.mp4"
1865
+ colour = _segment_colour(ffmpeg, path) if path.is_file() else None
1866
+ row: dict[str, Any] = {"分段": path.name}
1867
+ if colour is None:
1868
+ row["测量"] = "未测(分段缺失或不可读)"
1869
+ else:
1870
+ row.update({"平均亮度": round(colour[0], 1), "蓝减红": round(colour[1], 1)})
1871
+ rows.append(row)
1872
+ return rows
1873
+
1874
+
897
1875
  def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str, Any]:
898
1876
  """Measure the rendered film. Every entry is a number or an honest 未测."""
899
1877
 
@@ -931,11 +1909,177 @@ def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str,
931
1909
  else:
932
1910
  measurements["实测响度 LUFS"] = "未测(loudnorm 没有返回可解析的测量结果)"
933
1911
  measurements["交付响度目标"] = delivery.loudness_lufs
1912
+ measurements["分段色彩观测"] = _segment_colours(ffmpeg, episode / OUTPUT_DIRECTORY, cuts)
1913
+ measurements["画内可读文字"] = (
1914
+ "未测(抽有画内文字的帧,逐字对《剧本.md》的「画面文字」与提示词声明的内容)"
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))
934
1924
  measurements["台词完整性"] = "未测(本工具不做转写;在成片上转写后逐句对《剧本.md》原文)"
1925
+ measurements.update(_frame_report(
1926
+ _grey_frames(ffmpeg, final), stream["fps"], cuts, spans, _scene_keys(episode, cuts)
1927
+ ))
935
1928
  measurements["边界帧"] = "未测(抽剪辑点前后各一帧目视核对黑场/白场/半渲染帧)"
936
1929
  return measurements
937
1930
 
938
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
+
939
2083
  def _matches_frame_size(delivery: Delivery, stream: dict[str, Any]) -> Any:
940
2084
  if delivery.frame_size is None:
941
2085
  return "未测(剪辑单没有声明画幅)"
@@ -1004,7 +2148,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
1004
2148
  delivery, cuts, unused = parse_cut_list(episode / CUT_LIST_NAME)
1005
2149
  if arguments.command == "check":
1006
2150
  findings = check_cuts(
1007
- episode, cuts, project_root, probe=_which("ffprobe") is not None
2151
+ episode, cuts, project_root,
2152
+ probe=_which("ffprobe") is not None, unused=unused,
1008
2153
  )
1009
2154
  payload: dict[str, Any] = {
1010
2155
  "段数": len(cuts),
@@ -1018,7 +2163,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
1018
2163
  _emit(payload)
1019
2164
  return 1 if findings else 0
1020
2165
  if arguments.command == "render":
1021
- findings = check_cuts(episode, cuts, project_root, probe=True)
2166
+ findings = check_cuts(
2167
+ episode, cuts, project_root, probe=True, unused=unused
2168
+ )
1022
2169
  if findings:
1023
2170
  _emit({"findings": findings, "已渲染": False})
1024
2171
  return 1