@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
@@ -21,6 +21,7 @@ brittle and case-by-case. Two stages around an agent-authored pause:
21
21
  import argparse
22
22
  import base64
23
23
  import json
24
+ import os
24
25
  import re
25
26
  import unicodedata
26
27
  import wave
@@ -35,13 +36,15 @@ from lib import (
35
36
  mimo_tts_api_call,
36
37
  run_cmd,
37
38
  )
39
+ from tts_audio import implausible_tts_duration, rejected_take_hint, rejected_take_path
38
40
 
39
41
  CLONE_MODEL = "mimo-v2.5-tts-voiceclone"
40
42
  CLONE_SR = 24000 # mimo voiceclone returns 24kHz mono PCM16 wav
41
43
  DUB_DELIVERY_SR = 48000 # explicit delivery rate; loudnorm otherwise exposes its 96kHz internal rate
42
44
  ASR_MIME = "audio/wav"
43
45
  ATEMPO_CAP = 2.0 # max compression before we trim instead (atempo>2 also sounds rushed)
44
- DUB_FAST_SPEECH_CPS = 7.0
46
+ DUB_TARGET_CPS = 5.0 # what the brief asks the translator for
47
+ DUB_FAST_SPEECH_CPS = 7.0 # lint warns at or above this density
45
48
  DUB_TRIM_RISK_CPS = 7.0
46
49
  DUB_MIN_ASR_WINDOW_SECONDS = 0.5
47
50
  DUB_TTS_STYLE_PROMPT = "自然、清晰,保持原说话人的音色与节奏,语气平稳。"
@@ -50,41 +53,6 @@ DUB_SCHEMA_VERSION = 1
50
53
  # Tolerate ~1-frame rounding in agent-estimated timings so the gate blocks only GENUINE
51
54
  # errors, not rounding noise (the old render clamped such cases via min(slot_end, nxt)).
52
55
  DUB_TIMING_EPS = 0.05
53
- DUB_LINT_SCHEMA = {
54
- "schema_version": DUB_SCHEMA_VERSION,
55
- "artifact": "dub_lint.json",
56
- "fields": {
57
- "verdict": "PASS|FAIL",
58
- "issues": "[{severity, code, line, message, start, end}]",
59
- "summary": "{lines, errors, warnings, max_chars_per_second, trim_risk_lines}",
60
- },
61
- }
62
- DUB_REVIEW_SCHEMA = {
63
- "schema_version": DUB_SCHEMA_VERSION,
64
- "artifact": "dub_review.json",
65
- "fields": {
66
- "verdict": "PASS|REVISE|FAIL",
67
- "checks": "{faithful_to_source, spoken_chinese, speaker_tone, timing_fit, platform_fit}",
68
- "highest_return_edits": "[{line, start, issue, suggestion}]",
69
- "notes": "human/agent review notes; script-level output is deterministic pre-review guidance",
70
- },
71
- }
72
- DUB_ARTIFACT_SCHEMAS = {
73
- "dub_transcript.json": {
74
- "schema_version": DUB_SCHEMA_VERSION,
75
- "shape": {"video": "path", "duration": "seconds", "windows": [{"start": 0.0, "end": 6.0, "text": "English ASR"}]},
76
- },
77
- "dub_script.json": {
78
- "schema_version": DUB_SCHEMA_VERSION,
79
- "shape": [{"start": 0.0, "end": 2.4, "zh": "中文台词"}],
80
- },
81
- "dub_manifest.json": {
82
- "schema_version": DUB_SCHEMA_VERSION,
83
- "shape": {"video": "path", "duration": "seconds", "lines": [{"start": 0.0, "end": 2.4, "zh": "中文台词", "tts_cache": "hit|miss", "fitted_wav": "path", "fitted_dur": 1.8, "room": 2.4}]},
84
- },
85
- "dub_lint.json": DUB_LINT_SCHEMA,
86
- "dub_review.json": DUB_REVIEW_SCHEMA,
87
- }
88
56
 
89
57
 
90
58
  def _chars_per_second(text, room):
@@ -205,37 +173,6 @@ def lint_dub_script(script, duration, work_dir=None):
205
173
  return report
206
174
 
207
175
 
