@oh-story/dsh 0.1.7 → 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 +30 -15
  2. package/lib/client.js +93 -16
  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
@@ -2,15 +2,15 @@
2
2
 
3
3
  import hashlib
4
4
  import json
5
- import math
6
5
  import os
7
6
  import shlex
8
7
  import sys
9
8
  from pathlib import Path
9
+
10
+ from lib import load_json
10
11
  from recap_runtime import (
11
12
  _coerce_videos,
12
13
  _entry,
13
- _load_json,
14
14
  _load_run_manifest,
15
15
  _multi_run_manifest_payload,
16
16
  _run_manifest_payload,
@@ -44,87 +44,43 @@ _ALLOWED_VISUAL_OVERLAY_TYPES = {"top_title", "inline_label_or_callout"}
44
44
  _VISUAL_OVERLAYS = "visual_overlays.json"
45
45
 
46
46
 
47
- def _finite_number(value):
48
- try:
49
- number = float(value)
50
- except (TypeError, ValueError):
51
- return None
52
- return number if math.isfinite(number) else None
53
-
54
-
55
- def _iter_narration_segments(narration_data):
56
- if isinstance(narration_data, list):
57
- yield from (item for item in narration_data if isinstance(item, dict))
58
- return
59
- if not isinstance(narration_data, dict):
60
- return
61
- for key in ("segments", "narration", "items"):
62
- value = narration_data.get(key)
63
- if isinstance(value, list):
64
- yield from (item for item in value if isinstance(item, dict))
65
- return
66
-
67
-
68
- def _canonical_visual_overlay(overlay, segment=None):
69
- """Return the first-release assemble overlay contract, or None.
47
+ def _canonical_visual_overlay(overlay, segment):
48
+ """Return the first-release assemble overlay contract, or None for unsupported types.
70
49
 
71
- Recap owns the handoff artifact but does not invent richer overlay semantics. Only the
72
- two overlay types implemented by assemble are allowed through; all unknown platform or
73
- future types stay out of the canonical first-release file.
50
+ Recap owns the handoff artifact but does not invent richer overlay semantics: only the
51
+ two overlay types implemented by assemble pass through, an overlay without its own
52
+ start/end inherits the narration segment's, and renderer placement hints are preserved.
74
53
  """
75
- if not isinstance(overlay, dict):
76
- return None
77
- typ = overlay.get("type")
78
- if typ not in _ALLOWED_VISUAL_OVERLAY_TYPES:
79
- return None
80
- text = overlay.get("text")
81
- if text is None or str(text) == "":
82
- return None
83
- seg = segment if isinstance(segment, dict) else {}
84
- start = _finite_number(overlay.get("start"))
85
- end = _finite_number(overlay.get("end"))
86
- if start is None:
87
- start = _finite_number(seg.get("start"))
88
- if end is None:
89
- end = _finite_number(seg.get("end"))
90
- if start is None or end is None or end <= start:
54
+ if overlay["type"] not in _ALLOWED_VISUAL_OVERLAY_TYPES:
91
55
  return None
92
-
93
- item = {"type": typ, "text": str(text), "start": start, "end": end}
94
- # Preserve renderer-supported placement hints supplied by upstream facts; do not add new
95
- # semantic overlay kinds or infer platform-specific cards/chapters.
56
+ item = {
57
+ "type": overlay["type"],
58
+ "text": overlay["text"],
59
+ "start": overlay.get("start", segment["start"]),
60
+ "end": overlay.get("end", segment["end"]),
61
+ }
96
62
  for key in ("anchor", "x", "y", "max_width", "style"):
97
63
  if key in overlay:
98
64
  item[key] = overlay[key]
99
65
  return item
100
66
 
101
67
 
102
- def _extract_visual_overlays_from_narration(narration_path):
103
- data = _load_json(narration_path)
104
- overlays = []
105
- for segment in _iter_narration_segments(data):
106
- raw = segment.get("visual_overlays")
107
- if not isinstance(raw, list):
108
- continue
109
- for overlay in raw:
110
- item = _canonical_visual_overlay(overlay, segment)
111
- if item is not None:
112
- overlays.append(item)
113
- return overlays
114
-
115
-
116
68
  def _write_canonical_visual_overlays(work_dir, narration_path):
117
69
  """Write assemble's canonical work_dir/visual_overlays.json recap handoff.
118
70
 
119
71
  Direct assemble still supports user-authored/manual visual_overlays.json files. Once
120
- recap owns the handoff for a narration, however, the canonical artifact must be a
121
- deterministic reflection of the current narration so reused work_dirs cannot render
122
- stale overlays from a previous run. Unsupported/future overlay types are filtered out
123
- and represented as an explicit empty overlay list.
72
+ recap owns the handoff for a narration, the canonical artifact is a deterministic
73
+ reflection of the current narration so reused work_dirs cannot render stale overlays
74
+ from a previous run; unsupported types are filtered out and represented as an explicit
75
+ empty overlay list.
124
76
  """
125
- work_dir = Path(work_dir)
126
- path = work_dir / _VISUAL_OVERLAYS
127
- overlays = _extract_visual_overlays_from_narration(narration_path)
77
+ path = Path(work_dir) / _VISUAL_OVERLAYS
78
+ overlays = [
79
+ item
80
+ for segment in load_json(narration_path)
81
+ for overlay in segment.get("visual_overlays", [])
82
+ if (item := _canonical_visual_overlay(overlay, segment)) is not None
83
+ ]
128
84
  payload = {"schema_version": 1, "overlays": overlays}
129
85
  path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
130
86
  print(f"[video-recap] 🧩 visual overlays: {len(overlays)} → {path}", flush=True)
@@ -135,18 +91,14 @@ def _print_grounding_qc_pointer(work_dir):
135
91
  qc_path = Path(work_dir) / "grounding_qc.json"
136
92
  if not qc_path.exists():
137
93
  return
138
- data = _load_json(qc_path)
139
- if isinstance(data, dict):
140
- verdict = data.get("verdict", "unknown")
141
- ranges = (data.get("review_coverage") or {}).get("time_ranges") or []
142
- warnings = data.get("warnings") or []
143
- suffix = f" · warnings {len(warnings)}" if warnings else ""
144
- print(
145
- f"[video-recap] 🧭 Grounding QC: {verdict} · ranges {len(ranges)}{suffix} → {qc_path}",
146
- flush=True,
147
- )
148
- else:
149
- print(f"[video-recap] 🧭 Grounding QC → {qc_path}", flush=True)
94
+ data = load_json(qc_path)
95
+ ranges = data["review_coverage"]["time_ranges"]
96
+ warnings = data["warnings"]
97
+ suffix = f" · warnings {len(warnings)}" if warnings else ""
98
+ print(
99
+ f"[video-recap] 🧭 Grounding QC: {data['verdict']} · ranges {len(ranges)}{suffix} → {qc_path}",
100
+ flush=True,
101
+ )
150
102
 
151
103
 
152
104
  def _print_narration_review_pointer(work_dir, *, review_ran=True):
@@ -161,17 +113,20 @@ def _print_narration_review_pointer(work_dir, *, review_ran=True):
161
113
  review_md = Path(work_dir) / "narration_review.md"
162
114
  if not review_md.exists():
163
115
  return
164
- data = _load_json(Path(work_dir) / "narration_review.json")
165
- if isinstance(data, dict):
166
- findings = [f for f in (data.get("findings") or []) if isinstance(f, dict)]
167
- n_err = sum(1 for f in findings if f.get("severity") == "error")
168
- tag = str(data.get("verdict") or "见文件")
169
- print(
170
- f"[video-recap] 📋 解说评审(建议性,不拦截): {tag} · "
171
- f"{len(findings)} 条意见(error {n_err})→ {review_md}"
172
- )
173
- else:
174
- print(f"[video-recap] 📋 解说评审意见 → {review_md}")
116
+ review_json = Path(work_dir) / "narration_review.json"
117
+ if not review_json.exists():
118
+ print(f"[video-recap] 📋 解说评审(建议性,不拦截)→ {review_md}")
119
+ return
120
+ try:
121
+ data = load_json(review_json)
122
+ except (OSError, ValueError):
123
+ print(f"[video-recap] 📋 解说评审(建议性,不拦截)→ {review_md}")
124
+ return
125
+ n_err = sum(1 for f in data["findings"] if f["severity"] == "error")
126
+ print(
127
+ f"[video-recap] 📋 解说评审(建议性,不拦截): {data['verdict']} · "
128
+ f"{len(data['findings'])} 条意见(error {n_err})→ {review_md}"
129
+ )
175
130
 
176
131
 
177
132
  def _settings_for_compare(settings):
@@ -182,7 +137,7 @@ def _settings_for_compare(settings):
182
137
  old default (or missing the key entirely, pre-dating it) must still resume — otherwise
183
138
  flipping `--consolidate`'s default ON would hard-fail every in-flight work_dir.
184
139
  """
185
- s = dict(settings or {})
140
+ s = dict(settings)
186
141
  s.pop("consolidate", None)
187
142
  s.pop("consolidate_asr", None)
188
143
  return s
@@ -191,19 +146,16 @@ def _settings_for_compare(settings):
191
146
  def _manifest_mismatches(work_dir, video, args):
192
147
  expected = _run_manifest_payload(video, args)
193
148
  actual = _load_run_manifest(work_dir)
194
- if not actual:
195
- return [
196
- "缺少或无法读取 recap_run_manifest.json;不能证明 work_dir 属于当前视频/参数"
197
- ]
198
- mismatches = []
199
- for key in ("source_video", "source_video_fingerprint"):
200
- if actual.get(key) != expected.get(key):
201
- mismatches.append(
202
- f"{key}: expected {expected.get(key)!r}, got {actual.get(key)!r}"
203
- )
204
- if _settings_for_compare(actual.get("settings")) != _settings_for_compare(
205
- expected.get("settings")
206
- ):
149
+ if actual is None:
150
+ return ["缺少 recap_run_manifest.json;不能证明 work_dir 属于当前视频/参数"]
151
+ # A multi-source manifest carries `sources` instead of these keys, so .get() reports it
152
+ # as a mismatch rather than a crash.
153
+ mismatches = [
154
+ f"{key}: expected {expected[key]!r}, got {actual.get(key)!r}"
155
+ for key in ("source_video", "source_video_fingerprint")
156
+ if actual.get(key) != expected[key]
157
+ ]
158
+ if _settings_for_compare(actual["settings"]) != _settings_for_compare(expected["settings"]):
207
159
  mismatches.append("settings: 当前 CLI/env 参数与 Phase A manifest 不匹配")
208
160
  return mismatches
209
161
 
@@ -211,51 +163,29 @@ def _manifest_mismatches(work_dir, video, args):
211
163
  def _multi_manifest_mismatches(work_dir, videos, args, source_records):
212
164
  expected = _multi_run_manifest_payload(videos, args, source_records)
213
165
  actual = _load_run_manifest(work_dir)
214
- if not actual:
215
- return [
216
- "缺少或无法读取 recap_run_manifest.json;不能证明 work_dir 属于当前多视频/参数"
217
- ]
218
- mismatches = []
166
+ if actual is None:
167
+ return ["缺少 recap_run_manifest.json;不能证明 work_dir 属于当前多视频/参数"]
219
168
  if actual.get("mode") != "multi_source":
220
- mismatches.append(f"mode: expected 'multi_source', got {actual.get('mode')!r}")
221
- expected_sources = [
222
- {
223
- "source_id": s.get("source_id"),
224
- "source_path": s.get("source_path"),
225
- "source_video_fingerprint": s.get("source_video_fingerprint"),
226
- }
227
- for s in expected.get("sources", [])
228
- ]
229
- actual_sources = [
230
- {
231
- "source_id": s.get("source_id"),
232
- "source_path": s.get("source_path"),
233
- "source_video_fingerprint": s.get("source_video_fingerprint"),
234
- }
235
- for s in actual.get("sources", [])
236
- if isinstance(s, dict)
237
- ]
238
- if actual_sources != expected_sources:
169
+ return [f"mode: expected 'multi_source', got {actual.get('mode')!r}"]
170
+ mismatches = []
171
+ identity = ("source_id", "source_path", "source_video_fingerprint")
172
+ if [{k: s[k] for k in identity} for s in actual["sources"]] != [
173
+ {k: s[k] for k in identity} for s in expected["sources"]
174
+ ]:
239
175
  mismatches.append(
240
176
  "sources: 当前输入视频列表/顺序/source_id/fingerprint 与 Phase A manifest 不匹配"
241
177
  )
242
- if _settings_for_compare(actual.get("settings")) != _settings_for_compare(
243
- expected.get("settings")
244
- ):
178
+ if _settings_for_compare(actual["settings"]) != _settings_for_compare(expected["settings"]):
245
179
  mismatches.append("settings: 当前 CLI/env 参数与 Phase A manifest 不匹配")
246
180
  return mismatches
247
181
 
248
182
 
249
183
  def _read_assembly_output(work_dir):
250
- manifest = _load_json(Path(work_dir) / ASSEMBLY_MANIFEST)
251
- if isinstance(manifest, dict) and manifest.get("final_output"):
252
- return Path(manifest["final_output"])
253
- return None
184
+ return Path(load_json(Path(work_dir) / ASSEMBLY_MANIFEST)["final_output"])
254
185
 
255
186
 
256
187
  def _file_md5(path):
257
- path = Path(path)
258
- return hashlib.md5(path.read_bytes()).hexdigest() if path.exists() else None
188
+ return hashlib.md5(Path(path).read_bytes()).hexdigest()
259
189
 
260
190
 
261
191
  def _read_phase_ledger(work_dir):
@@ -264,9 +194,10 @@ def _read_phase_ledger(work_dir):
264
194
  Lets resume be driven by recorded phase state rather than bare file existence — the
265
195
  prerequisite for the cut-first/narrate-second two-pause flow, and the guard that keeps a
266
196
  narration written for one clip_plan from silently driving a different cut into TTS.
197
+ None before the first cut pass has recorded anything.
267
198
  """
268
- ledger = _load_json(Path(work_dir) / PHASE_LEDGER)
269
- return ledger if isinstance(ledger, dict) else None
199
+ path = Path(work_dir) / PHASE_LEDGER
200
+ return load_json(path) if path.exists() else None
270
201
 
271
202
 
272
203
  def _write_phase_ledger(work_dir, **fields):
@@ -282,18 +213,14 @@ def _cut_narration_is_stale(ledger, current_clip_plan_fp):
282
213
  """Two-pass cut: the narration is authored against the rendered cut shown at the A2 pause,
283
214
  i.e. against the clip_plan recorded in the ledger. If clip_plan changed since (a re-cut)
284
215
  while that narration is still present, it describes the OLD cut — stale."""
285
- if not ledger:
286
- return False
287
- recorded_cp = ledger.get("clip_plan_fingerprint")
288
- return bool(recorded_cp is not None and recorded_cp != current_clip_plan_fp)
216
+ return ledger is not None and ledger["clip_plan_fingerprint"] != current_clip_plan_fp
289
217
 
290
218
 
291
219
  def _continuation_command(video, work_dir, args):
292
- videos = _coerce_videos(video)
293
220
  parts = [
294
221
  sys.executable,
295
222
  str(_entry("video-recap", "recap.py")),
296
- *[str(v) for v in videos],
223
+ *[str(v) for v in _coerce_videos(video)],
297
224
  "--work-dir",
298
225
  str(work_dir),
299
226
  ]
@@ -307,58 +234,53 @@ def _continuation_command(video, work_dir, args):
307
234
  parts += ["--edit-mode", args.edit_mode]
308
235
  if args.target_duration:
309
236
  parts += ["--target-duration", args.target_duration]
310
- if getattr(args, "allow_duration_drift", False):
237
+ if args.allow_duration_drift:
311
238
  parts.append("--allow-duration-drift")
312
- if getattr(args, "allow_sparse_cut", False):
239
+ if args.allow_sparse_cut:
313
240
  parts.append("--allow-sparse-cut")
314
241
  if args.skip_asr:
315
242
  parts.append("--skip-asr")
316
243
  if args.mimo_video_overview:
317
244
  parts.append("--mimo-video-overview")
318
- mimo_mode = getattr(args, "mimo_qc", "off")
319
- if mimo_mode != "off":
320
- parts += ["--mimo-qc", mimo_mode]
321
- if getattr(args, "mimo_qc_refresh", False):
245
+ if args.mimo_qc != "off":
246
+ parts += ["--mimo-qc", args.mimo_qc]
247
+ if args.mimo_qc_refresh:
322
248
  parts.append("--mimo-qc-refresh")
323
- if not args.consolidate: # default is ON now; only the opt-out needs to round-trip
249
+ if not args.consolidate: # default is ON; only the opt-out needs to round-trip
324
250
  parts.append("--no-consolidate")
325
251
  if args.consolidate_asr:
326
252
  parts.append("--consolidate-asr")
327
- if getattr(args, "mimo_tts_voice", None):
253
+ if args.mimo_tts_voice:
328
254
  parts += ["--mimo-tts-voice", args.mimo_tts_voice]
329
- if getattr(args, "tts_provider", "auto") != "auto":
255
+ if args.tts_provider != "auto":
330
256
  parts += ["--tts-provider", args.tts_provider]
331
- if getattr(args, "voice_ref", None):
257
+ if args.voice_ref:
332
258
  parts += ["--voice-ref", args.voice_ref]
333
- if getattr(args, "allow_partial_tts", False):
259
+ if args.allow_partial_tts:
334
260
  parts.append("--allow-partial-tts")
335
- if getattr(args, "burn_subtitles", None) is not None:
336
- parts.append(
337
- "--burn-subtitles" if args.burn_subtitles else "--no-burn-subtitles"
338
- )
339
- if getattr(args, "subtitle_y_top", None) is not None:
261
+ if args.burn_subtitles is not None:
262
+ parts.append("--burn-subtitles" if args.burn_subtitles else "--no-burn-subtitles")
263
+ if args.subtitle_y_top is not None:
340
264
  parts += ["--subtitle-y-top", str(args.subtitle_y_top)]
341
- if getattr(args, "subtitle_y_bot", None) is not None:
265
+ if args.subtitle_y_bot is not None:
342
266
  parts += ["--subtitle-y-bot", str(args.subtitle_y_bot)]
343
- if getattr(args, "output_dir", None):
267
+ if args.output_dir:
344
268
  parts += ["--output-dir", args.output_dir]
345
- if getattr(args, "export_jianying", False):
269
+ if args.export_jianying:
346
270
  parts.append("--export-jianying")
347
- if getattr(args, "jianying_bundle_media", False):
271
+ if args.jianying_bundle_media:
348
272
  parts.append("--jianying-bundle-media")
349
- if getattr(args, "jianying_no_bundle_media", False):
273
+ if args.jianying_no_bundle_media:
350
274
  parts.append("--jianying-no-bundle-media")
351
- if getattr(args, "review_narration", None) is not None:
352
- parts.append(
353
- "--review-narration" if args.review_narration else "--no-review-narration"
354
- )
355
- if getattr(args, "require_narration_review", False):
275
+ if args.review_narration is not None:
276
+ parts.append("--review-narration" if args.review_narration else "--no-review-narration")
277
+ if args.require_narration_review:
356
278
  parts.append("--require-narration-review")
357
- if getattr(args, "material_library_dir", None):
279
+ if args.material_library_dir:
358
280
  parts += ["--material-library-dir", args.material_library_dir]
359
- if getattr(args, "use_materials", False):
281
+ if args.use_materials:
360
282
  parts.append("--use-materials")
361
- if getattr(args, "save_materials", False):
283
+ if args.save_materials:
362
284
  parts.append("--save-materials")
363
285
  return " ".join(shlex.quote(part) for part in parts)
364
286
 
@@ -374,13 +296,13 @@ def _understand_args_for_source(source_record, source_work_dir, args):
374
296
  str(source_work_dir),
375
297
  "--style",
376
298
  args.style,
299
+ "--edit-mode",
300
+ args.edit_mode,
377
301
  ]
378
302
  if args.context:
379
303
  uargs += ["--context", args.context]
380
304
  if args.scene_threshold is not None:
381
305
  uargs += ["--scene-threshold", str(args.scene_threshold)]
382
- if args.edit_mode:
383
- uargs += ["--edit-mode", args.edit_mode]
384
306
  if args.target_duration:
385
307
  uargs += ["--target-duration", args.target_duration]
386
308
  if args.skip_asr:
@@ -394,16 +316,10 @@ def _understand_args_for_source(source_record, source_work_dir, args):
394
316
 
395
317
 
396
318
  def _brief_excerpt(path, limit=1200):
397
- try:
398
- text = Path(path).read_text(encoding="utf-8")
399
- except OSError:
400
- return ""
401
- if limit <= 0:
402
- return ""
319
+ text = Path(path).read_text(encoding="utf-8")
403
320
  if len(text) <= limit:
404
321
  return text
405
- if limit < 8:
406
- return text[:limit]
322
+ marker = "\n…\n"
407
323
 
408
324
  def section(heading):
409
325
  lines = text.splitlines()
@@ -412,11 +328,7 @@ def _brief_excerpt(path, limit=1200):
412
328
  except ValueError:
413
329
  return ""
414
330
  end = next(
415
- (
416
- index
417
- for index in range(start + 1, len(lines))
418
- if lines[index].startswith("## ")
419
- ),
331
+ (i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")),
420
332
  len(lines),
421
333
  )
422
334
  return "\n".join(lines[start:end]).strip()
@@ -424,37 +336,29 @@ def _brief_excerpt(path, limit=1200):
424
336
  def clipped(value, budget):
425
337
  if len(value) <= budget:
426
338
  return value
427
- marker = "\n…\n"
428
- if budget <= len(marker) + 1:
429
- return value[:budget]
430
- usable = max(0, budget - len(marker))
431
- head = max(1, round(usable * 0.7))
432
- tail = max(0, usable - head)
433
- suffix = value[-tail:].lstrip() if tail else ""
434
- return value[:head].rstrip() + marker + suffix
339
+ usable = budget - len(marker)
340
+ head = round(usable * 0.7)
341
+ return value[:head].rstrip() + marker + value[len(value) - (usable - head):].lstrip()
435
342
 
436
343
  # The generic writing contract occupies the front of every generated brief. A raw
437
344
  # prefix therefore drops the source facts that multi-source planning actually needs.
438
345
  # Give evidence-bearing sections equal space and retain both their opening and tail.
439
346
  preferred = [
440
- section("## Story context (from background_research.json)"),
441
- section("## Understanding index (from consolidate.py)"),
442
- section("## ASR writing chunks (semantic windows)"),
443
- section("## Scene timing guide"),
347
+ value
348
+ for value in (
349
+ section("## Story context (from background_research.json)"),
350
+ section("## Understanding index (from consolidate.py)"),
351
+ section("## ASR writing chunks (semantic windows)"),
352
+ section("## Scene timing guide"),
353
+ )
354
+ if value
444
355
  ]
445
- preferred = [value for value in preferred if value]
446
356
  if preferred:
447
- separators = 2 * (len(preferred) - 1)
448
- per_section = max(1, (limit - separators) // len(preferred))
449
- excerpt = "\n\n".join(clipped(value, per_section) for value in preferred)
450
- return excerpt[:limit]
451
-
452
- marker = "\n…\n"
453
- usable = max(0, limit - len(marker))
454
- head = max(1, usable // 4)
455
- tail = max(0, usable - head)
456
- suffix = text[-tail:].lstrip() if tail else ""
457
- return text[:head].rstrip() + marker + suffix
357
+ per_section = (limit - 2 * (len(preferred) - 1)) // len(preferred)
358
+ return "\n\n".join(clipped(value, per_section) for value in preferred)[:limit]
359
+ usable = limit - len(marker)
360
+ head = usable // 4
361
+ return text[:head].rstrip() + marker + text[len(text) - (usable - head):].lstrip()
458
362
 
459
363
 
460
364
  def _write_multi_source_clip_brief(work_dir, source_records, args):
@@ -495,7 +399,7 @@ def _write_multi_source_clip_brief(work_dir, source_records, args):
495
399
  f"- path: `{s['source_path']}`",
496
400
  f"- work_dir: `{swd}`",
497
401
  f"- fingerprint: `{s['source_video_fingerprint']}`",
498
- f"- material_id: `{s.get('material_id')}`",
402
+ f"- material_id: `{s['material_id']}`",
499
403
  ]
500
404
  excerpt = _brief_excerpt(swd / "agent_narration_brief.md")
501
405
  if excerpt:
@@ -505,89 +409,71 @@ def _write_multi_source_clip_brief(work_dir, source_records, args):
505
409
  )
506
410
 
507
411
 
412
+ def _source_speech_rows(source_dir):
413
+ """The cleaned transcript wins when it carries text; same precedence as
414
+ audio_mix._handoff_speech_evidence and sentence_boundaries._load_source_speech_spans."""
415
+ clean = source_dir / "asr_clean.json"
416
+ if clean.exists():
417
+ rows = load_json(clean)["segments"]
418
+ if any(row["text"].strip() for row in rows):
419
+ return rows
420
+ raw = source_dir / "asr_result.json"
421
+ return load_json(raw) if raw.exists() else []
422
+
423
+
508
424
  def _write_multi_source_output_speech_evidence(work_dir, source_records, plan):
509
425
  """Map each source's speech and quiet evidence onto the combined output clock."""
510
- clips = plan.get("clips", []) if isinstance(plan, dict) else []
511
- source_by_id = {str(row["source_id"]): row for row in source_records}
426
+ source_by_id = {row["source_id"]: row for row in source_records}
512
427
  cache = {}
513
428
 
514
429
  def load_source(source_id):
515
- if source_id in cache:
516
- return cache[source_id]
517
- record = source_by_id.get(source_id)
518
- source_dir = _source_work_dir(work_dir, record) if record else None
519
- anchors = _load_json(source_dir / "speech_boundary_anchors.json") if source_dir else None
520
- speech = None
521
- # Same precedence as audio_mix._handoff_speech_evidence and
522
- # sentence_boundaries._load_source_speech_spans: the cleaned transcript wins.
523
- # Reading these in the opposite order made the multi-source output timeline
524
- # derive its speech spans from a different artifact than every other consumer.
525
- for name in ("asr_clean.json", "asr_result.json"):
526
- speech = _load_json(source_dir / name) if source_dir else None
527
- rows = speech.get("segments", []) if isinstance(speech, dict) else speech
528
- if isinstance(rows, list) and any(
529
- isinstance(row, dict) and str(row.get("text") or "").strip()
530
- for row in rows
531
- ):
532
- break
533
- if isinstance(speech, dict):
534
- speech = speech.get("segments", [])
535
- quiet = _load_json(source_dir / "silence_periods.json") if source_dir else None
536
- cache[source_id] = (
537
- anchors.get("sentence_anchors", []) if isinstance(anchors, dict) else [],
538
- speech if isinstance(speech, list) else [],
539
- quiet if isinstance(quiet, list) else [],
540
- )
430
+ if source_id not in cache:
431
+ source_dir = _source_work_dir(work_dir, source_by_id[source_id])
432
+ anchors_path = source_dir / "speech_boundary_anchors.json"
433
+ quiet_path = source_dir / "silence_periods.json"
434
+ cache[source_id] = (
435
+ load_json(anchors_path)["sentence_anchors"] if anchors_path.exists() else [],
436
+ _source_speech_rows(source_dir),
437
+ load_json(quiet_path) if quiet_path.exists() else [],
438
+ )
541
439
  return cache[source_id]
542
440
 
543
441
  mapped_anchors, mapped_speech, mapped_quiet = [], [], []
544
- for clip in clips:
545
- source_id = str(clip.get("source_id") or "")
546
- try:
547
- source_start = float(clip["source_start"])
548
- source_end = float(clip["source_end"])
549
- output_start = float(clip["output_start"])
550
- except (KeyError, TypeError, ValueError):
551
- continue
442
+ for clip in plan["clips"]:
443
+ source_id = clip["source_id"]
444
+ source_start = float(clip["source_start"])
445
+ source_end = float(clip["source_end"])
446
+ output_start = float(clip["output_start"])
552
447
  anchors, speech_rows, quiet_rows = load_source(source_id)
553
448
  for anchor in anchors:
554
- try:
555
- when = float(anchor.get("time"))
556
- except (AttributeError, TypeError, ValueError):
449
+ when = float(anchor["time"])
450
+ if not (source_start - 0.05 <= when <= source_end + 0.05):
557
451
  continue
558
- if source_start - 0.05 <= when <= source_end + 0.05:
559
- item = dict(anchor)
560
- try:
561
- pause = float(item.get("pause_start", when))
562
- except (TypeError, ValueError):
563
- pause = when
564
- if not math.isfinite(pause):
565
- pause = when
566
- pause = max(source_start, min(pause, when))
567
- item.update(
568
- source_id=source_id,
569
- source_time=round(when, 3),
570
- time=round(output_start + when - source_start, 3),
571
- source_pause_start=round(pause, 3),
572
- pause_start=round(output_start + pause - source_start, 3),
573
- )
574
- mapped_anchors.append(item)
452
+ try:
453
+ pause = float(anchor["pause_start"])
454
+ except (TypeError, ValueError):
455
+ pause = when
456
+ pause = max(source_start, min(pause, when))
457
+ item = dict(anchor)
458
+ item.update(
459
+ source_id=source_id,
460
+ source_time=round(when, 3),
461
+ time=round(output_start + when - source_start, 3),
462
+ source_pause_start=round(pause, 3),
463
+ pause_start=round(output_start + pause - source_start, 3),
464
+ )
465
+ mapped_anchors.append(item)
575
466
  for rows, destination, require_text in (
576
467
  (speech_rows, mapped_speech, True),
577
468
  (quiet_rows, mapped_quiet, False),
578
469
  ):
579
470
  for row in rows:
580
- if not isinstance(row, dict):
581
- continue
582
- if require_text and not str(row.get("text") or "").strip():
471
+ if require_text and not row["text"].strip():
583
472
  continue
584
- if not require_text and bool(row.get("has_speech", False)):
585
- continue
586
- try:
587
- start = max(source_start, float(row["start"]))
588
- end = min(source_end, float(row["end"]))
589
- except (KeyError, TypeError, ValueError):
473
+ if not require_text and row["has_speech"]:
590
474
  continue
475
+ start = max(source_start, float(row["start"]))
476
+ end = min(source_end, float(row["end"]))
591
477
  if end <= start:
592
478
  continue
593
479
  item = dict(row)
@@ -610,7 +496,7 @@ def _write_multi_source_output_speech_evidence(work_dir, source_records, plan):
610
496
  plan, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str
611
497
  ).encode("utf-8")
