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
700 lines
25 KiB
TypeScript
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 };
|
|
}
|