@oh-story/dsh 0.1.12 → 0.1.13

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 (264) hide show
  1. package/README.md +17 -11
  2. package/lib/client.js +85 -35
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +41 -36
  5. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +340 -13
  6. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +4 -2
  7. package/lib/drama/skills/short-drama-edit/SKILL.md +32 -12
  8. package/lib/drama/skills/short-drama-edit/references/still-cuts.md +90 -0
  9. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1035 -145
  10. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +48 -0
  11. package/lib/drama/skills/short-drama-produce/SKILL.md +6 -1
  12. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +12 -0
  13. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +138 -9
  14. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +6 -0
  15. package/lib/drama/skills/short-drama-storyboard/SKILL.md +2 -0
  16. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +3 -1
  17. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -0
  18. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +14 -0
  19. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +19 -6
  20. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
  21. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -0
  22. package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +4 -1
  23. package/lib/index.js +16 -7
  24. package/lib/index.js.map +2 -2
  25. package/lib/novel-to-game/README.md +98 -44
  26. package/lib/novel-to-game/README_ZH.md +100 -46
  27. package/lib/novel-to-game/VERSION +1 -1
  28. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +27 -202
  29. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +23 -115
  30. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/chamber.webp +0 -0
  31. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_night.webp +0 -0
  32. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_rain.webp +0 -0
  33. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/garden.webp +0 -0
  34. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/hall.webp +0 -0
  35. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/kitchen.webp +0 -0
  36. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street.webp +0 -0
  37. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street_dawn.webp +0 -0
  38. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/temple.webp +0 -0
  39. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/moon.webp +0 -0
  40. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/angry.webp +0 -0
  41. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/blush.webp +0 -0
  42. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/neutral.webp +0 -0
  43. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/sad.webp +0 -0
  44. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/smile.webp +0 -0
  45. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/surprised.webp +0 -0
  46. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/angry.webp +0 -0
  47. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/blush.webp +0 -0
  48. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/neutral.webp +0 -0
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/sad.webp +0 -0
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/smile.webp +0 -0
  51. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/surprised.webp +0 -0
  52. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/angry.webp +0 -0
  53. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/blush.webp +0 -0
  54. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/neutral.webp +0 -0
  55. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/sad.webp +0 -0
  56. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/smile.webp +0 -0
  57. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/surprised.webp +0 -0
  58. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/angry.webp +0 -0
  59. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/blush.webp +0 -0
  60. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/neutral.webp +0 -0
  61. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/sad.webp +0 -0
  62. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/smile.webp +0 -0
  63. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/surprised.webp +0 -0
  64. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/angry.webp +0 -0
  65. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/blush.webp +0 -0
  66. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/neutral.webp +0 -0
  67. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/sad.webp +0 -0
  68. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/smile.webp +0 -0
  69. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/surprised.webp +0 -0
  70. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +326 -2503
  71. package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +71 -4
  72. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/adv.js +221 -0
  73. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +113 -257
  74. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +511 -3026
  75. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/menus.js +277 -0
  76. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/petals.js +67 -0
  77. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/stage.js +180 -0
  78. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/store.js +70 -0
  79. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common.js +198 -0
  80. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common2.js +204 -0
  81. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common3.js +198 -0
  82. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/dates.js +186 -0
  83. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/index.js +122 -0
  84. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/meng.js +190 -0
  85. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pan.js +212 -0
  86. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pinger.js +198 -0
  87. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/xuee.js +199 -0
  88. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/yue.js +214 -0
  89. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -2
  90. package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +182 -45
  91. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +7 -17
  92. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +9 -9
  93. package/lib/novel-to-game/manifest.json +303 -78
  94. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +7 -2
  95. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +2 -0
  96. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +2 -0
  97. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +9 -0
  98. package/lib/types/client/drama-production-view.d.ts +11 -0
  99. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  100. package/lib/types/client/file-activity.d.ts +1 -1
  101. package/lib/types/client/file-activity.d.ts.map +1 -1
  102. package/lib/types/client/production-prompts.d.ts +7 -1
  103. package/lib/types/client/production-prompts.d.ts.map +1 -1
  104. package/lib/types/client/production-runtime.d.ts +7 -2
  105. package/lib/types/client/production-runtime.d.ts.map +1 -1
  106. package/lib/types/drama-adapters.d.ts.map +1 -1
  107. package/lib/types/skill-provider.d.ts.map +1 -1
  108. package/lib/types/video-project.d.ts.map +1 -1
  109. package/lib/video-recap/CHANGELOG.md +108 -1
  110. package/lib/video-recap/README.en.md +18 -14
  111. package/lib/video-recap/README.md +18 -14
  112. package/lib/video-recap/manifest.json +297 -321
  113. package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
  114. package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
  115. package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
  116. package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
  117. package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
  118. package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
  119. package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
  120. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
  121. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
  122. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
  123. package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
  124. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
  125. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
  126. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
  127. package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
  128. package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
  129. package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
  130. package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
  131. package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
  132. package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
  133. package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
  134. package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
  135. package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
  136. package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
  137. package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
  138. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
  139. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
  140. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
  141. package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
  142. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
  143. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
  144. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
  145. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
  146. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
  147. package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
  148. package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
  149. package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
  150. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
  151. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
  152. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
  153. package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
  154. package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
  155. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
  156. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
  157. package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
  158. package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
  159. package/lib/video-recap/skills/video-recap/SKILL.md +24 -27
  160. package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
  161. package/lib/video-recap/skills/video-recap/references/config-playbook.md +8 -11
  162. package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
  163. package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
  164. package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
  165. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
  166. package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
  167. package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
  168. package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
  169. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +49 -17
  170. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
  171. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +86 -116
  172. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
  173. package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
  174. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
  175. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
  176. package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
  177. package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
  178. package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
  179. package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
  180. package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
  181. package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
  182. package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
  183. package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
  184. package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
  185. package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
  186. package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
  187. package/lib/video-recap/skills/video-script/SKILL.md +9 -7
  188. package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
  189. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
  190. package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
  191. package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
  192. package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
  193. package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
  194. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
  195. package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
  196. package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
  197. package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
  198. package/lib/video-recap/skills/video-script/scripts/review_runner.py +2 -10
  199. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
  200. package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
  201. package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
  202. package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
  203. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
  204. package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
  205. package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
  206. package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
  207. package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
  208. package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
  209. package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
  210. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
  211. package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
  212. package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
  213. package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
  214. package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
  215. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
  216. package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
  217. package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
  218. package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
  219. package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
  220. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
  221. package/lib/video-recap/skills/video-voiceover/SKILL.md +19 -12
  222. package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
  223. package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
  224. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +60 -135
  225. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
  226. package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
  227. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
  228. package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
  229. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
  230. package/package.json +45 -45
  231. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
  232. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
  233. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
  234. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
  235. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
  236. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
  237. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
  238. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
  239. package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
  240. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
  241. package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
  242. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
  243. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
  244. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
  245. package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
  246. package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
  247. package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
  248. package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
  249. package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
  250. package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
  251. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
  252. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
  253. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
  254. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
  255. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
  256. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
  257. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
  258. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
  259. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
  260. package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
  261. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
  262. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
  263. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
  264. /package/lib/video-recap/skills/video-cut/scripts/{narration_mapping.py → cut_qc.py} +0 -0
@@ -5,8 +5,9 @@ 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, mix sound effects, burn subtitles and screen text, and
9
- normalize loudness into 制作成果/成片/.
8
+ ``render`` cut video and draw stills, join, mix sound effects and voice lines,
9
+ burn subtitles and screen text, and normalize loudness into
10
+ 制作成果/成片/.
10
11
  ``verify`` measure an already-rendered film and print the numbers.
11
12
 
12
13
  ``verify`` prints measurements, never verdicts. Whether the film is any good is
@@ -16,6 +17,7 @@ a question for review or for the creator, and no number here answers it.
16
17
  from __future__ import annotations
17
18
 
18
19
  import argparse
20
+ from array import array
19
21
  import json
20
22
  import operator
21
23
  import re
@@ -24,7 +26,7 @@ import subprocess
24
26
  import sys
25
27
  import tempfile
26
28
  from pathlib import Path
27
- from typing import Any, Callable, NamedTuple, Optional, Sequence, Union
29
+ from typing import Any, Callable, Iterable, NamedTuple, Optional, Sequence, Union
28
30
 
29
31
  MINIMUM_PYTHON = (3, 9)
30
32
  if sys.version_info < MINIMUM_PYTHON:
@@ -81,8 +83,57 @@ FIELD = re.compile(r"^-\s*([^:]+):\s*(.*)$")
81
83
  UNUSED_LINE = re.compile(r"^-\s*未采用镜头:\s*(.*)$")
82
84
  # A source line is "MOTION-... · relative/path". The separator is the same
83
85
  # middle dot the storyboard uses for reference slots, so the two documents read
