prepareSources applies every face, mark, and region protection before the compiler runs and fails closed when one cannot be honored: all face modes, tracking with interpolation and grown holds over lost detections, reframe_out crops with blur fallback, synthetic identity only through a licensed provider (blur fallback otherwise), inpainting with a quality floor that falls back to a cover, and person-bound marks re-found next to the face. The gate runs on the render output: classifier at the profile rate plus cuts, face tracking matched to people locally, pixel verification of every recorded protection, consent and age proof (always for paid video), text and technical checks. Auto-fix proposes only compliance repairs and stops after three rounds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
194 lines
8.2 KiB
TypeScript
194 lines
8.2 KiB
TypeScript
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<MediaServiceDeps, "repos">;
|
|
marketing: Pick<MediaMarketingService, "submitPlan">;
|
|
render: Pick<MediaRenderService, "start">;
|
|
}
|
|
|
|
/**
|
|
* 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<AutoFixResult> {
|
|
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 };
|
|
}
|