@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
@@ -111,9 +111,7 @@ def main():
111
111
  # implements padding — and it used to read the CLI flag alone, so setting the env var did
112
112
  # nothing at all while `clip_padding_source: "env"` reported otherwise. CLI still wins.
113
113
  clip_padding = (
114
- args.clip_padding
115
- if args.clip_padding is not None
116
- else float(CONFIG.get("clip_padding", 0.0) or 0.0)
114
+ args.clip_padding if args.clip_padding is not None else CONFIG["clip_padding"]
117
115
  )
118
116
 
119
117
  work_dir = Path(args.work_dir)
@@ -153,34 +151,32 @@ def main():
153
151
  # Keep boundaries off the original footage's hard cuts (avoids 闪烁 at the edit point).
154
152
  # This visual-only pass runs FIRST. The sentence/quiet pass below is the final authority:
155
153
  # a prettier edit point must never move the final boundary back inside a spoken sentence.
156
- if sources_manifest is None and CONFIG.get("scene_cut_snap", True):
154
+ if sources_manifest is None and CONFIG["scene_cut_snap"]:
157
155
  validated_plan = snap_clips_off_shot_changes(
158
156
  validated_plan,
159
157
  args.video,
160
- video_duration=video_duration,
161
- margin=CONFIG.get("scene_cut_snap_margin", 0.5),
162
- threshold=CONFIG.get("scene_cut_detect_threshold", 0.4),
158
+ margin=CONFIG["scene_cut_snap_margin"],
159
+ threshold=CONFIG["scene_cut_detect_threshold"],
163
160
  )
164
161
 
165
162
  if sources_manifest is None:
166
- silence_periods = _load_silence_for_source(work_dir, None)
167
- sentence_boundaries = _load_sentence_boundary_windows(work_dir)
168
163
  safe_boundaries = _combine_boundary_windows(
169
- silence_periods, sentence_boundaries
164
+ _load_silence_for_source(work_dir, None),
165
+ _load_sentence_boundary_windows(work_dir),
170
166
  )
171
- if CONFIG.get("snap_clip_line_end", True):
167
+ if CONFIG["snap_clip_line_end"]:
172
168
  validated_plan = snap_clip_starts_to_lines(
173
169
  validated_plan,
174
170
  safe_boundaries,
175
171
  video_duration,
176
- CONFIG.get("clip_start_snap_max_prepend", 1.8),
177
- max_trim=CONFIG.get("clip_start_snap_max_trim", 0.35),
172
+ CONFIG["clip_start_snap_max_prepend"],
173
+ max_trim=CONFIG["clip_start_snap_max_trim"],
178
174
  )
179
175
  validated_plan = snap_clip_ends_to_lines(
180
176
  validated_plan,
181
177
  safe_boundaries,
182
178
  video_duration,
183
- CONFIG.get("clip_snap_max_extend", 2.0),
179
+ CONFIG["clip_snap_max_extend"],
184
180
  )
