@oh-story/dsh 0.1.12 → 0.1.13

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 (264) hide show
  1. package/README.md +17 -11
  2. package/lib/client.js +85 -35
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +41 -36
  5. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +340 -13
  6. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +4 -2
  7. package/lib/drama/skills/short-drama-edit/SKILL.md +32 -12
  8. package/lib/drama/skills/short-drama-edit/references/still-cuts.md +90 -0
  9. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1035 -145
  10. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +48 -0
  11. package/lib/drama/skills/short-drama-produce/SKILL.md +6 -1
  12. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +12 -0
  13. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +138 -9
  14. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +6 -0
  15. package/lib/drama/skills/short-drama-storyboard/SKILL.md +2 -0
  16. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +3 -1
  17. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -0
  18. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +14 -0
  19. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +19 -6
  20. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
  21. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -0
  22. package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +4 -1
  23. package/lib/index.js +16 -7
  24. package/lib/index.js.map +2 -2
  25. package/lib/novel-to-game/README.md +98 -44
  26. package/lib/novel-to-game/README_ZH.md +100 -46
  27. package/lib/novel-to-game/VERSION +1 -1
  28. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +27 -202
  29. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +23 -115
  30. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/chamber.webp +0 -0
  31. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_night.webp +0 -0
  32. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_rain.webp +0 -0
  33. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/garden.webp +0 -0
  34. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/hall.webp +0 -0
  35. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/kitchen.webp +0 -0
  36. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street.webp +0 -0
  37. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street_dawn.webp +0 -0
  38. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/temple.webp +0 -0
  39. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/moon.webp +0 -0
  40. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/angry.webp +0 -0
  41. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/blush.webp +0 -0
  42. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/neutral.webp +0 -0
  43. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/sad.webp +0 -0
  44. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/smile.webp +0 -0
  45. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/surprised.webp +0 -0
  46. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/angry.webp +0 -0
  47. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/blush.webp +0 -0
  48. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/neutral.webp +0 -0
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/sad.webp +0 -0
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/smile.webp +0 -0
  51. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/surprised.webp +0 -0
  52. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/angry.webp +0 -0
  53. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/blush.webp +0 -0
  54. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/neutral.webp +0 -0
  55. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/sad.webp +0 -0
  56. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/smile.webp +0 -0
  57. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/surprised.webp +0 -0
  58. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/angry.webp +0 -0
  59. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/blush.webp +0 -0
  60. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/neutral.webp +0 -0
  61. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/sad.webp +0 -0
  62. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/smile.webp +0 -0
  63. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/surprised.webp +0 -0
  64. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/angry.webp +0 -0
  65. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/blush.webp +0 -0
  66. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/neutral.webp +0 -0
  67. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/sad.webp +0 -0
  68. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/smile.webp +0 -0
  69. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/surprised.webp +0 -0
  70. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +326 -2503
  71. package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +71 -4
  72. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/adv.js +221 -0
  73. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +113 -257
  74. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +511 -3026
  75. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/menus.js +277 -0
  76. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/petals.js +67 -0
  77. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/stage.js +180 -0
  78. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/store.js +70 -0
  79. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common.js +198 -0
  80. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common2.js +204 -0
  81. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common3.js +198 -0
  82. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/dates.js +186 -0
  83. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/index.js +122 -0
  84. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/meng.js +190 -0
  85. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pan.js +212 -0
  86. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pinger.js +198 -0
  87. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/xuee.js +199 -0
  88. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/yue.js +214 -0
  89. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -2
  90. package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +182 -45
  91. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +7 -17
  92. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +9 -9
  93. package/lib/novel-to-game/manifest.json +303 -78
  94. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +7 -2
  95. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +2 -0
  96. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +2 -0
  97. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +9 -0
  98. package/lib/types/client/drama-production-view.d.ts +11 -0
  99. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  100. package/lib/types/client/file-activity.d.ts +1 -1
  101. package/lib/types/client/file-activity.d.ts.map +1 -1
  102. package/lib/types/client/production-prompts.d.ts +7 -1
  103. package/lib/types/client/production-prompts.d.ts.map +1 -1
  104. package/lib/types/client/production-runtime.d.ts +7 -2
  105. package/lib/types/client/production-runtime.d.ts.map +1 -1
  106. package/lib/types/drama-adapters.d.ts.map +1 -1
  107. package/lib/types/skill-provider.d.ts.map +1 -1
  108. package/lib/types/video-project.d.ts.map +1 -1
  109. package/lib/video-recap/CHANGELOG.md +108 -1
  110. package/lib/video-recap/README.en.md +18 -14
  111. package/lib/video-recap/README.md +18 -14
  112. package/lib/video-recap/manifest.json +297 -321
  113. package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
  114. package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
  115. package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
  116. package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
  117. package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
  118. package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
  119. package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
  120. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
  121. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
  122. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
  123. package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
  124. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
  125. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
  126. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
  127. package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
  128. package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
  129. package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
  130. package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
  131. package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
  132. package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
  133. package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
  134. package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
  135. package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
  136. package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
  137. package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
  138. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
  139. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
  140. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
  141. package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
  142. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
  143. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
  144. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
  145. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
  146. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
  147. package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
  148. package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
  149. package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
  150. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
  151. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
  152. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
  153. package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
  154. package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
  155. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
  156. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
  157. package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
  158. package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
  159. package/lib/video-recap/skills/video-recap/SKILL.md +24 -27
  160. package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
  161. package/lib/video-recap/skills/video-recap/references/config-playbook.md +8 -11
  162. package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
  163. package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
  164. package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
  165. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
  166. package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
  167. package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
  168. package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
  169. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +49 -17
  170. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
  171. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +86 -116
  172. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
  173. package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
  174. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
  175. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
  176. package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
  177. package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
  178. package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
  179. package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
  180. package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
  181. package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
  182. package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
  183. package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
  184. package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
  185. package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
  186. package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
  187. package/lib/video-recap/skills/video-script/SKILL.md +9 -7
  188. package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
  189. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
  190. package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
  191. package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
  192. package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
  193. package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
  194. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
  195. package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
  196. package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
  197. package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
  198. package/lib/video-recap/skills/video-script/scripts/review_runner.py +2 -10
  199. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
  200. package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
  201. package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
  202. package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
  203. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
  204. package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
  205. package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
  206. package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
  207. package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
  208. package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
  209. package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
  210. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
  211. package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
  212. package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
  213. package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
  214. package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
  215. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
  216. package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
  217. package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
  218. package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
  219. package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
  220. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
  221. package/lib/video-recap/skills/video-voiceover/SKILL.md +19 -12
  222. package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
  223. package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
  224. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +60 -135
  225. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
  226. package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
  227. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
  228. package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
  229. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
  230. package/package.json +45 -45
  231. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
  232. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
  233. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
  234. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
  235. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
  236. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
  237. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
  238. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
  239. package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
  240. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
  241. package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
  242. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
  243. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
  244. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
  245. package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
  246. package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
  247. package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
  248. package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
  249. package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
  250. package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
  251. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
  252. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
  253. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
  254. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
  255. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
  256. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
  257. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
  258. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
  259. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
  260. package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
  261. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
  262. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
  263. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
  264. /package/lib/video-recap/skills/video-cut/scripts/{narration_mapping.py → cut_qc.py} +0 -0
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env python3
2
- """Final post-render QC and golden-eval reports for video-recap.
2
+ """Final post-render QC report (final_qc.json) for video-recap.
3
3
 
