Fail closed when anonymization cannot prepare every clip, retry a failing hardware encoder in software, verify each ffmpeg output exists, and fail renders whose video or poster still carries source metadata. Cover parsing, encoder detection, two-pass loudness, abort and cleanup, the render job (auto-fix, cancel, outputs) on memory and Postgres, and a synthetic 16:9 to 9:16 render plus a region blur check with ffprobe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
457 lines
17 KiB
TypeScript
457 lines
17 KiB
TypeScript
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { MediaRenderer, MotionGraphicsRenderer } from "@rakazo/adapter-kit";
|
|
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
|
|
import type { FfprobeReport, GateIssue, MediaRender, RenderPlan } from "@rakazo/contracts";
|
|
import { findMetadataLeaks } from "@rakazo/media";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import type { MediaPrivacyService } from "./media-context.js";
|
|
import { FfmpegMediaRenderer } from "./media-ffmpeg-renderer.js";
|
|
import type { FakePrivacy, RenderHarness } from "./media-render.fixtures.js";
|
|
import {
|
|
createFakePrivacy,
|
|
createRenderHarness,
|
|
hasMediaTools,
|
|
renderTestProfile,
|
|
writeSyntheticClip,
|
|
} from "./media-render.fixtures.js";
|
|
import { createMediaRenderHandler, createMediaRenderService, pickEncoder } from "./media-render.js";
|
|
|
|
const profile = renderTestProfile();
|
|
|
|
const motion: MotionGraphicsRenderer = {
|
|
id: "unused",
|
|
templates: () => [],
|
|
render: async () => {
|
|
throw new Error("no motion in these plans");
|
|
},
|
|
};
|
|
|
|
interface FakeRenderer extends MediaRenderer {
|
|
plans: RenderPlan[];
|
|
}
|
|
|
|
/**
|
|
* Executes write_file steps and writes placeholder outputs; probes report a
|
|
* clean, on-target file. `failEncoders` makes renders with those encoders fail.
|
|
*/
|
|
function fakeRenderer(
|
|
options: {
|
|
hardware?: string[];
|
|
failEncoders?: string[];
|
|
tags?: Record<string, string>;
|
|
posterTags?: Record<string, string>;
|
|
/** Resolves once a render started; the render then waits for its signal. */
|
|
hold?: { started: () => void };
|
|
} = {},
|
|
): FakeRenderer {
|
|
const plans: RenderPlan[] = [];
|
|
let durationMs = 0;
|
|
return {
|
|
id: "fake",
|
|
plans,
|
|
hardwareEncoders: async () => options.hardware ?? [],
|
|
async render(plan, { signal, onProgress } = {}) {
|
|
plans.push(plan);
|
|
const step = plan.steps.find((s) => s.kind === "ffmpeg" && s.id === "render");
|
|
if (step?.kind !== "ffmpeg") throw new Error("no render step");
|
|
durationMs = step.durationMs;
|
|
const encoder = step.argv[step.argv.indexOf("-c:v") + 1]!;
|
|
if (options.failEncoders?.includes(encoder)) throw new Error(`${encoder}: device lost`);
|
|
if (options.hold) {
|
|
options.hold.started();
|
|
await new Promise<void>((_, reject) => {
|
|
signal?.addEventListener("abort", () => {
|
|
const error = new Error("render aborted");
|
|
error.name = "AbortError";
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
for (const s of plan.steps) {
|
|
if (s.kind === "write_file") {
|
|
await mkdir(path.dirname(s.path), { recursive: true });
|
|
await writeFile(s.path, s.content);
|
|
}
|
|
if (s.kind === "ffmpeg") {
|
|
for (const file of s.produces) await writeFile(file, `rendered ${s.id}`);
|
|
}
|
|
}
|
|
onProgress?.({ stepId: "render", progress: 1 });
|
|
},
|
|
async probe(file): Promise<FfprobeReport> {
|
|
const poster = file.endsWith(".jpg");
|
|
return {
|
|
durationMs: poster ? 0 : durationMs,
|
|
width: profile.video.width,
|
|
height: profile.video.height,
|
|
fps: poster ? 0 : profile.video.fps,
|
|
videoCodec: poster ? "mjpeg" : "h264",
|
|
audioCodec: poster ? null : "aac",
|
|
sizeBytes: (await stat(file)).size,
|
|
integratedLufs: poster ? null : -14.2,
|
|
truePeakDb: poster ? null : -1.5,
|
|
tags: poster ? (options.posterTags ?? {}) : (options.tags ?? {}),
|
|
};
|
|
},
|
|
extractFrame: async () => {
|
|
throw new Error("not used");
|
|
},
|
|
};
|
|
}
|
|
|
|
function edlFor(assetId: string) {
|
|
return {
|
|
edlVersion: 1,
|
|
output: {
|
|
profileId: profile.id,
|
|
width: profile.video.width,
|
|
height: profile.video.height,
|
|
fps: 30,
|
|
durationTargetMs: 3000,
|
|
durationHardMinMs: 1000,
|
|
durationHardMaxMs: 120_000,
|
|
},
|
|
tracks: {
|
|
video: [
|
|
{ id: "c1", assetId, inMs: 0, outMs: 1500, atMs: 0, reframe: "auto" },
|
|
{ id: "c2", assetId, inMs: 2000, outMs: 3500, atMs: 1500, reframe: "auto" },
|
|
],
|
|
},
|
|
captions: { mode: "both" },
|
|
overlays: [{ id: "t1", kind: "text", atMs: 200, durationMs: 1000, text: "Example" }],
|
|
metadata: { title: "Example teaser" },
|
|
};
|
|
}
|
|
|
|
interface Setup {
|
|
harness: RenderHarness;
|
|
privacy: FakePrivacy;
|
|
renderer: MediaRenderer;
|
|
run: (planQuality?: "draft" | "final") => Promise<MediaRender>;
|
|
renderDir: (renderId: string) => string;
|
|
}
|
|
|
|
let cleanups: Array<() => Promise<void>> = [];
|
|
afterEach(async () => {
|
|
for (const cleanup of cleanups) await cleanup();
|
|
cleanups = [];
|
|
});
|
|
|
|
async function setup(options: {
|
|
renderer: MediaRenderer;
|
|
privacy?: (sources: RenderHarness["sources"]) => MediaPrivacyService;
|
|
failWith?: GateIssue;
|
|
realSource?: boolean;
|
|
}): Promise<Setup & { service: ReturnType<typeof createMediaRenderService> }> {
|
|
const harness = await createRenderHarness({
|
|
profiles: [profile],
|
|
renderer: options.renderer,
|
|
motion,
|
|
});
|
|
cleanups.push(() => harness.cleanup());
|
|
const assetId = await harness.createAsset("source.mp4");
|
|
const file = harness.assetPath(assetId, "source.mp4");
|
|
if (options.realSource) {
|
|
await writeSyntheticClip(file, { seconds: 4, width: 320, height: 180, volumeDb: -30 });
|
|
} else {
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, "synthetic");
|
|
}
|
|
harness.setSource(assetId, {
|
|
path: file,
|
|
width: 320,
|
|
height: 180,
|
|
durationMs: 4000,
|
|
fps: 30,
|
|
hasAudio: true,
|
|
rotation: 0,
|
|
});
|
|
await harness.addAnalysis(assetId, "transcript", {
|
|
language: "en",
|
|
words: [
|
|
{ startMs: 100, endMs: 500, text: "Hello", confidence: 0.9 },
|
|
{ startMs: 2100, endMs: 2600, text: "again.", confidence: 0.9 },
|
|
],
|
|
});
|
|
await harness.addSegments(assetId, [
|
|
{ startMs: 0, endMs: 2000 },
|
|
{ startMs: 2000, endMs: 4000 },
|
|
]);
|
|
const projectId = await harness.createProject({
|
|
assetIds: [assetId],
|
|
kit: null,
|
|
profileId: profile.id,
|
|
});
|
|
const plan = await harness.createPlan(projectId, edlFor(assetId));
|
|
const privacy = createFakePrivacy(harness.sources, { failWith: options.failWith });
|
|
const service = createMediaRenderService(harness.deps);
|
|
const handler = createMediaRenderHandler(
|
|
harness.deps,
|
|
options.privacy ? options.privacy(harness.sources) : privacy,
|
|
{ cancelPollMs: 50 },
|
|
);
|
|
const actor = { spaceId: harness.spaceId, userId: harness.userId };
|
|
return {
|
|
harness,
|
|
privacy,
|
|
renderer: options.renderer,
|
|
service,
|
|
renderDir: (renderId) => path.join(harness.mediaRoot, harness.spaceId, "renders", renderId),
|
|
async run(quality = "draft") {
|
|
const render = await service.start(actor, { planId: plan.id, quality });
|
|
await handler({ renderId: render.id });
|
|
return harness.deps.repos.getRender(harness.spaceId, render.id);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function exists(file: string): Promise<boolean> {
|
|
return stat(file).then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
}
|
|
|
|
describe("media render service", () => {
|
|
it("queues a render job and announces it", async () => {
|
|
const { harness, service } = await setup({ renderer: fakeRenderer() });
|
|
expect(harness.backend).toBe(
|
|
process.env.VERIFY_DATABASE && process.env.DATABASE_URL ? "postgres" : "memory",
|
|
);
|
|
const projectId = await harness.createProject({
|
|
assetIds: [],
|
|
kit: null,
|
|
profileId: profile.id,
|
|
});
|
|
const assetId = [...harness.sources.keys()][0]!;
|
|
const plan = await harness.createPlan(projectId, edlFor(assetId));
|
|
const actor = { spaceId: harness.spaceId, userId: harness.userId };
|
|
const render = await service.start(actor, { planId: plan.id, quality: "final" });
|
|
expect(render).toMatchObject({ status: "queued", quality: "final", planId: plan.id });
|
|
expect(harness.enqueued).toContain(`media.render:${JSON.stringify({ renderId: render.id })}`);
|
|
expect(harness.events).toContainEqual({
|
|
type: "media.render",
|
|
renderId: render.id,
|
|
projectId,
|
|
status: "queued",
|
|
progress: 0,
|
|
});
|
|
await expect(
|
|
service.start({ ...actor, spaceId: "space-other" }, { planId: plan.id, quality: "draft" }),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("renders, gates, stores outputs and serves only recorded files", async () => {
|
|
const renderer = fakeRenderer();
|
|
const { harness, service, run, renderDir } = await setup({ renderer });
|
|
const render = await run();
|
|
expect(render.status).toBe("done");
|
|
expect(render.progress).toBe(1);
|
|
expect(render.gateReport?.passed).toBe(true);
|
|
expect(render.ffprobe?.integratedLufs).toBe(-14.2);
|
|
expect(render.outputs.map((output) => output.fileName).sort()).toEqual([
|
|
"captions.srt",
|
|
"captions.vtt",
|
|
"poster.jpg",
|
|
"video.mp4",
|
|
]);
|
|
const video = render.outputs.find((output) => output.kind === "video")!;
|
|
expect(video.artifactId).toBeNull();
|
|
expect(render.outputs.filter((output) => output.artifactId !== null)).toHaveLength(3);
|
|
expect(await harness.artifactCount()).toBe(3);
|
|
// Intermediates are gone; only the published outputs stay.
|
|
expect((await readdir(renderDir(render.id))).sort()).toEqual([
|
|
"captions.srt",
|
|
"captions.vtt",
|
|
"poster.jpg",
|
|
"video.mp4",
|
|
]);
|
|
const actor = { spaceId: harness.spaceId, userId: harness.userId };
|
|
expect(await service.outputPath(actor, render.id, "video.mp4")).toBe(
|
|
path.join(renderDir(render.id), "video.mp4"),
|
|
);
|
|
expect(await service.outputPath(actor, render.id, "../video.mp4")).toBeNull();
|
|
expect(await service.outputPath(actor, render.id, "work")).toBeNull();
|
|
await expect(
|
|
service.outputPath({ ...actor, spaceId: "space-other" }, render.id, "video.mp4"),
|
|
).rejects.toThrow();
|
|
const statuses = harness.events.flatMap((event) =>
|
|
event.type === "media.render" && event.renderId === render.id ? [event.status] : [],
|
|
);
|
|
expect(statuses[0]).toBe("queued");
|
|
expect(statuses).toContain("gating");
|
|
expect(statuses.at(-1)).toBe("done");
|
|
});
|
|
|
|
it("refuses to render when redactions cannot be applied (fail closed)", async () => {
|
|
const renderer = fakeRenderer();
|
|
let calls = 0;
|
|
const { harness, run, renderDir } = await setup({
|
|
renderer,
|
|
privacy: (sources) => ({
|
|
...createFakePrivacy(sources),
|
|
async prepareSources() {
|
|
calls += 1;
|
|
throw new Error("face track lost for 800 ms");
|
|
},
|
|
}),
|
|
});
|
|
const render = await run("final");
|
|
expect(calls).toBe(1);
|
|
expect(render.status).toBe("failed");
|
|
expect(render.error).toBe("Anonymization could not be applied. Nothing was rendered.");
|
|
expect(render.outputs).toEqual([]);
|
|
expect(renderer.plans).toHaveLength(0);
|
|
expect(await exists(renderDir(render.id))).toBe(false);
|
|
expect(await harness.artifactCount()).toBe(0);
|
|
});
|
|
|
|
it("names the missing capability and refuses when a clip was not prepared", async () => {
|
|
const unsupported = await setup({
|
|
renderer: fakeRenderer(),
|
|
privacy: (sources) => ({
|
|
...createFakePrivacy(sources),
|
|
async prepareSources() {
|
|
throw new UnsupportedMediaOperationError("inpaintVideo", "no model");
|
|
},
|
|
}),
|
|
});
|
|
expect((await unsupported.run()).error).toBe(
|
|
"Anonymization needs inpaintVideo, which is not available. Nothing was rendered.",
|
|
);
|
|
|
|
const renderer = fakeRenderer();
|
|
const partial = await setup({
|
|
renderer,
|
|
privacy: (sources) => ({
|
|
...createFakePrivacy(sources),
|
|
async prepareSources() {
|
|
return { sources: new Map(), aiAlterations: [], fallbacks: [] };
|
|
},
|
|
}),
|
|
});
|
|
const render = await partial.run();
|
|
expect(render.status).toBe("failed");
|
|
expect(render.error).toBe("Anonymization did not prepare every clip. Nothing was rendered.");
|
|
expect(renderer.plans).toHaveLength(0);
|
|
});
|
|
|
|
it("falls back to software when a hardware encoder fails and skips it afterwards", async () => {
|
|
const renderer = fakeRenderer({
|
|
hardware: ["hevc_vaapi", "h264_vaapi"],
|
|
failEncoders: ["h264_vaapi"],
|
|
});
|
|
const { run } = await setup({ renderer });
|
|
const encoders = () =>
|
|
renderer.plans.map((plan) => {
|
|
const step = plan.steps.find((s) => s.kind === "ffmpeg" && s.id === "render");
|
|
return step?.kind === "ffmpeg" ? step.argv[step.argv.indexOf("-c:v") + 1] : null;
|
|
});
|
|
expect((await run("final")).status).toBe("done");
|
|
expect(encoders()).toEqual(["h264_vaapi", "libx264"]);
|
|
expect((await run("final")).status).toBe("done");
|
|
expect(encoders()).toEqual(["h264_vaapi", "libx264", "libx264"]);
|
|
});
|
|
|
|
it("uses software for drafts and a working hardware encoder for finals", async () => {
|
|
const renderer = fakeRenderer({ hardware: ["hevc_nvenc", "h264_nvenc"] });
|
|
expect(await pickEncoder(renderer, profile, "draft")).toBe("libx264");
|
|
expect(await pickEncoder(renderer, profile, "final")).toBe("h264_nvenc");
|
|
expect(await pickEncoder(fakeRenderer(), profile, "final")).toBe("libx264");
|
|
const hevc = renderTestProfile({
|
|
video: { ...profile.video, codec: "hevc" },
|
|
});
|
|
expect(await pickEncoder(renderer, hevc, "final")).toBe("hevc_nvenc");
|
|
expect(await pickEncoder(renderer, hevc, "draft")).toBe("libx265");
|
|
});
|
|
|
|
it("fails a render whose video or poster still carries source metadata", async () => {
|
|
const leaky = await setup({
|
|
renderer: fakeRenderer({ tags: { "format.location": "+00+000/" } }),
|
|
});
|
|
const render = await leaky.run();
|
|
expect(render.status).toBe("failed");
|
|
expect(render.error).toBe("The export still carried source metadata.");
|
|
expect(await exists(leaky.renderDir(render.id))).toBe(false);
|
|
|
|
const poster = await setup({
|
|
renderer: fakeRenderer({ posterTags: { "format.make": "ExampleCam" } }),
|
|
});
|
|
expect((await poster.run()).error).toBe("The poster still carried source metadata.");
|
|
});
|
|
|
|
it("auto-fixes a blocked gate at most three times, then blocks", async () => {
|
|
const renderer = fakeRenderer();
|
|
const { harness, privacy, run, renderDir } = await setup({
|
|
renderer,
|
|
failWith: {
|
|
check: "content",
|
|
severity: "block",
|
|
code: "classifier_violence",
|
|
message: "violence score above the profile threshold",
|
|
atMs: 0,
|
|
endMs: 400,
|
|
},
|
|
});
|
|
const render = await run();
|
|
expect(render.status).toBe("blocked");
|
|
expect(render.gateReport?.passed).toBe(false);
|
|
expect(privacy.gateCalls.map((call) => call.autoFixRound)).toEqual([0, 1, 2, 3]);
|
|
expect(renderer.plans).toHaveLength(4);
|
|
const record = await harness.deps.repos.getRenderRecord(harness.spaceId, render.id);
|
|
expect(record.autoFixRound).toBe(3);
|
|
const versions = await harness.planVersions(render.projectId, profile.id);
|
|
expect(versions).toHaveLength(4);
|
|
expect(versions[0]!.createdBy).toBe("agent");
|
|
expect(versions[0]!.rationale).toContain("Auto-fix 3/3");
|
|
expect(render.outputs).toEqual([]);
|
|
expect(await exists(renderDir(render.id))).toBe(false);
|
|
expect(await harness.artifactCount()).toBe(0);
|
|
});
|
|
|
|
it("cancels a running render, stops it and removes its files", async () => {
|
|
let started!: () => void;
|
|
const running = new Promise<void>((resolve) => {
|
|
started = resolve;
|
|
});
|
|
const renderer = fakeRenderer({ hold: { started: () => started() } });
|
|
const { harness, service, run, renderDir } = await setup({ renderer });
|
|
const result = run();
|
|
await running;
|
|
const active = harness.events.findLast((event) => event.type === "media.render");
|
|
const renderId = active?.type === "media.render" ? active.renderId : "";
|
|
const canceled = await service.cancel(
|
|
{ spaceId: harness.spaceId, userId: harness.userId },
|
|
renderId,
|
|
);
|
|
expect(canceled.status).toBe("canceled");
|
|
const render = await result;
|
|
expect(render.status).toBe("canceled");
|
|
expect(await exists(renderDir(renderId))).toBe(false);
|
|
await expect(
|
|
service.cancel({ spaceId: harness.spaceId, userId: harness.userId }, renderId),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!hasMediaTools)("media render job with real ffmpeg", () => {
|
|
it("renders a synthetic project end to end and records a clean probe", async () => {
|
|
const renderer = new FfmpegMediaRenderer();
|
|
const { run, renderDir } = await setup({ renderer, realSource: true });
|
|
const render = await run("draft");
|
|
expect(render.error).toBeNull();
|
|
expect(render.status).toBe("done");
|
|
const probe = render.ffprobe!;
|
|
expect(probe.width).toBe(profile.video.width);
|
|
expect(probe.height).toBe(profile.video.height);
|
|
expect(Math.abs(probe.durationMs - 3000)).toBeLessThanOrEqual(1000 / 30 + 1);
|
|
expect(Math.abs(probe.integratedLufs! - profile.audio.targetLufs)).toBeLessThanOrEqual(1);
|
|
expect(findMetadataLeaks(probe.tags)).toEqual([]);
|
|
const video = path.join(renderDir(render.id), "video.mp4");
|
|
const reprobed = await renderer.probe(video);
|
|
expect(reprobed.tags["format.title"]).toBe("Example teaser");
|
|
expect(Object.values(reprobed.tags).join(" ")).not.toContain("ExampleCam");
|
|
}, 120_000);
|
|
});
|