@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
@@ -59,7 +59,7 @@ def _explicit_source_video():
59
59
  """Return the cut-mode source video only when the caller opted in explicitly."""
60
60
  if not CONFIG.get("source_video_explicit", False):
61
61
  return ""
62
- return str(CONFIG.get("source_video", "") or "").strip()
62
+ return CONFIG["source_video"]
63
63
 
64
64
 
65
65
  def _source_video_identity():
@@ -72,17 +72,12 @@ def _source_video_identity():
72
72
 
73
73
  def _timeline_provenance_status(work_dir):
74
74
  data = _load_work_json(work_dir, "timeline.json")
75
- if not isinstance(data, dict):
76
- return None
77
- provenance = data.get("provenance")
78
- return provenance if isinstance(provenance, dict) else None
75
+ return None if data is None else data["provenance"]
79
76
 
80
77
 
81
78
  def _load_work_json(work_dir, name):
79
+ """Parse a JSON artifact in work_dir; None only when the file does not exist."""
82
80
  path = Path(work_dir) / name
83
81
  if not path.exists():
84
82
  return None
85
- try:
86
- return json.loads(path.read_text(encoding="utf-8"))
87
- except (ValueError, OSError):
88
- return None
83
+ return json.loads(path.read_text(encoding="utf-8"))
@@ -34,6 +34,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
34
34
 
35
35
  video_duration = lib.get_video_duration(input_video)
36
36
  canvas = media._probe_canvas(input_video) # drives subtitle PlayRes/scale so 竖屏 text isn't stretched
37
+ burn_subtitles = lib.CONFIG["burn_subtitles"]
37
38
 
38
39
  # 解说整体提速(可选)后,将所有 TTS 片段按时间位置合成到与视频等长的音轨上
39
40
  narration_audio._apply_narration_speed(tts_segments, work_dir)
@@ -53,25 +54,23 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
53
54
  srt_path = subtitle_render._generate_srt(tts_segments, work_dir, video_duration)
54
55
  lib.log(f"字幕文件: {srt_path}")
55
56
  ass_path = None
56
- if lib.CONFIG.get("burn_subtitles", False):
57
+ if burn_subtitles:
57
58
  ass_path = subtitle_render._generate_ass(tts_segments, work_dir, video_duration, canvas)
58
59
  lib.log(f"压制字幕文件: {ass_path}")
59
60
 
60
61
  # 可选 BGM:作为一条独立音轨(input [2:a])混入,旁白处自动压低
61
- bgm_path = lib.CONFIG.get("bgm_path", "")
62
+ bgm_path = lib.CONFIG["bgm_path"]
62
63
  has_bgm = bool(bgm_path) and os.path.exists(bgm_path)
63
64
  if bgm_path and not has_bgm:
64
65
  lib.log(f" ⚠️ BGM 文件不存在,跳过: {bgm_path}")
65
66
  elif has_bgm:
66
- lib.log(f"BGM 铺底: {bgm_path} (音量 {lib.CONFIG.get('bgm_volume', 0.18)},旁白时 {lib.CONFIG.get('bgm_ducking_volume', 0.10)})")
67
+ lib.log(f"BGM 铺底: {bgm_path} (音量 {lib.CONFIG['bgm_volume']},旁白时 {lib.CONFIG['bgm_ducking_volume']})")
67
68
 
68
69
  # 多轨时间线模型(timeline.json):canonical 渲染仍是 ffmpeg,此模型供检视/可选导出
69
- timeline_emit._emit_timeline(input_video, tts_segments, work_dir, video_duration, has_bgm)
70
+ timeline_emit._emit_timeline(input_video, tts_segments, work_dir, video_duration, canvas, has_bgm)
70
71
 
71
72
  overlay_filters, overlay_qc = visual_render._visual_overlay_filters(work_dir, canvas, video_duration)
