@oh-story/dsh 0.1.11 → 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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import shutil
5
6
  from pathlib import Path
6
7
 
7
8
  import artifacts
@@ -9,12 +10,14 @@ import assemble_constants as constants
9
10
  import assembly_contract
10
11
  import assembly_settings
11
12
  import audio_mix
13
+ import codec_peak
12
14
  import adoption.audio_mix_binding as audio_mix_binding
15
+ import adoption.av_clock as av_clock
13
16
  import adoption.frozen_audio as frozen_audio
17
+ import loudness
14
18
  import media
15
19
  import narration_audio
16
20
  import adoption.narration_binding as narration_binding
17
- import pair_media
18
21
  import render_preflight
19
22
  import adoption.strict_publish as strict_publish
20
23
  import subtitles.render as subtitle_render
@@ -75,6 +78,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
75
78
  audio_mode != "narration" or narration_adoption_path is None or tts_meta_path is None
76
79
  ):
77
80
  raise RuntimeError("audio mix adoption 要求 narration 模式及显式 narration adoption/tts_meta")
81
+ media._plan_clip_spans(work_dir) # a stale cut plan fails here, not after the render
78
82
 
79
83
  published_output = Path(output_path)
80
84
  if audio_mix_adoption_path is not None and published_output.exists():
@@ -129,6 +133,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
129
133
  explicit_mix, binding, tts_segments, work_dir
130
134
  )
131
135
  narration_wav = Path(explicit_runtime["voice_bus"]["path"])
136
+ _block_before_render(tts_segments, video_duration, work_dir, output_path, audio_mode)
132
137
  elif audio_mode == "narration":
133
138
  if binding["tempo_policy"]:
134
139
  narration_audio._apply_narration_speed(
@@ -142,13 +147,6 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
142
147
  tts_segments, narration_wav, video_duration, work_dir,
143
148
  tempo_policy=binding["tempo_policy"],
144
149
  )
145
- if any(
146
- segment.get("blocking") or segment.get("fit_status") == "no_safe_fit"
147
- for segment in tts_segments
148
- ):
149
- raise RuntimeError(
150
- "严格 narration adoption 存在 no_safe_fit,禁止提速或裁尾渲染"
151
- )
152
150
  else:
153
151
  narration_audio._build_timed_narration(
154
152
  tts_segments, narration_wav, video_duration, work_dir
@@ -162,6 +160,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
162
160
  for item in handoffs
163
161
  )
164
162
  )
163
+ _block_before_render(tts_segments, video_duration, work_dir, output_path, audio_mode)
165
164
  narration_binding.seal_render_inputs(binding, tts_segments, narration_wav)
166
165
 
167
166
  # 始终生成 SRT 字幕文件(原声留白处补烧原声字幕,传入成片时长以计算留白区间)
@@ -212,6 +211,9 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
212
211
  source_has_audio = media._has_audio_stream(input_video)
213
212
  adopted_audio = None
214
213
  loudnorm_measurement = None
214
+ peak_limiter = None
215
+ peak_target = loudness.first_render_peak_target()
216
+ mix_graph = None # the mix before the final loudness stage ([aout]); None: no stage
215
217
  original_audio_input = []
216
218
  bgm_input = []
217
219
  filter_complex = None
@@ -232,9 +234,10 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
232
234
  )
233
235
  else:
234
236
  filter_complex += ";[source]anull[aout]"
235
- final_ln = audio_mix.final_loudnorm_filter()
236
- filter_complex += f";[aout]{final_ln}[aoutln]"
237
- lib.log(f"source-mix 音频处理: source volume + {final_ln}")
237
+ mix_graph = filter_complex
238
+
239
+ def plan_loudness(_target, _measured=None):
240
+ return None, None
238
241
  elif explicit_mix is not None:
239
242
  audio_map = "1:a:0"
240
243
  audio_input_args = ["-i", explicit_mix["runtime"]["master"]["path"]]
@@ -251,7 +254,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
251
254
  ]
252
255
  original_audio_label = "2:a"
253
256
  bgm_audio_label = "3:a"
