@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
@@ -11,9 +11,11 @@ import math
11
11
  import shutil
12
12
  import subprocess
13
13
  from pathlib import Path
14
- from typing import Any, Callable, Mapping, Sequence
14
+ from typing import Any
15
+ from collections.abc import Callable, Mapping, Sequence
15
16
 
16
17
  import qc_contract
18
+ from lib import load_json
17
19
 
18
20
  FINAL_QC_ARTIFACT = "final_qc.json"
19
21
  GOLDEN_EVAL_ARTIFACT = "golden_eval.json"
@@ -26,139 +28,87 @@ _COLLECT_ARTIFACTS = (
26
28
  "preflight_qc.json",
27
29
  "mimo_qc.json",
28
30
  )
31
+ # video-assemble QC artifacts: {"verdict", "blocking", "blocking_codes": [...]}.
32
+ _UPSTREAM_QC_ARTIFACTS = ("assembly_qc.json", "visual_qc.json")
29
33
  ProbeRunner = Callable[[Path], Mapping[str, Any]]
30
34
 
31
35
 
32
- def redact_secrets(value: Any) -> Any:
33
- """Return a JSON-safe copy with likely credentials removed."""
34
- return qc_contract.redact_secrets(value)
36
+ def _load_fixture(value: Any) -> Any:
37
+ """A fixture is an in-memory JSON value or the path of a JSON file."""
38
+ if isinstance(value, (Mapping, list)):
39
+ return value
40
+ return load_json(value)
35
41
 
36
42
 
37
- def safe_load_json(path_or_value: str | Path | Mapping[str, Any] | Sequence[Any] | None) -> Any:
38
- """Safely load JSON from a path or return a redacted JSON-like fixture value."""
39
- if path_or_value is None:
40
- return None
41
- if isinstance(path_or_value, (Mapping, list, tuple)):
42
- return redact_secrets(path_or_value)
43
- path = Path(path_or_value)
44
- try:
45
- return redact_secrets(json.loads(path.read_text(encoding="utf-8")))
46
- except (OSError, ValueError, TypeError):
47
- return None
48
-
43
+ def fingerprint_file(path: Path) -> str | None:
44
+ return qc_contract.artifact_fingerprint(path) if path.is_file() else None
49
45
 
50
- def fingerprint_file(path: str | Path) -> str | None:
51
- path = Path(path)
52
- try:
53
- if path.exists() and path.is_file():
54
- return qc_contract.artifact_fingerprint(path)
55
- except OSError:
56
- return None
57
- return None
58
46
 
59
-
60
- def _json_safe(value: Any) -> Any:
61
- if isinstance(value, Path):
62
- return str(value)
63
- if isinstance(value, Mapping):
64
- return {str(k): _json_safe(v) for k, v in value.items()}
65
- if isinstance(value, (list, tuple)):
66
- return [_json_safe(v) for v in value]
67
- return value
47
+ def _resolve_in_work_dir(work_dir: Path, path: str | Path) -> Path:
48
+ p = Path(path)
49
+ return p if p.is_absolute() else work_dir / p
68
50
 
69
51
 
70
- def _resolve_in_work_dir(work_dir: Path, path: str | Path | None) -> Path | None:
71
- if path is None or str(path) == "":
52
+ def _read_json_mapping(path: Path) -> Mapping[str, Any] | None:
53
+ try:
54
+ data = load_json(path)
55
+ except (OSError, ValueError):
72
56
  return None
73
- p = Path(path)
74
- return p if p.is_absolute() else work_dir / p
57
+ return data if isinstance(data, Mapping) else None
75
58
 
76
59
 
77
60
  def _candidate_final_outputs(work_dir: Path, final_output: str | Path | None) -> list[Path]:
78
61
  candidates: list[Path] = []
79
- explicit = _resolve_in_work_dir(work_dir, final_output)
80
- if explicit is not None:
81
- candidates.append(explicit)
82
- manifest = safe_load_json(work_dir / "assembly_manifest.json")
83
- if isinstance(manifest, Mapping):
84
- for key in ("final_output", "output", "output_path", "video_path"):
85
- if manifest.get(key):
86
- p = _resolve_in_work_dir(work_dir, str(manifest[key]))
87
- if p is not None:
88
- candidates.append(p)
89
- for name in ("output.mp4", "recap.mp4", "final.mp4"):
90
- candidates.append(work_dir / name)
91
- deduped: list[Path] = []
92
- seen: set[str] = set()
93
- for p in candidates:
94
- key = str(p)
95
- if key not in seen:
96
- seen.add(key)
97
- deduped.append(p)
98
- return deduped
99
-
100
-
101
- def _select_final_output(work_dir: Path, final_output: str | Path | None) -> Path | None:
62
+ if final_output is not None:
63
+ candidates.append(_resolve_in_work_dir(work_dir, final_output))
64
+ manifest_path = work_dir / "assembly_manifest.json"
65
+ if manifest_path.exists():
66
+ manifest = _read_json_mapping(manifest_path)
67
+ manifest_output = manifest.get("final_output") if manifest else None
68
+ if isinstance(manifest_output, str) and manifest_output:
69
+ candidates.append(_resolve_in_work_dir(work_dir, manifest_output))
70
+ candidates += [work_dir / name for name in ("output.mp4", "recap.mp4", "final.mp4")]
71
+ return list(dict.fromkeys(candidates))
72
+
73
+
74
+ def _select_final_output(work_dir: Path, final_output: str | Path | None) -> Path:
102
75
  candidates = _candidate_final_outputs(work_dir, final_output)
103
76
  if final_output is not None:
