@oh-story/dsh 0.1.12 → 0.1.14

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 (269) hide show
  1. package/README.md +18 -12
  2. package/lib/client.js +1003 -652
  3. package/lib/client.js.map +4 -4
  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 +17 -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/canvas-viewport.d.ts +31 -0
  99. package/lib/types/client/canvas-viewport.d.ts.map +1 -0
  100. package/lib/types/client/drama-production-view.d.ts +14 -2
  101. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  102. package/lib/types/client/file-activity.d.ts +1 -1
  103. package/lib/types/client/file-activity.d.ts.map +1 -1
  104. package/lib/types/client/index.d.ts.map +1 -1
  105. package/lib/types/client/production-canvas.d.ts +24 -0
  106. package/lib/types/client/production-canvas.d.ts.map +1 -0
  107. package/lib/types/client/production-prompts.d.ts +7 -1
  108. package/lib/types/client/production-prompts.d.ts.map +1 -1
  109. package/lib/types/client/production-runtime.d.ts +7 -2
  110. package/lib/types/client/production-runtime.d.ts.map +1 -1
  111. package/lib/types/drama-adapters.d.ts.map +1 -1
  112. package/lib/types/skill-provider.d.ts.map +1 -1
  113. package/lib/types/video-project.d.ts.map +1 -1
  114. package/lib/video-recap/CHANGELOG.md +123 -1
  115. package/lib/video-recap/README.en.md +20 -16
  116. package/lib/video-recap/README.md +20 -16
  117. package/lib/video-recap/manifest.json +297 -321
  118. package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
  119. package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
  120. package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
  121. package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
  122. package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
  123. package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
  124. package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
  125. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
  126. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
  127. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
  128. package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
  129. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
  130. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
  131. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
  132. package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
  133. package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
  134. package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
  135. package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
  136. package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
  137. package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
  138. package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
  139. package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
  140. package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
  141. package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
  142. package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
  143. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
  144. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
  145. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
  146. package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
  147. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
  148. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
  149. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
  150. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
  151. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
  152. package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
  153. package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
  154. package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
  155. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
  156. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
  157. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
  158. package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
  159. package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
  160. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
  161. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
  162. package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
  163. package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
  164. package/lib/video-recap/skills/video-recap/SKILL.md +28 -29
  165. package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
  166. package/lib/video-recap/skills/video-recap/references/config-playbook.md +10 -13
  167. package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
  168. package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
  169. package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
  170. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
  171. package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
  172. package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
  173. package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
  174. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +55 -17
  175. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
  176. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +96 -117
  177. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
  178. package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
  179. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
  180. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
  181. package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
  182. package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
  183. package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
  184. package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
  185. package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
  186. package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
  187. package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
  188. package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
  189. package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
  190. package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
  191. package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
  192. package/lib/video-recap/skills/video-script/SKILL.md +53 -24
  193. package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
  194. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
  195. package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
  196. package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
  197. package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
  198. package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
  199. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
  200. package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
  201. package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
  202. package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
  203. package/lib/video-recap/skills/video-script/scripts/review_runner.py +12 -11
  204. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
  205. package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
  206. package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
  207. package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
  208. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
  209. package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
  210. package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
  211. package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
  212. package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
  213. package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
  214. package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
  215. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
  216. package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
  217. package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
  218. package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
  219. package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
  220. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
  221. package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
  222. package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
  223. package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
  224. package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
  225. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
  226. package/lib/video-recap/skills/video-voiceover/SKILL.md +51 -18
  227. package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
  228. package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
  229. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +81 -135
  230. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
  231. package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
  232. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
  233. package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
  234. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
  235. package/package.json +45 -45
  236. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
  237. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
  238. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
  239. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
  240. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
  241. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
  242. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
  243. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
  244. package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
  245. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
  246. package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
  247. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
  248. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
  249. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
  250. package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
  251. package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
  252. package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
  253. package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
  254. package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
  255. package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
  256. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
  257. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
  258. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
  259. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
  260. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
  261. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
  262. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
  263. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
  264. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
  265. package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
  266. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
  267. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
  268. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
  269. /package/lib/video-recap/skills/video-cut/scripts/{narration_mapping.py → cut_qc.py} +0 -0
