From 32c5eb13535423fc897bbc71032972a4adfa43d5 Mon Sep 17 00:00:00 2001 From: 1500122958 <1500122958@qq.com> Date: Tue, 4 Aug 2026 14:44:52 +0800 Subject: [PATCH 1/6] feat(b-roll): add candidate ranking and review revisions --- skills/video-add-b-roll/SKILL.md | 249 ++- .../video-add-b-roll/assets/broll-review.html | 121 +- .../examples/example-broll-plan.json | 227 ++- .../examples/example-candidate-ranking.json | 223 +++ .../video-add-b-roll/reference/broll-rules.md | 131 +- skills/video-add-b-roll/scripts/broll_plan.py | 829 +++++++++- .../scripts/build_review_page.py | 85 +- .../scripts/candidate_analysis.py | 1447 +++++++++++++++++ .../video-add-b-roll/scripts/check_broll.py | 48 +- .../scripts/normalize_broll.py | 301 +++- skills/video-add-b-roll/scripts/pexels.py | 79 +- skills/video-add-b-roll/tests/test_broll.py | 1334 ++++++++++++++- 12 files changed, 4931 insertions(+), 143 deletions(-) create mode 100644 skills/video-add-b-roll/examples/example-candidate-ranking.json create mode 100644 skills/video-add-b-roll/scripts/candidate_analysis.py diff --git a/skills/video-add-b-roll/SKILL.md b/skills/video-add-b-roll/SKILL.md index ba57bb20f..b196ee89c 100644 --- a/skills/video-add-b-roll/SKILL.md +++ b/skills/video-add-b-roll/SKILL.md @@ -5,7 +5,7 @@ description: Use when a talking-head, interview, documentary, or explanatory vid # Video Add B-Roll -Add a small number of evidence-backed visual cutaways to an understood Project Protocol V1 +Add a small number of evidence-backed visual cutaways to an understood canonical Project Protocol sequence. Read [broll-rules.md](reference/broll-rules.md) before planning or acquiring any media. Use [example-broll-plan.json](examples/example-broll-plan.json) as the plan-shape reference. @@ -86,8 +86,17 @@ contract; do not hand-build the operation. ```text work/b-roll/broll-plan.json # durable domain plan work/b-roll/broll-interaction.json # durable applied receipt +work/b-roll/broll-revision-request-.json # durable unapproved request bytes +work/b-roll/candidate-search.json # durable query and provider order +work/b-roll/candidate-analysis.json # durable deterministic evidence +work/b-roll/candidate-ranking.json # durable Agent ranking and Top 3 +work/cache/b-roll/candidate-analysis/media/ # disposable analysis variants +work/cache/b-roll/candidate-analysis/frames/ # reproducible samples and crops work/cache/b-roll/candidates/ # frozen acquired media work/cache/b-roll/normalized/ # reproducible silent overlays +review/03-b-roll/candidate-analysis-/ # immutable analysis packet +review/03-b-roll/candidate-analysis-.md # analysis and baseline summary +review/03-b-roll/candidate-analysis-/candidate-index.html review/03-b-roll/b-roll-review-.html # immutable candidate review review/03-b-roll/b-roll-review.html # latest convenience alias review/03-b-roll/stills/ # first/middle/last verification frames @@ -135,10 +144,13 @@ Validate the draft after every material edit: python -c "import sys; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,sys.argv[2]); import broll_plan,projectlib; plan=projectlib.load_json(root/'work/b-roll/broll-plan.json'); timeline=projectlib.load_json(root/'work/timeline.json'); transcript=projectlib.load_json(root/'work/understand/transcript.json'); project=projectlib.load_json(root/'work/project.json'); errors=broll_plan.validate_plan(plan,timeline,transcript,project=project,project_root=root,verify_files=True); print('\n'.join(errors) if errors else 'B-roll plan valid'); raise SystemExit(bool(errors))" $ProjectRoot $BrollScripts ``` -### 3. Acquire And Freeze Candidates +### 3. Analyze, Rank, And Freeze Candidates -For local video, write a provenance JSON object containing `source_type: "local"`, creator, -license, and a timezone-aware retrieval time, then import it: +Candidate analysis is advisory evidence before `candidates_ready`. It does not select media, +approve a shot, add a receipt, or change the lifecycle. Keep the existing two or three literal +queries for every shot. + +For local video, write truthful provenance and import it through the existing acquisition path: ```powershell python "$BrollScripts/pexels.py" import-local ` @@ -147,46 +159,137 @@ python "$BrollScripts/pexels.py" import-local ` "$ProjectRoot/work/b-roll/example-local-provenance.json" ``` -Copy the returned acquisition record into the shot candidate, use a protocol-relative -`cache_path` such as `cache/b-roll/candidates/example-owned-footage.mp4`, and retain its real -probe, byte count, SHA-256, and provenance. The current local importer validates video; do -not bypass it to force unsupported media into the plan. +Copy the returned record into the shot's `candidates`. Candidate analysis reads that exact frozen +cache file; it does not copy it into another provider or analysis system. Use `--local-only` when +the plan should not query Pexels. + +Collect Pexels results with round-robin query merging, provider-ID deduplication, and a maximum +of eight unique provider videos per shot. Choose the orientation that matches the timeline: + +```powershell +$CandidateAnalysis = Join-Path $BrollScripts 'candidate_analysis.py' +python $CandidateAnalysis search ` + $ProjectRoot ` + "$ProjectRoot/work/b-roll/broll-plan.json" ` + "$ProjectRoot/work/b-roll/candidate-search.json" ` + --orientation portrait ` + --per-page 8 +``` + +For local-only analysis, add `--local-only`. The search record preserves every literal query, +its original provider order, the round-robin order, and both Pexels variants. The analysis variant +is the smallest orientation-matched file whose short edge is at least 480 pixels, or the largest +valid matching file when none qualifies. The delivery variant remains the highest-resolution +orientation-matched file. -For Pexels, search each of the shot's two or three literal queries through the one direct -module: +Download and fully decode only analysis variants, sample them at 10/30/50/70/90 percent, and +write deterministic machine evidence: ```powershell -python "$BrollScripts/pexels.py" search "precision parts factory floor" ` - --orientation landscape --per-page 10 +python $CandidateAnalysis analyze ` + $ProjectRoot ` + "$ProjectRoot/work/b-roll/broll-plan.json" ` + "$ProjectRoot/work/b-roll/candidate-search.json" ` + "$ProjectRoot/work/timeline.json" ` + "$ProjectRoot/work/b-roll/candidate-analysis.json" ``` -Choose only a semantically accurate result. Save that single returned candidate object as -JSON, add its intended protocol-relative `cache_path`, then use the provided downloader: +The analysis contains hard checks, objective warnings, sampled-frame and center-crop hashes, +contact sheets, luma/clipping/edge/motion evidence, and duplicate evidence. It preserves the +shot-local exact rejection and strict perceptual threshold, then adds project-wide exact identity, +strict perceptual groups, and non-suppressing `possible_series` hints. Hard rejection is limited to +the objective failures in `reference/broll-rules.md`. + +Inspect the exact transcript evidence, five frames, crop simulations, warnings, and duplicate +evidence. Write `work/cache/b-roll/candidate-ranking-input.json` with this shape: + +```json +{ + "schema_version": 1, + "analysis_sha256": "", + "mode": "agent", + "actor": "", + "timestamp": "", + "overall_rationale": "", + "near_duplicate_groups": [{ + "group_id": "", + "match_type": "same_series", + "actor": "", + "timestamp": "", + "members": [ + {"shot_id": "", "candidate_id": ""}, + {"shot_id": "", "candidate_id": ""} + ], + "rationale": "" + }], + "shots": [{ + "shot_id": "", + "candidates": [{ + "candidate_id": "", + "semantic_fit": 0, + "context_fit": 0, + "composition_fit": 0, + "style_fit": 0, + "text_logo_risk": "uncertain", + "avoid_violation": false, + "primary_subject_visible": true, + "near_duplicate_group": null, + "rationale": "" + }] + }] +} +``` + +Use integer fit scores from 0 through 4. `text_logo_risk` is 0 through 4 or `uncertain`; never +claim OCR. Score every analyzable candidate, preserve the real Agent identity, and do not use +`mode: "human"` for ranking. Confirm a project-wide near-duplicate group only after comparing the +frozen frames, transcript evidence, visual intent, provider identity, creator, and source title. +Do not confirm from creator identity alone. Omit `near_duplicate_groups` when none are confirmed; +the per-candidate `near_duplicate_group` field remains accepted for older shot-local records. + +Validate and apply the fixed public ranking rules: ```powershell -$PexelsRecord = "$ProjectRoot/work/b-roll/pexels-12345-acquired.json" -$PexelsJson = & python "$BrollScripts/pexels.py" download ` - "$ProjectRoot/work/b-roll/pexels-candidate.json" ` - "$ProjectRoot/work/cache/b-roll/candidates/pexels-12345.mp4" -if ($LASTEXITCODE -ne 0) { throw 'Pexels acquisition failed.' } -[IO.File]::WriteAllText($PexelsRecord, ($PexelsJson -join "`n") + "`n", [Text.UTF8Encoding]::new($false)) +python $CandidateAnalysis rank ` + $ProjectRoot ` + "$ProjectRoot/work/b-roll/candidate-analysis.json" ` + "$ProjectRoot/work/cache/b-roll/candidate-ranking-input.json" ` + "$ProjectRoot/work/b-roll/candidate-ranking.json" ``` -`$PexelsRecord` is the authoritative acquisition record: it contains the final redirected -URL, `path`, protocol-relative `cache_path`, SHA-256, bytes, probe, and synchronized -provenance. Replace the shot's search-result candidate with this complete record, then -revalidate the plan. Never rerun a successfully published download using the unhashed search -record; only the acquired record binds the published bytes. +See [example-candidate-ranking.json](examples/example-candidate-ranking.json) for the durable +ranking shape. Exact project duplicates and Agent-confirmed near duplicates are allocated to the +shot with the strongest semantic/context evidence, then each affected shot refills from its next +independent eligible candidate. `possible_series` never suppresses automatically. A shortlist +contains at most three candidates; fewer than three is valid and no eligible result must be +`no_eligible_candidates`. -The downloader owns `.part` recovery. After a transient HTTP or network failure, rerun the -exact `pexels.py download` command with the same candidate and destination and let it validate -Range responses, redirects/hosts, size, media, and hash before atomic publication or cleanup. -Never manually promote, rename, or delete a `.part` file. On downloader-declared validation -failure, honestly skip the shot; do not substitute a generic clip or publish partial bytes. +Only after ranking, acquire the full delivery variants and bind their exact bytes plus the active +analysis/ranking hashes into the plan: + +```powershell +python $CandidateAnalysis acquire ` + $ProjectRoot ` + "$ProjectRoot/work/b-roll/broll-plan.json" ` + "$ProjectRoot/work/b-roll/candidate-analysis.json" ` + "$ProjectRoot/work/b-roll/candidate-ranking.json" +``` -External generation follows the local import command with `source_type: -"external-generated"` and complete truthful generation provenance. This is import only, not -authorization to call a generation service. +The acquire command moves a shot only to `candidates_ready` or `skipped`. It cannot select a +candidate or approve a plan. The downloader owns `.part` recovery for both analysis and delivery +variants. Never manually promote, rename, or delete a `.part` file. + +Publish the immutable analysis packet before exact-candidate review: + +```powershell +python $CandidateAnalysis publish ` + $ProjectRoot ` + "$ProjectRoot/work/b-roll/candidate-analysis.json" ` + "$ProjectRoot/work/b-roll/candidate-ranking.json" +``` + +External generation still follows `import-local` with `source_type: "external-generated"` and +complete truthful provenance. This is import only, not authorization to call a generation service. ### 4. Publish And Complete Exact-Candidate Review @@ -205,28 +308,83 @@ $ReviewPublication = & python "$BrollScripts/build_review_page.py" ` Start-Process (Resolve-Path "$ProjectRoot/review/03-b-roll/b-roll-review.html") ``` -Apply the review checks in `reference/broll-rules.md`. The export must decide every shot -exactly once. The browser downloads `b-roll-review-.json`; after exporting, bind the -actual operator-chosen download location rather than assuming it is in the review directory: +Apply the review checks in `reference/broll-rules.md`. The page uses the readable mapped transcript +as the primary A-roll context. It keeps word timing, clip mapping, search queries, and full +provenance in the payload while placing technical details behind collapsed disclosures. Visible +time values use at most two decimal places. Insert start/end move in explicit 0.5-second steps +inside the displayed original-value plus-or-minus-two-second bounds, then snap exported values to +the rational timeline frame grid. Select one to three ordered, unique candidates from the +hash-bound Top 3 for a shot. The page divides a new multi-candidate choice by integer timeline +frames, lets the user reorder segments or adjust an adjacent boundary, and keeps the total shot +range unchanged. Choose only `0.5x`, `1x`, `1.5x`, or `2x` playback for each segment; the page +calculates source end from the allocated program duration, exposes `remaining`/`overflow`, and +requires a visible `Fit to A-roll` result before export. Copy and Download JSON remain disabled +when no legal fit exists, and their JSON records every ordered segment, range, and playback rate. + +`Modification notes` is optional. A non-empty value, changed program timing, or a changed prefilled +segment forces `submission_intent: request_revision`; an explicit Request changes action may use +empty notes. `submission_intent: approve` means the exact current immutable page configuration. +`Copy` is the primary handoff and places the complete JSON in both a readonly textarea and the +clipboard when available. `Download JSON` downloads those same bytes for durable local transfer. +Neither action applies or approves the review. + +After `Download JSON`, bind the actual operator-chosen download location rather than assuming it is +in the review directory: ```powershell $ReviewExport = (Resolve-Path -LiteralPath "/b-roll-review-$($ReviewPublication.review_id).json").Path ``` -For human mode, present the page and stop. Apply only the JSON the user explicitly exports; -it must contain `explicit_user_action: true`. For Agent mode, proceed only when the user has +For human mode, present the page and stop. Apply only the complete JSON the user explicitly copies +or downloads; preserve copied JSON byte-for-byte in a file before validation. It must contain +`explicit_user_action: true`. For Agent mode, proceed only when the user has explicitly delegated the decision or requested autonomous completion. Inspect the same exact -assets, export truthful decisions under `mode: "agent"`, name the real Agent actor, and give +assets, record truthful decisions under `mode: "agent"`, name the real Agent actor, and give a non-empty decision rationale. Never create a human-mode receipt from silence or inference. -Apply the exported review and durably bind the interaction receipt: +Present the candidate-analysis summary and this exact full-candidate page together, then stop. +Do not apply review, normalize, build a render plan, or render delivery until the user explicitly +copies, downloads, or approves exact candidate selections in a later turn. Agent ranking is not +user approval. + +Inspect `submission_intent` before applying anything. For `request_revision`, preserve the exact +export bytes under `work/b-roll`, validate them against the old immutable page, rebuild an +unapproved proposal, and reclassify duration evidence without redownloading or resampling: + +```powershell +$Revision = Get-Content -LiteralPath $ReviewExport -Raw -Encoding UTF8 | ConvertFrom-Json +if ($Revision.submission_intent -ne 'request_revision') { throw 'Expected request_revision.' } +$SavedRequest = Join-Path $ProjectRoot "work/b-roll/broll-revision-request-$($Revision.review_id).json" +[IO.File]::Copy($ReviewExport, $SavedRequest, $false) + +python -c "import sys; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,sys.argv[2]); import broll_plan,projectlib; plan=projectlib.load_json(root/'work/b-roll/broll-plan.json'); request=projectlib.load_json(sys.argv[3]); timeline=projectlib.load_json(root/'work/timeline.json'); transcript=projectlib.load_json(root/'work/understand/transcript.json'); errors=broll_plan.validate_revision_request(plan,request,timeline,transcript); print('\n'.join(errors) if errors else 'Revision request valid'); raise SystemExit(bool(errors))" $ProjectRoot $BrollScripts $SavedRequest + +python -c "import sys; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,sys.argv[2]); import broll_plan,projectlib; path=root/'work/b-roll/broll-plan.json'; plan=projectlib.load_json(path); request=projectlib.load_json(sys.argv[3]); timeline=projectlib.load_json(root/'work/timeline.json'); transcript=projectlib.load_json(root/'work/understand/transcript.json'); projectlib.write_json(path,broll_plan.rebuild_plan_from_revision(plan,request,timeline,transcript))" $ProjectRoot $BrollScripts $SavedRequest + +python "$BrollScripts/candidate_analysis.py" reclassify ` + $ProjectRoot ` + "$ProjectRoot/work/b-roll/broll-plan.json" ` + "$ProjectRoot/work/b-roll/candidate-analysis.json" ` + "$ProjectRoot/work/timeline.json" ` + "$ProjectRoot/work/b-roll/candidate-analysis.json" +``` + +Inspect every `agent_rescore_required` marker. When true, compare the revised transcript evidence +against the same frozen frames and write a truthful Agent score update; never carry a semantic +rationale across changed evidence by inference. Rerun `rank` and `acquire` so the plan binds the new +analysis/ranking hashes. Existing SHA-256-identical analysis and delivery media are reused. Publish +a new UUID review page with `build_review_page.py`, present it, and stop again. Never route a +revision request to `apply_review()` or convert `revision_notes` into a human rationale. + +Apply the received review JSON and durably bind the interaction receipt: ```powershell -python -c "import sys; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,sys.argv[2]); import broll_plan,projectlib; path=root/'work/b-roll/broll-plan.json'; plan=projectlib.load_json(path); review=projectlib.load_json(sys.argv[3]); updated=broll_plan.apply_review(plan,review,mode=sys.argv[4],actor=sys.argv[5],rationale=sys.argv[6],interaction_path=root/'work/b-roll/broll-interaction.json'); projectlib.write_json(path,updated)" $ProjectRoot $BrollScripts $ReviewExport agent Codex "Selected literal process footage; skipped candidates that did not match the claim." +python -c "import sys; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,sys.argv[2]); import broll_plan,projectlib; path=root/'work/b-roll/broll-plan.json'; plan=projectlib.load_json(path); review=projectlib.load_json(sys.argv[3]); timeline=projectlib.load_json(root/'work/timeline.json'); updated=broll_plan.apply_review(plan,review,mode=sys.argv[4],actor=sys.argv[5],rationale=review['rationale'],interaction_path=root/'work/b-roll/broll-interaction.json',timeline=timeline); projectlib.write_json(path,updated)" $ProjectRoot $BrollScripts $ReviewExport human "Actual user name" ``` -Use `human` and the actual human actor only after explicit user export. The command rationale -must exactly match the exported rationale. +Use `human` and the actual human actor only after explicit user export. A new human approve uses +the page's factual `review_ui_explicit_action` rationale; it does not claim to quote a user-authored +reason. Agent mode still requires the real Agent actor and a specific exported Agent rationale. ### 5. Normalize Approved Selections @@ -238,8 +396,9 @@ python -c "import sys; from pathlib import Path; root=Path(sys.argv[1]); sys.pat Without active color grade, call the same API with `lut=None`. Never omit the LUT when color grade is active and never apply an unselected look. The normalizer produces silent H.264 -overlays at timeline dimensions and exact rational FPS, preserves aspect ratio, enforces the -selected trim or Ken Burns direction, and publishes each validated result atomically. +overlays at timeline dimensions and exact rational FPS, preserves aspect ratio, reads the complete +canonical source range, permits at most one frame of final quantization adjustment, and publishes +each validated result atomically. It never silently removes a multi-second canonical tail. Rerun `normalize_plan()` after interruption. It validates and preserves completed normalized shots before continuing; do not hand-promote `.part.mp4` or `.part.json` files. diff --git a/skills/video-add-b-roll/assets/broll-review.html b/skills/video-add-b-roll/assets/broll-review.html index 8fc8ef456..41d148a6e 100644 --- a/skills/video-add-b-roll/assets/broll-review.html +++ b/skills/video-add-b-roll/assets/broll-review.html @@ -4,20 +4,121 @@ B-roll candidate review -

B-roll candidate review

+
+

B-roll candidate review

+
+
+
+

Decision summary

+

+
+ + +
+ Submission intent + + +
+