72
- mask_filter = visual_render._source_subtitle_mask_filter(
73
- canvas, work_dir, tts_segments, video_duration=video_duration
74
- )
73
+ mask_filter = visual_render._source_subtitle_mask_filter(canvas, work_dir, tts_segments, video_duration)
75
74
  visual_qc = visual_render._build_visual_qc(
76
75
  tts_segments,
77
76
  work_dir,
@@ -83,8 +82,8 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
83
82
  visual_render._write_visual_qc(work_dir, visual_qc)
84
83
  assembly_qc_path = Path(work_dir) / constants.ASSEMBLY_QC
85
84
  assembly_qc_path.unlink(missing_ok=True)
86
- if visual_qc.get("blocking"):
87
- codes = ", ".join(visual_qc.get("blocking_codes", []))
85
+ if visual_qc["blocking"]:
86
+ codes = ", ".join(visual_qc["blocking_codes"])
88
87
  raise RuntimeError(f"视觉 QC 失败: {codes};详见 {Path(work_dir) / constants.VISUAL_QC}")
89
88
 
90
89
  # 混合原始音频 + 解说音频(+ 可选 BGM)
@@ -112,9 +111,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
112
111
  # duration=first + -t trim it back to the video length).
113
112
  bgm_input = ["-stream_loop", "-1", "-i", str(bgm_path)] if has_bgm else []
114
113
 
115
- # 对于超长 volume 表达式(多段解说),使用 -filter_complex_script 避免命令行溢出
116
114
  # 末端整体响度归一:ducking 只管相对平衡,这一步统一成片绝对响度
117
- aout_label = "[aout]"
118
115
  loudnorm_measurement = audio_mix._run_loudnorm_first_pass(
119
116
  input_video,
120
117
  narration_wav,
@@ -124,54 +121,42 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
124
121
  work_dir,
125
122
  )
126
123
  final_ln = audio_mix.final_loudnorm_filter(loudnorm_measurement)
127
- if final_ln:
128
- filter_complex += f";[aout]{final_ln}[aoutln]"
129
- aout_label = "[aoutln]"
130
- lib.log(f"成片响度归一: {final_ln}")
124
+ filter_complex += f";[aout]{final_ln}[aoutln]"
125
+ lib.log(f"成片响度归一: {final_ln}")
131
126
 
132
- filter_complex_bytes = filter_complex.encode('utf-8')
133
- if len(filter_complex_bytes) > constants.FILTER_SCRIPT_THRESHOLD_BYTES:
127
+ # 对于超长 volume 表达式(多段解说),使用 -filter_complex_script 避免命令行溢出
128
+ fc_script = None
129
+ if len(filter_complex.encode("utf-8")) > constants.FILTER_SCRIPT_THRESHOLD_BYTES:
134
130
  fc_script = Path(work_dir) / ".filter_complex.txt"
135
131
  fc_script.write_text(filter_complex, encoding="utf-8")
136
- lib.log(f"使用 filter_complex_script (表达式长度 {len(filter_complex_bytes)} bytes)")
137
- cmd = [
138
- "ffmpeg", "-y",
139
- "-i", str(input_video),
140
- "-i", str(narration_wav),
141
- *original_audio_input,
142
- *bgm_input,
143
- "-filter_complex_script", str(fc_script),
144
- # 0:v:0 (not 0:v): sources with attached cover art carry a second video stream.
145
- # -vf only ever applies to the first one, so mapping all of them makes ffmpeg
146
- # abort with "Could not write header (incorrect codec parameters ?)" and leave
147
- # an unreadable file — after the whole pipeline has already run.
148
- "-map", "0:v:0", "-map", aout_label,
149
- ]
132
+ lib.log(f"使用 filter_complex_script (表达式长度 {len(filter_complex.encode('utf-8'))} bytes)")
133
+ filter_args = ["-filter_complex_script", str(fc_script)]
150
134
  else:
151
- cmd = [
152
- "ffmpeg", "-y",
153
- "-i", str(input_video),
154
- "-i", str(narration_wav),
155
- *original_audio_input,
156
- *bgm_input,
157
- "-filter_complex", filter_complex,
158
- # 0:v:0 (not 0:v): sources with attached cover art carry a second video stream.
159
- # -vf only ever applies to the first one, so mapping all of them makes ffmpeg
160
- # abort with "Could not write header (incorrect codec parameters ?)" and leave
161
- # an unreadable file — after the whole pipeline has already run.
162
- "-map", "0:v:0", "-map", aout_label,
163
- ]
135
+ filter_args = ["-filter_complex", filter_complex]
136
+ cmd = [
137
+ "ffmpeg", "-y",
138
+ "-i", str(input_video),
139
+ "-i", str(narration_wav),
140
+ *original_audio_input,
141
+ *bgm_input,
142
+ *filter_args,
143
+ # 0:v:0 (not 0:v): sources with attached cover art carry a second video stream.
144
+ # -vf only ever applies to the first one, so mapping all of them makes ffmpeg
145
+ # abort with "Could not write header (incorrect codec parameters ?)" and leave
146
+ # an unreadable file — after the whole pipeline has already run.
147
+ "-map", "0:v:0", "-map", "[aoutln]",
148
+ ]
164
149
 
165
150
  # Video filter chain: mask source subtitles first (drawbox), then burn our subtitles
166
151
  # on top. Either one forces a re-encode; with neither, the video stream is copied.
167
- crf = str(lib.CONFIG.get("output_crf", 18)) # env_int already clamps to >=0; keep 0 (lossless) intact
168
- preset = str(lib.CONFIG.get("output_preset", "veryfast") or "veryfast")
169
- max_h = int(lib.CONFIG.get("output_max_height", 0) or 0)
152
+ crf = str(lib.CONFIG["output_crf"]) # env_int already clamps to >=0; keep 0 (lossless) intact
153
+ preset = lib.CONFIG["output_preset"]
154
+ max_h = lib.CONFIG["output_max_height"]
170
155
  vf_chain = []
171
156
  if mask_filter:
172
157
  vf_chain.append(mask_filter)
173
158
  vf_chain.extend(overlay_filters)
174
- if lib.CONFIG.get("burn_subtitles", False):
159
+ if burn_subtitles:
175
160
  vf_chain.append(visual_render._subtitle_burn_filter(ass_path))
176
161
  # Downscale LAST so the mask + burned subtitles render at native resolution and are then
177
162
  # scaled down with the frame (crisp). The helper forces both dimensions even so an odd
@@ -183,6 +168,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
183
168
  # needs EVEN width AND height, so normalize odd dims (4:2:2/4:4:4 permit them) before the
184
169
  # encode — otherwise libx264 aborts to a 0-byte file. The downscale helper already evens out.
185
170
  even = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
171
+ reencode = bool(vf_chain) or lib.CONFIG["force_video_reencode"]
186
172
  notes = []
187
173
  video_filter_script = None
188
174
  if vf_chain:
@@ -202,10 +188,10 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
202
188
  cmd += ["-c:v", "libx264", "-preset", preset, "-crf", crf, "-pix_fmt", "yuv420p"]
203
189
  notes = ((["遮挡原字幕"] if mask_filter else [])
204
190
  + ([f"视觉叠加×{len(overlay_filters)}"] if overlay_filters else [])
205
- + (["压制解说字幕"] if lib.CONFIG.get("burn_subtitles", False) else [])
191
+ + (["压制解说字幕"] if burn_subtitles else [])
206
192
  + ([f"缩放≤{max_h}p"] if max_h > 0 else []))
207
193
  lib.log(f"视频重编码: {' + '.join(notes)} (crf={crf}, preset={preset})")
208
- elif lib.CONFIG.get("force_video_reencode", False):
194
+ elif reencode:
209
195
  notes = ["force_video_reencode"]
210
196
  cmd += ["-vf", even, "-c:v", "libx264", "-preset", preset, "-crf", crf, "-pix_fmt", "yuv420p"]
211
197
  else:
@@ -220,8 +206,8 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
220
206
  if result.returncode != 0:
221
207
  raise RuntimeError(f"视频组装失败: {result.stderr}")
222
208
  finally:
223
- # 清理临时 filter_complex 脚本(无论 ffmpeg 是否成功)
224
- if len(filter_complex_bytes) > constants.FILTER_SCRIPT_THRESHOLD_BYTES:
209
+ # 清理临时 filter 脚本(无论 ffmpeg 是否成功)
210
+ if fc_script is not None:
225
211
  fc_script.unlink(missing_ok=True)
226
212
  if video_filter_script is not None:
227
213
  video_filter_script.unlink(missing_ok=True)
@@ -237,10 +223,10 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
237
223
  loudnorm_measurement=loudnorm_measurement,
238
224
  visual_qc=visual_qc,
239
225
  render_delivery={
240
- "video_encode_passes": 1 if (vf_chain or lib.CONFIG.get("force_video_reencode", False)) else 0,
241
- "reencode_reason": notes if (vf_chain or lib.CONFIG.get("force_video_reencode", False)) else [],
226
+ "video_encode_passes": 1 if reencode else 0,
227
+ "reencode_reason": notes,
242
228
  "audio_sample_rate": 48000,
243
- "final_compat_notes": ["yuv420p"] if (vf_chain or lib.CONFIG.get("force_video_reencode", False)) else ["video_copy", "aac_48000", "faststart"],
229
+ "final_compat_notes": ["yuv420p"] if reencode else ["video_copy", "aac_48000", "faststart"],
244
230
  },
245
231
  ),
246
232
  )
@@ -250,7 +236,6 @@ def assemble_video(input_video, tts_segments, work_dir, output_path):
250
236
  def main():
251
237
  import argparse
252
238
  import shutil
253
- from pathlib import Path
254
239
  ap = argparse.ArgumentParser(
255
240
  description="video-assemble: mux narration audio over the video, duck the original, render subtitles.")
256
241
  ap.add_argument("video", help="source video (edited_source.mp4 in cut mode, else the original)")
@@ -294,6 +279,8 @@ def main():
294
279
  if "SUBTITLE_MASK_OPACITY" not in os.environ:
295
280
  lib.CONFIG["subtitle_mask_opacity"] = 1.0
296
281
  if args.source_video:
282
+ if not os.path.exists(args.source_video):
283
+ ap.error(f"--source-video does not exist: {args.source_video}")
297
284
  lib.CONFIG["source_video"] = args.source_video
298
285
  lib.CONFIG["source_video_explicit"] = True
299
286
  else:
@@ -313,9 +300,9 @@ def main():
313
300
  tts_segments = json.loads(tts_meta.read_text(encoding="utf-8"))["segments"]
314
301
  output_path = work_dir / "output.mp4"
315
302
  assemble_video(args.video, tts_segments, work_dir, output_path)
316
- assembly_qc = artifacts._load_work_json(work_dir, constants.ASSEMBLY_QC) or {}
317
- if assembly_qc.get("blocking"):
318
- codes = ", ".join(assembly_qc.get("blocking_codes") or ["unknown"])
303
+ assembly_qc = artifacts._load_work_json(work_dir, constants.ASSEMBLY_QC)
304
+ if assembly_qc["blocking"]:
305
+ codes = ", ".join(assembly_qc["blocking_codes"])
319
306
  raise SystemExit(f"组装 QC 阻断交付: {codes};详见 {work_dir / constants.ASSEMBLY_QC}")
320
307
  stem = args.recap_stem or Path(args.video).stem
321
308
  base = Path(args.output_dir) if args.output_dir else work_dir.parent
@@ -333,7 +320,7 @@ def main():
333
320
 
334
321
  # OPTIONAL, decoupled: export a 剪映 draft from the timeline (lazy import; never
335
322
  # required by the core render path).
336
- if lib.CONFIG.get("export_jianying"):
323
+ if lib.CONFIG["export_jianying"]:
337
324
  from jianying_optional import _maybe_export_jianying
338
325
  _maybe_export_jianying(work_dir, args.jianying_out, stem)
339
326
 
@@ -10,7 +10,6 @@ from assemble_constants import (
10
10
  ASSEMBLY_MANIFEST,
11
11
  ASSEMBLY_QC,
12
12
  SEGMENT_AUDIO_SCHEMA_VERSION,
13
- VISUAL_QC,
14
13
  )
15
14
  from audio_mix import _loudness_mode
