@oh-story/dsh 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/README.md +11 -11
  2. package/lib/client.js +35 -14
  3. package/lib/client.js.map +2 -2
  4. package/lib/drama/manifest.json +165 -69
  5. package/lib/drama/skills/short-drama/SKILL.md +5 -1
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +126 -9
  7. package/lib/drama/skills/short-drama/scripts/project_tool.py +11 -0
  8. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +18 -0
  9. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +10 -1
  10. package/lib/drama/skills/short-drama-edit/SKILL.md +148 -0
  11. package/lib/drama/skills/short-drama-edit/agents/openai.yaml +4 -0
  12. package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +60 -0
  13. package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +15 -0
  14. package/lib/drama/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
  15. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
  16. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
  17. package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
  18. package/lib/drama/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
  19. package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
  20. package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
  21. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
  22. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +81 -0
  23. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +58 -0
  24. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +96 -0
  25. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +113 -0
  26. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +55 -0
  27. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1041 -0
  28. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +456 -0
  29. package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +5 -0
  30. package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +3 -0
  31. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
  32. package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +4 -1
  33. package/lib/drama/skills/short-drama-produce/SKILL.md +24 -3
  34. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +28 -0
  35. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +24 -5
  36. package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +7 -0
  37. package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +18 -1
  38. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +181 -2
  39. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +57 -2
  40. package/lib/drama/skills/short-drama-review/SKILL.md +3 -0
  41. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +20 -0
  42. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +36 -0
  43. package/lib/drama/skills/short-drama-storyboard/SKILL.md +7 -0
  44. package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +4 -0
  45. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +39 -0
  46. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -0
  47. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -1
  48. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +101 -6
  49. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +7 -1
  50. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +35 -0
  51. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +3 -1
  52. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +13 -3
  53. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +35 -4
  54. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -1
  55. package/lib/drama/skills/short-drama-write/SKILL.md +13 -2
  56. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +6 -0
  57. package/lib/drama/skills/short-drama-write/references/stage-contract.md +1 -0
  58. package/lib/index.js +4 -2
  59. package/lib/index.js.map +2 -2
  60. package/lib/novel-to-game/VERSION +1 -1
  61. package/lib/novel-to-game/manifest.json +28 -28
  62. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +6 -27
  63. package/lib/novel-to-game/skills/game-build/SKILL.md +12 -29
  64. package/lib/novel-to-game/skills/game-concept/SKILL.md +10 -26
  65. package/lib/novel-to-game/skills/game-qa/SKILL.md +5 -15
  66. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +1 -1
  67. package/lib/novel-to-game/skills/game-world-design/SKILL.md +19 -28
  68. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +1 -2
  69. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +6 -9
  70. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +6 -11
  71. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -4
  72. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +1 -3
  73. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +3 -5
  74. package/lib/oh-story/manifest.json +292 -172
  75. package/lib/oh-story/roles/character-designer.md +1 -1
  76. package/lib/oh-story/roles/narrative-writer.md +56 -242
  77. package/lib/oh-story/roles/story-architect.md +1 -1
  78. package/lib/oh-story/skills/story/SKILL.md +1 -1
  79. package/lib/oh-story/skills/story/VERSION +1 -1
  80. package/lib/oh-story/skills/story/references/author-memory.md +10 -8
  81. package/lib/oh-story/skills/story-deslop/SKILL.md +20 -182
  82. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +19 -47
  83. package/lib/oh-story/skills/story-deslop/references/author-memory.md +10 -8
  84. package/lib/oh-story/skills/story-deslop/references/banned-words.md +3 -1
  85. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +154 -0
  86. package/lib/oh-story/skills/story-deslop/references/style-resolution.md +27 -0
  87. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +6 -1
  88. package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +39 -20
  89. package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +10 -5
  90. package/lib/oh-story/skills/story-deslop/scripts/style-whitelist.js +39 -0
  91. package/lib/oh-story/skills/story-import/SKILL.md +2 -2
  92. package/lib/oh-story/skills/story-import/references/format-and-structure.md +2 -2
  93. package/lib/oh-story/skills/story-import/references/tracking-initialization.md +31 -0
  94. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +2 -29
  95. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +176 -12
  96. package/lib/oh-story/skills/story-long-analyze/SKILL.md +1 -1
  97. package/lib/oh-story/skills/story-long-write/SKILL.md +20 -193
  98. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +19 -47
  99. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +62 -7
  100. package/lib/oh-story/skills/story-long-write/references/author-memory.md +10 -8
  101. package/lib/oh-story/skills/story-long-write/references/banned-words.md +3 -1
  102. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +20 -0
  103. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +6 -4
  104. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +2 -2
  105. package/lib/oh-story/skills/story-long-write/references/long-format.md +2 -0
  106. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +2 -0
  107. package/lib/oh-story/skills/story-long-write/references/project-files.md +108 -0
  108. package/lib/oh-story/skills/story-long-write/references/reference-index.md +87 -0
  109. package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -1
  110. package/lib/oh-story/skills/story-long-write/references/style-craft.md +9 -29
  111. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
  112. package/lib/oh-story/skills/story-long-write/references/style-resolution.md +27 -0
  113. package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +31 -0
  114. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -29
  115. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +51 -52
  116. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +8 -7
  117. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +36 -15
  118. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +22 -0
  119. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +69 -2
  120. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +410 -0
  121. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +6 -1
  122. package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +39 -20
  123. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +176 -12
  124. package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +90 -23
  125. package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +10 -5
  126. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +283 -0
  127. package/lib/oh-story/skills/story-long-write/scripts/style-whitelist.js +39 -0
  128. package/lib/oh-story/skills/story-review/SKILL.md +13 -3
  129. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +19 -47
  130. package/lib/oh-story/skills/story-review/references/author-memory.md +10 -8
  131. package/lib/oh-story/skills/story-review/references/banned-words.md +3 -1
  132. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +6 -4
  133. package/lib/oh-story/skills/story-review/references/review-quality.md +2 -2
  134. package/lib/oh-story/skills/story-review/references/style-resolution.md +27 -0
  135. package/lib/oh-story/skills/story-review/references/tracking-initialization.md +31 -0
  136. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -29
  137. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +6 -1
  138. package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +39 -20
  139. package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +10 -5
  140. package/lib/oh-story/skills/story-review/scripts/style-whitelist.js +39 -0
  141. package/lib/oh-story/skills/story-setup/SKILL.md +17 -10
  142. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +19 -47
  143. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +3 -1
  144. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +154 -0
  145. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +6 -4
  146. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +2 -2
  147. package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +1 -1
  148. package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -1
  149. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
  150. package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +27 -0
  151. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +69 -2
  152. package/lib/oh-story/skills/story-setup/references/diagnostics.md +38 -0
  153. package/lib/oh-story/skills/story-short-analyze/references/analysis-writing-techniques.md +1 -1
  154. package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +2 -2
  155. package/lib/oh-story/skills/story-short-write/SKILL.md +18 -186
  156. package/lib/oh-story/skills/story-short-write/references/author-memory.md +10 -8
  157. package/lib/oh-story/skills/story-short-write/references/banned-words.md +3 -1
  158. package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +60 -0
  159. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +6 -4
  160. package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +2 -2
  161. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +1 -1
  162. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +3 -3
  163. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +3 -3
  164. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +3 -3
  165. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +5 -5
  166. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +4 -4
  167. package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +5 -5
  168. package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +3 -3
  169. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +1 -1
  170. package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +1 -1
  171. package/lib/oh-story/skills/story-short-write/references/reference-index.md +53 -0
  172. package/lib/oh-story/skills/story-short-write/references/short-craft.md +12 -14
  173. package/lib/oh-story/skills/story-short-write/references/short-deslop.md +10 -10
  174. package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -1
  175. package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -2
  176. package/lib/oh-story/skills/story-short-write/references/style-resolution.md +27 -0
  177. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +77 -0
  178. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +5 -5
  179. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +6 -1
  180. package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +39 -20
  181. package/lib/oh-story/skills/story-short-write/scripts/check-delivery-contract.js +34 -5
  182. package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +90 -23
  183. package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +10 -5
  184. package/lib/oh-story/skills/story-short-write/scripts/style-whitelist.js +39 -0
  185. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  186. package/lib/types/client/index.d.ts.map +1 -1
  187. package/lib/types/client/production-prompts.d.ts.map +1 -1
  188. package/lib/types/client/production-runtime.d.ts +17 -0
  189. package/lib/types/client/production-runtime.d.ts.map +1 -1
  190. package/lib/types/skill-provider.d.ts.map +1 -1
  191. package/lib/video-recap/CHANGELOG.md +31 -1
  192. package/lib/video-recap/manifest.json +162 -162
  193. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +4 -9
  194. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +47 -60
  195. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +77 -115
  196. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +41 -40
  197. package/lib/video-recap/skills/video-assemble/scripts/audio_automation.py +22 -32
  198. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +102 -192
  199. package/lib/video-recap/skills/video-assemble/scripts/jianying_builders.py +14 -35
  200. package/lib/video-recap/skills/video-assemble/scripts/jianying_model.py +14 -12
  201. package/lib/video-recap/skills/video-assemble/scripts/jianying_optional.py +8 -9
  202. package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +3 -14
  203. package/lib/video-recap/skills/video-assemble/scripts/jianying_timeline_contract.py +23 -12
  204. package/lib/video-recap/skills/video-assemble/scripts/jianying_tracks.py +1 -4
  205. package/lib/video-recap/skills/video-assemble/scripts/jianying_writer.py +24 -25
  206. package/lib/video-recap/skills/video-assemble/scripts/lib.py +31 -85
  207. package/lib/video-recap/skills/video-assemble/scripts/media.py +100 -144
  208. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +101 -195
  209. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +10 -21
  210. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +90 -169
  211. package/lib/video-recap/skills/video-assemble/scripts/subtitle_core.py +69 -122
  212. package/lib/video-recap/skills/video-assemble/scripts/subtitle_render.py +10 -17
  213. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +33 -48
  214. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +14 -20
  215. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +135 -213
  216. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +49 -57
  217. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +38 -74
  218. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +46 -95
  219. package/lib/video-recap/skills/video-cut/scripts/lib.py +23 -25
  220. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +70 -173
  221. package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +28 -53
  222. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +162 -322
  223. package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +6 -7
  224. package/lib/video-recap/skills/video-recap/scripts/doctor.py +104 -124
  225. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +255 -412
  226. package/lib/video-recap/skills/video-recap/scripts/lib.py +30 -52
  227. package/lib/video-recap/skills/video-recap/scripts/materials.py +97 -120
  228. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_client.py +7 -12
  229. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_evidence.py +46 -97
  230. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_observations.py +18 -36
  231. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_payload.py +29 -47
  232. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_report.py +43 -76
  233. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_runner.py +2 -1
  234. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +2 -1
  235. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +2 -2
  236. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +129 -145
  237. package/lib/video-recap/skills/video-recap/scripts/recap_review.py +27 -20
  238. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +20 -31
  239. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +31 -106
  240. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +43 -113
  241. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +196 -377
  242. package/lib/video-recap/skills/video-script/scripts/agent_brief.py +1 -1
  243. package/lib/video-recap/skills/video-script/scripts/agent_text.py +63 -172
  244. package/lib/video-recap/skills/video-script/scripts/brief_context.py +80 -135
  245. package/lib/video-recap/skills/video-script/scripts/brief_inputs.py +125 -167
  246. package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +93 -208
  247. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +20 -46
  248. package/lib/video-recap/skills/video-script/scripts/lib.py +30 -26
  249. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +145 -140
  250. package/lib/video-recap/skills/video-script/scripts/review_response.py +12 -42
  251. package/lib/video-recap/skills/video-script/scripts/review_runner.py +0 -6
  252. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +39 -100
  253. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +33 -124
  254. package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +1 -1
  255. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +63 -172
  256. package/lib/video-recap/skills/video-understanding/scripts/asr.py +1 -2
  257. package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +80 -135
  258. package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +125 -167
  259. package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +93 -208
  260. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +1 -1
  261. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +20 -46
  262. package/lib/video-recap/skills/video-understanding/scripts/lib.py +31 -26
  263. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +145 -140
  264. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +39 -100
  265. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +33 -124
  266. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +1 -4
  267. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +7 -13
  268. package/lib/video-recap/skills/video-voiceover/scripts/fish_audio.py +12 -30
  269. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +98 -131
  270. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +17 -28
  271. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +151 -240
  272. package/package.json +41 -41