185
181
  validated_plan = enforce_clip_sentence_boundaries(
186
182
  validated_plan,
@@ -194,54 +190,50 @@ def main():
194
190
  if sources_manifest is not None:
195
191
  validated_plan = snap_multi_source_clips(
196
192
  validated_plan,
197
- validated_plan.get("sources", {}),
193
+ validated_plan["sources"],
198
194
  work_dir,
199
- line_max_extend=CONFIG.get("clip_snap_max_extend", 2.0),
200
- scene_margin=CONFIG.get("scene_cut_snap_margin", 0.5),
201
- scene_threshold=CONFIG.get("scene_cut_detect_threshold", 0.4),
202
- do_line_snap=CONFIG.get("snap_clip_line_end", True),
203
- do_scene_snap=CONFIG.get("scene_cut_snap", True),
204
- start_max_prepend=CONFIG.get("clip_start_snap_max_prepend", 1.8),
205
- start_max_trim=CONFIG.get("clip_start_snap_max_trim", 0.35),
195
+ line_max_extend=CONFIG["clip_snap_max_extend"],
196
+ scene_margin=CONFIG["scene_cut_snap_margin"],
197
+ scene_threshold=CONFIG["scene_cut_detect_threshold"],
198
+ do_line_snap=CONFIG["snap_clip_line_end"],
199
+ do_scene_snap=CONFIG["scene_cut_snap"],
200
+ start_max_prepend=CONFIG["clip_start_snap_max_prepend"],
201
+ start_max_trim=CONFIG["clip_start_snap_max_trim"],
206
202
  )
207
203
 
208
- if isinstance(validated_plan, dict):
209
- validated_plan["raw_plan_fingerprint"] = value_fingerprint(raw_plan)
210
- validated_plan.setdefault("qc", {})["join_fade_ms"] = round(
211
- max(0.0, float(CONFIG.get("clip_join_audio_fade_ms", 30.0) or 0.0)), 3
212
- )
213
- source_paths = []
214
- for clip in validated_plan.get("clips", []):
215
- source_path = str(clip.get("source_path") or "")
216
- if source_path and source_path not in source_paths:
217
- source_paths.append(source_path)
218
- if not source_paths:
219
- source_paths = [str(args.video)]
220
- _, _, _, geometry_qc = _select_output_geometry(
221
- source_paths, validated_plan.get("clips", [])
222
- )
223
- validated_plan["qc"]["output_geometry"] = geometry_qc
224
- validated_plan["qc"]["output_geometry_reason"] = geometry_qc.get("reason")
225
- allow_duration_drift = bool(args.allow_duration_drift or args.allow_sparse_cut)
226
- drift_source = (
227
- "--allow-duration-drift"
228
- if args.allow_duration_drift
229
- else ("--allow-sparse-cut" if args.allow_sparse_cut else None)
230
- )
231
- update_cut_qc(
232
- validated_plan,
233
- allow_duration_drift=allow_duration_drift,
234
- duration_drift_allowed_by=drift_source,
235
- )
236
- update_delivery_qc(
237
- validated_plan,
238
- source_paths=source_paths,
239
- output_path=work_dir / "edited_source.mp4",
204
+ validated_plan["raw_plan_fingerprint"] = value_fingerprint(raw_plan)
205
+ validated_plan.setdefault("qc", {})["join_fade_ms"] = round(
206
+ CONFIG["clip_join_audio_fade_ms"], 3
207
+ )
208
+ # Single-source clips carry no source_path; the CLI video is the only input.
209
+ source_paths = list(
210
+ dict.fromkeys(
211
+ clip["source_path"] for clip in validated_plan["clips"] if "source_path" in clip
240
212
  )
213
+ ) or [str(args.video)]
214
+ _, _, _, geometry_qc = _select_output_geometry(source_paths, validated_plan["clips"])
215
+ validated_plan["qc"]["output_geometry"] = geometry_qc
216
+ validated_plan["qc"]["output_geometry_reason"] = geometry_qc["reason"]
217
+ allow_duration_drift = bool(args.allow_duration_drift or args.allow_sparse_cut)
218
+ drift_source = (
219
+ "--allow-duration-drift"
220
+ if args.allow_duration_drift
221
+ else ("--allow-sparse-cut" if args.allow_sparse_cut else None)
222
+ )
223
+ update_cut_qc(
224
+ validated_plan,
225
+ allow_duration_drift=allow_duration_drift,
226
+ duration_drift_allowed_by=drift_source,
227
+ )
228
+ update_delivery_qc(
229
+ validated_plan,
230
+ source_paths=source_paths,
231
+ output_path=work_dir / "edited_source.mp4",
232
+ )
241
233
  (work_dir / "clip_plan_validated.json").write_text(
242
234
  json.dumps(validated_plan, ensure_ascii=False, indent=2), encoding="utf-8"
243
235
  )
244
- if (validated_plan.get("qc") or {}).get("blocking"):
236
+ if validated_plan["qc"].get("blocking"):
245
237
  raise SystemExit(
246
238
  "clip_plan QC blocking: fix unsafe sentence boundaries or target-duration drift. "
247
239
  "Only duration drift can be explicitly accepted with --allow-duration-drift; "
@@ -305,12 +297,12 @@ def main():
305
297
  )
306
298
  for w in report["warnings"]:
307
299
  log(f" ⚠️ 剪后解说同步: {w['message']} [{w['code']}]")
308
- if report.get("clamped_count"):
300
+ if report["clamped_count"]:
309
301
  raise SystemExit(
310
302
  "剪后解说有句子被 clip 边界裁断。移动 clip_plan 边界或重写整句后重跑;"
311
303
  "--allow-sparse-cut 不能跳过语句完整性门禁。详见 narration_mapped_lint.json"
312
304
  )
313
- if report.get("blocking") and not args.allow_sparse_cut:
305
+ if report["blocking"] and not args.allow_sparse_cut:
314
306
  raise SystemExit(
315
307
  "剪后解说与保留片段对不上:丢弃过多或成片过稀疏。改 narration.json / clip_plan.json "
316
308
  "让解说落在保留片段内后重跑,或加 --allow-sparse-cut 接受当前映射。详见 narration_mapped_lint.json"
@@ -114,14 +114,11 @@ def value_fingerprint(value):
114
114
 
115
115
  def cut_plan_fingerprint(validated_plan):
116
116
  """Hash the exact normalized clip plan that determines edited_source.mp4 bytes."""
117
- if isinstance(validated_plan, dict):
118
- payload = dict(validated_plan)
119
- # Provenance for raw-plan freshness is not part of the edited media bytes.
120
- payload.pop("raw_plan_fingerprint", None)
121
- # QC is observability derived from the media plan, not an input range decision.
122
- payload.pop("qc", None)
123
- else:
124
- payload = validated_plan
117
+ payload = dict(validated_plan)
118
+ # Provenance for raw-plan freshness is not part of the edited media bytes.
119
+ payload.pop("raw_plan_fingerprint", None)
120
+ # QC is observability derived from the media plan, not an input range decision.
121
+ payload.pop("qc", None)
125
122
  return value_fingerprint(payload)
126
123
 
127
124
 
@@ -169,26 +166,19 @@ def _load_edited_source_meta(output_path):
169
166
  if not meta_path.exists():
170
167
  return None
171
168
  try:
172
- data = json.loads(meta_path.read_text(encoding="utf-8"))
169
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
173
170
  except (OSError, ValueError):
174
171
  return None
175
- return data if isinstance(data, dict) else None
172
+ return meta if isinstance(meta, dict) else None
176
173
 
177
174
 
178
175
  def _source_fingerprints_for_plan(validated_plan, input_video=None):
179
176
  """Fingerprint every media file that can affect edited_source.mp4 bytes."""
180
- paths = []
181
- for clip in (
182
- validated_plan.get("clips", []) if isinstance(validated_plan, dict) else []
183
- ):
184
- if clip.get("source_path"):
185
- paths.append(str(clip["source_path"]))
177
+ # Single-source plans carry no per-clip source_path; the CLI video is the only input.
178
+ paths = {clip["source_path"] for clip in validated_plan["clips"] if "source_path" in clip}
186
179
  if not paths and input_video is not None:
187
- paths.append(str(input_video))
188
- fingerprints = {}
189
- for path in sorted(set(paths)):
190
- fingerprints[str(Path(path))] = file_fingerprint(path)
191
- return fingerprints
180
+ paths.add(str(input_video))
181
+ return {str(Path(path)): file_fingerprint(path) for path in sorted(paths)}
192
182
 
193
183
 
194
184
  def edited_source_render_cache_payload():
@@ -200,9 +190,7 @@ def edited_source_render_cache_payload():
200
190
  return {
201
191
  "render_algorithm_version": EDITED_SOURCE_RENDER_ALGORITHM_VERSION,
202
192
  "geometry_render_algorithm_version": GEOMETRY_RENDER_ALGORITHM_VERSION,
203
- "clip_join_audio_fade_ms": round(
204
- max(0.0, float(CONFIG.get("clip_join_audio_fade_ms", 30.0) or 0.0)), 3
205
- ),
193
+ "clip_join_audio_fade_ms": round(CONFIG["clip_join_audio_fade_ms"], 3),
206
194
  }
207
195
 
208
196
 
@@ -211,38 +199,17 @@ def edited_source_render_fingerprint():
211
199
 
212
200
 
213
201
  def _write_edited_source_meta(output_path, validated_plan, input_video=None):
214
- meta_path = _edited_source_meta_path(output_path)
215
- source_fingerprints = _source_fingerprints_for_plan(validated_plan, input_video)
216
- has_plan_sources = any(
217
- clip.get("source_path")
218
- for clip in (
219
- validated_plan.get("clips", []) if isinstance(validated_plan, dict) else []
220
- )
221
- )
222
- legacy_source_fp = (
223
- file_fingerprint(input_video)
224
- if input_video is not None
225
- and not has_plan_sources
226
- and len(source_fingerprints) == 1
227
- else None
228
- )
229
202
  meta = {
230
203
  "schema_version": 2,
231
204
  "clip_plan_fingerprint": cut_plan_fingerprint(validated_plan),
232
205
  "render_fingerprint": edited_source_render_fingerprint(),
233
206
  "render_cache": edited_source_render_cache_payload(),
234
- "source_fingerprints": source_fingerprints,
207
+ "source_fingerprints": _source_fingerprints_for_plan(validated_plan, input_video),
235
208
  "edited_source_fingerprint": file_fingerprint(output_path),
236
- "total_duration": validated_plan.get("total_duration"),
237
- "clip_count": len(validated_plan.get("clips", [])),
209
+ "total_duration": validated_plan["total_duration"],
210
+ "clip_count": len(validated_plan["clips"]),
238
211
  }
239
- delivery_qc = (validated_plan.get("qc") or {}).get("delivery_qc")
240
- if delivery_qc:
241
- meta["delivery_qc"] = delivery_qc
242
- # Preserve the legacy key for existing single-source callers/tests/metadata readers.
243
- if legacy_source_fp is not None:
244
- meta["source_video_fingerprint"] = legacy_source_fp
245
- meta_path.write_text(
212
+ _edited_source_meta_path(output_path).write_text(
246
213
  json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
247
214
  )
248
215
 
@@ -253,20 +220,20 @@ def should_reuse_edited_source(output_path, validated_plan, input_video=None):
253
220
  if not output_path.exists():
254
221
  return False
255
222
  meta = _load_edited_source_meta(output_path)
256
- if not meta or meta.get("clip_plan_fingerprint") != cut_plan_fingerprint(
257
- validated_plan
223
+ if meta is None:
224
+ return False
225
+ if (
226
+ meta.get("clip_plan_fingerprint") != cut_plan_fingerprint(validated_plan)
227
+ or meta.get("render_fingerprint") != edited_source_render_fingerprint()
228
+ or meta.get("source_fingerprints")
229
+ != _source_fingerprints_for_plan(validated_plan, input_video)
258
230
  ):
259
231
  return False
260
- if meta.get("render_fingerprint") != edited_source_render_fingerprint():
232
+ try:
233
+ output_fingerprint = file_fingerprint(output_path)
234
+ except OSError:
261
235
  return False
262
- expected_sources = _source_fingerprints_for_plan(validated_plan, input_video)
263
- meta_sources = meta.get("source_fingerprints")
264
- if meta_sources is None and input_video is not None:
265
- meta_sources = {str(Path(input_video)): meta.get("source_video_fingerprint")}
266
- return bool(
267
- meta_sources == expected_sources
268
- and meta.get("edited_source_fingerprint") == file_fingerprint(output_path)
269
- )
236
+ return meta.get("edited_source_fingerprint") == output_fingerprint
270
237
 
271
238
 
272
239
  def _manifest_source_entries(sources_manifest):
@@ -313,9 +280,11 @@ def normalize_sources_manifest(sources_manifest):
313
280
  if duration in (None, ""):
314
281
  duration = get_video_duration(source_path)
315
282
  try:
316
- duration = max(0.0, float(duration or 0.0))
283
+ duration = float(duration)
317
284
  except (TypeError, ValueError) as exc:
318
285
  raise ValueError(f"source {source_id} has invalid duration") from exc
286
+ if duration <= 0:
287
+ raise ValueError(f"source {source_id} has invalid duration")
319
288
  sources[source_id] = {
320
289
  "source_id": source_id,
321
290
  "source_path": str(source_path),
@@ -358,8 +327,8 @@ def normalize_multi_source_clip_plan(
358
327
  if not isinstance(raw_clips, list):
359
328
  raise ValueError("clip_plan.json field `clips` must be an array")
360
329
 
361
- padding = max(0.0, float(clip_padding or 0.0))
362
- min_duration = max(0.05, float(min_clip_duration or 0.05))
330
+ padding = max(0.0, clip_padding)
331
+ min_duration = max(0.05, min_clip_duration)
363
332
  clips = []
364
333
  source_ranges = {}
365
334
  cursor = 0.0
@@ -422,9 +391,7 @@ def normalize_multi_source_clip_plan(
422
391
  plan = {
423
392
  "clips": clips,
424
393
  "total_duration": total_duration,
425
- "target_duration": round(float(target_duration), 3)
426
- if target_duration
427
- else None,
394
+ "target_duration": round(target_duration, 3) if target_duration else None,
428
395
  "sources": {
429
396
  sid: {
430
397
  "source_path": s["source_path"],
@@ -442,7 +409,7 @@ def normalize_multi_source_clip_plan(
442
409
  if target_duration and total_duration > target_duration * 1.15:
443
410
  plan["warning"] = (
444
411
  f"validated clips total {total_duration:.1f}s exceeds target "
445
- f"{float(target_duration):.1f}s by more than 15%"
412
+ f"{target_duration:.1f}s by more than 15%"
446
413
  )
447
414
  log(f"警告: {plan['warning']}")
448
415
  return plan
@@ -478,9 +445,8 @@ def normalize_clip_plan(
478
445
  if not isinstance(raw_clips, list):
479
446
  raise ValueError("clip_plan.json field `clips` must be an array")
480
447
 
481
- video_duration = max(0.0, float(video_duration or 0.0))
482
- padding = max(0.0, float(clip_padding or 0.0))
483
- min_duration = max(0.05, float(min_clip_duration or 0.05))
448
+ padding = max(0.0, clip_padding)
449
+ min_duration = max(0.05, min_clip_duration)
484
450
  clips = []
485
451
  source_ranges = []
486
452
  cursor = 0.0
@@ -530,16 +496,14 @@ def normalize_clip_plan(
530
496
  plan = {
531
497
  "clips": clips,
532
498
  "total_duration": total_duration,
533
- "target_duration": round(float(target_duration), 3)
534
- if target_duration
535
- else None,
499
+ "target_duration": round(target_duration, 3) if target_duration else None,
536
500
  "source_duration": round(video_duration, 3),
537
501
  "allow_overlap": bool(allow_overlap),
538
502
  }
539
503
  if target_duration and total_duration > target_duration * 1.15:
540
504
  plan["warning"] = (
541
505
  f"validated clips total {total_duration:.1f}s exceeds target "
542
- f"{float(target_duration):.1f}s by more than 15%"
506
+ f"{target_duration:.1f}s by more than 15%"
543
507
  )
544
508
  log(f"警告: {plan['warning']}")
545
509
  return plan
@@ -13,25 +13,11 @@ from sentence_boundaries import _continuous_source_join
13
13
 
14
14
 
15
15
  def _audio_segment_filter(
16
- label_in,
17
- label_out,
18
- start,
19
- end,
20
- duration,
21
- fade_ms,
22
- extra_filters="",
23
- fade_in_ms=None,
24
- fade_out_ms=None,
16
+ label_in, label_out, start, end, duration, fade_in_ms, fade_out_ms, extra_filters=""
25
17
  ):
26
- max_fade = max(0.0, float(duration or 0.0)) / 2
27
- fade_in = max(
28
- 0.0,
29
- min(float(fade_ms if fade_in_ms is None else fade_in_ms) / 1000.0, max_fade),
30
- )
31
- fade_out = max(
32
- 0.0,
33
- min(float(fade_ms if fade_out_ms is None else fade_out_ms) / 1000.0, max_fade),
34
- )
18
+ max_fade = duration / 2
19
+ fade_in = max(0.0, min(fade_in_ms / 1000.0, max_fade))
20
+ fade_out = max(0.0, min(fade_out_ms / 1000.0, max_fade))
35
21
  base = f"{label_in}atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS"
36
22
  if fade_in > 0:
37
23
  base += f",afade=t=in:st=0:d={fade_in:.3f}"
@@ -57,13 +43,12 @@ def _clip_audio_edge_fades(clips, idx, fade_ms):
57
43
  return fade_in, fade_out
58
44
 
59
45
 
60
- def _write_filter_script(filter_complex, work_dir):
61
- script_path = Path(work_dir) / "edit_filter_complex.txt"
62
- script_path.write_text(filter_complex, encoding="utf-8")
63
- return script_path
64
-
65
-
66
46
  def _probe_audio_sample_rate(video_path):
47
+ """Sample rate of the first audio stream, or None when it cannot be observed.
48
+
49
+ Delivery QC is observational and runs even on a plan that was never rendered, so an
50
+ ffprobe that is absent (OSError) reads the same as one that reports no audio stream.
51
+ """
67
52
  cmd = [
68
53
  "ffprobe",
69
54
  "-v",
@@ -78,46 +63,33 @@ def _probe_audio_sample_rate(video_path):
78
63
  ]
79
64
  try:
80
65
  result = run_cmd(cmd)
81
- except Exception: # noqa: BLE001 - delivery QC is observational
66
+ except OSError:
82
67
  return None
83
- if result.returncode != 0:
84
- return None
85
- try:
86
- return int(float((result.stdout or "").strip().splitlines()[0]))
87
- except (IndexError, TypeError, ValueError):
68
+ text = result.stdout.strip()
69
+ if result.returncode != 0 or not text:
88
70
  return None
71
+ return int(float(text))
89
72
 
90
73
 
91
74
  def _delivery_reencode_reason(source_paths, clips):
92
75
  reasons = ["trim_concat_filter_requires_reencode"]
93
- if len(source_paths or []) > 1:
76
+ if len(source_paths) > 1:
94
77
  reasons.append("multi_source_geometry_audio_normalization")
95
- if any(not clip.get("source_path") for clip in clips or []):
78
+ if any("source_path" not in clip for clip in clips):
96
79
  reasons.append("single_source_filter_concat_no_stream_copy")
97
80
  return "+".join(reasons)
98
81
 
99
82
 
100
- def update_delivery_qc(
101
- validated_plan, *, source_paths=None, output_path=None, rendered=False
102
- ):
83
+ def update_delivery_qc(validated_plan, *, source_paths, output_path=None, rendered=False):
103
84
  """Attach cut delivery facts to qc.delivery_qc without writing visual_qc."""
104
- qc = validated_plan.setdefault("qc", {})
105
- clips = validated_plan.get("clips") or []
106
- if source_paths is None:
107
- source_paths = sorted(
108
- {
109
- str(clip.get("source_path") or "")
110
- for clip in clips
111
- if str(clip.get("source_path") or "")
112
- }
113
- )
85
+ qc = validated_plan["qc"]
86
+ clips = validated_plan["clips"]
114
87
  target_sample_rate = 48000
115
88
  probed_sample_rate = (
116
89
  _probe_audio_sample_rate(output_path)
117
90
  if output_path and Path(output_path).exists()
118
91
  else None
119
92
  )
120
- output_geometry = qc.get("output_geometry")
121
93
  delivery_qc = {
122
94
  "schema_version": 1,
123
95
  "video_encode_passes": 1,
@@ -135,9 +107,9 @@ def update_delivery_qc(
135
107
  "audio encoded as AAC with 48000 Hz target for delivery compatibility",
136
108
  "edited_source.mp4 is an intermediate; downstream assembly may perform another intentional encode",
137
109
  ],
138
- "output_geometry": output_geometry,
139
- "rendered": bool(rendered),
140
- "planned": not bool(rendered),
110
+ "output_geometry": qc["output_geometry"],
111
+ "rendered": rendered,
112
+ "planned": not rendered,
141
113
  }
142
114
  if probed_sample_rate and probed_sample_rate != target_sample_rate:
143
115
  delivery_qc["final_compat_notes"].append(
@@ -148,12 +120,10 @@ def update_delivery_qc(
148
120
 
149
121
 
150
122
  def write_cut_delivery_qc(work_dir, validated_plan):
151
- delivery_qc = (validated_plan.get("qc") or {}).get("delivery_qc")
152
- if not delivery_qc or not delivery_qc.get("rendered"):
153
- return None
154
123
  path = Path(work_dir) / "cut_delivery_qc.json"
155
124
  path.write_text(
156
- json.dumps(delivery_qc, ensure_ascii=False, indent=2), encoding="utf-8"
125
+ json.dumps(validated_plan["qc"]["delivery_qc"], ensure_ascii=False, indent=2),
126
+ encoding="utf-8",
157
127
  )
158
128
  return path
159
129
 
@@ -163,23 +133,21 @@ def build_edited_source_video(input_video, validated_plan, work_dir, output_path
163
133
  work_dir = Path(work_dir)
164
134
  output_path = Path(output_path or work_dir / "edited_source.mp4")
165
135
  clips = validated_plan["clips"]
166
- if not clips:
167
- raise ValueError("validated clip plan has no clips")
168
136
 
169
137
  source_paths = []
170
138
  for clip in clips:
171
- source_path = str(clip.get("source_path") or input_video)
139
+ source_path = clip.get("source_path", str(input_video))
172
140
  if source_path not in source_paths:
173
141
  source_paths.append(source_path)
174
142
  source_index = {path: idx for idx, path in enumerate(source_paths)}
175
143
  audio_by_input = {path: _has_audio_stream(path) for path in source_paths}
176
- join_fade_ms = max(0.0, float(CONFIG.get("clip_join_audio_fade_ms", 30.0) or 0.0))
144
+ join_fade_ms = CONFIG["clip_join_audio_fade_ms"]
177
145
  qc = validated_plan.setdefault("qc", {})
178
146
  qc["join_fade_ms"] = round(join_fade_ms, 3)
179
- if not qc.get("output_geometry"):
147
+ if "output_geometry" not in qc:
180
148
  _, _, _, geometry_qc = _select_output_geometry(source_paths, clips)
181
149
  qc["output_geometry"] = geometry_qc
182
- qc["output_geometry_reason"] = geometry_qc.get("reason")
150
+ qc["output_geometry_reason"] = geometry_qc["reason"]
183
151
 
184
152
  parts = []
185
153
  concat_inputs = []
@@ -190,23 +158,12 @@ def build_edited_source_video(input_video, validated_plan, work_dir, output_path
190
158
  # segment to one canvas and give every clip an audio segment (real or synthesized
191
159
  # silence) so concat always succeeds with a continuous track and no source's audio
192
160
  # is dropped just because a sibling source is silent.
193
- # Reuse the geometry already probed+stored above (guard) or by main() — the
194
- # selection is deterministic, so re-probing every source here is wasted ffprobe work.
195
- geometry_qc = qc.get("output_geometry")
196
- if isinstance(geometry_qc, dict) and all(
197
- geometry_qc.get(k) for k in ("width", "height", "fps")
198
- ):
199
- canvas_w, canvas_h, canvas_fps = (
200
- int(geometry_qc["width"]),
201
- int(geometry_qc["height"]),
202
- geometry_qc["fps"],
203
- )
204
- else:
205
- canvas_w, canvas_h, canvas_fps, geometry_qc = _select_output_geometry(
206
- source_paths, clips
207
- )
208
- qc["output_geometry"] = geometry_qc
209
- qc["output_geometry_reason"] = geometry_qc.get("reason")
161
+ geometry_qc = qc["output_geometry"]
162
+ canvas_w, canvas_h, canvas_fps = (
163
+ geometry_qc["width"],
164
+ geometry_qc["height"],
165
+ geometry_qc["fps"],
166
+ )
210
167
  vnorm = (
211
168
  f"scale={canvas_w}:{canvas_h}:force_original_aspect_ratio=decrease,"
212
169
  f"pad={canvas_w}:{canvas_h}:(ow-iw)/2:(oh-ih)/2,setsar=1,"
@@ -214,18 +171,16 @@ def build_edited_source_video(input_video, validated_plan, work_dir, output_path
214
171
  )
215
172
  for clip_pos, clip in enumerate(clips):
216
173
  idx = clip["clip_id"]
217
- clip_source = str(clip.get("source_path") or input_video)
174
+ clip_source = clip.get("source_path", str(input_video))
218
175
  input_idx = source_index[clip_source]
219
176
  start = clip["source_start"]
220
177
  end = clip["source_end"]
221
- dur = max(0.0, float(end) - float(start))
222
- fade_in_ms, fade_out_ms = _clip_audio_edge_fades(
223
- clips, clip_pos, join_fade_ms
224
- )
178
+ dur = end - start
179
+ fade_in_ms, fade_out_ms = _clip_audio_edge_fades(clips, clip_pos, join_fade_ms)
225
180
  parts.append(
226
181
  f"[{input_idx}:v]trim=start={start:.3f}:end={end:.3f},setpts=PTS-STARTPTS,{vnorm}[v{idx}]"
227
182
  )
228
- if audio_by_input.get(clip_source):
183
+ if audio_by_input[clip_source]:
229
184
  parts.append(
230
185
  _audio_segment_filter(
231
186
  f"[{input_idx}:a]",
@@ -233,10 +188,9 @@ def build_edited_source_video(input_video, validated_plan, work_dir, output_path
233
188
  start,
234
189
  end,
235
190
  dur,
236
- join_fade_ms,
191
+ fade_in_ms,
192
+ fade_out_ms,
237
193
  extra_filters="aresample=48000,aformat=sample_rates=48000:channel_layouts=stereo",
238
- fade_in_ms=fade_in_ms,
239
- fade_out_ms=fade_out_ms,
240
194
  )
241
195
  )
242
196
  else:
@@ -251,7 +205,7 @@ def build_edited_source_video(input_video, validated_plan, work_dir, output_path
251
205
  has_audio = all(audio_by_input.values())
252
206
  for clip_pos, clip in enumerate(clips):
253
207
  idx = clip["clip_id"]
254
- input_idx = source_index[str(clip.get("source_path") or input_video)]
208
+ input_idx = source_index[clip.get("source_path", str(input_video))]
255
209
  start = clip["source_start"]
256
210
  end = clip["source_end"]
257
211
  parts.append(
@@ -268,10 +222,9 @@ def build_edited_source_video(input_video, validated_plan, work_dir, output_path
268
222
  f"[a{idx}]",
269
223
  start,
270
224
  end,
271
- max(0.0, float(end) - float(start)),
272
- join_fade_ms,
273
- fade_in_ms=fade_in_ms,
274
- fade_out_ms=fade_out_ms,
225
+ end - start,
226
+ fade_in_ms,
227
+ fade_out_ms,
275
228
  )
276
229
  )
277
230
  concat_inputs.append(f"[a{idx}]")
@@ -282,23 +235,21 @@ def build_edited_source_video(input_video, validated_plan, work_dir, output_path
282
235
  )
283
236
  maps = ["-map", "[v]", "-map", "[a]"]
284
237
  else:
285
- total = validated_plan.get("total_duration") or sum(
286
- c["duration"] for c in clips
287
- )
288
238
  parts.append("".join(concat_inputs) + f"concat=n={len(clips)}:v=1:a=0[v]")
289
239
  maps = ["-map", "[v]", "-map", f"{len(source_paths)}:a", "-shortest"]
290
240
  extra_inputs = [
291
241
  "-f",
292
242
  "lavfi",
293
243
  "-t",
294
- f"{float(total):.3f}",
244
+ f"{validated_plan['total_duration']:.3f}",
295
245
  "-i",
296
246
  "anullsrc=channel_layout=stereo:sample_rate=48000",
297
247
  ]
298
248
 
299
249
  filter_complex = ";".join(parts)
300
250
  if len(filter_complex.encode("utf-8")) > 7000:
301
- filter_script = _write_filter_script(filter_complex, work_dir)
251
+ filter_script = work_dir / "edit_filter_complex.txt"
252
+ filter_script.write_text(filter_complex, encoding="utf-8")
302
253
  filter_args = ["-filter_complex_script", str(filter_script)]
303
254
  else:
304
255
  filter_args = ["-filter_complex", filter_complex]