@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
@@ -4,49 +4,33 @@ import json
4
4
  import os
5
5
  from pathlib import Path
6
6
 
7
- from artifacts import _explicit_source_video
8
- from lib import log, run_cmd
9
-
10
- def _load_cut_timeline_plan(work_dir):
11
- """The cut plan, preferring clip_plan_validated.json unless the raw plan is newer; None in full mode."""
12
- raw_plan_path = Path(work_dir) / "clip_plan.json"
13
- validated_plan_path = Path(work_dir) / "clip_plan_validated.json"
14
- if not validated_plan_path.exists():
15
- return json.loads(raw_plan_path.read_text(encoding="utf-8")) if raw_plan_path.exists() else None
16
- if not raw_plan_path.exists():
17
- return json.loads(validated_plan_path.read_text(encoding="utf-8"))
18
- if validated_plan_path.stat().st_mtime_ns >= raw_plan_path.stat().st_mtime_ns:
19
- return json.loads(validated_plan_path.read_text(encoding="utf-8"))
20
- return json.loads(raw_plan_path.read_text(encoding="utf-8"))
21
-
7
+ from lib import CONFIG, log, run_cmd
22
8
 
23
9
  def _plan_clip_spans(work_dir):
24
10
  """Cut-mode clip spans [{source_start, source_end, output_start, output_end, entry}], or None.
25
11
 
26
- clip_plan.json is either a bare list or {"clips": [...]}; a clip names its source range as
27
- source_start/source_end or start/end. Clips without explicit output_start/output_end are laid
28
- out back to back on the output timeline.
12
+ Read only clip_plan_validated.json: edited_source.mp4 was rendered from it, and its clips
13
+ carry explicit source/output spans. Without it this is full mode (None = identity mapping).
14
+ A validated plan older than clip_plan.json no longer describes the picture, so it fails.
29
15
  """
30
- plan = _load_cut_timeline_plan(work_dir)
31
- if plan is None:
16
+ work_dir = Path(work_dir)
17
+ validated_path = work_dir / "clip_plan_validated.json"
18
+ if not validated_path.exists():
32
19
  return None
33
- entries = plan["clips"] if isinstance(plan, dict) else plan
34
- spans, cursor = [], 0.0
35
- for entry in entries:
36
- ss = float(entry.get("source_start", entry.get("start")))
37
- se = float(entry.get("source_end", entry.get("end")))
38
- if "output_start" in entry:
39
- out_s, out_e = float(entry["output_start"]), float(entry["output_end"])
40
- cursor = max(cursor, out_e)
41
- else:
42
- out_s, out_e = cursor, cursor + (se - ss)
43
- cursor = out_e
44
- spans.append({
45
- "source_start": ss, "source_end": se,
46
- "output_start": out_s, "output_end": out_e,
20
+ raw_path = work_dir / "clip_plan.json"
21
+ if raw_path.exists() and validated_path.stat().st_mtime_ns < raw_path.stat().st_mtime_ns:
22
+ raise ValueError(
23
+ "clip_plan.json 在剪辑之后被修改,clip_plan_validated.json 已过期;请先重新剪辑再组装"
24
+ )
25
+ plan = json.loads(validated_path.read_text(encoding="utf-8"))
26
+ return [
27
+ {
28
+ "source_start": float(entry["source_start"]), "source_end": float(entry["source_end"]),
29
+ "output_start": float(entry["output_start"]), "output_end": float(entry["output_end"]),
47
30
  "entry": entry,
48
- })
49
- return spans
31
+ }
32
+ for entry in plan["clips"]
33
+ ]
50
34
 
51
35
 
52
36
  def _ratio_to_float(value, default=1.0):
@@ -152,10 +136,10 @@ def _build_video_clips(input_video, work_dir, duration_s):
152
136
 
153
137
  In cut mode each plan entry becomes a clip referencing the ORIGINAL source
154
138
  range. Multi-source validated plans carry per-clip source_path and do not
