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(); }); });