import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { open } from "node:fs/promises"; import { extname } from "node:path"; import { Type } from "typebox"; import type { BrollNeed } from "../../src/contracts.ts"; import { planBgm, selectBgm } from "../../src/bgm.ts"; import { assertBrollAssetInMatchReceipt, BrollSelectionError, createBrollMatchReceipt, matchWorkspaceBrollAssets, selectBrollWindow, } from "../../src/broll.ts"; import { resolveBrollNeed, createBrollContinuityPlanReceipt, planBrollContinuity, } from "../../src/continuity.ts"; import { timelineDelivery } from "../../src/edl.ts"; import { applyTimeline, assertProjectSourcesUnchanged, assertSnapshotAssetsUnchanged, createTalkingHeadProject, getAnalysis, getTalkingHeadProject, } from "../../src/project.ts"; import { resolveExistingWorkspaceFile, snapshotFile } from "../../src/workspace.ts"; function result(details: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(details, null, 2) }], details, }; } import { createEditorialPlanReceipt } from "../../src/editorial.ts"; const DEFAULT_BGM_DIALOGUE_SEPARATION_LU = 12; const stableId = Type.String({ description: "Stable ID using lowercase letters, numbers, and interior hyphens (1-64 characters).", pattern: "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", }); const arollSegment = Type.Object({ id: stableId, sourceStartMs: Type.Number({ minimum: 0 }), sourceEndMs: Type.Number({ exclusiveMinimum: 0 }), }, { additionalProperties: false }); const wordRange = { startWordIndex: Type.Integer({ minimum: 0 }), endWordIndex: Type.Integer({ minimum: 0 }), }; const editorialPlan = Type.Object({ scope: Type.Union([Type.Literal("full"), Type.Literal("pauses-only")]), scopeReason: Type.Optional(Type.String({ minLength: 1, description: "For pauses-only, quote the user's explicit restriction. Never narrow a full breath edit yourself." })), reviewedSentenceIndexes: Type.Array(Type.Integer({ minimum: 0 }), { description: "All ASR sentence indexes, after full-passage review rather than candidate-only review." }), sourceReview: Type.Object({ method: Type.Union([Type.Literal("text-and-visual"), Type.Literal("listened")]), notes: Type.String({ minLength: 1 }), }, { additionalProperties: false }), decisions: Type.Array(Type.Object({ id: stableId, kind: Type.Union([Type.Literal("retake"), Type.Literal("false-start"), Type.Literal("filler"), Type.Literal("pause"), Type.Literal("other")]), action: Type.Union([Type.Literal("edit"), Type.Literal("keep"), Type.Literal("review")]), candidateIds: Type.Array(Type.String()), ranges: Type.Array(Type.Object({ ...wordRange, disposition: Type.Union([Type.Literal("keep"), Type.Literal("remove"), Type.Literal("review")]) }, { additionalProperties: false })), reason: Type.String({ minLength: 1 }), }, { additionalProperties: false }), { maxItems: 5000 }), uniqueInformation: Type.Array(Type.Object({ id: stableId, ...wordRange, reason: Type.String({ minLength: 1 }) }, { additionalProperties: false }), { maxItems: 1000 }), segmentReasons: Type.Array(Type.Object({ segmentId: stableId, reason: Type.String({ minLength: 1 }) }, { additionalProperties: false }), { maxItems: 1000 }), unresolvedIssues: Type.Array(Type.String({ minLength: 1 })), }, { additionalProperties: false }); const editorialPlanReceipt = Type.Object({ schemaVersion: Type.Literal(1), projectId: stableId, revision: Type.Integer({ minimum: 1 }), sourceSha256: Type.String(), transcriptSha256: Type.String(), arollSha256: Type.String(), plan: editorialPlan, planSha256: Type.String(), }, { additionalProperties: false }); const brollVisualIdentity = Type.Object({ subject: Type.String({ minLength: 1, maxLength: 200, description: "Concrete visible subject, based on picture review rather than the filename." }), action: Type.String({ minLength: 1, maxLength: 200, description: "Concrete visible action or state in the selected source window." }), shotScale: Type.Union([ Type.Literal("wide"), Type.Literal("medium"), Type.Literal("close-up"), Type.Literal("macro"), Type.Literal("screen"), Type.Literal("document"), Type.Literal("mixed"), Type.Literal("other"), ]), angle: Type.Union([ Type.Literal("front"), Type.Literal("side"), Type.Literal("top-down"), Type.Literal("over-shoulder"), Type.Literal("screen-capture"), Type.Literal("mixed"), Type.Literal("other"), ]), }, { additionalProperties: false }); const brollMatchReceipt = Type.Object({ schemaVersion: Type.Literal(1), projectId: stableId, revision: Type.Integer({ minimum: 1 }), continuityPlanSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), needId: stableId, needSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), assetDirectory: Type.String({ minLength: 1 }), candidateOffset: Type.Integer({ minimum: 0 }), candidateAssetPaths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 20 }), matchSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), }, { additionalProperties: false }); const brollPlacement = Type.Object({ id: stableId, assetPath: Type.String({ description: "Workspace-relative B-roll video path." }), outputStartMs: Type.Number({ minimum: 0 }), outputEndMs: Type.Number({ exclusiveMinimum: 0 }), assetStartMs: Type.Number({ minimum: 0, description: "Required manifest-backed source timestamp selected from the B-roll asset." }), fit: Type.Union([Type.Literal("cover"), Type.Literal("contain")]), audio: Type.Literal("keep-primary", { description: "B-roll never replaces the talking-head audio." }), query: Type.Optional(Type.String({ maxLength: 500 })), reason: Type.Optional(Type.String({ maxLength: 1_000 })), selectionReceipt: Type.Object({ assetBytes: Type.Integer({ minimum: 0 }), assetSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), manifestPath: Type.String({ minLength: 1 }), manifestSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), selectedEndMs: Type.Number({ minimum: 0 }), evidenceTimestampsMs: Type.Array(Type.Number({ minimum: 0 }), { minItems: 1, maxItems: 100 }), visualIdentity: Type.Optional(brollVisualIdentity), matchReceipt: Type.Optional(brollMatchReceipt), selectionSha256: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })), }, { additionalProperties: false }), }, { additionalProperties: false }); const brollNeedFields = { id: stableId, outputStartMs: Type.Number({ minimum: 0 }), outputEndMs: Type.Number({ exclusiveMinimum: 0 }), speechText: Type.String({ minLength: 1, maxLength: 2_000 }), searchTerms: Type.Array(Type.String({ minLength: 1, maxLength: 100 }), { minItems: 1, maxItems: 20 }), reason: Type.String({ minLength: 1, maxLength: 1_000 }), }; const semanticBrollNeed = Type.Object({ ...brollNeedFields, necessity: Type.Union([ Type.Literal("essential"), Type.Literal("supporting"), ], { description: "Use essential only when the spoken claim cannot be understood or verified as well without seeing the picture.", }), visualCueText: Type.String({ minLength: 1, maxLength: 500, description: "Exact words that need visual illustration. Keep outputStartMs/outputEndMs around this cue; speechText remains the full sentence context.", }), purpose: Type.Union([ Type.Literal("demonstrate"), Type.Literal("explain"), Type.Literal("evidence"), Type.Literal("establish"), Type.Literal("transition"), ]), }, { additionalProperties: false }); const maskCutReviewFields = { decision: Type.Literal("mask-with-broll"), reviewedJumpCutOutputMs: Type.Number({ minimum: 0 }), artifactPath: Type.String({ minLength: 1, description: "Workspace-relative image or video reviewed at this A-roll jump." }), }; const maskCutBrollNeedInput = Type.Object({ ...brollNeedFields, purpose: Type.Literal("mask-cut"), visualReview: Type.Object(maskCutReviewFields, { additionalProperties: false }), }, { additionalProperties: false }); const maskCutBrollNeed = Type.Object({ ...brollNeedFields, purpose: Type.Literal("mask-cut"), visualReview: Type.Object({ ...maskCutReviewFields, artifactSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), }, { additionalProperties: false }), }, { additionalProperties: false }); const brollNeedInput = Type.Union([semanticBrollNeed, maskCutBrollNeedInput]); const brollNeed = Type.Union([ semanticBrollNeed, maskCutBrollNeed, ]); const visualReviewArtifactExtensions = new Set([ ".png", ".jpg", ".jpeg", ".webp", ".avif", ".mp4", ".mov", ".m4v", ".webm", ".mkv", ]); function hasVisualMediaSignature(bytes: Buffer): boolean { const startsWith = (signature: number[]) => signature.every((byte, index) => bytes[index] === byte); const ascii = (start: number, value: string) => bytes.subarray(start, start + value.length).toString("ascii") === value; return startsWith([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) || startsWith([0xff, 0xd8, 0xff]) || (ascii(0, "RIFF") && ascii(8, "WEBP")) || ascii(4, "ftyp") || startsWith([0x1a, 0x45, 0xdf, 0xa3]); } async function assertVisualReviewArtifactBytes(cwd: string, artifactPath: string, signal?: AbortSignal): Promise { signal?.throwIfAborted(); const absolutePath = await resolveExistingWorkspaceFile(cwd, artifactPath); const handle = await open(absolutePath, "r"); try { const bytes = Buffer.alloc(16); const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); if (!hasVisualMediaSignature(bytes.subarray(0, bytesRead))) { throw new Error("visual review artifact must contain recognizable image or video bytes"); } } finally { await handle.close(); } } async function bindVisualReviewArtifacts( cwd: string, needs: BrollNeed[], signal?: AbortSignal, ): Promise { const verifiedNeeds: BrollNeed[] = []; for (const need of needs) { signal?.throwIfAborted(); if (need.purpose !== "mask-cut") { verifiedNeeds.push(structuredClone(need)); continue; } if (!visualReviewArtifactExtensions.has(extname(need.visualReview.artifactPath).toLowerCase())) { throw new Error(`B-roll mask-cut need ${need.id} visual review artifact must be an image or video`); } try { await assertVisualReviewArtifactBytes(cwd, need.visualReview.artifactPath, signal); } catch (error) { throw new Error(`B-roll mask-cut need ${need.id} ${(error as Error).message}`); } const artifact = await snapshotFile(cwd, need.visualReview.artifactPath, signal); verifiedNeeds.push({ ...structuredClone(need), visualReview: { ...structuredClone(need.visualReview), artifactPath: artifact.path, artifactSha256: artifact.sha256, }, }); } return verifiedNeeds; } const arollJumpCut = Type.Object({ fromSegmentId: stableId, toSegmentId: stableId, outputTimeMs: Type.Number({ minimum: 0 }), sourceDeltaMs: Type.Number(), suggestedOutputStartMs: Type.Number({ minimum: 0 }), suggestedOutputEndMs: Type.Number({ minimum: 0 }), coveredByNeedIds: Type.Array(stableId, { maxItems: 500 }), status: Type.Union([ Type.Literal("covered"), Type.Literal("review"), Type.Literal("needs-broll", { description: "Legacy v0.1.5 receipt value; new plans emit review." }), ]), }, { additionalProperties: false }); const continuityPlanReceiptBase = { projectId: stableId, revision: Type.Integer({ minimum: 1 }), arollSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), shortGapMs: Type.Integer({ minimum: 0, maximum: 2_000 }), cutCoverBeforeMs: Type.Integer({ minimum: 0, maximum: 2_000 }), cutCoverAfterMs: Type.Integer({ minimum: 0, maximum: 2_000 }), jumpCuts: Type.Array(arollJumpCut, { maxItems: 999 }), planSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), }; const continuityPlanReceiptV1 = Type.Object({ ...continuityPlanReceiptBase, schemaVersion: Type.Literal(1), needs: Type.Array(Type.Object({ id: stableId, outputStartMs: Type.Number({ minimum: 0 }), outputEndMs: Type.Number({ exclusiveMinimum: 0 }), }, { additionalProperties: false }), { maxItems: 500 }), }, { additionalProperties: false }); const continuityPlanReceiptV2 = Type.Object({ ...continuityPlanReceiptBase, schemaVersion: Type.Literal(2), needs: Type.Array(Type.Union([ Type.Object({ id: stableId, outputStartMs: Type.Number({ minimum: 0 }), outputEndMs: Type.Number({ exclusiveMinimum: 0 }), purpose: Type.Union([ Type.Literal("demonstrate"), Type.Literal("explain"), Type.Literal("evidence"), Type.Literal("establish"), Type.Literal("transition"), ]), }, { additionalProperties: false }), Type.Object({ id: stableId, outputStartMs: Type.Number({ minimum: 0 }), outputEndMs: Type.Number({ exclusiveMinimum: 0 }), purpose: Type.Literal("mask-cut"), visualReview: Type.Object({ ...maskCutReviewFields, artifactSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), }, { additionalProperties: false }), }, { additionalProperties: false }), ]), { maxItems: 500 }), }, { additionalProperties: false }); const continuityPlanReceiptV3 = Type.Object({ ...continuityPlanReceiptBase, schemaVersion: Type.Literal(3), needs: Type.Array(brollNeed, { maxItems: 500 }), }, { additionalProperties: false }); const continuityPlanReceiptV4 = Type.Object({ ...continuityPlanReceiptBase, schemaVersion: Type.Literal(4), minMeaningfulArollIslandMs: Type.Literal(3_000), minEndingArollMs: Type.Literal(3_000), needs: Type.Array(brollNeed, { maxItems: 500 }), }, { additionalProperties: false }); const continuityPlanReceipt = Type.Union([ continuityPlanReceiptV1, continuityPlanReceiptV2, continuityPlanReceiptV3, continuityPlanReceiptV4, ]); const fileRef = Type.Object({ path: Type.String({ minLength: 1 }), bytes: Type.Integer({ minimum: 0 }), sha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), }, { additionalProperties: false }); const bgmMix = Type.Object({ method: Type.Literal("ebu-r128-dialogue-relative"), dialogueAnalysisPath: Type.String({ minLength: 1 }), musicAnalysisPath: Type.String({ minLength: 1 }), dialogueIntegratedLufs: Type.Number({ minimum: -70, maximum: 0 }), musicIntegratedLufs: Type.Number({ minimum: -70, maximum: 0 }), targetMusicBelowDialogueLu: Type.Number({ minimum: 6, maximum: 30 }), maxTruePeakDbtp: Type.Number({ minimum: -6, maximum: -0.1 }), }, { additionalProperties: false }); const bgmPlanReceipt = Type.Object({ schemaVersion: Type.Literal(1), projectId: stableId, revision: Type.Integer({ minimum: 1 }), arollSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), outputDurationMs: Type.Number({ exclusiveMinimum: 0 }), musicDurationMs: Type.Number({ exclusiveMinimum: 0 }), durationRelation: Type.Union([Type.Literal("longer"), Type.Literal("equal"), Type.Literal("shorter")]), requiredPlayback: Type.Union([Type.Literal("trim"), Type.Literal("loop")]), voiceAnalysis: fileRef, musicAnalysis: fileRef, musicAsset: fileRef, dialogueIntegratedLufs: Type.Number({ minimum: -70, maximum: 0 }), fullMusicIntegratedLufs: Type.Number({ minimum: -70, maximum: 0 }), targetMusicBelowDialogueLu: Type.Number({ minimum: 6, maximum: 30 }), maxTruePeakDbtp: Type.Number({ minimum: -6, maximum: -0.1 }), planSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), }, { additionalProperties: false }); const bgmTrack = Type.Object({ assetPath: Type.String({ minLength: 1 }), assetBytes: Type.Integer({ minimum: 0 }), assetSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), sourceStartMs: Type.Number({ minimum: 0 }), sourceEndMs: Type.Number({ exclusiveMinimum: 0 }), outputStartMs: Type.Literal(0), outputEndMs: Type.Number({ exclusiveMinimum: 0 }), playback: Type.Union([Type.Literal("trim"), Type.Literal("loop")]), fadeInMs: Type.Number({ minimum: 0, maximum: 10_000 }), fadeOutMs: Type.Number({ minimum: 250, maximum: 10_000 }), ducking: Type.Literal("dialogue-sidechain"), mix: bgmMix, selectionReceipt: Type.Object({ planReceipt: bgmPlanReceipt, selectedMusicAnalysis: fileRef, mix: bgmMix, evidenceTimestampsMs: Type.Array(Type.Number({ minimum: 0 }), { minItems: 2, maxItems: 100 }), selectionSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }), }, { additionalProperties: false }), }, { additionalProperties: false }); export default function talkingHead(pi: ExtensionAPI): void { pi.registerTool({ name: "talking_head_create", label: "Create talking-head edit", description: "Create a workspace-local talking-head project from a source video and pi-speech-compatible word-timestamp transcript. Produces an explicitly labeled draft preview, content hints and timestamp diagnostics. Full breath editing includes retakes and false starts; an editorial plan is required before the timeline is content-reviewed. It does not render media.", parameters: Type.Object({ projectId: stableId, sourcePath: Type.String({ description: "Workspace-relative source video path." }), transcriptPath: Type.String({ description: "Workspace-relative pi-speech word-timestamp JSON path." }), sourceDurationMs: Type.Number({ minimum: 1, description: "Exact source duration in milliseconds from media_probe; used only to prevent tail padding from exceeding the source." }), cutThresholdMs: Type.Optional(Type.Number({ minimum: 150, maximum: 10_000 })), headPaddingMs: Type.Optional(Type.Number({ minimum: 30, maximum: 200 })), tailPaddingMs: Type.Optional(Type.Number({ minimum: 30, maximum: 200 })), }, { additionalProperties: false }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const policy = { ...(params.cutThresholdMs === undefined ? {} : { cutThresholdMs: params.cutThresholdMs }), ...(params.headPaddingMs === undefined ? {} : { headPaddingMs: params.headPaddingMs }), ...(params.tailPaddingMs === undefined ? {} : { tailPaddingMs: params.tailPaddingMs }), }; const created = await createTalkingHeadProject(ctx.cwd, { projectId: params.projectId, sourcePath: params.sourcePath, transcriptPath: params.transcriptPath, sourceDurationMs: params.sourceDurationMs, policy, }); return result({ ...created, ...timelineDelivery(created.project, created.snapshot), }); }, }); pi.registerTool({ name: "talking_head_get", label: "Inspect talking-head edit", description: "Read one immutable talking-head revision plus bounded sentence, pause, filler-word, and repetition evidence for Agent editorial judgment. Optionally include the generic pi-media timeline operation.", parameters: Type.Object({ projectId: stableId, revision: Type.Optional(Type.Integer({ minimum: 1 })), sentenceOffset: Type.Optional(Type.Integer({ minimum: 0 })), sentenceLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), pauseOffset: Type.Optional(Type.Integer({ minimum: 0 })), pauseLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), fillerOffset: Type.Optional(Type.Integer({ minimum: 0 })), fillerLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), repetitionOffset: Type.Optional(Type.Integer({ minimum: 0 })), repetitionLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), contentOffset: Type.Optional(Type.Integer({ minimum: 0 })), contentLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), includeWords: Type.Optional(Type.Boolean({ description: "Include word indexes/timestamps for precise editorial dispositions." })), includeTranscriptText: Type.Optional(Type.Boolean({ description: "Include the complete transcript text for passage-level Agent review." })), includeMediaOperation: Type.Optional(Type.Boolean()), }, { additionalProperties: false }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { project, snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision); const analysis = await getAnalysis(ctx.cwd, project); const sentenceOffset = params.sentenceOffset ?? 0; const sentenceLimit = params.sentenceLimit ?? 20; const pauseOffset = params.pauseOffset ?? 0; const pauseLimit = params.pauseLimit ?? 50; const fillerOffset = params.fillerOffset ?? 0; const fillerLimit = params.fillerLimit ?? 50; const repetitionOffset = params.repetitionOffset ?? 0; const repetitionLimit = params.repetitionLimit ?? 50; const sentences = analysis.sentences ?? []; const fillers = analysis.fillers ?? []; const repetitions = analysis.repetitions ?? []; const details: Record = { project, snapshot, editorialStatus: snapshot.editorialStatus ?? "draft", listeningStatus: "pending", diagnostics: analysis.diagnostics, contentCandidates: { offset: params.contentOffset ?? 0, limit: params.contentLimit ?? 50, total: analysis.contentCandidates.length, items: analysis.contentCandidates.slice(params.contentOffset ?? 0, (params.contentOffset ?? 0) + (params.contentLimit ?? 50)), }, sentences: { offset: sentenceOffset, limit: sentenceLimit, total: sentences.length, items: sentences.slice(sentenceOffset, sentenceOffset + sentenceLimit), }, pauses: { offset: pauseOffset, limit: pauseLimit, total: analysis.candidates.length, items: analysis.candidates.slice(pauseOffset, pauseOffset + pauseLimit), }, fillers: { offset: fillerOffset, limit: fillerLimit, total: fillers.length, items: fillers.slice(fillerOffset, fillerOffset + fillerLimit), }, repetitions: { offset: repetitionOffset, limit: repetitionLimit, total: repetitions.length, items: repetitions.slice(repetitionOffset, repetitionOffset + repetitionLimit), }, }; if (params.includeTranscriptText) details.transcriptText = analysis.text; if (params.includeWords) details.words = analysis.words.map((word, wordIndex) => ({ ...word, wordIndex })); if (params.includeMediaOperation) { await assertProjectSourcesUnchanged(ctx.cwd, project); await assertSnapshotAssetsUnchanged(ctx.cwd, snapshot); Object.assign(details, timelineDelivery(project, snapshot)); } return result(details); }, }); pi.registerTool({ name: "talking_head_editorial_plan", label: "Review talking-head content decisions", description: "Validate full-passage review, take selection, false-start removals, unique information and segment reasons against planned A-roll. All candidates require decisions, all removed words need dispositions, and unique information must survive. Returns a receipt bound to this revision and exact A-roll. Unresolved issues remain needs-review; source listening is not output listening approval.", parameters: Type.Object({ projectId: stableId, expectedRevision: Type.Integer({ minimum: 1 }), aroll: Type.Array(arollSegment, { minItems: 1, maxItems: 1000 }), plan: editorialPlan, }, { additionalProperties: false }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { project } = await getTalkingHeadProject(ctx.cwd, params.projectId); await assertProjectSourcesUnchanged(ctx.cwd, project); const analysis = await getAnalysis(ctx.cwd, project); return result(createEditorialPlanReceipt(project, params.expectedRevision, params.aroll, analysis, params.plan)); }, }); pi.registerTool({ name: "talking_head_broll_plan", label: "Plan B-roll visual continuity", description: "Create an A-roll-first B-roll plan before asset matching. The Agent decides total B-roll coverage from the topic and visible information, without a fixed percentage target. A-roll islands under 3 seconds stay in the same viewing run, runs stay within 8 seconds, and the ending reserves 3 seconds of A-roll. Brief selected flashes are bridged, while uncovered source jumps are reported only for picture review.", parameters: Type.Object({ projectId: stableId, revision: Type.Optional(Type.Integer({ minimum: 1 })), aroll: Type.Array(arollSegment, { minItems: 1, maxItems: 1_000 }), needs: Type.Array(brollNeedInput, { maxItems: 500 }), cutCoverBeforeMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 2_000, default: 250 })), cutCoverAfterMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 2_000, default: 500 })), }, { additionalProperties: false }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const { snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision); const needs = await bindVisualReviewArtifacts( ctx.cwd, params.needs as BrollNeed[], signal, ); const plan = planBrollContinuity({ aroll: params.aroll, needs, ...(params.cutCoverBeforeMs === undefined ? {} : { cutCoverBeforeMs: params.cutCoverBeforeMs }), ...(params.cutCoverAfterMs === undefined ? {} : { cutCoverAfterMs: params.cutCoverAfterMs }), }); const omittedInstruction = plan.selection.omittedNeeds.length === 0 ? "" : ` Do not match omitted needs: ${plan.selection.omittedNeeds.map((need) => need.id).join(", ")}.`; return result({ projectId: params.projectId, revision: snapshot.revision, plan, continuityPlanReceipt: createBrollContinuityPlanReceipt( params.projectId, snapshot.revision, params.aroll, plan, ), nextStep: plan.jumpCuts.some((cut) => cut.status === "review") ? `Review the actual picture at every status=review jump. Keep A-roll unless the cut is visibly objectionable; purpose=mask-cut requires visualReview with that exact output timestamp.${omittedInstruction} Then match only plan.needs.` : `${omittedInstruction.trim()} Match only plan.needs for B-roll asset matching and source-window selection.`.trim(), }); }, }); pi.registerTool({ name: "talking_head_broll_match", label: "Match local B-roll assets", description: "Filename-first B-roll matching for one approved output window. Pass needId and the unchanged continuityPlanReceipt instead of copying need fields; full need is still accepted, but never pass both. Scans only directory metadata, returns a bounded shortlist, and tells the Agent whether to inspect one selected asset, inspect an ambiguous shortlist, or fall back to low-cost visual screening. It never decodes, probes, or hashes video contents.", parameters: Type.Object({ projectId: stableId, revision: Type.Optional(Type.Integer({ minimum: 1 })), assetDirectory: Type.String({ minLength: 1, description: "Workspace-relative directory containing candidate B-roll videos." }), recursive: Type.Optional(Type.Boolean()), maxFiles: Type.Optional(Type.Integer({ minimum: 1, maximum: 5_000 })), maxEntries: Type.Optional(Type.Integer({ minimum: 1, maximum: 100_000 })), maxDepth: Type.Optional(Type.Integer({ minimum: 0, maximum: 50 })), maxCandidates: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })), candidateOffset: Type.Optional(Type.Integer({ minimum: 0 })), need: Type.Optional(brollNeed), needId: Type.Optional(stableId), continuityPlanReceipt: continuityPlanReceiptV4, }, { additionalProperties: false }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const { snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision); const need = resolveBrollNeed(params.projectId, snapshot.revision, params.continuityPlanReceipt, params); if (need.outputEndMs > snapshot.outputDurationMs) { throw new Error(`B-roll need ${need.id} exceeds output duration ${snapshot.outputDurationMs}ms`); } const match = await matchWorkspaceBrollAssets(ctx.cwd, { assetDirectory: params.assetDirectory, ...(params.recursive === undefined ? {} : { recursive: params.recursive }), ...(params.maxFiles === undefined ? {} : { maxFiles: params.maxFiles }), ...(params.maxEntries === undefined ? {} : { maxEntries: params.maxEntries }), ...(params.maxDepth === undefined ? {} : { maxDepth: params.maxDepth }), ...(params.maxCandidates === undefined ? {} : { maxCandidates: params.maxCandidates }), ...(params.candidateOffset === undefined ? {} : { candidateOffset: params.candidateOffset }), need, ...(signal === undefined ? {} : { signal }), }); return result({ projectId: params.projectId, revision: snapshot.revision, match, matchReceipt: createBrollMatchReceipt({ projectId: params.projectId, revision: snapshot.revision, continuityPlanSha256: params.continuityPlanReceipt.planSha256, need, match, }), ...(match.truncated ? { warning: `The bounded scan stopped after ${match.scannedEntryCount} entries and ${match.scannedFileCount} video filenames; filename-direct is disabled until the asset directory is narrowed or fully scanned.` } : {}), }); }, }); pi.registerTool({ name: "talking_head_broll_select", label: "Select B-roll source window", description: "Use needId with the unchanged continuityPlanReceipt (or a full need, never both). Validate that the asset came from the corresponding matching result, then validate one exact source window and visual identity against a pi-media contact_sheet_manifest.json. Binds the match, asset, manifest, timestamps, subject, action, scale, and angle into a selection hash.", parameters: Type.Object({ projectId: stableId, revision: Type.Optional(Type.Integer({ minimum: 1 })), need: Type.Optional(brollNeed), needId: Type.Optional(stableId), continuityPlanReceipt: continuityPlanReceiptV4, matchReceipt: brollMatchReceipt, assetPath: Type.String({ minLength: 1 }), manifestPath: Type.String({ minLength: 1 }), selectedStartMs: Type.Number({ minimum: 0 }), evidenceTimestampsMs: Type.Array(Type.Number({ minimum: 0 }), { minItems: 1, maxItems: 100 }), fit: Type.Union([Type.Literal("cover"), Type.Literal("contain")]), visualIdentity: brollVisualIdentity, }, { additionalProperties: false }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const { snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision); const need = resolveBrollNeed(params.projectId, snapshot.revision, params.continuityPlanReceipt, params); if (need.outputEndMs > snapshot.outputDurationMs) { throw new Error(`B-roll need ${need.id} exceeds output duration ${snapshot.outputDurationMs}ms`); } assertBrollAssetInMatchReceipt({ projectId: params.projectId, revision: snapshot.revision, continuityPlanReceipt: params.continuityPlanReceipt, need, assetPath: params.assetPath, matchReceipt: params.matchReceipt, }); try { return result({ projectId: params.projectId, revision: snapshot.revision, selection: await selectBrollWindow(ctx.cwd, { need, matchReceipt: params.matchReceipt, assetPath: params.assetPath, manifestPath: params.manifestPath, selectedStartMs: params.selectedStartMs, evidenceTimestampsMs: params.evidenceTimestampsMs, fit: params.fit, visualIdentity: params.visualIdentity, ...(signal === undefined ? {} : { signal }), }), }); } catch (error) { if (!(error instanceof BrollSelectionError)) throw error; return { ...result({ message: error.message, ...error.details }), isError: true }; } }, }); pi.registerTool({ name: "talking_head_bgm_plan", label: "Plan talking-head BGM", description: "Compare exact retained A-roll and complete BGM EBU R128 manifests, determine trim versus loop from their real durations, and bind the target loudness relationship into an immutable plan receipt. The default 12 LU separation keeps BGM audible before dialogue sidechain ducking; the final gain is measured again on the selected music window.", parameters: Type.Object({ projectId: stableId, revision: Type.Optional(Type.Integer({ minimum: 1 })), aroll: Type.Array(arollSegment, { minItems: 1, maxItems: 1_000 }), voiceAnalysisPath: Type.String({ minLength: 1, description: "pi-media audio_analysis_manifest.json measured over these exact A-roll source ranges." }), musicAnalysisPath: Type.String({ minLength: 1, description: "pi-media audio_analysis_manifest.json covering the complete BGM from zero." }), targetMusicBelowDialogueLu: Type.Optional(Type.Number({ minimum: 6, maximum: 30, default: DEFAULT_BGM_DIALOGUE_SEPARATION_LU })), }, { additionalProperties: false }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { project, snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision); return result(await planBgm(ctx.cwd, project, snapshot, { ...params, targetMusicBelowDialogueLu: params.targetMusicBelowDialogueLu ?? DEFAULT_BGM_DIALOGUE_SEPARATION_LU, })); }, }); pi.registerTool({ name: "talking_head_bgm_select", label: "Select BGM waveform window", description: "Validate start and end timestamps selected from provenance-bound waveform images plus a second EBU R128 manifest measured over exactly that window. Long BGM must use one exact output-length window; short BGM becomes a loop. Gain is derived from the exact window and dialogue sidechain ducking is mandatory.", parameters: Type.Object({ projectId: stableId, revision: Type.Optional(Type.Integer({ minimum: 1 })), aroll: Type.Array(arollSegment, { minItems: 1, maxItems: 1_000 }), planReceipt: bgmPlanReceipt, selectedMusicAnalysisPath: Type.String({ minLength: 1, description: "media_audio_analyze manifest measured over exactly selectedStartMs-selectedEndMs." }), selectedStartMs: Type.Number({ minimum: 0 }), selectedEndMs: Type.Number({ exclusiveMinimum: 0 }), evidenceTimestampsMs: Type.Array(Type.Number({ minimum: 0 }), { minItems: 2, maxItems: 100 }), fadeInMs: Type.Optional(Type.Number({ minimum: 0, maximum: 10_000, default: 500 })), fadeOutMs: Type.Optional(Type.Number({ minimum: 250, maximum: 10_000, default: 1_500, description: "End fade; at least 250ms so BGM never disappears abruptly." })), }, { additionalProperties: false }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { project, snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision); return result(await selectBgm(ctx.cwd, project, snapshot, { ...params, fadeInMs: params.fadeInMs ?? 500, fadeOutMs: params.fadeOutMs ?? 1_500, })); }, }); pi.registerTool({ name: "talking_head_apply", label: "Apply talking-head timeline", description: "Create a new immutable talking-head revision from word-boundary A-roll ranges and planned B-roll placements. For unchanged A-roll, set inheritBroll=true and omit broll/continuityPlanReceipt to reuse the verified B-roll track when adding BGM. Unchanged full placements also retain their stored provenance. Other tracks still use replacement semantics. B-roll requires continuity and per-placement match provenance; omitted ranges, repeated assets or visual identities, short presenter islands, insufficient ending A-roll, brief flashes, and overlaps are rejected. Returns mediaOperation only after full content review; otherwise returns previewMediaOperation with draft/needs-review/pauses-only status. Missing plans never imply completion. Changing A-roll invalidates previous review; unchanged A-roll retains it. Output listening remains pending.", parameters: Type.Object({ projectId: stableId, expectedRevision: Type.Integer({ minimum: 1 }), aroll: Type.Array(arollSegment, { minItems: 1, maxItems: 1_000 }), broll: Type.Optional(Type.Array(brollPlacement, { maxItems: 500 })), inheritBroll: Type.Optional(Type.Boolean({ description: "Reuse the current verified B-roll when A-roll is unchanged. Omit broll and continuityPlanReceipt. Existing receipts and files are revalidated; no matching/selection needs repeating." })), bgm: Type.Optional(bgmTrack), editorialPlanReceipt: Type.Optional(editorialPlanReceipt), continuityPlanReceipt: Type.Optional(continuityPlanReceipt), }, { additionalProperties: false }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const current = await getTalkingHeadProject(ctx.cwd, params.projectId); const snapshot = await applyTimeline(ctx.cwd, params, signal); return result({ projectId: params.projectId, snapshot, ...timelineDelivery(current.project, snapshot), }); }, }); }