155
- require an explicit ambient --source-video. Without any declared source (full
139
+ require --source-video. Without any declared source (full
156
140
  mode, or cut mode rendered without --source-video) the rendered input is one clip.
157
141
  """
158
- explicit_source_video = _explicit_source_video()
142
+ explicit_source_video = CONFIG["source_video"]
159
143
  spans = _plan_clip_spans(work_dir)
160
144
  multi_source = spans is not None and any(span["entry"].get("source_path") for span in spans)
161
145
  if spans is None or not (explicit_source_video or multi_source):
@@ -188,3 +172,139 @@ def _build_video_clips(input_video, work_dir, duration_s):
188
172
  "timeline_start": timeline_start,
189
173
  "timeline_end": timeline_end})
190
174
  return clips
175
+
176
+
177
+ # Delivered-picture format. `_probe_video_format`, `_output_color_tags`,
178
+ # `_color_tag_filter` and `_color_tag_args` are intentional function-level copies shared
179
+ # with the cut render (tests/orchestrator/test_render_format_parity.py keeps them identical).
180
+ _COLOR_FIELDS = (
181
+ ("color_space", "colorspace"),
182
+ ("color_primaries", "color_primaries"),
183
+ ("color_transfer", "color_trc"),
184
+ )
185
+ _UNTAGGED_COLOR = {None, "", "unknown", "unspecified", "reserved", "N/A"}
186
+ # ffprobe's colour space for an RGB picture (PNG, libx264rgb, FFV1 RGB). ffmpeg rejects
187
+ # `-colorspace gbr`, and the delivered picture is YUV anyway, so it is never written.
188
+ _RGB_COLOR_SPACE = "gbr"
189
+ # Pixel-format name prefixes of ffmpeg's RGB family (packed, planar `gbr*`, paletted, Bayer).
190
+ # Some RGB sources report no colour space at all (QuickTime RLE `argb`, raw `bgr24`, GIF
191
+ # `pal8`), so the pixel format decides too; tests/assemble/test_render_delivery.py checks
192
+ # the prefixes against every format a real ffprobe lists.
193
+ _RGB_PIX_FMT_PREFIXES = (
194
+ "rgb", "bgr", "gbr", "argb", "abgr", "0rgb", "0bgr", "x2rgb", "x2bgr", "pal8", "bayer_",
195
+ )
196
+ # Names ffprobe prints that setparams and the -colorspace/-color_primaries/-color_trc
197
+ # output options accept and libx264 writes back (tests/assemble/test_render_delivery.py
198
+ # runs each through a real ffmpeg); anything else is not written explicitly.
199
+ _KNOWN_COLOR_VALUES = {
200
+ "colorspace": {
201
+ "bt709", "fcc", "bt470bg", "smpte170m", "smpte240m", "ycgco", "bt2020nc", "bt2020c",
202
+ "smpte2085", "chroma-derived-nc", "chroma-derived-c", "ictcp",
203
+ },
204
+ "color_primaries": {
205
+ "bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "film", "bt2020",
206
+ "smpte428", "smpte431", "smpte432",
207
+ },
208
+ "color_trc": {
209
+ "bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "linear", "log100",
210
+ "log316", "iec61966-2-4", "bt1361e", "iec61966-2-1", "bt2020-10", "bt2020-12",
211
+ "smpte2084", "smpte428", "arib-std-b67",
212
+ },
213
+ }
214
+ # setparams only knows the BT.470 transfers by their ffprobe names, while ffmpeg 8's
215
+ # -color_trc output option only knows them as gamma22/gamma28 (ffmpeg 9 takes both).
216
+ _COLOR_OPTION_SPELLING = {"color_trc": {"bt470m": "gamma22", "bt470bg": "gamma28"}}
217
+
218
+
219
+ def _probe_video_format(video_path):
220
+ """Codec, pixel format, size and colour tags of the first video stream ({} if unreadable)."""
221
+ result = run_cmd([
222
+ "ffprobe", "-v", "error", "-select_streams", "v:0",
223
+ "-show_entries",
224
+ "stream=codec_name,pix_fmt,width,height,color_space,color_primaries,color_transfer,color_range",
225
+ "-of", "json", str(video_path),
226
+ ])
227
+ if result.returncode != 0:
228
+ return {}
229
+ try:
230
+ streams = json.loads(result.stdout).get("streams") or []
231
+ except (ValueError, AttributeError):
232
+ return {}
233
+ return streams[0] if streams and isinstance(streams[0], dict) else {}
234
+
235
+
236
+ def _output_color_tags(stream):
237
+ """Colour tags to write on the delivered picture, as {ffmpeg option: value}.
238
+
239
+ An untagged or BT.709 source is labelled BT.709, which is how players already decode
240
+ untagged web video. Any other declared colour space passes through as declared. The
241
+ range follows the source: full range stays `pc`, everything else is limited `tv`.
242
+ For a YUV source nothing here converts pixels; it only fixes the labels.
243
+
244
+ An RGB source (colour space `gbr`, or an RGB pixel format with or without a colour
245
+ space) has no YUV matrix to keep: it is converted to BT.709 limited range, and
246
+ `from_rgb` tells `_color_tag_filter` to do that conversion explicitly instead of
247
+ letting ffmpeg pick a matrix.
248
+ """
249
+ declared = {option: stream.get(key) for key, option in _COLOR_FIELDS}
250
+ from_rgb = (
251
+ declared["colorspace"] == _RGB_COLOR_SPACE
252
+ or str(stream.get("pix_fmt") or "").startswith(_RGB_PIX_FMT_PREFIXES)
253
+ )
254
+ if from_rgb:
255
+ declared["colorspace"] = "bt709"
256
+ if all(value in _UNTAGGED_COLOR or value == "bt709" for value in declared.values()):
257
+ tags = {option: "bt709" for _, option in _COLOR_FIELDS}
258
+ else:
259
+ tags = {
260
+ option: value for option, value in declared.items()
261
+ if value in _KNOWN_COLOR_VALUES[option]
262
+ }
263
+ if from_rgb:
264
+ tags["color_range"] = "tv"
265
+ tags["from_rgb"] = True
266
+ else:
267
+ tags["color_range"] = "pc" if stream.get("color_range") == "pc" else "tv"
268
+ return tags
269
+
270
+
271
+ def _color_tag_filter(tags):
272
+ """setparams filter that stamps the tags on every frame before the encoder.
273
+
274
+ Output options alone are not enough when re-encoding: ffmpeg 8/9 let the (untagged)
275
+ frame properties win for primaries and transfer, so the bitstream would stay untagged.
276
+ An RGB frame is first converted to BT.709 yuv420p explicitly: stamping a YUV colour
277
+ space on an RGB frame makes ffmpeg's own later conversion use the BT.601 matrix
278
+ while the file says BT.709.
279
+ """
280
+ parts = [f"{option}={tags[option]}" for _, option in _COLOR_FIELDS if option in tags]
281
+ parts.append(f"range={tags['color_range']}")
282
+ setparams = "setparams=" + ":".join(parts)
283
+ if tags.get("from_rgb"):
284
+ return (
285
+ f"scale=out_color_matrix=bt709:out_range={tags['color_range']},"
286
+ f"format=yuv420p,{setparams}"
287
+ )
288
+ return setparams
289
+
290
+
291
+ def _color_tag_args(tags):
292
+ """Output options that write the tags into the container (and encoder) metadata."""
293
+ args = []
294
+ for _, option in _COLOR_FIELDS:
295
+ if option in tags:
296
+ value = _COLOR_OPTION_SPELLING.get(option, {}).get(tags[option], tags[option])
297
+ args += [f"-{option}", value]
298
+ return args + ["-color_range", tags["color_range"]]
299
+
300
+
301
+ def _video_copy_safe(stream):
302
+ """True when the source picture can be stream-copied and still match a re-encoded
303
+ delivery: H.264, 8-bit 4:2:0 (yuvj420p is the same layout in full range), even size."""
304
+ width, height = stream.get("width"), stream.get("height")
305
+ return (
306
+ stream.get("codec_name") == "h264"
307
+ and stream.get("pix_fmt") in {"yuv420p", "yuvj420p"}
308
+ and isinstance(width, int) and isinstance(height, int)
309
+ and width % 2 == 0 and height % 2 == 0
310
+ )
@@ -4,6 +4,7 @@ import os
4
4
  import wave
5
5
  from pathlib import Path
6
6
 
7
+ from audio_mix import _dialogue_free_pull_start, _paragraph_pull_evidence
7
8
  from lib import CONFIG, get_video_duration, log, narration_tempo_budget, run_cmd
8
9
 
9
10
  def _apply_narration_speed(
@@ -44,6 +45,7 @@ def _adjust_tts_speed(
44
45
  tts_rate_offset=0.0,
45
46
  *,
46
47
  tempo_policy=None,
48
+ segment_index=None,
47
49
  ):
48
50
  """Fit overlong TTS with bounded atempo; never time-trim speech in assemble.
49
51
 
@@ -98,8 +100,9 @@ def _adjust_tts_speed(
98
100
  "placed_audio_duration": 0.0,
99
101
  "needed_tempo_factor": ratio,
100
102
  })
