merge: W2 privacy
This commit is contained in:
commit
90f18cea52
424
packages/adapters/src/media-anonymize.test.ts
Normal file
424
packages/adapters/src/media-anonymize.test.ts
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
import { copyFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { MediaInferenceProvider } from "@rakazo/adapter-kit";
|
||||||
|
import type { AnonymizeMode, Edl } from "@rakazo/contracts";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { prepareSources } from "./media-anonymize.js";
|
||||||
|
import { ffmpegAvailable } from "./media-emulator-clips.js";
|
||||||
|
import { runGate } from "./media-gate.js";
|
||||||
|
import type { PrivacyHarness } from "./media-privacy-fixtures.js";
|
||||||
|
import {
|
||||||
|
ACTOR,
|
||||||
|
cleanProbe,
|
||||||
|
createPrivacyHarness,
|
||||||
|
testEdl,
|
||||||
|
testProfile,
|
||||||
|
} from "./media-privacy-fixtures.js";
|
||||||
|
import { PrivacyUnavailableError } from "./media-privacy-io.js";
|
||||||
|
import { extractLuma, residualVisibility } from "./media-privacy-pixels.js";
|
||||||
|
import { readPrivacyRecord } from "./media-privacy-plan.js";
|
||||||
|
|
||||||
|
const hasFfmpeg = ffmpegAvailable();
|
||||||
|
let harness: PrivacyHarness | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
harness = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One asset with one face of person "alice" (stored track), one clip over it. */
|
||||||
|
async function setup(
|
||||||
|
mode: AnonymizeMode,
|
||||||
|
options: {
|
||||||
|
fileName?: string;
|
||||||
|
edl?: (assetId: string) => Edl;
|
||||||
|
inference?: (emulator: PrivacyHarness["inference"]) => MediaInferenceProvider | null;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
harness = await createPrivacyHarness(options.inference);
|
||||||
|
const asset = await harness.addAsset(options.fileName ?? "clip__faces-1____person-alice__.mp4");
|
||||||
|
harness.addPerson("alice", mode);
|
||||||
|
await harness.analyseFaces(asset.id, ["alice"]);
|
||||||
|
await harness.learnPerson("alice", asset.id);
|
||||||
|
const edl =
|
||||||
|
options.edl?.(asset.id) ?? testEdl([{ id: "c1", assetId: asset.id, inMs: 0, outMs: 2000 }]);
|
||||||
|
const workDir = await harness.workDir(`render-${mode}`);
|
||||||
|
return { h: harness, asset, edl, workDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gateOn(
|
||||||
|
h: PrivacyHarness,
|
||||||
|
input: { edl: Edl; workDir: string; prepared: string; outputName?: string },
|
||||||
|
profile = testProfile(),
|
||||||
|
) {
|
||||||
|
// The compiler is not under test: the prepared source stands in for the render output.
|
||||||
|
const outputPath = path.join(input.workDir, input.outputName ?? "output.mp4");
|
||||||
|
await copyFile(input.prepared, outputPath);
|
||||||
|
return runGate(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl: input.edl,
|
||||||
|
profile,
|
||||||
|
outputPath,
|
||||||
|
ffprobe: cleanProbe(input.edl),
|
||||||
|
copy: { title: null, caption: null, tags: [] },
|
||||||
|
bundleHasDisclosure: false,
|
||||||
|
autoFixRound: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasFfmpeg)("prepareSources", () => {
|
||||||
|
it.each([
|
||||||
|
"blur",
|
||||||
|
"pixelate",
|
||||||
|
"solid",
|
||||||
|
"sticker",
|
||||||
|
"partial_eyes",
|
||||||
|
"partial_upper",
|
||||||
|
"partial_lower",
|
||||||
|
] as const)("applies %s to a protected person and the gate verifies it", async (mode) => {
|
||||||
|
const { h, asset, edl, workDir } = await setup(mode);
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
const source = prepared.sources.get(asset.id);
|
||||||
|
expect(source?.path).toContain(`${path.sep}privacy${path.sep}`);
|
||||||
|
expect(source).toMatchObject({ width: 320, height: 180, rotation: 0, still: false });
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
expect(record?.protectedPeople).toEqual({ alice: mode });
|
||||||
|
const check = record?.assets[asset.id]?.checks[0];
|
||||||
|
expect(check).toMatchObject({ kind: "face", personId: "alice" });
|
||||||
|
|
||||||
|
// The protected box changed, the rest of the frame did not.
|
||||||
|
const base = await extractLuma(path.join(h.root, asset.storagePath!), {
|
||||||
|
tMs: 1000,
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
});
|
||||||
|
const treated = await extractLuma(source!.path, { tMs: 1000, width: 320, height: 180 });
|
||||||
|
const box = check!.keyframes.find((k) => k.tMs >= 1000)!.box;
|
||||||
|
expect(residualVisibility({ source: base, treated, box })).toBeLessThan(0.05);
|
||||||
|
expect(
|
||||||
|
residualVisibility({ source: base, treated, box: { x: 0.6, y: 0.6, w: 0.35, h: 0.35 } }),
|
||||||
|
).toBeGreaterThan(0.95);
|
||||||
|
|
||||||
|
const report = await gateOn(h, { edl, workDir, prepared: source!.path });
|
||||||
|
expect(report.issues.filter((i) => i.check === "anonymization")).toEqual([]);
|
||||||
|
expect(report.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crops a reframe_out face out of frame", async () => {
|
||||||
|
const { h, asset, workDir } = await setup("reframe_out");
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: asset.id, inMs: 0, outMs: 2000 }], {
|
||||||
|
width: 180,
|
||||||
|
height: 320,
|
||||||
|
});
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
const source = prepared.sources.get(asset.id)!;
|
||||||
|
expect(prepared.fallbacks).toEqual([]);
|
||||||
|
expect(source.width / source.height).toBeCloseTo(180 / 320, 1);
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
expect(record?.assets[asset.id]?.crop).not.toBeNull();
|
||||||
|
const report = await gateOn(h, { edl, workDir, prepared: source.path });
|
||||||
|
expect(report.issues.filter((i) => i.check === "anonymization")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back from reframe_out to blur and says so when no crop avoids the face", async () => {
|
||||||
|
const { h, asset, edl, workDir } = await setup("none", {
|
||||||
|
edl: (assetId) =>
|
||||||
|
testEdl([{ id: "c1", assetId, inMs: 0, outMs: 2000 }], {
|
||||||
|
redactions: {
|
||||||
|
faces: [
|
||||||
|
{
|
||||||
|
id: "big",
|
||||||
|
target: { personId: "alice" },
|
||||||
|
mode: "reframe_out",
|
||||||
|
paddingPct: 100,
|
||||||
|
featherPct: 0,
|
||||||
|
minSizePct: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
marks: [],
|
||||||
|
regions: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
expect(prepared.fallbacks).toEqual([
|
||||||
|
"person alice: no crop keeps the face out of frame; blurred instead",
|
||||||
|
]);
|
||||||
|
const source = prepared.sources.get(asset.id)!;
|
||||||
|
expect(source.width).toBe(320);
|
||||||
|
const report = await gateOn(h, { edl, workDir, prepared: source.path });
|
||||||
|
expect(report.issues.filter((i) => i.check === "anonymization")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks the export when tracking loses the face for more than 500 ms", async () => {
|
||||||
|
const { h, asset, edl, workDir } = await setup("blur", {
|
||||||
|
fileName: "clip__faces-1____person-alice____noface-gap-600-1400__.mp4",
|
||||||
|
});
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
expect(record?.trackingGaps).toEqual([
|
||||||
|
expect.objectContaining({ assetId: asset.id, personId: "alice" }),
|
||||||
|
]);
|
||||||
|
const report = await gateOn(h, {
|
||||||
|
edl,
|
||||||
|
workDir,
|
||||||
|
prepared: prepared.sources.get(asset.id)!.path,
|
||||||
|
});
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ code: "tracking_gap", severity: "block", personId: "alice" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to blur when synthetic identity is unavailable and never fakes it", async () => {
|
||||||
|
const { h, asset, edl, workDir } = await setup("synthetic_identity");
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
expect(prepared.fallbacks).toEqual([
|
||||||
|
"person alice: synthetic identity is not available; blurred instead",
|
||||||
|
]);
|
||||||
|
expect(prepared.aiAlterations).not.toContain("synthetic_identity");
|
||||||
|
const report = await gateOn(h, {
|
||||||
|
edl,
|
||||||
|
workDir,
|
||||||
|
prepared: prepared.sources.get(asset.id)!.path,
|
||||||
|
});
|
||||||
|
expect(report.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a licensed synthetic identity with a stable seed and no target image", async () => {
|
||||||
|
const synthesize = vi.fn();
|
||||||
|
const { h, asset, edl, workDir } = await setup("synthetic_identity", {
|
||||||
|
inference: (emulator) => {
|
||||||
|
const provider = Object.create(emulator) as MediaInferenceProvider;
|
||||||
|
provider.capabilities = async () => {
|
||||||
|
const caps = await emulator.capabilities();
|
||||||
|
caps.operations.synthesizeIdentity = {
|
||||||
|
available: true,
|
||||||
|
experimental: true,
|
||||||
|
model: "test-synth",
|
||||||
|
license: "Apache-2.0",
|
||||||
|
commercialUse: true,
|
||||||
|
};
|
||||||
|
return caps;
|
||||||
|
};
|
||||||
|
provider.synthesizeIdentity = async (request) => {
|
||||||
|
synthesize(request);
|
||||||
|
// Stand-in synthesis: a solid fill over the track (real models paint a face).
|
||||||
|
return emulator.redact({
|
||||||
|
path: request.path,
|
||||||
|
outPath: request.outPath,
|
||||||
|
redactions: [
|
||||||
|
{ keyframes: request.track, mode: "solid", paddingPct: 20, featherPct: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return provider;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
expect(prepared.aiAlterations).toContain("synthetic_identity");
|
||||||
|
expect(synthesize).toHaveBeenCalledTimes(1);
|
||||||
|
const request = synthesize.mock.calls[0]![0] as Record<string, unknown>;
|
||||||
|
expect(Object.keys(request).sort()).toEqual(["outPath", "path", "seed", "signal", "track"]);
|
||||||
|
const seed = h.store.people[0]!.syntheticSeed;
|
||||||
|
expect(request.seed).toBe(seed);
|
||||||
|
expect(seed).not.toBeNull();
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
expect(record?.expectedFaces).toEqual([
|
||||||
|
expect.objectContaining({ personId: "alice", mode: "synthetic_identity" }),
|
||||||
|
]);
|
||||||
|
const report = await gateOn(h, {
|
||||||
|
edl,
|
||||||
|
workDir,
|
||||||
|
prepared: prepared.sources.get(asset.id)!.path,
|
||||||
|
});
|
||||||
|
expect(report.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed without a media worker", async () => {
|
||||||
|
const { h, edl, workDir } = await setup("blur", { inference: () => null });
|
||||||
|
await expect(
|
||||||
|
prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(PrivacyUnavailableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the untouched source when nothing needs protection", async () => {
|
||||||
|
const { h, asset, edl, workDir } = await setup("none");
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
expect(prepared.sources.get(asset.id)?.path).toBe(path.join(h.root, asset.storagePath!));
|
||||||
|
expect(prepared.aiAlterations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blurs unassigned faces next to a protected person and records voice disguise", async () => {
|
||||||
|
const { h, asset, workDir } = await setup("blur", {
|
||||||
|
fileName: "clip__faces-2____person-alice__.mp4",
|
||||||
|
});
|
||||||
|
// Track 2 stays unassigned (analysis stored it without a person).
|
||||||
|
h.store.tracks[1]!.personId = null;
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: asset.id, inMs: 0, outMs: 2000 }], {
|
||||||
|
redactions: { faces: [], marks: [], regions: [], voice: { method: "pitch", semitones: 3 } },
|
||||||
|
});
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
expect(prepared.aiAlterations).toEqual(["voice_disguise"]);
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
expect(record?.protectUnassigned).toBe(true);
|
||||||
|
expect(record?.assets[asset.id]?.checks.map((c) => c.personId)).toEqual(["alice", null]);
|
||||||
|
const report = await gateOn(h, {
|
||||||
|
edl,
|
||||||
|
workDir,
|
||||||
|
prepared: prepared.sources.get(asset.id)!.path,
|
||||||
|
});
|
||||||
|
expect(report.issues.filter((i) => i.check === "anonymization")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("protects a photo and renders it from a redacted still", async () => {
|
||||||
|
const { h, asset, edl, workDir } = await setup("pixelate", {
|
||||||
|
fileName: "photo__faces-1____person-alice__.png",
|
||||||
|
});
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
const source = prepared.sources.get(asset.id)!;
|
||||||
|
expect(source).toMatchObject({ still: true, durationMs: 0, width: 320, height: 180 });
|
||||||
|
expect(source.path.endsWith(".png")).toBe(true);
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
const box = record!.assets[asset.id]!.checks[0]!.keyframes[0]!.box;
|
||||||
|
const base = await extractLuma(path.join(h.root, asset.storagePath!), {
|
||||||
|
tMs: 0,
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
still: true,
|
||||||
|
});
|
||||||
|
const treated = await extractLuma(source.path, {
|
||||||
|
tMs: 0,
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
still: true,
|
||||||
|
});
|
||||||
|
expect(residualVisibility({ source: base, treated, box })).toBeLessThan(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies EDL regions and covers one that no crop can avoid", async () => {
|
||||||
|
const { h, asset, workDir } = await setup("none");
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: asset.id, inMs: 0, outMs: 2000 }], {
|
||||||
|
redactions: {
|
||||||
|
faces: [],
|
||||||
|
marks: [],
|
||||||
|
regions: [
|
||||||
|
{
|
||||||
|
id: "plate",
|
||||||
|
clipId: "c1",
|
||||||
|
keyframes: [{ tMs: 0, box: { x: 0.7, y: 0.7, w: 0.2, h: 0.2 } }],
|
||||||
|
treatment: "blur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "everything",
|
||||||
|
clipId: "c1",
|
||||||
|
keyframes: [{ tMs: 0, box: { x: 0, y: 0, w: 1, h: 1 } }],
|
||||||
|
treatment: "reframe_out",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
expect(prepared.fallbacks).toEqual([
|
||||||
|
"region everything: no crop keeps it out of frame; covered instead",
|
||||||
|
]);
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
expect(record?.assets[asset.id]?.checks.map((c) => c.id)).toEqual(["plate", "everything"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when a provider claims to redact but leaves the face visible", async () => {
|
||||||
|
const { h, asset, edl, workDir } = await setup("blur", {
|
||||||
|
inference: (emulator) => {
|
||||||
|
const provider = Object.create(emulator) as MediaInferenceProvider;
|
||||||
|
provider.redact = async (request) => {
|
||||||
|
await copyFile(request.path, request.outPath);
|
||||||
|
return { outPath: request.outPath, framesProcessed: 50 };
|
||||||
|
};
|
||||||
|
return provider;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
const report = await gateOn(h, {
|
||||||
|
edl,
|
||||||
|
workDir,
|
||||||
|
prepared: prepared.sources.get(asset.id)!.path,
|
||||||
|
});
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ code: "face_not_covered", personId: "alice" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
928
packages/adapters/src/media-anonymize.ts
Normal file
928
packages/adapters/src/media-anonymize.ts
Normal file
@ -0,0 +1,928 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { RedactionInstruction } from "@rakazo/adapter-kit";
|
||||||
|
import type {
|
||||||
|
AiAlteration,
|
||||||
|
AnonymizeMode,
|
||||||
|
BoxKeyframe,
|
||||||
|
Edl,
|
||||||
|
EdlVideoClip,
|
||||||
|
MarkTreatment,
|
||||||
|
MediaCapabilities,
|
||||||
|
MediaRenderQuality,
|
||||||
|
NormalizedBox,
|
||||||
|
NormalizedPoint,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { MediaRepos } from "@rakazo/db";
|
||||||
|
import type { CompileSource } from "@rakazo/media";
|
||||||
|
import { MAX_TRACKING_GAP_MS, reframeExcluding } from "@rakazo/media";
|
||||||
|
import type { MediaActor, MediaPreparedSources, MediaServiceDeps } from "./media-context.js";
|
||||||
|
import {
|
||||||
|
anchorFromFace,
|
||||||
|
faceAt,
|
||||||
|
firstConfident,
|
||||||
|
placeAnchor,
|
||||||
|
promptBox,
|
||||||
|
segmentMark,
|
||||||
|
} from "./media-marks.js";
|
||||||
|
import type { PrivacyToolOptions, ProbedSource } from "./media-privacy-io.js";
|
||||||
|
import {
|
||||||
|
analysisFile,
|
||||||
|
copyInto,
|
||||||
|
cropFile,
|
||||||
|
firstFrame,
|
||||||
|
inferenceCapabilities,
|
||||||
|
isInsideRoot,
|
||||||
|
maskKeyframes,
|
||||||
|
operationAvailable,
|
||||||
|
PrivacyUnavailableError,
|
||||||
|
probeSource,
|
||||||
|
readTrackKeyframes,
|
||||||
|
resolveAssetFile,
|
||||||
|
resolveStored,
|
||||||
|
} from "./media-privacy-io.js";
|
||||||
|
import type {
|
||||||
|
CandidateTrack,
|
||||||
|
PixelBox,
|
||||||
|
PrivacyCheck,
|
||||||
|
PrivacyRecord,
|
||||||
|
Protection,
|
||||||
|
TimeWindow,
|
||||||
|
} from "./media-privacy-plan.js";
|
||||||
|
import {
|
||||||
|
boxAt,
|
||||||
|
clipWindows,
|
||||||
|
cropPixels,
|
||||||
|
effectiveModes,
|
||||||
|
FACE_TRACK_FPS,
|
||||||
|
iou,
|
||||||
|
isPartial,
|
||||||
|
MARK_PADDING_PCT,
|
||||||
|
MARK_QUALITY_MIN,
|
||||||
|
MARK_REVIEW_MIN_SCORE,
|
||||||
|
matchPerson,
|
||||||
|
outsideCrop,
|
||||||
|
overlapsWindows,
|
||||||
|
PRIVACY_RECORD_FILE,
|
||||||
|
padBox,
|
||||||
|
partialCheckBand,
|
||||||
|
protectKeyframes,
|
||||||
|
resolveProtection,
|
||||||
|
TRACK_MATCH_MIN_IOU,
|
||||||
|
trackOverlap,
|
||||||
|
unionBox,
|
||||||
|
writePrivacyRecord,
|
||||||
|
} from "./media-privacy-plan.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* prepareSources (spec 7): resolve every clip's file and apply all face, mark,
|
||||||
|
* and region protection before the compiler sees it. The compiler applies no
|
||||||
|
* redactions, so anything this function cannot honor throws
|
||||||
|
* (PrivacyUnavailableError) instead of returning an unredacted source.
|
||||||
|
*
|
||||||
|
* Per asset, in this order: synthetic identities, inpainted marks, one
|
||||||
|
* redaction pass (blur, pixelate, solid, sticker, partial bands, covers) by the
|
||||||
|
* inference provider, then a static crop for reframe_out. Faces are found from
|
||||||
|
* the stored tracks plus a fresh tracking pass; tracking gaps over 500 ms are
|
||||||
|
* covered by the grown last box and recorded so the gate blocks them. Voice
|
||||||
|
* disguise is applied by the compiler on the program audio and only recorded
|
||||||
|
* here. Everything the gate must verify goes into privacy.json in workDir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AnonymizeDeps = Pick<
|
||||||
|
MediaServiceDeps,
|
||||||
|
"repos" | "inference" | "mediaRoot" | "sources"
|
||||||
|
> &
|
||||||
|
PrivacyToolOptions;
|
||||||
|
|
||||||
|
type AssetRow = Awaited<ReturnType<MediaRepos["getAssetRecord"]>>;
|
||||||
|
type PersonRow = Awaited<ReturnType<MediaRepos["listPersonRecords"]>>[number];
|
||||||
|
|
||||||
|
/** reframe_out boxes are unioned per bucket of this length so the crop search stays small. */
|
||||||
|
const REFRAME_BUCKET_MS = 1000;
|
||||||
|
/** Landmark samples per partial-mode track. */
|
||||||
|
const MAX_LANDMARK_SAMPLES = 120;
|
||||||
|
|
||||||
|
interface AssetPlan {
|
||||||
|
asset: AssetRow;
|
||||||
|
clips: EdlVideoClip[];
|
||||||
|
windows: TimeWindow[];
|
||||||
|
base: string;
|
||||||
|
probe: ProbedSource;
|
||||||
|
dir: string;
|
||||||
|
candidates: CandidateTrack[];
|
||||||
|
instructions: RedactionInstruction[];
|
||||||
|
checks: PrivacyCheck[];
|
||||||
|
synthetic: Array<{ personId: string | null; keyframes: BoxKeyframe[]; seed: number }>;
|
||||||
|
inpaint: Array<{ markId: string; maskDir: string; keyframes: BoxKeyframe[] }>;
|
||||||
|
reframe: Array<{
|
||||||
|
check: PrivacyCheck;
|
||||||
|
/** Boxes the crop must exclude. */
|
||||||
|
boxes: Array<{ tMs: number; box: NormalizedBox }>;
|
||||||
|
/** Applied when no crop can exclude the boxes. */
|
||||||
|
fallback: () => void;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrepareInput {
|
||||||
|
actor: MediaActor;
|
||||||
|
projectId: string;
|
||||||
|
edl: Edl;
|
||||||
|
quality: MediaRenderQuality;
|
||||||
|
workDir: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareSources(
|
||||||
|
deps: AnonymizeDeps,
|
||||||
|
input: PrepareInput,
|
||||||
|
): Promise<MediaPreparedSources> {
|
||||||
|
const { actor, edl, signal } = input;
|
||||||
|
const spaceId = actor.spaceId;
|
||||||
|
const workDir = await resolveStored(deps.mediaRoot, input.workDir);
|
||||||
|
await mkdir(workDir, { recursive: true });
|
||||||
|
const privacyDir = path.join(workDir, "privacy");
|
||||||
|
await rm(privacyDir, { recursive: true, force: true });
|
||||||
|
// A record from an earlier attempt must never vouch for this one.
|
||||||
|
await rm(path.join(workDir, PRIVACY_RECORD_FILE), { force: true });
|
||||||
|
|
||||||
|
const clipsByAsset = new Map<string, EdlVideoClip[]>();
|
||||||
|
for (const clip of edl.tracks.video) {
|
||||||
|
clipsByAsset.set(clip.assetId, [...(clipsByAsset.get(clip.assetId) ?? []), clip]);
|
||||||
|
}
|
||||||
|
const assetIds = [...clipsByAsset.keys()].sort();
|
||||||
|
const assets = await deps.repos.listAssetRecords(spaceId, assetIds);
|
||||||
|
if (assets.length !== assetIds.length)
|
||||||
|
throw new PrivacyUnavailableError("a clip asset is missing");
|
||||||
|
|
||||||
|
const project = await deps.repos.getProjectRecord(spaceId, input.projectId);
|
||||||
|
const kit = project.brandKitId
|
||||||
|
? (await deps.repos.getBrandKit(spaceId, project.brandKitId)).kit
|
||||||
|
: null;
|
||||||
|
const people = await deps.repos.listPersonRecords(spaceId);
|
||||||
|
const peopleById = new Map(people.map((person) => [person.id, person]));
|
||||||
|
const defaults = new Map(
|
||||||
|
people.map((person) => [person.id, person.anonymizeByDefault as AnonymizeMode]),
|
||||||
|
);
|
||||||
|
const modes = effectiveModes(edl.redactions.faces, defaults);
|
||||||
|
const faceWork =
|
||||||
|
edl.redactions.faces.length > 0 || [...modes.values()].some((mode) => mode !== "none");
|
||||||
|
const capabilities = await inferenceCapabilities(deps.inference, signal);
|
||||||
|
const stored = await deps.repos.faceTrackRecords(spaceId, assetIds);
|
||||||
|
const centroids = faceWork ? await deps.repos.personCentroids(spaceId) : [];
|
||||||
|
|
||||||
|
const fallbacks: string[] = [];
|
||||||
|
const alterations = new Set<AiAlteration>();
|
||||||
|
if (edl.redactions.voice) alterations.add("voice_disguise");
|
||||||
|
const record: PrivacyRecord = {
|
||||||
|
version: 1,
|
||||||
|
assets: {},
|
||||||
|
trackingGaps: [],
|
||||||
|
reviews: [],
|
||||||
|
expectedFaces: [],
|
||||||
|
unassignedFaces: [],
|
||||||
|
aiAlterations: [],
|
||||||
|
protectedPeople: {},
|
||||||
|
visiblePeople: [],
|
||||||
|
protectUnassigned: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Files and face candidates per asset.
|
||||||
|
const plans: AssetPlan[] = [];
|
||||||
|
for (const asset of assets) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const clips = clipsByAsset.get(asset.id) ?? [];
|
||||||
|
const base = await resolveAssetFile(deps, spaceId, asset, input.quality);
|
||||||
|
const probe = await probeSource(base, asset.kind, { ...deps, signal });
|
||||||
|
const windows = probe.still ? [{ startMs: 0, endMs: 0 }] : clipWindows(clips);
|
||||||
|
const plan: AssetPlan = {
|
||||||
|
asset,
|
||||||
|
clips,
|
||||||
|
windows,
|
||||||
|
base,
|
||||||
|
probe,
|
||||||
|
dir: path.join(privacyDir, asset.id),
|
||||||
|
candidates: [],
|
||||||
|
instructions: [],
|
||||||
|
checks: [],
|
||||||
|
synthetic: [],
|
||||||
|
inpaint: [],
|
||||||
|
reframe: [],
|
||||||
|
};
|
||||||
|
const storedTracks = stored.filter((track) => track.assetId === asset.id);
|
||||||
|
const fromStore: CandidateTrack[] = [];
|
||||||
|
for (const track of storedTracks) {
|
||||||
|
fromStore.push({
|
||||||
|
id: track.id,
|
||||||
|
storedId: track.id,
|
||||||
|
personId: track.personId,
|
||||||
|
keyframes: await readTrackKeyframes(deps.mediaRoot, track.keyframesPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (faceWork) {
|
||||||
|
if (deps.inference && operationAvailable(capabilities, "trackFaces")) {
|
||||||
|
const file = (await analysisFile(deps, spaceId, asset)) ?? (await workingCopy(deps, plan));
|
||||||
|
const fresh = await deps.inference.trackFaces({
|
||||||
|
path: file,
|
||||||
|
sampleFps: FACE_TRACK_FPS,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
plan.candidates = mergeTracks(asset.id, fromStore, fresh.tracks, centroids);
|
||||||
|
} else {
|
||||||
|
const analysed = await deps.repos.latestAnalysis(spaceId, asset.id, "faces");
|
||||||
|
if (!analysed) {
|
||||||
|
throw new PrivacyUnavailableError(
|
||||||
|
`faces of asset ${asset.id} were never detected and the media worker is unavailable`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
plan.candidates = fromStore;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plan.candidates = fromStore;
|
||||||
|
}
|
||||||
|
plan.candidates = plan.candidates.filter((track) => visibleIn(track, plan));
|
||||||
|
plans.push(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Who is visible and who is protected.
|
||||||
|
const visible = new Set<string>();
|
||||||
|
for (const plan of plans) {
|
||||||
|
for (const track of plan.candidates) {
|
||||||
|
if (track.personId) visible.add(track.personId);
|
||||||
|
else {
|
||||||
|
const span = spanOf(track);
|
||||||
|
if (span) record.unassignedFaces.push({ assetId: plan.asset.id, ...span });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record.visiblePeople = [...visible].sort();
|
||||||
|
for (const [personId, mode] of modes) {
|
||||||
|
if (mode !== "none" && visible.has(personId)) record.protectedPeople[personId] = mode;
|
||||||
|
}
|
||||||
|
record.protectUnassigned = Object.keys(record.protectedPeople).length > 0;
|
||||||
|
const colors = {
|
||||||
|
fill: kit?.colors.primary,
|
||||||
|
sticker: kit?.colors.accent ?? kit?.colors.primary,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Faces.
|
||||||
|
const syntheticReady = operationAvailable(capabilities, "synthesizeIdentity");
|
||||||
|
for (const plan of plans) {
|
||||||
|
for (const track of plan.candidates) {
|
||||||
|
const protection = resolveProtection(track, {
|
||||||
|
faces: edl.redactions.faces,
|
||||||
|
defaults,
|
||||||
|
protectUnassigned: record.protectUnassigned,
|
||||||
|
});
|
||||||
|
if (!protection) continue;
|
||||||
|
const protectedTrack = protectKeyframes(track.keyframes, plan.windows, protection.minSizePct);
|
||||||
|
const keyframes = plan.probe.still
|
||||||
|
? stillKeyframes(protectedTrack.keyframes)
|
||||||
|
: protectedTrack.keyframes;
|
||||||
|
if (keyframes.length === 0) continue;
|
||||||
|
if (!plan.probe.still) {
|
||||||
|
for (const gap of protectedTrack.gaps) {
|
||||||
|
record.trackingGaps.push({ assetId: plan.asset.id, personId: track.personId, ...gap });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await planFace(deps, plan, {
|
||||||
|
track,
|
||||||
|
protection,
|
||||||
|
keyframes,
|
||||||
|
person: track.personId ? (peopleById.get(track.personId) ?? null) : null,
|
||||||
|
syntheticReady,
|
||||||
|
capabilities,
|
||||||
|
colors,
|
||||||
|
fallbacks,
|
||||||
|
record,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Marks (asset marks and the standing marks of people seen in the asset).
|
||||||
|
const edlMarks = new Map(edl.redactions.marks.map((mark) => [mark.markId, mark.treatment]));
|
||||||
|
for (const plan of plans) {
|
||||||
|
const marks = await deps.repos.marksForAsset(spaceId, plan.asset.id);
|
||||||
|
for (const mark of marks) {
|
||||||
|
const treatment: MarkTreatment = edlMarks.get(mark.id) ?? mark.treatment;
|
||||||
|
if (treatment === "none") continue;
|
||||||
|
const located = await locateMark(deps, plan, mark, signal);
|
||||||
|
if (!located) continue;
|
||||||
|
if (located.review) {
|
||||||
|
record.reviews.push({ markId: mark.id, assetId: plan.asset.id, score: located.score });
|
||||||
|
}
|
||||||
|
const keyframes = plan.probe.still
|
||||||
|
? stillKeyframes(located.keyframes)
|
||||||
|
: located.keyframes.filter((k) => nearWindows(k.tMs, plan.windows));
|
||||||
|
if (keyframes.length === 0) continue;
|
||||||
|
const check: PrivacyCheck = {
|
||||||
|
kind: "mark",
|
||||||
|
id: mark.id,
|
||||||
|
personId: mark.personId,
|
||||||
|
markId: mark.id,
|
||||||
|
keyframes,
|
||||||
|
maskDir: located.maskDir,
|
||||||
|
};
|
||||||
|
const cover = () => {
|
||||||
|
plan.instructions.push({
|
||||||
|
keyframes,
|
||||||
|
mode: "cover",
|
||||||
|
paddingPct: MARK_PADDING_PCT,
|
||||||
|
featherPct: 0,
|
||||||
|
maskDir: located.maskDir,
|
||||||
|
...(colors.fill ? { color: colors.fill } : {}),
|
||||||
|
});
|
||||||
|
plan.checks.push(check);
|
||||||
|
};
|
||||||
|
if (treatment === "blur") {
|
||||||
|
plan.instructions.push({
|
||||||
|
keyframes,
|
||||||
|
mode: "blur",
|
||||||
|
paddingPct: MARK_PADDING_PCT,
|
||||||
|
featherPct: 0,
|
||||||
|
maskDir: located.maskDir,
|
||||||
|
});
|
||||||
|
plan.checks.push(check);
|
||||||
|
} else if (treatment === "cover") {
|
||||||
|
cover();
|
||||||
|
} else if (treatment === "reframe_out") {
|
||||||
|
plan.reframe.push({
|
||||||
|
check,
|
||||||
|
boxes: keyframes.map((k) => ({ tMs: k.tMs, box: padBox(k.box, MARK_PADDING_PCT) })),
|
||||||
|
fallback: () => {
|
||||||
|
fallbacks.push(`mark ${mark.id}: no crop keeps it out of frame; covered instead`);
|
||||||
|
cover();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (operationAvailable(capabilities, "inpaintVideo")) {
|
||||||
|
plan.inpaint.push({ markId: mark.id, maskDir: located.maskDir, keyframes });
|
||||||
|
plan.checks.push(check);
|
||||||
|
} else {
|
||||||
|
fallbacks.push(`mark ${mark.id}: inpainting is unavailable; covered instead`);
|
||||||
|
cover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Regions drawn in the EDL.
|
||||||
|
for (const region of edl.redactions.regions) {
|
||||||
|
const clip = edl.tracks.video.find((candidate) => candidate.id === region.clipId);
|
||||||
|
const plan = plans.find((candidate) => candidate.asset.id === clip?.assetId);
|
||||||
|
if (!clip || !plan) throw new PrivacyUnavailableError(`region ${region.id} has no clip`);
|
||||||
|
const keyframes = plan.probe.still ? stillKeyframes(region.keyframes) : region.keyframes;
|
||||||
|
const check: PrivacyCheck = {
|
||||||
|
kind: "region",
|
||||||
|
id: region.id,
|
||||||
|
personId: null,
|
||||||
|
markId: null,
|
||||||
|
keyframes,
|
||||||
|
maskDir: null,
|
||||||
|
};
|
||||||
|
const apply = (mode: "blur" | "cover") => {
|
||||||
|
plan.instructions.push({
|
||||||
|
keyframes,
|
||||||
|
mode,
|
||||||
|
paddingPct: 0,
|
||||||
|
featherPct: 0,
|
||||||
|
...(mode === "cover" && colors.fill ? { color: colors.fill } : {}),
|
||||||
|
});
|
||||||
|
plan.checks.push(check);
|
||||||
|
};
|
||||||
|
if (region.treatment === "reframe_out") {
|
||||||
|
plan.reframe.push({
|
||||||
|
check,
|
||||||
|
boxes: keyframes,
|
||||||
|
fallback: () => {
|
||||||
|
fallbacks.push(`region ${region.id}: no crop keeps it out of frame; covered instead`);
|
||||||
|
apply("cover");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
apply(region.treatment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Apply per asset.
|
||||||
|
const sources = new Map<string, CompileSource>();
|
||||||
|
for (const plan of plans) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const crop = chooseCrop(plan, edl);
|
||||||
|
const hasWork =
|
||||||
|
plan.synthetic.length > 0 ||
|
||||||
|
plan.inpaint.length > 0 ||
|
||||||
|
plan.instructions.length > 0 ||
|
||||||
|
crop !== null;
|
||||||
|
if (!hasWork) {
|
||||||
|
sources.set(plan.asset.id, compileSource(plan.base, plan.probe, plan.probe.rotation));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const prepared = await applyPlan(deps, plan, crop, {
|
||||||
|
capabilities,
|
||||||
|
alterations,
|
||||||
|
fallbacks,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const probed = await probeSource(prepared, plan.asset.kind, { ...deps, signal });
|
||||||
|
verifyPrepared(plan, crop, probed);
|
||||||
|
sources.set(plan.asset.id, compileSource(prepared, probed, 0));
|
||||||
|
record.assets[plan.asset.id] = {
|
||||||
|
basePath: plan.base,
|
||||||
|
preparedPath: prepared,
|
||||||
|
width: plan.probe.width,
|
||||||
|
height: plan.probe.height,
|
||||||
|
still: plan.probe.still,
|
||||||
|
crop,
|
||||||
|
checks: plan.checks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
record.aiAlterations = [...alterations].sort();
|
||||||
|
await writePrivacyRecord(workDir, record);
|
||||||
|
return { sources, aiAlterations: record.aiAlterations, fallbacks };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Faces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fresh tracks take over stored ones they overlap (keeping the stored id and
|
||||||
|
* person); unmatched fresh tracks are matched to people by embedding; stored
|
||||||
|
* tracks the fresh pass missed stay (fail closed).
|
||||||
|
*/
|
||||||
|
export function mergeTracks(
|
||||||
|
assetId: string,
|
||||||
|
stored: CandidateTrack[],
|
||||||
|
fresh: Array<{
|
||||||
|
keyframes: Array<BoxKeyframe & { confidence: number }>;
|
||||||
|
embedding: number[] | null;
|
||||||
|
}>,
|
||||||
|
centroids: Array<{ personId: string; centroid: Uint8Array }>,
|
||||||
|
): CandidateTrack[] {
|
||||||
|
const taken = new Set<string>();
|
||||||
|
const merged: CandidateTrack[] = [];
|
||||||
|
fresh.forEach((track, index) => {
|
||||||
|
let best: { candidate: CandidateTrack; score: number } | null = null;
|
||||||
|
for (const candidate of stored) {
|
||||||
|
if (taken.has(candidate.id)) continue;
|
||||||
|
const score = trackOverlap(track.keyframes, candidate.keyframes);
|
||||||
|
if (score >= TRACK_MATCH_MIN_IOU && (!best || score > best.score))
|
||||||
|
best = { candidate, score };
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
taken.add(best.candidate.id);
|
||||||
|
merged.push({
|
||||||
|
...best.candidate,
|
||||||
|
personId: best.candidate.personId ?? matchPerson(track.embedding, centroids),
|
||||||
|
keyframes: [...track.keyframes, ...best.candidate.keyframes].sort((a, b) => a.tMs - b.tMs),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
merged.push({
|
||||||
|
id: `fresh-${assetId}-${index + 1}`,
|
||||||
|
storedId: null,
|
||||||
|
personId: matchPerson(track.embedding, centroids),
|
||||||
|
keyframes: track.keyframes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (const candidate of stored) if (!taken.has(candidate.id)) merged.push(candidate);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearWindows(tMs: number, windows: TimeWindow[]): boolean {
|
||||||
|
return windows.some(
|
||||||
|
(w) => tMs >= w.startMs - MAX_TRACKING_GAP_MS && tMs <= w.endMs + MAX_TRACKING_GAP_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spanOf(track: CandidateTrack): TimeWindow | null {
|
||||||
|
if (track.keyframes.length === 0) return null;
|
||||||
|
const times = track.keyframes.map((k) => k.tMs);
|
||||||
|
return { startMs: Math.min(...times), endMs: Math.max(...times) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleIn(track: CandidateTrack, plan: AssetPlan): boolean {
|
||||||
|
if (plan.probe.still) return track.keyframes.length > 0;
|
||||||
|
const span = spanOf(track);
|
||||||
|
return span !== null && overlapsWindows(span, plan.windows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A still shows one moment: every box at once, held over the whole image. */
|
||||||
|
function stillKeyframes(keyframes: BoxKeyframe[]): BoxKeyframe[] {
|
||||||
|
const first = keyframes[0];
|
||||||
|
if (!first) return [];
|
||||||
|
const box = keyframes.reduce((acc, k) => unionBox(acc, k.box), first.box);
|
||||||
|
return [
|
||||||
|
{ tMs: 0, box },
|
||||||
|
{ tMs: 1000, box },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable per-person seed for a synthetic identity (stored on first use). */
|
||||||
|
export function syntheticSeedFor(id: string): number {
|
||||||
|
return createHash("sha256").update(`synthetic:${id}`).digest().readInt32LE(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function planFace(
|
||||||
|
deps: AnonymizeDeps,
|
||||||
|
plan: AssetPlan,
|
||||||
|
input: {
|
||||||
|
track: CandidateTrack;
|
||||||
|
protection: Protection;
|
||||||
|
keyframes: BoxKeyframe[];
|
||||||
|
person: PersonRow | null;
|
||||||
|
syntheticReady: boolean;
|
||||||
|
capabilities: MediaCapabilities | null;
|
||||||
|
colors: { fill?: string; sticker?: string };
|
||||||
|
fallbacks: string[];
|
||||||
|
record: PrivacyRecord;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const { track, protection, keyframes } = input;
|
||||||
|
const who = track.personId ? `person ${track.personId}` : `face ${track.id}`;
|
||||||
|
let mode = protection.mode;
|
||||||
|
const padded = keyframes.map((k) => ({ tMs: k.tMs, box: padBox(k.box, protection.paddingPct) }));
|
||||||
|
const check: PrivacyCheck = {
|
||||||
|
kind: "face",
|
||||||
|
id: track.id,
|
||||||
|
personId: track.personId,
|
||||||
|
markId: null,
|
||||||
|
keyframes: padded,
|
||||||
|
maskDir: null,
|
||||||
|
};
|
||||||
|
const span = spanOf({ ...track, keyframes });
|
||||||
|
if (mode === "synthetic_identity") {
|
||||||
|
if (input.syntheticReady) {
|
||||||
|
let seed: number;
|
||||||
|
if (input.person) {
|
||||||
|
seed = input.person.syntheticSeed ?? syntheticSeedFor(input.person.id);
|
||||||
|
if (input.person.syntheticSeed === null) {
|
||||||
|
await deps.repos.updatePerson(plan.asset.spaceId, input.person.id, {
|
||||||
|
syntheticSeed: seed,
|
||||||
|
});
|
||||||
|
input.person.syntheticSeed = seed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seed = syntheticSeedFor(track.id);
|
||||||
|
}
|
||||||
|
plan.synthetic.push({ personId: track.personId, keyframes, seed });
|
||||||
|
plan.checks.push(check);
|
||||||
|
if (span) {
|
||||||
|
input.record.expectedFaces.push({
|
||||||
|
assetId: plan.asset.id,
|
||||||
|
personId: track.personId,
|
||||||
|
mode,
|
||||||
|
...span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.fallbacks.push(`${who}: synthetic identity is not available; blurred instead`);
|
||||||
|
mode = "blur";
|
||||||
|
}
|
||||||
|
if (mode === "reframe_out") {
|
||||||
|
plan.reframe.push({
|
||||||
|
check,
|
||||||
|
boxes: padded,
|
||||||
|
fallback: () => {
|
||||||
|
input.fallbacks.push(`${who}: no crop keeps the face out of frame; blurred instead`);
|
||||||
|
plan.instructions.push({
|
||||||
|
keyframes,
|
||||||
|
mode: "blur",
|
||||||
|
paddingPct: protection.paddingPct,
|
||||||
|
featherPct: protection.featherPct,
|
||||||
|
});
|
||||||
|
plan.checks.push(check);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const instruction: RedactionInstruction = {
|
||||||
|
keyframes,
|
||||||
|
mode,
|
||||||
|
paddingPct: protection.paddingPct,
|
||||||
|
featherPct: protection.featherPct,
|
||||||
|
};
|
||||||
|
if (mode === "solid" && input.colors.fill) instruction.color = input.colors.fill;
|
||||||
|
if (mode === "sticker" && input.colors.sticker) instruction.color = input.colors.sticker;
|
||||||
|
if (isPartial(mode)) {
|
||||||
|
const partial = mode;
|
||||||
|
const landmarks = await landmarksFor(deps, plan, keyframes, input.capabilities, input.signal);
|
||||||
|
if (landmarks.length > 0) instruction.landmarks = landmarks;
|
||||||
|
check.keyframes = padded.map((k) => ({ tMs: k.tMs, box: partialCheckBand(k.box, partial) }));
|
||||||
|
if (span) {
|
||||||
|
input.record.expectedFaces.push({
|
||||||
|
assetId: plan.asset.id,
|
||||||
|
personId: track.personId,
|
||||||
|
mode,
|
||||||
|
...span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan.instructions.push(instruction);
|
||||||
|
plan.checks.push(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Five-point landmarks per sampled keyframe for partial modes, when the provider has them. */
|
||||||
|
async function landmarksFor(
|
||||||
|
deps: AnonymizeDeps,
|
||||||
|
plan: AssetPlan,
|
||||||
|
keyframes: BoxKeyframe[],
|
||||||
|
capabilities: MediaCapabilities | null,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Array<{ tMs: number; points: NormalizedPoint[] }>> {
|
||||||
|
if (!deps.inference || !operationAvailable(capabilities, "faceLandmarks")) return [];
|
||||||
|
const step = Math.max(1, Math.ceil(keyframes.length / MAX_LANDMARK_SAMPLES));
|
||||||
|
const timesMs = keyframes.filter((_, i) => i % step === 0).map((k) => k.tMs);
|
||||||
|
const file =
|
||||||
|
(await analysisFile(deps, plan.asset.spaceId, plan.asset)) ?? (await workingCopy(deps, plan));
|
||||||
|
const result = await deps.inference.faceLandmarks({ path: file, timesMs, signal });
|
||||||
|
const out: Array<{ tMs: number; points: NormalizedPoint[] }> = [];
|
||||||
|
for (const frame of result.frames) {
|
||||||
|
const expected = boxAt(keyframes, frame.tMs);
|
||||||
|
if (!expected) continue;
|
||||||
|
const best = frame.faces
|
||||||
|
.map((face) => ({ face, score: iou(face.box, expected) }))
|
||||||
|
.sort((a, b) => b.score - a.score)[0];
|
||||||
|
if (best && best.score > 0.2) out.push({ tMs: frame.tMs, points: best.face.landmarks });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Marks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type MarkRow = Awaited<ReturnType<MediaRepos["marksForAsset"]>>[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask and keyframes of a mark in this asset. Marks drawn on the asset use
|
||||||
|
* their stored mask (segmenting now when none exists); standing person marks
|
||||||
|
* are re-prompted at the same place relative to the person's face. Low
|
||||||
|
* segmentation confidence sends the mark to review.
|
||||||
|
*/
|
||||||
|
async function locateMark(
|
||||||
|
deps: AnonymizeDeps,
|
||||||
|
plan: AssetPlan,
|
||||||
|
mark: MarkRow,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ maskDir: string; keyframes: BoxKeyframe[]; score: number; review: boolean } | null> {
|
||||||
|
const outDir = path.join(plan.dir, `mark-${mark.id}`);
|
||||||
|
if (mark.assetId === plan.asset.id) {
|
||||||
|
if (mark.maskDir) {
|
||||||
|
const maskDir = await resolveStored(deps.mediaRoot, mark.maskDir);
|
||||||
|
return { maskDir, keyframes: await maskKeyframes(maskDir), score: 1, review: false };
|
||||||
|
}
|
||||||
|
if (!mark.prompt) throw new PrivacyUnavailableError(`mark ${mark.id} has no prompt`);
|
||||||
|
const file =
|
||||||
|
(await analysisFile(deps, plan.asset.spaceId, plan.asset)) ?? (await workingCopy(deps, plan));
|
||||||
|
const segmented = await segmentMark(deps, { file, prompt: mark.prompt, outDir, signal });
|
||||||
|
return {
|
||||||
|
maskDir: segmented.maskDir,
|
||||||
|
keyframes: segmented.keyframes,
|
||||||
|
score: segmented.score,
|
||||||
|
review: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// A standing mark of a person seen in this asset.
|
||||||
|
if (!mark.personId) return null;
|
||||||
|
const tracks = plan.candidates.filter((track) => track.personId === mark.personId);
|
||||||
|
if (tracks.length === 0) return null;
|
||||||
|
const anchorFace = await originFace(deps, plan.asset.spaceId, mark);
|
||||||
|
const target = tracks
|
||||||
|
.map((track) =>
|
||||||
|
firstConfident(track.keyframes, Math.min(...plan.windows.map((w) => w.startMs))),
|
||||||
|
)
|
||||||
|
.filter((k) => k !== null)
|
||||||
|
.sort((a, b) => a.tMs - b.tMs)[0];
|
||||||
|
const placed =
|
||||||
|
anchorFace && target && mark.prompt
|
||||||
|
? placeAnchor(anchorFromFace(promptBox(mark.prompt), anchorFace), target.box)
|
||||||
|
: null;
|
||||||
|
if (!placed || !target) {
|
||||||
|
// Cannot relate the mark to the face here: cover the person's face region and review.
|
||||||
|
const keyframes = tracks.flatMap((track) =>
|
||||||
|
track.keyframes.map((k) => ({ tMs: k.tMs, box: k.box })),
|
||||||
|
);
|
||||||
|
return { maskDir: await emptyMaskDir(outDir), keyframes, score: 0, review: true };
|
||||||
|
}
|
||||||
|
const file =
|
||||||
|
(await analysisFile(deps, plan.asset.spaceId, plan.asset)) ?? (await workingCopy(deps, plan));
|
||||||
|
const segmented = await segmentMark(deps, {
|
||||||
|
file,
|
||||||
|
prompt: { tMs: target.tMs, box: placed },
|
||||||
|
outDir,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
maskDir: segmented.maskDir,
|
||||||
|
keyframes: segmented.keyframes,
|
||||||
|
score: segmented.score,
|
||||||
|
review: segmented.score < MARK_REVIEW_MIN_SCORE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The person's face in the frame a standing mark was drawn on. */
|
||||||
|
async function originFace(
|
||||||
|
deps: AnonymizeDeps,
|
||||||
|
spaceId: string,
|
||||||
|
mark: MarkRow,
|
||||||
|
): Promise<NormalizedBox | null> {
|
||||||
|
if (!mark.assetId || !mark.prompt || !mark.personId) return null;
|
||||||
|
const tracks = (await deps.repos.faceTrackRecords(spaceId, [mark.assetId])).filter(
|
||||||
|
(t) => t.personId === mark.personId,
|
||||||
|
);
|
||||||
|
for (const track of tracks) {
|
||||||
|
const face = faceAt(
|
||||||
|
await readTrackKeyframes(deps.mediaRoot, track.keyframesPath),
|
||||||
|
mark.prompt.tMs,
|
||||||
|
);
|
||||||
|
if (face) return face;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emptyMaskDir(dir: string): Promise<string> {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Applying
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** In-root copy of the base file for providers (once per asset). */
|
||||||
|
async function workingCopy(deps: AnonymizeDeps, plan: AssetPlan): Promise<string> {
|
||||||
|
if (!(await isInsideRoot(deps.mediaRoot, plan.base)))
|
||||||
|
plan.base = await copyInto(plan.base, plan.dir);
|
||||||
|
return plan.base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static crop that keeps every reframe_out box out of frame; applies fallbacks otherwise. */
|
||||||
|
function chooseCrop(plan: AssetPlan, edl: Edl): PixelBox | null {
|
||||||
|
if (plan.reframe.length === 0) return null;
|
||||||
|
const boxes = bucketBoxes(plan.reframe.flatMap((item) => item.boxes));
|
||||||
|
const geometry = {
|
||||||
|
sourceWidth: plan.probe.width,
|
||||||
|
sourceHeight: plan.probe.height,
|
||||||
|
outWidth: edl.output.width,
|
||||||
|
outHeight: edl.output.height,
|
||||||
|
durationMs: plan.probe.durationMs,
|
||||||
|
};
|
||||||
|
const keyframe = reframeExcluding(boxes, geometry)?.[0];
|
||||||
|
const crop = keyframe ? cropPixels(geometry, keyframe) : null;
|
||||||
|
if (
|
||||||
|
crop &&
|
||||||
|
boxes.every(({ box }) => outsideCrop(box, crop, plan.probe.width, plan.probe.height))
|
||||||
|
) {
|
||||||
|
for (const item of plan.reframe) plan.checks.push(item.check);
|
||||||
|
return crop;
|
||||||
|
}
|
||||||
|
for (const item of plan.reframe) item.fallback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union boxes per second so the crop search stays small; unions only widen the excluded area. */
|
||||||
|
function bucketBoxes(boxes: Array<{ tMs: number; box: NormalizedBox }>) {
|
||||||
|
const buckets = new Map<number, NormalizedBox>();
|
||||||
|
for (const { tMs, box } of boxes) {
|
||||||
|
const key = Math.floor(tMs / REFRAME_BUCKET_MS);
|
||||||
|
const current = buckets.get(key);
|
||||||
|
buckets.set(key, current ? unionBox(current, box) : box);
|
||||||
|
}
|
||||||
|
return [...buckets.entries()].map(([key, box]) => ({ tMs: key * REFRAME_BUCKET_MS, box }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepPath(plan: AssetPlan, file: string, step: string, ext = ".mp4"): string {
|
||||||
|
const stem = path
|
||||||
|
.basename(file, path.extname(file))
|
||||||
|
.replace(/\.(?:synth|inpaint|redacted|cropped)[-\w]*$/, "");
|
||||||
|
return path.join(plan.dir, `${stem}.${step}${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlan(
|
||||||
|
deps: AnonymizeDeps,
|
||||||
|
plan: AssetPlan,
|
||||||
|
crop: PixelBox | null,
|
||||||
|
context: {
|
||||||
|
capabilities: MediaCapabilities | null;
|
||||||
|
alterations: Set<AiAlteration>;
|
||||||
|
fallbacks: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
const { signal } = context;
|
||||||
|
await mkdir(plan.dir, { recursive: true });
|
||||||
|
const needsProvider = plan.synthetic.length + plan.inpaint.length + plan.instructions.length > 0;
|
||||||
|
if (needsProvider && !deps.inference) {
|
||||||
|
throw new PrivacyUnavailableError("redaction needs the media worker, which is not configured");
|
||||||
|
}
|
||||||
|
let current = needsProvider ? await workingCopy(deps, plan) : plan.base;
|
||||||
|
const inference = deps.inference;
|
||||||
|
if (inference) {
|
||||||
|
for (const [index, synthetic] of plan.synthetic.entries()) {
|
||||||
|
const outPath = stepPath(plan, current, `synth-${index + 1}`);
|
||||||
|
current = (
|
||||||
|
await inference.synthesizeIdentity({
|
||||||
|
path: current,
|
||||||
|
track: synthetic.keyframes,
|
||||||
|
seed: synthetic.seed,
|
||||||
|
outPath,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
).outPath;
|
||||||
|
context.alterations.add("synthetic_identity");
|
||||||
|
}
|
||||||
|
for (const mark of plan.inpaint) {
|
||||||
|
const outPath = stepPath(plan, current, `inpaint-${mark.markId}`);
|
||||||
|
const result = await inference.inpaintVideo({
|
||||||
|
path: current,
|
||||||
|
maskDir: mark.maskDir,
|
||||||
|
outPath,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (result.quality.score >= MARK_QUALITY_MIN) {
|
||||||
|
current = result.outPath;
|
||||||
|
context.alterations.add("inpaint");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Never ship artifacts silently: low quality falls back to a cover.
|
||||||
|
context.fallbacks.push(
|
||||||
|
`mark ${mark.markId}: inpainting scored ${result.quality.score.toFixed(2)}; covered instead`,
|
||||||
|
);
|
||||||
|
await rm(result.outPath, { force: true });
|
||||||
|
plan.instructions.push({
|
||||||
|
keyframes: mark.keyframes,
|
||||||
|
mode: "cover",
|
||||||
|
paddingPct: MARK_PADDING_PCT,
|
||||||
|
featherPct: 0,
|
||||||
|
maskDir: mark.maskDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (plan.instructions.length > 0) {
|
||||||
|
if (!operationAvailable(context.capabilities, "redact")) {
|
||||||
|
throw new PrivacyUnavailableError("the media worker cannot redact");
|
||||||
|
}
|
||||||
|
const outPath = stepPath(plan, current, "redacted");
|
||||||
|
const result = await inference.redact({
|
||||||
|
path: current,
|
||||||
|
redactions: plan.instructions,
|
||||||
|
outPath,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (result.framesProcessed <= 0)
|
||||||
|
throw new PrivacyUnavailableError("redaction processed no frames");
|
||||||
|
current = result.outPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (crop) {
|
||||||
|
const outPath = stepPath(plan, current, "cropped", plan.probe.still ? ".png" : ".mp4");
|
||||||
|
await cropFile(current, outPath, crop, plan.probe.still, { ...deps, signal });
|
||||||
|
current = outPath;
|
||||||
|
}
|
||||||
|
if (plan.probe.still && path.extname(current).toLowerCase() !== ".png" && current !== plan.base) {
|
||||||
|
const outPath = stepPath(plan, current, "still", ".png");
|
||||||
|
await firstFrame(current, outPath, { ...deps, signal });
|
||||||
|
current = outPath;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prepared file must show the whole clip range at the expected size. */
|
||||||
|
function verifyPrepared(plan: AssetPlan, crop: PixelBox | null, probed: ProbedSource): void {
|
||||||
|
const width = crop ? crop.w : plan.probe.width;
|
||||||
|
const height = crop ? crop.h : plan.probe.height;
|
||||||
|
if (probed.width !== width || probed.height !== height) {
|
||||||
|
throw new PrivacyUnavailableError(
|
||||||
|
`redacted asset ${plan.asset.id} is ${probed.width}x${probed.height}, expected ${width}x${height}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (plan.probe.still) return;
|
||||||
|
const neededMs = Math.max(...plan.clips.map((clip) => clip.outMs));
|
||||||
|
const frameMs = 1000 / Math.max(1, plan.probe.fps);
|
||||||
|
if (probed.durationMs + 2 * frameMs < Math.min(neededMs, plan.probe.durationMs)) {
|
||||||
|
throw new PrivacyUnavailableError(`redacted asset ${plan.asset.id} is shorter than its clips`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileSource(
|
||||||
|
file: string,
|
||||||
|
probe: ProbedSource,
|
||||||
|
rotation: CompileSource["rotation"],
|
||||||
|
): CompileSource {
|
||||||
|
const quarter = rotation === 90 || rotation === 270;
|
||||||
|
return {
|
||||||
|
path: file,
|
||||||
|
// CompileSource wants the coded size; probe reports the display size.
|
||||||
|
width: quarter ? probe.height : probe.width,
|
||||||
|
height: quarter ? probe.width : probe.height,
|
||||||
|
durationMs: probe.durationMs,
|
||||||
|
fps: probe.fps,
|
||||||
|
hasAudio: probe.hasAudio,
|
||||||
|
rotation,
|
||||||
|
still: probe.still,
|
||||||
|
};
|
||||||
|
}
|
||||||
97
packages/adapters/src/media-consent.test.ts
Normal file
97
packages/adapters/src/media-consent.test.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assertExportConsent,
|
||||||
|
ConsentRequiredError,
|
||||||
|
consentIssues,
|
||||||
|
consentRequirements,
|
||||||
|
enforceConsentFlags,
|
||||||
|
participantsOf,
|
||||||
|
} from "./media-consent.js";
|
||||||
|
import type { PrivacyHarness } from "./media-privacy-fixtures.js";
|
||||||
|
import { ACTOR, createPrivacyHarness, testEdl, testProfile } from "./media-privacy-fixtures.js";
|
||||||
|
|
||||||
|
const now = new Date("2026-06-01T12:00:00Z");
|
||||||
|
const past = new Date("2026-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
let harness: PrivacyHarness | null = null;
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
harness = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consent rules", () => {
|
||||||
|
it("always requires consent and age proof for paid video", () => {
|
||||||
|
expect(consentRequirements(testProfile())).toEqual({ consent: false, age: false });
|
||||||
|
expect(consentRequirements(testProfile({ kind: "paid_video" }))).toEqual({
|
||||||
|
consent: true,
|
||||||
|
age: true,
|
||||||
|
});
|
||||||
|
expect(enforceConsentFlags(testProfile({ kind: "paid_video" })).consent).toEqual({
|
||||||
|
requirePerformerConsent: true,
|
||||||
|
requireAgeVerification: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only recorded, past proof with a verification method", () => {
|
||||||
|
const paid = testProfile({ kind: "paid_video" });
|
||||||
|
const ok = {
|
||||||
|
id: "a",
|
||||||
|
consentRecordedAt: past,
|
||||||
|
ageVerifiedAt: past,
|
||||||
|
ageVerificationMethod: "platform_verified",
|
||||||
|
};
|
||||||
|
expect(consentIssues(paid, [ok], [], now)).toEqual([]);
|
||||||
|
const future = { ...ok, consentRecordedAt: new Date("2027-01-01T00:00:00Z") };
|
||||||
|
const noMethod = { ...ok, id: "b", ageVerificationMethod: "none" };
|
||||||
|
expect(consentIssues(paid, [future, noMethod], [{ atMs: 10 }], now).map((i) => i.code)).toEqual(
|
||||||
|
["consent_missing", "age_unverified", "unknown_performer"],
|
||||||
|
);
|
||||||
|
expect(consentIssues(testProfile(), [noMethod], [{}], now)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects the people and unassigned faces inside the clip ranges", () => {
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: "a1", inMs: 1000, outMs: 2000 }]);
|
||||||
|
expect(
|
||||||
|
participantsOf(edl, [
|
||||||
|
{ assetId: "a1", personId: "alice", startMs: 0, endMs: 1200 },
|
||||||
|
{ assetId: "a1", personId: "bob", startMs: 2500, endMs: 3000 },
|
||||||
|
{ assetId: "a1", personId: null, startMs: 1500, endMs: 1600 },
|
||||||
|
{ assetId: "a2", personId: "carol", startMs: 0, endMs: 5000 },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
personIds: ["alice"],
|
||||||
|
unassigned: [{ assetId: "a1", startMs: 1500, endMs: 1600 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export consent", () => {
|
||||||
|
it("blocks a paid export until every participant has consent and age proof", async () => {
|
||||||
|
harness = await createPrivacyHarness(() => null);
|
||||||
|
const alice = harness.addPerson("alice", "none");
|
||||||
|
harness.store.tracks.push({
|
||||||
|
id: "t1",
|
||||||
|
assetId: "a1",
|
||||||
|
personId: "alice",
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 1000,
|
||||||
|
confidenceMin: 0.9,
|
||||||
|
keyframesPath: "x.json",
|
||||||
|
modelVersion: "v1",
|
||||||
|
});
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: "a1", inMs: 0, outMs: 1000 }]);
|
||||||
|
const paid = testProfile({ kind: "paid_video" });
|
||||||
|
await expect(assertExportConsent(harness.deps, ACTOR, { edl, profile: paid })).rejects.toThrow(
|
||||||
|
ConsentRequiredError,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
assertExportConsent(harness.deps, ACTOR, { edl, profile: testProfile() }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
alice.consentRecordedAt = past;
|
||||||
|
alice.ageVerifiedAt = past;
|
||||||
|
alice.ageVerificationMethod = "document_on_file";
|
||||||
|
await expect(
|
||||||
|
assertExportConsent(harness.deps, ACTOR, { edl, profile: paid }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
161
packages/adapters/src/media-consent.ts
Normal file
161
packages/adapters/src/media-consent.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
188
packages/adapters/src/media-gate-autofix.test.ts
Normal file
188
packages/adapters/src/media-gate-autofix.test.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import type { Edl, GateReport } from "@rakazo/contracts";
|
||||||
|
import { MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { autoFixRender, nonCompliantChange, proposeAutoFix } from "./media-gate-autofix.js";
|
||||||
|
import type { PrivacyHarness } from "./media-privacy-fixtures.js";
|
||||||
|
import { ACTOR, createPrivacyHarness, testEdl } from "./media-privacy-fixtures.js";
|
||||||
|
|
||||||
|
let harness: PrivacyHarness | null = null;
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
harness = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function edlWithFace(): Edl {
|
||||||
|
return testEdl(
|
||||||
|
[
|
||||||
|
{ id: "c1", assetId: "a1", inMs: 0, outMs: 4000 },
|
||||||
|
{ id: "c2", assetId: "a2", inMs: 0, outMs: 4000 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
redactions: {
|
||||||
|
faces: [
|
||||||
|
{
|
||||||
|
id: "f1",
|
||||||
|
target: { personId: "alice" },
|
||||||
|
mode: "blur",
|
||||||
|
paddingPct: 20,
|
||||||
|
featherPct: 10,
|
||||||
|
minSizePct: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
marks: [{ markId: "m1", treatment: "inpaint" }],
|
||||||
|
regions: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocked(issues: GateReport["issues"], round = 0): GateReport {
|
||||||
|
return {
|
||||||
|
profileId: "test-profile",
|
||||||
|
passed: false,
|
||||||
|
checkedAt: "2026-06-01T12:00:00.000Z",
|
||||||
|
sampledFrames: 10,
|
||||||
|
issues,
|
||||||
|
autoFixRound: round,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHit = {
|
||||||
|
check: "content" as const,
|
||||||
|
severity: "block" as const,
|
||||||
|
code: "content_threshold",
|
||||||
|
message: "explicit_nudity 0.90 above 0.5",
|
||||||
|
atMs: 7000,
|
||||||
|
endMs: 7500,
|
||||||
|
category: "explicit_nudity" as const,
|
||||||
|
score: 0.9,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("proposeAutoFix", () => {
|
||||||
|
it("only widens protection, covers marks, and cuts offending footage", () => {
|
||||||
|
const edl = edlWithFace();
|
||||||
|
const proposal = proposeAutoFix({
|
||||||
|
edl,
|
||||||
|
report: blocked([
|
||||||
|
contentHit,
|
||||||
|
{
|
||||||
|
check: "anonymization",
|
||||||
|
severity: "block",
|
||||||
|
code: "face_not_covered",
|
||||||
|
message: "face covered 80 %",
|
||||||
|
atMs: 100,
|
||||||
|
personId: "alice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check: "anonymization",
|
||||||
|
severity: "block",
|
||||||
|
code: "mark_visible",
|
||||||
|
message: "visible",
|
||||||
|
atMs: 200,
|
||||||
|
markId: "m1",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
round: 0,
|
||||||
|
alternatives: [],
|
||||||
|
});
|
||||||
|
expect("edl" in proposal).toBe(true);
|
||||||
|
if (!("edl" in proposal)) return;
|
||||||
|
expect(proposal.edl.redactions.faces[0]!.paddingPct).toBe(45);
|
||||||
|
expect(proposal.edl.redactions.marks[0]!.treatment).toBe("cover");
|
||||||
|
expect(proposal.edl.tracks.video.find((c) => c.id === "c2")!.outMs).toBe(2500);
|
||||||
|
expect(proposal.changes.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(nonCompliantChange(edl, proposal.edl)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after the round limit", () => {
|
||||||
|
const proposal = proposeAutoFix({
|
||||||
|
edl: edlWithFace(),
|
||||||
|
report: blocked([contentHit], MAX_AUTO_FIX_ROUNDS),
|
||||||
|
round: MAX_AUTO_FIX_ROUNDS,
|
||||||
|
alternatives: [],
|
||||||
|
});
|
||||||
|
expect(proposal).toEqual({ reason: "round_limit", changes: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects any change that weakens protection or adds content", () => {
|
||||||
|
const edl = edlWithFace();
|
||||||
|
const weaker = structuredClone(edl);
|
||||||
|
weaker.redactions.faces[0]!.paddingPct = 5;
|
||||||
|
expect(nonCompliantChange(edl, weaker)).toMatch(/shrank/);
|
||||||
|
const removed = structuredClone(edl);
|
||||||
|
removed.redactions.faces = [];
|
||||||
|
expect(nonCompliantChange(edl, removed)).toMatch(/removed/);
|
||||||
|
const unmasked = structuredClone(edl);
|
||||||
|
unmasked.redactions.marks[0]!.treatment = "blur";
|
||||||
|
expect(nonCompliantChange(edl, unmasked)).toMatch(/changed to blur/);
|
||||||
|
const grown = structuredClone(edl);
|
||||||
|
grown.tracks.video[0]!.outMs = 9000;
|
||||||
|
expect(nonCompliantChange(edl, grown)).toMatch(/grew/);
|
||||||
|
const overlay = structuredClone(edl);
|
||||||
|
overlay.overlays.push({ id: "o1", kind: "text", atMs: 0, durationMs: 100, text: "x" });
|
||||||
|
expect(nonCompliantChange(edl, overlay)).toMatch(/overlays/);
|
||||||
|
const swapped = structuredClone(edl);
|
||||||
|
swapped.redactions.faces[0]!.mode = "partial_eyes";
|
||||||
|
expect(nonCompliantChange(edl, swapped)).toMatch(/mode/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("autoFixRender", () => {
|
||||||
|
async function blockedRender(round: number) {
|
||||||
|
harness = await createPrivacyHarness(() => null);
|
||||||
|
const edl = edlWithFace();
|
||||||
|
harness.store.plans.push({ id: "plan-1", projectId: "project-1", edl });
|
||||||
|
harness.store.renders.push({
|
||||||
|
id: "render-1",
|
||||||
|
planId: "plan-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
quality: "draft",
|
||||||
|
status: "blocked",
|
||||||
|
autoFixRound: round,
|
||||||
|
gateReport: blocked([contentHit], round),
|
||||||
|
});
|
||||||
|
harness.store.renders.push({ id: "render-2", status: "queued", autoFixRound: 0 });
|
||||||
|
const submitPlan = vi.fn(async () => ({
|
||||||
|
ok: true as const,
|
||||||
|
plan: { id: "plan-2" } as never,
|
||||||
|
}));
|
||||||
|
const start = vi.fn(async () => ({ id: "render-2" }) as never);
|
||||||
|
return {
|
||||||
|
services: { deps: harness.deps, marketing: { submitPlan }, render: { start } },
|
||||||
|
submitPlan,
|
||||||
|
start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("submits a new plan with the reasons and renders it as the next round", async () => {
|
||||||
|
const { services, submitPlan, start } = await blockedRender(1);
|
||||||
|
const result = await autoFixRender(services, ACTOR, "render-1");
|
||||||
|
expect(result.status).toBe("rerendering");
|
||||||
|
expect(submitPlan).toHaveBeenCalledWith(
|
||||||
|
ACTOR,
|
||||||
|
expect.objectContaining({
|
||||||
|
projectId: "project-1",
|
||||||
|
createdBy: "agent",
|
||||||
|
rationale: expect.stringMatching(/^Auto-fix round 2 of 3:\n- clip c2: /),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(start).toHaveBeenCalledWith(ACTOR, { planId: "plan-2", quality: "draft" });
|
||||||
|
expect(harness!.store.renders[1]!.autoFixRound).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands over to the user after three rounds", async () => {
|
||||||
|
const { services, submitPlan } = await blockedRender(MAX_AUTO_FIX_ROUNDS);
|
||||||
|
const result = await autoFixRender(services, ACTOR, "render-1");
|
||||||
|
expect(result).toEqual({ status: "user_required", reason: "round_limit", changes: [] });
|
||||||
|
expect(submitPlan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for a render that is not blocked", async () => {
|
||||||
|
const { services } = await blockedRender(0);
|
||||||
|
harness!.store.renders[0]!.status = "done";
|
||||||
|
expect(await autoFixRender(services, ACTOR, "render-1")).toMatchObject({
|
||||||
|
status: "user_required",
|
||||||
|
reason: "not_blocked",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
193
packages/adapters/src/media-gate-autofix.ts
Normal file
193
packages/adapters/src/media-gate-autofix.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
250
packages/adapters/src/media-gate.test.ts
Normal file
250
packages/adapters/src/media-gate.test.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { copyFile, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { MediaInferenceProvider, TrackFacesResult } from "@rakazo/adapter-kit";
|
||||||
|
import type { AnonymizeMode, Edl, PlatformProfile } from "@rakazo/contracts";
|
||||||
|
import { decodeVectors } from "@rakazo/media";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { prepareSources } from "./media-anonymize.js";
|
||||||
|
import { ffmpegAvailable } from "./media-emulator-clips.js";
|
||||||
|
import { cutTimes, GATE_PIXEL_STEP_MS, pixelSampleTimes, runGate } from "./media-gate.js";
|
||||||
|
import type { PrivacyHarness } from "./media-privacy-fixtures.js";
|
||||||
|
import {
|
||||||
|
ACTOR,
|
||||||
|
cleanProbe,
|
||||||
|
createPrivacyHarness,
|
||||||
|
testEdl,
|
||||||
|
testProfile,
|
||||||
|
} from "./media-privacy-fixtures.js";
|
||||||
|
import { PRIVACY_RECORD_FILE } from "./media-privacy-plan.js";
|
||||||
|
|
||||||
|
const hasFfmpeg = ffmpegAvailable();
|
||||||
|
let harness: PrivacyHarness | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
harness = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Faces the fake detector reports on the render output. */
|
||||||
|
let outputFaces: TrackFacesResult = { sampleFps: 4, tracks: [] };
|
||||||
|
let classifierAvailable = true;
|
||||||
|
|
||||||
|
function withOutputDetector(emulator: PrivacyHarness["inference"]): MediaInferenceProvider {
|
||||||
|
const provider = Object.create(emulator) as MediaInferenceProvider;
|
||||||
|
provider.trackFaces = async (request) =>
|
||||||
|
path.basename(request.path).startsWith("output") ? outputFaces : emulator.trackFaces(request);
|
||||||
|
provider.capabilities = async () => {
|
||||||
|
const caps = await emulator.capabilities();
|
||||||
|
caps.operations.classifyContent.available = classifierAvailable;
|
||||||
|
return caps;
|
||||||
|
};
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepared(
|
||||||
|
mode: AnonymizeMode,
|
||||||
|
options: { file?: string; unassigned?: boolean } = {},
|
||||||
|
) {
|
||||||
|
outputFaces = { sampleFps: 4, tracks: [] };
|
||||||
|
classifierAvailable = true;
|
||||||
|
harness = await createPrivacyHarness(withOutputDetector);
|
||||||
|
const asset = await harness.addAsset(options.file ?? "clip__faces-2____person-alice__.mp4");
|
||||||
|
harness.addPerson("alice", mode);
|
||||||
|
await harness.analyseFaces(asset.id, ["alice", options.unassigned ? null : "alice"]);
|
||||||
|
await harness.learnPerson("alice", asset.id);
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: asset.id, inMs: 0, outMs: 2000 }]);
|
||||||
|
const workDir = await harness.workDir(`gate-${mode}`);
|
||||||
|
const result = await prepareSources(harness.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
const output = path.join(workDir, "output.mp4");
|
||||||
|
await copyFile(result.sources.get(asset.id)!.path, output);
|
||||||
|
return { h: harness, asset, edl, workDir, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
function gate(
|
||||||
|
h: PrivacyHarness,
|
||||||
|
input: { edl: Edl; output: string },
|
||||||
|
options: {
|
||||||
|
profile?: PlatformProfile;
|
||||||
|
copy?: { title: string | null; caption: string | null; tags: string[] };
|
||||||
|
probe?: Partial<ReturnType<typeof cleanProbe>>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return runGate(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl: input.edl,
|
||||||
|
profile: options.profile ?? testProfile(),
|
||||||
|
outputPath: input.output,
|
||||||
|
ffprobe: { ...cleanProbe(input.edl), ...options.probe },
|
||||||
|
copy: options.copy ?? { title: null, caption: null, tags: [] },
|
||||||
|
bundleHasDisclosure: false,
|
||||||
|
autoFixRound: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = { x: 0.4, y: 0.4, w: 0.2, h: 0.2 };
|
||||||
|
const strangerEmbedding = Array.from({ length: 16 }, (_, i) => (i === 0 ? 1 : 0));
|
||||||
|
|
||||||
|
describe("gate sampling", () => {
|
||||||
|
it("samples each clip range and spreads long ones", () => {
|
||||||
|
const edl = testEdl([
|
||||||
|
{ id: "a", assetId: "x", inMs: 0, outMs: 1000 },
|
||||||
|
{ id: "b", assetId: "x", inMs: 5000, outMs: 6000 },
|
||||||
|
]);
|
||||||
|
expect(cutTimes(edl)).toEqual([0, 999, 1000, 1999]);
|
||||||
|
expect(pixelSampleTimes(edl.tracks.video, false)).toEqual([0, 500, 960, 5000, 5500, 5960]);
|
||||||
|
expect(pixelSampleTimes(edl.tracks.video, true)).toEqual([0]);
|
||||||
|
const long = pixelSampleTimes([{ ...edl.tracks.video[0]!, outMs: 600_000 }], false);
|
||||||
|
expect(long).toHaveLength(40);
|
||||||
|
expect(long[1]! - long[0]!).toBeGreaterThanOrEqual(GATE_PIXEL_STEP_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasFfmpeg)("runGate", () => {
|
||||||
|
it("passes a protected render with a clean classifier", async () => {
|
||||||
|
const { h, edl, output } = await prepared("blur");
|
||||||
|
const report = await gate(h, { edl, output });
|
||||||
|
expect(report.issues).toEqual([]);
|
||||||
|
expect(report.passed).toBe(true);
|
||||||
|
expect(report.sampledFrames).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies the rendered output and blocks above the threshold", async () => {
|
||||||
|
const { h, edl, workDir, output } = await prepared("blur");
|
||||||
|
const flagged = path.join(workDir, "output__nsfw-explicit_nudity-0.9__.mp4");
|
||||||
|
await copyFile(output, flagged);
|
||||||
|
const report = await gate(h, { edl, output: flagged });
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
check: "content",
|
||||||
|
code: "content_threshold",
|
||||||
|
category: "explicit_nudity",
|
||||||
|
score: 0.9,
|
||||||
|
atMs: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when the classifier is unavailable", async () => {
|
||||||
|
const { h, edl, output } = await prepared("blur");
|
||||||
|
classifierAvailable = false;
|
||||||
|
const report = await gate(h, { edl, output });
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ code: "classifier_unavailable", severity: "block" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a recognisable protected face and an unknown face in the output", async () => {
|
||||||
|
const { h, edl, output } = await prepared("blur");
|
||||||
|
const alice = h.store.people[0]!;
|
||||||
|
const embedding = [...decodeVectors(alice.centroid!, 16)[0]!];
|
||||||
|
outputFaces = {
|
||||||
|
sampleFps: 4,
|
||||||
|
tracks: [
|
||||||
|
{ trackId: "o1", keyframes: [{ tMs: 250, box, confidence: 0.9 }], embedding },
|
||||||
|
{
|
||||||
|
trackId: "o2",
|
||||||
|
keyframes: [{ tMs: 750, box, confidence: 0.9 }],
|
||||||
|
embedding: strangerEmbedding,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const report = await gate(h, { edl, output });
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ code: "face_not_covered", personId: "alice", atMs: 250 }),
|
||||||
|
);
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ code: "unknown_face", atMs: 750 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a partially covered face stay detectable without counting it as unknown", async () => {
|
||||||
|
const { h, edl, workDir, output } = await prepared("partial_eyes");
|
||||||
|
expect(path.basename(workDir)).toBe("gate-partial_eyes");
|
||||||
|
outputFaces = {
|
||||||
|
sampleFps: 4,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
trackId: "o1",
|
||||||
|
keyframes: [{ tMs: 250, box, confidence: 0.9 }],
|
||||||
|
embedding: strangerEmbedding,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const report = await gate(h, { edl, output });
|
||||||
|
expect(report.issues.filter((i) => i.check === "anonymization")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires consent and age proof of every participant for paid video", async () => {
|
||||||
|
const { h, edl, output } = await prepared("none");
|
||||||
|
// The profile's own flags are off; paid video turns them on regardless.
|
||||||
|
const paid = testProfile({ kind: "paid_video" });
|
||||||
|
const blocked = await gate(h, { edl, output }, { profile: paid });
|
||||||
|
expect(blocked.passed).toBe(false);
|
||||||
|
expect(blocked.issues.filter((i) => i.check === "consent").map((i) => i.code)).toEqual([
|
||||||
|
"consent_missing",
|
||||||
|
"age_unverified",
|
||||||
|
]);
|
||||||
|
const alice = h.store.people[0]!;
|
||||||
|
alice.consentRecordedAt = new Date("2026-01-01T00:00:00Z");
|
||||||
|
alice.ageVerifiedAt = new Date("2026-01-01T00:00:00Z");
|
||||||
|
alice.ageVerificationMethod = "document_on_file";
|
||||||
|
const allowed = await gate(h, { edl, output }, { profile: paid });
|
||||||
|
expect(allowed.issues.filter((i) => i.check === "consent")).toEqual([]);
|
||||||
|
expect(allowed.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks consent profiles when a face belongs to nobody", async () => {
|
||||||
|
const { h, edl, output } = await prepared("none", { unassigned: true });
|
||||||
|
const profile = testProfile({
|
||||||
|
consent: { requirePerformerConsent: true, requireAgeVerification: true },
|
||||||
|
});
|
||||||
|
const alice = h.store.people[0]!;
|
||||||
|
alice.consentRecordedAt = new Date("2026-01-01T00:00:00Z");
|
||||||
|
alice.ageVerifiedAt = new Date("2026-01-01T00:00:00Z");
|
||||||
|
alice.ageVerificationMethod = "platform_verified";
|
||||||
|
const report = await gate(h, { edl, output }, { profile });
|
||||||
|
expect(report.issues).toEqual([
|
||||||
|
expect.objectContaining({ check: "consent", code: "unknown_performer", atMs: 0 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks banned text and a duration outside the profile", async () => {
|
||||||
|
const { h, edl, output } = await prepared("blur");
|
||||||
|
const profile = testProfile({
|
||||||
|
duration: { hardMinMs: 0, hardMaxMs: 1500 },
|
||||||
|
text: {
|
||||||
|
captionMaxChars: 100,
|
||||||
|
hashtagLimit: 3,
|
||||||
|
linkPolicy: "forbidden",
|
||||||
|
bannedPatterns: [{ pattern: "free\\s+money", reason: "spam" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const report = await gate(
|
||||||
|
h,
|
||||||
|
{ edl, output },
|
||||||
|
{ profile, copy: { title: "Free money", caption: "see example.com", tags: [] } },
|
||||||
|
);
|
||||||
|
const codes = report.issues.map((issue) => issue.code);
|
||||||
|
expect(codes).toEqual(
|
||||||
|
expect.arrayContaining(["duration_too_long", "banned_pattern", "link_forbidden"]),
|
||||||
|
);
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when the sources were not prepared for this render", async () => {
|
||||||
|
const { h, edl, workDir, output } = await prepared("blur");
|
||||||
|
await rm(path.join(workDir, PRIVACY_RECORD_FILE));
|
||||||
|
const report = await gate(h, { edl, output });
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ code: "privacy_unverified", severity: "block" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
396
packages/adapters/src/media-gate.ts
Normal file
396
packages/adapters/src/media-gate.ts
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
ContentCategory,
|
||||||
|
Edl,
|
||||||
|
EdlVideoClip,
|
||||||
|
FfprobeReport,
|
||||||
|
GateIssue,
|
||||||
|
GateReport,
|
||||||
|
NormalizedBox,
|
||||||
|
PlatformProfile,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { GateInput } from "@rakazo/media";
|
||||||
|
import { clipLengthMs, evaluateGate, introMs, MAX_MARK_RESIDUAL } from "@rakazo/media";
|
||||||
|
import { assertInside } from "@rakazo/media/node";
|
||||||
|
import { consentIssues, enforceConsentFlags, hasAgeProof, hasConsent } from "./media-consent.js";
|
||||||
|
import type { MediaActor, MediaServiceDeps } from "./media-context.js";
|
||||||
|
import type { PrivacyToolOptions } from "./media-privacy-io.js";
|
||||||
|
import { inferenceCapabilities, maskTimes, operationAvailable } from "./media-privacy-io.js";
|
||||||
|
import type { LumaFrame } from "./media-privacy-pixels.js";
|
||||||
|
import {
|
||||||
|
controlAgreement,
|
||||||
|
extractLuma,
|
||||||
|
readPngLuma,
|
||||||
|
residualVisibility,
|
||||||
|
} from "./media-privacy-pixels.js";
|
||||||
|
import type { PrivacyCheck, PrivacyRecord, TimeWindow } from "./media-privacy-plan.js";
|
||||||
|
import {
|
||||||
|
boxAt,
|
||||||
|
isPartial,
|
||||||
|
matchPerson,
|
||||||
|
readPrivacyRecord,
|
||||||
|
sourceToOutput,
|
||||||
|
} from "./media-privacy-plan.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gate execution on the rendered output (spec 6). Content is classified on the
|
||||||
|
* output file at the profile's sample rate plus every cut; faces are tracked on
|
||||||
|
* the output and matched to people locally. Protection is verified pixel by
|
||||||
|
* pixel: for every protected region prepareSources recorded, the prepared
|
||||||
|
* frame is compared with the unredacted frame at the same moment. The
|
||||||
|
* compiler only crops, scales, and composites prepared frames, so a region
|
||||||
|
* altered there stays altered in the output. Anything the gate cannot check
|
||||||
|
* blocks (fail closed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GateDeps = Pick<MediaServiceDeps, "repos" | "inference" | "mediaRoot" | "now"> &
|
||||||
|
PrivacyToolOptions;
|
||||||
|
|
||||||
|
/** Face tracking rate on the output. */
|
||||||
|
export const GATE_FACE_FPS = 4;
|
||||||
|
/** Pixel verification step inside each clip range. */
|
||||||
|
export const GATE_PIXEL_STEP_MS = 500;
|
||||||
|
/** Pixel samples per asset at most (spread evenly). */
|
||||||
|
export const MAX_PIXEL_SAMPLES_PER_ASSET = 40;
|
||||||
|
/** Below this agreement outside the treated regions, two frames are not comparable. */
|
||||||
|
const MIN_CONTROL_AGREEMENT = 0.8;
|
||||||
|
|
||||||
|
export interface GateRunInput {
|
||||||
|
actor: MediaActor;
|
||||||
|
projectId: string;
|
||||||
|
edl: Edl;
|
||||||
|
profile: PlatformProfile;
|
||||||
|
outputPath: string;
|
||||||
|
ffprobe: FfprobeReport;
|
||||||
|
copy: { title: string | null; caption: string | null; tags: string[] };
|
||||||
|
bundleHasDisclosure: boolean;
|
||||||
|
autoFixRound: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Faces = GateInput["faces"];
|
||||||
|
type Marks = GateInput["marks"];
|
||||||
|
|
||||||
|
function block(issue: Omit<GateIssue, "severity">): GateIssue {
|
||||||
|
return { severity: "block", ...issue };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output times of every cut: clip starts and ends, intro end. */
|
||||||
|
export function cutTimes(edl: Edl): number[] {
|
||||||
|
const intro = introMs(edl);
|
||||||
|
const times = new Set<number>();
|
||||||
|
if (intro > 0) times.add(intro);
|
||||||
|
for (const clip of edl.tracks.video) {
|
||||||
|
times.add(intro + clip.atMs);
|
||||||
|
times.add(Math.max(0, intro + clip.atMs + clipLengthMs(clip) - 1));
|
||||||
|
}
|
||||||
|
return [...times].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipsOf(edl: Edl, assetId: string): EdlVideoClip[] {
|
||||||
|
return edl.tracks.video.filter((clip) => clip.assetId === assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output windows of a source span across every clip of its asset. */
|
||||||
|
function toOutput(edl: Edl, assetId: string, span: TimeWindow): TimeWindow[] {
|
||||||
|
return clipsOf(edl, assetId).flatMap((clip) => {
|
||||||
|
const mapped = sourceToOutput(edl, clip, span);
|
||||||
|
return mapped ? [mapped] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluate the full gate for a render output. */
|
||||||
|
export async function runGate(deps: GateDeps, input: GateRunInput): Promise<GateReport> {
|
||||||
|
const { actor, edl, signal } = input;
|
||||||
|
const profile = enforceConsentFlags(input.profile);
|
||||||
|
const outputPath = await assertInside(deps.mediaRoot, input.outputPath);
|
||||||
|
const record = await readPrivacyRecord(path.dirname(outputPath));
|
||||||
|
const capabilities = await inferenceCapabilities(deps.inference, signal);
|
||||||
|
const extra: GateIssue[] = [];
|
||||||
|
const now = deps.now();
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
extra.push(
|
||||||
|
block({
|
||||||
|
check: "anonymization",
|
||||||
|
code: "privacy_unverified",
|
||||||
|
message: "sources were not prepared for this render, so protection cannot be shown",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content on the rendered output.
|
||||||
|
let content: GateInput["content"] = [];
|
||||||
|
let sampledFrames = 0;
|
||||||
|
if (Object.keys(profile.content.thresholds).length > 0) {
|
||||||
|
if (deps.inference && operationAvailable(capabilities, "classifyContent")) {
|
||||||
|
const classified = await deps.inference.classifyContent({
|
||||||
|
path: outputPath,
|
||||||
|
sampleFps: profile.content.sampleFps,
|
||||||
|
extraTimesMs: cutTimes(edl),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
content = classified.frames.map((frame) => ({
|
||||||
|
tMs: frame.tMs,
|
||||||
|
scores: frame.scores as Partial<Record<ContentCategory, number>>,
|
||||||
|
}));
|
||||||
|
sampledFrames = content.length;
|
||||||
|
} else {
|
||||||
|
extra.push(
|
||||||
|
block({
|
||||||
|
check: "content",
|
||||||
|
code: "classifier_unavailable",
|
||||||
|
message: "the content classifier is unavailable, so the output cannot be checked",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faces re-detected on the output.
|
||||||
|
const faces: Faces = [];
|
||||||
|
const matchedPeople = new Set<string>();
|
||||||
|
const protectedPeople = record?.protectedPeople ?? {};
|
||||||
|
const consentRequired =
|
||||||
|
profile.consent.requirePerformerConsent || profile.consent.requireAgeVerification;
|
||||||
|
const needFaces = Object.keys(protectedPeople).length > 0 || consentRequired;
|
||||||
|
if (needFaces) {
|
||||||
|
if (deps.inference && operationAvailable(capabilities, "trackFaces")) {
|
||||||
|
const tracked = await deps.inference.trackFaces({
|
||||||
|
path: outputPath,
|
||||||
|
sampleFps: GATE_FACE_FPS,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const centroids = await deps.repos.personCentroids(actor.spaceId);
|
||||||
|
const expected = expectedOutputFaces(edl, record);
|
||||||
|
const unmatchedAt = new Map<number, number>();
|
||||||
|
const slot = (tMs: number) => Math.round(tMs / (1000 / GATE_FACE_FPS));
|
||||||
|
for (const track of tracked.tracks) {
|
||||||
|
const personId = matchPerson(track.embedding, centroids);
|
||||||
|
if (personId) matchedPeople.add(personId);
|
||||||
|
const mode = personId ? protectedPeople[personId] : undefined;
|
||||||
|
for (const keyframe of track.keyframes) {
|
||||||
|
if (personId) {
|
||||||
|
// Partial modes and synthetic identities stay detectable by design;
|
||||||
|
// their regions are verified pixel by pixel below.
|
||||||
|
if (mode && (isPartial(mode) || mode === "synthetic_identity")) continue;
|
||||||
|
faces.push({ tMs: keyframe.tMs, box: keyframe.box, personId, coverage: mode ? 0 : 1 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = slot(keyframe.tMs);
|
||||||
|
const seen = unmatchedAt.get(key) ?? 0;
|
||||||
|
unmatchedAt.set(key, seen + 1);
|
||||||
|
const allowance = expected.filter(
|
||||||
|
(w) => keyframe.tMs >= w.startMs && keyframe.tMs <= w.endMs,
|
||||||
|
).length;
|
||||||
|
if (seen < allowance) continue;
|
||||||
|
faces.push({ tMs: keyframe.tMs, box: keyframe.box, personId: null, coverage: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extra.push(
|
||||||
|
block({
|
||||||
|
check: "anonymization",
|
||||||
|
code: "face_check_unavailable",
|
||||||
|
message: "face detection is unavailable, so faces in the output cannot be checked",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pixel verification of every recorded protection.
|
||||||
|
const marks: Marks = [];
|
||||||
|
if (record) {
|
||||||
|
const verified = await verifyPixels(deps, edl, record, signal);
|
||||||
|
for (const sample of verified) {
|
||||||
|
const covered = sample.residual <= MAX_MARK_RESIDUAL;
|
||||||
|
const { check } = sample;
|
||||||
|
if (check.kind === "face") {
|
||||||
|
if (check.personId && protectedPeople[check.personId]) {
|
||||||
|
faces.push({
|
||||||
|
tMs: sample.outputMs,
|
||||||
|
box: sample.box,
|
||||||
|
personId: check.personId,
|
||||||
|
coverage: covered ? 1 : 1 - sample.residual,
|
||||||
|
});
|
||||||
|
} else if (!covered) {
|
||||||
|
extra.push(
|
||||||
|
block({
|
||||||
|
check: "anonymization",
|
||||||
|
code: "face_not_covered",
|
||||||
|
message: `face covered ${((1 - sample.residual) * 100).toFixed(0)} %`,
|
||||||
|
atMs: sample.outputMs,
|
||||||
|
endMs: sample.outputMs,
|
||||||
|
score: 1 - sample.residual,
|
||||||
|
...(check.personId ? { personId: check.personId } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
marks.push({
|
||||||
|
markId: check.markId ?? check.id,
|
||||||
|
tMs: sample.outputMs,
|
||||||
|
residualVisibility: sample.residual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const review of record.reviews) {
|
||||||
|
const window = toOutput(edl, review.assetId, {
|
||||||
|
startMs: 0,
|
||||||
|
endMs: Number.MAX_SAFE_INTEGER,
|
||||||
|
})[0];
|
||||||
|
extra.push(
|
||||||
|
block({
|
||||||
|
check: "anonymization",
|
||||||
|
code: "mark_needs_review",
|
||||||
|
message: `mark found with low confidence (${review.score.toFixed(2)}); review it`,
|
||||||
|
markId: review.markId,
|
||||||
|
...(window ? { atMs: window.startMs, endMs: window.endMs } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackingGaps: GateInput["trackingGaps"] = (record?.trackingGaps ?? []).flatMap((gap) =>
|
||||||
|
toOutput(edl, gap.assetId, gap).map((window) => ({ ...window, personId: gap.personId })),
|
||||||
|
);
|
||||||
|
|
||||||
|
// People in the output and their consent state.
|
||||||
|
const ids = [...new Set([...(record?.visiblePeople ?? []), ...matchedPeople])].sort();
|
||||||
|
const persons = ids.length ? await deps.repos.listPersonRecords(actor.spaceId, ids) : [];
|
||||||
|
const people: GateInput["people"] = persons.map((person) => ({
|
||||||
|
id: person.id,
|
||||||
|
anonymizeByDefault: protectedPeople[person.id] ?? "none",
|
||||||
|
consentRecordedAt: hasConsent(person, now)
|
||||||
|
? new Date(person.consentRecordedAt!).toISOString()
|
||||||
|
: null,
|
||||||
|
ageVerifiedAt: hasAgeProof(person, now) ? new Date(person.ageVerifiedAt!).toISOString() : null,
|
||||||
|
}));
|
||||||
|
if (record && consentRequired) {
|
||||||
|
const unassigned = record.unassignedFaces.flatMap((face) =>
|
||||||
|
toOutput(edl, face.assetId, face).map((w) => ({ atMs: w.startMs, endMs: w.endMs })),
|
||||||
|
);
|
||||||
|
extra.push(...consentIssues(profile, [], unassigned, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = evaluateGate({
|
||||||
|
profile,
|
||||||
|
edl,
|
||||||
|
ffprobe: input.ffprobe,
|
||||||
|
content,
|
||||||
|
faces,
|
||||||
|
trackingGaps,
|
||||||
|
marks,
|
||||||
|
people,
|
||||||
|
copy: input.copy,
|
||||||
|
aiAlterations: record?.aiAlterations ?? [],
|
||||||
|
bundleHasDisclosure: input.bundleHasDisclosure,
|
||||||
|
watermarkBox: null,
|
||||||
|
sampledFrames,
|
||||||
|
autoFixRound: input.autoFixRound,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
const issues = [...report.issues, ...extra];
|
||||||
|
return { ...report, issues, passed: !issues.some((issue) => issue.severity === "block") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output windows where a face stays detectable by design (synthetic identity, partial modes). */
|
||||||
|
function expectedOutputFaces(edl: Edl, record: PrivacyRecord | null): TimeWindow[] {
|
||||||
|
return (record?.expectedFaces ?? []).flatMap((face) => toOutput(edl, face.assetId, face));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PixelSample {
|
||||||
|
check: PrivacyCheck;
|
||||||
|
outputMs: number;
|
||||||
|
box: NormalizedBox;
|
||||||
|
residual: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source times to verify per asset: every GATE_PIXEL_STEP_MS inside its clips, spread evenly. */
|
||||||
|
export function pixelSampleTimes(clips: EdlVideoClip[], still: boolean): number[] {
|
||||||
|
if (still) return [0];
|
||||||
|
const times = new Set<number>();
|
||||||
|
for (const clip of clips) {
|
||||||
|
for (let t = clip.inMs; t < clip.outMs; t += GATE_PIXEL_STEP_MS) times.add(t);
|
||||||
|
times.add(Math.max(clip.inMs, clip.outMs - 40));
|
||||||
|
}
|
||||||
|
const sorted = [...times].sort((a, b) => a - b);
|
||||||
|
if (sorted.length <= MAX_PIXEL_SAMPLES_PER_ASSET) return sorted;
|
||||||
|
const step = (sorted.length - 1) / (MAX_PIXEL_SAMPLES_PER_ASSET - 1);
|
||||||
|
return Array.from(
|
||||||
|
{ length: MAX_PIXEL_SAMPLES_PER_ASSET },
|
||||||
|
(_, i) => sorted[Math.round(i * step)]!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPixels(
|
||||||
|
deps: GateDeps,
|
||||||
|
edl: Edl,
|
||||||
|
record: PrivacyRecord,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<PixelSample[]> {
|
||||||
|
const samples: PixelSample[] = [];
|
||||||
|
const maskCache = new Map<string, number[]>();
|
||||||
|
for (const [assetId, entry] of Object.entries(record.assets).sort((a, b) =>
|
||||||
|
a[0].localeCompare(b[0]),
|
||||||
|
)) {
|
||||||
|
if (entry.checks.length === 0) continue;
|
||||||
|
const clips = clipsOf(edl, assetId);
|
||||||
|
const treatedSize = entry.crop
|
||||||
|
? { width: entry.crop.w, height: entry.crop.h }
|
||||||
|
: { width: entry.width, height: entry.height };
|
||||||
|
const offset = entry.crop ? { x: entry.crop.x, y: entry.crop.y } : undefined;
|
||||||
|
for (const tMs of pixelSampleTimes(clips, entry.still)) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const active = entry.checks.flatMap((check) => {
|
||||||
|
const box = entry.still ? (check.keyframes[0]?.box ?? null) : boxAt(check.keyframes, tMs);
|
||||||
|
return box ? [{ check, box }] : [];
|
||||||
|
});
|
||||||
|
if (active.length === 0) continue;
|
||||||
|
const tool = { ffmpegPath: deps.ffmpegPath, signal };
|
||||||
|
const source = await extractLuma(
|
||||||
|
entry.basePath,
|
||||||
|
{ tMs, width: entry.width, height: entry.height, still: entry.still },
|
||||||
|
tool,
|
||||||
|
);
|
||||||
|
const treated = await extractLuma(
|
||||||
|
entry.preparedPath,
|
||||||
|
{ tMs, ...treatedSize, still: entry.still },
|
||||||
|
tool,
|
||||||
|
);
|
||||||
|
const aligned =
|
||||||
|
controlAgreement({ source, treated, boxes: active.map((a) => a.box), offset }) >=
|
||||||
|
MIN_CONTROL_AGREEMENT;
|
||||||
|
const outputTimes = entry.still
|
||||||
|
? clips.map((clip) => introMs(edl) + clip.atMs)
|
||||||
|
: clips.flatMap((clip) => {
|
||||||
|
const mapped = sourceToOutput(edl, clip, { startMs: tMs, endMs: tMs });
|
||||||
|
return mapped ? [mapped.startMs] : [];
|
||||||
|
});
|
||||||
|
for (const { check, box } of active) {
|
||||||
|
let residual = 1;
|
||||||
|
if (aligned) {
|
||||||
|
const mask = check.maskDir ? await maskAt(check.maskDir, tMs, maskCache) : undefined;
|
||||||
|
residual = residualVisibility({ source, treated, box, offset, mask });
|
||||||
|
}
|
||||||
|
for (const outputMs of outputTimes) samples.push({ check, outputMs, box, residual });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mask frame nearest at or before `tMs` (or the first one), if the directory has any. */
|
||||||
|
async function maskAt(
|
||||||
|
maskDir: string,
|
||||||
|
tMs: number,
|
||||||
|
cache: Map<string, number[]>,
|
||||||
|
): Promise<LumaFrame | undefined> {
|
||||||
|
let times = cache.get(maskDir);
|
||||||
|
if (!times) {
|
||||||
|
times = await maskTimes(maskDir).catch(() => []);
|
||||||
|
cache.set(maskDir, times);
|
||||||
|
}
|
||||||
|
if (times.length === 0) return undefined;
|
||||||
|
const before = times.filter((t) => t <= tMs).at(-1) ?? times[0]!;
|
||||||
|
return readPngLuma(path.join(maskDir, `${before}.png`));
|
||||||
|
}
|
||||||
300
packages/adapters/src/media-marks.test.ts
Normal file
300
packages/adapters/src/media-marks.test.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import { copyFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { MediaInferenceProvider } from "@rakazo/adapter-kit";
|
||||||
|
import type { Edl } from "@rakazo/contracts";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { prepareSources } from "./media-anonymize.js";
|
||||||
|
import { ffmpegAvailable } from "./media-emulator-clips.js";
|
||||||
|
import { runGate } from "./media-gate.js";
|
||||||
|
import { anchorFromFace, createMark, placeAnchor, promptBox, updateMark } from "./media-marks.js";
|
||||||
|
import { createMediaPrivacyService } from "./media-privacy.js";
|
||||||
|
import type { PrivacyHarness } from "./media-privacy-fixtures.js";
|
||||||
|
import {
|
||||||
|
ACTOR,
|
||||||
|
cleanProbe,
|
||||||
|
createPrivacyHarness,
|
||||||
|
testEdl,
|
||||||
|
testProfile,
|
||||||
|
} from "./media-privacy-fixtures.js";
|
||||||
|
import { PrivacyUnavailableError } from "./media-privacy-io.js";
|
||||||
|
import { readPrivacyRecord } from "./media-privacy-plan.js";
|
||||||
|
|
||||||
|
const hasFfmpeg = ffmpegAvailable();
|
||||||
|
let harness: PrivacyHarness | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
harness = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tattoo = { tMs: 500, box: { x: 0.6, y: 0.55, w: 0.2, h: 0.25 } };
|
||||||
|
|
||||||
|
async function render(h: PrivacyHarness, edl: Edl, name: string) {
|
||||||
|
const workDir = await h.workDir(name);
|
||||||
|
const prepared = await prepareSources(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
quality: "final",
|
||||||
|
workDir,
|
||||||
|
});
|
||||||
|
return { workDir, prepared };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gate(h: PrivacyHarness, edl: Edl, workDir: string, prepared: string) {
|
||||||
|
const outputPath = path.join(workDir, "output.mp4");
|
||||||
|
await copyFile(prepared, outputPath);
|
||||||
|
return runGate(h.deps, {
|
||||||
|
actor: ACTOR,
|
||||||
|
projectId: "project-1",
|
||||||
|
edl,
|
||||||
|
profile: testProfile(),
|
||||||
|
outputPath,
|
||||||
|
ffprobe: cleanProbe(edl),
|
||||||
|
copy: { title: null, caption: null, tags: [] },
|
||||||
|
bundleHasDisclosure: false,
|
||||||
|
autoFixRound: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mark geometry", () => {
|
||||||
|
it("turns a point prompt into a small box inside the frame", () => {
|
||||||
|
expect(promptBox({ tMs: 0, point: { x: 0.99, y: 0.01 } })).toEqual({
|
||||||
|
x: 0.88,
|
||||||
|
y: 0,
|
||||||
|
w: 0.12,
|
||||||
|
h: 0.12,
|
||||||
|
});
|
||||||
|
expect(() => promptBox({ tMs: 0 })).toThrow(PrivacyUnavailableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a mark's place relative to the face", () => {
|
||||||
|
const anchor = anchorFromFace(
|
||||||
|
{ x: 0.5, y: 0.5, w: 0.1, h: 0.1 },
|
||||||
|
{ x: 0.4, y: 0.3, w: 0.2, h: 0.2 },
|
||||||
|
);
|
||||||
|
const placed = placeAnchor(anchor, { x: 0.1, y: 0.1, w: 0.4, h: 0.4 })!;
|
||||||
|
expect(placed.x).toBeCloseTo(0.3);
|
||||||
|
expect(placed.y).toBeCloseTo(0.5);
|
||||||
|
expect(placed.w).toBeCloseTo(0.2);
|
||||||
|
expect(placeAnchor(anchor, { x: 0.95, y: 0.95, w: 0.4, h: 0.4 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasFfmpeg)("marks", () => {
|
||||||
|
it("segments, propagates, scores, and stores the mask under the media root", async () => {
|
||||||
|
harness = await createPrivacyHarness();
|
||||||
|
const asset = await harness.addAsset("clip.mp4");
|
||||||
|
const mark = await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "tattoo",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: asset.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
expect(mark).toMatchObject({ treatment: "blur", qualityScore: 0.9, suggestedFallback: null });
|
||||||
|
const stored = harness.store.marks[0]!;
|
||||||
|
expect(path.isAbsolute(stored.maskDir!)).toBe(false);
|
||||||
|
expect((await stat(path.join(harness.root, stored.maskDir!, "0.png"))).isFile()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests a fallback when inpainting scores low and rescoring follows the treatment", async () => {
|
||||||
|
harness = await createPrivacyHarness();
|
||||||
|
const asset = await harness.addAsset("clip__inpaintq-0.3__.mp4");
|
||||||
|
const mark = await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "tattoo",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: asset.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "inpaint",
|
||||||
|
});
|
||||||
|
expect(mark.qualityScore).toBeLessThanOrEqual(0.3);
|
||||||
|
expect(mark.suggestedFallback).toBe("cover");
|
||||||
|
const blurred = await updateMark(harness.deps, ACTOR, { markId: mark.id, treatment: "blur" });
|
||||||
|
expect(blurred).toMatchObject({
|
||||||
|
treatment: "blur",
|
||||||
|
qualityScore: 0.9,
|
||||||
|
suggestedFallback: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a mark without a worker and rendering it fails closed", async () => {
|
||||||
|
harness = await createPrivacyHarness(() => null);
|
||||||
|
const asset = await harness.addAsset("clip.mp4");
|
||||||
|
const mark = await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "license_plate",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: asset.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "cover",
|
||||||
|
});
|
||||||
|
expect(mark.qualityScore).toBeNull();
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: asset.id, inMs: 0, outMs: 2000 }]);
|
||||||
|
await expect(render(harness, edl, "no-worker")).rejects.toBeInstanceOf(PrivacyUnavailableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a marked region through the video and the gate checks what is left", async () => {
|
||||||
|
harness = await createPrivacyHarness();
|
||||||
|
const asset = await harness.addAsset("clip.mp4");
|
||||||
|
const mark = await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "tattoo",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: asset.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "none",
|
||||||
|
});
|
||||||
|
// The plan asks for a cover even though the mark itself says none.
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: asset.id, inMs: 0, outMs: 2000 }], {
|
||||||
|
redactions: { faces: [], marks: [{ markId: mark.id, treatment: "cover" }], regions: [] },
|
||||||
|
});
|
||||||
|
const { workDir, prepared } = await render(harness, edl, "cover");
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
expect(record?.assets[asset.id]?.checks).toEqual([
|
||||||
|
expect.objectContaining({ kind: "mark", markId: mark.id }),
|
||||||
|
]);
|
||||||
|
const report = await gate(harness, edl, workDir, prepared.sources.get(asset.id)!.path);
|
||||||
|
expect(report.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when the treatment left the mark visible", async () => {
|
||||||
|
harness = await createPrivacyHarness((emulator) => {
|
||||||
|
const provider = Object.create(emulator) as MediaInferenceProvider;
|
||||||
|
provider.redact = async (request) => {
|
||||||
|
await copyFile(request.path, request.outPath);
|
||||||
|
return { outPath: request.outPath, framesProcessed: 50 };
|
||||||
|
};
|
||||||
|
return provider;
|
||||||
|
});
|
||||||
|
const asset = await harness.addAsset("clip.mp4");
|
||||||
|
const mark = await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "background_detail",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: asset.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: asset.id, inMs: 0, outMs: 2000 }]);
|
||||||
|
const { workDir, prepared } = await render(harness, edl, "leak");
|
||||||
|
const report = await gate(harness, edl, workDir, prepared.sources.get(asset.id)!.path);
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ code: "mark_visible", markId: mark.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inpaints when quality holds and covers instead when it does not", async () => {
|
||||||
|
harness = await createPrivacyHarness();
|
||||||
|
const good = await harness.addAsset("good.mp4");
|
||||||
|
const poor = await harness.addAsset("poor__inpaintq-0.3__.mp4");
|
||||||
|
for (const asset of [good, poor]) {
|
||||||
|
await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "scar",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: asset.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "inpaint",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const edl = testEdl([
|
||||||
|
{ id: "c1", assetId: good.id, inMs: 0, outMs: 1000 },
|
||||||
|
{ id: "c2", assetId: poor.id, inMs: 0, outMs: 1000 },
|
||||||
|
]);
|
||||||
|
const { prepared } = await render(harness, edl, "inpaint");
|
||||||
|
expect(prepared.aiAlterations).toEqual(["inpaint"]);
|
||||||
|
expect(prepared.fallbacks).toEqual([
|
||||||
|
expect.stringMatching(/inpainting scored 0\.30; covered instead$/),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-finds a person's standing mark in another video relative to the face", async () => {
|
||||||
|
harness = await createPrivacyHarness();
|
||||||
|
const origin = await harness.addAsset("a__faces-1____person-alice__.mp4");
|
||||||
|
const other = await harness.addAsset("b__faces-1____person-alice__.mp4");
|
||||||
|
harness.addPerson("alice", "none");
|
||||||
|
await harness.analyseFaces(origin.id, ["alice"]);
|
||||||
|
await harness.analyseFaces(other.id, ["alice"]);
|
||||||
|
const segment = vi.spyOn(harness.inference, "segmentPrompted");
|
||||||
|
const mark = await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "tattoo",
|
||||||
|
label: "left forearm",
|
||||||
|
personId: "alice",
|
||||||
|
assetId: origin.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: other.id, inMs: 0, outMs: 2000 }]);
|
||||||
|
const { workDir, prepared } = await render(harness, edl, "standing");
|
||||||
|
expect(segment).toHaveBeenCalledTimes(2);
|
||||||
|
const reprompt = segment.mock.calls[1]![0];
|
||||||
|
expect(reprompt.path).toContain("b__faces-1");
|
||||||
|
expect(reprompt.prompt.box).toBeDefined();
|
||||||
|
const record = await readPrivacyRecord(workDir);
|
||||||
|
expect(record?.reviews).toEqual([]);
|
||||||
|
expect(record?.assets[other.id]?.checks).toEqual([
|
||||||
|
expect.objectContaining({ kind: "mark", markId: mark.id }),
|
||||||
|
]);
|
||||||
|
const report = await gate(harness, edl, workDir, prepared.sources.get(other.id)!.path);
|
||||||
|
expect(report.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a low-confidence re-find to review, which blocks the export", async () => {
|
||||||
|
harness = await createPrivacyHarness((emulator) => {
|
||||||
|
const provider = Object.create(emulator) as MediaInferenceProvider;
|
||||||
|
provider.segmentPrompted = async (request) => {
|
||||||
|
const result = await emulator.segmentPrompted(request);
|
||||||
|
return request.path.includes("b__") ? { ...result, score: 0.3 } : result;
|
||||||
|
};
|
||||||
|
return provider;
|
||||||
|
});
|
||||||
|
const origin = await harness.addAsset("a__faces-1____person-alice__.mp4");
|
||||||
|
const other = await harness.addAsset("b__faces-1____person-alice__.mp4");
|
||||||
|
harness.addPerson("alice", "none");
|
||||||
|
await harness.analyseFaces(origin.id, ["alice"]);
|
||||||
|
await harness.analyseFaces(other.id, ["alice"]);
|
||||||
|
const mark = await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "jewelry",
|
||||||
|
label: null,
|
||||||
|
personId: "alice",
|
||||||
|
assetId: origin.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
const edl = testEdl([{ id: "c1", assetId: other.id, inMs: 0, outMs: 2000 }]);
|
||||||
|
const { workDir, prepared } = await render(harness, edl, "review");
|
||||||
|
const report = await gate(harness, edl, workDir, prepared.sources.get(other.id)!.path);
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
expect(report.issues).toContainEqual(
|
||||||
|
expect.objectContaining({ code: "mark_needs_review", markId: mark.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a person with their tracks, marks, and files", async () => {
|
||||||
|
harness = await createPrivacyHarness();
|
||||||
|
const asset = await harness.addAsset("clip__faces-1____person-alice__.mp4");
|
||||||
|
harness.addPerson("alice", "blur");
|
||||||
|
await harness.analyseFaces(asset.id, ["alice"]);
|
||||||
|
await createMark(harness.deps, ACTOR, {
|
||||||
|
kind: "tattoo",
|
||||||
|
label: null,
|
||||||
|
personId: "alice",
|
||||||
|
assetId: asset.id,
|
||||||
|
prompt: tattoo,
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
const files = [
|
||||||
|
path.join(harness.root, harness.store.tracks[0]!.keyframesPath),
|
||||||
|
path.join(harness.root, harness.store.marks[0]!.maskDir!),
|
||||||
|
];
|
||||||
|
const service = createMediaPrivacyService(harness.deps);
|
||||||
|
await service.removePerson(ACTOR, "alice");
|
||||||
|
expect(harness.store.people).toEqual([]);
|
||||||
|
expect(harness.store.tracks).toEqual([]);
|
||||||
|
for (const file of files) await expect(stat(file)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
322
packages/adapters/src/media-marks.ts
Normal file
322
packages/adapters/src/media-marks.ts
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { SegmentPromptedResult } from "@rakazo/adapter-kit";
|
||||||
|
import type {
|
||||||
|
BoxKeyframe,
|
||||||
|
MarkTreatment,
|
||||||
|
MediaMark,
|
||||||
|
MediaMarkPrompt,
|
||||||
|
NormalizedBox,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { CreateMarkInputType, MediaActor, MediaServiceDeps } from "./media-context.js";
|
||||||
|
import type { PrivacyToolOptions } from "./media-privacy-io.js";
|
||||||
|
import {
|
||||||
|
analysisFile,
|
||||||
|
inferenceCapabilities,
|
||||||
|
operationAvailable,
|
||||||
|
PrivacyUnavailableError,
|
||||||
|
probeSource,
|
||||||
|
resolveStored,
|
||||||
|
toStored,
|
||||||
|
} from "./media-privacy-io.js";
|
||||||
|
import { extractLuma, temporalStability } from "./media-privacy-pixels.js";
|
||||||
|
import type { TrackKeyframe } from "./media-privacy-plan.js";
|
||||||
|
import { boxAt, FACE_MIN_CONFIDENCE, MARK_QUALITY_MIN, unionBox } from "./media-privacy-plan.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Identifying marks (spec 7): the user marks once by click or box, the
|
||||||
|
* segmenter (SAM2 in the sidecar) propagates the mask through the video, and
|
||||||
|
* the treatment is scored. A score under MARK_QUALITY_MIN never ships
|
||||||
|
* silently: the mark carries a suggested fallback and rendering applies it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Side of the box a point prompt stands for, normalized. */
|
||||||
|
const POINT_PROMPT_SIZE = 0.12;
|
||||||
|
/** Frames sampled for the local flicker measurement of an inpaint preview. */
|
||||||
|
const STABILITY_FRAMES = 6;
|
||||||
|
const STABILITY_STEP_MS = 200;
|
||||||
|
|
||||||
|
export type MarkDeps = Pick<MediaServiceDeps, "repos" | "inference" | "mediaRoot" | "sources"> &
|
||||||
|
PrivacyToolOptions;
|
||||||
|
|
||||||
|
/** Directory of a mark's masks and previews, relative to the media root. */
|
||||||
|
export function markDir(spaceId: string, markId: string): string {
|
||||||
|
return path.join(spaceId, "marks", markId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prompt as a box: a point prompt stands for a small box around it. */
|
||||||
|
export function promptBox(prompt: MediaMarkPrompt): NormalizedBox {
|
||||||
|
if (prompt.box) return prompt.box;
|
||||||
|
if (!prompt.point) throw new PrivacyUnavailableError("a mark needs a box or a point");
|
||||||
|
const x = Math.min(Math.max(0, prompt.point.x - POINT_PROMPT_SIZE / 2), 1 - POINT_PROMPT_SIZE);
|
||||||
|
const y = Math.min(Math.max(0, prompt.point.y - POINT_PROMPT_SIZE / 2), 1 - POINT_PROMPT_SIZE);
|
||||||
|
return { x, y, w: POINT_PROMPT_SIZE, h: POINT_PROMPT_SIZE };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback to propose when a treatment scores too low. */
|
||||||
|
export function fallbackFor(treatment: MarkTreatment): MarkTreatment {
|
||||||
|
return treatment === "cover" ? "reframe_out" : "cover";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a treatment. Segmentation confidence bounds every treatment (a mask
|
||||||
|
* that does not follow the mark cannot hide it); inpainting also needs the
|
||||||
|
* provider's temporal and edge quality and a local flicker check of its
|
||||||
|
* preview. Returns the score and, under MARK_QUALITY_MIN, the fallback.
|
||||||
|
*/
|
||||||
|
export async function scoreTreatment(
|
||||||
|
deps: MarkDeps,
|
||||||
|
input: {
|
||||||
|
treatment: MarkTreatment;
|
||||||
|
segmentationScore: number;
|
||||||
|
file: string;
|
||||||
|
maskDir: string;
|
||||||
|
keyframes: BoxKeyframe[];
|
||||||
|
previewDir: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
): Promise<{ qualityScore: number; suggestedFallback: MarkTreatment | null }> {
|
||||||
|
let score = input.segmentationScore;
|
||||||
|
if (input.treatment === "inpaint") {
|
||||||
|
const capabilities = await inferenceCapabilities(deps.inference, input.signal);
|
||||||
|
if (!deps.inference || !operationAvailable(capabilities, "inpaintVideo")) {
|
||||||
|
return { qualityScore: 0, suggestedFallback: "cover" };
|
||||||
|
}
|
||||||
|
await mkdir(input.previewDir, { recursive: true });
|
||||||
|
const preview = await deps.inference.inpaintVideo({
|
||||||
|
path: input.file,
|
||||||
|
maskDir: input.maskDir,
|
||||||
|
outPath: path.join(input.previewDir, "inpaint-preview.mp4"),
|
||||||
|
signal: input.signal,
|
||||||
|
});
|
||||||
|
const local = await previewStability(deps, preview.outPath, input.keyframes, input.signal);
|
||||||
|
score = Math.min(score, preview.quality.score, preview.quality.temporalStability, local);
|
||||||
|
}
|
||||||
|
const qualityScore = Math.round(score * 1000) / 1000;
|
||||||
|
return {
|
||||||
|
qualityScore,
|
||||||
|
suggestedFallback:
|
||||||
|
qualityScore < MARK_QUALITY_MIN && input.treatment !== "none"
|
||||||
|
? fallbackFor(input.treatment)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flicker inside the mark region of an inpainted preview around the first keyframe. */
|
||||||
|
async function previewStability(
|
||||||
|
deps: MarkDeps,
|
||||||
|
file: string,
|
||||||
|
keyframes: BoxKeyframe[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<number> {
|
||||||
|
const first = keyframes[0];
|
||||||
|
if (!first) return 1;
|
||||||
|
const { width, height } = await frameSize(file, deps, signal);
|
||||||
|
const frames = [];
|
||||||
|
let region: NormalizedBox = first.box;
|
||||||
|
for (let i = 0; i < STABILITY_FRAMES; i++) {
|
||||||
|
const tMs = first.tMs + i * STABILITY_STEP_MS;
|
||||||
|
const box = boxAt(keyframes, tMs, STABILITY_STEP_MS);
|
||||||
|
if (box) region = unionBox(region, box);
|
||||||
|
try {
|
||||||
|
frames.push(
|
||||||
|
await extractLuma(file, { tMs, width, height }, { ffmpegPath: deps.ffmpegPath, signal }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return temporalStability(frames, region);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frameSize(file: string, deps: PrivacyToolOptions, signal?: AbortSignal) {
|
||||||
|
const probed = await probeSource(file, "video", { ffprobePath: deps.ffprobePath, signal });
|
||||||
|
return { width: probed.width, height: probed.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segment a prompt and propagate it through the file into `outDir`. */
|
||||||
|
export async function segmentMark(
|
||||||
|
deps: MarkDeps,
|
||||||
|
input: { file: string; prompt: MediaMarkPrompt; outDir: string; signal?: AbortSignal },
|
||||||
|
): Promise<SegmentPromptedResult> {
|
||||||
|
const capabilities = await inferenceCapabilities(deps.inference, input.signal);
|
||||||
|
if (!deps.inference || !operationAvailable(capabilities, "segmentPrompted")) {
|
||||||
|
throw new PrivacyUnavailableError("mark segmentation needs the media worker");
|
||||||
|
}
|
||||||
|
await rm(input.outDir, { recursive: true, force: true });
|
||||||
|
await mkdir(input.outDir, { recursive: true });
|
||||||
|
return deps.inference.segmentPrompted({
|
||||||
|
path: input.file,
|
||||||
|
prompt: input.prompt,
|
||||||
|
propagate: true,
|
||||||
|
outDir: input.outDir,
|
||||||
|
signal: input.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a person-bound mark sits relative to the person's face, from the
|
||||||
|
* frame it was drawn in: offsets and size in face-box units.
|
||||||
|
*/
|
||||||
|
export interface MarkAnchor {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anchorFromFace(mark: NormalizedBox, face: NormalizedBox): MarkAnchor {
|
||||||
|
return {
|
||||||
|
dx: (mark.x - face.x) / face.w,
|
||||||
|
dy: (mark.y - face.y) / face.h,
|
||||||
|
w: mark.w / face.w,
|
||||||
|
h: mark.h / face.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place an anchor at a face, clamped to the frame. Null when it falls outside. */
|
||||||
|
export function placeAnchor(anchor: MarkAnchor, face: NormalizedBox): NormalizedBox | null {
|
||||||
|
const x0 = face.x + anchor.dx * face.w;
|
||||||
|
const y0 = face.y + anchor.dy * face.h;
|
||||||
|
const x1 = x0 + anchor.w * face.w;
|
||||||
|
const y1 = y0 + anchor.h * face.h;
|
||||||
|
const x = Math.max(0, x0);
|
||||||
|
const y = Math.max(0, y0);
|
||||||
|
const w = Math.min(1, x1) - x;
|
||||||
|
const h = Math.min(1, y1) - y;
|
||||||
|
return w > 0.001 && h > 0.001 ? { x, y, w, h } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First confident face keyframe of a track at or after `fromMs`. */
|
||||||
|
export function firstConfident(keyframes: TrackKeyframe[], fromMs = 0): TrackKeyframe | null {
|
||||||
|
return (
|
||||||
|
[...keyframes]
|
||||||
|
.filter((k) => k.tMs >= fromMs && (k.confidence ?? 1) >= FACE_MIN_CONFIDENCE)
|
||||||
|
.sort((a, b) => a.tMs - b.tMs)[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Face box of a track nearest to `tMs` (within 250 ms). */
|
||||||
|
export function faceAt(keyframes: TrackKeyframe[], tMs: number): NormalizedBox | null {
|
||||||
|
let best: TrackKeyframe | null = null;
|
||||||
|
for (const k of keyframes) {
|
||||||
|
if (Math.abs(k.tMs - tMs) > 250) continue;
|
||||||
|
if (!best || Math.abs(k.tMs - tMs) < Math.abs(best.tMs - tMs)) best = k;
|
||||||
|
}
|
||||||
|
return best?.box ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mark: store it, segment from the prompt, propagate through the
|
||||||
|
* asset, score the treatment, and store a fallback suggestion when the score
|
||||||
|
* is low. Without the media worker the mark is kept unsegmented; rendering a
|
||||||
|
* clip of that asset then fails closed until segmentation is possible.
|
||||||
|
*/
|
||||||
|
export async function createMark(
|
||||||
|
deps: MarkDeps,
|
||||||
|
actor: MediaActor,
|
||||||
|
input: CreateMarkInputType,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MediaMark> {
|
||||||
|
const asset = await deps.repos.getAssetRecord(actor.spaceId, input.assetId);
|
||||||
|
const mark = await deps.repos.createMark(actor.spaceId, {
|
||||||
|
kind: input.kind,
|
||||||
|
label: input.label,
|
||||||
|
personId: input.personId,
|
||||||
|
assetId: input.assetId,
|
||||||
|
prompt: input.prompt,
|
||||||
|
treatment: input.treatment,
|
||||||
|
});
|
||||||
|
const file = await analysisFile(deps, actor.spaceId, asset);
|
||||||
|
const capabilities = await inferenceCapabilities(deps.inference, signal);
|
||||||
|
if (!file || !operationAvailable(capabilities, "segmentPrompted")) return mark;
|
||||||
|
const relative = markDir(actor.spaceId, mark.id);
|
||||||
|
const outDir = await resolveStored(deps.mediaRoot, path.join(relative, "masks"));
|
||||||
|
try {
|
||||||
|
const segmented = await segmentMark(deps, { file, prompt: input.prompt, outDir, signal });
|
||||||
|
const scored = await scoreTreatment(deps, {
|
||||||
|
treatment: input.treatment,
|
||||||
|
segmentationScore: segmented.score,
|
||||||
|
file,
|
||||||
|
maskDir: segmented.maskDir,
|
||||||
|
keyframes: segmented.keyframes,
|
||||||
|
previewDir: await resolveStored(deps.mediaRoot, relative),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const record = await deps.repos.recordMarkResult(actor.spaceId, mark.id, {
|
||||||
|
...scored,
|
||||||
|
maskDir: await toStored(deps.mediaRoot, segmented.maskDir),
|
||||||
|
});
|
||||||
|
const { maskDir: _maskDir, ...dto } = record;
|
||||||
|
return dto;
|
||||||
|
} catch (error) {
|
||||||
|
// The mark stays without a mask (rendering fails closed); the caller sees why.
|
||||||
|
await deps.repos.removeMark(actor.spaceId, mark.id);
|
||||||
|
await rm(await resolveStored(deps.mediaRoot, relative), { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update treatment, label, or person. A new treatment is segmented and scored
|
||||||
|
* again so its suggestion reflects that treatment.
|
||||||
|
*/
|
||||||
|
export async function updateMark(
|
||||||
|
deps: MarkDeps,
|
||||||
|
actor: MediaActor,
|
||||||
|
input: {
|
||||||
|
markId: string;
|
||||||
|
treatment?: MarkTreatment;
|
||||||
|
label?: string | null;
|
||||||
|
personId?: string | null;
|
||||||
|
},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MediaMark> {
|
||||||
|
const before = await deps.repos.getMark(actor.spaceId, input.markId);
|
||||||
|
const updated = await deps.repos.updateMark(actor.spaceId, input.markId, {
|
||||||
|
...(input.treatment !== undefined ? { treatment: input.treatment } : {}),
|
||||||
|
...(input.label !== undefined ? { label: input.label } : {}),
|
||||||
|
...(input.personId !== undefined ? { personId: input.personId } : {}),
|
||||||
|
});
|
||||||
|
if (input.treatment === undefined || input.treatment === before.treatment) return updated;
|
||||||
|
if (!before.assetId || !before.prompt) return updated;
|
||||||
|
const asset = await deps.repos.getAssetRecord(actor.spaceId, before.assetId);
|
||||||
|
const file = await analysisFile(deps, actor.spaceId, asset);
|
||||||
|
const capabilities = await inferenceCapabilities(deps.inference, signal);
|
||||||
|
if (!file || !operationAvailable(capabilities, "segmentPrompted")) {
|
||||||
|
// Without the worker the old score says nothing about the new treatment.
|
||||||
|
const record = await deps.repos.recordMarkResult(actor.spaceId, input.markId, {
|
||||||
|
qualityScore: null,
|
||||||
|
suggestedFallback: null,
|
||||||
|
maskDir: before.maskDir,
|
||||||
|
});
|
||||||
|
const { maskDir: _maskDir, ...dto } = record;
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
const relative = markDir(actor.spaceId, input.markId);
|
||||||
|
const segmented = await segmentMark(deps, {
|
||||||
|
file,
|
||||||
|
prompt: before.prompt,
|
||||||
|
outDir: await resolveStored(deps.mediaRoot, path.join(relative, "masks")),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const scored = await scoreTreatment(deps, {
|
||||||
|
treatment: input.treatment,
|
||||||
|
segmentationScore: segmented.score,
|
||||||
|
file,
|
||||||
|
maskDir: segmented.maskDir,
|
||||||
|
keyframes: segmented.keyframes,
|
||||||
|
previewDir: await resolveStored(deps.mediaRoot, relative),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const record = await deps.repos.recordMarkResult(actor.spaceId, input.markId, {
|
||||||
|
...scored,
|
||||||
|
maskDir: await toStored(deps.mediaRoot, segmented.maskDir),
|
||||||
|
});
|
||||||
|
const { maskDir: _maskDir, ...dto } = record;
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
437
packages/adapters/src/media-privacy-fixtures.ts
Normal file
437
packages/adapters/src/media-privacy-fixtures.ts
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { MediaInferenceProvider } from "@rakazo/adapter-kit";
|
||||||
|
import type {
|
||||||
|
AnonymizeMode,
|
||||||
|
Edl,
|
||||||
|
FfprobeReport,
|
||||||
|
MarkTreatment,
|
||||||
|
MediaMarkKind,
|
||||||
|
MediaMarkPrompt,
|
||||||
|
PlatformProfile,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { EdlSchema, PlatformProfileSchema } from "@rakazo/contracts";
|
||||||
|
import type { MediaRepos } from "@rakazo/db";
|
||||||
|
import { edlDurationMs, encodeVectors } from "@rakazo/media";
|
||||||
|
import type { MediaServiceDeps } from "./media-context.js";
|
||||||
|
import { EmulatorMediaInferenceProvider } from "./media-emulator.js";
|
||||||
|
import { renderTestClip } from "./media-emulator-clips.js";
|
||||||
|
import { runMediaTool } from "./media-folder-source-probe.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* In-memory repositories and synthetic ffmpeg clips for the privacy tests.
|
||||||
|
* Clip names carry media-emulator markers (faces, person seeds, detection
|
||||||
|
* gaps, classifier scores); nothing here shows a real person.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SPACE = "space-1";
|
||||||
|
export const USER = "user-1";
|
||||||
|
export const ACTOR = { spaceId: SPACE, userId: USER };
|
||||||
|
|
||||||
|
interface AssetRow {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
sourceId: string;
|
||||||
|
externalId: string;
|
||||||
|
kind: string;
|
||||||
|
storage: string;
|
||||||
|
storagePath: string | null;
|
||||||
|
proxyPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonRow {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
displayName: string;
|
||||||
|
anonymizeByDefault: string;
|
||||||
|
consentRecordedAt: Date | null;
|
||||||
|
ageVerifiedAt: Date | null;
|
||||||
|
ageVerificationMethod: string;
|
||||||
|
syntheticSeed: number | null;
|
||||||
|
centroid: Uint8Array | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackRow {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
personId: string | null;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
confidenceMin: number;
|
||||||
|
keyframesPath: string;
|
||||||
|
modelVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkRow {
|
||||||
|
id: string;
|
||||||
|
kind: MediaMarkKind;
|
||||||
|
label: string | null;
|
||||||
|
personId: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
prompt: MediaMarkPrompt | null;
|
||||||
|
treatment: MarkTreatment;
|
||||||
|
qualityScore: number | null;
|
||||||
|
suggestedFallback: MarkTreatment | null;
|
||||||
|
maskDir: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivacyHarness {
|
||||||
|
root: string;
|
||||||
|
inference: EmulatorMediaInferenceProvider;
|
||||||
|
deps: MediaServiceDeps;
|
||||||
|
store: {
|
||||||
|
assets: AssetRow[];
|
||||||
|
people: PersonRow[];
|
||||||
|
tracks: TrackRow[];
|
||||||
|
marks: MarkRow[];
|
||||||
|
renders: Array<Record<string, unknown>>;
|
||||||
|
plans: Array<Record<string, unknown>>;
|
||||||
|
segments: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
/** A synthetic clip, or a still (PNG) when the name ends in .png. */
|
||||||
|
addAsset(fileName: string, options?: { seconds?: number; id?: string }): Promise<AssetRow>;
|
||||||
|
addPerson(
|
||||||
|
id: string,
|
||||||
|
mode: AnonymizeMode,
|
||||||
|
consent?: { consent?: boolean; age?: boolean },
|
||||||
|
): PersonRow;
|
||||||
|
/** Run emulator tracking on the asset and store the tracks (the analysis result). */
|
||||||
|
analyseFaces(assetId: string, personIds: Array<string | null>, sampleFps?: number): Promise<void>;
|
||||||
|
/** Store the emulator identity embedding of a person seed as the person's centroid. */
|
||||||
|
learnPerson(personId: string, assetId: string, trackIndex?: number): Promise<void>;
|
||||||
|
workDir(name: string): Promise<string>;
|
||||||
|
cleanup(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testEdl(
|
||||||
|
clips: Array<{ id: string; assetId: string; inMs: number; outMs: number; atMs?: number }>,
|
||||||
|
extra: Partial<Pick<Edl, "redactions">> & { width?: number; height?: number } = {},
|
||||||
|
): Edl {
|
||||||
|
let cursor = 0;
|
||||||
|
const video = clips.map((clip) => {
|
||||||
|
const atMs = clip.atMs ?? cursor;
|
||||||
|
cursor = atMs + clip.outMs - clip.inMs;
|
||||||
|
return { ...clip, atMs, reframe: "none" as const };
|
||||||
|
});
|
||||||
|
return EdlSchema.parse({
|
||||||
|
edlVersion: 1,
|
||||||
|
output: {
|
||||||
|
profileId: "test-profile",
|
||||||
|
width: extra.width ?? 320,
|
||||||
|
height: extra.height ?? 180,
|
||||||
|
fps: 25,
|
||||||
|
durationTargetMs: cursor,
|
||||||
|
durationHardMinMs: 0,
|
||||||
|
durationHardMaxMs: 600_000,
|
||||||
|
},
|
||||||
|
tracks: { video },
|
||||||
|
redactions: extra.redactions ?? {},
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testProfile(overrides: Record<string, unknown> = {}): PlatformProfile {
|
||||||
|
return PlatformProfileSchema.parse({
|
||||||
|
id: "test-profile",
|
||||||
|
name: "Test profile",
|
||||||
|
kind: "free_social",
|
||||||
|
reviewedAt: "2026-01-01",
|
||||||
|
video: {
|
||||||
|
aspectRatios: ["16:9", "9:16"],
|
||||||
|
width: 1920,
|
||||||
|
height: 1920,
|
||||||
|
fps: 60,
|
||||||
|
codec: "h264",
|
||||||
|
maxBitrateKbps: 20_000,
|
||||||
|
maxFileSizeMb: 500,
|
||||||
|
},
|
||||||
|
duration: { hardMinMs: 0, hardMaxMs: 600_000 },
|
||||||
|
safeZones: { topPct: 0, bottomPct: 0, leftPct: 0, rightPct: 0 },
|
||||||
|
captions: { recommended: false },
|
||||||
|
audio: {},
|
||||||
|
text: { captionMaxChars: 2200, hashtagLimit: 30, linkPolicy: "allowed" },
|
||||||
|
content: { thresholds: { explicit_nudity: 0.5 } },
|
||||||
|
disclosure: { aiAlteredLabelRequired: false },
|
||||||
|
consent: { requirePerformerConsent: false, requireAgeVerification: false },
|
||||||
|
export: {},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ffprobe report that passes the technical check for an EDL. */
|
||||||
|
export function cleanProbe(edl: Edl): FfprobeReport {
|
||||||
|
return {
|
||||||
|
durationMs: edlDurationMs(edl),
|
||||||
|
width: edl.output.width,
|
||||||
|
height: edl.output.height,
|
||||||
|
fps: edl.output.fps,
|
||||||
|
videoCodec: "h264",
|
||||||
|
audioCodec: null,
|
||||||
|
sizeBytes: 10_000,
|
||||||
|
integratedLufs: null,
|
||||||
|
truePeakDb: null,
|
||||||
|
tags: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isolation(): Error {
|
||||||
|
return new Error("not found in space");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPrivacyHarness(
|
||||||
|
inferenceOverride?: (emulator: EmulatorMediaInferenceProvider) => MediaInferenceProvider | null,
|
||||||
|
): Promise<PrivacyHarness> {
|
||||||
|
const root = await realpath(await mkdtemp(path.join(tmpdir(), "media-privacy-")));
|
||||||
|
const emulator = new EmulatorMediaInferenceProvider({ root });
|
||||||
|
const store: PrivacyHarness["store"] = {
|
||||||
|
assets: [],
|
||||||
|
people: [],
|
||||||
|
tracks: [],
|
||||||
|
marks: [],
|
||||||
|
renders: [],
|
||||||
|
plans: [],
|
||||||
|
segments: [],
|
||||||
|
};
|
||||||
|
let counter = 0;
|
||||||
|
const nextId = (prefix: string) => `${prefix}${++counter}`;
|
||||||
|
const markDto = (row: MarkRow) => {
|
||||||
|
const { maskDir: _maskDir, ...dto } = row;
|
||||||
|
return dto;
|
||||||
|
};
|
||||||
|
const findMark = (id: string) => {
|
||||||
|
const row = store.marks.find((mark) => mark.id === id);
|
||||||
|
if (!row) throw isolation();
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
const repos = {
|
||||||
|
async listAssetRecords(_spaceId: string, ids: string[]) {
|
||||||
|
return store.assets.filter((asset) => ids.includes(asset.id));
|
||||||
|
},
|
||||||
|
async getAssetRecord(_spaceId: string, id: string) {
|
||||||
|
const asset = store.assets.find((row) => row.id === id);
|
||||||
|
if (!asset) throw isolation();
|
||||||
|
return asset;
|
||||||
|
},
|
||||||
|
async getSource() {
|
||||||
|
return { kind: "folder", config: {} };
|
||||||
|
},
|
||||||
|
async getProjectRecord(_spaceId: string, projectId: string) {
|
||||||
|
return {
|
||||||
|
id: projectId,
|
||||||
|
brandKitId: null,
|
||||||
|
assetIds: store.assets.map((a) => a.id),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getBrandKit() {
|
||||||
|
throw isolation();
|
||||||
|
},
|
||||||
|
async listPersonRecords(_spaceId: string, ids?: string[]) {
|
||||||
|
return store.people.filter((person) => !ids || ids.includes(person.id));
|
||||||
|
},
|
||||||
|
async personCentroids() {
|
||||||
|
return store.people.flatMap((person) =>
|
||||||
|
person.centroid ? [{ personId: person.id, centroid: person.centroid }] : [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async updatePerson(_spaceId: string, personId: string, patch: { syntheticSeed?: number }) {
|
||||||
|
const person = store.people.find((row) => row.id === personId);
|
||||||
|
if (!person) throw isolation();
|
||||||
|
if (patch.syntheticSeed !== undefined) person.syntheticSeed = patch.syntheticSeed;
|
||||||
|
return person;
|
||||||
|
},
|
||||||
|
async faceTrackRecords(_spaceId: string, assetIds: string[]) {
|
||||||
|
return store.tracks.filter((track) => assetIds.includes(track.assetId));
|
||||||
|
},
|
||||||
|
async listFaceTracks() {
|
||||||
|
return store.tracks;
|
||||||
|
},
|
||||||
|
async latestAnalysis(_spaceId: string, assetId: string, kind: string) {
|
||||||
|
return kind === "faces" && store.tracks.some((t) => t.assetId === assetId) ? { kind } : null;
|
||||||
|
},
|
||||||
|
async marksForAsset(_spaceId: string, assetId: string) {
|
||||||
|
const peopleHere = new Set(
|
||||||
|
store.tracks.filter((t) => t.assetId === assetId && t.personId).map((t) => t.personId),
|
||||||
|
);
|
||||||
|
return store.marks.filter(
|
||||||
|
(mark) => mark.assetId === assetId || (mark.personId && peopleHere.has(mark.personId)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async createMark(
|
||||||
|
_spaceId: string,
|
||||||
|
input: Omit<MarkRow, "id" | "qualityScore" | "suggestedFallback" | "maskDir" | "createdAt">,
|
||||||
|
) {
|
||||||
|
const row: MarkRow = {
|
||||||
|
...input,
|
||||||
|
id: nextId("mark"),
|
||||||
|
qualityScore: null,
|
||||||
|
suggestedFallback: null,
|
||||||
|
maskDir: null,
|
||||||
|
createdAt: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
store.marks.push(row);
|
||||||
|
return markDto(row);
|
||||||
|
},
|
||||||
|
async getMark(_spaceId: string, id: string) {
|
||||||
|
return { ...findMark(id) };
|
||||||
|
},
|
||||||
|
async updateMark(_spaceId: string, id: string, patch: Partial<MarkRow>) {
|
||||||
|
Object.assign(findMark(id), patch);
|
||||||
|
return markDto(findMark(id));
|
||||||
|
},
|
||||||
|
async recordMarkResult(
|
||||||
|
_spaceId: string,
|
||||||
|
id: string,
|
||||||
|
outcome: Pick<MarkRow, "qualityScore" | "suggestedFallback" | "maskDir">,
|
||||||
|
) {
|
||||||
|
Object.assign(findMark(id), outcome);
|
||||||
|
return { ...findMark(id) };
|
||||||
|
},
|
||||||
|
async removeMark(_spaceId: string, id: string) {
|
||||||
|
const row = findMark(id);
|
||||||
|
store.marks = store.marks.filter((mark) => mark.id !== id);
|
||||||
|
return { maskDir: row.maskDir };
|
||||||
|
},
|
||||||
|
async removePersonCompletely(_spaceId: string, personId: string) {
|
||||||
|
const tracks = store.tracks.filter((t) => t.personId === personId);
|
||||||
|
const marks = store.marks.filter((m) => m.personId === personId);
|
||||||
|
store.tracks = store.tracks.filter((t) => t.personId !== personId);
|
||||||
|
store.marks = store.marks.filter((m) => m.personId !== personId);
|
||||||
|
store.people = store.people.filter((p) => p.id !== personId);
|
||||||
|
return {
|
||||||
|
deletedFaceTracks: tracks.length,
|
||||||
|
deletedMarks: marks.length,
|
||||||
|
keyframesPaths: tracks.map((t) => t.keyframesPath),
|
||||||
|
maskDirs: marks.flatMap((m) => (m.maskDir ? [m.maskDir] : [])),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getRenderRecord(_spaceId: string, id: string) {
|
||||||
|
const render = store.renders.find((row) => row.id === id);
|
||||||
|
if (!render) throw isolation();
|
||||||
|
return render;
|
||||||
|
},
|
||||||
|
async getPlan(_spaceId: string, id: string) {
|
||||||
|
const plan = store.plans.find((row) => row.id === id);
|
||||||
|
if (!plan) throw isolation();
|
||||||
|
return plan;
|
||||||
|
},
|
||||||
|
async listSegments() {
|
||||||
|
return store.segments;
|
||||||
|
},
|
||||||
|
async updateRender(_spaceId: string, id: string, patch: Record<string, unknown>) {
|
||||||
|
const render = store.renders.find((row) => row.id === id);
|
||||||
|
if (!render) return null;
|
||||||
|
Object.assign(render, patch);
|
||||||
|
return render;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const libraryDir = path.join(root, SPACE, "library");
|
||||||
|
await mkdir(libraryDir, { recursive: true });
|
||||||
|
const deps = {
|
||||||
|
repos: repos as unknown as MediaRepos,
|
||||||
|
inference: inferenceOverride ? inferenceOverride(emulator) : emulator,
|
||||||
|
mediaRoot: root,
|
||||||
|
sources: {},
|
||||||
|
now: () => new Date("2026-06-01T12:00:00Z"),
|
||||||
|
} as unknown as MediaServiceDeps;
|
||||||
|
|
||||||
|
const harness: PrivacyHarness = {
|
||||||
|
root,
|
||||||
|
inference: emulator,
|
||||||
|
deps,
|
||||||
|
store,
|
||||||
|
async addAsset(fileName, options = {}) {
|
||||||
|
const file = path.join(libraryDir, fileName);
|
||||||
|
const still = fileName.endsWith(".png");
|
||||||
|
if (still) {
|
||||||
|
await runMediaTool("ffmpeg", [
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"testsrc2=s=320x180:r=1:d=1",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
file,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await renderTestClip(file, { seconds: options.seconds ?? 2 });
|
||||||
|
}
|
||||||
|
const row: AssetRow = {
|
||||||
|
id: options.id ?? nextId("asset"),
|
||||||
|
spaceId: SPACE,
|
||||||
|
sourceId: "source-1",
|
||||||
|
externalId: fileName,
|
||||||
|
kind: still ? "photo" : "video",
|
||||||
|
storage: "uploaded",
|
||||||
|
storagePath: path.relative(root, file),
|
||||||
|
proxyPath: null,
|
||||||
|
};
|
||||||
|
store.assets.push(row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
addPerson(id, mode, consent = {}) {
|
||||||
|
const at = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const row: PersonRow = {
|
||||||
|
id,
|
||||||
|
spaceId: SPACE,
|
||||||
|
displayName: id,
|
||||||
|
anonymizeByDefault: mode,
|
||||||
|
consentRecordedAt: consent.consent ? at : null,
|
||||||
|
ageVerifiedAt: consent.age ? at : null,
|
||||||
|
ageVerificationMethod: consent.age ? "document_on_file" : "none",
|
||||||
|
syntheticSeed: null,
|
||||||
|
centroid: null,
|
||||||
|
};
|
||||||
|
store.people.push(row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
async analyseFaces(assetId, personIds, sampleFps = 10) {
|
||||||
|
const asset = store.assets.find((row) => row.id === assetId)!;
|
||||||
|
const tracked = await emulator.trackFaces({
|
||||||
|
path: path.join(root, asset.storagePath!),
|
||||||
|
sampleFps,
|
||||||
|
});
|
||||||
|
const dir = path.join(root, SPACE, "faces", assetId);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
for (const [index, track] of tracked.tracks.entries()) {
|
||||||
|
const file = path.join(dir, `${track.trackId}.json`);
|
||||||
|
await writeFile(file, JSON.stringify({ keyframes: track.keyframes }));
|
||||||
|
const times = track.keyframes.map((k) => k.tMs);
|
||||||
|
store.tracks.push({
|
||||||
|
id: nextId("track"),
|
||||||
|
assetId,
|
||||||
|
personId: personIds[index] ?? null,
|
||||||
|
startMs: Math.min(...times),
|
||||||
|
endMs: Math.max(...times),
|
||||||
|
confidenceMin: 0.9,
|
||||||
|
keyframesPath: path.relative(root, file),
|
||||||
|
modelVersion: "emulator",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async learnPerson(personId, assetId, trackIndex = 0) {
|
||||||
|
const asset = store.assets.find((row) => row.id === assetId)!;
|
||||||
|
const tracked = await emulator.trackFaces({
|
||||||
|
path: path.join(root, asset.storagePath!),
|
||||||
|
sampleFps: 2,
|
||||||
|
});
|
||||||
|
const embedding = tracked.tracks[trackIndex]?.embedding;
|
||||||
|
const person = store.people.find((row) => row.id === personId)!;
|
||||||
|
person.centroid = embedding ? encodeVectors([embedding]) : null;
|
||||||
|
},
|
||||||
|
async workDir(name) {
|
||||||
|
const dir = path.join(root, SPACE, "renders", name);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
},
|
||||||
|
async cleanup() {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
264
packages/adapters/src/media-privacy-io.ts
Normal file
264
packages/adapters/src/media-privacy-io.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import { constants } from "node:fs";
|
||||||
|
import { copyFile, mkdir, readdir, readFile, realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { MediaInferenceProvider } from "@rakazo/adapter-kit";
|
||||||
|
import type {
|
||||||
|
BoxKeyframe,
|
||||||
|
MediaCapabilities,
|
||||||
|
MediaInferenceOperation,
|
||||||
|
MediaRenderQuality,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { MediaRepos } from "@rakazo/db";
|
||||||
|
import { assertInside, resolveInside } from "@rakazo/media/node";
|
||||||
|
import type { MediaServiceDeps } from "./media-context.js";
|
||||||
|
import { probeMedia, runMediaTool } from "./media-folder-source-probe.js";
|
||||||
|
import { maskBounds, readPngLuma } from "./media-privacy-pixels.js";
|
||||||
|
import type { PixelBox, TrackKeyframe } from "./media-privacy-plan.js";
|
||||||
|
import { parseStoredKeyframes } from "./media-privacy-plan.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* File and tool helpers shared by anonymization, marks, and the gate. Stored
|
||||||
|
* media paths are relative to the media root; every resolution goes through
|
||||||
|
* the root checks of @rakazo/media/node. ffmpeg runs as an argv array only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A redaction or check that cannot be carried out. Rendering stops instead of exporting. */
|
||||||
|
export class PrivacyUnavailableError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PrivacyUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivacyToolOptions {
|
||||||
|
ffmpegPath?: string;
|
||||||
|
ffprobePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute path of a stored (root-relative) or already absolute path inside the media root. */
|
||||||
|
export async function resolveStored(root: string, stored: string): Promise<string> {
|
||||||
|
return path.isAbsolute(stored) ? assertInside(root, stored) : resolveInside(root, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isInsideRoot(root: string, file: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await assertInside(root, file);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The path of a file relative to the media root, for storing. */
|
||||||
|
export async function toStored(root: string, absolute: string): Promise<string> {
|
||||||
|
const inside = await assertInside(root, absolute);
|
||||||
|
return path.relative(await realpath(root), inside);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssetRow = Awaited<ReturnType<MediaRepos["getAssetRecord"]>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file a render reads for an asset: the 720p proxy for drafts (when one
|
||||||
|
* exists), otherwise the original. Originals still on a device cannot render.
|
||||||
|
*/
|
||||||
|
export async function resolveAssetFile(
|
||||||
|
deps: Pick<MediaServiceDeps, "repos" | "sources" | "mediaRoot">,
|
||||||
|
spaceId: string,
|
||||||
|
asset: AssetRow,
|
||||||
|
quality: MediaRenderQuality,
|
||||||
|
): Promise<string> {
|
||||||
|
if (quality === "draft" && asset.proxyPath) return resolveStored(deps.mediaRoot, asset.proxyPath);
|
||||||
|
return resolveOriginal(deps, spaceId, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveOriginal(
|
||||||
|
deps: Pick<MediaServiceDeps, "repos" | "sources" | "mediaRoot">,
|
||||||
|
spaceId: string,
|
||||||
|
asset: AssetRow,
|
||||||
|
): Promise<string> {
|
||||||
|
if (asset.storage === "uploaded") {
|
||||||
|
if (!asset.storagePath) throw new PrivacyUnavailableError(`asset ${asset.id} has no file yet`);
|
||||||
|
return resolveStored(deps.mediaRoot, asset.storagePath);
|
||||||
|
}
|
||||||
|
if (asset.storage === "referenced") {
|
||||||
|
const source = await deps.repos.getSource(spaceId, asset.sourceId);
|
||||||
|
const provider = deps.sources[source.kind as "folder" | "emulator"];
|
||||||
|
if (!provider) throw new PrivacyUnavailableError(`source of asset ${asset.id} is unavailable`);
|
||||||
|
return provider.resolve(source.config, asset.externalId);
|
||||||
|
}
|
||||||
|
throw new PrivacyUnavailableError(`asset ${asset.id} is still on a device; upload it first`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file inference can read: the proxy when it exists (normalized coordinates
|
||||||
|
* and the timeline match the original), else the original when it lies inside
|
||||||
|
* the media root. Null when neither does.
|
||||||
|
*/
|
||||||
|
export async function analysisFile(
|
||||||
|
deps: Pick<MediaServiceDeps, "repos" | "sources" | "mediaRoot">,
|
||||||
|
spaceId: string,
|
||||||
|
asset: AssetRow,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (asset.proxyPath) return resolveStored(deps.mediaRoot, asset.proxyPath);
|
||||||
|
try {
|
||||||
|
const original = await resolveOriginal(deps, spaceId, asset);
|
||||||
|
return (await isInsideRoot(deps.mediaRoot, original)) ? original : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProbedSource {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
durationMs: number;
|
||||||
|
fps: number;
|
||||||
|
hasAudio: boolean;
|
||||||
|
rotation: 0 | 90 | 180 | 270;
|
||||||
|
still: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display size (rotation applied), duration, and audio of a source file. */
|
||||||
|
export async function probeSource(
|
||||||
|
file: string,
|
||||||
|
kind: string,
|
||||||
|
options: PrivacyToolOptions & { signal?: AbortSignal } = {},
|
||||||
|
): Promise<ProbedSource> {
|
||||||
|
const probed = await probeMedia(file, {
|
||||||
|
ffprobePath: options.ffprobePath,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
if (!probed.hasVideo || !probed.width || !probed.height)
|
||||||
|
throw new PrivacyUnavailableError("source has no readable video stream");
|
||||||
|
const still = kind === "photo" || !probed.durationMs;
|
||||||
|
const rotation = (((Math.round(probed.rotation / 90) * 90) % 360) + 360) % 360;
|
||||||
|
return {
|
||||||
|
width: probed.width,
|
||||||
|
height: probed.height,
|
||||||
|
durationMs: still ? 0 : (probed.durationMs ?? 0),
|
||||||
|
fps: probed.fps ?? 30,
|
||||||
|
hasAudio: probed.hasAudio,
|
||||||
|
rotation: rotation as ProbedSource["rotation"],
|
||||||
|
still,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy a file into `dir` under its own name (reflink when the filesystem allows). */
|
||||||
|
export async function copyInto(file: string, dir: string): Promise<string> {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const target = path.join(dir, path.basename(file));
|
||||||
|
await copyFile(file, target, constants.COPYFILE_FICLONE);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyframes of a stored face track file (relative to the media root). */
|
||||||
|
export async function readTrackKeyframes(root: string, stored: string): Promise<TrackKeyframe[]> {
|
||||||
|
const file = await resolveStored(root, stored);
|
||||||
|
return parseStoredKeyframes(JSON.parse(await readFile(file, "utf8")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mask frame times of a mask directory, ascending. */
|
||||||
|
export async function maskTimes(maskDir: string): Promise<number[]> {
|
||||||
|
const names = await readdir(maskDir);
|
||||||
|
return names
|
||||||
|
.filter((name) => /^\d+\.png$/.test(name))
|
||||||
|
.map((name) => Number.parseInt(name, 10))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounding box per mask frame. Empty masks are skipped. */
|
||||||
|
export async function maskKeyframes(maskDir: string): Promise<BoxKeyframe[]> {
|
||||||
|
const keyframes: BoxKeyframe[] = [];
|
||||||
|
for (const tMs of await maskTimes(maskDir)) {
|
||||||
|
const box = maskBounds(await readPngLuma(path.join(maskDir, `${tMs}.png`)));
|
||||||
|
if (box) keyframes.push({ tMs, box });
|
||||||
|
}
|
||||||
|
return keyframes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an inference operation is available; synthetic identity also needs a commercial license. */
|
||||||
|
export function operationAvailable(
|
||||||
|
capabilities: MediaCapabilities | null,
|
||||||
|
operation: MediaInferenceOperation,
|
||||||
|
): boolean {
|
||||||
|
const entry = capabilities?.operations[operation];
|
||||||
|
if (!entry?.available) return false;
|
||||||
|
if (operation === "synthesizeIdentity") return entry.commercialUse === true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inferenceCapabilities(
|
||||||
|
inference: MediaInferenceProvider | null,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MediaCapabilities | null> {
|
||||||
|
return inference ? inference.capabilities(signal) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lossless static crop of a video (or a still to PNG). Audio is copied; all
|
||||||
|
* container metadata is dropped.
|
||||||
|
*/
|
||||||
|
export async function cropFile(
|
||||||
|
input: string,
|
||||||
|
output: string,
|
||||||
|
crop: PixelBox,
|
||||||
|
still: boolean,
|
||||||
|
options: PrivacyToolOptions & { signal?: AbortSignal } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const filter = `crop=${crop.w}:${crop.h}:${crop.x}:${crop.y}`;
|
||||||
|
const args = still
|
||||||
|
? [
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
input,
|
||||||
|
"-vf",
|
||||||
|
filter,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
output,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
input,
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-map",
|
||||||
|
"0:a?",
|
||||||
|
"-vf",
|
||||||
|
filter,
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-qp",
|
||||||
|
"0",
|
||||||
|
"-preset",
|
||||||
|
"ultrafast",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
output,
|
||||||
|
];
|
||||||
|
await runMediaTool(options.ffmpegPath ?? "ffmpeg", args, { signal: options.signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First frame of a video as PNG (redaction providers write videos, stills render from images). */
|
||||||
|
export async function firstFrame(
|
||||||
|
input: string,
|
||||||
|
output: string,
|
||||||
|
options: PrivacyToolOptions & { signal?: AbortSignal } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await runMediaTool(
|
||||||
|
options.ffmpegPath ?? "ffmpeg",
|
||||||
|
["-v", "error", "-y", "-i", input, "-frames:v", "1", "-map_metadata", "-1", output],
|
||||||
|
{ signal: options.signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
104
packages/adapters/src/media-privacy-pixels.test.ts
Normal file
104
packages/adapters/src/media-privacy-pixels.test.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { encodeMaskPng } from "./media-emulator.js";
|
||||||
|
import type { LumaFrame } from "./media-privacy-pixels.js";
|
||||||
|
import {
|
||||||
|
controlAgreement,
|
||||||
|
decodePngLuma,
|
||||||
|
maskBounds,
|
||||||
|
residualVisibility,
|
||||||
|
temporalStability,
|
||||||
|
} from "./media-privacy-pixels.js";
|
||||||
|
|
||||||
|
/** A 64x64 checkerboard of 4 px squares: detail everywhere. */
|
||||||
|
function checker(width = 64, height = 64, shift = 0): LumaFrame {
|
||||||
|
const luma = new Uint8Array(width * height);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
luma[y * width + x] = (Math.floor((x + shift) / 4) + Math.floor(y / 4)) % 2 ? 220 : 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width, height, luma };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(
|
||||||
|
frame: LumaFrame,
|
||||||
|
box: { x: number; y: number; w: number; h: number },
|
||||||
|
value: number,
|
||||||
|
) {
|
||||||
|
const out = { ...frame, luma: frame.luma.slice() };
|
||||||
|
for (
|
||||||
|
let y = Math.floor(box.y * frame.height);
|
||||||
|
y < Math.ceil((box.y + box.h) * frame.height);
|
||||||
|
y++
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let x = Math.floor(box.x * frame.width);
|
||||||
|
x < Math.ceil((box.x + box.w) * frame.width);
|
||||||
|
x++
|
||||||
|
) {
|
||||||
|
out.luma[y * frame.width + x] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = { x: 0.25, y: 0.25, w: 0.5, h: 0.5 };
|
||||||
|
|
||||||
|
describe("PNG masks", () => {
|
||||||
|
it("decodes the masks the segmenter writes", () => {
|
||||||
|
const mask = decodePngLuma(encodeMaskPng(40, 20, { x: 0.5, y: 0.25, w: 0.25, h: 0.5 }));
|
||||||
|
expect(mask).toMatchObject({ width: 40, height: 20 });
|
||||||
|
expect(maskBounds(mask)).toEqual({ x: 0.5, y: 0.25, w: 0.25, h: 0.5 });
|
||||||
|
expect(maskBounds(decodePngLuma(encodeMaskPng(8, 8, { x: 0, y: 0, w: 0, h: 0 })))).toBeNull();
|
||||||
|
expect(() => decodePngLuma(Buffer.from("nope"))).toThrow("not a PNG");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("residual visibility", () => {
|
||||||
|
it("is 1 for untouched detail and 0 for a filled or flat region", () => {
|
||||||
|
const source = checker();
|
||||||
|
expect(residualVisibility({ source, treated: source, box: region })).toBe(1);
|
||||||
|
expect(residualVisibility({ source, treated: fill(source, region, 128), box: region })).toBe(0);
|
||||||
|
const flat = fill(source, region, 90);
|
||||||
|
expect(
|
||||||
|
residualVisibility({ source: flat, treated: flat, box: { x: 0.3, y: 0.3, w: 0.4, h: 0.4 } }),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts only the part a crop still shows and only mask pixels", () => {
|
||||||
|
const source = checker();
|
||||||
|
// The treated frame is the right half of the source, untouched.
|
||||||
|
const half: LumaFrame = { width: 32, height: 64, luma: new Uint8Array(32 * 64) };
|
||||||
|
for (let y = 0; y < 64; y++)
|
||||||
|
half.luma.set(source.luma.subarray(y * 64 + 32, y * 64 + 64), y * 32);
|
||||||
|
const left = { x: 0, y: 0.25, w: 0.4, h: 0.5 };
|
||||||
|
expect(residualVisibility({ source, treated: half, box: left, offset: { x: 32, y: 0 } })).toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
residualVisibility({ source, treated: half, box: region, offset: { x: 32, y: 0 } }),
|
||||||
|
).toBe(1);
|
||||||
|
const mask = decodePngLuma(encodeMaskPng(64, 64, { x: 0.5, y: 0.25, w: 0.25, h: 0.5 }));
|
||||||
|
const treated = fill(source, { x: 0.25, y: 0.25, w: 0.25, h: 0.5 }, 128);
|
||||||
|
expect(residualVisibility({ source, treated, box: region, mask })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects frames that are not the same moment", () => {
|
||||||
|
const source = checker();
|
||||||
|
const treated = fill(source, region, 128);
|
||||||
|
expect(controlAgreement({ source, treated, boxes: [region] })).toBe(1);
|
||||||
|
expect(controlAgreement({ source, treated: checker(64, 64, 2), boxes: [region] })).toBeLessThan(
|
||||||
|
0.8,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("temporal stability", () => {
|
||||||
|
it("drops when the treated region flickers more than the frame", () => {
|
||||||
|
const a = checker();
|
||||||
|
expect(temporalStability([a, a, a], region)).toBe(1);
|
||||||
|
const flicker = [fill(a, region, 0), fill(a, region, 255), fill(a, region, 0)];
|
||||||
|
expect(temporalStability(flicker, region)).toBe(0);
|
||||||
|
expect(temporalStability([a], region)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
310
packages/adapters/src/media-privacy-pixels.ts
Normal file
310
packages/adapters/src/media-privacy-pixels.ts
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { inflateSync } from "node:zlib";
|
||||||
|
import type { NormalizedBox } from "@rakazo/contracts";
|
||||||
|
import { runMediaTool } from "./media-folder-source-probe.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pixel checks for the privacy gate and mark quality. Video frames are decoded
|
||||||
|
* by ffmpeg straight to 8-bit luma; mask PNGs from the segmenter go through the
|
||||||
|
* decoder below (8-bit gray, gray+alpha, RGB, RGBA; not interlaced, not palette).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LumaFrame {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** One luma byte per pixel, row-major. */
|
||||||
|
luma: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 4: 2, 6: 4 };
|
||||||
|
|
||||||
|
function paeth(a: number, b: number, c: number): number {
|
||||||
|
const p = a + b - c;
|
||||||
|
const pa = Math.abs(p - a);
|
||||||
|
const pb = Math.abs(p - b);
|
||||||
|
const pc = Math.abs(p - c);
|
||||||
|
if (pa <= pb && pa <= pc) return a;
|
||||||
|
return pb <= pc ? b : c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a PNG to luma (Rec. 601 weights). Throws on anything it cannot read exactly. */
|
||||||
|
export function decodePngLuma(png: Buffer): LumaFrame {
|
||||||
|
if (png.length < 8 || !png.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("not a PNG");
|
||||||
|
let offset = 8;
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
let channels = 0;
|
||||||
|
const data: Buffer[] = [];
|
||||||
|
while (offset + 8 <= png.length) {
|
||||||
|
const length = png.readUInt32BE(offset);
|
||||||
|
const type = png.toString("ascii", offset + 4, offset + 8);
|
||||||
|
const body = png.subarray(offset + 8, offset + 8 + length);
|
||||||
|
offset += 12 + length;
|
||||||
|
if (type === "IHDR") {
|
||||||
|
width = body.readUInt32BE(0);
|
||||||
|
height = body.readUInt32BE(4);
|
||||||
|
const depth = body[8];
|
||||||
|
const colorType = body[9] ?? -1;
|
||||||
|
const interlace = body[12];
|
||||||
|
channels = CHANNELS[colorType] ?? 0;
|
||||||
|
if (depth !== 8 || channels === 0 || interlace !== 0)
|
||||||
|
throw new Error("unsupported PNG layout");
|
||||||
|
} else if (type === "IDAT") {
|
||||||
|
data.push(body);
|
||||||
|
} else if (type === "IEND") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (width === 0 || height === 0 || channels === 0) throw new Error("PNG has no image header");
|
||||||
|
const raw = inflateSync(Buffer.concat(data));
|
||||||
|
const stride = width * channels;
|
||||||
|
if (raw.length < (stride + 1) * height) throw new Error("PNG data is truncated");
|
||||||
|
const pixels = Buffer.alloc(stride * height);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const filter = raw[y * (stride + 1)];
|
||||||
|
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
||||||
|
const row = y * stride;
|
||||||
|
for (let x = 0; x < stride; x++) {
|
||||||
|
const left = x >= channels ? (pixels[row + x - channels] ?? 0) : 0;
|
||||||
|
const up = y > 0 ? (pixels[row - stride + x] ?? 0) : 0;
|
||||||
|
const upLeft = y > 0 && x >= channels ? (pixels[row - stride + x - channels] ?? 0) : 0;
|
||||||
|
const value = line[x] ?? 0;
|
||||||
|
let out: number;
|
||||||
|
if (filter === 0) out = value;
|
||||||
|
else if (filter === 1) out = value + left;
|
||||||
|
else if (filter === 2) out = value + up;
|
||||||
|
else if (filter === 3) out = value + ((left + up) >> 1);
|
||||||
|
else if (filter === 4) out = value + paeth(left, up, upLeft);
|
||||||
|
else throw new Error("unknown PNG filter");
|
||||||
|
pixels[row + x] = out & 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const luma = new Uint8Array(width * height);
|
||||||
|
for (let i = 0; i < width * height; i++) {
|
||||||
|
const base = i * channels;
|
||||||
|
if (channels <= 2) {
|
||||||
|
luma[i] = pixels[base] ?? 0;
|
||||||
|
} else {
|
||||||
|
const r = pixels[base] ?? 0;
|
||||||
|
const g = pixels[base + 1] ?? 0;
|
||||||
|
const b = pixels[base + 2] ?? 0;
|
||||||
|
luma[i] = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width, height, luma };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPngLuma(file: string): Promise<LumaFrame> {
|
||||||
|
return decodePngLuma(await readFile(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the frame shown at `tMs` as luma, scaled to `width` x `height` with
|
||||||
|
* nearest-neighbour sampling (a no-op at the display size). Throws unless
|
||||||
|
* ffmpeg returns a full frame.
|
||||||
|
*/
|
||||||
|
export async function extractLuma(
|
||||||
|
file: string,
|
||||||
|
input: { tMs: number; width: number; height: number; still?: boolean },
|
||||||
|
options: { ffmpegPath?: string; signal?: AbortSignal } = {},
|
||||||
|
): Promise<LumaFrame> {
|
||||||
|
const seek = input.still ? [] : ["-ss", (Math.max(0, input.tMs) / 1000).toFixed(3)];
|
||||||
|
const { stdout } = await runMediaTool(
|
||||||
|
options.ffmpegPath ?? "ffmpeg",
|
||||||
|
[
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
...seek,
|
||||||
|
"-i",
|
||||||
|
file,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
`scale=${input.width}:${input.height}:flags=neighbor`,
|
||||||
|
"-f",
|
||||||
|
"rawvideo",
|
||||||
|
"-pix_fmt",
|
||||||
|
"gray",
|
||||||
|
"-",
|
||||||
|
],
|
||||||
|
{ signal: options.signal },
|
||||||
|
);
|
||||||
|
const size = input.width * input.height;
|
||||||
|
if (stdout.length < size) throw new Error(`no frame at ${input.tMs} ms`);
|
||||||
|
return {
|
||||||
|
width: input.width,
|
||||||
|
height: input.height,
|
||||||
|
luma: new Uint8Array(stdout.subarray(0, size)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounding box of the bright (>= 128) pixels of a mask, or null when the mask is empty. */
|
||||||
|
export function maskBounds(mask: LumaFrame): NormalizedBox | null {
|
||||||
|
let minX = mask.width;
|
||||||
|
let minY = mask.height;
|
||||||
|
let maxX = -1;
|
||||||
|
let maxY = -1;
|
||||||
|
for (let y = 0; y < mask.height; y++) {
|
||||||
|
for (let x = 0; x < mask.width; x++) {
|
||||||
|
if ((mask.luma[y * mask.width + x] ?? 0) < 128) continue;
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x);
|
||||||
|
maxY = Math.max(maxY, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxX < 0) return null;
|
||||||
|
return {
|
||||||
|
x: minX / mask.width,
|
||||||
|
y: minY / mask.height,
|
||||||
|
w: (maxX + 1 - minX) / mask.width,
|
||||||
|
h: (maxY + 1 - minY) / mask.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A luma step at least this large marks a detail pixel (an edge someone could recognise). */
|
||||||
|
export const DETAIL_MIN_STEP = 12;
|
||||||
|
/** A detail pixel within this many luma levels of the source counts as unchanged. */
|
||||||
|
export const UNCHANGED_MAX_DIFF = 2;
|
||||||
|
/** Looser tolerance for the control area outside treated regions (codec noise). */
|
||||||
|
const CONTROL_MAX_DIFF = 6;
|
||||||
|
/** Regions with fewer detail pixels show nothing identifying. */
|
||||||
|
const MIN_DETAIL_PIXELS = 16;
|
||||||
|
|
||||||
|
function pixelRange(box: NormalizedBox, width: number, height: number) {
|
||||||
|
return {
|
||||||
|
x0: Math.max(0, Math.floor(box.x * width)),
|
||||||
|
y0: Math.max(0, Math.floor(box.y * height)),
|
||||||
|
x1: Math.min(width, Math.ceil((box.x + box.w) * width)),
|
||||||
|
y1: Math.min(height, Math.ceil((box.y + box.h) * height)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailStep(frame: LumaFrame, x: number, y: number): number {
|
||||||
|
const at = y * frame.width + x;
|
||||||
|
const value = frame.luma[at] ?? 0;
|
||||||
|
const right = x + 1 < frame.width ? (frame.luma[at + 1] ?? value) : value;
|
||||||
|
const down = y + 1 < frame.height ? (frame.luma[at + frame.width] ?? value) : value;
|
||||||
|
return Math.abs(value - right) + Math.abs(value - down);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Residual visibility of a region (0..1): the share of the source's detail
|
||||||
|
* pixels (edges) that the treated frame left unchanged, value and edge
|
||||||
|
* strength alike. Blur, pixelation, fills, and inpainting all rewrite edges,
|
||||||
|
* so a treated region scores near 0, an untouched one 1. A flat region
|
||||||
|
* without detail scores 0: there is nothing to recognise.
|
||||||
|
*
|
||||||
|
* `source` and `treated` are pixel-aligned at full resolution; when `treated`
|
||||||
|
* is a crop of the source, `offset` is the crop origin in source pixels and
|
||||||
|
* detail outside the crop is invisible. Optional `mask` (normalized over the
|
||||||
|
* source frame) limits the region to mask pixels.
|
||||||
|
*/
|
||||||
|
export function residualVisibility(input: {
|
||||||
|
source: LumaFrame;
|
||||||
|
treated: LumaFrame;
|
||||||
|
box: NormalizedBox;
|
||||||
|
offset?: { x: number; y: number };
|
||||||
|
mask?: LumaFrame;
|
||||||
|
}): number {
|
||||||
|
const { source, treated, box, mask } = input;
|
||||||
|
const offset = input.offset ?? { x: 0, y: 0 };
|
||||||
|
const { x0, y0, x1, y1 } = pixelRange(box, source.width, source.height);
|
||||||
|
let detail = 0;
|
||||||
|
let unchanged = 0;
|
||||||
|
for (let y = y0; y < y1; y++) {
|
||||||
|
for (let x = x0; x < x1; x++) {
|
||||||
|
if (mask) {
|
||||||
|
const mx = Math.min(mask.width - 1, Math.floor((x / source.width) * mask.width));
|
||||||
|
const my = Math.min(mask.height - 1, Math.floor((y / source.height) * mask.height));
|
||||||
|
if ((mask.luma[my * mask.width + mx] ?? 0) < 128) continue;
|
||||||
|
}
|
||||||
|
const step = detailStep(source, x, y);
|
||||||
|
if (step < DETAIL_MIN_STEP) continue;
|
||||||
|
const tx = x - offset.x;
|
||||||
|
const ty = y - offset.y;
|
||||||
|
if (tx < 0 || ty < 0 || tx >= treated.width || ty >= treated.height) continue;
|
||||||
|
detail += 1;
|
||||||
|
const value = source.luma[y * source.width + x] ?? 0;
|
||||||
|
// Visible means the pixel and its edge survived, not a value that happens to match.
|
||||||
|
if (
|
||||||
|
Math.abs(value - (treated.luma[ty * treated.width + tx] ?? 0)) <= UNCHANGED_MAX_DIFF &&
|
||||||
|
Math.abs(step - detailStep(treated, tx, ty)) <= 2 * UNCHANGED_MAX_DIFF
|
||||||
|
)
|
||||||
|
unchanged += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detail < MIN_DETAIL_PIXELS ? 0 : unchanged / detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share of detail pixels outside every treated box that the treated frame kept
|
||||||
|
* (within codec tolerance). A low value means the two frames are not the same
|
||||||
|
* moment or framing, so a residual measured between them proves nothing.
|
||||||
|
* Returns 1 when the control area has too little detail to judge.
|
||||||
|
*/
|
||||||
|
export function controlAgreement(input: {
|
||||||
|
source: LumaFrame;
|
||||||
|
treated: LumaFrame;
|
||||||
|
boxes: NormalizedBox[];
|
||||||
|
offset?: { x: number; y: number };
|
||||||
|
}): number {
|
||||||
|
const { source, treated, boxes } = input;
|
||||||
|
const offset = input.offset ?? { x: 0, y: 0 };
|
||||||
|
const ranges = boxes.map((box) => pixelRange(box, source.width, source.height));
|
||||||
|
let detail = 0;
|
||||||
|
let kept = 0;
|
||||||
|
for (let y = 0; y < source.height; y++) {
|
||||||
|
for (let x = 0; x < source.width; x++) {
|
||||||
|
const tx = x - offset.x;
|
||||||
|
const ty = y - offset.y;
|
||||||
|
if (tx < 0 || ty < 0 || tx >= treated.width || ty >= treated.height) continue;
|
||||||
|
if (ranges.some((r) => x >= r.x0 - 2 && x < r.x1 + 2 && y >= r.y0 - 2 && y < r.y1 + 2))
|
||||||
|
continue;
|
||||||
|
if (detailStep(source, x, y) < DETAIL_MIN_STEP) continue;
|
||||||
|
detail += 1;
|
||||||
|
const value = source.luma[y * source.width + x] ?? 0;
|
||||||
|
if (Math.abs(value - (treated.luma[ty * treated.width + tx] ?? 0)) <= CONTROL_MAX_DIFF)
|
||||||
|
kept += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detail < MIN_DETAIL_PIXELS ? 1 : kept / detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporal stability of a treated region over consecutive frames (0..1):
|
||||||
|
* 1 when the region changes no more between neighbours than the rest of the
|
||||||
|
* frame, falling to 0 as it flickers 24+ luma levels more on average.
|
||||||
|
*/
|
||||||
|
export function temporalStability(frames: LumaFrame[], box: NormalizedBox): number {
|
||||||
|
if (frames.length < 2) return 1;
|
||||||
|
let excess = 0;
|
||||||
|
for (let i = 1; i < frames.length; i++) {
|
||||||
|
const a = frames[i - 1]!;
|
||||||
|
const b = frames[i]!;
|
||||||
|
if (a.width !== b.width || a.height !== b.height) throw new Error("frame sizes differ");
|
||||||
|
const r = pixelRange(box, a.width, a.height);
|
||||||
|
let inside = 0;
|
||||||
|
let insideCount = 0;
|
||||||
|
let outside = 0;
|
||||||
|
let outsideCount = 0;
|
||||||
|
for (let y = 0; y < a.height; y++) {
|
||||||
|
for (let x = 0; x < a.width; x++) {
|
||||||
|
const at = y * a.width + x;
|
||||||
|
const diff = Math.abs((a.luma[at] ?? 0) - (b.luma[at] ?? 0));
|
||||||
|
if (x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1) {
|
||||||
|
inside += diff;
|
||||||
|
insideCount += 1;
|
||||||
|
} else {
|
||||||
|
outside += diff;
|
||||||
|
outsideCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const meanInside = insideCount ? inside / insideCount : 0;
|
||||||
|
const meanOutside = outsideCount ? outside / outsideCount : 0;
|
||||||
|
excess += Math.max(0, meanInside - meanOutside);
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(1, 1 - excess / (frames.length - 1) / 24));
|
||||||
|
}
|
||||||
210
packages/adapters/src/media-privacy-plan.test.ts
Normal file
210
packages/adapters/src/media-privacy-plan.test.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import type { FaceRedaction } from "@rakazo/contracts";
|
||||||
|
import { encodeVectors } from "@rakazo/media";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { mergeTracks, syntheticSeedFor } from "./media-anonymize.js";
|
||||||
|
import { testEdl } from "./media-privacy-fixtures.js";
|
||||||
|
import {
|
||||||
|
boxAt,
|
||||||
|
cropPixels,
|
||||||
|
effectiveModes,
|
||||||
|
ensureMinSize,
|
||||||
|
GAP_HOLD_GROWTH,
|
||||||
|
matchPerson,
|
||||||
|
outsideCrop,
|
||||||
|
padBox,
|
||||||
|
parseStoredKeyframes,
|
||||||
|
protectKeyframes,
|
||||||
|
resolveProtection,
|
||||||
|
sourceToOutput,
|
||||||
|
} from "./media-privacy-plan.js";
|
||||||
|
|
||||||
|
const box = { x: 0.4, y: 0.4, w: 0.1, h: 0.1 };
|
||||||
|
|
||||||
|
function face(
|
||||||
|
id: string,
|
||||||
|
target: FaceRedaction["target"],
|
||||||
|
mode: FaceRedaction["mode"],
|
||||||
|
): FaceRedaction {
|
||||||
|
return { id, target, mode, paddingPct: 20, featherPct: 10, minSizePct: 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("protectKeyframes", () => {
|
||||||
|
it("interpolates short gaps and holds a grown box over long ones for review", () => {
|
||||||
|
const result = protectKeyframes(
|
||||||
|
[
|
||||||
|
{ tMs: 0, box },
|
||||||
|
{ tMs: 400, box: { ...box, x: 0.44 } },
|
||||||
|
{ tMs: 1400, box: { ...box, x: 0.5 } },
|
||||||
|
],
|
||||||
|
[{ startMs: 0, endMs: 2000 }],
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(result.gaps).toEqual([{ startMs: 400, endMs: 1400 }]);
|
||||||
|
expect(result.keyframes.map((k) => k.tMs)).toEqual([0, 100, 200, 300, 400, 401, 1399, 1400]);
|
||||||
|
const held = result.keyframes.find((k) => k.tMs === 401)!.box;
|
||||||
|
expect(held.w).toBeCloseTo(0.1 * (1 + 2 * GAP_HOLD_GROWTH), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats weak detections as lost but still covers them", () => {
|
||||||
|
const result = protectKeyframes(
|
||||||
|
[
|
||||||
|
{ tMs: 0, box, confidence: 0.9 },
|
||||||
|
{ tMs: 400, box: { ...box, x: 0.8 }, confidence: 0.2 },
|
||||||
|
{ tMs: 800, box, confidence: 0.9 },
|
||||||
|
],
|
||||||
|
[{ startMs: 0, endMs: 1000 }],
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(result.gaps).toEqual([{ startMs: 0, endMs: 800 }]);
|
||||||
|
const held = result.keyframes.find((k) => k.tMs === 1)!.box;
|
||||||
|
expect(held.x + held.w).toBeGreaterThanOrEqual(0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores detections far outside the clip ranges and enforces a minimum size", () => {
|
||||||
|
const result = protectKeyframes(
|
||||||
|
[
|
||||||
|
{ tMs: 0, box: { x: 0.5, y: 0.5, w: 0.001, h: 0.001 } },
|
||||||
|
{ tMs: 5000, box },
|
||||||
|
],
|
||||||
|
[{ startMs: 0, endMs: 100 }],
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(result.keyframes).toHaveLength(1);
|
||||||
|
expect(result.keyframes[0]!.box.w).toBeGreaterThanOrEqual(0.05);
|
||||||
|
expect(ensureMinSize({ x: 0.99, y: 0.99, w: 0.01, h: 0.01 }, 10)).toEqual({
|
||||||
|
x: 0.9,
|
||||||
|
y: 0.9,
|
||||||
|
w: 0.1,
|
||||||
|
h: 0.1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("protection", () => {
|
||||||
|
const defaults = new Map([
|
||||||
|
["alice", "pixelate" as const],
|
||||||
|
["bob", "none" as const],
|
||||||
|
]);
|
||||||
|
const faces = [
|
||||||
|
face("by-track", { trackId: "t1" }, "solid"),
|
||||||
|
face("by-person", { personId: "bob" }, "partial_eyes"),
|
||||||
|
face("strangers", { unassignedFaces: true }, "pixelate"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("prefers track over person over default over unassigned", () => {
|
||||||
|
const resolve = (storedId: string | null, personId: string | null, protectUnassigned = false) =>
|
||||||
|
resolveProtection({ storedId, personId }, { faces, defaults, protectUnassigned })?.mode;
|
||||||
|
expect(resolve("t1", "alice")).toBe("solid");
|
||||||
|
expect(resolve("t2", "bob")).toBe("partial_eyes");
|
||||||
|
expect(resolve("t2", "alice")).toBe("pixelate");
|
||||||
|
expect(resolve(null, null)).toBe("pixelate");
|
||||||
|
expect(
|
||||||
|
resolveProtection(
|
||||||
|
{ storedId: null, personId: null },
|
||||||
|
{ faces: [], defaults, protectUnassigned: true },
|
||||||
|
),
|
||||||
|
).toMatchObject({ mode: "blur", origin: "unassigned" });
|
||||||
|
expect(
|
||||||
|
resolveProtection(
|
||||||
|
{ storedId: null, personId: null },
|
||||||
|
{ faces: [], defaults, protectUnassigned: false },
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(effectiveModes(faces, defaults).get("bob")).toBe("partial_eyes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches faces to people only above the similarity threshold", () => {
|
||||||
|
const centroids = [
|
||||||
|
{ personId: "alice", centroid: encodeVectors([[1, 0, 0]]) },
|
||||||
|
{ personId: "bob", centroid: encodeVectors([[0, 1, 0]]) },
|
||||||
|
];
|
||||||
|
expect(matchPerson([0.9, 0.1, 0], centroids)).toBe("alice");
|
||||||
|
expect(matchPerson([0.5, 0.5, 0.7], centroids)).toBeNull();
|
||||||
|
expect(matchPerson(null, centroids)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges fresh tracks into stored ones and keeps what the fresh pass missed", () => {
|
||||||
|
const stored = [
|
||||||
|
{ id: "s1", storedId: "s1", personId: "alice", keyframes: [{ tMs: 0, box }] },
|
||||||
|
{ id: "s2", storedId: "s2", personId: null, keyframes: [{ tMs: 0, box: { ...box, x: 0 } }] },
|
||||||
|
];
|
||||||
|
const merged = mergeTracks(
|
||||||
|
"a1",
|
||||||
|
stored,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
keyframes: [
|
||||||
|
{ tMs: 0, box, confidence: 0.9 },
|
||||||
|
{ tMs: 100, box, confidence: 0.9 },
|
||||||
|
],
|
||||||
|
embedding: null,
|
||||||
|
},
|
||||||
|
{ keyframes: [{ tMs: 0, box: { ...box, y: 0.8 }, confidence: 0.9 }], embedding: [1, 0, 0] },
|
||||||
|
],
|
||||||
|
[{ personId: "carol", centroid: encodeVectors([[1, 0, 0]]) }],
|
||||||
|
);
|
||||||
|
expect(merged.map((t) => [t.id, t.personId, t.keyframes.length])).toEqual([
|
||||||
|
["s1", "alice", 3],
|
||||||
|
["fresh-a1-2", "carol", 1],
|
||||||
|
["s2", null, 1],
|
||||||
|
]);
|
||||||
|
expect(syntheticSeedFor("alice")).toBe(syntheticSeedFor("alice"));
|
||||||
|
expect(syntheticSeedFor("alice")).not.toBe(syntheticSeedFor("bob"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("geometry", () => {
|
||||||
|
it("crops with the output aspect and knows what lies outside", () => {
|
||||||
|
const geometry = { sourceWidth: 1920, sourceHeight: 1080, outWidth: 1080, outHeight: 1920 };
|
||||||
|
const crop = cropPixels(geometry, { cx: 0.8, cy: 0.5, scale: 1 });
|
||||||
|
expect(crop).toEqual({ x: 1232, y: 0, w: 608, h: 1080 });
|
||||||
|
expect(outsideCrop({ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }, crop, 1920, 1080)).toBe(true);
|
||||||
|
expect(outsideCrop({ x: 0.6, y: 0.1, w: 0.2, h: 0.2 }, crop, 1920, 1080)).toBe(false);
|
||||||
|
expect(padBox(box, 20)).toEqual({
|
||||||
|
x: 0.39,
|
||||||
|
y: 0.39,
|
||||||
|
w: expect.closeTo(0.12),
|
||||||
|
h: expect.closeTo(0.12),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps source spans onto the output timeline", () => {
|
||||||
|
const edl = testEdl([
|
||||||
|
{ id: "a", assetId: "x", inMs: 1000, outMs: 3000 },
|
||||||
|
{ id: "b", assetId: "y", inMs: 0, outMs: 1000 },
|
||||||
|
]);
|
||||||
|
const [a, b] = edl.tracks.video;
|
||||||
|
expect(sourceToOutput(edl, a!, { startMs: 1500, endMs: 5000 })).toEqual({
|
||||||
|
startMs: 500,
|
||||||
|
endMs: 2000,
|
||||||
|
});
|
||||||
|
expect(sourceToOutput(edl, b!, { startMs: 200, endMs: 300 })).toEqual({
|
||||||
|
startMs: 2200,
|
||||||
|
endMs: 2300,
|
||||||
|
});
|
||||||
|
expect(sourceToOutput(edl, a!, { startMs: 0, endMs: 500 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the box between keyframes", () => {
|
||||||
|
const keyframes = [
|
||||||
|
{ tMs: 0, box },
|
||||||
|
{ tMs: 100, box: { ...box, x: 0.5 } },
|
||||||
|
];
|
||||||
|
expect(boxAt(keyframes, 50)).toEqual({
|
||||||
|
x: 0.4,
|
||||||
|
y: 0.4,
|
||||||
|
w: expect.closeTo(0.2),
|
||||||
|
h: expect.closeTo(0.1),
|
||||||
|
});
|
||||||
|
expect(boxAt(keyframes, 150)).toEqual({ ...box, x: 0.5 });
|
||||||
|
expect(boxAt(keyframes, 500)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads stored keyframe files in both shapes", () => {
|
||||||
|
expect(parseStoredKeyframes([{ tMs: 0, box, extra: 1 }])).toEqual([{ tMs: 0, box }]);
|
||||||
|
expect(parseStoredKeyframes({ keyframes: [{ tMs: 5, box, confidence: 0.4 }] })).toEqual([
|
||||||
|
{ tMs: 5, box, confidence: 0.4 },
|
||||||
|
]);
|
||||||
|
expect(() => parseStoredKeyframes({ frames: [] })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
499
packages/adapters/src/media-privacy-plan.ts
Normal file
499
packages/adapters/src/media-privacy-plan.ts
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
AnonymizeMode,
|
||||||
|
BoxKeyframe,
|
||||||
|
Edl,
|
||||||
|
EdlVideoClip,
|
||||||
|
FaceRedaction,
|
||||||
|
NormalizedBox,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { AiAlterationSchema, AnonymizeModeSchema, BoxKeyframeSchema } from "@rakazo/contracts";
|
||||||
|
import { cosineSimilarity, decodeVectors, introMs, MAX_TRACKING_GAP_MS } from "@rakazo/media";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pure planning helpers for face, mark, and region protection, plus the
|
||||||
|
* privacy record that prepareSources leaves next to the render output for the
|
||||||
|
* gate. Times are source times of the asset unless a name says otherwise;
|
||||||
|
* boxes are normalized to the displayed (rotation-applied) frame.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Tracking rate of the fresh detection pass on clip sources. */
|
||||||
|
export const FACE_TRACK_FPS = 10;
|
||||||
|
/**
|
||||||
|
* Minimum cosine similarity between a face embedding and a person centroid to
|
||||||
|
* count as that person. Below it the face is unassigned, which fails closed:
|
||||||
|
* unassigned faces are redacted and block the gate while a protected person
|
||||||
|
* appears in the project.
|
||||||
|
*/
|
||||||
|
export const FACE_MATCH_MIN_COSINE = 0.6;
|
||||||
|
/** Detections below this confidence count as lost (spec 7: hold and grow the last mask). */
|
||||||
|
export const FACE_MIN_CONFIDENCE = 0.5;
|
||||||
|
/** A fresh track inherits a stored track's person when their boxes overlap this much on average. */
|
||||||
|
export const TRACK_MATCH_MIN_IOU = 0.3;
|
||||||
|
/** Step for linear interpolation across short detection gaps (<= MAX_TRACKING_GAP_MS). */
|
||||||
|
export const INTERPOLATE_STEP_MS = 100;
|
||||||
|
/** Lost detections hold the last box, enlarged by this share on every side (spec 7). */
|
||||||
|
export const GAP_HOLD_GROWTH = 0.25;
|
||||||
|
/** Padding around marks and regions, in percent of the box. */
|
||||||
|
export const MARK_PADDING_PCT = 10;
|
||||||
|
/** Person-bound marks re-found below this segmentation score go to review and block the gate. */
|
||||||
|
export const MARK_REVIEW_MIN_SCORE = 0.6;
|
||||||
|
/** Treatment quality below this suggests (and, while rendering, applies) a fallback. */
|
||||||
|
export const MARK_QUALITY_MIN = 0.7;
|
||||||
|
|
||||||
|
/** Name of the privacy record prepareSources writes into the render work directory. */
|
||||||
|
export const PRIVACY_RECORD_FILE = "privacy.json";
|
||||||
|
|
||||||
|
export type ProtectMode = Exclude<AnonymizeMode, "none">;
|
||||||
|
export type PartialMode = "partial_eyes" | "partial_upper" | "partial_lower";
|
||||||
|
|
||||||
|
export interface Protection {
|
||||||
|
mode: ProtectMode;
|
||||||
|
paddingPct: number;
|
||||||
|
featherPct: number;
|
||||||
|
minSizePct: number;
|
||||||
|
/** Redaction id from the EDL, "default" (person setting), or "unassigned". */
|
||||||
|
origin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackKeyframe = BoxKeyframe & { confidence?: number };
|
||||||
|
|
||||||
|
export interface CandidateTrack {
|
||||||
|
/** Stable id: the stored track id, or `fresh-<n>` for faces only the fresh pass found. */
|
||||||
|
id: string;
|
||||||
|
/** Stored track id when the face matches an analysed track, for EDL track targets. */
|
||||||
|
storedId: string | null;
|
||||||
|
personId: string | null;
|
||||||
|
keyframes: TrackKeyframe[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeWindow {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROTECTION = { paddingPct: 20, featherPct: 10, minSizePct: 2 };
|
||||||
|
|
||||||
|
export function isPartial(mode: AnonymizeMode): mode is PartialMode {
|
||||||
|
return mode === "partial_eyes" || mode === "partial_upper" || mode === "partial_lower";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRedaction(redaction: FaceRedaction): Protection {
|
||||||
|
return {
|
||||||
|
mode: redaction.mode,
|
||||||
|
paddingPct: redaction.paddingPct,
|
||||||
|
featherPct: redaction.featherPct,
|
||||||
|
minSizePct: redaction.minSizePct,
|
||||||
|
origin: redaction.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protection for one face track. Most specific wins: EDL track target, EDL
|
||||||
|
* person target, the person's default, then the EDL unassigned target, and
|
||||||
|
* finally a blur for unassigned faces while any protected person appears in
|
||||||
|
* the project.
|
||||||
|
*/
|
||||||
|
export function resolveProtection(
|
||||||
|
track: Pick<CandidateTrack, "storedId" | "personId">,
|
||||||
|
context: {
|
||||||
|
faces: FaceRedaction[];
|
||||||
|
defaults: Map<string, AnonymizeMode>;
|
||||||
|
protectUnassigned: boolean;
|
||||||
|
},
|
||||||
|
): Protection | null {
|
||||||
|
if (track.storedId) {
|
||||||
|
const byTrack = context.faces.find(
|
||||||
|
(face) => "trackId" in face.target && face.target.trackId === track.storedId,
|
||||||
|
);
|
||||||
|
if (byTrack) return fromRedaction(byTrack);
|
||||||
|
}
|
||||||
|
if (track.personId) {
|
||||||
|
const byPerson = context.faces.find(
|
||||||
|
(face) => "personId" in face.target && face.target.personId === track.personId,
|
||||||
|
);
|
||||||
|
if (byPerson) return fromRedaction(byPerson);
|
||||||
|
const mode = context.defaults.get(track.personId);
|
||||||
|
if (mode && mode !== "none") return { mode, ...DEFAULT_PROTECTION, origin: "default" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const unassigned = context.faces.find((face) => "unassignedFaces" in face.target);
|
||||||
|
if (unassigned) return fromRedaction(unassigned);
|
||||||
|
if (context.protectUnassigned)
|
||||||
|
return { mode: "blur", ...DEFAULT_PROTECTION, origin: "unassigned" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective mode per person: an EDL person target wins over the person's default. */
|
||||||
|
export function effectiveModes(
|
||||||
|
faces: FaceRedaction[],
|
||||||
|
defaults: Map<string, AnonymizeMode>,
|
||||||
|
): Map<string, AnonymizeMode> {
|
||||||
|
const modes = new Map(defaults);
|
||||||
|
for (const face of faces) {
|
||||||
|
if ("personId" in face.target) modes.set(face.target.personId, face.mode);
|
||||||
|
}
|
||||||
|
return modes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best person for an embedding by cosine to the centroids, or null below the threshold. */
|
||||||
|
export function matchPerson(
|
||||||
|
embedding: ArrayLike<number> | null,
|
||||||
|
centroids: Array<{ personId: string; centroid: Uint8Array }>,
|
||||||
|
): string | null {
|
||||||
|
if (!embedding || embedding.length === 0) return null;
|
||||||
|
let best: { personId: string; score: number } | null = null;
|
||||||
|
for (const { personId, centroid } of centroids) {
|
||||||
|
if (centroid.byteLength !== embedding.length * 4) continue;
|
||||||
|
const vector = decodeVectors(centroid, embedding.length)[0];
|
||||||
|
if (!vector) continue;
|
||||||
|
const score = cosineSimilarity(embedding, vector);
|
||||||
|
if (score >= FACE_MATCH_MIN_COSINE && (!best || score > best.score)) best = { personId, score };
|
||||||
|
}
|
||||||
|
return best?.personId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampUnit = (value: number) => Math.min(1, Math.max(0, value));
|
||||||
|
|
||||||
|
/** Grow a box by `share` of its size on every side, clamped to the frame. */
|
||||||
|
export function growBox(box: NormalizedBox, share: number): NormalizedBox {
|
||||||
|
const x = clampUnit(box.x - box.w * share);
|
||||||
|
const y = clampUnit(box.y - box.h * share);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: clampUnit(box.x + box.w + box.w * share) - x,
|
||||||
|
h: clampUnit(box.y + box.h + box.h * share) - y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Padding as the redaction providers apply it: paddingPct / 2 of the size on each side. */
|
||||||
|
export function padBox(box: NormalizedBox, paddingPct: number): NormalizedBox {
|
||||||
|
return growBox(box, Math.max(0, paddingPct) / 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enlarge a box around its centre until both sides reach `minSizePct` of the frame. */
|
||||||
|
export function ensureMinSize(box: NormalizedBox, minSizePct: number): NormalizedBox {
|
||||||
|
const min = minSizePct / 100;
|
||||||
|
if (box.w >= min && box.h >= min) return box;
|
||||||
|
const w = Math.min(1, Math.max(box.w, min));
|
||||||
|
const h = Math.min(1, Math.max(box.h, min));
|
||||||
|
const x = clampUnit(Math.min(box.x + box.w / 2 - w / 2, 1 - w));
|
||||||
|
const y = clampUnit(Math.min(box.y + box.h / 2 - h / 2, 1 - h));
|
||||||
|
return { x, y, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unionBox(a: NormalizedBox, b: NormalizedBox): NormalizedBox {
|
||||||
|
const x = Math.min(a.x, b.x);
|
||||||
|
const y = Math.min(a.y, b.y);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: Math.max(a.x + a.w, b.x + b.w) - x,
|
||||||
|
h: Math.max(a.y + a.h, b.y + b.h) - y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iou(a: NormalizedBox, b: NormalizedBox): number {
|
||||||
|
const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
||||||
|
const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
||||||
|
if (w <= 0 || h <= 0) return 0;
|
||||||
|
const inter = w * h;
|
||||||
|
return inter / (a.w * a.h + b.w * b.h - inter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerpBox(a: NormalizedBox, b: NormalizedBox, f: number): NormalizedBox {
|
||||||
|
const mix = (p: number, q: number) => p + (q - p) * f;
|
||||||
|
return { x: mix(a.x, b.x), y: mix(a.y, b.y), w: mix(a.w, b.w), h: mix(a.h, b.h) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round outward so a rounded box never covers less than the exact one. */
|
||||||
|
export function roundBoxOut(box: NormalizedBox): NormalizedBox {
|
||||||
|
const x = Math.floor(box.x * 10_000) / 10_000;
|
||||||
|
const y = Math.floor(box.y * 10_000) / 10_000;
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: Math.min(1 - x, Math.ceil((box.x + box.w - x) * 10_000) / 10_000),
|
||||||
|
h: Math.min(1 - y, Math.ceil((box.y + box.h - y) * 10_000) / 10_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearWindows(tMs: number, windows: TimeWindow[]): boolean {
|
||||||
|
return windows.some(
|
||||||
|
(w) => tMs >= w.startMs - MAX_TRACKING_GAP_MS && tMs <= w.endMs + MAX_TRACKING_GAP_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyframes for redaction near the windows (clip ranges plus the bridging
|
||||||
|
* limit on both sides). Detections below FACE_MIN_CONFIDENCE count as lost.
|
||||||
|
* Gaps up to MAX_TRACKING_GAP_MS between confident detections are
|
||||||
|
* interpolated; longer ones hold the last confident box grown by
|
||||||
|
* GAP_HOLD_GROWTH (unioned with any weak detections) until the face is found
|
||||||
|
* again, and are returned so the gate blocks them as needing review.
|
||||||
|
*/
|
||||||
|
export function protectKeyframes(
|
||||||
|
keyframes: TrackKeyframe[],
|
||||||
|
windows: TimeWindow[],
|
||||||
|
minSizePct: number,
|
||||||
|
): { keyframes: BoxKeyframe[]; gaps: TimeWindow[] } {
|
||||||
|
const inside = [...keyframes]
|
||||||
|
.filter((k) => nearWindows(k.tMs, windows))
|
||||||
|
.sort((a, b) => a.tMs - b.tMs)
|
||||||
|
.map((k) => ({
|
||||||
|
tMs: k.tMs,
|
||||||
|
box: ensureMinSize(k.box, minSizePct),
|
||||||
|
confident: (k.confidence ?? 1) >= FACE_MIN_CONFIDENCE,
|
||||||
|
}));
|
||||||
|
const out: BoxKeyframe[] = [];
|
||||||
|
const gaps: TimeWindow[] = [];
|
||||||
|
const confident = inside.filter((k) => k.confident);
|
||||||
|
const weak = inside.filter((k) => !k.confident);
|
||||||
|
if (confident.length === 0) {
|
||||||
|
// Only weak detections: cover them grown and send the whole span to review.
|
||||||
|
for (const k of weak)
|
||||||
|
out.push({ tMs: k.tMs, box: roundBoxOut(growBox(k.box, GAP_HOLD_GROWTH)) });
|
||||||
|
const first = weak[0];
|
||||||
|
const last = weak.at(-1);
|
||||||
|
if (first && last) gaps.push({ startMs: first.tMs, endMs: Math.max(last.tMs, first.tMs + 1) });
|
||||||
|
return { keyframes: out, gaps };
|
||||||
|
}
|
||||||
|
confident.forEach((current, index) => {
|
||||||
|
out.push({ tMs: current.tMs, box: roundBoxOut(current.box) });
|
||||||
|
const next = confident[index + 1];
|
||||||
|
if (!next) return;
|
||||||
|
const span = next.tMs - current.tMs;
|
||||||
|
const between = weak.filter((k) => k.tMs > current.tMs && k.tMs < next.tMs);
|
||||||
|
if (span <= MAX_TRACKING_GAP_MS) {
|
||||||
|
for (let t = current.tMs + INTERPOLATE_STEP_MS; t < next.tMs; t += INTERPOLATE_STEP_MS) {
|
||||||
|
let box = lerpBox(current.box, next.box, (t - current.tMs) / span);
|
||||||
|
for (const w of between)
|
||||||
|
if (Math.abs(w.tMs - t) < INTERPOLATE_STEP_MS) box = unionBox(box, w.box);
|
||||||
|
out.push({ tMs: t, box: roundBoxOut(box) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let held = growBox(current.box, GAP_HOLD_GROWTH);
|
||||||
|
for (const w of between) held = unionBox(held, w.box);
|
||||||
|
const box = roundBoxOut(held);
|
||||||
|
out.push({ tMs: current.tMs + 1, box }, { tMs: next.tMs - 1, box });
|
||||||
|
gaps.push({ startMs: current.tMs, endMs: next.tMs });
|
||||||
|
});
|
||||||
|
return { keyframes: out, gaps };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Box a keyframe list covers at `tMs`: the union of the bracketing keyframes, or null outside. */
|
||||||
|
export function boxAt(keyframes: BoxKeyframe[], tMs: number, holdMs = 100): NormalizedBox | null {
|
||||||
|
let before: BoxKeyframe | null = null;
|
||||||
|
let after: BoxKeyframe | null = null;
|
||||||
|
for (const k of keyframes) {
|
||||||
|
if (k.tMs <= tMs && (!before || k.tMs > before.tMs)) before = k;
|
||||||
|
if (k.tMs >= tMs && (!after || k.tMs < after.tMs)) after = k;
|
||||||
|
}
|
||||||
|
if (before && after) return unionBox(before.box, after.box);
|
||||||
|
if (before && tMs - before.tMs <= holdMs) return before.box;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mean IoU of two tracks over the times both observe (nearest keyframe within 60 ms). */
|
||||||
|
export function trackOverlap(a: BoxKeyframe[], b: BoxKeyframe[]): number {
|
||||||
|
const scores: number[] = [];
|
||||||
|
for (const k of a) {
|
||||||
|
let nearest: BoxKeyframe | null = null;
|
||||||
|
for (const other of b) {
|
||||||
|
if (Math.abs(other.tMs - k.tMs) > 60) continue;
|
||||||
|
if (!nearest || Math.abs(other.tMs - k.tMs) < Math.abs(nearest.tMs - k.tMs)) nearest = other;
|
||||||
|
}
|
||||||
|
if (nearest) scores.push(iou(k.box, nearest.box));
|
||||||
|
}
|
||||||
|
return scores.length === 0 ? 0 : scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source windows each clip of an asset shows. */
|
||||||
|
export function clipWindows(clips: EdlVideoClip[]): TimeWindow[] {
|
||||||
|
return clips.map((clip) => ({ startMs: clip.inMs, endMs: clip.outMs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlapsWindows(span: TimeWindow, windows: TimeWindow[]): boolean {
|
||||||
|
return windows.some((w) => span.startMs <= w.endMs && w.startMs <= span.endMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner part of a face box that a partial mode must alter. It sits inside the
|
||||||
|
* band every provider covers (eyes 22-55 %, upper 0-55 %, lower 50-100 % of
|
||||||
|
* the padded box), so the gate checks only pixels the mode promised to change.
|
||||||
|
*/
|
||||||
|
export function partialCheckBand(box: NormalizedBox, mode: PartialMode): NormalizedBox {
|
||||||
|
const [top, bottom] =
|
||||||
|
mode === "partial_eyes" ? [0.3, 0.48] : mode === "partial_upper" ? [0.05, 0.5] : [0.55, 0.95];
|
||||||
|
return {
|
||||||
|
x: box.x + box.w * 0.1,
|
||||||
|
y: box.y + box.h * top,
|
||||||
|
w: box.w * 0.8,
|
||||||
|
h: box.h * (bottom - top),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PixelBox {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static crop window in display pixels with the output aspect, as the
|
||||||
|
* compiler sizes it for a reframe keyframe. Even sizes and offsets for yuv420p.
|
||||||
|
*/
|
||||||
|
export function cropPixels(
|
||||||
|
geometry: { sourceWidth: number; sourceHeight: number; outWidth: number; outHeight: number },
|
||||||
|
keyframe: { cx: number; cy: number; scale: number },
|
||||||
|
): PixelBox {
|
||||||
|
const outAspect = geometry.outWidth / geometry.outHeight;
|
||||||
|
let w: number;
|
||||||
|
let h: number;
|
||||||
|
if (geometry.sourceWidth / geometry.sourceHeight > outAspect) {
|
||||||
|
h = geometry.sourceHeight;
|
||||||
|
w = h * outAspect;
|
||||||
|
} else {
|
||||||
|
w = geometry.sourceWidth;
|
||||||
|
h = w / outAspect;
|
||||||
|
}
|
||||||
|
const roundEven = (v: number) => Math.max(2, Math.round(v / 2) * 2);
|
||||||
|
const even = (v: number) => Math.max(0, Math.floor(v / 2) * 2);
|
||||||
|
const maxW = geometry.sourceWidth - (geometry.sourceWidth % 2);
|
||||||
|
const maxH = geometry.sourceHeight - (geometry.sourceHeight % 2);
|
||||||
|
const cw = Math.min(maxW, roundEven(w / keyframe.scale));
|
||||||
|
const ch = Math.min(maxH, roundEven(h / keyframe.scale));
|
||||||
|
const x = even(
|
||||||
|
Math.min(Math.max(0, keyframe.cx * geometry.sourceWidth - cw / 2), geometry.sourceWidth - cw),
|
||||||
|
);
|
||||||
|
const y = even(
|
||||||
|
Math.min(Math.max(0, keyframe.cy * geometry.sourceHeight - ch / 2), geometry.sourceHeight - ch),
|
||||||
|
);
|
||||||
|
return { x, y, w: cw, h: ch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a normalized box lies completely outside a pixel crop. */
|
||||||
|
export function outsideCrop(box: NormalizedBox, crop: PixelBox, width: number, height: number) {
|
||||||
|
const x0 = box.x * width;
|
||||||
|
const y0 = box.y * height;
|
||||||
|
const x1 = (box.x + box.w) * width;
|
||||||
|
const y1 = (box.y + box.h) * height;
|
||||||
|
return x1 <= crop.x || x0 >= crop.x + crop.w || y1 <= crop.y || y0 >= crop.y + crop.h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a source window of one clip onto the output timeline (intro included), or null. */
|
||||||
|
export function sourceToOutput(edl: Edl, clip: EdlVideoClip, span: TimeWindow): TimeWindow | null {
|
||||||
|
const start = Math.max(span.startMs, clip.inMs);
|
||||||
|
const end = Math.min(span.endMs, clip.outMs);
|
||||||
|
if (end < start) return null;
|
||||||
|
const toOutput = (t: number) =>
|
||||||
|
Math.round(introMs(edl) + clip.atMs + (t - clip.inMs) / clip.speed);
|
||||||
|
return { startMs: toOutput(start), endMs: toOutput(end) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Privacy record (prepareSources → gate)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PixelBoxSchema = z.object({
|
||||||
|
x: z.number().int().min(0),
|
||||||
|
y: z.number().int().min(0),
|
||||||
|
w: z.number().int().min(1),
|
||||||
|
h: z.number().int().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SpanSchema = { startMs: z.number(), endMs: z.number() };
|
||||||
|
|
||||||
|
/** One protected region the gate verifies pixel by pixel. */
|
||||||
|
export const PrivacyCheckSchema = z.object({
|
||||||
|
kind: z.enum(["face", "mark", "region"]),
|
||||||
|
/** Track, mark, or region id. */
|
||||||
|
id: z.string(),
|
||||||
|
personId: z.string().nullable(),
|
||||||
|
markId: z.string().nullable(),
|
||||||
|
/** Boxes the treatment promised to alter, normalized to the base frame. */
|
||||||
|
keyframes: z.array(BoxKeyframeSchema),
|
||||||
|
/** Absolute mask directory (`<ms>.png` per frame) or null for box-only regions. */
|
||||||
|
maskDir: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type PrivacyCheck = z.infer<typeof PrivacyCheckSchema>;
|
||||||
|
|
||||||
|
export const PrivacyRecordSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
assets: z.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
/** Unredacted file the prepared source derives from (pixel reference). */
|
||||||
|
basePath: z.string(),
|
||||||
|
preparedPath: z.string(),
|
||||||
|
width: z.number().int().positive(),
|
||||||
|
height: z.number().int().positive(),
|
||||||
|
still: z.boolean(),
|
||||||
|
/** Static crop applied for reframe_out, in base display pixels. */
|
||||||
|
crop: PixelBoxSchema.nullable(),
|
||||||
|
checks: z.array(PrivacyCheckSchema),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
/** Detection gaps over MAX_TRACKING_GAP_MS per asset (source time), for review. */
|
||||||
|
trackingGaps: z.array(
|
||||||
|
z.object({ assetId: z.string(), personId: z.string().nullable(), ...SpanSchema }),
|
||||||
|
),
|
||||||
|
/** Person-bound marks re-found with low confidence, for review. */
|
||||||
|
reviews: z.array(z.object({ markId: z.string(), assetId: z.string(), score: z.number() })),
|
||||||
|
/** Faces that stay detectable by design (synthetic identity, partial modes), source time. */
|
||||||
|
expectedFaces: z.array(
|
||||||
|
z.object({
|
||||||
|
assetId: z.string(),
|
||||||
|
personId: z.string().nullable(),
|
||||||
|
mode: AnonymizeModeSchema,
|
||||||
|
...SpanSchema,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
/** Faces in the clip ranges that belong to no person (consent cannot be shown). */
|
||||||
|
unassignedFaces: z.array(z.object({ assetId: z.string(), ...SpanSchema })),
|
||||||
|
aiAlterations: z.array(AiAlterationSchema),
|
||||||
|
/** Effective protection per person (EDL target over person default). */
|
||||||
|
protectedPeople: z.record(z.string(), AnonymizeModeSchema),
|
||||||
|
/** People whose faces appear in the clip ranges. */
|
||||||
|
visiblePeople: z.array(z.string()),
|
||||||
|
protectUnassigned: z.boolean(),
|
||||||
|
});
|
||||||
|
export type PrivacyRecord = z.infer<typeof PrivacyRecordSchema>;
|
||||||
|
|
||||||
|
export async function writePrivacyRecord(workDir: string, record: PrivacyRecord): Promise<void> {
|
||||||
|
await writeFile(path.join(workDir, PRIVACY_RECORD_FILE), JSON.stringify(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The record prepareSources wrote, or null when it is missing. Malformed records throw. */
|
||||||
|
export async function readPrivacyRecord(workDir: string): Promise<PrivacyRecord | null> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(path.join(workDir, PRIVACY_RECORD_FILE), "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return PrivacyRecordSchema.parse(JSON.parse(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored face keyframe files: an array of keyframes or a tracking result with `keyframes`. */
|
||||||
|
const StoredKeyframeSchema = BoxKeyframeSchema.extend({ confidence: z.number().optional() });
|
||||||
|
const StoredKeyframesSchema = z.union([
|
||||||
|
z.array(StoredKeyframeSchema.passthrough()),
|
||||||
|
z.object({ keyframes: z.array(StoredKeyframeSchema.passthrough()) }).passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseStoredKeyframes(json: unknown): TrackKeyframe[] {
|
||||||
|
const parsed = StoredKeyframesSchema.parse(json);
|
||||||
|
const list = Array.isArray(parsed) ? parsed : parsed.keyframes;
|
||||||
|
return list.map((k) => ({
|
||||||
|
tMs: k.tMs,
|
||||||
|
box: k.box,
|
||||||
|
...(k.confidence !== undefined ? { confidence: k.confidence } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
39
packages/adapters/src/media-privacy.ts
Normal file
39
packages/adapters/src/media-privacy.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { prepareSources } from "./media-anonymize.js";
|
||||||
|
import type { MediaPrivacyService, MediaServiceDeps } from "./media-context.js";
|
||||||
|
import { runGate } from "./media-gate.js";
|
||||||
|
import { createMark, updateMark } from "./media-marks.js";
|
||||||
|
import type { PrivacyToolOptions } from "./media-privacy-io.js";
|
||||||
|
import { resolveStored } from "./media-privacy-io.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* MediaPrivacyService: anonymization before rendering, the gate after it,
|
||||||
|
* identifying marks, and deleting a person with their biometric data. Face
|
||||||
|
* data never leaves this host: matching, tracking, and clustering run against
|
||||||
|
* the local inference provider and database only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function createMediaPrivacyService(
|
||||||
|
deps: MediaServiceDeps,
|
||||||
|
tools: PrivacyToolOptions = {},
|
||||||
|
): MediaPrivacyService {
|
||||||
|
const withTools = { ...deps, ...tools };
|
||||||
|
return {
|
||||||
|
prepareSources: (input) => prepareSources(withTools, input),
|
||||||
|
gate: (input) => runGate(withTools, input),
|
||||||
|
createMark: (actor, input) => createMark(withTools, actor, input),
|
||||||
|
updateMark: (actor, input) => updateMark(withTools, actor, input),
|
||||||
|
async removePerson(actor, personId) {
|
||||||
|
const removed = await deps.repos.removePersonCompletely(actor.spaceId, personId);
|
||||||
|
// Rows are gone; now the files: track keyframes and each mark's directory
|
||||||
|
// (masks plus previews). A path outside the media root throws untouched.
|
||||||
|
const markDirs = removed.maskDirs.map((dir) =>
|
||||||
|
path.basename(dir) === "masks" ? path.dirname(dir) : dir,
|
||||||
|
);
|
||||||
|
for (const stored of [...removed.keyframesPaths, ...markDirs]) {
|
||||||
|
await rm(await resolveStored(deps.mediaRoot, stored), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user