@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.
- package/README.md +17 -11
- package/lib/client.js +85 -35
- package/lib/client.js.map +3 -3
- package/lib/drama/manifest.json +41 -36
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +340 -13
- package/lib/drama/skills/short-drama-assets/references/voice-direction.md +4 -2
- package/lib/drama/skills/short-drama-edit/SKILL.md +32 -12
- package/lib/drama/skills/short-drama-edit/references/still-cuts.md +90 -0
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1035 -145
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +48 -0
- package/lib/drama/skills/short-drama-produce/SKILL.md +6 -1
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +12 -0
- package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +138 -9
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +6 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +3 -1
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -0
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +14 -0
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +19 -6
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -0
- package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +4 -1
- package/lib/index.js +16 -7
- package/lib/index.js.map +2 -2
- package/lib/novel-to-game/README.md +98 -44
- package/lib/novel-to-game/README_ZH.md +100 -46
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +27 -202
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +23 -115
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/chamber.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_night.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_rain.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/garden.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/hall.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/kitchen.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street_dawn.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/temple.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/moon.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +326 -2503
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +71 -4
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/adv.js +221 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +113 -257
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +511 -3026
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/menus.js +277 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/petals.js +67 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/stage.js +180 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/store.js +70 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common2.js +204 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common3.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/dates.js +186 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/index.js +122 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/meng.js +190 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pan.js +212 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pinger.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/xuee.js +199 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/yue.js +214 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -2
- package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +182 -45
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +7 -17
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +9 -9
- package/lib/novel-to-game/manifest.json +303 -78
- package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +7 -2
- package/lib/novel-to-game/skills/game-concept/references/concept-method.md +2 -0
- package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +2 -0
- package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +9 -0
- package/lib/types/client/drama-production-view.d.ts +11 -0
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/file-activity.d.ts +1 -1
- package/lib/types/client/file-activity.d.ts.map +1 -1
- package/lib/types/client/production-prompts.d.ts +7 -1
- package/lib/types/client/production-prompts.d.ts.map +1 -1
- package/lib/types/client/production-runtime.d.ts +7 -2
- package/lib/types/client/production-runtime.d.ts.map +1 -1
- package/lib/types/drama-adapters.d.ts.map +1 -1
- package/lib/types/skill-provider.d.ts.map +1 -1
- package/lib/types/video-project.d.ts.map +1 -1
- package/lib/video-recap/CHANGELOG.md +108 -1
- package/lib/video-recap/README.en.md +18 -14
- package/lib/video-recap/README.md +18 -14
- package/lib/video-recap/manifest.json +297 -321
- package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
- package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
- package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
- package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
- package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
- package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
- package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
- package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
- package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
- package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
- package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
- package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
- package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
- package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
- package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
- package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
- package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
- package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
- package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
- package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
- package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
- package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
- package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
- package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
- package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
- package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
- package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
- package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
- package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
- package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
- package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
- package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
- package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
- package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
- package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
- package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
- package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
- package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
- package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
- package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
- package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
- package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
- package/lib/video-recap/skills/video-recap/SKILL.md +24 -27
- package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
- package/lib/video-recap/skills/video-recap/references/config-playbook.md +8 -11
- package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
- package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
- package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
- package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
- package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
- package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
- package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
- package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +49 -17
- package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
- package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +86 -116
- package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
- package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
- package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
- package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
- package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
- package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
- package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
- package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
- package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
- package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
- package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
- package/lib/video-recap/skills/video-script/SKILL.md +9 -7
- package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
- package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
- package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
- package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
- package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
- package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
- package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
- package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
- package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
- package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
- package/lib/video-recap/skills/video-script/scripts/review_runner.py +2 -10
- package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
- package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
- package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
- package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
- package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
- package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
- package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
- package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
- package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
- package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
- package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
- package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
- package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
- package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
- package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
- package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
- package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
- package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
- package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
- package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
- package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
- package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
- package/lib/video-recap/skills/video-voiceover/SKILL.md +19 -12
- package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
- package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
- package/lib/video-recap/skills/video-voiceover/scripts/dub.py +60 -135
- package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
- package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
- package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
- package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
- package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
- package/package.json +45 -45
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
- package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
- package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
- package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
- package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
- package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
- package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
- package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
- package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
- package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
- package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
- package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
- package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
- package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
- /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
|
|
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
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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]
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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"]
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
319
|
+
next_action="rerender_final_output",
|
|
375
320
|
))
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
findings
|
|
382
|
-
|
|
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,
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
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
|
|
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
|
|
5
|
-
import
|
|
6
|
-
import
|
|
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
|