@@ -5,6 +5,12 @@ faithful Chinese translation spoken in the ORIGINAL speaker's cloned voice
5
5
  (`mimo-v2.5-tts-voiceclone`, same MIMO_API_KEY, pure stdlib + ffmpeg, no GPU). This differs
6
6
  from recap/解说, which overlays Chinese commentary on ducked original audio.
7
7
 
8
+ Experimental, and only on explicit request: both stages refuse to run without
9
+ `--confirm-voice-rights`, the user's confirmation that they may use this video's audio and clone
10
+ its speaker's voice. Remote calls: `prepare` sends 6-second windows of the source audio to MiMo
11
+ ASR; `render` sends each Chinese line plus the ~10-second source-voice reference clip to MiMo
12
+ voiceclone.
13
+
8
14
  Division of labour (the same as recap): CODE does only the mechanical parts; the AGENT does all
9
15
  the judgment. So there are NO text heuristics here (no sentence-splitting, hook-dedup, or junk
10
16
  filters) — those are exactly the things an LLM does better, and trying to do them in code is
@@ -21,6 +27,7 @@ brittle and case-by-case. Two stages around an agent-authored pause:
21
27
  import argparse
22
28
  import base64
23
29
  import json
30
+ import os
24
31
  import re
25
32
  import unicodedata
26
33
  import wave
@@ -35,56 +42,31 @@ from lib import (
35
42
  mimo_tts_api_call,
36
43
  run_cmd,
37
44
  )
45
+ from tts_audio import implausible_tts_duration, rejected_take_hint, rejected_take_path
38
46
 
39
47
  CLONE_MODEL = "mimo-v2.5-tts-voiceclone"
40
48
  CLONE_SR = 24000 # mimo voiceclone returns 24kHz mono PCM16 wav
41
49
  DUB_DELIVERY_SR = 48000 # explicit delivery rate; loudnorm otherwise exposes its 96kHz internal rate
42
50
  ASR_MIME = "audio/wav"
43
51
  ATEMPO_CAP = 2.0 # max compression before we trim instead (atempo>2 also sounds rushed)
44
- DUB_FAST_SPEECH_CPS = 7.0
52
+ DUB_TARGET_CPS = 5.0 # what the brief asks the translator for
53
+ DUB_FAST_SPEECH_CPS = 7.0 # lint warns at or above this density
45
54
  DUB_TRIM_RISK_CPS = 7.0
46
55
  DUB_MIN_ASR_WINDOW_SECONDS = 0.5
47
56
  DUB_TTS_STYLE_PROMPT = "自然、清晰,保持原说话人的音色与节奏,语气平稳。"
48
57
 
49
58
  DUB_SCHEMA_VERSION = 1
59
+ # dub sends the source speech to MiMo ASR and uses the source speaker's own voice as the
60
+ # voiceclone reference, so neither stage runs until the caller passes the user's explicit
61
+ # confirmation. Checked before any audio extraction or network request (fail closed).
62
+ VOICE_RIGHTS_REQUIRED = (
63
+ "[dub] 需要显式确认 --confirm-voice-rights:dub 会把源视频的音频分窗发送到 MiMo ASR 转写,"
64
+ "并截取原说话人约 10 秒声音作为 MiMo voiceclone 的参考音频来克隆其音色。"
65
+ "只有当用户有权使用这段视频与音频、且说话人同意被克隆声音时才可确认;否则不要运行 dub。"
66
+ )
50
67
  # Tolerate ~1-frame rounding in agent-estimated timings so the gate blocks only GENUINE
51
68
  # errors, not rounding noise (the old render clamped such cases via min(slot_end, nxt)).
52
69
  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
70
 
89
71
 
90
72
  def _chars_per_second(text, room):
@@ -205,37 +187,6 @@ def lint_dub_script(script, duration, work_dir=None):
205
187
  return report
206
188
 
207
189
 
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
190
  def _write_json(path, payload):
240
191
  Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
241
192
  return path
@@ -362,6 +313,11 @@ def _clone_cache_meta_path(raw_wav):
362
313
  return raw_wav.with_name(f"{raw_wav.name}.meta.json")
