@oh-story/dsh 0.1.4 → 0.1.6

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 (479) hide show
  1. package/README.md +56 -12
  2. package/lib/client.js +2707 -268
  3. package/lib/client.js.map +4 -4
  4. package/lib/drama/manifest.json +136 -101
  5. package/lib/drama/skills/short-drama/SKILL.md +61 -1
  6. package/lib/drama/skills/short-drama/references/contract-and-ownership.md +7 -4
  7. package/lib/drama/skills/short-drama/references/creator-documents.md +133 -8
  8. package/lib/drama/skills/short-drama/references/creator-workflow.md +38 -2
  9. package/lib/drama/skills/short-drama/references/knowhow-index.md +3 -0
  10. package/lib/drama/skills/short-drama/references/reference-roles.md +10 -7
  11. package/lib/drama/skills/short-drama/references/runtime-preflight.md +3 -0
  12. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +1002 -0
  13. package/lib/drama/skills/short-drama/scripts/project_tool.py +485 -5
  14. package/lib/drama/skills/short-drama-assets/SKILL.md +13 -3
  15. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +116 -0
  16. package/lib/drama/skills/short-drama-assets/references/identity-vs-variant.md +2 -1
  17. package/lib/drama/skills/short-drama-assets/references/prop-and-state.md +19 -0
  18. package/lib/drama/skills/short-drama-assets/references/stage-contract.md +2 -0
  19. package/lib/drama/skills/short-drama-assets/scripts/asset_check.py +1 -1
  20. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +6 -2
  21. package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +4 -3
  22. package/lib/drama/skills/short-drama-image-prompts/references/lookdev-frame.md +1 -1
  23. package/lib/drama/skills/short-drama-image-prompts/references/review-and-fixtures.md +1 -1
  24. package/lib/drama/skills/short-drama-image-prompts/references/stage-contract.md +3 -1
  25. package/lib/drama/skills/short-drama-image-prompts/scripts/image_prompt_check.py +8 -8
  26. package/lib/drama/skills/short-drama-image-prompts/scripts/selftest.py +4 -2
  27. package/lib/drama/skills/short-drama-produce/SKILL.md +46 -10
  28. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +65 -8
  29. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +85 -0
  30. package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +27 -10
  31. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +362 -3
  32. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +627 -40
  33. package/lib/drama/skills/short-drama-produce/scripts/selftest.py +5 -4
  34. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +12 -0
  35. package/lib/drama/skills/short-drama-review/scripts/review_check.py +1 -1
  36. package/lib/drama/skills/short-drama-storyboard/SKILL.md +76 -7
  37. package/lib/drama/skills/short-drama-storyboard/assets/shot-template.jsonl +1 -1
  38. package/lib/drama/skills/short-drama-storyboard/references/keyframe-craft.md +6 -2
  39. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +19 -4
  40. package/lib/drama/skills/short-drama-storyboard/references/review-and-fixtures.md +5 -2
  41. package/lib/drama/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +16 -0
  42. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +8 -3
  43. package/lib/drama/skills/short-drama-storyboard/scripts/storyboard_check.py +1 -1
  44. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +81 -13
  45. package/lib/drama/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +6 -1
  46. package/lib/drama/skills/short-drama-video-prompts/assets/video-prompts.md +17 -1
  47. package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +21 -1
  48. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +154 -0
  49. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +24 -6
  50. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +11 -7
  51. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +41 -0
  52. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +66 -0
  53. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +5 -2
  54. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +115 -0
  55. package/lib/drama/skills/short-drama-video-prompts/scripts/container_check.py +1 -1
  56. package/lib/drama/skills/short-drama-video-prompts/scripts/music_spec_check.py +1 -1
  57. package/lib/drama/skills/short-drama-write/SKILL.md +3 -2
  58. package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +1 -1
  59. package/lib/drama/skills/short-drama-write/scripts/screenplay_index.py +2 -2
  60. package/lib/drama/skills/short-drama-write/scripts/voice_sheet_check.py +1 -1
  61. package/lib/index.js +865 -33
  62. package/lib/index.js.map +4 -4
  63. package/lib/novel-to-game/LICENSE +21 -0
  64. package/lib/novel-to-game/README.md +180 -0
  65. package/lib/novel-to-game/README_ZH.md +178 -0
  66. package/lib/novel-to-game/VERSION +1 -0
  67. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +217 -0
  68. package/lib/novel-to-game/examples/jin-ping-mei/build/app/.vercelignore +3 -0
  69. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +130 -0
  70. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/compound_new_guofeng.webp +0 -0
  71. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/finale/fate_coda.webp +0 -0
  72. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/finale/meng.webp +0 -0
  73. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/finale/pan.webp +0 -0
  74. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/finale/pinger.webp +0 -0
  75. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/finale/xuee.webp +0 -0
  76. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/finale/yue.webp +0 -0
  77. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
  78. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_afterglow_five.webp +0 -0
  79. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_alliance.webp +0 -0
  80. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day10.webp +0 -0
  81. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
  82. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day5.webp +0 -0
  83. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/title_new_guofeng.webp +0 -0
  84. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/joint/pan_pinger.webp +0 -0
  85. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/joint/pan_xuee.webp +0 -0
  86. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/joint/yue_meng.webp +0 -0
  87. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/joint/yue_pan.webp +0 -0
  88. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/joint/yue_pinger.webp +0 -0
  89. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/meng/explicit.webp +0 -0
  90. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/meng/prelude.webp +0 -0
  91. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/milestone/meng.webp +0 -0
  92. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/milestone/pan.webp +0 -0
  93. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/milestone/pinger.webp +0 -0
  94. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/milestone/xuee.webp +0 -0
  95. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/milestone/yue.webp +0 -0
  96. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/pan/explicit.webp +0 -0
  97. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/pan/prelude.webp +0 -0
  98. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/pinger/explicit.webp +0 -0
  99. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/pinger/prelude.webp +0 -0
  100. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/xuee/explicit.webp +0 -0
  101. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/xuee/prelude.webp +0 -0
  102. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/explicit.webp +0 -0
  103. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/prelude.webp +0 -0
  104. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/heroine/meng/night.webp +0 -0
  105. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/heroine/pan/night.webp +0 -0
  106. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/heroine/pinger/night.webp +0 -0
  107. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/heroine/xuee/night.webp +0 -0
  108. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/heroine/yue/night.webp +0 -0
  109. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
  110. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/scene/gate_collector.webp +0 -0
  111. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +1105 -0
  112. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +2539 -0
  113. package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +18 -0
  114. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +147 -0
  115. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +332 -0
  116. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +7472 -0
  117. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +13077 -0
  118. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +3028 -0
  119. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +32 -0
  120. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +10 -0
  121. package/lib/novel-to-game/examples/jin-ping-mei/build/app/vercel.json +23 -0
  122. package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +161 -0
  123. package/lib/novel-to-game/examples/jin-ping-mei/example.json +12 -0
  124. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +31 -0
  125. package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +99 -0
  126. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +41 -0
  127. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +129 -0
  128. package/lib/novel-to-game/manifest.json +508 -0
  129. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +73 -0
  130. package/lib/novel-to-game/skills/game-art-direction/agents/openai.yaml +4 -0
  131. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +119 -0
  132. package/lib/novel-to-game/skills/game-build/SKILL.md +84 -0
  133. package/lib/novel-to-game/skills/game-build/agents/openai.yaml +4 -0
  134. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +109 -0
  135. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +56 -0
  136. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +81 -0
  137. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +37 -0
  138. package/lib/novel-to-game/skills/game-concept/SKILL.md +72 -0
  139. package/lib/novel-to-game/skills/game-concept/agents/openai.yaml +4 -0
  140. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +94 -0
  141. package/lib/novel-to-game/skills/game-qa/SKILL.md +50 -0
  142. package/lib/novel-to-game/skills/game-qa/agents/openai.yaml +4 -0
  143. package/lib/novel-to-game/skills/game-qa/references/qa-contract.md +66 -0
  144. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +45 -0
  145. package/lib/novel-to-game/skills/game-world-design/SKILL.md +63 -0
  146. package/lib/novel-to-game/skills/game-world-design/agents/openai.yaml +4 -0
  147. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +59 -0
  148. package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +53 -0
  149. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +84 -0
  150. package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +56 -0
  151. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +124 -0
  152. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +77 -0
  153. package/lib/novel-to-game/skills/novel-game-analyze/agents/openai.yaml +4 -0
  154. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +65 -0
  155. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +71 -0
  156. package/lib/novel-to-game/skills/novel-to-game/agents/openai.yaml +4 -0
  157. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +42 -0
  158. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +51 -0
  159. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +65 -0
  160. package/lib/oh-story/manifest.json +415 -305
  161. package/lib/oh-story/roles/consistency-checker.md +1 -1
  162. package/lib/oh-story/roles/narrative-writer.md +32 -33
  163. package/lib/oh-story/roles/story-architect.md +16 -26
  164. package/lib/oh-story/skills/browser-cdp/SKILL.md +1 -1
  165. package/lib/oh-story/skills/story/SKILL.md +16 -9
  166. package/lib/oh-story/skills/story/VERSION +1 -1
  167. package/lib/oh-story/skills/story/references/author-memory.md +163 -0
  168. package/lib/oh-story/skills/story/scripts/author_memory_commit.py +871 -0
  169. package/lib/oh-story/skills/story-cover/SKILL.md +1 -1
  170. package/lib/oh-story/skills/story-deslop/SKILL.md +10 -5
  171. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +2 -1
  172. package/lib/oh-story/skills/story-deslop/references/author-memory.md +163 -0
  173. package/lib/oh-story/skills/story-deslop/references/banned-words.md +6 -4
  174. package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +871 -0
  175. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +64 -1
  176. package/lib/oh-story/skills/story-import/SKILL.md +19 -10
  177. package/lib/oh-story/skills/story-import/references/format-and-structure.md +9 -10
  178. package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +2 -2
  179. package/lib/oh-story/skills/story-import/references/structure-mapping-short.md +1 -1
  180. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +14 -6
  181. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +251 -0
  182. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +98 -9
  183. package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +246 -0
  184. package/lib/oh-story/skills/story-long-analyze/SKILL.md +4 -4
  185. package/lib/oh-story/skills/story-long-scan/SKILL.md +1 -1
  186. package/lib/oh-story/skills/story-long-write/SKILL.md +35 -21
  187. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +2 -1
  188. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +7 -3
  189. package/lib/oh-story/skills/story-long-write/references/author-memory.md +163 -0
  190. package/lib/oh-story/skills/story-long-write/references/banned-words.md +6 -4
  191. package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +6 -6
  192. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards.md +1 -1
  193. package/lib/oh-story/skills/story-long-write/references/genre-readers.md +1 -1
  194. package/lib/oh-story/skills/{story-setup/references/agent-references/hooks-chapter.md → story-long-write/references/long-chapter-hooks.md} +5 -5
  195. package/lib/oh-story/skills/story-long-write/references/{quality-checklist.md → long-chapter-quality.md} +2 -1
  196. package/lib/oh-story/skills/story-long-write/references/long-format.md +42 -0
  197. package/lib/oh-story/skills/{story-setup/references/agent-references/genre-catalog.md → story-long-write/references/long-genre-catalog.md} +11 -97
  198. package/lib/oh-story/skills/{story-setup/references/agent-references/genre-core-mechanics.md → story-long-write/references/long-genre-mechanics.md} +1 -1
  199. package/lib/oh-story/skills/{story-short-write/references/reversal-toolkit.md → story-long-write/references/long-reversal.md} +18 -32
  200. package/lib/oh-story/skills/story-long-write/references/{hooks-suspense.md → long-suspense.md} +6 -6
  201. package/lib/oh-story/skills/story-long-write/references/opening-design.md +1 -1
  202. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +1 -1
  203. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
  204. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +14 -6
  205. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +32 -27
  206. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +4 -4
  207. package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +2 -2
  208. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +14 -14
  209. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +54 -54
  210. package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +871 -0
  211. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +64 -1
  212. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +251 -0
  213. package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +303 -0
  214. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +98 -9
  215. package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +246 -0
  216. package/lib/oh-story/skills/story-review/SKILL.md +26 -17
  217. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +2 -1
  218. package/lib/oh-story/skills/story-review/references/author-memory.md +163 -0
  219. package/lib/oh-story/skills/story-review/references/banned-words.md +6 -4
  220. package/lib/oh-story/skills/story-review/references/{quality-checklist.md → review-quality.md} +22 -21
  221. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +14 -6
  222. package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +871 -0
  223. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +64 -1
  224. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +98 -9
  225. package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +246 -0
  226. package/lib/oh-story/skills/story-setup/SKILL.md +74 -33
  227. package/lib/oh-story/skills/story-setup/references/agent-references/agent-quality.md +72 -0
  228. package/lib/oh-story/skills/story-setup/references/agent-references/agent-reference-profiles.md +55 -0
  229. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +2 -1
  230. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +6 -4
  231. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +9 -10
  232. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards.md +1 -1
  233. package/lib/oh-story/skills/story-setup/references/agent-references/genre-readers.md +1 -1
  234. package/lib/oh-story/skills/{story-short-analyze/references/hooks-chapter.md → story-setup/references/agent-references/long-chapter-hooks.md} +5 -5
  235. package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +209 -0
  236. package/lib/oh-story/skills/{story-long-write/references/genre-catalog.md → story-setup/references/agent-references/long-genre-catalog.md} +11 -97
  237. package/lib/oh-story/skills/{story-long-write/references/genre-core-mechanics.md → story-setup/references/agent-references/long-genre-mechanics.md} +1 -1
  238. package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +40 -0
  239. package/lib/oh-story/skills/story-setup/references/agent-references/{reversal-toolkit.md → long-reversal.md} +18 -32
  240. package/lib/oh-story/skills/{story-short-write/references/hooks-suspense.md → story-setup/references/agent-references/long-suspense.md} +6 -6
  241. package/lib/oh-story/skills/story-setup/references/agent-references/opening-design.md +1 -1
  242. package/lib/oh-story/skills/story-setup/references/agent-references/{emotional-methods.md → short-emotional-methods.md} +4 -4
  243. package/lib/oh-story/skills/{story-long-write/references/genre-writing-formulas.md → story-setup/references/agent-references/short-genre-formulas.md} +32 -44
  244. package/lib/oh-story/skills/story-setup/references/agent-references/short-quality.md +39 -0
  245. package/lib/oh-story/skills/{story-long-write/references/reversal-toolkit.md → story-setup/references/agent-references/short-reversal.md} +29 -23
  246. package/lib/oh-story/skills/story-setup/references/agent-references/{hooks-suspense.md → short-suspense.md} +31 -31
  247. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
  248. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +55 -55
  249. package/lib/oh-story/skills/story-short-analyze/SKILL.md +28 -28
  250. package/lib/oh-story/skills/story-short-analyze/references/{character-basics.md → analysis-character-basics.md} +1 -1
  251. package/lib/oh-story/skills/story-short-analyze/references/{character-design-methods.md → analysis-character-design.md} +1 -1
  252. package/lib/oh-story/skills/story-short-analyze/references/{character-relations.md → analysis-character-relations.md} +1 -1
  253. package/lib/oh-story/skills/story-short-analyze/references/{hooks-paragraph.md → analysis-paragraph-hooks.md} +1 -1
  254. package/lib/oh-story/skills/story-short-analyze/references/{genre-readers.md → analysis-reader-profile.md} +2 -2
  255. package/lib/oh-story/skills/story-short-analyze/references/analysis-report-style.md +45 -0
  256. package/lib/oh-story/skills/story-short-analyze/references/analysis-short-genres.md +65 -0
  257. package/lib/oh-story/skills/story-short-analyze/references/analysis-short-hooks.md +77 -0
  258. package/lib/oh-story/skills/story-short-analyze/references/analysis-short-mechanics.md +82 -0
  259. package/lib/oh-story/skills/story-short-analyze/references/analysis-short-patterns.md +50 -0
  260. package/lib/oh-story/skills/story-short-analyze/references/analysis-short-suspense.md +98 -0
  261. package/lib/oh-story/skills/story-short-analyze/references/{genre-writing-techniques.md → analysis-writing-techniques.md} +21 -19
  262. package/lib/oh-story/skills/story-short-analyze/references/deconstruction-examples.md +1 -1
  263. package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +21 -28
  264. package/lib/oh-story/skills/story-short-analyze/references/output-contract.md +1 -1
  265. package/lib/oh-story/skills/story-short-analyze/references/output-templates.md +17 -17
  266. package/lib/oh-story/skills/story-short-analyze/references/{quality-checklist.md → source-story-quality.md} +28 -49
  267. package/lib/oh-story/skills/story-short-analyze/references/zhihu-style.md +2 -2
  268. package/lib/oh-story/skills/story-short-scan/SKILL.md +1 -1
  269. package/lib/oh-story/skills/story-short-write/SKILL.md +78 -55
  270. package/lib/oh-story/skills/story-short-write/references/author-memory.md +163 -0
  271. package/lib/oh-story/skills/story-short-write/references/banned-words.md +6 -4
  272. package/lib/oh-story/skills/story-short-write/references/cross-book-recall.md +3 -3
  273. package/lib/oh-story/skills/story-short-write/references/emotional-methods.md +4 -4
  274. package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +3 -3
  275. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +2 -2
  276. 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
  277. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +6 -6
  278. 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
  279. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +4 -4
  280. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +7 -7
  281. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/262/231/351/233/225/350/204/221/346/264/236.md +5 -5
  282. package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +4 -4
  283. 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
  284. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +32 -44
  285. package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +20 -18
  286. package/lib/oh-story/skills/story-short-write/references/output-contract.md +1 -1
  287. package/lib/oh-story/skills/story-short-write/references/short-craft.md +18 -18
  288. package/lib/oh-story/skills/story-short-write/references/short-deslop.md +1 -1
  289. package/lib/oh-story/skills/story-short-write/references/short-format.md +38 -8
  290. package/lib/oh-story/skills/story-short-write/references/{quality-checklist.md → short-prose-quality.md} +22 -21
  291. package/lib/oh-story/skills/story-short-write/references/short-reversal.md +355 -0
  292. package/lib/oh-story/skills/{story-short-analyze/references/hooks-suspense.md → story-short-write/references/short-suspense.md} +31 -31
  293. package/lib/oh-story/skills/story-short-write/references/submission-craft.md +2 -2
  294. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +31 -22
  295. package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +871 -0
  296. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +64 -1
  297. package/lib/oh-story/skills/story-short-write/scripts/check-delivery-contract.js +212 -0
  298. package/lib/oh-story/skills/story-short-write/scripts/check-phase2-contract.js +363 -0
  299. package/lib/types/client/drama-production-view.d.ts +34 -0
  300. package/lib/types/client/drama-production-view.d.ts.map +1 -0
  301. package/lib/types/client/drama-production.d.ts +92 -0
  302. package/lib/types/client/drama-production.d.ts.map +1 -0
  303. package/lib/types/client/file-activity.d.ts +4 -2
  304. package/lib/types/client/file-activity.d.ts.map +1 -1
  305. package/lib/types/client/index.d.ts +1 -1
  306. package/lib/types/client/index.d.ts.map +1 -1
  307. package/lib/types/client/production-intents.d.ts +9 -0
  308. package/lib/types/client/production-intents.d.ts.map +1 -0
  309. package/lib/types/client/production-prompts.d.ts +9 -0
  310. package/lib/types/client/production-prompts.d.ts.map +1 -0
  311. package/lib/types/client/production-runtime.d.ts +58 -0
  312. package/lib/types/client/production-runtime.d.ts.map +1 -0
  313. package/lib/types/client/video-studio.d.ts +41 -0
  314. package/lib/types/client/video-studio.d.ts.map +1 -0
  315. package/lib/types/client/workbench-ui.d.ts +5 -0
  316. package/lib/types/client/workbench-ui.d.ts.map +1 -0
  317. package/lib/types/game-verification.d.ts +16 -0
  318. package/lib/types/game-verification.d.ts.map +1 -0
  319. package/lib/types/index.d.ts +3 -1
  320. package/lib/types/index.d.ts.map +1 -1
  321. package/lib/types/production-intent.d.ts +21 -0
  322. package/lib/types/production-intent.d.ts.map +1 -0
  323. package/lib/types/production-tool.d.ts +5 -0
  324. package/lib/types/production-tool.d.ts.map +1 -0
  325. package/lib/types/role-tool.d.ts.map +1 -1
  326. package/lib/types/skill-provider.d.ts +7 -0
  327. package/lib/types/skill-provider.d.ts.map +1 -1
  328. package/lib/types/video-project.d.ts +42 -0
  329. package/lib/types/video-project.d.ts.map +1 -0
  330. package/lib/types/workspace-request-trust.d.ts +6 -0
  331. package/lib/types/workspace-request-trust.d.ts.map +1 -1
  332. package/lib/types/workspace-route.d.ts +20 -0
  333. package/lib/types/workspace-route.d.ts.map +1 -1
  334. package/lib/video-recap/CHANGELOG.md +282 -0
  335. package/lib/video-recap/LICENSE +21 -0
  336. package/lib/video-recap/README.en.md +257 -0
  337. package/lib/video-recap/README.md +255 -0
  338. package/lib/video-recap/manifest.json +684 -0
  339. package/lib/video-recap/skills/video-assemble/SKILL.md +97 -0
  340. package/lib/video-recap/skills/video-assemble/references/jianying/LICENSE.duo-video +21 -0
  341. package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +15 -0
  342. package/lib/video-recap/skills/video-assemble/references/jianying/empty_draft_meta_info.json +69 -0
  343. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +84 -0
  344. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +98 -0
  345. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +204 -0
  346. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_material_video.json +83 -0
  347. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_meta_material_value.json +23 -0
  348. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_project_info.json +133 -0
  349. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_segment.json +70 -0
  350. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_text_styles.json +30 -0
  351. package/lib/video-recap/skills/video-assemble/references/jianying/empty_yj_material_audio.json +41 -0
  352. package/lib/video-recap/skills/video-assemble/references/jianying/empty_yj_material_text.json +109 -0
  353. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +88 -0
  354. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +345 -0
  355. package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +34 -0
  356. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +305 -0
  357. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +94 -0
  358. package/lib/video-recap/skills/video-assemble/scripts/audio_automation.py +205 -0
  359. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +555 -0
  360. package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +261 -0
  361. package/lib/video-recap/skills/video-assemble/scripts/jianying_builders.py +767 -0
  362. package/lib/video-recap/skills/video-assemble/scripts/jianying_model.py +111 -0
  363. package/lib/video-recap/skills/video-assemble/scripts/jianying_optional.py +24 -0
  364. package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +109 -0
  365. package/lib/video-recap/skills/video-assemble/scripts/jianying_templates.py +37 -0
  366. package/lib/video-recap/skills/video-assemble/scripts/jianying_timeline_contract.py +191 -0
  367. package/lib/video-recap/skills/video-assemble/scripts/jianying_tracks.py +75 -0
  368. package/lib/video-recap/skills/video-assemble/scripts/jianying_writer.py +394 -0
  369. package/lib/video-recap/skills/video-assemble/scripts/lib.py +239 -0
  370. package/lib/video-recap/skills/video-assemble/scripts/media.py +243 -0
  371. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +477 -0
  372. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +46 -0
  373. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +486 -0
  374. package/lib/video-recap/skills/video-assemble/scripts/subtitle_core.py +376 -0
  375. package/lib/video-recap/skills/video-assemble/scripts/subtitle_render.py +88 -0
  376. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +248 -0
  377. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +89 -0
  378. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +512 -0
  379. package/lib/video-recap/skills/video-cut/SKILL.md +90 -0
  380. package/lib/video-recap/skills/video-cut/scripts/cut.py +67 -0
  381. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +320 -0
  382. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +545 -0
  383. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +348 -0
  384. package/lib/video-recap/skills/video-cut/scripts/lib.py +77 -0
  385. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +341 -0
  386. package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +249 -0
  387. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +745 -0
  388. package/lib/video-recap/skills/video-recap/SKILL.md +216 -0
  389. package/lib/video-recap/skills/video-recap/references/config-playbook.md +61 -0
  390. package/lib/video-recap/skills/video-recap/references/creative-editing-playbook.md +225 -0
  391. package/lib/video-recap/skills/video-recap/references/data-schema.md +534 -0
  392. package/lib/video-recap/skills/video-recap/references/research-guide.md +74 -0
  393. package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +23 -0
  394. package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +251 -0
  395. package/lib/video-recap/skills/video-recap/scripts/doctor.py +483 -0
  396. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +737 -0
  397. package/lib/video-recap/skills/video-recap/scripts/lib.py +242 -0
  398. package/lib/video-recap/skills/video-recap/scripts/materials.py +457 -0
  399. package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +95 -0
  400. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_client.py +18 -0
  401. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_contract.py +9 -0
  402. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_evidence.py +371 -0
  403. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_observations.py +201 -0
  404. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_payload.py +227 -0
  405. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_report.py +362 -0
  406. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_runner.py +129 -0
  407. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +338 -0
  408. package/lib/video-recap/skills/video-recap/scripts/recap.py +9 -0
  409. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +140 -0
  410. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +550 -0
  411. package/lib/video-recap/skills/video-recap/scripts/recap_review.py +77 -0
  412. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +654 -0
  413. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +335 -0
  414. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +217 -0
  415. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +795 -0
  416. package/lib/video-recap/skills/video-script/SKILL.md +248 -0
  417. package/lib/video-recap/skills/video-script/references/creative-editing-playbook.md +225 -0
  418. package/lib/video-recap/skills/video-script/references/research-guide.md +75 -0
  419. package/lib/video-recap/skills/video-script/scripts/agent_brief.py +399 -0
  420. package/lib/video-recap/skills/video-script/scripts/agent_text.py +410 -0
  421. package/lib/video-recap/skills/video-script/scripts/brief_context.py +403 -0
  422. package/lib/video-recap/skills/video-script/scripts/brief_inputs.py +332 -0
  423. package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +442 -0
  424. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +237 -0
  425. package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +492 -0
  426. package/lib/video-recap/skills/video-script/scripts/lib.py +327 -0
  427. package/lib/video-recap/skills/video-script/scripts/narration.py +12 -0
  428. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +678 -0
  429. package/lib/video-recap/skills/video-script/scripts/review.py +42 -0
  430. package/lib/video-recap/skills/video-script/scripts/review_grounding.py +262 -0
  431. package/lib/video-recap/skills/video-script/scripts/review_response.py +761 -0
  432. package/lib/video-recap/skills/video-script/scripts/review_runner.py +200 -0
  433. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +204 -0
  434. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +199 -0
  435. package/lib/video-recap/skills/video-script/scripts/validate.py +164 -0
  436. package/lib/video-recap/skills/video-understanding/SKILL.md +75 -0
  437. package/lib/video-recap/skills/video-understanding/references/data-schema.md +299 -0
  438. package/lib/video-recap/skills/video-understanding/references/prompt-templates.md +37 -0
  439. package/lib/video-recap/skills/video-understanding/references/research-guide.md +74 -0
  440. package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +399 -0
  441. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +410 -0
  442. package/lib/video-recap/skills/video-understanding/scripts/asr.py +260 -0
  443. package/lib/video-recap/skills/video-understanding/scripts/brief.py +12 -0
  444. package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +403 -0
  445. package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +332 -0
  446. package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +442 -0
  447. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +647 -0
  448. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +237 -0
  449. package/lib/video-recap/skills/video-understanding/scripts/detect.py +513 -0
  450. package/lib/video-recap/skills/video-understanding/scripts/extract.py +62 -0
  451. package/lib/video-recap/skills/video-understanding/scripts/lib.py +470 -0
  452. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +678 -0
  453. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +204 -0
  454. package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +507 -0
  455. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +199 -0
  456. package/lib/video-recap/skills/video-understanding/scripts/understand.py +9 -0
  457. package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +216 -0
  458. package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +327 -0
  459. package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +367 -0
  460. package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +162 -0
  461. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +705 -0
  462. package/lib/video-recap/skills/video-voiceover/SKILL.md +81 -0
  463. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +637 -0
  464. package/lib/video-recap/skills/video-voiceover/scripts/fish_audio.py +109 -0
  465. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +421 -0
  466. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +113 -0
  467. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +782 -0
  468. package/package.json +55 -40
  469. package/lib/oh-story/skills/story-deslop/scripts/check-outline-copy.js +0 -241
  470. package/lib/oh-story/skills/story-long-write/references/format-and-structure.md +0 -169
  471. package/lib/oh-story/skills/story-setup/references/agent-references/genre-writing-formulas.md +0 -444
  472. package/lib/oh-story/skills/story-setup/references/agent-references/quality-checklist.md +0 -247
  473. package/lib/oh-story/skills/story-short-analyze/references/anti-ai-writing.md +0 -582
  474. package/lib/oh-story/skills/story-short-analyze/references/banned-words.md +0 -147
  475. package/lib/oh-story/skills/story-short-analyze/references/genre-catalog.md +0 -490
  476. package/lib/oh-story/skills/story-short-analyze/references/genre-core-mechanics.md +0 -425
  477. package/lib/oh-story/skills/story-short-analyze/references/genre-writing-formulas.md +0 -453
  478. /package/lib/oh-story/skills/{story-long-write/references/hooks-chapter.md → story-setup/references/agent-references/short-chapter-hooks.md} +0 -0
  479. /package/lib/oh-story/skills/{story-long-write/references/hooks-paragraph.md → story-setup/references/agent-references/short-paragraph-hooks.md} +0 -0