104
- return candidates[0] if candidates else _resolve_in_work_dir(work_dir, final_output)
105
- for p in candidates:
106
- try:
107
- if p.exists() and p.is_file() and p.stat().st_size > 0:
108
- return p
109
- except OSError:
110
- continue
111
- return candidates[0] if candidates else None
112
-
113
-
114
- def _file_metadata(path: Path | None, work_dir: Path | None = None) -> dict[str, Any]:
115
- if path is None:
116
- return {"path": None, "exists": False, "bytes": 0, "fingerprint": None}
117
- display = str(path)
118
- if work_dir is not None:
119
- try:
120
- display = path.relative_to(work_dir).as_posix()
121
- except ValueError:
122
- pass
123
- item: dict[str, Any] = {"path": display}
77
+ return candidates[0]
78
+ return next((path for path in candidates if path.is_file() and path.stat().st_size > 0), candidates[0])
79
+
80
+
81
+ def _file_metadata(path: Path, work_dir: Path) -> dict[str, Any]:
124
82
  try:
125
- exists = path.exists() and path.is_file()
126
- item["exists"] = exists
127
- item["bytes"] = path.stat().st_size if exists else 0
128
- item["fingerprint"] = fingerprint_file(path) if exists else None
129
- except OSError as exc:
130
- item.update({"exists": False, "bytes": 0, "fingerprint": None, "error": str(exc)})
131
- return redact_secrets(item)
83
+ display = path.relative_to(work_dir).as_posix()
84
+ except ValueError: # final outputs normally live next to work_dir, not inside it
85
+ display = str(path)
86
+ exists = path.is_file()
87
+ return {
88
+ "path": display,
89
+ "exists": exists,
90
+ "bytes": path.stat().st_size if exists else 0,
91
+ "fingerprint": fingerprint_file(path),
92
+ }
132
93
 
133
94
 
134
95
  def _artifact_summary(work_dir: Path, name: str) -> dict[str, Any]:
135
96
  path = work_dir / name
136
97
  meta = _file_metadata(path, work_dir)
137
- if meta.get("exists") and path.suffix.lower() == ".json":
138
- data = safe_load_json(path)
139
- if isinstance(data, Mapping):
98
+ if meta["exists"]:
99
+ data = _read_json_mapping(path)
100
+ if data is None:
101
+ meta["summary"] = {"invalid": True}
102
+ else:
140
103
  meta["summary"] = {
141
- "schema_version": data.get("schema_version"),
142
- "artifact": data.get("artifact"),
143
- "stage": data.get("stage"),
144
- "ok": data.get("ok"),
145
- "blocker_count": data.get("blocker_count"),
146
- "finding_count": data.get("finding_count"),
104
+ key: data.get(key)
105
+ for key in ("schema_version", "artifact", "stage", "ok", "blocker_count", "finding_count")
147
106
  }
148
- elif data is not None:
149
- meta["summary"] = {"type": type(data).__name__}
150
- return redact_secrets(meta)
107
+ return meta
151
108
 
152
109
 
153
- def _artifact_fingerprints(*paths: Path | None) -> dict[str, str]:
154
- out: dict[str, str] = {}
155
- for path in paths:
156
- if path is None:
157
- continue
158
- fp = fingerprint_file(path)
159
- if fp:
160
- out[path.name] = fp
161
- return out
110
+ def _artifact_fingerprints(*paths: Path) -> dict[str, str]:
111
+ return {path.name: fp for path in paths if (fp := fingerprint_file(path))}
162
112
 
163
113
 
164
114
  def _finding(*, finding_id: str, code: str, message: str, category: str = "schema_invalid",
@@ -176,9 +126,9 @@ def _finding(*, finding_id: str, code: str, message: str, category: str = "schem
176
126
  message=message,
177
127
  deterministic=True,
178
128
  blocking=True,
179
- source=redact_secrets(source or {}),
180
- evidence=redact_secrets(evidence or {}),
181
- artifact_fingerprints=redact_secrets(fingerprints or {}),
129
+ source=source,
130
+ evidence=evidence,
131
+ artifact_fingerprints=fingerprints,
182
132
  next_action=next_action,
183
133
  model_used="local_deterministic_final_qc_v1",
184
134
  )
@@ -193,15 +143,11 @@ def _run_ffprobe(path: Path) -> Mapping[str, Any]:
193
143
  ]
194
144
  res = subprocess.run(cmd, capture_output=True, text=True)
195
145
  if res.returncode != 0:
196
- detail = (res.stderr or res.stdout or "ffprobe failed").strip()
197
- raise RuntimeError(detail)
146
+ raise RuntimeError((res.stderr or res.stdout or "ffprobe failed").strip())
198
147
  try:
199
- data = json.loads(res.stdout or "{}")
148
+ return json.loads(res.stdout)
200
149
  except ValueError as exc:
201
150
  raise RuntimeError(f"ffprobe returned invalid JSON: {exc}") from exc
202
- if not isinstance(data, Mapping):
203
- raise RuntimeError("ffprobe JSON must be an object")
204
- return data
205
151
 
206
152
 
207
153
  def _tail_decode_check(path: Path) -> tuple[bool | None, str | None]:
@@ -227,127 +173,71 @@ def _tail_decode_check(path: Path) -> tuple[bool | None, str | None]:
227
173
 
228
174
 
229
175
  def _probe_metadata(path: Path, *, probe_fixture: Any = None, probe_runner: ProbeRunner | None = None) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