208
- def build_dub_review(script, transcript, lint=None):
209
- """Script-level review scaffold: deterministic timing/naturalness signals for agent review."""
210
- duration = float(transcript["duration"])
211
- lint = lint or lint_dub_script(script, duration)
212
- lines = _normalize_dub_script(script) if isinstance(script, list) else []
213
- edits = []
214
- for issue in lint["issues"]:
215
- if issue["severity"] in {"error", "warning"}:
216
- edits.append({
217
- "line": issue["line"],
218
- "start": issue.get("start"), # _issue only records start when the line had one
219
- "issue": f"{issue['code']}: {issue['message']}",
220
- "suggestion": "缩短译文、调整 start/end,或回到 transcript 核对原句边界。",
221
- })
222
- verdict = "FAIL" if lint["verdict"] == "FAIL" else ("REVISE" if edits else "PASS")
223
- return {
224
- "schema_version": DUB_SCHEMA_VERSION,
225
- "verdict": verdict,
226
- "checks": {
227
- "faithful_to_source": "needs_agent_review",
228
- "spoken_chinese": "REVISE" if any(i["code"] == "fast_speech" for i in lint["issues"]) else "PASS",
229
- "speaker_tone": "needs_agent_review",
230
- "timing_fit": verdict,
231
- "platform_fit": "needs_agent_review",
232
- },
233
- "highest_return_edits": edits[:8],
234
- "notes": "Deterministic script-level review; an agent/human should fill semantic fidelity and tone judgments against dub_transcript.json.",
235
- "coverage": {"transcript_windows": len(transcript["windows"]), "script_lines": len(lines)},
236
- }
237
-
238
-
239
176
  def _write_json(path, payload):
240
177
  Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
241
178
  return path
@@ -362,6 +299,11 @@ def _clone_cache_meta_path(raw_wav):
362
299
  return raw_wav.with_name(f"{raw_wav.name}.meta.json")
363
300
 
364
301
 
302
+ def _wav_seconds(path):
303
+ with wave.open(str(path), "rb") as w:
304
+ return w.getnframes() / w.getframerate()
305
+
306
+
365
307
  def _usable_clone_wav(path):
366
308
  try:
367
309
  sample_rate, channels, frames = _wav_frames(path)
@@ -376,6 +318,11 @@ def _write_clone_cache_meta(raw_wav, text, ref_identity):
376
318
 
377
319
 
378
320
  def _ensure_clone_tts(text, ref_b64, ref_identity, raw_wav):
321
+ """Reuse or synthesize one line's raw clone WAV; returns True on a cache hit.
322
+
323
+ A take far longer than reading `text` (the line plus invented speech) is the same failure
324
+ voiceover rejects: retried up to TTS_RETRIES times and never cached or time-fitted; the
325
+ latest such take is kept as `<name>.rejected.wav` for listening."""
379
326
  raw_wav = Path(raw_wav)
380
327
  expected = _clone_cache_inputs(text, ref_identity)
381
328
  meta_path = _clone_cache_meta_path(raw_wav)
@@ -385,13 +332,32 @@ def _ensure_clone_tts(text, ref_b64, ref_identity, raw_wav):
385
332
  meta = json.loads(meta_path.read_text(encoding="utf-8"))
386
333
  # A meta from the content-hash schema has no "inputs" and is simply a miss.
387
334
  if meta.get("inputs") == expected and _usable_clone_wav(raw_wav):