254
- filter_complex = audio_mix._build_audio_filter_complex(
257
+ mix_graph = audio_mix._build_audio_filter_complex(
255
258
  tts_segments,
256
259
  has_bgm,
257
260
  original_audio_label=original_audio_label,
@@ -260,20 +263,22 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
260
263
  # BGM is input [2:a]; -stream_loop -1 loops it to cover the whole timeline (amix
261
264
  # duration=first + -t trim it back to the video length).
262
265
  bgm_input = ["-stream_loop", "-1", "-i", str(bgm_path)] if has_bgm else []
263
- # 末端整体响度归一:ducking 只管相对平衡,这一步统一成片绝对响度
264
- loudnorm_measurement = audio_mix._run_loudnorm_first_pass(
265
- input_video,
266
- narration_wav,
267
- original_audio_input,
268
- bgm_input,
269
- filter_complex,
270
- work_dir,
271
- )
272
- final_ln = audio_mix.final_loudnorm_filter(loudnorm_measurement)
273
- filter_complex += f";[aout]{final_ln}[aoutln]"
274
- lib.log(f"成片响度归一: {final_ln}")
266
+
267
+ def plan_loudness(target, measured=None):
268
+ return loudness.plan_final_loudness(
269
+ input_video, narration_wav, original_audio_input, bgm_input, mix_graph,
270
+ work_dir, peak_target=target, measured=measured,
271
+ )
272
+
275
273
  audio_map = "[aoutln]"
276
274
  audio_input_args = ["-i", str(narration_wav), *original_audio_input]
275
+ if mix_graph is not None:
276
+ # 末端整体响度归一:ducking 只管相对平衡,这一步统一成片绝对响度
277
+ loudnorm_measurement, peak_limiter = plan_loudness(peak_target)
278
+ final_ln = loudness.final_loudnorm_filter(loudnorm_measurement, peak_limiter,
279
+ peak_target)
280
+ filter_complex = f"{mix_graph};[aout]{final_ln}[aoutln]"
281
+ lib.log(f"成片响度归一 ({audio_mode}): {final_ln}")
277
282
 
278
283
  # 对于超长 volume 表达式(多段解说),从脚本文件读取 filter_complex 避免命令行溢出
279
284
  if filter_complex is not None:
@@ -318,12 +323,21 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
318
323
  # needs EVEN width AND height, so normalize odd dims (4:2:2/4:4:4 permit them) before the
319
324
  # encode — otherwise libx264 aborts to a 0-byte file. The downscale helper already evens out.
320
325
  even = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
321
- reencode = bool(vf_chain or packaging_layers) or lib.CONFIG["force_video_reencode"]
326
+ # A stream copy only stays when the source already is what a re-encode would deliver
327
+ # (H.264 8-bit 4:2:0, even size); otherwise a filter-free render re-encodes too, so the
328
+ # yuv420p guarantee holds on every path, including no-burn and degraded-burn runs.
329
+ source_format = media._probe_video_format(input_video)
330
+ color_tags = media._output_color_tags(source_format)
331
+ copy_unsafe = not media._video_copy_safe(source_format)
332
+ reencode = (
333
+ bool(vf_chain or packaging_layers) or lib.CONFIG["force_video_reencode"] or copy_unsafe
334
+ )
322
335
  notes = []
323
336
  video_filter_script = None
324
337
  if vf_chain or packaging_layers:
325
338
  if max_h <= 0: # no downscale in the chain to force even dims
326
339
  vf_chain.append(even)
340
+ vf_chain.append(media._color_tag_filter(color_tags))
327
341
  video_filter = packaging.compose_video_filter(
328
342
  vf_chain, packaging_layers, mask_first=bool(mask_filter)
329
343
  )
@@ -345,10 +359,24 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
345
359
  + ([f"缩放≤{max_h}p"] if max_h > 0 else []))
346
360
  lib.log(f"视频重编码: {' + '.join(notes)} (crf={crf}, preset={preset})")
347
361
  elif reencode:
348
- notes = ["force_video_reencode"]
349
- cmd += ["-vf", even, "-c:v", "libx264", "-preset", preset, "-crf", crf, "-pix_fmt", "yuv420p"]
362
+ notes = (
363
+ ["force_video_reencode"] if lib.CONFIG["force_video_reencode"]
364
+ else ["normalize_source_format"]
365
+ )
366
+ cmd += ["-vf", f"{even},{media._color_tag_filter(color_tags)}",
367
+ "-c:v", "libx264", "-preset", preset, "-crf", crf, "-pix_fmt", "yuv420p"]
368
+ if not lib.CONFIG["force_video_reencode"]:
369
+ lib.log(
370
+ "视频重编码: 源画面不是 H.264 yuv420p "
371
+ f"(codec={source_format.get('codec_name')}, pix_fmt={source_format.get('pix_fmt')})"
372
+ )
350
373
  else:
351
374
  cmd += ["-c:v", "copy"]
375
+ # Container (and, on a re-encode, bitstream) colour tags; see media._output_color_tags.
376
+ cmd += media._color_tag_args(color_tags)
377
+ # Never inherit the source's container/stream tags (a scraper's title, comment or URL)
378
+ # or its chapters, whose times no longer match an edit; every audio path muxes here.
379
+ cmd += ["-map_metadata", "-1", "-map_chapters", "-1"]
352
380
 
353
381
  # +faststart relocates the moov atom to the front so web/social players can start
354
382
  # before the full file downloads; valid (and beneficial) on the copy path too.
@@ -375,13 +403,32 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
375
403
  if video_filter_script is not None:
376
404
  video_filter_script.unlink(missing_ok=True)
377
405
 
406
+ loudness_stderr = result.stderr
407
+ delivered_peak = None
408
+ if mix_graph is not None and lib.CONFIG["final_loudnorm"]:
409
+ def reencode_audio(target):
410
+ measured, limiter = plan_loudness(target, loudnorm_measurement)
411
+ graph = (f"{mix_graph};[aout]"
412
+ f"{loudness.final_loudnorm_filter(measured, limiter, target)}[aoutln]")
413
+ stderr = codec_peak.reencode_audio_track(
414
+ output_path, ["-i", str(input_video), *audio_input_args, *bgm_input],
415
+ graph, audio_map, video_duration, work_dir,
416
+ )
417
+ return stderr, measured, limiter
418
+
419
+ delivered_peak, corrected = codec_peak.deliver_under_true_peak(
420
+ output_path, peak_target, reencode_audio
421
+ )
422
+ if corrected is not None:
423
+ loudness_stderr, loudnorm_measurement, peak_limiter = corrected
424
+ peak_target = delivered_peak["peak_target_dbtp"]
378
425
  if audio_mode == "adopted-packet-copy":
379
426
  # Either check failing means the file at the final path is unverified: never leave it.
380
427
  try:
381
428
  adopted_audio = frozen_audio.verify_adopted_audio(
382
429
  input_video, output_path, audio_stream_index
383
430
  )
384
- pair_media.validate_aac_packet_interval(adopted_audio["output"])
431
+ av_clock.validate_aac_packet_interval(adopted_audio["output"])
385
432
  except (RuntimeError, ValueError):
386
433
  output_path.unlink(missing_ok=True)
387
434
  raise
@@ -408,14 +455,27 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
408
455
  "audio_sample_rate": (
409
456
  adopted_audio["output"]["sample_rate"] if adopted_audio else 48000
410
457
  ),
458
+ "color_tags": color_tags,
411
459
  "final_compat_notes": (
412
460
  (["yuv420p"] if reencode else ["video_copy"])
413
461
  + (["aac_packet_copy", "faststart"] if adopted_audio else ["aac_48000", "faststart"])
414
462
  ),
415
463
  }