230
- """Return (probe, error). Existing non-empty media gets deterministic error on failure."""
231
- if probe_fixture is not None:
232
- data = safe_load_json(probe_fixture)
233
- if isinstance(data, Mapping):
234
- return redact_secrets(dict(data)), None
235
- return None, {"code": "probe_failed", "message": "probe_fixture is missing or invalid JSON"}
176
+ """Return (probe, error); an ffprobe failure on existing media becomes a deterministic blocker."""
236
177
  try:
237
- runner = probe_runner or _run_ffprobe
238
- data = runner(path)
239
- if not isinstance(data, Mapping):
240
- raise RuntimeError("probe_runner must return an object")
241
- return redact_secrets(dict(data)), None
242
- except Exception as exc: # deterministic report-only blocker, no crash
178
+ raw = _load_fixture(probe_fixture) if probe_fixture is not None else (probe_runner or _run_ffprobe)(path)
179
+ if not isinstance(raw, Mapping):
180
+ raise TypeError("probe metadata must be a JSON object")
181
+ streams = raw.get("streams", [])
182
+ format_info = raw.get("format", {})
183
+ if not isinstance(streams, list) or any(not isinstance(stream, Mapping) for stream in streams):
184
+ raise TypeError("probe metadata streams must be an array of objects")
185
+ if not isinstance(format_info, Mapping):
186
+ raise TypeError("probe metadata format must be an object")
187
+ normalized = dict(raw)
188
+ normalized["streams"] = [dict(stream) for stream in streams]
189
+ normalized["format"] = dict(format_info)
190
+ return normalized, None
191
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, TypeError) as exc:
243
192
  return None, {"code": "probe_failed", "message": str(exc) or "ffprobe failed"}
244
193
 
245
194
 
246
- def _first_video_stream(probe: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
247
- if not isinstance(probe, Mapping):
248
- return None
249
- streams = probe.get("streams")
250
- if not isinstance(streams, list):
251
- return None
252
- for stream in streams:
253
- if isinstance(stream, Mapping) and stream.get("codec_type") == "video":
254
- return stream
255
- return None
195
+ def _first_video_stream(probe: Mapping[str, Any]) -> Mapping[str, Any] | None:
196
+ return next((s for s in probe.get("streams", []) if s.get("codec_type") == "video"), None)
256
197
 
257
198
 
258
- def _parse_positive_finite_number(value: Any) -> float | None:
259
- if value is None:
260
- return None
261
- if isinstance(value, str):
262
- value = value.strip()
263
- if not value:
264
- return None
265
- if "/" in value:
199
+ def _positive_finite(value: Any) -> float | None:
200
+ """Parse an ffprobe number or rational ("30000/1001"); None unless positive and finite."""
201
+ try:
202
+ if isinstance(value, str) and "/" in value:
266
203
  numerator, denominator = value.split("/", 1)
267
- try:
268
- den = float(denominator)
269
- if den == 0:
270
- return None
271
- number = float(numerator) / den
272
- except (TypeError, ValueError):
273
- return None
204
+ number = float(numerator) / float(denominator)
274
205
  else:
275
- try:
276
- number = float(value)
277
- except (TypeError, ValueError):
278
- return None
279
- else:
280
- try:
281
206
  number = float(value)
282
- except (TypeError, ValueError):
283
- return None
284
- if math.isfinite(number) and number > 0:
285
- return number
286
- return None
287
-
288
-
289
- def _probe_duration_status(probe: Mapping[str, Any] | None) -> tuple[float | None, str | None, Any]:
290
- if not isinstance(probe, Mapping):
291
- return None, "missing_duration", None
292
- candidates: list[Any] = []
293
- fmt = probe.get("format")
294
- if isinstance(fmt, Mapping):
295
- candidates.append(fmt.get("duration"))
296
- candidates.append(probe.get("duration"))
297
- seen = [value for value in candidates if value not in (None, "")]
298
- if not seen:
299
- return None, "missing_duration", None
300
- for value in seen:
301
- duration = _parse_positive_finite_number(value)
302
- if duration is not None:
303
- return duration, None, value
304
- return None, "invalid_duration", seen[0]
305
-
306
-
307
- def _probe_fps_status(video_stream: Mapping[str, Any] | None, probe: Mapping[str, Any] | None) -> tuple[float | None, str | None, Any]:
308
- candidates: list[Any] = []
309
- if isinstance(video_stream, Mapping):
310
- for key in ("avg_frame_rate", "r_frame_rate", "fps", "frame_rate"):
311
- candidates.append(video_stream.get(key))
312
- if isinstance(probe, Mapping):
313
- for key in ("fps", "frame_rate"):
314
- candidates.append(probe.get(key))
207
+ except (TypeError, ValueError, ZeroDivisionError):
208
+ return None
209
+ return number if math.isfinite(number) and number > 0 else None
210
+
211
+
212
+ def _first_positive(candidates: Sequence[Any]) -> tuple[float | None, str | None, Any]:
213
+ """(parsed, problem, raw): the first positive finite candidate wins; otherwise problem is
214
+ 'missing' (no candidate at all) or 'invalid' (with the first raw candidate)."""
315
215
  seen = [value for value in candidates if value not in (None, "")]
216
+ for raw in seen:
217
+ parsed = _positive_finite(raw)
218
+ if parsed is not None:
219
+ return parsed, None, raw
316
220
  if not seen:
317
- return None, "missing_fps", None
318
- for value in seen:
319
- fps = _parse_positive_finite_number(value)
320
- if fps is not None:
321
- return fps, None, value
322
- return None, "invalid_fps", seen[0]
323
-
324
- def _duration_from_probe(probe: Mapping[str, Any] | None) -> float | None:
325
- if not isinstance(probe, Mapping):
326
- return None
327
- candidates: list[Any] = []
328
- fmt = probe.get("format")
329
- if isinstance(fmt, Mapping):
330
- candidates.append(fmt.get("duration"))
331
- candidates.append(probe.get("duration"))
332
- for value in candidates:
333
- try:
334
- duration = float(value)
335
- except (TypeError, ValueError):
336
- continue
337
- if duration >= 0:
338
- return duration
339
- return None
221
+ return None, "missing", None
222
+ return None, "invalid", seen[0]
340
223
 
341
224
 
342
- def _codec_from_probe(probe: Mapping[str, Any] | None) -> str | None:
343
- vs = _first_video_stream(probe)
344
- name = vs.get("codec_name") if isinstance(vs, Mapping) else None
345
- return str(name) if name else None
225
+ def _probe_duration(probe: Mapping[str, Any]) -> tuple[float | None, str | None, Any]:
226
+ return _first_positive([probe.get("format", {}).get("duration")])
346
227
 
347
228
 
229
+ def _probe_fps(video_stream: Mapping[str, Any] | None) -> tuple[float | None, str | None, Any]:
230
+ if video_stream is None:
231
+ return None, "missing", None
232
+ return _first_positive([
233
+ video_stream.get(key)
234
+ for key in ("avg_frame_rate", "r_frame_rate", "fps", "frame_rate")
235
+ ])
348
236
 
349
- def _probe_contract_findings(probe: Mapping[str, Any] | None, *, final_meta: Mapping[str, Any], fingerprints: Mapping[str, Any]) -> list[dict[str, Any]]:
237
+
238
+ def _probe_contract_findings(probe: Mapping[str, Any], *, final_meta: Mapping[str, Any], fingerprints: Mapping[str, Any]) -> list[dict[str, Any]]:
350
239
  findings: list[dict[str, Any]] = []
240
+ source = {"artifact": final_meta["path"]}
351
241
  video_stream = _first_video_stream(probe)
352
242
  if video_stream is None:
353
243
  findings.append(_finding(
@@ -355,123 +245,101 @@ def _probe_contract_findings(probe: Mapping[str, Any] | None, *, final_meta: Map
355
245
  code="missing_video_stream",
356
246
  message="final output probe metadata has no video stream",
357
247
  category="stream",
358
- source={"artifact": final_meta.get("path")},
359
- evidence={"streams": probe.get("streams") if isinstance(probe, Mapping) else None},
248
+ source=source,
249
+ evidence={"streams": probe.get("streams")},
360
250
  fingerprints=fingerprints,
361
251
  next_action="rerender_final_output_with_video_stream",
362
252
  ))
363
253
 
364
- _duration, duration_code, duration_value = _probe_duration_status(probe)
365
- if duration_code is not None:
254
+ _duration, problem, raw = _probe_duration(probe)
255
+ if problem is not None:
366
256
  findings.append(_finding(
367
- finding_id=f"final-qc-{duration_code.replace('_', '-')}",
368
- code=duration_code,
369
- message="final output probe metadata is missing a positive finite duration" if duration_code == "missing_duration" else "final output probe metadata duration is not positive and finite",
257
+ finding_id=f"final-qc-{problem}-duration",
258
+ code=f"{problem}_duration",
259
+ message="final output probe metadata is missing a positive finite duration" if problem == "missing" else "final output probe metadata duration is not positive and finite",
370
260
  category="duration",
371
- source={"artifact": final_meta.get("path")},
372
- evidence={"duration": duration_value},
261
+ source=source,
262
+ evidence={"duration": raw},
373
263
  fingerprints=fingerprints,
374
264
  next_action="rerender_final_output_with_valid_duration",
375
265
  ))
376
266
 
377
- codec = str(video_stream.get("codec_name")).strip() if isinstance(video_stream, Mapping) and video_stream.get("codec_name") is not None else ""
378
- if not codec:
267
+ if not (video_stream or {}).get("codec_name"):
379
268
  findings.append(_finding(
380
269
  finding_id="final-qc-missing-codec",
381
270
  code="missing_codec",
382
271
  message="final output probe metadata is missing a video codec",
383
272
  category="stream",
384
- source={"artifact": final_meta.get("path")},
273
+ source=source,
385
274
  evidence={"video_stream": video_stream},
386
275
  fingerprints=fingerprints,
387
276
  next_action="rerender_final_output_with_video_codec",
388
277
  ))
389
278
 
390
- _fps, fps_code, fps_value = _probe_fps_status(video_stream, probe)
391
- if fps_code is not None:
279
+ _fps, problem, raw = _probe_fps(video_stream)
280
+ if problem is not None:
392
281
  findings.append(_finding(
393
- finding_id=f"final-qc-{fps_code.replace('_', '-')}",
394
- code=fps_code,
395
- message="final output probe metadata is missing a positive finite video fps" if fps_code == "missing_fps" else "final output probe metadata video fps is not positive and finite",
282
+ finding_id=f"final-qc-{problem}-fps",
283
+ code=f"{problem}_fps",
284
+ message="final output probe metadata is missing a positive finite video fps" if problem == "missing" else "final output probe metadata video fps is not positive and finite",
396
285
  category="stream",
397
- source={"artifact": final_meta.get("path")},
398
- evidence={"fps": fps_value, "video_stream": video_stream},
286
+ source=source,
287
+ evidence={"fps": raw, "video_stream": video_stream},
399
288
  fingerprints=fingerprints,
400
289
  next_action="rerender_final_output_with_valid_fps",
401
290
  ))
402
291
  return findings
403
292
 
404
293
 
405
- def _blocking_codes(value: Any) -> list[str]:
406
- if isinstance(value, str):
407
- values: Sequence[Any] = [value]
408
- elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
409
- values = value
410
- else:
411
- return []
412
- codes: list[str] = []
413
- for item in values:
414
- code = str(item).strip()
415
- if code:
416
- codes.append(code)
417
- return codes
418
-
419
-
420
- def _failing_verdict(value: Any) -> bool:
421
- if not isinstance(value, str):
422
- return False
423
- verdict = value.strip().lower()
424
- return verdict in {"fail", "failed", "failing", "blocker", "blocked", "error", "errored", "reject", "rejected"}
425
-
426
-
427
294
  def _upstream_blockers(work_dir: Path, artifact_name: str) -> list[dict[str, Any]]:
295
+ """Roll a blocking video-assemble QC artifact into one final blocker per blocking code."""
428
296
  path = work_dir / artifact_name
429
- data = safe_load_json(path)
430
- if not isinstance(data, Mapping):
297
+ if not path.exists():
431
298
  return []
432
- findings = data.get("findings")
433
- blocker_count = data.get("blocker_count")
434
- ok = data.get("ok")
435
- blocked: list[Mapping[str, Any]] = []
436
- if isinstance(findings, list):
437
- blocked.extend(f for f in findings if isinstance(f, Mapping) and f.get("blocking") is True)
438
- elif ok is False or (isinstance(blocker_count, int) and blocker_count > 0):
439
- blocked.append({"code": "reported_blocker", "message": f"{artifact_name} reported blockers"})
440
-
441
- blocking_codes = _blocking_codes(data.get("blocking_codes"))
442
- if data.get("blocking") is True or _failing_verdict(data.get("verdict")):
443
- if not blocking_codes:
444
- blocking_codes = ["reported_blocker"]
445
- for code in blocking_codes:
446
- blocked.append({
447
- "code": code,
448
- "message": f"{artifact_name} reported {code}",
449
- "artifact": data.get("artifact"),
450
- "verdict": data.get("verdict"),
451
- "blocking": data.get("blocking"),
452
- })
453
-
454
- out = []
299
+ data = _read_json_mapping(path)
455
300
  fp = fingerprint_file(path)
456
- for idx, item in enumerate(blocked):
457
- code = str(item.get("code") or item.get("rule_id") or "reported_blocker")
458
- out.append(_finding(
301
+ invalid = data is None
302
+ codes = data.get("blocking_codes") if data is not None else None
303
+ if not invalid and codes is None:
304
+ findings = data.get("findings")
305
+ invalid = not isinstance(findings, list) or any(
306
+ not isinstance(finding, Mapping)
307
+ or not isinstance(finding.get("blocking"), bool)
308
+ or not isinstance(finding.get("code"), str)
309
+ or not finding["code"]
310
+ for finding in findings or []
311
+ )
312
+ if not invalid:
313
+ codes = [finding["code"] for finding in findings if finding["blocking"]]
314
+ elif isinstance(codes, str):
315
+ codes = [codes]
316
+ elif not invalid and (
317
+ not isinstance(codes, list)
318
+ or any(not isinstance(code, str) or not code for code in codes)
319
+ ):
320
+ invalid = True
321
+ if invalid:
322
+ return [_finding(
323
+ finding_id=f"final-qc-invalid-upstream-{artifact_name}",
324
+ code=f"upstream_{artifact_name.replace('.', '_')}_schema_invalid",
325
+ message=f"{artifact_name} is not a valid deterministic QC report",
326
+ source={"artifact": artifact_name},
327
+ evidence={"schema_invalid": True},
328
+ fingerprints={artifact_name: fp},
329
+ next_action="regenerate_upstream_qc",
330
+ )]
331
+ return [
332
+ _finding(
459
333
  finding_id=f"final-qc-upstream-{artifact_name}-{idx}",
460
334
  code=f"upstream_{artifact_name.replace('.', '_')}_{code}",
461
- message=f"{artifact_name} has blocking finding: {item.get('message') or item.get('decision_reason') or code}",
462
- category="schema_invalid",
463
- source={"artifact": artifact_name, "finding_id": item.get("finding_id") or item.get("id")},
464
- evidence={
465
- "upstream_code": code,
466
- "upstream_message": item.get("message") or item.get("decision_reason"),
467
- "upstream_artifact": item.get("artifact"),
468
- "upstream_verdict": item.get("verdict"),
469
- "upstream_blocking": item.get("blocking"),
470
- },
471
- fingerprints={artifact_name: fp} if fp else {},
335
+ message=f"{artifact_name} reported {code}",
336
+ source={"artifact": artifact_name},
337
+ evidence={"upstream_code": code, "upstream_verdict": data.get("verdict")},
338
+ fingerprints={artifact_name: fp},
472
339
  next_action="fix_upstream_qc_blocker",
473
- ))
474
- return out
340
+ )
341
+ for idx, code in enumerate(codes)
342
+ ]
475
343
 
476
344
 
477
345
  def collect_metadata(work_dir: str | Path, *, final_output: str | Path | None = None,
@@ -480,20 +348,18 @@ def collect_metadata(work_dir: str | Path, *, final_output: str | Path | None =
480
348
  selected = _select_final_output(root, final_output)
481
349
  probe = probe_error = None
482
350
  final_meta = _file_metadata(selected, root)
483
- if final_meta.get("exists") and final_meta.get("bytes", 0) > 0:
351
+ if final_meta["exists"] and final_meta["bytes"] > 0:
484
352
  probe, probe_error = _probe_metadata(selected, probe_fixture=probe_fixture, probe_runner=probe_runner)
485
- artifacts = {name: _artifact_summary(root, name) for name in _COLLECT_ARTIFACTS}
486
- metadata = {
353
+ return {
487
354
  "work_dir": str(root),
488
355
  "final_output": final_meta,
489
356
  "final_output_candidates": [_file_metadata(p, root) for p in _candidate_final_outputs(root, final_output)],
490
- "artifacts": artifacts,
357
+ "artifacts": {name: _artifact_summary(root, name) for name in _COLLECT_ARTIFACTS},
491
358
  "probe": probe,
492
359
  "probe_error": probe_error,
493
360
  # mimo_qc.json is advisory metadata only and is not rolled into final blockers.
494
361
  "auto_repair": False,
495
362
  }
496
- return redact_secrets(_json_safe(metadata))
497
363
 
498
364
 
499
365
  def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
@@ -505,7 +371,7 @@ def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
505
371
  final_meta = metadata["final_output"]
506
372
  findings: list[dict[str, Any]] = []
507
373
  fps = _artifact_fingerprints(selected)
508
- if not final_meta.get("exists"):
374
+ if not final_meta["exists"]:
509
375
  findings.append(_finding(
510
376
  finding_id="final-qc-missing-final-output",
511
377
  code="missing_final_output",
@@ -516,97 +382,80 @@ def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
516
382
  fingerprints=fps,
517
383
  next_action="render_final_output",
518
384
  ))
519
- elif int(final_meta.get("bytes") or 0) <= 0:
385
+ elif final_meta["bytes"] == 0:
520
386
  findings.append(_finding(
521
387
  finding_id="final-qc-empty-final-output",
522
388
  code="empty_final_output",
523
389
  message="final output mp4 is empty",
524
390
  category="missing_artifact",
525
- source={"artifact": final_meta.get("path")},
391
+ source={"artifact": final_meta["path"]},
526
392
  evidence={"final_output": final_meta},
527
393
  fingerprints=fps,
528
394
  next_action="rerender_final_output",
529
395
  ))
396
+ elif metadata["probe_error"] is not None:
397
+ findings.append(_finding(
398
+ finding_id="final-qc-probe-failed",
399
+ code="probe_failed",
400
+ message="ffprobe failed or was unavailable for existing non-empty final output",
401
+ category="stream",
402
+ source={"artifact": final_meta["path"]},
403
+ evidence=metadata["probe_error"],
404
+ fingerprints=fps,
405
+ next_action="inspect_or_rerender_final_output",
406
+ ))
530
407
  else:
531
- probe_error = metadata.get("probe_error")
532
- if isinstance(probe_error, Mapping):
533
- findings.append(_finding(
534
- finding_id="final-qc-probe-failed",
535
- code="probe_failed",
536
- message="ffprobe failed or was unavailable for existing non-empty final output",
537
- category="stream",
538
- source={"artifact": final_meta.get("path")},
539
- evidence=probe_error,
540
- fingerprints=fps,
541
- next_action="inspect_or_rerender_final_output",
542
- ))
543
- else:
544
- probe = metadata.get("probe")
545
- probe_findings = _probe_contract_findings(
546
- probe if isinstance(probe, Mapping) else None,
547
- final_meta=final_meta,
548
- fingerprints=fps,
549
- )
550
- findings.extend(probe_findings)
551
- # Header probing cannot see a container-valid but media-truncated/corrupt payload.
552
- # A cheap tail decode catches it; skip for offline fixtures and when ffmpeg is absent
553
- # (decode_ok is None). Only add on a definite decode failure to avoid false-blocking.
554
- if probe_fixture is None and not probe_findings:
555
- decode_ok, decode_detail = (decode_runner or _tail_decode_check)(selected)
556
- if decode_ok is False:
557
- findings.append(_finding(
558
- finding_id="final-qc-undecodable-stream",
559
- code="undecodable_stream",
560
- message="final output tail failed to decode (truncated or corrupt media payload)",
561
- category="stream",
562
- source={"artifact": final_meta.get("path")},
563
- evidence={"decode_error": decode_detail},
564
- fingerprints=fps,
565
- next_action="rerender_final_output",
566
- ))
567
- findings.extend(_upstream_blockers(root, "assembly_qc.json"))
568
- findings.extend(_upstream_blockers(root, "visual_qc.json"))
569
- report = qc_contract.build_report(
408
+ probe_findings = _probe_contract_findings(metadata["probe"], final_meta=final_meta, fingerprints=fps)
409
+ findings.extend(probe_findings)
410
+ # Header probing cannot see a container-valid but media-truncated/corrupt payload.
411
+ # A cheap tail decode catches it; skip for offline fixtures and when ffmpeg is absent
412
+ # (decode_ok is None). Only add on a definite decode failure to avoid false-blocking.
413
+ if probe_fixture is None and not probe_findings:
414
+ decode_ok, decode_detail = (decode_runner or _tail_decode_check)(selected)
415
+ if decode_ok is False:
416
+ findings.append(_finding(
417
+ finding_id="final-qc-undecodable-stream",
418
+ code="undecodable_stream",
419
+ message="final output tail failed to decode (truncated or corrupt media payload)",
420
+ category="stream",
421
+ source={"artifact": final_meta["path"]},
422
+ evidence={"decode_error": decode_detail},
423
+ fingerprints=fps,
424
+ next_action="rerender_final_output",
425
+ ))
426
+ for name in _UPSTREAM_QC_ARTIFACTS:
427
+ findings.extend(_upstream_blockers(root, name))
428
+ return qc_contract.build_report(
570
429
  artifact=FINAL_QC_ARTIFACT,
571
430
  stage=POST_RENDER_STAGE,
572
431
  findings=findings,
573
432
  metadata=metadata,
574
433
  )
575
- qc_contract.validate_report(report)
576
- return report
577
434
 
578
435
 
579
436
  def _load_or_build_final_qc(work_dir: Path, final_qc_report: Mapping[str, Any] | None) -> dict[str, Any]:
580
437
  if final_qc_report is not None:
581
- report = dict(redact_secrets(final_qc_report))
582
- else:
583
- existing = safe_load_json(work_dir / FINAL_QC_ARTIFACT)
584
- report = dict(existing) if isinstance(existing, Mapping) else build_final_qc(work_dir)
585
- qc_contract.validate_report(report)
586
- return report
438
+ return dict(final_qc_report)
439
+ path = work_dir / FINAL_QC_ARTIFACT
440
+ return load_json(path) if path.exists() else build_final_qc(work_dir)
587
441
 
588
442
 
589
443
  def build_golden_eval(work_dir: str | Path, final_qc_report: Mapping[str, Any] | None = None,
590
444
  golden_fixture: Any = None) -> dict[str, Any]:
591
445
  root = Path(work_dir)
592
446
  final_report = _load_or_build_final_qc(root, final_qc_report)
593
- fixture = safe_load_json(golden_fixture)
594
- fixture = fixture if isinstance(fixture, Mapping) else {}
447
+ fixture = _load_fixture(golden_fixture) if golden_fixture is not None else {}
448
+ final_fp = fingerprint_file(root / FINAL_QC_ARTIFACT)
595
449
  metadata = {
596
450
  "work_dir": str(root),
597
451
  "fixture": fixture,
598
- "final_qc": {
599
- "ok": final_report.get("ok"),
600
- "blocker_count": final_report.get("blocker_count"),
601
- "artifact": final_report.get("artifact"),
602
- "stage": final_report.get("stage"),
603
- },
604
- "final_qc_fingerprint": fingerprint_file(root / FINAL_QC_ARTIFACT),
452
+ "final_qc": {key: final_report[key] for key in ("ok", "blocker_count", "artifact", "stage")},
453
+ "final_qc_fingerprint": final_fp,
605
454
  "auto_repair": False,
606
455
  }
607
456
  findings: list[dict[str, Any]] = []
608
457
  expected_ok = fixture.get("expected_final_qc_ok", True)
609
- if isinstance(expected_ok, bool) and bool(final_report.get("ok")) != expected_ok:
458
+ if final_report["ok"] != expected_ok:
610
459
  findings.append(_finding(
611
460
  finding_id="golden-final-qc-ok-mismatch",
612
461
  stage=GOLDEN_STAGE,
@@ -614,89 +463,83 @@ def build_golden_eval(work_dir: str | Path, final_qc_report: Mapping[str, Any] |
614
463
  message="final_qc ok state does not match golden expectation",
615
464
  category="schema_invalid",
616
465
  source={"artifact": FINAL_QC_ARTIFACT},
617
- evidence={"expected": expected_ok, "actual": final_report.get("ok")},
618
- fingerprints={FINAL_QC_ARTIFACT: metadata["final_qc_fingerprint"]} if metadata["final_qc_fingerprint"] else {},
466
+ evidence={"expected": expected_ok, "actual": final_report["ok"]},
467
+ fingerprints={FINAL_QC_ARTIFACT: final_fp} if final_fp else {},
619
468
  next_action="fix_final_qc_blockers",
620
469
  ))
621
- final_meta = {}
622
- probe = None
623
- if isinstance(final_report.get("metadata"), Mapping):
624
- final_meta = final_report["metadata"].get("final_output") or {}
625
- probe = final_report["metadata"].get("probe")
626
- duration = _duration_from_probe(probe)
627
- codec = _codec_from_probe(probe)
628
- if fixture.get("min_duration") is not None and (duration is None or duration < float(fixture["min_duration"])):
470
+ final_meta = final_report["metadata"]["final_output"]
471
+ probe = final_report["metadata"]["probe"]
472
+ duration = _probe_duration(probe)[0] if probe else None
473
+ video_stream = _first_video_stream(probe) if probe else None
474
+ codec = video_stream.get("codec_name") if video_stream else None
475
+ min_duration = fixture.get("min_duration")
476
+ if min_duration is not None and (duration is None or duration < min_duration):
629
477
  findings.append(_finding(
630
478
  finding_id="golden-min-duration-mismatch",
631
479
  stage=GOLDEN_STAGE,
632
480
  code="min_duration_mismatch",
633
481
  message="final output duration is below golden minimum",
634
482
  category="duration",
635
- source={"artifact": final_meta.get("path")},
636
- evidence={"expected_min_duration": fixture.get("min_duration"), "actual_duration": duration},
483
+ source={"artifact": final_meta["path"]},
484
+ evidence={"expected_min_duration": min_duration, "actual_duration": duration},
637
485
  next_action="adjust_render_duration",
638
486
  ))
639
- if fixture.get("max_duration") is not None and (duration is None or duration > float(fixture["max_duration"])):
487
+ max_duration = fixture.get("max_duration")
488
+ if max_duration is not None and (duration is None or duration > max_duration):
640
489
  findings.append(_finding(
641
490
  finding_id="golden-max-duration-mismatch",
642
491
  stage=GOLDEN_STAGE,
643
492
  code="max_duration_mismatch",
644
493
  message="final output duration is above golden maximum",
645
494
  category="duration",
646
- source={"artifact": final_meta.get("path")},
647
- evidence={"expected_max_duration": fixture.get("max_duration"), "actual_duration": duration},
495
+ source={"artifact": final_meta["path"]},
496
+ evidence={"expected_max_duration": max_duration, "actual_duration": duration},
648
497
  next_action="adjust_render_duration",
649
498
  ))
650
- expected_codec = fixture.get("expected_codec") or fixture.get("expected_video_codec")
651
- if expected_codec is not None and codec != str(expected_codec):
499
+ expected_codec = fixture.get("expected_codec")
500
+ if expected_codec is not None and codec != expected_codec:
652
501
  findings.append(_finding(
653
502
  finding_id="golden-codec-mismatch",
654
503
  stage=GOLDEN_STAGE,
655
504
  code="codec_mismatch",
656
505
  message="final output video codec does not match golden expectation",
657
506
  category="stream",
658
- source={"artifact": final_meta.get("path")},
507
+ source={"artifact": final_meta["path"]},
659
508
  evidence={"expected_codec": expected_codec, "actual_codec": codec},
660
509
  next_action="adjust_render_codec",
661
510
  ))
662
- required = fixture.get("required_artifacts") or []
663
- if isinstance(required, Sequence) and not isinstance(required, (str, bytes)):
664
- for idx, name in enumerate(required):
665
- artifact_path = root / str(name)
666
- if not artifact_path.exists() or not artifact_path.is_file() or artifact_path.stat().st_size <= 0:
667
- findings.append(_finding(
668
- finding_id=f"golden-required-artifact-missing-{idx}",
669
- stage=GOLDEN_STAGE,
670
- code="required_artifact_missing",
671
- message="golden fixture requires an artifact that is missing or empty",
672
- category="missing_artifact",
673
- source={"artifact": str(name)},
674
- evidence={"required_artifact": str(name)},
675
- next_action="produce_required_artifact",
676
- ))
511
+ for idx, name in enumerate(fixture.get("required_artifacts", [])):
512
+ artifact_path = root / name
513
+ if not artifact_path.is_file() or artifact_path.stat().st_size == 0:
514
+ findings.append(_finding(
515
+ finding_id=f"golden-required-artifact-missing-{idx}",
516
+ stage=GOLDEN_STAGE,
517
+ code="required_artifact_missing",
518
+ message="golden fixture requires an artifact that is missing or empty",
519
+ category="missing_artifact",
520
+ source={"artifact": name},
521
+ evidence={"required_artifact": name},
522
+ next_action="produce_required_artifact",
523
+ ))
677
524
  metadata["observed"] = {"duration": duration, "codec": codec, "final_output": final_meta}
678
- report = qc_contract.build_report(
525
+ return qc_contract.build_report(
679
526
  artifact=GOLDEN_EVAL_ARTIFACT,
680
527
  stage=GOLDEN_STAGE,
681
528
  findings=findings,
682
- metadata=redact_secrets(_json_safe(metadata)),
529
+ metadata=metadata,
683
530
  )
684
- qc_contract.validate_report(report)
685
- return report
686
531
 
687
532
 
688
533
  def _write_report(path: Path, report: Mapping[str, Any]) -> None:
689
- qc_contract.validate_report(report)
690
- path.write_text(json.dumps(redact_secrets(report), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
691
- qc_contract.validate_report(json.loads(path.read_text(encoding="utf-8")))
534
+ path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
692
535
 
693
536
 
694
537
  def run(work_dir: str | Path, final_output: str | Path | None = None,
695
538
  probe_fixture: Any = None, golden_fixture: Any = None,
696
- probe_runner: ProbeRunner | None = None, only: str | None = None) -> dict[str, Any]:
539
+ probe_runner: ProbeRunner | None = None, only: str = "all") -> dict[str, Any]:
697
540
  root = Path(work_dir)
698
541
  root.mkdir(parents=True, exist_ok=True)
699
- mode = {"final": "final_qc", "golden": "golden_eval"}.get(only or "all", only or "all")
542
+ mode = {"final": "final_qc", "golden": "golden_eval"}.get(only, only)
700
543
  if mode not in {"all", "final_qc", "golden_eval"}:
701
544
  raise ValueError("only must be one of all, final_qc, golden_eval, final, golden")
702
545
  result: dict[str, Any] = {"work_dir": str(root), "written": []}
@@ -704,12 +547,12 @@ def run(work_dir: str | Path, final_output: str | Path | None = None,
704
547
  if mode in {"all", "final_qc"}:
705
548
  final_report = build_final_qc(root, final_output=final_output, probe_fixture=probe_fixture, probe_runner=probe_runner)
706
549
  _write_report(root / FINAL_QC_ARTIFACT, final_report)
707
- result.update({"final_qc": {"ok": final_report["ok"], "blocker_count": final_report["blocker_count"]}})
550
+ result["final_qc"] = {"ok": final_report["ok"], "blocker_count": final_report["blocker_count"]}
708
551
  result["written"].append(FINAL_QC_ARTIFACT)
709
552
  if mode in {"all", "golden_eval"}:
710
553
  golden_report = build_golden_eval(root, final_qc_report=final_report, golden_fixture=golden_fixture)
711
554
  _write_report(root / GOLDEN_EVAL_ARTIFACT, golden_report)
712
- result.update({"golden_eval": {"ok": golden_report["ok"], "blocker_count": golden_report["blocker_count"]}})
555
+ result["golden_eval"] = {"ok": golden_report["ok"], "blocker_count": golden_report["blocker_count"]}
713
556
  result["written"].append(GOLDEN_EVAL_ARTIFACT)
714
557
  return result
715
558