import { createHash } from "node:crypto"; import type { ArollJumpCut, ArollSegment, BrollContinuityBridge, BrollContinuityPlan, BrollContinuityPlanReceipt, BrollContinuityPlanReceiptV2, BrollContinuityPlanReceiptV3, BrollContinuityPlanReceiptV4, BrollCoverageSummary, BrollNeed, BrollPlanSelection, OmittedBrollNeed, } from "./contracts.ts"; import { timelineDuration } from "./transcript.ts"; export const LONG_CONTINUOUS_BROLL_REVIEW_MS = 8_000; export const MAX_INITIAL_CONTINUOUS_BROLL_MS = 8_000; export const MIN_MEANINGFUL_AROLL_ISLAND_MS = 3_000; export const MIN_ENDING_AROLL_MS = 3_000; export interface PlanBrollContinuityInput { aroll: ArollSegment[]; needs: BrollNeed[]; shortGapMs?: number; cutCoverBeforeMs?: number; cutCoverAfterMs?: number; } export interface BrollOutputWindow { id: string; outputStartMs: number; outputEndMs: number; } export interface ShortArollFlashGap { fromBrollId: string; toBrollId: string; startMs: number; endMs: number; durationMs: number; } export interface OverlappingBrollWindows { underBrollId: string; overBrollId: string; } interface BrollViewingRun { startMs: number; endMs: number; durationMs: number; needIds: string[]; } function brollViewingRuns( windows: BrollOutputWindow[], minMeaningfulArollIslandMs = MIN_MEANINGFUL_AROLL_ISLAND_MS, ): BrollViewingRun[] { const sorted = [...windows].sort((left, right) => ( left.outputStartMs - right.outputStartMs || left.outputEndMs - right.outputEndMs || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) )); const runs: BrollViewingRun[] = []; for (const window of sorted) { const current = runs.at(-1); if (!current || window.outputStartMs - current.endMs >= minMeaningfulArollIslandMs) { runs.push({ startMs: window.outputStartMs, endMs: window.outputEndMs, durationMs: window.outputEndMs - window.outputStartMs, needIds: [window.id], }); continue; } current.endMs = Math.max(current.endMs, window.outputEndMs); current.durationMs = current.endMs - current.startMs; current.needIds.push(window.id); } return runs; } function boundedMilliseconds(value: number | undefined, fallback: number, name: string, maximum: number): number { const selected = value ?? fallback; if (!Number.isInteger(selected) || selected < 0 || selected > maximum) { throw new Error(`${name} must be an integer within 0-${maximum}`); } return selected; } export function findShortArollFlashGaps( windows: BrollOutputWindow[], shortGapMs = 500, ): ShortArollFlashGap[] { const threshold = boundedMilliseconds(shortGapMs, 500, "shortGapMs", 2_000); const sorted = [...windows].sort((left, right) => ( left.outputStartMs - right.outputStartMs || left.outputEndMs - right.outputEndMs || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) )); const gaps: ShortArollFlashGap[] = []; let frontier = sorted[0]; let coveredUntilMs = frontier?.outputEndMs ?? 0; for (let index = 1; index < sorted.length; index += 1) { const next = sorted[index]!; if (next.outputStartMs <= coveredUntilMs) { if (next.outputEndMs > coveredUntilMs) { frontier = next; coveredUntilMs = next.outputEndMs; } continue; } const durationMs = next.outputStartMs - coveredUntilMs; if (durationMs > threshold) { frontier = next; coveredUntilMs = next.outputEndMs; continue; } gaps.push({ fromBrollId: frontier!.id, toBrollId: next.id, startMs: coveredUntilMs, endMs: next.outputStartMs, durationMs, }); frontier = next; coveredUntilMs = next.outputEndMs; } return gaps; } export function findOverlappingBrollWindows(windows: BrollOutputWindow[]): OverlappingBrollWindows[] { const sorted = [...windows].sort((left, right) => ( left.outputStartMs - right.outputStartMs || right.outputEndMs - left.outputEndMs || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) )); const overlaps: OverlappingBrollWindows[] = []; let frontier = sorted[0]; for (let index = 1; index < sorted.length; index += 1) { const next = sorted[index]!; if (frontier && next.outputStartMs < frontier.outputEndMs) { overlaps.push({ underBrollId: frontier.id, overBrollId: next.id }); if (next.outputEndMs > frontier.outputEndMs) frontier = next; continue; } frontier = next; } return overlaps; } export function summarizeBrollCoverage( windows: BrollOutputWindow[], outputDurationMs: number, ): BrollCoverageSummary { if (!Number.isFinite(outputDurationMs) || outputDurationMs <= 0) { throw new Error("outputDurationMs must be a positive finite number"); } for (const window of windows) { if (!Number.isFinite(window.outputStartMs) || !Number.isFinite(window.outputEndMs) || window.outputStartMs < 0 || window.outputEndMs <= window.outputStartMs || window.outputEndMs > outputDurationMs) { throw new Error(`Invalid B-roll coverage window: ${window.id}`); } } const sorted = [...windows].sort((left, right) => ( left.outputStartMs - right.outputStartMs || left.outputEndMs - right.outputEndMs || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) )); let brollDurationMs = 0; let longestContinuousBrollMs = 0; let runStartMs: number | undefined; let runEndMs: number | undefined; for (const window of sorted) { if (runStartMs === undefined || runEndMs === undefined) { runStartMs = window.outputStartMs; runEndMs = window.outputEndMs; continue; } if (window.outputStartMs <= runEndMs) { runEndMs = Math.max(runEndMs, window.outputEndMs); continue; } const durationMs = runEndMs - runStartMs; brollDurationMs += durationMs; longestContinuousBrollMs = Math.max(longestContinuousBrollMs, durationMs); runStartMs = window.outputStartMs; runEndMs = window.outputEndMs; } if (runStartMs !== undefined && runEndMs !== undefined) { const durationMs = runEndMs - runStartMs; brollDurationMs += durationMs; longestContinuousBrollMs = Math.max(longestContinuousBrollMs, durationMs); } const brollCoverageRatio = Math.round((brollDurationMs / outputDurationMs) * 10_000) / 10_000; const warnings: BrollCoverageSummary["warnings"] = []; if (longestContinuousBrollMs > LONG_CONTINUOUS_BROLL_REVIEW_MS) { warnings.push({ code: "long-continuous-run", message: `One continuous B-roll run lasts ${longestContinuousBrollMs}ms; confirm that its source window remains useful for the full spoken idea or split it into a varied shot group.`, }); } return { brollDurationMs, arollDurationMs: outputDurationMs - brollDurationMs, brollCoverageRatio, longestContinuousBrollMs, warnings, }; } function validateSegments(aroll: ArollSegment[]): void { if (aroll.length === 0 || aroll.length > 1_000) throw new Error("A-roll must contain 1-1000 segments"); const ids = new Set(); for (const segment of aroll) { if (ids.has(segment.id)) throw new Error(`Duplicate A-roll segment ID: ${segment.id}`); ids.add(segment.id); if (!Number.isFinite(segment.sourceStartMs) || !Number.isFinite(segment.sourceEndMs) || segment.sourceStartMs < 0 || segment.sourceEndMs <= segment.sourceStartMs) { throw new Error(`Invalid A-roll segment range: ${segment.id}`); } } } function arollJumpCutOutputTimes(aroll: ArollSegment[]): Set { const outputTimes = new Set(); let outputTimeMs = 0; for (let index = 0; index < aroll.length - 1; index += 1) { const current = aroll[index]!; const next = aroll[index + 1]!; outputTimeMs += current.sourceEndMs - current.sourceStartMs; if (next.sourceStartMs !== current.sourceEndMs) outputTimes.add(outputTimeMs); } return outputTimes; } function validateNeeds(needs: BrollNeed[], outputDurationMs: number, aroll: ArollSegment[]): void { if (needs.length > 500) throw new Error("B-roll plan must contain at most 500 needs"); const ids = new Set(); const jumpCutOutputTimes = arollJumpCutOutputTimes(aroll); for (const need of needs) { const unexpectedVisualReview = (need as { visualReview?: unknown }).visualReview; if (ids.has(need.id)) throw new Error(`Duplicate B-roll need ID: ${need.id}`); ids.add(need.id); if (!Number.isFinite(need.outputStartMs) || !Number.isFinite(need.outputEndMs) || need.outputStartMs < 0 || need.outputEndMs <= need.outputStartMs || need.outputEndMs > outputDurationMs) { throw new Error(`Invalid B-roll need output range: ${need.id}`); } if (!need.speechText.trim()) throw new Error(`B-roll need ${need.id} must name the complete spoken idea it supports`); if (!need.reason.trim()) throw new Error(`B-roll need ${need.id} must explain why the picture adds information`); if (need.purpose !== "mask-cut") { if (!need.visualCueText.trim()) throw new Error(`B-roll need ${need.id} must name the exact visual cue`); if (need.necessity !== "essential" && need.necessity !== "supporting") { throw new Error(`B-roll need ${need.id} must classify necessity as essential or supporting`); } } if (need.purpose === "mask-cut") { if (need.visualReview?.decision !== "mask-with-broll" || !Number.isFinite(need.visualReview.reviewedJumpCutOutputMs)) { throw new Error(`B-roll mask-cut need ${need.id} requires visualReview evidence for an objectionable A-roll jump`); } const reviewedOutputMs = need.visualReview.reviewedJumpCutOutputMs; if (!jumpCutOutputTimes.has(reviewedOutputMs)) { throw new Error(`B-roll mask-cut need ${need.id} visualReview does not identify an A-roll source jump`); } if (reviewedOutputMs <= need.outputStartMs || reviewedOutputMs >= need.outputEndMs) { throw new Error(`B-roll mask-cut need ${need.id} must span the reviewed A-roll jump`); } if (!need.visualReview.artifactPath.trim() || !/^[a-f0-9]{64}$/.test(need.visualReview.artifactSha256 ?? "")) { throw new Error(`B-roll mask-cut need ${need.id} requires a verified visual review artifact`); } } else if (unexpectedVisualReview !== undefined) { throw new Error(`B-roll visualReview is only valid for purpose=mask-cut: ${need.id}`); } } const overlap = findOverlappingBrollWindows(needs)[0]; if (overlap) throw new Error(`B-roll needs ${overlap.underBrollId} and ${overlap.overBrollId} overlap; z-order is not supported`); } function bridgeShortGaps(needs: BrollNeed[], shortGapMs: number): { needs: BrollNeed[]; bridges: BrollContinuityBridge[]; } { const sorted = structuredClone(needs).sort((left, right) => ( left.outputStartMs - right.outputStartMs || left.outputEndMs - right.outputEndMs || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) )); const bridges: BrollContinuityBridge[] = []; let frontier = sorted[0]; let coveredUntilMs = frontier?.outputEndMs ?? 0; for (let index = 1; index < sorted.length; index += 1) { const next = sorted[index]!; if (next.outputStartMs <= coveredUntilMs) { if (next.outputEndMs > coveredUntilMs) { frontier = next; coveredUntilMs = next.outputEndMs; } continue; } const durationMs = next.outputStartMs - coveredUntilMs; if (durationMs > shortGapMs) { frontier = next; coveredUntilMs = next.outputEndMs; continue; } bridges.push({ fromNeedId: frontier!.id, toNeedId: next.id, gapStartMs: coveredUntilMs, gapEndMs: next.outputStartMs, durationMs, strategy: "extend-previous-broll", }); frontier!.outputEndMs = next.outputStartMs; frontier = next; coveredUntilMs = next.outputEndMs; } return { needs: sorted, bridges }; } function supportingRemovalRank(need: BrollNeed): number { switch (need.purpose) { case "transition": return 0; case "establish": return 1; case "explain": return 2; case "demonstrate": return 3; case "evidence": return 4; case "mask-cut": return Number.POSITIVE_INFINITY; } } function selectSparseBrollNeeds( needs: BrollNeed[], outputDurationMs: number, shortGapMs: number, ): { needs: BrollNeed[]; omittedNeeds: OmittedBrollNeed[]; } { const selected = structuredClone(needs).filter((need) => ( need.purpose === "transition" ? need.necessity === "essential" : true )); const omittedNeeds: OmittedBrollNeed[] = needs.flatMap((need) => ( need.purpose === "transition" && need.necessity !== "essential" ? [{ id: need.id, reason: "a-roll-preferred" as const }] : [] )); while (selected.length > 0) { const bridged = bridgeShortGaps(selected, shortGapMs); const endingNeedIds = bridged.needs.filter((need) => ( need.outputEndMs > outputDurationMs - MIN_ENDING_AROLL_MS )).map((need) => need.id); const overlongViewingRun = brollViewingRuns(bridged.needs).find((run) => ( run.durationMs > MAX_INITIAL_CONTINUOUS_BROLL_MS )); if (endingNeedIds.length === 0 && !overlongViewingRun) break; const violatingNeedIds = endingNeedIds.length > 0 ? endingNeedIds : overlongViewingRun!.needIds; const removable = selected.filter((need) => ( violatingNeedIds.includes(need.id) && need.purpose !== "mask-cut" && need.necessity !== "essential" )).sort((left, right) => ( supportingRemovalRank(left) - supportingRemovalRank(right) || (right.outputEndMs - right.outputStartMs) - (left.outputEndMs - left.outputStartMs) || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) )); const removed = removable[0]; if (!removed) { throw new Error(endingNeedIds.length > 0 ? `essential B-roll needs leave less than ${MIN_ENDING_AROLL_MS}ms of ending A-roll; shorten or move the visual cue before matching assets` : "essential B-roll needs exceed the continuous viewing-run budget; shorten or split the visual cue before matching assets"); } selected.splice(selected.findIndex((need) => need.id === removed.id), 1); omittedNeeds.push({ id: removed.id, reason: endingNeedIds.length > 0 ? "ending-a-roll" : "continuous-run", }); } return { needs: selected, omittedNeeds }; } function coverageForRange(needs: BrollOutputWindow[], startMs: number, endMs: number): { covered: boolean; needIds: string[]; } { const overlapping = needs.filter((need) => need.outputEndMs > startMs && need.outputStartMs < endMs).sort((left, right) => ( left.outputStartMs - right.outputStartMs || left.outputEndMs - right.outputEndMs || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) )); let cursor = startMs; const needIds: string[] = []; for (const need of overlapping) { if (need.outputStartMs > cursor) return { covered: false, needIds }; needIds.push(need.id); cursor = Math.max(cursor, need.outputEndMs); if (cursor >= endMs) return { covered: true, needIds }; } return { covered: false, needIds }; } export function analyzeArollJumpCuts( aroll: ArollSegment[], needs: BrollOutputWindow[], outputDurationMs: number, cutCoverBeforeMs: number, cutCoverAfterMs: number, ): ArollJumpCut[] { const jumpCuts: ArollJumpCut[] = []; let outputTimeMs = 0; for (let index = 0; index < aroll.length - 1; index += 1) { const current = aroll[index]!; const next = aroll[index + 1]!; outputTimeMs += current.sourceEndMs - current.sourceStartMs; const sourceDeltaMs = next.sourceStartMs - current.sourceEndMs; if (sourceDeltaMs === 0) continue; const suggestedOutputStartMs = Math.max(0, outputTimeMs - cutCoverBeforeMs); const suggestedOutputEndMs = Math.min(outputDurationMs, outputTimeMs + cutCoverAfterMs); const coverage = coverageForRange(needs, suggestedOutputStartMs, suggestedOutputEndMs); jumpCuts.push({ fromSegmentId: current.id, toSegmentId: next.id, outputTimeMs, sourceDeltaMs, suggestedOutputStartMs, suggestedOutputEndMs, coveredByNeedIds: coverage.needIds, status: coverage.covered ? "covered" : "review", }); } return jumpCuts; } function buildSelectedBrollContinuityPlan( input: PlanBrollContinuityInput, selection: Pick, ): BrollContinuityPlan { validateSegments(input.aroll); const outputDurationMs = timelineDuration(input.aroll); validateNeeds(input.needs, outputDurationMs, input.aroll); const shortGapMs = boundedMilliseconds(input.shortGapMs, 500, "shortGapMs", 2_000); const cutCoverBeforeMs = boundedMilliseconds(input.cutCoverBeforeMs, 250, "cutCoverBeforeMs", 2_000); const cutCoverAfterMs = boundedMilliseconds(input.cutCoverAfterMs, 500, "cutCoverAfterMs", 2_000); if (cutCoverBeforeMs === 0 && cutCoverAfterMs === 0) { throw new Error("cutCoverBeforeMs and cutCoverAfterMs cannot both be zero"); } const bridged = bridgeShortGaps(input.needs, shortGapMs); const coverage = summarizeBrollCoverage(bridged.needs, outputDurationMs); return { outputDurationMs, shortGapMs, minMeaningfulArollIslandMs: MIN_MEANINGFUL_AROLL_ISLAND_MS, minEndingArollMs: MIN_ENDING_AROLL_MS, cutCoverBeforeMs, cutCoverAfterMs, needs: bridged.needs, bridges: bridged.bridges, coverage, selection: { inputNeedCount: selection.inputNeedCount, selectedNeedCount: bridged.needs.length, omittedNeeds: selection.omittedNeeds, maxInitialContinuousBrollMs: MAX_INITIAL_CONTINUOUS_BROLL_MS, minMeaningfulArollIslandMs: MIN_MEANINGFUL_AROLL_ISLAND_MS, minEndingArollMs: MIN_ENDING_AROLL_MS, }, jumpCuts: analyzeArollJumpCuts( input.aroll, bridged.needs, outputDurationMs, cutCoverBeforeMs, cutCoverAfterMs, ), }; } export function planBrollContinuity(input: PlanBrollContinuityInput): BrollContinuityPlan { validateSegments(input.aroll); const outputDurationMs = timelineDuration(input.aroll); validateNeeds(input.needs, outputDurationMs, input.aroll); const shortGapMs = boundedMilliseconds(input.shortGapMs, 500, "shortGapMs", 2_000); const selected = selectSparseBrollNeeds(input.needs, outputDurationMs, shortGapMs); return buildSelectedBrollContinuityPlan({ ...input, needs: selected.needs }, { inputNeedCount: input.needs.length, omittedNeeds: selected.omittedNeeds, }); } type BrollContinuityPlanReceiptPayload = Omit; function planReceiptPayload(receipt: BrollContinuityPlanReceiptPayload): string { return canonicalJson(receipt); } function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") { const serialized = JSON.stringify(value); if (serialized === undefined) throw new Error("Cannot canonicalize an undefined value"); return serialized; } if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; const object = value as Record; return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`; } function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } function arollSha256(aroll: ArollSegment[]): string { return sha256(canonicalJson(aroll)); } function plannedRanges(needs: Array>): Array<{ id: string; outputStartMs: number; outputEndMs: number; }> { return needs.map((need) => ({ id: need.id, outputStartMs: need.outputStartMs, outputEndMs: need.outputEndMs, })); } function plannedNeedsV2(needs: BrollNeed[]): BrollContinuityPlanReceiptV2["needs"] { return needs.map((need) => { const range = { id: need.id, outputStartMs: need.outputStartMs, outputEndMs: need.outputEndMs, }; if (need.purpose !== "mask-cut") return { ...range, purpose: need.purpose }; const artifactSha256 = need.visualReview.artifactSha256; if (artifactSha256 === undefined) { throw new Error(`B-roll mask-cut need ${need.id} requires a verified visual review artifact`); } return { ...range, purpose: "mask-cut", visualReview: { ...structuredClone(need.visualReview), artifactSha256, }, }; }); } function plannedNeedsV3(needs: BrollNeed[]): BrollContinuityPlanReceiptV3["needs"] { return needs.map((need) => { if (need.purpose !== "mask-cut") return structuredClone(need); const artifactSha256 = need.visualReview.artifactSha256; if (artifactSha256 === undefined) { throw new Error(`B-roll mask-cut need ${need.id} requires a verified visual review artifact`); } return { ...structuredClone(need), purpose: "mask-cut", visualReview: { ...structuredClone(need.visualReview), artifactSha256, }, }; }); } function verificationNeedV2(need: BrollContinuityPlanReceiptV2["needs"][number]): BrollNeed { const context = { speechText: "continuity receipt", searchTerms: ["continuity"], reason: "continuity receipt verification", }; if (need.purpose === "mask-cut") return { ...need, ...context }; return { ...need, ...context, visualCueText: "continuity receipt", necessity: "supporting", }; } export function normalizeArollJumpCutStatuses(jumpCuts: ArollJumpCut[]): ArollJumpCut[] { return jumpCuts.map((jumpCut) => ({ ...jumpCut, status: jumpCut.status === "needs-broll" ? "review" : jumpCut.status, })); } export function createBrollContinuityPlanReceipt( projectId: string, revision: number, aroll: ArollSegment[], plan: BrollContinuityPlan, ): BrollContinuityPlanReceiptV4 { const payload: Omit = { schemaVersion: 4, projectId, revision, arollSha256: arollSha256(aroll), shortGapMs: plan.shortGapMs, minMeaningfulArollIslandMs: plan.minMeaningfulArollIslandMs, minEndingArollMs: plan.minEndingArollMs, cutCoverBeforeMs: plan.cutCoverBeforeMs, cutCoverAfterMs: plan.cutCoverAfterMs, needs: plannedNeedsV3(plan.needs), jumpCuts: structuredClone(plan.jumpCuts), }; return { ...payload, planSha256: sha256(planReceiptPayload(payload)) }; } /** Resolve exactly one approved need without requiring callers to transcribe its fields. */ export function resolveBrollNeed( projectId: string, revision: number, receipt: BrollContinuityPlanReceipt, input: { need?: BrollNeed; needId?: string }, ): BrollNeed { if ((input.need === undefined) === (input.needId === undefined)) throw new Error("Provide exactly one of needId or need; prefer needId with the unchanged continuityPlanReceipt"); if (receipt.schemaVersion !== 4) throw new Error("B-roll matching requires a v4 continuity receipt"); const need = input.need ?? receipt.needs.find(candidate => candidate.id === input.needId); if (!need) throw new Error(`B-roll need ${input.needId} was omitted by the B-roll continuity plan`); assertBrollNeedInContinuityPlanReceipt(projectId, revision, need, receipt); return structuredClone(need); } export function assertBrollNeedInContinuityPlanReceipt( projectId: string, revision: number, need: BrollNeed, receipt: BrollContinuityPlanReceipt, ): void { if (receipt.schemaVersion !== 4 || receipt.projectId !== projectId || receipt.revision !== revision) { throw new Error("B-roll asset matching requires the v4 continuity receipt for the current project revision"); } const { planSha256, ...payload } = receipt; if (planSha256 !== sha256(planReceiptPayload(payload))) { throw new Error("B-roll continuity receipt hash is invalid"); } const selectedNeed = receipt.needs.find((candidate) => candidate.id === need.id); if (!selectedNeed) throw new Error(`B-roll need ${need.id} was omitted by the B-roll continuity plan`); const expectedNeed = plannedNeedsV3([need])[0]; if (canonicalJson(selectedNeed) !== canonicalJson(expectedNeed)) { throw new Error(`B-roll need ${need.id} does not match the selected continuity plan semantics`); } } export function verifyBrollContinuityPlanReceipt( projectId: string, revision: number, aroll: ArollSegment[], placements: BrollOutputWindow[], receipt: BrollContinuityPlanReceipt, ): void { if ((receipt.schemaVersion !== 1 && receipt.schemaVersion !== 2 && receipt.schemaVersion !== 3 && receipt.schemaVersion !== 4) || receipt.projectId !== projectId || receipt.revision !== revision) { throw new Error("B-roll continuity receipt does not match the current project revision"); } if (receipt.arollSha256 !== arollSha256(aroll)) { throw new Error("B-roll continuity receipt does not match the planned A-roll"); } const { planSha256, ...payload } = receipt; if (planSha256 !== sha256(planReceiptPayload(payload))) { throw new Error("B-roll continuity receipt hash is invalid"); } if (receipt.schemaVersion !== 4 && receipt.needs.length > 0) { throw new Error("Legacy B-roll continuity receipts must be replanned with the current v4 policy before apply"); } if (receipt.schemaVersion === 4) { if (receipt.minMeaningfulArollIslandMs !== MIN_MEANINGFUL_AROLL_ISLAND_MS || receipt.minEndingArollMs !== MIN_ENDING_AROLL_MS) { throw new Error("B-roll continuity receipt does not use the current A-roll visibility policy"); } const outputDurationMs = timelineDuration(aroll); const endingNeed = receipt.needs.find((need) => ( need.outputEndMs > outputDurationMs - receipt.minEndingArollMs )); if (endingNeed) { throw new Error(`B-roll need ${endingNeed.id} leaves less than ${receipt.minEndingArollMs}ms of ending A-roll`); } const overlongViewingRun = brollViewingRuns( receipt.needs, receipt.minMeaningfulArollIslandMs, ).find((run) => run.durationMs > MAX_INITIAL_CONTINUOUS_BROLL_MS); if (overlongViewingRun) { throw new Error(`B-roll viewing run ${overlongViewingRun.needIds.join(", ")} lasts ${overlongViewingRun.durationMs}ms after short A-roll islands are included`); } } const dummyNeeds: BrollNeed[] = receipt.schemaVersion === 1 ? receipt.needs.map((need) => ({ ...need, speechText: "continuity receipt", visualCueText: "continuity receipt", necessity: "supporting", purpose: "demonstrate", searchTerms: ["continuity"], reason: "continuity receipt verification", })) : receipt.schemaVersion === 2 ? receipt.needs.map(verificationNeedV2) : structuredClone(receipt.needs); const recomputed = buildSelectedBrollContinuityPlan({ aroll, needs: dummyNeeds, shortGapMs: receipt.shortGapMs, cutCoverBeforeMs: receipt.cutCoverBeforeMs, cutCoverAfterMs: receipt.cutCoverAfterMs, }, { inputNeedCount: dummyNeeds.length, omittedNeeds: [] }); const recomputedNeeds = receipt.schemaVersion === 1 ? plannedRanges(recomputed.needs) : receipt.schemaVersion === 2 ? plannedNeedsV2(recomputed.needs) : plannedNeedsV3(recomputed.needs); if (canonicalJson(recomputedNeeds) !== canonicalJson(receipt.needs) || canonicalJson(normalizeArollJumpCutStatuses(recomputed.jumpCuts)) !== canonicalJson(normalizeArollJumpCutStatuses(receipt.jumpCuts))) { throw new Error("B-roll continuity receipt does not match the recomputed plan"); } const actualRanges = [...placements].sort((left, right) => ( left.outputStartMs - right.outputStartMs || left.outputEndMs - right.outputEndMs )).map((placement) => ({ id: placement.id, outputStartMs: placement.outputStartMs, outputEndMs: placement.outputEndMs, })); if (canonicalJson(actualRanges) !== canonicalJson(plannedRanges(receipt.needs))) { throw new Error("B-roll placements do not match the planned ranges"); } }