diff --git a/skills/video-add-b-roll/SKILL.md b/skills/video-add-b-roll/SKILL.md index ba57bb20f..3d4529279 100644 --- a/skills/video-add-b-roll/SKILL.md +++ b/skills/video-add-b-roll/SKILL.md @@ -1,11 +1,11 @@ --- name: video-add-b-roll -description: Use when a talking-head, interview, documentary, or explanatory video needs selective transcript-timed visual cutaways from local media or Pexels. +description: Use when a talking-head, interview, documentary, or explanatory video needs deliberate transcript-timed visual cutaways from local media or Pexels. --- # 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. @@ -85,9 +85,25 @@ contract; do not hand-build the operation. ```text work/b-roll/broll-plan.json # durable domain plan +work/b-roll/presentation-decision.json # durable Agent-chat route decision work/b-roll/broll-interaction.json # durable applied receipt +work/b-roll/broll-selection.json # durable non-approval exact selection +work/b-roll/speaker-inset-analysis.json # durable scene/evidence packet +work/b-roll/speaker-inset-agent-input.json # durable Agent ROI decisions +work/b-roll/speaker-inset-preview.json # durable exact preview bindings +work/b-roll/speaker-inset-clearance.json # durable Agent obstruction check +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/speaker-inset/ # reproducible evidence and previews 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 @@ -113,7 +129,7 @@ timeline, transcript, dependency revision, grade plan, LUT, review video, candid or reviewed asset makes downstream work stale; refresh the plan and repeat review rather than carrying an old receipt forward. -### 2. Author A Selective Plan +### 2. Author A Dynamic-Social Plan Hand-author `work/b-roll/broll-plan.json`; scripts perform precision, not editorial judgment. For each proposed shot: @@ -122,12 +138,32 @@ For each proposed shot: - map it through `work/timeline.json` into exact `source_ranges`; - preserve at least one exact mapped transcript word with source/program ranges and clip ID; - state a concrete `editorial_reason` and `visual_intent`; -- write two or three literal English queries containing subject, action, and useful setting; +- set `brief.search_context` with a concrete `topic`, `visual_direction`, and `1-12 unique keywords`; +- set each `semantic_role` to `direct`, `supportive`, or `atmospheric`; +- write two or three layered literal English queries: a `direct subject/action` query, a + `defensible context/process` query, and, when a third query is useful, a `theme-enhancing variant`; - begin at `planned`, move to `candidates_ready` after acquisition, or use `skipped` when no relevant candidate exists. -Keep `brief.density` equal to `selective`. Do not pad the plan to meet a count. If no moment -earns B-roll, an approved all-skipped plan is a valid no-op. +Keep `brief.density` equal to `dynamic-social`. Planned coverage begins with a complete A-roll +scan of the mapped transcript and timeline, followed by hand-authored chronological, non-overlapping, +editorially valid ranges. Treat `0.40` to `0.70` as a soft target. Below `0.40`, take one second +pass and only add or extend defensible ranges. Above `0.70`, internally review A-roll concealment. +Never add irrelevant filler or weaken relevance, rights, quality, or another eligibility gate to hit +the target. Generic topical terms such as `tech` or `entertainment` may refine a query only; they +never replace the per-shot visible relationship between the candidate and the transcript claim. +If no moment earns B-roll, an approved all-skipped plan is a valid no-op. + +Do not add `speaker_inset_style` while authoring the draft. After candidate acquisition, the +explicit Agent-chat presentation decision selects the ordinary or speaker-inset route. The latter +automatically installs the strict project-level rounded-rectangle style: `width_ratio: 0.39`, gray +border, aspect ratio, corner radius, margins, and subtitle-safe bottom area. Preset and anchor do +not belong to the style. The Agent records `project_layout_strategy` plus one per-shot +`layout_recommendation` after inspecting the exact selected media. Never introduce a face detector, +segmentation model, cross-cut identity tracker, or guessed ROI. Fit speaker ROI pixels into the +window with aspect-preserving cover scaling; never stretch them to the configured aspect ratio. +Center the crop horizontally and anchor it to the top so the complete head and face take priority +over lower-body coverage. Validate the draft after every material edit: @@ -135,10 +171,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,51 +186,172 @@ 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. -For Pexels, search each of the shot's two or three literal queries through the one direct -module: +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 -python "$BrollScripts/pexels.py" search "precision parts factory floor" ` - --orientation landscape --per-page 10 +$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. + +Download and fully decode only analysis variants, sample them at 10/30/50/70/90 percent, and +write deterministic machine evidence: + +```powershell +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" +``` + +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 against visible frozen-frame evidence: 0 is a mismatch or +unusable, 1 is weak, 2 is acceptable with concerns, 3 is strong, and 4 is unusually strong. +`text_logo_risk` is 0 through 4 or `uncertain`; never claim OCR. Both `semantic_fit == 0` and +`context_fit == 0` are retained hard ineligibility protections. `semantic_fit == 0` rejects a +candidate for semantic mismatch; `context_fit == 0` rejects unsupported context. A +`semantic_fit` 1 remains eligible as `weak_semantic_match` when it passes the context and other +independent gates, and ranks below semantic 2-4 matches. 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 +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" +``` + +See [example-candidate-ranking.json](examples/example-candidate-ranking.json) for the durable +ranking shape. Rank eligible candidates in semantic-first lexicographic order: `semantic_fit`, then +`context_fit`, then the combined `composition_fit + style_fit` score, fewer deterministic warnings, +and finally stable provider and candidate IDs. 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`. + +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" ``` -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 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 -$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 publish ` + $ProjectRoot ` + "$ProjectRoot/work/b-roll/candidate-analysis.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. +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. + +### 3a. Choose The Presentation Route In Agent Chat + +After acquisition produces `candidates_ready` or `skipped` shots, inspect the exact A-roll and +frozen candidate evidence. In the current Agent chat, present an Agent recommendation for either `ordinary` +full-screen B-roll or `speaker-inset`, with a concrete rationale. Then ask the user for an explicit +route choice in natural language. Do not infer this choice from the original draft, a candidate +selection, or silence. -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. +Record the reply with `broll_plan.record_chat_presentation_decision()`. Its input must preserve the +actual `user_response`, the Agent `presentation_mode` recommendation and rationale, the real user +actor, a UUID and timestamp, `explicit_user_action: true`, and +`rationale_source: "agent_chat_explicit_action"`. It writes the hash-bound +`work/b-roll/presentation-decision.json` receipt and the plan's `presentation` binding. The receipt +binds the review-video, candidate manifest, and pre-review plan hashes. A changed candidate, plan, +or review video requires a new chat choice before a review page can be published. -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. +Choosing `ordinary` removes any speaker style and continues to the existing one-page exact +candidate review. Choosing `speaker-inset` installs the default rounded-rectangle style and still +uses that first page only to freeze exact B-roll selections. It must then run `prepare_composite`, +produce the existing second composite page, and stop for its separate approval. The chat response +selects a route; it never substitutes for either webpage's explicit review action. ### 4. Publish And Complete Exact-Candidate Review Set `input_hashes.review_video_sha256` to the actual current review video's SHA-256, revalidate, -then publish the immutable local review: +then confirm the current Agent-chat presentation receipt and publish the immutable local review: ```powershell $ReviewPublication = & python "$BrollScripts/build_review_page.py" ` @@ -205,28 +365,99 @@ $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 -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." +$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" ``` -Use `human` and the actual human actor only after explicit user export. The command rationale -must exactly match the exported rationale. +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. + +For an `ordinary` chat decision, apply the received one-page approval 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]); 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. 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. + +For a `speaker-inset` chat decision, apply first-page `prepare_composite`, then use +`speaker_inset.py` for evidence, Agent ROI, layout recommendation, exact anchor previews, and clearance; follow +`reference/broll-rules.md`. The Agent must assess `focused-panel`, `full-bleed-wash`, and +`corner-pip`, choose a project primary preset, and keep one preset and anchor for each complete +shot. `corner-pip` supports only the shot-level `top-left` and `top-right` anchors. Only the +republished page's hash-bound `review_stage: composite` approval may select shots. +Agent recommendation is advisory; present the exact composite page and stop for user approval. + +Before accepting `ambiguous`, request dense supplemental evidence inside that subshot and rebuild +the analysis hash. Require `subject_legibility: pass` for every displayed subshot and a shot-level +continuity decision. Treat +an enabled run shorter than 1.5 seconds followed by a longer pure-B-roll run as `short_flash`; extend +the independently confirmed ROI, disable the whole shot inset, or record a specific intentional +transition. Never relax the confirmed-speaker rule to hide a continuity problem. ### 5. Normalize Approved Selections @@ -237,9 +468,26 @@ 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. +grade is active and never apply an unselected look. For an enabled inset without active color +grade, pass the exact hash-bound review video explicitly: + +```powershell +python -c "import sys; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,sys.argv[2]); import normalize_broll; normalize_broll.normalize_plan(root/'work/b-roll/broll-plan.json',root/'work/timeline.json',root,lut=None,review_video=Path(sys.argv[3]).resolve())" $ProjectRoot $BrollScripts $ReviewVideo +``` + +With active color grade and an enabled inset, pass both exact inputs: + +```powershell +python -c "import sys; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,sys.argv[2]); import normalize_broll; normalize_broll.normalize_plan(root/'work/b-roll/broll-plan.json',root/'work/timeline.json',root,lut=Path(sys.argv[3]).resolve(),review_video=Path(sys.argv[4]).resolve())" $ProjectRoot $BrollScripts "$ProjectRoot/final/selected-color-look.cube" $ReviewVideo +``` + +The normalizer produces silent H.264 +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. For an enabled inset it preserves a reusable `broll-NNN-base.mp4` +and publishes one preset-treated, clearance-effective speaker composite as `broll-NNN.mp4`; the +registered overlay is always the final composite. 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. @@ -296,7 +544,8 @@ After actual inspection, create an Agent- or human-authored JSON export containi `review_id`, the canonical SHA-256 of the verified plan without `visual_review`, a timezone-aware `timestamp`, `mode`, real `actor`, non-empty `rationale`, and exactly these boolean results: `semantic_fit`, `unwanted_logos_or_text`, `jump_cuts`, `entry_exit_boundaries`, and -`grade_match`. Every result must be `true`. Use `mode: "human"` and +`grade_match`. When the inset is enabled, also require `speaker_layout_fidelity`, +`speaker_legibility`, and `broll_focal_clearance`. Every applicable result must be `true`. Use `mode: "human"` and `explicit_user_action: true` only after an explicit user action; otherwise use delegated `mode: "agent"`. Never infer or fabricate a human pass. diff --git a/skills/video-add-b-roll/assets/broll-review.html b/skills/video-add-b-roll/assets/broll-review.html index 8fc8ef456..688e1283b 100644 --- a/skills/video-add-b-roll/assets/broll-review.html +++ b/skills/video-add-b-roll/assets/broll-review.html @@ -4,20 +4,156 @@ 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..dc6c42eb6 100644 --- a/skills/video-add-b-roll/examples/example-broll-plan.json +++ b/skills/video-add-b-roll/examples/example-broll-plan.json @@ -21,7 +21,19 @@ "review_video_sha256": "5555555555555555555555555555555555555555555555555555555555555555" }, "brief": { - "density": "selective", + "density": "dynamic-social", + "search_context": { + "topic": "Manufacturing automation workflow", + "visual_direction": "Visible precision manufacturing and operations software", + "keywords": [ + "manufacturing", + "automation", + "precision parts", + "assembly line", + "operations dashboard", + "industrial software" + ] + }, "style": "literal documentary footage", "avoid": [ "logos", @@ -29,43 +41,58 @@ "generic office footage" ] }, - "review_status": "approved", - "decision": { - "mode": "agent", - "actor": "Example Agent", - "rationale": "Selected the literal manufacturing process shot and skipped the weak software visual." + "speaker_inset_style": { + "enabled": true, + "shape": "rounded-rectangle", + "width_ratio": 0.39, + "aspect_ratio": 0.8, + "border": { + "width_px": 3, + "color": "#9E9E9E" + }, + "corner_radius_ratio": 0.1, + "margin_ratio": 0.04, + "reserved_bottom_ratio": 0.2 + }, + "presentation": { + "status": "chosen", + "mode": "speaker-inset", + "path": "b-roll/presentation-decision.json", + "sha256": "9999999999999999999999999999999999999999999999999999999999999999", + "decision_id": "123e4567-e89b-12d3-a456-426614174009", + "actor": "Actual user", + "timestamp": "2026-08-04T11:30:00Z" }, - "review": { - "status": "approved", + "decision": null, + "review": null, + "selection": { + "status": "prepared", + "submission_intent": "prepare_composite", + "path": "b-roll/broll-selection.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "review_id": "123e4567-e89b-12d3-a456-426614174000", - "mode": "agent", - "actor": "Example Agent", - "rationale": "Selected the literal manufacturing process shot and skipped the weak software visual.", - "timestamp": "2026-07-23T12:00:00Z", - "plan_sha256": "278fe37aa4ac3bf528ebd0161bdb19a917158bd707debd19090f69b0f81fa2df", - "candidate_manifest_sha256": "4d46ef77624f6c2dfc1e0b2c62d54aba8bdac44609be6bc3ef5c1e8a625ab616", - "review_video_sha256": "5555555555555555555555555555555555555555555555555555555555555555", - "decisions": [ - { - "id": "example-manufacturing-process", - "decision": "select", - "candidate_id": "example-owned-factory-video", - "source_trim": { - "start_s": 0.5, - "end_s": 3.5 - } - }, - { - "id": "example-software-claim", - "decision": "skip" - } - ], - "decision_skipped_shot_ids": [ - "example-software-claim" - ], - "selected_asset_sha256": [ - "6666666666666666666666666666666666666666666666666666666666666666" - ] + "mode": "human", + "actor": "Actual user", + "timestamp": "2026-08-04T12:00:00Z", + "style_sha256": "ab2a11307eabc99255f230422a4c0fe99ebc46fe7287808bd25d47423f730c72" + }, + "speaker_inset": { + "analysis": { + "path": "b-roll/speaker-inset-analysis.json", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "agent_input": { + "path": "b-roll/speaker-inset-agent-input.json", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "preview": { + "path": "b-roll/speaker-inset-preview.json", + "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "clearance": { + "path": "b-roll/speaker-inset-clearance.json", + "sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } }, "shots": [ { @@ -98,10 +125,12 @@ ] }, "editorial_reason": "Shows the physical process named in the claim.", - "visual_intent": "A wide, readable view of precision parts moving through a factory line.", + "visual_intent": "A readable view of precision parts moving through a factory line.", + "semantic_role": "direct", "queries": [ "precision parts factory line", - "manufacturing assembly process wide shot" + "manufacturing assembly process wide shot", + "industrial automation workflow detail" ], "candidates": [ { @@ -121,72 +150,61 @@ "source_type": "local", "creator": "Example Studio", "license": "Example owned footage - replace with real rights statement", - "retrieval_time": "2026-07-23T10:00:00Z", + "retrieval_time": "2026-08-04T10:00:00Z", "original_path": "input/example-owned-factory-video.mp4" } + }, + { + "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-08-04T10:00:00Z", + "original_path": "input/example-owned-factory-detail-video.mp4" + } } ], "selected": { - "candidate_id": "example-owned-factory-video", - "source_trim": { - "start_s": 0.5, - "end_s": 3.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", - "sha256": "7777777777777777777777777777777777777777777777777777777777777777", - "probe": { - "duration_s": 3.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 - }, - "grade_plan_sha256": "3333333333333333333333333333333333333333333333333333333333333333", - "selected_lut_sha256": "4444444444444444444444444444444444444444444444444444444444444444" - }, - "verification": { - "status": "pass", - "normalized_sha256": "7777777777777777777777777777777777777777777777777777777777777777", - "stills": { - "first": { - "path": "review/03-b-roll/stills/001-example-manufacturing-process-first.png", - "sha256": "8888888888888888888888888888888888888888888888888888888888888888" - }, - "middle": { - "path": "review/03-b-roll/stills/001-example-manufacturing-process-middle.png", - "sha256": "9999999999999999999999999999999999999999999999999999999999999999" + "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 }, - "last": { - "path": "review/03-b-roll/stills/001-example-manufacturing-process-last.png", - "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + { + "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 } - }, - "contact_sheet": { - "path": "review/03-b-roll/contact-sheet.jpg", - "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "boundary_reel": { - "path": "review/03-b-roll/boundary-reel.mp4", - "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - }, - "report": { - "path": "review/03-b-roll/b-roll-summary.md", - "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" - } - } + ] + }, + "status": "composite_pending" }, { "id": "example-software-claim", @@ -219,9 +237,11 @@ }, "editorial_reason": "A literal interface could clarify the software claim.", "visual_intent": "A readable product interface showing the exact workflow described.", + "semantic_role": "supportive", "queries": [ "manufacturing workflow software interface", - "factory operations dashboard close up" + "factory operations dashboard close up", + "industrial data flow screen" ], "candidates": [], "selected": null, 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..f3853814e 100644 --- a/skills/video-add-b-roll/reference/broll-rules.md +++ b/skills/video-add-b-roll/reference/broll-rules.md @@ -2,21 +2,27 @@ ## Editorial Selection -- Default to selective use. Add B-roll only when it clarifies a claim, supplies concrete - evidence, or hides a necessary edit without obscuring an important expression. +- Use the `dynamic-social` profile. Begin planned coverage with a complete A-roll scan, then + hand-author chronological, non-overlapping transcript-backed intervals. +- Treat `0.40` to `0.70` planned coverage as a soft target. Below `0.40`, make a second pass that + only adds or extends defensible intervals. Above `0.70`, conduct an internal A-roll concealment + review. Never add irrelevant filler or weaken eligibility to hit the target. - Prefer literal nouns, actions, products, locations, and processes. Reject generic mood footage, loose topical matches, and repeated or near-identical visuals. - Preserve the first or last spoken beat when the speaker's face carries the meaning. - Keep proposed ranges positive, half-open, chronological, non-overlapping, and supported by exact mapped transcript words. -- Write two or three narrow literal queries per shot. Include the subject, action, setting, - or useful framing. Do not search full transcript sentences or abstract adjectives. -- There is no minimum coverage. Skip a moment when no candidate fits its meaning, quality, - duration, framing, and license. +- Require `brief.search_context` with a concrete `topic`, `visual_direction`, and `1-12 unique + keywords`. Give every search shot a `semantic_role`: `direct`, `supportive`, or `atmospheric`. +- Write two or three layered narrow literal queries per shot: `direct subject/action`, + `defensible context/process`, and, when useful, a `theme-enhancing variant`. Do not search full + transcript sentences or abstract adjectives. Generic topical terms such as `tech` or + `entertainment` may refine queries only; they never replace the per-shot visible relationship. +- Skip a moment when no candidate fits its meaning, quality, duration, framing, and license. ## 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 +36,74 @@ 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` from visible frozen-frame + evidence 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. +- Both `semantic_fit == 0` and `context_fit == 0` are retained hard ineligibility protections. + Mark a candidate ineligible when either applies, a hard check fails, it clearly violates + `brief.avoid`, or its primary subject cannot be identified in the target framing. + `semantic_fit == 0` rejects a semantic mismatch; `context_fit == 0` rejects unsupported context. + A `semantic_fit` of 1 is an eligible `weak_semantic_match` when it passes the context and other + independent gates; it ranks below semantic 2-4 matches. +- 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 in semantic-first lexicographic order: `semantic_fit`, then + `context_fit`, then the combined `composition_fit + style_fit` score, 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 +117,169 @@ - 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. + +## Speaker Inset Review + +- After candidates are frozen and before publishing any review page, the current Agent must present + a recommendation for `ordinary` or `speaker-inset` in chat and obtain an explicit user choice. + Persist the actual user response, recommendation, rationale, plan, candidate-manifest, and + review-video bindings in `work/b-roll/presentation-decision.json` with + `rationale_source: "agent_chat_explicit_action"`. The plan binds that receipt by SHA-256. This + chat decision selects a route only; it cannot impersonate a `review_ui_explicit_action` or replace + either webpage approval. +- An `ordinary` choice removes `speaker_inset_style` and uses the existing one-page workflow. A + `speaker-inset` choice installs the default style. When enabled, require + `shape: "rounded-rectangle"`, `width_ratio: 0.39`, `aspect_ratio: 0.80`, a 3px `#9E9E9E` border, + `corner_radius_ratio: 0.10`, `margin_ratio: 0.04`, and `reserved_bottom_ratio: 0.20`. Keep only + common appearance in this project-level style; preset and anchor belong to the Agent input. +- The first-page `prepare_composite` action freezes exact B-roll segments in + `broll-selection.json` and moves selected shots to `composite_pending`. It is not approval, + creates no `review_status`, and never authorizes normalization or registration. +- Split every selected program range at canonical clip discontinuities and conservative FFmpeg + scene candidates. Align boundaries and keyframes to rational timeline frames. Never interpolate + a ROI across a cut or infer identity across cuts. +- Build temporal A-roll evidence from the hash-bound upstream review video. Include entry, middle, + exit, adjacent frames, transcript context, frame path, dimensions, program time, and SHA-256. + A single still is insufficient evidence for a current speaker in a multi-person shot. +- When the baseline bursts cannot isolate one current speaker, request one or more frame-aligned + supplemental points inside that subshot. Extract a denser adjacent-frame burst, rebuild the + analysis ID/hash, and inspect it before using `ambiguous`. Never carry an old Agent input across + the rebuilt analysis. +- Use the delegated Agent only for visual speaker judgment, sparse normalized ROI annotation, and + composition review. Do not install or invoke a face detector, person segmentation/tracking model, + or external identity service. +- In `speaker-inset-agent-input.json`, require one `project_layout_strategy` with a primary preset, + one or two used presets by default, and a specific rationale. Require one + `layout_recommendation` per selected shot with preset, fixed anchor, high/medium/low confidence, + rationale, and pass/warn/fail assessments for all three presets. A low-confidence recommendation + requires one non-failing alternate; recommendation never grants approval. +- Use `focused-panel` when the exact B-roll benefits from a distinct upper presentation area. Build + the frame from a blurred full-frame copy plus a crisp cover-scaled panel at normalized bounds + `x=0.04`, `y=0.08`, `width=0.92`, `height=0.40`, surrounded by a 3px `#9E9E9E` border; place the + speaker at `lower-center` directly above the subtitle-safe area. +- Use `full-bleed-wash` for atmospheric or supporting B-roll that tolerates lower contrast. Keep it + full-frame, apply one uniform white layer at opacity `0.30`, and place the speaker at + `upper-center`. Do not use this preset for text, charts, interfaces, or required fine detail. +- Use `corner-pip` for information-dense B-roll that must remain full-frame and full-contrast. Keep + B-roll unchanged and choose exactly one shot-level `top-left` or `top-right` anchor. Only upper + corners are supported so the subtitle-safe lower area remains clear. +- Default every shot to the project primary preset. Use a secondary preset only when the primary is + warn/fail for that exact shot and the secondary passes with a visible benefit. Three presets need + three distinct documented shot needs and approval of the exact previews. Keep one preset and one + anchor for the complete shot; never switch corners inside a shot. +- Require `confirmed`, `ambiguous`, `absent`, or `occluded` for every subshot and a specific + rationale. Only confirmed tracks may enable the window. Every other status must use + `pure_broll`, no anchor, and no keyframes. +- Keep confirmed ROI values finite, positive, inside the A-roll frame, strictly ordered, frame + aligned, and covering the complete subshot. Interpolate only within that subshot. +- Fit each speaker ROI into the configured window with an aspect-preserving cover crop, centered + horizontally and anchored to the top. Never resize speaker pixels non-uniformly. Preserve the + complete head, forehead, face, chin, and visible headroom before lower-body coverage. If that + framing still removes important subject content, revise and reapprove the ROI instead of + stretching it. +- Render the user's exact B-roll bytes, trims, segment order, speeds, timeline geometry, rational + FPS, and selected LUT. Read speaker pixels from the already graded upstream review video; never + apply the LUT to those pixels again. +- Apply the recommended B-roll treatment to every frame, including a `pure_broll` subshot; only the + speaker pixels toggle. Freeze the exact recommended composite, every supported anchor preview for + that preset, and the single required low-confidence alternate. Agent clearance must inspect these + composited pixels, not isolated A-roll and B-roll sources. +- Record `subject_legibility: pass` for every enabled subshot only when its final-size pixels keep + the complete face and head inside the border and rounded mask with visible headroom. Tighten the + ROI and rebuild when the subject occupies too little of the window. Use + `not_applicable` for pure B-roll. When the user asks for a larger or smaller speaker, translate the + request into one explicit numeric `width_ratio`, state it, and invalidate every style-bound + artifact and approval before rebuilding. Do not silently change one shot. +- Record one continuity assessment per shot. Derive `short_flash` when an enabled run is shorter + than 1.5 seconds and is followed by a longer pure-B-roll run. Resolve it by independently + confirming and extending the later subshot, disabling the whole shot inset, or explicitly + justifying an intentional transition. Do not use a fade or a guessed identity to mask it. +- `pass` binds the recommended enabled anchor actually checked. `no_safe_position` must list every + anchor and resolve the subshot to `pure_broll`; never shrink the project style, cover focal B-roll + content, replace the selected B-roll, or invent a fallback speaker image. +- The second immutable page must show the locked selection, temporal evidence, ROI keyframes, + project strategy, recommendation rationale, three assessments, exact recommended composite, + supported anchor previews, optional alternate, common style, and clearance reasoning. Approval requires + `review_stage: composite` and current selection, analysis, Agent input, preview, clearance, and + style hashes. Any modification requests a new immutable page. +- After composite approval, normalize a reusable pure B-roll base and precompose the preset + treatment plus clearance-effective speaker pixels into the one existing per-shot overlay. Bind + selection, analysis, Agent input, preview, clearance, style, review video, and composite review + UUID by SHA-256. Verification stills, contact sheet, and boundary reel must read this final + composite, never the base. +- Pre-register the verified final composite as the existing approved/pending overlay, render final + delivery once, then inspect the actual normalized and final pixels. In addition to the ordinary + checks, require `speaker_layout_fidelity`, `speaker_legibility`, and `broll_focal_clearance` before + final registration becomes verified. + +## 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..1743aed0f 100644 --- a/skills/video-add-b-roll/scripts/broll_plan.py +++ b/skills/video-add-b-roll/scripts/broll_plan.py @@ -14,14 +14,26 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "video-understand" / "scripts")) import projectlib +import speaker_inset RANGE_EPSILON = 1e-6 +DYNAMIC_SOCIAL_MIN_RATIO = 0.40 +DYNAMIC_SOCIAL_MAX_RATIO = 0.70 KEN_BURNS_DIRECTIONS = {"zoom-in", "pan-left", "pan-right"} +HUMAN_APPROVAL_RATIONALE = "Explicit user action approved the exact configuration shown in this review." +HUMAN_PREPARE_COMPOSITE_RATIONALE = "Explicit user action locked the exact B-roll selection for composite preview." +REVIEW_INTENTS = {"approve", "request_revision"} +PRESENTATION_MODES = {"ordinary", "speaker-inset"} +CHAT_PRESENTATION_RATIONALE_SOURCE = "agent_chat_explicit_action" +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", ) +SPEAKER_VISUAL_REVIEW_CHECKS = ( + "speaker_layout_fidelity", "speaker_legibility", "broll_focal_clearance", +) PEXELS_LICENSE_URL = "https://www.pexels.com/license/" PEXELS_TERMS_URL = "https://www.pexels.com/terms-of-service/" _INVALID_NUMBER = object() @@ -39,6 +51,13 @@ def canonical_sha256(value): return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() +def visual_review_checks(plan): + checks = VISUAL_REVIEW_CHECKS + if isinstance(plan, dict) and speaker_inset.style_enabled(plan.get("speaker_inset_style")): + checks += SPEAKER_VISUAL_REVIEW_CHECKS + return checks + + def visual_review_subject(plan): return {key: value for key, value in plan.items() if key != "visual_review"} @@ -85,6 +104,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")))] @@ -108,13 +378,190 @@ def clean(item): clean(value) for shot in value.get("shots", []): status = shot.get("status") if isinstance(shot, dict) else None - if isinstance(status, str) and status in ("planned", "candidates_ready", "selected", "normalized", "verified"): + if isinstance(status, str) and status in ("planned", "candidates_ready", "composite_pending", "selected", "normalized", "verified"): shot["status"] = "reviewable" elif status == "skipped" and isinstance(shot.get("id"), str) and shot["id"] in decision_skipped_ids: shot["status"] = "reviewable" return value +def presentation_subject(plan): + value = copy.deepcopy(plan) + for key in ( + "presentation", "speaker_inset_style", "speaker_inset", "selection", + "decision", "review", "review_status", "visual_review"): + value.pop(key, None) + + def clean(item): + if isinstance(item, dict): + for key in ("selected", "normalized", "verification"): + item.pop(key, None) + for child in item.values(): + clean(child) + elif isinstance(item, list): + for child in item: + clean(child) + + clean(value) + for shot in value.get("shots", []): + if isinstance(shot, dict) and shot.get("status") in { + "planned", "candidates_ready", "composite_pending", "selected", + "normalized", "verified"}: + shot["status"] = "reviewable" + return value + + +def presentation_errors(plan, *, project_root=None, required=False): + if not isinstance(plan, dict): + return ["plan must be an object"] + presentation = plan.get("presentation") + if presentation is None: + return ["agent-chat presentation decision is required"] if required else [] + if not isinstance(presentation, dict): + return ["presentation decision binding must be an object"] + errors = [] + if presentation.get("status") != "chosen": + errors.append("presentation decision status must be chosen") + mode = presentation.get("mode") + if mode not in PRESENTATION_MODES: + errors.append("presentation decision mode is invalid") + if not _is_uuid(presentation.get("decision_id")): + errors.append("presentation decision id is invalid") + if not isinstance(presentation.get("actor"), str) or not presentation["actor"].strip(): + errors.append("presentation decision actor is required") + if not _valid_timestamp(presentation.get("timestamp")): + errors.append("presentation decision timestamp is invalid") + if presentation.get("path") != "b-roll/presentation-decision.json": + errors.append("presentation decision path is invalid") + if not _is_sha256(presentation.get("sha256")): + errors.append("presentation decision SHA-256 is invalid") + if mode == "speaker-inset": + style = plan.get("speaker_inset_style") + if not speaker_inset.style_enabled(style) or speaker_inset.style_errors(style): + errors.append("speaker-inset presentation requires enabled speaker_inset_style") + elif speaker_inset.style_enabled(plan.get("speaker_inset_style")): + errors.append("ordinary presentation must not enable speaker_inset_style") + if project_root is None or errors: + return errors + root = Path(project_root).resolve() + path = root / "work" / presentation["path"] + try: + path.resolve().relative_to((root / "work").resolve()) + except ValueError: + return errors + ["presentation decision path escapes work"] + if not path.is_file() or sha256_file(path) != presentation["sha256"]: + return errors + ["presentation decision artifact is missing or stale"] + try: + receipt = projectlib.load_json(path) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return errors + ["presentation decision artifact is invalid"] + if not isinstance(receipt, dict): + return errors + ["presentation decision artifact must be an object"] + expected = { + "schema_version": 1, + "status": "chosen", + "mode": "human", + "rationale_source": CHAT_PRESENTATION_RATIONALE_SOURCE, + "explicit_user_action": True, + "decision_id": presentation.get("decision_id"), + "actor": presentation.get("actor"), + "timestamp": presentation.get("timestamp"), + "presentation_mode": mode, + "plan_sha256": canonical_sha256(presentation_subject(plan)), + "candidate_manifest_sha256": canonical_sha256(candidate_manifest(plan)), + "review_video_sha256": plan.get("input_hashes", {}).get("review_video_sha256"), + } + for field, value in expected.items(): + if receipt.get(field) != value: + errors.append(f"presentation decision {field} does not match") + if not isinstance(receipt.get("user_response"), str) or not receipt["user_response"].strip(): + errors.append("presentation decision user_response is required") + recommendation = receipt.get("agent_recommendation") + if (not isinstance(recommendation, dict) + or recommendation.get("presentation_mode") not in PRESENTATION_MODES + or not isinstance(recommendation.get("rationale"), str) + or not recommendation["rationale"].strip()): + errors.append("presentation decision agent_recommendation is invalid") + return errors + + +def record_chat_presentation_decision(plan, decision, *, project_root): + """Persist an explicit Agent-chat route choice before any B-roll review page.""" + if not isinstance(plan, dict): + raise ValueError("plan must be an object") + if not isinstance(decision, dict): + raise ValueError("presentation decision must be an object") + if plan.get("presentation") is not None: + raise ValueError("presentation decision is already recorded; rebuild the plan to change it") + if any(key in plan for key in ("selection", "speaker_inset", "review_status", "visual_review")): + raise ValueError("presentation decision must precede selection and review") + if any(isinstance(shot, dict) and shot.get("status") not in {"candidates_ready", "skipped"} + for shot in plan.get("shots", [])): + raise ValueError("presentation decision requires candidates_ready or skipped shots") + expected = { + "mode": "human", + "rationale_source": CHAT_PRESENTATION_RATIONALE_SOURCE, + "explicit_user_action": True, + "plan_sha256": canonical_sha256(presentation_subject(plan)), + "candidate_manifest_sha256": canonical_sha256(candidate_manifest(plan)), + "review_video_sha256": plan.get("input_hashes", {}).get("review_video_sha256"), + } + for field, value in expected.items(): + if decision.get(field) != value: + raise ValueError(f"presentation decision {field} does not match") + if not _is_uuid(decision.get("decision_id")): + raise ValueError("presentation decision id is invalid") + if not isinstance(decision.get("actor"), str) or not decision["actor"].strip(): + raise ValueError("presentation decision actor is required") + if not _valid_timestamp(decision.get("timestamp")): + raise ValueError("presentation decision timestamp is invalid") + if not isinstance(decision.get("user_response"), str) or not decision["user_response"].strip(): + raise ValueError("presentation decision user_response is required") + mode = decision.get("presentation_mode") + recommendation = decision.get("agent_recommendation") + if mode not in PRESENTATION_MODES: + raise ValueError("presentation decision mode is invalid") + if (not isinstance(recommendation, dict) + or recommendation.get("presentation_mode") not in PRESENTATION_MODES + or not isinstance(recommendation.get("rationale"), str) + or not recommendation["rationale"].strip()): + raise ValueError("presentation decision agent_recommendation is invalid") + result = copy.deepcopy(plan) + if mode == "speaker-inset": + style = result.get("speaker_inset_style") + if style is None: + result["speaker_inset_style"] = speaker_inset.default_style() + elif not speaker_inset.style_enabled(style) or speaker_inset.style_errors(style): + raise ValueError("speaker-inset presentation requires enabled speaker_inset_style") + else: + result.pop("speaker_inset_style", None) + root = Path(project_root).resolve() + target = root / "work/b-roll/presentation-decision.json" + target.parent.mkdir(parents=True, exist_ok=True) + receipt = copy.deepcopy(decision) + receipt.update({"schema_version": 1, "status": "chosen"}) + part = target.with_suffix(".part.json") + try: + projectlib.write_json(part, receipt) + os.replace(part, target) + finally: + part.unlink(missing_ok=True) + result["presentation"] = { + "status": "chosen", + "mode": mode, + "path": "b-roll/presentation-decision.json", + "sha256": sha256_file(target), + "decision_id": decision["decision_id"], + "actor": decision["actor"].strip(), + "timestamp": decision["timestamp"], + } + errors = presentation_errors(result, project_root=root, required=True) + if errors: + target.unlink(missing_ok=True) + raise ValueError("invalid presentation decision: " + "; ".join(errors)) + return result + + def _decision_manifest(shots): if not isinstance(shots, list): return None @@ -128,24 +575,39 @@ def _decision_manifest(shots): if status == "skipped": decisions.append({"id": shot_id, "decision": "skip"}) continue - if status not in ("selected", "normalized", "verified"): + if status not in ("composite_pending", "selected", "normalized", "verified"): return None 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 +619,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 +647,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 +660,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 +695,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") @@ -230,6 +708,76 @@ def _review_errors(plan, shots): input_hashes = {} if review.get("review_video_sha256") != input_hashes.get("review_video_sha256"): errors.append("review video SHA-256 does not match") + speaker_review_required = ( + speaker_inset.style_enabled(plan.get("speaker_inset_style")) + and any( + isinstance(shot, dict) and shot.get("status") in { + "composite_pending", "selected", "normalized", "verified", + } + for shot in shots + ) + ) + if speaker_review_required: + if review.get("review_stage") != "composite": + errors.append("speaker inset approval review_stage must be composite") + speaker = plan.get("speaker_inset") + expected_speaker = { + "selection_sha256": plan.get("selection", {}).get("sha256"), + "analysis_sha256": speaker.get("analysis", {}).get("sha256") if isinstance(speaker, dict) else None, + "agent_input_sha256": speaker.get("agent_input", {}).get("sha256") if isinstance(speaker, dict) else None, + "preview_sha256": speaker.get("preview", {}).get("sha256") if isinstance(speaker, dict) else None, + "clearance_sha256": speaker.get("clearance", {}).get("sha256") if isinstance(speaker, dict) else None, + "style_sha256": canonical_sha256(plan.get("speaker_inset_style")), + } + for field, expected in expected_speaker.items(): + if review.get(field) != expected: + errors.append(f"review {field} does not match current speaker artifacts") + return errors + + +def _speaker_artifact_binding_errors(plan, shots): + style = plan.get("speaker_inset_style") + enabled = speaker_inset.style_enabled(style) + speaker = plan.get("speaker_inset") + if not enabled: + return ["speaker_inset artifacts require enabled speaker_inset_style"] if speaker is not None else [] + selected = any( + isinstance(shot, dict) and shot.get("status") in ("selected", "normalized", "verified") + for shot in shots + ) + if speaker is None: + return ["selected speaker inset shots require speaker_inset artifacts"] if selected else [] + if not isinstance(speaker, dict): + return ["speaker_inset must be an object"] + errors = [] + expected_paths = { + "analysis": "b-roll/speaker-inset-analysis.json", + "agent_input": "b-roll/speaker-inset-agent-input.json", + "preview": "b-roll/speaker-inset-preview.json", + "clearance": "b-roll/speaker-inset-clearance.json", + } + if any(key not in expected_paths for key in speaker): + errors.append("speaker_inset contains unsupported artifacts") + seen_missing = False + for name, expected_path in expected_paths.items(): + binding = speaker.get(name) + if binding is None: + seen_missing = True + if selected: + errors.append(f"selected speaker inset shots require {name} artifact") + continue + if seen_missing: + errors.append(f"speaker_inset {name} requires all preceding artifacts") + if not isinstance(binding, dict): + errors.append(f"speaker_inset {name} binding must be an object") + continue + if binding.get("path") != expected_path: + errors.append(f"speaker_inset {name} path is invalid") + if not _is_sha256(binding.get("sha256")): + errors.append(f"speaker_inset {name} SHA-256 is invalid") + selection = plan.get("selection") + if isinstance(selection, dict) and selection.get("style_sha256") != canonical_sha256(style): + errors.append("prepared selection speaker inset style SHA-256 is stale") return errors @@ -253,9 +801,10 @@ def _visual_review_errors(plan, *, project_root=None, verify_files=False): errors.append("visual review timestamp is invalid") if review.get("mode") == "human" and review.get("explicit_user_action") is not True: errors.append("human visual review requires explicit_user_action true") + required_checks = visual_review_checks(plan) checks = review.get("checks") - if (not isinstance(checks, dict) or set(checks) != set(VISUAL_REVIEW_CHECKS) - or any(checks[key] is not True for key in VISUAL_REVIEW_CHECKS)): + if (not isinstance(checks, dict) or set(checks) != set(required_checks) + or any(checks[key] is not True for key in required_checks)): errors.append("all visual checks must be true booleans") for name, expected_path in ( ("receipt", "work/b-roll/b-roll-visual-review.json"), @@ -290,6 +839,54 @@ def _range(value): return start, end +def coverage_summary(plan, planned=(), shortlisted=(), selected=()): + """Summarize internal B-roll coverage without changing plan validity.""" + duration = _positive_duration(plan.get("program_duration_s")) if isinstance(plan, dict) else None + duration = duration if duration is not None else 0.0 + shot_ranges = { + shot.get("id"): shot.get("program_range") + for shot in plan.get("shots", []) if isinstance(plan, dict) and isinstance(shot, dict) + } + + def stage_summary(members): + if isinstance(members, dict): + members = [members] + elif not isinstance(members, (list, tuple, set)): + members = [] + ranges = [] + for member in members: + value = shot_ranges.get(member) if isinstance(member, str) else member + interval = _range(value.get("program_range")) if isinstance(value, dict) and "program_range" in value else _range(value) + if not interval or duration <= 0: + continue + start, end = max(0.0, interval[0]), min(duration, interval[1]) + if end > start: + ranges.append((start, end)) + merged = [] + for start, end in sorted(ranges): + if not merged or start > merged[-1][1]: + merged.append([start, end]) + else: + merged[-1][1] = max(merged[-1][1], end) + covered = round(sum(end - start for start, end in merged), 9) + ratio = round(covered / duration, 9) if duration else 0.0 + status = ( + "below_target" if ratio < DYNAMIC_SOCIAL_MIN_RATIO + else "above_target" if ratio > DYNAMIC_SOCIAL_MAX_RATIO + else "within_target" + ) + return {"duration_s": covered, "ratio": ratio, "status": status} + + return { + "profile": "dynamic-social", + "target_min_ratio": DYNAMIC_SOCIAL_MIN_RATIO, + "target_max_ratio": DYNAMIC_SOCIAL_MAX_RATIO, + "planned": stage_summary(planned), + "shortlisted": stage_summary(shortlisted), + "selected": stage_summary(selected), + } + + def _timeline_source_ranges(program, timeline): clips = timeline.get("clips") if isinstance(timeline, dict) else None if not program or not isinstance(clips, list): @@ -479,6 +1076,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") @@ -527,6 +1295,14 @@ def _verified_overlays(plan): shot_ids.add(shot_id) if plan.get("review_status") != "approved": raise ValueError("review_status must be approved") + inset_enabled = speaker_inset.style_enabled(plan.get("speaker_inset_style")) + if inset_enabled: + for shot in plan["shots"]: + if shot.get("status") == "skipped": + continue + composition_errors = speaker_inset.normalized_composition_errors(plan, shot) + if composition_errors: + raise ValueError("; ".join(composition_errors)) errors = _review_errors(plan, plan["shots"]) if errors: raise ValueError("; ".join(errors)) @@ -541,8 +1317,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 +1332,13 @@ 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 inset_enabled and verification.get("composition_sha256") != canonical_sha256( + normalized.get("composition")): + raise ValueError("speaker inset verification composition SHA-256 is stale") + 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 +1453,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") @@ -678,7 +1469,10 @@ def validate_plan(plan, timeline, transcript, project=None, project_root=None, v if timeline_duration is not None and plan_duration != timeline_duration: errors.append("plan program_duration_s does not match timeline") brief = plan.get("brief") if not isinstance(brief, dict): errors.append("brief must be an object") - elif brief.get("density") != "selective": errors.append("brief density must be selective") + elif brief.get("density") != "dynamic-social": errors.append("brief density must be dynamic-social") + style = plan.get("speaker_inset_style") + if style is not None: + errors.extend(speaker_inset.style_errors(style)) duration = timeline_duration if timeline_duration is not None else 0 source_duration = _strict_finite_number(timeline.get("source_duration_s")) if source_duration is _INVALID_NUMBER or source_duration < 0: @@ -701,6 +1495,10 @@ def validate_plan(plan, timeline, transcript, project=None, project_root=None, v if not program or program[0] < 0 or program[1] <= program[0] or program[1] > duration: errors.append(f"{shot_id} program range is outside timeline") else: + if (frame_duration is not None + and (not _frame_aligned(program[0], frame_duration) + or not _frame_aligned(program[1], frame_duration))): + errors.append(f"{shot_id} program range must align to timeline frames") if previous_program_start is not None and program[0] < previous_program_start: errors.append("shots must be in chronological program order") previous_program_start = program[0] @@ -760,17 +1558,56 @@ def validate_plan(plan, timeline, transcript, project=None, project_root=None, v elif not path.is_file(): errors.append(f"{prefix} file is missing") elif candidate.get("sha256") != sha256_file(path): errors.append(f"{prefix} SHA-256 is stale") selected, status = shot.get("selected"), shot.get("status") - if not isinstance(status, str) or status not in ("planned", "candidates_ready", "selected", "normalized", "verified", "skipped"): errors.append(f"{shot_id} status is invalid") + if not isinstance(status, str) or status not in ("planned", "candidates_ready", "composite_pending", "selected", "normalized", "verified", "skipped"): errors.append(f"{shot_id} status is invalid") 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": + elif status in {"composite_pending", "selected", "normalized", "verified"} and not isinstance(selected, dict): errors.append(f"{shot_id} {status} shot requires a selection") + elif status in {"composite_pending", "selected", "normalized", "verified"}: + 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): @@ -780,18 +1617,47 @@ def validate_plan(plan, timeline, transcript, project=None, project_root=None, v errors.append(f"{shot_id} normalized path is invalid") if not _is_sha256(normalized.get("sha256")): errors.append(f"{shot_id} normalized SHA-256 is invalid") + errors.extend(speaker_inset.normalized_composition_errors(plan, shot)) elif "normalized" in shot: errors.append(f"{shot_id} {status} shot must not carry normalized") if status == "verified": verification = shot.get("verification") if not isinstance(verification, dict) or verification.get("status") != "pass": errors.append(f"{shot_id} verified verification must pass") + elif (speaker_inset.style_enabled(style) + and verification.get("composition_sha256") != canonical_sha256( + shot.get("normalized", {}).get("composition"))): + errors.append(f"{shot_id} speaker inset verification composition SHA-256 is stale") elif "verification" in shot: errors.append(f"{shot_id} {status} shot must not carry verification") + if any(isinstance(shot, dict) and shot.get("status") == "composite_pending" for shot in shots): + if not speaker_inset.style_enabled(style): + errors.append("composite_pending shots require enabled speaker_inset_style") + selection = plan.get("selection") + if not isinstance(selection, dict) or selection.get("status") != "prepared": + errors.append("composite_pending shots require a prepared selection") + elif not _is_sha256(selection.get("sha256")): + errors.append("prepared selection SHA-256 is invalid") + elif selection.get("path") != "b-roll/broll-selection.json": + errors.append("prepared selection path is invalid") + elif verify_files and project_root: + selection_path = Path(project_root).resolve() / "work" / selection["path"] + if not selection_path.is_file(): + errors.append("prepared selection file is missing") + elif sha256_file(selection_path) != selection["sha256"]: + errors.append("prepared selection SHA-256 is stale") + errors.extend(_speaker_artifact_binding_errors(plan, shots)) + errors.extend(speaker_inset.artifact_errors( + plan, timeline, transcript, + project_root=project_root, verify_files=verify_files, + )) for start, end, shot_id in sorted(ranges): 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 +1773,465 @@ 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 + for key in ( + "candidate_ranking", "presentation", "speaker_inset_style", "selection", + "speaker_inset", "review_status", "visual_review", + ): + result.pop(key, 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_exact_entries(result, entries, *, timeline, explicit_intent, target_status, action): + shots = {shot.get("id"): shot for shot in result["shots"]} + seen = set() + for entry in entries: + shot_id = entry.get("id") + if not isinstance(shot_id, str) or not shot_id.strip(): + raise ValueError("review shot id is required") + if shot_id in seen: + raise ValueError(f"duplicate review shot id: {shot_id}") + seen.add(shot_id) + if shot_id not in shots: + raise ValueError(f"review has unknown shots: {shot_id}") + missing = sorted(set(shots) - seen) + if missing: + raise ValueError("review is missing shots: " + ", ".join(missing)) + + entries_by_id = {entry["id"]: entry for entry in entries} + selected_hashes = [] + decision_skipped_ids = [] + for shot in result["shots"]: + entry = entries_by_id[shot["id"]] + decision = entry.get("decision") + if decision not in ("select", "skip"): + raise ValueError(f"{shot['id']} decision must be select or skip") + if shot.get("status") == "skipped" and decision != "skip": + raise ValueError(f"{shot['id']} was already skipped and requires decision skip") + 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']} {action} 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 + + 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']} {action} 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']} {action} 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) + 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"] = {"segments": copy.deepcopy(segments)} + shot["status"] = target_status + selected_hashes.extend(item["sha256"] for item 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") + if option == "ken_burns" and not _valid_ken_burns(entry.get(option)): + raise ValueError(f"{shot['id']} select requires a valid ken_burns direction") + shot["selected"] = { + "candidate_id": candidate["id"], option: copy.deepcopy(entry[option]), + } + shot["status"] = target_status + selected_hashes.append(candidate["sha256"]) + return selected_hashes, decision_skipped_ids + + +def prepare_composite(plan, selection, *, mode, actor, rationale, project_root, timeline): + """Freeze exact B-roll choices without creating approval or normalization authority.""" + if not isinstance(plan, dict): + raise ValueError("plan must be an object") + if not isinstance(selection, dict): + raise ValueError("selection must be an object") + if selection.get("submission_intent") != "prepare_composite": + raise ValueError("submission_intent must be prepare_composite") + notes = selection.get("revision_notes", "") + if not isinstance(notes, str) or notes.strip(): + raise ValueError("prepare_composite requires empty revision_notes") + if not speaker_inset.style_enabled(plan.get("speaker_inset_style")): + raise ValueError("prepare_composite requires enabled speaker_inset_style") + style_validation = speaker_inset.style_errors(plan["speaker_inset_style"]) + if style_validation: + raise ValueError("invalid speaker_inset_style: " + "; ".join(style_validation)) + presentation_validation = presentation_errors( + plan, project_root=project_root, required=True, + ) + if presentation_validation: + raise ValueError( + "invalid presentation decision: " + "; ".join(presentation_validation) + ) + + plan_shots, entries = plan.get("shots"), selection.get("shots") + if not isinstance(plan_shots, list): + raise ValueError("plan shots must be a list") + if not isinstance(entries, list): + raise ValueError("selection shots must be a list") + for shot in plan_shots: + if not isinstance(shot, dict): + raise ValueError("plan shot must be an object") + candidates = shot.get("candidates", []) + if not isinstance(candidates, list): + raise ValueError(f"{shot.get('id', '')} candidates must be a list") + for candidate in candidates: + candidate_validation = _candidate_errors(shot.get("id", ""), candidate) + if candidate_validation: + raise ValueError("; ".join(candidate_validation)) + if any(not isinstance(entry, dict) for entry in entries): + raise ValueError("selection shot must be an object") + if mode not in ("human", "agent"): + raise ValueError("mode must be human or agent") + if not isinstance(actor, str) or not actor.strip(): + raise ValueError("actor is required") + if not isinstance(rationale, str) or not rationale.strip(): + raise ValueError("rationale is required") + rationale = rationale.strip() + if (not isinstance(selection.get("rationale"), str) + or selection["rationale"].strip() != rationale): + raise ValueError("exported rationale does not match selection rationale") + if not _valid_timestamp(selection.get("timestamp")): + raise ValueError("selection timestamp is invalid") + if mode == "human" and selection.get("explicit_user_action") is not True: + raise ValueError("human selection requires explicit_user_action true") + rationale_source = selection.get("rationale_source") + if mode == "human" and ( + rationale != HUMAN_PREPARE_COMPOSITE_RATIONALE + or rationale_source != "review_ui_explicit_action"): + raise ValueError("human prepare_composite requires the explicit review UI action rationale") + if not _is_uuid(selection.get("review_id")): + raise ValueError("review_id must be a UUID") + input_hashes = plan.get("input_hashes") + if not isinstance(input_hashes, dict): + raise ValueError("plan input_hashes must be an object") + 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"), + } + if not _is_sha256(expected_bindings["review_video_sha256"]): + raise ValueError("plan review_video_sha256 is invalid") + for field, expected in expected_bindings.items(): + if selection.get(field) != expected: + raise ValueError(f"{field} does not match current review artifacts") + if selection.get("timeline_fps") != timeline.get("fps"): + raise ValueError("timeline_fps does not match canonical timeline") + + result = copy.deepcopy(plan) + result["decision"] = None + result["review"] = None + result.pop("review_status", None) + result.pop("visual_review", None) + result.pop("selection", None) + selected_hashes, decision_skipped_ids = _apply_exact_entries( + result, entries, timeline=timeline, explicit_intent=True, + target_status="composite_pending", action="prepare_composite", + ) + decisions = _decision_manifest(result["shots"]) + if decisions is None: + raise ValueError("selection decision manifest cannot be reconstructed") + receipt = { + "schema_version": 1, + "status": "prepared", + "submission_intent": "prepare_composite", + "review_id": selection["review_id"], + "mode": mode, + "actor": actor.strip(), + "rationale": rationale, + "rationale_source": rationale_source, + "timestamp": selection["timestamp"], + "explicit_user_action": selection.get("explicit_user_action") is True, + **expected_bindings, + "style_sha256": canonical_sha256(plan["speaker_inset_style"]), + "timeline_fps": copy.deepcopy(timeline["fps"]), + "decisions": decisions, + "decision_skipped_shot_ids": sorted(set(decision_skipped_ids)), + "selected_asset_sha256": sorted(set(selected_hashes)), + } + root = Path(project_root).resolve() + target = root / "work/b-roll/broll-selection.json" + target.parent.mkdir(parents=True, exist_ok=True) + temp = None + try: + with tempfile.NamedTemporaryFile( + "w", delete=False, dir=target.parent, suffix=".json", encoding="utf-8") as handle: + temp = Path(handle.name) + projectlib.write_json(temp, receipt) + os.replace(temp, target) + finally: + if temp is not None: + temp.unlink(missing_ok=True) + result["selection"] = { + "status": "prepared", + "submission_intent": "prepare_composite", + "path": "b-roll/broll-selection.json", + "sha256": sha256_file(target), + "review_id": receipt["review_id"], + "mode": receipt["mode"], + "actor": receipt["actor"], + "timestamp": receipt["timestamp"], + "style_sha256": receipt["style_sha256"], + } + 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 +2258,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") @@ -946,51 +2272,86 @@ def apply_review(plan, review, *, mode, actor, rationale, interaction_path=None) "candidate_manifest_sha256": canonical_sha256(candidate_manifest(plan)), "review_video_sha256": input_hashes.get("review_video_sha256"), } + all_skipped = ( + len(entries) == len(plan_shots) + and all( + isinstance(entry, dict) and entry.get("decision") == "skip" + for entry in entries + ) + ) + composite_review = ( + speaker_inset.style_enabled(plan.get("speaker_inset_style")) + and not all_skipped + ) + if composite_review: + if review.get("review_stage") != "composite": + raise ValueError("speaker inset approval review_stage must be composite") + speaker = plan.get("speaker_inset") + if not isinstance(speaker, dict): + raise ValueError("speaker inset approval requires speaker artifacts") + expected_bindings.update({ + "selection_sha256": plan.get("selection", {}).get("sha256"), + "analysis_sha256": speaker.get("analysis", {}).get("sha256"), + "agent_input_sha256": speaker.get("agent_input", {}).get("sha256"), + "preview_sha256": speaker.get("preview", {}).get("sha256"), + "clearance_sha256": speaker.get("clearance", {}).get("sha256"), + "style_sha256": canonical_sha256(plan.get("speaker_inset_style")), + }) + if any(not _is_sha256(value) for field, value in expected_bindings.items() + if field not in ("plan_sha256", "candidate_manifest_sha256", "review_video_sha256")): + raise ValueError("speaker inset approval requires complete current artifact bindings") if not isinstance(expected_bindings["review_video_sha256"], str) or len(expected_bindings["review_video_sha256"]) != 64 or any(char not in "0123456789abcdefABCDEF" for char in expected_bindings["review_video_sha256"]): raise ValueError("plan review_video_sha256 is invalid") for field, expected in expected_bindings.items(): if review.get(field) != expected: raise ValueError(f"{field} does not match current review artifacts") - result, shots = copy.deepcopy(plan), {shot.get("id"): shot for shot in plan_shots} + result = copy.deepcopy(plan) result.pop("visual_review", None) - seen = set() - for entry in entries: - shot_id = entry.get("id") - if not isinstance(shot_id, str) or not shot_id.strip(): raise ValueError("review shot id is required") - if shot_id in seen: raise ValueError(f"duplicate review shot id: {shot_id}") - seen.add(shot_id) - if shot_id not in shots: raise ValueError(f"review has unknown shots: {shot_id}") - missing = sorted(set(shots) - seen) - if missing: raise ValueError("review is missing shots: " + ", ".join(missing)) - entries_by_id = {entry["id"]: entry for entry in entries} - selected_hashes = [] - decision_skipped_ids = [] - for shot in result["shots"]: - entry, decision = entries_by_id[shot["id"]], entries_by_id[shot["id"]].get("decision") - if decision not in ("select", "skip"): raise ValueError(f"{shot['id']} decision must be select or skip") - if shot.get("status") == "skipped" and decision != "skip": - raise ValueError(f"{shot['id']} was already skipped and requires decision skip") - shot.pop("normalized", None) - shot.pop("verification", None) - if decision == "skip": - 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") - 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") - if option == "ken_burns" and not _valid_ken_burns(entry.get(option)): - raise ValueError(f"{shot['id']} select requires a valid ken_burns direction") - shot["selected"], shot["status"] = {"candidate_id": candidate["id"], option: copy.deepcopy(entry[option])}, "selected"; selected_hashes.append(candidate["sha256"]) + if explicit_intent and review.get("timeline_fps") != timeline.get("fps"): + raise ValueError("timeline_fps does not match canonical timeline") + if composite_review: + locked_decisions = _decision_manifest(plan_shots) + if entries != locked_decisions: + raise ValueError("composite review shots must match the locked selection") + selected_hashes = [] + decision_skipped_ids = [] + for shot in result["shots"]: + if shot.get("status") == "composite_pending": + shot["status"] = "selected" + candidates = shot.get("candidates", []) + for candidate_id in selected_candidate_ids(shot["selected"]): + candidate = next( + (item for item in candidates + if isinstance(item, dict) and item.get("id") == candidate_id), + None, + ) + if candidate is None: + raise ValueError(f"{shot['id']} locked selection candidate is missing") + selected_hashes.append(candidate["sha256"]) + elif shot.get("status") == "skipped": + decision_skipped_ids.append(shot["id"]) + else: + raise ValueError("composite approval requires composite_pending or skipped shots") + else: + selected_hashes, decision_skipped_ids = _apply_exact_entries( + result, entries, timeline=timeline, explicit_intent=explicit_intent, + target_status="selected", action="approve", + ) decisions = _decision_manifest(result["shots"]) if decisions is 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 composite_review: + result["review"]["review_stage"] = "composite" + 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..16e4be5dd 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 @@ -17,6 +18,7 @@ import broll_plan import projectlib +import speaker_inset TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "assets" / "broll-review.html" PAYLOAD_MARKER = "__BROLL_REVIEW_DATA__" @@ -69,15 +71,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,17 +138,184 @@ 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 +def _load_speaker_document(plan, root, name): + binding = plan.get("speaker_inset", {}).get(name) + if not isinstance(binding, dict): + raise ValueError(f"speaker inset {name} binding is missing") + path = root / "work" / str(binding.get("path", "")) + if not _inside(root / "work", path) or not path.is_file(): + raise ValueError(f"speaker inset {name} path is invalid") + if _hash(path) != binding.get("sha256"): + raise ValueError(f"speaker inset {name} SHA-256 is stale") + return projectlib.load_json(path) + + +def _composite_payload(plan, root, assets_dir): + analysis = _load_speaker_document(plan, root, "analysis") + agent_input = _load_speaker_document(plan, root, "agent_input") + preview = _load_speaker_document(plan, root, "preview") + clearance = _load_speaker_document(plan, root, "clearance") + analysis_shots = {item["shot_id"]: item for item in analysis["shots"]} + agent_shots = {item["shot_id"]: item for item in agent_input["shots"]} + preview_shots = {item["shot_id"]: item for item in preview["shots"]} + clearance_shots = {item["shot_id"]: item for item in clearance["shots"]} + asset_specs = [] + asset_names = {} + + def freeze(binding, label): + source = root / "work" / str(binding.get("path", "")) + if not _inside(root / "work", source) or not source.is_file(): + raise ValueError(f"{label} path is invalid") + digest = binding.get("sha256") + if _hash(source) != digest: + raise ValueError(f"{label} SHA-256 is stale") + key = (str(source), digest) + if key not in asset_names: + suffix = source.suffix.lower() if re.fullmatch(r"\.[a-zA-Z0-9]{1,8}", source.suffix) else "" + basename = f"speaker-{len(asset_names) + 1:03d}{suffix}" + asset_names[key] = basename + asset_specs.append((source, basename, digest)) + return { + "path": f"{assets_dir.name}/{asset_names[key]}", + "sha256": digest, + } + + payload_shots = [] + pre_skipped_ids = [] + for shot_index, shot in enumerate(plan["shots"], 1): + if shot["status"] == "skipped": + pre_skipped_ids.append(shot["id"]) + continue + if shot["status"] != "composite_pending": + raise ValueError("composite review requires composite_pending or skipped shots") + shot_id = shot["id"] + if any(shot_id not in values for values in ( + analysis_shots, agent_shots, preview_shots, clearance_shots)): + raise ValueError(f"{shot_id} speaker artifacts are incomplete") + candidates = {item["id"]: item for item in shot["candidates"]} + selected_candidates = [] + for candidate_index, candidate_id in enumerate( + broll_plan.selected_candidate_ids(shot["selected"]), 1): + candidate = candidates[candidate_id] + source = broll_plan._candidate_path(root, candidate["cache_path"]) + if source is None or not source.is_file() or _hash(source) != candidate["sha256"]: + raise ValueError(f"{shot_id} selected candidate is stale") + suffix = source.suffix.lower() if re.fullmatch(r"\.[a-zA-Z0-9]{1,8}", source.suffix) else "" + basename = f"locked-candidate-{shot_index:03d}-{candidate_index:03d}{suffix}" + asset_specs.append((source, basename, candidate["sha256"])) + selected_candidates.append({ + "id": candidate_id, + "media_type": candidate["media_type"], + "path": f"{assets_dir.name}/{basename}", + "sha256": candidate["sha256"], + "provenance": copy.deepcopy(candidate["provenance"]), + }) + analysis_subshots = { + item["id"]: item for item in analysis_shots[shot_id]["subshots"] + } + agent_subshots = { + item["id"]: item for item in agent_shots[shot_id]["subshots"] + } + clearance_subshots = { + item["id"]: item for item in clearance_shots[shot_id]["subshots"] + } + subshots = [] + for subshot_id, analysis_subshot in analysis_subshots.items(): + agent_subshot = agent_subshots[subshot_id] + clearance_subshot = clearance_subshots[subshot_id] + evidence_frames = [] + seen_frames = set() + for point in analysis_subshot.get("evidence_points", []): + for frame in point.get("frames", []): + key = (frame.get("path"), frame.get("sha256")) + if key in seen_frames: + continue + seen_frames.add(key) + frozen = freeze(frame, f"{subshot_id} evidence frame") + frozen["program_time_s"] = frame.get("program_time_s") + evidence_frames.append(frozen) + subshots.append({ + "id": subshot_id, + "program_range": copy.deepcopy(analysis_subshot["program_range"]), + "speaker_status": agent_subshot["speaker_status"], + "speaker_rationale": agent_subshot["rationale"], + "keyframes": copy.deepcopy(agent_subshot["keyframes"]), + "display_mode": clearance_subshot["display_mode"], + "anchor": clearance_subshot.get("anchor"), + "clearance_status": clearance_subshot["clearance_status"], + "checked_anchors": copy.deepcopy(clearance_subshot["checked_anchors"]), + "subject_legibility": clearance_subshot["subject_legibility"], + "clearance_rationale": clearance_subshot["rationale"], + "evidence_frames": evidence_frames, + }) + preview_shot = preview_shots[shot_id] + payload_shot = { + "id": shot_id, + "program_range": copy.deepcopy(shot["program_range"]), + "locked_selection": copy.deepcopy(shot["selected"]), + "layout_recommendation": copy.deepcopy( + agent_shots[shot_id]["layout_recommendation"] + ), + "selected_candidates": selected_candidates, + "base_broll": freeze(preview_shot["base_broll"], f"{shot_id} base B-roll"), + "preview": freeze(preview_shot["preview"], f"{shot_id} contextual preview"), + "anchor_previews": { + anchor: freeze(binding, f"{shot_id} {anchor} preview") + for anchor, binding in preview_shot["anchor_previews"].items() + }, + "continuity": copy.deepcopy(clearance_shots[shot_id]["continuity"]), + "subshots": subshots, + } + if isinstance(preview_shot.get("alternate_preview"), dict): + alternate = preview_shot["alternate_preview"] + payload_shot["alternate_preview"] = { + "preset": alternate["preset"], + "anchor": alternate["anchor"], + **freeze(alternate, f"{shot_id} alternate preview"), + } + payload_shots.append(payload_shot) + return ( + payload_shots, asset_specs, pre_skipped_ids, + copy.deepcopy(agent_input["project_layout_strategy"]), + ) + + def _write_alias(page, alias): with tempfile.NamedTemporaryFile(dir=alias.parent, delete=False) as handle: staged = Path(handle.name) @@ -141,6 +359,13 @@ def build_review_page(plan, timeline, transcript, video, output_dir, *, project_ errors = broll_plan.validate_plan(plan, canonical_timeline, canonical_transcript, project=canonical_values["project"], project_root=root, verify_files=True) if errors: raise ValueError("invalid plan: " + "; ".join(errors)) + presentation_errors = broll_plan.presentation_errors( + plan, project_root=root, required=True, + ) + if presentation_errors: + raise ValueError( + "invalid presentation decision: " + "; ".join(presentation_errors) + ) expected_video_hash = plan.get("input_hashes", {}).get("review_video_sha256") if not isinstance(expected_video_hash, str) or len(expected_video_hash) != 64 or any(char not in "0123456789abcdefABCDEF" for char in expected_video_hash): raise ValueError("plan review video SHA-256 is invalid") @@ -159,7 +384,22 @@ 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) + speaker_enabled = speaker_inset.style_enabled( + plan.get("speaker_inset_style") + ) + review_mode = ( + "composite" if speaker_enabled and isinstance( + plan.get("speaker_inset", {}).get("clearance"), dict, + ) else "selection" if speaker_enabled else "standard" + ) + if review_mode == "composite": + shots, candidate_specs, pre_skipped_ids, speaker_layout_strategy = _composite_payload( + plan, root, assets_dir, + ) + else: + 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 @@ -173,14 +413,31 @@ def build_review_page(plan, timeline, transcript, video, output_dir, *, project_ shutil.copyfile(source, frozen) if _hash(frozen) != digest: raise ValueError(f"candidate SHA-256 changed during review publication: {source}") - for index, shot in enumerate(shots, 1): - frame = staged_assets / f"frame-{index:03d}.jpg" - program = shot["program_range"] - _extract_frame(video, (float(program["start_s"]) + float(program["end_s"])) / 2, frame) - _validate_jpeg(frame) - shot["source_frame"]["sha256"] = _hash(frame) + if review_mode != "composite": + for index, shot in enumerate(shots, 1): + frame = staged_assets / f"frame-{index:03d}.jpg" + program = shot["program_range"] + _extract_frame(video, (float(program["start_s"]) + float(program["end_s"])) / 2, frame) + _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, "review_mode": review_mode, "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} + if review_mode == "composite": + speaker = plan["speaker_inset"] + payload.update({ + "speaker_style": copy.deepcopy(plan["speaker_inset_style"]), + "speaker_layout_strategy": speaker_layout_strategy, + "speaker_bindings": { + "selection_sha256": plan["selection"]["sha256"], + "analysis_sha256": speaker["analysis"]["sha256"], + "agent_input_sha256": speaker["agent_input"]["sha256"], + "preview_sha256": speaker["preview"]["sha256"], + "clearance_sha256": speaker["clearance"]["sha256"], + "style_sha256": broll_plan.canonical_sha256( + plan["speaker_inset_style"] + ), + }, + }) 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") @@ -191,7 +448,10 @@ def build_review_page(plan, timeline, transcript, video, output_dir, *, project_ os.replace(asset, assets_dir / asset.name) os.link(staged_page, page) published_page = True - hashes = {"page": _hash(page), **{asset.relative_to(output_dir).as_posix(): _hash(asset) for asset in assets_dir.glob("*.jpg")}} + hashes = {"page": _hash(page), **{ + asset.relative_to(output_dir).as_posix(): _hash(asset) + for asset in assets_dir.iterdir() if asset.is_file() + }} _write_alias(page, output_dir / "b-roll-review.html") except Exception: if published_page: 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..ccaff1b8e --- /dev/null +++ b/skills/video-add-b-roll/scripts/candidate_analysis.py @@ -0,0 +1,1553 @@ +"""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"} +SEMANTIC_ROLES = {"direct", "supportive", "atmospheric"} +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 _validate_search_context(value): + if not isinstance(value, dict) or set(value) != {"topic", "visual_direction", "keywords"}: + raise ValueError("brief search_context must contain exactly topic, visual_direction, and keywords") + for field in ("topic", "visual_direction"): + if not isinstance(value[field], str) or not value[field].strip(): + raise ValueError(f"brief search_context {field} must be a nonblank string") + keywords = value["keywords"] + if not isinstance(keywords, list) or not 1 <= len(keywords) <= 12: + raise ValueError("brief search_context keywords must contain one to twelve values") + if any(not isinstance(keyword, str) or not keyword.strip() for keyword in keywords): + raise ValueError("brief search_context keywords must be nonblank strings") + normalized = [keyword.strip().casefold() for keyword in keywords] + if len(normalized) != len(set(normalized)): + raise ValueError("brief search_context keywords must be unique") + return copy.deepcopy(value) + + +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") + brief = plan.get("brief") + search_context = _validate_search_context(brief.get("search_context") if isinstance(brief, dict) else None) + 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") + semantic_role = shot.get("semantic_role") + if not isinstance(semantic_role, str) or semantic_role not in SEMANTIC_ROLES: + raise ValueError(f"{shot['id']} semantic_role is invalid") + 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"], + "semantic_role": semantic_role, + "queries": copy.deepcopy(queries), + "query_results": copy.deepcopy(query_results), + "merged_candidates": merged + local_candidates, + }) + return {"schema_version": 1, "search_context": search_context, "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") + try: + _validate_search_context(analysis.get("search_context")) + except ValueError as error: + errors.append(f"candidate analysis {error}") + 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) + semantic_role = shot.get("semantic_role") + if not isinstance(semantic_role, str) or semantic_role not in SEMANTIC_ROLES: + errors.append(f"{shot_id} candidate analysis semantic_role is invalid") + 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") + brief = plan.get("brief") + plan_context = _validate_search_context(brief.get("search_context") if isinstance(brief, dict) else None) + search_context = _validate_search_context(search.get("search_context")) + if search_context != plan_context: + raise ValueError("candidate search context does not match plan") + plan_shot_ids = [] + for shot in plan["shots"]: + shot_id = shot.get("id") if isinstance(shot, dict) else None + if not isinstance(shot_id, str) or not shot_id.strip(): + raise ValueError("plan shot id is required") + plan_shot_ids.append(shot_id) + search_shot_ids = [] + for item in search["shots"]: + if not isinstance(item, dict): + raise ValueError("candidate search shot must be an object") + shot_id = item.get("shot_id") + if not isinstance(shot_id, str) or not shot_id.strip(): + raise ValueError("candidate search shot id is required") + if shot_id in search_shot_ids: + raise ValueError(f"duplicate candidate search shot id: {shot_id}") + search_shot_ids.append(shot_id) + if search_shot_ids != plan_shot_ids: + raise ValueError("candidate search shots do not match plan") + search_by_shot = dict(zip(search_shot_ids, search["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}") + semantic_role = shot.get("semantic_role") + if not isinstance(semantic_role, str) or semantic_role not in SEMANTIC_ROLES: + raise ValueError(f"{shot_id} semantic_role is invalid") + search_role = search_shot.get("semantic_role") + if not isinstance(search_role, str) or search_role not in SEMANTIC_ROLES: + raise ValueError(f"candidate search {shot_id} semantic_role is invalid") + if search_role != semantic_role: + raise ValueError(f"candidate search {shot_id} semantic_role does not match plan") + if search_shot.get("queries") != shot.get("queries"): + raise ValueError(f"candidate search {shot_id} queries do not match plan") + 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") + 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, + "semantic_role": shot["semantic_role"], + "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_context": copy.deepcopy(plan_context), + "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), + -(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") + warnings = copy.deepcopy(candidate.get("warnings", [])) + if entry["semantic_fit"] == 1 and "weak_semantic_match" not in warnings: + warnings.append("weak_semantic_match") + 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": warnings, + "warning_count": len(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 _write_coverage_summary(root, plan, ranking=None): + shortlisted_ids = { + shot.get("shot_id") for shot in ranking.get("shots", []) + if isinstance(shot, dict) and shot.get("top3") + } if isinstance(ranking, dict) else set() + shortlisted = [ + shot for shot in plan.get("shots", []) + if isinstance(shot, dict) and shot.get("id") in shortlisted_ids + ] if isinstance(plan, dict) else [] + final_path = Path(root) / "work/b-roll/coverage-summary.json" + final_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + dir=final_path.parent, prefix=f".{final_path.name}.", suffix=".tmp", delete=False) as handle: + staged_path = Path(handle.name) + try: + projectlib.write_json( + staged_path, + broll_plan.coverage_summary( + plan, + planned=plan.get("shots", []) if isinstance(plan, dict) else [], + shortlisted=shortlisted, + selected=[], + ), + ) + os.replace(staged_path, final_path) + finally: + staged_path.unlink(missing_ok=True) + + +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") + plan = projectlib.load_json(args.plan) + _write_coverage_summary(root, plan) + value = search_plan(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) + _write_coverage_summary(root, projectlib.load_json(root / "work/b-roll/broll-plan.json"), 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..324fcf3f5 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 @@ -175,13 +179,20 @@ def _contact_sheet(records, timeline, path): def _boundary_reel(selected, timeline, base_video, path): total = normalize_broll._number(timeline.get("program_duration_s"), "timeline program_duration_s") + width, height, num, den = normalize_broll._timeline_spec(timeline) + frame = den / num + + def snap_to_frame(value): + return min(total, max(0.0, round(value / frame) * frame)) + command = ["ffmpeg", "-y", "-loglevel", "error"] filters, outputs, expected = [], [], 0.0 for _, shot, _, overlay in selected: shot_start = float(shot["program_range"]["start_s"]) shot_end = float(shot["program_range"]["end_s"]) for boundary in (shot_start, shot_end): - window_start, window_end = max(0.0, boundary - 0.5), min(total, boundary + 0.5) + window_start = snap_to_frame(max(0.0, boundary - 0.5)) + window_end = snap_to_frame(min(total, boundary + 0.5)) overlap_start, overlap_end = max(window_start, shot_start), min(window_end, shot_end) command.extend(["-i", str(base_video), "-i", str(overlay)]) input_index = len(outputs) * 2 @@ -202,7 +213,6 @@ def _boundary_reel(selected, timeline, base_video, path): ]) _run(command, "boundary reel render failed") probe = normalize_broll._probe(path) - width, height, num, den = normalize_broll._timeline_spec(timeline) normalize_broll._check_probe(probe, width, height, num, den, expected) _run(["ffmpeg", "-v", "error", "-i", str(path), "-map", "0:v:0", "-f", "null", "-"], "boundary reel decode failed") @@ -279,6 +289,9 @@ def _visual_review_report(receipt): "jump_cuts": "Jump cuts", "entry_exit_boundaries": "Entry and exit boundaries", "grade_match": "Grade match", + "speaker_layout_fidelity": "Speaker layout fidelity", + "speaker_legibility": "Speaker legibility", + "broll_focal_clearance": "B-roll focal clearance", } lines = [ "# B-roll visual review", "", "Visual review status: completed", "", @@ -288,7 +301,7 @@ def _visual_review_report(receipt): f"- Reviewed plan SHA-256: `{receipt['plan_sha256']}`", f"- Rationale: {receipt['rationale']}", "", "## Visual checks", "", ] - lines.extend(f"- [x] {labels[key]}: pass" for key in broll_plan.VISUAL_REVIEW_CHECKS) + lines.extend(f"- [x] {labels[key]}: pass" for key in receipt["checks"]) lines.extend(["", "## Bound artifacts", ""]) artifacts = receipt["artifacts"] for still in artifacts["stills"]: @@ -357,16 +370,17 @@ def complete_visual_review(plan_path, project_root, review, final_video): raise ValueError("visual review timestamp is invalid") if mode == "human" and review.get("explicit_user_action") is not True: raise ValueError("human visual review requires explicit_user_action true") + required_checks = broll_plan.visual_review_checks(plan) checks = review.get("checks") - if (not isinstance(checks, dict) or set(checks) != set(broll_plan.VISUAL_REVIEW_CHECKS) - or any(checks[key] is not True for key in broll_plan.VISUAL_REVIEW_CHECKS)): + if (not isinstance(checks, dict) or set(checks) != set(required_checks) + or any(checks[key] is not True for key in required_checks)): raise ValueError("all visual checks must be true booleans") artifacts = _visual_review_artifacts(plan, root, final_video) receipt = { "schema_version": 1, "status": "completed", "review_id": active["review_id"], "plan_sha256": plan_sha256, "mode": mode, "actor": actor.strip(), "rationale": rationale.strip(), "timestamp": review["timestamp"], - "checks": {key: True for key in broll_plan.VISUAL_REVIEW_CHECKS}, + "checks": {key: True for key in required_checks}, "artifacts": artifacts, } if mode == "human": @@ -408,6 +422,17 @@ def complete_visual_review(plan_path, project_root, review, final_video): def _summary(plan, selected, records, artifacts, root, stage, destination, path): review = plan["review"] + recommendations = {} + agent_binding = plan.get("speaker_inset", {}).get("agent_input", {}) + if isinstance(agent_binding, dict) and isinstance(agent_binding.get("path"), str): + agent_path = root / "work" / agent_binding["path"] + if (agent_path.is_file() + and broll_plan.sha256_file(agent_path) == agent_binding.get("sha256")): + agent_input = projectlib.load_json(agent_path) + recommendations = { + item.get("shot_id"): item.get("layout_recommendation", {}) + for item in agent_input.get("shots", []) if isinstance(item, dict) + } lines = [ "# B-roll verification summary", "", "Manual review status: pending.", "", f"- Timeline ID: `{plan.get('timeline_id')}`", @@ -419,7 +444,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 +452,55 @@ 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')}`", ]) + composition = normalized.get("composition") + base = normalized.get("broll_base") + if isinstance(composition, dict) and isinstance(base, dict): + lines.extend([ + f"- Project primary preset: `{composition.get('project_primary_preset')}`", + f"- Shot layout preset: `{composition.get('layout_preset')}`", + f"- Layout recommendation rationale: {recommendations.get(shot.get('id'), {}).get('rationale', '')}", + f"- Final composite SHA-256: `{normalized['sha256']}`", + f"- B-roll base SHA-256: `{base.get('sha256')}`", + ]) + lines.extend( + f"- Composition {field}: `{value}`" + for field, value in composition.items() + ) + 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])}`)") @@ -735,6 +802,45 @@ def _commit(stage, review_dir, plan_path, result): _ignore_remove(plan_part) +def _write_coverage_summary(root, plan_path): + plan = projectlib.load_json(plan_path) + ranking_binding = plan.get("candidate_ranking") + ranking_path = None + ranking_sha256 = None + shortlists = [] + if isinstance(ranking_binding, dict): + ranking_path = root / "work" / ranking_binding["path"] + ranking_sha256 = broll_plan.sha256_file(ranking_path) + shortlists = ranking_binding["shortlists"] + shortlisted_ids = { + shortlist.get("shot_id") for shortlist in shortlists + if shortlist.get("candidate_ids") + } + shots = plan.get("shots", []) + summary = { + "schema_version": 1, + "timeline_id": plan.get("timeline_id"), + "program_duration_s": plan.get("program_duration_s"), + "plan_sha256": broll_plan.sha256_file(plan_path), + "ranking_sha256": ranking_sha256, + **broll_plan.coverage_summary( + plan, + planned=shots, + shortlisted=[shot for shot in shots if shot.get("id") in shortlisted_ids], + selected=[shot for shot in shots if shot.get("status") != "skipped"], + ), + } + target = root / "work/b-roll/coverage-summary.json" + part = target.with_suffix(".part.json") + target.parent.mkdir(parents=True, exist_ok=True) + part.unlink(missing_ok=True) + try: + projectlib.write_json(part, summary) + os.replace(part, target) + finally: + part.unlink(missing_ok=True) + + def verify_plan(plan_path, timeline_path, project_root, video_path, *, review_dir=None): """Verify canonical normalized shots, publish review artifacts, and persist pass bindings.""" root = Path(project_root).resolve() @@ -790,12 +896,18 @@ def verify_plan(plan_path, timeline_path, project_root, video_path, *, review_di "boundary_reel": _hash_binding(reel, final_reel, root), "report": _hash_binding(summary, final_summary, root), } + composition = shot["normalized"].get("composition") + if isinstance(composition, dict): + shot["verification"]["composition_sha256"] = broll_plan.canonical_sha256( + composition + ) errors = broll_plan.validate_plan( result, timeline, transcript, project=project, project_root=root, verify_files=True ) if errors: raise ValueError("invalid verified B-roll plan: " + "; ".join(errors)) _commit(stage, destination, plan_path, result) + _write_coverage_summary(root, plan_path) return result, { "stills": final_stills, "contact_sheet": final_contact, "boundary_reel": final_reel, "summary": final_summary, diff --git a/skills/video-add-b-roll/scripts/normalize_broll.py b/skills/video-add-b-roll/scripts/normalize_broll.py index e516b40f0..6dcb84b58 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 @@ -14,6 +15,7 @@ import projectlib import broll_plan +import speaker_inset KEN_BURNS_DIRECTIONS = {"zoom-in", "pan-left", "pan-right"} @@ -173,20 +175,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 +241,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}:round=up"] + if option["format"] == "legacy": + filters.append(f"trim=duration={duration:.9f}") else: zoom, x, y = option inputs = ["-loop", "1", "-i", str(source)] @@ -229,8 +259,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 +271,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 +279,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 @@ -255,18 +421,134 @@ def normalize_shot(candidate, shot, timeline, destination, *, lut=None): def _validate_normalized(record, candidate, shot, timeline, output, root, grade_hashes): if not isinstance(record, dict): raise ValueError("normalized record is required") + if "composition" in record or "broll_base" in record: + composition = record.get("composition") + base = record.get("broll_base") + if not isinstance(composition, dict) or composition.get("kind") != "speaker-inset": + raise ValueError("speaker inset normalized composition is missing") + if not isinstance(base, dict): + raise ValueError("speaker inset normalized B-roll base is missing") + base_output = output.with_name(f"{output.stem}-base.mp4") + expected_base_path = base_output.relative_to(root / "work").as_posix() + if base.get("path") != expected_base_path: + raise ValueError("speaker inset normalized B-roll base path is stale") + base_record = copy.deepcopy(record) + base_record.update({ + "path": base.get("path"), "sha256": base.get("sha256"), + "probe": copy.deepcopy(base.get("probe")), + }) + base_record.pop("composition", None) + base_record.pop("broll_base", None) + _validate_normalized( + base_record, candidate, shot, timeline, base_output, root, grade_hashes, + ) + 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") + if record.get("sha256") != broll_plan.sha256_file(output): + raise ValueError("normalized output SHA-256 is stale") + width, height, num, den = _timeline_spec(timeline) + 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 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: @@ -274,7 +556,51 @@ def _validate_normalized(record, candidate, shot, timeline, output, root, grade_ subprocess.run(["ffmpeg", "-v", "error", "-i", str(output), "-map", "0:v:0", "-f", "null", "-"], check=True, capture_output=True) -def normalize_plan(plan_path, timeline_path, project_root, *, lut=None): +def _speaker_documents(plan, root): + speaker = plan.get("speaker_inset") + if not isinstance(speaker, dict): + raise ValueError("enabled speaker inset requires delivery composite artifacts") + documents = {} + for name in ("analysis", "agent_input", "preview", "clearance"): + binding = speaker.get(name) + if not isinstance(binding, dict) or not isinstance(binding.get("path"), str): + raise ValueError(f"enabled speaker inset requires {name} artifact") + path = root / "work" / binding["path"] + try: + path.resolve().relative_to((root / "work").resolve()) + except ValueError as exc: + raise ValueError(f"speaker inset {name} path escapes work") from exc + if not path.is_file() or binding.get("sha256") != broll_plan.sha256_file(path): + raise ValueError(f"speaker inset {name} artifact is missing or stale") + try: + documents[name] = projectlib.load_json(path) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"speaker inset {name} artifact is invalid") from exc + return documents + + +def _composition_record(plan, agent_input, shot): + recommendation = next( + item["layout_recommendation"] + for item in agent_input["shots"] if item.get("shot_id") == shot.get("id") + ) + speaker = plan["speaker_inset"] + return { + "kind": "speaker-inset", + "layout_preset": recommendation["preset"], + "project_primary_preset": agent_input["project_layout_strategy"]["primary_preset"], + "review_id": plan["review"]["review_id"], + "selection_sha256": plan["selection"]["sha256"], + "analysis_sha256": speaker["analysis"]["sha256"], + "agent_input_sha256": speaker["agent_input"]["sha256"], + "preview_sha256": speaker["preview"]["sha256"], + "clearance_sha256": speaker["clearance"]["sha256"], + "style_sha256": broll_plan.canonical_sha256(plan["speaker_inset_style"]), + "review_video_sha256": plan["input_hashes"]["review_video_sha256"], + } + + +def normalize_plan(plan_path, timeline_path, project_root, *, lut=None, review_video=None): """Validate, resume, and durably normalize each selected shot.""" root = Path(project_root).resolve() plan_path, timeline_path = Path(plan_path).resolve(), Path(timeline_path).resolve() @@ -294,6 +620,19 @@ def normalize_plan(plan_path, timeline_path, project_root, *, lut=None): raise ValueError("plan shots must be a list") if plan.get("review_status") != "approved": raise ValueError("review_status must be approved") + inset_selected = ( + speaker_inset.style_enabled(plan.get("speaker_inset_style")) + and any( + isinstance(shot, dict) and shot.get("status") != "skipped" + for shot in plan["shots"] + ) + ) + if inset_selected and review_video is None: + raise ValueError( + "enabled speaker inset requires delivery composite and explicit review_video" + ) + if not inset_selected and review_video is not None: + raise ValueError("review_video is only valid for enabled speaker inset") errors = broll_plan.validate_plan( plan, timeline, transcript, project=project, project_root=root, verify_files=True ) @@ -310,6 +649,13 @@ def normalize_plan(plan_path, timeline_path, project_root, *, lut=None): for key, digest in grade_hashes.items(): if input_hashes.get(key) != digest: raise ValueError(f"{key} is stale") + speaker_documents = None + review_path = None + if inset_selected: + review_path, timeline = speaker_inset.validate_review_video( + plan, timeline, review_video, root, + ) + speaker_documents = _speaker_documents(plan, root) result = copy.deepcopy(plan) for index, shot in enumerate(result["shots"], 1): if not isinstance(shot, dict): @@ -319,17 +665,59 @@ 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) - record["path"] = output.relative_to(root / "work").as_posix() + normalized_output = output + if inset_selected: + normalized_output = output.with_name(f"{output.stem}-base.mp4") + if "segments" in selected: + record = normalize_selection( + selected_candidates, shot, timeline, normalized_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, normalized_output, lut=lut_path, + ) + record["path"] = normalized_output.relative_to(root / "work").as_posix() + if inset_selected: + base_binding = { + "path": record["path"], + "sha256": record["sha256"], + "probe": copy.deepcopy(record["probe"]), + } + composite = speaker_inset.render_delivery_composite( + plan=plan, shot=shot, + analysis=speaker_documents["analysis"], + agent_input=speaker_documents["agent_input"], + preview=speaker_documents["preview"], + clearance=speaker_documents["clearance"], + timeline=timeline, style=plan["speaker_inset_style"], + base_video=normalized_output, review_video=review_path, + destination=output, project_root=root, + ) + record.update({ + "path": output.relative_to(root / "work").as_posix(), + "sha256": composite["sha256"], + "probe": copy.deepcopy(composite["probe"]), + "composition": _composition_record( + plan, speaker_documents["agent_input"], shot, + ), + "broll_base": base_binding, + }) shot["normalized"], shot["status"] = record, "normalized" plan_part = plan_path.with_suffix(".part.json") try: 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/scripts/speaker_inset.py b/skills/video-add-b-roll/scripts/speaker_inset.py new file mode 100644 index 000000000..dc5f6a565 --- /dev/null +++ b/skills/video-add-b-roll/scripts/speaker_inset.py @@ -0,0 +1,1901 @@ +"""Build and validate speaker-inset evidence and contextual previews.""" + +import copy +import hashlib +import json +import math +import os +import re +import shutil +import subprocess +import tempfile +import uuid +from datetime import datetime +from pathlib import Path + +from PIL import Image, ImageColor, ImageDraw, ImageFilter, ImageOps + +import projectlib + + +LAYOUT_PRESETS = ("focused-panel", "full-bleed-wash", "corner-pip") +PRESET_ANCHORS = { + "focused-panel": ("lower-center",), + "full-bleed-wash": ("upper-center",), + "corner-pip": ("top-left", "top-right"), +} +ALLOWED_ANCHORS = { + anchor for anchors in PRESET_ANCHORS.values() for anchor in anchors +} +ARTIFACT_SHOT_STATUSES = {"composite_pending", "selected", "normalized", "verified"} +STYLE_FIELDS = { + "enabled", "shape", "width_ratio", "aspect_ratio", "border", + "corner_radius_ratio", "margin_ratio", "reserved_bottom_ratio", +} +DEFAULT_STYLE = { + "enabled": True, + "shape": "rounded-rectangle", + "width_ratio": 0.39, + "aspect_ratio": 0.80, + "border": {"width_px": 3, "color": "#9E9E9E"}, + "corner_radius_ratio": 0.10, + "margin_ratio": 0.04, + "reserved_bottom_ratio": 0.20, +} +SPEAKER_STATUSES = {"confirmed", "ambiguous", "absent", "occluded"} +PRESET_ASSESSMENTS = {"pass", "warn", "fail"} +RECOMMENDATION_CONFIDENCE = {"high", "medium", "low"} +RANGE_EPSILON = 1e-6 +SHORT_FLASH_SECONDS = 1.5 + + +def _number(value): + if not isinstance(value, (int, float)) or isinstance(value, bool): + return None + number = float(value) + return number if math.isfinite(number) else None + + +def style_enabled(style): + return isinstance(style, dict) and style.get("enabled") is True + + +def default_style(): + return copy.deepcopy(DEFAULT_STYLE) + + +def style_errors(style): + """Return strict project-level speaker inset style errors.""" + if not isinstance(style, dict): + return ["speaker_inset_style must be an object"] + if not isinstance(style.get("enabled"), bool): + return ["speaker_inset_style enabled must be a boolean"] + if not style["enabled"]: + return [] + + errors = [] + unsupported = sorted(set(style) - STYLE_FIELDS) + for field in unsupported: + errors.append(f"speaker_inset_style {field} is unsupported; re-author the enabled style") + shape = style.get("shape") + if shape != "rounded-rectangle": + errors.append("speaker_inset_style shape must be rounded-rectangle") + width = _number(style.get("width_ratio")) + if width is None or not 0 < width < 1: + errors.append("speaker_inset_style width_ratio must be between 0 and 1") + aspect = _number(style.get("aspect_ratio")) + if aspect is None or aspect <= 0: + errors.append("speaker_inset_style aspect_ratio must be positive") + margin = _number(style.get("margin_ratio")) + if margin is None or not 0 <= margin < 0.5: + errors.append("speaker_inset_style margin_ratio must be between 0 and 0.5") + reserved = _number(style.get("reserved_bottom_ratio")) + if reserved is None or not 0 <= reserved < 1: + errors.append("speaker_inset_style reserved_bottom_ratio must be between 0 and 1") + + border = style.get("border") + if not isinstance(border, dict): + errors.append("speaker_inset_style border must be an object") + else: + border_width = border.get("width_px") + if (not isinstance(border_width, int) or isinstance(border_width, bool) + or border_width < 0): + errors.append("speaker_inset_style border width_px must be a nonnegative integer") + if not isinstance(border.get("color"), str) or not re.fullmatch( + r"#[0-9a-fA-F]{6}", border["color"]): + errors.append("speaker_inset_style border color must be #RRGGBB") + + corner = style.get("corner_radius_ratio") + if _number(corner) is None or not 0 <= float(corner) <= 0.5: + errors.append( + "speaker_inset_style corner_radius_ratio must be between 0 and 0.5 " + "for rounded-rectangle" + ) + if width is not None and margin is not None and width + 2 * margin > 1: + errors.append("speaker_inset_style width and margins do not fit the frame") + return errors + + +def _sha256_file(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 _canonical_sha256(value): + encoded = json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _json_document_sha256(value): + text = json.dumps(value, ensure_ascii=False, indent=2) + "\n" + encoded = text.replace("\n", os.linesep).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _is_sha256(value): + return ( + isinstance(value, str) and len(value) == 64 + and all(char in "0123456789abcdefABCDEF" for char in value) + ) + + +def _range(value): + if not isinstance(value, dict): + return None + start, end = _number(value.get("start_s")), _number(value.get("end_s")) + if start is None or end is None or end <= start: + return None + return start, end + + +def _fps(timeline): + value = timeline.get("fps") if isinstance(timeline, dict) else None + num = value.get("num") if isinstance(value, dict) else None + den = value.get("den") if isinstance(value, 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 num, den + + +def _frame_index(value, frame_duration): + number = _number(value) + if number is None: + return None + index = round(number / frame_duration) + return index if abs(number - index * frame_duration) <= RANGE_EPSILON else None + + +def _frame_time(index, frame_duration): + return round(index * frame_duration, 9) + + +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, path, label): + root, path = Path(root).resolve(), Path(path).resolve() + try: + path.relative_to(root) + except ValueError as exc: + raise ValueError(f"{label} must be inside project_root") from exc + return path + + +def _probe_video(path): + result = subprocess.run([ + "ffprobe", "-v", "error", "-show_entries", + "stream=codec_type,width,height,avg_frame_rate:format=duration", + "-of", "json", str(path), + ], check=True, capture_output=True, text=True, encoding="utf-8", errors="replace") + payload = json.loads(result.stdout) + streams = payload.get("streams") if isinstance(payload, dict) else None + video = next((item for item in streams or [] + if isinstance(item, dict) and item.get("codec_type") == "video"), None) + if not isinstance(video, dict): + raise ValueError("review video has no video stream") + width, height = video.get("width"), video.get("height") + raw_duration = payload.get("format", {}).get("duration") + try: + duration = float(raw_duration) + except (TypeError, ValueError): + duration = None + if duration is not None and not math.isfinite(duration): + duration = None + rate = str(video.get("avg_frame_rate", "")).split("/", 1) + if (not isinstance(width, int) or width <= 0 or not isinstance(height, int) or height <= 0 + or duration is None or duration <= 0 or len(rate) != 2): + raise ValueError("review video probe is invalid") + try: + num, den = int(rate[0]), int(rate[1]) + except ValueError as exc: + raise ValueError("review video fps is invalid") from exc + if num <= 0 or den <= 0: + raise ValueError("review video fps is invalid") + return { + "width": width, "height": height, "duration_s": duration, + "fps": {"num": num, "den": den}, + } + + +def _ffmpeg_version(): + result = subprocess.run( + ["ffmpeg", "-version"], check=True, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ) + return result.stdout.splitlines()[0].strip() + + +def _detect_scene_times(video, threshold): + if _number(threshold) is None or not 0 < float(threshold) < 1: + raise ValueError("scene threshold must be between 0 and 1") + expression = f"select='gt(scene,{float(threshold):.6f})',showinfo" + result = subprocess.run([ + "ffmpeg", "-hide_banner", "-i", str(video), "-vf", expression, + "-an", "-f", "null", os.devnull, + ], check=True, capture_output=True, text=True, encoding="utf-8", errors="replace") + return [ + float(value) for value in re.findall(r"pts_time:([0-9]+(?:\.[0-9]+)?)", result.stderr) + ] + + +def _extract_frame(video, time_s, output): + output = Path(output) + output.parent.mkdir(parents=True, exist_ok=True) + subprocess.run([ + "ffmpeg", "-v", "error", "-y", "-i", str(video), + "-ss", f"{float(time_s):.9f}", "-frames:v", "1", + "-vf", "scale=960:-2", str(output), + ], check=True) + + +def _validate_jpeg(path): + try: + with Image.open(path) as image: + image.verify() + with Image.open(path) as image: + width, height = image.size + if image.format != "JPEG" or width <= 0 or height <= 0: + raise ValueError + except (OSError, SyntaxError, ValueError) as exc: + raise ValueError(f"invalid evidence JPEG: {path}") from exc + + +def _atomic_json(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile( + "w", delete=False, dir=path.parent, suffix=".json", encoding="utf-8") as handle: + temporary = Path(handle.name) + projectlib.write_json(temporary, value) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _mapped_words(transcript, timeline): + mapped = projectlib.map_transcript_to_timeline(transcript, timeline) + return [ + word for segment in mapped.get("segments", []) if isinstance(segment, dict) + for word in segment.get("words", []) if isinstance(word, dict) + ] + + +def _candidate_boundaries(shot_range, timeline, scene_times, frame_duration): + start_frame = round(shot_range[0] / frame_duration) + end_frame = round(shot_range[1] / frame_duration) + sources = {start_frame: {"shot-start"}, end_frame: {"shot-end"}} + for clip in timeline.get("clips", []): + program = _range(clip.get("program_range")) if isinstance(clip, dict) else None + if not program: + continue + for value in program: + frame = round(value / frame_duration) + if start_frame < frame < end_frame: + sources.setdefault(frame, set()).add("timeline-cut") + for value in scene_times: + frame = round(float(value) / frame_duration) + if start_frame < frame < end_frame: + sources.setdefault(frame, set()).add("scene") + return sorted(sources), sources + + +def _point_frames(start_frame, end_frame): + last = end_frame - 1 + centers = sorted({start_frame, (start_frame + last) // 2, last}) + return [(center, sorted({ + max(start_frame, center - 1), center, min(last, center + 1), + })) for center in centers] + + +def _supplemental_frames(center, start_frame, end_frame): + last = end_frame - 1 + return list(range(max(start_frame, center - 3), min(last, center + 3) + 1)) + + +def _normalized_supplemental_points(value, frame_duration): + if value is None: + return {} + if not isinstance(value, dict): + raise ValueError("supplemental_points must be an object") + normalized = {} + for subshot_id, times in value.items(): + if not isinstance(subshot_id, str) or not subshot_id.strip(): + raise ValueError("supplemental_points subshot IDs must be non-empty strings") + if not isinstance(times, list) or not times: + raise ValueError(f"{subshot_id} supplemental_points must be a non-empty list") + frames = [] + for time_s in times: + number = _number(time_s) + if number is None: + raise ValueError(f"{subshot_id} supplemental point must be finite") + frames.append(round(number / frame_duration)) + if len(frames) != len(set(frames)): + raise ValueError(f"{subshot_id} supplemental points must be unique") + normalized[subshot_id] = [_frame_time(frame, frame_duration) for frame in sorted(frames)] + return normalized + + +def prepare_evidence(plan, timeline, transcript, review_video, project_root, *, + scene_threshold=0.32, supplemental_points=None): + """Extract immutable temporal evidence for every composite-pending shot.""" + if not isinstance(plan, dict) or not isinstance(timeline, dict) or not isinstance(transcript, dict): + raise ValueError("plan, timeline, and transcript must be objects") + root = Path(project_root).resolve() + video = _inside(root, review_video, "review video") + if not video.is_file(): + raise FileNotFoundError(f"review video is missing: {video}") + selection = plan.get("selection") + if not isinstance(selection, dict) or selection.get("status") != "prepared": + raise ValueError("prepared selection is required") + selection_path = root / "work" / str(selection.get("path", "")) + if (not selection_path.is_file() or not _is_sha256(selection.get("sha256")) + or _sha256_file(selection_path) != selection["sha256"]): + raise ValueError("prepared selection file is missing or stale") + style = plan.get("speaker_inset_style") + validation = style_errors(style) if style is not None else ["speaker_inset_style is required"] + if validation or not style_enabled(style): + raise ValueError("enabled speaker_inset_style is invalid: " + "; ".join(validation)) + expected_video_hash = plan.get("input_hashes", {}).get("review_video_sha256") + if not _is_sha256(expected_video_hash) or _sha256_file(video) != expected_video_hash: + raise ValueError("review video SHA-256 is stale") + num, den = _fps(timeline) + frame_duration = den / num + supplements = _normalized_supplemental_points(supplemental_points, frame_duration) + probe = _probe_video(video) + if probe.get("fps") != timeline.get("fps"): + raise ValueError("review video fps does not match timeline") + duration = _number(timeline.get("program_duration_s")) + if duration is None or abs(probe["duration_s"] - duration) > frame_duration + RANGE_EPSILON: + raise ValueError("review video duration does not match timeline") + scene_times = sorted({ + _frame_time(round(float(value) / frame_duration), frame_duration) + for value in _detect_scene_times(video, scene_threshold) + if _number(value) is not None and 0 < float(value) < duration + }) + ffmpeg_version = _ffmpeg_version() + subject = { + "timeline_sha256": plan.get("input_hashes", {}).get("timeline_sha256"), + "transcript_sha256": plan.get("input_hashes", {}).get("transcript_sha256"), + "review_video_sha256": expected_video_hash, + "selection_sha256": selection["sha256"], + "style_sha256": _canonical_sha256(style), + "scene_threshold": float(scene_threshold), + "scene_times": scene_times, + "supplemental_points": supplements, + "ffmpeg_version": ffmpeg_version, + } + analysis_id = _canonical_sha256(subject)[:20] + cache_root = root / "work/cache/b-roll/speaker-inset/evidence" + final_dir = cache_root / analysis_id + stage = cache_root / f".{analysis_id}-{uuid.uuid4().hex}.part" + stage.mkdir(parents=True, exist_ok=False) + words = _mapped_words(transcript, timeline) + shots = [] + try: + for shot in plan.get("shots", []): + if not isinstance(shot, dict) or shot.get("status") != "composite_pending": + continue + shot_range = _range(shot.get("program_range")) + if not shot_range: + raise ValueError(f"{shot.get('id')} program_range is invalid") + boundary_frames, boundary_sources = _candidate_boundaries( + shot_range, timeline, scene_times, frame_duration, + ) + subshots = [] + for index, (start_frame, end_frame) in enumerate( + zip(boundary_frames, boundary_frames[1:]), 1): + if end_frame <= start_frame: + continue + subshot_id = f"{shot['id']}-subshot-{index:03d}" + evidence_points = [] + for point_index, (center, burst) in enumerate( + _point_frames(start_frame, end_frame), 1): + frames = [] + for burst_index, frame_index in enumerate(burst, 1): + basename = ( + f"{shot['id']}-{index:03d}-{point_index:02d}-{burst_index:02d}.jpg" + ) + staged_frame = stage / basename + frame_time = _frame_time(frame_index, frame_duration) + _extract_frame(video, frame_time, staged_frame) + _validate_jpeg(staged_frame) + frames.append({ + "program_time_s": frame_time, + "path": ( + f"cache/b-roll/speaker-inset/evidence/{analysis_id}/{basename}" + ), + "sha256": _sha256_file(staged_frame), + }) + evidence_points.append({ + "program_time_s": _frame_time(center, frame_duration), + "evidence_kind": "baseline", + "frames": frames, + }) + for supplemental_index, time_s in enumerate( + supplements.get(subshot_id, []), 1): + center = round(time_s / frame_duration) + if not start_frame <= center < end_frame: + raise ValueError( + f"{subshot_id} supplemental point must remain inside its subshot" + ) + frames = [] + for burst_index, frame_index in enumerate( + _supplemental_frames(center, start_frame, end_frame), 1): + basename = ( + f"{shot['id']}-{index:03d}-supplemental-" + f"{supplemental_index:02d}-{burst_index:02d}.jpg" + ) + staged_frame = stage / basename + frame_time = _frame_time(frame_index, frame_duration) + _extract_frame(video, frame_time, staged_frame) + _validate_jpeg(staged_frame) + frames.append({ + "program_time_s": frame_time, + "path": ( + f"cache/b-roll/speaker-inset/evidence/{analysis_id}/{basename}" + ), + "sha256": _sha256_file(staged_frame), + }) + evidence_points.append({ + "program_time_s": _frame_time(center, frame_duration), + "evidence_kind": "supplemental", + "frames": frames, + }) + source_names = sorted(boundary_sources.get(start_frame, {"scene"})) + subshots.append({ + "id": subshot_id, + "program_range": { + "start_s": _frame_time(start_frame, frame_duration), + "end_s": _frame_time(end_frame, frame_duration), + }, + "boundary_source": "+".join(source_names), + "evidence_points": evidence_points, + }) + shots.append({ + "shot_id": shot["id"], + "program_range": copy.deepcopy(shot["program_range"]), + "transcript_words": [ + copy.deepcopy(word) for word in words + if (_range(word.get("program_range")) + and _range(word["program_range"])[0] >= shot_range[0] - RANGE_EPSILON + and _range(word["program_range"])[1] <= shot_range[1] + RANGE_EPSILON) + ], + "subshots": subshots, + }) + known_subshots = { + subshot["id"] for shot in shots for subshot in shot.get("subshots", []) + } + unknown_supplements = sorted(set(supplements) - known_subshots) + if unknown_supplements: + raise ValueError( + "supplemental_points reference unknown subshots: " + + ", ".join(unknown_supplements) + ) + if final_dir.exists(): + shutil.rmtree(final_dir) + final_dir.parent.mkdir(parents=True, exist_ok=True) + os.replace(stage, final_dir) + except Exception: + shutil.rmtree(stage, ignore_errors=True) + raise + + packet = { + "schema_version": 1, + "analysis_id": analysis_id, + **subject, + "timeline_fps": copy.deepcopy(timeline["fps"]), + "review_video_probe": probe, + "scene_detection": { + "algorithm": "ffmpeg-scene-score", + "threshold": float(scene_threshold), + "ffmpeg_version": ffmpeg_version, + "candidate_times_s": scene_times, + }, + "shots": shots, + } + output = root / "work/b-roll/speaker-inset-analysis.json" + _atomic_json(output, packet) + result = copy.deepcopy(plan) + result["speaker_inset"] = { + "analysis": { + "path": "b-roll/speaker-inset-analysis.json", + "sha256": _sha256_file(output), + "analysis_id": analysis_id, + } + } + return result + + +def analysis_errors(analysis, plan, timeline, transcript, *, project_root=None, verify_files=False): + errors = [] + if not isinstance(analysis, dict): + return ["speaker analysis must be an object"] + if analysis.get("schema_version") != 1: + errors.append("speaker analysis schema_version must be 1") + selection = plan.get("selection") if isinstance(plan, dict) else None + style = plan.get("speaker_inset_style") if isinstance(plan, dict) else None + input_hashes = plan.get("input_hashes", {}) if isinstance(plan, dict) else {} + expected = { + "selection_sha256": selection.get("sha256") if isinstance(selection, dict) else None, + "style_sha256": _canonical_sha256(style) if isinstance(style, dict) else None, + "review_video_sha256": input_hashes.get("review_video_sha256"), + "timeline_sha256": input_hashes.get("timeline_sha256"), + "transcript_sha256": input_hashes.get("transcript_sha256"), + } + for field, value in expected.items(): + if analysis.get(field) != value: + errors.append(f"speaker analysis {field} does not match") + if analysis.get("timeline_fps") != timeline.get("fps"): + errors.append("speaker analysis timeline_fps does not match") + try: + num, den = _fps(timeline) + frame_duration = den / num + except ValueError as exc: + return errors + [str(exc)] + expected_shots = { + shot.get("id"): shot for shot in plan.get("shots", []) + if isinstance(shot, dict) and shot.get("status") in ARTIFACT_SHOT_STATUSES + } + shots = analysis.get("shots") + if not isinstance(shots, list): + return errors + ["speaker analysis shots must be a list"] + if [shot.get("shot_id") for shot in shots if isinstance(shot, dict)] != list(expected_shots): + errors.append("speaker analysis shots do not match composite_pending shots") + for shot in shots: + if not isinstance(shot, dict) or shot.get("shot_id") not in expected_shots: + continue + expected_range = _range(expected_shots[shot["shot_id"]].get("program_range")) + subshots = shot.get("subshots") + if not isinstance(subshots, list) or not subshots: + errors.append(f"{shot['shot_id']} speaker analysis requires subshots") + continue + previous_end = expected_range[0] if expected_range else None + for subshot in subshots: + subshot_range = _range(subshot.get("program_range")) if isinstance(subshot, dict) else None + if not subshot_range: + errors.append(f"{shot['shot_id']} subshot range is invalid") + continue + if previous_end is None or abs(subshot_range[0] - previous_end) > RANGE_EPSILON: + errors.append(f"{shot['shot_id']} subshots must be continuous") + previous_end = subshot_range[1] + if (_frame_index(subshot_range[0], frame_duration) is None + or _frame_index(subshot_range[1], frame_duration) is None): + errors.append(f"{shot['shot_id']} subshots must align to timeline frames") + points = subshot.get("evidence_points") + if not isinstance(points, list) or not points: + errors.append(f"{subshot.get('id')} requires temporal evidence points") + continue + for point in points: + frames = point.get("frames") if isinstance(point, dict) else None + if not isinstance(point, dict) or point.get("evidence_kind") not in { + "baseline", "supplemental"}: + errors.append( + f"{subshot.get('id')} evidence point kind must be baseline or supplemental" + ) + if not isinstance(frames, list) or not frames: + errors.append(f"{subshot.get('id')} evidence point requires frames") + continue + for frame in frames: + if not isinstance(frame, dict) or not _is_sha256(frame.get("sha256")): + errors.append(f"{subshot.get('id')} evidence frame binding is invalid") + continue + if verify_files and project_root: + path = Path(project_root).resolve() / "work" / str(frame.get("path", "")) + try: + path.resolve().relative_to((Path(project_root).resolve() / "work").resolve()) + except ValueError: + errors.append(f"{subshot.get('id')} evidence frame path escapes work") + else: + if not path.is_file(): + errors.append(f"{subshot.get('id')} evidence frame is missing") + elif _sha256_file(path) != frame["sha256"]: + errors.append(f"{subshot.get('id')} evidence frame SHA-256 is stale") + if expected_range and (previous_end is None or abs(previous_end - expected_range[1]) > RANGE_EPSILON): + errors.append(f"{shot['shot_id']} subshots must cover the shot") + return errors + + +def _roi_errors(roi): + if not isinstance(roi, dict): + return ["ROI must be an object"] + values = {key: _number(roi.get(key)) for key in ("x", "y", "width", "height")} + if any(value is None for value in values.values()): + return ["ROI values must be finite numbers"] + if values["width"] <= 0 or values["height"] <= 0: + return ["ROI must have positive area"] + if (values["x"] < 0 or values["y"] < 0 + or values["x"] + values["width"] > 1 + RANGE_EPSILON + or values["y"] + values["height"] > 1 + RANGE_EPSILON): + return ["ROI must remain inside the A-roll frame"] + return [] + + +def _preset_anchor_errors(preset, anchor, label): + if preset not in LAYOUT_PRESETS: + return [f"{label} preset is invalid"] + if anchor not in PRESET_ANCHORS[preset]: + return [f"{label} preset/anchor combination is invalid"] + return [] + + +def _recommendation_errors(recommendation, label): + if not isinstance(recommendation, dict): + return [f"{label} layout_recommendation must be an object"] + errors = _preset_anchor_errors( + recommendation.get("preset"), recommendation.get("anchor"), label, + ) + confidence = recommendation.get("confidence") + if confidence not in RECOMMENDATION_CONFIDENCE: + errors.append(f"{label} recommendation confidence is invalid") + if (not isinstance(recommendation.get("rationale"), str) + or not recommendation["rationale"].strip()): + errors.append(f"{label} recommendation rationale is required") + assessments = recommendation.get("preset_assessments") + if (not isinstance(assessments, dict) + or set(assessments) != set(LAYOUT_PRESETS) + or any(value not in PRESET_ASSESSMENTS for value in assessments.values())): + errors.append(f"{label} must assess all three presets with pass, warn, or fail") + assessments = {} + alternate = recommendation.get("alternate") + if alternate is None: + if confidence == "low" and any( + preset != recommendation.get("preset") + and assessments.get(preset) in {"pass", "warn"} + for preset in LAYOUT_PRESETS): + errors.append(f"{label} low-confidence recommendation requires alternate") + elif confidence != "low": + errors.append(f"{label} alternate is only valid for low confidence") + elif not isinstance(alternate, dict) or set(alternate) != {"preset", "anchor"}: + errors.append(f"{label} alternate must contain only preset and anchor") + else: + errors.extend(_preset_anchor_errors( + alternate.get("preset"), alternate.get("anchor"), f"{label} alternate", + )) + alternate_preset = alternate.get("preset") + if alternate_preset == recommendation.get("preset"): + errors.append(f"{label} alternate preset must differ from recommendation") + if assessments.get(alternate_preset) not in {"pass", "warn"}: + errors.append(f"{label} alternate preset assessment must not fail") + return errors + + +def _strategy_errors(strategy): + if not isinstance(strategy, dict): + return ["speaker Agent input project_layout_strategy must be an object"] + errors = [] + primary = strategy.get("primary_preset") + if primary not in LAYOUT_PRESETS: + errors.append("speaker Agent input primary_preset is invalid") + used = strategy.get("used_presets") + if (not isinstance(used, list) or not 1 <= len(used) <= 3 + or any(preset not in LAYOUT_PRESETS for preset in used) + or len(used) != len(set(used))): + errors.append("speaker Agent input used_presets must be unique supported presets") + elif used[0] != primary: + errors.append("speaker Agent input used_presets must start with primary_preset") + if (not isinstance(strategy.get("rationale"), str) + or not strategy["rationale"].strip()): + errors.append("speaker Agent input project layout rationale is required") + return errors + + +def agent_input_errors(agent_input, analysis, plan, timeline): + errors = [] + if not isinstance(agent_input, dict): + return ["speaker Agent input must be an object"] + if agent_input.get("schema_version") != 1: + errors.append("speaker Agent input schema_version must be 1") + if agent_input.get("mode") != "agent": + errors.append("speaker Agent input mode must be agent") + for field in ("actor", "rationale"): + if not isinstance(agent_input.get(field), str) or not agent_input[field].strip(): + errors.append(f"speaker Agent input {field} is required") + if not _valid_timestamp(agent_input.get("timestamp")): + errors.append("speaker Agent input timestamp is invalid") + strategy = agent_input.get("project_layout_strategy") + errors.extend(_strategy_errors(strategy)) + bindings = plan.get("speaker_inset", {}).get("analysis", {}) + expected = { + "analysis_sha256": bindings.get("sha256"), + "selection_sha256": plan.get("selection", {}).get("sha256"), + "style_sha256": _canonical_sha256(plan.get("speaker_inset_style")), + "review_video_sha256": plan.get("input_hashes", {}).get("review_video_sha256"), + } + for field, value in expected.items(): + if agent_input.get(field) != value: + errors.append(f"speaker Agent input {field} does not match") + try: + num, den = _fps(timeline) + frame_duration = den / num + except ValueError as exc: + return errors + [str(exc)] + analysis_shots = { + shot.get("shot_id"): shot for shot in analysis.get("shots", []) + if isinstance(shot, dict) + } + shots = agent_input.get("shots") + if not isinstance(shots, list): + return errors + ["speaker Agent input shots must be a list"] + if [shot.get("shot_id") for shot in shots if isinstance(shot, dict)] != list(analysis_shots): + errors.append("speaker Agent input shots do not match analysis") + recommendations = [] + for shot in shots: + if not isinstance(shot, dict) or shot.get("shot_id") not in analysis_shots: + continue + recommendation = shot.get("layout_recommendation") + errors.extend(_recommendation_errors( + recommendation, f"{shot['shot_id']}", + )) + if isinstance(recommendation, dict): + recommendations.append(recommendation) + expected_subshots = { + subshot.get("id"): subshot for subshot in analysis_shots[shot["shot_id"]].get("subshots", []) + if isinstance(subshot, dict) + } + subshots = shot.get("subshots") + if not isinstance(subshots, list): + errors.append(f"{shot['shot_id']} Agent subshots must be a list") + continue + if [item.get("id") for item in subshots if isinstance(item, dict)] != list(expected_subshots): + errors.append(f"{shot['shot_id']} Agent subshots do not match analysis") + for item in subshots: + if not isinstance(item, dict) or item.get("id") not in expected_subshots: + continue + label = item["id"] + status = item.get("speaker_status") + mode = item.get("display_mode") + rationale = item.get("rationale") + keyframes = item.get("keyframes") + if status not in SPEAKER_STATUSES: + errors.append(f"{label} speaker_status is invalid") + continue + if not isinstance(rationale, str) or not rationale.strip(): + errors.append(f"{label} rationale is required") + if status != "confirmed": + if mode != "pure_broll" or item.get("anchor") is not None or keyframes != []: + errors.append(f"{label} non-confirmed speaker must use pure_broll without keyframes") + if status == "ambiguous" and not any( + point.get("evidence_kind") == "supplemental" + for point in expected_subshots[label].get("evidence_points", []) + if isinstance(point, dict)): + errors.append( + f"{label} ambiguous speaker requires supplemental temporal evidence" + ) + continue + if mode != "enabled": + errors.append(f"{label} confirmed speaker must be enabled") + recommended_anchor = ( + recommendation.get("anchor") if isinstance(recommendation, dict) else None + ) + if item.get("anchor") != recommended_anchor: + errors.append(f"{label} must use the shot-level recommended anchor") + if not isinstance(keyframes, list) or not keyframes: + errors.append(f"{label} confirmed speaker requires keyframes") + continue + subshot_range = _range(expected_subshots[label].get("program_range")) + start_frame = round(subshot_range[0] / frame_duration) + end_frame = round(subshot_range[1] / frame_duration) + expected_last = end_frame - 1 + seen_frames = [] + for keyframe in keyframes: + time_s = keyframe.get("program_time_s") if isinstance(keyframe, dict) else None + frame_index = _frame_index(time_s, frame_duration) + if frame_index is None: + errors.append(f"{label} keyframe must align to timeline frames") + continue + if not start_frame <= frame_index < end_frame: + errors.append(f"{label} keyframe must remain inside its subshot") + if frame_index in seen_frames or (seen_frames and frame_index < seen_frames[-1]): + errors.append(f"{label} keyframes must be strictly ordered") + seen_frames.append(frame_index) + errors.extend(f"{label} {error}" for error in _roi_errors(keyframe.get("roi"))) + if seen_frames and (seen_frames[0] != start_frame or seen_frames[-1] != expected_last): + errors.append(f"{label} keyframes must cover its subshot") + if isinstance(strategy, dict) and isinstance(strategy.get("used_presets"), list): + assigned = [] + for recommendation in recommendations: + preset = recommendation.get("preset") + if preset in LAYOUT_PRESETS and preset not in assigned: + assigned.append(preset) + used = strategy["used_presets"] + if set(used) != set(assigned): + errors.append("speaker Agent input used_presets must match shot recommendations") + primary = strategy.get("primary_preset") + for recommendation in recommendations: + if recommendation.get("preset") == primary: + continue + assessments = recommendation.get("preset_assessments", {}) + if (assessments.get(primary) not in {"warn", "fail"} + or assessments.get(recommendation.get("preset")) != "pass"): + errors.append( + "secondary preset requires a warn/fail primary and passing recommendation" + ) + return errors + + +def attach_agent_input(plan, analysis, agent_input, timeline, project_root): + errors = agent_input_errors(agent_input, analysis, plan, timeline) + if errors: + raise ValueError("invalid speaker Agent input: " + "; ".join(errors)) + root = Path(project_root).resolve() + target = root / "work/b-roll/speaker-inset-agent-input.json" + _atomic_json(target, agent_input) + result = copy.deepcopy(plan) + speaker = result.setdefault("speaker_inset", {}) + speaker["agent_input"] = { + "path": "b-roll/speaker-inset-agent-input.json", + "sha256": _sha256_file(target), + "actor": agent_input["actor"], + "timestamp": agent_input["timestamp"], + } + return result + + +def interpolate_roi(keyframes, program_time_s, subshot_range): + parsed = _range(subshot_range) + time_s = _number(program_time_s) + if not parsed or time_s is None or time_s < parsed[0] or time_s >= parsed[1]: + raise ValueError("program time is outside subshot") + if not isinstance(keyframes, list) or not keyframes: + raise ValueError("keyframes are required") + ordered = sorted(keyframes, key=lambda item: float(item["program_time_s"])) + if time_s <= float(ordered[0]["program_time_s"]): + return copy.deepcopy(ordered[0]["roi"]) + if time_s >= float(ordered[-1]["program_time_s"]): + return copy.deepcopy(ordered[-1]["roi"]) + left, right = ordered[0], ordered[-1] + for candidate_left, candidate_right in zip(ordered, ordered[1:]): + if float(candidate_left["program_time_s"]) <= time_s <= float(candidate_right["program_time_s"]): + left, right = candidate_left, candidate_right + break + start, end = float(left["program_time_s"]), float(right["program_time_s"]) + ratio = 0.0 if end == start else (time_s - start) / (end - start) + return { + key: round(float(left["roi"][key]) + ( + float(right["roi"][key]) - float(left["roi"][key]) + ) * ratio, 9) + for key in ("x", "y", "width", "height") + } + + +def _anchor_position(frame_size, inset_size, style, anchor): + width, height = frame_size + inset_width, inset_height = inset_size + margin = round(width * float(style["margin_ratio"])) + safe_bottom = round(height * (1 - float(style["reserved_bottom_ratio"]))) + if anchor in {"upper-center", "lower-center"}: + x = (width - inset_width) // 2 + elif anchor == "top-left": + x = margin + elif anchor == "top-right": + x = width - margin - inset_width + else: + raise ValueError("speaker inset preset/anchor combination is invalid") + if anchor in {"top-left", "top-right"}: + y = margin + elif anchor == "upper-center": + y = max(margin, (safe_bottom - inset_height) // 4) + else: + # lower-center is exclusive to focused-panel, so place the larger window + # directly above the reserved subtitle area rather than overlapping its panel. + y = safe_bottom - inset_height + if x < 0 or y < 0 or x + inset_width > width or y + inset_height > safe_bottom: + raise ValueError("speaker inset anchor does not fit the frame or reserved bottom") + return x, y + + +def _inset_size(frame_size, style, anchor): + """Return a proportional window size, clamped only when the safe area is too small.""" + width = max(2, round(frame_size[0] * float(style["width_ratio"]))) + height = max(2, round(width / float(style["aspect_ratio"]))) + safe_height = round(frame_size[1] * (1 - float(style["reserved_bottom_ratio"]))) + if anchor != "lower-center": + safe_height -= round(frame_size[0] * float(style["margin_ratio"])) + if height > safe_height: + height = max(2, safe_height) + width = max(2, round(height * float(style["aspect_ratio"]))) + return width, height + + +def _apply_broll_treatment(base, preset): + base = base.convert("RGB") + if preset == "corner-pip": + return base.copy() + if preset == "full-bleed-wash": + return Image.blend(base, Image.new("RGB", base.size, "white"), 0.30) + if preset != "focused-panel": + raise ValueError("speaker inset layout preset is invalid") + result = base.filter(ImageFilter.GaussianBlur( + radius=0.025 * min(base.width, base.height), + )) + x = round(base.width * 0.04) + y = round(base.height * 0.08) + width = round(base.width * 0.92) + height = round(base.height * 0.40) + resampling = getattr(Image, "Resampling", Image).LANCZOS + panel = ImageOps.fit(base, (width, height), method=resampling) + result.paste(panel, (x, y)) + ImageDraw.Draw(result).rectangle( + (x, y, x + width - 1, y + height - 1), + outline=ImageColor.getrgb("#9E9E9E"), width=3, + ) + return result + + +def _paste_speaker(base, speaker, roi, style, anchor): + """Crop, mask, border, and paste one speaker ROI onto a treated frame.""" + if anchor not in ALLOWED_ANCHORS: + raise ValueError("speaker inset preset/anchor combination is invalid") + errors = style_errors(style) + if errors or not style_enabled(style): + raise ValueError("invalid enabled speaker_inset_style: " + "; ".join(errors)) + roi_validation = _roi_errors(roi) + if roi_validation: + raise ValueError("invalid speaker ROI: " + "; ".join(roi_validation)) + base = base.convert("RGB") + speaker = speaker.convert("RGB") + source_width, source_height = speaker.size + left = max(0, min(source_width - 1, math.floor(float(roi["x"]) * source_width))) + top = max(0, min(source_height - 1, math.floor(float(roi["y"]) * source_height))) + right = max(left + 1, min( + source_width, math.ceil((float(roi["x"]) + float(roi["width"])) * source_width), + )) + bottom = max(top + 1, min( + source_height, math.ceil((float(roi["y"]) + float(roi["height"])) * source_height), + )) + inset_width, inset_height = _inset_size(base.size, style, anchor) + resampling = getattr(Image, "Resampling", Image).LANCZOS + crop = ImageOps.fit( + speaker.crop((left, top, right, bottom)), + (inset_width, inset_height), method=resampling, centering=(0.5, 0.0), + ) + mask = Image.new("L", (inset_width, inset_height), 0) + mask_draw = ImageDraw.Draw(mask) + bounds = (0, 0, inset_width - 1, inset_height - 1) + radius = round( + min(inset_width, inset_height) * float(style["corner_radius_ratio"]) + ) + mask_draw.rounded_rectangle(bounds, radius=radius, fill=255) + layer = crop.convert("RGBA") + layer.putalpha(mask) + border_width = int(style["border"]["width_px"]) + if border_width: + color = ImageColor.getrgb(style["border"]["color"]) + (255,) + draw = ImageDraw.Draw(layer) + inset = max(0, border_width // 2) + outline = (inset, inset, inset_width - 1 - inset, inset_height - 1 - inset) + draw.rounded_rectangle( + outline, radius=max(0, radius - inset), outline=color, width=border_width, + ) + position = _anchor_position(base.size, layer.size, style, anchor) + result = base.copy() + result.paste(layer, position, layer) + return result + + +def composite_frame(base, speaker, roi, style, preset, anchor): + """Apply one preset, then optionally composite one cleared speaker ROI.""" + errors = style_errors(style) + if errors or not style_enabled(style): + raise ValueError("invalid enabled speaker_inset_style: " + "; ".join(errors)) + result = _apply_broll_treatment(base, preset) + if speaker is None: + if roi is not None or anchor is not None: + raise ValueError("pure B-roll must not provide speaker ROI or anchor") + return result + anchor_errors = _preset_anchor_errors(preset, anchor, "speaker inset") + if anchor_errors: + raise ValueError("; ".join(anchor_errors)) + return _paste_speaker(result, speaker, roi, style, anchor) + + +def _candidate_source(candidate, root): + import broll_plan + + path = broll_plan._candidate_path(root, candidate.get("cache_path", "")) + if path is None or not path.is_file(): + raise ValueError("selected candidate path is missing or escapes project root") + digest = _sha256_file(path) + if candidate.get("sha256") != digest: + raise ValueError("selected candidate SHA-256 is stale") + return path + + +def _validated_lut(plan, root, lut): + import normalize_broll + + active = "color-grade" in plan.get("dependencies", []) + if active and lut is None: + raise ValueError("selected LUT is required for contextual preview") + if not active: + if lut is not None: + raise ValueError("selected LUT must be omitted when color grade is inactive") + return None + path, hashes = normalize_broll._grade(lut, root) + expected = plan.get("input_hashes", {}).get("selected_lut_sha256") + if hashes.get("selected_lut_sha256") != expected: + raise ValueError("selected LUT SHA-256 is stale") + return path + + +def _check_preview_probe(path, timeline, duration): + probe = _probe_video(path) + width, height = timeline["width"], timeline["height"] + num, den = _fps(timeline) + if probe["width"] != width or probe["height"] != height: + raise ValueError("context preview dimensions do not match timeline") + if probe["fps"] != {"num": num, "den": den}: + raise ValueError("context preview fps does not match timeline") + if abs(probe["duration_s"] - duration) > den / num + RANGE_EPSILON: + raise ValueError("context preview duration does not match shot") + subprocess.run([ + "ffmpeg", "-v", "error", "-i", str(path), + "-map", "0:v:0", "-f", "null", "-", + ], check=True, capture_output=True) + return probe + + +def _render_broll_base(plan, shot, timeline, root, destination, lut_path): + import broll_plan + + candidates = shot.get("candidates", []) + details = broll_plan.selection_details(shot, candidates, timeline) + if details.get("format") != "canonical": + raise ValueError("speaker inset preview requires canonical video segments") + candidate_map = {candidate.get("id"): candidate for candidate in candidates} + width, height = timeline["width"], timeline["height"] + num, den = _fps(timeline) + shot_range = _range(shot.get("program_range")) + duration = shot_range[1] - shot_range[0] + inputs = [] + filters = [] + labels = [] + for index, segment in enumerate(details["segments"]): + candidate = candidate_map[segment["candidate_id"]] + source = _candidate_source(candidate, root) + source_range = _range(segment["source_range"]) + program_range = _range(segment["program_range"]) + segment_frame_count = round( + (program_range[1] - program_range[0]) * num / den + ) + inputs.extend([ + "-ss", f"{source_range[0]:.9f}", + "-t", f"{source_range[1] - source_range[0]:.9f}", + "-i", str(source), + ]) + chain = [ + f"scale={width}:{height}:force_original_aspect_ratio=increase", + f"crop={width}:{height}", "setsar=1", + f"setpts=(PTS-STARTPTS)/{float(segment['playback_rate']):g}", + f"fps={num}/{den}", + "tpad=stop_mode=clone:stop=-1", + f"trim=end_frame={segment_frame_count}", + "setpts=PTS-STARTPTS", + ] + if lut_path is not None: + chain.append(f"lut3d={lut_path.name}") + filters.append(f"[{index}:v]{','.join(chain)}[v{index}]") + labels.append(f"[v{index}]") + if len(labels) == 1: + output_label = labels[0] + else: + filters.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0[outv]") + output_label = "[outv]" + target = Path(destination) + part = target.with_suffix(".part.mp4") + target.parent.mkdir(parents=True, exist_ok=True) + part.unlink(missing_ok=True) + try: + subprocess.run([ + "ffmpeg", "-y", "-loglevel", "error", *inputs, + "-filter_complex", ";".join(filters), "-map", output_label, + "-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), + ], cwd=lut_path.parent if lut_path else None, check=True, capture_output=True) + _check_preview_probe(part, timeline, duration) + os.replace(part, target) + finally: + part.unlink(missing_ok=True) + return target + + +def _read_exact(stream, size): + chunks = [] + remaining = size + while remaining: + chunk = stream.read(remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _agent_subshots(agent_input): + return { + (shot["shot_id"], subshot["id"]): subshot + for shot in agent_input.get("shots", []) if isinstance(shot, dict) + for subshot in shot.get("subshots", []) if isinstance(subshot, dict) + } + + +def _analysis_subshot_at(analysis_shot, program_time_s): + for subshot in analysis_shot.get("subshots", []): + value = _range(subshot.get("program_range")) if isinstance(subshot, dict) else None + if value and value[0] - RANGE_EPSILON <= program_time_s < value[1] - RANGE_EPSILON: + return subshot + return None + + +def _close_process(process): + if process is None: + return + if process.poll() is None: + process.kill() + process.wait() + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None and not stream.closed: + stream.close() + + +def _render_composite_video(base_video, review_video, shot, analysis_shot, agent_input, + timeline, style, destination, *, preset, + anchor_override=None, display_choices=None): + width, height = timeline["width"], timeline["height"] + num, den = _fps(timeline) + frame_duration = den / num + shot_range = _range(shot.get("program_range")) + duration = shot_range[1] - shot_range[0] + frame_count = round(duration / frame_duration) + frame_bytes = width * height * 3 + target = Path(destination) + part = target.with_suffix(".part.mp4") + target.parent.mkdir(parents=True, exist_ok=True) + part.unlink(missing_ok=True) + base_process = speaker_process = encoder = None + try: + base_process = subprocess.Popen([ + "ffmpeg", "-v", "error", "-i", str(base_video), + "-an", "-f", "rawvideo", "-pix_fmt", "rgb24", "-", + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + speaker_filter = ( + f"trim=start={shot_range[0]:.9f}:end={shot_range[1]:.9f}," + f"setpts=PTS-STARTPTS,fps={num}/{den},scale={width}:{height}" + ) + speaker_process = subprocess.Popen([ + "ffmpeg", "-v", "error", "-i", str(review_video), + "-vf", speaker_filter, "-an", "-f", "rawvideo", "-pix_fmt", "rgb24", "-", + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + encoder = subprocess.Popen([ + "ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", + "-s", f"{width}x{height}", "-r", f"{num}/{den}", "-i", "-", + "-vf", "setsar=1", + "-an", "-sn", "-dn", "-map_metadata", "-1", "-write_tmcd", "0", + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", + str(part), + ], stdin=subprocess.PIPE, stderr=subprocess.PIPE) + agent_choices = _agent_subshots(agent_input) + effective_choices = display_choices or agent_choices + for index in range(frame_count): + base_bytes = _read_exact(base_process.stdout, frame_bytes) + speaker_bytes = _read_exact(speaker_process.stdout, frame_bytes) + if len(base_bytes) != frame_bytes or len(speaker_bytes) != frame_bytes: + raise ValueError("context preview decoder ended before the shot duration") + base_frame = Image.frombytes("RGB", (width, height), base_bytes) + speaker_frame = Image.frombytes("RGB", (width, height), speaker_bytes) + program_time = _frame_time( + round(shot_range[0] / frame_duration) + index, frame_duration, + ) + subshot = _analysis_subshot_at(analysis_shot, program_time) + if subshot is None: + raise ValueError("context preview frame is outside speaker subshots") + choice = agent_choices[(shot["id"], subshot["id"])] + effective = effective_choices[(shot["id"], subshot["id"])] + if effective.get("display_mode") == "enabled": + roi = interpolate_roi( + choice["keyframes"], program_time, subshot["program_range"], + ) + base_frame = composite_frame( + base_frame, speaker_frame, roi, style, preset, + anchor_override or effective["anchor"], + ) + else: + base_frame = composite_frame( + base_frame, None, None, style, preset, None, + ) + encoder.stdin.write(base_frame.tobytes()) + encoder.stdin.close() + encoder.stdin = None + encoder_error = encoder.stderr.read().decode("utf-8", errors="replace") + encoder_code = encoder.wait() + base_error = base_process.stderr.read().decode("utf-8", errors="replace") + speaker_error = speaker_process.stderr.read().decode("utf-8", errors="replace") + base_code, speaker_code = base_process.wait(), speaker_process.wait() + if encoder_code or base_code or speaker_code: + raise RuntimeError( + "context preview ffmpeg failed: " + + " | ".join(value for value in (base_error, speaker_error, encoder_error) if value) + ) + probe = _check_preview_probe(part, timeline, duration) + digest = _sha256_file(part) + os.replace(part, target) + return {"path": target, "sha256": digest, "probe": probe} + finally: + if encoder is not None and encoder.stdin is not None: + encoder.stdin.close() + _close_process(base_process) + _close_process(speaker_process) + _close_process(encoder) + part.unlink(missing_ok=True) + + +def validate_review_video(plan, timeline, review_video, project_root): + """Return the exact hash-bound review video and timeline media geometry.""" + import normalize_broll + + root = Path(project_root).resolve() + video = _inside(root, review_video, "review video") + if not video.is_file(): + raise ValueError("review video is missing") + if _sha256_file(video) != plan.get("input_hashes", {}).get("review_video_sha256"): + raise ValueError("review video SHA-256 is stale") + media_timeline = normalize_broll._timeline_with_media_geometry(timeline, root) + width, height, num, den = normalize_broll._timeline_spec(media_timeline) + media_timeline = copy.deepcopy(media_timeline) + media_timeline.update({"width": width, "height": height}) + probe = _probe_video(video) + duration = _number(media_timeline.get("program_duration_s")) + if (probe.get("width") != width or probe.get("height") != height + or probe.get("fps") != {"num": num, "den": den} + or duration is None + or abs(probe.get("duration_s", -1) - duration) > den / num + RANGE_EPSILON): + raise ValueError("review video geometry, fps, or duration does not match timeline") + subprocess.run([ + "ffmpeg", "-v", "error", "-i", str(video), + "-map", "0:v:0", "-f", "null", "-", + ], check=True, capture_output=True) + return video, media_timeline + + +def render_delivery_composite(*, plan, shot, analysis, agent_input, preview, clearance, + timeline, style, base_video, review_video, destination, + project_root): + """Render one clearance-effective speaker composite for normalized delivery.""" + import broll_plan + import normalize_broll + + root = Path(project_root).resolve() + if not isinstance(plan, dict) or not isinstance(shot, dict): + raise ValueError("speaker inset delivery plan and shot must be objects") + if shot not in plan.get("shots", []): + raise ValueError("speaker inset delivery shot is not in the plan") + if style != plan.get("speaker_inset_style"): + raise ValueError("speaker inset delivery style does not match plan") + errors = style_errors(style) + errors.extend(analysis_errors( + analysis, plan, timeline, {}, project_root=root, verify_files=True, + )) + errors = [error for error in errors if "transcript_sha256" not in error] + errors.extend(agent_input_errors(agent_input, analysis, plan, timeline)) + errors.extend(preview_errors( + preview, plan, analysis, agent_input, timeline, + project_root=root, verify_files=True, + )) + errors.extend(clearance_errors(clearance, preview, agent_input, analysis, plan)) + errors.extend(broll_plan._review_errors(plan, plan.get("shots", []))) + if errors: + raise ValueError("invalid speaker inset delivery artifacts: " + "; ".join(errors)) + + video, media_timeline = validate_review_video( + plan, timeline, review_video, root, + ) + width, height, num, den = normalize_broll._timeline_spec(media_timeline) + + base = _inside(root, base_video, "normalized B-roll base") + if not base.is_file(): + raise ValueError("normalized B-roll base is missing") + shot_id = shot.get("id") + analysis_shot = next(( + item for item in analysis.get("shots", []) + if isinstance(item, dict) and item.get("shot_id") == shot_id + ), None) + agent_shot = next(( + item for item in agent_input.get("shots", []) + if isinstance(item, dict) and item.get("shot_id") == shot_id + ), None) + clearance_shot = next(( + item for item in clearance.get("shots", []) + if isinstance(item, dict) and item.get("shot_id") == shot_id + ), None) + if not all(isinstance(item, dict) for item in ( + analysis_shot, agent_shot, clearance_shot)): + raise ValueError("speaker inset delivery shot artifacts are missing") + recommendation = agent_shot.get("layout_recommendation", {}) + choices = { + (shot_id, item["id"]): item + for item in clearance_shot.get("subshots", []) if isinstance(item, dict) + } + rendered = _render_composite_video( + base, video, shot, analysis_shot, agent_input, + media_timeline, style, destination, + preset=recommendation.get("preset"), display_choices=choices, + ) + output = Path(rendered["path"]) + probe = normalize_broll._probe(output) + normalize_broll._check_probe( + probe, width, height, num, den, _range(shot["program_range"])[1] + - _range(shot["program_range"])[0], + ) + return {"path": output, "sha256": _sha256_file(output), "probe": probe} + + +def render_context_previews(plan, analysis, agent_input, timeline, review_video, + project_root, *, lut=None): + """Render exact B-roll plus approved Agent ROI/anchor contextual previews.""" + analysis_validation = analysis_errors(analysis, plan, timeline, {}, verify_files=False) + analysis_validation = [ + error for error in analysis_validation if "transcript_sha256" not in error + ] + if analysis_validation: + raise ValueError("invalid speaker analysis: " + "; ".join(analysis_validation)) + agent_validation = agent_input_errors(agent_input, analysis, plan, timeline) + if agent_validation: + raise ValueError("invalid speaker Agent input: " + "; ".join(agent_validation)) + root = Path(project_root).resolve() + video = _inside(root, review_video, "review video") + if _sha256_file(video) != plan.get("input_hashes", {}).get("review_video_sha256"): + raise ValueError("review video SHA-256 is stale") + import normalize_broll + + media_timeline = normalize_broll._timeline_with_media_geometry(timeline, root) + width, height, _, _ = normalize_broll._timeline_spec(media_timeline) + media_timeline = copy.deepcopy(media_timeline) + media_timeline.update({"width": width, "height": height}) + lut_path = _validated_lut(plan, root, lut) + preview_dir = ( + root / "work/cache/b-roll/speaker-inset/previews" / analysis["analysis_id"] + ) + preview_dir.mkdir(parents=True, exist_ok=True) + analysis_shots = {shot["shot_id"]: shot for shot in analysis["shots"]} + agent_shots = { + shot["shot_id"]: shot for shot in agent_input["shots"] + } + shot_records = [] + for shot in plan.get("shots", []): + if not isinstance(shot, dict) or shot.get("status") != "composite_pending": + continue + base = _render_broll_base( + plan, shot, media_timeline, root, + preview_dir / f"base-{shot['id']}.mp4", lut_path, + ) + recommendation = agent_shots[shot["id"]]["layout_recommendation"] + preset, anchor = recommendation["preset"], recommendation["anchor"] + context = _render_composite_video( + base, video, shot, analysis_shots[shot["id"]], agent_input, + media_timeline, plan["speaker_inset_style"], + preview_dir / f"context-{shot['id']}.mp4", + preset=preset, anchor_override=anchor, + ) + anchor_previews = {} + for preview_anchor in PRESET_ANCHORS[preset]: + alternate = _render_composite_video( + base, video, shot, analysis_shots[shot["id"]], agent_input, + media_timeline, plan["speaker_inset_style"], + preview_dir / f"context-{shot['id']}-{preview_anchor}.mp4", + preset=preset, anchor_override=preview_anchor, + ) + anchor_previews[preview_anchor] = { + "path": alternate["path"].relative_to(root / "work").as_posix(), + "sha256": alternate["sha256"], + "probe": alternate["probe"], + } + record = { + "shot_id": shot["id"], + "program_range": copy.deepcopy(shot["program_range"]), + "base_broll": { + "path": base.relative_to(root / "work").as_posix(), + "sha256": _sha256_file(base), + }, + "preview": { + "path": context["path"].relative_to(root / "work").as_posix(), + "sha256": context["sha256"], + "probe": context["probe"], + }, + "anchor_previews": anchor_previews, + } + alternate = recommendation.get("alternate") + if isinstance(alternate, dict): + alternate_render = _render_composite_video( + base, video, shot, analysis_shots[shot["id"]], agent_input, + media_timeline, plan["speaker_inset_style"], + preview_dir / f"context-{shot['id']}-alternate.mp4", + preset=alternate["preset"], anchor_override=alternate["anchor"], + ) + record["alternate_preview"] = { + "preset": alternate["preset"], + "anchor": alternate["anchor"], + "path": alternate_render["path"].relative_to(root / "work").as_posix(), + "sha256": alternate_render["sha256"], + "probe": alternate_render["probe"], + } + shot_records.append(record) + record = { + "schema_version": 1, + "analysis_sha256": plan["speaker_inset"]["analysis"]["sha256"], + "agent_input_sha256": plan["speaker_inset"]["agent_input"]["sha256"], + "selection_sha256": plan["selection"]["sha256"], + "style_sha256": _canonical_sha256(plan["speaker_inset_style"]), + "review_video_sha256": plan["input_hashes"]["review_video_sha256"], + "shots": shot_records, + } + output = root / "work/b-roll/speaker-inset-preview.json" + _atomic_json(output, record) + result = copy.deepcopy(plan) + result["speaker_inset"]["preview"] = { + "path": "b-roll/speaker-inset-preview.json", + "sha256": _sha256_file(output), + } + return result + + +def _preview_binding_errors(binding, label, *, root=None, verify_files=False): + if not isinstance(binding, dict): + return [f"{label} binding must be an object"] + errors = [] + path_value = binding.get("path") + if not isinstance(path_value, str) or not path_value.strip(): + errors.append(f"{label} path is required") + if not _is_sha256(binding.get("sha256")): + errors.append(f"{label} SHA-256 is invalid") + if verify_files and root is not None and not errors: + try: + path = _inside(root / "work", root / "work" / path_value, label) + except ValueError: + errors.append(f"{label} path escapes work") + else: + if not path.is_file(): + errors.append(f"{label} file is missing") + elif _sha256_file(path) != binding["sha256"]: + errors.append(f"{label} SHA-256 is stale") + return errors + + +def preview_errors(preview, plan, analysis, agent_input, timeline, *, + project_root=None, verify_files=False): + """Validate the exact contextual preview record and its frozen media bindings.""" + if not isinstance(preview, dict): + return ["speaker inset preview must be an object"] + errors = [] + if preview.get("schema_version") != 1: + errors.append("speaker inset preview schema_version must be 1") + if "size_review" in preview: + errors.append("speaker inset preview size_review is unsupported") + expected = { + "analysis_sha256": plan.get("speaker_inset", {}).get("analysis", {}).get("sha256"), + "agent_input_sha256": plan.get("speaker_inset", {}).get("agent_input", {}).get("sha256"), + "selection_sha256": plan.get("selection", {}).get("sha256"), + "style_sha256": _canonical_sha256(plan.get("speaker_inset_style")), + "review_video_sha256": plan.get("input_hashes", {}).get("review_video_sha256"), + } + for field, value in expected.items(): + if preview.get(field) != value: + errors.append(f"speaker inset preview {field} does not match") + if preview.get("analysis_sha256") != analysis.get("analysis_sha256", expected["analysis_sha256"]): + errors.append("speaker inset preview analysis binding is stale") + if preview.get("agent_input_sha256") != _json_document_sha256(agent_input): + errors.append("speaker inset preview Agent input binding is stale") + try: + _fps(timeline) + except ValueError as exc: + errors.append(str(exc)) + expected_shots = { + shot.get("id"): shot for shot in plan.get("shots", []) + if isinstance(shot, dict) and shot.get("status") in ARTIFACT_SHOT_STATUSES + } + shots = preview.get("shots") + if not isinstance(shots, list): + return errors + ["speaker inset preview shots must be a list"] + if [shot.get("shot_id") for shot in shots if isinstance(shot, dict)] != list(expected_shots): + errors.append("speaker inset preview shots do not match composite_pending shots") + root = Path(project_root).resolve() if project_root is not None else None + agent_shots = { + shot.get("shot_id"): shot for shot in agent_input.get("shots", []) + if isinstance(shot, dict) + } + for shot in shots: + if not isinstance(shot, dict) or shot.get("shot_id") not in expected_shots: + continue + shot_id = shot["shot_id"] + if _range(shot.get("program_range")) != _range(expected_shots[shot_id].get("program_range")): + errors.append(f"{shot_id} preview program range does not match") + errors.extend(_preview_binding_errors( + shot.get("base_broll"), f"{shot_id} base B-roll", + root=root, verify_files=verify_files, + )) + errors.extend(_preview_binding_errors( + shot.get("preview"), f"{shot_id} contextual preview", + root=root, verify_files=verify_files, + )) + recommendation = agent_shots.get(shot_id, {}).get("layout_recommendation", {}) + preset = recommendation.get("preset") + allowed = list(PRESET_ANCHORS.get(preset, ())) + anchor_previews = shot.get("anchor_previews") + if not isinstance(anchor_previews, dict) or list(anchor_previews) != allowed: + errors.append(f"{shot_id} anchor previews must match recommended preset anchors") + continue + for anchor, binding in anchor_previews.items(): + errors.extend(_preview_binding_errors( + binding, f"{shot_id} {anchor} preview", + root=root, verify_files=verify_files, + )) + alternate = recommendation.get("alternate") + alternate_preview = shot.get("alternate_preview") + if alternate is None: + if alternate_preview is not None: + errors.append(f"{shot_id} alternate preview requires an alternate recommendation") + elif not isinstance(alternate_preview, dict): + errors.append(f"{shot_id} alternate preview is required") + else: + if (alternate_preview.get("preset") != alternate.get("preset") + or alternate_preview.get("anchor") != alternate.get("anchor")): + errors.append(f"{shot_id} alternate preview does not match recommendation") + errors.extend(_preview_binding_errors( + alternate_preview, f"{shot_id} alternate preview", + root=root, verify_files=verify_files, + )) + return errors + + +def _continuity_expectation(analysis_subshots, clearance_subshots): + runs = [] + for analysis_item, clearance_item in zip(analysis_subshots, clearance_subshots): + program = _range(analysis_item.get("program_range")) if isinstance(analysis_item, dict) else None + mode = clearance_item.get("display_mode") if isinstance(clearance_item, dict) else None + if not program or mode not in {"enabled", "pure_broll"}: + continue + duration = program[1] - program[0] + if runs and runs[-1]["mode"] == mode: + runs[-1]["duration_s"] += duration + else: + runs.append({"mode": mode, "duration_s": duration}) + modes = {run["mode"] for run in runs} + if modes == {"enabled"}: + return "none", "continuous" + if modes == {"pure_broll"}: + return "none", "all_pure_broll" + short_flash = any( + run["mode"] == "enabled" + and run["duration_s"] < SHORT_FLASH_SECONDS - RANGE_EPSILON + and index + 1 < len(runs) + and runs[index + 1]["mode"] == "pure_broll" + and runs[index + 1]["duration_s"] > run["duration_s"] + RANGE_EPSILON + for index, run in enumerate(runs) + ) + return ("short_flash" if short_flash else "mode_change"), "intentional_transition" + + +def clearance_errors(clearance, preview, agent_input, analysis, plan): + """Validate Agent clearance against exact composited anchor previews.""" + if not isinstance(clearance, dict): + return ["speaker inset clearance must be an object"] + errors = [] + if clearance.get("schema_version") != 1: + errors.append("speaker inset clearance schema_version must be 1") + if clearance.get("mode") != "agent": + errors.append("speaker inset clearance mode must be agent") + for field in ("actor", "rationale"): + if not isinstance(clearance.get(field), str) or not clearance[field].strip(): + errors.append(f"speaker inset clearance {field} is required") + if not _valid_timestamp(clearance.get("timestamp")): + errors.append("speaker inset clearance timestamp is invalid") + expected = { + "analysis_sha256": plan.get("speaker_inset", {}).get("analysis", {}).get("sha256"), + "agent_input_sha256": plan.get("speaker_inset", {}).get("agent_input", {}).get("sha256"), + "preview_sha256": plan.get("speaker_inset", {}).get("preview", {}).get("sha256"), + "selection_sha256": plan.get("selection", {}).get("sha256"), + "style_sha256": _canonical_sha256(plan.get("speaker_inset_style")), + } + for field, value in expected.items(): + if clearance.get(field) != value: + errors.append(f"speaker inset clearance {field} does not match") + if clearance.get("analysis_sha256") != preview.get("analysis_sha256"): + errors.append("speaker inset clearance analysis binding does not match preview") + if clearance.get("agent_input_sha256") != preview.get("agent_input_sha256"): + errors.append("speaker inset clearance Agent input binding does not match preview") + + if "size_assessment" in clearance: + errors.append("speaker inset clearance size_assessment is unsupported") + + analysis_shots = { + shot.get("shot_id"): shot for shot in analysis.get("shots", []) + if isinstance(shot, dict) + } + agent_shots = { + shot.get("shot_id"): shot for shot in agent_input.get("shots", []) + if isinstance(shot, dict) + } + preview_shots = { + shot.get("shot_id"): shot for shot in preview.get("shots", []) + if isinstance(shot, dict) + } + shots = clearance.get("shots") + if not isinstance(shots, list): + return errors + ["speaker inset clearance shots must be a list"] + if [shot.get("shot_id") for shot in shots if isinstance(shot, dict)] != list(analysis_shots): + errors.append("speaker inset clearance shots do not match analysis") + for shot in shots: + shot_id = shot.get("shot_id") if isinstance(shot, dict) else None + if shot_id not in analysis_shots or shot_id not in agent_shots: + continue + analysis_subshots = { + item.get("id"): item for item in analysis_shots[shot_id].get("subshots", []) + if isinstance(item, dict) + } + agent_subshots = { + item.get("id"): item for item in agent_shots[shot_id].get("subshots", []) + if isinstance(item, dict) + } + items = shot.get("subshots") + if not isinstance(items, list): + errors.append(f"{shot_id} clearance subshots must be a list") + continue + if [item.get("id") for item in items if isinstance(item, dict)] != list(analysis_subshots): + errors.append(f"{shot_id} clearance subshots do not match analysis") + continuity = shot.get("continuity") + expected_risk, expected_decision = _continuity_expectation( + analysis_shots[shot_id].get("subshots", []), items, + ) + if not isinstance(continuity, dict): + errors.append(f"{shot_id} continuity assessment is required") + else: + if continuity.get("risk") != expected_risk: + errors.append( + f"{shot_id} continuity risk must be {expected_risk}" + ) + if continuity.get("decision") != expected_decision: + errors.append( + f"{shot_id} continuity decision must be {expected_decision}" + ) + if (not isinstance(continuity.get("rationale"), str) + or not continuity["rationale"].strip()): + errors.append(f"{shot_id} continuity rationale is required") + available = preview_shots.get(shot_id, {}).get("anchor_previews", {}) + recommendation = agent_shots[shot_id].get("layout_recommendation", {}) + allowed = list(PRESET_ANCHORS.get(recommendation.get("preset"), ())) + for item in items: + label = item.get("id") if isinstance(item, dict) else None + if label not in analysis_subshots or label not in agent_subshots: + continue + agent_choice = agent_subshots[label] + rationale = item.get("rationale") + if not isinstance(rationale, str) or not rationale.strip(): + errors.append(f"{label} clearance rationale is required") + expected_legibility = ( + "pass" if item.get("display_mode") == "enabled" else "not_applicable" + ) + if item.get("subject_legibility") != expected_legibility: + errors.append( + f"{label} subject_legibility must be {expected_legibility}" + ) + checked = item.get("checked_anchors") + if (not isinstance(checked, list) or len(checked) != len(set(checked)) + or any(anchor not in allowed for anchor in checked)): + errors.append(f"{label} checked_anchors must be unique allowed anchors") + checked = [] + if agent_choice.get("speaker_status") != "confirmed": + if (agent_choice.get("display_mode") != "pure_broll" + or item.get("display_mode") != "pure_broll" + or item.get("anchor") is not None + or item.get("clearance_status") != "pass" + or checked != []): + errors.append(f"{label} non-confirmed speaker clearance must remain pure_broll") + continue + status = item.get("clearance_status") + if status == "pass": + anchor = item.get("anchor") + if (item.get("display_mode") != "enabled" + or agent_choice.get("display_mode") != "enabled" + or anchor != recommendation.get("anchor") + or anchor not in checked + or anchor not in available): + errors.append(f"{label} passing clearance must bind the enabled checked anchor") + elif status == "no_safe_position": + if (item.get("display_mode") != "pure_broll" + or item.get("anchor") is not None + or checked != allowed + or any(anchor not in available for anchor in allowed)): + errors.append(f"{label} no_safe_position must check all allowed anchors and use pure_broll") + else: + errors.append(f"{label} clearance_status must be pass or no_safe_position") + return errors + + +def attach_clearance(plan, analysis, agent_input, preview, clearance, project_root, timeline): + errors = preview_errors( + preview, plan, analysis, agent_input, timeline, verify_files=False, + ) + errors.extend(clearance_errors(clearance, preview, agent_input, analysis, plan)) + if errors: + raise ValueError("invalid speaker inset clearance: " + "; ".join(errors)) + root = Path(project_root).resolve() + target = root / "work/b-roll/speaker-inset-clearance.json" + _atomic_json(target, clearance) + result = copy.deepcopy(plan) + speaker = result.setdefault("speaker_inset", {}) + speaker["clearance"] = { + "path": "b-roll/speaker-inset-clearance.json", + "sha256": _sha256_file(target), + "actor": clearance["actor"], + "timestamp": clearance["timestamp"], + } + return result + + +def normalized_composition_errors(plan, shot, *, agent_input=None): + """Validate one normalized speaker composition against its frozen plan bindings.""" + normalized = shot.get("normalized") if isinstance(shot, dict) else None + composition = normalized.get("composition") if isinstance(normalized, dict) else None + base = normalized.get("broll_base") if isinstance(normalized, dict) else None + enabled = style_enabled(plan.get("speaker_inset_style")) if isinstance(plan, dict) else False + status = shot.get("status") if isinstance(shot, dict) else None + if not enabled: + if composition is not None or base is not None: + return ["speaker inset normalized composition requires enabled style"] + return [] + if status not in {"normalized", "verified"}: + return [] + if not isinstance(composition, dict): + return ["speaker inset normalized composition is missing"] + errors = [] + required = { + "kind", "layout_preset", "project_primary_preset", "review_id", + "selection_sha256", "analysis_sha256", "agent_input_sha256", + "preview_sha256", "clearance_sha256", "style_sha256", + "review_video_sha256", + } + if set(composition) != required: + errors.append("speaker inset normalized composition fields are invalid") + speaker = plan.get("speaker_inset") if isinstance(plan.get("speaker_inset"), dict) else {} + review = plan.get("review") if isinstance(plan.get("review"), dict) else {} + expected = { + "kind": "speaker-inset", + "review_id": review.get("review_id"), + "selection_sha256": plan.get("selection", {}).get("sha256"), + "analysis_sha256": speaker.get("analysis", {}).get("sha256"), + "agent_input_sha256": speaker.get("agent_input", {}).get("sha256"), + "preview_sha256": speaker.get("preview", {}).get("sha256"), + "clearance_sha256": speaker.get("clearance", {}).get("sha256"), + "style_sha256": _canonical_sha256(plan.get("speaker_inset_style")), + "review_video_sha256": plan.get("input_hashes", {}).get("review_video_sha256"), + } + for field, value in expected.items(): + if composition.get(field) != value: + errors.append(f"speaker inset normalized composition {field} is stale") + for field in ("layout_preset", "project_primary_preset"): + if composition.get(field) not in LAYOUT_PRESETS: + errors.append(f"speaker inset normalized composition {field} is invalid") + if agent_input is not None: + strategy = agent_input.get("project_layout_strategy", {}) + recommendation = next(( + item.get("layout_recommendation", {}) + for item in agent_input.get("shots", []) + if isinstance(item, dict) and item.get("shot_id") == shot.get("id") + ), {}) + if composition.get("layout_preset") != recommendation.get("preset"): + errors.append("speaker inset normalized composition layout_preset is stale") + if composition.get("project_primary_preset") != strategy.get("primary_preset"): + errors.append("speaker inset normalized composition project_primary_preset is stale") + if not isinstance(base, dict) or set(base) != {"path", "sha256", "probe"}: + errors.append("speaker inset normalized composition B-roll base is invalid") + else: + path = base.get("path") + if (not isinstance(path, str) + or not re.fullmatch(r"cache/b-roll/normalized/broll-\d{3}-base\.mp4", path)): + errors.append("speaker inset normalized composition B-roll base path is invalid") + if not _is_sha256(base.get("sha256")): + errors.append("speaker inset normalized composition B-roll base SHA-256 is invalid") + if not isinstance(base.get("probe"), dict): + errors.append("speaker inset normalized composition B-roll base probe is invalid") + return errors + + +def artifact_errors(plan, timeline, transcript, *, project_root=None, verify_files=False): + """Validate durable speaker artifacts and every transitive hash binding.""" + speaker = plan.get("speaker_inset") if isinstance(plan, dict) else None + if not isinstance(speaker, dict) or not verify_files: + return [] + if project_root is None: + return ["speaker inset artifact verification requires project_root"] + root = Path(project_root).resolve() + specs = { + "analysis": "b-roll/speaker-inset-analysis.json", + "agent_input": "b-roll/speaker-inset-agent-input.json", + "preview": "b-roll/speaker-inset-preview.json", + "clearance": "b-roll/speaker-inset-clearance.json", + } + errors = [] + documents = {} + for name, expected_path in specs.items(): + binding = speaker.get(name) + if binding is None: + continue + if not isinstance(binding, dict) or binding.get("path") != expected_path: + continue + path = root / "work" / expected_path + if not path.is_file(): + errors.append(f"speaker_inset {name} file is missing") + continue + if _sha256_file(path) != binding.get("sha256"): + errors.append(f"speaker_inset {name} SHA-256 is stale") + continue + try: + documents[name] = projectlib.load_json(path) + except (OSError, ValueError, json.JSONDecodeError): + errors.append(f"speaker_inset {name} JSON is invalid") + + analysis = documents.get("analysis") + if analysis is not None: + errors.extend(analysis_errors( + analysis, plan, timeline, transcript, + project_root=root, verify_files=True, + )) + agent_input = documents.get("agent_input") + if agent_input is not None and analysis is not None: + errors.extend(agent_input_errors(agent_input, analysis, plan, timeline)) + for shot in plan.get("shots", []): + if isinstance(shot, dict): + errors.extend(normalized_composition_errors( + plan, shot, agent_input=agent_input, + )) + preview = documents.get("preview") + if preview is not None and analysis is not None and agent_input is not None: + errors.extend(preview_errors( + preview, plan, analysis, agent_input, timeline, + project_root=root, verify_files=True, + )) + clearance = documents.get("clearance") + if (clearance is not None and preview is not None + and analysis is not None and agent_input is not None): + errors.extend(clearance_errors( + clearance, preview, agent_input, analysis, plan, + )) + return errors diff --git a/skills/video-add-b-roll/tests/test_broll.py b/skills/video-add-b-roll/tests/test_broll.py index e8081021e..2643e0dee 100644 --- a/skills/video-add-b-roll/tests/test_broll.py +++ b/skills/video-add-b-roll/tests/test_broll.py @@ -4,6 +4,7 @@ import base64 import contextlib import io +import inspect import json import math import os @@ -16,16 +17,18 @@ from unittest import mock from urllib.error import HTTPError from urllib.request import Request -from PIL import Image +from PIL import Image, ImageDraw 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 import normalize_broll import check_broll +import speaker_inset class _BrollFixture: @@ -50,7 +53,7 @@ def setUp(self): asset.write_bytes(b"asset") self.mapped_words = projectlib.map_transcript_to_timeline(self.transcript, self.timeline)["segments"][0]["words"] candidate = {"id": "asset", "media_type": "video", "cache_path": "cache/b-roll/factory.mp4", "sha256": broll_plan.sha256_file(asset), "bytes": asset.stat().st_size, "duration_s": 2.0, "probe": {"duration_s": 2.0, "width": 1920, "height": 1080}, "provenance": {"source_type": "local", "creator": "me", "license": "owned", "retrieval_time": "2026-07-23T00:00:00Z", "original_path": "input/factory.mp4"}} - self.plan = {"schema_version": 1, "timeline_id": "main", "timebase": "program", "program_duration_s": 10.0, "dependencies": ["understanding", "cut", "color-grade"], "based_on": {"understanding": 1, "cut": 2, "color-grade": 3}, "input_hashes": {"transcript_sha256": broll_plan.sha256_file(self.transcript_path), "timeline_sha256": broll_plan.sha256_file(self.timeline_path), "grade_plan_sha256": broll_plan.sha256_file(self.grade_plan_path), "selected_lut_sha256": broll_plan.sha256_file(self.selected_lut_path), "review_video_sha256": "b" * 64}, "brief": {"density": "selective"}, "decision": None, "review": None, "shots": [{"id": "shot", "program_range": {"start_s": 1.0, "end_s": 2.0}, "source_ranges": [{"clip_id": "one", "start_s": 1.0, "end_s": 2.0}], "transcript_evidence": {"words": [self.mapped_words[0]]}, "editorial_reason": "Supports the statement.", "visual_intent": "Factory work.", "queries": ["factory assembly", "manufacturing line"], "candidates": [candidate], "selected": None, "status": "candidates_ready"}]} + self.plan = {"schema_version": 1, "timeline_id": "main", "timebase": "program", "program_duration_s": 10.0, "dependencies": ["understanding", "cut", "color-grade"], "based_on": {"understanding": 1, "cut": 2, "color-grade": 3}, "input_hashes": {"transcript_sha256": broll_plan.sha256_file(self.transcript_path), "timeline_sha256": broll_plan.sha256_file(self.timeline_path), "grade_plan_sha256": broll_plan.sha256_file(self.grade_plan_path), "selected_lut_sha256": broll_plan.sha256_file(self.selected_lut_path), "review_video_sha256": "b" * 64}, "brief": {"density": "dynamic-social"}, "decision": None, "review": None, "shots": [{"id": "shot", "program_range": {"start_s": 1.0, "end_s": 2.0}, "source_ranges": [{"clip_id": "one", "start_s": 1.0, "end_s": 2.0}], "transcript_evidence": {"words": [self.mapped_words[0]]}, "editorial_reason": "Supports the statement.", "visual_intent": "Factory work.", "queries": ["factory assembly", "manufacturing line"], "candidates": [candidate], "selected": None, "status": "candidates_ready"}]} self.project = {"active_sequence": "main", "sequences": {"main": {"operations": ["cut", "color-grade"]}}, "operations": [{"id": "understanding", "revision": 1}, {"id": "cut", "revision": 2}, {"id": "color-grade", "revision": 3, "render": {"plan": "color-grade/grade-plan.json"}}]} self.project_path = self.root / "work/project.json" projectlib.write_json(self.project_path, self.project) @@ -63,6 +66,36 @@ def review(self, **extra): def review_for(self, plan, shots, rationale="Relevant footage.", timestamp="2026-07-23T12:00:00Z", **extra): return {"review_id": "123e4567-e89b-12d3-a456-426614174000", "plan_sha256": broll_plan.canonical_sha256(broll_plan.review_subject(plan)), "candidate_manifest_sha256": broll_plan.canonical_sha256(broll_plan.candidate_manifest(plan)), "review_video_sha256": plan["input_hashes"]["review_video_sha256"], "rationale": rationale, "timestamp": timestamp, "shots": shots, **extra} + def record_presentation(self, plan, mode): + decision = { + "decision_id": ( + "123e4567-e89b-12d3-a456-426614174002" + if mode == "speaker-inset" + else "123e4567-e89b-12d3-a456-426614174001" + ), + "mode": "human", + "actor": "Actual user", + "timestamp": "2026-08-06T12:00:00Z", + "explicit_user_action": True, + "rationale_source": "agent_chat_explicit_action", + "user_response": f"Use the {mode} presentation route.", + "presentation_mode": mode, + "agent_recommendation": { + "presentation_mode": mode, + "rationale": f"The selected media supports the {mode} presentation route.", + }, + "plan_sha256": broll_plan.canonical_sha256( + broll_plan.presentation_subject(plan) + ), + "candidate_manifest_sha256": broll_plan.canonical_sha256( + broll_plan.candidate_manifest(plan) + ), + "review_video_sha256": plan["input_hashes"]["review_video_sha256"], + } + return broll_plan.record_chat_presentation_decision( + plan, decision, project_root=self.root, + ) + def pexels_candidate(self): candidate = copy.deepcopy(self.plan["shots"][0]["candidates"][0]) candidate.update({ @@ -91,18 +124,790 @@ def pexels_candidate(self): class BrollPlanTests(_BrollFixture, unittest.TestCase): + def test_dynamic_social_density_is_required_without_coverage_validation(self): + self.assertEqual([], broll_plan.validate_plan( + self.plan, self.timeline, self.transcript, + )) + selective = copy.deepcopy(self.plan) + selective["brief"]["density"] = "selective" + self.assertIn("brief density must be dynamic-social", broll_plan.validate_plan( + selective, self.timeline, self.transcript, + )) + for start_s, end_s, status in ( + (1.0, 2.0, "below_target"), + (0.0, 8.0, "above_target"), + ): + with self.subTest(coverage_status=status): + plan = copy.deepcopy(self.plan) + plan["shots"][0].update({ + "program_range": {"start_s": start_s, "end_s": end_s}, + "source_ranges": [{"clip_id": "one", "start_s": start_s, "end_s": end_s}], + "candidates": [], + "selected": None, + "status": "planned", + }) + summary = broll_plan.coverage_summary(plan, planned=plan["shots"]) + self.assertEqual(status, summary["planned"]["status"]) + self.assertEqual([], broll_plan.validate_plan( + plan, self.timeline, self.transcript, + )) + + def test_coverage_summary_merges_ranges_and_reports_stage_status(self): + summary = broll_plan.coverage_summary( + self.plan, + [ + {"program_range": {"start_s": 0.0, "end_s": 4.0}}, + {"program_range": {"start_s": 3.0, "end_s": 5.0}}, + ], + [ + {"start_s": 0.0, "end_s": 2.0}, + {"start_s": 3.0, "end_s": 4.0}, + ], + [{"program_range": {"start_s": 0.0, "end_s": 8.0}}], + ) + + self.assertEqual("dynamic-social", summary["profile"]) + self.assertEqual(0.40, summary["target_min_ratio"]) + self.assertEqual(0.70, summary["target_max_ratio"]) + self.assertEqual({"duration_s": 5.0, "ratio": 0.5, "status": "within_target"}, summary["planned"]) + self.assertEqual({"duration_s": 3.0, "ratio": 0.3, "status": "below_target"}, summary["shortlisted"]) + self.assertEqual({"duration_s": 8.0, "ratio": 0.8, "status": "above_target"}, summary["selected"]) + + def test_coverage_summary_empty_stage_and_zero_duration_are_below(self): + empty = broll_plan.coverage_summary(self.plan, planned=[], shortlisted=[], selected=[]) + self.assertEqual(0.0, empty["planned"]["duration_s"]) + self.assertEqual(0.0, empty["planned"]["ratio"]) + self.assertEqual("below_target", empty["planned"]["status"]) + + zero_duration = copy.deepcopy(self.plan) + zero_duration["program_duration_s"] = 0.0 + zero = broll_plan.coverage_summary( + zero_duration, + planned=[{"start_s": 0.0, "end_s": 1.0}], + shortlisted=[{"start_s": 0.0, "end_s": 1.0}], + selected=[{"start_s": 0.0, "end_s": 1.0}], + ) + for stage in ("planned", "shortlisted", "selected"): + with self.subTest(stage=stage): + self.assertEqual(0.0, zero[stage]["duration_s"]) + self.assertEqual(0.0, zero[stage]["ratio"]) + self.assertEqual("below_target", zero[stage]["status"]) + + @staticmethod + def _speaker_style(): + return { + "enabled": True, + "shape": "rounded-rectangle", + "width_ratio": 0.39, + "aspect_ratio": 0.80, + "border": {"width_px": 3, "color": "#9E9E9E"}, + "corner_radius_ratio": 0.10, + "margin_ratio": 0.04, + "reserved_bottom_ratio": 0.20, + } + + def test_speaker_inset_style_is_optional_and_enabled_style_is_strict(self): + self.assertEqual([], broll_plan.validate_plan( + self.plan, self.timeline, self.transcript, + )) + valid = copy.deepcopy(self.plan) + valid["speaker_inset_style"] = self._speaker_style() + self.assertEqual([], broll_plan.validate_plan( + valid, self.timeline, self.transcript, + )) + + invalid_values = ( + ({**self._speaker_style(), "shape": "circle"}, "rounded-rectangle"), + ({**self._speaker_style(), "width_ratio": 0}, "width_ratio"), + ({**self._speaker_style(), "aspect_ratio": True}, "aspect_ratio"), + ({**self._speaker_style(), "border": {"width_px": -1, "color": "white"}}, "border"), + ({**self._speaker_style(), "size_candidates": [0.24, 0.30]}, "size_candidates"), + ({**self._speaker_style(), "layout_preset": "corner-pip"}, "layout_preset"), + ({**self._speaker_style(), "allowed_anchors": ["top-left"]}, "allowed_anchors"), + ) + for style, message in invalid_values: + plan = copy.deepcopy(self.plan) + plan["speaker_inset_style"] = style + with self.subTest(message=message): + self.assertTrue(any( + message in error for error in broll_plan.validate_plan( + plan, self.timeline, self.transcript, + ) + )) + + def test_chat_presentation_decision_binds_user_response_and_recommendation(self): + decision = { + "decision_id": "123e4567-e89b-12d3-a456-426614174001", + "mode": "human", + "actor": "Actual user", + "timestamp": "2026-08-06T12:00:00Z", + "explicit_user_action": True, + "rationale_source": "agent_chat_explicit_action", + "user_response": "Use ordinary full-screen B-roll for this video.", + "presentation_mode": "ordinary", + "agent_recommendation": { + "presentation_mode": "speaker-inset", + "rationale": "The speaker should remain visible for continuity.", + }, + "plan_sha256": broll_plan.canonical_sha256( + broll_plan.presentation_subject(self.plan) + ), + "candidate_manifest_sha256": broll_plan.canonical_sha256( + broll_plan.candidate_manifest(self.plan) + ), + "review_video_sha256": self.plan["input_hashes"]["review_video_sha256"], + } + + updated = broll_plan.record_chat_presentation_decision( + self.plan, decision, project_root=self.root, + ) + + receipt_path = self.root / "work/b-roll/presentation-decision.json" + self.assertTrue(receipt_path.is_file()) + self.assertEqual("ordinary", updated["presentation"]["mode"]) + self.assertEqual(broll_plan.sha256_file(receipt_path), updated["presentation"]["sha256"]) + receipt = projectlib.load_json(receipt_path) + self.assertEqual(decision["user_response"], receipt["user_response"]) + self.assertEqual(decision["agent_recommendation"], receipt["agent_recommendation"]) + self.assertEqual([], broll_plan.presentation_errors( + updated, project_root=self.root, required=True, + )) + + def test_chat_speaker_presentation_decision_enables_default_inset_style(self): + decision = { + "decision_id": "123e4567-e89b-12d3-a456-426614174002", + "mode": "human", + "actor": "Actual user", + "timestamp": "2026-08-06T12:00:00Z", + "explicit_user_action": True, + "rationale_source": "agent_chat_explicit_action", + "user_response": "Keep the speaker and show me the composite preview.", + "presentation_mode": "speaker-inset", + "agent_recommendation": { + "presentation_mode": "speaker-inset", + "rationale": "The speaker's explanation remains editorially important.", + }, + "plan_sha256": broll_plan.canonical_sha256( + broll_plan.presentation_subject(self.plan) + ), + "candidate_manifest_sha256": broll_plan.canonical_sha256( + broll_plan.candidate_manifest(self.plan) + ), + "review_video_sha256": self.plan["input_hashes"]["review_video_sha256"], + } + + updated = broll_plan.record_chat_presentation_decision( + self.plan, decision, project_root=self.root, + ) + + self.assertEqual("speaker-inset", updated["presentation"]["mode"]) + self.assertEqual(BrollPlanTests._speaker_style(), updated["speaker_inset_style"]) + + def test_prepare_composite_requires_chat_speaker_presentation_decision(self): + candidates = self._canonical_candidates(1) + segment = { + "candidate_id": candidates[0]["id"], + "source_range": {"start_s": 0.0, "end_s": 1.0}, + "program_range": copy.deepcopy(self.plan["shots"][0]["program_range"]), + "playback_rate": 1.0, + } + self.plan["speaker_inset_style"] = self._speaker_style() + selection = self._canonical_review([segment], intent="prepare_composite") + selection.update({ + "rationale": broll_plan.HUMAN_PREPARE_COMPOSITE_RATIONALE, + "rationale_source": "review_ui_explicit_action", + }) + + with self.assertRaisesRegex(ValueError, "agent-chat presentation decision is required"): + broll_plan.prepare_composite( + self.plan, selection, + mode="human", actor="Actual user", + rationale=broll_plan.HUMAN_PREPARE_COMPOSITE_RATIONALE, + project_root=self.root, timeline=self.timeline, + ) + + def test_speaker_inset_skill_docs_and_example_match_delivery_contract(self): + skill = (ROOT / "SKILL.md").read_text(encoding="utf-8") + rules = (ROOT / "reference/broll-rules.md").read_text(encoding="utf-8") + example = json.loads(( + ROOT / "examples/example-broll-plan.json" + ).read_text(encoding="utf-8")) + style = example["speaker_inset_style"] + self.assertEqual(self._speaker_style(), style) + for text in (skill, rules): + for value in ( + "focused-panel", "full-bleed-wash", "corner-pip", + "project_layout_strategy", "layout_recommendation", + "top-left", "top-right", "width_ratio", "0.39", + "agent_chat_explicit_action", "presentation-decision.json", + "ordinary", "speaker-inset"): + self.assertIn(value, text) + self.assertNotIn("size_candidates", text) + self.assertNotIn("bottom-left", text) + self.assertNotIn("bottom-right", text) + self.assertNotIn('shape: "circle"', text) + self.assertIn("review_video=Path(sys.argv[3]).resolve()", skill) + self.assertNotIn("currently stops at the second-page review gate", rules) + self.assertNotIn("do not normalize\nor deliver", skill) + self.assertNotIn("layout_preset", style) + self.assertNotIn("allowed_anchors", style) + self.assertEqual("chosen", example["presentation"]["status"]) + self.assertEqual("speaker-inset", example["presentation"]["mode"]) + self.assertEqual("b-roll/presentation-decision.json", example["presentation"]["path"]) + + def test_dynamic_social_docs_and_example_define_search_context_and_semantic_ranking_contract(self): + skill = (ROOT / "SKILL.md").read_text(encoding="utf-8") + rules = (ROOT / "reference/broll-rules.md").read_text(encoding="utf-8") + example = json.loads(( + ROOT / "examples/example-broll-plan.json" + ).read_text(encoding="utf-8")) + + for text in (skill, rules): + normalized = " ".join(text.split()) + for value in ( + "dynamic-social", "0.40", "0.70", + "brief.search_context", "topic", "visual_direction", "1-12 unique keywords", + "semantic_role", "direct", "supportive", "atmospheric", + "direct subject/action", "defensible context/process", "theme-enhancing variant", + "visible frozen-frame evidence", "weak_semantic_match", + "semantic-first lexicographic", "tech", "entertainment", + "Both `semantic_fit == 0` and `context_fit == 0` are retained hard ineligibility protections", + "then the combined `composition_fit + style_fit` score"): + self.assertIn(value, normalized) + self.assertIn("complete A-roll scan", normalized) + self.assertNotIn("selective", text.lower()) + self.assertNotIn("no minimum coverage", text.lower()) + + self.assertEqual("dynamic-social", example["brief"]["density"]) + self.assertEqual( + {"topic", "visual_direction", "keywords"}, + set(example["brief"]["search_context"]), + ) + self.assertTrue(example["brief"]["search_context"]["topic"].strip()) + self.assertTrue(example["brief"]["search_context"]["visual_direction"].strip()) + keywords = example["brief"]["search_context"]["keywords"] + self.assertGreaterEqual(len(keywords), 1) + self.assertLessEqual(len(keywords), 12) + self.assertEqual(len(keywords), len({item.casefold().strip() for item in keywords})) + self.assertTrue(all(item.strip() for item in keywords)) + self.assertTrue(all( + shot["semantic_role"] in {"direct", "supportive", "atmospheric"} + for shot in example["shots"] + )) + + def test_prepare_composite_writes_non_approval_selection_snapshot(self): + candidates = self._canonical_candidates(1) + segment = { + "candidate_id": candidates[0]["id"], + "source_range": {"start_s": 0.0, "end_s": 1.0}, + "program_range": copy.deepcopy(self.plan["shots"][0]["program_range"]), + "playback_rate": 1.0, + } + self.plan["speaker_inset_style"] = self._speaker_style() + self.plan = self.record_presentation(self.plan, "speaker-inset") + selection = self._canonical_review([segment], intent="prepare_composite") + selection.update({ + "rationale": broll_plan.HUMAN_PREPARE_COMPOSITE_RATIONALE, + "rationale_source": "review_ui_explicit_action", + }) + + prepared = broll_plan.prepare_composite( + self.plan, selection, + mode="human", actor="Actual user", + rationale=broll_plan.HUMAN_PREPARE_COMPOSITE_RATIONALE, + project_root=self.root, timeline=self.timeline, + ) + + self.assertEqual("composite_pending", prepared["shots"][0]["status"]) + self.assertEqual({"segments": [segment]}, prepared["shots"][0]["selected"]) + self.assertNotIn("review_status", prepared) + self.assertIsNone(prepared["decision"]) + self.assertIsNone(prepared["review"]) + receipt = prepared["selection"] + self.assertEqual("prepared", receipt["status"]) + self.assertEqual("prepare_composite", receipt["submission_intent"]) + self.assertEqual("b-roll/broll-selection.json", receipt["path"]) + selection_path = self.root / "work" / receipt["path"] + self.assertTrue(selection_path.is_file()) + self.assertEqual(receipt["sha256"], broll_plan.sha256_file(selection_path)) + self.assertEqual([], broll_plan.validate_plan( + prepared, self.timeline, self.transcript, + project_root=self.root, verify_files=True, + )) + + def test_composite_review_binds_all_speaker_artifacts_and_cannot_change_selection(self): + candidates = self._canonical_candidates(1) + segment = { + "candidate_id": candidates[0]["id"], + "source_range": {"start_s": 0.0, "end_s": 1.0}, + "program_range": copy.deepcopy(self.plan["shots"][0]["program_range"]), + "playback_rate": 1.0, + } + self.plan["speaker_inset_style"] = self._speaker_style() + self.plan = self.record_presentation(self.plan, "speaker-inset") + selection = self._canonical_review([segment], intent="prepare_composite") + selection.update({ + "rationale": broll_plan.HUMAN_PREPARE_COMPOSITE_RATIONALE, + "rationale_source": "review_ui_explicit_action", + }) + prepared = broll_plan.prepare_composite( + self.plan, selection, + mode="human", actor="Actual user", + rationale=broll_plan.HUMAN_PREPARE_COMPOSITE_RATIONALE, + project_root=self.root, timeline=self.timeline, + ) + prepared["speaker_inset"] = { + "analysis": { + "path": "b-roll/speaker-inset-analysis.json", "sha256": "a" * 64, + }, + "agent_input": { + "path": "b-roll/speaker-inset-agent-input.json", "sha256": "b" * 64, + }, + "preview": { + "path": "b-roll/speaker-inset-preview.json", "sha256": "c" * 64, + }, + "clearance": { + "path": "b-roll/speaker-inset-clearance.json", "sha256": "d" * 64, + }, + } + decisions = broll_plan._decision_manifest(prepared["shots"]) + review = self.review_for( + prepared, decisions, + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + submission_intent="approve", + review_stage="composite", + explicit_user_action=True, + revision_notes="", + rationale_source="review_ui_explicit_action", + timeline_fps=copy.deepcopy(self.timeline["fps"]), + selection_sha256=prepared["selection"]["sha256"], + analysis_sha256=prepared["speaker_inset"]["analysis"]["sha256"], + agent_input_sha256=prepared["speaker_inset"]["agent_input"]["sha256"], + preview_sha256=prepared["speaker_inset"]["preview"]["sha256"], + clearance_sha256=prepared["speaker_inset"]["clearance"]["sha256"], + style_sha256=broll_plan.canonical_sha256(prepared["speaker_inset_style"]), + ) + approved = broll_plan.apply_review( + prepared, review, + mode="human", actor="Actual user", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + timeline=self.timeline, + ) + self.assertEqual("selected", approved["shots"][0]["status"]) + self.assertEqual("composite", approved["review"]["review_stage"]) + self.assertEqual( + prepared["speaker_inset"]["clearance"]["sha256"], + approved["review"]["clearance_sha256"], + ) + self.assertEqual([], broll_plan.validate_plan( + approved, self.timeline, self.transcript, + )) + + stale = copy.deepcopy(review) + stale["preview_sha256"] = "f" * 64 + with self.assertRaisesRegex(ValueError, "preview_sha256"): + broll_plan.apply_review( + prepared, stale, + mode="human", actor="Actual user", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + timeline=self.timeline, + ) + changed = copy.deepcopy(review) + changed["shots"][0]["segments"][0]["source_range"]["start_s"] = 0.25 + with self.assertRaisesRegex(ValueError, "locked selection"): + broll_plan.apply_review( + prepared, changed, + mode="human", actor="Actual user", + rationale=broll_plan.HUMAN_APPROVAL_RATIONALE, + timeline=self.timeline, + ) + + 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)) plan = copy.deepcopy(self.plan); plan["shots"][0]["program_range"] = {"start_s": -1, "end_s": 1} self.assertIn("shot program range is outside timeline", broll_plan.validate_plan(plan, self.timeline, self.transcript)) + def test_rejects_non_frame_aligned_program_range_before_review_publication(self): + plan = copy.deepcopy(self.plan) + plan["shots"][0]["program_range"]["end_s"] = 2.01 + plan["shots"][0]["source_ranges"][0]["end_s"] = 2.01 + + self.assertIn( + "shot program range must align to timeline frames", + broll_plan.validate_plan(plan, self.timeline, self.transcript), + ) + def test_evidence_word_must_exactly_match_mapped_transcript_word(self): plan = copy.deepcopy(self.plan); plan["shots"][0]["transcript_evidence"]["words"][0]["source_range"]["start_s"] = 1.1 self.assertIn("shot transcript evidence word is not mapped from transcript", broll_plan.validate_plan(plan, self.timeline, self.transcript)) 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_revision_rebuild_clears_stale_presentation_route(self): + presented = self.record_presentation(self.plan, "speaker-inset") + request = self.review_for( + presented, + [{ + "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="Use the revised range.", + ) + request.pop("rationale") + + rebuilt = broll_plan.rebuild_plan_from_revision( + presented, request, self.timeline, self.transcript, + ) + + self.assertNotIn("presentation", rebuilt) + self.assertNotIn("speaker_inset_style", rebuilt) + self.assertEqual("ordinary", self.record_presentation(rebuilt, "ordinary")["presentation"]["mode"]) + + 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") @@ -1444,6 +2249,30 @@ def test_register_operation_requires_verified_approved_normalized_shots_and_orde with self.subTest(change=change): with self.assertRaises(ValueError): broll_plan.register_operation(project, plan) + def test_registered_speaker_inset_requires_composited_normalized_overlay(self): + plan = self._registered_plan((2, 3)) + plan["speaker_inset_style"] = self._speaker_style() + + with self.assertRaisesRegex( + ValueError, "speaker inset normalized composition is missing"): + broll_plan._verified_overlays(plan) + + def test_enabled_speaker_style_all_skipped_remains_a_noop(self): + plan = copy.deepcopy(self.plan) + plan["speaker_inset_style"] = self._speaker_style() + review = self.review_for( + plan, [{"id": "shot", "decision": "skip"}], + rationale="No useful footage.", + ) + + approved = broll_plan.apply_review( + plan, review, mode="agent", actor="agent", + rationale="No useful footage.", timeline=self.timeline, + ) + + self.assertEqual("skipped", approved["shots"][0]["status"]) + self.assertEqual([], broll_plan._verified_overlays(approved)) + def test_register_operation_removes_old_registration_for_no_selected_shots(self): project = self._registration_project(["cut", "b-roll", "b-roll", "captions"]) project["operations"].append({"id": "b-roll", "revision": 7}) @@ -1565,6 +2394,7 @@ def setUp(self): self.video.parent.mkdir(parents=True, exist_ok=True) self.video.write_bytes(b"program") self.plan["input_hashes"]["review_video_sha256"] = broll_plan.sha256_file(self.video) + self.plan = self.record_presentation(self.plan, "ordinary") self.real_probe_video = build_review_page._probe_video self.probe = mock.patch.object(build_review_page, "_probe_video", return_value=10.0) self.probe.start() @@ -1606,6 +2436,193 @@ def test_build_review_page_publishes_local_payload_and_immutable_assets(self): with self.assertRaises(FileExistsError): build_review_page.build_review_page(self.plan, self.timeline, self.transcript, self.video, self.review_dir, project_root=self.root, review_id=review_id) + def test_build_review_page_requires_chat_presentation_decision(self): + plan = copy.deepcopy(self.plan) + plan.pop("presentation") + with mock.patch.object(build_review_page, "_extract_frame", side_effect=self._frame): + with self.assertRaisesRegex(ValueError, "agent-chat presentation decision is required"): + build_review_page.build_review_page( + plan, self.timeline, self.transcript, self.video, + self.review_dir, project_root=self.root, + review_id="123e4567-e89b-12d3-a456-426614174010", + ) + + def test_review_page_switches_selection_intent_and_publishes_composite_assets(self): + selection_plan = copy.deepcopy(self.plan) + selection_plan.pop("presentation") + selection_plan["speaker_inset_style"] = BrollPlanTests._speaker_style() + selection_plan = self.record_presentation(selection_plan, "speaker-inset") + with mock.patch.object(build_review_page, "_extract_frame", side_effect=self._frame): + selection_result = build_review_page.build_review_page( + selection_plan, self.timeline, self.transcript, self.video, self.review_dir, + project_root=self.root, + review_id="123e4567-e89b-12d3-a456-426614174020", + ) + selection_payload = json.loads(base64.b64decode( + build_review_page.PAYLOAD_RE.search( + selection_result["page"].read_text(encoding="utf-8") + ).group(1) + )) + self.assertEqual("selection", selection_payload["review_mode"]) + + plan = copy.deepcopy(selection_plan) + segment = { + "candidate_id": "asset", + "source_range": {"start_s": 0.0, "end_s": 1.0}, + "program_range": copy.deepcopy(plan["shots"][0]["program_range"]), + "playback_rate": 1.0, + } + plan["shots"][0].update({ + "selected": {"segments": [segment]}, + "status": "composite_pending", + }) + plan["selection"] = { + "status": "prepared", "submission_intent": "prepare_composite", + "path": "b-roll/broll-selection.json", "sha256": "9" * 64, + "style_sha256": broll_plan.canonical_sha256(plan["speaker_inset_style"]), + } + cache = self.root / "work/cache/b-roll/speaker-inset" + cache.mkdir(parents=True) + evidence = cache / "evidence.jpg" + Image.new("RGB", (160, 90), "navy").save(evidence, "JPEG") + base = cache / "base.mp4" + context = cache / "context.mp4" + base.write_bytes(b"base-preview") + context.write_bytes(b"context-preview") + alternates = {} + for anchor in speaker_inset.PRESET_ANCHORS["corner-pip"]: + path = cache / f"context-{anchor}.mp4" + path.write_bytes(f"context-{anchor}".encode("ascii")) + alternates[anchor] = { + "path": path.relative_to(self.root / "work").as_posix(), + "sha256": broll_plan.sha256_file(path), + } + analysis = { + "schema_version": 1, + "shots": [{ + "shot_id": "shot", + "subshots": [{ + "id": "shot-subshot-001", + "program_range": copy.deepcopy(plan["shots"][0]["program_range"]), + "evidence_points": [{ + "frames": [{ + "program_time_s": 1.0, + "path": evidence.relative_to(self.root / "work").as_posix(), + "sha256": broll_plan.sha256_file(evidence), + }], + }], + }], + }], + } + analysis_path = self.root / "work/b-roll/speaker-inset-analysis.json" + projectlib.write_json(analysis_path, analysis) + agent_input = { + "schema_version": 1, "actor": "Codex", + "project_layout_strategy": SpeakerInsetTests._layout_strategy(), + "shots": [{ + "shot_id": "shot", + "layout_recommendation": SpeakerInsetTests._layout_recommendation(), + "subshots": [{ + "id": "shot-subshot-001", "speaker_status": "confirmed", + "display_mode": "enabled", "anchor": "top-left", + "rationale": "One current speaker is visible.", + "keyframes": [{ + "program_time_s": 1.0, + "roi": {"x": 0.1, "y": 0.1, "width": 0.3, "height": 0.6}, + }], + }], + }], + } + agent_path = self.root / "work/b-roll/speaker-inset-agent-input.json" + projectlib.write_json(agent_path, agent_input) + preview = { + "schema_version": 1, + "shots": [{ + "shot_id": "shot", + "base_broll": { + "path": base.relative_to(self.root / "work").as_posix(), + "sha256": broll_plan.sha256_file(base), + }, + "preview": { + "path": context.relative_to(self.root / "work").as_posix(), + "sha256": broll_plan.sha256_file(context), + }, + "anchor_previews": alternates, + }], + } + preview_path = self.root / "work/b-roll/speaker-inset-preview.json" + projectlib.write_json(preview_path, preview) + clearance = { + "schema_version": 1, "actor": "Codex", + "shots": [{ + "shot_id": "shot", + "continuity": { + "risk": "none", + "decision": "continuous", + "rationale": "The inset remains enabled for the complete shot.", + }, + "subshots": [{ + "id": "shot-subshot-001", "display_mode": "enabled", + "anchor": "top-left", "clearance_status": "pass", + "checked_anchors": ["top-left"], + "subject_legibility": "pass", + "rationale": "The inset does not cover the B-roll focal action.", + }], + }], + } + clearance_path = self.root / "work/b-roll/speaker-inset-clearance.json" + projectlib.write_json(clearance_path, clearance) + plan["speaker_inset"] = { + "analysis": {"path": "b-roll/speaker-inset-analysis.json", "sha256": broll_plan.sha256_file(analysis_path)}, + "agent_input": {"path": "b-roll/speaker-inset-agent-input.json", "sha256": broll_plan.sha256_file(agent_path)}, + "preview": {"path": "b-roll/speaker-inset-preview.json", "sha256": broll_plan.sha256_file(preview_path)}, + "clearance": {"path": "b-roll/speaker-inset-clearance.json", "sha256": broll_plan.sha256_file(clearance_path)}, + } + with mock.patch.object(broll_plan, "validate_plan", return_value=[]), mock.patch.object( + build_review_page, "_extract_frame") as extract: + result = build_review_page.build_review_page( + plan, self.timeline, self.transcript, self.video, self.review_dir, + project_root=self.root, + review_id="123e4567-e89b-12d3-a456-426614174021", + ) + extract.assert_not_called() + html = result["page"].read_text(encoding="utf-8") + payload = json.loads(base64.b64decode( + build_review_page.PAYLOAD_RE.search(html).group(1) + )) + self.assertEqual("composite", payload["review_mode"]) + self.assertEqual({"segments": [segment]}, payload["shots"][0]["locked_selection"]) + self.assertEqual("pass", payload["shots"][0]["subshots"][0]["clearance_status"]) + self.assertEqual("pass", payload["shots"][0]["subshots"][0]["subject_legibility"]) + self.assertEqual("continuous", payload["shots"][0]["continuity"]["decision"]) + self.assertEqual( + SpeakerInsetTests._layout_strategy(), + payload["speaker_layout_strategy"], + ) + self.assertEqual( + SpeakerInsetTests._layout_recommendation(), + payload["shots"][0]["layout_recommendation"], + ) + self.assertEqual(0.39, payload["speaker_style"]["width_ratio"]) + self.assertNotIn("speaker_size_review", payload) + self.assertEqual( + plan["speaker_inset"]["clearance"]["sha256"], + payload["speaker_bindings"]["clearance_sha256"], + ) + for key in ("base_broll", "preview"): + self.assertTrue((result["page"].parent / payload["shots"][0][key]["path"]).is_file()) + self.assertTrue(( + result["page"].parent + / payload["shots"][0]["subshots"][0]["evidence_frames"][0]["path"] + ).is_file()) + for text in ( + "prepare_composite", "value.review_stage='composite'", "speaker_bindings", + "Project layout strategy", "Preset assessments", "subject_legibility", + "continuity"): + self.assertIn(text, html) + self.assertNotIn("Project speaker size", html) + self.assertNotIn("size-candidates", html) + def test_build_review_page_keeps_prior_publication_when_a_second_uuid_is_published(self): first = "123e4567-e89b-12d3-a456-426614174001" second = "123e4567-e89b-12d3-a456-426614174002" @@ -1669,10 +2686,12 @@ def test_published_candidate_survives_in_place_cache_mutation(self): def test_payload_is_ascii_safe_and_unicode_round_trips(self): plan = copy.deepcopy(self.plan) + plan.pop("presentation") plan["shots"][0]["id"] = "shot-cafe\u0301" plan["shots"][0]["queries"] = ["usine animee", "工場の映像"] plan["shots"][0]["candidates"][0]["id"] = "候補" plan["shots"][0]["candidates"][0]["provenance"]["creator"] = "Jose Alvarez - 東京" + plan = self.record_presentation(plan, "ordinary") with mock.patch.object(build_review_page, "_extract_frame", side_effect=self._frame): result = build_review_page.build_review_page(plan, self.timeline, self.transcript, self.video, self.review_dir, project_root=self.root) encoded = build_review_page.PAYLOAD_RE.search(result["page"].read_text(encoding="utf-8")).group(1) @@ -1688,8 +2707,17 @@ def test_build_review_page_rejects_video_path_hash_and_duration_before_frames(se outside = self.root.parent / f"{self.root.name}-outside.mp4" outside.write_bytes(b"program") self.addCleanup(outside.unlink, missing_ok=True) - cases = [(outside, self.plan, 10.0, "inside project_root"), (self.video, {**self.plan, "input_hashes": {**self.plan["input_hashes"], "review_video_sha256": "0" * 64}}, 10.0, "review video SHA-256"), (self.video, self.plan, 9.9, "duration")] + cases = [ + (outside, self.plan, 10.0, "inside project_root"), + (self.video, self.plan, 9.9, "duration"), + (self.video, None, 10.0, "review video SHA-256"), + ] for video, plan, duration, message in cases: + if message == "review video SHA-256": + plan = copy.deepcopy(self.plan) + plan.pop("presentation") + plan["input_hashes"]["review_video_sha256"] = "0" * 64 + plan = self.record_presentation(plan, "ordinary") with self.subTest(message=message), mock.patch.object(build_review_page, "_probe_video", return_value=duration), mock.patch.object(build_review_page, "_extract_frame") as extract: with self.assertRaisesRegex((ValueError, FileNotFoundError), message): build_review_page.build_review_page(plan, self.timeline, self.transcript, video, self.review_dir, project_root=self.root) @@ -1831,9 +2859,11 @@ def test_payload_candidate_duration_is_always_a_positive_finite_number(self): ] for index, (variant, removed, expected) in enumerate(variants, 10): plan = copy.deepcopy(self.plan) + plan.pop("presentation") for key in removed: plan["shots"][0]["candidates"][0].pop(key) plan["shots"][0]["candidates"][0].update(variant) + plan = self.record_presentation(plan, "ordinary") review_id = f"123e4567-e89b-12d3-a456-4266141740{index}" with self.subTest(variant=variant), mock.patch.object(build_review_page, "_extract_frame", side_effect=self._frame): result = build_review_page.build_review_page( @@ -1849,6 +2879,170 @@ 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"', + '