@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
@@ -1,10 +1,10 @@
1
- """Render-affecting settings payload recorded in the manifest and compared by resume logic."""
1
+ """Render-affecting settings payload recorded in assembly_manifest.json for audit."""
2
2
 
3
3
  from pathlib import Path
4
4
 
5
5
  from artifacts import _artifact_identity
6
6
  from assemble_constants import VISUAL_OVERLAYS
7
- from audio_mix import _loudness_mode, final_loudnorm_filter
7
+ from loudness import _loudness_mode, final_loudnorm_filter
8
8
  from lib import CONFIG
9
9
  from source_subtitles import _has_user_subtitles, _source_subtitle_mask_policy
10
10
  from subtitles.core import _subtitle_style_config
@@ -14,10 +14,10 @@ from packaging import packaging_settings
14
14
 
15
15
 
16
16
  def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_stream_index=0):
17
- """Settings that affect the rendered video, as a plain nested dict compared with ``==`` by
18
- pipeline resume logic. When work_dir is given, a user_subtitles presence flag and the
19
- ``{size, mtime_ns}`` identity of the overlay/subtitle-track inputs are included so dropping
20
- in or rewriting one of those files rebuilds the cached subtitles."""
17
+ """Settings that affect the rendered video, as a plain nested dict. assembly_manifest.json
18
+ records it for audit; nothing compares it to decide a rebuild. When work_dir is given, a
19
+ user_subtitles presence flag and the ``{size, mtime_ns}`` identity of the overlay and
20
+ subtitle-track inputs are included."""
21
21
  burn_subtitles = CONFIG["burn_subtitles"]
22
22
  mask_policy = _source_subtitle_mask_policy(work_dir)
23
23
  mask_source_subtitles = mask_policy["active"]