363
314
 
364
315
 
316
+ def _wav_seconds(path):
317
+ with wave.open(str(path), "rb") as w:
318
+ return w.getnframes() / w.getframerate()
319
+
320
+
365
321
  def _usable_clone_wav(path):
366
322
  try:
367
323
  sample_rate, channels, frames = _wav_frames(path)
@@ -376,6 +332,11 @@ def _write_clone_cache_meta(raw_wav, text, ref_identity):
376
332
 
377
333
 
378
334
  def _ensure_clone_tts(text, ref_b64, ref_identity, raw_wav):
335
+ """Reuse or synthesize one line's raw clone WAV; returns True on a cache hit.
336
+
337
+ A take far longer than reading `text` (the line plus invented speech) is the same failure
338
+ voiceover rejects: retried up to TTS_RETRIES times and never cached or time-fitted; the
339
+ latest such take is kept as `<name>.rejected.wav` for listening."""
379
340
  raw_wav = Path(raw_wav)
380
341
  expected = _clone_cache_inputs(text, ref_identity)
381
342
  meta_path = _clone_cache_meta_path(raw_wav)
@@ -385,13 +346,32 @@ def _ensure_clone_tts(text, ref_b64, ref_identity, raw_wav):
385
346
  meta = json.loads(meta_path.read_text(encoding="utf-8"))
386
347
  # A meta from the content-hash schema has no "inputs" and is simply a miss.
387
348
  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
349
+ implausible = implausible_tts_duration(text, _wav_seconds(raw_wav))
350
+ if not implausible:
351
+ return True
352
+ # Cached before this bound existed: re-synthesize instead of fitting it every rerun.
353
+ log(f" {raw_wav.name}: 不复用缓存,重新合成:{implausible}")
354
+
355
+ rejected = rejected_take_path(raw_wav)
356
+ rejected.unlink(missing_ok=True)
357
+ retries = CONFIG["tts_retries"]
358
+ implausible = None
359
+ for attempt in range(1, retries + 1):
360
+ meta_path.unlink(missing_ok=True) # never leave a sidecar vouching for an unchecked take
361
+ _clone_tts(text, ref_b64, raw_wav)
362
+ if not _usable_clone_wav(raw_wav):
363
+ raise RuntimeError(f"MiMo voiceclone returned an invalid WAV: {raw_wav}")
364
+ implausible = implausible_tts_duration(text, _wav_seconds(raw_wav))
365
+ if not implausible:
366
+ rejected.unlink(missing_ok=True)
367
+ _write_clone_cache_meta(raw_wav, text, ref_identity)
368
+ return False
369
+ os.replace(raw_wav, rejected)
370
+ if attempt < retries:
371
+ log(f" {raw_wav.name}: voiceclone 重试 {attempt + 1}/{retries}: {implausible}")
372
+ raise RuntimeError(
373
+ f"MiMo voiceclone 合成失败(«{text[:18]}»): {implausible}{rejected_take_hint(rejected)}"
374
+ )
395
375
 
396
376
 
397
377
  def _time_fit(raw_wav, fitted_wav, room_seconds):
@@ -432,11 +412,15 @@ def _build_dub_track(lines, duration, out_wav):
432
412
 
433
413
 
434
414
  def _mux(video, dub_wav, out_video):
415
+ # The picture is stream-copied as the source has it (no yuv420p / colour-tag
416
+ # normalisation in dub mode); +faststart is copy-safe and lets web players start early.
417
+ # The source's container/stream tags (a scraper's title, comment or URL, the original
418
+ # audio language) and chapters are not carried into the dubbed deliverable.
435
419
  _ffmpeg(["-i", str(video), "-i", str(dub_wav),
436
420
  "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy",
437
421
  "-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)])
422
+ "-ar", str(DUB_DELIVERY_SR), "-map_metadata", "-1", "-map_chapters", "-1",
423
+ "-shortest", "-movflags", "+faststart", str(out_video)])
440
424
 
441
425
 
442
426
  # ── stages ───────────────────────────────────────────────────────────
@@ -446,7 +430,7 @@ def _ref_window(duration, ref_start, ref_dur):
446
430
  return start, max(2.0, min(ref_dur, duration - start))