+
+ + + +
+ + +
+
+
diff --git a/skills/video-add-b-roll/examples/example-broll-plan.json b/skills/video-add-b-roll/examples/example-broll-plan.json index ded3f6b27..21c00a158 100644 --- a/skills/video-add-b-roll/examples/example-broll-plan.json +++ b/skills/video-add-b-roll/examples/example-broll-plan.json @@ -29,6 +29,24 @@ "generic office footage" ] }, + "candidate_ranking": { + "path": "b-roll/candidate-ranking.json", + "sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "analysis_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "shortlists": [ + { + "shot_id": "example-manufacturing-process", + "candidate_ids": [ + "example-owned-factory-video", + "example-owned-factory-detail-video" + ] + }, + { + "shot_id": "example-software-claim", + "candidate_ids": [] + } + ] + }, "review_status": "approved", "decision": { "mode": "agent", @@ -37,6 +55,8 @@ }, "review": { "status": "approved", + "submission_intent": "approve", + "revision_notes": "", "review_id": "123e4567-e89b-12d3-a456-426614174000", "mode": "agent", "actor": "Example Agent", @@ -49,11 +69,36 @@ { "id": "example-manufacturing-process", "decision": "select", - "candidate_id": "example-owned-factory-video", - "source_trim": { - "start_s": 0.5, - "end_s": 3.5 - } + "program_range": { + "start_s": 6.0, + "end_s": 9.0 + }, + "segments": [ + { + "candidate_id": "example-owned-factory-video", + "source_range": { + "start_s": 0.5, + "end_s": 1.5 + }, + "program_range": { + "start_s": 6.0, + "end_s": 7.0 + }, + "playback_rate": 1.0 + }, + { + "candidate_id": "example-owned-factory-detail-video", + "source_range": { + "start_s": 0.5, + "end_s": 3.5 + }, + "program_range": { + "start_s": 7.0, + "end_s": 9.0 + }, + "playback_rate": 1.5 + } + ] }, { "id": "example-software-claim", @@ -64,7 +109,8 @@ "example-software-claim" ], "selected_asset_sha256": [ - "6666666666666666666666666666666666666666666666666666666666666666" + "6666666666666666666666666666666666666666666666666666666666666666", + "6767676767676767676767676767676767676767676767676767676767676767" ] }, "shots": [ @@ -123,22 +169,177 @@ "license": "Example owned footage - replace with real rights statement", "retrieval_time": "2026-07-23T10:00:00Z", "original_path": "input/example-owned-factory-video.mp4" + }, + "ranking": { + "rank": 1, + "scores": { + "semantic_fit": 4, + "context_fit": 4, + "composition_fit": 3, + "style_fit": 3, + "text_logo_risk": 0 + }, + "warnings": [], + "rationale": "The visible manufacturing line directly supports the spoken process claim and remains readable in the target framing.", + "duplicate_notes": [] + } + }, + { + "id": "example-owned-factory-detail-video", + "media_type": "video", + "cache_path": "cache/b-roll/candidates/example-owned-factory-detail-video.mp4", + "sha256": "6767676767676767676767676767676767676767676767676767676767676767", + "bytes": 1123456, + "duration_s": 4.0, + "probe": { + "duration_s": 4.0, + "width": 1920, + "height": 1080, + "codec": "h264" + }, + "provenance": { + "source_type": "local", + "creator": "Example Studio", + "license": "Example owned footage - replace with real rights statement", + "retrieval_time": "2026-07-23T10:00:00Z", + "original_path": "input/example-owned-factory-detail-video.mp4" + }, + "ranking": { + "rank": 2, + "scores": { + "semantic_fit": 3, + "context_fit": 4, + "composition_fit": 4, + "style_fit": 3, + "text_logo_risk": 0 + }, + "warnings": [], + "rationale": "The closer detail view supplies a distinct second beat while remaining specific to the manufacturing claim.", + "duplicate_notes": [] } } ], "selected": { - "candidate_id": "example-owned-factory-video", - "source_trim": { - "start_s": 0.5, - "end_s": 3.5 - } + "segments": [ + { + "candidate_id": "example-owned-factory-video", + "source_range": { + "start_s": 0.5, + "end_s": 1.5 + }, + "program_range": { + "start_s": 6.0, + "end_s": 7.0 + }, + "playback_rate": 1.0 + }, + { + "candidate_id": "example-owned-factory-detail-video", + "source_range": { + "start_s": 0.5, + "end_s": 3.5 + }, + "program_range": { + "start_s": 7.0, + "end_s": 9.0 + }, + "playback_rate": 1.5 + } + ] }, "status": "verified", "normalized": { "path": "cache/b-roll/normalized/broll-001.mp4", - "source_path": "cache/b-roll/candidates/example-owned-factory-video.mp4", - "source_sha256": "6666666666666666666666666666666666666666666666666666666666666666", + "selection_format": "canonical", "sha256": "7777777777777777777777777777777777777777777777777777777777777777", + "concat_sha256": "7777777777777777777777777777777777777777777777777777777777777777", + "source_paths": [ + "cache/b-roll/candidates/example-owned-factory-video.mp4", + "cache/b-roll/candidates/example-owned-factory-detail-video.mp4" + ], + "source_sha256s": [ + "6666666666666666666666666666666666666666666666666666666666666666", + "6767676767676767676767676767676767676767676767676767676767676767" + ], + "segments": [ + { + "candidate_id": "example-owned-factory-video", + "segment": { + "candidate_id": "example-owned-factory-video", + "source_range": { + "start_s": 0.5, + "end_s": 1.5 + }, + "program_range": { + "start_s": 6.0, + "end_s": 7.0 + }, + "playback_rate": 1.0 + }, + "source_path": "cache/b-roll/candidates/example-owned-factory-video.mp4", + "source_sha256": "6666666666666666666666666666666666666666666666666666666666666666", + "normalized_path": "cache/b-roll/normalized/broll-001-segment-01.mp4", + "normalized_sha256": "7878787878787878787878787878787878787878787878787878787878787878", + "source_duration_s": 1.0, + "effective_duration_s": 1.0, + "program_duration_s": 1.0, + "playback_rate": 1.0, + "probe": { + "duration_s": 1.0, + "width": 1920, + "height": 1080, + "fps": { + "num": 30000, + "den": 1001 + }, + "sar": "1:1", + "codec": "h264", + "pix_fmt": "yuv420p", + "has_audio": false, + "has_subtitles": false, + "has_data": false + } + }, + { + "candidate_id": "example-owned-factory-detail-video", + "segment": { + "candidate_id": "example-owned-factory-detail-video", + "source_range": { + "start_s": 0.5, + "end_s": 3.5 + }, + "program_range": { + "start_s": 7.0, + "end_s": 9.0 + }, + "playback_rate": 1.5 + }, + "source_path": "cache/b-roll/candidates/example-owned-factory-detail-video.mp4", + "source_sha256": "6767676767676767676767676767676767676767676767676767676767676767", + "normalized_path": "cache/b-roll/normalized/broll-001-segment-02.mp4", + "normalized_sha256": "7979797979797979797979797979797979797979797979797979797979797979", + "source_duration_s": 3.0, + "effective_duration_s": 2.0, + "program_duration_s": 2.0, + "playback_rate": 1.5, + "probe": { + "duration_s": 2.0, + "width": 1920, + "height": 1080, + "fps": { + "num": 30000, + "den": 1001 + }, + "sar": "1:1", + "codec": "h264", + "pix_fmt": "yuv420p", + "has_audio": false, + "has_subtitles": false, + "has_data": false + } + } + ], + "program_duration_s": 3.0, "probe": { "duration_s": 3.0, "width": 1920, diff --git a/skills/video-add-b-roll/examples/example-candidate-ranking.json b/skills/video-add-b-roll/examples/example-candidate-ranking.json new file mode 100644 index 000000000..bed7af417 --- /dev/null +++ b/skills/video-add-b-roll/examples/example-candidate-ranking.json @@ -0,0 +1,223 @@ +{ + "schema_version": 1, + "analysis_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "decision": { + "mode": "agent", + "actor": "Example Analysis Agent", + "timestamp": "2026-07-23T11:30:00Z", + "overall_rationale": "The frozen frames were compared across both transcript moments before global allocation." + }, + "project_duplicate_evidence": { + "exact_groups": [ + { + "group_id": "exact-001", + "members": [ + { + "shot_id": "example-manufacturing-process", + "candidate_id": "example-owned-factory-video" + }, + { + "shot_id": "example-output-system", + "candidate_id": "example-duplicate-factory-video" + } + ], + "evidence": [ + { + "kind": "delivery_sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + } + ], + "strict_perceptual_groups": [], + "possible_series": [ + { + "hint_id": "possible-series-001", + "members": [ + { + "shot_id": "example-manufacturing-process", + "candidate_id": "example-owned-factory-video" + }, + { + "shot_id": "example-output-system", + "candidate_id": "example-factory-control-room" + } + ], + "evidence": [ + { + "kind": "same_creator", + "value": "Example Owner" + }, + { + "kind": "shared_source_terms", + "value": [ + "factory" + ] + } + ] + } + ] + }, + "agent_confirmed_near_duplicates": [], + "global_allocations": [ + { + "allocation_id": "global-allocation-001", + "source_groups": [ + { + "kind": "exact_duplicate", + "group_id": "exact-001" + } + ], + "members": [ + { + "shot_id": "example-manufacturing-process", + "candidate_id": "example-owned-factory-video" + }, + { + "shot_id": "example-output-system", + "candidate_id": "example-duplicate-factory-video" + } + ], + "kept": { + "shot_id": "example-manufacturing-process", + "candidate_id": "example-owned-factory-video" + }, + "suppressed": [ + { + "shot_id": "example-output-system", + "candidate_id": "example-duplicate-factory-video" + } + ], + "reason": "Kept in the manufacturing claim because its semantic and context evidence is stronger there." + } + ], + "shots": [ + { + "shot_id": "example-manufacturing-process", + "base_order": [ + "example-owned-factory-video" + ], + "base_top3": [ + "example-owned-factory-video" + ], + "outcome": "ranked", + "top3": [ + "example-owned-factory-video" + ], + "refills": [], + "duplicate_groups": [], + "candidates": [ + { + "candidate_id": "example-owned-factory-video", + "provider_id": null, + "scores": { + "semantic_fit": 4, + "context_fit": 4, + "composition_fit": 3, + "style_fit": 3, + "text_logo_risk": 0 + }, + "rationale": "The visible manufacturing line directly supports the spoken process claim.", + "avoid_violation": false, + "primary_subject_visible": true, + "near_duplicate_group": null, + "warnings": [], + "warning_count": 0, + "eligible": true, + "ineligibility_reasons": [], + "suppressed_near_duplicate": false, + "suppressed_global_duplicate": false, + "base_rank": 1, + "rank": 1, + "global_allocation": { + "status": "kept", + "reason": "This is the strongest semantic and context placement for the exact media." + }, + "shortlist_reason": "Retained from the shot base ranking after global allocation.", + "similar_footage": [ + { + "shot_id": "example-output-system", + "candidate_id": "example-factory-control-room" + } + ] + } + ] + }, + { + "shot_id": "example-output-system", + "base_order": [ + "example-factory-control-room", + "example-duplicate-factory-video" + ], + "base_top3": [ + "example-factory-control-room", + "example-duplicate-factory-video" + ], + "outcome": "ranked", + "top3": [ + "example-factory-control-room" + ], + "refills": [], + "duplicate_groups": [], + "candidates": [ + { + "candidate_id": "example-factory-control-room", + "provider_id": null, + "scores": { + "semantic_fit": 4, + "context_fit": 3, + "composition_fit": 3, + "style_fit": 3, + "text_logo_risk": 0 + }, + "rationale": "The control room directly supports the output-system claim without repeating the line footage.", + "avoid_violation": false, + "primary_subject_visible": true, + "near_duplicate_group": null, + "warnings": [], + "warning_count": 0, + "eligible": true, + "ineligibility_reasons": [], + "suppressed_near_duplicate": false, + "suppressed_global_duplicate": false, + "base_rank": 1, + "rank": 1, + "shortlist_reason": "Retained from the shot base ranking after global allocation.", + "similar_footage": [ + { + "shot_id": "example-manufacturing-process", + "candidate_id": "example-owned-factory-video" + } + ] + }, + { + "candidate_id": "example-duplicate-factory-video", + "provider_id": null, + "scores": { + "semantic_fit": 3, + "context_fit": 2, + "composition_fit": 3, + "style_fit": 3, + "text_logo_risk": 0 + }, + "rationale": "The line is relevant but belongs more strongly to the manufacturing claim.", + "avoid_violation": false, + "primary_subject_visible": true, + "near_duplicate_group": null, + "warnings": [], + "warning_count": 0, + "eligible": true, + "ineligibility_reasons": [], + "suppressed_near_duplicate": true, + "suppressed_global_duplicate": true, + "base_rank": 2, + "global_allocation": { + "status": "suppressed", + "reason": "The exact media is kept at the stronger manufacturing placement." + }, + "similar_footage": [] + } + ] + } + ] +} diff --git a/skills/video-add-b-roll/reference/broll-rules.md b/skills/video-add-b-roll/reference/broll-rules.md index cfd759582..d1572f0c2 100644 --- a/skills/video-add-b-roll/reference/broll-rules.md +++ b/skills/video-add-b-roll/reference/broll-rules.md @@ -16,7 +16,7 @@ ## Sources And Provenance -- V1 sources are validated local media and direct Pexels results acquired by +- Supported sources are validated local media and direct Pexels results acquired by `scripts/pexels.py`. Do not introduce a provider interface, factory, broker, another stock source, or raw download command. - Every candidate needs a stable ID, media type, project-cache path, positive byte count, @@ -30,6 +30,69 @@ or job ID, creator, usage rights, retrieval time, and original path. Never imply the skill generated it or call a paid generation API. +## Candidate Collection And Analysis + +- Keep two or three narrow literal queries per shot. Preserve each query and its original Pexels + order, merge results round-robin, deduplicate by positive `provider_id`, and analyze at most + eight unique Pexels videos per shot. +- Keep separate Pexels `analysis_variant` and `delivery_variant` records. The analysis variant is + the smallest orientation-matched file with a short edge of at least 480 pixels, or the largest + valid matching file when none qualifies. The delivery variant is the highest-resolution + orientation-matched file and is downloaded only for the ranked shortlist. +- Never promote analysis media to a delivery candidate. Bind both variants' provider/file IDs, + dimensions, direct URLs, acquisition records, byte counts, probes, and SHA-256 values. +- Analyze an already imported local or externally generated candidate from its exact frozen cache + file. Do not create a provider wrapper or a second acquisition system for it. +- Extract exactly five frames at 10, 30, 50, 70, and 90 percent. Preserve frame/crop paths, + dimensions, SHA-256 values, luma/clipping/edge/motion metrics, center-crop simulations, and fixed + perceptual hashes. Every number must be finite and JSON-safe. +- Hard-reject only invalid provenance or URL identity, path escape, unsafe or incomplete download, + invalid video metadata, failed full decode, insufficient duration beyond one timeline-frame + tolerance, failed five-frame sampling, or an exact provider/byte/frame duplicate. +- Treat low resolution, crop loss, unusual luma or clipping, low edge detail, unusual sampled-frame + change, suspected compression/noise/shake/text/logo/watermark, and non-exact perceptual similarity + as warnings. Uncalibrated metrics must not reject intentional night, static, shallow-focus, or + high-motion footage. +- Classify duration as reject when `candidate_duration + one timeline frame < shot_duration`, warn + when it covers the shot with less than one second of trim padding, and pass otherwise. +- Build project-wide exact identity groups from matching provider candidate identity, provider and + delivery file identity, analysis or delivery SHA-256, or an equivalent frozen local path, byte + count, and SHA-256. Keep every cross-shot instance analyzable so semantic scoring decides the + strongest placement; do not reject by encounter order. +- Keep the five-frame perceptual-hash average threshold at `6`. Treat it as a strict near-identical + signal, not a same-series classifier. Do not widen it to cover visually related shoots. +- Record same-provider, same-creator, nearby provider ID, shared source-title terms, or partial + perceptual overlap as `possible_series` evidence only. A possible-series hint never changes + eligibility or ranking by itself. + +## Agent Scoring And Ranking + +- Agent ranking requires actual delegated authority, `mode: "agent"`, the real actor name, a + timezone-aware timestamp, a non-empty overall rationale, and a concrete rationale for every + analyzable candidate. Ranking is advisory and is never human approval. +- Score `semantic_fit`, `context_fit`, `composition_fit`, and `style_fit` with integers from 0 + through 4: 0 mismatch/unusable, 1 weak, 2 acceptable with concerns, 3 strong, and 4 unusually + strong. Record `text_logo_risk` from 0 through 4 or `uncertain`; never claim OCR from sampled + frames. +- Mark a candidate ineligible when a hard check fails, semantic or context fit is zero, it clearly + violates `brief.avoid`, or its primary subject cannot be identified in the target framing. +- Confirm project-wide near-duplicate groups from provider identity, exact/perceptual evidence, and + visible comparison across the frozen five-frame sets. Record the real Agent actor, timezone-aware + timestamp, member shot and candidate IDs, match type, and a concrete reason the footage would + repeat across the named transcript moments. Never confirm from creator identity alone. +- Rank eligible candidates by: semantic plus context fit; the lower of those two scores; + composition plus style fit; fewer deterministic warnings; then stable `provider_id` and candidate + ID. Preserve the base order before global allocation. + Preserve every sub-score and rationale instead of an opaque total. +- Allocate every exact or Agent-confirmed duplicate component to its strongest semantic/context + placement across all shots. Suppress the other members, refill from each shot's next independent + eligible candidate, and preserve the keep, suppress, and refill reasons. Never use a duplicate + fallback to reach three candidates. +- Return at most three candidates. Return fewer when fewer qualify. Record + `no_eligible_candidates` when none qualify; never invent a weak third choice or fallback media. +- Hash-bind search order, analysis media, sampled frames, deterministic evidence, Agent scoring, + ranking, shortlist, acquired delivery bytes, plan binding, and exact-candidate review. + ## Stills - A still is never a fallback for a failed video candidate. @@ -43,11 +106,75 @@ - Review the exact frozen candidate bytes against the exact source/program moment. - Check semantic truth, start/end boundaries, framing, visual quality, repeated imagery, unwanted logos or text, and compatibility with the selected grade. -- Human mode requires an explicit user export. Delegated Agent mode requires actual delegated +- Use the readable mapped A-roll transcript as the primary timing context. Keep the A-roll frame + as secondary visual context. Retain exact word timing, clip mapping, literal stock queries, and + complete provenance in the immutable payload, but place those technical details behind collapsed + disclosures. Present candidate provenance as a concise source, creator, dimensions, duration, + license, source-link, and terms-link summary rather than raw JSON. +- Keep the current immutable page's insert start and end independently within their original + values plus or minus two seconds, further limited by the timeline, adjacent shots, valid source + mapping, and a minimum duration of one rational timeline frame. Move each visible control by + 0.5 seconds per action, display time values with at most two decimal places, and snap exported + values to frame boundaries without replacing untouched canonical values with display rounding. +- Treat `Modification notes` as optional `revision_notes`. Preserve the user's text exactly. A + non-empty value requests a revision and is never an approval rationale. +- Set `submission_intent` to `approve` only for the exact configuration displayed by the current + page. Set it to `request_revision` for changed program timing, a changed prefilled segment, + non-empty notes, or an explicit Request changes action. +- Reject `request_revision` in `apply_review()` before any plan or receipt write. Validate its + review UUID, plan/candidate/video hashes, explicit action, timing, and selected candidate; then + rebuild the proposal, source mapping, transcript evidence, hashes, and a new immutable page. + Stop for approval again. Never convert revision notes into human authority. +- Human mode requires an explicit user `Copy` or `Download JSON` action. Both controls must use one + receipt builder, the same validation, and the same JSON bytes while the form is unchanged. Copy + keeps a readonly textarea fallback when clipboard access fails. Neither control applies review. +- Delegated Agent mode requires actual delegated authority, the real actor name, exact decisions, and a non-empty rationale. Never fabricate human approval. - Select an explicit video trim or still motion for every selected shot; explicitly skip all others. Missing, rejected, or unrecoverable candidates stay skipped without random fallback. +- Keep full global keep, suppress, and refill audit in the candidate-analysis summary. On exact + candidate cards, show only unresolved cross-shot similarity that directly helps selection, with + the other shot ID and candidate ID. Do not show internal suppression notes on cards. +- Show the analysis summary and exact full-candidate page together, then stop. Do not apply review, + normalize, compile, or render until the user explicitly copies, downloads, or approves exact + selections. + +## Segment Timing And Playback + +- Store every new selected video as one to three ordered canonical `segments`. Each segment names a + unique candidate from the shot's hash-bound Top 3 and includes complete `source_range`, + `program_range`, and `playback_rate` fields. Accept only `0.5`, `1.0`, `1.5`, or `2.0`. +- Allocate program time in integer timeline frames. One segment occupies the complete shot. Split two + or three new segments evenly and give any remainder frames to the last segment. Keep every segment + at least one frame, continuous, non-overlapping, and collectively equal to the shot program range. +- Keep allocation with its segment when reordering. Recompute its program start/end from the new + order without changing its duration, source range, or speed. Moving a boundary adds frames to one + neighbor and removes the same number from the other. +- Treat A-roll program allocation as controlling. Compute required source duration as + `program_duration * playback_rate` and source end as `source_start + required_source_duration`. + Display source-end differences as `remaining` or `overflow`; never hide a repair in Copy, + `apply_review()`, or normalization. +- Make `Fit to A-roll` explicit. First satisfy a segment from its remaining source duration, then + redistribute unavailable frames from the end toward earlier segments that have capacity. Preserve + playback rates, segment identity, and segment count. Show the proposed ranges before export and + disable Copy/Download when no legal fit exists. +- Require `source_duration = source_end - source_start`, + `effective_duration = source_duration / playback_rate`, and + `program_duration = program_end - program_start` to differ by no more than one frame derived from + `timeline.fps.den / timeline.fps.num` for every segment. +- Normalize each source range independently with its reviewed rate, probe and fully decode it, and + bind its source hash, exact parameters, output hash, probe, and three durations. Hard-cut the + validated silent segments in program order, then probe and fully decode the concat before atomically + publishing the shot overlay. Do not add transitions, loop, repeat, silently truncate, auto-change + speed, delete a segment, or select fallback media. +- On rerun, reuse a component only when its sidecar, source hash, parameters, normalized hash, probe, + and full decode still match. Keep an existing published shot overlay when a later component or + concat fails and remove only incomplete `.part` outputs. +- Read legacy `selected.candidate_id + source_trim` without rewriting the file. Record both a long + legacy requested trim and its effective source range so old normalized output remains recoverable; + reject a legacy trim that cannot cover the program duration. Never emit an ambiguous legacy trim + from a new review page. ## Interrupted Work diff --git a/skills/video-add-b-roll/scripts/broll_plan.py b/skills/video-add-b-roll/scripts/broll_plan.py index fd64394be..0ebf909f2 100644 --- a/skills/video-add-b-roll/scripts/broll_plan.py +++ b/skills/video-add-b-roll/scripts/broll_plan.py @@ -18,6 +18,9 @@ RANGE_EPSILON = 1e-6 KEN_BURNS_DIRECTIONS = {"zoom-in", "pan-left", "pan-right"} +HUMAN_APPROVAL_RATIONALE = "Explicit user action approved the exact configuration shown in this review." +REVIEW_INTENTS = {"approve", "request_revision"} +PLAYBACK_RATES = (0.5, 1.0, 1.5, 2.0) VISUAL_REVIEW_CHECKS = ( "semantic_fit", "unwanted_logos_or_text", "jump_cuts", "entry_exit_boundaries", "grade_match", @@ -85,6 +88,257 @@ def _valid_ken_burns(value): return isinstance(value, dict) and isinstance(value.get("direction"), str) and value["direction"] in KEN_BURNS_DIRECTIONS +def timeline_frame_duration(timeline): + fps = timeline.get("fps") if isinstance(timeline, dict) else None + num = fps.get("num") if isinstance(fps, dict) else None + den = fps.get("den") if isinstance(fps, dict) else None + if (not isinstance(num, int) or isinstance(num, bool) or num <= 0 + or not isinstance(den, int) or isinstance(den, bool) or den <= 0): + raise ValueError("timeline fps num and den must be positive integers") + return den / num + + +def _range_dict(value): + parsed = _range(value) + if not parsed: + return None + return {"start_s": parsed[0], "end_s": parsed[1]} + + +def _ranges_equal(left, right, *, tolerance=RANGE_EPSILON): + left_range, right_range = _range(left), _range(right) + return bool( + left_range and right_range + and abs(left_range[0] - right_range[0]) <= tolerance + and abs(left_range[1] - right_range[1]) <= tolerance + ) + + +def _frame_index(value, frame_duration): + number = _strict_finite_number(value) + if number is _INVALID_NUMBER: + return None + frame = round(number / frame_duration) + return frame if abs(number - frame * frame_duration) <= RANGE_EPSILON else None + + +def allocate_program_ranges(program_range, segment_count, timeline): + """Split a frame-aligned program range; the final segment absorbs the remainder.""" + if (not isinstance(segment_count, int) or isinstance(segment_count, bool) + or segment_count < 1 or segment_count > 3): + raise ValueError("segment_count must be 1-3") + frame_duration = timeline_frame_duration(timeline) + program = _range(program_range) + if not program: + raise ValueError("program_range is invalid") + start_frame = _frame_index(program[0], frame_duration) + end_frame = _frame_index(program[1], frame_duration) + if start_frame is None or end_frame is None or end_frame - start_frame < segment_count: + raise ValueError("program_range must be frame-aligned with at least one frame per segment") + total_frames = end_frame - start_frame + base_frames = total_frames // segment_count + frame_counts = [base_frames] * segment_count + frame_counts[-1] += total_frames % segment_count + ranges = [] + cursor = start_frame + for index, frame_count in enumerate(frame_counts): + following = cursor + frame_count + ranges.append({ + "start_s": program[0] if index == 0 else round(cursor * frame_duration, 9), + "end_s": program[1] if index == segment_count - 1 else round(following * frame_duration, 9), + }) + cursor = following + return ranges + + +def _canonical_segment_errors(segment, candidate, program_range, frame_duration): + errors = [] + if not isinstance(segment, dict): + return ["segment must be an object"] + candidate_id = candidate.get("id") if isinstance(candidate, dict) else None + if segment.get("candidate_id") != candidate_id: + errors.append("segment candidate_id does not match candidate") + rate = _strict_finite_number(segment.get("playback_rate")) + if rate is _INVALID_NUMBER or rate not in PLAYBACK_RATES: + errors.append("segment playback_rate must be one of 0.5, 1.0, 1.5, or 2.0") + source = _range(segment.get("source_range")) + program = _range(segment.get("program_range")) + expected_program = _range(program_range) + if not source or source[0] < 0 or source[1] <= source[0]: + errors.append("segment source_range is invalid") + elif not _valid_source_trim(segment.get("source_range"), candidate): + errors.append("segment source_range exceeds candidate duration") + if not program or program[1] <= program[0]: + errors.append("segment program_range is invalid") + elif not expected_program or not _ranges_equal(segment.get("program_range"), program_range): + errors.append("segment program_range must equal the shot program range") + if source and program and rate is not _INVALID_NUMBER and rate > 0: + source_duration = source[1] - source[0] + program_duration = program[1] - program[0] + if abs(source_duration / rate - program_duration) > frame_duration + RANGE_EPSILON: + errors.append("segment source and program durations must match within one timeline frame") + return errors + + +def _canonical_segments_errors(segments, candidates, program_range, frame_duration): + if not isinstance(segments, list) or not 1 <= len(segments) <= 3: + return ["canonical selection requires 1-3 segments"] + if not isinstance(candidates, list): + return ["shot candidates must be a list"] + candidate_map = { + candidate.get("id"): candidate for candidate in candidates + if isinstance(candidate, dict) and isinstance(candidate.get("id"), str) + } + ranked_ids = { + candidate_id for candidate_id, candidate in candidate_map.items() + if (isinstance(candidate.get("ranking"), dict) + and isinstance(candidate["ranking"].get("rank"), int) + and not isinstance(candidate["ranking"].get("rank"), bool) + and 1 <= candidate["ranking"]["rank"] <= 3) + } + allowed_ids = ranked_ids if ranked_ids else set(candidate_map) + shot_program = _range(program_range) + shot_start = _frame_index(shot_program[0], frame_duration) if shot_program else None + shot_end = _frame_index(shot_program[1], frame_duration) if shot_program else None + errors = [] + seen_ids = set() + previous_end = None + for index, segment in enumerate(segments): + if not isinstance(segment, dict): + errors.append(f"segment {index + 1} must be an object") + continue + candidate_id = segment.get("candidate_id") + if not isinstance(candidate_id, str) or not candidate_id.strip(): + errors.append(f"segment {index + 1} candidate_id is required") + continue + if candidate_id in seen_ids: + errors.append("segment candidate IDs must be unique") + seen_ids.add(candidate_id) + candidate = candidate_map.get(candidate_id) + if candidate is None or candidate.get("media_type") != "video": + errors.append("segment candidate must belong to shot video candidates") + continue + if candidate_id not in allowed_ids: + errors.append("segment candidate must belong to the bound Top 3") + segment_program = _range(segment.get("program_range")) + errors.extend(_canonical_segment_errors( + segment, candidate, segment.get("program_range"), frame_duration, + )) + if not segment_program: + continue + start_frame = _frame_index(segment_program[0], frame_duration) + end_frame = _frame_index(segment_program[1], frame_duration) + if start_frame is None or end_frame is None: + errors.append("segment program ranges must align to timeline frames") + continue + if end_frame - start_frame < 1: + errors.append("each segment must occupy at least one timeline frame") + if previous_end is not None and start_frame != previous_end: + errors.append("segment program ranges must be continuous without gaps or overlaps") + previous_end = end_frame + if index == 0 and shot_start is not None and start_frame != shot_start: + errors.append("segment program ranges must completely cover the shot program range") + if (shot_start is None or shot_end is None): + errors.append("shot program_range must align to timeline frames") + elif previous_end is not None and previous_end != shot_end: + errors.append("segment program ranges must completely cover the shot program range") + return errors + + +def selection_details(shot, candidate, timeline): + """Return canonical segment details while preserving legacy trim intent.""" + if not isinstance(shot, dict): + raise ValueError("shot must be an object") + candidates = candidate if isinstance(candidate, list) else [candidate] + if not candidates or any(not isinstance(item, dict) for item in candidates): + raise ValueError("candidate must be an object or list of objects") + selected = shot.get("selected") + if not isinstance(selected, dict): + raise ValueError("shot selection is required") + frame = timeline_frame_duration(timeline) + program = _range(shot.get("program_range")) + if not program or program[1] <= program[0]: + raise ValueError("shot program_range is invalid") + program_range = {"start_s": program[0], "end_s": program[1]} + program_duration = program[1] - program[0] + segments = selected.get("segments") + if segments is not None: + errors = _canonical_segments_errors(segments, candidates, program_range, frame) + if errors: + raise ValueError("; ".join(errors)) + canonical = copy.deepcopy(segments) + segment_details = [] + for segment in canonical: + source = _range(segment["source_range"]) + segment_program = _range(segment["program_range"]) + rate = float(segment["playback_rate"]) + segment_details.append({ + "candidate_id": segment["candidate_id"], + "source_duration_s": source[1] - source[0], + "effective_duration_s": (source[1] - source[0]) / rate, + "program_duration_s": segment_program[1] - segment_program[0], + "playback_rate": rate, + }) + return { + "format": "canonical", + "segments": canonical, + "segment_details": segment_details, + "source_duration_s": sum(item["source_duration_s"] for item in segment_details), + "effective_duration_s": sum(item["effective_duration_s"] for item in segment_details), + "program_duration_s": program_duration, + } + candidate = next((item for item in candidates if item.get("id") == selected.get("candidate_id")), None) + if candidate is None: + raise ValueError("legacy video selection does not match candidate") + if candidate.get("media_type") != "video" or selected.get("candidate_id") != candidate.get("id"): + raise ValueError("legacy video selection does not match candidate") + requested = _range(selected.get("source_trim")) + if not requested or not _valid_source_trim(selected.get("source_trim"), candidate): + raise ValueError("legacy video selection requires a valid source_trim") + requested_duration = requested[1] - requested[0] + if requested_duration + frame + RANGE_EPSILON < program_duration: + raise ValueError("legacy source_trim cannot cover the shot program duration") + effective_end = min(requested[1], requested[0] + program_duration) + segment = { + "candidate_id": candidate["id"], + "source_range": {"start_s": requested[0], "end_s": effective_end}, + "program_range": program_range, + "playback_rate": 1.0, + } + return { + "format": "legacy", + "segments": [segment], + "segment_details": [{ + "candidate_id": candidate["id"], + "source_duration_s": effective_end - requested[0], + "effective_duration_s": effective_end - requested[0], + "program_duration_s": program_duration, + "playback_rate": 1.0, + }], + "legacy_requested_source_range": {"start_s": requested[0], "end_s": requested[1]}, + "source_duration_s": effective_end - requested[0], + "effective_duration_s": effective_end - requested[0], + "program_duration_s": program_duration, + } + + +def selected_candidate_id(selected): + values = selected_candidate_ids(selected) + return values[0] if values else None + + +def selected_candidate_ids(selected): + if not isinstance(selected, dict): + return [] + segments = selected.get("segments") + if segments is not None: + if not isinstance(segments, list) or any(not isinstance(segment, dict) for segment in segments): + return [] + return [segment.get("candidate_id") for segment in segments] + candidate_id = selected.get("candidate_id") + return [candidate_id] if candidate_id is not None else [] + + def candidate_manifest(plan): return [{"id": shot.get("id"), "candidates": sorted(copy.deepcopy(shot.get("candidates", [])), key=lambda item: str(item.get("id")))} for shot in sorted(plan.get("shots", []), key=lambda item: str(item.get("id")))] @@ -133,19 +387,34 @@ def _decision_manifest(shots): selected, candidates = shot.get("selected"), shot.get("candidates") if not isinstance(selected, dict) or not isinstance(candidates, list): return None - selected_id = selected.get("candidate_id") - if not isinstance(selected_id, str) or not selected_id.strip(): + segments = selected.get("segments") + if segments is not None: + if (not isinstance(segments, list) or not 1 <= len(segments) <= 3 + or any(not isinstance(segment, dict) for segment in segments)): + return None + selected_ids = [segment.get("candidate_id") for segment in segments] + else: + selected_ids = [selected.get("candidate_id")] + if (any(not isinstance(candidate_id, str) or not candidate_id.strip() + for candidate_id in selected_ids) + or len(selected_ids) != len(set(selected_ids))): return None - candidate = None - for item in candidates: - if not isinstance(item, dict): - continue - candidate_id = item.get("id") - if isinstance(candidate_id, str) and candidate_id.strip() and candidate_id == selected_id: - candidate = item - break - if candidate is None: + selected_candidates = [ + next((item for item in candidates + if isinstance(item, dict) and item.get("id") == candidate_id), None) + for candidate_id in selected_ids + ] + if any(candidate is None for candidate in selected_candidates): return None + if segments is not None and all(candidate.get("media_type") == "video" for candidate in selected_candidates): + decisions.append({ + "id": shot_id, + "decision": "select", + "program_range": copy.deepcopy(shot.get("program_range")), + "segments": copy.deepcopy(segments), + }) + continue + candidate = selected_candidates[0] media_type = candidate.get("media_type") if media_type == "video": option, value = "source_trim", selected.get("source_trim") @@ -157,7 +426,7 @@ def _decision_manifest(shots): return None else: return None - decisions.append({"id": shot_id, "decision": "select", "candidate_id": selected_id, option: copy.deepcopy(value)}) + decisions.append({"id": shot_id, "decision": "select", "candidate_id": selected_ids[0], option: copy.deepcopy(value)}) return decisions @@ -185,6 +454,11 @@ def _review_errors(plan, shots): if not isinstance(review, dict): errors.append("review trust requires review object") if not isinstance(decision, dict) or not isinstance(review, dict): return errors if review.get("status") != "approved": errors.append("review status must be approved") + persisted_intent = review.get("submission_intent") + if persisted_intent is not None and persisted_intent != "approve": + errors.append("approved review submission_intent must be approve") + if persisted_intent == "approve" and (not isinstance(review.get("revision_notes"), str) or review["revision_notes"].strip()): + errors.append("approved review revision_notes must be empty") if not isinstance(review.get("review_id"), str) or not review["review_id"].strip(): errors.append("review_id is required") elif not _is_uuid(review["review_id"]): errors.append("review_id must be a UUID") mode, actor, rationale = decision.get("mode"), decision.get("actor"), decision.get("rationale") @@ -193,9 +467,17 @@ def _review_errors(plan, shots): if not isinstance(rationale, str) or not rationale.strip(): errors.append("review rationale is required") if any(review.get(key) != decision.get(key) for key in ("mode", "actor", "rationale")): errors.append("decision and review authority do not match") + if ("rationale_source" in review or "rationale_source" in decision) and review.get("rationale_source") != decision.get("rationale_source"): + errors.append("decision and review rationale_source do not match") if not _valid_timestamp(review.get("timestamp")): errors.append("review timestamp is invalid") if mode == "human" and (decision.get("explicit_user_action") is not True or review.get("explicit_user_action") is not True): errors.append("human review requires explicit_user_action true") + if persisted_intent == "approve" and mode == "human": + if rationale != HUMAN_APPROVAL_RATIONALE: + errors.append("new human review rationale must describe the explicit UI action") + if (decision.get("rationale_source") != "review_ui_explicit_action" + or review.get("rationale_source") != "review_ui_explicit_action"): + errors.append("new human review rationale_source is invalid") decisions = _decision_manifest(shots) if decisions is None: errors.append("review decision manifest cannot be reconstructed") @@ -220,8 +502,11 @@ def _review_errors(plan, shots): if not isinstance(shot, dict) or not isinstance(shot.get("status"), str) or shot.get("status") not in ("selected", "normalized", "verified") or not isinstance(shot.get("selected"), dict): continue candidates = shot.get("candidates") if not isinstance(candidates, list): continue - candidate = next((item for item in candidates if isinstance(item, dict) and item.get("id") == shot["selected"].get("candidate_id")), None) - if isinstance(candidate, dict) and isinstance(candidate.get("sha256"), str): selected_hashes.append(candidate["sha256"]) + selected = shot["selected"] + for selected_id in selected_candidate_ids(selected): + candidate = next((item for item in candidates if isinstance(item, dict) and item.get("id") == selected_id), None) + if isinstance(candidate, dict) and isinstance(candidate.get("sha256"), str): + selected_hashes.append(candidate["sha256"]) if review.get("selected_asset_sha256") != sorted(set(selected_hashes)): errors.append("review selected asset hashes do not match") input_hashes = plan.get("input_hashes") @@ -479,6 +764,177 @@ def _candidate_path(root, value): return candidate +def _inside(root, target): + try: + Path(target).resolve().relative_to(Path(root).resolve()) + except ValueError: + return False + return True + + +def _candidate_ranking_errors(plan, shots, *, project_root=None, verify_files=False): + binding = plan.get("candidate_ranking") + if binding is None: + return [] + errors = [] + if not isinstance(binding, dict): + return ["candidate_ranking must be an object"] + path_value = binding.get("path") + raw_path = Path(path_value) if isinstance(path_value, str) else None + if (raw_path is None or raw_path.is_absolute() or ".." in raw_path.parts + or not raw_path.parts or raw_path.parts[0] != "b-roll"): + errors.append("candidate ranking path is invalid") + if not _is_sha256(binding.get("sha256")): + errors.append("candidate ranking SHA-256 is invalid") + if not _is_sha256(binding.get("analysis_sha256")): + errors.append("candidate ranking analysis SHA-256 is invalid") + shortlists = binding.get("shortlists") + if not isinstance(shortlists, list): + errors.append("candidate ranking shortlists must be a list") + shortlists = [] + shortlist_map, seen = {}, set() + for shortlist in shortlists: + if not isinstance(shortlist, dict): + errors.append("candidate ranking shortlist must be an object") + continue + shot_id, candidate_ids = shortlist.get("shot_id"), shortlist.get("candidate_ids") + if not isinstance(shot_id, str) or not shot_id.strip() or shot_id in seen: + errors.append("candidate ranking shortlist shot ids must be unique nonblank strings") + continue + seen.add(shot_id) + if (not isinstance(candidate_ids, list) or len(candidate_ids) > 3 + or any(not isinstance(item, str) or not item.strip() for item in candidate_ids) + or len(candidate_ids) != len(set(candidate_ids))): + errors.append(f"{shot_id} candidate ranking shortlist is invalid") + candidate_ids = [] + shortlist_map[shot_id] = candidate_ids + shot_map = {shot.get("id"): shot for shot in shots if isinstance(shot, dict) and isinstance(shot.get("id"), str)} + if set(shortlist_map) != set(shot_map): + errors.append("candidate ranking shortlists do not match plan shots") + for shot_id, shot in shot_map.items(): + candidate_ids = [item.get("id") for item in shot.get("candidates", []) if isinstance(item, dict)] + expected_ids = shortlist_map.get(shot_id, []) + if candidate_ids != expected_ids: + errors.append(f"{shot_id} candidates do not match ranked Top 3") + for index, candidate in enumerate(shot.get("candidates", []), 1): + if not isinstance(candidate, dict): + continue + candidate_id = candidate.get("id") + ranking = candidate.get("ranking") + if not isinstance(ranking, dict): + errors.append(f"{shot_id} candidate {candidate_id} ranking evidence is required") + continue + if ranking.get("rank") != index: + errors.append(f"{shot_id} candidate {candidate_id} rank does not match shortlist order") + scores = ranking.get("scores") + if not isinstance(scores, dict): + errors.append(f"{shot_id} candidate {candidate_id} ranking scores are invalid") + else: + for field in ("semantic_fit", "context_fit", "composition_fit", "style_fit"): + value = scores.get(field) + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 4: + errors.append(f"{shot_id} candidate {candidate_id} ranking {field} is invalid") + risk = scores.get("text_logo_risk") + if (not isinstance(risk, int) or isinstance(risk, bool) or not 0 <= risk <= 4) and risk != "uncertain": + errors.append(f"{shot_id} candidate {candidate_id} ranking text_logo_risk is invalid") + if not isinstance(ranking.get("rationale"), str) or not ranking["rationale"].strip(): + errors.append(f"{shot_id} candidate {candidate_id} ranking rationale is required") + for field in ("warnings", "duplicate_notes"): + value = ranking.get(field) + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + errors.append(f"{shot_id} candidate {candidate_id} ranking {field} must contain strings") + similar = ranking.get("similar_footage") + if similar is not None and (not isinstance(similar, list) or any( + not isinstance(item, dict) or not isinstance(item.get("shot_id"), str) or not item["shot_id"].strip() + or not isinstance(item.get("candidate_id"), str) or not item["candidate_id"].strip() + for item in similar)): + errors.append(f"{shot_id} candidate {candidate_id} ranking similar_footage is invalid") + if verify_files: + if project_root is None: + errors.append("candidate ranking file verification requires project root") + elif raw_path is not None and not raw_path.is_absolute() and ".." not in raw_path.parts: + root = Path(project_root).resolve() + ranking_path = (root / "work" / raw_path).resolve() + if not _inside(root / "work/b-roll", ranking_path): + errors.append("candidate ranking path escapes work/b-roll") + elif not ranking_path.is_file(): + errors.append("candidate ranking file is missing") + else: + if binding.get("sha256") != sha256_file(ranking_path): + errors.append("candidate ranking SHA-256 is stale") + try: + ranking_document = projectlib.load_json(ranking_path) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + errors.append("candidate ranking file is invalid") + ranking_document = None + if isinstance(ranking_document, dict): + if ranking_document.get("analysis_sha256") != binding.get("analysis_sha256"): + errors.append("candidate ranking analysis SHA-256 does not match") + ranked_shots = ranking_document.get("shots") + if not isinstance(ranked_shots, list): + errors.append("candidate ranking file shots are invalid") + else: + ranked_map = {item.get("shot_id"): item for item in ranked_shots if isinstance(item, dict) and isinstance(item.get("shot_id"), str)} + ranked_refs = { + (shot_id, candidate.get("candidate_id")) + for shot_id, ranked_shot in ranked_map.items() + for candidate in ranked_shot.get("candidates", []) + if isinstance(candidate, dict) and isinstance(candidate.get("candidate_id"), str) + } + selected_refs = { + (shot_id, candidate_id) + for shot_id, ranked_shot in ranked_map.items() + for candidate_id in ranked_shot.get("top3", []) + if isinstance(candidate_id, str) + } + allocations = ranking_document.get("global_allocations") + if allocations is not None: + if not isinstance(allocations, list): + errors.append("candidate ranking global allocations must be a list") + else: + seen_allocations = set() + for allocation in allocations: + if not isinstance(allocation, dict): + errors.append("candidate ranking global allocation must be an object") + continue + allocation_id = allocation.get("allocation_id") + if not isinstance(allocation_id, str) or not allocation_id.strip() or allocation_id in seen_allocations: + errors.append("candidate ranking global allocation ids must be unique nonblank strings") + seen_allocations.add(allocation_id) + members = allocation.get("members") + member_refs = [(member.get("shot_id"), member.get("candidate_id")) for member in members] if isinstance(members, list) and all(isinstance(member, dict) for member in members) else [] + kept = allocation.get("kept") + kept_ref = (kept.get("shot_id"), kept.get("candidate_id")) if isinstance(kept, dict) else None + suppressed = allocation.get("suppressed") + suppressed_refs = [(member.get("shot_id"), member.get("candidate_id")) for member in suppressed] if isinstance(suppressed, list) and all(isinstance(member, dict) for member in suppressed) else [] + if (not isinstance(members, list) or len(member_refs) < 2 or len(member_refs) != len(set(member_refs)) + or any(ref not in ranked_refs for ref in member_refs) or kept_ref not in member_refs + or set(suppressed_refs) != set(member_refs) - {kept_ref}): + errors.append(f"candidate ranking global allocation {allocation_id} members are invalid") + continue + selected_members = set(member_refs) & selected_refs + if len(selected_members) > 1 or selected_members and selected_members != {kept_ref}: + errors.append(f"candidate ranking global allocation {allocation_id} is not unique across shortlists") + for shot_id, expected_ids in shortlist_map.items(): + ranked_shot = ranked_map.get(shot_id) + if not isinstance(ranked_shot, dict) or ranked_shot.get("top3") != expected_ids: + errors.append(f"{shot_id} ranked Top 3 does not match binding") + continue + details = {item.get("candidate_id"): item for item in ranked_shot.get("candidates", []) if isinstance(item, dict)} + for index, candidate in enumerate(shot_map.get(shot_id, {}).get("candidates", []), 1): + if not isinstance(candidate, dict): + continue + detail, summary = details.get(candidate.get("id")), candidate.get("ranking") + if (not isinstance(detail, dict) or not isinstance(summary, dict) + or detail.get("rank") != index + or detail.get("scores") != summary.get("scores") + or detail.get("warnings") != summary.get("warnings") + or detail.get("rationale") != summary.get("rationale") + or detail.get("similar_footage", []) != summary.get("similar_footage", [])): + errors.append(f"{shot_id} candidate {candidate.get('id')} ranking evidence does not match ranking file") + return errors + + def _project_parts(project): if not isinstance(project, dict): raise ValueError("project must be an object") @@ -541,8 +997,13 @@ def _verified_overlays(plan): if not isinstance(verification, dict) or verification.get("status") != "pass": raise ValueError("verified shot verification must pass") selection = shot.get("selected") - candidate = next((item for item in shot.get("candidates", []) if isinstance(item, dict) and isinstance(selection, dict) and item.get("id") == selection.get("candidate_id")), None) - if candidate is None: + candidate_ids = selected_candidate_ids(selection) + candidates = [ + next((item for item in shot.get("candidates", []) + if isinstance(item, dict) and item.get("id") == candidate_id), None) + for candidate_id in candidate_ids + ] + if not candidate_ids or any(candidate is None for candidate in candidates): raise ValueError("verified shot selected candidate is invalid") normalized = shot.get("normalized") program = _range(shot.get("program_range")) @@ -551,8 +1012,10 @@ def _verified_overlays(plan): digest = normalized.get("sha256") if not _is_sha256(digest): raise ValueError("verified shot normalized SHA-256 is invalid") - if "source_path" in normalized and normalized["source_path"] != candidate.get("cache_path"): + if "source_path" in normalized and len(candidates) == 1 and normalized["source_path"] != candidates[0].get("cache_path"): raise ValueError("verified shot normalized source path does not match selected candidate") + if "source_paths" in normalized and normalized["source_paths"] != [candidate.get("cache_path") for candidate in candidates]: + raise ValueError("verified shot normalized source paths do not match selected candidates") if not program or program[1] <= program[0]: raise ValueError("verified shot program range is invalid") overlays.append({"kind": "overlay", "asset": normalized["path"], "start_s": program[0], "duration_s": program[1] - program[0]}) @@ -667,6 +1130,11 @@ def validate_plan(plan, timeline, transcript, project=None, project_root=None, v if plan.get("schema_version") != 1: errors.append("plan schema_version must be 1") if plan.get("timebase") != "program": errors.append("plan timebase must be program") if plan.get("timeline_id") != timeline.get("timeline_id"): errors.append("plan timeline_id does not match timeline") + try: + frame_duration = timeline_frame_duration(timeline) + except ValueError as exc: + errors.append(str(exc)) + frame_duration = None timeline_duration = _strict_finite_number(timeline.get("program_duration_s")) if timeline_duration is _INVALID_NUMBER or timeline_duration < 0: errors.append("timeline program_duration_s is invalid") @@ -764,13 +1232,52 @@ def validate_plan(plan, timeline, transcript, project=None, project_root=None, v elif status in {"planned", "candidates_ready", "skipped"} and selected is not None: errors.append(f"{shot_id} {status} shot must not select a candidate") elif status in {"selected", "normalized", "verified"} and not isinstance(selected, dict): errors.append(f"{shot_id} {status} shot requires a selection") elif status in {"selected", "normalized", "verified"}: - candidate = next((item for item in candidates if isinstance(item, dict) and item.get("id") == selected.get("candidate_id")), None) - if candidate is None: errors.append(f"{shot_id} selected candidate does not belong to shot") - elif candidate.get("media_type") == "video": + selected_ids = selected_candidate_ids(selected) + selected_candidates = [ + next((item for item in candidates + if isinstance(item, dict) and item.get("id") == candidate_id), None) + for candidate_id in selected_ids + ] + candidate = selected_candidates[0] if len(selected_candidates) == 1 else None + if not selected_ids or any(item is None for item in selected_candidates): + errors.append(f"{shot_id} selected candidate does not belong to shot") + elif "segments" in selected: + if frame_duration is not None: + try: + selection_details(shot, candidates, timeline) + except ValueError as exc: + errors.append(f"{shot_id} {status} video {exc}") + elif candidate is not None and candidate.get("media_type") == "video": if not _valid_source_trim(selected.get("source_trim"), candidate): errors.append(f"{shot_id} {status} video requires a valid source_trim") - elif candidate.get("media_type") == "image" and not _valid_ken_burns(selected.get("ken_burns")): + elif candidate is not None and candidate.get("media_type") == "image" and not _valid_ken_burns(selected.get("ken_burns")): errors.append(f"{shot_id} {status} image requires a non-empty ken_burns") errors.append(f"{shot_id} {status} image requires a valid ken_burns direction") + review_default = shot.get("review_default") + if review_default is not None: + if not isinstance(review_default, dict) or review_default.get("decision") not in ("select", "skip"): + errors.append(f"{shot_id} review_default is invalid") + elif review_default.get("decision") == "select": + segments = review_default.get("segments") + review_candidate_ids = ( + [segment.get("candidate_id") for segment in segments] + if isinstance(segments, list) and all(isinstance(segment, dict) for segment in segments) + else [] + ) + selected_candidates = [ + next((item for item in candidates + if isinstance(item, dict) and item.get("id") == candidate_id), None) + for candidate_id in review_candidate_ids + ] + if (not review_candidate_ids or any(candidate is None or candidate.get("media_type") != "video" + for candidate in selected_candidates)): + errors.append(f"{shot_id} review_default candidate is invalid") + elif frame_duration is not None: + default_shot = copy.deepcopy(shot) + default_shot["selected"] = {"segments": copy.deepcopy(segments)} + try: + selection_details(default_shot, candidates, timeline) + except ValueError as exc: + errors.append(f"{shot_id} review_default {exc}") if status in ("normalized", "verified"): normalized = shot.get("normalized") if not isinstance(normalized, dict): @@ -792,6 +1299,9 @@ def validate_plan(plan, timeline, transcript, project=None, project_root=None, v for previous_start, previous_end, previous_id in ranges: if previous_id != shot_id and previous_start < end and start < previous_end: errors.append(f"{shot_id} program range overlaps {previous_id}"); break + errors.extend(_candidate_ranking_errors( + plan, shots, project_root=project_root, verify_files=verify_files + )) errors.extend(_review_errors(plan, shots)) if "visual_review" in plan: errors.extend(_visual_review_errors( @@ -907,9 +1417,229 @@ def validate_plan(plan, timeline, transcript, project=None, project_root=None, v return errors -def apply_review(plan, review, *, mode, actor, rationale, interaction_path=None): +def _mapped_word_records(transcript, timeline): + try: + mapped = projectlib.map_transcript_to_timeline(transcript, timeline) + except (AttributeError, KeyError, OverflowError, TypeError, ValueError) as exc: + raise ValueError("transcript cannot be mapped to timeline") from exc + records = [] + segments = mapped.get("segments") if isinstance(mapped, dict) else None + if not isinstance(segments, list): + raise ValueError("mapped transcript segments are invalid") + for segment in segments: + words = segment.get("words") if isinstance(segment, dict) else None + if not isinstance(words, list): + continue + for word in words: + if not isinstance(word, dict): + continue + source = _range(word.get("source_range")) + program = _range(word.get("program_range")) + if (isinstance(word.get("word"), str) and isinstance(word.get("clip_id"), str) + and source and program): + records.append(copy.deepcopy(word)) + return records + + +def revision_program_bounds(plan, timeline, shot_id): + shots = plan.get("shots") if isinstance(plan, dict) else None + if not isinstance(shots, list): + raise ValueError("plan shots must be a list") + index = next((index for index, shot in enumerate(shots) + if isinstance(shot, dict) and shot.get("id") == shot_id), None) + if index is None: + raise ValueError(f"unknown shot: {shot_id}") + frame = timeline_frame_duration(timeline) + duration = _strict_finite_number(timeline.get("program_duration_s")) + original = _range(shots[index].get("program_range")) + if duration is _INVALID_NUMBER or duration <= 0 or not original: + raise ValueError("timeline or shot program range is invalid") + previous_end = 0.0 + if index: + previous = _range(shots[index - 1].get("program_range")) + if not previous: + raise ValueError("previous shot program range is invalid") + previous_end = previous[1] + next_start = duration + if index + 1 < len(shots): + following = _range(shots[index + 1].get("program_range")) + if not following: + raise ValueError("next shot program range is invalid") + next_start = following[0] + return { + "start_s": { + "min": max(0.0, original[0] - 2.0, previous_end), + "max": min(duration - frame, original[0] + 2.0, next_start - frame), + }, + "end_s": { + "min": max(frame, original[1] - 2.0, previous_end + frame), + "max": min(duration, original[1] + 2.0, next_start), + }, + } + + +def _frame_aligned(value, frame): + number = _strict_finite_number(value) + if number is _INVALID_NUMBER: + return False + return abs(number / frame - round(number / frame)) <= RANGE_EPSILON + + +def validate_revision_request(plan, request, timeline, transcript): + """Validate a request without mutating the plan or creating approval state.""" + errors = [] + if not isinstance(plan, dict): + return ["plan must be an object"] + if not isinstance(request, dict): + return ["revision request must be an object"] + if request.get("submission_intent") != "request_revision": + errors.append("submission_intent must be request_revision") + if request.get("explicit_user_action") is not True: + errors.append("revision request requires explicit_user_action true") + if not _is_uuid(request.get("review_id")): + errors.append("review_id must be a UUID") + if not _valid_timestamp(request.get("timestamp")): + errors.append("revision request timestamp is invalid") + notes = request.get("revision_notes", "") + if not isinstance(notes, str): + errors.append("revision_notes must be a string") + input_hashes = plan.get("input_hashes") + if not isinstance(input_hashes, dict): + errors.append("plan input_hashes must be an object") + input_hashes = {} + expected_bindings = { + "plan_sha256": canonical_sha256(review_subject(plan)), + "candidate_manifest_sha256": canonical_sha256(candidate_manifest(plan)), + "review_video_sha256": input_hashes.get("review_video_sha256"), + } + for field, expected in expected_bindings.items(): + if request.get(field) != expected: + errors.append(f"{field} does not match current review artifacts") + try: + frame = timeline_frame_duration(timeline) + except ValueError as exc: + errors.append(str(exc)) + return errors + entries = request.get("shots") + plan_shots = plan.get("shots") + if not isinstance(entries, list): + return errors + ["revision request shots must be a list"] + if not isinstance(plan_shots, list): + return errors + ["plan shots must be a list"] + if any(not isinstance(entry, dict) for entry in entries): + return errors + ["revision request shot must be an object"] + ids = [entry.get("id") for entry in entries] + plan_ids = [shot.get("id") for shot in plan_shots if isinstance(shot, dict)] + ids_valid = all(isinstance(shot_id, str) and shot_id.strip() for shot_id in ids) + if not ids_valid: + errors.append("revision request shot id is required") + if ids_valid and len(ids) != len(set(ids)): + errors.append("revision request shot ids must be unique") + if ids_valid and sorted(ids) != sorted(plan_ids, key=str): + errors.append("revision request shots do not match plan shots") + entries_by_id = {entry.get("id"): entry for entry in entries} + requested_ranges = [] + for shot in plan_shots: + if not isinstance(shot, dict) or shot.get("id") not in entries_by_id: + continue + shot_id = shot["id"] + entry = entries_by_id[shot_id] + decision = entry.get("decision") + if decision not in ("select", "skip"): + errors.append(f"{shot_id} decision must be select or skip") + continue + if shot.get("status") == "skipped" and decision != "skip": + errors.append(f"{shot_id} was already skipped and requires decision skip") + if decision == "skip": + continue + requested = _range(entry.get("requested_program_range")) + if not requested or requested[1] <= requested[0]: + errors.append(f"{shot_id} requested_program_range is invalid") + continue + bounds = revision_program_bounds(plan, timeline, shot_id) + if (requested[0] < bounds["start_s"]["min"] - RANGE_EPSILON + or requested[0] > bounds["start_s"]["max"] + RANGE_EPSILON + or requested[1] < bounds["end_s"]["min"] - RANGE_EPSILON + or requested[1] > bounds["end_s"]["max"] + RANGE_EPSILON): + errors.append(f"{shot_id} requested program range is outside allowed bounds") + if requested[1] - requested[0] + RANGE_EPSILON < frame: + errors.append(f"{shot_id} requested program range must be at least one timeline frame") + if not _frame_aligned(requested[0], frame) or not _frame_aligned(requested[1], frame): + errors.append(f"{shot_id} requested program range must align to timeline frames") + requested_ranges.append((requested[0], requested[1], shot_id)) + mapped_ranges = _timeline_source_ranges(requested, timeline) + if not mapped_ranges: + errors.append(f"{shot_id} requested program range cannot be mapped to source") + segments = entry.get("segments") + candidates = shot.get("candidates") + errors.extend(f"{shot_id} {error}" for error in _canonical_segments_errors( + segments, candidates, + {"start_s": requested[0], "end_s": requested[1]}, frame, + )) + for index, (start, end, shot_id) in enumerate(sorted(requested_ranges)): + for other_start, other_end, other_id in sorted(requested_ranges)[index + 1:]: + if other_start < end - RANGE_EPSILON and start < other_end - RANGE_EPSILON: + errors.append(f"{shot_id} requested program range overlaps {other_id}") + return errors + + +def rebuild_plan_from_revision(plan, request, timeline, transcript): + """Return an unapproved revised proposal after validating the bound request.""" + errors = validate_revision_request(plan, request, timeline, transcript) + if errors: + raise ValueError("invalid revision request: " + "; ".join(errors)) + result = copy.deepcopy(plan) + entries = {entry["id"]: entry for entry in request["shots"]} + words = _mapped_word_records(transcript, timeline) + result["decision"] = None + result["review"] = None + result.pop("review_status", None) + result.pop("visual_review", None) + for shot in result["shots"]: + entry = entries[shot["id"]] + shot.pop("normalized", None) + shot.pop("verification", None) + if entry["decision"] == "skip": + shot["selected"] = None + shot["status"] = "skipped" + shot.pop("review_default", None) + continue + requested = _range(entry["requested_program_range"]) + shot["program_range"] = {"start_s": requested[0], "end_s": requested[1]} + shot["source_ranges"] = _timeline_source_ranges(requested, timeline) + inside = [word for word in words + if (_range(word.get("program_range"))[0] >= requested[0] - RANGE_EPSILON + and _range(word.get("program_range"))[1] <= requested[1] + RANGE_EPSILON)] + if not inside: + raise ValueError(f"{shot['id']} revised program range contains no complete transcript word") + shot["transcript_evidence"] = {"words": inside} + shot["review_default"] = { + "decision": "select", + "segments": copy.deepcopy(entry["segments"]), + } + shot["selected"] = None + shot["status"] = "candidates_ready" + validation = validate_plan(result, timeline, transcript) + if validation: + raise ValueError("rebuilt plan is invalid: " + "; ".join(validation)) + return result + + +def apply_review(plan, review, *, mode, actor, rationale, interaction_path=None, timeline=None): if not isinstance(plan, dict): raise ValueError("plan must be an object") if not isinstance(review, dict): raise ValueError("review must be an object") + explicit_intent = "submission_intent" in review + intent = review.get("submission_intent", "approve") + if intent not in REVIEW_INTENTS: + raise ValueError("submission_intent must be approve or request_revision") + if intent == "request_revision": + raise ValueError("request_revision must be validated and rebuilt before apply_review") + if explicit_intent: + notes = review.get("revision_notes", "") + if not isinstance(notes, str): + raise ValueError("revision_notes must be a string") + if notes.strip(): + raise ValueError("approve requires empty revision_notes") plan_shots, entries = plan.get("shots"), review.get("shots") if not isinstance(plan_shots, list): raise ValueError("plan shots must be a list") if not isinstance(entries, list): raise ValueError("review shots must be a list") @@ -936,6 +1666,10 @@ def apply_review(plan, review, *, mode, actor, rationale, interaction_path=None) if not _valid_timestamp(review.get("timestamp")): raise ValueError("review timestamp is invalid") if mode == "human" and review.get("explicit_user_action") is not True: raise ValueError("human review requires explicit_user_action true") + rationale_source = review.get("rationale_source") + if explicit_intent and mode == "human": + if rationale != HUMAN_APPROVAL_RATIONALE or rationale_source != "review_ui_explicit_action": + raise ValueError("new human approve requires the explicit review UI action rationale") if not isinstance(review.get("review_id"), str) or not review["review_id"].strip(): raise ValueError("review_id is required") if not _is_uuid(review["review_id"]): raise ValueError("review_id must be a UUID") input_hashes = plan.get("input_hashes") @@ -973,11 +1707,52 @@ def apply_review(plan, review, *, mode, actor, rationale, interaction_path=None) shot.pop("normalized", None) shot.pop("verification", None) if decision == "skip": + if isinstance(shot.get("review_default"), dict) and shot["review_default"].get("decision") != "skip": + raise ValueError(f"{shot['id']} approve does not match the exact review default") if shot.get("status") != "skipped": decision_skipped_ids.append(shot["id"]) shot["selected"], shot["status"] = None, "skipped" continue - candidate = next((item for item in shot.get("candidates", []) if item.get("id") == entry.get("candidate_id")), None) - if not candidate: raise ValueError(f"{shot['id']} selected candidate does not belong to shot") + segments = entry.get("segments") + if segments is not None and (not isinstance(segments, list) or not 1 <= len(segments) <= 3): + raise ValueError(f"{shot['id']} select requires 1-3 segments") + candidate_ids = ( + [segment.get("candidate_id") for segment in segments if isinstance(segment, dict)] + if isinstance(segments, list) else [entry.get("candidate_id")] + ) + candidates = shot.get("candidates", []) + selected_candidates = [ + next((item for item in candidates + if isinstance(item, dict) and item.get("id") == candidate_id), None) + for candidate_id in candidate_ids + ] + if (not candidate_ids or len(candidate_ids) != len(segments or candidate_ids) + or any(candidate is None for candidate in selected_candidates)): + raise ValueError(f"{shot['id']} selected candidate does not belong to shot") + candidate = selected_candidates[0] + if explicit_intent: + if not _ranges_equal(entry.get("program_range"), shot.get("program_range")): + raise ValueError(f"{shot['id']} approve program_range does not match current review") + default = shot.get("review_default") + if isinstance(default, dict): + actual = {"decision": "select", "segments": copy.deepcopy(segments)} + if actual != default: + raise ValueError(f"{shot['id']} approve does not match the exact review default") + if segments is not None: + if any(candidate.get("media_type") != "video" for candidate in selected_candidates): + raise ValueError(f"{shot['id']} canonical segments require a video candidate") + if not isinstance(timeline, dict): + raise ValueError("canonical segment approval requires the canonical timeline") + frame_duration = timeline_frame_duration(timeline) + if review.get("timeline_fps") != timeline.get("fps"): + raise ValueError("timeline_fps does not match canonical timeline") + segment_errors = _canonical_segments_errors( + segments, candidates, shot.get("program_range"), frame_duration, + ) + if segment_errors: + raise ValueError(f"{shot['id']} " + "; ".join(segment_errors)) + shot["selected"], shot["status"] = {"segments": copy.deepcopy(segments)}, "selected" + selected_hashes.extend(candidate["sha256"] for candidate in selected_candidates) + continue option = "source_trim" if candidate.get("media_type") == "video" else "ken_burns" if option == "source_trim" and not _valid_source_trim(entry.get(option), candidate): raise ValueError(f"{shot['id']} select requires a valid source_trim") @@ -989,8 +1764,14 @@ def apply_review(plan, review, *, mode, actor, rationale, interaction_path=None) raise ValueError("review decision manifest cannot be reconstructed") result["review_status"] = "approved" result["decision"] = {"mode": mode, "actor": actor, "rationale": rationale} + if rationale_source is not None: + result["decision"]["rationale_source"] = rationale_source if mode == "human": result["decision"]["explicit_user_action"] = True result["review"] = {"status": "approved", "review_id": review["review_id"], "mode": mode, "actor": actor, "rationale": rationale, "timestamp": review["timestamp"], **expected_bindings, "decisions": decisions, "decision_skipped_shot_ids": sorted(set(decision_skipped_ids)), "selected_asset_sha256": sorted(set(selected_hashes))} + if explicit_intent: + result["review"].update({"submission_intent": "approve", "revision_notes": ""}) + if rationale_source is not None: + result["review"]["rationale_source"] = rationale_source if mode == "human": result["review"]["explicit_user_action"] = True if interaction_path: target = Path(interaction_path); target.parent.mkdir(parents=True, exist_ok=True) diff --git a/skills/video-add-b-roll/scripts/build_review_page.py b/skills/video-add-b-roll/scripts/build_review_page.py index a07f79ff3..005e52146 100644 --- a/skills/video-add-b-roll/scripts/build_review_page.py +++ b/skills/video-add-b-roll/scripts/build_review_page.py @@ -2,6 +2,7 @@ import argparse import base64 +import copy import hashlib import json import math @@ -69,15 +70,64 @@ def _review_id(value): raise ValueError("review_id must be a UUID") from error -def _payload(plan, root, assets_dir): +def _word_context(words, program_range): + program = broll_plan._range(program_range) + if not program: + raise ValueError("shot program range is invalid") + context_start, context_end = max(0.0, program[0] - 2.0), program[1] + 2.0 + inside, context = [], [] + for word in words: + mapped = broll_plan._range(word.get("program_range")) if isinstance(word, dict) else None + if not mapped: + continue + if mapped[0] < context_end - broll_plan.RANGE_EPSILON and mapped[1] > context_start + broll_plan.RANGE_EPSILON: + context.append(copy.deepcopy(word)) + if (mapped[0] >= program[0] - broll_plan.RANGE_EPSILON + and mapped[1] <= program[1] + broll_plan.RANGE_EPSILON): + inside.append(copy.deepcopy(word)) + return {"inside": inside, "context": context} + + +def _candidate_default(shot, candidate): + program = broll_plan._range(shot.get("program_range")) + duration = program[1] - program[0] + review_default = shot.get("review_default") + segments = review_default.get("segments") if isinstance(review_default, dict) else None + if isinstance(segments, list): + match = next((segment for segment in segments + if isinstance(segment, dict) + and segment.get("candidate_id") == candidate.get("id")), None) + if match is not None: + return copy.deepcopy(match) + source_end = duration + return { + "candidate_id": candidate["id"], + "source_range": {"start_s": 0.0, "end_s": source_end}, + "program_range": {"start_s": program[0], "end_s": program[1]}, + "playback_rate": 1.0, + } + + +def _payload(plan, timeline, transcript, root, assets_dir): payload_shots = [] pre_skipped_ids = [] candidate_specs = [] + words = broll_plan._mapped_word_records(transcript, timeline) for shot_index, shot in enumerate(plan["shots"], 1): if shot["status"] == "skipped": pre_skipped_ids.append(shot["id"]) continue frame = assets_dir / f"frame-{len(payload_shots) + 1:03d}.jpg" + frame_duration = broll_plan.timeline_frame_duration(timeline) + program = broll_plan._range(shot["program_range"]) + total_frames = round((program[1] - program[0]) / frame_duration) + default_allocations = {} + for count in range(1, min(3, total_frames) + 1): + ranges = broll_plan.allocate_program_ranges(shot["program_range"], count, timeline) + default_allocations[str(count)] = [ + round((item["end_s"] - item["start_s"]) / frame_duration) + for item in ranges + ] candidates = [] for candidate_index, candidate in enumerate(shot["candidates"], 1): path = broll_plan._candidate_path(root, candidate["cache_path"]) @@ -87,14 +137,39 @@ def _payload(plan, root, assets_dir): basename = f"candidate-{shot_index:03d}-{candidate_index:03d}{suffix}" candidate_specs.append((path, basename, candidate["sha256"])) item = {"id": candidate["id"], "media_type": candidate["media_type"], "path": f"{assets_dir.name}/{basename}", "sha256": candidate["sha256"], "provenance": candidate["provenance"]} + if "ranking" in candidate: + item["ranking"] = copy.deepcopy(candidate["ranking"]) if candidate["media_type"] == "video": probe = candidate.get("probe") duration = broll_plan._positive_duration(probe.get("duration_s")) if isinstance(probe, dict) else None if duration is None: raise ValueError(f"{shot['id']} candidate {candidate['id']} has no valid review duration") item["duration_s"] = duration + item["source_bounds"] = {"start_s": 0.0, "end_s": duration} + item["default_segment"] = _candidate_default(shot, candidate) + item["default_segment"]["feasible"] = ( + item["default_segment"]["source_range"]["end_s"] <= duration + broll_plan.RANGE_EPSILON + ) candidates.append(item) - payload_shots.append({"id": shot["id"], "program_range": shot["program_range"], "source_ranges": shot["source_ranges"], "transcript_evidence": shot["transcript_evidence"], "editorial_reason": shot["editorial_reason"], "visual_intent": shot["visual_intent"], "queries": shot["queries"], "source_frame": {"path": f"{assets_dir.name}/{frame.name}", "sha256": None}, "candidates": candidates}) + payload_shots.append({ + "id": shot["id"], + "program_range": copy.deepcopy(shot["program_range"]), + "original_program_range": copy.deepcopy(shot["program_range"]), + "allowed_program_range": broll_plan.revision_program_bounds(plan, timeline, shot["id"]), + "total_program_frames": total_frames, + "frame_duration_s": frame_duration, + "default_allocations": default_allocations, + "source_ranges": copy.deepcopy(shot["source_ranges"]), + "clip_ids": sorted({item.get("clip_id") for item in shot["source_ranges"] if isinstance(item, dict) and isinstance(item.get("clip_id"), str)}), + "transcript": _word_context(words, shot["program_range"]), + "transcript_evidence": copy.deepcopy(shot["transcript_evidence"]), + "editorial_reason": shot["editorial_reason"], + "visual_intent": shot["visual_intent"], + "queries": copy.deepcopy(shot["queries"]), + "review_default": copy.deepcopy(shot.get("review_default")), + "source_frame": {"path": f"{assets_dir.name}/{frame.name}", "sha256": None}, + "candidates": candidates, + }) return payload_shots, candidate_specs, pre_skipped_ids @@ -159,7 +234,9 @@ def build_review_page(plan, timeline, transcript, video, output_dir, *, project_ page, assets_dir = output_dir / f"b-roll-review-{identifier}.html", output_dir / f"b-roll-review-{identifier}-assets" if page.exists() or assets_dir.exists(): raise FileExistsError(f"review publication already exists: {identifier}") - shots, candidate_specs, pre_skipped_ids = _payload(plan, root, assets_dir) + shots, candidate_specs, pre_skipped_ids = _payload( + plan, canonical_timeline, canonical_transcript, root, assets_dir, + ) output_dir.parent.mkdir(parents=True, exist_ok=True) published_assets = False published_page = False @@ -180,7 +257,7 @@ def build_review_page(plan, timeline, transcript, video, output_dir, *, project_ _validate_jpeg(frame) shot["source_frame"]["sha256"] = _hash(frame) subject_hash = broll_plan.canonical_sha256(broll_plan.review_subject(plan)) - payload = {"review_id": identifier, "plan_sha256": subject_hash, "plan_subject_sha256": subject_hash, "candidate_manifest_sha256": broll_plan.canonical_sha256(broll_plan.candidate_manifest(plan)), "review_video_sha256": expected_video_hash, "decision_modes": ["human", "agent"], "pre_skipped_ids": pre_skipped_ids, "shots": shots} + payload = {"review_id": identifier, "plan_sha256": subject_hash, "plan_subject_sha256": subject_hash, "candidate_manifest_sha256": broll_plan.canonical_sha256(broll_plan.candidate_manifest(plan)), "review_video_sha256": expected_video_hash, "timeline": {"fps": copy.deepcopy(canonical_timeline["fps"]), "program_duration_s": canonical_timeline["program_duration_s"], "clips": copy.deepcopy(canonical_timeline["clips"])}, "decision_modes": ["human", "agent"], "pre_skipped_ids": pre_skipped_ids, "shots": shots} document = template.replace(PAYLOAD_MARKER, base64.b64encode(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")).decode("ascii")) staged_page = stage / page.name staged_page.write_text(document, encoding="utf-8") diff --git a/skills/video-add-b-roll/scripts/candidate_analysis.py b/skills/video-add-b-roll/scripts/candidate_analysis.py new file mode 100644 index 000000000..59e2e3438 --- /dev/null +++ b/skills/video-add-b-roll/scripts/candidate_analysis.py @@ -0,0 +1,1447 @@ +"""Collect, analyze, rank, and bind explainable B-roll candidate shortlists.""" + +import argparse +import copy +import hashlib +import html +import json +import math +import os +import re +import shutil +import subprocess +import sys +import tempfile +import uuid +from datetime import datetime +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageStat + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "video-understand" / "scripts")) +import projectlib + +import broll_plan +import pexels + + +SAMPLE_FRACTIONS = (0.10, 0.30, 0.50, 0.70, 0.90) +SCORE_FIELDS = ("semantic_fit", "context_fit", "composition_fit", "style_fit") +TEXT_LOGO_RISKS = {0, 1, 2, 3, 4, "uncertain"} +ANALYSIS_CACHE = Path("work/cache/b-roll/candidate-analysis") +ANALYSIS_MEDIA = ANALYSIS_CACHE / "media" +ANALYSIS_FRAMES = ANALYSIS_CACHE / "frames" + + +def canonical_sha256(value): + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8") + ).hexdigest() + + +def _sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_json(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + serialized = json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n" + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as handle: + staged = Path(handle.name) + try: + staged.write_text(serialized, encoding="utf-8") + os.replace(staged, path) + finally: + staged.unlink(missing_ok=True) + + +def _finite(value, label, *, positive=False): + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError(f"{label} must be a finite number") + number = float(value) + if not math.isfinite(number) or (positive and number <= 0): + raise ValueError(f"{label} must be a finite {'positive ' if positive else ''}number") + return number + + +def _valid_timestamp(value): + if not isinstance(value, str) or not value.strip(): + return False + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return False + return parsed.tzinfo is not None and parsed.utcoffset() is not None + + +def _inside(root, target): + try: + Path(target).resolve().relative_to(Path(root).resolve()) + except ValueError: + return False + return True + + +def _project_root_from(path): + resolved = Path(path).resolve() + for parent in (resolved, *resolved.parents): + if parent.name == "work": + return parent.parent + raise ValueError("analysis output must be inside a project work directory") + + +def _project_path(root, value, *, prefix=None): + if not isinstance(value, str) or not value.strip(): + return None + raw = Path(value) + if raw.is_absolute() or ".." in raw.parts: + return None + target = (Path(root).resolve() / raw).resolve() + if not _inside(root, target): + return None + if prefix is not None and not _inside(Path(root).resolve() / prefix, target): + return None + return target + + +def _safe_name(value): + name = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(value)).strip("-.") + return name or "candidate" + + +def merge_query_results(queries, results_by_query, *, limit=8): + if not isinstance(queries, list) or not 1 <= len(queries) <= 3 or any(not isinstance(item, str) or not item.strip() for item in queries): + raise ValueError("queries must contain one to three nonblank strings") + if not isinstance(results_by_query, list) or len(results_by_query) != len(queries) or any(not isinstance(items, list) for items in results_by_query): + raise ValueError("query results do not match queries") + if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 8: + raise ValueError("candidate limit must be 1..8") + merged, seen = [], set() + max_results = max((len(items) for items in results_by_query), default=0) + for provider_rank in range(max_results): + for query_index, items in enumerate(results_by_query): + if provider_rank >= len(items): + continue + candidate = items[provider_rank] + if not isinstance(candidate, dict): + continue + provider_id = candidate.get("provider_id") + if not isinstance(provider_id, int) or isinstance(provider_id, bool) or provider_id <= 0 or provider_id in seen: + continue + item = copy.deepcopy(candidate) + item["search"] = { + "query": queries[query_index], + "query_index": query_index, + "provider_rank": provider_rank, + "merge_rank": len(merged), + } + merged.append(item) + seen.add(provider_id) + if len(merged) == limit: + return merged + return merged + + +def search_plan(plan, *, orientation, per_page=8, include_pexels=True, searcher=pexels.search_videos): + if orientation not in {"landscape", "portrait", "square"}: + raise ValueError("invalid orientation") + if not isinstance(plan, dict) or not isinstance(plan.get("shots"), list): + raise ValueError("plan shots are required") + shots = [] + for shot in plan["shots"]: + if not isinstance(shot, dict) or not isinstance(shot.get("id"), str): + raise ValueError("plan shot id is required") + queries = shot.get("queries") + if not isinstance(queries, list) or not 2 <= len(queries) <= 3: + raise ValueError(f"{shot['id']} queries must contain two or three values") + query_results = [searcher(query, orientation=orientation, per_page=per_page) for query in queries] if include_pexels else [[] for _ in queries] + merged = merge_query_results(queries, query_results, limit=8) + for candidate in merged: + candidate["provider_candidate_id"] = candidate.get("id") + candidate["id"] = f"{_safe_name(shot['id'])}-{candidate['id']}" + local_candidates = [] + for candidate in shot.get("candidates", []): + source_type = candidate.get("provenance", {}).get("source_type") if isinstance(candidate, dict) else None + if source_type not in {"local", "external-generated"}: + continue + item = copy.deepcopy(candidate) + item["search"] = {"source": source_type, "merge_rank": len(merged) + len(local_candidates)} + local_candidates.append(item) + shots.append({ + "shot_id": shot["id"], + "queries": copy.deepcopy(queries), + "query_results": copy.deepcopy(query_results), + "merged_candidates": merged + local_candidates, + }) + return {"schema_version": 1, "orientation": orientation, "per_page": per_page, "candidate_limit": 8, "shots": shots} + + +def duration_classification(candidate_duration, shot_duration, frame_duration): + candidate = _finite(candidate_duration, "candidate duration", positive=True) + shot = _finite(shot_duration, "shot duration", positive=True) + frame = _finite(frame_duration, "frame duration", positive=True) + if candidate + frame < shot: + return "reject" + return "warn" if candidate - shot < 1.0 else "pass" + + +def reclassify_durations(analysis, plan, timeline): + """Rebind revised shot timing while preserving frozen media and frame evidence.""" + if not isinstance(analysis, dict) or not isinstance(analysis.get("shots"), list): + raise ValueError("candidate analysis shots are required") + if not isinstance(plan, dict) or not isinstance(plan.get("shots"), list): + raise ValueError("plan shots are required") + fps = timeline.get("fps") if isinstance(timeline, dict) else None + num = fps.get("num") if isinstance(fps, dict) else None + den = fps.get("den") if isinstance(fps, dict) else None + if any(not isinstance(value, int) or isinstance(value, bool) or value <= 0 for value in (num, den)): + raise ValueError("timeline fps is invalid") + plan_shots = {shot.get("id"): shot for shot in plan["shots"] if isinstance(shot, dict)} + result = copy.deepcopy(analysis) + for shot in result["shots"]: + if not isinstance(shot, dict) or shot.get("shot_id") not in plan_shots: + raise ValueError("candidate analysis shot does not match revised plan") + revised = plan_shots[shot["shot_id"]] + program = revised.get("program_range") + start = _finite(program.get("start_s"), "shot start") if isinstance(program, dict) else None + end = _finite(program.get("end_s"), "shot end") if isinstance(program, dict) else None + if start is None or end is None or end <= start: + raise ValueError(f"{shot['shot_id']} shot duration is invalid") + semantic_changed = shot.get("transcript_evidence") != revised.get("transcript_evidence") + shot["program_range"] = copy.deepcopy(program) + shot["transcript_evidence"] = copy.deepcopy(revised.get("transcript_evidence")) + shot["editorial_reason"] = revised.get("editorial_reason") + shot["visual_intent"] = revised.get("visual_intent") + shot["agent_rescore_required"] = semantic_changed + selection = revised.get("selected") + if not isinstance(selection, dict): + review_default = revised.get("review_default") + selection = review_default if isinstance(review_default, dict) else {} + selected_segments = selection.get("segments") + segment_requirements = {} + if isinstance(selected_segments, list): + for segment in selected_segments: + if not isinstance(segment, dict): + continue + segment_program = segment.get("program_range") + segment_start = _finite(segment_program.get("start_s"), "segment program start") if isinstance(segment_program, dict) else None + segment_end = _finite(segment_program.get("end_s"), "segment program end") if isinstance(segment_program, dict) else None + rate = _finite(segment.get("playback_rate"), "segment playback rate", positive=True) + candidate_id = segment.get("candidate_id") + if (isinstance(candidate_id, str) and segment_start is not None + and segment_end is not None and segment_end > segment_start): + segment_requirements[candidate_id] = { + "program_duration_s": segment_end - segment_start, + "playback_rate": rate, + "required_source_duration_s": (segment_end - segment_start) * rate, + } + candidates = shot.get("candidates") + if not isinstance(candidates, list): + raise ValueError(f"{shot['shot_id']} candidate analysis candidates are invalid") + for candidate in candidates: + if not isinstance(candidate, dict): + continue + media = candidate.get("analysis_media") + probe = media.get("probe") if isinstance(media, dict) else None + duration = probe.get("duration_s") if isinstance(probe, dict) else None + if duration is None: + source = candidate.get("source_candidate") + source_probe = source.get("probe") if isinstance(source, dict) else None + duration = source.get("duration_s", source_probe.get("duration_s") if isinstance(source_probe, dict) else None) if isinstance(source, dict) else None + if duration is None: + continue + evidence = segment_requirements.get(candidate.get("candidate_id"), { + "program_duration_s": end - start, + "playback_rate": 1.0, + "required_source_duration_s": end - start, + }) + candidate["duration_evidence"] = copy.deepcopy(evidence) + state = duration_classification( + duration, evidence["required_source_duration_s"], + (den / num) * evidence["playback_rate"], + ) + checks = candidate.setdefault("hard_checks", {}) + checks["duration"] = state + revised_duration_reason = "candidate cannot cover the revised shot duration" + warnings = [value for value in candidate.get("warnings", []) + if value != "less_than_one_second_trim_pad"] + if state == "warn": + warnings.append("less_than_one_second_trim_pad") + candidate["warnings"] = sorted(set(warnings)) + reasons = [value for value in candidate.get("rejection_reasons", []) + if value != revised_duration_reason] + if state == "reject" and candidate.get("analysis_status") == "analyzed": + candidate["analysis_status"] = "rejected" + checks["status"] = "reject" + if state == "reject": + reasons.append(revised_duration_reason) + elif (candidate.get("analysis_status") == "rejected" + and revised_duration_reason in candidate.get("rejection_reasons", []) + and not reasons): + candidate["analysis_status"] = "analyzed" + checks["status"] = "pass" + candidate["rejection_reasons"] = reasons + eligible = [candidate for candidate in candidates + if isinstance(candidate, dict) and candidate.get("analysis_status") == "analyzed"] + shot["duplicate_evidence"] = duplicate_evidence(eligible) + if set(plan_shots) != {shot.get("shot_id") for shot in result["shots"] if isinstance(shot, dict)}: + raise ValueError("candidate analysis is missing revised plan shots") + result["project_duplicate_evidence"] = project_duplicate_evidence(result["shots"]) + return result + + +def _extract_frame(media, timestamp, destination): + subprocess.run( + ["ffmpeg", "-y", "-v", "error", "-ss", f"{timestamp:.9f}", "-i", str(media), "-frames:v", "1", str(destination)], + check=True, capture_output=True, + ) + + +def _full_decode(media): + subprocess.run( + ["ffmpeg", "-v", "error", "-xerror", "-i", str(media), "-f", "null", "-"], + check=True, capture_output=True, + ) + + +def _perceptual_hash(gray): + small = gray.resize((8, 8), Image.Resampling.LANCZOS) + values = list(small.get_flattened_data()) if hasattr(small, "get_flattened_data") else list(small.getdata()) + average = sum(values) / len(values) + bits = "".join("1" if value >= average else "0" for value in values) + return f"{int(bits, 2):016x}" + + +def _center_crop(image, target_width, target_height): + target_ratio = target_width / target_height + source_ratio = image.width / image.height + if source_ratio > target_ratio: + width, height = max(1, round(image.height * target_ratio)), image.height + else: + width, height = image.width, max(1, round(image.width / target_ratio)) + left, top = (image.width - width) // 2, (image.height - height) // 2 + return image.crop((left, top, left + width, top + height)), (width * height) / (image.width * image.height) + + +def _frame_metrics(image, previous_gray=None): + gray = image.convert("L") + histogram = gray.histogram() + pixels = max(1, image.width * image.height) + luma = sum(index * count for index, count in enumerate(histogram)) / pixels + edge = ImageStat.Stat(gray.filter(ImageFilter.FIND_EDGES)).mean[0] + motion = None + if previous_gray is not None: + left = previous_gray.resize((64, 64), Image.Resampling.BILINEAR) + right = gray.resize((64, 64), Image.Resampling.BILINEAR) + motion = ImageStat.Stat(ImageChops.difference(left, right)).mean[0] / 255.0 + return { + "average_luma": round(luma, 6), + "black_clip_ratio": round(sum(histogram[:17]) / pixels, 6), + "highlight_clip_ratio": round(sum(histogram[240:]) / pixels, 6), + "edge_detail_energy": round(edge, 6), + "perceptual_hash": _perceptual_hash(gray), + "adjacent_frame_change": None if motion is None else round(motion, 6), + }, gray + + +def _contact_sheet(rows, destination): + cell_width, cell_height, label_height = 320, 220, 24 + sheet = Image.new("RGB", (cell_width * 2, (cell_height + label_height) * len(rows)), "white") + draw = ImageDraw.Draw(sheet) + for index, (label, frame, crop) in enumerate(rows): + top = index * (cell_height + label_height) + draw.text((6, top + 5), label, fill="black") + for column, image in enumerate((frame, crop)): + preview = image.copy() + preview.thumbnail((cell_width, cell_height), Image.Resampling.LANCZOS) + x = column * cell_width + (cell_width - preview.width) // 2 + y = top + label_height + (cell_height - preview.height) // 2 + sheet.paste(preview, (x, y)) + sheet.save(destination, "PNG") + + +def sample_media(media, *, duration_s, output_dir, timeline_width, timeline_height, extract_frame=_extract_frame): + duration = _finite(duration_s, "media duration", positive=True) + if any(not isinstance(value, int) or isinstance(value, bool) or value <= 0 for value in (timeline_width, timeline_height)): + raise ValueError("timeline dimensions must be positive integers") + media, output_dir = Path(media).resolve(), Path(output_dir).resolve() + if not media.is_file(): + raise FileNotFoundError(f"analysis media is missing: {media}") + root = _project_root_from(output_dir) + if not _inside(root / ANALYSIS_FRAMES, output_dir): + raise ValueError("sample output must be inside the candidate-analysis frame cache") + output_dir.mkdir(parents=True, exist_ok=True) + samples, rows, previous = [], [], None + for index, fraction in enumerate(SAMPLE_FRACTIONS, 1): + timestamp = duration * fraction + frame_path = output_dir / f"frame-{index:02d}.png" + crop_path = output_dir / f"crop-{index:02d}.png" + extract_frame(media, timestamp, frame_path) + try: + with Image.open(frame_path) as opened: + frame = opened.convert("RGB") + except OSError as error: + raise ValueError("sample frame is not a decodable image") from error + if frame.width <= 0 or frame.height <= 0: + raise ValueError("sample frame dimensions are invalid") + crop, retained = _center_crop(frame, timeline_width, timeline_height) + crop.save(crop_path, "PNG") + metrics, previous = _frame_metrics(frame, previous) + samples.append({ + "fraction": fraction, + "timestamp_s": round(timestamp, 6), + "frame_path": frame_path.relative_to(root).as_posix(), + "crop_path": crop_path.relative_to(root).as_posix(), + "width": frame.width, + "height": frame.height, + "sha256": _sha256(frame_path), + "crop_sha256": _sha256(crop_path), + "crop_retained_ratio": round(retained, 6), + **metrics, + }) + rows.append((f"{round(fraction * 100)}% | original / center crop", frame, crop)) + contact_sheet = output_dir / "contact-sheet.png" + _contact_sheet(rows, contact_sheet) + changes = [item["adjacent_frame_change"] for item in samples if item["adjacent_frame_change"] is not None] + metrics = { + "average_luma": round(sum(item["average_luma"] for item in samples) / len(samples), 6), + "black_clip_ratio": round(sum(item["black_clip_ratio"] for item in samples) / len(samples), 6), + "highlight_clip_ratio": round(sum(item["highlight_clip_ratio"] for item in samples) / len(samples), 6), + "edge_detail_energy": round(sum(item["edge_detail_energy"] for item in samples) / len(samples), 6), + "sampled_frame_change": round(sum(changes) / len(changes), 6), + "crop_retained_ratio": min(item["crop_retained_ratio"] for item in samples), + } + warnings = [] + if metrics["average_luma"] < 45: warnings.append("dark_luma_distribution") + if metrics["average_luma"] > 210: warnings.append("bright_luma_distribution") + if metrics["black_clip_ratio"] > 0.15: warnings.append("black_clipping") + if metrics["highlight_clip_ratio"] > 0.15: warnings.append("highlight_clipping") + if metrics["edge_detail_energy"] < 8: warnings.append("low_edge_detail") + if metrics["sampled_frame_change"] < 0.01: warnings.append("very_low_sampled_frame_change") + if metrics["sampled_frame_change"] > 0.30: warnings.append("very_high_sampled_frame_change") + if metrics["crop_retained_ratio"] < 0.65: warnings.append("severe_center_crop_loss_risk") + return { + "samples": samples, + "metrics": metrics, + "warnings": warnings, + "contact_sheet_path": contact_sheet.relative_to(root).as_posix(), + "contact_sheet_sha256": _sha256(contact_sheet), + } + + +def _hamming(left, right): + return (int(left, 16) ^ int(right, 16)).bit_count() + + +def duplicate_evidence(candidates): + exact = {} + for candidate in candidates: + digest = candidate.get("analysis_media", {}).get("sha256") + if isinstance(digest, str): + exact.setdefault(digest, []).append(candidate.get("candidate_id")) + exact_groups = [ + {"evidence": digest, "candidate_ids": ids} + for digest, ids in exact.items() if len(ids) > 1 + ] + identifiers = [item.get("candidate_id") for item in candidates] + adjacency = {identifier: set() for identifier in identifiers} + for index, left in enumerate(candidates): + left_hashes = [item.get("perceptual_hash") for item in left.get("samples", [])] + for right in candidates[index + 1:]: + right_hashes = [item.get("perceptual_hash") for item in right.get("samples", [])] + if not left_hashes or len(left_hashes) != len(right_hashes) or any(not isinstance(value, str) for value in left_hashes + right_hashes): + continue + if sum(_hamming(a, b) for a, b in zip(left_hashes, right_hashes)) / len(left_hashes) <= 6: + adjacency[left["candidate_id"]].add(right["candidate_id"]) + adjacency[right["candidate_id"]].add(left["candidate_id"]) + groups, visited = [], set() + for identifier in identifiers: + if identifier in visited or not adjacency.get(identifier): + continue + stack, members = [identifier], [] + while stack: + current = stack.pop() + if current in visited: continue + visited.add(current); members.append(current); stack.extend(adjacency[current] - visited) + if len(members) > 1: + groups.append({"candidate_ids": [value for value in identifiers if value in members]}) + rejected = [identifier for group in exact_groups for identifier in group["candidate_ids"][1:]] + return {"exact_groups": exact_groups, "perceptual_groups": groups, "hard_rejected_candidate_ids": rejected} + + +def _candidate_ref(shot_id, candidate_id): + return {"shot_id": shot_id, "candidate_id": candidate_id} + + +def _ref_key(value): + if not isinstance(value, dict): + return None + shot_id, candidate_id = value.get("shot_id"), value.get("candidate_id") + if not isinstance(shot_id, str) or not shot_id.strip() or not isinstance(candidate_id, str) or not candidate_id.strip(): + return None + return shot_id, candidate_id + + +def _identity_evidence(candidate): + source = candidate.get("source_candidate") if isinstance(candidate.get("source_candidate"), dict) else {} + provenance = source.get("provenance") if isinstance(source.get("provenance"), dict) else {} + evidence = [] + provider_candidate_id = source.get("provider_candidate_id", candidate.get("provider_candidate_id")) + if isinstance(provider_candidate_id, (str, int)) and not isinstance(provider_candidate_id, bool) and str(provider_candidate_id).strip(): + evidence.append(("provider_candidate_id", str(provider_candidate_id))) + provider_id = source.get("provider_id", candidate.get("provider_id")) + delivery = candidate.get("delivery_variant") if isinstance(candidate.get("delivery_variant"), dict) else source.get("delivery_variant") + delivery_file_id = delivery.get("file_id") if isinstance(delivery, dict) else None + if isinstance(provider_id, int) and not isinstance(provider_id, bool) and isinstance(delivery_file_id, int) and not isinstance(delivery_file_id, bool): + evidence.append(("provider_delivery_file", f"{provider_id}:{delivery_file_id}")) + analysis_media = candidate.get("analysis_media") if isinstance(candidate.get("analysis_media"), dict) else {} + analysis_sha256 = analysis_media.get("sha256") + if broll_plan._is_sha256(analysis_sha256): + evidence.append(("analysis_sha256", analysis_sha256)) + delivery_sha256 = source.get("sha256") + if broll_plan._is_sha256(delivery_sha256): + evidence.append(("delivery_sha256", delivery_sha256)) + if provenance.get("source_type") in {"local", "external-generated"}: + path = source.get("cache_path", analysis_media.get("path")) + digest = source.get("sha256", analysis_sha256) + byte_count = source.get("bytes", analysis_media.get("bytes")) + if isinstance(path, str) and path.strip() and broll_plan._is_sha256(digest) and isinstance(byte_count, int) and not isinstance(byte_count, bool) and byte_count >= 0: + normalized = Path(path).as_posix().casefold() + evidence.append(("local_frozen_media", f"{normalized}|{byte_count}|{digest}")) + return evidence + + +def _perceptual_distance(left, right): + left_hashes = [item.get("perceptual_hash") for item in left.get("samples", []) if isinstance(item, dict)] + right_hashes = [item.get("perceptual_hash") for item in right.get("samples", []) if isinstance(item, dict)] + if not left_hashes or len(left_hashes) != len(right_hashes) or any(not isinstance(value, str) for value in left_hashes + right_hashes): + return None + distances = [_hamming(a, b) for a, b in zip(left_hashes, right_hashes)] + return {"average_hamming_distance": sum(distances) / len(distances), "minimum_hamming_distance": min(distances)} + + +def _source_terms(candidate): + source = candidate.get("source_candidate") if isinstance(candidate.get("source_candidate"), dict) else {} + provenance = source.get("provenance") if isinstance(source.get("provenance"), dict) else {} + value = provenance.get("source_url") + if not isinstance(value, str): + return set() + ignored = {"https", "http", "www", "pexels", "com", "video", "videos", "with", "from", "this", "that", "footage"} + return {token for token in re.findall(r"[a-z0-9]+", value.casefold()) if len(token) >= 4 and not token.isdigit() and token not in ignored} + + +def _series_evidence(left, right): + left_source = left.get("source_candidate") if isinstance(left.get("source_candidate"), dict) else {} + right_source = right.get("source_candidate") if isinstance(right.get("source_candidate"), dict) else {} + left_provenance = left_source.get("provenance") if isinstance(left_source.get("provenance"), dict) else {} + right_provenance = right_source.get("provenance") if isinstance(right_source.get("provenance"), dict) else {} + evidence = [] + left_provider, right_provider = left_provenance.get("provider"), right_provenance.get("provider") + if isinstance(left_provider, str) and left_provider.strip() and left_provider.casefold() == str(right_provider).casefold(): + evidence.append({"kind": "same_provider", "value": left_provider}) + left_creator, right_creator = left_provenance.get("creator"), right_provenance.get("creator") + if isinstance(left_creator, str) and left_creator.strip() and left_creator.casefold() == str(right_creator).casefold(): + evidence.append({"kind": "same_creator", "value": left_creator}) + left_id = left_source.get("provider_id", left.get("provider_id")) + right_id = right_source.get("provider_id", right.get("provider_id")) + if all(isinstance(value, int) and not isinstance(value, bool) for value in (left_id, right_id)) and abs(left_id - right_id) <= 100: + evidence.append({"kind": "provider_id_proximity", "value": abs(left_id - right_id)}) + shared_terms = sorted(_source_terms(left) & _source_terms(right)) + if shared_terms: + evidence.append({"kind": "shared_source_terms", "value": shared_terms}) + distance = _perceptual_distance(left, right) + if distance is not None and distance["average_hamming_distance"] > 6 and distance["minimum_hamming_distance"] <= 6: + evidence.append({"kind": "partial_perceptual_overlap", "value": distance}) + kinds = {item["kind"] for item in evidence} + if "same_creator" in kinds and kinds & {"provider_id_proximity", "shared_source_terms", "partial_perceptual_overlap"}: + return evidence + return [] + + +def _connected_groups(entries, adjacency): + order = [entry["key"] for entry in entries] + visited, groups = set(), [] + for key in order: + if key in visited or not adjacency.get(key): + continue + stack, members = [key], [] + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + members.append(current) + stack.extend(adjacency[current] - visited) + if len(members) > 1: + groups.append([value for value in order if value in members]) + return groups + + +def project_duplicate_evidence(shots): + entries = [] + for shot in shots: + if not isinstance(shot, dict): + continue + shot_id = shot.get("shot_id") + for candidate in shot.get("candidates", []): + if isinstance(candidate, dict) and candidate.get("analysis_status") == "analyzed": + key = (shot_id, candidate.get("candidate_id")) + entries.append({"key": key, "shot_id": shot_id, "candidate": candidate}) + by_key = {entry["key"]: entry for entry in entries} + identity_buckets = {} + for entry in entries: + for evidence in _identity_evidence(entry["candidate"]): + identity_buckets.setdefault(evidence, []).append(entry["key"]) + exact_adjacency = {entry["key"]: set() for entry in entries} + for members in identity_buckets.values(): + for left in members: + exact_adjacency[left].update(value for value in members if value != left) + exact_groups = [] + exact_pairs = set() + for index, members in enumerate(_connected_groups(entries, exact_adjacency), 1): + member_set = set(members) + for left_index, left in enumerate(members): + for right in members[left_index + 1:]: + exact_pairs.add(frozenset((left, right))) + evidence = [ + {"kind": kind, "value": value} + for (kind, value), bucket in sorted(identity_buckets.items()) + if len(member_set & set(bucket)) > 1 + ] + exact_groups.append({"group_id": f"exact-{index:03d}", "members": [_candidate_ref(*key) for key in members], "evidence": evidence}) + + strict_adjacency = {entry["key"]: set() for entry in entries} + strict_pairs = {} + possible_series = [] + for left_index, left in enumerate(entries): + for right in entries[left_index + 1:]: + pair = frozenset((left["key"], right["key"])) + distance = _perceptual_distance(left["candidate"], right["candidate"]) + if distance is not None and distance["average_hamming_distance"] <= 6: + strict_adjacency[left["key"]].add(right["key"]) + strict_adjacency[right["key"]].add(left["key"]) + strict_pairs[pair] = distance + continue + if pair in exact_pairs: + continue + evidence = _series_evidence(left["candidate"], right["candidate"]) + if evidence: + possible_series.append({ + "hint_id": f"possible-series-{len(possible_series) + 1:03d}", + "members": [_candidate_ref(*left["key"]), _candidate_ref(*right["key"])], + "evidence": evidence, + }) + strict_groups = [] + for index, members in enumerate(_connected_groups(entries, strict_adjacency), 1): + pairs = [] + for left_index, left in enumerate(members): + for right in members[left_index + 1:]: + distance = strict_pairs.get(frozenset((left, right))) + if distance is not None: + pairs.append({"members": [_candidate_ref(*left), _candidate_ref(*right)], **distance}) + strict_groups.append({"group_id": f"strict-perceptual-{index:03d}", "members": [_candidate_ref(*key) for key in members], "pairs": pairs}) + return {"exact_groups": exact_groups, "strict_perceptual_groups": strict_groups, "possible_series": possible_series} + + +def validate_analysis_document(analysis, project_root=None, *, verify_files=False): + errors = [] + if not isinstance(analysis, dict): + return ["candidate analysis must be an object"] + if analysis.get("schema_version") != 1: errors.append("candidate analysis schema_version must be 1") + if not broll_plan._is_sha256(analysis.get("search_sha256")): errors.append("candidate analysis search SHA-256 is invalid") + shots = analysis.get("shots") + if not isinstance(shots, list): return errors + ["candidate analysis shots must be a list"] + seen_shots, seen_candidates = set(), set() + root = Path(project_root).resolve() if project_root is not None else None + for shot in shots: + if not isinstance(shot, dict): errors.append("candidate analysis shot must be an object"); continue + shot_id = shot.get("shot_id") + if not isinstance(shot_id, str) or not shot_id.strip(): errors.append("candidate analysis shot id is required"); shot_id = "" + elif shot_id in seen_shots: errors.append(f"duplicate candidate analysis shot id: {shot_id}") + seen_shots.add(shot_id) + candidates = shot.get("candidates") + if not isinstance(candidates, list): errors.append(f"{shot_id} analysis candidates must be a list"); continue + for candidate in candidates: + if not isinstance(candidate, dict): errors.append(f"{shot_id} analysis candidate must be an object"); continue + candidate_id = candidate.get("candidate_id") + if not isinstance(candidate_id, str) or not candidate_id.strip(): errors.append(f"{shot_id} analysis candidate id is required"); candidate_id = "" + elif candidate_id in seen_candidates: errors.append(f"duplicate analyzed candidate id: {candidate_id}") + seen_candidates.add(candidate_id) + status = candidate.get("analysis_status") + if status not in {"analyzed", "rejected"}: errors.append(f"{shot_id} candidate {candidate_id} analysis status is invalid") + if not isinstance(candidate.get("warnings"), list) or any(not isinstance(item, str) for item in candidate.get("warnings", [])): + errors.append(f"{shot_id} candidate {candidate_id} warnings must be strings") + hard_checks = candidate.get("hard_checks") + if not isinstance(hard_checks, dict) or hard_checks.get("status") not in {"pass", "reject"}: + errors.append(f"{shot_id} candidate {candidate_id} hard checks are invalid") + if status != "analyzed": + continue + media = candidate.get("analysis_media") + if not isinstance(media, dict) or not broll_plan._is_sha256(media.get("sha256")): + errors.append(f"{shot_id} candidate {candidate_id} analysis media is invalid") + samples = candidate.get("samples") + if not isinstance(samples, list) or len(samples) != len(SAMPLE_FRACTIONS): + errors.append(f"{shot_id} candidate {candidate_id} must have five samples") + samples = [] + if verify_files: + if root is None: + errors.append("candidate analysis file verification requires project root") + continue + source_type = candidate.get("source_candidate", {}).get("provenance", {}).get("source_type") + media_prefix = Path("work/cache/b-roll/candidates") if source_type in {"local", "external-generated"} else ANALYSIS_MEDIA + media_path = _project_path(root, media.get("path") if isinstance(media, dict) else None, prefix=media_prefix) + if media_path is None: errors.append(f"{shot_id} candidate {candidate_id} analysis media path is invalid") + elif not media_path.is_file(): errors.append(f"{shot_id} candidate {candidate_id} analysis media is missing") + elif _sha256(media_path) != media.get("sha256"): errors.append(f"{shot_id} candidate {candidate_id} analysis media SHA-256 is stale") + for sample in samples: + if not isinstance(sample, dict): errors.append(f"{shot_id} candidate {candidate_id} sample is invalid"); continue + for field, hash_field, label in (("frame_path", "sha256", "sample frame"), ("crop_path", "crop_sha256", "sample crop")): + if field not in sample and field == "crop_path": + continue + path = _project_path(root, sample.get(field), prefix=ANALYSIS_FRAMES) + if path is None: errors.append(f"{shot_id} candidate {candidate_id} {label} path is invalid") + elif not path.is_file(): errors.append(f"{shot_id} candidate {candidate_id} {label} is missing") + elif _sha256(path) != sample.get(hash_field): errors.append(f"{shot_id} candidate {candidate_id} {label} SHA-256 is stale") + project_evidence = analysis.get("project_duplicate_evidence") + if project_evidence is not None: + if not isinstance(project_evidence, dict): + errors.append("candidate analysis project duplicate evidence must be an object") + else: + valid_refs = { + (shot.get("shot_id"), candidate.get("candidate_id")) + for shot in shots if isinstance(shot, dict) + for candidate in shot.get("candidates", []) if isinstance(candidate, dict) and candidate.get("analysis_status") == "analyzed" + } + for field, identifier_field in (("exact_groups", "group_id"), ("strict_perceptual_groups", "group_id"), ("possible_series", "hint_id")): + groups = project_evidence.get(field) + if not isinstance(groups, list): + errors.append(f"candidate analysis {field} must be a list") + continue + seen_ids = set() + for group in groups: + if not isinstance(group, dict): + errors.append(f"candidate analysis {field} entry must be an object") + continue + identifier = group.get(identifier_field) + if not isinstance(identifier, str) or not identifier.strip() or identifier in seen_ids: + errors.append(f"candidate analysis {field} ids must be unique nonblank strings") + seen_ids.add(identifier) + members = group.get("members") + member_keys = [_ref_key(member) for member in members] if isinstance(members, list) else [] + if not isinstance(members, list) or len(members) < 2 or None in member_keys or len(member_keys) != len(set(member_keys)) or any(key not in valid_refs for key in member_keys): + errors.append(f"candidate analysis {field} members are invalid") + evidence_field = "pairs" if field == "strict_perceptual_groups" else "evidence" + if not isinstance(group.get(evidence_field), list): + errors.append(f"candidate analysis {field} {evidence_field} must be a list") + return errors + + +def _analysis_candidate(base, acquired, sampled, duration_state, timeline_width, timeline_height, hard_checks): + path = Path(acquired["path"]) + warnings = list(sampled["warnings"]) + if duration_state == "warn": warnings.append("less_than_one_second_trim_pad") + if acquired["probe"]["width"] < timeline_width or acquired["probe"]["height"] < timeline_height: + warnings.append("source_resolution_below_timeline") + checks = copy.deepcopy(hard_checks) + checks.update({"status": "pass", "sampling": "pass", "exact_duplicate": "pass"}) + return { + "candidate_id": base["id"], + "provider_id": base.get("provider_id"), + "provider_order": copy.deepcopy(base.get("search")), + "source_candidate": copy.deepcopy(base), + "analysis_variant": copy.deepcopy(base.get("analysis_variant")), + "delivery_variant": copy.deepcopy(base.get("delivery_variant")), + "analysis_status": "analyzed", + "hard_checks": checks, + "rejection_reasons": [], + "warnings": sorted(set(warnings)), + "analysis_media": {"path": path.as_posix(), "sha256": acquired["sha256"], "bytes": acquired["bytes"], "probe": copy.deepcopy(acquired["probe"])}, + **sampled, + } + + +def analyze_search(plan, search, timeline, project_root, *, downloader=pexels.download_candidate, extractor=_extract_frame): + root = Path(project_root).resolve() + if not isinstance(plan, dict) or not isinstance(plan.get("shots"), list): raise ValueError("plan shots are required") + if not isinstance(search, dict) or search.get("schema_version") != 1 or not isinstance(search.get("shots"), list): raise ValueError("candidate search is invalid") + width, height, fps = timeline.get("width"), timeline.get("height"), timeline.get("fps") + if any(not isinstance(value, int) or isinstance(value, bool) or value <= 0 for value in (width, height)): + media_path = root / "work/understand/media.json" + media = projectlib.load_json(media_path) if media_path.is_file() else {} + width, height = media.get("width"), media.get("height") + if any(not isinstance(value, int) or isinstance(value, bool) or value <= 0 for value in (width, height)) or not isinstance(fps, dict): + raise ValueError("timeline dimensions and fps are required") + num, den = fps.get("num"), fps.get("den") + if any(not isinstance(value, int) or isinstance(value, bool) or value <= 0 for value in (num, den)): + raise ValueError("timeline fps is invalid") + search_by_shot = {item.get("shot_id"): item for item in search["shots"] if isinstance(item, dict)} + analysis_shots = [] + for shot in plan["shots"]: + shot_id = shot.get("id") + search_shot = search_by_shot.get(shot_id) + if not isinstance(search_shot, dict): raise ValueError(f"candidate search is missing shot {shot_id}") + program = shot.get("program_range", {}) + shot_duration = _finite(program.get("end_s"), "shot end") - _finite(program.get("start_s"), "shot start") + if shot_duration <= 0: raise ValueError(f"{shot_id} shot duration is invalid") + analyzed = [] + for base in search_shot.get("merged_candidates", []): + candidate_id = base.get("id") if isinstance(base, dict) else "" + hard_checks = {"status": "reject", "source": "reject"} + try: + source_type = base.get("provenance", {}).get("source_type") if isinstance(base, dict) else None + if source_type == "pexels": + source = pexels.variant_candidate(base, "analysis") + elif source_type in {"local", "external-generated"}: + source = copy.deepcopy(base) + else: + raise ValueError("candidate source is invalid") + source_probe = source.get("probe") if isinstance(source.get("probe"), dict) else {} + source_duration = source.get("duration_s", source_probe.get("duration_s")) + duration_state = duration_classification(source_duration, shot_duration, den / num) + hard_checks["source"] = "pass" + hard_checks["duration"] = duration_state + if duration_state == "reject": + raise ValueError("candidate cannot cover the planned shot duration") + if source_type == "pexels": + file_id = source["file_id"] + name = f"{_safe_name(shot_id)}-{source['provider_id']}-{file_id}.mp4" + destination = root / ANALYSIS_MEDIA / name + source["cache_path"] = (ANALYSIS_MEDIA / name).as_posix() + acquired = downloader(source, destination, purpose="analysis") + hard_checks["download"] = "pass" + else: + file_id = source["id"] + path = broll_plan._candidate_path(root, source.get("cache_path")) + if path is None or not path.is_file(): raise ValueError("local analysis media path is invalid") + if source.get("sha256") != _sha256(path): raise ValueError("local analysis media SHA-256 is stale") + if source.get("bytes") != path.stat().st_size: raise ValueError("local analysis media byte count is stale") + acquired = copy.deepcopy(source); acquired["path"] = path + hard_checks["download"] = "not_applicable" + _full_decode(acquired["path"]) + hard_checks["decode"] = "pass" + probe = acquired.get("probe") + if not isinstance(probe, dict) or any(_finite(probe.get(field), f"probe {field}", positive=True) <= 0 for field in ("duration_s", "width", "height")): + raise ValueError("analysis media metadata is invalid") + hard_checks["metadata"] = "pass" + source_key = source.get("provider_id", source.get("id", "local")) + sample_dir = root / ANALYSIS_FRAMES / f"{_safe_name(shot_id)}-{_safe_name(source_key)}-{_safe_name(file_id)}" + sampled = sample_media(acquired["path"], duration_s=probe["duration_s"], output_dir=sample_dir, timeline_width=width, timeline_height=height, extract_frame=extractor) + clean = copy.deepcopy(acquired) + clean["path"] = Path(acquired["path"]).relative_to(root).as_posix() + analyzed.append(_analysis_candidate(base, clean, sampled, duration_state, width, height, hard_checks)) + except Exception as error: + analyzed.append({ + "candidate_id": candidate_id, + "provider_id": base.get("provider_id") if isinstance(base, dict) else None, + "provider_order": copy.deepcopy(base.get("search")) if isinstance(base, dict) else None, + "source_candidate": copy.deepcopy(base) if isinstance(base, dict) else None, + "analysis_variant": copy.deepcopy(base.get("analysis_variant")) if isinstance(base, dict) else None, + "delivery_variant": copy.deepcopy(base.get("delivery_variant")) if isinstance(base, dict) else None, + "analysis_status": "rejected", + "hard_checks": hard_checks, + "rejection_reasons": [str(error)], + "warnings": [], + }) + eligible = [item for item in analyzed if item["analysis_status"] == "analyzed"] + duplicates = duplicate_evidence(eligible) + for candidate_id in duplicates["hard_rejected_candidate_ids"]: + item = next(candidate for candidate in analyzed if candidate["candidate_id"] == candidate_id) + item["analysis_status"] = "rejected" + item["hard_checks"]["status"] = "reject" + item["hard_checks"]["exact_duplicate"] = "reject" + item["rejection_reasons"].append("exact duplicate of an earlier candidate") + for group in duplicates["perceptual_groups"]: + for candidate_id in group["candidate_ids"]: + item = next(candidate for candidate in analyzed if candidate["candidate_id"] == candidate_id) + item["warnings"] = sorted(set(item["warnings"] + ["perceptual_similarity_requires_agent_confirmation"])) + analysis_shots.append({ + "shot_id": shot_id, + "program_range": copy.deepcopy(shot.get("program_range")), + "transcript_evidence": copy.deepcopy(shot.get("transcript_evidence")), + "editorial_reason": shot.get("editorial_reason"), + "visual_intent": shot.get("visual_intent"), + "avoid": copy.deepcopy(plan.get("brief", {}).get("avoid", [])), + "queries": copy.deepcopy(shot.get("queries")), + "original_provider_order": [item.get("provider_id") for results in search_shot.get("query_results", []) for item in results if isinstance(item, dict)], + "duplicate_evidence": duplicates, + "candidates": analyzed, + }) + result = { + "schema_version": 1, + "search_sha256": canonical_sha256(search), + "timeline": {"width": width, "height": height, "fps": {"num": num, "den": den}}, + "sample_fractions": list(SAMPLE_FRACTIONS), + "shots": analysis_shots, + } + result["project_duplicate_evidence"] = project_duplicate_evidence(analysis_shots) + errors = validate_analysis_document(result, root, verify_files=True) + if errors: raise ValueError("invalid candidate analysis: " + "; ".join(errors)) + return result + + +def _score_errors(analysis, scores): + errors = [] + if not isinstance(scores, dict): return ["Agent scoring must be an object"] + if scores.get("schema_version") != 1: errors.append("Agent scoring schema_version must be 1") + if scores.get("analysis_sha256") != canonical_sha256(analysis): errors.append("Agent scoring analysis SHA-256 does not match") + if scores.get("mode") != "agent": errors.append("Agent scoring mode must be agent") + actor = scores.get("actor") + if not isinstance(actor, str) or not actor.strip() or actor.strip().casefold() in {"human", "user"}: errors.append("Agent scoring actor must name the real Agent") + if not _valid_timestamp(scores.get("timestamp")): errors.append("Agent scoring timestamp must be timezone-aware") + if not isinstance(scores.get("overall_rationale"), str) or not scores["overall_rationale"].strip(): errors.append("Agent scoring overall rationale is required") + analysis_shots = {item.get("shot_id"): item for item in analysis.get("shots", []) if isinstance(item, dict)} + score_shots = scores.get("shots") + if not isinstance(score_shots, list): return errors + ["Agent scoring shots must be a list"] + if len(score_shots) != len(analysis_shots): errors.append("Agent scoring shots do not match analysis") + seen_shots = set() + for shot in score_shots: + if not isinstance(shot, dict): errors.append("Agent scoring shot must be an object"); continue + shot_id = shot.get("shot_id") + if shot_id not in analysis_shots or shot_id in seen_shots: errors.append("Agent scoring shot ids do not match analysis"); continue + seen_shots.add(shot_id) + expected = {item.get("candidate_id") for item in analysis_shots[shot_id].get("candidates", []) if isinstance(item, dict) and item.get("analysis_status") == "analyzed"} + entries = shot.get("candidates") + if not isinstance(entries, list): errors.append(f"{shot_id} Agent candidate scores must be a list"); continue + actual, seen = set(), set() + for entry in entries: + if not isinstance(entry, dict): errors.append(f"{shot_id} Agent candidate score must be an object"); continue + candidate_id = entry.get("candidate_id") + if candidate_id in seen or candidate_id not in expected: errors.append(f"{shot_id} Agent candidate ids do not match analysis") + seen.add(candidate_id); actual.add(candidate_id) + for field in SCORE_FIELDS: + value = entry.get(field) + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 4: + errors.append(f"{shot_id} candidate {candidate_id} {field} must be an integer from 0 through 4") + risk = entry.get("text_logo_risk") + if risk not in TEXT_LOGO_RISKS or isinstance(risk, bool): errors.append(f"{shot_id} candidate {candidate_id} text_logo_risk is invalid") + if not isinstance(entry.get("rationale"), str) or not entry["rationale"].strip(): errors.append(f"{shot_id} candidate {candidate_id} rationale is required") + if not isinstance(entry.get("avoid_violation"), bool): errors.append(f"{shot_id} candidate {candidate_id} avoid_violation must be boolean") + if not isinstance(entry.get("primary_subject_visible"), bool): errors.append(f"{shot_id} candidate {candidate_id} primary_subject_visible must be boolean") + group = entry.get("near_duplicate_group") + if group is not None and (not isinstance(group, str) or not group.strip()): errors.append(f"{shot_id} candidate {candidate_id} near_duplicate_group is invalid") + if actual != expected: errors.append(f"{shot_id} Agent candidate scores do not cover every analyzable candidate") + if seen_shots != set(analysis_shots): errors.append("Agent scoring shots do not cover analysis") + confirmed = scores.get("near_duplicate_groups") + if confirmed is not None: + if not isinstance(confirmed, list): + errors.append("Agent near-duplicate groups must be a list") + else: + valid_refs = { + (shot_id, candidate.get("candidate_id")) + for shot_id, shot in analysis_shots.items() + for candidate in shot.get("candidates", []) + if isinstance(candidate, dict) and candidate.get("analysis_status") == "analyzed" + } + seen_groups, grouped_members = set(), set() + for group in confirmed: + if not isinstance(group, dict): + errors.append("Agent near-duplicate group must be an object") + continue + group_id = group.get("group_id") + if not isinstance(group_id, str) or not group_id.strip() or group_id in seen_groups: + errors.append("Agent near-duplicate group ids must be unique nonblank strings") + seen_groups.add(group_id) + if group.get("match_type") not in {"exact_duplicate", "same_series"}: + errors.append(f"Agent near-duplicate group {group_id} match_type is invalid") + group_actor = group.get("actor") + if not isinstance(group_actor, str) or not group_actor.strip() or group_actor.strip().casefold() in {"human", "user"}: + errors.append(f"Agent near-duplicate group {group_id} actor must name the real Agent") + if not _valid_timestamp(group.get("timestamp")): + errors.append(f"Agent near-duplicate group {group_id} timestamp must be timezone-aware") + if not isinstance(group.get("rationale"), str) or not group["rationale"].strip(): + errors.append(f"Agent near-duplicate group {group_id} rationale is required") + members = group.get("members") + member_keys = [_ref_key(member) for member in members] if isinstance(members, list) else [] + if (not isinstance(members, list) or len(members) < 2 or None in member_keys + or len(member_keys) != len(set(member_keys)) or any(key not in valid_refs for key in member_keys)): + errors.append(f"Agent near-duplicate group {group_id} members are invalid") + continue + if any(key in grouped_members for key in member_keys): + errors.append("Agent candidate cannot belong to more than one near-duplicate group") + grouped_members.update(member_keys) + return errors + + +def _provider_key(candidate): + provider_id = candidate.get("provider_id") + return (provider_id if isinstance(provider_id, int) and not isinstance(provider_id, bool) else 2 ** 63, str(candidate.get("candidate_id"))) + + +def _ranking_key(item): + score = item.get("scores", {}) + return ( + -(score.get("semantic_fit", -1) + score.get("context_fit", -1)), + -min(score.get("semantic_fit", -1), score.get("context_fit", -1)), + -(score.get("composition_fit", -1) + score.get("style_fit", -1)), + item["warning_count"], + *_provider_key(item), + ) + + +def rank_candidates(analysis, scores): + analysis_errors = validate_analysis_document(analysis) + if analysis_errors: raise ValueError("invalid candidate analysis: " + "; ".join(analysis_errors)) + errors = _score_errors(analysis, scores) + if errors: raise ValueError("invalid Agent scoring: " + "; ".join(errors)) + score_shots = {item["shot_id"]: item for item in scores["shots"]} + ranked_shots, ref_items = [], {} + for analysis_shot in analysis["shots"]: + shot_id = analysis_shot["shot_id"] + inputs = {item["candidate_id"]: item for item in score_shots[shot_id]["candidates"]} + ranked = [] + for candidate in analysis_shot["candidates"]: + candidate_id = candidate["candidate_id"] + entry = inputs.get(candidate_id) + if entry is None: + item = { + "candidate_id": candidate_id, "provider_id": candidate.get("provider_id"), + "warnings": copy.deepcopy(candidate.get("warnings", [])), "warning_count": len(candidate.get("warnings", [])), + "eligible": False, "ineligibility_reasons": copy.deepcopy(candidate.get("rejection_reasons", ["deterministic hard check failed"])), + "suppressed_near_duplicate": False, "suppressed_global_duplicate": False, "similar_footage": [], + } + else: + reasons = [] + if entry["semantic_fit"] == 0: reasons.append("semantic_fit is zero") + if entry["context_fit"] == 0: reasons.append("context_fit is zero") + if entry["avoid_violation"]: reasons.append("explicit avoid rule is violated") + if not entry["primary_subject_visible"]: reasons.append("primary subject is not identifiable in target framing") + item = { + "candidate_id": candidate_id, + "provider_id": candidate.get("provider_id"), + "scores": {field: entry[field] for field in (*SCORE_FIELDS, "text_logo_risk")}, + "rationale": entry["rationale"].strip(), + "avoid_violation": entry["avoid_violation"], + "primary_subject_visible": entry["primary_subject_visible"], + "near_duplicate_group": entry.get("near_duplicate_group"), + "warnings": copy.deepcopy(candidate.get("warnings", [])), + "warning_count": len(candidate.get("warnings", [])), + "eligible": not reasons, + "ineligibility_reasons": reasons, + "suppressed_near_duplicate": False, + "suppressed_global_duplicate": False, + "similar_footage": [], + } + ranked.append(item) + ref_items[(shot_id, candidate_id)] = item + eligible = sorted((item for item in ranked if item["eligible"]), key=_ranking_key) + for index, item in enumerate(eligible, 1): + item["base_rank"] = index + ranked_shots.append({ + "shot_id": shot_id, + "base_order": [item["candidate_id"] for item in eligible], + "base_top3": [item["candidate_id"] for item in eligible[:3]], + "duplicate_groups": [], + "candidates": eligible + sorted((item for item in ranked if not item["eligible"]), key=lambda value: str(value["candidate_id"])), + }) + + source_groups = [] + project_evidence = analysis.get("project_duplicate_evidence") + if not isinstance(project_evidence, dict): + project_evidence = {"exact_groups": [], "strict_perceptual_groups": [], "possible_series": []} + for group in project_evidence.get("exact_groups", []): + source_groups.append({"kind": "exact_duplicate", "group_id": group["group_id"], "members": copy.deepcopy(group["members"])}) + for group in scores.get("near_duplicate_groups", []): + source_groups.append({"kind": "agent_confirmed", "group_id": group["group_id"], "members": copy.deepcopy(group["members"])}) + for ranked_shot in ranked_shots: + legacy = {} + shot_id = ranked_shot["shot_id"] + for item in ranked_shot["candidates"]: + if item["eligible"] and item.get("near_duplicate_group"): + legacy.setdefault(item["near_duplicate_group"], []).append(item) + for group_id, members in legacy.items(): + if len(members) < 2: + continue + source_groups.append({ + "kind": "legacy_agent_group", + "group_id": f"{shot_id}:{group_id}", + "members": [_candidate_ref(shot_id, item["candidate_id"]) for item in members], + }) + ranked_shot["duplicate_groups"].append({ + "group_id": group_id, + "kept_candidate_id": members[0]["candidate_id"], + "suppressed_candidate_ids": [item["candidate_id"] for item in members[1:]], + }) + + eligible_refs = {key for key, item in ref_items.items() if item["eligible"]} + adjacency = {key: set() for key in eligible_refs} + normalized_sources = [] + for group in source_groups: + members = [key for key in (_ref_key(member) for member in group["members"]) if key in eligible_refs] + if len(members) < 2: + continue + normalized_sources.append({**group, "member_keys": members}) + for left in members: + adjacency[left].update(value for value in members if value != left) + eligible_entries = [{"key": key} for key in ref_items if key in eligible_refs] + global_allocations = [] + component_by_ref = {} + for index, members in enumerate(_connected_groups(eligible_entries, adjacency), 1): + member_set = set(members) + sources = [ + {"kind": group["kind"], "group_id": group["group_id"]} + for group in normalized_sources if len(member_set & set(group["member_keys"])) > 1 + ] + kept = min(members, key=lambda key: _ranking_key(ref_items[key])) + suppressed = [key for key in members if key != kept] + source_labels = ", ".join(f"{item['kind']}:{item['group_id']}" for item in sources) + kept_reason = f"Kept globally for {source_labels} because it has the strongest semantic/context ranking evidence." + ref_items[kept]["global_allocation"] = {"status": "kept", "reason": kept_reason} + kept_label = f"{kept[0]} / candidate {kept[1]}" + for key in suppressed: + ref_items[key]["suppressed_near_duplicate"] = True + ref_items[key]["suppressed_global_duplicate"] = True + ref_items[key]["global_allocation"] = { + "status": "suppressed", + "reason": f"Suppressed for {source_labels}; {kept_label} is the globally stronger placement.", + } + allocation_id = f"global-allocation-{index:03d}" + for key in members: + component_by_ref[key] = allocation_id + global_allocations.append({ + "allocation_id": allocation_id, + "source_groups": sources, + "members": [_candidate_ref(*key) for key in members], + "kept": _candidate_ref(*kept), + "suppressed": [_candidate_ref(*key) for key in suppressed], + "reason": kept_reason, + }) + + selected_refs = set() + for ranked_shot in ranked_shots: + shot_id = ranked_shot["shot_id"] + eligible = [item for item in ranked_shot["candidates"] if item["eligible"]] + shortlist = [item for item in eligible if not item["suppressed_global_duplicate"]][:3] + suppressed_base = [candidate_id for candidate_id in ranked_shot["base_top3"] if ref_items[(shot_id, candidate_id)]["suppressed_global_duplicate"]] + refills = [] + for index, item in enumerate(shortlist, 1): + item["rank"] = index + selected_refs.add((shot_id, item["candidate_id"])) + if item["candidate_id"] not in ranked_shot["base_top3"]: + refill = { + "candidate_id": item["candidate_id"], + "replaced_candidate_ids": copy.deepcopy(suppressed_base), + "reason": "Refilled with the next independent eligible candidate after global duplicate suppression.", + } + refills.append(refill) + item["shortlist_reason"] = refill["reason"] + else: + item["shortlist_reason"] = "Retained from the shot base ranking after global allocation." + ranked_shot.update({ + "outcome": "ranked" if shortlist else "no_eligible_candidates", + "top3": [item["candidate_id"] for item in shortlist], + "refills": refills, + }) + + for field in ("strict_perceptual_groups", "possible_series"): + for group in project_evidence.get(field, []): + members = [key for key in (_ref_key(member) for member in group.get("members", [])) if key in selected_refs] + for left_index, left in enumerate(members): + for right in members[left_index + 1:]: + if left[0] == right[0] or component_by_ref.get(left) == component_by_ref.get(right) and component_by_ref.get(left) is not None: + continue + ref_items[left]["similar_footage"].append(_candidate_ref(*right)) + ref_items[right]["similar_footage"].append(_candidate_ref(*left)) + shot_order = {shot["shot_id"]: index for index, shot in enumerate(ranked_shots)} + for item in ref_items.values(): + item["similar_footage"] = sorted( + {(_ref_key(value)): value for value in item["similar_footage"]}.values(), + key=lambda value: (shot_order.get(value["shot_id"], len(shot_order)), value["candidate_id"]), + ) + return { + "schema_version": 1, + "analysis_sha256": canonical_sha256(analysis), + "decision": {key: copy.deepcopy(scores[key]) for key in ("mode", "actor", "timestamp", "overall_rationale")}, + "project_duplicate_evidence": copy.deepcopy(project_evidence), + "agent_confirmed_near_duplicates": copy.deepcopy(scores.get("near_duplicate_groups", [])), + "global_allocations": global_allocations, + "shots": ranked_shots, + } + + +def _work_relative(path): + path = Path(path).resolve() + for parent in path.parents: + if parent.name == "work": + return path.relative_to(parent).as_posix(), parent.parent + raise ValueError("ranking path must be inside the project work directory") + + +def bind_ranking(plan, ranking, ranking_path, acquired_by_shot): + if not isinstance(plan, dict) or not isinstance(plan.get("shots"), list): raise ValueError("plan shots are required") + if not isinstance(ranking, dict) or ranking.get("schema_version") != 1 or not broll_plan._is_sha256(ranking.get("analysis_sha256")): + raise ValueError("candidate ranking is invalid") + ranking_path = Path(ranking_path).resolve() + if not ranking_path.is_file() or canonical_sha256(projectlib.load_json(ranking_path)) != canonical_sha256(ranking): + raise ValueError("candidate ranking file does not match ranking") + relative, _ = _work_relative(ranking_path) + ranked = {item.get("shot_id"): item for item in ranking.get("shots", []) if isinstance(item, dict)} + result = copy.deepcopy(plan) + shortlists = [] + for shot in result["shots"]: + shot_id = shot.get("id") + ranking_shot = ranked.get(shot_id) + if not isinstance(ranking_shot, dict): raise ValueError(f"candidate ranking is missing shot {shot_id}") + top3 = ranking_shot.get("top3") + acquired = acquired_by_shot.get(shot_id) if isinstance(acquired_by_shot, dict) else None + if not isinstance(top3, list) or len(top3) > 3 or not isinstance(acquired, list) or [item.get("id") for item in acquired if isinstance(item, dict)] != top3: + raise ValueError(f"{shot_id} acquired candidates do not match ranked Top 3") + details = {item["candidate_id"]: item for item in ranking_shot.get("candidates", []) if isinstance(item, dict) and isinstance(item.get("candidate_id"), str)} + duplicate_notes = {item["kept_candidate_id"]: [f"Kept over {', '.join(item['suppressed_candidate_ids'])} in near-duplicate group {item['group_id']}." ] for item in ranking_shot.get("duplicate_groups", [])} + bound = [] + for index, candidate in enumerate(acquired, 1): + item = copy.deepcopy(candidate) + detail = details.get(item["id"]) + if not isinstance(detail, dict) or not detail.get("eligible"): raise ValueError(f"{shot_id} ranked candidate details are missing") + item["ranking"] = { + "rank": index, + "scores": copy.deepcopy(detail["scores"]), + "warnings": copy.deepcopy(detail["warnings"]), + "rationale": detail["rationale"], + "duplicate_notes": duplicate_notes.get(item["id"], []), + "similar_footage": copy.deepcopy(detail.get("similar_footage", [])), + } + bound.append(item) + if shot.get("status") not in {"planned", "candidates_ready", "skipped"}: raise ValueError(f"{shot_id} lifecycle is past candidate analysis") + shot["candidates"] = bound + shot["selected"] = None + shot["status"] = "candidates_ready" if bound else "skipped" + shortlists.append({"shot_id": shot_id, "candidate_ids": copy.deepcopy(top3)}) + result["candidate_ranking"] = { + "path": relative, + "sha256": _sha256(ranking_path), + "analysis_sha256": ranking["analysis_sha256"], + "shortlists": shortlists, + } + return result + + +def acquire_shortlist(plan, analysis, ranking, ranking_path, project_root, *, downloader=pexels.download_candidate): + root = Path(project_root).resolve() + if ranking.get("analysis_sha256") != canonical_sha256(analysis): raise ValueError("candidate ranking analysis SHA-256 does not match") + analysis_shots = {item.get("shot_id"): item for item in analysis.get("shots", []) if isinstance(item, dict)} + acquired = {} + for ranking_shot in ranking.get("shots", []): + shot_id = ranking_shot["shot_id"] + analysis_shot = analysis_shots.get(shot_id) + if not isinstance(analysis_shot, dict): raise ValueError(f"candidate analysis is missing shot {shot_id}") + candidates = {item.get("candidate_id"): item for item in analysis_shot.get("candidates", []) if isinstance(item, dict)} + acquired[shot_id] = [] + for candidate_id in ranking_shot.get("top3", []): + analyzed = candidates.get(candidate_id) + if not isinstance(analyzed, dict) or analyzed.get("analysis_status") != "analyzed": raise ValueError(f"{shot_id} shortlist candidate is not analyzable") + source = analyzed.get("source_candidate") + if not isinstance(source, dict): raise ValueError(f"{shot_id} shortlist source candidate is missing") + source_type = source.get("provenance", {}).get("source_type") + if source_type == "pexels": + delivery = pexels.variant_candidate(source, "delivery") + name = f"{_safe_name(shot_id)}-{delivery['provider_id']}-{delivery['file_id']}.mp4" + delivery["cache_path"] = f"cache/b-roll/candidates/{name}" + record = downloader(delivery, root / "work/cache/b-roll/candidates" / name, purpose="delivery") + record = copy.deepcopy(record); record["path"] = Path(record["path"]).relative_to(root).as_posix() + elif source_type in {"local", "external-generated"}: + record = copy.deepcopy(source) + path = broll_plan._candidate_path(root, record.get("cache_path")) + if path is None or not path.is_file() or record.get("sha256") != _sha256(path): raise ValueError(f"{shot_id} local shortlist candidate is stale") + else: + raise ValueError(f"{shot_id} shortlist source type is invalid") + record["id"] = candidate_id + acquired[shot_id].append(record) + return bind_ranking(plan, ranking, ranking_path, acquired) + + +def _ranking_map(ranking): + return {shot["shot_id"]: {item["candidate_id"]: item for item in shot.get("candidates", []) if isinstance(item, dict)} for shot in ranking.get("shots", []) if isinstance(shot, dict)} + + +def publish_review_packet(analysis, ranking, project_root, *, review_id=None): + root = Path(project_root).resolve() + errors = validate_analysis_document(analysis, root, verify_files=True) + if errors: raise ValueError("invalid candidate analysis: " + "; ".join(errors)) + if ranking.get("analysis_sha256") != canonical_sha256(analysis): raise ValueError("candidate ranking analysis SHA-256 does not match") + try: identifier = str(uuid.UUID(str(review_id))) if review_id is not None else str(uuid.uuid4()) + except (AttributeError, TypeError, ValueError) as error: raise ValueError("review_id must be a UUID") from error + review_root = root / "review/03-b-roll" + packet = review_root / f"candidate-analysis-{identifier}" + summary = review_root / f"candidate-analysis-{identifier}.md" + if packet.exists() or summary.exists(): raise FileExistsError(f"candidate analysis review already exists: {identifier}") + review_root.mkdir(parents=True, exist_ok=True) + rank_map = _ranking_map(ranking) + published_packet = False + try: + with tempfile.TemporaryDirectory(dir=review_root, prefix=".candidate-analysis-") as temporary: + stage = Path(temporary) + stage_packet = stage / packet.name + stage_packet.mkdir() + write_json(stage_packet / "candidate-analysis.json", analysis) + write_json(stage_packet / "candidate-ranking.json", ranking) + lines = [ + "# B-roll candidate analysis", "", + f"Analysis SHA-256: `{canonical_sha256(analysis)}`", "", + f"Ranking SHA-256: `{canonical_sha256(ranking)}`", "", + f"Project candidate index: [{packet.name}/candidate-index.html]({packet.name}/candidate-index.html)", "", + ] + evidence = ranking.get("project_duplicate_evidence", {}) + sections = ( + ("Exact duplicates", evidence.get("exact_groups", [])), + ("Strict perceptual matches", evidence.get("strict_perceptual_groups", [])), + ("Possible series", evidence.get("possible_series", [])), + ("Agent-confirmed near duplicates", ranking.get("agent_confirmed_near_duplicates", [])), + ("Global keep, suppress, and refill results", ranking.get("global_allocations", [])), + ) + for title, entries in sections: + lines.extend([f"## {title}", ""]) + if not entries: + lines.extend(["None.", ""]) + continue + for entry in entries: + lines.extend([f"- `{json.dumps(entry, ensure_ascii=False, sort_keys=True)}`", ""]) + index_entries = [] + for shot in analysis["shots"]: + shot_id = shot["shot_id"] + ranked_shot = next(item for item in ranking["shots"] if item["shot_id"] == shot_id) + lines.extend([ + f"## {shot_id}", "", + f"Transcript evidence: `{json.dumps(shot.get('transcript_evidence'), ensure_ascii=False)}`", "", + f"Editorial reason: {shot.get('editorial_reason')}", "", + f"Visual intent: {shot.get('visual_intent')}", "", + f"Queries: {' | '.join(shot.get('queries', []))}", "", + f"Original provider order: {', '.join(map(str, shot.get('original_provider_order', [])))}", "", + f"Base ranking: {', '.join(ranked_shot.get('base_order', [])) or 'no_eligible_candidates'}", "", + f"Shortlist: {', '.join(ranked_shot.get('top3', [])) or 'no_eligible_candidates'}", "", + f"Refills: `{json.dumps(ranked_shot.get('refills', []), ensure_ascii=False, sort_keys=True)}`", "", + ]) + for candidate in shot["candidates"]: + candidate_id = candidate["candidate_id"] + detail = rank_map.get(shot_id, {}).get(candidate_id, {}) + lines.extend([f"### {candidate_id}", "", f"Machine status: {candidate.get('analysis_status')}", "", f"Warnings: {', '.join(candidate.get('warnings', [])) or 'none'}", ""]) + if detail.get("scores"): + lines.extend([f"Agent scores: `{json.dumps(detail['scores'], ensure_ascii=False)}`", "", f"Rationale: {detail.get('rationale')}", ""]) + if detail.get("global_allocation"): + lines.extend([f"Global allocation: `{json.dumps(detail['global_allocation'], ensure_ascii=False, sort_keys=True)}`", ""]) + if detail.get("shortlist_reason"): + lines.extend([f"Shortlist reason: {detail['shortlist_reason']}", ""]) + candidate_assets = stage_packet / f"{_safe_name(shot_id)}-{_safe_name(candidate_id)}" + if candidate.get("samples"): + candidate_assets.mkdir() + for index, sample in enumerate(candidate.get("samples", []), 1): + for field, hash_field, label in (("frame_path", "sha256", "frame"), ("crop_path", "crop_sha256", "crop")): + source = _project_path(root, sample.get(field), prefix=ANALYSIS_FRAMES) + if source is None or not source.is_file(): raise ValueError("sample asset is missing during publication") + destination = candidate_assets / f"{index:02d}-{label}.png" + shutil.copyfile(source, destination) + if _sha256(destination) != sample.get(hash_field): raise ValueError("sample asset SHA-256 changed during publication") + for field in ("contact_sheet_path",): + source_value = candidate.get(field) + source = _project_path(root, source_value, prefix=ANALYSIS_FRAMES) if source_value else None + if source is not None and source.is_file(): + candidate_assets.mkdir(exist_ok=True) + destination = candidate_assets / "contact-sheet.png" + shutil.copyfile(source, destination) + if _sha256(destination) != candidate.get("contact_sheet_sha256"): raise ValueError("contact sheet SHA-256 changed during publication") + lines.extend([f"![{candidate_id} sampled frames]({packet.name}/{candidate_assets.name}/{destination.name})", ""]) + source_candidate = candidate.get("source_candidate") if isinstance(candidate.get("source_candidate"), dict) else {} + provenance = source_candidate.get("provenance") if isinstance(source_candidate.get("provenance"), dict) else {} + index_entries.append({ + "shot_id": shot_id, + "candidate_id": candidate_id, + "image": f"{candidate_assets.name}/{destination.name}", + "provider_id": candidate.get("provider_id"), + "creator": provenance.get("creator"), + "source_url": provenance.get("source_url"), + "rank": detail.get("rank"), + }) + index_cards = [] + for entry in index_entries: + source_url = entry.get("source_url") + source_link = f'source' if isinstance(source_url, str) and source_url.startswith(("https://", "http://")) else "source unavailable" + index_cards.append( + '
{}{}{}Rank: {}Provider: {} / Creator: {}{}
'.format( + html.escape(f"{entry['shot_id']} {entry['candidate_id']} sampled frames", quote=True), + html.escape(entry["image"], quote=True), + html.escape(entry["candidate_id"]), + html.escape(entry["shot_id"]), + html.escape(str(entry.get("rank") or "not shortlisted")), + html.escape(str(entry.get("provider_id") or "local")), + html.escape(str(entry.get("creator") or "unknown")), + source_link, + ) + ) + index_document = 'B-roll project candidate index