@@ -1,43 +1,22 @@
1
1
  """Snap clip boundaries to complete speech and clean shot transitions."""
2
2
 
3
3
  import json
4
-
5
4
  import re
6
-
7
5
  import subprocess
8
-
9
6
  from pathlib import Path
10
7
 
11
8
  from lib import log
12
9
 
13
10
 
14
- def _valid_silence_windows(silence_periods):
15
- """Return sorted well-formed quiet windows as {start,end} floats."""
16
- windows = []
17
- for w in silence_periods or []:
18
- if not isinstance(w, dict):
19
- continue
20
- try:
21
- start = float(w.get("start"))
22
- end = float(w.get("end"))
23
- except (TypeError, ValueError):
24
- continue
25
- if end >= start:
26
- windows.append({"start": start, "end": end})
27
- return sorted(windows, key=lambda w: (w["start"], w["end"]))
28
-
29
-
30
- def _source_artifact_candidates(
31
- work_dir, filename, source_id=None, source_work_dir=None
32
- ):
11
+ def _find_source_artifact(work_dir, filename, source_id=None, source_work_dir=None):
12
+ """First existing copy of a per-source understanding artifact, by layout precedence."""
33
13
  candidates = []
34
14
  if source_work_dir:
35
15
  candidates.append(Path(work_dir) / source_work_dir / filename)