388
- return True
389
-
390
- _clone_tts(text, ref_b64, raw_wav)
391
- if not _usable_clone_wav(raw_wav):
392
- raise RuntimeError(f"MiMo voiceclone returned an invalid WAV: {raw_wav}")
393
- _write_clone_cache_meta(raw_wav, text, ref_identity)
394
- return False
335
+ implausible = implausible_tts_duration(text, _wav_seconds(raw_wav))
336
+ if not implausible:
337
+ return True
338
+ # Cached before this bound existed: re-synthesize instead of fitting it every rerun.
339
+ log(f" {raw_wav.name}: 不复用缓存,重新合成:{implausible}")
340
+
341
+ rejected = rejected_take_path(raw_wav)
342
+ rejected.unlink(missing_ok=True)
343
+ retries = CONFIG["tts_retries"]
344
+ implausible = None
345
+ for attempt in range(1, retries + 1):
346
+ meta_path.unlink(missing_ok=True) # never leave a sidecar vouching for an unchecked take
347
+ _clone_tts(text, ref_b64, raw_wav)
348
+ if not _usable_clone_wav(raw_wav):
349
+ raise RuntimeError(f"MiMo voiceclone returned an invalid WAV: {raw_wav}")
350
+ implausible = implausible_tts_duration(text, _wav_seconds(raw_wav))
351
+ if not implausible:
352
+ rejected.unlink(missing_ok=True)
353
+ _write_clone_cache_meta(raw_wav, text, ref_identity)
354
+ return False
355
+ os.replace(raw_wav, rejected)
356
+ if attempt < retries:
357
+ log(f" {raw_wav.name}: voiceclone 重试 {attempt + 1}/{retries}: {implausible}")
358
+ raise RuntimeError(
359
+ f"MiMo voiceclone 合成失败(«{text[:18]}»): {implausible}{rejected_take_hint(rejected)}"
360
+ )
395
361
 
396
362
 
397
363
  def _time_fit(raw_wav, fitted_wav, room_seconds):
@@ -432,11 +398,15 @@ def _build_dub_track(lines, duration, out_wav):
432
398
 
433
399
 
434
400
  def _mux(video, dub_wav, out_video):
401
+ # The picture is stream-copied as the source has it (no yuv420p / colour-tag
402
+ # normalisation in dub mode); +faststart is copy-safe and lets web players start early.
403
+ # The source's container/stream tags (a scraper's title, comment or URL, the original
404
+ # audio language) and chapters are not carried into the dubbed deliverable.
435
405
  _ffmpeg(["-i", str(video), "-i", str(dub_wav),
436
406
  "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy",
437
407
  "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", "aac", "-b:a", "192k",
438
- "-ar", str(DUB_DELIVERY_SR),
439
- "-shortest", str(out_video)])
408
+ "-ar", str(DUB_DELIVERY_SR), "-map_metadata", "-1", "-map_chapters", "-1",
409
+ "-shortest", "-movflags", "+faststart", str(out_video)])
440
410
 
441
411
 
442
412
  # ── stages ───────────────────────────────────────────────────────────
@@ -446,7 +416,7 @@ def _ref_window(duration, ref_start, ref_dur):
446
416
  return start, max(2.0, min(ref_dur, duration - start))
447
417
 
448
418
 
449
- def stage_prepare(video, work, asr_window, ref_start, ref_dur):
419
+ def stage_prepare(video, work, asr_window=6.0, ref_start=2.0, ref_dur=10.0):
450
420
  work.mkdir(parents=True, exist_ok=True)
451
421
  segs_dir = work / "dub_asr"
452
422
  segs_dir.mkdir(exist_ok=True)
@@ -484,7 +454,8 @@ def _brief_md(windows, duration):
484
454
  "原声重复,配音也跟着重复。",
485
455
  "2. 每句放在它在原声里被说出的时间,`start`/`end` 取那句话的起止——这样配音才跟着原声节奏走"
486
456
  "(该停顿处自然留白)。",
487
- "3. 译文忠实精简,能在自己的 `start`→`end` 区间内用正常语速说完(约 5 字/秒)。",
457
+ f"3. 译文忠实精简,能在自己的 `start`→`end` 区间内用正常语速说完(目标约 {DUB_TARGET_CPS:g} 字/秒;"
458
+ f"达到 {DUB_FAST_SPEECH_CPS:g} 字/秒 lint 会警告,渲染时可能被大幅加速或截断)。",
488
459
  "4. 只有完全被截断、没法说完整的残句,才取其中说得完整的部分。",
489
460
  "",
490
461
  "输出 `dub_script.json` = `[{\"start\": 起秒, \"end\": 止秒, \"zh\": \"中文台词\"}, ...]`,按 start "
@@ -500,16 +471,13 @@ def _brief_md(windows, duration):
500
471
  return "\n".join(lines) + "\n"
501
472
 
502
473
 
503
- def stage_render(video, work, ref_start, ref_dur):
474
+ def stage_render(video, work, ref_start=2.0, ref_dur=10.0):
504
475
  transcript = json.loads((work / "dub_transcript.json").read_text(encoding="utf-8"))