447
431
 
448
432
 
449
- def stage_prepare(video, work, asr_window, ref_start, ref_dur):
433
+ def stage_prepare(video, work, asr_window=6.0, ref_start=2.0, ref_dur=10.0):
450
434
  work.mkdir(parents=True, exist_ok=True)
451
435
  segs_dir = work / "dub_asr"
452
436
  segs_dir.mkdir(exist_ok=True)
@@ -484,7 +468,8 @@ def _brief_md(windows, duration):
484
468
  "原声重复,配音也跟着重复。",
485
469
  "2. 每句放在它在原声里被说出的时间,`start`/`end` 取那句话的起止——这样配音才跟着原声节奏走"
486
470
  "(该停顿处自然留白)。",
487
- "3. 译文忠实精简,能在自己的 `start`→`end` 区间内用正常语速说完(约 5 字/秒)。",
471
+ f"3. 译文忠实精简,能在自己的 `start`→`end` 区间内用正常语速说完(目标约 {DUB_TARGET_CPS:g} 字/秒;"
472
+ f"达到 {DUB_FAST_SPEECH_CPS:g} 字/秒 lint 会警告,渲染时可能被大幅加速或截断)。",
488
473
  "4. 只有完全被截断、没法说完整的残句,才取其中说得完整的部分。",
489
474
  "",
490
475
  "输出 `dub_script.json` = `[{\"start\": 起秒, \"end\": 止秒, \"zh\": \"中文台词\"}, ...]`,按 start "
@@ -500,16 +485,13 @@ def _brief_md(windows, duration):
500
485
  return "\n".join(lines) + "\n"
501
486
 
502
487
 
503
- def stage_render(video, work, ref_start, ref_dur):
488
+ def stage_render(video, work, ref_start=2.0, ref_dur=10.0):
504
489
  transcript = json.loads((work / "dub_transcript.json").read_text(encoding="utf-8"))
505
490
  duration = transcript["duration"]
506
491
  script = json.loads((work / "dub_script.json").read_text(encoding="utf-8"))
507
492
  # Mechanical hard gate BEFORE any voiceclone spend: empty/overlapping/out-of-range lines
508
493
  # 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)
494
+ lint = lint_dub_script(script, duration, work_dir=work)
513
495
  if lint["verdict"] != "PASS":
514
496
  raise SystemExit(f"[dub] dub_script.json failed lint; see {work / 'dub_lint.json'}")
515
497
  lines = [{"start": ln["start"], "end": ln["end"], "zh": ln["zh"]}
@@ -556,67 +538,31 @@ def stage_render(video, work, ref_start, ref_dur):
556
538
  ensure_ascii=False))
557
539
 
558
540
 
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
541
  def main():
584
542
  ap = argparse.ArgumentParser(
585
543
  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. "
544
+ "English→Chinese dub workflow, run by recap.py --edit-mode dub. Artifacts: "
545
+ "dub_transcript.json, agent-authored dub_script.json, dub_lint.json, dub_manifest.json. "
588
546
  "Uses MiMo ASR plus mimo-v2.5-tts-voiceclone; ordinary narration TTS config is separate."
589
547
  )
590
548
  )
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")
549
+ ap.add_argument("--stage", choices=["prepare", "render"], required=True,
550
+ help="prepare writes the ASR brief; render lints dub_script.json before voiceclone")
551
+ ap.add_argument("--video", required=True, help="source video")
552
+ ap.add_argument("--work-dir", required=True, help="work directory containing dub artifacts")
553
+ ap.add_argument(
554
+ "--confirm-voice-rights",
555
+ action="store_true",
556
+ help="required: the user confirms they may use this video's audio and clone its speaker's voice",
557
+ )
600
558
  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
559
+ if not args.confirm_voice_rights:
560
+ raise SystemExit(VOICE_RIGHTS_REQUIRED)
561
+ video, work = Path(args.video), Path(args.work_dir)
612
562
  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)
563
+ stage_prepare(video, work)
618
564
  else:
619
- stage_render(video, work, args.ref_start, args.ref_dur)
565
+ stage_render(video, work)
620
566
 
621
567
 
622
568
  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)