@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
@@ -7,7 +7,6 @@ import tempfile
7
7
  import time
8
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
9
  from pathlib import Path
10
- from threading import Lock
11
10
 
12
11
  from approved_text_policy import (
13
12
  PRESERVE_APPROVED_TEXT_POLICY,
@@ -18,12 +17,10 @@ from approved_text_policy import (
18
17
  validate_required_texts,
19
18
  write_json_atomically as _write_tts_meta_atomically,
20
19
  )
21
- from providers.fish_audio import synthesize_fish_audio
20
+ from providers.fish_audio import fish_speed, synthesize_fish_audio
22
21
  import providers.index_tts as index_provider
23
22
  from lib import (
24
23
  CONFIG,
25
- _text_char_count,
26
- _truncate_at_sentence,
27
24
  file_identity,
28
25
  get_video_duration,
29
26
  log,
@@ -31,21 +28,17 @@ from lib import (
31
28
  narration_tempo_budget,
32
29
  run_cmd,
33
30
  )
34
-
35
- # Re-exported: tests and callers reach these through voiceover, the module owns the flow.
36
- from tts_audio import _maybe_normalize_tts_wav, _normalize_tts_wav_rms # noqa: F401
31
+ import tts_cache
32
+ from tts_audio import (
33
+ _maybe_normalize_tts_wav,
34
+ implausible_tts_duration,
35
+ rejected_take_hint,
36
+ rejected_take_path,
37
+ )
37
38
 
38
39
  SUPPORTED_TTS_ENGINES = {"mimo-tts", "fish-audio", "index-tts"}
39
40
  SEGMENT_AUDIO_SCHEMA_VERSION = 1
40
41
  VOICE_REFERENCE_PREP_VERSION = 1
41
- _VOICE_REFERENCE_LOCK = Lock()
42
- # Process-wide voice-reference state; prepared bytes are invocation-scoped.
43
- _VOICE_REFERENCE_STATE_KEYS = (
44
- "voice_ref_b64",
45
- "voice_ref_snapshot_path",
46
- "voice_ref_snapshot_signature",
47
- "voice_ref_snapshot_locked",
48
- )
49
42
 
50
43
 
51
44
  def authored_text_policy():
@@ -122,68 +115,70 @@ def _clean_narration_text(text):
122
115
  return re.sub(r'\s+', ' ', text).strip()
123
116
 
124
117
 
125
- def _synthesize_segment(i, seg, narration, tts_dir, engine, prepared=None):
118
+ def _synthesize_segment(i, seg, narration, tts_dir, engine, prepared=None, voice_ref_b64=None):
126
119
  """合成单个 TTS 段(线程安全),支持 resume 跳过已有文件。
127
120
 
128
121
  `prepared` is the `_prepare_tts_segment` tuple of a segment synthesize_tts already probed
129
- as a cache miss; passing it skips a second sidecar read + settings resolution."""
122
+ as a cache miss; passing it skips a second sidecar read + settings resolution.
123
+ `voice_ref_b64` is the reference audio synthesize_tts transcoded once for this run."""
130
124
  if prepared is None:
131
125
  prepared = _prepare_tts_segment(i, seg, narration, tts_dir, engine)
132
126
  if prepared is None:
133
127
  return None
134
- cached = _reuse_tts_segment_cache(i, seg, prepared[1], prepared[4], engine)
128
+ cached = _reuse_tts_segment_cache(
129
+ i, seg, prepared[1], prepared[4], engine, _parse_rate_offset(prepared[2]))
135
130
  if cached:
136
131
  return cached
137
132
  text, output_wav, rate, pitch, cache_inputs = prepared
138
133
 
134
+ # output_wav may be a hard link into the cache (another block's audio until now): unlink it
135
+ # so the provider writes a new file instead of overwriting that shared audio in place.
136
+ _cleanup_partial_tts_outputs(output_wav)
139
137
  provider_receipt = _run_tts_engine(
140
- engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion")
138
+ engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion"),
139
+ voice_ref_b64=voice_ref_b64, segment_number=i + 1,
141
140
  )
142
141
 
143
142
  dur = get_video_duration(output_wav)
144
- seg_slot = seg["end"] - seg["start"]
145
- seg_pause = seg.get("pause_after_ms", CONFIG["breath_ms"]) / 1000
146
- available = max(0.5, seg_slot - seg_pause)
147
143
  rate_offset = _parse_rate_offset(rate)
148
- budget = narration_tempo_budget(rate_offset)
149
- raw_budget = available * budget["max_raw_duration_factor"]
150
- truncated = False
151
- truncate_reason = "none"
152
144
  try:
153
- enforce_approved_text_policy(i, seg, seg["narration"], text, dur, available, raw_budget)
145
+ _check_segment_window(i, seg, text, dur, rate_offset)
154
146
  except ApprovedTextDurationError:
155
147
  _cleanup_partial_tts_outputs(output_wav)
156
148
  raise
157
- if dur > raw_budget and len(text) > 5:
158
- chars_per_sec = _text_char_count(text) / dur
159
- target_chars = max(5, int(raw_budget * chars_per_sec) - 1)
160
- shortened = _truncate_at_sentence(text, target_chars)
161
- if shortened and len(shortened) >= 5 and shortened != text:
162
- log(f" 段 {i+1}: 解说超出片段时长,按累计语速预算句界缩短 {len(text)}→{len(shortened)} 字以适配(建议在解说里改写得更短)")
163
- text = shortened
164
- truncated = True
165
- truncate_reason = "sentence_boundary"
166
- provider_receipt = _run_tts_engine(
167
- engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion")
168
- )
169
- dur = get_video_duration(output_wav)
170
149
 
171
150
  norm_meta = _maybe_normalize_tts_wav(output_wav)
172
151
  if norm_meta:
173
152
  dur = get_video_duration(output_wav)
174
- _write_tts_segment_cache(output_wav, cache_inputs, text, dur, rate_offset,
175
- truncated, truncate_reason, norm_meta, provider_receipt)
153
+ _write_tts_segment_cache(output_wav, cache_inputs, text, dur, norm_meta, provider_receipt)
176
154
  return _build_tts_segment_result(
177
- i, seg, text, output_wav, dur, rate_offset, truncated, truncate_reason, norm_meta,
178
- provider_receipt)
155
+ i, seg, text, output_wav, dur, rate_offset, norm_meta, provider_receipt)
156
+
157
+
158
+ def _check_segment_window(index, seg, spoken_text, duration, rate_offset):
159
+ """Fail an approved-text block that overflows its window; log any other overflow."""
160
+ seg_slot = seg["end"] - seg["start"]
161
+ seg_pause = seg.get("pause_after_ms", CONFIG["breath_ms"]) / 1000
162
+ available = max(0.5, seg_slot - seg_pause)
163
+ raw_budget = available * narration_tempo_budget(rate_offset)["max_raw_duration_factor"]
164
+ enforce_approved_text_policy(index, seg, seg["narration"], spoken_text, duration,
165
+ available, raw_budget)
166
+ if duration > raw_budget:
167
+ # Never shorten authored text here: a truncated segment always blocked later as
168
+ # `truncated_speech`. assemble either fits it with bounded tempo or blocks it as
169
+ # `no_safe_fit` before the video encode.
170
+ log(
171
+ f" 段 {index+1}: 超出预算 {duration:.1f}s > {raw_budget:.1f}s,保留原稿,"
172
+ "交由 assemble 有界提速或在渲染前阻断"
173
+ )
179
174
 
180
175
 
181
176
  def _build_tts_segment_result(index, seg, text, output_wav, duration, rate_offset,
182
- truncated=False, truncate_reason="none", norm_meta=None,
183
- provider_receipt=None):
177
+ norm_meta=None, provider_receipt=None):
184
178
  budget = narration_tempo_budget(rate_offset)
185
179
  authored_text = _clean_narration_text(seg["narration"])
186
- resolved_truncated = truncated or text != authored_text
180
+ # voiceover never shortens text; still flag any spoken/authored mismatch (e.g. a reused sidecar).
181
+ resolved_truncated = text != authored_text
187
182
  result = {
188
183
  "segment_audio_schema_version": SEGMENT_AUDIO_SCHEMA_VERSION,
189
184
  "index": index,
@@ -193,7 +188,7 @@ def _build_tts_segment_result(index, seg, text, output_wav, duration, rate_offse
193
188
  "authored_text": seg["narration"],
194
189
  "spoken_text": text,
195
190
  "truncated": resolved_truncated,
196
- "truncate_reason": (truncate_reason if truncate_reason != "none" else "sentence_boundary") if resolved_truncated else "none",
191
+ "truncate_reason": "sentence_boundary" if resolved_truncated else "none",
197
192
  "fit_status": "pending_assembly",
198
193
  "audio_path": str(output_wav),
199
194
  "audio_duration": duration,
@@ -261,15 +256,8 @@ def _build_tts_meta(segments, engine, narration_name, failures):
261
256
  }
262
257
 
263
258
 
264
- def _reset_voice_reference_state():
265
- """Drop prepared reference bytes so a reused process re-hashes the live source."""
266
- for key in _VOICE_REFERENCE_STATE_KEYS:
267
- CONFIG.pop(key, None)
268
-
269
-
270
259
  def synthesize_tts(narration, work_dir):
271
260
  """合成解说音频(并行)。Returns (segments, engine, failures)."""
272
- _reset_voice_reference_state()
273
261
  voice_ref = CONFIG["voice_ref"]
274
262
  tts_dir = work_dir / "tts_segments"
275
263
  tts_dir.mkdir(exist_ok=True)
@@ -285,58 +273,80 @@ def synthesize_tts(narration, work_dir):
285
273
  if cache_engine == "fish-audio":
286
274
  raise RuntimeError("Fish Audio 不接受本地 VOICE_REF/--voice-ref;请改用 FISH_TTS_REFERENCE_ID")
287
275
  raise RuntimeError("index-tts 不支持本地 VOICE_REF/--voice-ref 克隆")
288
- # A fully cached narration needs no credential: probe every segment's sidecar once here;
276
+ # A fully cached narration needs no credential: probe every segment's cache once here;
289
277
  # hits are final and only misses reach the workers (with their prepared inputs).
290
278
  segments = []
291
279
  misses = []
280
+ failures = []
292
281
  for i, seg in enumerate(narration):
293
282
  prepared = _prepare_tts_segment(i, seg, narration, tts_dir, cache_engine)
294
283
  if prepared is None:
295
284
  continue
296
- cached = _reuse_tts_segment_cache(i, seg, prepared[1], prepared[4], cache_engine)
285
+ try:
286
+ cached = _reuse_tts_segment_cache(
287
+ i, seg, prepared[1], prepared[4], cache_engine, _parse_rate_offset(prepared[2]))
288
+ except ApprovedTextDurationError as e:
289
+ failures.append(_tts_failure_record(i, seg, e))
290
+ continue
297
291
  if cached:
298
292
  segments.append(cached)
299
293
  else:
300
294
  misses.append((i, seg, prepared))
301
- if not segments and not misses:
295
+ if not segments and not misses and not failures:
302
296
  raise RuntimeError("narration.json 没有可配音的有效文本,已中止以避免生成无解说视频")
303
- if not misses:
304
- segments.sort(key=lambda x: x["index"])
297
+ if misses:
298
+ engine = _synthesize_misses(misses, narration, tts_dir, segments, failures)
299
+ else:
300
+ engine = cache_engine
305
301
  log(f"TTS 引擎: {cache_engine} (cache)")
306
- return segments, cache_engine, []
302
+ return _finish_tts(narration, segments, engine, failures)
303
+
307
304
 
305
+ def _synthesize_misses(misses, narration, tts_dir, segments, failures):
306
+ """Synthesize the cache misses in parallel into `segments` / `failures`; returns the engine."""
308
307
  engine = resolve_tts_engine()
308
+ voice_ref = CONFIG["voice_ref"]
309
309
 
310
- if voice_ref:
311
- _cache_prepared_voice_reference(voice_ref)
312
- CONFIG["voice_ref_snapshot_locked"] = True
310
+ # Transcode the reference once per run, and only now: a fully cached rerun never runs
311
+ # ffmpeg. The misses' cache keys hold the identity probed above, so a reference edited
312
+ # meanwhile would label new audio with the old identity; fail instead.
313
+ voice_ref_b64 = None
314
+ if voice_ref and engine == "mimo-tts":
315
+ probed_identity = misses[0][2][4]["settings"]["voice_ref_identity"] # [4] = cache inputs
316
+ voice_ref_b64 = _prepare_voice_reference(voice_ref)
317
+ if _voice_reference_signature(voice_ref) != probed_identity:
318
+ raise RuntimeError(f"参考音频在配音期间被修改: {voice_ref};请重新运行")
313
319
 
314
320
  log(f"TTS 引擎: {engine}")
315
321
 
316
- failures = []
317
322
  max_workers = min(len(misses), CONFIG["tts_workers"])
318
323
 
319
- try:
320
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
321
- futures = {
322
- executor.submit(_synthesize_segment, i, seg, narration, tts_dir, engine, prepared): i
323
- for i, seg, prepared in misses
324
- }
325
- for future in as_completed(futures):
326
- try:
327
- result = future.result()
328
- except Exception as e:
329
- i = futures[future]
330
- failures.append(_tts_failure_record(i, narration[i], e))
331
- log(f" TTS 段 {i+1} 失败: {e}")
332
- continue
333
- if result:
334
- segments.append(result)
335
- log(f" 段 {result['index']+1}: {result['audio_duration']:.1f}s - {result['narration'][:25]}...")
336
- finally:
337
- CONFIG.pop("voice_ref_snapshot_locked", None)
338
-
324
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
325
+ futures = {
326
+ executor.submit(
327
+ _synthesize_segment, i, seg, narration, tts_dir, engine, prepared,
328
+ voice_ref_b64=voice_ref_b64,
329
+ ): i
330
+ for i, seg, prepared in misses
331
+ }
332
+ for future in as_completed(futures):
333
+ try:
334
+ result = future.result()
335
+ except Exception as e:
336
+ i = futures[future]
337
+ failures.append(_tts_failure_record(i, narration[i], e))
338
+ log(f" TTS 段 {i+1} 失败: {e}")
339
+ continue
340
+ if result:
341
+ segments.append(result)
342
+ log(f" 段 {result['index']+1}: {result['audio_duration']:.1f}s - {result['narration'][:25]}...")
343
+ return engine
344
+
345
+
346
+ def _finish_tts(narration, segments, engine, failures):
347
+ """Apply the failure policy to one run's results; returns (segments, engine, failures)."""
339
348
  segments.sort(key=lambda x: x["index"])
349
+ failures.sort(key=lambda x: x["index"])
340
350
  approved_text_failures = [
341
351
  failure for failure in failures
342
352
  if failure.get("failure_kind") == "approved_text_duration_conflict"
@@ -363,10 +373,10 @@ def synthesize_tts(narration, work_dir):
363
373
  f"示例: {sample}。如确需继续,可设置 ALLOW_PARTIAL_TTS=1 或 --allow-partial-tts。"
364
374
  )
365
375
  if failures:
366
- missing = ", ".join(str(f["index"] + 1) for f in failures[:8])
376
+ missing = "、".join(f"段 {f['index'] + 1}" for f in failures[:8])
367
377
  more = "…" if len(failures) > 8 else ""
368
378
  log(
369
- f"警告: TTS 部分失败 {len(failures)}/{len(narration)} 段(段 {missing}{more}),"
379
+ f"警告: TTS 部分失败 {len(failures)}/{len(narration)} 段({missing}{more}),"
370
380
  "成片可预览但不建议直接发布;详见 tts_meta.json failures"
371
381
  )
372
382
  if not segments:
@@ -374,36 +384,51 @@ def synthesize_tts(narration, work_dir):
374
384
  return segments, engine, failures
375
385
 
376
386
 
377
- def _run_tts_engine(engine, text, output_wav, rate="+0%", pitch="+0Hz", emotion=None):
387
+ def _run_tts_engine(engine, text, output_wav, rate="+0%", pitch="+0Hz", emotion=None,
388
+ voice_ref_b64=None, segment_number=None):
378
389
  """Run one TTS engine with retry and remove partial files after failures.
379
390
 
380
391
  `engine` comes from resolve_tts_engine; index-tts controls were already forced to the
381
- provider defaults by _prepare_tts_segment."""
392
+ provider defaults by _prepare_tts_segment. A take rejected as implausibly long is kept as
393
+ `<name>.rejected.wav` (the latest one) so the user can listen before relaxing the bound."""
382
394
  retries = CONFIG["tts_retries"]
383
395
  last_error = None
396
+ rejected = rejected_take_path(output_wav)
397
+ rejected.unlink(missing_ok=True)
398
+ label = f"段 {segment_number} " if segment_number is not None else ""
384
399
 
385
400
  for attempt in range(1, retries + 1):
386
401
  try:
387
402
  _cleanup_partial_tts_outputs(output_wav)
388
403
  receipt = None
389
404
  if engine == "mimo-tts":
390
- _tts_mimo(text, output_wav, rate=rate, pitch=pitch, emotion=emotion)
405
+ _tts_mimo(text, output_wav, rate=rate, pitch=pitch, emotion=emotion,
406
+ voice_ref_b64=voice_ref_b64)
391
407
  elif engine == "fish-audio":
392
408
  synthesize_fish_audio(text, output_wav, rate=rate)
393
409
  else:
394
410
  receipt = index_provider.synthesize_configured(text, output_wav, CONFIG)
395
- if get_video_duration(output_wav) <= 0:
411
+ duration = get_video_duration(output_wav)
412
+ if duration <= 0:
396
413
  raise RuntimeError(f"{engine} 输出音频时长无效")
414
+ # A hallucinated reading (the text plus invented speech) is a failed attempt: it is
415
+ # retried, never cached, and never reaches assemble as an over-budget block.
416
+ implausible = implausible_tts_duration(text, duration)
417
+ if implausible:
418
+ os.replace(output_wav, rejected)
419
+ raise RuntimeError(implausible)
420
+ rejected.unlink(missing_ok=True)
397
421
  return receipt
398
422
  except Exception as exc:
399
423
  last_error = exc
400
424
  _cleanup_partial_tts_outputs(output_wav)
401
425
  if attempt < retries:
402
426
  wait = min(2 ** (attempt - 1), 8)
403
- log(f" TTS 重试 {attempt+1}/{retries}: {exc},等待 {wait}s")
427
+ log(f" {label}TTS 重试 {attempt+1}/{retries}: {exc},等待 {wait}s")
404
428
  time.sleep(wait)
405
429
 
406
- raise RuntimeError(f"{engine} 合成失败: {last_error}") from last_error
430
+ hint = rejected_take_hint(rejected)
431
+ raise RuntimeError(f"{engine} 合成失败: {last_error}{hint}") from last_error
407
432
 
408
433
 
409
434
  def _cleanup_partial_tts_outputs(output_wav):
@@ -413,15 +438,11 @@ def _cleanup_partial_tts_outputs(output_wav):
413
438
  wav_path,
414
439
  wav_path.with_suffix(".mp3"),
415
440
  Path(str(wav_path) + ".part"),
416
- _tts_segment_cache_path(output_wav),
441
+ tts_cache.legacy_sidecar_path(output_wav),
417
442
  ):
418
443
  path.unlink(missing_ok=True)
419
444
 
420
445
 
421
- def _tts_segment_cache_path(output_wav):
422
- return Path(str(output_wav) + ".cache.json")
423
-
424
-
425
446
  def _prepare_tts_segment(index, seg, narration, tts_dir, engine):
426
447
  text = _clean_narration_text(seg["narration"])
427
448
  if not text:
@@ -429,20 +450,33 @@ def _prepare_tts_segment(index, seg, narration, tts_dir, engine):
429
450
  output_wav = tts_dir / f"narr_{index:03d}.wav"
430
451
  if engine == "index-tts":
431
452
  rate, pitch = index_provider.default_controls(seg)
432
- elif CONFIG["tts_dynamic_params"]:
433
- rate, pitch = _compute_tts_params(text, narration, index)
434
453
  else:
435
- rate, pitch = "+0%", "+0Hz"
436
- cache_inputs = _tts_segment_cache_inputs(engine, index, seg, text, rate, pitch)
454
+ rate, pitch = _compute_tts_params(text, narration, index)
455
+ cache_inputs = _tts_segment_cache_inputs(engine, seg, text, rate, pitch)
437
456
  return text, output_wav, rate, pitch, cache_inputs
438
457
 
439
458
 
440
- def _reuse_tts_segment_cache(index, seg, output_wav, cache_inputs, engine):
441
- cached = _load_tts_segment_cache(output_wav, cache_inputs)
459
+ def _reuse_tts_segment_cache(index, seg, output_wav, cache_inputs, engine, rate_offset):
460
+ """A result from the content-addressed cache, with `output_wav` pointing at its audio.
461
+
462
+ `rate_offset` is this block's own nominal rate. The key holds only what the provider
463
+ receives, so a take synthesized at another nominal rate that produced the same request
464
+ is reused, and reports this block's rate exactly as a fresh synthesis would."""
465
+ cached = tts_cache.load(output_wav.parent, cache_inputs)
442
466
  if cached is None:
443
467
  return None
444
468
  if engine == "index-tts" and not index_provider.valid_cached_receipt(cached, CONFIG):
445
469
  return None
470
+ implausible = implausible_tts_duration(cached["spoken_text"], cached["audio_duration"])
471
+ if implausible:
472
+ # Written before this bound existed: re-synthesize instead of blocking every rerun.
473
+ log(f" 段 {index+1}: 不复用缓存,重新合成:{implausible}")
474
+ return None
475
+ # The window is not part of the key (it never changes the audio), so an approved-text
476
+ # block moved into a shorter window is re-checked here instead of re-synthesized.
477
+ _check_segment_window(index, seg, cached["spoken_text"], cached["audio_duration"],
478
+ rate_offset)
479
+ tts_cache.materialize(output_wav.parent, cache_inputs, output_wav)
446
480
  # The sidecar's audio identity (size, mtime_ns) still matches the WAV that produced
447
481
  # `audio_duration`; re-probing would be one ffprobe process per segment on every rerun.
448
482
  log(f" 段 {index+1}: 复用已有 ({cached['audio_duration']:.1f}s)")
@@ -452,26 +486,36 @@ def _reuse_tts_segment_cache(index, seg, output_wav, cache_inputs, engine):
452
486
  cached["spoken_text"],
453
487
  output_wav,
454
488
  cached["audio_duration"],
455
- cached["tts_rate_offset"],
456
- cached["truncated"],
457
- cached["truncate_reason"],
489
+ rate_offset,
458
490
  cached["normalization"],
459
491
  cached.get("provider_receipt"),
460
492
  )
461
493
 
462
494
 
463
- def _tts_segment_cache_inputs(engine, index, seg, source_text, rate, pitch):
464
- """The exact inputs that make a cached segment safe to reuse (compared by equality)."""
495
+ def _provider_prosody_request(engine, rate, pitch, emotion):
496
+ """The prosody part of the request the provider actually receives for one block.
497
+
498
+ MiMo gets a natural-language instruction in which rate offsets only change the wording
499
+ at >= +6% or <= -3% (pitch only as zero/non-zero); Fish Audio gets a numeric speed and
500
+ ignores pitch and emotion; index-tts takes no per-block controls."""
501
+ if engine == "mimo-tts":
502
+ return {"instruction": _mimo_tts_style_instruction(rate, pitch, emotion)}
503
+ if engine == "fish-audio":
504
+ return {"speed": fish_speed(rate)}
505
+ return {}
506
+
507
+
508
+ def _tts_segment_cache_inputs(engine, seg, source_text, rate, pitch):
509
+ """The exact inputs that make cached audio safe to reuse (compared by equality).
510
+
511
+ Only what changes the audio: text, the prosody request the provider receives, and
512
+ provider/voice settings. The block's position and window are left out, so deleting or
513
+ inserting a block reuses every other block's audio; a block whose position changes its
514
+ nominal rate is re-synthesized only if the provider would receive a different request."""
465
515
  payload = {
466
516
  "engine": engine,
467
517
  "source_text": source_text,
468
- "segment_index": index,
469
- "start": round(seg["start"], 3),
470
- "end": round(seg["end"], 3),
471
- "pause_after_ms": seg.get("pause_after_ms", CONFIG["breath_ms"]),
472
- "rate": rate,
473
- "pitch": pitch,
474
- "emotion": seg.get("emotion", ""),
518
+ "provider_request": _provider_prosody_request(engine, rate, pitch, seg.get("emotion")),
475
519
  "settings": tts_settings_payload(engine),
476
520
  }
477
521
  if authored_text_policy() == PRESERVE_APPROVED_TEXT_POLICY:
@@ -483,42 +527,15 @@ def _tts_segment_cache_inputs(engine, index, seg, source_text, rate, pitch):
483
527
  return payload
484
528
 
485
529
 
486
- def _load_tts_segment_cache(output_wav, cache_inputs):
487
- """Return cache metadata only when the sidecar's inputs and WAV identity still match."""
488
- cache_path = _tts_segment_cache_path(output_wav)
489
- if not output_wav.exists() or output_wav.stat().st_size == 0 or not cache_path.exists():
490
- return None
491
- # This skill wrote the sidecar (_write_tts_segment_cache): one it cannot parse is a bug
492
- # to surface. One from the earlier content-hash schema (cache_key/audio_fingerprint,
493
- # no settings/audio) is a plain miss: the segment is re-synthesized once.
494
- try:
495
- data = json.loads(cache_path.read_text(encoding="utf-8"))
496
- except ValueError as exc:
497
- raise RuntimeError(f"TTS 缓存 sidecar 损坏: {cache_path}") from exc
498
- if not isinstance(data, dict) or "settings" not in data or "audio" not in data:
499
- return None
500
- stale = data["settings"] != cache_inputs or data["audio"] != file_identity(output_wav)
501
- return None if stale else data
502
-
503
-
504
- def _write_tts_segment_cache(output_wav, cache_inputs, spoken_text, duration, rate_offset,
505
- truncated=False, truncate_reason="none", norm_meta=None,
506
- provider_receipt=None):
507
- """Persist non-secret inputs and the WAV identity for safe per-segment TTS reuse."""
508
- _tts_segment_cache_path(output_wav).write_text(
509
- json.dumps({
510
- "settings": cache_inputs,
511
- "audio": file_identity(output_wav),
512
- "spoken_text": spoken_text,
513
- "audio_duration": duration,
514
- "tts_rate_offset": rate_offset,
515
- "truncated": truncated,
516
- "truncate_reason": truncate_reason if truncated else "none",
517
- "normalization": norm_meta or None,
518
- "provider_receipt": provider_receipt,
519
- }, ensure_ascii=False, indent=2),
520
- encoding="utf-8",
521
- )
530
+ def _write_tts_segment_cache(output_wav, cache_inputs, spoken_text, duration,
531
+ norm_meta=None, provider_receipt=None):
532
+ """Store the finished block WAV under its non-secret synthesis inputs for reuse."""
533
+ tts_cache.store(output_wav, cache_inputs, {
534
+ "spoken_text": spoken_text,
535
+ "audio_duration": duration,
536
+ "normalization": norm_meta or None,
537
+ "provider_receipt": provider_receipt,
538
+ })
522
539
 
523
540
 
524
541
  def _configured_tts_engine_for_cache():
@@ -555,7 +572,6 @@ def tts_settings_payload(engine):
555
572
  """Return non-secret TTS settings that materially affect generated audio."""
556
573
  settings = {
557
574
  "engine": engine,
558
- "tts_dynamic_params": CONFIG["tts_dynamic_params"],
559
575
  "narration_speed": CONFIG["narration_speed"],
560
576
  "narration_cumulative_tempo_max": CONFIG["narration_cumulative_tempo_max"],
561
577
  "narration_cumulative_tempo_hard_max": CONFIG["narration_cumulative_tempo_hard_max"],
@@ -587,7 +603,7 @@ def tts_settings_payload(engine):
587
603
  if voice_ref and engine == "mimo-tts":
588
604
  ref_path = Path(voice_ref).expanduser()
589
605
  settings.pop("mimo_tts_voice", None) # ignored by the voiceclone API
590
- settings["voice_ref_identity"] = _voice_reference_identity(ref_path)
606
+ settings["voice_ref_identity"] = _voice_reference_signature(ref_path)
591
607
  settings["voice_ref_preparation"] = (
592
608
  f"pcm_s16le:24000hz:mono:30s:v{VOICE_REFERENCE_PREP_VERSION}"
593
609
  )
@@ -634,52 +650,15 @@ def _voice_reference_signature(ref_path):
634
650
  return {"path": str(ref), **file_identity(ref)}
635
651
 
636
652
 
637
- def _voice_reference_identity(ref_path):
638
- """Identity for cache settings: the locked snapshot's while a run is in flight, else live."""
639
- resolved = str(Path(ref_path).expanduser().resolve())
640
- if (
641
- CONFIG.get("voice_ref_snapshot_locked")
642
- and CONFIG.get("voice_ref_b64")
643
- and CONFIG.get("voice_ref_snapshot_path") == resolved
644
- and CONFIG.get("voice_ref_snapshot_signature")
645
- ):
646
- return CONFIG["voice_ref_snapshot_signature"]
647
- return _voice_reference_signature(ref_path)
648
-
649
-
650
- def _cache_prepared_voice_reference(ref_path):
651
- with _VOICE_REFERENCE_LOCK:
652
- ref = Path(ref_path).expanduser().resolve()
653
- resolved = str(ref)
654
- if (
655
- CONFIG.get("voice_ref_snapshot_locked")
656
- and CONFIG.get("voice_ref_b64")
657
- and CONFIG.get("voice_ref_snapshot_path") == resolved
658
- ):
659
- return CONFIG["voice_ref_b64"]
660
- if not ref.is_file():
661
- raise FileNotFoundError(f"参考音频不存在或不是文件: {ref}")
662
- signature = _voice_reference_signature(ref)
663
- if (
664
- CONFIG.get("voice_ref_b64")
665
- and CONFIG.get("voice_ref_snapshot_path") == resolved
666
- and CONFIG.get("voice_ref_snapshot_signature") == signature
667
- ):
668
- return CONFIG["voice_ref_b64"]
669
- encoded = _prepare_voice_reference(ref)
670
- CONFIG["voice_ref_b64"] = encoded
671
- CONFIG["voice_ref_snapshot_path"] = resolved
672
- CONFIG["voice_ref_snapshot_signature"] = signature
673
- return encoded
674
-
675
-
676
- def _tts_mimo(text, output_path, rate="+0%", pitch="+0Hz", emotion=None):
653
+ def _tts_mimo(text, output_path, rate="+0%", pitch="+0Hz", emotion=None, voice_ref_b64=None):
677
654
  """使用 Xiaomi MiMo-V2.5-TTS 合成,按需用参考音频克隆音色。
678
655
 
679
656
  MiMo-v2.5-tts 是 instruct-TTS:user 消息里的自然语言指令控制整句的情绪/语气/语速。
680
- 每段 narration 的 `emotion` 标签即写进该指令,让解说有起伏、不机械。"""
657
+ 每段 narration 的 `emotion` 标签即写进该指令,让解说有起伏、不机械。
658
+ `voice_ref_b64` is the run's prepared reference; a direct call transcodes the live source."""
681
659
  voice_ref = CONFIG["voice_ref"]
682
- voice_ref_b64 = _cache_prepared_voice_reference(voice_ref) if voice_ref else None
660
+ if voice_ref and voice_ref_b64 is None:
661
+ voice_ref_b64 = _prepare_voice_reference(voice_ref)
683
662
  payload = {
684
663
  "model": "mimo-v2.5-tts-voiceclone" if voice_ref else CONFIG["mimo_tts_model"],
685
664
  "messages": [
@@ -741,10 +720,8 @@ def main():
741
720
  ap.error("--mimo-voice and --voice-ref are mutually exclusive")
742
721
  if args.mimo_voice and args.tts_provider in {"fish-audio", "index-tts"}:
743
722
  ap.error("--mimo-voice is only supported by the MiMo TTS provider")
744
- # A local --voice-ref with fish-audio/index-tts is rejected by synthesize_tts.
745
- # Voice-reference normalization is intentionally lazy: a fully cached rerun should not
746
- # invoke ffmpeg. _tts_mimo uses a process-wide lock so a fresh parallel run still converts
747
- # the reference exactly once.
723
+ # A local --voice-ref with fish-audio/index-tts is rejected by synthesize_tts, which also
724
+ # transcodes the reference lazily: once per run, and never for a fully cached rerun.
748
725
  if args.allow_partial_tts:
749
726
  CONFIG["allow_partial_tts"] = True
750
727
  if args.narration: