import type { AiAlteration, ContentCategory, Edl, EdlVideoClip, FaceRedaction, FfprobeReport, GateIssue, GateReport, NormalizedBox, PlatformProfile, } from "@rakazo/contracts"; import { CONTENT_CATEGORIES, MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts"; import { BLENDED_TRANSITIONS, boxInsideSafeZone, clipLengthMs, edlDurationMs, locateOutputTimeAll, matchesAspect, sortedVideoClips, } from "./edl.js"; /** * Tags a clean export may carry. Everything else is a metadata leak. * * - `title`: the only descriptive tag the compiler writes, and only when set. * - `major_brand`, `minor_version`, `compatible_brands`: the MP4 file-type * box, always written by the muxer; it names the format, not the author. * - `handler_name`, `vendor_id`, `language`: per-stream boilerplate the MP4 * muxer writes ("VideoHandler", "[0][0][0][0]", "und"). * - `encoder`: the MP4 sample description always names the codec. It is * allowed only without a version number (bitexact output writes * "Lavc libx264"); a versioned value such as "Lavf60.16.100" is a leak. * * `creation_time`, `location`, `com.apple.quicktime.*`, `make`, `model`, * `software`, GPS keys and anything unknown always count as leaks. */ export const METADATA_ALLOWED_TAGS: readonly string[] = [ "title", "major_brand", "minor_version", "compatible_brands", "handler_name", "vendor_id", "language", "encoder", ]; const ALLOWED = new Set(METADATA_ALLOWED_TAGS); /** "Lavf60.16.100", "Lavc60.31", "x264 core 164", "HandBrake 1.7.0". */ const VERSIONED = /\d+\.\d+|lav[cf]\d|core\s*\d/i; /** Tag name without a "format." / "stream.0." style prefix, lower-cased. */ function tagName(key: string): string { return key .trim() .toLowerCase() .replace(/^(?:format|streams?(?:[.:/]\d+)?)[.:/]/, ""); } export function findMetadataLeaks(tags: Record): string[] { return Object.entries(tags) .filter(([key, value]) => { const name = tagName(key); if (!ALLOWED.has(name)) return true; if (name === "encoder") return VERSIONED.test(value); return false; }) .map(([key]) => key) .sort(); } export interface GateInput { profile: PlatformProfile; edl: Edl; ffprobe: FfprobeReport; /** Classifier scores on the rendered output. */ content: Array<{ tMs: number; scores: Partial> }>; /** * Faces re-detected on the rendered output. personId null means the face * could not be matched to a known person. */ faces: Array<{ tMs: number; box: NormalizedBox; personId: string | null; coverage: number }>; /** Frames where tracking lost a face that must stay anonymized for longer than allowed. */ trackingGaps: Array<{ startMs: number; endMs: number; personId: string | null }>; /** Residual visibility 0..1 of treated marks in the output. */ marks: Array<{ markId: string; tMs: number; residualVisibility: number }>; /** People visible in the output with their protection and consent state. */ people: Array<{ id: string; anonymizeByDefault: string; consentRecordedAt: string | null; ageVerifiedAt: string | null; }>; copy: { title: string | null; caption: string | null; tags: string[] }; aiAlterations: AiAlteration[]; bundleHasDisclosure: boolean; watermarkBox: NormalizedBox | null; sampledFrames: number; autoFixRound: number; now: Date; } /** Longest tracking gap the anonymizer may bridge (spec 7). */ export const MAX_TRACKING_GAP_MS = 500; /** Residual visibility of a treated mark above which the gate blocks. */ export const MAX_MARK_RESIDUAL = 0.05; /** Loudness tolerance around the target, in LU. */ export const LOUDNESS_TOLERANCE_LU = 2; const TRUE_PEAK_TOLERANCE_DB = 0.5; const ms = (value: number) => Math.max(0, Math.round(value)); /** Merge time-sorted hits into ranges when they are at most `gapMs` apart. */ function ranges(hits: T[], gapMs: number): T[][] { const sorted = [...hits].sort((a, b) => a.tMs - b.tMs); const out: T[][] = []; for (const hit of sorted) { const last = out.at(-1); if (last && hit.tMs - last.at(-1)!.tMs <= gapMs) last.push(hit); else out.push([hit]); } return out; } export function checkTechnical( profile: PlatformProfile, edl: Edl, probe: FfprobeReport, ): GateIssue[] { const issues: GateIssue[] = []; const block = (code: string, message: string) => issues.push({ check: "technical", severity: "block", code, message }); if (probe.width !== edl.output.width || probe.height !== edl.output.height) { block( "resolution_mismatch", `output is ${probe.width}x${probe.height}, plan expects ${edl.output.width}x${edl.output.height}`, ); } if (!matchesAspect(probe.width, probe.height, profile.video.aspectRatios)) { block("aspect_not_allowed", `${probe.width}x${probe.height} is not an allowed aspect ratio`); } if (Math.max(probe.width, probe.height) > Math.max(profile.video.width, profile.video.height)) { block("resolution_too_high", "output is larger than the profile allows"); } if ( Math.abs(probe.fps - edl.output.fps) > edl.output.fps * 0.01 || probe.fps > profile.video.fps * 1.001 ) { block("fps_mismatch", `output runs at ${probe.fps} fps`); } const frameMs = 1000 / Math.max(1, edl.output.fps); const expected = edlDurationMs(edl); if (probe.durationMs < profile.duration.hardMinMs - frameMs) { block("duration_too_short", `${probe.durationMs} ms is below ${profile.duration.hardMinMs} ms`); } if (probe.durationMs > profile.duration.hardMaxMs + frameMs) { block("duration_too_long", `${probe.durationMs} ms is above ${profile.duration.hardMaxMs} ms`); } if (Math.abs(probe.durationMs - expected) > Math.max(2 * frameMs, 100)) { issues.push({ check: "technical", severity: "warn", code: "duration_drift", message: `output is ${probe.durationMs} ms, plan is ${expected} ms`, }); } if (probe.sizeBytes > profile.video.maxFileSizeMb * 1_000_000) { block("file_too_large", `file is larger than ${profile.video.maxFileSizeMb} MB`); } const codec = probe.videoCodec.toLowerCase(); const expectedCodec = profile.video.codec === "hevc" ? ["hevc", "h265"] : ["h264", "avc1"]; if (!expectedCodec.includes(codec)) { block("codec_mismatch", `video codec ${probe.videoCodec} is not ${profile.video.codec}`); } if (probe.audioCodec !== null && probe.audioCodec.toLowerCase() !== "aac") { issues.push({ check: "technical", severity: "warn", code: "audio_codec", message: `audio codec ${probe.audioCodec} is not AAC`, }); } const target = edl.targetLufs ?? profile.audio.targetLufs; if (probe.audioCodec !== null) { if (probe.integratedLufs === null) { block("loudness_unmeasured", "loudness was not measured"); } else if (Math.abs(probe.integratedLufs - target) > LOUDNESS_TOLERANCE_LU) { block("loudness_off_target", `${probe.integratedLufs} LUFS, target ${target} LUFS`); } if ( probe.truePeakDb !== null && probe.truePeakDb > profile.audio.truePeakDb + TRUE_PEAK_TOLERANCE_DB ) { block("true_peak_exceeded", `true peak ${probe.truePeakDb} dBTP`); } } const leaks = findMetadataLeaks(probe.tags); if (leaks.length > 0) block("metadata_leak", `metadata not removed: ${leaks.join(", ")}`); return issues; } const URL_PATTERN = /\b(?:https?:\/\/|www\.)\S+|\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:com|net|org|io|de|at|ch|eu|ly|co|me|link|shop|store|app|tv|gg|xyz)\b(?:\/\S*)?/i; const HASHTAG_PATTERN = /#[\p{L}\p{N}_]+/gu; /** Profile patterns compile case-insensitively; an invalid one is reported, never thrown. */ function compilePattern(pattern: string): RegExp | null { try { return new RegExp(pattern, "i"); } catch { return null; } } export function checkCopy( profile: PlatformProfile, copy: { title: string | null; caption: string | null; tags: string[] }, ): GateIssue[] { const issues: GateIssue[] = []; const block = (code: string, message: string) => issues.push({ check: "text", severity: "block", code, message }); const { text } = profile; const title = copy.title ?? ""; const caption = copy.caption ?? ""; if (text.titleMaxChars !== undefined && [...title].length > text.titleMaxChars) { block( "title_too_long", `title has ${[...title].length} characters, limit ${text.titleMaxChars}`, ); } if ([...caption].length > text.captionMaxChars) { block( "caption_too_long", `caption has ${[...caption].length} characters, limit ${text.captionMaxChars}`, ); } const hashtags = new Set(); for (const tag of copy.tags) { const clean = tag.replace(/^#+/, "").trim().toLowerCase(); if (clean) hashtags.add(clean); } for (const match of caption.matchAll(HASHTAG_PATTERN)) hashtags.add(match[0].slice(1).toLowerCase()); if (hashtags.size > text.hashtagLimit) { block("too_many_hashtags", `${hashtags.size} hashtags, limit ${text.hashtagLimit}`); } const fields: Array<[string, string]> = [ ["title", title], ["caption", caption], ...copy.tags.map((tag, i): [string, string] => [`tags[${i}]`, tag]), ]; for (const banned of text.bannedPatterns) { const regex = compilePattern(banned.pattern); if (!regex) { block("invalid_banned_pattern", `profile pattern cannot be checked: ${banned.reason}`); continue; } for (const [field, value] of fields) { if (value && regex.test(value)) block("banned_pattern", `${field}: ${banned.reason}`); } } if (text.linkPolicy !== "allowed") { for (const [field, value] of fields) { if (!value || !URL_PATTERN.test(value)) continue; if (text.linkPolicy === "forbidden") block("link_forbidden", `${field} contains a link`); else block("link_bio_only", `${field} contains a link; links belong in the bio`); } } return issues; } function checkContent(input: GateInput): GateIssue[] { const { profile } = input; const issues: GateIssue[] = []; const thresholds = profile.content.thresholds; const gapMs = (1000 / profile.content.sampleFps) * 1.5; const sampled = Math.max(input.content.length, input.sampledFrames, 1); for (const category of CONTENT_CATEGORIES) { const threshold = thresholds[category]; if (threshold === undefined) continue; const hits = input.content.flatMap((sample) => { const score = sample.scores[category]; return score !== undefined && score > threshold ? [{ tMs: sample.tMs, score }] : []; }); if (hits.length === 0) continue; const blocking = profile.content.blockOn === "any_frame" || hits.length / sampled > (profile.content.blockRatio ?? 0); for (const range of ranges(hits, gapMs)) { const score = Math.max(...range.map((h) => h.score)); issues.push({ check: "content", severity: blocking ? "block" : "warn", code: "content_threshold", message: `${category} ${score.toFixed(2)} above ${threshold}`, atMs: ms(range[0]!.tMs), endMs: ms(range.at(-1)!.tMs), category, score, }); } } return issues; } function checkAnonymization(input: GateInput): GateIssue[] { const issues: GateIssue[] = []; const protectedIds = new Set( input.people.filter((p) => p.anonymizeByDefault !== "none").map((p) => p.id), ); const gapMs = 1000; const uncovered = input.faces.filter( (face) => face.personId !== null && protectedIds.has(face.personId) && face.coverage < 1, ); const byPerson = new Map(); for (const face of uncovered) { const list = byPerson.get(face.personId!) ?? []; list.push(face); byPerson.set(face.personId!, list); } for (const [personId, faces] of [...byPerson.entries()].sort((a, b) => a[0].localeCompare(b[0]), )) { for (const range of ranges(faces, gapMs)) { const coverage = Math.min(...range.map((f) => f.coverage)); issues.push({ check: "anonymization", severity: "block", code: "face_not_covered", message: `face covered ${(coverage * 100).toFixed(0)} %`, atMs: ms(range[0]!.tMs), endMs: ms(range.at(-1)!.tMs), personId, score: coverage, }); } } if (protectedIds.size > 0) { const unknown = input.faces.filter((face) => face.personId === null); for (const range of ranges(unknown, gapMs)) { issues.push({ check: "anonymization", severity: "block", code: "unknown_face", message: "a face that cannot be matched appears next to a protected person", atMs: ms(range[0]!.tMs), endMs: ms(range.at(-1)!.tMs), }); } } for (const gap of input.trackingGaps) { if (gap.endMs - gap.startMs <= MAX_TRACKING_GAP_MS) continue; issues.push({ check: "anonymization", severity: "block", code: "tracking_gap", message: `face tracking lost for ${Math.round(gap.endMs - gap.startMs)} ms (needs review)`, atMs: ms(gap.startMs), endMs: ms(gap.endMs), ...(gap.personId ? { personId: gap.personId } : {}), }); } const visibleMarks = input.marks.filter((mark) => mark.residualVisibility > MAX_MARK_RESIDUAL); const byMark = new Map(); for (const mark of visibleMarks) { const list = byMark.get(mark.markId) ?? []; list.push(mark); byMark.set(mark.markId, list); } for (const [markId, marks] of [...byMark.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { for (const range of ranges(marks, gapMs)) { const residual = Math.max(...range.map((m) => m.residualVisibility)); issues.push({ check: "anonymization", severity: "block", code: "mark_visible", message: `treated mark still ${(residual * 100).toFixed(0)} % visible`, atMs: ms(range[0]!.tMs), endMs: ms(range.at(-1)!.tMs), markId, score: residual, }); } } return issues; } function checkConsent(input: GateInput): GateIssue[] { const { consent } = input.profile; if (!consent.requirePerformerConsent && !consent.requireAgeVerification) return []; const issues: GateIssue[] = []; for (const person of [...input.people].sort((a, b) => a.id.localeCompare(b.id))) { if (consent.requirePerformerConsent && !person.consentRecordedAt) { issues.push({ check: "consent", severity: "block", code: "consent_missing", message: "no recorded consent", personId: person.id, }); } if (consent.requireAgeVerification && !person.ageVerifiedAt) { issues.push({ check: "consent", severity: "block", code: "age_unverified", message: "age is not verified", personId: person.id, }); } } for (const range of ranges( input.faces.filter((face) => face.personId === null), 1000, )) { issues.push({ check: "consent", severity: "block", code: "unknown_performer", message: "a visible face is not assigned to a person with consent", atMs: ms(range[0]!.tMs), endMs: ms(range.at(-1)!.tMs), }); } return issues; } /** Deterministic gate (spec 6). Any blocking issue fails the report. */ export function evaluateGate(input: GateInput): GateReport { const { profile } = input; const issues: GateIssue[] = [ ...checkTechnical(profile, input.edl, input.ffprobe), ...checkContent(input), ...checkAnonymization(input), ...checkConsent(input), ...checkCopy(profile, input.copy), ]; const alterations = new Set([...input.aiAlterations, ...input.edl.metadata.aiAlterations]); if ( alterations.size > 0 && profile.disclosure.aiAlteredLabelRequired && !input.bundleHasDisclosure ) { issues.push({ check: "disclosure", severity: "block", code: "ai_label_missing", message: `AI alterations (${[...alterations].sort().join(", ")}) need the platform label`, }); } if (input.watermarkBox && !boxInsideSafeZone(input.watermarkBox, profile)) { issues.push({ check: "brand", severity: "warn", code: "watermark_outside_safe_zone", message: "watermark is covered by platform interface", }); } return { profileId: profile.id, passed: !issues.some((issue) => issue.severity === "block"), checkedAt: input.now.toISOString(), sampledFrames: Math.max(0, Math.round(input.sampledFrames)), issues, autoFixRound: Math.min(MAX_AUTO_FIX_ROUNDS, Math.max(0, Math.round(input.autoFixRound))), }; } export interface AutoFixResult { edl: Edl; changes: string[]; } const PADDING_STEP = 25; const CUT_MARGIN_MS = 500; const MIN_KEEP_MS = 500; /** Lay clips out back to back again, keeping blended transitions' overlap. */ function repack(edl: Edl): Edl { const clips = sortedVideoClips(edl); const ids = new Set(clips.map((c) => c.id)); const transitions = edl.transitions.filter((t) => ids.has(t.fromClipId) && ids.has(t.toClipId)); const adjacent = new Set(); for (let i = 0; i + 1 < clips.length; i++) adjacent.add(`${clips[i]!.id}>${clips[i + 1]!.id}`); const kept = transitions.filter((t) => adjacent.has(`${t.fromClipId}>${t.toClipId}`)); const blend = new Map( kept .filter((t) => BLENDED_TRANSITIONS.has(t.kind) && t.durationMs > 0) .map((t) => [`${t.fromClipId}>${t.toClipId}`, t.durationMs]), ); let cursor = 0; const packed: EdlVideoClip[] = clips.map((clip, i) => { const previous = clips[i - 1]; let overlap = previous ? (blend.get(`${previous.id}>${clip.id}`) ?? 0) : 0; overlap = Math.min(overlap, clipLengthMs(clip) - 1, cursor); const atMs = Math.max(0, cursor - Math.max(0, overlap)); cursor = atMs + clipLengthMs(clip); return { ...clip, atMs }; }); const regions = edl.redactions.regions.filter((r) => ids.has(r.clipId)); const next = { ...edl, tracks: { ...edl.tracks, video: packed }, transitions: kept.filter((t) => { if (!BLENDED_TRANSITIONS.has(t.kind)) return true; const from = packed.find((c) => c.id === t.fromClipId)!; const to = packed.find((c) => c.id === t.toClipId)!; return t.durationMs < Math.min(clipLengthMs(from), clipLengthMs(to)); }), redactions: { ...edl.redactions, regions }, }; // Overlays never grow or move; they are only clamped to the new end. const total = edlDurationMs(next); next.overlays = edl.overlays.flatMap((overlay) => { if (overlay.atMs >= total) return []; return [{ ...overlay, durationMs: Math.min(overlay.durationMs, total - overlay.atMs) }]; }); return next; } /** * Compliance-only repairs: trim or drop offending clips, change crop, widen * redaction padding, swap to another segment. Never adds obfuscation aimed at * classifiers. Returns null when the round limit is reached or nothing safe applies. * * - Content blocks and tracking gaps: the clip showing that moment is swapped * for an alternative segment of at least the same length that does not * overlap the offending source range, else trimmed (when the range sits at * one end) or dropped. The timeline is then repacked. * - Uncovered faces: padding of the person's face redaction grows by 25 * points; a missing redaction is added as blur. * - Unknown faces: the unassigned-face redaction grows or is added as blur. * - Visible marks: the mark treatment falls back to cover. */ export function planAutoFix( edl: Edl, report: GateReport, alternatives: Array<{ segmentId: string; assetId: string; startMs: number; endMs: number }>, ): AutoFixResult | null { if (report.autoFixRound >= MAX_AUTO_FIX_ROUNDS || report.passed) return null; const blocks = report.issues.filter((issue) => issue.severity === "block"); const changes: string[] = []; let clips: EdlVideoClip[] = edl.tracks.video.map((clip) => ({ ...clip })); let faces: FaceRedaction[] = edl.redactions.faces.map((f) => ({ ...f })); let marks = edl.redactions.marks.map((m) => ({ ...m })); let clipsChanged = false; // Source ranges to remove, per clip. const offending = new Map>(); for (const issue of blocks) { const timeline = issue.check === "content" || issue.code === "tracking_gap" || (issue.code === "mark_visible" && !edl.redactions.marks.some((m) => m.markId === issue.markId)); if (!timeline || issue.atMs === undefined) continue; const endMs = issue.endMs ?? issue.atMs; const step = Math.max(1, Math.floor(1000 / edl.output.fps)); const times: number[] = []; for (let t = issue.atMs; t < endMs; t += step) times.push(t); times.push(endMs); for (const t of times) { for (const hit of locateOutputTimeAll(edl, t)) { const list = offending.get(hit.clipId) ?? []; const last = list.at(-1); if (last && last.reason === issue.code && hit.sourceMs - last.end <= step * 4) { last.end = Math.max(last.end, hit.sourceMs); } else { list.push({ start: hit.sourceMs, end: hit.sourceMs, reason: issue.code }); } offending.set(hit.clipId, list); } } } const usedAlternatives = new Set(); for (const [clipId, found] of [...offending.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { const clip = clips.find((c) => c.id === clipId); if (!clip) continue; const start = Math.max(clip.inMs, Math.min(...found.map((r) => r.start)) - CUT_MARGIN_MS); const end = Math.min(clip.outMs, Math.max(...found.map((r) => r.end)) + CUT_MARGIN_MS); const length = clip.outMs - clip.inMs; const alternative = alternatives .filter( (a) => !usedAlternatives.has(a.segmentId) && a.segmentId !== clip.segmentId && a.endMs - a.startMs >= length && !(a.assetId === clip.assetId && a.startMs < end && start < a.endMs), ) .sort((a, b) => a.segmentId.localeCompare(b.segmentId))[0]; clipsChanged = true; if (alternative) { usedAlternatives.add(alternative.segmentId); Object.assign(clip, { assetId: alternative.assetId, segmentId: alternative.segmentId, inMs: alternative.startMs, outMs: alternative.startMs + length, reframe: clip.reframe === "none" ? "none" : "auto", }); changes.push( `clip ${clip.id}: swapped to segment ${alternative.segmentId} (${found[0]!.reason})`, ); } else if (start - clip.inMs >= MIN_KEEP_MS && end >= clip.outMs - 1) { clip.outMs = start; changes.push(`clip ${clip.id}: trimmed the end at ${start} ms (${found[0]!.reason})`); } else if (clip.outMs - end >= MIN_KEEP_MS && start <= clip.inMs + 1) { clip.inMs = end; changes.push(`clip ${clip.id}: trimmed the start to ${end} ms (${found[0]!.reason})`); } else { clips = clips.filter((c) => c.id !== clip.id); changes.push(`clip ${clip.id}: removed (${found[0]!.reason})`); } } for (const clip of clips) { if (Array.isArray(clip.reframe)) { const inside = clip.reframe.filter((k) => k.tMs >= clip.inMs && k.tMs <= clip.outMs); clip.reframe = inside.length > 0 ? inside : "auto"; } } const widen = (match: (f: FaceRedaction) => boolean, add: () => FaceRedaction, label: string) => { const existing = faces.filter(match); if (existing.length === 0) { faces = [...faces, add()]; changes.push(`${label}: added blur redaction`); return; } for (const face of existing) { if (face.paddingPct >= 100) continue; face.paddingPct = Math.min(100, face.paddingPct + PADDING_STEP); changes.push(`${label}: redaction ${face.id} padding ${face.paddingPct} %`); } }; const widened = new Set(); for (const issue of blocks) { if (issue.code === "face_not_covered" && issue.personId && !widened.has(issue.personId)) { const personId = issue.personId; widened.add(personId); widen( (f) => "personId" in f.target && f.target.personId === personId, () => ({ id: `fix-face-${widened.size}`, target: { personId }, mode: "blur", paddingPct: 20 + PADDING_STEP, featherPct: 10, minSizePct: 2, }), `person ${personId}`, ); } if (issue.code === "unknown_face" && !widened.has("(unassigned)")) { widened.add("(unassigned)"); widen( (f) => "unassignedFaces" in f.target, () => ({ id: "fix-face-unassigned", target: { unassignedFaces: true }, mode: "blur", paddingPct: 20 + PADDING_STEP, featherPct: 10, minSizePct: 2, }), "unassigned faces", ); } if (issue.code === "mark_visible" && issue.markId) { const markId = issue.markId; marks = marks.map((mark) => { if (mark.markId !== markId || mark.treatment === "cover") return mark; changes.push(`mark ${markId}: ${mark.treatment} replaced by cover`); return { ...mark, treatment: "cover" as const }; }); } } if (changes.length === 0 || clips.length === 0) return null; let next: Edl = { ...edl, tracks: { ...edl.tracks, video: clips }, redactions: { ...edl.redactions, faces, marks }, }; if (clipsChanged) next = repack(next); const stillAltered = new Set( next.metadata.aiAlterations.filter( (a) => a !== "inpaint" || marks.some((m) => m.treatment === "inpaint"), ), ); const explicit = edl.metadata.aiAltered && edl.metadata.aiAlterations.length === 0; next.metadata = { ...next.metadata, aiAlterations: [...stillAltered], aiAltered: stillAltered.size > 0 || explicit, }; return { edl: next, changes }; }