103
+ label = "" if segment_index is None else f"段 {segment_index + 1} "
101
104
  log(
102
- f" TTS 无安全放置: {current_dur:.1f}s 需 x{ratio:.2f},"
105
+ f" TTS 无安全放置: {label}{current_dur:.1f}s 需 x{ratio:.2f},"
103
106
  f"超过段内预算 x{effective_max:.2f}(assemble 不按时间硬切)"
104
107
  )
105
108
  return (str(audio_path), current_dur, meta)
@@ -196,18 +199,16 @@ def _build_timed_narration(
196
199
  no_safe_fit_count = 0 # 超预算但不能安全截断;交由 QC/manifest 阻断
197
200
  prev_authored_end = None # 上一段作者标注的结束时间,用于判断"段落"边界
198
201
  run_gap = CONFIG["narration_run_gap_seconds"] # 作者留白 > 此值 = 新段落
199
- tighten = CONFIG["narration_tighten"]
200
202
  tight_pause_samples = int(CONFIG["narration_tight_pause_seconds"] * sample_rate)
201
203
  # 漂移上限:收紧时一句最多比作者标注的时间提前 max_pull 秒,避免整段解说被全部压到前面、与画面脱节
202
204
  max_pull_samples = int(CONFIG["narration_max_pull_seconds"] * sample_rate)
203
- configured_delay = CONFIG["narration_delay_seconds"]
204
- tail_pad = CONFIG["narration_tail_pad_seconds"]
205
+ pull_evidence = None # (dialogue, quiet),第一次真的要提前时才读
205
206
 
206
207
  for seg in tts_segments:
207
208
  wav_path = seg["audio_path"]
208
209
  pause_samples = int(seg["pause_after_ms"] * sample_rate / 1000)
209
210
  # 段落收紧:同一段落内(与上一句作者留白 <= run_gap)把这一句紧贴上一句的实际收尾播放,
210
- # 句间间隔固定为 tight_pause,不受 slot 内居中延迟 / TTS 时长波动影响。段落之间(作者特意留
211
+ # 句间间隔固定为 tight_pause,不受 slot 余量 / TTS 时长波动影响。段落之间(作者特意留
211
212
  # 的大留白,让精彩原声透出)才放回原声。这样句间间隔稳定、不会出现"一句解说一段空白"。
212
213
  cur_authored_start = float(seg["start"])
213
214
  is_run_start = (placed_count == 0 or prev_authored_end is None
@@ -234,21 +235,26 @@ def _build_timed_narration(
234
235
  tts_rate_offset = seg["tts_rate_offset"]
235
236
  tts_dur = seg["audio_duration"]
236
237
 
237
- slot_duration = max(0.0, float(seg["end"]) - float(seg["start"]))
238
- max_delay = max(0.0, slot_duration - tts_dur - tail_pad)
239
- narration_delay = min(configured_delay, max_delay)
240
- start_sample = int((seg["start"] + narration_delay) * sample_rate)
238
+ start_sample = int(seg["start"] * sample_rate)
241
239
  end_boundary = int(min(seg["end"], video_duration) * sample_rate)
242
240
 
243
241
  # 段间间隔:使用前一段的 pause_after_ms(来自 narration.json)
244
242
  min_start_with_pause = last_written_end + prev_pause_samples
245
- if tighten and not is_run_start:
246
- # 段落内:紧贴上一句的实际收尾播放,句间间隔固定为 tight_pause(不被 slot 内居中延迟撑大),
243
+ if not is_run_start:
244
+ # 段落内:紧贴上一句的实际收尾播放,句间间隔固定为 tight_pause(不被 slot 余量撑大),
247
245
  # 但不早于"作者标注起始 - max_pull",防止整段被压到前面与画面脱节。
248
246
  drift_floor = int(cur_authored_start * sample_rate) - max_pull_samples
249
247
  actual_start = max(last_written_end + tight_pause_samples, drift_floor)
248
+ if actual_start < start_sample:
249
+ # 校验只检查过写的 start;提前的这一段不能进入原声对白,否则停在最后一段对白结束处
250
+ if pull_evidence is None:
251
+ pull_evidence = _paragraph_pull_evidence(work_dir)
252
+ safe_start = _dialogue_free_pull_start(
253
+ actual_start / sample_rate, cur_authored_start, *pull_evidence
254
+ )
255
+ actual_start = min(start_sample, max(actual_start, int(round(safe_start * sample_rate))))
250
256
  else:
251
- # 段落起点(或关闭收紧):尊重作者标注的起始 + 入场延迟,让画面/原声先立住
257
+ # 段落起点:采用作者标注的起始,让画面/原声先立住;上一块超时则顺延到它结尾 + pause_after_ms
252
258
  actual_start = max(start_sample, min_start_with_pause)
253
259
  actual_start = min(actual_start, end_boundary) # 不超出 slot 边界
254
260
 
@@ -259,11 +265,12 @@ def _build_timed_narration(
259
265
  if tempo_policy:
260
266
  wav_path, _actual_dur, fit_meta = _adjust_tts_speed(
261
267
  wav_path, available_duration, tts_rate_offset,
262
- tempo_policy=tempo_policy,
268
+ tempo_policy=tempo_policy, segment_index=seg["index"],
263
269
  )
264
270
  else:
265
271
  wav_path, _actual_dur, fit_meta = _adjust_tts_speed(
266
- wav_path, available_duration, tts_rate_offset
272
+ wav_path, available_duration, tts_rate_offset,
273
+ segment_index=seg["index"],
267
274
  )
268
275
  seg.update({
269
276
  "fit_status": fit_meta["fit_status"],
@@ -273,6 +280,8 @@ def _build_timed_narration(
273
280
  "blocking": fit_meta["blocking"],
274
281
  })
275
282
  if fit_meta["fit_status"] == "no_safe_fit":
283
+ if "needed_tempo_factor" in fit_meta:
284
+ seg["needed_tempo_factor"] = round(fit_meta["needed_tempo_factor"], 4)
276
285
  _unplaced(seg, actual_start / sample_rate, "no_safe_fit",
277
286
  fit_meta["truncate_reason"], blocking=True)
278
287
  prev_pause_samples = pause_samples
@@ -331,7 +340,7 @@ def _build_timed_narration(
331
340
  # consonant/vowel release. _adjust_tts_speed must produce a complete file
332
341
  # that fits; otherwise block and ask the Agent to shorten/move the block.
333
342
  over = (audio_samples - available) / sample_rate
334
- log(f" TTS 无安全放置: 段 {seg['index']} 超出可用窗口 {over:.3f}s;禁止裁尾,交由 QC 阻断")
343
+ log(f" TTS 无安全放置: 段 {seg['index'] + 1} 超出可用窗口 {over:.3f}s;禁止裁尾,交由 QC 阻断")
335
344
  _unplaced(seg, actual_start / sample_rate, "no_safe_fit", "no_safe_boundary", blocking=True)
336
345
  prev_pause_samples = pause_samples
337
346
  skipped_count += 1
@@ -342,7 +351,7 @@ def _build_timed_narration(
342
351
  if actual_start < last_written_end:
343
352
  overlap_ms = (last_written_end - actual_start) * 1000 / sample_rate
344
353
  if last_written_end >= actual_start + write_samples:
345
- log(f" 跳过重叠段: {actual_start/sample_rate:.1f}s "
354
+ log(f" 跳过重叠段: 段 {seg['index'] + 1} {actual_start/sample_rate:.1f}s "
346
355
  f"(与前段重叠 {overlap_ms:.0f}ms)")
347
356
  _unplaced(seg, seg["start"], "no_safe_fit", "no_room", blocking=True)
348
357
  prev_pause_samples = pause_samples
@@ -391,6 +400,11 @@ def _build_timed_narration(
391
400
  buffer[actual_start * 2: actual_start * 2 + write_samples * 2] = wf_data
392
401
  seg["actual_place_start"] = actual_start / sample_rate
393
402
  seg["actual_place_end"] = (actual_start + write_samples) / sample_rate
403
+ if actual_start < start_sample:
404
+ # 段落收紧把这一块提前到写的 start 之前;若它成为新压低段的首块,
405
+ # 原声交接会用自己的入口判定覆盖这个状态
406
+ seg["source_entry_status"] = "paragraph_tightened"
407
+ seg["written_start"] = cur_authored_start
394
408
  seg["placed_audio_duration"] = write_samples / sample_rate
395
409
  last_written_end = actual_start + write_samples
396
410
  prev_pause_samples = pause_samples
@@ -1,9 +1,12 @@
1
- """Local ffmpeg capability preflight for subtitle burn-in."""
1
+ """Local ffmpeg capability preflight for subtitle burn-in and drawtext overlays."""
2
2
 
3
3
  import shutil
4
4
  import subprocess
5
+ from pathlib import Path
5
6
 
6
- from lib import CONFIG
7
+ from assemble_constants import VISUAL_OVERLAYS
8
+ from lib import CONFIG, log
9
+ from visual_render import _load_visual_overlays
7
10
 
8
11
  def _ffmpeg_filters():
9
12
  """Return ffmpeg's compiled-in filter names (caller has confirmed ffmpeg exists)."""
@@ -19,15 +22,53 @@ def _ffmpeg_filters():
19
22
  return filters
20
23
 
21
24
 
25
+ SUBTITLE_BURN_DEGRADED = "subtitle_burn_degraded"
26
+ LIBASS_MISSING = "ffmpeg_missing_libass"
27
+
28
+
22
29
  def _preflight_burn_subtitles():
23
- """Fail before the (re-encoding) render when burn-in is on but ffmpeg lacks the libass
24
- `subtitles` filter. Only fires when ffmpeg EXISTS but can't burn — an absent ffmpeg fails
25
- the render regardless."""
30
+ """Settle subtitle burn-in before the render when ffmpeg lacks the libass `subtitles`
31
+ filter. An explicit request (--burn-subtitles or BURN_SUBTITLES in the environment)
32
+ fails here. The implicit default degrades: burn turns off, the reason is recorded in
33
+ CONFIG["burn_subtitles_degraded"], and the run delivers the .srt sidecar instead (the
34
+ warning lands in visual_qc.json and assembly_manifest.json). Only fires when ffmpeg
35
+ EXISTS but can't burn; an absent ffmpeg fails the render regardless."""
26
36
  if not CONFIG["burn_subtitles"]:
27
37
  return
28
38
  if shutil.which("ffmpeg") is None:
29
39
  return
30
- if "subtitles" not in _ffmpeg_filters():
40
+ if "subtitles" in _ffmpeg_filters():
41
+ return
42
+ if CONFIG["burn_subtitles_explicit"]:
31
43
  raise SystemExit(
32
- "字幕烧录已开启,但当前 ffmpeg 不支持 subtitles/libass 滤镜,渲染会在最后一步失败。\n"
33
- " 解决:安装带 libass 的 ffmpeg,或加 --no-burn-subtitles 关闭烧录(仍输出 .srt 外挂字幕)。")
44
+ "已显式要求烧录字幕(--burn-subtitles 或 BURN_SUBTITLES),但当前 ffmpeg 不支持"
45
+ " subtitles/libass 滤镜,渲染会在最后一步失败。\n"
46
+ " 解决:安装带 libass 的 ffmpeg;或去掉显式烧录要求,默认运行会改为输出 .srt 外挂字幕;"
47
+ "或加 --no-burn-subtitles。")
48
+ CONFIG["burn_subtitles"] = False
49
+ CONFIG["burn_subtitles_degraded"] = LIBASS_MISSING
50
+ log("⚠️ 当前 ffmpeg 不支持 subtitles/libass 滤镜:本次不烧录字幕,改为输出 .srt 外挂字幕"
51
+ "(visual_qc.json / assembly_manifest.json 记录 subtitle_burn_degraded)。"
52
+ "要烧录字幕请安装带 libass 的 ffmpeg。")
53
+
54
+
55
+ def _preflight_visual_overlays(work_dir):
56
+ """Fail before the render when visual_overlays.json asks for text but this ffmpeg has no
57
+ drawtext (libfreetype). Overlays are authored content, never a default, so they are not
58
+ silently dropped: remove them or install an ffmpeg with drawtext. An unreadable overlay
59
+ file is left to the render's own validation."""
60
+ if shutil.which("ffmpeg") is None:
61
+ return
62
+ try:
63
+ overlays, _source = _load_visual_overlays(work_dir)
64
+ except ValueError:
65
+ return
66
+ if not any(str(item.get("text", "")).strip() for item in overlays):
67
+ return
68
+ if "drawtext" in _ffmpeg_filters():
69
+ return
70
+ raise SystemExit(
71
+ f"{Path(work_dir) / VISUAL_OVERLAYS} 有 {len(overlays)} 个画面文字叠加,但当前 ffmpeg 不支持"
72
+ " drawtext 滤镜(需要 libfreetype),渲染会在最后一步失败。\n"
73
+ " 解决:安装带 drawtext/libfreetype 的 ffmpeg,或删掉这些叠加"
74
+ "(recap 运行时删掉 narration.json 各段的 visual_overlays)后重跑。")
@@ -57,11 +57,11 @@ def _source_subtitle_mask_policy(work_dir=None):
57
57
  if raw_policy == "off":
58
58
  active = False
59
59
  elif raw_policy in {"opt_in", "forced"}:
60
- active = burn and legacy_flag
60
+ active = legacy_flag
61
61
  trigger = "burn_subtitles_and_legacy_mask_flag"
62
62
  reason = "explicit policy permits masking only with burned recap subtitles"
63
63
  elif raw_policy == "safe":
64
- active = burn and (legacy_flag or user_subtitles)
64
+ active = legacy_flag or user_subtitles
65
65
  trigger = "safe_policy_with_burned_subtitles"
66
66
  reason = "safe policy masks only when recap subtitles are burned and an original-subtitle source is declared"
67
67
  else:
@@ -70,8 +70,15 @@ def _source_subtitle_mask_policy(work_dir=None):
70
70
  reason = "mask_source_subtitles requires explicit SOURCE_SUBTITLE_MASK_POLICY"
71
71
  if not burn and active:
72
72
  active = False
73
- trigger = "burn_subtitles_disabled"
74
- reason = "mask-only black band is forbidden without burned recap subtitles"
73
+ if CONFIG["burn_subtitles_degraded"]:
74
+ trigger = "burn_subtitles_degraded"
75
+ reason = (
76
+ "subtitle burn-in degraded to the .srt sidecar; a mask-only black band is "
77
+ "forbidden without burned recap subtitles"
78
+ )
79
+ else:
80
+ trigger = "burn_subtitles_disabled"
81
+ reason = "mask-only black band is forbidden without burned recap subtitles"
75
82
  return {
76
83
  "policy": raw_policy,
77
84
  "declared": bool(declared and raw_policy in allowed),
@@ -269,14 +276,18 @@ def _narration_gap_windows(tts_segments, video_duration, min_gap=_MIN_GAP_TO_SUB
269
276
 
270
277
  def _original_gap_subtitle_entries(tts_segments, work_dir, video_duration):
271
278
  """Subtitle entries for the ORIGINAL dialogue during the original-audio blocks (narration
272
- gaps), so the band is not blank while the original speaks. Off unless we are burning and
279
+ gaps), so the band is not blank while the original speaks. Off unless subtitles are
280
+ delivered (burned, or burn degraded to the .srt sidecar because ffmpeg lacks libass) and
273
281
  subtitle_original_in_gaps is set; no-op when there is no ASR. Cut mode remaps ASR to output."""
274
282
  # Fill the gaps when either (a) we are masking the source's own burned-in subs (so the band is
275
283
  # blank without us), or (b) the user supplied their own subtitle file — a clear signal they want
276
284
  # the original dialogue shown, e.g. a clean/foreign source with mask OFF (no burned subs to
277
285
  # double). Without a user file we keep the mask requirement so we don't double the source's own
278
286
  # visible subs. subtitle_original_in_gaps is the explicit override either way.
279
- if not (CONFIG["burn_subtitles"]
287
+ # A degraded burn keeps the cues a burned run would show, so the sidecar .srt is the same
288
+ # subtitle deliverable; with the mask off only the user-file trigger can still pass.
289
+ delivering = CONFIG["burn_subtitles"] or bool(CONFIG["burn_subtitles_degraded"])
290
+ if not (delivering
280
291
  and CONFIG["subtitle_original_in_gaps"]
281
292
  and (_source_subtitle_mask_covers_gaps(work_dir) or _has_user_subtitles(work_dir))):
282
293
  return []
@@ -13,6 +13,13 @@ from assemble_constants import (
13
13
  )
14
14
  from media import _ratio_to_float
15
15
 
16
+ # Measured subtitle bands accept near-square pixels: Y rows are converted between the display
17
+ # canvas and the decoded frame exactly (_frame_row), so the only SAR effect left is a burned
18
+ # ASS line stretched horizontally by the same factor — invisible within 2%. Kept equal to the
19
+ # orchestrator's preflight copy and the measuring tool's (parity-tested).
20
+ SUBTITLE_BAND_SAR_TOLERANCE = 0.02
21
+
22
+
16
23
  def _seconds_to_srt_time(seconds):
17
24
  """Floor times to SRT milliseconds without float remainder artifacts.
18
25
 
@@ -102,9 +109,11 @@ def _measured_subtitle_band(canvas):
102
109
  if y_top < 0 and y_bot < 0:
103
110
  return None
104
111
  sar_text = canvas["sample_aspect_ratio"]
105
- if abs(_ratio_to_float(sar_text, 0.0) - 1.0) >= 1e-9:
112
+ # Unspecified SAR ("0:1"/"N/A") is square, exactly as the canvas probe reads it.
113
+ if abs(_ratio_to_float(sar_text, 1.0) - 1.0) > SUBTITLE_BAND_SAR_TOLERANCE + 1e-9:
106
114
  raise ValueError(
107
- f"字幕带坐标仅支持方形像素画布 (SAR 1:1);当前 SAR={sar_text}"
115
+ "字幕带坐标仅支持方形或近方形像素画布 "
116
+ f"(SAR 与 1:1 相差不超过 {SUBTITLE_BAND_SAR_TOLERANCE:.0%});当前 SAR={sar_text}"
108
117
  )
109
118
  canvas_h = canvas["height"]
110
119
  if not 0 <= y_top < y_bot <= canvas_h:
@@ -115,6 +124,19 @@ def _measured_subtitle_band(canvas):
115
124
  return y_top, y_bot
116
125
 
117
126
 
127
+ def _frame_row(canvas, display_y):
128
+ """Display-canvas row → row of the decoded, auto-rotated frame that drawbox paints on.
129
+
130
+ The canvas keeps the frame's pixel width and stretches the height by the SAR only for a
131
+ 90°/270° stream (its stored width becomes the height); otherwise both share rows exactly.
132
+ """
133
+ rotated = canvas["rotation"] in {90, 270}
134
+ frame_h = canvas["storage_width"] if rotated else canvas["storage_height"]
135
+ if frame_h == canvas["height"]:
136
+ return display_y
137
+ return round(display_y * frame_h / canvas["height"])
138
+
139
+
118
140
  def _style_for_measured_subtitle_band(style, canvas):
119
141
  """Fit the ASS baseline and font into explicit auto-rotated display-frame Y coordinates."""
120
142
  style = dict(style)
@@ -16,8 +16,6 @@ from pathlib import Path
16
16
 
17
17
  SCHEMA_VERSION = 1
18
18
 
19
- # Digest keys older tracks declared; they are ignored, never a reason to fail.
20
- _LEGACY_BINDING_KEYS = frozenset({"sha256", "edit_sha256"})
21
19
  _EVIDENCE_KINDS = frozenset(
22
20
  {
23
21
  "human_verified",
@@ -112,7 +110,7 @@ def _load_document(path_or_mapping):
112
110
 
113
111
  def _validate_picture_binding(binding, expected):
114
112
  binding = _strict_fields(
115
- binding, required={"path"}, optional={"edit_plan"} | _LEGACY_BINDING_KEYS,
113
+ binding, required={"path"}, optional={"edit_plan"},
116
114
  path="bindings.picture",
117
115
  )
118
116
  expected = _mapping(expected, "expected_picture_identity")
@@ -137,8 +135,7 @@ _AUDIO_BINDING_FACTS = ("selected_stream", "sample_rate", "packet_count")
137
135
 
138
136
  def _validate_audio_binding(binding, expected):
139
137
  binding = _strict_fields(
140
- binding, required=set(_AUDIO_BINDING_FACTS), optional=_LEGACY_BINDING_KEYS,
141
- path="bindings.audio",
138
+ binding, required=set(_AUDIO_BINDING_FACTS), path="bindings.audio",
142
139
  )
143
140
  expected = _mapping(expected, "expected_audio_identity")
144
141
  result = {}
@@ -17,8 +17,6 @@ from subtitles.track import load_subtitle_track
17
17
 
18
18
  TRACK = 'subtitle_track.json'
19
19
  VALIDATION = 'subtitle_track_validation.json'
20
- VALIDATION_SCHEMA = 1
21
- PROJECTOR_VERSION = 1
22
20
 
23
21
 
24
22
  def current_bindings(video, selected_audio_stream=0, *, edit_plan_path=None):
@@ -113,8 +111,6 @@ def prepare_subtitle_track(input_video, work_dir, video_duration, *, audio_mode,
113
111
  start=float(Fraction(start['pts'])), end=float(Fraction(end['pts'])))
114
112
  entry['frame_projection'] = {'requested_ticks': [cue['start_tick'], cue['end_tick']],
115
113
  'start': start, 'end': end}
116
- loaded['validation_schema'] = VALIDATION_SCHEMA
117
- loaded['projector_version'] = PROJECTOR_VERSION
118
114
  loaded['binding'] = {
119
115
  'input_video': str(Path(input_video).resolve()), 'identities': bindings,
120
116
  'inputs': {'track': file_identity(path), 'video': file_identity(input_video),
@@ -129,14 +125,11 @@ def prepare_subtitle_track(input_video, work_dir, video_duration, *, audio_mode,
129
125
 
130
126
 
131
127
  def _load_validation(work):
128
+ """The record prepare_subtitle_track rewrote earlier in this same assemble run."""
132
129
  path = Path(work) / VALIDATION
133
130
  if not path.exists():
134
131
  raise ValueError('Explicit subtitle track must be prepared against current media before use')
135
- loaded = json.loads(path.read_text(encoding='utf-8'))
136
- if (type(loaded.get('validation_schema')) is not int or loaded['validation_schema'] != VALIDATION_SCHEMA
137
- or type(loaded.get('projector_version')) is not int or loaded['projector_version'] != PROJECTOR_VERSION):
138
- raise ValueError('Subtitle validation/projector version changed; prepare again')
139
- return loaded
132
+ return json.loads(path.read_text(encoding='utf-8'))
140
133
 
141
134
 
142
135
  def bound_subtitle_entries(work_dir, video_duration):