B-roll project candidate index

' + "".join(index_cards) + "
" + (stage_packet / "candidate-index.html").write_text(index_document, encoding="utf-8") + staged_summary = stage / summary.name + staged_summary.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + os.replace(stage_packet, packet); published_packet = True + os.replace(staged_summary, summary) + except Exception: + if published_packet: shutil.rmtree(packet, ignore_errors=True) + summary.unlink(missing_ok=True) + raise + return {"review_id": identifier, "packet": packet, "summary": summary, "packet_sha256": canonical_sha256({path.relative_to(packet).as_posix(): _sha256(path) for path in sorted(packet.rglob("*")) if path.is_file()}), "summary_sha256": _sha256(summary)} + + +def _canonical_output(root, value, relative): + target = Path(value).resolve() + expected = (Path(root).resolve() / relative).resolve() + if target != expected: raise ValueError(f"output must be {expected}") + return target + + +def _json_ready(value): + if isinstance(value, Path): return value.as_posix() + raise TypeError(f"not JSON serializable: {type(value).__name__}") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + search = commands.add_parser("search") + search.add_argument("project_root"); search.add_argument("plan"); search.add_argument("output") + search.add_argument("--orientation", required=True, choices=("landscape", "portrait", "square")); search.add_argument("--per-page", type=int, default=8); search.add_argument("--local-only", action="store_true") + analyze = commands.add_parser("analyze") + analyze.add_argument("project_root"); analyze.add_argument("plan"); analyze.add_argument("search"); analyze.add_argument("timeline"); analyze.add_argument("output") + reclassify = commands.add_parser("reclassify") + reclassify.add_argument("project_root"); reclassify.add_argument("plan"); reclassify.add_argument("analysis"); reclassify.add_argument("timeline"); reclassify.add_argument("output") + rank = commands.add_parser("rank") + rank.add_argument("project_root"); rank.add_argument("analysis"); rank.add_argument("scores"); rank.add_argument("output") + acquire = commands.add_parser("acquire") + acquire.add_argument("project_root"); acquire.add_argument("plan"); acquire.add_argument("analysis"); acquire.add_argument("ranking") + publish = commands.add_parser("publish") + publish.add_argument("project_root"); publish.add_argument("analysis"); publish.add_argument("ranking"); publish.add_argument("--review-id") + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + root = Path(args.project_root).resolve() + if args.command == "search": + output = _canonical_output(root, args.output, "work/b-roll/candidate-search.json") + value = search_plan(projectlib.load_json(args.plan), orientation=args.orientation, per_page=args.per_page, include_pexels=not args.local_only) + write_json(output, value); result = {"output": output, "sha256": _sha256(output), "search_sha256": canonical_sha256(value)} + elif args.command == "analyze": + output = _canonical_output(root, args.output, "work/b-roll/candidate-analysis.json") + value = analyze_search(projectlib.load_json(args.plan), projectlib.load_json(args.search), projectlib.load_json(args.timeline), root) + write_json(output, value); result = {"output": output, "sha256": _sha256(output), "analysis_sha256": canonical_sha256(value)} + elif args.command == "reclassify": + output = _canonical_output(root, args.output, "work/b-roll/candidate-analysis.json") + value = reclassify_durations( + projectlib.load_json(args.analysis), projectlib.load_json(args.plan), + projectlib.load_json(args.timeline), + ) + write_json(output, value); result = {"output": output, "sha256": _sha256(output), "analysis_sha256": canonical_sha256(value)} + elif args.command == "rank": + output = _canonical_output(root, args.output, "work/b-roll/candidate-ranking.json") + value = rank_candidates(projectlib.load_json(args.analysis), projectlib.load_json(args.scores)) + write_json(output, value); result = {"output": output, "sha256": _sha256(output), "ranking_sha256": canonical_sha256(value)} + elif args.command == "acquire": + plan_path = (root / "work/b-roll/broll-plan.json").resolve() + if Path(args.plan).resolve() != plan_path: raise ValueError(f"plan must be {plan_path}") + value = acquire_shortlist(projectlib.load_json(plan_path), projectlib.load_json(args.analysis), projectlib.load_json(args.ranking), args.ranking, root) + write_json(plan_path, value); result = {"output": plan_path, "sha256": _sha256(plan_path)} + else: + result = publish_review_packet(projectlib.load_json(args.analysis), projectlib.load_json(args.ranking), root, review_id=args.review_id) + print(json.dumps(result, default=_json_ready, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills/video-add-b-roll/scripts/check_broll.py b/skills/video-add-b-roll/scripts/check_broll.py index de8702c3e..5a0ac6681 100644 --- a/skills/video-add-b-roll/scripts/check_broll.py +++ b/skills/video-add-b-roll/scripts/check_broll.py @@ -83,18 +83,22 @@ def _selected_shots(plan, timeline, root, grade_hashes): if status not in ("normalized", "verified"): raise ValueError("verify_plan requires normalized, verified, or skipped shots") choice = shot.get("selected", {}) - candidate = next((item for item in shot.get("candidates", []) - if item.get("id") == choice.get("candidate_id")), None) - if candidate is None: + candidate_ids = broll_plan.selected_candidate_ids(choice) + candidates = [ + next((item for item in shot.get("candidates", []) + if isinstance(item, dict) and item.get("id") == candidate_id), None) + for candidate_id in candidate_ids + ] + if not candidate_ids or any(candidate is None for candidate in candidates): raise ValueError("selected candidate does not belong to shot") output = _inside(normalized_root / f"broll-{index:03d}.mp4", normalized_root, "normalized path") try: normalize_broll._validate_normalized( - shot.get("normalized"), candidate, shot, timeline, output, root, grade_hashes + shot.get("normalized"), candidates, shot, timeline, output, root, grade_hashes ) except subprocess.CalledProcessError as exc: raise ValueError("normalized media decode failed") from exc - selected.append((index, shot, candidate, output)) + selected.append((index, shot, candidates, output)) return selected @@ -419,7 +423,7 @@ def _summary(plan, selected, records, artifacts, root, stage, destination, path) ] if not selected: lines.extend(["No B-roll shots were selected; all approved decisions are skips.", ""]) - for (_, shot, candidate, _), (_, _, times, stills) in zip(selected, records): + for (_, shot, candidates, _), (_, _, times, stills) in zip(selected, records): normalized = shot["normalized"] evidence = ", ".join(str(word.get("word", "")).strip() for word in shot["transcript_evidence"]["words"]) lines.extend([ @@ -427,13 +431,41 @@ def _summary(plan, selected, records, artifacts, root, stage, destination, path) f"- Program range: `{json.dumps(shot['program_range'], sort_keys=True)}`", f"- Source ranges: `{json.dumps(shot['source_ranges'], sort_keys=True)}`", f"- Transcript evidence: {evidence}", - f"- Selected source: `{candidate.get('cache_path')}` (`{candidate.get('sha256')}`)", - f"- Source provenance: `{json.dumps(candidate.get('provenance', {}), sort_keys=True)}`", + f"- Selection format: `{normalized.get('selection_format', 'legacy')}`", + f"- Program duration: `{normalized.get('program_duration_s', 'legacy record')}s`", f"- Normalized SHA-256: `{normalized['sha256']}`", + f"- Concat SHA-256: `{normalized.get('concat_sha256', normalized['sha256'])}`", f"- Normalized probe: `{json.dumps(normalized.get('probe', {}), sort_keys=True)}`", f"- Grade plan SHA-256: `{normalized.get('grade_plan_sha256', 'not active')}`", f"- Selected LUT SHA-256: `{normalized.get('selected_lut_sha256', 'not active')}`", ]) + component_records = normalized.get("segments") + if isinstance(component_records, list): + candidate_map = {candidate.get("id"): candidate for candidate in candidates} + for index, component in enumerate(component_records, 1): + candidate = candidate_map.get(component.get("candidate_id"), {}) + lines.extend([ + f"- Segment {index}: `{component.get('candidate_id')}`", + f" - Selected source: `{candidate.get('cache_path')}` (`{candidate.get('sha256')}`)", + f" - Source provenance: `{json.dumps(candidate.get('provenance', {}), sort_keys=True)}`", + f" - Segment: `{json.dumps(component.get('segment', {}), sort_keys=True)}`", + f" - Source duration: `{component.get('source_duration_s')}s`", + f" - Effective duration: `{component.get('effective_duration_s')}s`", + f" - Program duration: `{component.get('program_duration_s')}s`", + f" - Playback rate: `{component.get('playback_rate')}x`", + f" - Normalized segment SHA-256: `{component.get('normalized_sha256')}`", + ]) + else: + candidate = candidates[0] + lines.extend([ + f"- Selected source: `{candidate.get('cache_path')}` (`{candidate.get('sha256')}`)", + f"- Source provenance: `{json.dumps(candidate.get('provenance', {}), sort_keys=True)}`", + f"- Segment: `{json.dumps(normalized.get('segment', {}), sort_keys=True)}`", + f"- Source duration: `{normalized.get('source_duration_s', 'legacy record')}s`", + f"- Effective duration: `{normalized.get('effective_duration_s', 'legacy record')}s`", + ]) + if "legacy_requested_source_range" in normalized: + lines.append(f"- Legacy requested source range: `{json.dumps(normalized['legacy_requested_source_range'], sort_keys=True)}`") for label in ("first", "middle", "last"): published = destination / stills[label].relative_to(stage) lines.append(f"- {label.title()} (+{times[label]:.3f}s): `{_relative(published, root)}` (`{broll_plan.sha256_file(stills[label])}`)") diff --git a/skills/video-add-b-roll/scripts/normalize_broll.py b/skills/video-add-b-roll/scripts/normalize_broll.py index e516b40f0..d23e4f723 100644 --- a/skills/video-add-b-roll/scripts/normalize_broll.py +++ b/skills/video-add-b-roll/scripts/normalize_broll.py @@ -5,6 +5,7 @@ import math import os import re +import shutil import subprocess import sys from fractions import Fraction @@ -173,20 +174,41 @@ def _check_probe(probe, width, height, num, den, duration): def _selection(candidate, shot, duration, num, den): selected = shot.get("selected") - if not isinstance(selected, dict) or selected.get("candidate_id") != candidate.get("id"): + candidates = candidate if isinstance(candidate, list) else [candidate] + if (not isinstance(selected, dict) or not candidates + or any(not isinstance(item, dict) for item in candidates)): raise ValueError("shot selection does not match candidate") + selected_ids = broll_plan.selected_candidate_ids(selected) + candidate_ids = [item.get("id") for item in candidates] + if not selected_ids or any(candidate_id not in candidate_ids for candidate_id in selected_ids): + raise ValueError("shot selection does not match candidate") + if "segments" in selected: + if any(next(item for item in candidates if item.get("id") == candidate_id).get("media_type") != "video" + for candidate_id in selected_ids): + raise ValueError("canonical segments require video candidates") + try: + details = broll_plan.selection_details( + shot, candidates, {"fps": {"num": num, "den": den}}, + ) + except ValueError as exc: + raise ValueError(str(exc)) from exc + if abs(details["program_duration_s"] - duration) > den / num + 1e-6: + raise ValueError("selection program duration does not match shot") + return "video", details + if len(candidates) != 1 or selected_ids != candidate_ids: + raise ValueError("legacy selection requires exactly one candidate") + candidate = candidates[0] media_type = candidate.get("media_type") if media_type == "video": - trim = selected.get("source_trim") - if not isinstance(trim, dict): - raise ValueError("video selection requires explicit source_trim") - start = _number(trim.get("start_s"), "source_trim start_s") - end = _number(trim.get("end_s"), "source_trim end_s") - probe = candidate.get("probe") - source_duration = _number(probe.get("duration_s") if isinstance(probe, dict) else None, "candidate probe.duration_s") - if start < 0 or end <= start or end > source_duration: - raise ValueError("video selection requires a valid source_trim") - return media_type, (start, end) + try: + details = broll_plan.selection_details( + shot, candidate, {"fps": {"num": num, "den": den}}, + ) + except ValueError as exc: + raise ValueError(str(exc)) from exc + if abs(details["program_duration_s"] - duration) > den / num + 1e-6: + raise ValueError("selection program duration does not match shot") + return media_type, details if media_type != "image": raise ValueError("candidate media_type must be video or image") motion = selected.get("ken_burns") @@ -218,9 +240,16 @@ def normalize_shot(candidate, shot, timeline, destination, *, lut=None): f"crop={width}:{height}", "setsar=1", ] if media_type == "video": - start, end = option - inputs = ["-ss", f"{start:.9f}", "-t", f"{end - start:.9f}", "-i", str(source)] - filters = common + [f"fps={num}/{den}", f"trim=duration={duration:.9f}", "setpts=PTS-STARTPTS"] + segment = option["segments"][0] + start = segment["source_range"]["start_s"] + end = segment["source_range"]["end_s"] + requested = option.get("legacy_requested_source_range", segment["source_range"]) + input_start, input_end = requested["start_s"], requested["end_s"] + inputs = ["-ss", f"{input_start:.9f}", "-t", f"{input_end - input_start:.9f}", "-i", str(source)] + rate = float(segment["playback_rate"]) + filters = common + [f"setpts=(PTS-STARTPTS)/{rate:g}", f"fps={num}/{den}"] + if option["format"] == "legacy": + filters.append(f"trim=duration={duration:.9f}") else: zoom, x, y = option inputs = ["-loop", "1", "-i", str(source)] @@ -229,8 +258,10 @@ def normalize_shot(candidate, shot, timeline, destination, *, lut=None): filters.append(f"lut3d={lut_path.name}") command = [ "ffmpeg", "-y", "-loglevel", "error", *inputs, + "-map", "0:v:0", "-vf", ",".join(filters), "-t", f"{duration:.9f}", - "-an", "-sn", "-dn", "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-an", "-sn", "-dn", "-map_metadata", "-1", "-write_tmcd", "0", + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(part), ] subprocess.run(command, cwd=lut_path.parent if lut_path else None, check=True, capture_output=True) @@ -239,7 +270,7 @@ def normalize_shot(candidate, shot, timeline, destination, *, lut=None): subprocess.run(["ffmpeg", "-v", "error", "-i", str(part), "-map", "0:v:0", "-f", "null", "-"], check=True, capture_output=True) digest = broll_plan.sha256_file(part) os.replace(part, target) - return { + record = { "path": target, "source_path": candidate.get("cache_path"), "source_sha256": source_digest, @@ -247,6 +278,140 @@ def normalize_shot(candidate, shot, timeline, destination, *, lut=None): "probe": probe, **grade_hashes, } + if media_type == "video": + record.update({ + "selection_format": option["format"], + "segment": copy.deepcopy(option["segments"][0]), + "source_duration_s": option["source_duration_s"], + "effective_duration_s": option["effective_duration_s"], + "program_duration_s": option["program_duration_s"], + }) + if "legacy_requested_source_range" in option: + record["legacy_requested_source_range"] = copy.deepcopy( + option["legacy_requested_source_range"] + ) + return record + except BaseException: + part.unlink(missing_ok=True) + raise + + +def _segment_output(target, index): + return target.with_name(f"{target.stem}-segment-{index:02d}.mp4") + + +def _segment_sidecar(output): + return output.with_suffix(".json") + + +def _segment_shot(shot, segment, candidate): + return { + "id": shot.get("id"), + "status": "selected", + "program_range": copy.deepcopy(segment["program_range"]), + "candidates": [candidate], + "selected": {"segments": [copy.deepcopy(segment)]}, + } + + +def _load_reusable_segment(sidecar, candidate, shot, timeline, output, root, grade_hashes): + if not sidecar.is_file(): + return None + try: + record = projectlib.load_json(sidecar) + _validate_normalized(record, candidate, shot, timeline, output, root, grade_hashes) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError, subprocess.CalledProcessError): + return None + return record + + +def normalize_selection(candidates, shot, timeline, destination, *, lut=None): + """Normalize 1-3 canonical segments, then atomically publish their hard-cut concat.""" + target, root = _destination(destination) + part = target.with_suffix(".part.mp4") + target.parent.mkdir(parents=True, exist_ok=True) + part.unlink(missing_ok=True) + width, height, num, den = _timeline_spec(timeline) + duration = _shot_duration(shot, timeline) + media_type, details = _selection(candidates, shot, duration, num, den) + if media_type != "video" or details["format"] != "canonical": + if len(candidates) != 1: + raise ValueError("non-canonical selection requires exactly one candidate") + return normalize_shot(candidates[0], shot, timeline, target, lut=lut) + candidate_map = {candidate.get("id"): candidate for candidate in candidates} + lut_path, grade_hashes = _grade(lut, root) + segment_records = [] + try: + for index, (segment, timing) in enumerate(zip(details["segments"], details["segment_details"]), 1): + candidate = candidate_map[segment["candidate_id"]] + output = _segment_output(target, index) + sidecar = _segment_sidecar(output) + segment_shot = _segment_shot(shot, segment, candidate) + record = _load_reusable_segment( + sidecar, candidate, segment_shot, timeline, output, root, grade_hashes, + ) + if record is None: + record = normalize_shot(candidate, segment_shot, timeline, output, lut=lut_path) + record["path"] = output.relative_to(root / "work").as_posix() + sidecar_part = sidecar.with_suffix(".part.json") + try: + projectlib.write_json(sidecar_part, record) + os.replace(sidecar_part, sidecar) + finally: + sidecar_part.unlink(missing_ok=True) + segment_records.append({ + "candidate_id": candidate["id"], + "segment": copy.deepcopy(segment), + "source_path": record["source_path"], + "source_sha256": record["source_sha256"], + "normalized_path": record["path"], + "normalized_sha256": record["sha256"], + "probe": copy.deepcopy(record["probe"]), + "source_duration_s": timing["source_duration_s"], + "effective_duration_s": timing["effective_duration_s"], + "program_duration_s": timing["program_duration_s"], + "playback_rate": timing["playback_rate"], + }) + segment_paths = [root / "work" / record["normalized_path"] for record in segment_records] + if len(segment_paths) == 1: + shutil.copyfile(segment_paths[0], part) + else: + inputs = [value for path in segment_paths for value in ("-i", str(path))] + labels = [] + filters = [] + for index in range(len(segment_paths)): + filters.append(f"[{index}:v]setpts=PTS-STARTPTS[v{index}]") + labels.append(f"[v{index}]") + filters.append(f"{''.join(labels)}concat=n={len(segment_paths)}:v=1:a=0[outv]") + command = [ + "ffmpeg", "-y", "-loglevel", "error", *inputs, + "-filter_complex", ";".join(filters), "-map", "[outv]", + "-t", f"{duration:.9f}", "-r", f"{num}/{den}", + "-an", "-sn", "-dn", "-map_metadata", "-1", "-write_tmcd", "0", + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", + str(part), + ] + subprocess.run(command, check=True, capture_output=True) + probe = _probe(part) + _check_probe(probe, width, height, num, den, duration) + subprocess.run( + ["ffmpeg", "-v", "error", "-i", str(part), "-map", "0:v:0", "-f", "null", "-"], + check=True, capture_output=True, + ) + digest = broll_plan.sha256_file(part) + os.replace(part, target) + return { + "path": target, + "selection_format": "canonical", + "segments": segment_records, + "source_paths": [record["source_path"] for record in segment_records], + "source_sha256s": [record["source_sha256"] for record in segment_records], + "sha256": digest, + "concat_sha256": digest, + "probe": probe, + "program_duration_s": details["program_duration_s"], + **grade_hashes, + } except BaseException: part.unlink(missing_ok=True) raise @@ -258,15 +423,95 @@ def _validate_normalized(record, candidate, shot, timeline, output, root, grade_ expected_path = output.relative_to(root / "work").as_posix() if record.get("path") != expected_path or not output.is_file(): raise ValueError("normalized output path is stale") - _, source_digest = _source(candidate, root) - if record.get("source_path") != candidate.get("cache_path") or record.get("source_sha256") != source_digest: - raise ValueError("normalized source identity is stale") if record.get("sha256") != broll_plan.sha256_file(output): raise ValueError("normalized output SHA-256 is stale") for key in ("grade_plan_sha256", "selected_lut_sha256"): if (key in record or key in grade_hashes) and record.get(key) != grade_hashes.get(key): raise ValueError("normalized grade identity is stale") width, height, num, den = _timeline_spec(timeline) + candidates = candidate if isinstance(candidate, list) else [candidate] + if any(not isinstance(item, dict) for item in candidates): + raise ValueError("normalized candidates are invalid") + component_records = record.get("segments") + if component_records is not None: + _, details = _selection( + candidates, shot, _shot_duration(shot, timeline), num, den, + ) + if (details["format"] != "canonical" or not isinstance(component_records, list) + or len(component_records) != len(details["segments"])): + raise ValueError("normalized segments are stale") + candidate_map = {item.get("id"): item for item in candidates} + expected_source_paths = [] + expected_source_hashes = [] + for index, (component, segment, timing) in enumerate(zip( + component_records, details["segments"], details["segment_details"]), 1): + if not isinstance(component, dict) or component.get("segment") != segment: + raise ValueError("normalized segment is stale") + selected_candidate = candidate_map.get(segment["candidate_id"]) + if selected_candidate is None: + raise ValueError("normalized segment candidate is stale") + _, source_digest = _source(selected_candidate, root) + expected_source_paths.append(selected_candidate.get("cache_path")) + expected_source_hashes.append(source_digest) + if (component.get("candidate_id") != selected_candidate.get("id") + or component.get("source_path") != selected_candidate.get("cache_path") + or component.get("source_sha256") != source_digest): + raise ValueError("normalized segment source identity is stale") + component_output = _segment_output(output, index) + expected_component_path = component_output.relative_to(root / "work").as_posix() + if (component.get("normalized_path") != expected_component_path + or not component_output.is_file() + or component.get("normalized_sha256") != broll_plan.sha256_file(component_output)): + raise ValueError("normalized segment output is stale") + for key in ("source_duration_s", "effective_duration_s", "program_duration_s", "playback_rate"): + actual = _number(component.get(key), f"normalized segment {key}") + if abs(actual - timing[key]) > den / num + 1e-6: + raise ValueError(f"normalized segment {key} is stale") + component_probe = _probe(component_output) + _check_probe(component_probe, width, height, num, den, timing["program_duration_s"]) + if component.get("probe") != component_probe: + raise ValueError("normalized segment probe is stale") + subprocess.run( + ["ffmpeg", "-v", "error", "-i", str(component_output), "-map", "0:v:0", "-f", "null", "-"], + check=True, capture_output=True, + ) + if record.get("source_paths") != expected_source_paths or record.get("source_sha256s") != expected_source_hashes: + raise ValueError("normalized segment source bindings are stale") + if record.get("concat_sha256") != record.get("sha256"): + raise ValueError("normalized concat SHA-256 is stale") + actual_program_duration = _number(record.get("program_duration_s"), "normalized program_duration_s") + if abs(actual_program_duration - details["program_duration_s"]) > den / num + 1e-6: + raise ValueError("normalized program_duration_s is stale") + probe = _probe(output) + _check_probe(probe, width, height, num, den, _shot_duration(shot, timeline)) + if record.get("probe") != probe: + raise ValueError("normalized probe is stale") + subprocess.run( + ["ffmpeg", "-v", "error", "-i", str(output), "-map", "0:v:0", "-f", "null", "-"], + check=True, capture_output=True, + ) + return + if len(candidates) != 1: + raise ValueError("legacy normalized record requires one candidate") + candidate = candidates[0] + _, source_digest = _source(candidate, root) + if record.get("source_path") != candidate.get("cache_path") or record.get("source_sha256") != source_digest: + raise ValueError("normalized source identity is stale") + if candidate.get("media_type") == "video": + _, details = _selection( + candidate, shot, _shot_duration(shot, timeline), num, den, + ) + if details["format"] == "canonical" or "selection_format" in record: + if record.get("selection_format") != details["format"]: + raise ValueError("normalized selection format is stale") + if record.get("segment") != details["segments"][0]: + raise ValueError("normalized segment is stale") + for key in ("source_duration_s", "effective_duration_s", "program_duration_s"): + actual = _number(record.get(key), f"normalized {key}") + if abs(actual - details[key]) > den / num + 1e-6: + raise ValueError(f"normalized {key} is stale") + if record.get("legacy_requested_source_range") != details.get("legacy_requested_source_range"): + raise ValueError("normalized legacy requested source range is stale") probe = _probe(output) _check_probe(probe, width, height, num, den, _shot_duration(shot, timeline)) if record.get("probe") != probe: @@ -319,16 +564,26 @@ def normalize_plan(plan_path, timeline_path, project_root, *, lut=None): selected, candidates = shot.get("selected"), shot.get("candidates") if not isinstance(selected, dict) or not isinstance(candidates, list): raise ValueError("selected shot is invalid") - candidate = next((item for item in candidates if isinstance(item, dict) and item.get("id") == selected.get("candidate_id")), None) - if candidate is None: + selected_ids = broll_plan.selected_candidate_ids(selected) + selected_candidates = [ + next((item for item in candidates + if isinstance(item, dict) and item.get("id") == candidate_id), None) + for candidate_id in selected_ids + ] + if not selected_ids or any(candidate is None for candidate in selected_candidates): raise ValueError("selected candidate does not belong to shot") output = root / f"work/cache/b-roll/normalized/broll-{index:03d}.mp4" if shot.get("status") == "normalized": - _validate_normalized(shot.get("normalized"), candidate, shot, timeline, output, root, grade_hashes) + _validate_normalized(shot.get("normalized"), selected_candidates, shot, timeline, output, root, grade_hashes) continue if shot.get("status") != "selected": raise ValueError("normalize_plan requires selected, normalized, or skipped shots") - record = normalize_shot(candidate, shot, timeline, output, lut=lut_path) + if "segments" in selected: + record = normalize_selection(selected_candidates, shot, timeline, output, lut=lut_path) + else: + if len(selected_candidates) != 1: + raise ValueError("legacy selection requires exactly one candidate") + record = normalize_shot(selected_candidates[0], shot, timeline, output, lut=lut_path) record["path"] = output.relative_to(root / "work").as_posix() shot["normalized"], shot["status"] = record, "normalized" plan_part = plan_path.with_suffix(".part.json") diff --git a/skills/video-add-b-roll/scripts/pexels.py b/skills/video-add-b-roll/scripts/pexels.py index 66047bc9e..73f900189 100644 --- a/skills/video-add-b-roll/scripts/pexels.py +++ b/skills/video-add-b-roll/scripts/pexels.py @@ -68,6 +68,15 @@ def _matches_orientation(width, height, orientation): return width > height if orientation == "landscape" else height > width if orientation == "portrait" else width == height +def _variant(item, url): + return { + "file_id": item["id"], + "download_url": url, + "width": item["width"], + "height": item["height"], + } + + def search_videos(query, *, orientation="landscape", per_page=10, api_key=None, opener=None): if not isinstance(query, str) or not query.strip(): raise ValueError("query is required") if orientation not in {"landscape", "portrait", "square"}: raise ValueError("invalid orientation") @@ -89,7 +98,7 @@ def search_videos(query, *, orientation="landscape", per_page=10, api_key=None, if not isinstance(duration, (int, float)) or duration <= 0 or not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0: continue try: source_url = validate_url(video.get("url"), PAGE_HOSTS) except ValueError: continue - choices = [] + choices, local_file_ids = [], set() files = video.get("video_files", []) if not isinstance(files, list): continue for item in files: @@ -97,19 +106,56 @@ def search_videos(query, *, orientation="landscape", per_page=10, api_key=None, file_id = item.get("id") if not isinstance(file_id, int) or isinstance(file_id, bool) or file_id <= 0: continue file_key = (video_id, file_id) - if file_key in seen_files: continue + if file_key in seen_files or file_id in local_file_ids: continue try: url = validate_url(item.get("link"), VIDEO_HOSTS) except ValueError: continue iw, ih = item.get("width"), item.get("height") - if isinstance(iw, int) and isinstance(ih, int) and iw > 0 and ih > 0 and _matches_orientation(iw, ih, orientation): choices.append((iw * ih, item, url)) + if isinstance(iw, int) and isinstance(ih, int) and iw > 0 and ih > 0 and _matches_orientation(iw, ih, orientation): + local_file_ids.add(file_id) + choices.append((iw * ih, file_id, item, url)) if not choices: continue - _, item, url = max(choices, key=lambda value: value[0]) - seen_videos.add(video_id); seen_files.add((video_id, item["id"])) + _, _, item, url = max(choices, key=lambda value: (value[0], value[1])) + qualifying = [choice for choice in choices if min(choice[2]["width"], choice[2]["height"]) >= 480] + analysis_choice = min(qualifying, key=lambda value: (value[0], value[1])) if qualifying else max(choices, key=lambda value: (value[0], value[1])) + _, _, analysis_item, analysis_url = analysis_choice + seen_videos.add(video_id); seen_files.update((video_id, choice[2]["id"]) for choice in choices) creator = video.get("user", {}).get("name") if isinstance(video.get("user"), dict) else None - records.append({"id": f"{video['id']}-{item['id']}", "provider_id": video["id"], "file_id": item["id"], "media_type": "video", "download_url": url, "width": item["width"], "height": item["height"], "duration_s": duration, "provenance": {"source_type": "pexels", "provider_id": video["id"], "source_url": source_url, "creator": creator or "Pexels creator", "license": "Pexels License", "license_url": LICENSE_URL, "terms_url": TERMS_URL, "retrieval_time": _now(), "download_url": url, "dimensions": {"width": item["width"], "height": item["height"]}, "duration_s": duration}}) + delivery_variant = _variant(item, url) + analysis_variant = _variant(analysis_item, analysis_url) + records.append({"id": f"{video['id']}-{item['id']}", "provider_id": video["id"], "file_id": item["id"], "media_type": "video", "download_url": url, "width": item["width"], "height": item["height"], "duration_s": duration, "analysis_variant": analysis_variant, "delivery_variant": delivery_variant, "provenance": {"source_type": "pexels", "provider_id": video["id"], "source_url": source_url, "creator": creator or "Pexels creator", "license": "Pexels License", "license_url": LICENSE_URL, "terms_url": TERMS_URL, "retrieval_time": _now(), "download_url": url, "dimensions": {"width": item["width"], "height": item["height"]}, "duration_s": duration}}) return records +def variant_candidate(candidate, role): + if role not in {"analysis", "delivery"}: + raise ValueError("variant role must be analysis or delivery") + if not isinstance(candidate, dict) or candidate.get("provenance", {}).get("source_type") != "pexels": + raise ValueError("variant candidate must be a Pexels record") + variant = candidate.get(f"{role}_variant") + if not isinstance(variant, dict): + raise ValueError(f"candidate has no {role} variant") + required = ("file_id", "download_url", "width", "height") + if any(field not in variant for field in required): + raise ValueError(f"candidate {role} variant is incomplete") + provider_id, file_id = candidate.get("provider_id"), variant.get("file_id") + width, height = variant.get("width"), variant.get("height") + if any(not isinstance(value, int) or isinstance(value, bool) or value <= 0 for value in (provider_id, file_id, width, height)): + raise ValueError(f"candidate {role} variant identifiers or dimensions are invalid") + download_url = validate_url(variant.get("download_url"), VIDEO_HOSTS) + provenance = candidate.get("provenance") + if (not isinstance(provenance, dict) or provenance.get("provider_id") != provider_id + or validate_url(provenance.get("source_url"), PAGE_HOSTS) != provenance.get("source_url") + or provenance.get("license_url") != LICENSE_URL or provenance.get("terms_url") != TERMS_URL): + raise ValueError("candidate Pexels provenance is invalid") + result = copy.deepcopy(candidate) + result.update({field: variant[field] for field in required}) + result["download_url"] = download_url + result["variant_role"] = role + result["provenance"]["download_url"] = result["download_url"] + result["provenance"]["dimensions"] = {"width": result["width"], "height": result["height"]} + return result + + def probe_media(path): path = Path(path) try: @@ -125,14 +171,17 @@ def probe_media(path): return {"duration_s": duration, "width": width, "height": height, "codec": stream.get("codec_name")} -def _cache_destination(destination): +def _cache_destination(destination, purpose="delivery"): + if purpose not in {"delivery", "analysis"}: + raise ValueError("invalid cache purpose") target = Path(destination).resolve() + expected = ("work", "cache", "b-roll", "candidates") if purpose == "delivery" else ("work", "cache", "b-roll", "candidate-analysis", "media") for parent in (target.parent, *target.parents): - if parent.parts[-4:] == ("work", "cache", "b-roll", "candidates"): + if parent.parts[-len(expected):] == expected: try: target.relative_to(parent) except ValueError: break if target.parent == parent and target.name and target.name not in {".", ".."}: return target - raise ValueError("destination must be directly beneath work/cache/b-roll/candidates") + raise ValueError(f"destination must be directly beneath {'/'.join(expected)}") def _sha256(path): @@ -148,10 +197,10 @@ def _record(candidate, target, probe, digest=None): return result -def download_candidate(candidate, destination, *, opener=None, max_bytes=250_000_000, retries=3): +def download_candidate(candidate, destination, *, opener=None, max_bytes=250_000_000, retries=3, purpose="delivery"): if not isinstance(candidate, dict) or not isinstance(candidate.get("download_url"), str): raise ValueError("candidate download_url is required") if not isinstance(max_bytes, int) or max_bytes <= 0 or not isinstance(retries, int) or retries < 1: raise ValueError("invalid download limits") - target = _cache_destination(destination); target.parent.mkdir(parents=True, exist_ok=True); part = target.with_name(target.name + ".part") + target = _cache_destination(destination, purpose=purpose); target.parent.mkdir(parents=True, exist_ok=True); part = target.with_name(target.name + ".part") url = validate_url(candidate["download_url"], VIDEO_HOSTS) expected = candidate.get("sha256") if target.exists() and isinstance(expected, str) and _sha256(target) == expected: @@ -250,11 +299,15 @@ def main(argv=None): raise SystemExit(2) parser = argparse.ArgumentParser(); commands = parser.add_subparsers(dest="command", required=True) search = commands.add_parser("search"); search.add_argument("query"); search.add_argument("--orientation", default="landscape"); search.add_argument("--per-page", type=int, default=10) - download = commands.add_parser("download"); download.add_argument("candidate_json"); download.add_argument("destination") + download = commands.add_parser("download"); download.add_argument("candidate_json"); download.add_argument("destination"); download.add_argument("--variant", choices=("analysis", "delivery")) local = commands.add_parser("import-local"); local.add_argument("source"); local.add_argument("destination"); local.add_argument("provenance_json") args = parser.parse_args(argv) if args.command == "search": value = search_videos(args.query, orientation=args.orientation, per_page=args.per_page) - elif args.command == "download": value = download_candidate(projectlib.load_json(args.candidate_json), args.destination) + elif args.command == "download": + candidate = projectlib.load_json(args.candidate_json) + if args.variant: + candidate = variant_candidate(candidate, args.variant) + value = download_candidate(candidate, args.destination, purpose="analysis" if args.variant == "analysis" else "delivery") else: value = import_local(args.source, args.destination, projectlib.load_json(args.provenance_json)) print(_json(value)) diff --git a/skills/video-add-b-roll/tests/test_broll.py b/skills/video-add-b-roll/tests/test_broll.py index e8081021e..9dc7b8f28 100644 --- a/skills/video-add-b-roll/tests/test_broll.py +++ b/skills/video-add-b-roll/tests/test_broll.py @@ -21,6 +21,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path[:0] = [str(ROOT / "scripts"), str(ROOT.parent / "video-understand" / "scripts")] import broll_plan +import candidate_analysis import projectlib import pexels import build_review_page @@ -91,6 +92,189 @@ def pexels_candidate(self): class BrollPlanTests(_BrollFixture, unittest.TestCase): + def _canonical_candidates(self, count=3): + shot = self.plan["shots"][0] + base = shot["candidates"][0] + candidates = [] + for index in range(count): + candidate = copy.deepcopy(base) + candidate_id = f"asset-{index + 1}" + path = self.root / f"work/cache/b-roll/{candidate_id}.mp4" + path.write_bytes(f"asset-{index + 1}".encode("ascii")) + candidate.update({ + "id": candidate_id, + "cache_path": path.relative_to(self.root / "work").as_posix(), + "sha256": broll_plan.sha256_file(path), + "bytes": path.stat().st_size, + "duration_s": 4.0, + "probe": {"duration_s": 4.0, "width": 1920, "height": 1080}, + "ranking": { + "rank": index + 1, + "scores": { + "semantic_fit": 4, "context_fit": 4, + "composition_fit": 3, "style_fit": 3, + "text_logo_risk": 0, + }, + "warnings": [], "rationale": "Relevant footage.", + "duplicate_notes": [], "similar_footage": [], + }, + }) + candidates.append(candidate) + shot["candidates"] = candidates + return candidates + + def _canonical_review(self, segments, *, intent="approve"): + return self.review_for( + self.plan, + [{ + "id": "shot", "decision": "select", + "program_range": copy.deepcopy(self.plan["shots"][0]["program_range"]), + "segments": copy.deepcopy(segments), + }], + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + submission_intent=intent, + explicit_user_action=True, + revision_notes="", + rationale_source="review_ui_explicit_action", + timeline_fps=copy.deepcopy(self.timeline["fps"]), + ) + + def test_canonical_segments_allow_fixed_rates_and_one_to_three_unique_candidates(self): + candidates = self._canonical_candidates() + segments = [ + { + "candidate_id": candidates[0]["id"], + "source_range": {"start_s": 0.0, "end_s": 0.25}, + "program_range": {"start_s": 1.0, "end_s": 1.5}, + "playback_rate": 0.5, + }, + { + "candidate_id": candidates[1]["id"], + "source_range": {"start_s": 0.0, "end_s": 0.75}, + "program_range": {"start_s": 1.5, "end_s": 2.0}, + "playback_rate": 1.5, + }, + ] + approved = broll_plan.apply_review( + self.plan, self._canonical_review(segments), mode="human", actor="User", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, timeline=self.timeline, + ) + self.assertEqual(segments, approved["shots"][0]["selected"]["segments"]) + self.assertEqual( + sorted(candidate["sha256"] for candidate in candidates[:2]), + approved["review"]["selected_asset_sha256"], + ) + details = broll_plan.selection_details( + approved["shots"][0], candidates, self.timeline, + ) + self.assertEqual([0.5, 1.5], [item["playback_rate"] for item in details["segments"]]) + self.assertEqual(2, len(details["segment_details"])) + + for rate in (0.5, 1.0, 1.5, 2.0): + single = [{ + "candidate_id": candidates[0]["id"], + "source_range": {"start_s": 0.0, "end_s": rate}, + "program_range": {"start_s": 1.0, "end_s": 2.0}, + "playback_rate": rate, + }] + with self.subTest(rate=rate): + result = broll_plan.apply_review( + self.plan, self._canonical_review(single), mode="human", actor="User", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, timeline=self.timeline, + ) + self.assertEqual(rate, result["shots"][0]["selected"]["segments"][0]["playback_rate"]) + + def test_canonical_segments_reject_bad_rates_cardinality_identity_and_program_coverage(self): + candidates = self._canonical_candidates() + base = { + "candidate_id": candidates[0]["id"], + "source_range": {"start_s": 0.0, "end_s": 1.0}, + "program_range": {"start_s": 1.0, "end_s": 2.0}, + "playback_rate": 1.0, + } + for rate in (True, float("nan"), float("inf"), 0, -1, 0.75, 2.5): + segment = copy.deepcopy(base) + segment["playback_rate"] = rate + with self.subTest(rate=rate), self.assertRaisesRegex(ValueError, "playback_rate"): + broll_plan.apply_review( + self.plan, self._canonical_review([segment]), mode="human", actor="User", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, timeline=self.timeline, + ) + + cases = [] + cases.append(([], "1-3 segments")) + cases.append(([copy.deepcopy(base)] * 4, "1-3 segments")) + duplicate = [copy.deepcopy(base), copy.deepcopy(base)] + duplicate[0]["program_range"]["end_s"] = 1.5 + duplicate[0]["source_range"]["end_s"] = 0.5 + duplicate[1]["program_range"]["start_s"] = 1.5 + duplicate[1]["source_range"]["end_s"] = 0.5 + cases.append((duplicate, "unique")) + unknown = [copy.deepcopy(base)] + unknown[0]["candidate_id"] = "unknown" + cases.append((unknown, "belong")) + gap = [copy.deepcopy(base), copy.deepcopy(base)] + gap[0].update({ + "candidate_id": candidates[0]["id"], + "source_range": {"start_s": 0.0, "end_s": 0.4}, + "program_range": {"start_s": 1.0, "end_s": 1.4}, + }) + gap[1].update({ + "candidate_id": candidates[1]["id"], + "source_range": {"start_s": 0.0, "end_s": 0.5}, + "program_range": {"start_s": 1.5, "end_s": 2.0}, + }) + cases.append((gap, "continuous")) + for segments, message in cases: + with self.subTest(message=message), self.assertRaisesRegex(ValueError, message): + broll_plan.apply_review( + self.plan, self._canonical_review(segments), mode="human", actor="User", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, timeline=self.timeline, + ) + + def test_equal_program_frame_allocation_covers_range_and_last_absorbs_remainder(self): + timeline = {"fps": {"num": 30, "den": 1}} + program = {"start_s": 1.0, "end_s": 2.0} + self.assertEqual( + [{"start_s": 1.0, "end_s": 2.0}], + broll_plan.allocate_program_ranges(program, 1, timeline), + ) + two = broll_plan.allocate_program_ranges(program, 2, timeline) + self.assertEqual([15, 15], [round((item["end_s"] - item["start_s"]) * 30) for item in two]) + odd = broll_plan.allocate_program_ranges( + {"start_s": 0.0, "end_s": 31 / 30}, 3, timeline, + ) + self.assertEqual([10, 10, 11], [round((item["end_s"] - item["start_s"]) * 30) for item in odd]) + self.assertEqual(odd[0]["end_s"], odd[1]["start_s"]) + self.assertEqual(odd[1]["end_s"], odd[2]["start_s"]) + self.assertAlmostEqual(31 / 30, odd[-1]["end_s"]) + + def test_multiple_review_defaults_preserve_candidate_id_validation_state(self): + plan = copy.deepcopy(self.plan) + first = plan["shots"][0] + first["review_default"] = { + "decision": "select", + "segments": [{ + "candidate_id": "asset", + "source_range": {"start_s": 0.0, "end_s": 1.0}, + "program_range": {"start_s": 1.0, "end_s": 2.0}, + "playback_rate": 1.0, + }], + } + second = copy.deepcopy(first) + second["id"] = "second" + second["program_range"] = {"start_s": 2.0, "end_s": 3.0} + second["source_ranges"] = [{"clip_id": "one", "start_s": 2.0, "end_s": 3.0}] + second["transcript_evidence"] = {"words": [self.mapped_words[1]]} + second["candidates"][0]["id"] = "asset-2" + second["review_default"]["segments"][0].update({ + "candidate_id": "asset-2", + "program_range": {"start_s": 2.0, "end_s": 3.0}, + }) + plan["shots"].append(second) + + self.assertEqual([], broll_plan.validate_plan(plan, self.timeline, self.transcript)) + def test_rejects_invalid_overlapping_or_out_of_bounds_ranges(self): plan = copy.deepcopy(self.plan); duplicate = copy.deepcopy(plan["shots"][0]); duplicate["id"] = "second"; duplicate["program_range"] = {"start_s": 1.5, "end_s": 3}; plan["shots"].append(duplicate) self.assertIn("second program range overlaps shot", broll_plan.validate_plan(plan, self.timeline, self.transcript)) @@ -103,6 +287,159 @@ def test_evidence_word_must_exactly_match_mapped_transcript_word(self): plan = copy.deepcopy(self.plan); plan["shots"][0]["transcript_evidence"]["words"][0]["program_range"]["end_s"] = 2.1 self.assertIn("shot transcript evidence word is not mapped from transcript", broll_plan.validate_plan(plan, self.timeline, self.transcript)) + def test_apply_review_rejects_revision_request_before_writing_or_mutating(self): + request = self.review( + submission_intent="request_revision", + explicit_user_action=True, + revision_notes="Start on the next phrase.", + ) + request.pop("rationale") + interaction = self.root / "work/b-roll/broll-interaction.json" + original = copy.deepcopy(self.plan) + + with self.assertRaisesRegex(ValueError, "request_revision"): + broll_plan.apply_review( + self.plan, request, mode="human", actor="User", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + interaction_path=interaction, + ) + + self.assertEqual(original, self.plan) + self.assertFalse(interaction.exists()) + + def test_canonical_single_segment_approve_requires_fixed_rate_and_frame_match(self): + segment = { + "candidate_id": "asset", + "source_range": {"start_s": 0.25, "end_s": 1.25}, + "program_range": {"start_s": 1.0, "end_s": 2.0}, + "playback_rate": 1.0, + } + review = self.review_for( + self.plan, + [{ + "id": "shot", "decision": "select", + "program_range": copy.deepcopy(self.plan["shots"][0]["program_range"]), + "segments": [copy.deepcopy(segment)], + }], + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + submission_intent="approve", + explicit_user_action=True, + revision_notes="", + rationale_source="review_ui_explicit_action", + timeline_fps=copy.deepcopy(self.timeline["fps"]), + ) + approved = broll_plan.apply_review( + self.plan, review, mode="human", actor="User", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + timeline=self.timeline, + ) + self.assertEqual({"segments": [segment]}, approved["shots"][0]["selected"]) + self.assertEqual("review_ui_explicit_action", approved["review"]["rationale_source"]) + self.assertEqual("canonical", broll_plan.selection_details( + approved["shots"][0], approved["shots"][0]["candidates"][0], self.timeline, + )["format"]) + tampered = copy.deepcopy(approved) + tampered["review"].pop("rationale_source") + tampered["decision"].pop("rationale_source") + self.assertIn( + "new human review rationale_source is invalid", + broll_plan.validate_plan(tampered, self.timeline, self.transcript), + ) + + for label, mutate, message in ( + ("speed", lambda value: value.update(playback_rate=0.75), "playback_rate"), + ("duration", lambda value: value["source_range"].update(end_s=1.5), "one timeline frame"), + ): + invalid = copy.deepcopy(review) + mutate(invalid["shots"][0]["segments"][0]) + with self.subTest(label=label), self.assertRaisesRegex(ValueError, message): + broll_plan.apply_review( + self.plan, invalid, mode="human", actor="User", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + timeline=self.timeline, + ) + + multiple = copy.deepcopy(review) + multiple["shots"][0]["segments"].append(copy.deepcopy(segment)) + with self.assertRaisesRegex(ValueError, "unique"): + broll_plan.apply_review( + self.plan, multiple, mode="human", actor="User", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + timeline=self.timeline, + ) + + def test_revision_request_validates_bounds_and_rebuilds_unapproved_plan(self): + request = self.review_for( + self.plan, + [{ + "id": "shot", "decision": "select", + "requested_program_range": {"start_s": 1.0, "end_s": 3.0}, + "segments": [{ + "candidate_id": "asset", + "source_range": {"start_s": 0.0, "end_s": 2.0}, + "program_range": {"start_s": 1.0, "end_s": 3.0}, + "playback_rate": 1.0, + }], + }], + submission_intent="request_revision", + explicit_user_action=True, + revision_notes="End after process.", + ) + request.pop("rationale") + + self.assertEqual([], broll_plan.validate_revision_request( + self.plan, request, self.timeline, self.transcript, + )) + rebuilt = broll_plan.rebuild_plan_from_revision( + self.plan, request, self.timeline, self.transcript, + ) + shot = rebuilt["shots"][0] + self.assertEqual({"start_s": 1.0, "end_s": 3.0}, shot["program_range"]) + self.assertEqual(["factory", "process"], [ + word["word"] for word in shot["transcript_evidence"]["words"] + ]) + self.assertEqual("candidates_ready", shot["status"]) + self.assertIsNone(shot["selected"]) + self.assertEqual(request["shots"][0]["segments"], shot["review_default"]["segments"]) + self.assertIsNone(rebuilt["review"]) + self.assertIsNone(rebuilt["decision"]) + self.assertNotEqual( + broll_plan.canonical_sha256(broll_plan.review_subject(self.plan)), + broll_plan.canonical_sha256(broll_plan.review_subject(rebuilt)), + ) + + stale = copy.deepcopy(request) + stale["plan_sha256"] = "0" * 64 + self.assertTrue(any("plan_sha256 does not match" in error for error in + broll_plan.validate_revision_request( + self.plan, stale, self.timeline, self.transcript, + ))) + out_of_bounds = copy.deepcopy(request) + out_of_bounds["shots"][0]["requested_program_range"] = {"start_s": 1.0, "end_s": 4.033333333} + out_of_bounds["shots"][0]["segments"][0]["program_range"] = copy.deepcopy( + out_of_bounds["shots"][0]["requested_program_range"] + ) + out_of_bounds["shots"][0]["segments"][0]["source_range"]["end_s"] = 3.033333333 + self.assertTrue(any("allowed" in error or "candidate" in error for error in + broll_plan.validate_revision_request( + self.plan, out_of_bounds, self.timeline, self.transcript, + ))) + + def test_legacy_long_trim_exposes_requested_and_effective_ranges(self): + review = self.review_for(self.plan, [{ + "id": "shot", "decision": "select", "candidate_id": "asset", + "source_trim": {"start_s": 0, "end_s": 2}, + }]) + approved = broll_plan.apply_review( + self.plan, review, mode="agent", actor="agent", rationale="Relevant footage.", + ) + details = broll_plan.selection_details( + approved["shots"][0], approved["shots"][0]["candidates"][0], self.timeline, + ) + self.assertEqual("legacy", details["format"]) + self.assertEqual({"start_s": 0.0, "end_s": 2.0}, details["legacy_requested_source_range"]) + self.assertEqual({"start_s": 0.0, "end_s": 1.0}, details["segments"][0]["source_range"]) + def test_apply_review_rejects_human_without_action_and_agent_blank_rationale(self): with self.assertRaisesRegex(ValueError, "explicit_user_action"): broll_plan.apply_review(self.plan, self.review(rationale="ok"), mode="human", actor="person", rationale="ok") @@ -1849,6 +2186,168 @@ def test_payload_candidate_duration_is_always_a_positive_finite_number(self): self.assertGreater(duration, 0) self.assertEqual(expected, duration) + def test_payload_exposes_transcript_mapping_bounds_fps_and_single_segment_defaults(self): + with mock.patch.object(build_review_page, "_extract_frame", side_effect=self._frame): + result = build_review_page.build_review_page( + self.plan, self.timeline, self.transcript, self.video, self.review_dir, + project_root=self.root, + ) + payload = json.loads(base64.b64decode( + build_review_page.PAYLOAD_RE.search( + result["page"].read_text(encoding="utf-8") + ).group(1) + )) + shot = payload["shots"][0] + + self.assertEqual({"num": 30, "den": 1}, payload["timeline"]["fps"]) + self.assertEqual(10.0, payload["timeline"]["program_duration_s"]) + self.assertEqual({"start_s": 1.0, "end_s": 2.0}, shot["original_program_range"]) + self.assertEqual(0.0, shot["allowed_program_range"]["start_s"]["min"]) + self.assertEqual(3.0, shot["allowed_program_range"]["start_s"]["max"]) + self.assertEqual(4.0, shot["allowed_program_range"]["end_s"]["max"]) + self.assertEqual(["one"], shot["clip_ids"]) + self.assertEqual(["factory"], [word["word"] for word in shot["transcript"]["inside"]]) + self.assertEqual(["factory", "process", "output"], [ + word["word"] for word in shot["transcript"]["context"] + ]) + for word in shot["transcript"]["context"]: + self.assertIn("program_range", word) + self.assertIn("source_range", word) + self.assertEqual("one", word["clip_id"]) + default = shot["candidates"][0]["default_segment"] + self.assertEqual("asset", default["candidate_id"]) + self.assertEqual({"start_s": 0.0, "end_s": 1.0}, default["source_range"]) + self.assertEqual({"start_s": 1.0, "end_s": 2.0}, default["program_range"]) + self.assertEqual(1.0, default["playback_rate"]) + + def test_payload_exposes_frame_first_defaults_and_prefills_multiple_segments(self): + candidates = [] + base = self.plan["shots"][0]["candidates"][0] + for index in range(3): + candidate = copy.deepcopy(base) + candidate["id"] = f"asset-{index + 1}" + candidates.append(candidate) + self.plan["shots"][0]["candidates"] = candidates + self.plan["shots"][0]["review_default"] = { + "decision": "select", + "segments": [ + { + "candidate_id": "asset-1", + "source_range": {"start_s": 0.0, "end_s": 0.5}, + "program_range": {"start_s": 1.0, "end_s": 1.5}, + "playback_rate": 1.0, + }, + { + "candidate_id": "asset-2", + "source_range": {"start_s": 0.0, "end_s": 1.0}, + "program_range": {"start_s": 1.5, "end_s": 2.0}, + "playback_rate": 2.0, + }, + ], + } + shots, _, _ = build_review_page._payload( + self.plan, self.timeline, self.transcript, self.root, + self.review_dir / "assets", + ) + shot = shots[0] + self.assertEqual(30, shot["total_program_frames"]) + self.assertAlmostEqual(1 / 30, shot["frame_duration_s"]) + self.assertEqual([15, 15], shot["default_allocations"]["2"]) + self.assertEqual([10, 10, 10], shot["default_allocations"]["3"]) + self.assertEqual( + self.plan["shots"][0]["review_default"], shot["review_default"], + ) + + def test_template_supports_multiselect_order_speed_timeline_and_explicit_fit(self): + template = build_review_page.TEMPLATE_PATH.read_text(encoding="utf-8") + for text in ( + 'type="checkbox"', + 'class="segments-list"', + 'move-up', + 'move-down', + 'class="playback-rate"', + '