505
476
  duration = transcript["duration"]
506
477
  script = json.loads((work / "dub_script.json").read_text(encoding="utf-8"))
507
478
  # Mechanical hard gate BEFORE any voiceclone spend: empty/overlapping/out-of-range lines
508
479
  # cannot produce a publishable dub, so fail fast instead of paying for a broken render.
509
- lint = lint_dub_script(script, duration)
510
- _write_json(work / "dub_lint.json", lint)
511
- review = build_dub_review(script, transcript, lint)
512
- _write_json(work / "dub_review.json", review)
480
+ lint = lint_dub_script(script, duration, work_dir=work)
513
481
  if lint["verdict"] != "PASS":
514
482
  raise SystemExit(f"[dub] dub_script.json failed lint; see {work / 'dub_lint.json'}")
515
483
  lines = [{"start": ln["start"], "end": ln["end"], "zh": ln["zh"]}
@@ -556,67 +524,24 @@ def stage_render(video, work, ref_start, ref_dur):
556
524
  ensure_ascii=False))
557
525
 
558
526
 
559
- def stage_lint(work):
560
- transcript = json.loads((work / "dub_transcript.json").read_text(encoding="utf-8"))
561
- script = json.loads((work / "dub_script.json").read_text(encoding="utf-8"))
562
- lint = lint_dub_script(script, transcript["duration"])
563
- path = _write_json(work / "dub_lint.json", lint)
564
- print(json.dumps({"status": "dub_linted", "verdict": lint["verdict"], "issues": len(lint["issues"]), "dub_lint": str(path)}, ensure_ascii=False))
565
- return lint
566
-
567
-
568
- def stage_review(work):
569
- transcript = json.loads((work / "dub_transcript.json").read_text(encoding="utf-8"))
570
- script = json.loads((work / "dub_script.json").read_text(encoding="utf-8"))
571
- lint = lint_dub_script(script, transcript["duration"])
572
- _write_json(work / "dub_lint.json", lint)
573
- review = build_dub_review(script, transcript, lint)
574
- path = _write_json(work / "dub_review.json", review)
575
- print(json.dumps({"status": "dub_reviewed", "verdict": review["verdict"], "edits": len(review["highest_return_edits"]), "dub_review": str(path)}, ensure_ascii=False))
576
- return review
577
-
578
-
579
- def print_schemas():
580
- print(json.dumps(DUB_ARTIFACT_SCHEMAS, ensure_ascii=False, indent=2))
581
-
582
-
583
527
  def main():
584
528
  ap = argparse.ArgumentParser(
585
529
  description=(
586
- "English→Chinese dub workflow. Artifacts: dub_transcript.json, "
587
- "agent-authored dub_script.json, dub_lint.json, dub_review.json, dub_manifest.json. "
530
+ "English→Chinese dub workflow, run by recap.py --edit-mode dub. Artifacts: "
531
+ "dub_transcript.json, agent-authored dub_script.json, dub_lint.json, dub_manifest.json. "
588
532
  "Uses MiMo ASR plus mimo-v2.5-tts-voiceclone; ordinary narration TTS config is separate."
589
533
  )
590
534
  )
591
- ap.add_argument("--stage", choices=["prepare", "lint", "review", "render"], required=False,
592
- help="prepare ASR brief; lint/review dub_script.json; render writes lint/review before voiceclone")
593
- ap.add_argument("--video", required=False, help="source video (required for prepare/render)")
594
- ap.add_argument("--work-dir", required=False, help="work directory containing dub artifacts")
595
- ap.add_argument("--asr-window", type=float, default=6.0)
596
- ap.add_argument("--ref-start", type=float, default=2.0)
597
- ap.add_argument("--ref-dur", type=float, default=10.0)
598
- ap.add_argument("--print-schema", action="store_true",
599
- help="print JSON schemas for dub_transcript/script/lint/review/manifest and exit")
535
+ ap.add_argument("--stage", choices=["prepare", "render"], required=True,
536
+ help="prepare writes the ASR brief; render lints dub_script.json before voiceclone")
537
+ ap.add_argument("--video", required=True, help="source video")
538
+ ap.add_argument("--work-dir", required=True, help="work directory containing dub artifacts")
600
539
  args = ap.parse_args()