464
+ loudnorm_final = None
465
+ if audio_mode != "adopted-packet-copy" and explicit_mix is None and lib.CONFIG["final_loudnorm"]:
466
+ loudnorm_final = loudness.loudnorm_final_pass(
467
+ loudness_stderr, loudnorm_measurement, peak_limiter, peak_target
468
+ )
469
+ loudnorm_final["delivered"] = delivered_peak
470
+ if loudnorm_measurement and loudnorm_final["normalization_type"] == "dynamic":
471
+ lib.log(" ⚠️ loudnorm 第二遍退回动态模式(测得的响度范围或峰值超出线性条件)")
416
472
  loudness_mode = (
417
473
  "not_run" if audio_mode == "adopted-packet-copy" else
418
- "fixed_master_gain_no_loudnorm" if explicit_mix is not None else None
474
+ "fixed_master_gain_no_loudnorm" if explicit_mix is not None else
475
+ loudness._loudness_mode(
476
+ loudnorm_measurement, (loudnorm_final or {}).get("normalization_type"),
477
+ peak_limiter,
478
+ )
419
479
  )
420
480
  source_audio_status = "prepared_bed_adopted" if explicit_mix is not None else None
421
481
  render_output = strict_publish.publish_render(
@@ -424,7 +484,8 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
424
484
  render_output=render_output, published_output=published_output,
425
485
  audio_mode=audio_mode, audio_operations=audio_operations,
426
486
  adopted_audio=adopted_audio, loudness_mode=loudness_mode,
427
- loudnorm_measurement=loudnorm_measurement, visual_qc=visual_qc,
487
+ loudnorm_measurement=loudnorm_measurement, loudnorm_final_pass=loudnorm_final,
488
+ visual_qc=visual_qc,
428
489
  source_has_audio=source_has_audio, video_duration=video_duration,
429
490
  render_delivery=render_delivery, source_audio_status=source_audio_status,
430
491
  )