4
- Local deterministic/report-only checks only: no network, no repair, no secrets.
4
+ Local deterministic/report-only checks only: no network, no repair. Every finding is
5
+ a blocker; ``ok`` / ``blocker_count`` are what ``--require-final-qc`` and the dashboard read.
6
+ The ffprobe result is trimmed to the stream/format fields the checks use, so container
7
+ tags copied from the source (comment/purl URLs) never reach the report.
5
8
  """
6
9
  from __future__ import annotations
7
10
 
@@ -14,22 +17,20 @@ from pathlib import Path
14
17
  from typing import Any
15
18
  from collections.abc import Callable, Mapping, Sequence
16
19
 
17
- import qc_contract
18
- from lib import load_json
20
+ from lib import load_json, read_json_object
19
21
 
22
+ SCHEMA_VERSION = 2
20
23
  FINAL_QC_ARTIFACT = "final_qc.json"
21
- GOLDEN_EVAL_ARTIFACT = "golden_eval.json"
22
- POST_RENDER_STAGE = "post_render"
23
- GOLDEN_STAGE = "golden"
24
- _COLLECT_ARTIFACTS = (
25
- "assembly_manifest.json",
26
- "assembly_qc.json",
27
- "visual_qc.json",
28
- "preflight_qc.json",
29
- "mimo_qc.json",
24
+ # Summarised in metadata only. video-assemble exits nonzero on a blocking assembly/visual
25
+ # QC before recap reaches final_qc, so their verdicts are recorded, not re-raised.
26
+ _COLLECT_ARTIFACTS = ("assembly_manifest.json", "assembly_qc.json", "visual_qc.json")
27
+ _SUMMARY_KEYS = ("schema_version", "verdict", "blocking", "blocking_codes")
28
+ _PROBE_STREAM_KEYS = (
29
+ "index", "codec_type", "codec_name", "profile", "pix_fmt", "width", "height",
30
+ "avg_frame_rate", "r_frame_rate", "fps", "frame_rate", "duration",
31
+ "sample_rate", "channels",
30
32
  )
31
- # video-assemble QC artifacts: {"verdict", "blocking", "blocking_codes": [...]}.
32
- _UPSTREAM_QC_ARTIFACTS = ("assembly_qc.json", "visual_qc.json")
33
+ _PROBE_FORMAT_KEYS = ("format_name", "duration", "size", "bit_rate", "nb_streams")
33
34
  ProbeRunner = Callable[[Path], Mapping[str, Any]]
34
35
 
35
36
 
@@ -45,14 +46,6 @@ def _resolve_in_work_dir(work_dir: Path, path: str | Path) -> Path:
45
46
  return p if p.is_absolute() else work_dir / p
46
47
 
47
48
 
48
- def _read_json_mapping(path: Path) -> Mapping[str, Any] | None:
49
- try:
50
- data = load_json(path)
51
- except (OSError, ValueError):
52
- return None
53
- return data if isinstance(data, Mapping) else None
54
-
55
-
56
49
  def _final_output_path(work_dir: Path, final_output: str | Path | None) -> Path | None:
57
50
  """The rendered final output: the caller's path, else assembly_manifest.final_output