84
- # alike; a plain slash would collide with the path itself.
85
- SOURCE = re.compile(r"^(MOTION-\S+)\s*·\s*(.+?)\s*$")
86
+ # alike; a plain slash would collide with the path itself. A still cut -- the
87
+ # static 漫剧 route, keyframes and voiceover -- names the storyboard shot or the
88
+ # image prompt its picture answers to instead.
89
+ SOURCE = re.compile(r"^((?:MOTION|SHOT|IMG)-\S+)\s*·\s*(.+?)\s*$")
90
+ IMAGE_DOCUMENT = "图片提示词.md"
91
+ IMAGE_HEADING = re.compile(r"^##\s+(IMG-[^\s·]+)")
92
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
93
+ # A still holds the screen for as long as it is written to; shorter than this
94
+ # it reads as a flash frame rather than a shot.
95
+ STILL_MINIMUM = 0.50
96
+ # 「运镜:推近 8%」 moves the frame across a still. Past 30% the picture is
97
+ # being re-framed, not moved, and the upscale starts to show.
98
+ CAMERA_MOVES = ("推近", "拉远", "左移", "右移", "上移", "下移")
99
+ CAMERA_MOVE = re.compile(r"^(" + "|".join(CAMERA_MOVES) + r")\s*([0-9]+)\s*[%%]$")
100
+ # 「推近 2.5%/秒」: a speed rather than a distance, so a scene's cuts can share
101
+ # one and the move runs on through the cut instead of settling at every one.
102
+ CAMERA_RATE = re.compile(
103
+ r"^(" + "|".join(CAMERA_MOVES) + r")\s*([0-9]+(?:\.[0-9]+)?)\s*[%%]\s*[//]\s*秒$"
104
+ )
105
+ CAMERA_HOLD = "固定"
106
+ CAMERA_MOVE_LIMITS = (1, 30)
107
+ CAMERA_RATE_LIMITS = (0.1, 10.0)
108
+ # A move that starts or stops from rest ramps its speed over this long.
109
+ CAMERA_EASE = 0.5
110
+ CAMERA_OPPOSITES = {"推近": "拉远", "拉远": "推近", "左移": "右移", "右移": "左移", "上移": "下移", "下移": "上移"}
111
+ # Adjacent moves in one scene whose speeds differ by more than this read as a lurch.
112
+ CAMERA_SPEED_NOTICE = 0.30
113
+ # 「环境声:[<起>] <文件>」 lays a room under the film from this cut on, looping the
114
+ # file, until the next 环境声 line (「无」 stops it).
115
+ BED_FIELD = "环境声"
116
+ BED = re.compile(r"^\s*(?:(-?[0-9]+(?:\.[0-9]+)?)\s+)?(.+?)\s*$")
117
+ BED_FADE = 0.3
118
+ # The silent track under a still when there is no video audio to match.
119
+ STILL_AUDIO = (48000, "stereo")
120
+ # 「配音 N:<起> <文件>」 plays a voice file to its end, from its cut's start plus
121
+ # 起. It may run on over the cuts after it (an L-cut), and 起 may be negative, as
122
+ # far back as the previous cut's start (a J-cut): the line is part of the film's
123
+ # sound, not of one picture.
124
+ VOICE_FIELD = re.compile(r"^配音(?:\s*(\d+))?$")
125
+ VOICE = re.compile(r"^\s*(-?[0-9]+(?:\.[0-9]+)?)\s+(.+?)\s*$")
126
+ VOICE_OVERRUN = 0.05
127
+ # A sound line's trailing 「(起点:<秒>;增益:<dB>)」, either or both, in any order.
128
+ SOUND_OPTIONS = re.compile(r"[((]\s*((?:起点|增益)[::][^()()]*)[))]\s*$")
129
+ SOUND_OPTION = re.compile(r"^(起点|增益)[::]\s*([+-]?[0-9]+(?:\.[0-9]+)?)\s*(dB)?$", re.I)
130
+ # What counts as audible in a voice file: a 20 ms window within this many dB of
131
+ # its loudest window. TTS pads each line with a breath of silence at both ends;
132
+ # measured on the files, the checks would keep two lines 0.4 s apart that are
133
+ # not overlapping at all.
134
+ AUDIBLE_RANGE_DB = 40.0
135
+ AUDIBLE_WINDOW = 0.02
136
+ AUDIBLE_RATE = 8000
86
137
  SUBTITLE_WINDOW = re.compile(r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s*$")
87
138
  # One shot can carry several lines: an exchange of three is one shot, not three.
88
139
  # Numbered fields keep them ordered and let each one state its own window.
@@ -134,7 +185,6 @@ SCREENPLAY_SCREEN_TEXT = re.compile(r"^\s*\[画面文字\]\s*(.+?)\s*$", re.MULT
134
185
  # screen: a chip that persists across five shots must not re-enter five times.
135
186
  SCREEN_TEXT_JOIN_GAP = 0.05
136
187
  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
188
  GAIN_LIMITS = (-40.0, 6.0)
139
189
  # A shot-match is three numbers and nothing else. Anything richer belongs in a
140
190
  # grading tool, and anything implicit belongs nowhere: a correction the cut list
@@ -211,11 +261,61 @@ class SoundEffect(NamedTuple):
211
261
  end: float
212
262
  path: str
213
263
  gain_db: float = 0.0
264
+ # 「起点」: where in the file playback begins.
265
+ offset: float = 0.0
266
+
267
+
268
+ class CameraMove(NamedTuple):
269
+ """「运镜」 on a still: 固定, a push, pull or pan of `amount` percent, or at `rate` %/s."""
270
+
271
+ kind: str = CAMERA_HOLD
272
+ amount: float = 0
273
+ rate: Optional[float] = None
274
+
275
+
276
+ class Bed(NamedTuple):
277
+ """One 「环境声」 line: from `start` seconds into its cut, `path` looping; None is 「无」."""
278
+
279
+ start: float
280
+ path: Optional[str]
281
+ gain_db: float = 0.0
282
+ offset: float = 0.0
283
+
284
+
285
+ class Placed(NamedTuple):
286
+ """A sound laid on the film: output start and length, the file, and how it is played."""
287
+
288
+ start: float
289
+ duration: float
290
+ path: str
291
+ gain_db: float = 0.0
292
+ offset: float = 0.0
293
+ loop: bool = False
294
+ fade_in: float = 0.0
295
+ fade_out: float = 0.05
296
+
297
+
298
+ class Voice(NamedTuple):
299
+ """One 「配音」 line: the file from `offset` to its end, `start` seconds into the cut."""
300
+
301
+ start: float
302
+ path: str
303
+ gain_db: float = 0.0
304
+ offset: float = 0.0
305
+
306
+
307
+ class Audible(NamedTuple):
308
+ """A sound file's length and the span inside it that is not silence, in seconds."""
309
+
310
+ duration: float
311
+ head: float
312
+ tail: float
214
313
 
215
314
 
216
315
  class Cut(NamedTuple):
217
316
  cut_id: str
218
317
  title: str
318
+ # The source ID: MOTION-... for a video cut, SHOT-... or IMG-... for a still.
219
319
  motion: str
220
320
  media: str
221
321
  start: float
@@ -228,6 +328,14 @@ class Cut(NamedTuple):
228
328
  sound_effects: tuple[SoundEffect, ...] = ()
229
329
  # 「画面:不校」: neither a stated nor an automatic correction.
230
330
  untouched: bool = False
331
+ # Stills only; None on a video cut.
332
+ move: Optional[CameraMove] = None
333
+ voices: tuple[Voice, ...] = ()
334
+ bed: Optional[Bed] = None
335
+
336
+ @property
337
+ def still(self) -> bool:
338
+ return not self.motion.startswith("MOTION-")
231
339
 
232
340
 
233
341
  class Delivery(NamedTuple):
@@ -369,6 +477,7 @@ def _repeated_field(name: str, cut_id: str, line: int) -> str:
369
477
  where = f"{CUT_LIST_NAME}:{line}: {cut_id} 的「{name}」写了两遍"
370
478
  for pattern, label in (
371
479
  (SUBTITLE_FIELD, "字幕"), (SCREEN_TEXT_FIELD, "画面文字"), (SOUND_EFFECT_FIELD, "音效"),
480
+ (VOICE_FIELD, "配音"),
372
481
  ):
373
482
  if pattern.match(name):
374
483
  return f"{where};一段有多条{label}时全部编号,且编号不重复:「{label} 1」「{label} 2」…"
@@ -390,7 +499,15 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
390
499
  if not source:
391
500
  raise EditError(
392
501
  f"{CUT_LIST_NAME}:{source_line}: {cut_id} 的来源要写成 "
393
- f"「MOTION-... · 项目相对路径」,当前是 {source_raw!r}"
502
+ f"「MOTION-... · 项目相对路径」,静帧写「SHOT-... · 图片路径」或「IMG-... · 图片路径」,"
503
+ f"当前是 {source_raw!r}"
504
+ )
505
+ still = not source.group(1).startswith("MOTION-")
506
+ if still != (Path(source.group(2)).suffix.lower() in IMAGE_SUFFIXES):
507
+ raise EditError(
508
+ f"{CUT_LIST_NAME}:{source_line}: {cut_id} 的来源 {source.group(1)} 与文件类型不符;"
509
+ "视频素材写 MOTION-...,静帧(" + "/".join(sorted(IMAGE_SUFFIXES))
510
+ + ")写 SHOT-... 或 IMG-..."
394
511
  )
395
512
  start_raw, start_line = required("入点")
396
513
  end_raw, end_line = required("出点")
@@ -432,9 +549,64 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
432
549
  screen_texts=_parse_screen_texts(fields, cut_id=cut_id, line=line),
433
550
  sound_effects=_parse_sound_effects(fields, cut_id=cut_id, line=line),
434
551
  untouched=untouched,
552
+ move=_parse_move(fields, cut_id=cut_id, still=still),
553
+ voices=_parse_voices(fields, cut_id=cut_id, line=line),
554
+ bed=_parse_bed(fields, cut_id=cut_id),
435
555
  )
436
556
 
437
557
 
558
+ def _parse_bed(fields: dict[str, tuple[str, int]], *, cut_id: str) -> Optional[Bed]:
559
+ """Read 「环境声:[<起>] <文件>(起点:<秒>;增益:<dB>)」 or 「环境声:[<起>] 无」."""
560
+
561
+ raw = fields.get(BED_FIELD)
562
+ if raw is None:
563
+ return None
564
+ value, gain, offset = _sound_options(raw[0], BED_FIELD, cut_id=cut_id, where=raw[1])
565
+ found = BED.match(value)
566
+ # A bed has a start, not a window: it runs until the next 环境声 line.
567
+ if not found or SUBTITLE_CUE.match(value):
568
+ raise EditError(
569
+ f"{CUT_LIST_NAME}:{raw[1]}: {cut_id} 的「环境声」要写成「[<起>] <项目相对路径>」或「[<起>] 无」"
570
+ )
571
+ path = found.group(2)
572
+ return Bed(float(found.group(1) or 0.0), None if path == "无" else path, gain, offset)
573
+
574
+
575
+ def _parse_move(
576
+ fields: dict[str, tuple[str, int]], *, cut_id: str, still: bool
577
+ ) -> Optional[CameraMove]:
578
+ """Read 「运镜:固定|推近 <n>%|拉远|左移|右移|上移|下移」. Stills only; default 固定."""
579
+
580
+ raw = fields.get("运镜")
581
+ if raw is None:
582
+ return CameraMove() if still else None
583
+ value, where = raw[0].strip(), raw[1]
584
+ if not still:
585
+ raise EditError(
586
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 是视频段,「运镜」只写在静帧段上;"
587
+ "视频的运镜在生成时就定了"
588
+ )
589
+ if value == CAMERA_HOLD:
590
+ return CameraMove()
591
+ paced = CAMERA_RATE.match(value)
592
+ if paced:
593
+ low, high = CAMERA_RATE_LIMITS
594
+ rate = float(paced.group(2))
595
+ if not low <= rate <= high:
596
+ raise EditError(
597
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的运镜速度 {rate:g}%/秒 超出 {low:g}–{high:g}"
598
+ )
599
+ return CameraMove(paced.group(1), 0, rate)
600
+ found = CAMERA_MOVE.match(value)
601
+ low, high = CAMERA_MOVE_LIMITS
602
+ if not found or not low <= int(found.group(2)) <= high:
603
+ raise EditError(
604
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的运镜要写成「{CAMERA_HOLD}」、"
605
+ f"「{'/'.join(CAMERA_MOVES)} <{low}–{high}>%」或「… <速度>%/秒」,当前是 {value!r}"
606
+ )
607
+ return CameraMove(found.group(1), int(found.group(2)))
608
+
609
+
438
610
  def _field_entries(
439
611
  fields: dict[str, tuple[str, int]],
440
612
  pattern: re.Pattern[str],
@@ -544,37 +716,79 @@ def _parse_screen_texts(
544
716
  return tuple(texts)
545
717
 
546
718
 
719
+ def _sound_options(
720
+ value: str, label: str, *, cut_id: str, where: int
721
+ ) -> tuple[str, float, float]:
722
+ """Split 「(起点:<秒>;增益:<dB>)」 off a sound line: (rest, dB, 起点 seconds)."""
723
+
724
+ stated = SOUND_OPTIONS.search(value)
725
+ if not stated:
726
+ return value, 0.0, 0.0
727
+ options: dict[str, float] = {}
728
+ for part in re.split(r"[;;]", stated.group(1)):
729
+ found = SOUND_OPTION.match(part.strip())
730
+ if not found or found.group(1) in options or (found.group(3) and found.group(1) != "增益"):
731
+ raise EditError(
732
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「{label}」括号里只写「起点:<秒>」"
733
+ f"和「增益:<dB>」,各至多一次,用「;」分隔,当前是 {part.strip()!r}"
734
+ )
735
+ options[found.group(1)] = float(found.group(2))
736
+ gain, offset = options.get("增益", 0.0), options.get("起点", 0.0)
737
+ low, high = GAIN_LIMITS
738
+ if not low <= gain <= high:
739
+ raise EditError(
740
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「{label}」增益 {gain:g} dB "
741
+ f"超出 {low:g} 到 {high:g}"
742
+ )
743
+ if offset < 0:
744
+ raise EditError(f"{CUT_LIST_NAME}:{where}: {cut_id} 的「{label}」起点不能为负")
745
+ return value[: stated.start()], gain, offset
746
+
747
+
547
748
  def _parse_sound_effects(
548
749
  fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
549
750
  ) -> tuple[SoundEffect, ...]:
550
- """Read 「音效 N:<起>-<止> <文件>(增益:<dB>)」 lines."""
751
+ """Read 「音效 N:<起>-<止> <文件>(起点:<秒>;增益:<dB>)」 lines."""
551
752
 
552
753
  _, entries = _field_entries(fields, SOUND_EFFECT_FIELD, "音效", cut_id=cut_id, line=line)
553
754
  effects: list[SoundEffect] = []
554
755
  for index, value, where in entries:
555
756
  if value.strip() == "无":
556
757
  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
- )
758
+ value, gain, offset = _sound_options(value, f"音效 {index}", cut_id=cut_id, where=where)
568
759
  found = SUBTITLE_CUE.match(value)
569
760
  if not found:
570
761
  raise EditError(
571
762
  f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」要写成"
572
763
  "「<起>-<止> <项目相对路径>」"
573
764
  )
574
- effects.append(SoundEffect(float(found.group(1)), float(found.group(2)), found.group(3), gain))
765
+ effects.append(SoundEffect(
766
+ float(found.group(1)), float(found.group(2)), found.group(3), gain, offset
767
+ ))
575
768
  return tuple(effects)
576
769
 
577
770
 
771
+ def _parse_voices(
772
+ fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
773
+ ) -> tuple[Voice, ...]:
774
+ """Read 「配音 N:<起> <文件>(起点:<秒>;增益:<dB>)」 lines. The file plays to its end."""
775
+
776
+ _, entries = _field_entries(fields, VOICE_FIELD, "配音", cut_id=cut_id, line=line)
777
+ voices: list[Voice] = []
778
+ for index, value, where in entries:
779
+ if value.strip() == "无":
780
+ continue
781
+ value, gain, offset = _sound_options(value, f"配音 {index}", cut_id=cut_id, where=where)
782
+ found = VOICE.match(value)
783
+ if not found:
784
+ raise EditError(
785
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「配音 {index}」要写成"
786
+ "「<起> <项目相对路径>」,从起点放到文件结束"
787
+ )
788
+ voices.append(Voice(float(found.group(1)), found.group(2), gain, offset))
789
+ return tuple(voices)
790
+
791
+
578
792
  def _parse_subtitles(
579
793
  fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
580
794
  ) -> tuple[Subtitle, ...]:
@@ -688,16 +902,47 @@ def probe_stream(media: Path) -> dict[str, Any]:
688
902
  }
689
903
 
690
904
 
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."""
905
+ def _excused(unused: Sequence[str]) -> set[str]:
906
+ """IDs listed under 「未采用镜头」 with a non-empty reason."""
693
907
 
694
- used = {cut.motion for cut in cuts}
695
908
  excused = set()
696
909
  for note in unused:
697
- match = re.fullmatch(r"(MOTION-[\w-]+)\s*[((]理由[::]\s*(.+?)[))]", note.strip())
910
+ match = re.fullmatch(
911
+ r"((?:MOTION|SHOT|IMG)-[\w-]+)\s*[((]理由[::]\s*(.+?)[))]", note.strip()
912
+ )
698
913
  if match and match.group(2).strip():
699
914
  excused.add(match.group(1))
700
- missing = sorted(known - used - excused)
915
+ return excused
916
+
917
+
918
+ def probe_audio(media: Path) -> Optional[tuple[int, str]]:
919
+ """(sample rate, channel layout) of the first audio stream; None when it has none."""
920
+
921
+ probe = _require("ffprobe")
922
+ result = subprocess.run(
923
+ [probe, "-v", "error", "-select_streams", "a:0", "-show_entries",
924
+ "stream=sample_rate,channels,channel_layout", "-of", "json", str(media)],
925
+ capture_output=True, text=True, check=False,
926
+ )
927
+ streams = json.loads(result.stdout or "{}").get("streams") if result.returncode == 0 else None
928
+ if not streams:
929
+ return None
930
+ stream = streams[0]
931
+ layout = stream.get("channel_layout") or ("mono" if stream.get("channels") == 1 else "stereo")
932
+ return int(stream.get("sample_rate") or STILL_AUDIO[0]), layout
933
+
934
+
935
+ def _unaccounted_shots(
936
+ known: set[str],
937
+ cuts: Sequence[Cut],
938
+ unused: Sequence[str],
939
+ *,
940
+ accounted: Iterable[str] = (),
941
+ ) -> list[str]:
942
+ """Require each source to be used, accounted for another way, or omitted with a reason."""
943
+
944
+ used = {cut.motion for cut in cuts} | set(accounted)
945
+ missing = sorted(known - used - _excused(unused))
701
946
  if not missing:
702
947
  return []
703
948
  return [
@@ -706,6 +951,196 @@ def _unaccounted_shots(known: set[str], cuts: Sequence[Cut], unused: Sequence[st
706
951
  ]
707
952
 
708
953
 
954
+ def _headings(path: Path, heading: re.Pattern[str]) -> Optional[set[str]]:
955
+ """The IDs a document declares as `##` headings; None when it does not exist."""
956
+
957
+ if not path.is_file():
958
+ return None
959
+ return {
960
+ match.group(1)
961
+ for match in map(heading.match, path.read_text(encoding="utf-8").splitlines())
962
+ if match
963
+ }
964
+
965
+
966
+ def _motion_shots(episode: Path) -> dict[str, str]:
967
+ """`{MOTION-...: SHOT-...}` from each video prompt's 「分镜」 line."""
968
+
969
+ fields = _heading_fields(episode / MOTION_DOCUMENT, MOTION_HEADING, "分镜")
970
+ return {
971
+ motion: found.group(0)
972
+ for motion, value in fields.items()
973
+ for found in [SHOT_REFERENCE.search(value)]
974
+ if found
975
+ }
976
+
977
+
978
+ def _source_findings(episode: Path, cuts: Sequence[Cut], unused: Sequence[str]) -> list[str]:
979
+ """Each source ID exists in its document, and each shot is in the film or excused.
980
+
981
+ Video prompts are held to coverage when the list has a video cut, storyboard
982
+ shots when it has a SHOT- still. Across the two, a shot counts once however
983
+ it reached the film: a MOTION whose storyboard shot went in as a still is
984
+ accounted for, and so is a SHOT whose MOTION was used or excused.
985
+ """
986
+
987
+ findings: list[str] = []
988
+ documents = (
989
+ ("MOTION-", MOTION_DOCUMENT, MOTION_HEADING),
990
+ ("SHOT-", STORYBOARD_DOCUMENT, SHOT_HEADING),
991
+ ("IMG-", IMAGE_DOCUMENT, IMAGE_HEADING),
992
+ )
993
+ known: dict[str, Optional[set[str]]] = {}
994
+ for prefix, name, heading in documents:
995
+ using = [cut for cut in cuts if cut.motion.startswith(prefix)]
996
+ declared = known[prefix] = _headings(episode / name, heading) if using else None
997
+ if not using:
998
+ continue
999
+ if declared is None:
1000
+ findings.append(f"没有 {episode / name},来源 {prefix[:-1]} 无法核对")
1001
+ continue
1002
+ findings.extend(
1003
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的来源 "
1004
+ f"{cut.motion} 不在《{name}》中"
1005
+ for cut in using if cut.motion not in declared
1006
+ )
1007
+
1008
+ shot_of = _motion_shots(episode)
1009
+ used = {cut.motion for cut in cuts}
1010
+ motions, shots = known["MOTION-"], known["SHOT-"]
1011
+ if motions is not None:
1012
+ findings.extend(_unaccounted_shots(
1013
+ motions, cuts, unused, accounted={m for m in motions if shot_of.get(m) in used},
1014
+ ))
1015
+ if shots is not None:
1016
+ findings.extend(_unaccounted_shots(
1017
+ shots, cuts, unused,
1018
+ accounted={shot_of[m] for m in used | _excused(unused) if m in shot_of},
1019
+ ))
1020
+ return findings
1021
+
1022
+
1023
+ def probe_audible(media: Path) -> Audible:
1024
+ """A sound file's length and where its sound starts and stops, decoded small and mono."""
1025
+
1026
+ ffmpeg = _require("ffmpeg")
1027
+ result = subprocess.run(
1028
+ [ffmpeg, "-v", "error", "-i", str(media), "-ac", "1", "-ar", str(AUDIBLE_RATE),
1029
+ "-f", "s16le", "-"],
1030
+ capture_output=True, check=False,
1031
+ )
1032
+ if result.returncode != 0:
1033
+ raise EditError(f"ffmpeg 读不出声音: {media} ({result.stderr.decode(errors='replace').strip()})")
1034
+ samples = array("h")
1035
+ samples.frombytes(result.stdout[: len(result.stdout) // 2 * 2])
1036
+ if sys.byteorder == "big":
1037
+ samples.byteswap()
1038
+ return _audible_span(samples, AUDIBLE_RATE)
1039
+
1040
+
1041
+ def _audible_span(samples: Sequence[int], rate: int) -> Audible:
1042
+ """The first and last 20 ms window within AUDIBLE_RANGE_DB of the loudest one."""
1043
+
1044
+ size = max(1, round(rate * AUDIBLE_WINDOW))
1045
+ energies = [
1046
+ sum(value * value for value in samples[at:at + size]) / len(samples[at:at + size])
1047
+ for at in range(0, len(samples), size)
1048
+ ]
1049
+ duration = len(samples) / rate
1050
+ loudest = max(energies, default=0.0)
1051
+ if loudest <= 0:
1052
+ return Audible(duration, 0.0, 0.0)
1053
+ floor = loudest * 10 ** (-AUDIBLE_RANGE_DB / 10)
1054
+ heard = [index for index, energy in enumerate(energies) if energy >= floor]
1055
+ return Audible(duration, heard[0] * size / rate, min(duration, (heard[-1] + 1) * size / rate))
1056
+
1057
+
1058
+ def _voice_sounds(episode: Path, project_root: Path, cuts: Sequence[Cut]) -> dict[str, Audible]:
1059
+ """Every 「配音」 file, measured, keyed as written. Raises when one cannot be read."""
1060
+
1061
+ sounds: dict[str, Audible] = {}
1062
+ for cut in cuts:
1063
+ for voice in cut.voices:
1064
+ if voice.path in sounds:
1065
+ continue
1066
+ media = _resolve_media(episode, project_root, voice.path)
1067
+ if media is None:
1068
+ raise EditError(f"配音文件不存在或不在项目目录内: {voice.path}")
1069
+ sounds[voice.path] = probe_audible(media)
1070
+ return sounds
1071
+
1072
+
1073
+ def _voice_spans(
1074
+ cuts: Sequence[Cut], sounds: dict[str, Audible], lengths: Sequence[float]
1075
+ ) -> list[tuple[float, float, Cut, Voice]]:
1076
+ """Each voice line's audible span in film seconds, cuts laid end to end at `lengths`."""
1077
+
1078
+ spans: list[tuple[float, float, Cut, Voice]] = []
1079
+ for cut, cursor, length in _timeline(cuts, lengths):
1080
+ for voice in cut.voices:
1081
+ sound = sounds[voice.path]
1082
+ # Where the file's own second zero lands on the film.
1083
+ zero = _at(cut, cursor, length, voice.start) - voice.offset
1084
+ spans.append((zero + max(sound.head, voice.offset), zero + sound.tail, cut, voice))
1085
+ return sorted(spans, key=lambda span: span[0])
1086
+
1087
+
1088
+ def _voice_findings(
1089
+ cuts: Sequence[Cut], sounds: dict[str, Audible], lengths: Sequence[float]
1090
+ ) -> list[str]:
1091
+ """A voice line is heard inside the film and never over another line, across cuts too."""
1092
+
1093
+ total = sum(lengths)
1094
+ findings: list[str] = []
1095
+ latest: Optional[tuple[float, float, Cut, Voice]] = None
1096
+ for span in _voice_spans(cuts, sounds, lengths):
1097
+ start, end, cut, voice = span
1098
+ where = f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id}"
1099
+ if end <= start:
1100
+ findings.append(f"{where} 的配音 {voice.path} 起点 {voice.offset:g} 秒之后没有声音")
1101
+ continue
1102
+ if end > total + VOICE_OVERRUN:
1103
+ findings.append(
1104
+ f"{where} 的配音 {voice.path} 在成片 {start:.2f} 秒开口、{end:.2f} 秒才说完,"
1105
+ f"超出成片结尾 {total:.2f};加长末几段或提前起点"
1106
+ )
1107
+ if latest is not None and start < latest[1] - TOLERANCE:
1108
+ findings.append(
1109
+ f"{where} 的配音 {voice.path}(成片 {start:.2f} 秒开口)压在 {latest[2].cut_id} 的"
1110
+ f"配音 {latest[3].path} 上(说到 {latest[1]:.2f} 秒)"
1111
+ )
1112
+ if latest is None or end > latest[1]:
1113
+ latest = span
1114
+ return findings
1115
+
1116
+
1117
+ def _subtitle_findings(cuts: Sequence[Cut], lengths: Sequence[float]) -> list[str]:
1118
+ """A subtitle may run on past its cut, but stays in the film and off every other subtitle."""
1119
+
1120
+ total = sum(lengths)
1121
+ placed: list[tuple[float, float, Cut]] = []
1122
+ findings: list[str] = []
1123
+ for cut, cursor, length in _timeline(cuts, lengths):
1124
+ for window_start, window_end, _, _ in cut.subtitles:
1125
+ start = 0.0 if window_start is None else window_start
1126
+ end = cut.declared if window_end is None else window_end
1127
+ shown = (_at(cut, cursor, length, start), _at(cut, cursor, length, end))
1128
+ if not 0 <= start < min(end, cut.declared) or shown[1] > total + TOLERANCE:
1129
+ findings.append(
1130
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间要从本段内开始、"
1131
+ f"在成片结尾之前结束: {start:g}-{end:g}"
1132
+ )
1133
+ placed.append((*shown, cut))
1134
+ placed.sort(key=lambda item: item[0])
1135
+ for (_, end, earlier), (start, _, later) in zip(placed, placed[1:]):
1136
+ if earlier is not later and start < end - TOLERANCE:
1137
+ findings.append(
1138
+ f"{CUT_LIST_NAME}:{later.line_number}: {later.cut_id} 的字幕与 {earlier.cut_id} "
1139
+ f"跨过来的字幕同时在屏上(成片 {start:.2f} 秒)"
1140
+ )
1141
+ return findings
1142
+
1143
+
709
1144
  def check_cuts(
710
1145
  episode: Path,
711
1146
  cuts: Sequence[Cut],
@@ -713,8 +1148,17 @@ def check_cuts(
713
1148
  *,
714
1149
  probe: bool,
715
1150
  unused: Sequence[str] = (),
1151
+ delivery: Optional[Delivery] = None,
1152
+ listen: Optional[bool] = None,
716
1153
  ) -> list[str]:
717
- """Every mechanical cross-check the cut list can be held to. Returns findings."""
1154
+ """Every mechanical cross-check the cut list can be held to. Returns findings.
1155
+
1156
+ `probe` reads media with ffprobe; `listen` (default: `probe`) decodes voice
1157
+ files with ffmpeg to find where they are audible.
1158
+ """
1159
+
1160
+ if listen is None:
1161
+ listen = probe
718
1162
 
719
1163
  findings: list[str] = []
720
1164
  if not cuts:
@@ -727,25 +1171,22 @@ def check_cuts(
727
1171
  findings.append(f"{CUT_LIST_NAME}:{cut.line_number}: CUT ID 重复: {cut.cut_id}")
728
1172
  seen.add(cut.cut_id)
729
1173
 
730
- motion_path = episode / MOTION_DOCUMENT
731
- if motion_path.is_file():
732
- known = {
733
- match.group(1)
734
- for match in (
735
- MOTION_HEADING.match(line)
736
- for line in motion_path.read_text(encoding="utf-8").splitlines()
1174
+ findings.extend(_source_findings(episode, cuts, unused))
1175
+ # Stills are drawn at the delivery frame; video in the same film must match it.
1176
+ spec: Optional[tuple[Any, Any, float]] = None
1177
+ if any(cut.still for cut in cuts):
1178
+ if delivery is None or delivery.frame_size is None or delivery.fps is None:
1179
+ findings.append(
1180
+ f"{CUT_LIST_NAME}: 有静帧段时,交付规格必须写「画幅与帧率」"
1181
+ "(如 1080×1920 · 24fps);静帧按它出画"
737
1182
  )
738
- if match
739
- }
740
- for cut in cuts:
741
- if cut.motion not in known:
1183
+ else:
1184
+ spec = (*delivery.frame_size, round(delivery.fps, 3))
1185
+ if any(side % 2 for side in delivery.frame_size):
742
1186
  findings.append(
743
- f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的来源 "
744
- f"{cut.motion} 不在《{MOTION_DOCUMENT}》中"
1187
+ f"{CUT_LIST_NAME}: 交付画幅 {delivery.frame_size[0]}×{delivery.frame_size[1]} "
1188
+ "有奇数边;静帧按它编码成 4:2:0,宽高都要是偶数"
745
1189
  )
746
- findings.extend(_unaccounted_shots(known, cuts, unused))
747
- else:
748
- findings.append(f"没有 {motion_path},来源 MOTION 无法核对")
749
1190
 
750
1191
  screenplay = ""
751
1192
  screenplay_path = episode / SCREENPLAY_DOCUMENT
@@ -753,6 +1194,7 @@ def check_cuts(
753
1194
  screenplay = screenplay_path.read_text(encoding="utf-8")
754
1195
 
755
1196
  media_format: Optional[tuple[Any, Any, float]] = None
1197
+ voiced = True
756
1198
  for cut in cuts:
757
1199
  span = cut.end - cut.start
758
1200
  if cut.start < 0:
@@ -766,11 +1208,28 @@ def check_cuts(
766
1208
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 - 入点 = "
767
1209
  f"{span:.2f},与「时长:{cut.declared:.2f}」不符"
768
1210
  )
1211
+ if cut.still and abs(cut.start) > TOLERANCE:
1212
+ findings.append(
1213
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 是静帧,入点必须是 0.00,"
1214
+ "出点等于时长"
1215
+ )
1216
+ if cut.still and cut.declared < STILL_MINIMUM - TOLERANCE:
1217
+ findings.append(
1218
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 是静帧,时长 "
1219
+ f"{cut.declared:.2f} 短于 {STILL_MINIMUM:.2f} 秒"
1220
+ )
769
1221
  media = _resolve_media(episode, project_root, cut.media)
770
1222
  if media is None:
771
1223
  findings.append(
772
- f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材不存在: {cut.media}"
1224
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材不存在或不在项目目录内: {cut.media}"
773
1225
  )
1226
+ elif probe and cut.still:
1227
+ try:
1228
+ probe_stream(media)
1229
+ except EditError as error:
1230
+ findings.append(
1231
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的图片读不出: {error}"
1232
+ )
774
1233
  elif probe:
775
1234
  stream = probe_stream(media)
776
1235
  available = stream["duration"]
@@ -783,35 +1242,87 @@ def check_cuts(
783
1242
  f"{current_format} 与首段 {media_format} 不一致;"
784
1243
  "先在外部统一素材规格,再更新来源路径与入出点"
785
1244
  )
1245
+ if spec is not None and current_format != spec:
1246
+ findings.append(
1247
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的画幅或帧率 "
1248
+ f"{current_format} 与交付规格 {spec} 不一致;静帧按交付规格出画,"
1249
+ "同一条片里的视频段须与之相同"
1250
+ )
786
1251
  if cut.end > available + TOLERANCE:
787
1252
  findings.append(
788
1253
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 {cut.end:.2f} "
789
1254
  f"超过素材实际时长 {available:.2f}"
790
1255
  )
791
- for window_start, window_end, text, _ in cut.subtitles:
1256
+ for _, _, text, _ in cut.subtitles:
792
1257
  if screenplay and _normalize(text) not in _normalize(screenplay):
793
1258
  findings.append(
794
1259
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕在《"
795
1260
  f"{SCREENPLAY_DOCUMENT}》里找不到原文: {text}"
796
1261
  )
797
- if window_start is None or window_end is None:
798
- continue
799
- if not 0 <= window_start < window_end <= span + TOLERANCE:
800
- findings.append(
801
- f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间超出本段区间"
802
- f": {window_start}-{window_end}"
803
- )
804
1262
  for effect in cut.sound_effects:
805
1263
  if not 0 <= effect.start < effect.end <= span + TOLERANCE:
806
1264
  findings.append(
807
1265
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效时间超出本段区间"
808
1266
  f": {effect.start:g}-{effect.end:g}"
809
1267
  )
810
- if _resolve_media(episode, project_root, effect.path) is None:
1268
+ effect_media = _resolve_media(episode, project_root, effect.path)
1269
+ if effect_media is None:
811
1270
  findings.append(
812
- f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效文件不存在: "
1271
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效文件不存在或不在项目目录内: "
813
1272
  f"{effect.path}"
814
1273
  )
1274
+ elif probe and effect.offset > 0:
1275
+ try:
1276
+ available_sound = probe_duration(effect_media)
1277
+ if effect.offset >= available_sound:
1278
+ findings.append(
1279
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效起点 "
1280
+ f"{effect.offset:g} 必须小于 {effect.path} 的实际时长 {available_sound:g}"
1281
+ )
1282
+ except EditError as error:
1283
+ findings.append(f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效读不出: {error}")
1284
+ for voice in cut.voices:
1285
+ if _resolve_media(episode, project_root, voice.path) is None:
1286
+ voiced = False
1287
+ findings.append(
1288
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的配音文件不存在或不在项目目录内: "
1289
+ f"{voice.path}"
1290
+ )
1291
+
1292
+ # The film's rate: the spec's, else the one the probe loop read off the video.
1293
+ fps = delivery.fps if delivery is not None and delivery.fps else (media_format or (0, 0, 0.0))[2]
1294
+ lengths = _film_spans(cuts, fps)
1295
+ for index, cut in enumerate(cuts):
1296
+ where = f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id}"
1297
+ if cut.move is not None and cut.move.rate is not None:
1298
+ reach = cut.move.rate * lengths[index]
1299
+ if reach > CAMERA_MOVE_LIMITS[1]:
1300
+ findings.append(
1301
+ f"{where} 的运镜 {cut.move.rate:g}%/秒 走完这一段是 {reach:.0f}%,"
1302
+ f"超过 {CAMERA_MOVE_LIMITS[1]}%;放慢或缩短这一段"
1303
+ )
1304
+ if cut.bed is not None:
1305
+ earliest = -lengths[index - 1] if index else 0.0
1306
+ if not earliest - TOLERANCE <= cut.bed.start < cut.end - cut.start:
1307
+ findings.append(
1308
+ f"{where} 的环境声起点 {cut.bed.start:g} 要在 {earliest:g}(上一段开头)到本段结尾之间"
1309
+ )
1310
+ if cut.bed.path is not None and _resolve_media(episode, project_root, cut.bed.path) is None:
1311
+ findings.append(f"{where} 的环境声文件不存在或不在项目目录内: {cut.bed.path}")
1312
+ for voice in cut.voices:
1313
+ # A J-cut reaches back at most to the previous cut's start.
1314
+ earliest = -lengths[index - 1] if index else 0.0
1315
+ if not earliest - TOLERANCE <= voice.start < cut.end - cut.start:
1316
+ findings.append(
1317
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的配音起点 {voice.start:g} "
1318
+ f"要在 {earliest:g}(上一段开头)到本段结尾之间"
1319
+ )
1320
+ findings.extend(_subtitle_findings(cuts, lengths))
1321
+ if listen and voiced and any(cut.voices for cut in cuts):
1322
+ try:
1323
+ findings.extend(_voice_findings(cuts, _voice_sounds(episode, project_root, cuts), lengths))
1324
+ except EditError as error:
1325
+ findings.append(f"{CUT_LIST_NAME}: {error}")
815
1326
 
816
1327
  findings.extend(_screen_text_findings(cuts, screenplay))
817
1328
  findings.extend(_overlap_findings(cuts))
@@ -876,22 +1387,26 @@ def _stale_window_findings(
876
1387
  any(subtitle[0] is not None for subtitle in cut.subtitles)
877
1388
  or cut.screen_texts
878
1389
  or cut.sound_effects
1390
+ or cut.voices
879
1391
  )
880
1392
  if not timed:
881
1393
  continue
882
- media = _resolve_media(episode, project_root, cut.media)
883
- if media is None:
884
- continue
885
- try:
886
- changed = media.stat().st_mtime
887
- except OSError:
888
- continue
889
- if changed > authored + 1.0:
890
- findings.append(
891
- f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材比剪辑单新"
892
- f"({cut.media});这一段的字幕、画面文字与音效时间是按旧素材反推的,"
893
- "重出之后必须重测再改,不能沿用"
894
- )
1394
+ # A still's timings were measured on its voice files, not on the picture.
1395
+ written = [voice.path for voice in cut.voices] + ([] if cut.still else [cut.media])
1396
+ for relative in written:
1397
+ media = _resolve_media(episode, project_root, relative)
1398
+ if media is None:
1399
+ continue
1400
+ try:
1401
+ changed = media.stat().st_mtime
1402
+ except OSError:
1403
+ continue
1404
+ if changed > authored + 1.0:
1405
+ findings.append(
1406
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材比剪辑单新"
1407
+ f"({relative});这一段的字幕、画面文字与音效时间是按旧素材反推的,"
1408
+ "重出之后必须重测再改,不能沿用"
1409
+ )
895
1410
  return findings
896
1411
 
897
1412
 
@@ -901,7 +1416,9 @@ def _overlap_findings(cuts: Sequence[Cut]) -> list[str]:
901
1416
  findings: list[str] = []
902
1417
  by_media: dict[str, list[Cut]] = {}
903
1418
  for cut in cuts:
904
- by_media.setdefault(cut.media, []).append(cut)
1419
+ # A keyframe reused in a shot/reverse-shot is a choice, not repeated footage.
1420
+ if not cut.still:
1421
+ by_media.setdefault(cut.media, []).append(cut)
905
1422
  for media, group in by_media.items():
906
1423
  ordered = sorted(group, key=lambda item: item.start)
907
1424
  for earlier, later in zip(ordered, ordered[1:]):
@@ -920,9 +1437,16 @@ def _normalize(text: str) -> str:
920
1437
 
921
1438
 
922
1439
  def _resolve_media(episode: Path, project_root: Path, relative: str) -> Optional[Path]:
1440
+ """The file a cut list names, against the episode then the project; None if absent.
1441
+
1442
+ A path that leaves the project (`../`, an absolute path, a link out) is
1443
+ treated as absent: the cut list describes this project's material only.
1444
+ """
1445
+
1446
+ root = project_root.resolve()
923
1447
  for base in (episode, project_root):
924
1448
  candidate = (base / relative).resolve()
925
- if candidate.is_file():
1449
+ if candidate.is_file() and (candidate == root or root in candidate.parents):
926
1450
  return candidate
927
1451
  return None
928
1452
 
@@ -946,31 +1470,62 @@ def render(
946
1470
 
947
1471
  def measure(cut: Cut) -> Optional[ChannelStats]:
948
1472
  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)
1473
+ if media is None:
1474
+ return None
1475
+ if cut.still:
1476
+ return None if delivery.frame_size is None else _still_stats(
1477
+ ffmpeg, media, delivery.frame_size
1478
+ )
1479
+ return _channel_stats(ffmpeg, media, cut.start, cut.end - cut.start)
950
1480
 
951
1481
  scenes = _scene_keys(episode, cuts)
952
1482
  pictures, auto = _picture_plan(cuts, scenes, measure, enabled=delivery.shot_match)
1483
+ silence = _silent_track(episode, project_root, cuts, delivery)
1484
+ sounds = _voice_sounds(episode, project_root, cuts)
1485
+ fps = _film_fps(episode, project_root, cuts, delivery, probe=True)
1486
+ spans = _film_spans(cuts, fps)
953
1487
 
1488
+ eases = _move_ease(cuts, scenes)
954
1489
  segments: list[Path] = []
955
- spans: list[float] = []
956
- for cut, match in zip(cuts, pictures):
1490
+ for cut, match, length, ease in zip(cuts, pictures, spans, eases):
957
1491
  media = _resolve_media(episode, project_root, cut.media)
958
1492
  if media is None:
959
- raise EditError(f"{cut.cut_id} 的素材不存在: {cut.media}")
1493
+ raise EditError(f"{cut.cut_id} 的素材不存在或不在项目目录内: {cut.media}")
960
1494
  segment = segments_root / f"{cut.cut_id}.mp4"
961
- command = [
962
- ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
963
- "-ss", f"{cut.start:.3f}", "-t", f"{cut.end - cut.start:.3f}", "-i", str(media),
964
- ]
965
- if match:
966
- command += ["-vf", match]
1495
+ if cut.still:
1496
+ command = _still_command(ffmpeg, media, cut, delivery, silence, match, length, ease)
1497
+ else:
1498
+ # Read enough source for the whole frames the film gives this cut.
1499
+ span = max(cut.end - cut.start, length)
1500
+ command = [
1501
+ ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
1502
+ "-ss", f"{cut.start:.3f}", "-t", f"{span:.3f}", "-i", str(media),
1503
+ ]
1504
+ if probe_audio(media) is None:
1505
+ rate, layout = silence
1506
+ command += [
1507
+ "-f", "lavfi", "-t", f"{span:.3f}", "-i", f"anullsrc=r={rate}:cl={layout}",
1508
+ "-map", "0:v", "-map", "1:a",
1509
+ ]
1510
+ if match:
1511
+ command += ["-vf", match]
1512
+ if fps:
1513
+ command += ["-frames:v", str(round(length * fps)), "-t", f"{length:.6f}"]
967
1514
  command += [
968
1515
  "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
1516
+ "-ar", str(silence[0]), "-channel_layout", silence[1],
969
1517
  "-c:a", "aac", "-b:a", "192k", str(segment),
970
1518
  ]
971
1519
  _run(command)
972
1520
  segments.append(segment)
973
- spans.append(probe_duration(segment))
1521
+ # Everything after this cut is placed by `spans`; a segment that came
1522
+ # out short (the source ran out of frames) would shift all of it.
1523
+ rendered = probe_duration(segment)
1524
+ if fps and abs(rendered - length) > 1.5 / fps:
1525
+ raise EditError(
1526
+ f"{cut.cut_id} 渲染出 {rendered:.3f} 秒,按 {fps:g} fps 应为 {length:.3f} 秒;"
1527
+ "素材在出点附近可能不够帧,先把出点提前一帧"
1528
+ )
974
1529
 
975
1530
  with tempfile.TemporaryDirectory() as scratch:
976
1531
  listing = Path(scratch) / "segments.txt"
@@ -978,15 +1533,22 @@ def render(
978
1533
  "".join(f"file '{segment.as_posix()}'\n" for segment in segments), encoding="utf-8"
979
1534
  )
980
1535
  joined = output_root / "成片-未混音.mp4"
981
- _run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
1536
+ # AAC encoder priming starts before zero. Keep that timestamp rather
1537
+ # than shifting the video by one audio packet (and duplicating a frame
1538
+ # when downstream tools decode at the delivery frame rate).
1539
+ _run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-copyts",
982
1540
  "-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", str(joined)])
983
1541
 
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)
1542
+ effects: list[Placed] = []
1543
+ placed = (
1544
+ _placed_sound_effects(cuts, spans) + _placed_voices(cuts, sounds, spans)
1545
+ + _placed_beds(cuts, spans)
1546
+ )
1547
+ for sound in placed:
1548
+ resolved = _resolve_media(episode, project_root, sound.path)
987
1549
  if resolved is None:
988
- raise EditError(f"音效文件不存在: {written}")
989
- effects.append((start, duration, resolved, gain))
1550
+ raise EditError(f"音效、配音或环境声文件不存在或不在项目目录内: {sound.path}")
1551
+ effects.append(sound._replace(path=str(resolved)))
990
1552
  if effects:
991
1553
  # Mixed into its own file first, so the loudness pass below measures
992
1554
  # the film the audience hears rather than the one before the chimes.
@@ -1077,13 +1639,182 @@ def render(
1077
1639
  "叠层": str(overlay_path) if overlay_path else None,
1078
1640
  "字幕渲染": renderer if subtitle_path else None,
1079
1641
  "画面文字": len(layers),
1080
- "音效": len(effects),
1642
+ "音效": sum(len(cut.sound_effects) for cut in cuts),
1643
+ "配音": sum(len(cut.voices) for cut in cuts),
1644
+ "环境声": len(_placed_beds(cuts, spans)),
1645
+ "静帧段": sum(cut.still for cut in cuts),
1081
1646
  "自动接镜": _shot_match_report(auto, scenes, enabled=delivery.shot_match),
1082
1647
  "段数": len(segments),
1083
1648
  "各段时长之和": round(sum(cut.end - cut.start for cut in cuts), 2),
1084
1649
  }
1085
1650
 
1086
1651
 
1652
+ def _silent_track(
1653
+ episode: Path, project_root: Path, cuts: Sequence[Cut], delivery: Delivery
1654
+ ) -> tuple[int, str]:
1655
+ """Shared (sample rate, layout) for every rendered segment.
1656
+
1657
+ Segments are joined by stream copy, so every segment needs an audio track
1658
+ encoded like the first video audio beside it, including audible clips: a
1659
+ segment without one shifts every sound after it, and a mismatched one
1660
+ decodes wrong. 48 kHz stereo when no clip has sound.
1661
+ """
1662
+
1663
+ if any(cut.still for cut in cuts) and (delivery.frame_size is None or delivery.fps is None):
1664
+ raise EditError("有静帧段时,交付规格必须写「画幅与帧率」;静帧按它出画")
1665
+ for cut in cuts:
1666
+ media = None if cut.still else _resolve_media(episode, project_root, cut.media)
1667
+ heard = probe_audio(media) if media is not None else None
1668
+ if heard is not None:
1669
+ return heard
1670
+ return STILL_AUDIO
1671
+
1672
+
1673
+ def _move_ease(
1674
+ cuts: Sequence[Cut], scenes: Sequence[Optional[tuple[str, ...]]]
1675
+ ) -> list[tuple[bool, bool]]:
1676
+ """(ease in, ease out) per cut: a move ramps only where it starts or stops from rest.
1677
+
1678
+ Two adjacent rate moves (「%/秒」) in the same direction and the same scene
1679
+ run straight through the cut between them: the camera does not stop at the
1680
+ join. A move eases in where the cut before it is in another scene, is 固定,
1681
+ or moves some other way, and eases out likewise. The percentage form always
1682
+ eases at both ends: it states where the move ends, not how fast it goes.
1683
+ """
1684
+
1685
+ def continues(earlier: int, later: int) -> bool:
1686
+ first, second = cuts[earlier].move, cuts[later].move
1687
+ return (
1688
+ first is not None and second is not None
1689
+ and first.rate is not None and second.rate is not None
1690
+ and first.kind == second.kind != CAMERA_HOLD
1691
+ and scenes[earlier] is not None and scenes[earlier] == scenes[later]
1692
+ )
1693
+
1694
+ last = len(cuts) - 1
1695
+ return [
1696
+ (not (index and continues(index - 1, index)), not (index < last and continues(index, index + 1)))
1697
+ for index in range(len(cuts))
1698
+ ]
1699
+
1700
+
1701
+ def _move_progress(
1702
+ move: CameraMove, frames: int, fps: float, ease: tuple[bool, bool]
1703
+ ) -> tuple[str, float]:
1704
+ """How far the move has gone at output frame `on`, in percent, and how far it goes in all.
1705
+
1706
+ A rate move cruises at its speed and ramps (a half cosine, CAMERA_EASE long)
1707
+ only at the ends `ease` names, so where it does not ease its speed at the
1708
+ cut equals the next cut's. The percentage form eases over the whole cut.
1709
+ """
1710
+
1711
+ last = max(frames - 1, 1)
1712
+ if move.rate is None:
1713
+ return f"({move.amount}*(1-cos(PI*on/{last}))/2)", float(move.amount)
1714
+ span = last / fps
1715
+ ramp_in = min(CAMERA_EASE, span / 2) if ease[0] else 0.0
1716
+ ramp_out = min(CAMERA_EASE, span / 2) if ease[1] else 0.0
1717
+ t = f"(on/{fps:g})"
1718
+ terms = [t]
1719
+ # Distance lost to each ramp so far: the integral of (1 - speed / cruise).
1720
+ if ramp_in:
1721
+ a = f"min({t},{ramp_in:.6f})"
1722
+ terms.append(f"-({a}+{ramp_in / 3.141592653589793:.6f}*sin(PI*{a}/{ramp_in:.6f}))/2")
1723
+ if ramp_out:
1724
+ b = f"max(0,{t}-{span - ramp_out:.6f})"
1725
+ terms.append(f"-({b}-{ramp_out / 3.141592653589793:.6f}*sin(PI*{b}/{ramp_out:.6f}))/2")
1726
+ total = move.rate * (span - ramp_in / 2 - ramp_out / 2)
1727
+ return f"({move.rate:g}*({''.join(terms)}))", total
1728
+
1729
+
1730
+ def _still_filter(
1731
+ move: CameraMove, width: int, height: int, frames: int,
1732
+ fps: float = 24.0, ease: tuple[bool, bool] = (True, True),
1733
+ ) -> str:
1734
+ """Cover-fit a still to the frame and move across it on an eased curve.
1735
+
1736
+ The move is a window over the fitted picture, `1/zoom` of it on each side,
1737
+ whose corners `perspective` samples per frame at sub-pixel positions.
1738
+ zoompan and crop place the window on whole pixels, which is what makes a
1739
+ slow push judder. Measured on 1080×1920 renders, the frame-to-frame motion
1740
+ of a push wavered 0.34 px rms with zoompan, 0.14 px with zoompan at 4×
1741
+ internal scale and 0.10 px here; of a pan, 0.84, 0.23 and 0.11 px. This
1742
+ route also renders no slower than the 4× one.
1743
+ """
1744
+
1745
+ fitted = (
1746
+ f"scale={width}:{height}:force_original_aspect_ratio=increase:flags=lanczos,"
1747
+ f"crop={width}:{height},setsar=1"
1748
+ )
1749
+ if move.kind == CAMERA_HOLD:
1750
+ return fitted
1751
+ gone, total = _move_progress(move, frames, fps, ease)
1752
+ reach = total / 100
1753
+ # Share of the move done, 0 to 1.
1754
+ eased = f"({gone}/{total:.6f})" if total > 0 else "0"
1755
+ zoom = {
1756
+ "推近": f"(1+{reach:.6f}*{eased})",
1757
+ "拉远": f"(1+{reach:.6f}*(1-{eased}))",
1758
+ }.get(move.kind, f"{1 + reach:.6f}")
1759
+ across, down = f"(W/{zoom})", f"(H/{zoom})"
1760
+ room_x, room_y = f"(W-{across})", f"(H-{down})"
1761
+ # A camera move: 左移 slides the window left, so the picture drifts right.
1762
+ x = {"左移": f"{room_x}*(1-{eased})", "右移": f"{room_x}*{eased}"}.get(move.kind, f"{room_x}/2")
1763
+ y = {"上移": f"{room_y}*(1-{eased})", "下移": f"{room_y}*{eased}"}.get(move.kind, f"{room_y}/2")
1764
+ corners = ((x, y), (f"{x}+{across}", y), (x, f"{y}+{down}"), (f"{x}+{across}", f"{y}+{down}"))
1765
+ points = ":".join(f"x{i}='{cx}':y{i}='{cy}'" for i, (cx, cy) in enumerate(corners))
1766
+ return f"{fitted},perspective={points}:interpolation=cubic:eval=frame"
1767
+
1768
+
1769
+ def _still_command(
1770
+ ffmpeg: str,
1771
+ image: Path,
1772
+ cut: Cut,
1773
+ delivery: Delivery,
1774
+ silence: tuple[int, str],
1775
+ match: str,
1776
+ seconds: float,
1777
+ ease: tuple[bool, bool] = (True, True),
1778
+ ) -> list[str]:
1779
+ """The input half of a still segment's command: picture, move, correction and silence.
1780
+
1781
+ `seconds` is the cut's span from `_film_spans`, a whole number of frames.
1782
+ """
1783
+
1784
+ assert delivery.frame_size is not None and delivery.fps is not None
1785
+ width, height = delivery.frame_size
1786
+ frames = max(1, round(seconds * delivery.fps))
1787
+ chain = _still_filter(cut.move or CameraMove(), width, height, frames, delivery.fps, ease)
1788
+ rate, layout = silence
1789
+ return [
1790
+ ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
1791
+ "-loop", "1", "-framerate", f"{delivery.fps:g}", "-i", str(image),
1792
+ "-f", "lavfi", "-t", f"{seconds:.3f}", "-i", f"anullsrc=r={rate}:cl={layout}",
1793
+ "-vf", ",".join(filter(None, [chain, match])),
1794
+ "-map", "0:v", "-map", "1:a", "-frames:v", str(frames), "-t", f"{seconds:.3f}",
1795
+ ]
1796
+
1797
+
1798
+ def _placed_voices(
1799
+ cuts: Sequence[Cut], sounds: dict[str, Audible], spans: Sequence[float]
1800
+ ) -> list[Placed]:
1801
+ """Each voice line in the form `_placed_sound_effects` gives, for the same mix.
1802
+
1803
+ Its start follows the rendered cut positions, like an effect's. Its length
1804
+ does not: an effect's window is a stretch of its cut and scales with the
1805
+ frame rounding, but a voice plays its file from 起点 to the end, and
1806
+ scaling that would clip the line. It is laid at its cut's start plus 起
1807
+ however far past the cut it runs, which is what makes an L-cut.
1808
+ """
1809
+
1810
+ return [
1811
+ Placed(max(0.0, _at(cut, cursor, length, voice.start)), sounds[voice.path].duration - voice.offset,
1812
+ voice.path, voice.gain_db, voice.offset)
1813
+ for cut, cursor, length in _timeline(cuts, spans)
1814
+ for voice in cut.voices
1815
+ ]
1816
+
1817
+
1087
1818
  def _shot_match_filter(cut: Cut) -> str:
1088
1819
  """The correction a cut states in 「画面:」. It replaces the automatic match."""
1089
1820
 
@@ -1127,20 +1858,54 @@ def _heading_fields(path: Path, heading: re.Pattern[str], field: str) -> dict[st
1127
1858
  def _scene_keys(episode: Path, cuts: Sequence[Cut]) -> list[Optional[tuple[str, ...]]]:
1128
1859
  """Each cut's scene: MOTION → its 分镜 SHOT → the scene IDs that SHOT's 来源 names.
1129
1860
 
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.
1861
+ A SHOT- still starts at the SHOT. A shot drawing on two scenes keys on both,
1862
+ so it matches neither neighbour. None where the chain breaks (an IMG- still
1863
+ names no shot); such a cut is never matched.
1132
1864
  """
1133
1865
 
1134
- shots = _heading_fields(episode / MOTION_DOCUMENT, MOTION_HEADING, "分镜")
1866
+ shots = _motion_shots(episode)
1135
1867
  sources = _heading_fields(episode / STORYBOARD_DOCUMENT, SHOT_HEADING, "来源")
1136
1868
  keys: list[Optional[tuple[str, ...]]] = []
1137
1869
  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 ()
1870
+ shot = cut.motion if cut.motion.startswith("SHOT-") else shots.get(cut.motion)
1871
+ scenes = tuple(SCENE_ID.findall(sources.get(shot, ""))) if shot else ()
1140
1872
  keys.append(scenes or None)
1141
1873
  return keys
1142
1874
 
1143
1875
 
1876
+ def move_notices(
1877
+ cuts: Sequence[Cut], scenes: Sequence[Optional[tuple[str, ...]]], lengths: Sequence[float]
1878
+ ) -> list[str]:
1879
+ """Joins inside one scene that will read as a jolt: a speed jump or a reversed move.
1880
+
1881
+ Not errors: a deliberate change of pace is the creator's to make.
1882
+ """
1883
+
1884
+ def speed(cut: Cut, length: float) -> Optional[float]:
1885
+ move = cut.move
1886
+ if move is None or move.kind == CAMERA_HOLD:
1887
+ return None
1888
+ return move.rate if move.rate is not None else move.amount / length if length else None
1889
+
1890
+ notices: list[str] = []
1891
+ for index in range(1, len(cuts)):
1892
+ before, after = cuts[index - 1], cuts[index]
1893
+ if scenes[index - 1] is None or scenes[index - 1] != scenes[index]:
1894
+ continue
1895
+ first, second = speed(before, lengths[index - 1]), speed(after, lengths[index])
1896
+ if first is None or second is None or before.move is None or after.move is None:
1897
+ continue
1898
+ pair = f"{before.cut_id}({before.move.kind} 约 {first:.1f}%/秒)→ {after.cut_id}({after.move.kind} 约 {second:.1f}%/秒)"
1899
+ if CAMERA_OPPOSITES[before.move.kind] == after.move.kind:
1900
+ notices.append(f"{pair}:同一场里镜头来回反向,切过去像晃了一下;改成同向,或让其中一段固定")
1901
+ elif max(first, second) > (1 + CAMERA_SPEED_NOTICE) * min(first, second):
1902
+ notices.append(
1903
+ f"{pair}:同一场里运镜速度差了 {max(first, second) / min(first, second) - 1:.0%},"
1904
+ "切过去像换了一台机器;同一场用一个速度,写成「%/秒」最省事"
1905
+ )
1906
+ return notices
1907
+
1908
+
1144
1909
  def _scene_runs(scenes: Sequence[Optional[tuple[str, ...]]]) -> list[list[int]]:
1145
1910
  """Indexes of consecutive cuts from one scene. Intercut scenes form separate runs."""
1146
1911
 
@@ -1265,11 +2030,30 @@ def _channel_stats(
1265
2030
  ) -> Optional[ChannelStats]:
1266
2031
  """Per-channel mean and spread of one cut's source range, sampled small."""
1267
2032
 
2033
+ return _sampled_stats([
2034
+ ffmpeg, "-v", "error", "-ss", f"{start:.3f}", "-t", f"{duration:.3f}",
2035
+ "-i", str(media), "-vf", SHOT_MATCH_SAMPLE,
2036
+ ])
2037
+
2038
+
2039
+ def _still_stats(ffmpeg: str, image: Path, frame_size: tuple[int, int]) -> Optional[ChannelStats]:
2040
+ """Per-channel mean and spread of a still as the frame shows it, cover-fitted.
2041
+
2042
+ An image has no timeline: `fps` drops its only frame, and a seek on a JPEG
2043
+ returns none, so it is read once, whole.
2044
+ """
2045
+
2046
+ width, height = frame_size
2047
+ return _sampled_stats([
2048
+ ffmpeg, "-v", "error", "-i", str(image), "-vf",
2049
+ f"scale={width}:{height}:force_original_aspect_ratio=increase,"
2050
+ f"crop={width}:{height},scale=64:-2",
2051
+ ])
2052
+
2053
+
2054
+ def _sampled_stats(command: list[str]) -> Optional[ChannelStats]:
1268
2055
  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,
2056
+ command + ["-pix_fmt", "rgb24", "-f", "rawvideo", "-"], capture_output=True, check=False
1273
2057
  )
1274
2058
  if result.returncode != 0 or len(result.stdout) < 3:
1275
2059
  return None
@@ -1458,42 +2242,81 @@ def _frame_geometry(media: Path) -> str:
1458
2242
  def _subtitle_cues(
1459
2243
  cuts: Sequence[Cut], spans: Sequence[float]
1460
2244
  ) -> list[tuple[float, float, str, tuple[str, ...]]]:
1461
- """Place each line in output time, measuring the segments that were written.
2245
+ """Place each line in film seconds, on the frame-rounded spans the segments are rendered at.
1462
2246
 
1463
- A segment lands on a frame boundary, so it is a few milliseconds longer than
1464
- the cut list declares. Accumulating the declared numbers instead drifts —
1465
- a third of a second by the end of eight cuts here — and the subtitle leaves
1466
- before the actor stops speaking. The rendered files are the timeline.
2247
+ A segment lands on a frame boundary, so it runs a few milliseconds off the
2248
+ length the cut list declares. Accumulating the declared numbers instead
2249
+ drifts -- a third of a second by the end of eight cuts here -- and the
2250
+ subtitle leaves before the actor stops speaking.
1467
2251
  """
1468
2252
 
1469
2253
  cues: list[tuple[float, float, str, tuple[str, ...]]] = []
1470
- for cut, cursor, scale in _timeline(cuts, spans):
1471
- declared = cut.end - cut.start
2254
+ for cut, cursor, length in _timeline(cuts, spans):
1472
2255
  for window_start, window_end, text, words in cut.subtitles:
1473
2256
  start = 0.0 if window_start is None else window_start
1474
- end = declared if window_end is None else window_end
1475
- cues.append((cursor + start * scale, cursor + end * scale, text, words))
2257
+ end = cut.declared if window_end is None else window_end
2258
+ cues.append((_at(cut, cursor, length, start), _at(cut, cursor, length, end), text, words))
1476
2259
  return cues
1477
2260
 
1478
2261
 
2262
+ def _film_spans(cuts: Sequence[Cut], fps: Optional[float]) -> list[float]:
2263
+ """How long each cut runs in the film: its declared length rounded to whole frames.
2264
+
2265
+ render writes every segment as exactly this many frames, and everything
2266
+ that places or checks something on the film -- subtitles, screen text,
2267
+ effects, voices, the film's end -- lays the cuts end to end at these spans.
2268
+ Nothing re-derives timing from the declared lengths, so no path can drift
2269
+ from the picture by the frame rounding. Without a known rate, the declared
2270
+ lengths are all there is.
2271
+ """
2272
+
2273
+ if not fps:
2274
+ return [cut.declared for cut in cuts]
2275
+ return [max(1, round(cut.declared * fps)) / fps for cut in cuts]
2276
+
2277
+
2278
+ def _film_fps(
2279
+ episode: Path, project_root: Path, cuts: Sequence[Cut], delivery: Optional[Delivery], *, probe: bool
2280
+ ) -> Optional[float]:
2281
+ """The film's frame rate: the delivery spec's, else the first video cut's."""
2282
+
2283
+ if delivery is not None and delivery.fps:
2284
+ return delivery.fps
2285
+ if probe:
2286
+ for cut in cuts:
2287
+ media = None if cut.still else _resolve_media(episode, project_root, cut.media)
2288
+ if media is not None:
2289
+ return probe_stream(media)["fps"] or None
2290
+ return None
2291
+
2292
+
1479
2293
  def _timeline(
1480
2294
  cuts: Sequence[Cut], spans: Sequence[float]
1481
2295
  ) -> 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
- """
2296
+ """Each cut with its start in the film and its span there."""
1487
2297
 
1488
2298
  placed: list[tuple[Cut, float, float]] = []
1489
2299
  cursor = 0.0
1490
2300
  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))
2301
+ placed.append((cut, cursor, span))
1493
2302
  cursor += span
1494
2303
  return placed
1495
2304
 
1496
2305
 
2306
+ def _at(cut: Cut, cursor: float, span: float, seconds: float) -> float:
2307
+ """A time written against a cut, in film seconds.
2308
+
2309
+ Inside the cut it keeps its place relative to the cut's rendered length, so
2310
+ a window written to end with the cut still does. Before or past the cut --
2311
+ a J-cut's start, an L-cut subtitle's end -- it is plain seconds from the
2312
+ cut's start, the same clock a voice file plays on.
2313
+ """
2314
+
2315
+ if 0 <= seconds <= cut.declared and cut.declared > 0:
2316
+ return cursor + seconds * span / cut.declared
2317
+ return cursor + seconds
2318
+
2319
+
1497
2320
  class DisplayCue(NamedTuple):
1498
2321
  """One subtitle as burned: output seconds, display text, speaker kind, highlights."""
1499
2322
 
@@ -1639,10 +2462,10 @@ def _screen_text_layers(
1639
2462
 
1640
2463
  layers: list[dict[str, Any]] = []
1641
2464
  latest: Optional[dict[str, Any]] = None
1642
- for cut, cursor, scale in _timeline(cuts, spans):
2465
+ for cut, cursor, length in _timeline(cuts, spans):
1643
2466
  for text in sorted(cut.screen_texts, key=lambda item: item.start):
1644
- start = cursor + text.start * scale
1645
- end = cursor + text.end * scale
2467
+ start = _at(cut, cursor, length, text.start)
2468
+ end = _at(cut, cursor, length, text.end)
1646
2469
  countdown = text.countdown
1647
2470
  if text.resume:
1648
2471
  if latest is None:
@@ -1680,30 +2503,56 @@ def _screen_text_layers(
1680
2503
  return layers
1681
2504
 
1682
2505
 
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)."""
2506
+ def _placed_sound_effects(cuts: Sequence[Cut], spans: Sequence[float]) -> list[Placed]:
2507
+ """Each effect in output seconds, with its path as written."""
1687
2508
 
1688
2509
  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)
2510
+ Placed(max(0.0, _at(cut, cursor, length, effect.start)),
2511
+ _at(cut, cursor, length, effect.end) - _at(cut, cursor, length, effect.start),
2512
+ effect.path, effect.gain_db, effect.offset)
2513
+ for cut, cursor, length in _timeline(cuts, spans)
1692
2514
  for effect in cut.sound_effects
1693
2515
  ]
1694
2516
 
1695
2517
 
2518
+ def _placed_beds(cuts: Sequence[Cut], spans: Sequence[float]) -> list[Placed]:
2519
+ """Each 「环境声」 run, looping its file with no break at the cuts it spans.
2520
+
2521
+ A run starts at its line's cut plus 起 and lasts until the next 环境声 line:
2522
+ to that line's cut start, or to its 起 when that is later. A negative 起
2523
+ therefore overlaps the two rooms across the cut, a sound bridge. It fades
2524
+ only at its own two ends.
2525
+ """
2526
+
2527
+ lines = [
2528
+ (_at(cut, cursor, length, cut.bed.start), cursor, cut.bed)
2529
+ for cut, cursor, length in _timeline(cuts, spans) if cut.bed is not None
2530
+ ]
2531
+ total = sum(spans)
2532
+ placed: list[Placed] = []
2533
+ for index, (start, _, bed) in enumerate(lines):
2534
+ if bed.path is None:
2535
+ continue
2536
+ end = total if index + 1 == len(lines) else max(lines[index + 1][0], lines[index + 1][1])
2537
+ start = max(0.0, start)
2538
+ if end - start > TOLERANCE:
2539
+ placed.append(Placed(start, end - start, bed.path, bed.gain_db, bed.offset,
2540
+ loop=True, fade_in=BED_FADE, fade_out=BED_FADE))
2541
+ return placed
2542
+
2543
+
1696
2544
  def _sound_effect_command(
1697
2545
  ffmpeg: str,
1698
2546
  film: Path,
1699
- effects: Sequence[tuple[float, float, Path, float]],
2547
+ effects: Sequence[Union[Placed, tuple[Any, ...]]],
1700
2548
  output: Path,
1701
2549
  ) -> list[str]:
1702
- """Lay each effect into the film's own audio at its output time.
2550
+ """Lay each effect, voice and bed into the film's own audio at its output time.
1703
2551
 
1704
2552
  `normalize=0` keeps the dialogue at its level: amix's default divides every
1705
2553
  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.
2554
+ is added. A short fade closes each sound so a trimmed tail does not click;
2555
+ a bed loops its file and fades in and out only at its own ends.
1707
2556
  """
1708
2557
 
1709
2558
  command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(film)]
@@ -1711,14 +2560,17 @@ def _sound_effect_command(
1711
2560
  # stereo costs it 3 dB, and the effects are converted to match instead.
1712
2561
  stages = ["[0:a]aformat=sample_rates=48000[a0]"]
1713
2562
  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)
2563
+ for index, raw in enumerate(effects, start=1):
2564
+ sound = Placed(*raw)
2565
+ command += (["-stream_loop", "-1"] if sound.loop else []) + ["-i", str(sound.path)]
2566
+ fade_in = min(sound.fade_in, sound.duration / 2)
2567
+ fade_out = min(sound.fade_out, sound.duration / 2)
1717
2568
  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,"
2569
+ f"[{index}:a]atrim=start={sound.offset:.3f}:duration={sound.duration:.3f},asetpts=PTS-STARTPTS,"
2570
+ + (f"afade=t=in:st=0:d={fade_in:.3f}," if fade_in else "")
2571
+ + f"afade=t=out:st={sound.duration - fade_out:.3f}:d={fade_out:.3f},volume={sound.gain_db:g}dB,"
1720
2572
  "aformat=sample_rates=48000,"
1721
- f"adelay={round(start * 1000)}:all=1[s{index}]"
2573
+ f"adelay={round(sound.start * 1000)}:all=1[s{index}]"
1722
2574
  )
1723
2575
  labels.append(f"[s{index}]")
1724
2576
  stages.append(
@@ -1872,7 +2724,9 @@ def _segment_colours(ffmpeg: str, output_root: Path, cuts: Sequence[Cut]) -> lis
1872
2724
  return rows
1873
2725
 
1874
2726
 
1875
- def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str, Any]:
2727
+ def verify(
2728
+ episode: Path, cuts: Sequence[Cut], delivery: Delivery, project_root: Optional[Path] = None
2729
+ ) -> dict[str, Any]:
1876
2730
  """Measure the rendered film. Every entry is a number or an honest 未测."""
1877
2731
 
1878
2732
  ffmpeg = _require("ffmpeg")
@@ -1913,14 +2767,20 @@ def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str,
1913
2767
  measurements["画内可读文字"] = (
1914
2768
  "未测(抽有画内文字的帧,逐字对《剧本.md》的「画面文字」与提示词声明的内容)"
1915
2769
  )
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
- )
2770
+ # The same spans render placed everything on; the film's own rate when no spec states one.
2771
+ spans = _film_spans(cuts, delivery.fps or stream["fps"])
1922
2772
  if any(cut.screen_texts or cut.sound_effects for cut in cuts):
1923
2773
  measurements.update(_placements_for_sampling(cuts, spans))
2774
+ beds = _placed_beds(cuts, spans)
2775
+ if beds:
2776
+ measurements["环境声落点"] = [
2777
+ {"起": round(bed.start, 2), "止": round(bed.start + bed.duration, 2), "文件": bed.path}
2778
+ for bed in beds
2779
+ ]
2780
+ if any(cut.voices for cut in cuts):
2781
+ measurements["配音落点"] = _voice_placements(
2782
+ episode, project_root or episode, cuts, spans
2783
+ )
1924
2784
  measurements["台词完整性"] = "未测(本工具不做转写;在成片上转写后逐句对《剧本.md》原文)"
1925
2785
  measurements.update(_frame_report(
1926
2786
  _grey_frames(ffmpeg, final), stream["fps"], cuts, spans, _scene_keys(episode, cuts)
@@ -2073,13 +2933,34 @@ def _placements_for_sampling(cuts: Sequence[Cut], spans: Optional[Sequence[float
2073
2933
  }
2074
2934
  for layer in placed
2075
2935
  ],
2076
- "音效落点": [
2077
- {"起": round(start, 2), "止": round(start + duration, 2), "文件": written}
2078
- for start, duration, written, _ in _placed_sound_effects(cuts, spans)
2079
- ],
2936
+ "音效落点": _sound_placements(cuts, spans),
2080
2937
  }
2081
2938
 
2082
2939
 
2940
+ def _sound_placements(cuts: Sequence[Cut], spans: Sequence[float]) -> list[dict[str, Any]]:
2941
+ return [
2942
+ {"起": round(start, 2), "止": round(start + duration, 2), "文件": written}
2943
+ for start, duration, written, *_ in _placed_sound_effects(cuts, spans)
2944
+ ]
2945
+
2946
+
2947
+ def _voice_placements(
2948
+ episode: Path, project_root: Path, cuts: Sequence[Cut], spans: Optional[Sequence[float]]
2949
+ ) -> Union[list[dict[str, Any]], str]:
2950
+ """Where each 「配音」 line is heard in the film, for listening back."""
2951
+
2952
+ if spans is None:
2953
+ return "未测(分段缺失,无法换算成片时间)"
2954
+ try:
2955
+ sounds = _voice_sounds(episode, project_root, cuts)
2956
+ except EditError as error:
2957
+ return f"未测({error})"
2958
+ return [
2959
+ {"起": round(start, 2), "止": round(end, 2), "段": cut.cut_id, "文件": voice.path}
2960
+ for start, end, cut, voice in _voice_spans(cuts, sounds, spans)
2961
+ ]
2962
+
2963
+
2083
2964
  def _matches_frame_size(delivery: Delivery, stream: dict[str, Any]) -> Any:
2084
2965
  if delivery.frame_size is None:
2085
2966
  return "未测(剪辑单没有声明画幅)"
@@ -2149,7 +3030,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
2149
3030
  if arguments.command == "check":
2150
3031
  findings = check_cuts(
2151
3032
  episode, cuts, project_root,
2152
- probe=_which("ffprobe") is not None, unused=unused,
3033
+ probe=_which("ffprobe") is not None, unused=unused, delivery=delivery,
3034
+ listen=_which("ffmpeg") is not None,
2153
3035
  )
2154
3036
  payload: dict[str, Any] = {
2155
3037
  "段数": len(cuts),
@@ -2158,13 +3040,21 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
2158
3040
  "未采用镜头": unused,
2159
3041
  "findings": findings,
2160
3042
  }
3043
+ notices = move_notices(cuts, _scene_keys(episode, cuts), _film_spans(cuts, delivery.fps))
3044
+ if notices:
3045
+ payload["提醒"] = notices
3046
+ unmeasured = []
2161
3047
  if _which("ffprobe") is None:
2162
- payload["未测"] = ["区间是否超过素材实际时长(PATH 上没有 ffprobe)"]
3048
+ unmeasured.append("区间是否超过素材实际时长(PATH 上没有 ffprobe)")
3049
+ if _which("ffmpeg") is None and any(cut.voices for cut in cuts):
3050
+ unmeasured.append("配音是否越过成片结尾、是否互相重叠(PATH 上没有 ffmpeg)")
3051
+ if unmeasured:
3052
+ payload["未测"] = unmeasured
2163
3053
  _emit(payload)
2164
3054
  return 1 if findings else 0
2165
3055
  if arguments.command == "render":
2166
3056
  findings = check_cuts(
2167
- episode, cuts, project_root, probe=True, unused=unused
3057
+ episode, cuts, project_root, probe=True, unused=unused, delivery=delivery
2168
3058
  )
2169
3059
  if findings:
2170
3060
  _emit({"findings": findings, "已渲染": False})
@@ -2177,7 +3067,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
2177
3067
  remotion_concurrency=arguments.remotion_concurrency,
2178
3068
  ))
2179
3069
  return 0
2180
- _emit(verify(episode, cuts, delivery))
3070
+ _emit(verify(episode, cuts, delivery, project_root))
2181
3071
  return 0
2182
3072
  except EditError as error:
2183
3073
  print(str(error), file=sys.stderr)