@@ -31,6 +31,7 @@ def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_st
31
31
  settings = {
32
32
  "user_subtitles": _has_user_subtitles(work_dir),
33
33
  "burn_subtitles": burn_subtitles,
34
+ "subtitle_burn_degraded": CONFIG["burn_subtitles_degraded"],
34
35
  "force_video_reencode": CONFIG["force_video_reencode"],
35
36
  "encode": {
36
37
  "output_crf": CONFIG["output_crf"],
@@ -82,8 +83,6 @@ def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_st
82
83
  narration_binding.get("tempo_policy") if narration_binding else None
83
84
  )
84
85
  settings["narration_timing"] = {
85
- "delay_seconds": 0.0 if explicit_mix else CONFIG["narration_delay_seconds"],
86
- "tail_pad_seconds": 0.0 if explicit_mix else CONFIG["narration_tail_pad_seconds"],
87
86
  "fade_ms": 0 if explicit_mix else CONFIG["fade_ms"],
88
87
  "narration_speed": (
89
88
  1.0 if explicit_mix else
@@ -107,7 +106,6 @@ def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_st
107
106
  if audio_mode in {"narration", "source-mix"} and not explicit_mix:
108
107
  # adopted-packet-copy never decodes or mixes, so mix settings cannot change it.
109
108
  settings["audio_mix"] = {
110
- "ducking_mode": CONFIG["ducking_mode"],
111
109
  "duck_fade_seconds": CONFIG["duck_fade_seconds"],
112
110
  "duck_bridge_seconds": CONFIG["duck_bridge_seconds"],
113
111
  "ducking_narr_weight": CONFIG["ducking_narr_weight"],
@@ -115,14 +113,9 @@ def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_st
115
113
  "idle_orig_volume": CONFIG["idle_orig_volume"],
116
114
  "speech_ducking_volume": CONFIG["speech_ducking_volume"],
117
115
  "zone_ducking_volume": CONFIG["zone_ducking_volume"],
118
- "ducking_threshold": CONFIG["ducking_threshold"],
119
- "ducking_ratio": CONFIG["ducking_ratio"],
120
- "ducking_attack": CONFIG["ducking_attack"],
121
- "ducking_release": CONFIG["ducking_release"],
122
- "ducking_level_sc": CONFIG["ducking_level_sc"],
123
- "ducking_makeup": CONFIG["ducking_makeup"],
124
116
  "final_loudnorm": final_loudnorm_filter(),
125
117
  "loudness_mode": _loudness_mode(),
118
+ "loudness_limiter_max_db": CONFIG["loudness_limiter_max_db"],
126
119
  "bgm_path": CONFIG["bgm_path"],
127
120
  "bgm_volume": CONFIG["bgm_volume"],
128
121
  "bgm_ducking_volume": CONFIG["bgm_ducking_volume"],
@@ -1,6 +1,5 @@
1
- """Loudness, source handoffs, ducking envelopes, and audio mix graphs."""
1
+ """Source handoffs, ducking envelopes, and audio mix graphs."""
2
2
 
3
- import json
4
3
  import re
5
4
  from pathlib import Path
6
5
 
@@ -10,104 +9,7 @@ from audio_automation import (
10
9
  ducking_expression,
11
10
  release_ducking_expression,
12
11
  )
13
- from lib import CONFIG, filter_file_args, log, run_cmd
14
-
15
- def _limiter_filter():
16
- return f"alimiter=limit={CONFIG['final_limiter_peak']:.2f}:level=false"
17
-
18
-
19
- def _loudness_mode(measured=None):
20
- if not CONFIG["final_loudnorm"]:
21
- return "limiter_only"
22
- return "two_pass_linear" if measured else "equivalent"
23
-
24
-
25
- def final_loudnorm_filter(measured=None):
26
- """Final-mix loudness normalization/limiter filter from CONFIG.
27
-
28
- Ducking branches set only relative balance; this single stage owns the
29
- absolute output loudness so the recap is not left too quiet. When `measured`
30
- is supplied from a first loudnorm pass, ffmpeg runs the deterministic second
31
- pass; without it we still force the same target and peak limiter as a
32
- documented equivalent/fallback path.
33
- """
34
- if not CONFIG["final_loudnorm"]:
35
- return _limiter_filter()
36
- filt = (
37
- f"loudnorm=I={CONFIG['target_lufs']}"
38
- f":TP={CONFIG['target_true_peak']}"
39
- f":LRA={CONFIG['target_lra']}"
40
- f":linear=true"
41
- )
42
- if measured:
43
- for src, dst in (
44
- ("input_i", "measured_I"),
45
- ("input_tp", "measured_TP"),
46
- ("input_lra", "measured_LRA"),
47
- ("input_thresh", "measured_thresh"),
48
- ("target_offset", "offset"),
49
- ):
50
- if src in measured:
51
- filt += f":{dst}={measured[src]}"
52
- filt += ":print_format=summary"
53
- return f"{filt},{_limiter_filter()}"
54
-
55
-
56
- def _parse_loudnorm_json(text):
57
- """Extract ffmpeg loudnorm JSON from stderr/stdout."""
58
- for match in reversed(list(re.finditer(r"\{[\s\S]*?\}", text))):
59
- try:
60
- data = json.loads(match.group(0))
61
- except ValueError:
62
- continue
63
- if {"input_i", "input_tp", "input_lra", "input_thresh", "target_offset"} <= set(data):
64
- return data
65
- return None
66
-
67
-
68
- def _loudnorm_first_pass_filter():
69
- return (
70
- f"loudnorm=I={CONFIG['target_lufs']}"
71
- f":TP={CONFIG['target_true_peak']}"
72
- f":LRA={CONFIG['target_lra']}"
73
- f":print_format=json"
74
- )
75
-
76
-
77
- def _run_loudnorm_first_pass(input_video, narration_wav, original_audio_input,
78
- bgm_input, filter_complex, work_dir):
79
- """Measure the exact mixed audio graph before final render.
80
-
81
- Returns ffmpeg loudnorm JSON, or None when probing fails. The caller then
82
- falls back to the documented equivalent single-pass target+limiter filter.
83
- """
84
- if not CONFIG["final_loudnorm"]:
85
- return None
86
- probe_fc = f"{filter_complex};[aout]{_loudnorm_first_pass_filter()}[lnprobe]"
87
- probe_script = Path(work_dir) / ".filter_complex_loudnorm_probe.txt"
88
- probe_script.write_text(probe_fc, encoding="utf-8")
89
- cmd = [
90
- "ffmpeg", "-y",
91
- "-i", str(input_video),
92
- "-i", str(narration_wav),
93
- *original_audio_input,
94
- *bgm_input,
95
- *filter_file_args("filter_complex", probe_script),
96
- "-map", "[lnprobe]",
97
- "-f", "null", "-",
98
- ]
99
- try:
100
- result = run_cmd(cmd)
101
- finally:
102
- probe_script.unlink(missing_ok=True)
103
- if result.returncode != 0:
104
- log(f" ⚠️ loudnorm 首遍测量失败,降级到目标滤镜+limiter: {result.stderr}")
105
- return None
106
- measured = _parse_loudnorm_json(result.stdout + "\n" + result.stderr)
107
- if not measured:
108
- log(" ⚠️ loudnorm 首遍未返回 JSON,降级到目标滤镜+limiter")
109
- return None
110
- return measured
12
+ from lib import CONFIG, log
111
13
 
112
14
 
113
15
  def _seg_place_window(seg):
@@ -115,8 +17,14 @@ def _seg_place_window(seg):
115
17
  return seg["actual_place_start"], seg["actual_place_end"]
116
18
 
117
19
 
20
+ # After narration ends, original audio may stay ducked this long waiting for a sentence-end
21
+ # anchor. Beyond it the duck releases at the narration end: coarse-ASR anchors are estimates,
22
+ # and holding a duck for tens of seconds buries dialogue the viewer must hear.
23
+ SOURCE_HANDOFF_MAX_HOLD_SECONDS = 3.0
24
+
25
+
118
26
  def _load_sentence_handoff_anchors(work_dir):
119
- """Load high/medium sentence anchors and their measured pause windows."""
27
+ """Load usable sentence anchors (`boundary_use` verified/unverified) and their pauses."""
120
28
  work_dir = Path(work_dir)
121
29
  cut_mode = (work_dir / "edited_source.mp4").exists() or (
122
30
  work_dir / "clip_plan_validated.json"
@@ -139,18 +47,33 @@ def _load_sentence_handoff_anchors(work_dir):
139
47
  payload = {**payload, "require_measured": True}
140
48
  anchors = {}
141
49
  for item in payload["sentence_anchors"]:
142
- if item["confidence"] not in {"high", "medium"}:
50
+ # Schema-1 anchors (no `boundary_use`) came from the old coarse estimator: high/medium
51
+ # labels there are usable but unverified.
52
+ use = item.get("boundary_use") or (
53
+ "unverified" if item["confidence"] in {"high", "medium"} else "none"
54
+ )
55
+ if use == "none":
143
56
  continue
144
57
  when = float(item["time"])
145
58
  pause_start = float(item.get("pause_start", when - 0.12))
146
59
  row = {
147
60
  "time": round(when, 4),
148
61
  "pause_start": round(max(0.0, min(pause_start, when)), 4),
62
+ "verified": use == "verified",
149
63
  }
150
- anchors[(row["time"], row["pause_start"])] = row
64
+ key = (row["time"], row["pause_start"])
65
+ row["verified"] = row["verified"] or anchors.get(key, {}).get("verified", False)
66
+ anchors[key] = row
151
67
  return sorted(anchors.values(), key=lambda row: row["time"]), artifact, payload
152
68
 
153
69
 
70
+ def _prefer_verified(matches):
71
+ """First verified anchor among time-ordered matches, else the first match, else None."""
72
+ return next((anchor for anchor in matches if anchor["verified"]), None) or (
73
+ matches[0] if matches else None
74
+ )
75
+
76
+
154
77
  def _timed_rows(rows):
155
78
  return [{"start": float(row["start"]), "end": float(row["end"])} for row in rows]
156
79
 
@@ -163,17 +86,86 @@ def _asr_segments(work_dir):
163
86
  return _load_work_json(work_dir, "asr_result.json") or []
164
87
 
165
88
 
89
+ # Interjections and common ASR artifacts on screams/music. A window whose text is only these
90
+ # is not dialogue at a clip edge or narration entry; real short lines such as "救我!" still are.
91
+ # Same copy in video-cut, video-script and video-assemble (parity-tested by function).
92
+ _NON_DIALOGUE_TOKENS = frozenset(
93
+ "啊 嗯 哼 哦 呃 唉 嘿 呦 哈 呀 hi yeah ok okay oh uh ah hmm".split()
94
+ )
95
+ _NON_DIALOGUE_CJK = frozenset("啊嗯哼哦呃唉嘿呦哈呀")
96
+ # Lines cross 15s ASR window edges (a line may run 13.2–15.4 while its window ends at 15.0),
97
+ # so an interjection-only window next to real dialogue keeps this much of its shared edge.
98
+ _INTERJECTION_GUARD_SECONDS = 1.0
99
+
100
+
101
+ def _interjection_only(text):
102
+ tokens = [token for token in re.split(r"[\W_]+", text.lower()) if token]
103
+ # Punctuation-only rows ("……", "?") are often ASR for unintelligible speech: keep them.
104
+ return bool(tokens) and all(
105
+ token in _NON_DIALOGUE_TOKENS or set(token) <= _NON_DIALOGUE_CJK for token in tokens
106
+ )
107
+
108
+
109
+ def _dialogue_speech_spans(rows):
110
+ """Merged dialogue spans from timed ASR rows.
111
+
112
+ A row holding only interjections ("啊!", "Hi.") is not dialogue, except a
113
+ `_INTERJECTION_GUARD_SECONDS` guard on an edge it shares with a dialogue row. A row
114
+ whose text is empty or whitespace (ASR heard no words) is timing-only evidence: it is
115
+ skipped here and guards nothing. A row with no `text` field is measured timing whose
116
+ words are unknown and counts as dialogue.
117
+ """
118
+ rows = sorted(
119
+ (
120
+ {
121
+ "start": row["start"],
122
+ "end": row["end"],
123
+ "dialogue": not _interjection_only(row.get("text", "")),
124
+ }
125
+ for row in rows
126
+ if "text" not in row or row["text"].strip()
127
+ ),
128
+ key=lambda row: (row["start"], row["end"]),
129
+ )
130
+ spans = []
131
+ for idx, row in enumerate(rows):
132
+ if row["dialogue"]:
133
+ spans.append({"start": row["start"], "end": row["end"]})
134
+ continue
135
+ before = rows[idx - 1] if idx > 0 else None
136
+ after = rows[idx + 1] if idx + 1 < len(rows) else None
137
+ if before and before["dialogue"] and row["start"] - before["end"] <= 0.05:
138
+ end = min(row["end"], row["start"] + _INTERJECTION_GUARD_SECONDS)
139
+ spans.append({"start": row["start"], "end": end})
140
+ if after and after["dialogue"] and after["start"] - row["end"] <= 0.05:
141
+ start = max(row["start"], row["end"] - _INTERJECTION_GUARD_SECONDS)
142
+ spans.append({"start": start, "end": row["end"]})
143
+ spans.sort(key=lambda row: (row["start"], row["end"]))
144
+ merged = []
145
+ for span in spans:
146
+ if merged and span["start"] <= merged[-1]["end"] + 0.05:
147
+ merged[-1]["end"] = max(merged[-1]["end"], span["end"])
148
+ else:
149
+ merged.append(span)
150
+ return merged
151
+
152
+
166
153
  def _handoff_speech_evidence(work_dir, payload):
167
- speech = _timed_rows(payload.get("speech_spans", []))
154
+ """(speech, quiet, dialogue) rows; `dialogue` drops interjection-only windows and
155
+ decides only whether a narration entry interrupts source speech.
156
+
157
+ ASR fallback rows with empty text are windows where nothing was recognized, not
158
+ speech (video-cut and video-script drop them the same way). Output-clock
159
+ `speech_spans` carry no `text` and stay timing-only dialogue evidence.
160
+ """
161
+ rows = payload.get("speech_spans", [])
168
162
  quiet = _timed_rows(payload.get("quiet_windows", []))
169
- if payload.get("require_measured"):
170
- return speech, quiet
171
- if not speech:
172
- speech = _timed_rows(_asr_segments(work_dir))
173
- if not quiet:
174
- silence = _load_work_json(work_dir, "silence_periods.json") or []
175
- quiet = _timed_rows(row for row in silence if not row["has_speech"])
176
- return speech, quiet
163
+ if not payload.get("require_measured"):
164
+ rows = rows or [row for row in _asr_segments(work_dir) if row["text"].strip()]
165
+ if not quiet:
166
+ silence = _load_work_json(work_dir, "silence_periods.json") or []
167
+ quiet = _timed_rows(row for row in silence if not row["has_speech"])
168
+ return _timed_rows(rows), quiet, _timed_rows(_dialogue_speech_spans(rows))
177
169
 
178
170
 
179
171
  def _merged_handoff_intervals(start, end, rows):
@@ -221,17 +213,65 @@ def _measured_speech_owned(
221
213
 
222
214
 
223
215
  def _entry_speech_owned(
224
- start, speech, quiet, anchors, authored, require_measured=False, tolerance=0.05
216
+ start, speech, dialogue, quiet, anchors, authored, require_measured=False, tolerance=0.05
225
217
  ):
226
218
  if any(row["start"] - tolerance <= start <= row["end"] + tolerance for row in quiet):
227
219
  return False
228
- if any(row["start"] - tolerance <= start < row["end"] - tolerance for row in speech):
220
+ if any(row["start"] - tolerance <= start < row["end"] - tolerance for row in dialogue):
229
221
  return True
230
222
  if speech:
231
223
  return False
232
224
  return True if anchors or require_measured else bool(authored)
233
225
 
234
226
 
227
+ def _unowned_entry_status(start, speech, quiet, tolerance=0.05):
228
+ """Entry status when a narration entry does not interrupt source dialogue.
229
+
230
+ `non_dialogue_source` when the entry lands inside measured speech that holds only
231
+ interjections (a scream, "Hi."); `quiet_source` for measured quiet or no speech.
232
+ """
233
+ if any(row["start"] - tolerance <= start <= row["end"] + tolerance for row in quiet):
234
+ return "quiet_source"
235
+ if any(row["start"] <= start < row["end"] for row in speech):
236
+ return "non_dialogue_source"
237
+ return "quiet_source"
238
+
239
+
240
+ def _dialogue_free_pull_start(candidate, written_start, dialogue, quiet, tolerance=0.05):
241
+ """Earliest start in [candidate, written_start] whose pulled stretch holds no dialogue.
242
+
243
+ Paragraph tightening plays a block up to `narration_max_pull_seconds` before its
244
+ written `start`, after narration lint checked only that written entry. Measured quiet
245
+ inside a dialogue span is not dialogue. When dialogue remains in the stretch, the
246
+ block starts no earlier than the end of the last such piece.
247
+ """
248
+ safe = candidate
249
+ quiet_intervals = _merged_handoff_intervals(candidate, written_start, quiet)
250
+ for left, right in _merged_handoff_intervals(candidate, written_start, dialogue):
251
+ pieces = [(left, right)]
252
+ for quiet_left, quiet_right in quiet_intervals:
253
+ pieces = [
254
+ part
255
+ for piece_left, piece_right in pieces
256
+ for part in (
257
+ (piece_left, min(piece_right, quiet_left)),
258
+ (max(piece_left, quiet_right), piece_right),
259
+ )
260
+ if part[1] - part[0] > 0
261
+ ]
262
+ for piece_left, piece_right in pieces:
263
+ if piece_right - piece_left > tolerance:
264
+ safe = max(safe, piece_right)
265
+ return safe
266
+
267
+
268
+ def _paragraph_pull_evidence(work_dir):
269
+ """(dialogue, quiet) rows on the narration clock for `_dialogue_free_pull_start`."""
270
+ _anchors, _artifact, payload = _load_sentence_handoff_anchors(work_dir)
271
+ _speech, quiet, dialogue = _handoff_speech_evidence(work_dir, payload)
272
+ return dialogue, quiet
273
+
274
+
235
275
  def _work_has_source_speech(work_dir, speech_spans, require_measured):
236
276
  if speech_spans or require_measured:
237
277
  return True
@@ -239,15 +279,20 @@ def _work_has_source_speech(work_dir, speech_spans, require_measured):
239
279
 
240
280
 
241
281
  def _apply_source_sentence_handoffs(tts_segments, work_dir, video_duration):
242
- """Keep source audio ducked until a safe sentence boundary after narration.
282
+ """Keep source audio ducked until a nearby sentence boundary after narration.
243
283
 
244
284
  This does not move or trim narration. It only extends the ORIGINAL-audio duck
245
- envelope so returning the source track cannot reveal the middle of a sentence.
285
+ envelope so returning the source track does not reveal the middle of a sentence —
286
+ for at most SOURCE_HANDOFF_MAX_HOLD_SECONDS. With no anchor in that window the duck
287
+ releases at the narration end (`bounded_release`) instead of burying source dialogue
288
+ until a distant anchor.
246
289
  """
247
290
  fade = CONFIG["duck_fade_seconds"]
248
291
  bridge = CONFIG["duck_bridge_seconds"]
249
292
  anchors, artifact, evidence_payload = _load_sentence_handoff_anchors(work_dir)
250
- speech_spans, quiet_windows = _handoff_speech_evidence(work_dir, evidence_payload)
293
+ speech_spans, quiet_windows, dialogue_spans = _handoff_speech_evidence(
294
+ work_dir, evidence_payload
295
+ )
251
296
  require_measured = evidence_payload.get("require_measured", False)
252
297
  placed = []
253
298
  for seg in tts_segments:
@@ -287,44 +332,66 @@ def _apply_source_sentence_handoffs(tts_segments, work_dir, video_duration):
287
332
  entry_owned = _entry_speech_owned(
288
333
  run["start"],
289
334
  speech_spans,
335
+ dialogue_spans,
290
336
  quiet_windows,
291
337
  anchors,
292
338
  first["overlaps_speech"],
293
339
  require_measured=require_measured,
294
340
  )
295
341
  speech_owned = entry_owned or any(ownership)
342
+ if not entry_owned:
343
+ first["source_entry_status"] = _unowned_entry_status(
344
+ run["start"], speech_spans, quiet_windows
345
+ )
296
346
  if not speech_owned:
297
347
  report.append({"start": run["start"], "end": run["end"], "status": "quiet_source"})
298
348
  continue
299
349
  last = run["segments"][-1]
300
- start_safe = run["start"] <= 0.25 or any(
301
- anchor["pause_start"] - 0.05 <= run["start"] <= anchor["time"] + 0.08
350
+ entry_anchor = _prefer_verified([
351
+ anchor
302
352
  for anchor in anchors
303
- )
353
+ if anchor["pause_start"] - 0.05 <= run["start"] <= anchor["time"] + 0.08
354
+ ])
355
+ start_safe = run["start"] <= 0.25 or entry_anchor is not None
304
356
  if entry_owned and anchors and not start_safe:
305
357
  first["source_handoff_blocking"] = True
306
358
  first["source_entry_status"] = "unsafe_entry"
307
- elif not entry_owned:
308
- first["source_entry_status"] = "quiet_source"
309
- else:
310
- first["source_entry_status"] = "sentence_boundary" if anchors else "unverified"
311
-
312
- restore_anchor = next(
313
- (anchor for anchor in anchors if anchor["time"] >= run["end"] - 0.01),
314
- None,
315
- )
359
+ elif entry_owned and not anchors:
360
+ first["source_entry_status"] = "unverified"
361
+ elif entry_owned and entry_anchor is not None and not entry_anchor["verified"]:
362
+ first["source_entry_status"] = "sentence_boundary_unverified"
363
+ elif entry_owned:
364
+ first["source_entry_status"] = "sentence_boundary"
365
+
366
+ max_hold = SOURCE_HANDOFF_MAX_HOLD_SECONDS
367
+ restore_anchor = _prefer_verified([
368
+ anchor
369
+ for anchor in anchors
370
+ if run["end"] - 0.01 <= anchor["time"] <= run["end"] + max_hold
371
+ ])
316
372
  if restore_anchor is not None:
317
373
  # Hold the source low through its last spoken sample, then fit the release
318
374
  # entirely inside the measured pause. Never begin the ramp `fade` seconds
319
375
  # before the anchor when that would expose the final source phoneme.
320
376
  duck_end = max(run["end"], restore_anchor["pause_start"])
321
377
  restore_at = max(duck_end, restore_anchor["time"])
322
- status = "sentence_boundary"
323
- elif anchors:
324
- # No later complete source sentence: never expose a fragment at the tail.
378
+ status = (
379
+ "sentence_boundary"
380
+ if restore_anchor["verified"]
381
+ else "sentence_boundary_unverified"
382
+ )
383
+ elif anchors and float(video_duration) - run["end"] <= max_hold:
384
+ # No complete source sentence before a near tail: never expose a fragment there.
325
385
  restore_at = float(video_duration)
326
386
  duck_end = float(video_duration)
327
387
  status = "held_to_timeline_end"
388
+ elif anchors:
389
+ # No anchor within the bound: release at the narration end, same values as
390
+ # `no_source_speech`. Up to the ramp of a source tail is audible; a long duck
391
+ # would instead bury dialogue that may matter more.
392
+ restore_at = run["end"] + fade
393
+ duck_end = run["end"]
394
+ status = "bounded_release"
328
395
  elif source_has_speech:
329
396
  first["source_handoff_blocking"] = True
330
397
  first["source_entry_status"] = "anchors_unavailable"
@@ -343,6 +410,7 @@ def _apply_source_sentence_handoffs(tts_segments, work_dir, video_duration):
343
410
  "start": round(run["start"], 4),
344
411
  "end": round(run["end"], 4),
345
412
  "restore_at": last["source_restore_at"],
413
+ "hold_seconds": round(max(0.0, last["source_restore_at"] - run["end"]), 4),
346
414
  "status": status,
347
415
  "anchor_artifact": artifact,
348
416
  })
@@ -404,17 +472,8 @@ def _build_audio_filter_complex(
404
472
  the gaps so the recap never drops to dead air between sentences.
405
473
  - bgm (input [2:a], optional): a looped music bed, gently ducked under narration.
406
474
  - narration (input [1:a]): the TTS, boosted and laid on top.
407
- CONFIG["ducking_mode"] (default "fixed") selects the original-track strategy:
408
- fixed = the gap-fill envelope above; sidechaincompress = auto-duck keyed off the
409
- narration; none = no ducking. Placement comes from actual_place_start/end.
475
+ Placement comes from actual_place_start/end.
410
476
  """
411
- ducking_mode = CONFIG["ducking_mode"]
412
- if ducking_mode == "sidechaincompress" and any(
413
- "source_duck_end" in seg and seg["source_duck_end"] > seg["actual_place_end"] + 1e-6
414
- for seg in tts_segments
415
- ):
416
- log("sidechaincompress 无法保持句末交接窗口,已回退 fixed ducking")
417
- ducking_mode = "fixed"
418
477
  narr_vol = CONFIG["ducking_narr_weight"]
419
478
  fade = CONFIG["duck_fade_seconds"]
420
479
  bridge = CONFIG["duck_bridge_seconds"]
@@ -431,25 +490,7 @@ def _build_audio_filter_complex(
431
490
  else:
432
491
  bgm_chain = f"{bgm_in}volume={base},aresample=48000[bgm];"
433
492
 
434
- if ducking_mode == "sidechaincompress":
435
- # The narration keys the compressor; split it so it can also be mixed in.
436
- head = (
437
- f"{original_in}aresample=48000[o0];"
438
- "[1:a]aresample=48000,asplit=2[sckey][scnarr];"
439
- f"[o0][sckey]sidechaincompress="
440
- f"threshold={CONFIG['ducking_threshold']}:ratio={CONFIG['ducking_ratio']}"
441
- f":attack={CONFIG['ducking_attack']}:release={CONFIG['ducking_release']}"
442
- f":knee=2.5:makeup={CONFIG['ducking_makeup']}:level_sc={CONFIG['ducking_level_sc']}[orig];"
443
- )
444
- narr = f"[scnarr]volume={narr_vol}[narr];"
445
- if bgm_chain:
446
- return head + bgm_chain + narr + "[orig][bgm][narr]amix=inputs=3:duration=first:dropout_transition=0:normalize=0[aout]"
447
- return head + narr + "[orig][narr]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
448
-
449
- if ducking_mode == "none":
450
- return f"{original_in}aresample=48000[orig];" + _amix_tail(narr_vol, bgm_chain)
451
-
452
- # fixed (default): gap-fill ducking envelope on the original track.
493
+ # Gap-fill ducking envelope on the original track.
453
494
  idle = CONFIG["idle_orig_volume"]
454
495
  speech_vol = CONFIG["speech_ducking_volume"]
455
496
  quiet_vol = CONFIG["zone_ducking_volume"]
@@ -0,0 +1,112 @@
1
+ """Delivered true peak: measure the encoded file and correct an AAC overshoot over TP.
2
+
3
+ The loudness stage keeps the PCM mix under its true-peak target, but the lossy AAC encode
4
+ adds overshoot (0.3-1.4 dB at 192 kb/s on real renders, most on heavily limited mixes), so
5
+ the delivered file can sit over TP while the stage reported -1.1 dBTP. The first render
6
+ aims `loudness.CODEC_PEAK_HEADROOM_DB` under TP; `deliver_under_true_peak` then decodes
7
+ the delivered audio, and while its true peak is still over TP lowers the pre-encode target
8
+ by the measured overshoot plus CODEC_PEAK_MARGIN_DB and re-encodes only the audio (the
9
+ video stream is copied from the render), at most CODEC_PEAK_MAX_CORRECTIONS times.
10
+ """
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import assemble_constants as constants
16
+ import lib
17
+ from lib import CONFIG, filter_file_args, log
18
+ from loudness import (
19
+ _loudnorm_first_pass_filter, _loudnorm_summary_value, _parse_loudnorm_json,
20
+ )
21
+
22
+ CODEC_PEAK_MARGIN_DB = 0.1
23
+ CODEC_PEAK_MAX_CORRECTIONS = 2
24
+
25
+
26
+ def delivered_loudness(path):
27
+ """Integrated loudness and true peak of the file's first audio stream, or None.
28
+
29
+ The true peak is the higher of loudnorm's and ebur128's readings of the decoded audio:
30
+ the two oversampling detectors disagree by up to about 1 dB on dense transients.
31
+ """
32
+ result = lib.run_cmd([
33
+ "ffmpeg", "-hide_banner", "-nostats", "-i", str(path), "-map", "0:a:0",
34
+ "-af", f"ebur128=peak=true:framelog=quiet,{_loudnorm_first_pass_filter()}",
35
+ "-f", "null", "-",
36
+ ])
37
+ text = f"{result.stdout or ''}\n{result.stderr or ''}"
38
+ measured = _parse_loudnorm_json(text) if result.returncode == 0 else None
39
+ ebur128_peak = _loudnorm_summary_value(text.split("Summary:")[-1], "Peak")
40
+ try:
41
+ integrated, true_peak = float(measured["input_i"]), float(measured["input_tp"])
42
+ except (KeyError, TypeError, ValueError):
43
+ log(f" ⚠️ 成片真峰值测量失败: {path}")
44
+ return None
45
+ if ebur128_peak is not None:
46
+ true_peak = max(true_peak, ebur128_peak)
47
+ return {"integrated": integrated, "true_peak": true_peak}
48
+
49
+
50
+ def deliver_under_true_peak(output_path, peak_target, reencode_audio):
51
+ """Re-encode the delivered audio until its true peak is under TP; returns (record, state).
52
+
53
+ `reencode_audio(peak_target)` renders the audio again for the lower pre-encode target
54
+ into `output_path` and returns its state; `state` is the last one (None when no
55
+ correction ran). `record` holds the delivered `integrated` / `true_peak` (None when
56
+ unmeasured), the final `peak_target_dbtp` and the number of `corrections`.
57
+ """
58
+ true_peak = float(CONFIG["target_true_peak"])
59
+ delivered = delivered_loudness(output_path)
60
+ state = None
61
+ corrections = 0
62
+ while (delivered is not None and delivered["true_peak"] > true_peak
63
+ and corrections < CODEC_PEAK_MAX_CORRECTIONS):
64
+ overshoot = delivered["true_peak"] - true_peak
65
+ peak_target = round(peak_target - overshoot - CODEC_PEAK_MARGIN_DB, 2)
66
+ log(f" 成片 AAC 真峰值 {delivered['true_peak']} dBTP 超过 {true_peak},"
67
+ f"编码前目标降到 {peak_target} dBTP 后重编码音频")
68
+ state = reencode_audio(peak_target)
69
+ corrections += 1
70
+ delivered = delivered_loudness(output_path)
71
+ return {
72
+ "integrated": delivered["integrated"] if delivered else None,
73
+ "true_peak": delivered["true_peak"] if delivered else None,
74
+ "peak_target_dbtp": peak_target,
75
+ "corrections": corrections,
76
+ }, state
77
+
78
+
79
+ def reencode_audio_track(output_path, inputs, filter_complex, audio_map, video_duration,
80
+ work_dir):
81
+ """Replace `output_path`'s audio with `audio_map` rendered from `inputs`, copying its video.
82
+
83
+ `inputs` are the render's own input arguments, so `filter_complex` keeps its indices;
84
+ the rendered file is appended as the last input. Returns ffmpeg's stderr.
85
+ """
86
+ output_path = Path(output_path)
87
+ video_index = inputs.count("-i")
88
+ candidate = output_path.with_name(f".{output_path.stem}.audio-reencode{output_path.suffix}")
89
+ script = None
90
+ if len(filter_complex.encode("utf-8")) > constants.FILTER_SCRIPT_THRESHOLD_BYTES:
91
+ script = Path(work_dir) / ".filter_complex_reencode.txt"
92
+ script.write_text(filter_complex, encoding="utf-8")
93
+ filter_args = filter_file_args("filter_complex", script)
94
+ else:
95
+ filter_args = ["-filter_complex", filter_complex]
96
+ cmd = [
97
+ "ffmpeg", "-y", *inputs, "-i", str(output_path), *filter_args,
98
+ "-map", f"{video_index}:v:0", "-map", audio_map, "-c:v", "copy",
99
+ "-map_metadata", "-1", "-map_chapters", "-1",
100
+ "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-movflags", "+faststart",
101
+ "-t", str(video_duration), str(candidate),
102
+ ]
103
+ try:
104
+ result = lib.run_cmd(cmd)
105
+ if result.returncode != 0:
106
+ raise RuntimeError(f"成片音频重编码失败: {result.stderr}")
107
+ os.replace(candidate, output_path)
108
+ finally:
109
+ candidate.unlink(missing_ok=True)
110
+ if script is not None:
111
+ script.unlink(missing_ok=True)
112
+ return result.stderr