58
51
  (video-assemble always writes it; output.mp4 is only the assembler's intermediate).
@@ -84,37 +77,23 @@ def _artifact_summary(work_dir: Path, name: str) -> dict[str, Any]:
84
77
  path = work_dir / name
85
78
  meta = _file_metadata(path, work_dir)
86
79
  if meta["exists"]:
87
- data = _read_json_mapping(path)
80
+ data = read_json_object(path)
88
81
  if data is None:
89
82
  meta["summary"] = {"invalid": True}
90
83
  else:
91
- meta["summary"] = {
92
- key: data.get(key)
93
- for key in ("schema_version", "artifact", "stage", "ok", "blocker_count", "finding_count")
94
- }
84
+ meta["summary"] = {key: data.get(key) for key in _SUMMARY_KEYS}
95
85
  return meta
96
86
 
97
87
 
98
- def _finding(*, finding_id: str, code: str, message: str, category: str = "schema_invalid",
99
- stage: str = POST_RENDER_STAGE, source: Mapping[str, Any] | None = None,
100
- evidence: Mapping[str, Any] | None = None,
101
- next_action: str = "manual_review") -> dict[str, Any]:
102
- return qc_contract.build_finding(
103
- finding_id=finding_id,
104
- stage=stage,
105
- severity="blocker",
106
- confidence="objective",
107
- sample_policy={"type": "deterministic"},
108
- category=category,
109
- code=code,
110
- message=message,
111
- deterministic=True,
112
- blocking=True,
113
- source=source,
114
- evidence=evidence,
115
- next_action=next_action,
116
- model_used="local_deterministic_final_qc_v1",
117
- )
88
+ def _finding(code: str, message: str, *, evidence: Mapping[str, Any],
89
+ next_action: str) -> dict[str, Any]:
90
+ """One deterministic blocker; next_action is a repair hint an agent can act on."""
91
+ return {"code": code, "message": message, "blocking": True,
92
+ "evidence": dict(evidence), "next_action": next_action}
93
+
94
+
95
+ def _pick(mapping: Mapping[str, Any], keys: Sequence[str]) -> dict[str, Any]:
96
+ return {key: mapping[key] for key in keys if key in mapping}
118
97
 
119
98
 
120
99
  def _run_ffprobe(path: Path) -> Mapping[str, Any]:
@@ -167,10 +146,10 @@ def _probe_metadata(path: Path, *, probe_fixture: Any = None, probe_runner: Prob
167
146
  raise TypeError("probe metadata streams must be an array of objects")
168
147
  if not isinstance(format_info, Mapping):
169
148
  raise TypeError("probe metadata format must be an object")
170
- normalized = dict(raw)
171
- normalized["streams"] = [dict(stream) for stream in streams]
172
- normalized["format"] = dict(format_info)
173
- return normalized, None
149
+ return {
150
+ "streams": [_pick(stream, _PROBE_STREAM_KEYS) for stream in streams],
151
+ "format": _pick(format_info, _PROBE_FORMAT_KEYS),
152
+ }, None
174
153
  except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, TypeError) as exc:
175
154
  return None, {"code": "probe_failed", "message": str(exc) or "ffprobe failed"}
176
155
 
@@ -218,17 +197,13 @@ def _probe_fps(video_stream: Mapping[str, Any] | None) -> tuple[float | None, st
218
197
  ])
219
198
 
220
199
 
221
- def _probe_contract_findings(probe: Mapping[str, Any], *, final_meta: Mapping[str, Any]) -> list[dict[str, Any]]:
200
+ def _probe_contract_findings(probe: Mapping[str, Any]) -> list[dict[str, Any]]:
222
201
  findings: list[dict[str, Any]] = []
223
- source = {"artifact": final_meta["path"]}
224
202
  video_stream = _first_video_stream(probe)
225
203
  if video_stream is None:
226
204
  findings.append(_finding(
227
- finding_id="final-qc-missing-video-stream",
228
- code="missing_video_stream",
229
- message="final output probe metadata has no video stream",
230
- category="stream",
231
- source=source,
205
+ "missing_video_stream",
206
+ "final output probe metadata has no video stream",
232
207
  evidence={"streams": probe.get("streams")},
233
208
  next_action="rerender_final_output_with_video_stream",
234
209
  ))
