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
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
import type { Edl, GateIssue, PlatformProfile } from "@rakazo/contracts";
|
|
import type { MediaActor, MediaServiceDeps } from "./media-context.js";
|
|
import { clipWindows, overlapsWindows } from "./media-privacy-plan.js";
|
|
|
|
/*
|
|
* Consent and age proof (spec 0 and 6.4). A paid-video profile always needs
|
|
* documented consent and age verification of every recognised participant,
|
|
* whatever its own flags say; other profiles follow their consent flags.
|
|
* Faces that belong to no person can never show consent and block.
|
|
*/
|
|
|
|
export interface ConsentRequirements {
|
|
consent: boolean;
|
|
age: boolean;
|
|
}
|
|
|
|
export function consentRequirements(profile: PlatformProfile): ConsentRequirements {
|
|
const paid = profile.kind === "paid_video";
|
|
return {
|
|
consent: paid || profile.consent.requirePerformerConsent,
|
|
age: paid || profile.consent.requireAgeVerification,
|
|
};
|
|
}
|
|
|
|
/** The profile with its consent flags raised to what the hard rules demand. */
|
|
export function enforceConsentFlags(profile: PlatformProfile): PlatformProfile {
|
|
const required = consentRequirements(profile);
|
|
return {
|
|
...profile,
|
|
consent: { requirePerformerConsent: required.consent, requireAgeVerification: required.age },
|
|
};
|
|
}
|
|
|
|
export interface PersonConsentState {
|
|
id: string;
|
|
consentRecordedAt: Date | string | null;
|
|
ageVerifiedAt: Date | string | null;
|
|
ageVerificationMethod: string;
|
|
}
|
|
|
|
/** Valid proof: a recorded date that is not in the future. */
|
|
function proven(value: Date | string | null, now: Date): boolean {
|
|
if (!value) return false;
|
|
const at = value instanceof Date ? value : new Date(value);
|
|
return !Number.isNaN(at.getTime()) && at.getTime() <= now.getTime();
|
|
}
|
|
|
|
export function hasConsent(person: PersonConsentState, now: Date): boolean {
|
|
return proven(person.consentRecordedAt, now);
|
|
}
|
|
|
|
export function hasAgeProof(person: PersonConsentState, now: Date): boolean {
|
|
return person.ageVerificationMethod !== "none" && proven(person.ageVerifiedAt, now);
|
|
}
|
|
|
|
/**
|
|
* Consent issues for the participants of an export. `unassigned` counts faces
|
|
* no person could be matched to; each blocks because nobody can consent for it.
|
|
*/
|
|
export function consentIssues(
|
|
profile: PlatformProfile,
|
|
people: PersonConsentState[],
|
|
unassigned: Array<{ atMs?: number; endMs?: number }>,
|
|
now: Date,
|
|
): GateIssue[] {
|
|
const required = consentRequirements(profile);
|
|
if (!required.consent && !required.age) return [];
|
|
const issues: GateIssue[] = [];
|
|
for (const person of [...people].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
if (required.consent && !hasConsent(person, now)) {
|
|
issues.push({
|
|
check: "consent",
|
|
severity: "block",
|
|
code: "consent_missing",
|
|
message: "no recorded consent",
|
|
personId: person.id,
|
|
});
|
|
}
|
|
if (required.age && !hasAgeProof(person, now)) {
|
|
issues.push({
|
|
check: "consent",
|
|
severity: "block",
|
|
code: "age_unverified",
|
|
message: "age is not verified",
|
|
personId: person.id,
|
|
});
|
|
}
|
|
}
|
|
for (const face of unassigned) {
|
|
issues.push({
|
|
check: "consent",
|
|
severity: "block",
|
|
code: "unknown_performer",
|
|
message: "a visible face is not assigned to a person with consent",
|
|
...(face.atMs !== undefined ? { atMs: face.atMs } : {}),
|
|
...(face.endMs !== undefined ? { endMs: face.endMs } : {}),
|
|
});
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
/**
|
|
* People and unassigned faces in the clip ranges of an EDL, from the stored
|
|
* face tracks of its assets.
|
|
*/
|
|
export function participantsOf(
|
|
edl: Edl,
|
|
tracks: Array<{ assetId: string; personId: string | null; startMs: number; endMs: number }>,
|
|
): {
|
|
personIds: string[];
|
|
unassigned: Array<{ assetId: string; startMs: number; endMs: number }>;
|
|
} {
|
|
const personIds = new Set<string>();
|
|
const unassigned: Array<{ assetId: string; startMs: number; endMs: number }> = [];
|
|
for (const track of tracks) {
|
|
const windows = clipWindows(edl.tracks.video.filter((clip) => clip.assetId === track.assetId));
|
|
if (!overlapsWindows(track, windows)) continue;
|
|
if (track.personId) personIds.add(track.personId);
|
|
else unassigned.push({ assetId: track.assetId, startMs: track.startMs, endMs: track.endMs });
|
|
}
|
|
return { personIds: [...personIds].sort(), unassigned };
|
|
}
|
|
|
|
export class ConsentRequiredError extends Error {
|
|
constructor(readonly issues: GateIssue[]) {
|
|
super(
|
|
`export blocked: ${issues.length} participant${issues.length === 1 ? "" : "s"} without documented consent or age proof`,
|
|
);
|
|
this.name = "ConsentRequiredError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Consent issues for exporting a render to a profile, re-read at export time
|
|
* so a withdrawn consent stops a bundle that passed the gate earlier.
|
|
*/
|
|
export async function exportConsentIssues(
|
|
deps: Pick<MediaServiceDeps, "repos" | "now">,
|
|
actor: MediaActor,
|
|
input: { edl: Edl; profile: PlatformProfile },
|
|
): Promise<GateIssue[]> {
|
|
const required = consentRequirements(input.profile);
|
|
if (!required.consent && !required.age) return [];
|
|
const assetIds = [...new Set(input.edl.tracks.video.map((clip) => clip.assetId))];
|
|
const tracks = await deps.repos.faceTrackRecords(actor.spaceId, assetIds);
|
|
const { personIds, unassigned } = participantsOf(input.edl, tracks);
|
|
const people = personIds.length
|
|
? await deps.repos.listPersonRecords(actor.spaceId, personIds)
|
|
: [];
|
|
return consentIssues(input.profile, people, unassigned, deps.now());
|
|
}
|
|
|
|
/** Throws ConsentRequiredError when the export may not happen. */
|
|
export async function assertExportConsent(
|
|
deps: Pick<MediaServiceDeps, "repos" | "now">,
|
|
actor: MediaActor,
|
|
input: { edl: Edl; profile: PlatformProfile },
|
|
): Promise<void> {
|
|
const issues = await exportConsentIssues(deps, actor, input);
|
|
if (issues.length > 0) throw new ConsentRequiredError(issues);
|
|
}
|