Claude 26d95a374e
feat(media): implement pure domain logic for Media Studio
Session and theme clustering, EDL validation and timeline mapping, the
EDL-to-ffmpeg compiler with caption files and filtergraph escaping,
teaser selection and learning, the compliance gate with compliance-only
auto-fix, and the marketing helpers (UTM, hashtags, CSV metrics, intro
proposals, brand-kit checks, upload checklist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:02 +00:00

700 lines
25 KiB
TypeScript

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, string>): 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<Record<ContentCategory, number>> }>;
/**
* 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<T extends { tMs: number }>(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<string>();
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<string, typeof uncovered>();
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<string, typeof visibleMarks>();
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<string>();
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<string, Array<{ start: number; end: number; reason: string }>>();
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<string>();
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<string>();
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<AiAlteration>(
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 };
}