@@ -432,9 +493,55 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
432
493
  return render_output
433
494
 
434
495
 
496
+ class AssemblyBlockedBeforeRender(RuntimeError):
497
+ """Narration can never pass assembly QC; raised before the video encode."""
498
+
499
+
500
+ def _block_before_render(tts_segments, video_duration, work_dir, output_path, audio_mode):
501
+ """Fail before the video encode when the narration track can already never pass QC.
502
+
503
+ The segment-level codes (no_safe_fit, skipped_segments, unsafe_source_handoff, ...) are
504
+ final once narration is placed; `main` would block the same codes after a full render.
505
+ """
506
+ qc = assembly_contract._build_assembly_qc(
507
+ tts_segments, video_duration, audio_operations={},
508
+ render_delivery={
509
+ "video_encode_passes": 0, "reencode_reason": ["blocked_before_render"],
510
+ "audio_sample_rate": None, "final_compat_notes": [],
511
+ },
512
+ audio_mode=audio_mode,
513
+ )
514
+ if not qc["blocking"]:
515
+ return
516
+ # Never leave an earlier render or its timeline beside a FAIL for these placements.
517
+ Path(output_path).unlink(missing_ok=True)
518
+ (Path(work_dir) / "timeline.json").unlink(missing_ok=True)
519
+ assembly_contract._write_assembly_qc(work_dir, qc)
520
+ summary = qc["summary"]
521
+ blocked = sorted(set(
522
+ summary["no_safe_fit_segments"] + summary["skipped_segments"]
523
+ + summary["tempo_exceeded_segments"] + summary["truncated_segments"]
524
+ + summary["unsafe_source_handoff_segments"]
525
+ + summary["timeline_audio_mismatch_segments"]
526
+ ))
527
+ needed = {
528
+ seg["index"]: seg["needed_tempo_factor"]
529
+ for seg in tts_segments if seg.get("needed_tempo_factor") is not None
530
+ }
531
+ detail = ", ".join(
532
+ f"段 {index + 1}"
533
+ + (f" needed_tempo_factor={needed[index]:.2f}" if index in needed else "")
534
+ for index in blocked
535
+ )
536
+ raise AssemblyBlockedBeforeRender(
537
+ f"组装 QC 在渲染前阻断: {', '.join(qc['blocking_codes'])}"
538
+ + (f"({detail})" if detail else "")
539
+ + f";详见 {Path(work_dir) / constants.ASSEMBLY_QC}"
540
+ )
541
+
542
+
435
543
  def main():
436
544
  import argparse
437
- import shutil
438
545
  ap = argparse.ArgumentParser(
439
546
  description="video-assemble: mux narration audio over the video, duck the original, render subtitles.")
440
547
  ap.add_argument("video", help="source video (edited_source.mp4 in cut mode, else the original)")
@@ -455,7 +562,9 @@ def main():
455
562
  ap.add_argument("--recap-stem", default=None, help="final recap filename stem (default: video stem)")
456
563
  ap.add_argument("--output-dir", default=None)
457
564
  ap.add_argument("--burn-subtitles", action=argparse.BooleanOptionalAction, default=None,
458
- help="burn narration subtitles into the video (default on; --no-burn-subtitles to disable)")
565
+ help="burn narration subtitles into the video (default on; without libass the default "
566
+ "delivers a .srt sidecar instead, while an explicit --burn-subtitles fails; "
567
+ "--no-burn-subtitles to disable)")
459
568
  ap.add_argument("--subtitle-y-top", type=int, default=None,
460
569
  help="inclusive top of a measured subtitle band in display-frame pixels")