612
498
  ).hexdigest(),
613
- "sentence_anchors": sorted(mapped_anchors, key=lambda row: float(row["time"])),
499
+ "sentence_anchors": sorted(mapped_anchors, key=lambda row: row["time"]),
614
500
  "speech_spans": sorted(mapped_speech, key=lambda row: (row["start"], row["end"])),
615
501
  "quiet_windows": sorted(mapped_quiet, key=lambda row: (row["start"], row["end"])),
616
502
  }
@@ -621,8 +507,7 @@ def _write_multi_source_output_speech_evidence(work_dir, source_records, plan):
621
507
 
622
508
 
623
509
  def _write_multi_source_output_brief(work_dir, source_records, validated_plan_path):
624
- plan = _load_json(validated_plan_path)
625
- clips = plan.get("clips", []) if isinstance(plan, dict) else []
510
+ plan = load_json(validated_plan_path)
626
511
  source_by_id = {s["source_id"]: s for s in source_records}
627
512
  speech_evidence = _write_multi_source_output_speech_evidence(
628
513
  work_dir, source_records, plan
@@ -652,22 +537,21 @@ def _write_multi_source_output_brief(work_dir, source_records, validated_plan_pa
652
537
  "",
653
538
  "## Kept clips (output → source)",
654
539
  ]
655
- for c in clips:
656
- sid = c.get("source_id")
657
- src = source_by_id.get(sid, {})
540
+ for c in plan["clips"]:
541
+ src = source_by_id[c["source_id"]]
542
+ reason = f" — {c['reason']}" if c["reason"] else ""
658
543
  lines.append(
659
- f"- output {_fmt_range(c.get('output_start'), c.get('output_end'))} → "
660
- f"{sid} `{src.get('source_path', c.get('source_path', ''))}` "
661
- f"source {_fmt_range(c.get('source_start'), c.get('source_end'))} "
662
- f"{('— ' + str(c.get('reason'))) if c.get('reason') else ''}"
544
+ f"- output {_fmt_range(c['output_start'], c['output_end'])} → "
545
+ f"{c['source_id']} `{src['source_path']}` "
546
+ f"source {_fmt_range(c['source_start'], c['source_end'])}{reason}"
663
547
  )
664
548
  anchors = speech_evidence["sentence_anchors"]
665
549
  if anchors:
666
550
  lines += ["", "## 原声句末安全切入点"]
667
551
  lines.extend(
668
- f"- {float(row['time']):.3f}s ({row.get('source_id')})"
552
+ f"- {row['time']:.3f}s ({row['source_id']})"
669
553
  for row in anchors
670
- if row.get("confidence") in {"high", "medium"}
554
+ if row["confidence"] in {"high", "medium"}
671
555
  )
672
556
  lines += ["", "## Source work dirs"]
673
557
  for s in source_records:
@@ -677,97 +561,34 @@ def _write_multi_source_output_brief(work_dir, source_records, validated_plan_pa
677
561
  )
678
562
 
679
563
 
680
- def _qc_status_is_blocking(value):
681
- if isinstance(value, str):
682
- return value.strip().lower() in {
683
- "blocking",
684
- "blocked",
685
- "fail",
686
- "failed",
687
- "error",
688
- }
689
- return bool(value) if isinstance(value, bool) else False
690
-
691
-
692
- def _cut_qc_blocking_reasons(qc):
693
- if not isinstance(qc, dict):
694
- return []
695
- reasons = []
696
- for key in (
697
- "status",
698
- "target_duration_status",
699
- "duration_status",
700
- "boundary_status",
701
- ):
702
- if _qc_status_is_blocking(qc.get(key)):
703
- reasons.append(f"{key}={qc.get(key)}")
704
- for key in ("blocking", "blocked", "failed", "fail", "errors"):
705
- value = qc.get(key)
706
- if value:
707
- reasons.append(f"{key}={value}")
708
- checks = qc.get("checks") if isinstance(qc.get("checks"), list) else []
709
- for item in checks:
710
- if isinstance(item, dict) and _qc_status_is_blocking(item.get("status")):
711
- reasons.append(f"{item.get('name', 'check')}={item.get('status')}")
712
- return reasons
713
-
714
-
715
- def _cut_qc_summary_lines(qc):
716
- if not isinstance(qc, dict) or not qc:
717
- return []
718
- parts = []
719
- for key in (
720
- "target_duration_status",
721
- "total_duration",
722
- "clip_count",
723
- "join_fade_ms",
724
- ):
725
- if key in qc:
726
- parts.append(f"{key}={qc.get(key)}")
727
- geometry = qc.get("output_geometry")
728
- if isinstance(geometry, dict):
729
- dims = "x".join(
730
- str(geometry.get(k))
731
- for k in ("width", "height")
732
- if geometry.get(k) is not None
733
- )
734
- fps = geometry.get("fps")
735
- reason = geometry.get("reason") or qc.get("output_geometry_reason")
736
- fps_suffix = f"@{fps}fps" if fps else ""
737
- reason_suffix = f" reason={reason}" if reason else ""
738
- parts.append(f"output_geometry={dims or geometry}{fps_suffix}{reason_suffix}")
739
- warnings = qc.get("warnings")
740
- if warnings:
741
- parts.append(
742
- f"warnings={len(warnings) if isinstance(warnings, list) else warnings}"
743
- )
744
- return ["[video-recap] cut QC: " + "; ".join(parts)] if parts else []
564
+ def _cut_qc_summary_line(qc):
565
+ geometry = qc["output_geometry"]
566
+ parts = [
567
+ f"target_duration_status={qc['target_duration_status']}",
568
+ f"total_duration={qc['total_duration']}",
569
+ f"clip_count={qc['clip_count']}",
570
+ f"join_fade_ms={qc['join_fade_ms']}",
571
+ f"output_geometry={geometry['width']}x{geometry['height']}@{geometry['fps']}fps"
572
+ f" reason={qc['output_geometry_reason']}",
573
+ ]
574
+ if qc.get("warnings"):
575
+ parts.append(f"warnings={len(qc['warnings'])}")
576
+ return "[video-recap] cut QC: " + "; ".join(parts)
745
577
 
746
578
 
747
579
  def _surface_cut_qc(work_dir):
748
- plan = _load_json(Path(work_dir) / "clip_plan_validated.json")
749
- qc = plan.get("qc") if isinstance(plan, dict) else None
750
- for line in _cut_qc_summary_lines(qc):
751
- print(line, flush=True)
752
- blocking = _cut_qc_blocking_reasons(qc)
753
- if blocking:
754
- raise SystemExit("video-cut QC blocking/fail status: " + "; ".join(blocking))
580
+ """Print the cut QC video-cut recorded; cut.py itself already fails on blocking QC."""
581
+ qc = load_json(Path(work_dir) / "clip_plan_validated.json")["qc"]
582
+ print(_cut_qc_summary_line(qc), flush=True)
755
583
  return qc
756
584
 
757
585
 
758
586
  def _fmt_range(start, end):
759
- try:
760
- return f"{float(start):.3f}-{float(end):.3f}s"
761
- except (TypeError, ValueError):
762
- return f"{start}-{end}s"
587
+ return f"{start:.3f}-{end:.3f}s"
763
588
 
764
589
 
765
590
  def _material_library_dir(args):
766
- return (
767
- args.material_library_dir
768
- or os.environ.get("VIDEO_RECAP_MATERIAL_LIBRARY_DIR")
769
- or None
770
- )
591
+ return args.material_library_dir or os.environ.get("VIDEO_RECAP_MATERIAL_LIBRARY_DIR") or None
771
592
 
772
593
 
773
594
  def _materials_enabled(args):
@@ -781,9 +602,7 @@ def _save_materials_enabled(args):
781
602
  def _pause_for_agent(work_dir, need_text, cont, inspect_hint=None):
782
603
  brief = Path(work_dir) / "agent_narration_brief.md"
783
604
  print("=" * 50)
784
- if brief.exists() and "Research the story FIRST" in brief.read_text(
785
- encoding="utf-8"
786
- ):
605
+ if "Research the story FIRST" in brief.read_text(encoding="utf-8"):
787
606
  print(
788
607
  "[video-recap] ⚑ 理解素材偏薄:先按 brief 顶部「Research the story FIRST」调研并写 "
789
608
  "background_research.json,再写解说,避免看图说话。"