@oh-story/dsh 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/lib/client.js +35 -14
- package/lib/client.js.map +2 -2
- package/lib/drama/manifest.json +165 -69
- package/lib/drama/skills/short-drama/SKILL.md +5 -1
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +126 -9
- package/lib/drama/skills/short-drama/scripts/project_tool.py +11 -0
- package/lib/drama/skills/short-drama-assets/references/character-and-look.md +18 -0
- package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +10 -1
- package/lib/drama/skills/short-drama-edit/SKILL.md +148 -0
- package/lib/drama/skills/short-drama-edit/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +60 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +15 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
- package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
- package/lib/drama/skills/short-drama-edit/references/cut-craft.md +81 -0
- package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +58 -0
- package/lib/drama/skills/short-drama-edit/references/generated-footage.md +96 -0
- package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +113 -0
- package/lib/drama/skills/short-drama-edit/references/stage-contract.md +55 -0
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1041 -0
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +456 -0
- package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +5 -0
- package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +3 -0
- package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
- package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +4 -1
- package/lib/drama/skills/short-drama-produce/SKILL.md +24 -3
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +28 -0
- package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +24 -5
- package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +7 -0
- package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +18 -1
- package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +181 -2
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +57 -2
- package/lib/drama/skills/short-drama-review/SKILL.md +3 -0
- package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +20 -0
- package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +36 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +7 -0
- package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +4 -0
- package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +39 -0
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -0
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -1
- package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +101 -6
- package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +7 -1
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +35 -0
- package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +3 -1
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +13 -3
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +35 -4
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -1
- package/lib/drama/skills/short-drama-write/SKILL.md +13 -2
- package/lib/drama/skills/short-drama-write/references/screenplay-format.md +6 -0
- package/lib/drama/skills/short-drama-write/references/stage-contract.md +1 -0
- package/lib/index.js +4 -2
- package/lib/index.js.map +2 -2
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/manifest.json +28 -28
- package/lib/novel-to-game/skills/game-art-direction/SKILL.md +6 -27
- package/lib/novel-to-game/skills/game-build/SKILL.md +12 -29
- package/lib/novel-to-game/skills/game-concept/SKILL.md +10 -26
- package/lib/novel-to-game/skills/game-qa/SKILL.md +5 -15
- package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +1 -1
- package/lib/novel-to-game/skills/game-world-design/SKILL.md +19 -28
- package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +1 -2
- package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +6 -9
- package/lib/novel-to-game/skills/novel-to-game/SKILL.md +6 -11
- package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -4
- package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +1 -3
- package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +3 -5
- package/lib/oh-story/manifest.json +292 -172
- package/lib/oh-story/roles/character-designer.md +1 -1
- package/lib/oh-story/roles/narrative-writer.md +56 -242
- package/lib/oh-story/roles/story-architect.md +1 -1
- package/lib/oh-story/skills/story/SKILL.md +1 -1
- package/lib/oh-story/skills/story/VERSION +1 -1
- package/lib/oh-story/skills/story/references/author-memory.md +10 -8
- package/lib/oh-story/skills/story-deslop/SKILL.md +20 -182
- package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +19 -47
- package/lib/oh-story/skills/story-deslop/references/author-memory.md +10 -8
- package/lib/oh-story/skills/story-deslop/references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +154 -0
- package/lib/oh-story/skills/story-deslop/references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +6 -1
- package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +39 -20
- package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +10 -5
- package/lib/oh-story/skills/story-deslop/scripts/style-whitelist.js +39 -0
- package/lib/oh-story/skills/story-import/SKILL.md +2 -2
- package/lib/oh-story/skills/story-import/references/format-and-structure.md +2 -2
- package/lib/oh-story/skills/story-import/references/tracking-initialization.md +31 -0
- package/lib/oh-story/skills/story-import/references/tracking-transaction.md +2 -29
- package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +176 -12
- package/lib/oh-story/skills/story-long-analyze/SKILL.md +1 -1
- package/lib/oh-story/skills/story-long-write/SKILL.md +20 -193
- package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +19 -47
- package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +62 -7
- package/lib/oh-story/skills/story-long-write/references/author-memory.md +10 -8
- package/lib/oh-story/skills/story-long-write/references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +20 -0
- package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +6 -4
- package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/long-format.md +2 -0
- package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +2 -0
- package/lib/oh-story/skills/story-long-write/references/project-files.md +108 -0
- package/lib/oh-story/skills/story-long-write/references/reference-index.md +87 -0
- package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/style-craft.md +9 -29
- package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +31 -0
- package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -29
- package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +51 -52
- package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +8 -7
- package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +36 -15
- package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +22 -0
- package/lib/oh-story/skills/story-long-write/references/writing-craft.md +69 -2
- package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +410 -0
- package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +6 -1
- package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +39 -20
- package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +176 -12
- package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +90 -23
- package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +10 -5
- package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +283 -0
- package/lib/oh-story/skills/story-long-write/scripts/style-whitelist.js +39 -0
- package/lib/oh-story/skills/story-review/SKILL.md +13 -3
- package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +19 -47
- package/lib/oh-story/skills/story-review/references/author-memory.md +10 -8
- package/lib/oh-story/skills/story-review/references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +6 -4
- package/lib/oh-story/skills/story-review/references/review-quality.md +2 -2
- package/lib/oh-story/skills/story-review/references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-review/references/tracking-initialization.md +31 -0
- package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -29
- package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +6 -1
- package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +39 -20
- package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +10 -5
- package/lib/oh-story/skills/story-review/scripts/style-whitelist.js +39 -0
- package/lib/oh-story/skills/story-setup/SKILL.md +17 -10
- package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +19 -47
- package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +154 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +6 -4
- package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +2 -2
- package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +69 -2
- package/lib/oh-story/skills/story-setup/references/diagnostics.md +38 -0
- package/lib/oh-story/skills/story-short-analyze/references/analysis-writing-techniques.md +1 -1
- package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +2 -2
- package/lib/oh-story/skills/story-short-write/SKILL.md +18 -186
- package/lib/oh-story/skills/story-short-write/references/author-memory.md +10 -8
- package/lib/oh-story/skills/story-short-write/references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +60 -0
- package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +6 -4
- package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +5 -5
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +4 -4
- package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +5 -5
- package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/reference-index.md +53 -0
- package/lib/oh-story/skills/story-short-write/references/short-craft.md +12 -14
- package/lib/oh-story/skills/story-short-write/references/short-deslop.md +10 -10
- package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -1
- package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-short-write/references/workflow-design.md +77 -0
- package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +5 -5
- package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +6 -1
- package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +39 -20
- package/lib/oh-story/skills/story-short-write/scripts/check-delivery-contract.js +34 -5
- package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +90 -23
- package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +10 -5
- package/lib/oh-story/skills/story-short-write/scripts/style-whitelist.js +39 -0
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/production-prompts.d.ts.map +1 -1
- package/lib/types/client/production-runtime.d.ts +17 -0
- package/lib/types/client/production-runtime.d.ts.map +1 -1
- package/lib/types/skill-provider.d.ts.map +1 -1
- package/lib/video-recap/CHANGELOG.md +31 -1
- package/lib/video-recap/manifest.json +162 -162
- package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +4 -9
- package/lib/video-recap/skills/video-assemble/scripts/assemble.py +47 -60
- package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +77 -115
- package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +41 -40
- package/lib/video-recap/skills/video-assemble/scripts/audio_automation.py +22 -32
- package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +102 -192
- package/lib/video-recap/skills/video-assemble/scripts/jianying_builders.py +14 -35
- package/lib/video-recap/skills/video-assemble/scripts/jianying_model.py +14 -12
- package/lib/video-recap/skills/video-assemble/scripts/jianying_optional.py +8 -9
- package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +3 -14
- package/lib/video-recap/skills/video-assemble/scripts/jianying_timeline_contract.py +23 -12
- package/lib/video-recap/skills/video-assemble/scripts/jianying_tracks.py +1 -4
- package/lib/video-recap/skills/video-assemble/scripts/jianying_writer.py +24 -25
- package/lib/video-recap/skills/video-assemble/scripts/lib.py +31 -85
- package/lib/video-recap/skills/video-assemble/scripts/media.py +100 -144
- package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +101 -195
- package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +10 -21
- package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +90 -169
- package/lib/video-recap/skills/video-assemble/scripts/subtitle_core.py +69 -122
- package/lib/video-recap/skills/video-assemble/scripts/subtitle_render.py +10 -17
- package/lib/video-recap/skills/video-assemble/scripts/timeline.py +33 -48
- package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +14 -20
- package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +135 -213
- package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +49 -57
- package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +38 -74
- package/lib/video-recap/skills/video-cut/scripts/cut_render.py +46 -95
- package/lib/video-recap/skills/video-cut/scripts/lib.py +23 -25
- package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +70 -173
- package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +28 -53
- package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +162 -322
- package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +6 -7
- package/lib/video-recap/skills/video-recap/scripts/doctor.py +104 -124
- package/lib/video-recap/skills/video-recap/scripts/final_qc.py +255 -412
- package/lib/video-recap/skills/video-recap/scripts/lib.py +30 -52
- package/lib/video-recap/skills/video-recap/scripts/materials.py +97 -120
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_client.py +7 -12
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_evidence.py +46 -97
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_observations.py +18 -36
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_payload.py +29 -47
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_report.py +43 -76
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_runner.py +2 -1
- package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +2 -1
- package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +2 -2
- package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +129 -145
- package/lib/video-recap/skills/video-recap/scripts/recap_review.py +27 -20
- package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +20 -31
- package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +31 -106
- package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +43 -113
- package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +196 -377
- package/lib/video-recap/skills/video-script/scripts/agent_brief.py +1 -1
- package/lib/video-recap/skills/video-script/scripts/agent_text.py +63 -172
- package/lib/video-recap/skills/video-script/scripts/brief_context.py +80 -135
- package/lib/video-recap/skills/video-script/scripts/brief_inputs.py +125 -167
- package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +93 -208
- package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +20 -46
- package/lib/video-recap/skills/video-script/scripts/lib.py +30 -26
- package/lib/video-recap/skills/video-script/scripts/narration_lint.py +145 -140
- package/lib/video-recap/skills/video-script/scripts/review_response.py +12 -42
- package/lib/video-recap/skills/video-script/scripts/review_runner.py +0 -6
- package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +39 -100
- package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +33 -124
- package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +1 -1
- package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +63 -172
- package/lib/video-recap/skills/video-understanding/scripts/asr.py +1 -2
- package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +80 -135
- package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +125 -167
- package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +93 -208
- package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +1 -1
- package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +20 -46
- package/lib/video-recap/skills/video-understanding/scripts/lib.py +31 -26
- package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +145 -140
- package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +39 -100
- package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +33 -124
- package/lib/video-recap/skills/video-understanding/scripts/vlm.py +1 -4
- package/lib/video-recap/skills/video-voiceover/scripts/dub.py +7 -13
- package/lib/video-recap/skills/video-voiceover/scripts/fish_audio.py +12 -30
- package/lib/video-recap/skills/video-voiceover/scripts/lib.py +98 -131
- package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +17 -28
- package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +151 -240
- package/package.json +41 -41
|
@@ -11,9 +11,11 @@ import math
|
|
|
11
11
|
import shutil
|
|
12
12
|
import subprocess
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import Any
|
|
14
|
+
from typing import Any
|
|
15
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
15
16
|
|
|
16
17
|
import qc_contract
|
|
18
|
+
from lib import load_json
|
|
17
19
|
|
|
18
20
|
FINAL_QC_ARTIFACT = "final_qc.json"
|
|
19
21
|
GOLDEN_EVAL_ARTIFACT = "golden_eval.json"
|
|
@@ -26,139 +28,87 @@ _COLLECT_ARTIFACTS = (
|
|
|
26
28
|
"preflight_qc.json",
|
|
27
29
|
"mimo_qc.json",
|
|
28
30
|
)
|
|
31
|
+
# video-assemble QC artifacts: {"verdict", "blocking", "blocking_codes": [...]}.
|
|
32
|
+
_UPSTREAM_QC_ARTIFACTS = ("assembly_qc.json", "visual_qc.json")
|
|
29
33
|
ProbeRunner = Callable[[Path], Mapping[str, Any]]
|
|
30
34
|
|
|
31
35
|
|
|
32
|
-
def
|
|
33
|
-
"""
|
|
34
|
-
|
|
36
|
+
def _load_fixture(value: Any) -> Any:
|
|
37
|
+
"""A fixture is an in-memory JSON value or the path of a JSON file."""
|
|
38
|
+
if isinstance(value, (Mapping, list)):
|
|
39
|
+
return value
|
|
40
|
+
return load_json(value)
|
|
35
41
|
|
|
36
42
|
|
|
37
|
-
def
|
|
38
|
-
|
|
39
|
-
if path_or_value is None:
|
|
40
|
-
return None
|
|
41
|
-
if isinstance(path_or_value, (Mapping, list, tuple)):
|
|
42
|
-
return redact_secrets(path_or_value)
|
|
43
|
-
path = Path(path_or_value)
|
|
44
|
-
try:
|
|
45
|
-
return redact_secrets(json.loads(path.read_text(encoding="utf-8")))
|
|
46
|
-
except (OSError, ValueError, TypeError):
|
|
47
|
-
return None
|
|
48
|
-
|
|
43
|
+
def fingerprint_file(path: Path) -> str | None:
|
|
44
|
+
return qc_contract.artifact_fingerprint(path) if path.is_file() else None
|
|
49
45
|
|
|
50
|
-
def fingerprint_file(path: str | Path) -> str | None:
|
|
51
|
-
path = Path(path)
|
|
52
|
-
try:
|
|
53
|
-
if path.exists() and path.is_file():
|
|
54
|
-
return qc_contract.artifact_fingerprint(path)
|
|
55
|
-
except OSError:
|
|
56
|
-
return None
|
|
57
|
-
return None
|
|
58
46
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if
|
|
62
|
-
return str(value)
|
|
63
|
-
if isinstance(value, Mapping):
|
|
64
|
-
return {str(k): _json_safe(v) for k, v in value.items()}
|
|
65
|
-
if isinstance(value, (list, tuple)):
|
|
66
|
-
return [_json_safe(v) for v in value]
|
|
67
|
-
return value
|
|
47
|
+
def _resolve_in_work_dir(work_dir: Path, path: str | Path) -> Path:
|
|
48
|
+
p = Path(path)
|
|
49
|
+
return p if p.is_absolute() else work_dir / p
|
|
68
50
|
|
|
69
51
|
|
|
70
|
-
def
|
|
71
|
-
|
|
52
|
+
def _read_json_mapping(path: Path) -> Mapping[str, Any] | None:
|
|
53
|
+
try:
|
|
54
|
+
data = load_json(path)
|
|
55
|
+
except (OSError, ValueError):
|
|
72
56
|
return None
|
|
73
|
-
|
|
74
|
-
return p if p.is_absolute() else work_dir / p
|
|
57
|
+
return data if isinstance(data, Mapping) else None
|
|
75
58
|
|
|
76
59
|
|
|
77
60
|
def _candidate_final_outputs(work_dir: Path, final_output: str | Path | None) -> list[Path]:
|
|
78
61
|
candidates: list[Path] = []
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
seen: set[str] = set()
|
|
93
|
-
for p in candidates:
|
|
94
|
-
key = str(p)
|
|
95
|
-
if key not in seen:
|
|
96
|
-
seen.add(key)
|
|
97
|
-
deduped.append(p)
|
|
98
|
-
return deduped
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _select_final_output(work_dir: Path, final_output: str | Path | None) -> Path | None:
|
|
62
|
+
if final_output is not None:
|
|
63
|
+
candidates.append(_resolve_in_work_dir(work_dir, final_output))
|
|
64
|
+
manifest_path = work_dir / "assembly_manifest.json"
|
|
65
|
+
if manifest_path.exists():
|
|
66
|
+
manifest = _read_json_mapping(manifest_path)
|
|
67
|
+
manifest_output = manifest.get("final_output") if manifest else None
|
|
68
|
+
if isinstance(manifest_output, str) and manifest_output:
|
|
69
|
+
candidates.append(_resolve_in_work_dir(work_dir, manifest_output))
|
|
70
|
+
candidates += [work_dir / name for name in ("output.mp4", "recap.mp4", "final.mp4")]
|
|
71
|
+
return list(dict.fromkeys(candidates))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _select_final_output(work_dir: Path, final_output: str | Path | None) -> Path:
|
|
102
75
|
candidates = _candidate_final_outputs(work_dir, final_output)
|
|
103
76
|
if final_output is not None:
|
|
104
|
-
return candidates[0]
|
|
105
|
-
for
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
except OSError:
|
|
110
|
-
continue
|
|
111
|
-
return candidates[0] if candidates else None
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _file_metadata(path: Path | None, work_dir: Path | None = None) -> dict[str, Any]:
|
|
115
|
-
if path is None:
|
|
116
|
-
return {"path": None, "exists": False, "bytes": 0, "fingerprint": None}
|
|
117
|
-
display = str(path)
|
|
118
|
-
if work_dir is not None:
|
|
119
|
-
try:
|
|
120
|
-
display = path.relative_to(work_dir).as_posix()
|
|
121
|
-
except ValueError:
|
|
122
|
-
pass
|
|
123
|
-
item: dict[str, Any] = {"path": display}
|
|
77
|
+
return candidates[0]
|
|
78
|
+
return next((path for path in candidates if path.is_file() and path.stat().st_size > 0), candidates[0])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _file_metadata(path: Path, work_dir: Path) -> dict[str, Any]:
|
|
124
82
|
try:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
83
|
+
display = path.relative_to(work_dir).as_posix()
|
|
84
|
+
except ValueError: # final outputs normally live next to work_dir, not inside it
|
|
85
|
+
display = str(path)
|
|
86
|
+
exists = path.is_file()
|
|
87
|
+
return {
|
|
88
|
+
"path": display,
|
|
89
|
+
"exists": exists,
|
|
90
|
+
"bytes": path.stat().st_size if exists else 0,
|
|
91
|
+
"fingerprint": fingerprint_file(path),
|
|
92
|
+
}
|
|
132
93
|
|
|
133
94
|
|
|
134
95
|
def _artifact_summary(work_dir: Path, name: str) -> dict[str, Any]:
|
|
135
96
|
path = work_dir / name
|
|
136
97
|
meta = _file_metadata(path, work_dir)
|
|
137
|
-
if meta
|
|
138
|
-
data =
|
|
139
|
-
if
|
|
98
|
+
if meta["exists"]:
|
|
99
|
+
data = _read_json_mapping(path)
|
|
100
|
+
if data is None:
|
|
101
|
+
meta["summary"] = {"invalid": True}
|
|
102
|
+
else:
|
|
140
103
|
meta["summary"] = {
|
|
141
|
-
|
|
142
|
-
"
|
|
143
|
-
"stage": data.get("stage"),
|
|
144
|
-
"ok": data.get("ok"),
|
|
145
|
-
"blocker_count": data.get("blocker_count"),
|
|
146
|
-
"finding_count": data.get("finding_count"),
|
|
104
|
+
key: data.get(key)
|
|
105
|
+
for key in ("schema_version", "artifact", "stage", "ok", "blocker_count", "finding_count")
|
|
147
106
|
}
|
|
148
|
-
|
|
149
|
-
meta["summary"] = {"type": type(data).__name__}
|
|
150
|
-
return redact_secrets(meta)
|
|
107
|
+
return meta
|
|
151
108
|
|
|
152
109
|
|
|
153
|
-
def _artifact_fingerprints(*paths: Path
|
|
154
|
-
|
|
155
|
-
for path in paths:
|
|
156
|
-
if path is None:
|
|
157
|
-
continue
|
|
158
|
-
fp = fingerprint_file(path)
|
|
159
|
-
if fp:
|
|
160
|
-
out[path.name] = fp
|
|
161
|
-
return out
|
|
110
|
+
def _artifact_fingerprints(*paths: Path) -> dict[str, str]:
|
|
111
|
+
return {path.name: fp for path in paths if (fp := fingerprint_file(path))}
|
|
162
112
|
|
|
163
113
|
|
|
164
114
|
def _finding(*, finding_id: str, code: str, message: str, category: str = "schema_invalid",
|
|
@@ -176,9 +126,9 @@ def _finding(*, finding_id: str, code: str, message: str, category: str = "schem
|
|
|
176
126
|
message=message,
|
|
177
127
|
deterministic=True,
|
|
178
128
|
blocking=True,
|
|
179
|
-
source=
|
|
180
|
-
evidence=
|
|
181
|
-
artifact_fingerprints=
|
|
129
|
+
source=source,
|
|
130
|
+
evidence=evidence,
|
|
131
|
+
artifact_fingerprints=fingerprints,
|
|
182
132
|
next_action=next_action,
|
|
183
133
|
model_used="local_deterministic_final_qc_v1",
|
|
184
134
|
)
|
|
@@ -193,15 +143,11 @@ def _run_ffprobe(path: Path) -> Mapping[str, Any]:
|
|
|
193
143
|
]
|
|
194
144
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
|
195
145
|
if res.returncode != 0:
|
|
196
|
-
|
|
197
|
-
raise RuntimeError(detail)
|
|
146
|
+
raise RuntimeError((res.stderr or res.stdout or "ffprobe failed").strip())
|
|
198
147
|
try:
|
|
199
|
-
|
|
148
|
+
return json.loads(res.stdout)
|
|
200
149
|
except ValueError as exc:
|
|
201
150
|
raise RuntimeError(f"ffprobe returned invalid JSON: {exc}") from exc
|
|
202
|
-
if not isinstance(data, Mapping):
|
|
203
|
-
raise RuntimeError("ffprobe JSON must be an object")
|
|
204
|
-
return data
|
|
205
151
|
|
|
206
152
|
|
|
207
153
|
def _tail_decode_check(path: Path) -> tuple[bool | None, str | None]:
|
|
@@ -227,127 +173,71 @@ def _tail_decode_check(path: Path) -> tuple[bool | None, str | None]:
|
|
|
227
173
|
|
|
228
174
|
|
|
229
175
|
def _probe_metadata(path: Path, *, probe_fixture: Any = None, probe_runner: ProbeRunner | None = None) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
230
|
-
"""Return (probe, error)
|
|
231
|
-
if probe_fixture is not None:
|
|
232
|
-
data = safe_load_json(probe_fixture)
|
|
233
|
-
if isinstance(data, Mapping):
|
|
234
|
-
return redact_secrets(dict(data)), None
|
|
235
|
-
return None, {"code": "probe_failed", "message": "probe_fixture is missing or invalid JSON"}
|
|
176
|
+
"""Return (probe, error); an ffprobe failure on existing media becomes a deterministic blocker."""
|
|
236
177
|
try:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
178
|
+
raw = _load_fixture(probe_fixture) if probe_fixture is not None else (probe_runner or _run_ffprobe)(path)
|
|
179
|
+
if not isinstance(raw, Mapping):
|
|
180
|
+
raise TypeError("probe metadata must be a JSON object")
|
|
181
|
+
streams = raw.get("streams", [])
|
|
182
|
+
format_info = raw.get("format", {})
|
|
183
|
+
if not isinstance(streams, list) or any(not isinstance(stream, Mapping) for stream in streams):
|
|
184
|
+
raise TypeError("probe metadata streams must be an array of objects")
|
|
185
|
+
if not isinstance(format_info, Mapping):
|
|
186
|
+
raise TypeError("probe metadata format must be an object")
|
|
187
|
+
normalized = dict(raw)
|
|
188
|
+
normalized["streams"] = [dict(stream) for stream in streams]
|
|
189
|
+
normalized["format"] = dict(format_info)
|
|
190
|
+
return normalized, None
|
|
191
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, TypeError) as exc:
|
|
243
192
|
return None, {"code": "probe_failed", "message": str(exc) or "ffprobe failed"}
|
|
244
193
|
|
|
245
194
|
|
|
246
|
-
def _first_video_stream(probe: Mapping[str, Any]
|
|
247
|
-
|
|
248
|
-
return None
|
|
249
|
-
streams = probe.get("streams")
|
|
250
|
-
if not isinstance(streams, list):
|
|
251
|
-
return None
|
|
252
|
-
for stream in streams:
|
|
253
|
-
if isinstance(stream, Mapping) and stream.get("codec_type") == "video":
|
|
254
|
-
return stream
|
|
255
|
-
return None
|
|
195
|
+
def _first_video_stream(probe: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
196
|
+
return next((s for s in probe.get("streams", []) if s.get("codec_type") == "video"), None)
|
|
256
197
|
|
|
257
198
|
|
|
258
|
-
def
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
value = value.strip()
|
|
263
|
-
if not value:
|
|
264
|
-
return None
|
|
265
|
-
if "/" in value:
|
|
199
|
+
def _positive_finite(value: Any) -> float | None:
|
|
200
|
+
"""Parse an ffprobe number or rational ("30000/1001"); None unless positive and finite."""
|
|
201
|
+
try:
|
|
202
|
+
if isinstance(value, str) and "/" in value:
|
|
266
203
|
numerator, denominator = value.split("/", 1)
|
|
267
|
-
|
|
268
|
-
den = float(denominator)
|
|
269
|
-
if den == 0:
|
|
270
|
-
return None
|
|
271
|
-
number = float(numerator) / den
|
|
272
|
-
except (TypeError, ValueError):
|
|
273
|
-
return None
|
|
204
|
+
number = float(numerator) / float(denominator)
|
|
274
205
|
else:
|
|
275
|
-
try:
|
|
276
|
-
number = float(value)
|
|
277
|
-
except (TypeError, ValueError):
|
|
278
|
-
return None
|
|
279
|
-
else:
|
|
280
|
-
try:
|
|
281
206
|
number = float(value)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if math.isfinite(number) and number > 0
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if not isinstance(probe, Mapping):
|
|
291
|
-
return None, "missing_duration", None
|
|
292
|
-
candidates: list[Any] = []
|
|
293
|
-
fmt = probe.get("format")
|
|
294
|
-
if isinstance(fmt, Mapping):
|
|
295
|
-
candidates.append(fmt.get("duration"))
|
|
296
|
-
candidates.append(probe.get("duration"))
|
|
297
|
-
seen = [value for value in candidates if value not in (None, "")]
|
|
298
|
-
if not seen:
|
|
299
|
-
return None, "missing_duration", None
|
|
300
|
-
for value in seen:
|
|
301
|
-
duration = _parse_positive_finite_number(value)
|
|
302
|
-
if duration is not None:
|
|
303
|
-
return duration, None, value
|
|
304
|
-
return None, "invalid_duration", seen[0]
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def _probe_fps_status(video_stream: Mapping[str, Any] | None, probe: Mapping[str, Any] | None) -> tuple[float | None, str | None, Any]:
|
|
308
|
-
candidates: list[Any] = []
|
|
309
|
-
if isinstance(video_stream, Mapping):
|
|
310
|
-
for key in ("avg_frame_rate", "r_frame_rate", "fps", "frame_rate"):
|
|
311
|
-
candidates.append(video_stream.get(key))
|
|
312
|
-
if isinstance(probe, Mapping):
|
|
313
|
-
for key in ("fps", "frame_rate"):
|
|
314
|
-
candidates.append(probe.get(key))
|
|
207
|
+
except (TypeError, ValueError, ZeroDivisionError):
|
|
208
|
+
return None
|
|
209
|
+
return number if math.isfinite(number) and number > 0 else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _first_positive(candidates: Sequence[Any]) -> tuple[float | None, str | None, Any]:
|
|
213
|
+
"""(parsed, problem, raw): the first positive finite candidate wins; otherwise problem is
|
|
214
|
+
'missing' (no candidate at all) or 'invalid' (with the first raw candidate)."""
|
|
315
215
|
seen = [value for value in candidates if value not in (None, "")]
|
|
216
|
+
for raw in seen:
|
|
217
|
+
parsed = _positive_finite(raw)
|
|
218
|
+
if parsed is not None:
|
|
219
|
+
return parsed, None, raw
|
|
316
220
|
if not seen:
|
|
317
|
-
return None, "
|
|
318
|
-
|
|
319
|
-
fps = _parse_positive_finite_number(value)
|
|
320
|
-
if fps is not None:
|
|
321
|
-
return fps, None, value
|
|
322
|
-
return None, "invalid_fps", seen[0]
|
|
323
|
-
|
|
324
|
-
def _duration_from_probe(probe: Mapping[str, Any] | None) -> float | None:
|
|
325
|
-
if not isinstance(probe, Mapping):
|
|
326
|
-
return None
|
|
327
|
-
candidates: list[Any] = []
|
|
328
|
-
fmt = probe.get("format")
|
|
329
|
-
if isinstance(fmt, Mapping):
|
|
330
|
-
candidates.append(fmt.get("duration"))
|
|
331
|
-
candidates.append(probe.get("duration"))
|
|
332
|
-
for value in candidates:
|
|
333
|
-
try:
|
|
334
|
-
duration = float(value)
|
|
335
|
-
except (TypeError, ValueError):
|
|
336
|
-
continue
|
|
337
|
-
if duration >= 0:
|
|
338
|
-
return duration
|
|
339
|
-
return None
|
|
221
|
+
return None, "missing", None
|
|
222
|
+
return None, "invalid", seen[0]
|
|
340
223
|
|
|
341
224
|
|
|
342
|
-
def
|
|
343
|
-
|
|
344
|
-
name = vs.get("codec_name") if isinstance(vs, Mapping) else None
|
|
345
|
-
return str(name) if name else None
|
|
225
|
+
def _probe_duration(probe: Mapping[str, Any]) -> tuple[float | None, str | None, Any]:
|
|
226
|
+
return _first_positive([probe.get("format", {}).get("duration")])
|
|
346
227
|
|
|
347
228
|
|
|
229
|
+
def _probe_fps(video_stream: Mapping[str, Any] | None) -> tuple[float | None, str | None, Any]:
|
|
230
|
+
if video_stream is None:
|
|
231
|
+
return None, "missing", None
|
|
232
|
+
return _first_positive([
|
|
233
|
+
video_stream.get(key)
|
|
234
|
+
for key in ("avg_frame_rate", "r_frame_rate", "fps", "frame_rate")
|
|
235
|
+
])
|
|
348
236
|
|
|
349
|
-
|
|
237
|
+
|
|
238
|
+
def _probe_contract_findings(probe: Mapping[str, Any], *, final_meta: Mapping[str, Any], fingerprints: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
350
239
|
findings: list[dict[str, Any]] = []
|
|
240
|
+
source = {"artifact": final_meta["path"]}
|
|
351
241
|
video_stream = _first_video_stream(probe)
|
|
352
242
|
if video_stream is None:
|
|
353
243
|
findings.append(_finding(
|
|
@@ -355,123 +245,101 @@ def _probe_contract_findings(probe: Mapping[str, Any] | None, *, final_meta: Map
|
|
|
355
245
|
code="missing_video_stream",
|
|
356
246
|
message="final output probe metadata has no video stream",
|
|
357
247
|
category="stream",
|
|
358
|
-
source=
|
|
359
|
-
evidence={"streams": probe.get("streams")
|
|
248
|
+
source=source,
|
|
249
|
+
evidence={"streams": probe.get("streams")},
|
|
360
250
|
fingerprints=fingerprints,
|
|
361
251
|
next_action="rerender_final_output_with_video_stream",
|
|
362
252
|
))
|
|
363
253
|
|
|
364
|
-
_duration,
|
|
365
|
-
if
|
|
254
|
+
_duration, problem, raw = _probe_duration(probe)
|
|
255
|
+
if problem is not None:
|
|
366
256
|
findings.append(_finding(
|
|
367
|
-
finding_id=f"final-qc-{
|
|
368
|
-
code=
|
|
369
|
-
message="final output probe metadata is missing a positive finite duration" if
|
|
257
|
+
finding_id=f"final-qc-{problem}-duration",
|
|
258
|
+
code=f"{problem}_duration",
|
|
259
|
+
message="final output probe metadata is missing a positive finite duration" if problem == "missing" else "final output probe metadata duration is not positive and finite",
|
|
370
260
|
category="duration",
|
|
371
|
-
source=
|
|
372
|
-
evidence={"duration":
|
|
261
|
+
source=source,
|
|
262
|
+
evidence={"duration": raw},
|
|
373
263
|
fingerprints=fingerprints,
|
|
374
264
|
next_action="rerender_final_output_with_valid_duration",
|
|
375
265
|
))
|
|
376
266
|
|
|
377
|
-
|
|
378
|
-
if not codec:
|
|
267
|
+
if not (video_stream or {}).get("codec_name"):
|
|
379
268
|
findings.append(_finding(
|
|
380
269
|
finding_id="final-qc-missing-codec",
|
|
381
270
|
code="missing_codec",
|
|
382
271
|
message="final output probe metadata is missing a video codec",
|
|
383
272
|
category="stream",
|
|
384
|
-
source=
|
|
273
|
+
source=source,
|
|
385
274
|
evidence={"video_stream": video_stream},
|
|
386
275
|
fingerprints=fingerprints,
|
|
387
276
|
next_action="rerender_final_output_with_video_codec",
|
|
388
277
|
))
|
|
389
278
|
|
|
390
|
-
_fps,
|
|
391
|
-
if
|
|
279
|
+
_fps, problem, raw = _probe_fps(video_stream)
|
|
280
|
+
if problem is not None:
|
|
392
281
|
findings.append(_finding(
|
|
393
|
-
finding_id=f"final-qc-{
|
|
394
|
-
code=
|
|
395
|
-
message="final output probe metadata is missing a positive finite video fps" if
|
|
282
|
+
finding_id=f"final-qc-{problem}-fps",
|
|
283
|
+
code=f"{problem}_fps",
|
|
284
|
+
message="final output probe metadata is missing a positive finite video fps" if problem == "missing" else "final output probe metadata video fps is not positive and finite",
|
|
396
285
|
category="stream",
|
|
397
|
-
source=
|
|
398
|
-
evidence={"fps":
|
|
286
|
+
source=source,
|
|
287
|
+
evidence={"fps": raw, "video_stream": video_stream},
|
|
399
288
|
fingerprints=fingerprints,
|
|
400
289
|
next_action="rerender_final_output_with_valid_fps",
|
|
401
290
|
))
|
|
402
291
|
return findings
|
|
403
292
|
|
|
404
293
|
|
|
405
|
-
def _blocking_codes(value: Any) -> list[str]:
|
|
406
|
-
if isinstance(value, str):
|
|
407
|
-
values: Sequence[Any] = [value]
|
|
408
|
-
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
409
|
-
values = value
|
|
410
|
-
else:
|
|
411
|
-
return []
|
|
412
|
-
codes: list[str] = []
|
|
413
|
-
for item in values:
|
|
414
|
-
code = str(item).strip()
|
|
415
|
-
if code:
|
|
416
|
-
codes.append(code)
|
|
417
|
-
return codes
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def _failing_verdict(value: Any) -> bool:
|
|
421
|
-
if not isinstance(value, str):
|
|
422
|
-
return False
|
|
423
|
-
verdict = value.strip().lower()
|
|
424
|
-
return verdict in {"fail", "failed", "failing", "blocker", "blocked", "error", "errored", "reject", "rejected"}
|
|
425
|
-
|
|
426
|
-
|
|
427
294
|
def _upstream_blockers(work_dir: Path, artifact_name: str) -> list[dict[str, Any]]:
|
|
295
|
+
"""Roll a blocking video-assemble QC artifact into one final blocker per blocking code."""
|
|
428
296
|
path = work_dir / artifact_name
|
|
429
|
-
|
|
430
|
-
if not isinstance(data, Mapping):
|
|
297
|
+
if not path.exists():
|
|
431
298
|
return []
|
|
432
|
-
|
|
433
|
-
blocker_count = data.get("blocker_count")
|
|
434
|
-
ok = data.get("ok")
|
|
435
|
-
blocked: list[Mapping[str, Any]] = []
|
|
436
|
-
if isinstance(findings, list):
|
|
437
|
-
blocked.extend(f for f in findings if isinstance(f, Mapping) and f.get("blocking") is True)
|
|
438
|
-
elif ok is False or (isinstance(blocker_count, int) and blocker_count > 0):
|
|
439
|
-
blocked.append({"code": "reported_blocker", "message": f"{artifact_name} reported blockers"})
|
|
440
|
-
|
|
441
|
-
blocking_codes = _blocking_codes(data.get("blocking_codes"))
|
|
442
|
-
if data.get("blocking") is True or _failing_verdict(data.get("verdict")):
|
|
443
|
-
if not blocking_codes:
|
|
444
|
-
blocking_codes = ["reported_blocker"]
|
|
445
|
-
for code in blocking_codes:
|
|
446
|
-
blocked.append({
|
|
447
|
-
"code": code,
|
|
448
|
-
"message": f"{artifact_name} reported {code}",
|
|
449
|
-
"artifact": data.get("artifact"),
|
|
450
|
-
"verdict": data.get("verdict"),
|
|
451
|
-
"blocking": data.get("blocking"),
|
|
452
|
-
})
|
|
453
|
-
|
|
454
|
-
out = []
|
|
299
|
+
data = _read_json_mapping(path)
|
|
455
300
|
fp = fingerprint_file(path)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
301
|
+
invalid = data is None
|
|
302
|
+
codes = data.get("blocking_codes") if data is not None else None
|
|
303
|
+
if not invalid and codes is None:
|
|
304
|
+
findings = data.get("findings")
|
|
305
|
+
invalid = not isinstance(findings, list) or any(
|
|
306
|
+
not isinstance(finding, Mapping)
|
|
307
|
+
or not isinstance(finding.get("blocking"), bool)
|
|
308
|
+
or not isinstance(finding.get("code"), str)
|
|
309
|
+
or not finding["code"]
|
|
310
|
+
for finding in findings or []
|
|
311
|
+
)
|
|
312
|
+
if not invalid:
|
|
313
|
+
codes = [finding["code"] for finding in findings if finding["blocking"]]
|
|
314
|
+
elif isinstance(codes, str):
|
|
315
|
+
codes = [codes]
|
|
316
|
+
elif not invalid and (
|
|
317
|
+
not isinstance(codes, list)
|
|
318
|
+
or any(not isinstance(code, str) or not code for code in codes)
|
|
319
|
+
):
|
|
320
|
+
invalid = True
|
|
321
|
+
if invalid:
|
|
322
|
+
return [_finding(
|
|
323
|
+
finding_id=f"final-qc-invalid-upstream-{artifact_name}",
|
|
324
|
+
code=f"upstream_{artifact_name.replace('.', '_')}_schema_invalid",
|
|
325
|
+
message=f"{artifact_name} is not a valid deterministic QC report",
|
|
326
|
+
source={"artifact": artifact_name},
|
|
327
|
+
evidence={"schema_invalid": True},
|
|
328
|
+
fingerprints={artifact_name: fp},
|
|
329
|
+
next_action="regenerate_upstream_qc",
|
|
330
|
+
)]
|
|
331
|
+
return [
|
|
332
|
+
_finding(
|
|
459
333
|
finding_id=f"final-qc-upstream-{artifact_name}-{idx}",
|
|
460
334
|
code=f"upstream_{artifact_name.replace('.', '_')}_{code}",
|
|
461
|
-
message=f"{artifact_name}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
"upstream_code": code,
|
|
466
|
-
"upstream_message": item.get("message") or item.get("decision_reason"),
|
|
467
|
-
"upstream_artifact": item.get("artifact"),
|
|
468
|
-
"upstream_verdict": item.get("verdict"),
|
|
469
|
-
"upstream_blocking": item.get("blocking"),
|
|
470
|
-
},
|
|
471
|
-
fingerprints={artifact_name: fp} if fp else {},
|
|
335
|
+
message=f"{artifact_name} reported {code}",
|
|
336
|
+
source={"artifact": artifact_name},
|
|
337
|
+
evidence={"upstream_code": code, "upstream_verdict": data.get("verdict")},
|
|
338
|
+
fingerprints={artifact_name: fp},
|
|
472
339
|
next_action="fix_upstream_qc_blocker",
|
|
473
|
-
)
|
|
474
|
-
|
|
340
|
+
)
|
|
341
|
+
for idx, code in enumerate(codes)
|
|
342
|
+
]
|
|
475
343
|
|
|
476
344
|
|
|
477
345
|
def collect_metadata(work_dir: str | Path, *, final_output: str | Path | None = None,
|
|
@@ -480,20 +348,18 @@ def collect_metadata(work_dir: str | Path, *, final_output: str | Path | None =
|
|
|
480
348
|
selected = _select_final_output(root, final_output)
|
|
481
349
|
probe = probe_error = None
|
|
482
350
|
final_meta = _file_metadata(selected, root)
|
|
483
|
-
if final_meta
|
|
351
|
+
if final_meta["exists"] and final_meta["bytes"] > 0:
|
|
484
352
|
probe, probe_error = _probe_metadata(selected, probe_fixture=probe_fixture, probe_runner=probe_runner)
|
|
485
|
-
|
|
486
|
-
metadata = {
|
|
353
|
+
return {
|
|
487
354
|
"work_dir": str(root),
|
|
488
355
|
"final_output": final_meta,
|
|
489
356
|
"final_output_candidates": [_file_metadata(p, root) for p in _candidate_final_outputs(root, final_output)],
|
|
490
|
-
"artifacts":
|
|
357
|
+
"artifacts": {name: _artifact_summary(root, name) for name in _COLLECT_ARTIFACTS},
|
|
491
358
|
"probe": probe,
|
|
492
359
|
"probe_error": probe_error,
|
|
493
360
|
# mimo_qc.json is advisory metadata only and is not rolled into final blockers.
|
|
494
361
|
"auto_repair": False,
|
|
495
362
|
}
|
|
496
|
-
return redact_secrets(_json_safe(metadata))
|
|
497
363
|
|
|
498
364
|
|
|
499
365
|
def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
|
|
@@ -505,7 +371,7 @@ def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
|
|
|
505
371
|
final_meta = metadata["final_output"]
|
|
506
372
|
findings: list[dict[str, Any]] = []
|
|
507
373
|
fps = _artifact_fingerprints(selected)
|
|
508
|
-
if not final_meta
|
|
374
|
+
if not final_meta["exists"]:
|
|
509
375
|
findings.append(_finding(
|
|
510
376
|
finding_id="final-qc-missing-final-output",
|
|
511
377
|
code="missing_final_output",
|
|
@@ -516,97 +382,80 @@ def build_final_qc(work_dir: str | Path, final_output: str | Path | None = None,
|
|
|
516
382
|
fingerprints=fps,
|
|
517
383
|
next_action="render_final_output",
|
|
518
384
|
))
|
|
519
|
-
elif
|
|
385
|
+
elif final_meta["bytes"] == 0:
|
|
520
386
|
findings.append(_finding(
|
|
521
387
|
finding_id="final-qc-empty-final-output",
|
|
522
388
|
code="empty_final_output",
|
|
523
389
|
message="final output mp4 is empty",
|
|
524
390
|
category="missing_artifact",
|
|
525
|
-
source={"artifact": final_meta
|
|
391
|
+
source={"artifact": final_meta["path"]},
|
|
526
392
|
evidence={"final_output": final_meta},
|
|
527
393
|
fingerprints=fps,
|
|
528
394
|
next_action="rerender_final_output",
|
|
529
395
|
))
|
|
396
|
+
elif metadata["probe_error"] is not None:
|
|
397
|
+
findings.append(_finding(
|
|
398
|
+
finding_id="final-qc-probe-failed",
|
|
399
|
+
code="probe_failed",
|
|
400
|
+
message="ffprobe failed or was unavailable for existing non-empty final output",
|
|
401
|
+
category="stream",
|
|
402
|
+
source={"artifact": final_meta["path"]},
|
|
403
|
+
evidence=metadata["probe_error"],
|
|
404
|
+
fingerprints=fps,
|
|
405
|
+
next_action="inspect_or_rerender_final_output",
|
|
406
|
+
))
|
|
530
407
|
else:
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
# A cheap tail decode catches it; skip for offline fixtures and when ffmpeg is absent
|
|
553
|
-
# (decode_ok is None). Only add on a definite decode failure to avoid false-blocking.
|
|
554
|
-
if probe_fixture is None and not probe_findings:
|
|
555
|
-
decode_ok, decode_detail = (decode_runner or _tail_decode_check)(selected)
|
|
556
|
-
if decode_ok is False:
|
|
557
|
-
findings.append(_finding(
|
|
558
|
-
finding_id="final-qc-undecodable-stream",
|
|
559
|
-
code="undecodable_stream",
|
|
560
|
-
message="final output tail failed to decode (truncated or corrupt media payload)",
|
|
561
|
-
category="stream",
|
|
562
|
-
source={"artifact": final_meta.get("path")},
|
|
563
|
-
evidence={"decode_error": decode_detail},
|
|
564
|
-
fingerprints=fps,
|
|
565
|
-
next_action="rerender_final_output",
|
|
566
|
-
))
|
|
567
|
-
findings.extend(_upstream_blockers(root, "assembly_qc.json"))
|
|
568
|
-
findings.extend(_upstream_blockers(root, "visual_qc.json"))
|
|
569
|
-
report = qc_contract.build_report(
|
|
408
|
+
probe_findings = _probe_contract_findings(metadata["probe"], final_meta=final_meta, fingerprints=fps)
|
|
409
|
+
findings.extend(probe_findings)
|
|
410
|
+
# Header probing cannot see a container-valid but media-truncated/corrupt payload.
|
|
411
|
+
# A cheap tail decode catches it; skip for offline fixtures and when ffmpeg is absent
|
|
412
|
+
# (decode_ok is None). Only add on a definite decode failure to avoid false-blocking.
|
|
413
|
+
if probe_fixture is None and not probe_findings:
|
|
414
|
+
decode_ok, decode_detail = (decode_runner or _tail_decode_check)(selected)
|
|
415
|
+
if decode_ok is False:
|
|
416
|
+
findings.append(_finding(
|
|
417
|
+
finding_id="final-qc-undecodable-stream",
|
|
418
|
+
code="undecodable_stream",
|
|
419
|
+
message="final output tail failed to decode (truncated or corrupt media payload)",
|
|
420
|
+
category="stream",
|
|
421
|
+
source={"artifact": final_meta["path"]},
|
|
422
|
+
evidence={"decode_error": decode_detail},
|
|
423
|
+
fingerprints=fps,
|
|
424
|
+
next_action="rerender_final_output",
|
|
425
|
+
))
|
|
426
|
+
for name in _UPSTREAM_QC_ARTIFACTS:
|
|
427
|
+
findings.extend(_upstream_blockers(root, name))
|
|
428
|
+
return qc_contract.build_report(
|
|
570
429
|
artifact=FINAL_QC_ARTIFACT,
|
|
571
430
|
stage=POST_RENDER_STAGE,
|
|
572
431
|
findings=findings,
|
|
573
432
|
metadata=metadata,
|
|
574
433
|
)
|
|
575
|
-
qc_contract.validate_report(report)
|
|
576
|
-
return report
|
|
577
434
|
|
|
578
435
|
|
|
579
436
|
def _load_or_build_final_qc(work_dir: Path, final_qc_report: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
580
437
|
if final_qc_report is not None:
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
report = dict(existing) if isinstance(existing, Mapping) else build_final_qc(work_dir)
|
|
585
|
-
qc_contract.validate_report(report)
|
|
586
|
-
return report
|
|
438
|
+
return dict(final_qc_report)
|
|
439
|
+
path = work_dir / FINAL_QC_ARTIFACT
|
|
440
|
+
return load_json(path) if path.exists() else build_final_qc(work_dir)
|
|
587
441
|
|
|
588
442
|
|
|
589
443
|
def build_golden_eval(work_dir: str | Path, final_qc_report: Mapping[str, Any] | None = None,
|
|
590
444
|
golden_fixture: Any = None) -> dict[str, Any]:
|
|
591
445
|
root = Path(work_dir)
|
|
592
446
|
final_report = _load_or_build_final_qc(root, final_qc_report)
|
|
593
|
-
fixture =
|
|
594
|
-
|
|
447
|
+
fixture = _load_fixture(golden_fixture) if golden_fixture is not None else {}
|
|
448
|
+
final_fp = fingerprint_file(root / FINAL_QC_ARTIFACT)
|
|
595
449
|
metadata = {
|
|
596
450
|
"work_dir": str(root),
|
|
597
451
|
"fixture": fixture,
|
|
598
|
-
"final_qc": {
|
|
599
|
-
|
|
600
|
-
"blocker_count": final_report.get("blocker_count"),
|
|
601
|
-
"artifact": final_report.get("artifact"),
|
|
602
|
-
"stage": final_report.get("stage"),
|
|
603
|
-
},
|
|
604
|
-
"final_qc_fingerprint": fingerprint_file(root / FINAL_QC_ARTIFACT),
|
|
452
|
+
"final_qc": {key: final_report[key] for key in ("ok", "blocker_count", "artifact", "stage")},
|
|
453
|
+
"final_qc_fingerprint": final_fp,
|
|
605
454
|
"auto_repair": False,
|
|
606
455
|
}
|
|
607
456
|
findings: list[dict[str, Any]] = []
|
|
608
457
|
expected_ok = fixture.get("expected_final_qc_ok", True)
|
|
609
|
-
if
|
|
458
|
+
if final_report["ok"] != expected_ok:
|
|
610
459
|
findings.append(_finding(
|
|
611
460
|
finding_id="golden-final-qc-ok-mismatch",
|
|
612
461
|
stage=GOLDEN_STAGE,
|
|
@@ -614,89 +463,83 @@ def build_golden_eval(work_dir: str | Path, final_qc_report: Mapping[str, Any] |
|
|
|
614
463
|
message="final_qc ok state does not match golden expectation",
|
|
615
464
|
category="schema_invalid",
|
|
616
465
|
source={"artifact": FINAL_QC_ARTIFACT},
|
|
617
|
-
evidence={"expected": expected_ok, "actual": final_report
|
|
618
|
-
fingerprints={FINAL_QC_ARTIFACT:
|
|
466
|
+
evidence={"expected": expected_ok, "actual": final_report["ok"]},
|
|
467
|
+
fingerprints={FINAL_QC_ARTIFACT: final_fp} if final_fp else {},
|
|
619
468
|
next_action="fix_final_qc_blockers",
|
|
620
469
|
))
|
|
621
|
-
final_meta =
|
|
622
|
-
probe =
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if fixture.get("min_duration") is not None and (duration is None or duration < float(fixture["min_duration"])):
|
|
470
|
+
final_meta = final_report["metadata"]["final_output"]
|
|
471
|
+
probe = final_report["metadata"]["probe"]
|
|
472
|
+
duration = _probe_duration(probe)[0] if probe else None
|
|
473
|
+
video_stream = _first_video_stream(probe) if probe else None
|
|
474
|
+
codec = video_stream.get("codec_name") if video_stream else None
|
|
475
|
+
min_duration = fixture.get("min_duration")
|
|
476
|
+
if min_duration is not None and (duration is None or duration < min_duration):
|
|
629
477
|
findings.append(_finding(
|
|
630
478
|
finding_id="golden-min-duration-mismatch",
|
|
631
479
|
stage=GOLDEN_STAGE,
|
|
632
480
|
code="min_duration_mismatch",
|
|
633
481
|
message="final output duration is below golden minimum",
|
|
634
482
|
category="duration",
|
|
635
|
-
source={"artifact": final_meta
|
|
636
|
-
evidence={"expected_min_duration":
|
|
483
|
+
source={"artifact": final_meta["path"]},
|
|
484
|
+
evidence={"expected_min_duration": min_duration, "actual_duration": duration},
|
|
637
485
|
next_action="adjust_render_duration",
|
|
638
486
|
))
|
|
639
|
-
|
|
487
|
+
max_duration = fixture.get("max_duration")
|
|
488
|
+
if max_duration is not None and (duration is None or duration > max_duration):
|
|
640
489
|
findings.append(_finding(
|
|
641
490
|
finding_id="golden-max-duration-mismatch",
|
|
642
491
|
stage=GOLDEN_STAGE,
|
|
643
492
|
code="max_duration_mismatch",
|
|
644
493
|
message="final output duration is above golden maximum",
|
|
645
494
|
category="duration",
|
|
646
|
-
source={"artifact": final_meta
|
|
647
|
-
evidence={"expected_max_duration":
|
|
495
|
+
source={"artifact": final_meta["path"]},
|
|
496
|
+
evidence={"expected_max_duration": max_duration, "actual_duration": duration},
|
|
648
497
|
next_action="adjust_render_duration",
|
|
649
498
|
))
|
|
650
|
-
expected_codec = fixture.get("expected_codec")
|
|
651
|
-
if expected_codec is not None and codec !=
|
|
499
|
+
expected_codec = fixture.get("expected_codec")
|
|
500
|
+
if expected_codec is not None and codec != expected_codec:
|
|
652
501
|
findings.append(_finding(
|
|
653
502
|
finding_id="golden-codec-mismatch",
|
|
654
503
|
stage=GOLDEN_STAGE,
|
|
655
504
|
code="codec_mismatch",
|
|
656
505
|
message="final output video codec does not match golden expectation",
|
|
657
506
|
category="stream",
|
|
658
|
-
source={"artifact": final_meta
|
|
507
|
+
source={"artifact": final_meta["path"]},
|
|
659
508
|
evidence={"expected_codec": expected_codec, "actual_codec": codec},
|
|
660
509
|
next_action="adjust_render_codec",
|
|
661
510
|
))
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
next_action="produce_required_artifact",
|
|
676
|
-
))
|
|
511
|
+
for idx, name in enumerate(fixture.get("required_artifacts", [])):
|
|
512
|
+
artifact_path = root / name
|
|
513
|
+
if not artifact_path.is_file() or artifact_path.stat().st_size == 0:
|
|
514
|
+
findings.append(_finding(
|
|
515
|
+
finding_id=f"golden-required-artifact-missing-{idx}",
|
|
516
|
+
stage=GOLDEN_STAGE,
|
|
517
|
+
code="required_artifact_missing",
|
|
518
|
+
message="golden fixture requires an artifact that is missing or empty",
|
|
519
|
+
category="missing_artifact",
|
|
520
|
+
source={"artifact": name},
|
|
521
|
+
evidence={"required_artifact": name},
|
|
522
|
+
next_action="produce_required_artifact",
|
|
523
|
+
))
|
|
677
524
|
metadata["observed"] = {"duration": duration, "codec": codec, "final_output": final_meta}
|
|
678
|
-
|
|
525
|
+
return qc_contract.build_report(
|
|
679
526
|
artifact=GOLDEN_EVAL_ARTIFACT,
|
|
680
527
|
stage=GOLDEN_STAGE,
|
|
681
528
|
findings=findings,
|
|
682
|
-
metadata=
|
|
529
|
+
metadata=metadata,
|
|
683
530
|
)
|
|
684
|
-
qc_contract.validate_report(report)
|
|
685
|
-
return report
|
|
686
531
|
|
|
687
532
|
|
|
688
533
|
def _write_report(path: Path, report: Mapping[str, Any]) -> None:
|
|
689
|
-
|
|
690
|
-
path.write_text(json.dumps(redact_secrets(report), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
691
|
-
qc_contract.validate_report(json.loads(path.read_text(encoding="utf-8")))
|
|
534
|
+
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
692
535
|
|
|
693
536
|
|
|
694
537
|
def run(work_dir: str | Path, final_output: str | Path | None = None,
|
|
695
538
|
probe_fixture: Any = None, golden_fixture: Any = None,
|
|
696
|
-
probe_runner: ProbeRunner | None = None, only: str
|
|
539
|
+
probe_runner: ProbeRunner | None = None, only: str = "all") -> dict[str, Any]:
|
|
697
540
|
root = Path(work_dir)
|
|
698
541
|
root.mkdir(parents=True, exist_ok=True)
|
|
699
|
-
mode = {"final": "final_qc", "golden": "golden_eval"}.get(only
|
|
542
|
+
mode = {"final": "final_qc", "golden": "golden_eval"}.get(only, only)
|
|
700
543
|
if mode not in {"all", "final_qc", "golden_eval"}:
|
|
701
544
|
raise ValueError("only must be one of all, final_qc, golden_eval, final, golden")
|
|
702
545
|
result: dict[str, Any] = {"work_dir": str(root), "written": []}
|
|
@@ -704,12 +547,12 @@ def run(work_dir: str | Path, final_output: str | Path | None = None,
|
|
|
704
547
|
if mode in {"all", "final_qc"}:
|
|
705
548
|
final_report = build_final_qc(root, final_output=final_output, probe_fixture=probe_fixture, probe_runner=probe_runner)
|
|
706
549
|
_write_report(root / FINAL_QC_ARTIFACT, final_report)
|
|
707
|
-
result
|
|
550
|
+
result["final_qc"] = {"ok": final_report["ok"], "blocker_count": final_report["blocker_count"]}
|
|
708
551
|
result["written"].append(FINAL_QC_ARTIFACT)
|
|
709
552
|
if mode in {"all", "golden_eval"}:
|
|
710
553
|
golden_report = build_golden_eval(root, final_qc_report=final_report, golden_fixture=golden_fixture)
|
|
711
554
|
_write_report(root / GOLDEN_EVAL_ARTIFACT, golden_report)
|
|
712
|
-
result
|
|
555
|
+
result["golden_eval"] = {"ok": golden_report["ok"], "blocker_count": golden_report["blocker_count"]}
|
|
713
556
|
result["written"].append(GOLDEN_EVAL_ARTIFACT)
|
|
714
557
|
return result
|
|
715
558
|
|