461
570
  ap.add_argument("--subtitle-y-bot", type=int, default=None,
@@ -475,6 +584,7 @@ def main():
475
584
  work_dir = Path(args.work_dir)
476
585
  if args.burn_subtitles is not None:
477
586
  lib.CONFIG["burn_subtitles"] = args.burn_subtitles
587
+ lib.CONFIG["burn_subtitles_explicit"] = True
478
588
  if (args.subtitle_y_top is None) != (args.subtitle_y_bot is None):
479
589
  ap.error("--subtitle-y-top and --subtitle-y-bot must be provided together")
480
590
  if args.subtitle_y_top is not None:
@@ -491,22 +601,17 @@ def main():
491
601
  # chose a different opacity through the existing environment override.
492
602
  if "SUBTITLE_MASK_OPACITY" not in os.environ:
493
603
  lib.CONFIG["subtitle_mask_opacity"] = 1.0
494
- if args.source_video:
495
- if not os.path.exists(args.source_video):
496
- ap.error(f"--source-video does not exist: {args.source_video}")
497
- lib.CONFIG["source_video"] = args.source_video
498
- lib.CONFIG["source_video_explicit"] = True
499
- else:
500
- # SOURCE_VIDEO is an ambient env var in lib.CONFIG. Do not let a stale
501
- # shell value silently bind full-mode/direct timeline.json or JianYing
502
- # exports to an unrelated original; cut mode must pass --source-video.
503
- lib.CONFIG["source_video"] = ""
504
- lib.CONFIG["source_video_explicit"] = False
604
+ if args.source_video and not os.path.exists(args.source_video):
605
+ ap.error(f"--source-video does not exist: {args.source_video}")
606
+ lib.CONFIG["source_video"] = args.source_video or ""
505
607
  if args.export_jianying:
506
608
  lib.CONFIG["export_jianying"] = True
507
609
  if args.jianying_bundle_media is not None:
508
610
  lib.CONFIG["jianying_bundle_media"] = args.jianying_bundle_media
509
- render_preflight._preflight_burn_subtitles() # fail before the render if burn-in is on but ffmpeg lacks libass
611
+ # Before the render: an explicit burn without libass fails, the default degrades to the
612
+ # .srt sidecar; drawtext overlays without drawtext fail.
613
+ render_preflight._preflight_burn_subtitles()
614
+ render_preflight._preflight_visual_overlays(work_dir)
510
615
  # Argument combinations are validated once, by assemble_video.
511
616
  tts_meta = Path(args.tts_meta) if args.tts_meta else None
512
617
  tts_segments = []
@@ -520,14 +625,18 @@ def main():
520
625
  ap.error("explicit audio mix requires a new final delivery path")
521
626
  delivery_stage = None
522
627
  owned_alias = None
628
+ sidecar = None
523
629
  output_path = work_dir / "output.mp4"
524
630
  try:
525
- assemble_video(
526
- args.video, tts_segments, work_dir, output_path,
527
- audio_mode=args.audio_mode, audio_stream_index=args.audio_stream_index,
528
- narration_adoption_path=args.narration_adoption, tts_meta_path=tts_meta,
529
- audio_mix_adoption_path=args.audio_mix_adoption,
530
- )
631
+ try:
632
+ assemble_video(
633
+ args.video, tts_segments, work_dir, output_path,
634
+ audio_mode=args.audio_mode, audio_stream_index=args.audio_stream_index,
635
+ narration_adoption_path=args.narration_adoption, tts_meta_path=tts_meta,
636
+ audio_mix_adoption_path=args.audio_mix_adoption,
637
+ )
638
+ except AssemblyBlockedBeforeRender as exc:
639
+ raise SystemExit(str(exc)) from None
531
640
  assembly_qc = artifacts._load_work_json(work_dir, constants.ASSEMBLY_QC)
532
641
  if assembly_qc["blocking"]:
533
642
  codes = ", ".join(assembly_qc["blocking_codes"])
@@ -556,6 +665,7 @@ def main():
556
665
  delivery_stage = None
557
666
  else:
558
667
  shutil.copy2(str(output_path), str(final_output))
668
+ sidecar = _publish_subtitle_sidecar(work_dir, final_output)
559
669
  manifest = assembly_contract._assembly_manifest_payload(
560
670
  args.video, tts_segments, work_dir, output_path,
561
671
  tts_meta_path=tts_meta,
@@ -566,8 +676,14 @@ def main():
566
676
  audio_mode=args.audio_mode,
567
677
  audio_stream_index=args.audio_stream_index,
568
678
  )
679
+ visual_qc = artifacts._load_work_json(work_dir, constants.VISUAL_QC) or {}
680
+ warnings = visual_qc.get("warnings", [])
681
+ manifest["subtitle_sidecar"] = str(sidecar) if sidecar else None
682
+ manifest["warnings"] = warnings
569
683
  assembly_contract._write_assembly_manifest(work_dir, manifest)
570
684
  except BaseException:
685
+ if sidecar is not None:
686
+ sidecar.unlink(missing_ok=True)
571
687
  if delivery_stage is not None:
572
688
  delivery_stage.unlink(missing_ok=True)
573
689
  if owned_alias is not None and final_output.exists():
@@ -583,9 +699,30 @@ def main():
583
699
  from jianying.optional import maybe_export_jianying
584
700
  maybe_export_jianying(work_dir, args.jianying_out, stem)
585
701
 
586
- print(json.dumps({"status": "assembled", "output": str(final_output), "work_dir": str(work_dir)},
702
+ for warning in warnings:
703
+ lib.log(f"⚠️ {warning['code']}: {warning['message']}(外挂字幕: {sidecar})")
704
+ print(json.dumps({"status": "assembled", "output": str(final_output), "work_dir": str(work_dir),
705
+ "subtitle_sidecar": str(sidecar) if sidecar else None,
706
+ "warnings": [warning["code"] for warning in warnings]},
587
707
  ensure_ascii=False))
588
708
 
589
709
 
710
+ def _publish_subtitle_sidecar(work_dir, final_output):
711
+ """Ship subtitles.srt next to the recap when the subtitles are not burned in.
712
+
713
+ The pair shares the stable recap_<stem> alias, so a burned run removes a sidecar left by
714
+ an earlier unburned run instead of letting players stack it over the burned text. A run
715
+ with no subtitle cues (e.g. source-mix without user subtitles) ships no empty sidecar.
716
+ """
717
+ sidecar = final_output.with_suffix(".srt")
718
+ srt = work_dir / "subtitles.srt"
719
+ has_cues = srt.is_file() and bool(srt.read_text(encoding="utf-8").strip())
720
+ if lib.CONFIG["burn_subtitles"] or not has_cues:
721
+ sidecar.unlink(missing_ok=True)
722
+ return None
723
+ shutil.copy2(srt, sidecar)
724
+ return sidecar
725
+
726
+
590
727
  if __name__ == "__main__":
591
728
  main()
@@ -7,7 +7,6 @@ ASSEMBLY_MANIFEST = "assembly_manifest.json"
7
7
  ASSEMBLY_QC = "assembly_qc.json"
8
8
  VISUAL_QC = "visual_qc.json"
9
9
  VISUAL_OVERLAYS = "visual_overlays.json"
10
- SEGMENT_AUDIO_SCHEMA_VERSION = 1
11
10
 
12
11
  # The picture codecs every packet/frame clock proof in this skill accepts.
13
12
  SUPPORTED_PICTURE_CODECS = frozenset({"h264", "hevc"})
@@ -8,23 +8,10 @@ import wave
8
8
  from pathlib import Path
9
9
 
10
10
  from lib import CONFIG
11
- from assemble_constants import (
12
- ASSEMBLY_MANIFEST,
13
- ASSEMBLY_QC,
14
- SEGMENT_AUDIO_SCHEMA_VERSION,
15
- )
16
- from audio_mix import _loudness_mode
11
+ from assemble_constants import ASSEMBLY_MANIFEST, ASSEMBLY_QC
12
+ from loudness import _loudness_mode
17
13
  from subtitles.track_binding import manifest_subtitle_evidence
18
- from artifacts import (
19
- _load_work_json,
20
- _source_video_identity,
21
- _timeline_provenance_status,
22
- )
23
-
24
- _AUDIO_QC_CODES = frozenset({
25
- "missing_narration", "skipped_segments", "no_safe_fit", "effective_tempo_exceeded",
26
- "empty_narration", "truncated_speech", "unsafe_source_handoff", "timeline_audio_mismatch",
27
- })
14
+ from artifacts import _source_video_identity, _timeline_provenance_status
28
15
 
29
16
 
30
17
  def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
@@ -32,13 +19,12 @@ def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
32
19
  audio_mode="narration", audio_stream_index=0,
33
20
  narration_input_binding=None, audio_mix_binding=None):
34
21
  """Slim render record. The orchestrator reads `final_output` to report the result;
35
- `source_video` stays None unless cut mode explicitly passed --source-video, proving a
36
- stale ambient SOURCE_VIDEO never leaked into a full-mode timeline / 剪映 export."""
22
+ `source_video` is set only when cut mode passed --source-video. QC results live in
23
+ assembly_qc.json (`qc_path`), not here."""
37
24
  input_video = Path(input_video)
38
25
  output_path = Path(output_path)
39
26
  source_video_identity = _source_video_identity()
40
27
  qc_path = Path(work_dir) / ASSEMBLY_QC
41
- qc = _load_work_json(work_dir, ASSEMBLY_QC) # always written by publish_render first
42
28
  settings = settings_payload(
43
29
  work_dir, audio_mode=audio_mode, audio_stream_index=audio_stream_index
44
30
  )
@@ -53,27 +39,14 @@ def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
53
39
  "selected_audio_stream_index": audio_stream_index,
54
40
  "assembly_settings": settings,
55
41
  "output_path": str(output_path.resolve()),
56
- "segment_audio_schema_version": SEGMENT_AUDIO_SCHEMA_VERSION,
57
42
  "qc_path": str(qc_path.resolve()),
58
- "qc_verdict": qc["verdict"],
59
- "qc_blocking_codes": qc["blocking_codes"],
60
- # The settings payload records the configured/fallback loudness policy; these QC
61
- # fields record what the just-finished render actually used after the loudnorm probe.
62
- "qc_loudness_mode": qc["loudness_mode"],
63
- "qc_loudnorm_measurement": qc["loudnorm_measurement"],
64
- "audio_operations": qc["audio_operations"],
65
- "adopted_audio": qc["adopted_audio"],
66
43
  "narration_input_binding": narration_input_binding,
67
44
  "audio_mix_binding": audio_mix_binding,
68
45
  "audio_segments": [
69
46
  {
70
47
  "index": seg["index"],
71
- # Informational manifest fields: a tts_meta without the schema marker is v1,
72
- # and the loudness measurements are None whenever voiceover skipped
73
- # normalization (strict adoption fixtures in tests/orchestrator omit both).
74
- "segment_audio_schema_version": seg.get(
75
- "segment_audio_schema_version", SEGMENT_AUDIO_SCHEMA_VERSION
76
- ),
48
+ # The loudness measurements are None whenever voiceover skipped
49
+ # normalization (strict adoption fixtures in tests/orchestrator omit them).
77
50
  "narration": seg["narration"],
78
51
  "spoken_text": seg["spoken_text"],
79
52
  "truncated": seg["truncated"],
@@ -89,6 +62,7 @@ def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
89
62
  "source_restore_at": seg.get("source_restore_at"),
90
63
  "source_handoff_status": seg.get("source_handoff_status"),
91
64
  "source_entry_status": seg.get("source_entry_status"),
65
+ "written_start": seg.get("written_start"),
92
66
  "global_narration_speed": seg["global_narration_speed"],
93
67
  "segment_tempo_factor": seg["segment_tempo_factor"],
94
68
  "effective_tempo": seg["effective_tempo"],
@@ -119,33 +93,6 @@ def _write_assembly_manifest(work_dir, manifest):
119
93
  return path
120
94
 
121
95
 
122
- def _visual_qc_rollup(visual_qc):
123
- subtitles = visual_qc["subtitles"]
124
- overlays = visual_qc["overlays"]
125
- return {
126
- "present": True,
127
- "artifact": visual_qc["artifact"],
128
- "verdict": visual_qc["verdict"],
129
- "blocking": visual_qc["blocking"],
130
- "blocking_codes": list(visual_qc["blocking_codes"]),
131
- "summary": visual_qc["summary"],
132
- "geometry": visual_qc["geometry"],
133
- "subtitles": {
134
- "entries": subtitles["entries"],
135
- "multi_line": subtitles["multi_line"],
136
- "overflow": subtitles["overflow"],
137
- "safe_area": subtitles["safe_area"],
138
- },
139
- "mask": visual_qc["mask"],
140
- "overlays": {
141
- "present": overlays["present"],
142
- "rendered": overlays["rendered"],
143
- "unsupported": overlays["unsupported"],
144
- "overflow": overlays["overflow"],
145
- },
146
- }
147
-
148
-
149
96
  def _placed_audio_matches_timeline(seg):
150
97
  """True when the persisted per-beat WAV is exactly what the serialized timeline window plays."""
151
98
  placed_path = Path(seg["placed_audio_path"])
@@ -182,14 +129,15 @@ def _placed_audio_matches_timeline(seg):
182
129
 
183
130
  def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render_delivery,
184
131
  output_path=None, source_has_audio=None, loudness_mode=None,
185
- loudnorm_measurement=None, visual_qc=None, audio_mode="narration",
132
+ loudnorm_measurement=None, loudnorm_final_pass=None,
133
+ visual_qc=None, audio_mode="narration",
186
134
  adopted_audio=None,
187
135
  narration_input_binding=None, audio_mix_binding=None,
188
136
  source_audio_status=None):
189
137
  """Machine-readable assembly release gate.
190
138
 
191
- Visual facts are rolled up from visual_qc.json. Delivery/render facts live here
192
- (or render/delivery QC in future), never in visual_qc.json.
139
+ Only visual_qc.json's verdict and blocking codes are carried here; its facts stay in
140
+ visual_qc.json. Delivery/render facts live here, never in visual_qc.json.
193
141
  """
194
142
  hard_max = CONFIG["narration_cumulative_tempo_hard_max"]
195
143
  segments = tts_segments
@@ -218,6 +166,12 @@ def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render
218
166
  timeline_audio_failed.append(s["index"])
219
167
 
220
168
  placed = [s["placed_audio_duration"] for s in segments]
169
+ # Information only: how long original audio stays ducked after each narration run ends.
170
+ duck_holds = [
171
+ float(s["source_restore_at"]) - float(s["actual_place_end"])
172
+ for s in segments
173
+ if s.get("source_restore_at") is not None and s.get("actual_place_end") is not None
174
+ ]
221
175
  blocking_codes = []
222
176
  if audio_mode == "narration" and not segments:
223
177
  blocking_codes.append("missing_narration")
@@ -235,12 +189,16 @@ def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render
235
189
  blocking_codes.append("timeline_audio_mismatch")
236
190
  if placed and max(placed) <= 0.0 and not no_safe:
237
191
  blocking_codes.append("empty_narration")
238
- visual_rollup = (
239
- _visual_qc_rollup(visual_qc)
192
+ delivered_peak = ((loudnorm_final_pass or {}).get("delivered") or {}).get("true_peak")
193
+ if delivered_peak is not None and delivered_peak > float(CONFIG["target_true_peak"]):
194
+ # The encoded file, not the PCM mix, is what must stay under TP (codec_peak.py).
195
+ blocking_codes.append("delivered_true_peak_over_target")
196
+ visual_verdict = (
197
+ {"verdict": visual_qc["verdict"], "blocking_codes": list(visual_qc["blocking_codes"])}
240
198
  if visual_qc is not None
241
- else {"present": False, "verdict": "NOT_RUN", "blocking": False, "blocking_codes": [], "summary": {}}
199
+ else {"verdict": "NOT_RUN", "blocking_codes": []}
242
200
  )
243
- if visual_rollup["blocking"]:
201
+ if visual_qc is not None and visual_qc["blocking"]:
244
202
  blocking_codes.append("visual_qc_failed")
245
203
  if source_audio_status is not None:
246
204
  source_audio = source_audio_status
@@ -278,18 +236,14 @@ def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render
278
236
  "source_audio": source_audio,
279
237
  "loudness_mode": loudness_mode or _loudness_mode(loudnorm_measurement),
280
238
  "loudnorm_measurement": loudnorm_measurement,
281
- "release_gate": {
282
- "verdict": "FAIL" if blocking_codes else "PASS",
283
- "visual_qc": visual_rollup["verdict"],
284
- "delivery_qc": "PASS",
285
- "audio_qc": "FAIL" if _AUDIO_QC_CODES.intersection(blocking_codes) else "PASS",
286
- },
287
- "visual_qc": visual_rollup,
239
+ "loudnorm_final_pass": loudnorm_final_pass,
240
+ "visual_qc": visual_verdict,
288
241
  "delivery_qc": {
289
242
  "video_encode_passes": render_delivery["video_encode_passes"],
290
243
  "reencode_reason": render_delivery["reencode_reason"],
291
244
  "audio_sample_rate": render_delivery["audio_sample_rate"],
292
245
  "final_compat_notes": render_delivery["final_compat_notes"],
246
+ "color_tags": render_delivery.get("color_tags"),
293
247
  },
294
248
  "summary": {
295
249
  "segments": len(segments),
@@ -301,6 +255,9 @@ def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render
301
255
  "truncated_segments": truncated,
302
256
  "unsafe_source_handoff_segments": handoff_failed,
303
257
  "timeline_audio_mismatch_segments": timeline_audio_failed,
258
+ "max_source_duck_hold_seconds": (
259
+ round(max(0.0, max(duck_holds)), 4) if duck_holds else None
260
+ ),
304
261
  },
305
262
  "output": output,
306
263
  }