16
15
  from artifacts import (
@@ -19,6 +18,12 @@ from artifacts import (
19
18
  _timeline_provenance_status,
20
19
  )
21
20
 
21
+ _AUDIO_QC_CODES = frozenset({
22
+ "missing_narration", "skipped_segments", "no_safe_fit", "effective_tempo_exceeded",
23
+ "empty_narration", "truncated_speech", "unsafe_source_handoff", "timeline_audio_mismatch",
24
+ })
25
+
26
+
22
27
  def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
23
28
  tts_meta_path=None, final_output=None, *, settings_fingerprint):
24
29
  """Slim render record. The orchestrator reads `final_output` to report the result;
@@ -28,34 +33,34 @@ def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
28
33
  output_path = Path(output_path)
29
34
  source_video, source_video_fingerprint = _source_video_identity()
30
35
  qc_path = Path(work_dir) / ASSEMBLY_QC
31
- qc = _load_work_json(work_dir, ASSEMBLY_QC) if qc_path.exists() else None
36
+ qc = _load_work_json(work_dir, ASSEMBLY_QC) or {}
32
37
  payload = {
33
38
  "schema_version": 2,
34
39
  "input_video": str(input_video.resolve()),
35
40
  "source_video": source_video,
36
41
  "source_video_fingerprint": source_video_fingerprint,
37
42
  "tts_meta": str(Path(tts_meta_path).resolve()) if tts_meta_path else None,
38
- "tts_segments": len(tts_segments or []),
43
+ "tts_segments": len(tts_segments),
39
44
  "assembly_settings": settings_fingerprint(work_dir),
40
45
  "output_path": str(output_path.resolve()),
41
46
  "segment_audio_schema_version": SEGMENT_AUDIO_SCHEMA_VERSION,
42
- "qc_path": str(qc_path.resolve()) if qc_path.exists() else None,
43
- "qc_verdict": qc.get("verdict") if isinstance(qc, dict) else None,
44
- "qc_blocking_codes": qc.get("blocking_codes", []) if isinstance(qc, dict) else [],
47
+ "qc_path": str(qc_path.resolve()) if qc else None,
48
+ "qc_verdict": qc.get("verdict"),
49
+ "qc_blocking_codes": qc.get("blocking_codes", []),
45
50
  # The settings fingerprint records the configured/fallback loudness policy; these QC
46
51
  # fields record what the just-finished render actually used after the loudnorm probe.
47
- "qc_loudness_mode": qc.get("loudness_mode") if isinstance(qc, dict) else None,
48
- "qc_loudnorm_measurement": qc.get("loudnorm_measurement") if isinstance(qc, dict) else None,
52
+ "qc_loudness_mode": qc.get("loudness_mode"),
53
+ "qc_loudnorm_measurement": qc.get("loudnorm_measurement"),
49
54
  "audio_segments": [
50
55
  {
51
- "index": seg.get("index"),
56
+ "index": seg["index"],
52
57
  "segment_audio_schema_version": seg.get("segment_audio_schema_version", SEGMENT_AUDIO_SCHEMA_VERSION),
53
- "narration": seg.get("narration", ""),
54
- "spoken_text": seg.get("spoken_text") or seg.get("narration", ""),
55
- "truncated": bool(seg.get("truncated", False)),
58
+ "narration": seg["narration"],
59
+ "spoken_text": seg.get("spoken_text", seg["narration"]),
60
+ "truncated": seg.get("truncated", False),
56
61
  "truncate_reason": seg.get("truncate_reason", "none"),
57
62
  "fit_status": seg.get("fit_status"),
58
- "blocking": bool(seg.get("blocking", False)),
63
+ "blocking": seg.get("blocking", False),
59
64
  "audio_duration": seg.get("audio_duration"),
60
65
  "placed_audio_duration": seg.get("placed_audio_duration"),
61
66
  "placed_audio_path": seg.get("placed_audio_path"),
@@ -72,8 +77,7 @@ def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
72
77
  "rms_dbfs_after": seg.get("rms_dbfs_after"),
73
78
  "peak_after": seg.get("peak_after"),
74
79
  }
75
- for seg in (tts_segments or [])
76
- if isinstance(seg, dict)
80
+ for seg in tts_segments
77
81
  ],
78
82
  }
79
83
  if final_output is not None:
@@ -91,119 +95,89 @@ def _write_assembly_manifest(work_dir, manifest):
91
95
 
92
96
 
93
97
  def _visual_qc_rollup(visual_qc):
94
- if not isinstance(visual_qc, dict):
95
- return {
96
- "present": False,
97
- "verdict": "MISSING",
98
- "blocking": True,
99
- "blocking_codes": ["missing_visual_qc"],
100
- "summary": {},
101
- }
102
- subtitles = visual_qc.get("subtitles") or visual_qc.get("subtitle") or {}
103
- overlays = visual_qc.get("overlays") or visual_qc.get("overlay") or {}
98
+ subtitles = visual_qc["subtitles"]
99
+ overlays = visual_qc["overlays"]
104
100
  return {
105
101
  "present": True,
106
- "artifact": visual_qc.get("artifact", VISUAL_QC),
107
- "verdict": visual_qc.get("verdict"),
108
- "blocking": bool(visual_qc.get("blocking", False)),
109
- "blocking_codes": list(visual_qc.get("blocking_codes") or []),
110
- "summary": visual_qc.get("summary", {}),
111
- "geometry": visual_qc.get("geometry", {}),
102
+ "artifact": visual_qc["artifact"],
103
+ "verdict": visual_qc["verdict"],
104
+ "blocking": visual_qc["blocking"],
105
+ "blocking_codes": list(visual_qc["blocking_codes"]),
106
+ "summary": visual_qc["summary"],
107
+ "geometry": visual_qc["geometry"],
112
108
  "subtitles": {
113
- "entries": subtitles.get("entries", 0),
114
- "multi_line": subtitles.get("multi_line", False),
115
- "overflow": subtitles.get("overflow", False),
116
- "safe_area": subtitles.get("safe_area", {}),
109
+ "entries": subtitles["entries"],
110
+ "multi_line": subtitles["multi_line"],
111
+ "overflow": subtitles["overflow"],
112
+ "safe_area": subtitles["safe_area"],
117
113
  },
118
- "subtitle": subtitles,
119
- "mask": visual_qc.get("mask", {}),
114
+ "mask": visual_qc["mask"],
120
115
  "overlays": {
121
- "present": overlays.get("present", bool(overlays.get("items"))),
122
- "rendered": overlays.get("rendered", len(overlays.get("items", [])) if isinstance(overlays.get("items"), list) else 0),
116
+ "present": overlays["present"],
117
+ "rendered": overlays["rendered"],
123
118
  "unsupported": overlays.get("unsupported", []),
124
119
  "overflow": overlays.get("overflow", []),
125
120
  },
126
- "overlay": overlays,
127
121
  }
128
122
 
129
123
 
124
+ def _placed_audio_matches_timeline(seg):
125
+ """True when the persisted per-beat WAV is exactly what the serialized timeline window plays."""
126
+ placed_path = Path(seg["placed_audio_path"])
127
+ if not placed_path.exists():
128
+ return False
129
+ with wave.open(str(placed_path), "rb") as placed_wav:
130
+ placed_duration = placed_wav.getnframes() / placed_wav.getframerate()
131
+ tolerance = 1.0 / placed_wav.getframerate()
132
+ timeline_start = round(float(seg["actual_place_start"]), 4)
133
+ timeline_end = math.ceil(float(seg["actual_place_end"]) * 10_000 - 1e-9) / 10_000
134
+ serialized_span = timeline_end - timeline_start
135
+ return (
136
+ abs(placed_duration - seg["placed_audio_duration"]) <= tolerance + 1e-9
137
+ and serialized_span + 1e-9 >= placed_duration
138
+ )
139
+
140
+
130
141
  def _build_assembly_qc(tts_segments, video_duration, *, output_path=None,
131
142
  source_has_audio=None, loudness_mode=None, loudnorm_measurement=None,
132
- visual_qc=None, render_delivery=None, delivery_qc=None):
143
+ visual_qc=None, render_delivery=None):
133
144
  """Machine-readable assembly release gate.
