@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
@@ -8,8 +8,6 @@ import tempfile
8
8
 
9
9
 
10
10
  # ── 配置 ──────────────────────────────────────────────────────────────
11
- _EXISTING_CONFIG_REF = globals().get("CONFIG")
12
-
13
11
 
14
12
  def env_int(name, default, *, minimum=None):
15
13
  """Read an integer env var; an unset/empty value yields `default`, a malformed one is an error."""
@@ -54,36 +52,25 @@ _foreign_source_audio = env_bool("FOREIGN_SOURCE_AUDIO", False)
54
52
  _foreign_under_narration_volume = 0.05 # original volume under narration when source audio is foreign
55
53
 
56
54
  CONFIG = {
57
- "fade_ms": env_int("FADE_MS", 120, minimum=0), # 每段 TTS 淡入淡出(ms);过大会让紧凑的句子一顿一顿,120ms 防爆音又不发闷
58
- "ducking_mode": "fixed", # fixed | sidechaincompress | none
59
- "ducking_threshold": 0.15,
60
- "ducking_ratio": 3,
61
- "ducking_attack": 10,
62
- "ducking_release": 300,
63
- "ducking_level_sc": 2.0,
64
- "ducking_makeup": 1.2,
55
+ "fade_ms": 120, # 每段 TTS 淡入淡出(ms);过大会让紧凑的句子一顿一顿,120ms 防爆音又不发闷
65
56
  "ducking_narr_weight": 1.5,
66
- "ducking_orig_volume": env_float("DUCKING_ORIG_VOLUME", 0.3, minimum=0.0), # 解说时原声基准音量
67
- # Derived report of the FOREIGN_SOURCE_AUDIO knob this skill implements: it selects the
68
- # ducking volumes below. Declared so callers can see which policy is in effect.
69
- "foreign_source_audio": _foreign_source_audio,
57
+ "ducking_orig_volume": 0.3, # 解说时原声基准音量;仅在 beat 无放置信息时兜底
70
58
  "zone_ducking_volume": env_float("ZONE_DUCKING_VOLUME",
71
59
  _foreign_under_narration_volume if _foreign_source_audio else 0.12, minimum=0.0), # 解说时原声压低到的音量
72
60
  "idle_orig_volume": env_float("IDLE_ORIG_VOLUME", 1.0, minimum=0.0), # 解说块之间的"原声块"音量:默认满音量(1.0),让精彩原声整段放出来,不被压低(用户要求解说成块、原声也成块)
73
61
  "duck_fade_seconds": env_float("DUCK_FADE_SECONDS", 0.3, minimum=0.0), # 解说块/原声块切换的淡入淡出(秒),略放宽到 0.3 让满音量↔压低的过渡更顺
74
62
  "duck_bridge_seconds": env_float("DUCK_BRIDGE_SECONDS", 1.5, minimum=0.0), # 仅把间隔小于此值的相邻解说窗口并成一段压低;超过则视为作者特意留的"原声块",原声放回满音量。默认 1.5s:解说块内部连续压低,块与块之间的留白放出满音量原声。该值只控制短间隔合并,不设定旁白/原声配额。调大→更连续铺底、原声块更少;调小→更碎
75
63
  "bgm_path": os.environ.get("BGM_PATH", "").strip(), # 背景音乐文件(可选),留空则不加 BGM
76
- "source_video": os.environ.get("SOURCE_VIDEO", "").strip(), # 剪辑模式下的原始视频(可选),用于时间线/剪映导出引用原片片段
77
- "source_video_explicit": False, # 仅 assemble.py --source-video 显式传入时为 True;环境变量 SOURCE_VIDEO 不算显式
64
+ "source_video": "", # 剪辑模式下的原始视频,只由 assemble.py --source-video 设置;用于时间线/剪映导出引用原片片段
78
65
  "export_jianying": env_bool("EXPORT_JIANYING", False), # 渲染后可选导出剪映草稿(默认关;与核心解耦)
79
66
  "jianying_draft_dir": os.environ.get("JIANYING_DRAFT_DIR", "").strip(), # 剪映草稿输出父目录(留空=work_dir)
80
67
  "jianying_bundle_media": env_bool("JIANYING_BUNDLE_MEDIA", True), # 默认开:macOS 剪映沙箱读不到外部路径,须把素材拷进草稿目录
81
68
  "bgm_volume": env_float("BGM_VOLUME", 0.18, minimum=0.0), # BGM 铺底音量
82
69
  "bgm_ducking_volume": env_float("BGM_DUCKING_VOLUME", 0.10, minimum=0.0), # 旁白时 BGM 压低到的音量
83
70
  "narration_speed": env_float("NARRATION_SPEED", 1.15, minimum=0.5), # 解说整体提速(atempo),默认回到可懂区间;长片可设 1.0
84
- "narration_cumulative_tempo_max": env_float("NARRATION_CUMULATIVE_TEMPO_MAX", 1.35, minimum=1.0), # TTS rate × 全局 atempo × 段内 atempo 的累计上限
85
- "narration_cumulative_tempo_hard_max": env_float("NARRATION_CUMULATIVE_TEMPO_HARD_MAX", 1.40, minimum=1.0), # QC/阻断硬上限
86
- "tts_segment_tempo_max": env_float("TTS_SEGMENT_TEMPO_MAX", 1.20, minimum=1.0), # 兼容旧段内 atempo 上限;实际会被累计预算收紧
71
+ "narration_cumulative_tempo_max": 1.35, # TTS rate × 全局 atempo × 段内 atempo 的累计上限
72
+ "narration_cumulative_tempo_hard_max": 1.40, # QC/阻断硬上限
73
+ "tts_segment_tempo_max": 1.20, # 兼容旧段内 atempo 上限;实际会被累计预算收紧
87
74
  "mask_source_subtitles": env_bool("MASK_SOURCE_SUBTITLES", False), # 遮挡原片烧录字幕;必须配合显式 SOURCE_SUBTITLE_MASK_POLICY
88
75
  "source_subtitle_mask_policy_declared": bool(os.environ.get("SOURCE_SUBTITLE_MASK_POLICY", "").strip()),
89
76
  "source_subtitle_mask_policy": (
@@ -96,16 +83,17 @@ CONFIG = {
96
83
  "subtitle_mask_padding": env_int("SUBTITLE_MASK_PADDING", 4, minimum=0),
97
84
  "subtitle_y_top": env_int("SUBTITLE_Y_TOP", -1, minimum=-1), # 自动旋转后的显示画布坐标;top/bot 同时有效时贴合原字幕带
98
85
  "subtitle_y_bot": env_int("SUBTITLE_Y_BOT", -1, minimum=-1),
99
- "narration_delay_seconds": env_float("NARRATION_DELAY_SECONDS", 0.0, minimum=0.0), # 默认严格采用 Agent 写入的 start;旧项目可显式恢复延迟
100
- "narration_tighten": env_bool("NARRATION_TIGHTEN", True), # 段落内把句子紧贴上一句实际收尾播放,句间间隔稳定≤tight_pause,杜绝"一句解说一段空白"的卡顿
101
- "narration_run_gap_seconds": env_float("NARRATION_RUN_GAP_SECONDS", 1.6, minimum=0.0), # 作者留白超过此值=新段落(让精彩原声透出);小于则视为同一连续段落
102
- "narration_tight_pause_seconds": env_float("NARRATION_TIGHT_PAUSE_SECONDS", 0.35, minimum=0.0), # 段落内句间固定间隔(秒)
103
- "narration_max_pull_seconds": env_float("NARRATION_MAX_PULL_SECONDS", 1.2, minimum=0.0), # 收紧时一句最多比作者标注提前的秒数(漂移上限,越小越贴画面)
104
- "narration_tail_pad_seconds": 0.1, # 解说尾部最少留白;短 slot 会自动压低 delay 避免截断
86
+ # 段落内把句子紧贴上一句实际收尾播放,句间间隔稳定≤tight_pause,杜绝"一句解说一段空白"的卡顿
87
+ "narration_run_gap_seconds": 1.6, # 作者留白超过此值=新段落(让精彩原声透出);小于则视为同一连续段落
88
+ "narration_tight_pause_seconds": 0.35, # 段落内句间固定间隔(秒)
89
+ "narration_max_pull_seconds": 1.2, # 收紧时一句最多比作者标注提前的秒数(漂移上限,越小越贴画面)
105
90
  "quiet_overlap_min_ratio": 0.8, # 解说段至少多少比例落在安静窗口内才标记为非对白重叠
106
91
  "speech_ducking_volume": env_float("SPEECH_DUCKING_VOLUME",
107
92
  _foreign_under_narration_volume if _foreign_source_audio else 0.2, minimum=0.0), # 解说与对白重叠时原声音量
108
93
  "burn_subtitles": env_bool("BURN_SUBTITLES", True), # 烧录解说字幕(默认开;遮挡原字幕后需自带字幕,否则字幕区空白)
94
+ # 显式要求烧录(设了 BURN_SUBTITLES 或传了 --burn-subtitles)时缺 libass 直接失败;只是默认开时降级为外挂 .srt
95
+ "burn_subtitles_explicit": "BURN_SUBTITLES" in os.environ,
96
+ "burn_subtitles_degraded": None, # 降级原因(如 ffmpeg_missing_libass),由 render_preflight 写入
109
97
  "subtitle_original_in_gaps": env_bool("SUBTITLE_ORIGINAL_IN_GAPS", True), # 原声留白处补烧原声台词字幕(来自 ASR)
110
98
  "force_video_reencode": env_bool("FORCE_VIDEO_REENCODE", False), # 组装时重编码视频,修复部分容器时间戳问题
111
99
  # 成片压制(仅在重编码时生效:烧字幕/遮罩/缩放/FORCE_VIDEO_REENCODE 任一触发重编码)。
@@ -115,9 +103,11 @@ CONFIG = {
115
103
  # 成片末端整体响度归一(默认混音偏轻,归一后更接近常见短视频响度;样片约 -11.9,默认取更安全的 -14)
116
104
  "final_loudnorm": env_bool("FINAL_LOUDNORM", True), # 组装末端做一次整体响度归一
117
105
  "target_lufs": env_float("TARGET_LUFS", -14.0), # 目标综合响度 (LUFS)
118
- "target_true_peak": env_float("TARGET_TRUE_PEAK", -1.0), # 目标真峰值 (dBTP)
119
- "target_lra": env_float("TARGET_LRA", 11.0), # 目标响度范围 (LU)
120
- "final_limiter_peak": env_float("FINAL_LIMITER_PEAK", 0.98, minimum=0.1), # loudnorm 后峰值保护 limiter
106
+ "target_true_peak": -1.0, # 目标真峰值 (dBTP)
107
+ "target_lra": 11.0, # 目标响度范围 (LU)
108
+ "final_limiter_peak": 0.98, # loudnorm 后峰值保护 limiter
109
+ # 峰值放不下线性增益时,先过真峰值限幅器再线性增益;限幅器最多削这么多 dB,超出部分才下调目标响度。0=不限幅
110
+ "loudness_limiter_max_db": env_float("LOUDNESS_LIMITER_MAX_DB", 6.0, minimum=0.0),
121
111
  "subtitle_font_name": os.environ.get("SUBTITLE_FONT_NAME", "Arial"),
122
112
  # 可选字体文件:ASS 烧录经 fontsdir 加载,画面文字经 drawtext fontfile 使用;family 名仍由 SUBTITLE_FONT_NAME 指定
123
113
  "subtitle_font_file": os.environ.get("SUBTITLE_FONT_FILE", "").strip(),
@@ -127,18 +117,14 @@ CONFIG = {
127
117
  "subtitle_outline": env_float("SUBTITLE_OUTLINE", 2.0, minimum=0.0),
128
118
  "subtitle_shadow": env_float("SUBTITLE_SHADOW", 1.0, minimum=0.0),
129
119
  "subtitle_margin_v": env_int("SUBTITLE_MARGIN_V", 48, minimum=0),
130
- "subtitle_margin_l": env_int("SUBTITLE_MARGIN_L", 40, minimum=0),
131
- "subtitle_margin_r": env_int("SUBTITLE_MARGIN_R", 40, minimum=0),
120
+ "subtitle_margin_l": 40,
121
+ "subtitle_margin_r": 40,
132
122
  "subtitle_alignment": env_int("SUBTITLE_ALIGNMENT", 2, minimum=1),
133
123
  "subtitle_max_chars": env_int("SUBTITLE_MAX_CHARS", 20, minimum=6),
134
124
  "subtitle_max_lines": env_int("SUBTITLE_MAX_LINES", 2, minimum=1),
135
125
  "subtitle_play_res_x": env_int("SUBTITLE_PLAY_RES_X", 1280, minimum=1),
136
126
  "subtitle_play_res_y": env_int("SUBTITLE_PLAY_RES_Y", 720, minimum=1),
137
127
  }
138
- if isinstance(_EXISTING_CONFIG_REF, dict):
139
- _EXISTING_CONFIG_REF.clear()
140
- _EXISTING_CONFIG_REF.update(CONFIG)
141
- CONFIG = _EXISTING_CONFIG_REF
142
128
 
143
129
  def narration_tempo_budget(tts_rate_offset=0.0):
144
130
  """Return the canonical tempo budget shared by voiceover and assemble.
@@ -0,0 +1,332 @@
1
+ """Final-mix loudness: two-pass linear loudnorm, behind a true-peak limiter when peaks block the gain.
2
+
3
+ ffmpeg's `loudnorm=linear=true` applies one constant gain only while
4
+ measured_TP + (I - measured_I) <= TP and measured_LRA <= LRA; otherwise it silently runs
5
+ dynamic (3-second AGC) normalisation, which pumps. Real mixes often carry a few transients
6
+ (gunshots, music hits) at 0 dBTP, so reaching TARGET_LUFS with one gain needs those peaks
7
+ limited first. The chain then is: constant pre-gain -> oversampled lookahead limiter (sample
8
+ peaks at 4x the rate approximate true peaks) -> second loudnorm pass in linear mode, measured
9
+ on the limited signal, that trims the last fraction of a LU without crossing TP.
10
+
11
+ Every stage aims at a pre-encode true-peak target (`peak_target`, default TP). The AAC
12
+ encode raises the delivered true peak over it, so `codec_peak.py` renders the first time
13
+ CODEC_PEAK_HEADROOM_DB under TP and lowers the target further when the delivered file
14
+ still measures over TP.
15
+ """
16
+
17
+ import functools
18
+ import json
19
+ import math
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ from pathlib import Path
24
+
25
+ from lib import CONFIG, filter_file_args, log, run_cmd
26
+
27
+ # loudnorm's LRA option range is 1-20 in older ffmpeg (1-50 in current releases).
28
+ _LOUDNORM_MAX_LRA = 20.0
29
+ # The limiter ceiling sits this far under the true-peak target, leaving the linear loudnorm
30
+ # stage room to make up the loudness the limiter shaved off (about 0.3 LU at 6 dB of
31
+ # reduction on a real film mix, 1.1 LU at 10 dB).
32
+ LIMITER_HEADROOM_DB = 1.0
33
+ # The limiter runs at 4x the 48 kHz mix rate so its sample peaks track the true peak.
34
+ _LIMITER_OVERSAMPLE_RATE = 192000
35
+ _LIMITER_ATTACK_MS = 5
36
+ _LIMITER_RELEASE_MS = 100
37
+ # AAC at 192 kb/s raised the true peak 0.3-1.4 dB over the loudnorm stage's output on real
38
+ # renders (most on heavily limited mixes); the first render aims this far under TP.
39
+ CODEC_PEAK_HEADROOM_DB = 0.5
40
+
41
+
42
+ def first_render_peak_target():
43
+ """The pre-encode true-peak target (dBTP) of the first render: TP minus codec headroom."""
44
+ return round(float(CONFIG["target_true_peak"]) - CODEC_PEAK_HEADROOM_DB, 2)
45
+
46
+
47
+ def _peak_target(peak_target):
48
+ return float(CONFIG["target_true_peak"]) if peak_target is None else float(peak_target)
49
+
50
+
51
+ def _limiter_filter():
52
+ return f"alimiter=limit={CONFIG['final_limiter_peak']:.2f}:level=false"
53
+
54
+
55
+ @functools.lru_cache(maxsize=None)
56
+ def _alimiter_compensates_latency():
57
+ """Whether this ffmpeg's alimiter has the `latency` option (asked once per process).
58
+
59
+ Without it the lookahead delays the audio by the attack time (5 ms).
60
+ """
61
+ if shutil.which("ffmpeg") is None:
62
+ return False
63
+ result = subprocess.run(["ffmpeg", "-hide_banner", "-h", "filter=alimiter"],
64
+ stdin=subprocess.DEVNULL, capture_output=True, text=True,
65
+ encoding="utf-8", errors="replace", timeout=20)
66
+ return "latency" in result.stdout
67
+
68
+
69
+ def _measured_values(measured):
70
+ """(I, TP, LRA, threshold) from a loudnorm measurement, or None when it is unusable."""
71
+ try:
72
+ values = [float(measured[key])
73
+ for key in ("input_i", "input_tp", "input_lra", "input_thresh")]
74
+ except (KeyError, TypeError, ValueError):
75
+ return None
76
+ measured_i, measured_tp, measured_lra, measured_thresh = values
77
+ # ffmpeg's own "not measured" sentinels (silence measures -inf / -70 / 0).
78
+ if (not all(math.isfinite(v) for v in values) or measured_tp == 99
79
+ or measured_thresh == -70 or measured_lra == 0 or measured_i == 0):
80
+ return None
81
+ return values
82
+
83
+
84
+ def _linear_loudnorm_targets(measured, peak_target=None):
85
+ """Second-pass targets that keep loudnorm linear, or None when no such targets exist.
86
+
87
+ The integrated target is lowered until the gained true peak fits under `peak_target`
88
+ (default TP; quieter than TARGET_LUFS instead of compressed), and the LRA target, which
89
+ linear mode never applies, is raised to the measured range.
90
+ """
91
+ values = _measured_values(measured)
92
+ if values is None:
93
+ return None
94
+ measured_i, measured_tp, measured_lra, _thresh = values
95
+ requested = float(CONFIG["target_lufs"])
96
+ true_peak = _peak_target(peak_target)
97
+ # 0.01 LU under the exact limit: ffmpeg compares the floating-point sum.
98
+ peak_limited = math.floor((true_peak - measured_tp + measured_i) * 100) / 100 - 0.01
99
+ integrated = round(min(requested, peak_limited), 2)
100
+ lra = max(float(CONFIG["target_lra"]), measured_lra)
101
+ if integrated < -70 or lra > _LOUDNORM_MAX_LRA:
102
+ return None
103
+ return {
104
+ "integrated": integrated,
105
+ "lra": lra,
106
+ "true_peak": true_peak,
107
+ "gain_capped_db": round(max(0.0, requested - integrated), 2),
108
+ }
109
+
110
+
111
+ def _peak_limiter_plan(measured, peak_target=None):
112
+ """Pre-gain and ceiling for the true-peak limiter, or None when it is not needed.
113
+
114
+ The ceiling sits LIMITER_HEADROOM_DB under `peak_target` (default TP). None when one
115
+ linear gain already reaches TARGET_LUFS under the target, when the measurement is
116
+ unusable or too wide for linear mode (a limiter does not narrow LRA), or when
117
+ LOUDNESS_LIMITER_MAX_DB is 0. Above that maximum the pre-gain stops where the limiter
118
+ would remove exactly the maximum; the rest of the gain is capped as before.
119
+ """
120
+ values = _measured_values(measured)
121
+ max_reduction = float(CONFIG["loudness_limiter_max_db"])
122
+ if values is None or max_reduction <= 0:
123
+ return None
124
+ measured_i, measured_tp, measured_lra, _thresh = values
125
+ if max(float(CONFIG["target_lra"]), measured_lra) > _LOUDNORM_MAX_LRA:
126
+ return None
127
+ gain = float(CONFIG["target_lufs"]) - measured_i
128
+ true_peak = _peak_target(peak_target)
129
+ if measured_tp + gain <= true_peak:
130
+ return None
131
+ ceiling = round(true_peak - LIMITER_HEADROOM_DB, 2)
132
+ required = measured_tp + gain - ceiling
133
+ pre_gain = min(gain, ceiling + max_reduction - measured_tp)
134
+ return {
135
+ "pre_gain_db": round(pre_gain, 2),
136
+ "ceiling_dbtp": ceiling,
137
+ "required_reduction_db": round(required, 2),
138
+ "max_reduction_db": max_reduction,
139
+ }
140
+
141
+
142
+ def _peak_limiter_chain(limiter):
143
+ """Constant pre-gain, then the oversampled lookahead limiter, back at the mix rate."""
144
+ limit = 10 ** (limiter["ceiling_dbtp"] / 20)
145
+ latency = ":latency=true" if _alimiter_compensates_latency() else ""
146
+ return (
147
+ f"volume={limiter['pre_gain_db']}dB,aresample={_LIMITER_OVERSAMPLE_RATE},"
148
+ f"alimiter=limit={limit:.6f}:attack={_LIMITER_ATTACK_MS}"
149
+ f":release={_LIMITER_RELEASE_MS}:level=false{latency},aresample=48000"
150
+ )
151
+
152
+
153
+ def _loudnorm_stage_measurement(measured, limiter):
154
+ """What the second loudnorm pass sees: the limited signal when a limiter runs."""
155
+ return limiter["measurement"] if limiter else measured
156
+
157
+
158
+ def _loudness_mode(measured=None, normalization_type=None, limiter=None):
159
+ """`limiter_only`, `equivalent` (single pass), or the two-pass mode ffmpeg really ran.
160
+
161
+ `normalization_type` is what the final render's loudnorm reported; without it the mode
162
+ is predicted from whether `_linear_loudnorm_targets` found linear targets.
163
+ """
164
+ if not CONFIG["final_loudnorm"]:
165
+ return "limiter_only"
166
+ if not measured:
167
+ return "equivalent"
168
+ if normalization_type is None:
169
+ stage = _loudnorm_stage_measurement(measured, limiter)
170
+ normalization_type = "linear" if _linear_loudnorm_targets(stage) else "dynamic"
171
+ if normalization_type != "linear":
172
+ return "two_pass_dynamic"
173
+ return "two_pass_linear_peak_limited" if limiter else "two_pass_linear"
174
+
175
+
176
+ def final_loudnorm_filter(measured=None, limiter=None, peak_target=None):
177
+ """Final-mix loudness normalization/limiter filter from CONFIG.
178
+
179
+ Ducking branches set only relative balance; this single stage owns the
180
+ absolute output loudness so the recap is not left too quiet. When `measured`
181
+ is supplied from a first loudnorm pass, ffmpeg runs the deterministic second
182
+ pass with the targets from `_linear_loudnorm_targets`, behind the `limiter` chain
183
+ when one was planned (its loudnorm then uses the limited signal's measurement);
184
+ without it we still force the same target and peak limiter as a documented
185
+ equivalent/fallback path. Every path aims at `peak_target` (default TP).
186
+ """
187
+ if not CONFIG["final_loudnorm"]:
188
+ return _limiter_filter()
189
+ stage = _loudnorm_stage_measurement(measured, limiter) if measured else None
190
+ targets = _linear_loudnorm_targets(stage, peak_target) if stage else None
191
+ integrated = targets["integrated"] if targets else CONFIG["target_lufs"]
192
+ lra = targets["lra"] if targets else CONFIG["target_lra"]
193
+ filt = (
194
+ f"loudnorm=I={integrated}"
195
+ f":TP={_peak_target(peak_target)}"
196
+ f":LRA={lra}"
197
+ f":linear=true"
198
+ )
199
+ if stage:
200
+ for src, dst in (
201
+ ("input_i", "measured_I"),
202
+ ("input_tp", "measured_TP"),
203
+ ("input_lra", "measured_LRA"),
204
+ ("input_thresh", "measured_thresh"),
205
+ ("target_offset", "offset"),
206
+ ):
207
+ if src in stage:
208
+ filt += f":{dst}={stage[src]}"
209
+ filt += ":print_format=summary"
210
+ if measured and limiter:
211
+ filt = f"{_peak_limiter_chain(limiter)},{filt}"
212
+ return f"{filt},{_limiter_filter()}"
213
+
214
+
215
+ def _parse_loudnorm_json(text):
216
+ """Extract ffmpeg loudnorm JSON from stderr/stdout."""
217
+ for match in reversed(list(re.finditer(r"\{[\s\S]*?\}", text))):
218
+ try:
219
+ data = json.loads(match.group(0))
220
+ except ValueError:
221
+ continue
222
+ if {"input_i", "input_tp", "input_lra", "input_thresh", "target_offset"} <= set(data):
223
+ return data
224
+ return None
225
+
226
+
227
+ def _loudnorm_summary_value(text, label):
228
+ """Last `<label>: <number>` in a loudnorm summary (None when absent, e.g. `-inf`)."""
229
+ matches = re.findall(rf"{label}:\s*([+-]?\d+(?:\.\d+)?)\b", text)
230
+ return float(matches[-1]) if matches else None
231
+
232
+
233
+ def loudnorm_final_pass(stderr, measured=None, limiter=None, peak_target=None):
234
+ """What the final render's loudness stage actually did, from its `print_format=summary`.
235
+
236
+ `normalization_type` is `linear` or `dynamic` (None when ffmpeg printed no summary);
237
+ `target` holds the second-pass targets when a first pass measured the mix, and its
238
+ `gain_capped_db` is how far the delivered target sits under TARGET_LUFS. `peak_limiter`
239
+ is the limiter that ran before it (None when none did). `output_true_peak` is the
240
+ stage's pre-encode output; the AAC file's own peak is `codec_peak`'s `delivered`.
241
+ """
242
+ text = stderr or ""
243
+ kinds = re.findall(r"Normalization Type:\s*(Linear|Dynamic)", text)
244
+ stage = _loudnorm_stage_measurement(measured, limiter) if measured else None
245
+ return {
246
+ "normalization_type": kinds[-1].lower() if kinds else None,
247
+ "target": _linear_loudnorm_targets(stage, peak_target) if stage else None,
248
+ "output_integrated": _loudnorm_summary_value(text, "Output Integrated"),
249
+ "output_true_peak": _loudnorm_summary_value(text, "Output True Peak"),
250
+ "peak_limiter": limiter if measured else None,
251
+ }
252
+
253
+
254
+ def _loudnorm_first_pass_filter():
255
+ return (
256
+ f"loudnorm=I={CONFIG['target_lufs']}"
257
+ f":TP={CONFIG['target_true_peak']}"
258
+ f":LRA={CONFIG['target_lra']}"
259
+ f":print_format=json"
260
+ )
261
+
262
+
263
+ def _measure_loudness(input_video, narration_wav, original_audio_input,
264
+ bgm_input, filter_complex, work_dir, pre_chain=None):
265
+ """Measure the exact mixed audio graph (after `pre_chain` when given) before the render.
266
+
267
+ Returns ffmpeg loudnorm JSON, or None when probing fails.
268
+ """
269
+ stage = f"{pre_chain}," if pre_chain else ""
270
+ probe_fc = f"{filter_complex};[aout]{stage}{_loudnorm_first_pass_filter()}[lnprobe]"
271
+ probe_script = Path(work_dir) / ".filter_complex_loudnorm_probe.txt"
272
+ probe_script.write_text(probe_fc, encoding="utf-8")
273
+ cmd = [
274
+ "ffmpeg", "-y",
275
+ "-i", str(input_video),
276
+ "-i", str(narration_wav),
277
+ *original_audio_input,
278
+ *bgm_input,
279
+ *filter_file_args("filter_complex", probe_script),
280
+ "-map", "[lnprobe]",
281
+ "-f", "null", "-",
282
+ ]
283
+ try:
284
+ result = run_cmd(cmd)
285
+ finally:
286
+ probe_script.unlink(missing_ok=True)
287
+ if result.returncode != 0:
288
+ log(f" ⚠️ loudnorm 测量失败: {result.stderr}")
289
+ return None
290
+ measured = _parse_loudnorm_json(result.stdout + "\n" + result.stderr)
291
+ if not measured:
292
+ log(" ⚠️ loudnorm 测量未返回 JSON")
293
+ return measured
294
+
295
+
296
+ def plan_final_loudness(input_video, narration_wav, original_audio_input,
297
+ bgm_input, filter_complex, work_dir, peak_target=None, measured=None):
298
+ """Measure the mix and pick the final loudness stage for `peak_target`: (measured, limiter).
299
+
300
+ `measured` is the first-pass loudnorm JSON (None: single-pass fallback); a caller that
301
+ already has it passes it in and only the limited signal is measured. `limiter` is
302
+ the planned true-peak limiter plus `measurement` (the limited signal's loudnorm JSON)
303
+ and `reduction_db` (measured true peak before minus after it), or None when one linear
304
+ gain fits or the limited signal could not be measured (the gain is then capped).
305
+ """
306
+ if not CONFIG["final_loudnorm"]:
307
+ return None, None
308
+ args = (input_video, narration_wav, original_audio_input, bgm_input, filter_complex,
309
+ work_dir)
310
+ measured = measured or _measure_loudness(*args)
311
+ if not measured:
312
+ log(" ⚠️ loudnorm 首遍测量不可用,降级到单遍目标滤镜+limiter")
313
+ return None, None
314
+ limiter = _peak_limiter_plan(measured, peak_target)
315
+ if limiter:
316
+ limited = _measure_loudness(*args, pre_chain=_peak_limiter_chain(limiter))
317
+ if limited and _linear_loudnorm_targets(limited, peak_target):
318
+ reduction = (float(measured["input_tp"]) + limiter["pre_gain_db"]
319
+ - float(limited["input_tp"]))
320
+ limiter = {**limiter, "reduction_db": round(reduction, 2), "measurement": limited}
321
+ log(f" 成片真峰值限幅:先增益 {limiter['pre_gain_db']} dB,峰值压到 "
322
+ f"{limiter['ceiling_dbtp']} dBTP(削去 {limiter['reduction_db']} dB),"
323
+ "再线性增益到目标响度")
324
+ else:
325
+ log(" ⚠️ 限幅后的混音无法测量或无法线性归一,改为下调目标响度")
326
+ limiter = None
327
+ targets = _linear_loudnorm_targets(_loudnorm_stage_measurement(measured, limiter),
328
+ peak_target)
329
+ if targets and targets["gain_capped_db"] > 0:
330
+ log(f" 成片目标响度从 {CONFIG['target_lufs']} 降到 {targets['integrated']} LUFS"
331
+ f"(限幅上限 {CONFIG['loudness_limiter_max_db']} dB),保持线性增益")
332
+ return measured, limiter