601
- if args.print_schema:
602
- print_schemas()
603
- return
604
- if not args.stage:
605
- ap.error("--stage is required unless --print-schema is used")
606
- if not args.work_dir:
607
- ap.error("--work-dir is required")
608
- work = Path(args.work_dir)
609
- if args.stage in {"prepare", "render"} and not args.video:
610
- ap.error("--video is required for prepare/render")
611
- video = Path(args.video) if args.video else None
540
+ video, work = Path(args.video), Path(args.work_dir)
612
541
  if args.stage == "prepare":
613
- stage_prepare(video, work, args.asr_window, args.ref_start, args.ref_dur)
614
- elif args.stage == "lint":
615
- stage_lint(work)
616
- elif args.stage == "review":
617
- stage_review(work)
542
+ stage_prepare(video, work)
618
543
  else:
619
- stage_render(video, work, args.ref_start, args.ref_dur)
544
+ stage_render(video, work)
620
545
 
621
546
 
622
547
  if __name__ == "__main__":
@@ -120,10 +120,7 @@ CONFIG = {
120
120
  "mimo_tts_model": os.environ.get("MIMO_TTS_MODEL", DEFAULT_MIMO_TTS_MODEL),
121
121
  "mimo_tts_voice": os.environ.get("MIMO_TTS_VOICE", "冰糖"),
122
122
  "voice_ref": os.environ.get("VOICE_REF", "").strip(), # optional arbitrary reference audio for narration voice clone
123
- "mimo_tts_style": os.environ.get(
124
- "MIMO_TTS_STYLE",
125
- "自然、清晰、有感染力,像在给观众讲故事;随剧情起伏,该紧张时紧张、该动情时动情,不平铺直叙。",
126
- ),
123
+ "mimo_tts_style": "自然、清晰、有感染力,像在给观众讲故事;随剧情起伏,该紧张时紧张、该动情时动情,不平铺直叙。",
127
124
  "tts_provider": os.environ.get("TTS_PROVIDER", "auto").strip().lower(),
128
125
  "tts_timeout": env_int("TTS_TIMEOUT", 300, minimum=1),
129
126
  "fish_api_key": os.environ.get("FISH_API_KEY", ""),
@@ -135,16 +132,17 @@ CONFIG = {
135
132
  "mimo_disable_thinking": env_bool("MIMO_DISABLE_THINKING", True),
136
133
  "breath_ms": 250, # 段间呼吸空间(ms);block recap 块内连贯、块间留原声呼吸
137
134
  "narration_speed": env_float("NARRATION_SPEED", 1.15, minimum=0.5), # 解说整体提速(atempo),默认回到可懂区间;长片可设 1.0
138
- "narration_cumulative_tempo_max": env_float("NARRATION_CUMULATIVE_TEMPO_MAX", 1.35, minimum=1.0), # TTS rate × 全局 atempo × 段内 atempo 的累计上限
139
- "narration_cumulative_tempo_hard_max": env_float("NARRATION_CUMULATIVE_TEMPO_HARD_MAX", 1.40, minimum=1.0), # QC/阻断硬上限
140
- "tts_segment_tempo_max": env_float("TTS_SEGMENT_TEMPO_MAX", 1.20, minimum=1.0), # 兼容旧段内 atempo 上限;实际会被累计预算收紧
141
- "tts_dynamic_params": True, # 启用动态语速调节
135
+ "narration_cumulative_tempo_max": 1.35, # TTS rate × 全局 atempo × 段内 atempo 的累计上限
136
+ "narration_cumulative_tempo_hard_max": 1.40, # QC/阻断硬上限
137
+ "tts_segment_tempo_max": 1.20, # 兼容旧段内 atempo 上限;实际会被累计预算收紧
142
138
  "tts_workers": env_int("TTS_WORKERS", 4, minimum=1), # TTS 并行合成线程数
143
139
  "tts_retries": env_int("TTS_RETRIES", 3, minimum=1), # 单段 TTS 失败重试次数
140
+ # 段音频时长合理性下限语速(字/秒,拉丁词按 1.5):比它还慢说明 TTS 多读了内容,按失败重试;0 关闭
141
+ "tts_min_speech_rate": env_float("TTS_MIN_SPEECH_RATE", 2.5, minimum=0.0),
144
142
  "allow_partial_tts": env_bool("ALLOW_PARTIAL_TTS", False),
145
- "tts_segment_normalize": env_bool("TTS_SEGMENT_NORMALIZE", True), # 单段 TTS RMS 归一,降低段间忽大忽小
146
- "tts_segment_target_rms_dbfs": env_float("TTS_SEGMENT_TARGET_RMS_DBFS", -20.0),
147
- "tts_segment_peak_limit": env_float("TTS_SEGMENT_PEAK_LIMIT", 0.98, minimum=0.1),
143
+ "tts_segment_normalize": True, # 单段 TTS RMS 归一,降低段间忽大忽小
144
+ "tts_segment_target_rms_dbfs": -20.0,
145
+ "tts_segment_peak_limit": 0.98,
148
146
  }
149
147
 
150
148
 
@@ -327,26 +325,3 @@ def api_call(payload, *, api_url, api_key, api_env_var, max_retries=8):
327
325
  raise RuntimeError(f"API 调用失败 {max_retries} 次: {safe_error}")
328
326
 
329
327
 
330
- def _text_char_count(text):
331
- """计算文本的有效字数(去除标点和空白,这些不占 TTS 朗读时间)。"""
332
- return len(re.sub(r'[,。!?、;:…“”‘’《》〈〉\s"\'「」『』()()【】\[\]—~·,.!?;:\\-]', '', text))
333
-
334
-
335
- def _truncate_at_sentence(text, max_chars):
336
- """在句子边界截断,不产生残句。max_chars 按有效字符计(不含标点空白)。"""
337
- if _text_char_count(text) <= max_chars:
338
- return text
339
- eff = 0
340
- cutoff = len(text)
341
- for i, ch in enumerate(text):
342
- eff += 1 if _text_char_count(ch) else 0
343
- if eff > max_chars:
344
- cutoff = i + 1
345
- break
346
- idx = max(text[:cutoff].rfind(sep) for sep in ['。', '!', '?', '!', '?'])
347
- if idx > 0:
348
- return text[:idx + 1]
349
- idx = max(text[:cutoff].rfind(sep) for sep in [',', '、', ';', ','])
350
- if idx > 3:
351
- return text[:idx] + '。'
352
- return ""
@@ -13,13 +13,18 @@ from pathlib import Path
13
13
  from lib import CONFIG, _sanitize_api_error
14
14
 
15
15
 
16
+ def fish_speed(rate):
17
+ """The numeric prosody speed Fish Audio receives for a '+5%'-style rate."""
18
+ return 1.0 + float(rate.rstrip("%")) / 100.0
19
+
20
+
16
21
  def _fish_payload(text, rate):
17
22
  payload = {
18
23
  "text": text,
19
24
  "format": "wav",
20
25
  "normalize": True,
21
26
  "prosody": {
22
- "speed": 1.0 + float(rate.rstrip("%")) / 100.0,
27
+ "speed": fish_speed(rate),
23
28
  "volume": 0,
24
29
  "normalize_loudness": True,
25
30
  },
@@ -1,4 +1,4 @@
1
- """Loudness normalization for synthesized TTS blocks.
1
+ """Loudness normalization and a duration sanity bound for synthesized TTS blocks.
2
2
 
3
3
  Split out of voiceover.py to keep that module within the bundle's per-module line
4
4
  budget. Dependency-free on purpose (stdlib `wave` + `array` only) so QC and assembly
@@ -7,6 +7,7 @@ can reuse the returned metadata without pulling in the synthesis path.
7
7
  import array
8
8
  import math
9
9
  import os
10
+ import re
10
11
  import sys
11
12
  import wave
12
13
  from pathlib import Path
@@ -100,3 +101,65 @@ def _maybe_normalize_tts_wav(output_wav):
100
101
  )
101
102
  os.replace(tmp, output_wav)
102
103
  return meta
104
+
105
+
106
+ # Speech units for the duration bound: a CJK character or a digit (half or full width, read
107
+ # one by one as in 二〇二六) is one syllable, "%" is three (百分之), a Latin word about one and
108
+ # a half. Pause marks get a fixed allowance each; "……" is a long pause.
109
+ _CJK_OR_DIGIT = re.compile(r"[\u3007\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff0-9\uff10-\uff19]")
110
+ _PERCENT = re.compile(r"[%\uff05]")
111
+ _PERCENT_UNITS = 3
112
+ _LATIN_WORD = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?")
113
+ _LONG_PAUSE = re.compile(r"……|\.\.\.|——")
114
+ _PAUSE_MARK = re.compile(r"[,。!?;:、,.!?;:]")
115
+ TTS_EDGE_SILENCE_SECONDS = 1.5
116
+ TTS_PAUSE_SECONDS = 0.4
117
+ TTS_LONG_PAUSE_SECONDS = 0.8
118
+
119
+
120
+ def speech_units(text):
121
+ """Syllable-like units a faithful reading of `text` speaks (pauses not included)."""
122
+ rest = _LONG_PAUSE.sub("", text)
123
+ return (len(_CJK_OR_DIGIT.findall(rest)) + _PERCENT_UNITS * len(_PERCENT.findall(rest))
124
+ + 1.5 * len(_LATIN_WORD.findall(rest)))
125
+
126
+
127
+ def max_plausible_tts_seconds(text):
128
+ """Longest a faithful reading of `text` can last, or None when the bound is disabled.
129
+
130
+ Reading every unit at TTS_MIN_SPEECH_RATE (default 2.5 units/s; MiMo's median is ~3.5
131
+ CJK chars/s) plus generous pause and edge-silence allowances. Audio past this is the
132
+ provider reading more than the text: MiMo TTS has returned a sentence followed by
133
+ invented speech at 1.17x this bound, while the slowest of ~100 faithful MiMo segments
134
+ from real runs reached 0.86x."""
135
+ min_rate = CONFIG["tts_min_speech_rate"]
136
+ if min_rate <= 0:
137
+ return None
138
+ long_pauses = len(_LONG_PAUSE.findall(text))
139
+ rest = _LONG_PAUSE.sub("", text)
140
+ return (TTS_EDGE_SILENCE_SECONDS + speech_units(text) / min_rate
141
+ + TTS_PAUSE_SECONDS * len(_PAUSE_MARK.findall(rest))
142
+ + TTS_LONG_PAUSE_SECONDS * long_pauses)
143
+
144
+
145
+ def implausible_tts_duration(text, duration):
146
+ """A user-facing reason when `duration` is too long to be a reading of `text`, else None."""
147
+ limit = max_plausible_tts_seconds(text)
148
+ if limit is None or duration <= limit:
149
+ return None
150
+ return (f"音频 {duration:.1f}s 超过这段文字({len(text)} 字)的合理上限 {limit:.1f}s,"
151
+ "疑似 TTS 多读了原稿以外的内容(幻读),这段音频不会被缓存或交付")
152
+
153
+
154
+ def rejected_take_path(output_wav):
155
+ """Where the latest take rejected by the bound is kept for listening."""
156
+ output_wav = Path(output_wav)
157
+ return output_wav.with_name(f"{output_wav.stem}.rejected{output_wav.suffix}")
158
+
159
+
160
+ def rejected_take_hint(rejected):
161
+ """Where the last rejected take is, and how to accept such takes; empty when none was kept."""
162
+ if not Path(rejected).is_file():
163
+ return ""
164
+ return (f";最后一次被拒的音频保留在 {rejected},试听确认确实只读了原稿时,"
165
+ "可调低 TTS_MIN_SPEECH_RATE(0 关闭检查)后重跑")
@@ -0,0 +1,99 @@
1
+ """Content-addressed store for synthesized narration blocks.
2
+
3
+ A block's audio depends on its text and synthesis settings, never on where the block sits in
4
+ narration.json. Entries live in `tts_segments/cache/<digest>.wav` next to a `<digest>.json`
5
+ sidecar recording the exact inputs, the stored WAV's `{size, mtime_ns}` and the facts the
6
+ rerun needs (spoken text, duration, normalization, provider receipt). The index-named
7
+ `narr_NNN.wav` files that tts_meta.json and assemble read are views of an entry: a hard link,
8
+ or a copy where the filesystem has no links. Deleting or inserting a block therefore only
9
+ re-synthesizes the blocks whose own inputs changed.
10
+ """
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import shutil
15
+ import threading
16
+ from pathlib import Path
17
+
18
+ from approved_text_policy import write_json_atomically
19
+ from lib import file_identity
20
+
21
+ CACHE_DIR_NAME = "cache"
22
+ _RECORD_KEYS = ("spoken_text", "audio_duration", "normalization", "provider_receipt")
23
+
24
+
25
+ def cache_entry(tts_dir, cache_inputs):
26
+ """(stored WAV, sidecar) paths for one set of synthesis inputs."""
27
+ blob = json.dumps(cache_inputs, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
28
+ digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()[:32]
29
+ base = Path(tts_dir) / CACHE_DIR_NAME
30
+ return base / f"{digest}.wav", base / f"{digest}.json"
31
+
32
+
33
+ def legacy_sidecar_path(output_wav):
34
+ """The per-index sidecar earlier versions wrote; never read (its keys differ), only removed."""
35
+ return Path(str(output_wav) + ".cache.json")
36
+
37
+
38
+ def load(tts_dir, cache_inputs):
39
+ """The stored record for these inputs, or None when absent or its WAV changed since."""
40
+ stored_wav, sidecar = cache_entry(tts_dir, cache_inputs)
41
+ if not sidecar.exists():
42
+ return None
43
+ # This skill wrote the sidecar: one it cannot parse is a bug to surface, not a silent miss
44
+ # that re-bills the provider.
45
+ try:
46
+ data = json.loads(sidecar.read_text(encoding="utf-8"))
47
+ except ValueError as exc:
48
+ raise RuntimeError(f"TTS 缓存 sidecar 损坏: {sidecar}") from exc
49
+ if not isinstance(data, dict) or data.get("settings") != cache_inputs:
50
+ return None
51
+ if not stored_wav.is_file() or stored_wav.stat().st_size == 0:
52
+ return None
53
+ return data if data.get("audio") == file_identity(stored_wav) else None
54
+
55
+
56
+ def store(output_wav, cache_inputs, record):
57
+ """Publish a finished block WAV under its inputs; returns the sidecar payload."""
58
+ stored_wav, sidecar = cache_entry(Path(output_wav).parent, cache_inputs)
59
+ stored_wav.parent.mkdir(exist_ok=True)
60
+ tmp = _tmp_path(stored_wav)
61
+ _link_or_copy(output_wav, tmp)
62
+ # Identity of exactly the bytes being published: if a concurrent worker replaces the entry
63
+ # with another take of the same inputs, the sidecars disagree with the WAV and miss safely.
64
+ identity = file_identity(tmp)
65
+ os.replace(tmp, stored_wav)
66
+ data = {"settings": cache_inputs, "audio": identity,
67
+ **{key: record.get(key) for key in _RECORD_KEYS}}
68
+ write_json_atomically(sidecar, data)
69
+ return data
70
+
71
+
72
+ def materialize(tts_dir, cache_inputs, output_wav):
73
+ """Point the index-named `output_wav` at the stored WAV for these inputs."""
74
+ stored_wav, _sidecar = cache_entry(tts_dir, cache_inputs)
75
+ output_wav = Path(output_wav)
76
+ if output_wav.exists():
77
+ try:
78
+ if os.path.samefile(stored_wav, output_wav):
79
+ return
80
+ except OSError:
81
+ pass
82
+ # Anything else is re-linked (or re-copied): {size, mtime_ns} cannot tell two takes of the
83
+ # same length apart when they were written within one timestamp tick, and a stale slot
84
+ # would silently play the wrong block.
85
+ tmp = _tmp_path(output_wav)
86
+ _link_or_copy(stored_wav, tmp)
87
+ os.replace(tmp, output_wav)
88
+
89
+
90
+ def _tmp_path(path):
91
+ return path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
92
+
93
+
94
+ def _link_or_copy(source, destination):
95
+ destination.unlink(missing_ok=True)
96
+ try:
97
+ os.link(source, destination)
98
+ except OSError:
99
+ shutil.copy2(source, destination)