@oh-story/dsh 0.1.8 → 0.1.9

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 (272) hide show
  1. package/README.md +11 -11
  2. package/lib/client.js +35 -14
  3. package/lib/client.js.map +2 -2
  4. package/lib/drama/manifest.json +165 -69
  5. package/lib/drama/skills/short-drama/SKILL.md +5 -1
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +126 -9
  7. package/lib/drama/skills/short-drama/scripts/project_tool.py +11 -0
  8. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +18 -0
  9. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +10 -1
  10. package/lib/drama/skills/short-drama-edit/SKILL.md +148 -0
  11. package/lib/drama/skills/short-drama-edit/agents/openai.yaml +4 -0
  12. package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +60 -0
  13. package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +15 -0
  14. package/lib/drama/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
  15. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
  16. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
  17. package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
  18. package/lib/drama/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
  19. package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
  20. package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
  21. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
  22. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +81 -0
  23. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +58 -0
  24. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +96 -0
  25. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +113 -0
  26. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +55 -0
  27. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1041 -0
  28. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +456 -0
  29. package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +5 -0
  30. package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +3 -0
  31. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
  32. package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +4 -1
  33. package/lib/drama/skills/short-drama-produce/SKILL.md +24 -3
  34. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +28 -0
  35. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +24 -5
  36. package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +7 -0
  37. package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +18 -1
  38. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +181 -2
  39. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +57 -2
  40. package/lib/drama/skills/short-drama-review/SKILL.md +3 -0
  41. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +20 -0
  42. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +36 -0
  43. package/lib/drama/skills/short-drama-storyboard/SKILL.md +7 -0
  44. package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +4 -0
  45. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +39 -0
  46. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -0
  47. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -1
  48. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +101 -6
  49. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +7 -1
  50. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +35 -0
  51. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +3 -1
  52. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +13 -3
  53. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +35 -4
  54. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -1
  55. package/lib/drama/skills/short-drama-write/SKILL.md +13 -2
  56. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +6 -0
  57. package/lib/drama/skills/short-drama-write/references/stage-contract.md +1 -0
  58. package/lib/index.js +4 -2
  59. package/lib/index.js.map +2 -2
  60. package/lib/novel-to-game/VERSION +1 -1
  61. package/lib/novel-to-game/manifest.json +28 -28
  62. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +6 -27
  63. package/lib/novel-to-game/skills/game-build/SKILL.md +12 -29
  64. package/lib/novel-to-game/skills/game-concept/SKILL.md +10 -26
  65. package/lib/novel-to-game/skills/game-qa/SKILL.md +5 -15
  66. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +1 -1
  67. package/lib/novel-to-game/skills/game-world-design/SKILL.md +19 -28
  68. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +1 -2
  69. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +6 -9
  70. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +6 -11
  71. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -4
  72. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +1 -3
  73. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +3 -5
  74. package/lib/oh-story/manifest.json +292 -172
  75. package/lib/oh-story/roles/character-designer.md +1 -1
  76. package/lib/oh-story/roles/narrative-writer.md +56 -242
  77. package/lib/oh-story/roles/story-architect.md +1 -1
  78. package/lib/oh-story/skills/story/SKILL.md +1 -1
  79. package/lib/oh-story/skills/story/VERSION +1 -1
  80. package/lib/oh-story/skills/story/references/author-memory.md +10 -8
  81. package/lib/oh-story/skills/story-deslop/SKILL.md +20 -182
  82. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +19 -47
  83. package/lib/oh-story/skills/story-deslop/references/author-memory.md +10 -8
  84. package/lib/oh-story/skills/story-deslop/references/banned-words.md +3 -1
  85. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +154 -0
  86. package/lib/oh-story/skills/story-deslop/references/style-resolution.md +27 -0
  87. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +6 -1
  88. package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +39 -20
  89. package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +10 -5
  90. package/lib/oh-story/skills/story-deslop/scripts/style-whitelist.js +39 -0
  91. package/lib/oh-story/skills/story-import/SKILL.md +2 -2
  92. package/lib/oh-story/skills/story-import/references/format-and-structure.md +2 -2
  93. package/lib/oh-story/skills/story-import/references/tracking-initialization.md +31 -0
  94. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +2 -29
  95. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +176 -12
  96. package/lib/oh-story/skills/story-long-analyze/SKILL.md +1 -1
  97. package/lib/oh-story/skills/story-long-write/SKILL.md +20 -193
  98. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +19 -47
  99. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +62 -7
  100. package/lib/oh-story/skills/story-long-write/references/author-memory.md +10 -8
  101. package/lib/oh-story/skills/story-long-write/references/banned-words.md +3 -1
  102. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +20 -0
  103. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +6 -4
  104. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +2 -2
  105. package/lib/oh-story/skills/story-long-write/references/long-format.md +2 -0
  106. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +2 -0
  107. package/lib/oh-story/skills/story-long-write/references/project-files.md +108 -0
  108. package/lib/oh-story/skills/story-long-write/references/reference-index.md +87 -0
  109. package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -1
  110. package/lib/oh-story/skills/story-long-write/references/style-craft.md +9 -29
  111. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
  112. package/lib/oh-story/skills/story-long-write/references/style-resolution.md +27 -0
  113. package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +31 -0
  114. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -29
  115. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +51 -52
  116. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +8 -7
  117. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +36 -15
  118. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +22 -0
  119. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +69 -2
  120. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +410 -0
  121. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +6 -1
  122. package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +39 -20
  123. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +176 -12
  124. package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +90 -23
  125. package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +10 -5
  126. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +283 -0
  127. package/lib/oh-story/skills/story-long-write/scripts/style-whitelist.js +39 -0
  128. package/lib/oh-story/skills/story-review/SKILL.md +13 -3
  129. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +19 -47
  130. package/lib/oh-story/skills/story-review/references/author-memory.md +10 -8
  131. package/lib/oh-story/skills/story-review/references/banned-words.md +3 -1
  132. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +6 -4
  133. package/lib/oh-story/skills/story-review/references/review-quality.md +2 -2
  134. package/lib/oh-story/skills/story-review/references/style-resolution.md +27 -0
  135. package/lib/oh-story/skills/story-review/references/tracking-initialization.md +31 -0
  136. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -29
  137. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +6 -1
  138. package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +39 -20
  139. package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +10 -5
  140. package/lib/oh-story/skills/story-review/scripts/style-whitelist.js +39 -0
  141. package/lib/oh-story/skills/story-setup/SKILL.md +17 -10
  142. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +19 -47
  143. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +3 -1
  144. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +154 -0
  145. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +6 -4
  146. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +2 -2
  147. package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +1 -1
  148. package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -1
  149. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
  150. package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +27 -0
  151. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +69 -2
  152. package/lib/oh-story/skills/story-setup/references/diagnostics.md +38 -0
  153. package/lib/oh-story/skills/story-short-analyze/references/analysis-writing-techniques.md +1 -1
  154. package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +2 -2
  155. package/lib/oh-story/skills/story-short-write/SKILL.md +18 -186
  156. package/lib/oh-story/skills/story-short-write/references/author-memory.md +10 -8
  157. package/lib/oh-story/skills/story-short-write/references/banned-words.md +3 -1
  158. package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +60 -0
  159. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +6 -4
  160. package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +2 -2
  161. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +1 -1
  162. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +3 -3
  163. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +3 -3
  164. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +3 -3
  165. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +5 -5
  166. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +4 -4
  167. package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +5 -5
  168. package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +3 -3
  169. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +1 -1
  170. package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +1 -1
  171. package/lib/oh-story/skills/story-short-write/references/reference-index.md +53 -0
  172. package/lib/oh-story/skills/story-short-write/references/short-craft.md +12 -14
  173. package/lib/oh-story/skills/story-short-write/references/short-deslop.md +10 -10
  174. package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -1
  175. package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -2
  176. package/lib/oh-story/skills/story-short-write/references/style-resolution.md +27 -0
  177. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +77 -0
  178. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +5 -5
  179. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +6 -1
  180. package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +39 -20
  181. package/lib/oh-story/skills/story-short-write/scripts/check-delivery-contract.js +34 -5
  182. package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +90 -23
  183. package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +10 -5
  184. package/lib/oh-story/skills/story-short-write/scripts/style-whitelist.js +39 -0
  185. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  186. package/lib/types/client/index.d.ts.map +1 -1
  187. package/lib/types/client/production-prompts.d.ts.map +1 -1
  188. package/lib/types/client/production-runtime.d.ts +17 -0
  189. package/lib/types/client/production-runtime.d.ts.map +1 -1
  190. package/lib/types/skill-provider.d.ts.map +1 -1
  191. package/lib/video-recap/CHANGELOG.md +31 -1
  192. package/lib/video-recap/manifest.json +162 -162
  193. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +4 -9
  194. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +47 -60
  195. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +77 -115
  196. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +41 -40
  197. package/lib/video-recap/skills/video-assemble/scripts/audio_automation.py +22 -32
  198. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +102 -192
  199. package/lib/video-recap/skills/video-assemble/scripts/jianying_builders.py +14 -35
  200. package/lib/video-recap/skills/video-assemble/scripts/jianying_model.py +14 -12
  201. package/lib/video-recap/skills/video-assemble/scripts/jianying_optional.py +8 -9
  202. package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +3 -14
  203. package/lib/video-recap/skills/video-assemble/scripts/jianying_timeline_contract.py +23 -12
  204. package/lib/video-recap/skills/video-assemble/scripts/jianying_tracks.py +1 -4
  205. package/lib/video-recap/skills/video-assemble/scripts/jianying_writer.py +24 -25
  206. package/lib/video-recap/skills/video-assemble/scripts/lib.py +31 -85
  207. package/lib/video-recap/skills/video-assemble/scripts/media.py +100 -144
  208. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +101 -195
  209. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +10 -21
  210. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +90 -169
  211. package/lib/video-recap/skills/video-assemble/scripts/subtitle_core.py +69 -122
  212. package/lib/video-recap/skills/video-assemble/scripts/subtitle_render.py +10 -17
  213. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +33 -48
  214. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +14 -20
  215. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +135 -213
  216. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +49 -57
  217. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +38 -74
  218. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +46 -95
  219. package/lib/video-recap/skills/video-cut/scripts/lib.py +23 -25
  220. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +70 -173
  221. package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +28 -53
  222. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +162 -322
  223. package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +6 -7
  224. package/lib/video-recap/skills/video-recap/scripts/doctor.py +104 -124
  225. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +255 -412
  226. package/lib/video-recap/skills/video-recap/scripts/lib.py +30 -52
  227. package/lib/video-recap/skills/video-recap/scripts/materials.py +97 -120
  228. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_client.py +7 -12
  229. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_evidence.py +46 -97
  230. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_observations.py +18 -36
  231. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_payload.py +29 -47
  232. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_report.py +43 -76
  233. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_runner.py +2 -1
  234. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +2 -1
  235. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +2 -2
  236. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +129 -145
  237. package/lib/video-recap/skills/video-recap/scripts/recap_review.py +27 -20
  238. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +20 -31
  239. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +31 -106
  240. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +43 -113
  241. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +196 -377
  242. package/lib/video-recap/skills/video-script/scripts/agent_brief.py +1 -1
  243. package/lib/video-recap/skills/video-script/scripts/agent_text.py +63 -172
  244. package/lib/video-recap/skills/video-script/scripts/brief_context.py +80 -135
  245. package/lib/video-recap/skills/video-script/scripts/brief_inputs.py +125 -167
  246. package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +93 -208
  247. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +20 -46
  248. package/lib/video-recap/skills/video-script/scripts/lib.py +30 -26
  249. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +145 -140
  250. package/lib/video-recap/skills/video-script/scripts/review_response.py +12 -42
  251. package/lib/video-recap/skills/video-script/scripts/review_runner.py +0 -6
  252. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +39 -100
  253. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +33 -124
  254. package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +1 -1
  255. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +63 -172
  256. package/lib/video-recap/skills/video-understanding/scripts/asr.py +1 -2
  257. package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +80 -135
  258. package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +125 -167
  259. package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +93 -208
  260. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +1 -1
  261. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +20 -46
  262. package/lib/video-recap/skills/video-understanding/scripts/lib.py +31 -26
  263. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +145 -140
  264. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +39 -100
  265. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +33 -124
  266. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +1 -4
  267. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +7 -13
  268. package/lib/video-recap/skills/video-voiceover/scripts/fish_audio.py +12 -30
  269. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +98 -131
  270. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +17 -28
  271. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +151 -240
  272. package/package.json +41 -41
