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; posterTags?: Record; /** 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((_, 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 { 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; renderDir: (renderId: string) => string; } let cleanups: Array<() => Promise> = []; 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 }> { 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 { 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((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); });