evOo-local/packages/adapters/src/media-render.test.ts
Claude 3ebe0fe47b
feat(media): ffmpeg renderer, render job and real render integration test
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
2026-10-03 18:44:58 +00:00

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