import type { Edl, EdlError, GateReport, MediaRender, MediaRenderQuality } from "@rakazo/contracts"; import { EdlSchema, GateReportSchema, MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts"; import { ACTIVE_RENDER_STATUSES } from "@rakazo/db"; import { planAutoFix } from "@rakazo/media"; import type { MediaActor, MediaMarketingService, MediaRenderService, MediaServiceDeps, } from "./media-context.js"; /* * Auto-fix for blocked renders (spec 6): only compliance-preserving repairs * (trim or drop a clip, take another segment, change the crop, widen a * redaction, cover a mark) through planAutoFix, re-checked here so no round * can weaken protection or add anything new. Each round submits a new plan * version with the reasons as rationale and starts a new render, which runs * the gate again. After MAX_AUTO_FIX_ROUNDS the user takes over. */ export type AutoFixResult = | { status: "rerendering"; round: number; render: MediaRender; changes: string[] } | { status: "user_required"; reason: "not_blocked" | "round_limit" | "no_safe_fix" | "plan_rejected"; changes: string[]; errors?: EdlError[]; }; export interface AutoFixDeps { deps: Pick; marketing: Pick; render: Pick; } /** * Why a proposed fix is not a pure compliance repair, or null when it is. * Allowed: fewer or shorter clips, other segments, other crops, wider padding * on the same face redactions, added blur redactions, marks switched to cover. * Forbidden: anything that removes or weakens protection or adds content. */ export function nonCompliantChange(before: Edl, after: Edl): string | null { const facesBefore = new Map(before.redactions.faces.map((f) => [f.id, f])); for (const face of before.redactions.faces) { const next = after.redactions.faces.find((f) => f.id === face.id); if (!next) return `face redaction ${face.id} was removed`; if (JSON.stringify(next.target) !== JSON.stringify(face.target)) return `face redaction ${face.id} changed its target`; if (next.mode !== face.mode) return `face redaction ${face.id} changed its mode`; if (next.paddingPct < face.paddingPct || next.minSizePct < face.minSizePct) return `face redaction ${face.id} shrank`; } for (const face of after.redactions.faces) { if (!facesBefore.has(face.id) && face.mode !== "blur") return `added face redaction ${face.id} is not a blur`; } for (const mark of before.redactions.marks) { const next = after.redactions.marks.find((m) => m.markId === mark.markId); if (!next) return `mark ${mark.markId} lost its treatment`; if (next.treatment !== mark.treatment && next.treatment !== "cover") return `mark ${mark.markId} changed to ${next.treatment}`; } if (after.redactions.marks.length !== before.redactions.marks.length) return "mark treatments were added"; const clipIds = new Set(after.tracks.video.map((clip) => clip.id)); for (const region of before.redactions.regions) { if (!clipIds.has(region.clipId)) continue; const next = after.redactions.regions.find((r) => r.id === region.id); if (!next || JSON.stringify(next) !== JSON.stringify(region)) return `region ${region.id} changed`; } if (after.redactions.regions.length > before.redactions.regions.length) return "regions were added"; if (JSON.stringify(after.redactions.voice) !== JSON.stringify(before.redactions.voice)) return "voice disguise changed"; const beforeClips = new Map(before.tracks.video.map((clip) => [clip.id, clip])); for (const clip of after.tracks.video) { const old = beforeClips.get(clip.id); if (!old) return `clip ${clip.id} was added`; if (clip.speed !== old.speed || clip.muted !== old.muted) return `clip ${clip.id} changed speed or sound`; if (JSON.stringify(clip.color) !== JSON.stringify(old.color)) return `clip ${clip.id} changed its colors`; if (clip.assetId === old.assetId && (clip.inMs < old.inMs || clip.outMs > old.outMs)) { if (clip.segmentId === old.segmentId) return `clip ${clip.id} grew`; } } if (after.overlays.length > before.overlays.length) return "overlays were added"; for (const overlay of after.overlays) { const old = before.overlays.find((o) => o.id === overlay.id); if (!old || old.text !== overlay.text || old.kind !== overlay.kind) return `overlay ${overlay.id} changed`; } if ( JSON.stringify(after.tracks.audio) !== JSON.stringify(before.tracks.audio) || JSON.stringify(after.captions) !== JSON.stringify(before.captions) || JSON.stringify(after.intro) !== JSON.stringify(before.intro) || JSON.stringify(after.outro) !== JSON.stringify(before.outro) || JSON.stringify(after.output) !== JSON.stringify(before.output) ) { return "audio, captions, bookends, or output settings changed"; } for (const alteration of after.metadata.aiAlterations) { if (!before.metadata.aiAlterations.includes(alteration)) return `AI alteration ${alteration} was added`; } return null; } /** Plan one auto-fix round for a blocked render, or say why the user must take over. */ export function proposeAutoFix(input: { edl: Edl; report: GateReport; round: number; alternatives: Array<{ segmentId: string; assetId: string; startMs: number; endMs: number }>; }): { edl: Edl; changes: string[] } | { reason: "round_limit" | "no_safe_fix"; changes: string[] } { if (input.round >= MAX_AUTO_FIX_ROUNDS) return { reason: "round_limit", changes: [] }; const fix = planAutoFix( input.edl, { ...input.report, autoFixRound: input.round }, input.alternatives, ); if (!fix) return { reason: "no_safe_fix", changes: [] }; const parsed = EdlSchema.safeParse(fix.edl); if (!parsed.success) return { reason: "no_safe_fix", changes: fix.changes }; const violation = nonCompliantChange(input.edl, parsed.data); if (violation) return { reason: "no_safe_fix", changes: [...fix.changes, `rejected: ${violation}`] }; return { edl: parsed.data, changes: fix.changes }; } /** * Run one auto-fix round for a blocked render: new plan version (createdBy * agent, rationale lists every change) and a new render carrying the next * round number, whose gate decides again. */ export async function autoFixRender( services: AutoFixDeps, actor: MediaActor, renderId: string, ): Promise { const { repos } = services.deps; const row = await repos.getRenderRecord(actor.spaceId, renderId); const report = GateReportSchema.nullable().parse(row.gateReport ?? null); if (row.status !== "blocked" || !report || report.passed) { return { status: "user_required", reason: "not_blocked", changes: [] }; } const plan = await repos.getPlan(actor.spaceId, row.planId); const project = await repos.getProjectRecord(actor.spaceId, row.projectId); const assetIds = [ ...new Set([...project.assetIds, ...plan.edl.tracks.video.map((clip) => clip.assetId)]), ]; const segments = await repos.listSegments(actor.spaceId, assetIds); const alternatives = segments .filter((segment) => !segment.paidOnly) .map((segment) => ({ segmentId: segment.id, assetId: segment.assetId, startMs: segment.startMs, endMs: segment.endMs, })); const proposal = proposeAutoFix({ edl: plan.edl, report, round: row.autoFixRound, alternatives }); if ("reason" in proposal) { return { status: "user_required", reason: proposal.reason, changes: proposal.changes }; } const round = row.autoFixRound + 1; const rationale = [ `Auto-fix round ${round} of ${MAX_AUTO_FIX_ROUNDS}:`, ...proposal.changes, ].join("\n- "); const submitted = await services.marketing.submitPlan(actor, { projectId: row.projectId, edl: proposal.edl, rationale, createdBy: "agent", }); if (!submitted.ok) { return { status: "user_required", reason: "plan_rejected", changes: proposal.changes, errors: submitted.errors, }; } const started = await services.render.start(actor, { planId: submitted.plan.id, quality: row.quality as MediaRenderQuality, }); const updated = await repos.updateRender(actor.spaceId, started.id, { autoFixRound: round }, [ ...ACTIVE_RENDER_STATUSES, ]); return { status: "rerendering", round, render: updated ?? started, changes: proposal.changes }; }