@@ -236,22 +211,16 @@ def _probe_contract_findings(probe: Mapping[str, Any], *, final_meta: Mapping[st
236
211
  _duration, problem, raw = _probe_duration(probe)
237
212
  if problem is not None:
238
213
  findings.append(_finding(
239
- finding_id=f"final-qc-{problem}-duration",
240
- code=f"{problem}_duration",
241
- 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",
242
- category="duration",
243
- source=source,
214
+ f"{problem}_duration",
215
+ "final output probe metadata is missing a positive finite duration" if problem == "missing" else "final output probe metadata duration is not positive and finite",
244
216
  evidence={"duration": raw},
245
217
  next_action="rerender_final_output_with_valid_duration",
246
218
  ))
247
219
 
248
220
  if not (video_stream or {}).get("codec_name"):
249
221
  findings.append(_finding(
250
- finding_id="final-qc-missing-codec",
251
- code="missing_codec",
252
- message="final output probe metadata is missing a video codec",
253
- category="stream",
254
- source=source,
222
+ "missing_codec",
223
+ "final output probe metadata is missing a video codec",
255
224
  evidence={"video_stream": video_stream},
256
225
  next_action="rerender_final_output_with_video_codec",
257
226
  ))
@@ -259,45 +228,14 @@ def _probe_contract_findings(probe: Mapping[str, Any], *, final_meta: Mapping[st
259
228
  _fps, problem, raw = _probe_fps(video_stream)
260
229
  if problem is not None:
261
230
  findings.append(_finding(
262
- finding_id=f"final-qc-{problem}-fps",
263
- code=f"{problem}_fps",
264
- 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",
265
- category="stream",
266
- source=source,
231
+ f"{problem}_fps",
232
+ "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",
267
233
  evidence={"fps": raw, "video_stream": video_stream},
268
234
  next_action="rerender_final_output_with_valid_fps",
269
235
  ))
270
236
  return findings
271
237
 
272
238
 
273
- def _upstream_blockers(work_dir: Path, artifact_name: str) -> list[dict[str, Any]]:
274
- """Roll a blocking video-assemble QC artifact into one final blocker per blocking code."""
275
- path = work_dir / artifact_name
276
- if not path.exists():
277
- return []
278
- data = _read_json_mapping(path)
279
- if data is None: # unreadable upstream QC is itself a deterministic blocker
280
- return [_finding(
281
- finding_id=f"final-qc-invalid-upstream-{artifact_name}",
282
- code=f"upstream_{artifact_name.replace('.', '_')}_schema_invalid",
283
- message=f"{artifact_name} is not a valid deterministic QC report",
284
- source={"artifact": artifact_name},
285
- evidence={"schema_invalid": True},
286
- next_action="regenerate_upstream_qc",
287
- )]
288
- return [
289
- _finding(
290
- finding_id=f"final-qc-upstream-{artifact_name}-{idx}",
291
- code=f"upstream_{artifact_name.replace('.', '_')}_{code}",
292
- message=f"{artifact_name} reported {code}",
293
- source={"artifact": artifact_name},
294
- evidence={"upstream_code": code, "upstream_verdict": data["verdict"]},
295
- next_action="fix_upstream_qc_blocker",
296
- )
297
- for idx, code in enumerate(data["blocking_codes"])
298
- ]
299
-
300
-
301
239
  def collect_metadata(work_dir: str | Path, *, final_output: str | Path | None = None,
302
240
  probe_fixture: Any = None, probe_runner: ProbeRunner | None = None) -> dict[str, Any]:
303
241
  root = Path(work_dir)
@@ -312,11 +250,30 @@ def collect_metadata(work_dir: str | Path, *, final_output: str | Path | None =
312
250
  "artifacts": {name: _artifact_summary(root, name) for name in _COLLECT_ARTIFACTS},
313
251
  "probe": probe,
314
252
  "probe_error": probe_error,
315
- # mimo_qc.json is advisory metadata only and is not rolled into final blockers.
253
+ "warnings": _render_warnings(root, selected),
316
254
  "auto_repair": False,
317
255
  }
318
256
 
319
257
 
258
+ def _render_warnings(work_dir: Path, final_output: Path | None) -> list[dict[str, Any]]:
259
+ """Non-blocking render facts an agent must relay, e.g. a default subtitle burn that
260
+ degraded to the .srt sidecar because ffmpeg lacks libass (from visual_qc.json).
261
+
262
+ Only relayed when assembly_manifest.json says video-assemble rendered `final_output`:
263
+ a mode that delivers without it (dub) would otherwise inherit a stale visual_qc.json
264
+ left in the work_dir by an earlier full/cut run."""
265
+ manifest = read_json_object(work_dir / "assembly_manifest.json") or {}
266
+ rendered = manifest.get("final_output")
267
+ if final_output is None or not isinstance(rendered, str) or \
268
+ _resolve_in_work_dir(work_dir, rendered).resolve() != final_output.resolve():
269
+ return []
270
+ data = read_json_object(work_dir / "visual_qc.json")
271
+ warnings = (data or {}).get("warnings")
272
+ if not isinstance(warnings, list):
273
+ return []
274
+ return [dict(item) for item in warnings if isinstance(item, Mapping) and item.get("code")]
275
+
276
+
320
277
  def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
321
278
  probe_fixture: Any = None, probe_runner: ProbeRunner | None = None,
322
279
  decode_runner: Callable[[Path], tuple[bool | None, str | None]] | None = None) -> dict[str, Any]:
@@ -327,36 +284,27 @@ def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
327
284
  findings: list[dict[str, Any]] = []
328
285
  if not final_meta["exists"]:
329
286
  findings.append(_finding(
330
- finding_id="final-qc-missing-final-output",
331
- code="missing_final_output",
332
- message="final output mp4 is missing",
333
- category="missing_artifact",
334
- source={"artifact": str(final_output) if final_output else "final_output"},
287
+ "missing_final_output",
288
+ "final output mp4 is missing",
335
289
  evidence={"final_output": final_meta},
336
290
  next_action="render_final_output",
337
291
  ))
338
292
  elif final_meta["bytes"] == 0:
339
293
  findings.append(_finding(
340
- finding_id="final-qc-empty-final-output",
341
- code="empty_final_output",
342
- message="final output mp4 is empty",
343
- category="missing_artifact",
344
- source={"artifact": final_meta["path"]},
294
+ "empty_final_output",
295
+ "final output mp4 is empty",
345
296
  evidence={"final_output": final_meta},
346
297
  next_action="rerender_final_output",
347
298
  ))
348
299
  elif metadata["probe_error"] is not None:
349
300
  findings.append(_finding(
350
- finding_id="final-qc-probe-failed",
351
- code="probe_failed",
352
- message="ffprobe failed or was unavailable for existing non-empty final output",
353
- category="stream",
354
- source={"artifact": final_meta["path"]},
301
+ "probe_failed",
302
+ "ffprobe failed or was unavailable for existing non-empty final output",
355
303
  evidence=metadata["probe_error"],
356
304
  next_action="inspect_or_rerender_final_output",
357
305
  ))
358
306
  else:
359
- probe_findings = _probe_contract_findings(metadata["probe"], final_meta=final_meta)
307
+ probe_findings = _probe_contract_findings(metadata["probe"])
360
308
  findings.extend(probe_findings)
361
309
  # Header probing cannot see a container-valid but media-truncated/corrupt payload.
362
310
  # A cheap tail decode catches it; skip for offline fixtures and when ffmpeg is absent
@@ -365,116 +313,20 @@ def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
365
313
  decode_ok, decode_detail = (decode_runner or _tail_decode_check)(selected)
366
314
  if decode_ok is False:
367
315
  findings.append(_finding(
368
- finding_id="final-qc-undecodable-stream",
369
- code="undecodable_stream",
370
- message="final output tail failed to decode (truncated or corrupt media payload)",
371
- category="stream",
372
- source={"artifact": final_meta["path"]},
316
+ "undecodable_stream",
317
+ "final output tail failed to decode (truncated or corrupt media payload)",
373
318
  evidence={"decode_error": decode_detail},
374
- next_action="rerender_final_output",
319
+ next_action="rerender_final_output",
375
320
  ))
376
- for name in _UPSTREAM_QC_ARTIFACTS:
377
- findings.extend(_upstream_blockers(root, name))
378
- return qc_contract.build_report(
379
- artifact=FINAL_QC_ARTIFACT,
380
- stage=POST_RENDER_STAGE,
381
- findings=findings,
382
- metadata=metadata,
383
- )
384
-
385
-
386
- def _load_or_build_final_qc(work_dir: Path, final_qc_report: Mapping[str, Any] | None) -> dict[str, Any]:
387
- if final_qc_report is not None:
388
- return dict(final_qc_report)
389
- path = work_dir / FINAL_QC_ARTIFACT
390
- return load_json(path) if path.exists() else build_final_qc(work_dir)
391
-
392
-
393
- def build_golden_eval(work_dir: str | Path, final_qc_report: Mapping[str, Any] | None = None,
394
- golden_fixture: Any = None) -> dict[str, Any]:
395
- root = Path(work_dir)
396
- final_report = _load_or_build_final_qc(root, final_qc_report)
397
- fixture = _load_fixture(golden_fixture) if golden_fixture is not None else {}
398
- metadata = {
399
- "work_dir": str(root),
400
- "fixture": fixture,
401
- "final_qc": {key: final_report[key] for key in ("ok", "blocker_count", "artifact", "stage")},
402
- "auto_repair": False,
321
+ return {
322
+ "schema_version": SCHEMA_VERSION,
323
+ "artifact": FINAL_QC_ARTIFACT,
324
+ "ok": not findings,
325
+ "blocker_count": len(findings),
326
+ "finding_count": len(findings),
327
+ "findings": findings,
328
+ "metadata": metadata,
403
329
  }
404
- findings: list[dict[str, Any]] = []
405
- expected_ok = fixture.get("expected_final_qc_ok", True)
406
- if final_report["ok"] != expected_ok:
407
- findings.append(_finding(
408
- finding_id="golden-final-qc-ok-mismatch",
409
- stage=GOLDEN_STAGE,
410
- code="expected_final_qc_ok_mismatch",
411
- message="final_qc ok state does not match golden expectation",
412
- category="schema_invalid",
413
- source={"artifact": FINAL_QC_ARTIFACT},
414
- evidence={"expected": expected_ok, "actual": final_report["ok"]},
415
- next_action="fix_final_qc_blockers",
416
- ))
417
- final_meta = final_report["metadata"]["final_output"]
418
- probe = final_report["metadata"]["probe"]
419
- duration = _probe_duration(probe)[0] if probe else None
420
- video_stream = _first_video_stream(probe) if probe else None
421
- codec = video_stream.get("codec_name") if video_stream else None
422
- min_duration = fixture.get("min_duration")
423
- if min_duration is not None and (duration is None or duration < min_duration):
424
- findings.append(_finding(
425
- finding_id="golden-min-duration-mismatch",
426
- stage=GOLDEN_STAGE,
427
- code="min_duration_mismatch",
428
- message="final output duration is below golden minimum",
429
- category="duration",
430
- source={"artifact": final_meta["path"]},
431
- evidence={"expected_min_duration": min_duration, "actual_duration": duration},
432
- next_action="adjust_render_duration",
433
- ))
434
- max_duration = fixture.get("max_duration")
435
- if max_duration is not None and (duration is None or duration > max_duration):
436
- findings.append(_finding(
437
- finding_id="golden-max-duration-mismatch",
438
- stage=GOLDEN_STAGE,
439
- code="max_duration_mismatch",
440
- message="final output duration is above golden maximum",
441
- category="duration",
442
- source={"artifact": final_meta["path"]},
443
- evidence={"expected_max_duration": max_duration, "actual_duration": duration},
444
- next_action="adjust_render_duration",
445
- ))
446
- expected_codec = fixture.get("expected_codec")
447
- if expected_codec is not None and codec != expected_codec:
448
- findings.append(_finding(
449
- finding_id="golden-codec-mismatch",
450
- stage=GOLDEN_STAGE,
451
- code="codec_mismatch",
452
- message="final output video codec does not match golden expectation",
453
- category="stream",
454
- source={"artifact": final_meta["path"]},
455
- evidence={"expected_codec": expected_codec, "actual_codec": codec},
456
- next_action="adjust_render_codec",
457
- ))
458
- for idx, name in enumerate(fixture.get("required_artifacts", [])):
459
- artifact_path = root / name
460
- if not artifact_path.is_file() or artifact_path.stat().st_size == 0:
461
- findings.append(_finding(
462
- finding_id=f"golden-required-artifact-missing-{idx}",
463
- stage=GOLDEN_STAGE,
464
- code="required_artifact_missing",
465
- message="golden fixture requires an artifact that is missing or empty",
466
- category="missing_artifact",
467
- source={"artifact": name},
468
- evidence={"required_artifact": name},
469
- next_action="produce_required_artifact",
470
- ))
471
- metadata["observed"] = {"duration": duration, "codec": codec, "final_output": final_meta}
472
- return qc_contract.build_report(
473
- artifact=GOLDEN_EVAL_ARTIFACT,
474
- stage=GOLDEN_STAGE,
475
- findings=findings,
476
- metadata=metadata,
477
- )
478
330
 
479
331
 
480
332
  def _write_report(path: Path, report: Mapping[str, Any]) -> None:
@@ -482,43 +334,26 @@ def _write_report(path: Path, report: Mapping[str, Any]) -> None:
482
334
 
483
335
 
484
336
  def run(work_dir: str | Path, final_output: str | Path | None = None,
485
- probe_fixture: Any = None, golden_fixture: Any = None,
486
- probe_runner: ProbeRunner | None = None, only: str = "all") -> dict[str, Any]:
337
+ probe_fixture: Any = None, probe_runner: ProbeRunner | None = None) -> dict[str, Any]:
487
338
  root = Path(work_dir)
488
339
  root.mkdir(parents=True, exist_ok=True)
489
- mode = {"final": "final_qc", "golden": "golden_eval"}.get(only, only)
490
- if mode not in {"all", "final_qc", "golden_eval"}:
491
- raise ValueError("only must be one of all, final_qc, golden_eval, final, golden")
492
- result: dict[str, Any] = {"work_dir": str(root), "written": []}
493
- final_report: dict[str, Any] | None = None
494
- if mode in {"all", "final_qc"}:
495
- final_report = build_final_qc(root, final_output=final_output, probe_fixture=probe_fixture, probe_runner=probe_runner)
496
- _write_report(root / FINAL_QC_ARTIFACT, final_report)
497
- result["final_qc"] = {"ok": final_report["ok"], "blocker_count": final_report["blocker_count"]}
498
- result["written"].append(FINAL_QC_ARTIFACT)
499
- if mode in {"all", "golden_eval"}:
500
- golden_report = build_golden_eval(root, final_qc_report=final_report, golden_fixture=golden_fixture)
501
- _write_report(root / GOLDEN_EVAL_ARTIFACT, golden_report)
502
- result["golden_eval"] = {"ok": golden_report["ok"], "blocker_count": golden_report["blocker_count"]}
503
- result["written"].append(GOLDEN_EVAL_ARTIFACT)
504
- return result
340
+ report = build_final_qc(root, final_output=final_output, probe_fixture=probe_fixture, probe_runner=probe_runner)
341
+ _write_report(root / FINAL_QC_ARTIFACT, report)
342
+ return {
343
+ "work_dir": str(root),
344
+ "written": [FINAL_QC_ARTIFACT],
345
+ "final_qc": {"ok": report["ok"], "blocker_count": report["blocker_count"],
346
+ "warnings": [item["code"] for item in report["metadata"]["warnings"]]},
347
+ }
505
348
 
506
349
 
507
350
  def main(argv: Sequence[str] | None = None) -> int:
508
- ap = argparse.ArgumentParser(description="Write final_qc.json and golden_eval.json for a video-recap work_dir.")
351
+ ap = argparse.ArgumentParser(description="Write final_qc.json for a video-recap work_dir.")
509
352
  ap.add_argument("--work-dir", required=True)
510
353
  ap.add_argument("--final-output", default=None)
511
354
  ap.add_argument("--probe-fixture", default=None)
512
- ap.add_argument("--golden-fixture", default=None)
513
- ap.add_argument("--only", choices=["all", "final_qc", "golden_eval", "final", "golden"], default="all")
514
355
  args = ap.parse_args(argv)
515
- summary = run(
516
- args.work_dir,
517
- final_output=args.final_output,
518
- probe_fixture=args.probe_fixture,
519
- golden_fixture=args.golden_fixture,
520
- only=args.only,
521
- )
356
+ summary = run(args.work_dir, final_output=args.final_output, probe_fixture=args.probe_fixture)
522
357
  print(json.dumps(summary, ensure_ascii=False, sort_keys=True))
523
358
  return 0
524
359
 
@@ -1,9 +1,9 @@
1
- """Self-contained config and MiMo client for the video-recap orchestrator."""
1
+ """Self-contained config, JSON, file-identity and ffmpeg-capability helpers for the video-recap orchestrator."""
2
2
  import json
3
3
  import os
4
- import socket
5
- import urllib.error
6
- import urllib.request
4
+ import re
5
+ import shutil
6
+ import subprocess
7
7
  from pathlib import Path
8
8
 
9
9
 
@@ -22,6 +22,8 @@ DEFAULT_MIMO_TTS_MODEL = "mimo-v2.5-tts" # text-to-speech
22
22
  DEFAULT_FISH_TTS_API_URL = "https://api.fish.audio/v1/tts"
23
23
  DEFAULT_FISH_TTS_MODEL = "s2.1-pro-free"
24
24
  DEFAULT_FISH_TTS_REFERENCE_ID = "5653cea4ac83480aaf2bf45406556185"
25
+ # Accepted values of TTS_PROVIDER / --tts-provider, shared by recap.py and doctor.py.
26
+ TTS_PROVIDERS = ("auto", "mimo-tts", "fish-audio", "index-tts")
25
27
 
26
28
 
27
29
  def normalize_api_url(raw_url):
@@ -83,6 +85,81 @@ def load_json(path):
83
85
  return json.loads(Path(path).read_text(encoding="utf-8"))
84
86
 
85
87
 
88
+ def read_json_object(path):
89
+ """A JSON object file as a dict, or None when it is unreadable, malformed or not an object."""
90
+ try:
91
+ data = load_json(path)
92
+ except (OSError, ValueError):
93
+ return None
94
+ return data if isinstance(data, dict) else None
95
+
96
+
97
+ # ── 文件身份与 id ─────────────────────────────────────────────────────
98
+
99
+ def file_identity(path):
100
+ """``{size, mtime_ns}`` of a file: the identity recap records and compares for a source
101
+ video or adopted artifact. A file rewritten in place gets a new mtime_ns."""
102
+ st = os.stat(os.fspath(path))
103
+ return {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
104
+
105
+
106
+ def _slug(text, max_len=48):
107
+ raw = Path(text).stem.lower()
108
+ raw = re.sub(r"[^a-z0-9\u4e00-\u9fff._-]+", "-", raw).strip("-._")
109
+ return (raw or "material")[:max_len].strip("-._") or "material"
110
+
111
+
112
+ def _id_stem(source_path, max_len=32):
113
+ raw = re.sub(r"[^a-z0-9]+", "-", Path(source_path).stem.lower()).strip("-")
114
+ return (raw or "source")[:max_len].strip("-") or "source"
115
+
116
+
117
+ def source_id_for(source_path):
118
+ """``src_<stem>_<size>``: readable, stable across runs, and distinct for a different cut
119
+ of the same title (the size changes)."""
120
+ return f"src_{_id_stem(source_path)}_{os.stat(os.fspath(source_path)).st_size}"
121
+
122
+
123
+ def material_id_for(source_path, source_identity):
124
+ return f"{_slug(str(source_path))}-{source_identity['size']}"
125
+
126
+
127
+ # ── ffmpeg 能力 ───────────────────────────────────────────────────────
128
+
129
+ def ffmpeg_filters():
130
+ """Filters the installed ffmpeg lists; empty when ffmpeg is absent.
131
+
132
+ A present ffmpeg whose `-filters` fails or hangs is an environment fault and raises,
133
+ so it is never misreported downstream as "filter absent"."""
134
+ ffmpeg = shutil.which("ffmpeg")
135
+ if not ffmpeg:
136
+ return set()
137
+ try:
138
+ result = subprocess.run(
139
+ [ffmpeg, "-hide_banner", "-filters"], text=True, capture_output=True, timeout=20
140
+ )
141
+ except (OSError, subprocess.SubprocessError) as exc:
142
+ raise RuntimeError(f"`ffmpeg -filters` failed or hung: {exc}") from exc
143
+ if result.returncode != 0:
144
+ detail = (result.stderr or result.stdout or "").strip()[:300]
145
+ raise RuntimeError(f"`ffmpeg -filters` failed (exit {result.returncode}): {detail}")
146
+ filters = set()
147
+ for line in result.stdout.splitlines():
148
+ parts = line.split()
149
+ if len(parts) >= 2 and parts[0] and parts[0][0] in ".TSCAPN|":
150
+ filters.add(parts[1])
151
+ return filters
152
+
153
+
154
+ def ffmpeg_has_subtitles_filter():
155
+ """True when this ffmpeg can burn subtitles — its filter list includes the libass
156
+ `subtitles` filter. The render burns even the .ass file through `subtitles=` (see
157
+ video-assemble assemble.py:_subtitle_burn_filter), so this — not the `ass` filter — is
158
+ the exact capability `--burn-subtitles` needs. The orchestrator preflight
159
+ (recap_runtime.py) uses it to fail fast before any API spend; doctor.py reports it."""
160
+ return "subtitles" in ffmpeg_filters()
161
+
162
+
86
163
  # Single MiMo credential powers ASR + VLM + TTS. Per-capability overrides
87
164
  # (MIMO_VIDEO_API_KEY / MIMO_TTS_API_KEY / MIMO_ASR_API_KEY and their *_API_URL forms)
88
165
  # are optional and fall back to MIMO_API_KEY / MIMO_API_URL. Token-Plan keys (tp-*) auto-
@@ -114,24 +191,6 @@ CONFIG = {
114
191
  "api_url_source": "env" if os.environ.get("MIMO_API_URL") else "default",
115
192
  "api_key": _mimo_api_key,
116
193
  "api_env_var": "MIMO_API_KEY",
117
- # Read through a COPY of CONFIG by qc.mimo_evidence._effective_config /
118
- # safe_mimo_config, which is why neither a `CONFIG.get(...)` grep nor live-dict
119
- # instrumentation sees them. They drive the QC model fallback chain and the
120
- # provenance recorded in the QC report.
121
- "mimo_qc_model": os.environ.get("MIMO_QC_MODEL") or os.environ.get("MIMO_VIDEO_MODEL")
122
- or os.environ.get("MIMO_MODEL", DEFAULT_MIMO_MODEL),
123
- "mimo_qc_model_source": "env" if os.environ.get("MIMO_QC_MODEL") else "fallback",
124
- "mimo_model": os.environ.get("MIMO_MODEL", DEFAULT_MIMO_MODEL),
125
- "mimo_model_source": "env" if os.environ.get("MIMO_MODEL") else "default",
126
- "mimo_api_url": normalize_api_url(_raw_api_url),
127
- "mimo_api_url_source": "env" if os.environ.get("MIMO_API_URL") else "default",
128
- "mimo_video_api_url_source": "env" if (
129
- os.environ.get("MIMO_VIDEO_API_URL") or os.environ.get("MIMO_API_URL")
130
- ) else "default",
131
- "mimo_disable_thinking": env_bool("MIMO_DISABLE_THINKING", True),
132
- "mimo_disable_thinking_source": "env" if os.environ.get("MIMO_DISABLE_THINKING") else "default",
133
- "mimo_media_resolution": os.environ.get("MIMO_MEDIA_RESOLUTION", "default"),
134
- "mimo_media_resolution_source": "env" if os.environ.get("MIMO_MEDIA_RESOLUTION") else "default",
135
194
  "mimo_api_key": _mimo_api_key,
136
195
  "mimo_video_api_url": normalize_api_url(_raw_mimo_video_api_url),
137
196
  "mimo_video_api_key": _mimo_video_api_key,
@@ -170,51 +229,3 @@ CONFIG = {
170
229
  ),
171
230
  "vlm_workers": env_int("VLM_WORKERS", 8, minimum=1), # VLM 并行分析线程数
172
231
  }
173
-
174
-
175
- class MiMoQCRequestError(RuntimeError):
176
- """Sanitized, fail-open transport error for the advisory QC request."""
177
-
178
-
179
- def mimo_qc_api_call(payload, *, config=None, timeout=60):
180
- """Send exactly one OpenAI-compatible MiMo request for one QC stage.
181
-
182
- Deliberately no retries: the QC feature is advisory, and the orchestrator's
183
- one-request-per-stage contract is more important than hiding 429/timeout
184
- behavior. Callers turn every failure into a non-blocking status report.
185
- """
186
- cfg = dict(CONFIG)
187
- if config:
188
- cfg.update(config)
189
- api_key = cfg.get("mimo_video_api_key") or cfg.get("mimo_api_key") or cfg.get("api_key")
190
- if not api_key:
191
- raise MiMoQCRequestError("missing_key")
192
- endpoint = normalize_api_url(
193
- cfg.get("mimo_video_api_url") or cfg.get("mimo_api_url") or cfg.get("api_url")
194
- )
195
- request = urllib.request.Request(
196
- endpoint,
197
- data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
198
- headers={
199
- "Content-Type": "application/json",
200
- "User-Agent": "video-recap/mimo-qc",
201
- "api-key": api_key,
202
- },
203
- method="POST",
204
- )
205
- try:
206
- with urllib.request.urlopen(request, timeout=timeout) as response:
207
- raw = response.read().decode("utf-8")
208
- except urllib.error.HTTPError as exc:
209
- raise MiMoQCRequestError(f"http_{exc.code}") from None
210
- except (TimeoutError, socket.timeout):
211
- raise MiMoQCRequestError("timeout") from None
212
- except (urllib.error.URLError, OSError):
213
- raise MiMoQCRequestError("network_error") from None
214
- try:
215
- result = json.loads(raw)
216
- except ValueError:
217
- raise MiMoQCRequestError("invalid_json") from None
218
- if not isinstance(result, dict):
219
- raise MiMoQCRequestError("invalid_response")
220
- return result