36
- if source_id not in (None, ""):
37
- candidates.append(Path(work_dir) / "sources" / str(source_id) / filename)
16
+ if source_id is not None:
17
+ candidates.append(Path(work_dir) / "sources" / source_id / filename)
38
18
  candidates.append(Path(work_dir) / filename)
39
- # Preserve precedence while avoiding duplicate reads when layouts resolve to one path.
40
- return list(dict.fromkeys(candidates))
19
+ return next((path for path in candidates if path.exists()), None)
41
20
 
42
21
 
43
22
  def _load_sentence_boundary_windows(work_dir, source_id=None, source_work_dir=None):
@@ -47,112 +26,69 @@ def _load_sentence_boundary_windows(work_dir, source_id=None, source_work_dir=No
47
26
  after the final spoken sample. Any cut within that closed interval preserves the sentence.
48
27
  Low-confidence anchors are deliberately excluded from the hard-safety path.
49
28
  """
50
- for path in _source_artifact_candidates(
29
+ path = _find_source_artifact(
51
30
  work_dir, "speech_boundary_anchors.json", source_id, source_work_dir
52
- ):
53
- if not path.exists():
54
- continue
55
- try:
56
- payload = json.loads(path.read_text(encoding="utf-8"))
57
- except (OSError, ValueError):
58
- return []
59
- anchors = payload.get("sentence_anchors") if isinstance(payload, dict) else None
60
- windows = []
61
- for anchor in anchors or []:
62
- if not isinstance(anchor, dict):
63
- continue
64
- confidence = str(anchor.get("confidence") or "").strip().lower()
65
- if confidence not in {"high", "medium"}:
66
- continue
67
- try:
68
- end = float(anchor.get("time"))
69
- start = float(anchor.get("pause_start", end))
70
- except (TypeError, ValueError):
71
- continue
72
- start = min(start, end)
73
- windows.append(
74
- {
75
- "start": round(max(0.0, start), 3),
76
- "end": round(max(0.0, end), 3),
77
- "kind": "sentence_anchor",
78
- "confidence": confidence,
79
- }
80
- )
81
- return sorted(windows, key=lambda row: (row["start"], row["end"]))
82
- return []
31
+ )
32
+ if path is None:
33
+ return []
34
+ payload = json.loads(path.read_text(encoding="utf-8"))
35
+ windows = [
36
+ {
37
+ "start": round(anchor["pause_start"], 3),
38
+ "end": round(anchor["time"], 3),
39
+ "kind": "sentence_anchor",
40
+ "confidence": anchor["confidence"],
41
+ }
42
+ for anchor in payload["sentence_anchors"]
43
+ if anchor["confidence"] in {"high", "medium"}
44
+ ]
45
+ return sorted(windows, key=lambda row: (row["start"], row["end"]))
83
46
 
84
47
 
85
48
  def _load_source_speech_spans(work_dir, source_id=None, source_work_dir=None):
86
- """Best-effort ASR speech ownership spans used only to decide whether an unsafe edge blocks."""
87
- payload = None
49
+ """Merged ASR speech spans (asr_clean.json wins over asr_result.json).
50
+
51
+ Only used to decide whether an unsafe edge blocks; a missing transcript means unchecked.
52
+ """
53
+ rows = []
88
54
  for filename in ("asr_clean.json", "asr_result.json"):
89
- for path in _source_artifact_candidates(
90
- work_dir, filename, source_id, source_work_dir
91
- ):
92
- if not path.exists():
93
- continue
94
- try:
95
- payload = json.loads(path.read_text(encoding="utf-8"))
96
- except (OSError, ValueError):
97
- payload = None
98
- if payload is not None:
99
- break
100
- if payload is not None:
55
+ path = _find_source_artifact(work_dir, filename, source_id, source_work_dir)
56
+ if path is not None:
57
+ payload = json.loads(path.read_text(encoding="utf-8"))
58
+ rows = payload["segments"] if filename == "asr_clean.json" else payload
101
59
  break
102
- if isinstance(payload, dict):
103
- payload = payload.get("segments") or payload.get("items") or []
104
- spans = []
105
- for row in payload or []:
106
- if not isinstance(row, dict) or not str(row.get("text") or "").strip():
107
- continue
108
- try:
109
- start, end = float(row.get("start")), float(row.get("end"))
110
- except (TypeError, ValueError):
111
- continue
112
- if end > start:
113
- spans.append({"start": max(0.0, start), "end": end})
60
+ spans = sorted(
61
+ ({"start": row["start"], "end": row["end"]} for row in rows if row["text"].strip()),
62
+ key=lambda row: (row["start"], row["end"]),
63
+ )
114
64
  merged = []
115
- for span in sorted(spans, key=lambda row: (row["start"], row["end"])):
65
+ for span in spans:
116
66
  if merged and span["start"] <= merged[-1]["end"] + 0.05:
117
67
  merged[-1]["end"] = max(merged[-1]["end"], span["end"])
118
68
  else:
119
- merged.append(dict(span))
69
+ merged.append(span)
120
70
  return merged
121
71
 
122
72
 
123
73
  def _combine_boundary_windows(*groups):
124
- rows = []
125
- for group in groups:
126
- rows.extend(_valid_silence_windows(group))
127
- unique = {(round(row["start"], 3), round(row["end"], 3)) for row in rows}
74
+ unique = {
75
+ (round(row["start"], 3), round(row["end"], 3)) for group in groups for row in group
76
+ }
128
77
  return [{"start": start, "end": end} for start, end in sorted(unique)]
129
78
 
130
79
 
131
80
  def _same_source(left, right):
132
- left_id, right_id = left.get("source_id"), right.get("source_id")
133
- if left_id not in (None, "") or right_id not in (None, ""):
134
- return (
135
- left_id not in (None, "")
136
- and right_id not in (None, "")
137
- and str(left_id) == str(right_id)
138
- )
139
- left_path, right_path = left.get("source_path"), right.get("source_path")
140
- if left_path or right_path:
141
- return bool(left_path and right_path and str(left_path) == str(right_path))
142
- return True # single-source normalized plans intentionally omit source identity
81
+ # Single-source normalized plans omit source identity; both sides then read as None.
82
+ return left.get("source_id") == right.get("source_id") and left.get(
83
+ "source_path"
84
+ ) == right.get("source_path")
143
85
 
144
86
 
145
87
  def _continuous_source_join(left, right, tolerance=0.05):
146
- return bool(
88
+ return (
147
89
  _same_source(left, right)
148
- and abs(
149
- float(left.get("source_end", 0.0)) - float(right.get("source_start", 0.0))
150
- )
151
- <= tolerance
152
- and abs(
153
- float(left.get("output_end", 0.0)) - float(right.get("output_start", 0.0))
154
- )
155
- <= tolerance
90
+ and abs(left["source_end"] - right["source_start"]) <= tolerance
91
+ and abs(left["output_end"] - right["output_start"]) <= tolerance
156
92
  )
157
93
 
158
94
 
@@ -165,28 +101,14 @@ def enforce_clip_sentence_boundaries(
165
101
  same-source join (no media is removed). Missing ASR timing degrades to `unchecked` rather
166
102
  than inventing speech. Once ASR says an edge is speech-owned, failure to snap is blocking.
167
103
  """
168
- clips = plan.get("clips") or []
169
- windows = _valid_silence_windows(boundary_windows)
170
- spans = _valid_silence_windows(speech_spans)
171
- video_duration = max(0.0, float(video_duration or 0.0))
172
- tolerance = max(0.001, float(tolerance or 0.05))
104
+ clips = plan["clips"]
173
105
  checks, new_blockers = [], []
174
106
 
175
- def in_windows(ts):
176
- return any(
177
- row["start"] - tolerance <= ts <= row["end"] + tolerance for row in windows
178
- )
179
-
180
- def in_speech(ts):
181
- return any(
182
- row["start"] - tolerance <= ts <= row["end"] + tolerance for row in spans
183
- )
107
+ def inside(rows, ts):
108
+ return any(row["start"] - tolerance <= ts <= row["end"] + tolerance for row in rows)
184
109
 
185
110
  for idx, clip in enumerate(clips):
186
- for edge, ts in (
187
- ("start", float(clip["source_start"])),
188
- ("end", float(clip["source_end"])),
189
- ):
111
+ for edge, ts in (("start", clip["source_start"]), ("end", clip["source_end"])):
190
112
  contiguous = (
191
113
  edge == "start"
192
114
  and idx > 0
@@ -202,16 +124,16 @@ def enforce_clip_sentence_boundaries(
202
124
  status, reason = "safe", "source_end"
203
125
  elif contiguous:
204
126
  status, reason = "safe", "continuous_source_join"
205
- elif in_windows(ts):
127
+ elif inside(boundary_windows, ts):
206
128
  status, reason = "safe", "sentence_or_quiet_boundary"
207
- elif not spans:
129
+ elif not speech_spans:
208
130
  status, reason = "unchecked", "speech_timing_unavailable"
209
- elif not in_speech(ts):
131
+ elif not inside(speech_spans, ts):
210
132
  status, reason = "safe", "outside_detected_speech"
211
133
  else:
212
134
  status, reason = "blocking", "inside_detected_speech"
213
135
  check = {
214
- "clip_id": clip.get("clip_id", idx),
136
+ "clip_id": clip["clip_id"],
215
137
  "source_id": clip.get("source_id"),
216
138
  "edge": edge,
217
139
  "time": round(ts, 3),
@@ -232,9 +154,8 @@ def enforce_clip_sentence_boundaries(
232
154
  qc.setdefault("boundary_status", {})["sentence_checks"] = checks
233
155
  existing = [
234
156
  row
235
- for row in (qc.get("blocking") or [])
236
- if not isinstance(row, dict)
237
- or row.get("code") != "unsafe_clip_sentence_boundary"
157
+ for row in qc.get("blocking", [])
158
+ if row["code"] != "unsafe_clip_sentence_boundary"
238
159
  ]
239
160
  if existing or new_blockers:
240
161
  qc["blocking"] = existing + new_blockers
@@ -246,7 +167,7 @@ def enforce_clip_sentence_boundaries(
246
167
  def _recompute_clip_timeline(clips):
247
168
  cursor = 0.0
248
169
  for clip in clips:
249
- duration = round(float(clip["source_end"]) - float(clip["source_start"]), 3)
170
+ duration = round(clip["source_end"] - clip["source_start"], 3)
250
171
  clip["duration"] = duration
251
172
  clip["output_start"] = round(cursor, 3)
252
173
  clip["output_end"] = round(cursor + duration, 3)
@@ -254,15 +175,26 @@ def _recompute_clip_timeline(clips):
254
175
  return round(cursor, 3)
255
176
 
256
177
 
178
+ def _plan_with_snapped_clips(plan, clips, boundary_key, events):
179
+ """Copy of `plan` carrying the snapped clips, a cursor-based output timeline, and the
180
+ per-pass boundary events under qc.boundary_status[boundary_key]."""
181
+ result = dict(plan)
182
+ result["clips"] = clips
183
+ result["total_duration"] = _recompute_clip_timeline(clips)
184
+ qc = dict(plan.get("qc", {}))
185
+ boundary = dict(qc.get("boundary_status", {}))
186
+ boundary[boundary_key] = events
187
+ qc["boundary_status"] = boundary
188
+ result["qc"] = qc
189
+ return result
190
+
191
+
257
192
  def _candidate_overlaps(clips, idx, new_start, new_end):
258
- for j, other in enumerate(clips):
259
- if j == idx:
260
- continue
261
- if new_start < float(other["source_end"]) and new_end > float(
262
- other["source_start"]
263
- ):
264
- return True
265
- return False
193
+ return any(
194
+ new_start < other["source_end"] and new_end > other["source_start"]
195
+ for j, other in enumerate(clips)
196
+ if j != idx
197
+ )
266
198
 
267
199
 
268
200
  def snap_clip_starts_to_lines(
@@ -282,23 +214,18 @@ def snap_clip_starts_to_lines(
282
214
  (<= max_trim) may trim forward if duration/overlap safety holds.
283
215
  - never overlap/collapse when allow_overlap is false; unsafe attempts keep-and-warn.
284
216
  """
285
- silence_periods = _valid_silence_windows(silence_periods)
286
217
  if not silence_periods:
287
218
  return plan
288
219
 
289
220
  clips = [dict(c) for c in plan["clips"]]
290
- video_duration = max(0.0, float(video_duration or 0.0))
291
- max_prepend = max(0.0, float(max_prepend or 0.0))
292
- max_trim = max(0.0, float(max_trim or 0.0))
293
- min_duration = max(0.05, float(min_clip_duration or 0.05))
294
- allow_overlap = bool(plan.get("allow_overlap", False))
221
+ allow_overlap = plan["allow_overlap"]
295
222
  events = []
296
223
 
297
224
  for i, clip in enumerate(clips):
298
- original_start = float(clip["source_start"])
299
- source_end = float(clip["source_end"])
225
+ original_start = clip["source_start"]
226
+ source_end = clip["source_end"]
300
227
  event = {
301
- "clip_id": clip.get("clip_id", i),
228
+ "clip_id": clip["clip_id"],
302
229
  "source_id": clip.get("source_id"),
303
230
  "original_start": round(original_start, 3),
304
231
  "action": "kept",
@@ -313,12 +240,10 @@ def snap_clip_starts_to_lines(
313
240
  if prior_ends:
314
241
  candidate_start = max(prior_ends)
315
242
  if original_start - candidate_start <= max_prepend:
316
- candidate_start = round(max(0.0, candidate_start), 3)
317
- safe = candidate_start < source_end - min_duration + 1e-9
243
+ candidate_start = round(candidate_start, 3)
244
+ safe = candidate_start < source_end - min_clip_duration + 1e-9
318
245
  if safe and not allow_overlap:
319
- safe = not _candidate_overlaps(
320
- clips, i, candidate_start, source_end
321
- )
246
+ safe = not _candidate_overlaps(clips, i, candidate_start, source_end)
322
247
  if safe:
323
248
  clip["source_start"] = candidate_start
324
249
  event.update(
@@ -336,19 +261,15 @@ def snap_clip_starts_to_lines(
336
261
  else:
337
262
  reason = "no_prior_quiet"
338
263
 
339
- next_starts = [
340
- w["start"] for w in silence_periods if w["start"] >= original_start
341
- ]
264
+ next_starts = [w["start"] for w in silence_periods if w["start"] >= original_start]
342
265
  if next_starts:
343
266
  candidate_start = min(next_starts)
344
267
  trim_delta = candidate_start - original_start
345
268
  if 0 < trim_delta <= max_trim:
346
269
  candidate_start = round(min(video_duration, candidate_start), 3)
347
- safe = source_end - candidate_start >= min_duration
270
+ safe = source_end - candidate_start >= min_clip_duration
348
271
  if safe and not allow_overlap:
349
- safe = not _candidate_overlaps(
350
- clips, i, candidate_start, source_end
351
- )
272
+ safe = not _candidate_overlaps(clips, i, candidate_start, source_end)
352
273
  if safe:
353
274
  clip["source_start"] = candidate_start
354
275
  event.update(
@@ -367,69 +288,53 @@ def snap_clip_starts_to_lines(
367
288
  event["warning_code"] = "clip_start_unsnapped"
368
289
  events.append(event)
369
290
 
370
- total_duration = _recompute_clip_timeline(clips)
371
- result = dict(plan)
372
- result["clips"] = clips
373
- result["total_duration"] = total_duration
374
- qc = dict(result.get("qc") or {})
375
- boundary = dict(qc.get("boundary_status") or {})
376
- boundary["start_snaps"] = events
377
- qc["boundary_status"] = boundary
378
- warnings = list(qc.get("warnings") or [])
291
+ result = _plan_with_snapped_clips(plan, clips, "start_snaps", events)
292
+ warnings = list(result["qc"].get("warnings", []))
379
293
  for event in events:
380
- if event.get("warning_code"):
294
+ if "warning_code" in event:
381
295
  warnings.append(
382
296
  {
383
297
  "code": event["warning_code"],
384
298
  "clip_id": event["clip_id"],
385
- "source_id": event.get("source_id"),
386
- "start_unsnapped_reason": event.get("start_unsnapped_reason"),
299
+ "source_id": event["source_id"],
300
+ "start_unsnapped_reason": event["start_unsnapped_reason"],
387
301
  }
388
302
  )
389
303
  if warnings:
390
- qc["warnings"] = warnings
391
- result["qc"] = qc
304
+ result["qc"]["warnings"] = warnings
392
305
  return result
393
306
 
394
307
 
395
308
  def snap_clip_ends_to_lines(plan, silence_periods, video_duration, max_extend):
396
309
  """Extend each clip's source_end forward to the next natural pause, preventing mid-sentence cuts.
397
310
 
398
- - If silence_periods is empty/None, returns plan unchanged.
399
- - If a clip's source_end already falls inside a quiet window, no snap is applied.
400
- - Otherwise, extends to the next quiet window start, capped by max_extend and video_duration.
401
- - When plan["allow_overlap"] is False, will not extend into another clip's source range.
402
- - After snapping, recomputes output_start/output_end/duration for all clips cursor-based.
403
- - Returns the updated plan dict (all other keys preserved).
311
+ - No quiet windows: the plan is returned unchanged.
312
+ - A source_end already inside a quiet window is left alone.
313
+ - Otherwise extend to the next quiet window start, capped by max_extend and video_duration.
314
+ - When plan["allow_overlap"] is False, never extend into another clip's source range.
315
+ - Recomputes output_start/output_end/duration for all clips cursor-based.
404
316
  """
405
- # silence_periods is loaded from disk; tolerate a stale/hand-edited row by keeping only
406
- # well-formed quiet windows so a single bad entry can't KeyError-abort the whole cut.
407
- silence_periods = _valid_silence_windows(silence_periods)
408
317
  if not silence_periods:
409
318
  return plan
410
319
 
411
320
  clips = [dict(c) for c in plan["clips"]]
412
- video_duration = float(video_duration)
413
- max_extend = float(max_extend)
414
- allow_overlap = bool(plan.get("allow_overlap", False))
321
+ allow_overlap = plan["allow_overlap"]
415
322
  events = []
416
323
 
417
324
  for i, clip in enumerate(clips):
418
325
  source_end = clip["source_end"]
419
326
  event = {
420
- "clip_id": clip.get("clip_id", i),
327
+ "clip_id": clip["clip_id"],
421
328
  "source_id": clip.get("source_id"),
422
- "original_end": round(float(source_end), 3),
329
+ "original_end": round(source_end, 3),
423
330
  "action": "kept",
424
331
  }
425
332
 
426
- # Already inside a quiet window → already at a natural pause.
427
333
  if any(w["start"] <= source_end <= w["end"] for w in silence_periods):
428
334
  event["reason"] = "already_quiet"
429
335
  events.append(event)
430
336
  continue
431
337
 
432
- # Find the next quiet window start at or after source_end.
433
338
  candidates = [w["start"] for w in silence_periods if w["start"] >= source_end]
434
339
  if not candidates:
435
340
  event["end_unsnapped_reason"] = "no_next_quiet"
@@ -437,7 +342,6 @@ def snap_clip_ends_to_lines(plan, silence_periods, video_duration, max_extend):
437
342
  continue
438
343
  next_quiet_start = min(candidates)
439
344
 
440
- # Only snap if the next pause is within reach (≤ max_extend away).
441
345
  if next_quiet_start > source_end + max_extend:
442
346
  event["end_unsnapped_reason"] = "next_quiet_too_far"
443
347
  events.append(event)
@@ -456,8 +360,7 @@ def snap_clip_ends_to_lines(plan, silence_periods, video_duration, max_extend):
456
360
  if j != i and c["source_start"] > source_end
457
361
  ]
458
362
  if other_starts:
459
- nearest_other_start = min(other_starts)
460
- candidate_end = min(candidate_end, nearest_other_start)
363
+ candidate_end = min(candidate_end, min(other_starts))
461
364
  if candidate_end <= source_end:
462
365
  event["end_unsnapped_reason"] = "overlap_or_collapse"
463
366
  events.append(event)
@@ -468,23 +371,12 @@ def snap_clip_ends_to_lines(plan, silence_periods, video_duration, max_extend):
468
371
  {
469
372
  "action": "extended",
470
373
  "new_end": clip["source_end"],
471
- "delta": round(float(clip["source_end"]) - float(source_end), 3),
374
+ "delta": round(clip["source_end"] - source_end, 3),
472
375
  }
473
376
  )
474
377
  events.append(event)
475
378
 
476
- # Recompute output timeline cursor-based (same cursor logic as normalize_clip_plan).
477
- total_duration = _recompute_clip_timeline(clips)
478
-
479
- result = dict(plan)
480
- result["clips"] = clips
481
- result["total_duration"] = total_duration
482
- qc = dict(result.get("qc") or {})
483
- boundary = dict(qc.get("boundary_status") or {})
484
- boundary["end_snaps"] = events
485
- qc["boundary_status"] = boundary
486
- result["qc"] = qc
487
- return result
379
+ return _plan_with_snapped_clips(plan, clips, "end_snaps", events)
488
380
 
489
381
 
490
382
  def _detect_shot_changes(video, win_start, win_end, threshold, lead=0.25):
@@ -493,10 +385,8 @@ def _detect_shot_changes(video, win_start, win_end, threshold, lead=0.25):
493
385
  Input-seek to a little before the window: the rebased output PTS restarts at ~0 at the seek
494
386
  target, so `seek + pts_time` recovers absolute source time. The `lead` keeps the seek/keyframe
495
387
  settling artifact frame outside [win_start, win_end] so it is filtered out, not mistaken for a
496
- cut. Returns [] on any ffmpeg trouble (advisory pass must never block the cut).
388
+ cut.
497
389
  """
498
- win_start = max(0.0, float(win_start))
499
- win_end = max(win_start, float(win_end))
500
390
  if win_end - win_start < 1e-3:
501
391
  return []
502
392
  seek = max(0.0, win_start - lead)
@@ -519,21 +409,18 @@ def _detect_shot_changes(video, win_start, win_end, threshold, lead=0.25):
519
409
  "null",
520
410
  "-",
521
411
  ]
522
- try:
523
- proc = subprocess.run(cmd, capture_output=True, text=True)
524
- except (OSError, ValueError):
525
- return []
526
- changes = []
527
- for m in re.finditer(r"pts_time:([0-9.]+)", proc.stderr or ""):
412
+ proc = subprocess.run(cmd, capture_output=True, text=True)
413
+ if proc.returncode != 0:
414
+ raise RuntimeError(f"ffmpeg 切镜头检测失败: {video}: {proc.stderr.strip()[-500:]}")
415
+ changes = set()
416
+ for m in re.finditer(r"pts_time:([0-9.]+)", proc.stderr):
528
417
  t = seek + float(m.group(1))
529
418
  if win_start <= t <= win_end:
530
- changes.append(round(t, 3))
531
- return sorted(set(changes))
419
+ changes.add(round(t, 3))
420
+ return sorted(changes)
532
421
 
533
422
 
534
- def snap_clips_off_shot_changes(
535
- plan, video, video_duration, margin, threshold, min_keep=0.5
536
- ):
423
+ def snap_clips_off_shot_changes(plan, video, margin, threshold, min_keep=0.5):
537
424
  """Nudge each clip's boundaries clear of the ORIGINAL footage's hard cuts to avoid 闪烁.
538
425
 
539
426
  A clip whose source_start sits just before a shot-change opens on a brief sliver of the old
@@ -542,22 +429,19 @@ def snap_clips_off_shot_changes(
542
429
  - move source_start FORWARD onto a shot-change in (start, start+margin] → clean open
543
430
  - move source_end BACK onto a shot-change in [end-margin, end) → clean close
544
431
  Boundaries already on a cut, or with no nearby cut, are left untouched. Snaps that would shrink
545
- a clip below `min_keep` are skipped. Recomputes the output timeline cursor-based. Advisory: any
546
- detection failure leaves that boundary as-is.
432
+ a clip below `min_keep` are skipped. Recomputes the output timeline cursor-based.
547
433
  """
548
- del video_duration # Positional compatibility for downstream direct callers.
549
- margin = float(margin)
550
- if margin <= 0 or not plan.get("clips"):
434
+ if margin <= 0:
551
435
  return plan
552
436
  clips = [dict(c) for c in plan["clips"]]
553
437
  n_start = n_end = 0
554
438
  events = []
555
- for i, clip in enumerate(clips):
556
- s = float(clip["source_start"])
557
- e = float(clip["source_end"])
439
+ for clip in clips:
440
+ s = clip["source_start"]
441
+ e = clip["source_end"]
558
442
  new_s, new_e = s, e
559
443
  event = {
560
- "clip_id": clip.get("clip_id", i),
444
+ "clip_id": clip["clip_id"],
561
445
  "source_id": clip.get("source_id"),
562
446
  "original_start": round(s, 3),
563
447
  "original_end": round(e, 3),
@@ -598,36 +482,17 @@ def snap_clips_off_shot_changes(
598
482
  clip["source_end"] = new_e
599
483
  events.append(event)
600
484
 
601
- # Recompute output timeline cursor-based (same cursor logic as snap_clip_ends_to_lines).
602
- total_duration = _recompute_clip_timeline(clips)
603
-
604
485
  if n_start or n_end:
605
486
  log(
606
487
  f"避让原片切镜头: {n_start} 个起点前移、{n_end} 个终点回收 (margin={margin}s, 阈值={threshold})"
607
488
  )
608
- result = dict(plan)
609
- result["clips"] = clips
610
- result["total_duration"] = total_duration
611
- qc = dict(result.get("qc") or {})
612
- boundary = dict(qc.get("boundary_status") or {})
613
- boundary["shot_snaps"] = events
614
- qc["boundary_status"] = boundary
615
- result["qc"] = qc
616
- return result
489
+ return _plan_with_snapped_clips(plan, clips, "shot_snaps", events)
617
490
 
618
491
 
619
492
  def _load_silence_for_source(work_dir, source_id, source_work_dir=None):
620
- """Read a source's silence_periods.json from the multi-source work layout (best-effort)."""
621
- for path in _source_artifact_candidates(
622
- work_dir, "silence_periods.json", source_id, source_work_dir
623
- ):
624
- if path.exists():
625
- try:
626
- data = json.loads(path.read_text(encoding="utf-8"))
627
- except (OSError, ValueError):
628
- return []
629
- return data if isinstance(data, list) else []
630
- return []
493
+ """Read a source's silence_periods.json (project layout when source_id is None); [] when absent."""
494
+ path = _find_source_artifact(work_dir, "silence_periods.json", source_id, source_work_dir)
495
+ return json.loads(path.read_text(encoding="utf-8")) if path else []
631
496
 
632
497
 
633
498
  def snap_multi_source_clips(
@@ -638,25 +503,23 @@ def snap_multi_source_clips(
638
503
  line_max_extend,
639
504
  scene_margin,
640
505
  scene_threshold,
506
+ start_max_prepend,
507
+ start_max_trim,
641
508
  do_line_snap=True,
642
509
  do_scene_snap=True,
643
- start_max_prepend=None,
644
- start_max_trim=None,
645
510
  ):
646
511
  """Per-source line/shot snapping for a multi-source validated plan.
647
512
 
648
513
  Each clip is snapped using ITS OWN source's silence windows / shot changes and duration
649
514
  (a clip in source B never constrains a clip in source A), then the global OUTPUT timeline
650
- is recomputed once in plan order. Advisory: missing silence data or ffmpeg trouble leaves a
651
- boundary unchanged. Mirrors the single-source snap_clip_ends_to_lines + snap_clips_off_shot_changes.
515
+ is recomputed once in plan order. Missing silence data leaves a boundary unchanged.
516
+ Mirrors the single-source snap_clip_ends_to_lines + snap_clips_off_shot_changes.
652
517
  """
653
- clips = plan.get("clips") or []
654
- if not clips:
655
- return plan
656
- allow_overlap = bool(plan.get("allow_overlap", False))
518
+ clips = plan["clips"]
519
+ allow_overlap = plan["allow_overlap"]
657
520
  groups = {}
658
521
  for clip in clips:
659
- groups.setdefault(str(clip.get("source_id")), []).append(clip)
522
+ groups.setdefault(clip["source_id"], []).append(clip)
660
523
  boundary_accum = {
661
524
  "start_snaps": [],
662
525
  "end_snaps": [],
@@ -665,34 +528,24 @@ def snap_multi_source_clips(
665
528
  }
666
529
  blocking_accum = []
667
530
  for sid, group in groups.items():
668
- source = sources.get(sid, {}) if isinstance(sources, dict) else {}
669
- duration = float(source.get("duration") or 0.0) or max(
670
- (float(c["source_end"]) for c in group), default=0.0
671
- )
531
+ source = sources[sid]
532
+ duration = source["duration"]
672
533
  mini = {"clips": [dict(c) for c in group], "allow_overlap": allow_overlap}
673
534
  # Visual cleanup goes first. Sentence/quiet snapping is the final authority because
674
535
  # a clean picture is never allowed to reintroduce a mid-sentence audio cut.
675
- if do_scene_snap and source.get("source_path"):
536
+ if do_scene_snap:
676
537
  mini = snap_clips_off_shot_changes(
677
- mini,
678
- source["source_path"],
679
- video_duration=duration,
680
- margin=scene_margin,
681
- threshold=scene_threshold,
538
+ mini, source["source_path"], margin=scene_margin, threshold=scene_threshold
682
539
  )
683
540
  source_work_dir = source.get("source_work_dir")
684
- silence = _load_silence_for_source(work_dir, sid, source_work_dir)
685
- anchors = _load_sentence_boundary_windows(work_dir, sid, source_work_dir)
686
- boundaries = _combine_boundary_windows(silence, anchors)
541
+ boundaries = _combine_boundary_windows(
542
+ _load_silence_for_source(work_dir, sid, source_work_dir),
543
+ _load_sentence_boundary_windows(work_dir, sid, source_work_dir),
544
+ )
687
545
  if do_line_snap:
688
- if start_max_prepend is not None:
689
- mini = snap_clip_starts_to_lines(
690
- mini,
691
- boundaries,
692
- duration,
693
- start_max_prepend,
694
- max_trim=start_max_trim if start_max_trim is not None else 0.35,
695
- )
546
+ mini = snap_clip_starts_to_lines(
547
+ mini, boundaries, duration, start_max_prepend, max_trim=start_max_trim
548
+ )
696
549
  mini = snap_clip_ends_to_lines(mini, boundaries, duration, line_max_extend)
697
550
  mini = enforce_clip_sentence_boundaries(
698
551
  mini,
@@ -700,46 +553,33 @@ def snap_multi_source_clips(
700
553
  _load_source_speech_spans(work_dir, sid, source_work_dir),
701
554
  duration,
702
555
  )
703
- mini_boundary = (mini.get("qc") or {}).get("boundary_status") or {}
704
- for key in boundary_accum:
705
- boundary_accum[key].extend(
706
- [e for e in mini_boundary.get(key, []) if isinstance(e, dict)]
707
- )
708
- blocking_accum.extend(
709
- [
710
- row
711
- for row in ((mini.get("qc") or {}).get("blocking") or [])
712
- if isinstance(row, dict)
713
- ]
714
- )
556
+ mini_boundary = mini["qc"]["boundary_status"]
557
+ for key, events in boundary_accum.items():
558
+ events.extend(mini_boundary.get(key, []))
559
+ blocking_accum.extend(mini["qc"].get("blocking", []))
715
560
  for original, snapped in zip(group, mini["clips"]):
716
561
  original["source_start"] = snapped["source_start"]
717
562
  original["source_end"] = snapped["source_end"]
718
563
  # Recompute the global output timeline cursor-based, in plan order (not group order).
719
564
  plan["total_duration"] = _recompute_clip_timeline(clips)
720
565
 
721
- # Preserve per-source boundary QC as the single validated-plan QC source.
722
- if any(boundary_accum.values()):
723
- qc = plan.setdefault("qc", {})
724
- boundary = qc.setdefault("boundary_status", {})
725
- for key, events in boundary_accum.items():
726
- if events:
727
- boundary.setdefault(key, []).extend(events)
728
- warnings = qc.setdefault("warnings", [])
729
- for event in boundary_accum["start_snaps"]:
730
- if event.get("warning_code"):
731
- warnings.append(
732
- {
733
- "code": event["warning_code"],
734
- "clip_id": event.get("clip_id"),
735
- "source_id": event.get("source_id"),
736
- "start_unsnapped_reason": event.get("start_unsnapped_reason"),
737
- }
738
- )
739
- # Blocking rows are propagated unconditionally. They used to sit inside the
740
- # `any(boundary_accum.values())` branch, which happens to hold today only because
741
- # enforce_clip_sentence_boundaries always emits sentence_checks — a mid-sentence cut
742
- # must never be silently dropped because some sibling QC list came back empty.
566
+ qc = plan.setdefault("qc", {})
567
+ boundary = qc.setdefault("boundary_status", {})
568
+ for key, events in boundary_accum.items():
569
+ if events:
570
+ boundary.setdefault(key, []).extend(events)
571
+ warnings = qc.setdefault("warnings", [])
572
+ for event in boundary_accum["start_snaps"]:
573
+ if "warning_code" in event:
574
+ warnings.append(
575
+ {
576
+ "code": event["warning_code"],
577
+ "clip_id": event["clip_id"],
578
+ "source_id": event["source_id"],
579
+ "start_unsnapped_reason": event["start_unsnapped_reason"],
580
+ }
581
+ )
582
+ # A mid-sentence cut must never be dropped because some sibling QC list came back empty.
743
583
  if blocking_accum:
744
- plan.setdefault("qc", {}).setdefault("blocking", []).extend(blocking_accum)
584
+ qc.setdefault("blocking", []).extend(blocking_accum)
745
585
  return plan