@@ -0,0 +1,782 @@
1
+ import base64
2
+ import json
3
+ import math
4
+ import os
5
+ import re
6
+ import shutil
7
+ import tempfile
8
+ import time
9
+ from concurrent.futures import ThreadPoolExecutor, as_completed
10
+ from pathlib import Path
11
+ from threading import Lock
12
+
13
+ from fish_audio import synthesize_fish_audio
14
+ from lib import CONFIG
15
+ from lib import (
16
+ _text_char_count,
17
+ _truncate_at_sentence,
18
+ file_fingerprint,
19
+ get_video_duration,
20
+ log,
21
+ mimo_tts_api_call,
22
+ narration_tempo_budget,
23
+ run_cmd,
24
+ stable_hash,
25
+ )
26
+
27
+ # Re-exported: tests and callers reach these through voiceover, the module owns the flow.
28
+ from tts_audio import _maybe_normalize_tts_wav, _normalize_tts_wav_rms # noqa: F401
29
+
30
+ SUPPORTED_TTS_ENGINES = {"mimo-tts", "fish-audio"}
31
+ SEGMENT_AUDIO_SCHEMA_VERSION = 1
32
+ TTS_CACHE_VERSION = 2
33
+ VOICE_REFERENCE_PREP_VERSION = 1
34
+ _VOICE_REFERENCE_LOCK = Lock()
35
+
36
+
37
+ def _parse_rate_offset(rate_str):
38
+ """'+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
43
+
44
+
45
+ def _compute_tts_params(text, narration, seg_index):
46
+ """根据内容特征和位置计算 TTS 语速/音高参数"""
47
+ rate = "+5%"
48
+ pitch = "+0Hz"
49
+ total = len(narration)
50
+ # 位置相关
51
+ if seg_index == 0:
52
+ rate = "+5%" # 开头稍快,抓住注意力
53
+ elif seg_index >= total - 1:
54
+ rate = "-5%" # 结尾放慢,收束感
55
+ elif seg_index >= total - 2:
56
+ rate = "-2%" # 倒数第二段略慢
57
+
58
+ # 内容相关
59
+ has_exclamation = any(c in text for c in "!!")
60
+ has_question = "?" in text or "?" in text
61
+ has_ellipsis = "……" in text or "..." in text
62
+
63
+ if has_exclamation:
64
+ rate = "+8%"
65
+ pitch = "+3Hz" # 感叹句加速+微升调
66
+ elif has_question:
67
+ pitch = "+5Hz" # 疑问句升调
68
+ elif has_ellipsis:
69
+ rate = "-3%" # 省略号(悬念/犹豫)放慢
70
+
71
+ # 长文本稍快
72
+ if len(text) > 35 and not has_ellipsis:
73
+ rate = max(rate, "+6%", key=lambda x: int(x.rstrip('%+-')))
74
+
75
+ return rate, pitch
76
+
77
+ def _clean_narration_text(text):
78
+ """清理解说文本中 TTS 不应读出的内容"""
79
+ if not text:
80
+ return text
81
+ # 移除 markdown 格式标记
82
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # **bold** → bold
83
+ text = re.sub(r'\*(.+?)\*', r'\1', text) # *italic* → italic
84
+ text = re.sub(r'「(.+?)」', r'\1', text) # 「quote」 → quote
85
+ text = re.sub(r'」|「|『|』', '', text)
86
+ # 移除方括号标注([climax]、[suspense] 等舞台指示)
87
+ text = re.sub(r'\[[^\]]*\]', '', text)
88
+ # 移除圆括号标注((旁白)、(转场)等)
89
+ text = re.sub(r'[((][^))]*[))]', '', text)
90
+ # 规范化省略号和重复标点
91
+ text = re.sub(r'\.{3,}|…{2,}', '……', text)
92
+ text = re.sub(r'……+', '……', text)
93
+ text = re.sub(r'([。!?,;:])\1+', r'\1', text) # 重复标点 → 单个
94
+ # 移除 emoji
95
+ text = re.sub(r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF'
96
+ r'\U0001F1E0-\U0001F1FF\U00002702-\U000027B0\U0000FE00-\U0000FEFF]', '', text)
97
+ # 清理多余空白
98
+ text = re.sub(r'\s+', ' ', text).strip()
99
+ return text
100
+
101
+
102
+ def _synthesize_segment(i, seg, narration, tts_dir, engine):
103
+ """合成单个 TTS 段(线程安全),支持 resume 跳过已有文件"""
104
+ prepared = _prepare_tts_segment(i, seg, narration, tts_dir, engine)
105
+ if prepared is None:
106
+ return None
107
+ text, output_wav, rate, pitch, cache_key = prepared
108
+ cached = _reuse_tts_segment_cache(i, seg, output_wav, text, rate, cache_key)
109
+ if cached:
110
+ return cached
111
+
112
+ _run_tts_engine(engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion"))
113
+
114
+ dur = _get_audio_duration(output_wav)
115
+ seg_slot = seg["end"] - seg["start"]
116
+ seg_pause = seg.get("pause_after_ms", CONFIG.get("breath_ms", 250)) / 1000
117
+ available = max(0.5, seg_slot - seg_pause)
118
+ rate_offset = _parse_rate_offset(rate)
119
+ budget = narration_tempo_budget(rate_offset)
120
+ raw_budget = available * budget["max_raw_duration_factor"]
121
+ truncated = False
122
+ truncate_reason = "none"
123
+ if dur > raw_budget and len(text) > 5:
124
+ chars_per_sec = _text_char_count(text) / dur if dur > 0 else 3.0
125
+ target_chars = max(5, int(raw_budget * chars_per_sec) - 1)
126
+ shortened = _truncate_at_sentence(text, target_chars)
127
+ if shortened and len(shortened) >= 5 and shortened != text:
128
+ log(f" 段 {i+1}: 解说超出片段时长,按累计语速预算句界缩短 {len(text)}→{len(shortened)} 字以适配(建议在解说里改写得更短)")
129
+ text = shortened
130
+ truncated = True
131
+ truncate_reason = "sentence_boundary"
132
+ _run_tts_engine(engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion"))
133
+ dur = _get_audio_duration(output_wav)
134
+
135
+ norm_meta = _maybe_normalize_tts_wav(output_wav)
136
+ if norm_meta:
137
+ dur = _get_audio_duration(output_wav)
138
+ _write_tts_segment_cache(output_wav, cache_key, text, dur, rate_offset,
139
+ truncated, truncate_reason, norm_meta)
140
+ result = _build_tts_segment_result(
141
+ i, seg, text, output_wav, dur, rate_offset, truncated, truncate_reason, norm_meta)
142
+ return result
143
+
144
+
145
+ def _build_tts_segment_result(index, seg, text, output_wav, duration, rate_offset,
146
+ truncated=False, truncate_reason="none", norm_meta=None):
147
+ 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)
150
+ result = {
151
+ "segment_audio_schema_version": SEGMENT_AUDIO_SCHEMA_VERSION,
152
+ "index": index,
153
+ "start": seg["start"],
154
+ "end": seg["end"],
155
+ "narration": authored_text,
156
+ "spoken_text": text,
157
+ "truncated": resolved_truncated,
158
+ "truncate_reason": (truncate_reason if truncate_reason != "none" else "sentence_boundary") if resolved_truncated else "none",
159
+ "fit_status": "pending_assembly",
160
+ "audio_path": str(output_wav),
161
+ "audio_duration": duration,
162
+ "placed_audio_duration": None,
163
+ "actual_place_start": None,
164
+ "actual_place_end": None,
165
+ "global_narration_speed": budget["global_narration_speed"],
166
+ "segment_tempo_factor": 1.0,
167
+ "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
+ "tts_rate_offset": rate_offset,
172
+ "pause_after_ms": seg.get("pause_after_ms", CONFIG.get("breath_ms", 250)),
173
+ "overlaps_speech": seg.get("overlaps_speech", True),
174
+ }
175
+ for optional_key in ("source_start", "source_end", "source_clip_id", "emotion"):
176
+ if optional_key in seg:
177
+ result[optional_key] = seg[optional_key]
178
+ return result
179
+
180
+
181
+ def _tts_failure_record(index, seg, error):
182
+ """Build a user-visible failure record for partial TTS output."""
183
+ return {
184
+ "index": int(index),
185
+ "start": seg.get("start"),
186
+ "end": seg.get("end"),
187
+ "text": _clean_narration_text(str(seg.get("narration", ""))),
188
+ "error": str(error),
189
+ }
190
+
191
+
192
+ def _build_tts_meta(segments, engine, narration_name, failures=None):
193
+ """Stable tts_meta.json payload, including partial-failure visibility."""
194
+ failures = list(failures or [])
195
+ return {
196
+ "segments": segments,
197
+ "engine": engine,
198
+ "narration": narration_name,
199
+ "partial": bool(failures),
200
+ "failures": failures,
201
+ }
202
+
203
+
204
+ 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)
221
+ tts_dir = work_dir / "tts_segments"
222
+ tts_dir.mkdir(exist_ok=True)
223
+
224
+ if not narration:
225
+ raise RuntimeError("narration.json 没有可配音的解说段,已中止以避免生成无解说视频")
226
+
227
+ cache_engine = _configured_tts_engine_for_cache()
228
+ if cache_engine == "fish-audio" and voice_ref:
229
+ raise RuntimeError(
230
+ "Fish Audio 不接受本地 VOICE_REF/--voice-ref;请改用 FISH_TTS_REFERENCE_ID"
231
+ )
232
+ cached_segments = []
233
+ needs_fresh = False
234
+ prepared_count = 0
235
+ for i, seg in enumerate(narration):
236
+ prepared = _prepare_tts_segment(i, seg, narration, tts_dir, cache_engine)
237
+ if prepared is None:
238
+ continue
239
+ 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)
242
+ if not cached:
243
+ needs_fresh = True
244
+ break
245
+ cached_segments.append(cached)
246
+ if prepared_count == 0:
247
+ raise RuntimeError("narration.json 没有可配音的有效文本,已中止以避免生成无解说视频")
248
+ if not needs_fresh:
249
+ cached_segments.sort(key=lambda x: x["index"])
250
+ log(f"TTS 引擎: {cache_engine} (cache)")
251
+ synthesize_tts.last_failures = []
252
+ return cached_segments, cache_engine
253
+
254
+ engine = resolve_tts_engine()
255
+
256
+ if voice_ref:
257
+ _cache_prepared_voice_reference(voice_ref)
258
+ CONFIG["voice_ref_snapshot_locked"] = True
259
+
260
+ log(f"TTS 引擎: {engine}")
261
+
262
+ segments = []
263
+ failures = []
264
+ max_workers = max(1, min(len(narration), CONFIG.get("tts_workers", 4)))
265
+
266
+ try:
267
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
268
+ futures = {
269
+ executor.submit(_synthesize_segment, i, seg, narration, tts_dir, engine): i
270
+ for i, seg in enumerate(narration)
271
+ }
272
+ for future in as_completed(futures):
273
+ try:
274
+ result = future.result()
275
+ except Exception as e:
276
+ i = futures[future]
277
+ failures.append(_tts_failure_record(i, narration[i], e))
278
+ log(f" TTS 段 {i+1} 失败: {e}")
279
+ continue
280
+ if result:
281
+ segments.append(result)
282
+ log(f" 段 {result['index']+1}: {result['audio_duration']:.1f}s - {result['narration'][:25]}...")
283
+ finally:
284
+ CONFIG.pop("voice_ref_snapshot_locked", None)
285
+
286
+ segments.sort(key=lambda x: x["index"])
287
+ if failures and not CONFIG.get("allow_partial_tts", False):
288
+ sample = "; ".join(f"段 {f['index']+1}: {f['error']}" for f in failures[:3])
289
+ raise RuntimeError(
290
+ f"TTS 失败 {len(failures)}/{len(narration)} 段,已中止以避免生成缺解说的视频。"
291
+ f"示例: {sample}。如确需继续,可设置 ALLOW_PARTIAL_TTS=1 或 --allow-partial-tts。"
292
+ )
293
+ synthesize_tts.last_failures = failures
294
+ if failures:
295
+ missing = ", ".join(str(f["index"] + 1) for f in failures[:8])
296
+ more = "…" if len(failures) > 8 else ""
297
+ log(
298
+ f"警告: TTS 部分失败 {len(failures)}/{len(narration)} 段(段 {missing}{more}),"
299
+ "成片可预览但不建议直接发布;详见 tts_meta.json failures"
300
+ )
301
+ if not segments:
302
+ raise RuntimeError("TTS 没有生成任何有效解说音频,已中止以避免生成无解说视频")
303
+ return segments, engine
304
+
305
+
306
+ def _run_tts_engine(engine, text, output_wav, rate="+0%", pitch="+0Hz", emotion=None):
307
+ """Run one TTS engine with retry and remove partial files after failures."""
308
+ retries = max(1, CONFIG.get("tts_retries", 3))
309
+ last_error = None
310
+
311
+ for attempt in range(1, retries + 1):
312
+ try:
313
+ _cleanup_partial_tts_outputs(output_wav)
314
+ if engine == "mimo-tts":
315
+ _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
+ 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:
327
+ raise RuntimeError(f"{engine} 输出音频时长无效")
328
+ return
329
+ except Exception as exc:
330
+ last_error = exc
331
+ _cleanup_partial_tts_outputs(output_wav)
332
+ if attempt < retries:
333
+ wait = min(2 ** (attempt - 1), 8)
334
+ log(f" TTS 重试 {attempt+1}/{retries}: {exc},等待 {wait}s")
335
+ time.sleep(wait)
336
+
337
+ raise RuntimeError(f"{engine} 合成失败: {last_error}") from last_error
338
+
339
+
340
+ def _cleanup_partial_tts_outputs(output_wav):
341
+ """Remove stale partial media files before/after a failed TTS attempt."""
342
+ 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
363
+
364
+
365
+ def _tts_segment_cache_path(output_wav):
366
+ return Path(str(output_wav) + ".cache.json")
367
+
368
+
369
+ def _prepare_tts_segment(index, seg, narration, tts_dir, engine):
370
+ text = _clean_narration_text(seg["narration"])
371
+ if not text or not text.strip():
372
+ return None
373
+ output_wav = tts_dir / f"narr_{index:03d}.wav"
374
+ if CONFIG.get("tts_dynamic_params", True):
375
+ rate, pitch = _compute_tts_params(text, narration, index)
376
+ else:
377
+ rate, pitch = "+0%", "+0Hz"
378
+ cache_key = _tts_segment_cache_key(engine, index, seg, text, rate, pitch)
379
+ return text, output_wav, rate, pitch, cache_key
380
+
381
+
382
+ def _reuse_tts_segment_cache(index, seg, output_wav, source_text, rate, cache_key):
383
+ cached = _load_tts_segment_cache(output_wav, cache_key)
384
+ if not cached:
385
+ return None
386
+ # 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)
404
+
405
+
406
+ def _tts_segment_cache_key(engine, index, seg, source_text, rate, pitch):
407
+ """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
+ payload = {
414
+ "version": TTS_CACHE_VERSION,
415
+ "engine": engine,
416
+ "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,
421
+ "rate": rate,
422
+ "pitch": pitch,
423
+ "emotion": (str(seg.get("emotion")).strip() if seg.get("emotion") else ""),
424
+ "settings": tts_settings_fingerprint(engine),
425
+ }
426
+ return stable_hash(payload)
427
+
428
+
429
+ def _load_tts_segment_cache(output_wav, cache_key):
430
+ """Return cache metadata only when the sidecar proves the WAV matches narration."""
431
+ if not output_wav.exists():
432
+ return None
433
+ cache_path = _tts_segment_cache_path(output_wav)
434
+ if not cache_path.exists():
435
+ return None
436
+ try:
437
+ import json
438
+ data = json.loads(cache_path.read_text(encoding="utf-8"))
439
+ except (OSError, ValueError):
440
+ return None
441
+ if not isinstance(data, dict) or data.get("cache_key") != cache_key:
442
+ return None
443
+ try:
444
+ if data.get("audio_fingerprint") != file_fingerprint(output_wav):
445
+ return None
446
+ except OSError:
447
+ return None
448
+ return data
449
+
450
+
451
+ def _write_tts_segment_cache(output_wav, cache_key, spoken_text, duration, rate_offset,
452
+ truncated=False, truncate_reason="none", norm_meta=None):
453
+ """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}")
472
+
473
+
474
+ def _configured_tts_engine_for_cache():
475
+ """Resolve provider intent without requiring a live credential for cache probes."""
476
+ provider = str(CONFIG.get("tts_provider") or "auto").strip().lower()
477
+ if provider == "auto":
478
+ if CONFIG.get("mimo_tts_api_key") or not CONFIG.get("fish_api_key"):
479
+ return "mimo-tts"
480
+ return "fish-audio"
481
+ if provider not in SUPPORTED_TTS_ENGINES:
482
+ raise RuntimeError(
483
+ "TTS_PROVIDER/--tts-provider 必须是 auto、mimo-tts 或 fish-audio"
484
+ )
485
+ return provider
486
+
487
+
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
499
+ if engine == "fish-audio":
500
+ 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
518
+
519
+
520
+ def tts_settings_fingerprint(engine=None):
521
+ """Return non-secret TTS settings that materially affect generated audio."""
522
+ resolved = engine or resolve_tts_engine()
523
+ 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),
533
+ }
534
+ if resolved == "fish-audio":
535
+ settings.update(
536
+ {
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"),
540
+ }
541
+ )
542
+ else:
543
+ settings.update(
544
+ {
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"),
549
+ }
550
+ )
551
+ voice_ref = str(CONFIG.get("voice_ref") or "").strip()
552
+ if voice_ref and resolved == "mimo-tts":
553
+ ref_path = Path(voice_ref).expanduser()
554
+ settings.pop("mimo_tts_voice", None) # ignored by the voiceclone API
555
+ settings["voice_ref_fingerprint"] = _voice_reference_fingerprint(ref_path)
556
+ settings["voice_ref_preparation"] = (
557
+ f"pcm_s16le:24000hz:mono:30s:v{VOICE_REFERENCE_PREP_VERSION}"
558
+ )
559
+ settings["mimo_tts_model"] = "mimo-v2.5-tts-voiceclone"
560
+ return settings
561
+
562
+
563
+ 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}」的情绪和语气演绎这句解说,代入感强、有起伏,不要平铺直叙。"
568
+ else:
569
+ tone = "语气有感染力、有起伏,像在给观众讲故事,不要平淡机械。"
570
+ rate_offset = _parse_rate_offset(rate)
571
+ if rate_offset >= 0.06:
572
+ speed = "语速略快,但吐字保持清楚。"
573
+ elif rate_offset <= -0.03:
574
+ speed = "语速略慢,适当停顿,保留收束感。"
575
+ else:
576
+ speed = "语速中等,节奏稳定。"
577
+ pitch_hint = "疑问句或情绪抬升处可自然微升调。" if pitch and pitch != "+0Hz" else "音调自然。"
578
+ return f"{style} {tone} {speed} {pitch_hint}"
579
+
580
+
581
+ def _prepare_voice_reference(ref_path):
582
+ """Normalize arbitrary reference audio to MiMo voiceclone's 24 kHz mono WAV."""
583
+ ref = Path(ref_path).expanduser()
584
+ if not ref.is_file():
585
+ raise FileNotFoundError(f"参考音频不存在或不是文件: {ref}")
586
+ with tempfile.TemporaryDirectory(prefix="video-recap-voice-ref-") as temp_dir:
587
+ normalized = Path(temp_dir) / "voice_ref.wav"
588
+ result = run_cmd([
589
+ "ffmpeg", "-y", "-i", str(ref), "-vn", "-ar", "24000", "-ac", "1",
590
+ "-t", "30", "-acodec", "pcm_s16le", str(normalized),
591
+ ])
592
+ 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}")
595
+ return base64.b64encode(normalized.read_bytes()).decode("ascii")
596
+
597
+
598
+ def _voice_reference_signature(ref_path):
599
+ """Cheaply identify the prepared source so a reused process cannot serve stale audio."""
600
+ ref = Path(ref_path).expanduser().resolve()
601
+ stat = ref.stat()
602
+ return f"{ref}:{stat.st_size}:{stat.st_mtime_ns}"
603
+
604
+
605
+ def _voice_reference_fingerprint(ref_path):
606
+ ref = Path(ref_path).expanduser()
607
+ if not ref.is_file():
608
+ return f"missing:{ref.resolve()}"
609
+ resolved = str(ref.resolve())
610
+ if (
611
+ CONFIG.get("voice_ref_snapshot_locked")
612
+ and CONFIG.get("voice_ref_b64")
613
+ and CONFIG.get("voice_ref_snapshot_path") == resolved
614
+ and CONFIG.get("voice_ref_fingerprint")
615
+ ):
616
+ return CONFIG["voice_ref_fingerprint"]
617
+ signature = _voice_reference_signature(ref)
618
+ if (
619
+ CONFIG.get("voice_ref_source_signature") == signature
620
+ and CONFIG.get("voice_ref_fingerprint")
621
+ ):
622
+ return CONFIG["voice_ref_fingerprint"]
623
+ fingerprint = file_fingerprint(ref)
624
+ CONFIG["voice_ref_source_signature"] = signature
625
+ CONFIG["voice_ref_fingerprint"] = fingerprint
626
+ return fingerprint
627
+
628
+
629
+ def _cache_prepared_voice_reference(ref_path):
630
+ with _VOICE_REFERENCE_LOCK:
631
+ ref = Path(ref_path).expanduser().resolve()
632
+ resolved = str(ref)
633
+ if (
634
+ CONFIG.get("voice_ref_snapshot_locked")
635
+ and CONFIG.get("voice_ref_b64")
636
+ and CONFIG.get("voice_ref_snapshot_path") == resolved
637
+ ):
638
+ return CONFIG["voice_ref_b64"]
639
+ signature = _voice_reference_signature(ref)
640
+ snapshot_path = CONFIG.get("voice_ref_snapshot_path")
641
+ 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
+ if (
647
+ CONFIG.get("voice_ref_b64")
648
+ and (
649
+ (snapshot_path == resolved and snapshot_signature == signature)
650
+ or legacy_cache_valid
651
+ )
652
+ ):
653
+ return CONFIG["voice_ref_b64"]
654
+ if not ref.is_file():
655
+ raise FileNotFoundError(f"参考音频不存在或不是文件: {ref}")
656
+ # ffmpeg and the cache fingerprint must consume the same immutable bytes. Copy first,
657
+ # then derive both the normalized WAV and identity from that snapshot rather than reading
658
+ # a caller-mutable source twice.
659
+ with tempfile.TemporaryDirectory(prefix="video-recap-voice-ref-snapshot-") as temp_dir:
660
+ snapshot = Path(temp_dir) / f"source{ref.suffix or '.audio'}"
661
+ shutil.copyfile(ref, snapshot)
662
+ fingerprint = file_fingerprint(snapshot)
663
+ encoded = _prepare_voice_reference(snapshot)
664
+ CONFIG["voice_ref_b64"] = encoded
665
+ CONFIG["voice_ref_snapshot_path"] = str(ref)
666
+ CONFIG["voice_ref_snapshot_signature"] = signature
667
+ CONFIG["voice_ref_source_signature"] = signature
668
+ CONFIG["voice_ref_fingerprint"] = fingerprint
669
+ return encoded
670
+
671
+
672
+ def _tts_mimo(text, output_path, rate="+0%", pitch="+0Hz", emotion=None):
673
+ """使用 Xiaomi MiMo-V2.5-TTS 合成,按需用参考音频克隆音色。
674
+
675
+ MiMo-v2.5-tts 是 instruct-TTS:user 消息里的自然语言指令控制整句的情绪/语气/语速。
676
+ 每段 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)
681
+ payload = {
682
+ "model": "mimo-v2.5-tts-voiceclone" if voice_ref else CONFIG.get("mimo_tts_model", "mimo-v2.5-tts"),
683
+ "messages": [
684
+ {"role": "user", "content": _mimo_tts_style_instruction(rate, pitch, emotion)},
685
+ {"role": "assistant", "content": text},
686
+ ],
687
+ "audio": {
688
+ "format": "wav",
689
+ "voice": (
690
+ f"data:audio/wav;base64,{voice_ref_b64}"
691
+ if voice_ref else CONFIG.get("mimo_tts_voice", "mimo_default")
692
+ ),
693
+ },
694
+ }
695
+ resp = mimo_tts_api_call(payload)
696
+ try:
697
+ audio_data = resp["choices"][0]["message"]["audio"]["data"]
698
+ except (KeyError, IndexError, TypeError) as exc:
699
+ raise RuntimeError("MiMo-TTS 响应缺少 audio.data") from exc
700
+ try:
701
+ output_path.write_bytes(base64.b64decode(audio_data))
702
+ except (TypeError, ValueError) as exc:
703
+ raise RuntimeError("MiMo-TTS 返回的 audio.data 不是有效 base64") from exc
704
+
705
+
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
+ def main():
715
+ import argparse
716
+ from pathlib import Path
717
+ ap = argparse.ArgumentParser(
718
+ description="video-voiceover: synthesize narration audio segments from narration.json.")
719
+ ap.add_argument("--work-dir", required=True)
720
+ ap.add_argument("--narration", default=None,
721
+ help="narration json (default: <work-dir>/narration.json; pass narration_mapped.json explicitly for legacy cut runs)")
722
+ ap.add_argument("--mimo-voice", default=None, help="MiMo TTS voice name")
723
+ ap.add_argument(
724
+ "--tts-provider",
725
+ default=os.environ.get("TTS_PROVIDER", "auto"),
726
+ choices=["auto", "mimo-tts", "fish-audio"],
727
+ help="TTS provider (default: MiMo when configured, otherwise Fish Audio)",
728
+ )
729
+ ap.add_argument("--voice-ref", default=None,
730
+ help="reference audio (wav/mp3/etc.) for mimo-v2.5-tts-voiceclone")
731
+ ap.add_argument("--allow-partial-tts", action="store_true",
732
+ help="allow output when some narration segments fail TTS")
733
+ args = ap.parse_args()
734
+ work_dir = Path(args.work_dir)
735
+ CONFIG["tts_provider"] = args.tts_provider
736
+ effective_voice_ref = (
737
+ args.voice_ref if args.voice_ref is not None else os.environ.get("VOICE_REF", "").strip()
738
+ )
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
+ if args.mimo_voice:
750
+ CONFIG["mimo_tts_voice"] = args.mimo_voice
751
+ if args.mimo_voice and CONFIG.get("voice_ref"):
752
+ 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")):
754
+ ap.error("--mimo-voice/--voice-ref are only supported by the MiMo TTS provider")
755
+ # Voice-reference normalization is intentionally lazy: a fully cached rerun should not
756
+ # invoke ffmpeg. _tts_mimo uses a process-wide lock so a fresh parallel run still converts
757
+ # the reference exactly once.
758
+ if args.allow_partial_tts:
759
+ CONFIG["allow_partial_tts"] = True
760
+ if args.narration:
761
+ narration_path = Path(args.narration)
762
+ else:
763
+ # Canonical cut mode is cut-first/narrate-second: narration.json is already on the
764
+ # output timeline. A stale legacy narration_mapped.json in the same work_dir must not
765
+ # silently override it; legacy direct-cut callers can still pass --narration explicitly.
766
+ narration_path = work_dir / "narration.json"
767
+ 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", [])
770
+ meta = _build_tts_meta(tts_segments, engine_used, narration_path.name, failures)
771
+ (work_dir / "tts_meta.json").write_text(
772
+ json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
773
+ if failures:
774
+ log(f"配音完成但缺 {len(failures)} 段:成片可预览但不建议直接发布")
775
+ log(f"配音完成: {len(tts_segments)} 段, 引擎 {engine_used}")
776
+ print(json.dumps({"status": "voiced", "segments": len(tts_segments), "engine": engine_used,
777
+ "partial": bool(failures), "failures": len(failures),
778
+ "tts_meta": str(work_dir / "tts_meta.json")}, ensure_ascii=False))
779
+
780
+
781
+ if __name__ == "__main__":
782
+ main()