@@ -1,6 +1,6 @@
1
+ import argparse
1
2
  import base64
2
3
  import json
3
- import math
4
4
  import os
5
5
  import re
6
6
  import shutil
@@ -11,8 +11,8 @@ from pathlib import Path
11
11
  from threading import Lock
12
12
 
13
13
  from fish_audio import synthesize_fish_audio
14
- from lib import CONFIG
15
14
  from lib import (
15
+ CONFIG,
16
16
  _text_char_count,
17
17
  _truncate_at_sentence,
18
18
  file_fingerprint,
@@ -32,14 +32,20 @@ SEGMENT_AUDIO_SCHEMA_VERSION = 1
32
32
  TTS_CACHE_VERSION = 2
33
33
  VOICE_REFERENCE_PREP_VERSION = 1
34
34
  _VOICE_REFERENCE_LOCK = Lock()
35
+ # Process-wide voice-reference state; prepared bytes are invocation-scoped.
36
+ _VOICE_REFERENCE_STATE_KEYS = (
37
+ "voice_ref_b64",
38
+ "voice_ref_snapshot_path",
39
+ "voice_ref_snapshot_signature",
40
+ "voice_ref_source_signature",
41
+ "voice_ref_fingerprint",
42
+ "voice_ref_snapshot_locked",
43
+ )
35
44
 
36
45
 
37
46
  def _parse_rate_offset(rate_str):
38
47
  """'+5%' -> 0.05, '-3%' -> -0.03, '+0%' -> 0.0"""
39
- m = re.match(r'([+-])(\d+)%', rate_str)
40
- if m:
41
- return float(m.group(1) + m.group(2)) / 100.0
42
- return 0.0
48
+ return float(rate_str.rstrip("%")) / 100.0
43
49
 
44
50
 
45
51
  def _compute_tts_params(text, narration, seg_index):
@@ -74,10 +80,9 @@ def _compute_tts_params(text, narration, seg_index):
74
80
 
75
81
  return rate, pitch
76
82
 
83
+
77
84
  def _clean_narration_text(text):
78
85
  """清理解说文本中 TTS 不应读出的内容"""
79
- if not text:
80
- return text
81
86
  # 移除 markdown 格式标记
82
87
  text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # **bold** → bold
83
88
  text = re.sub(r'\*(.+?)\*', r'\1', text) # *italic* → italic
@@ -95,8 +100,7 @@ def _clean_narration_text(text):
95
100
  text = re.sub(r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF'
96
101
  r'\U0001F1E0-\U0001F1FF\U00002702-\U000027B0\U0000FE00-\U0000FEFF]', '', text)
97
102
  # 清理多余空白
98
- text = re.sub(r'\s+', ' ', text).strip()
99
- return text
103
+ return re.sub(r'\s+', ' ', text).strip()
100
104
 
101
105
 
102
106
  def _synthesize_segment(i, seg, narration, tts_dir, engine):
@@ -105,15 +109,15 @@ def _synthesize_segment(i, seg, narration, tts_dir, engine):
105
109
  if prepared is None:
106
110
  return None
107
111
  text, output_wav, rate, pitch, cache_key = prepared
108
- cached = _reuse_tts_segment_cache(i, seg, output_wav, text, rate, cache_key)
112
+ cached = _reuse_tts_segment_cache(i, seg, output_wav, cache_key)
109
113
  if cached:
110
114
  return cached
111
115
 
112
116
  _run_tts_engine(engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion"))
113
117
 
114
- dur = _get_audio_duration(output_wav)
118
+ dur = get_video_duration(output_wav)
115
119
  seg_slot = seg["end"] - seg["start"]
116
- seg_pause = seg.get("pause_after_ms", CONFIG.get("breath_ms", 250)) / 1000
120
+ seg_pause = seg.get("pause_after_ms", CONFIG["breath_ms"]) / 1000
117
121
  available = max(0.5, seg_slot - seg_pause)
118
122
  rate_offset = _parse_rate_offset(rate)
119
123
  budget = narration_tempo_budget(rate_offset)
@@ -121,7 +125,7 @@ def _synthesize_segment(i, seg, narration, tts_dir, engine):
121
125
  truncated = False
122
126
  truncate_reason = "none"
123
127
  if dur > raw_budget and len(text) > 5:
124
- chars_per_sec = _text_char_count(text) / dur if dur > 0 else 3.0
128
+ chars_per_sec = _text_char_count(text) / dur
125
129
  target_chars = max(5, int(raw_budget * chars_per_sec) - 1)
126
130
  shortened = _truncate_at_sentence(text, target_chars)
127
131
  if shortened and len(shortened) >= 5 and shortened != text:
@@ -130,23 +134,22 @@ def _synthesize_segment(i, seg, narration, tts_dir, engine):
130
134
  truncated = True
131
135
  truncate_reason = "sentence_boundary"
132
136
  _run_tts_engine(engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion"))
133
- dur = _get_audio_duration(output_wav)
137
+ dur = get_video_duration(output_wav)
134
138
 
135
139
  norm_meta = _maybe_normalize_tts_wav(output_wav)
136
140
  if norm_meta:
137
- dur = _get_audio_duration(output_wav)
141
+ dur = get_video_duration(output_wav)
138
142
  _write_tts_segment_cache(output_wav, cache_key, text, dur, rate_offset,
139
143
  truncated, truncate_reason, norm_meta)
140
- result = _build_tts_segment_result(
144
+ return _build_tts_segment_result(
141
145
  i, seg, text, output_wav, dur, rate_offset, truncated, truncate_reason, norm_meta)
142
- return result
143
146
 
144
147
 
145
148
  def _build_tts_segment_result(index, seg, text, output_wav, duration, rate_offset,
146
149
  truncated=False, truncate_reason="none", norm_meta=None):
147
150
  budget = narration_tempo_budget(rate_offset)
148
- authored_text = _clean_narration_text(str(seg.get("narration", text)))
149
- resolved_truncated = bool(truncated) or (text != authored_text)
151
+ authored_text = _clean_narration_text(seg["narration"])
152
+ resolved_truncated = truncated or text != authored_text
150
153
  result = {
151
154
  "segment_audio_schema_version": SEGMENT_AUDIO_SCHEMA_VERSION,
152
155
  "index": index,
@@ -165,11 +168,11 @@ def _build_tts_segment_result(index, seg, text, output_wav, duration, rate_offse
165
168
  "global_narration_speed": budget["global_narration_speed"],
166
169
  "segment_tempo_factor": 1.0,
167
170
  "effective_tempo": budget["global_narration_speed"] * budget["tts_rate_factor"],
168
- "rms_dbfs_before": (norm_meta or {}).get("rms_dbfs_before"),
169
- "rms_dbfs_after": (norm_meta or {}).get("rms_dbfs_after"),
170
- "peak_after": (norm_meta or {}).get("peak_after"),
171
+ "rms_dbfs_before": norm_meta["rms_dbfs_before"] if norm_meta else None,
172
+ "rms_dbfs_after": norm_meta["rms_dbfs_after"] if norm_meta else None,
173
+ "peak_after": norm_meta["peak_after"] if norm_meta else None,
171
174
  "tts_rate_offset": rate_offset,
172
- "pause_after_ms": seg.get("pause_after_ms", CONFIG.get("breath_ms", 250)),
175
+ "pause_after_ms": seg.get("pause_after_ms", CONFIG["breath_ms"]),
173
176
  "overlaps_speech": seg.get("overlaps_speech", True),
174
177
  }
175
178
  for optional_key in ("source_start", "source_end", "source_clip_id", "emotion"):
@@ -181,17 +184,16 @@ def _build_tts_segment_result(index, seg, text, output_wav, duration, rate_offse
181
184
  def _tts_failure_record(index, seg, error):
182
185
  """Build a user-visible failure record for partial TTS output."""
183
186
  return {
184
- "index": int(index),
185
- "start": seg.get("start"),
186
- "end": seg.get("end"),
187
- "text": _clean_narration_text(str(seg.get("narration", ""))),
187
+ "index": index,
188
+ "start": seg["start"],
189
+ "end": seg["end"],
190
+ "text": _clean_narration_text(seg["narration"]),
188
191
  "error": str(error),
189
192
  }
190
193
 
191
194
 
192
- def _build_tts_meta(segments, engine, narration_name, failures=None):
195
+ def _build_tts_meta(segments, engine, narration_name, failures):
193
196
  """Stable tts_meta.json payload, including partial-failure visibility."""
194
- failures = list(failures or [])
195
197
  return {
196
198
  "segments": segments,
197
199
  "engine": engine,
@@ -201,23 +203,16 @@ def _build_tts_meta(segments, engine, narration_name, failures=None):
201
203
  }
202
204
 
203
205
 
206
+ def _reset_voice_reference_state():
207
+ """Drop prepared reference bytes so a reused process re-hashes the live source."""
208
+ for key in _VOICE_REFERENCE_STATE_KEYS:
209
+ CONFIG.pop(key, None)
210
+
211
+
204
212
  def synthesize_tts(narration, work_dir):
205
- """合成解说音频(并行)"""
206
- synthesize_tts.last_failures = []
207
- voice_ref = str(CONFIG.get("voice_ref") or "").strip()
208
- if voice_ref:
209
- # Prepared bytes are invocation-scoped. Re-hash the live source once for the cache-only
210
- # probe; if fresh synthesis is needed, a stable snapshot below becomes the one identity
211
- # used by both the API request and the final segment cache keys.
212
- for key in (
213
- "voice_ref_b64",
214
- "voice_ref_snapshot_path",
215
- "voice_ref_snapshot_signature",
216
- "voice_ref_source_signature",
217
- "voice_ref_fingerprint",
218
- "voice_ref_snapshot_locked",
219
- ):
220
- CONFIG.pop(key, None)
213
+ """合成解说音频(并行)。Returns (segments, engine, failures)."""
214
+ _reset_voice_reference_state()
215
+ voice_ref = CONFIG["voice_ref"]
221
216
  tts_dir = work_dir / "tts_segments"
222
217
  tts_dir.mkdir(exist_ok=True)
223
218
 
@@ -229,6 +224,7 @@ def synthesize_tts(narration, work_dir):
229
224
  raise RuntimeError(
230
225
  "Fish Audio 不接受本地 VOICE_REF/--voice-ref;请改用 FISH_TTS_REFERENCE_ID"
231
226
  )
227
+ # A fully cached narration needs no credential: probe every segment's sidecar first.
232
228
  cached_segments = []
233
229
  needs_fresh = False
234
230
  prepared_count = 0
@@ -237,8 +233,7 @@ def synthesize_tts(narration, work_dir):
237
233
  if prepared is None:
238
234
  continue
239
235
  prepared_count += 1
240
- text, output_wav, rate, _pitch, cache_key = prepared
241
- cached = _reuse_tts_segment_cache(i, seg, output_wav, text, rate, cache_key)
236
+ cached = _reuse_tts_segment_cache(i, seg, prepared[1], prepared[4])
242
237
  if not cached:
243
238
  needs_fresh = True
244
239
  break
@@ -248,8 +243,7 @@ def synthesize_tts(narration, work_dir):
248
243
  if not needs_fresh:
249
244
  cached_segments.sort(key=lambda x: x["index"])
250
245
  log(f"TTS 引擎: {cache_engine} (cache)")
251
- synthesize_tts.last_failures = []
252
- return cached_segments, cache_engine
246
+ return cached_segments, cache_engine, []
253
247
 
254
248
  engine = resolve_tts_engine()
255
249
 
@@ -261,7 +255,7 @@ def synthesize_tts(narration, work_dir):
261
255
 
262
256
  segments = []
263
257
  failures = []
264
- max_workers = max(1, min(len(narration), CONFIG.get("tts_workers", 4)))
258
+ max_workers = min(len(narration), CONFIG["tts_workers"])
265
259
 
266
260
  try:
267
261
  with ThreadPoolExecutor(max_workers=max_workers) as executor:
@@ -284,13 +278,12 @@ def synthesize_tts(narration, work_dir):
284
278
  CONFIG.pop("voice_ref_snapshot_locked", None)
285
279
 
286
280
  segments.sort(key=lambda x: x["index"])
287
- if failures and not CONFIG.get("allow_partial_tts", False):
281
+ if failures and not CONFIG["allow_partial_tts"]:
288
282
  sample = "; ".join(f"段 {f['index']+1}: {f['error']}" for f in failures[:3])
289
283
  raise RuntimeError(
290
284
  f"TTS 失败 {len(failures)}/{len(narration)} 段,已中止以避免生成缺解说的视频。"
291
285
  f"示例: {sample}。如确需继续,可设置 ALLOW_PARTIAL_TTS=1 或 --allow-partial-tts。"
292
286
  )
293
- synthesize_tts.last_failures = failures
294
287
  if failures:
295
288
  missing = ", ".join(str(f["index"] + 1) for f in failures[:8])
296
289
  more = "…" if len(failures) > 8 else ""
@@ -300,12 +293,14 @@ def synthesize_tts(narration, work_dir):
300
293
  )
301
294
  if not segments:
302
295
  raise RuntimeError("TTS 没有生成任何有效解说音频,已中止以避免生成无解说视频")
303
- return segments, engine
296
+ return segments, engine, failures
304
297
 
305
298
 
306
299
  def _run_tts_engine(engine, text, output_wav, rate="+0%", pitch="+0Hz", emotion=None):
307
300
  """Run one TTS engine with retry and remove partial files after failures."""
308
- retries = max(1, CONFIG.get("tts_retries", 3))
301
+ if engine not in SUPPORTED_TTS_ENGINES:
302
+ raise RuntimeError(f"不支持的 TTS 引擎: {engine}。当前支持 mimo-tts、fish-audio。")
303
+ retries = CONFIG["tts_retries"]
309
304
  last_error = None
310
305
 
311
306
  for attempt in range(1, retries + 1):
@@ -313,17 +308,9 @@ def _run_tts_engine(engine, text, output_wav, rate="+0%", pitch="+0Hz", emotion=
313
308
  _cleanup_partial_tts_outputs(output_wav)
314
309
  if engine == "mimo-tts":
315
310
  _tts_mimo(text, output_wav, rate=rate, pitch=pitch, emotion=emotion)
316
- elif engine == "fish-audio":
317
- synthesize_fish_audio(
318
- text, output_wav, rate=rate, pitch=pitch, emotion=emotion
319
- )
320
311
  else:
321
- raise RuntimeError(
322
- f"不支持的 TTS 引擎: {engine}。当前支持 mimo-tts、fish-audio。"
323
- )
324
-
325
- dur = _get_audio_duration(output_wav)
326
- if dur <= 0:
312
+ synthesize_fish_audio(text, output_wav, rate=rate)
313
+ if get_video_duration(output_wav) <= 0:
327
314
  raise RuntimeError(f"{engine} 输出音频时长无效")
328
315
  return
329
316
  except Exception as exc:
@@ -340,26 +327,13 @@ def _run_tts_engine(engine, text, output_wav, rate="+0%", pitch="+0Hz", emotion=
340
327
  def _cleanup_partial_tts_outputs(output_wav):
341
328
  """Remove stale partial media files before/after a failed TTS attempt."""
342
329
  wav_path = Path(output_wav)
343
- mp3_path = wav_path.with_suffix(".mp3")
344
- partial_path = Path(str(wav_path) + ".part")
345
- cache_path = str(_tts_segment_cache_path(output_wav))
346
- for path in (str(wav_path), str(mp3_path), str(partial_path), cache_path):
347
- try:
348
- if os.path.exists(path):
349
- os.remove(path)
350
- except OSError:
351
- pass
352
-
353
-
354
- def _finite_positive(value):
355
- """Return value as a positive float, or None for missing/malformed/non-positive input."""
356
- try:
357
- number = float(value)
358
- except (TypeError, ValueError):
359
- return None
360
- if not math.isfinite(number) or number <= 0:
361
- return None
362
- return number
330
+ for path in (
331
+ wav_path,
332
+ wav_path.with_suffix(".mp3"),
333
+ Path(str(wav_path) + ".part"),
334
+ _tts_segment_cache_path(output_wav),
335
+ ):
336
+ path.unlink(missing_ok=True)
363
337
 
364
338
 
365
339
  def _tts_segment_cache_path(output_wav):
@@ -368,10 +342,10 @@ def _tts_segment_cache_path(output_wav):
368
342
 
369
343
  def _prepare_tts_segment(index, seg, narration, tts_dir, engine):
370
344
  text = _clean_narration_text(seg["narration"])
371
- if not text or not text.strip():
345
+ if not text:
372
346
  return None
373
347
  output_wav = tts_dir / f"narr_{index:03d}.wav"
374
- if CONFIG.get("tts_dynamic_params", True):
348
+ if CONFIG["tts_dynamic_params"]:
375
349
  rate, pitch = _compute_tts_params(text, narration, index)
376
350
  else:
377
351
  rate, pitch = "+0%", "+0Hz"
@@ -379,48 +353,39 @@ def _prepare_tts_segment(index, seg, narration, tts_dir, engine):
379
353
  return text, output_wav, rate, pitch, cache_key
380
354
 
381
355
 
382
- def _reuse_tts_segment_cache(index, seg, output_wav, source_text, rate, cache_key):
356
+ def _reuse_tts_segment_cache(index, seg, output_wav, cache_key):
383
357
  cached = _load_tts_segment_cache(output_wav, cache_key)
384
- if not cached:
358
+ if cached is None:
385
359
  return None
386
360
  # The sidecar's audio_fingerprint already proved these exact bytes are what produced
387
- # `audio_duration`, so re-probing is a wasted ffprobe process per segment — on a
388
- # fully-cached rerun of a 200-block narration that is 200 subprocess spawns for a
389
- # number we already hold. Only fall back to ffprobe for pre-existing sidecars written
390
- # before the duration was recorded.
391
- existing_dur = _finite_positive(cached.get("audio_duration"))
392
- if existing_dur is None:
393
- existing_dur = _get_audio_duration(output_wav)
394
- if existing_dur <= 0:
395
- return None
396
- spoken_text = str(cached.get("spoken_text") or source_text)
397
- rate_offset = float(cached.get("tts_rate_offset", _parse_rate_offset(rate)) or 0.0)
398
- truncated = bool(cached.get("truncated", False))
399
- truncate_reason = str(cached.get("truncate_reason") or ("sentence_boundary" if truncated else "none"))
400
- norm_meta = cached.get("normalization") if isinstance(cached.get("normalization"), dict) else None
401
- log(f" 段 {index+1}: 复用已有 ({existing_dur:.1f}s)")
402
- return _build_tts_segment_result(index, seg, spoken_text, output_wav, existing_dur,
403
- rate_offset, truncated, truncate_reason, norm_meta)
361
+ # `audio_duration`; re-probing would be one ffprobe process per segment on every rerun.
362
+ log(f" 段 {index+1}: 复用已有 ({cached['audio_duration']:.1f}s)")
363
+ return _build_tts_segment_result(
364
+ index,
365
+ seg,
366
+ cached["spoken_text"],
367
+ output_wav,
368
+ cached["audio_duration"],
369
+ cached["tts_rate_offset"],
370
+ cached["truncated"],
371
+ cached["truncate_reason"],
372
+ cached["normalization"],
373
+ )
404
374
 
405
375
 
406
376
  def _tts_segment_cache_key(engine, index, seg, source_text, rate, pitch):
407
377
  """Fingerprint the exact inputs that make a cached segment safe to reuse."""
408
- pause = seg.get("pause_after_ms", CONFIG.get("breath_ms", 250))
409
- try:
410
- pause = int(pause)
411
- except (TypeError, ValueError):
412
- pause = CONFIG.get("breath_ms", 250)
413
378
  payload = {
414
379
  "version": TTS_CACHE_VERSION,
415
380
  "engine": engine,
416
381
  "source_text": source_text,
417
- "segment_index": int(index),
418
- "start": round(float(seg.get("start", 0.0)), 3),
419
- "end": round(float(seg.get("end", 0.0)), 3),
420
- "pause_after_ms": pause,
382
+ "segment_index": index,
383
+ "start": round(seg["start"], 3),
384
+ "end": round(seg["end"], 3),
385
+ "pause_after_ms": seg.get("pause_after_ms", CONFIG["breath_ms"]),
421
386
  "rate": rate,
422
387
  "pitch": pitch,
423
- "emotion": (str(seg.get("emotion")).strip() if seg.get("emotion") else ""),
388
+ "emotion": seg.get("emotion", ""),
424
389
  "settings": tts_settings_fingerprint(engine),
425
390
  }
426
391
  return stable_hash(payload)
@@ -428,54 +393,52 @@ def _tts_segment_cache_key(engine, index, seg, source_text, rate, pitch):
428
393
 
429
394
  def _load_tts_segment_cache(output_wav, cache_key):
430
395
  """Return cache metadata only when the sidecar proves the WAV matches narration."""
431
- if not output_wav.exists():
432
- return None
433
396
  cache_path = _tts_segment_cache_path(output_wav)
434
- if not cache_path.exists():
397
+ if not output_wav.exists() or not cache_path.exists():
435
398
  return None
436
399
  try:
437
- import json
438
400
  data = json.loads(cache_path.read_text(encoding="utf-8"))
439
401
  except (OSError, ValueError):
440
402
  return None
441
- if not isinstance(data, dict) or data.get("cache_key") != cache_key:
403
+ required = {
404
+ "cache_key", "audio_fingerprint", "spoken_text", "audio_duration",
405
+ "tts_rate_offset", "truncated", "truncate_reason", "normalization",
406
+ }
407
+ if not isinstance(data, dict) or not required <= data.keys():
442
408
  return None
443
409
  try:
444
- if data.get("audio_fingerprint") != file_fingerprint(output_wav):
445
- return None
410
+ audio_fingerprint = file_fingerprint(output_wav)
446
411
  except OSError:
447
412
  return None
413
+ if data["cache_key"] != cache_key or data["audio_fingerprint"] != audio_fingerprint:
414
+ return None
448
415
  return data
449
416
 
450
417
 
451
418
  def _write_tts_segment_cache(output_wav, cache_key, spoken_text, duration, rate_offset,
452
419
  truncated=False, truncate_reason="none", norm_meta=None):
453
420
  """Persist non-secret provenance for safe per-segment TTS reuse."""
454
- try:
455
- import json
456
- _tts_segment_cache_path(output_wav).write_text(
457
- json.dumps({
458
- "version": TTS_CACHE_VERSION,
459
- "cache_key": cache_key,
460
- "audio_fingerprint": file_fingerprint(output_wav),
461
- "spoken_text": spoken_text,
462
- "audio_duration": duration,
463
- "tts_rate_offset": rate_offset,
464
- "truncated": bool(truncated),
465
- "truncate_reason": truncate_reason if truncated else "none",
466
- "normalization": norm_meta or None,
467
- }, ensure_ascii=False, indent=2),
468
- encoding="utf-8",
469
- )
470
- except OSError as exc:
471
- log(f" TTS 缓存元数据写入失败(忽略): {exc}")
421
+ _tts_segment_cache_path(output_wav).write_text(
422
+ json.dumps({
423
+ "version": TTS_CACHE_VERSION,
424
+ "cache_key": cache_key,
425
+ "audio_fingerprint": file_fingerprint(output_wav),
426
+ "spoken_text": spoken_text,
427
+ "audio_duration": duration,
428
+ "tts_rate_offset": rate_offset,
429
+ "truncated": truncated,
430
+ "truncate_reason": truncate_reason if truncated else "none",
431
+ "normalization": norm_meta or None,
432
+ }, ensure_ascii=False, indent=2),
433
+ encoding="utf-8",
434
+ )
472
435
 
473
436
 
474
437
  def _configured_tts_engine_for_cache():
475
438
  """Resolve provider intent without requiring a live credential for cache probes."""
476
- provider = str(CONFIG.get("tts_provider") or "auto").strip().lower()
439
+ provider = CONFIG["tts_provider"]
477
440
  if provider == "auto":
478
- if CONFIG.get("mimo_tts_api_key") or not CONFIG.get("fish_api_key"):
441
+ if CONFIG["mimo_tts_api_key"] or not CONFIG["fish_api_key"]:
479
442
  return "mimo-tts"
480
443
  return "fish-audio"
481
444
  if provider not in SUPPORTED_TTS_ENGINES:
@@ -485,71 +448,52 @@ def _configured_tts_engine_for_cache():
485
448
  return provider
486
449
 
487
450
 
488
- def _detect_tts_engine():
489
- """Select the configured provider and require its credential."""
490
- return _require_tts_credential(_configured_tts_engine_for_cache())
491
-
492
-
493
- def _require_tts_credential(engine):
494
- """Require the credential for an already-resolved TTS provider."""
495
- if engine == "mimo-tts" and CONFIG.get("mimo_tts_api_key"):
496
- return engine
497
- if engine == "fish-audio" and CONFIG.get("fish_api_key"):
498
- return engine
451
+ def resolve_tts_engine():
452
+ """Resolve the selected MiMo or Fish Audio TTS engine and require its credential."""
453
+ engine = _configured_tts_engine_for_cache()
499
454
  if engine == "fish-audio":
455
+ if CONFIG["fish_api_key"]:
456
+ return engine
500
457
  raise RuntimeError("没有可用的 TTS 引擎:请设置 FISH_API_KEY(Fish Audio 需要)。")
501
- key_name = CONFIG.get("mimo_tts_api_key_source", "MIMO_API_KEY")
502
- raise RuntimeError(f"没有可用的 TTS 引擎:请设置 {key_name}(MiMo TTS 需要)。")
503
-
504
-
505
- def resolve_tts_engine(prefer_existing=None):
506
- """Resolve the selected MiMo or Fish Audio TTS engine.
507
-
508
- `prefer_existing` lets an assemble-only rerun reuse already-generated audio
509
- even when no fresh provider credential is configured.
510
- """
511
- selected = _configured_tts_engine_for_cache()
512
- try:
513
- return _require_tts_credential(selected)
514
- except RuntimeError:
515
- if prefer_existing == selected:
516
- return prefer_existing
517
- raise
458
+ if CONFIG["mimo_tts_api_key"]:
459
+ return engine
460
+ raise RuntimeError(
461
+ f"没有可用的 TTS 引擎:请设置 {CONFIG['mimo_tts_api_key_source']}(MiMo TTS 需要)。"
462
+ )
518
463
 
519
464
 
520
- def tts_settings_fingerprint(engine=None):
465
+ def tts_settings_fingerprint(engine):
521
466
  """Return non-secret TTS settings that materially affect generated audio."""
522
- resolved = engine or resolve_tts_engine()
523
467
  settings = {
524
- "engine": resolved,
525
- "tts_dynamic_params": bool(CONFIG.get("tts_dynamic_params", True)),
526
- "narration_speed": float(CONFIG.get("narration_speed", 1.0) or 1.0),
527
- "narration_cumulative_tempo_max": float(CONFIG.get("narration_cumulative_tempo_max", 1.35) or 1.35),
528
- "narration_cumulative_tempo_hard_max": float(CONFIG.get("narration_cumulative_tempo_hard_max", 1.40) or 1.40),
529
- "tts_segment_tempo_max": float(CONFIG.get("tts_segment_tempo_max", 1.20) or 1.20),
530
- "tts_segment_normalize": bool(CONFIG.get("tts_segment_normalize", True)),
531
- "tts_segment_target_rms_dbfs": float(CONFIG.get("tts_segment_target_rms_dbfs", -20.0) or -20.0),
532
- "tts_segment_peak_limit": float(CONFIG.get("tts_segment_peak_limit", 0.98) or 0.98),
468
+ "engine": engine,
469
+ "tts_dynamic_params": CONFIG["tts_dynamic_params"],
470
+ "narration_speed": CONFIG["narration_speed"],
471
+ "narration_cumulative_tempo_max": CONFIG["narration_cumulative_tempo_max"],
472
+ "narration_cumulative_tempo_hard_max": CONFIG["narration_cumulative_tempo_hard_max"],
473
+ "tts_segment_tempo_max": CONFIG["tts_segment_tempo_max"],
474
+ "tts_segment_normalize": CONFIG["tts_segment_normalize"],
475
+ "tts_segment_target_rms_dbfs": CONFIG["tts_segment_target_rms_dbfs"],
476
+ "tts_segment_peak_limit": CONFIG["tts_segment_peak_limit"],
533
477
  }
534
- if resolved == "fish-audio":
478
+ if engine == "fish-audio":
535
479
  settings.update(
536
480
  {
537
- "fish_tts_api_url": CONFIG.get("fish_tts_api_url"),
538
- "fish_tts_model": CONFIG.get("fish_tts_model"),
539
- "fish_tts_reference_id": CONFIG.get("fish_tts_reference_id"),
481
+ "fish_tts_api_url": CONFIG["fish_tts_api_url"],
482
+ "fish_tts_model": CONFIG["fish_tts_model"],
483
+ "fish_tts_reference_id": CONFIG["fish_tts_reference_id"],
540
484
  }
541
485
  )
542
486
  else:
543
487
  settings.update(
544
488
  {
545
- "mimo_tts_api_url": CONFIG.get("mimo_tts_api_url"),
546
- "mimo_tts_model": CONFIG.get("mimo_tts_model"),
547
- "mimo_tts_voice": CONFIG.get("mimo_tts_voice"),
548
- "mimo_tts_style": CONFIG.get("mimo_tts_style"),
489
+ "mimo_tts_api_url": CONFIG["mimo_tts_api_url"],
490
+ "mimo_tts_model": CONFIG["mimo_tts_model"],
491
+ "mimo_tts_voice": CONFIG["mimo_tts_voice"],
492
+ "mimo_tts_style": CONFIG["mimo_tts_style"],
549
493
  }
550
494
  )
551
- voice_ref = str(CONFIG.get("voice_ref") or "").strip()
552
- if voice_ref and resolved == "mimo-tts":
495
+ voice_ref = CONFIG["voice_ref"]
496
+ if voice_ref and engine == "mimo-tts":
553
497
  ref_path = Path(voice_ref).expanduser()
554
498
  settings.pop("mimo_tts_voice", None) # ignored by the voiceclone API
555
499
  settings["voice_ref_fingerprint"] = _voice_reference_fingerprint(ref_path)
@@ -561,10 +505,9 @@ def tts_settings_fingerprint(engine=None):
561
505
 
562
506
 
563
507
  def _mimo_tts_style_instruction(rate="+0%", pitch="+0Hz", emotion=None):
564
- style = CONFIG.get("mimo_tts_style") or "自然、清晰、适合中文视频解说。"
565
- emo = str(emotion).strip() if emotion else ""
566
- if emo:
567
- tone = f"用「{emo}」的情绪和语气演绎这句解说,代入感强、有起伏,不要平铺直叙。"
508
+ style = CONFIG["mimo_tts_style"]
509
+ if emotion:
510
+ tone = f"用「{emotion}」的情绪和语气演绎这句解说,代入感强、有起伏,不要平铺直叙。"
568
511
  else:
569
512
  tone = "语气有感染力、有起伏,像在给观众讲故事,不要平淡机械。"
570
513
  rate_offset = _parse_rate_offset(rate)
@@ -574,7 +517,7 @@ def _mimo_tts_style_instruction(rate="+0%", pitch="+0Hz", emotion=None):
574
517
  speed = "语速略慢,适当停顿,保留收束感。"
575
518
  else:
576
519
  speed = "语速中等,节奏稳定。"
577
- pitch_hint = "疑问句或情绪抬升处可自然微升调。" if pitch and pitch != "+0Hz" else "音调自然。"
520
+ pitch_hint = "疑问句或情绪抬升处可自然微升调。" if pitch != "+0Hz" else "音调自然。"
578
521
  return f"{style} {tone} {speed} {pitch_hint}"
579
522
 
580
523
 
@@ -590,8 +533,7 @@ def _prepare_voice_reference(ref_path):
590
533
  "-t", "30", "-acodec", "pcm_s16le", str(normalized),
591
534
  ])
592
535
  if result.returncode != 0 or not normalized.is_file() or normalized.stat().st_size <= 44:
593
- detail = (result.stderr or "").strip()
594
- raise RuntimeError(f"参考音频转码失败: {detail or ref}")
536
+ raise RuntimeError(f"参考音频转码失败: {result.stderr.strip() or ref}")
595
537
  return base64.b64encode(normalized.read_bytes()).decode("ascii")
596
538
 
597
539
 
@@ -604,8 +546,6 @@ def _voice_reference_signature(ref_path):
604
546
 
605
547
  def _voice_reference_fingerprint(ref_path):
606
548
  ref = Path(ref_path).expanduser()
607
- if not ref.is_file():
608
- return f"missing:{ref.resolve()}"
609
549
  resolved = str(ref.resolve())
610
550
  if (
611
551
  CONFIG.get("voice_ref_snapshot_locked")
@@ -639,16 +579,10 @@ def _cache_prepared_voice_reference(ref_path):
639
579
  signature = _voice_reference_signature(ref)
640
580
  snapshot_path = CONFIG.get("voice_ref_snapshot_path")
641
581
  snapshot_signature = CONFIG.get("voice_ref_snapshot_signature")
642
- legacy_cache_valid = (
643
- snapshot_path is None
644
- and CONFIG.get("voice_ref_source_signature") == signature
645
- )
646
582
  if (
647
583
  CONFIG.get("voice_ref_b64")
648
- and (
649
- (snapshot_path == resolved and snapshot_signature == signature)
650
- or legacy_cache_valid
651
- )
584
+ and snapshot_path == resolved
585
+ and snapshot_signature == signature
652
586
  ):
653
587
  return CONFIG["voice_ref_b64"]
654
588
  if not ref.is_file():
@@ -674,12 +608,10 @@ def _tts_mimo(text, output_path, rate="+0%", pitch="+0Hz", emotion=None):
674
608
 
675
609
  MiMo-v2.5-tts 是 instruct-TTS:user 消息里的自然语言指令控制整句的情绪/语气/语速。
676
610
  每段 narration 的 `emotion` 标签即写进该指令,让解说有起伏、不机械。"""
677
- voice_ref = str(CONFIG.get("voice_ref") or "").strip()
678
- voice_ref_b64 = None
679
- if voice_ref:
680
- voice_ref_b64 = _cache_prepared_voice_reference(voice_ref)
611
+ voice_ref = CONFIG["voice_ref"]
612
+ voice_ref_b64 = _cache_prepared_voice_reference(voice_ref) if voice_ref else None
681
613
  payload = {
682
- "model": "mimo-v2.5-tts-voiceclone" if voice_ref else CONFIG.get("mimo_tts_model", "mimo-v2.5-tts"),
614
+ "model": "mimo-v2.5-tts-voiceclone" if voice_ref else CONFIG["mimo_tts_model"],
683
615
  "messages": [
684
616
  {"role": "user", "content": _mimo_tts_style_instruction(rate, pitch, emotion)},
685
617
  {"role": "assistant", "content": text},
@@ -688,7 +620,7 @@ def _tts_mimo(text, output_path, rate="+0%", pitch="+0Hz", emotion=None):
688
620
  "format": "wav",
689
621
  "voice": (
690
622
  f"data:audio/wav;base64,{voice_ref_b64}"
691
- if voice_ref else CONFIG.get("mimo_tts_voice", "mimo_default")
623
+ if voice_ref else CONFIG["mimo_tts_voice"]
692
624
  ),
693
625
  },
694
626
  }
@@ -703,17 +635,7 @@ def _tts_mimo(text, output_path, rate="+0%", pitch="+0Hz", emotion=None):
703
635
  raise RuntimeError("MiMo-TTS 返回的 audio.data 不是有效 base64") from exc
704
636
 
705
637
 
706
- def _get_audio_duration(audio_path):
707
- """获取音频文件时长(复用 common.get_video_duration 的 ffprobe 探测)。"""
708
- return get_video_duration(audio_path)
709
-
710
-
711
- # ── Step 7: 视频组装 ─────────────────────────────────────────────────
712
-
713
-
714
638
  def main():
715
- import argparse
716
- from pathlib import Path
717
639
  ap = argparse.ArgumentParser(
718
640
  description="video-voiceover: synthesize narration audio segments from narration.json.")
719
641
  ap.add_argument("--work-dir", required=True)
@@ -733,24 +655,14 @@ def main():
733
655
  args = ap.parse_args()
734
656
  work_dir = Path(args.work_dir)
735
657
  CONFIG["tts_provider"] = args.tts_provider
736
- effective_voice_ref = (
658
+ CONFIG["voice_ref"] = (
737
659
  args.voice_ref if args.voice_ref is not None else os.environ.get("VOICE_REF", "").strip()
738
660
  )
739
- CONFIG["voice_ref"] = effective_voice_ref
740
- for key in (
741
- "voice_ref_b64",
742
- "voice_ref_snapshot_path",
743
- "voice_ref_snapshot_signature",
744
- "voice_ref_fingerprint",
745
- "voice_ref_source_signature",
746
- "voice_ref_snapshot_locked",
747
- ):
748
- CONFIG.pop(key, None)
749
661
  if args.mimo_voice:
750
662
  CONFIG["mimo_tts_voice"] = args.mimo_voice
751
- if args.mimo_voice and CONFIG.get("voice_ref"):
663
+ if args.mimo_voice and CONFIG["voice_ref"]:
752
664
  ap.error("--mimo-voice and --voice-ref are mutually exclusive")
753
- if args.tts_provider == "fish-audio" and (args.mimo_voice or CONFIG.get("voice_ref")):
665
+ if args.tts_provider == "fish-audio" and (args.mimo_voice or CONFIG["voice_ref"]):
754
666
  ap.error("--mimo-voice/--voice-ref are only supported by the MiMo TTS provider")
755
667
  # Voice-reference normalization is intentionally lazy: a fully cached rerun should not
756
668
  # invoke ffmpeg. _tts_mimo uses a process-wide lock so a fresh parallel run still converts
@@ -765,8 +677,7 @@ def main():
765
677
  # silently override it; legacy direct-cut callers can still pass --narration explicitly.
766
678
  narration_path = work_dir / "narration.json"
767
679
  narration = json.loads(narration_path.read_text(encoding="utf-8"))
768
- tts_segments, engine_used = synthesize_tts(narration, work_dir)
769
- failures = getattr(synthesize_tts, "last_failures", [])
680
+ tts_segments, engine_used, failures = synthesize_tts(narration, work_dir)
770
681
  meta = _build_tts_meta(tts_segments, engine_used, narration_path.name, failures)
771
682
  (work_dir / "tts_meta.json").write_text(
772
683
  json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")