@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
@@ -1,707 +0,0 @@
1
- """Enforce narration timing, evidence, budget, and sentence-integrity rules.
2
-
3
- This is the single validator for the agent-authored narration.json: shape, numeric
4
- timing and text checks live here, and every later consumer trusts its result.
5
- """
6
-
7
- import json
8
- import math
9
- from pathlib import Path
10
-
11
- from lib import CONFIG, log
12
- from agent_text import (
13
- _clean_narration_punctuation,
14
- _find_scene_for_midpoint,
15
- _normalise_narration_segment,
16
- _post_dedup_narration,
17
- _recommended_char_budget,
18
- _scene_available_seconds,
19
- _text_char_count,
20
- _truncate_at_sentence,
21
- )
22
- from deslop_qc import analyze_deslop_qc
23
- from speech_ownership import (
24
- entry_overlaps_source_speech,
25
- load_source_sentence_evidence,
26
- )
27
-
28
-
29
- def _lint_issue(level, index, code, message, **extra):
30
- issue = {"level": level, "index": index, "code": code, "message": message}
31
- issue.update(extra)
32
- return issue
33
-
34
-
35
- def _is_number(value):
36
- return (
37
- isinstance(value, (int, float))
38
- and not isinstance(value, bool)
39
- and math.isfinite(value)
40
- )
41
-
42
-
43
- def _visual_overlay_issues(index, seg):
44
- """Shape-check the renderer handoff recap reads verbatim from a validated segment.
45
-
46
- recap owns which overlay `type` values it actually renders and drops the rest; lint owns
47
- the shape, because nothing between here and that filter re-checks it — and the filter runs
48
- after TTS, so a malformed overlay caught there would already have cost a synthesis pass.
49
- """
50
- overlays = seg.get("visual_overlays")
51
- if overlays is None:
52
- return []
53
- if not isinstance(overlays, list):
54
- return [
55
- _lint_issue(
56
- "error", index, "invalid_visual_overlays", "visual_overlays must be an array"
57
- )
58
- ]
59
- issues = []
60
- for position, overlay in enumerate(overlays):
61
- if not isinstance(overlay, dict):
62
- issues.append(
63
- _lint_issue(
64
- "error", index, "invalid_visual_overlay",
65
- "visual_overlays entries must be objects",
66
- overlay_index=position,
67
- )
68
- )
69
- continue
70
- for key in ("type", "text"):
71
- value = overlay.get(key)
72
- if not isinstance(value, str) or not value.strip():
73
- issues.append(
74
- _lint_issue(
75
- "error", index, "invalid_visual_overlay",
76
- f"visual_overlays[].{key} must be a non-empty string",
77
- overlay_index=position,
78
- )
79
- )
80
- for key in ("start", "end"):
81
- if key in overlay and not _is_number(overlay[key]):
82
- issues.append(
83
- _lint_issue(
84
- "error", index, "invalid_visual_overlay",
85
- f"visual_overlays[].{key} must be numeric",
86
- overlay_index=position,
87
- )
88
- )
89
- return issues
90
-
91
-
92
- def _scene_bounds_for_midpoint(scenes_analysis, start, end):
93
- scene = _find_scene_for_midpoint(scenes_analysis, start, end)
94
- if not scene:
95
- return None
96
- return scene["start"], scene["end"]
97
-
98
-
99
- def _frame_fact_times_for_segment(scenes_analysis, start, end):
100
- """Return frame-fact timestamps covered by a narration segment.
101
-
102
- These timestamps are cheap visual anchors. A narration slot that spans too
103
- many anchors is more likely to drift into "general story summary" instead
104
- of staying attached to what is on screen.
105
- """
106
- scene = _find_scene_for_midpoint(scenes_analysis, start, end)
107
- if not scene:
108
- return []
109
- return sorted(
110
- ts
111
- for ts in map(float, scene.get("frame_facts", {}))
112
- if start <= ts <= end
113
- )
114
-
115
-
116
- def _clip_span(clip):
117
- """A validated plan clip carries source_start/source_end; a raw agent plan start/end."""
118
- if "source_start" in clip:
119
- return clip["source_start"], clip["source_end"]
120
- return clip["start"], clip["end"]
121
-
122
-
123
- def _clip_matches_for_segment(seg, clip_plan, requested_clip_id):
124
- if not clip_plan:
125
- return []
126
- clips = clip_plan["clips"]
127
- midpoint = (seg["start"] + seg["end"]) / 2
128
- if requested_clip_id is not None:
129
- clips = [clip for clip in clips if clip.get("clip_id") == requested_clip_id]
130
- return [
131
- clip
132
- for clip in clips
133
- if _clip_span(clip)[0] <= midpoint <= _clip_span(clip)[1]
134
- ]
135
-
136
-
137
- def _source_sentence_entry_issue(index, start, anchors, speech_owned):
138
- """Return a blocking issue when narration enters midway through source speech.
139
-
140
- The validator reports a correction instead of silently moving audio. Source
141
- sentence integrity is invariant; an editorial policy string cannot bypass it.
142
- """
143
- if not speech_owned:
144
- return None
145
- # A cold-open at the first frame is not an interruption: the source sentence
146
- # has not been allowed to start. All later entries must use a measured anchor.
147
- if start <= 0.25:
148
- return None
149
- if not anchors:
150
- return _lint_issue(
151
- "error",
152
- index,
153
- "source_sentence_anchors_unavailable",
154
- "Source speech owns this entry but no verified sentence-end anchor is available. "
155
- "Move/remove the narration or regenerate output speech evidence before TTS.",
156
- entry_time=round(start, 3),
157
- suggested_start=None,
158
- )
159
- # Only the measured acoustic pause owns a safe entry. A tiny 80ms post-anchor
160
- # tolerance covers timestamp/sample rounding; the old +450ms allowance could
161
- # already be several Chinese syllables into the next sentence.
162
- for anchor in anchors:
163
- when = anchor["time"]
164
- pause_start = anchor.get("pause_start", when - 0.12)
165
- if pause_start - 0.05 <= start <= when + 0.08:
166
- return None
167
-
168
- suggested = next(
169
- (anchor for anchor in anchors if anchor["time"] > start + 0.08), None
170
- )
171
- return _lint_issue(
172
- "error",
173
- index,
174
- "interrupts_source_sentence",
175
- "Narration enters before the source sentence finishes. Move this block to the suggested "
176
- "sentence-end anchor, or shorten/move/remove it when there is no later verified anchor, "
177
- "then rerun lint before TTS. Source sentence interruption has no override.",
178
- entry_time=round(start, 3),
179
- suggested_start=round(suggested["time"], 3) if suggested else None,
180
- source_text_tail=str(suggested.get("text_tail", "")).strip() if suggested else "",
181
- anchor_confidence=suggested["confidence"] if suggested else None,
182
- )
183
-
184
-
185
- def _has_connected_predecessor(narration, idx, start):
186
- """A back-to-back narration handoff (<=150ms gap) does not expose the source track,
187
- so the next TTS block is not a new source-speech entry."""
188
- for other_idx, other in enumerate(narration):
189
- if other_idx == idx or not isinstance(other, dict):
190
- continue
191
- other_start, other_end = other.get("start"), other.get("end")
192
- if not _is_number(other_start) or not _is_number(other_end):
193
- continue
194
- if other_start < start and -0.001 <= start - other_end <= 0.15:
195
- return True
196
- return False
197
-
198
-
199
- def lint_narration(
200
- narration, scenes_analysis=None, *, clip_plan=None, mode="full", work_dir=None,
201
- require_chronological=False,
202
- ):
203
- """Preflight-check agent narration before TTS; write narration_lint.json when work_dir is set.
204
-
205
- Segments are sorted by start for the timing checks; ``require_chronological`` (the
206
- --preserve-approved-text contract) additionally rejects input that is not already in order."""
207
- scenes_analysis = scenes_analysis or []
208
- errors = []
209
- warnings = []
210
- normalized = []
211
- source_evidence = load_source_sentence_evidence(work_dir, mode=mode)
212
- source_sentence_anchors = source_evidence["anchors"]
213
- if not isinstance(narration, list):
214
- errors.append(
215
- _lint_issue(
216
- "error",
217
- None,
218
- "invalid_json_shape",
219
- "narration.json must be a JSON array",
220
- )
221
- )
222
- else:
223
- previous_start = None
224
- for idx, seg in enumerate(narration):
225
- if not isinstance(seg, dict):
226
- errors.append(
227
- _lint_issue(
228
- "error",
229
- idx,
230
- "invalid_segment",
231
- "Narration segment must be an object",
232
- )
233
- )
234
- continue
235
- start, end = seg.get("start"), seg.get("end")
236
- if not _is_number(start) or not _is_number(end):
237
- errors.append(
238
- _lint_issue(
239
- "error", idx, "invalid_time", "start/end must be finite numbers"
240
- )
241
- )
242
- continue
243
- if require_chronological and previous_start is not None and start < previous_start:
244
- errors.append(
245
- _lint_issue(
246
- "error",
247
- idx,
248
- "out_of_order",
249
- "Segments must be listed in chronological order",
250
- start=start,
251
- previous_start=previous_start,
252
- )
253
- )
254
- previous_start = start
255
- text = seg.get("narration")
256
- if not isinstance(text, str):
257
- errors.append(
258
- _lint_issue(
259
- "error",
260
- idx,
261
- "invalid_narration",
262
- "narration must be a string",
263
- start=start,
264
- end=end,
265
- )
266
- )
267
- continue
268
- text = text.strip()
269
- pause = seg.get("pause_after_ms", CONFIG["breath_ms"])
270
- if not isinstance(pause, int) or isinstance(pause, bool) or pause < 0:
271
- errors.append(
272
- _lint_issue(
273
- "error",
274
- idx,
275
- "invalid_pause",
276
- "pause_after_ms must be a non-negative integer number of milliseconds",
277
- )
278
- )
279
- continue
280
- errors.extend(_visual_overlay_issues(idx, seg))
281
- if end <= start:
282
- errors.append(
283
- _lint_issue(
284
- "error",
285
- idx,
286
- "invalid_time_range",
287
- "end must be greater than start",
288
- start=start,
289
- end=end,
290
- )
291
- )
292
- continue
293
- if not text:
294
- errors.append(
295
- _lint_issue(
296
- "error",
297
- idx,
298
- "empty_narration",
299
- "narration text must not be empty",
300
- start=start,
301
- end=end,
302
- )
303
- )
304
- continue
305
-
306
- char_count = _text_char_count(text)
307
- budget = _recommended_char_budget(start, end)
308
- # estimate at the REAL playback rate (after the narration_speed atempo); otherwise a
309
- # beat sized to its 1.3x-sped slot looks "over budget" when it actually fits.
310
- play_rate = CONFIG["speech_rate"] * CONFIG["narration_speed"]
311
- estimated_tts_seconds = char_count / play_rate
312
- slot_seconds = _scene_available_seconds(start, end)
313
- if budget < 5:
314
- warnings.append(
315
- _lint_issue(
316
- "warning",
317
- idx,
318
- "slot_too_short",
319
- "Narration slot is very short; TTS may be clipped",
320
- start=start,
321
- end=end,
322
- budget_chars=budget,
323
- )
324
- )
325
- elif estimated_tts_seconds > slot_seconds:
326
- warnings.append(
327
- _lint_issue(
328
- "warning",
329
- idx,
330
- "over_budget",
331
- "Text may exceed the available TTS slot",
332
- start=start,
333
- end=end,
334
- budget_chars=budget,
335
- actual_chars=char_count,
336
- estimated_tts_seconds=round(estimated_tts_seconds, 2),
337
- slot_seconds=round(slot_seconds, 2),
338
- )
339
- )
340
- if text[-1] not in "。!?!?….":
341
- warnings.append(
342
- _lint_issue(
343
- "warning",
344
- idx,
345
- "incomplete_sentence",
346
- "Narration should end with a complete sentence punctuation",
347
- text_tail=text[-8:],
348
- )
349
- )
350
-
351
- if not _has_connected_predecessor(narration, idx, start):
352
- entry_issue = _source_sentence_entry_issue(
353
- idx,
354
- start,
355
- source_sentence_anchors,
356
- entry_overlaps_source_speech(
357
- start,
358
- source_evidence,
359
- authored_overlap=seg.get("overlaps_speech", True),
360
- ),
361
- )
362
- if entry_issue:
363
- errors.append(entry_issue)
364
-
365
- scene_bounds = _scene_bounds_for_midpoint(scenes_analysis, start, end)
366
- if scenes_analysis and not scene_bounds:
367
- warnings.append(
368
- _lint_issue(
369
- "warning",
370
- idx,
371
- "outside_scene",
372
- "Narration midpoint does not match any detected scene",
373
- start=start,
374
- end=end,
375
- )
376
- )
377
- elif scene_bounds and (start < scene_bounds[0] or end > scene_bounds[1]):
378
- warnings.append(
379
- _lint_issue(
380
- "warning",
381
- idx,
382
- "crosses_scene_boundary",
383
- "Narration extends outside its midpoint scene boundary",
384
- start=start,
385
- end=end,
386
- scene_start=scene_bounds[0],
387
- scene_end=scene_bounds[1],
388
- )
389
- )
390
-
391
- frame_fact_times = _frame_fact_times_for_segment(
392
- scenes_analysis, start, end
393
- )
394
- if (end - start) > CONFIG["visual_beat_max_seconds"] and len(
395
- frame_fact_times
396
- ) > CONFIG["visual_beat_max_facts"]:
397
- warnings.append(
398
- _lint_issue(
399
- "warning",
400
- idx,
401
- "visual_beat_too_broad",
402
- "Narration spans many visual anchors; split or tighten timing so the voiceover stays tied to current pictures",
403
- start=start,
404
- end=end,
405
- duration=round(end - start, 2),
406
- frame_fact_times=[round(ts, 2) for ts in frame_fact_times[:8]],
407
- )
408
- )
409
-
410
- if mode == "cut":
411
- requested_clip_id = seg.get("source_clip_id")
412
- if requested_clip_id is not None:
413
- try:
414
- requested_clip_id = int(requested_clip_id)
415
- except (TypeError, ValueError):
416
- errors.append(
417
- _lint_issue(
418
- "error",
419
- idx,
420
- "invalid_source_clip_id",
421
- "source_clip_id must be an integer",
422
- )
423
- )
424
- continue
425
- matches = _clip_matches_for_segment(seg, clip_plan, requested_clip_id)
426
- if not matches:
427
- errors.append(
428
- _lint_issue(
429
- "error",
430
- idx,
431
- "outside_clip_plan",
432
- "Cut-mode narration must fall inside a selected clip",
433
- start=start,
434
- end=end,
435
- )
436
- )
437
- elif len(matches) > 1 and requested_clip_id is None:
438
- errors.append(
439
- _lint_issue(
440
- "error",
441
- idx,
442
- "ambiguous_source_clip",
443
- "Repeated/overlapping clips require source_clip_id",
444
- start=start,
445
- end=end,
446
- )
447
- )
448
- if len(matches) == 1:
449
- clip_start, clip_end = _clip_span(matches[0])
450
- if start < clip_start or end > clip_end:
451
- warnings.append(
452
- _lint_issue(
453
- "warning",
454
- idx,
455
- "crosses_clip_boundary",
456
- "Narration extends beyond its clip; it will be trimmed to the clip and may describe footage that was cut",
457
- start=start,
458
- end=end,
459
- clip_start=round(clip_start, 3),
460
- clip_end=round(clip_end, 3),
461
- )
462
- )
463
-
464
- normalized.append(
465
- {"index": idx, "start": start, "end": end, "char_count": char_count}
466
- )
467
-
468
- sorted_segments = sorted(normalized, key=lambda item: item["start"])
469
- if isinstance(narration, list) and not sorted_segments:
470
- errors.append(
471
- _lint_issue(
472
- "error",
473
- None,
474
- "empty_narration_file",
475
- "narration.json must contain at least one valid narration segment",
476
- )
477
- )
478
- for prev, curr in zip(sorted_segments, sorted_segments[1:]):
479
- if curr["start"] < prev["end"]:
480
- errors.append(
481
- _lint_issue(
482
- "error",
483
- curr["index"],
484
- "time_overlap",
485
- "Segment overlaps the previous narration segment",
486
- previous_index=prev["index"],
487
- previous_end=prev["end"],
488
- start=curr["start"],
489
- end=curr["end"],
490
- )
491
- )
492
-
493
- # Block-coverage check (full mode only; cut-mode density is measured on the mapped output
494
- # timeline, not the source timestamps used here). Coverage is diagnostic, not a creative quota:
495
- # flag wall-to-wall or sparse drafts so the Agent consciously checks the visual/audio board,
496
- # plus missing original-audio gaps and fragmented one-sentence TTS blocks.
497
- metrics = {}
498
- if mode == "full" and len(sorted_segments) >= 2:
499
- timeline_start = 0.0
500
- timeline_end = sorted_segments[-1]["end"]
501
- scene_ends = [scene["end"] for scene in scenes_analysis]
502
- if scene_ends:
503
- timeline_end = max(timeline_end, max(scene_ends))
504
- span = max(0.0, timeline_end - timeline_start)
505
- # Score coverage at the SAME conservative rate the writer is budgeted at
506
- # (_recommended_char_budget uses speech_rate * speech_safety_margin * narration_speed),
507
- # else the metric scores ~18% stricter than its own budget and false-flags under_narrated.
508
- play_rate = (
509
- CONFIG["speech_rate"] * CONFIG["speech_safety_margin"] * CONFIG["narration_speed"]
510
- )
511
- spoken = [s["char_count"] / play_rate for s in sorted_segments]
512
- spoken_ends = [
513
- min(sorted_segments[i]["end"], sorted_segments[i]["start"] + spoken[i])
514
- for i in range(len(sorted_segments))
515
- ]
516
- # Measure the UNION of estimated playback intervals. Adjacent authored
517
- # blocks can overlap the conservative text-duration estimate; summing them
518
- # double-counted time and falsely called a recap wall-to-wall.
519
- estimated_intervals = sorted(
520
- (sorted_segments[i]["start"], spoken_ends[i])
521
- for i in range(len(sorted_segments))
522
- if spoken_ends[i] > sorted_segments[i]["start"]
523
- )
524
- merged_intervals = []
525
- for start, end in estimated_intervals:
526
- if merged_intervals and start <= merged_intervals[-1][1]:
527
- merged_intervals[-1][1] = max(merged_intervals[-1][1], end)
528
- else:
529
- merged_intervals.append([start, end])
530
- narrated_seconds = sum(end - start for start, end in merged_intervals)
531
- coverage = narrated_seconds / span if span > 0 else 0.0
532
- orig_min = CONFIG["original_block_min_seconds"]
533
- orig_gaps = [sorted_segments[0]["start"] - timeline_start]
534
- orig_gaps.extend(
535
- sorted_segments[i + 1]["start"] - spoken_ends[i]
536
- for i in range(len(sorted_segments) - 1)
537
- )
538
- orig_gaps.append(timeline_end - spoken_ends[-1])
539
- original_blocks = sum(1 for g in orig_gaps if g >= orig_min)
540
- avg_chars = sum(s["char_count"] for s in sorted_segments) / len(sorted_segments)
541
- cov_target = CONFIG["narration_coverage_target"]
542
- cov_max = CONFIG["narration_coverage_max"]
543
- cov_min = CONFIG["narration_coverage_min"]
544
- block_min_chars = CONFIG["narration_block_min_chars"]
545
- metrics = {
546
- "segment_count": len(sorted_segments),
547
- "timeline_span_seconds": round(span, 2),
548
- "narrated_seconds": round(narrated_seconds, 2),
549
- "narration_coverage": round(coverage, 2),
550
- "coverage_target": cov_target,
551
- "original_block_count": original_blocks,
552
- "avg_block_chars": round(avg_chars, 1),
553
- }
554
- if coverage > cov_max:
555
- warnings.append(
556
- _lint_issue(
557
- "warning",
558
- None,
559
- "no_original_blocks",
560
- "Narration is nearly wall-to-wall — the original audio never gets to breathe. Pull back at a "
561
- "few strong moments and write NO narration there so the original plays at full volume. "
562
- "Choose those moments by audio_owner, not by a fixed ratio.",
563
- narration_coverage=round(coverage, 2),
564
- coverage_max=cov_max,
565
- )
566
- )
567
- elif coverage < cov_min:
568
- warnings.append(
569
- _lint_issue(
570
- "warning",
571
- None,
572
- "under_narrated",
573
- "Narration coverage is sparse. This is not automatically wrong: verify that every long gap is "
574
- "intentionally owned by original dialogue/action/ambience/music/silence in visual_audio_board.json. "
575
- "Only add a block when it has a specific narration job.",
576
- narration_coverage=round(coverage, 2),
577
- coverage_min=cov_min,
578
- )
579
- )
580
- if original_blocks == 0 and span >= 3 * orig_min:
581
- warnings.append(
582
- _lint_issue(
583
- "warning",
584
- None,
585
- "no_original_breaks",
586
- "No deliberate original-audio blocks — narration runs end-to-end with no gap for a strong "
587
- "original moment (a key line, an action beat, the music). Leave a few multi-second gaps "
588
- "between blocks where the original plays alone.",
589
- original_block_min_seconds=orig_min,
590
- )
591
- )
592
- if len(sorted_segments) >= 8 and avg_chars < block_min_chars:
593
- warnings.append(
594
- _lint_issue(
595
- "warning",
596
- None,
597
- "fragmented_beats",
598
- "Beats are fragmented into single short sentences; each is synthesized as a separate TTS "
599
- "utterance, which sounds choppy. Merge adjacent related sentences into one fluent BLOCK "
600
- "that completes a continuous thought; sentence count is not the target.",
601
- avg_block_chars=round(avg_chars, 1),
602
- block_min_chars=block_min_chars,
603
- )
604
- )
605
-
606
- deslop_qc = analyze_deslop_qc(
607
- [seg for seg in narration if isinstance(seg, dict)]
608
- if isinstance(narration, list)
609
- else [],
610
- work_dir=work_dir,
611
- )
612
- for blocker in deslop_qc["blockers"]:
613
- errors.append(
614
- _lint_issue(
615
- "error",
616
- blocker["index"],
617
- blocker["code"],
618
- blocker["message"],
619
- source=blocker["source"],
620
- matches=blocker.get("matches"),
621
- sentence=blocker.get("sentence"),
622
- )
623
- )
624
-
625
- report = {
626
- "ok": not errors,
627
- "error_count": len(errors),
628
- "warning_count": len(warnings),
629
- "metrics": metrics,
630
- "deslop_qc": deslop_qc,
631
- "errors": errors,
632
- "warnings": warnings,
633
- }
634
- if work_dir is not None:
635
- Path(work_dir, "deslop_qc.json").write_text(
636
- json.dumps(deslop_qc, ensure_ascii=False, indent=2), encoding="utf-8"
637
- )
638
- Path(work_dir, "narration_lint.json").write_text(
639
- json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
640
- )
641
- return report
642
-
643
-
644
- def validate_narration_or_raise(
645
- narration, scenes_analysis=None, *, clip_plan=None, mode="full", work_dir=None,
646
- require_chronological=False,
647
- ):
648
- report = lint_narration(
649
- narration, scenes_analysis, clip_plan=clip_plan, mode=mode, work_dir=work_dir,
650
- require_chronological=require_chronological,
651
- )
652
- if report["errors"]:
653
- sample = "; ".join(
654
- f"#{e['index']}: {e['code']}" for e in report["errors"][:3]
655
- )
656
- raise ValueError(f"narration.json 预检失败: {sample}; 详见 narration_lint.json")
657
- if report["warnings"]:
658
- log(
659
- f"narration lint: {len(report['warnings'])} warnings (see narration_lint.json)"
660
- )
661
- else:
662
- log("narration lint: ok")
663
- return report
664
-
665
-
666
- def _validate_narration_budget(narration, scenes_analysis):
667
- """Trim lint-validated narration to its timing budgets; drop what cannot be spoken."""
668
- del scenes_analysis # scene boundaries are advisory (lint warns); authored timing is kept
669
- cleaned = []
670
- for raw in narration:
671
- item = _normalise_narration_segment(raw)
672
- max_chars = _recommended_char_budget(item["start"], item["end"])
673
- if max_chars < 5:
674
- log(f" 丢弃过短解说段 {item['start']:.1f}-{item['end']:.1f}s")
675
- continue
676
- if _text_char_count(item["narration"]) > max_chars * 1.25:
677
- truncated = _truncate_at_sentence(item["narration"], max_chars)
678
- if truncated and _text_char_count(truncated) >= 5:
679
- log(f" 解说超预算,已截短: {item['start']:.1f}-{item['end']:.1f}s")
680
- item["narration"] = truncated
681
- else:
682
- log(
683
- f" 解说超预算且无法安全截断,已丢弃: {item['start']:.1f}-{item['end']:.1f}s"
684
- )
685
- continue
686
- item["narration"] = _clean_narration_punctuation(item["narration"])
687
- stripped = item["narration"].strip()
688
- if stripped and stripped[-1] in ",:、;,—":
689
- item["narration"] = stripped.rstrip(",:、;,—") + "。"
690
- cleaned.append(item)
691
-
692
- cleaned.sort(key=lambda n: n["start"])
693
- deduped = []
694
- for item in cleaned:
695
- if deduped and item["start"] < deduped[-1]["end"]:
696
- prev = deduped[-1]
697
- log(
698
- f" 解说时间重叠: {item['start']:.1f}-{item['end']:.1f}s vs "
699
- f"{prev['start']:.1f}-{prev['end']:.1f}s"
700
- )
701
- if _text_char_count(item["narration"]) > _text_char_count(
702
- prev["narration"]
703
- ):
704
- deduped[-1] = item
705
- else:
706
- deduped.append(item)
707
- return _post_dedup_narration(deduped)