134
145
 
135
146
  Visual facts are rolled up from visual_qc.json. Delivery/render facts live here
136
147
  (or render/delivery QC in future), never in visual_qc.json.
137
148
  """
138
- hard_max = float(CONFIG.get("narration_cumulative_tempo_hard_max", 1.40) or 1.40)
139
- segments = [s for s in (tts_segments or []) if isinstance(s, dict)]
149
+ hard_max = CONFIG["narration_cumulative_tempo_hard_max"]
150
+ segments = tts_segments
140
151
  no_safe = [
141
- int(s.get("index", i))
142
- for i, s in enumerate(segments)
152
+ s["index"] for s in segments
143
153
  if s.get("fit_status") == "no_safe_fit"
144
154
  or s.get("truncate_reason") in {"no_safe_boundary", "no_room"}
145
- or bool(s.get("blocking", False))
146
- ]
147
- skipped = [
148
- int(s.get("index", i))
149
- for i, s in enumerate(segments)
150
- if s.get("fit_status") == "skipped"
151
- ]
152
- fit_failed = [
153
- int(s.get("index", i))
154
- for i, s in enumerate(segments)
155
- if s.get("fit_status") == "speed_adjust_failed"
155
+ or s.get("blocking", False)
156
156
  ]
157
+ skipped = [s["index"] for s in segments if s.get("fit_status") == "skipped"]
157
158
  tempo_exceeded = []
158
159
  truncated = []
159
160
  handoff_failed = []
160
161
  timeline_audio_failed = []
161
162
  max_effective = 0.0
162
- for i, s in enumerate(segments):
163
- try:
164
- eff = float(s.get("effective_tempo", 0.0) or 0.0)
165
- except (TypeError, ValueError):
166
- eff = 0.0
163
+ for s in segments:
164
+ eff = float(s["effective_tempo"])
167
165
  max_effective = max(max_effective, eff)
168
166
  if eff > hard_max + 1e-6:
169
- tempo_exceeded.append(int(s.get("index", i)))
170
- if bool(s.get("truncated", False)) or s.get("truncate_reason") == "tail_trim_tolerance":
171
- truncated.append(int(s.get("index", i)))
172
- if bool(s.get("source_handoff_blocking", False)):
173
- handoff_failed.append(int(s.get("index", i)))
174
- if s.get("actual_place_start") is not None and float(s.get("placed_audio_duration", 0.0) or 0.0) > 0:
175
- placed_path = s.get("placed_audio_path")
176
- valid_placed_file = False
177
- if placed_path and Path(placed_path).exists():
178
- try:
179
- with wave.open(str(placed_path), "rb") as placed_wav:
180
- placed_duration = placed_wav.getnframes() / placed_wav.getframerate()
181
- tolerance = 1.0 / placed_wav.getframerate()
182
- timeline_start = round(float(s.get("actual_place_start")), 4)
183
- timeline_end = math.ceil(
184
- float(s.get("actual_place_end")) * 10_000 - 1e-9
185
- ) / 10_000
186
- serialized_span = timeline_end - timeline_start
187
- valid_placed_file = abs(
188
- placed_duration - float(s.get("placed_audio_duration") or 0.0)
189
- ) <= tolerance + 1e-9 and serialized_span + 1e-9 >= placed_duration
190
- except (OSError, wave.Error, ZeroDivisionError, TypeError, ValueError):
191
- valid_placed_file = False
192
- if not valid_placed_file:
193
- timeline_audio_failed.append(int(s.get("index", i)))
167
+ tempo_exceeded.append(s["index"])
168
+ if s.get("truncated", False) or s.get("truncate_reason") == "tail_trim_tolerance":
169
+ truncated.append(s["index"])
170
+ if s.get("source_handoff_blocking", False):
171
+ handoff_failed.append(s["index"])
172
+ if s["placed_audio_duration"] > 0 and not _placed_audio_matches_timeline(s):
173
+ timeline_audio_failed.append(s["index"])
194
174
 
195
- placed = [
196
- float(s.get("placed_audio_duration", 0.0) or 0.0)
197
- for s in segments
198
- if s.get("placed_audio_duration") is not None
199
- ]
175
+ placed = [s["placed_audio_duration"] for s in segments]
200
176
  blocking_codes = []
201
177
  if not segments:
202
178
  blocking_codes.append("missing_narration")
203
179
  if skipped:
204
180
  blocking_codes.append("skipped_segments")
205
- if fit_failed:
206
- blocking_codes.append("fit_failed")
207
181
  if no_safe:
208
182
  blocking_codes.append("no_safe_fit")
209
183
  if tempo_exceeded:
@@ -221,7 +195,7 @@ def _build_assembly_qc(tts_segments, video_duration, *, output_path=None,
221
195
  if visual_qc is not None
222
196
  else {"present": False, "verdict": "NOT_RUN", "blocking": False, "blocking_codes": [], "summary": {}}
223
197
  )
224
- if visual_rollup.get("blocking"):
198
+ if visual_rollup["blocking"]:
225
199
  blocking_codes.append("visual_qc_failed")
226
200
  if source_has_audio is False:
227
201
  # Not blocking: assemble can synthesize a silent original track.
@@ -242,43 +216,34 @@ def _build_assembly_qc(tts_segments, video_duration, *, output_path=None,
242
216
  if output_path.exists() and output["bytes"] <= 0:
243
217
  blocking_codes.append("empty_output")
244
218
 
245
- render_delivery = render_delivery or delivery_qc or {}
246
- qc_payload = {
219
+ delivery = render_delivery if render_delivery is not None else {}
220
+ return {
247
221
  "schema_version": 1,
248
222
  "artifact": ASSEMBLY_QC,
249
223
  "verdict": "FAIL" if blocking_codes else "PASS",
250
224
  "blocking": bool(blocking_codes),
251
225
  "blocking_codes": blocking_codes,
252
- "duration": round(float(video_duration or 0.0), 4),
226
+ "duration": round(float(video_duration), 4),
253
227
  "source_audio": source_audio,
254
228
  "loudness_mode": loudness_mode or _loudness_mode(loudnorm_measurement),
255
229
  "loudnorm_measurement": loudnorm_measurement,
256
230
  "release_gate": {
257
231
  "verdict": "FAIL" if blocking_codes else "PASS",
258
- "visual_qc": visual_rollup.get("verdict"),
232
+ "visual_qc": visual_rollup["verdict"],
259
233
  "delivery_qc": "PASS",
260
- "audio_qc": "FAIL" if any(
261
- c in blocking_codes
262
- for c in (
263
- "missing_narration", "skipped_segments", "fit_failed", "no_safe_fit",
264
- "effective_tempo_exceeded", "empty_narration", "truncated_speech",
265
- "unsafe_source_handoff",
266
- "timeline_audio_mismatch",
267
- )
268
- ) else "PASS",
234
+ "audio_qc": "FAIL" if _AUDIO_QC_CODES.intersection(blocking_codes) else "PASS",
269
235
  },
270
236
  "visual_qc": visual_rollup,
271
237
  "delivery_qc": {
272
- "video_encode_passes": render_delivery.get("video_encode_passes"),
273
- "reencode_reason": render_delivery.get("reencode_reason"),
274
- "audio_sample_rate": render_delivery.get("audio_sample_rate"),
275
- "final_compat_notes": render_delivery.get("final_compat_notes", []),
238
+ "video_encode_passes": delivery.get("video_encode_passes"),
239
+ "reencode_reason": delivery.get("reencode_reason"),
240
+ "audio_sample_rate": delivery.get("audio_sample_rate"),
241
+ "final_compat_notes": delivery.get("final_compat_notes", []),
276
242
  },
277
243
  "summary": {
278
244
  "segments": len(segments),
279
245
  "placed_segments": sum(1 for x in placed if x > 0.0),
280
246
  "skipped_segments": skipped,
281
- "fit_failed_segments": fit_failed,
282
247
  "no_safe_fit_segments": no_safe,
283
248
  "tempo_exceeded_segments": tempo_exceeded,
284
249
  "max_effective_tempo": round(max_effective, 4),
@@ -288,9 +253,6 @@ def _build_assembly_qc(tts_segments, video_duration, *, output_path=None,
288
253
  },
289
254
  "output": output,
290
255
  }
291
- qc_payload["visual_rollup"] = visual_rollup
292
- qc_payload["delivery_rollup"] = qc_payload["delivery_qc"]
293
- return qc_payload
294
256
 
295
257
 
296
258
  def _write_assembly_qc(work_dir, qc):