feat(media): inference emulator, sidecar client, folder source and factory

Add the Media Studio adapters behind the frozen provider contracts: a
deterministic offline inference emulator driven by file-name markers that
renders real redactions with ffmpeg, a synthetic emulator library, the
bearer-auth HTTP client for the local media sidecar with validated
responses, typed errors, busy retries and timeouts, a lazily paginated
folder source that never follows symlinks, and the configuration factory.
A shared conformance suite runs against the emulator and, in the Docker
integration job, against the sidecar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
Claude 2026-10-03 17:52:17 +00:00
parent 7ccc96ac11
commit 270ea8068a
No known key found for this signature in database
12 changed files with 3260 additions and 0 deletions

View File

@ -0,0 +1,281 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { MediaInferenceProvider } from "@rakazo/adapter-kit";
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
import type { MediaInferenceOperation } from "@rakazo/contracts";
import { MediaCapabilitiesSchema, MediaInferenceOperationSchema } from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { EmulatorMediaInferenceProvider } from "./media-emulator.js";
import { extractRgbFrame, ffmpegAvailable, renderTestClip } from "./media-emulator-clips.js";
import { MEDIA_SIDECAR_RESPONSE_SCHEMAS, SidecarMediaInferenceProvider } from "./media-sidecar.js";
/*
* One contract for every MediaInferenceProvider. The emulator always runs; the
* sidecar runs only in the Docker integration job, which sets
* MEDIA_CONFORMANCE_URL, MEDIA_CONFORMANCE_TOKEN and MEDIA_CONFORMANCE_ROOT (a
* directory inside the sidecar's MEDIA_ROOT, mounted at the same path here).
*/
const WIDTH = 320;
const HEIGHT = 180;
const hasFfmpeg = ffmpegAvailable();
interface Target {
name: string;
/** Directory inside the provider's root where the suite writes fixtures. */
root: () => Promise<string>;
provider: (root: string) => MediaInferenceProvider;
}
const targets: Target[] = [
{
name: "emulator",
root: () => mkdtemp(path.join(tmpdir(), "media-conformance-")),
provider: (root) => new EmulatorMediaInferenceProvider({ root }),
},
];
const sidecarUrl = process.env.MEDIA_CONFORMANCE_URL;
const sidecarToken = process.env.MEDIA_CONFORMANCE_TOKEN;
const sidecarRoot = process.env.MEDIA_CONFORMANCE_ROOT;
if (sidecarUrl && sidecarToken && sidecarRoot) {
targets.push({
name: "sidecar",
root: async () => {
await mkdir(sidecarRoot, { recursive: true });
return mkdtemp(path.join(sidecarRoot, "conformance-"));
},
provider: () => new SidecarMediaInferenceProvider({ baseUrl: sidecarUrl, token: sidecarToken }),
});
}
function requestFor(operation: MediaInferenceOperation, clip: string, root: string) {
const box = { x: 0.25, y: 0.25, w: 0.25, h: 0.25 };
switch (operation) {
case "detectScenes":
case "audioFeatures":
return { path: clip };
case "transcribe":
return { path: clip, language: "en" };
case "detectFaces":
case "trackFaces":
return { path: clip, sampleFps: 2 };
case "faceLandmarks":
case "embedFrames":
case "saliency":
return { path: clip, timesMs: [0, 500, 1000] };
case "classifyContent":
return { path: clip, sampleFps: 2, extraTimesMs: [750] };
case "segmentPrompted":
return {
path: clip,
prompt: { tMs: 500, box },
propagate: true,
outDir: path.join(root, "masks"),
};
case "inpaintVideo":
return {
path: clip,
maskDir: path.join(root, "masks"),
outPath: path.join(root, "inpaint.mp4"),
};
case "redact":
return {
path: clip,
redactions: [
{
keyframes: [
{ tMs: 0, box },
{ tMs: 2000, box },
],
mode: "blur" as const,
paddingPct: 0,
featherPct: 0,
},
],
outPath: path.join(root, "redacted-any.mp4"),
};
case "synthesizeIdentity":
return {
path: clip,
track: [{ tMs: 0, box }],
seed: 7,
outPath: path.join(root, "synthetic.mp4"),
};
}
}
/** Mean absolute RGB difference inside and outside a pixel box (with a guard margin). */
function regionDiff(
a: Buffer,
b: Buffer,
box: { x: number; y: number; w: number; h: number },
margin = 4,
) {
let inside = 0;
let insideCount = 0;
let outside = 0;
let outsideCount = 0;
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const within = x >= box.x && x < box.x + box.w && y >= box.y && y < box.y + box.h;
const near =
x >= box.x - margin &&
x < box.x + box.w + margin &&
y >= box.y - margin &&
y < box.y + box.h + margin;
let diff = 0;
for (let c = 0; c < 3; c++) {
const i = (y * WIDTH + x) * 3 + c;
diff += Math.abs((a[i] ?? 0) - (b[i] ?? 0));
}
if (within) {
inside += diff / 3;
insideCount++;
} else if (!near) {
outside += diff / 3;
outsideCount++;
}
}
}
return { inside: inside / insideCount, outside: outside / outsideCount };
}
for (const target of targets) {
describe.skipIf(!hasFfmpeg)(`MediaInferenceProvider conformance: ${target.name}`, () => {
let root: string;
let clip: string;
let provider: MediaInferenceProvider;
beforeAll(async () => {
root = await target.root();
clip = await renderTestClip(path.join(root, "clip.mp4"), {
seconds: 2,
width: WIDTH,
height: HEIGHT,
});
provider = target.provider(root);
});
afterAll(async () => {
if (root) await rm(root, { recursive: true, force: true });
});
it("reports a capabilities document for every operation", async () => {
const capabilities = MediaCapabilitiesSchema.parse(await provider.capabilities());
expect(Object.keys(capabilities.operations).sort()).toEqual(
[...MediaInferenceOperationSchema.options].sort(),
);
});
it("returns schema-valid output or Unsupported for every operation", async () => {
const capabilities = await provider.capabilities();
// segmentPrompted runs first so inpaintVideo finds its mask directory.
for (const operation of MediaInferenceOperationSchema.options) {
const request = requestFor(operation, clip, root);
const call = provider[operation] as (input: unknown) => Promise<unknown>;
try {
const result = await call.call(provider, request);
const parsed = MEDIA_SIDECAR_RESPONSE_SCHEMAS[operation].safeParse(result);
expect(parsed.success, `${operation}: ${parsed.error?.message}`).toBe(true);
expect(capabilities.operations[operation]?.available, operation).toBe(true);
} catch (error) {
if (!(error instanceof UnsupportedMediaOperationError)) throw error;
expect(error.operation).toBe(operation);
expect(capabilities.operations[operation]?.available, operation).toBe(false);
}
}
});
it("rejects paths outside the media root", async () => {
const outside = await mkdtemp(path.join(tmpdir(), "media-outside-"));
try {
const stray = path.join(outside, "stray.mp4");
await writeFile(stray, "not inside the root");
await expect(provider.detectScenes({ path: stray })).rejects.toThrow();
await expect(
provider.detectScenes({
path: path.join(root, "..", path.basename(outside), "stray.mp4"),
}),
).rejects.toThrow();
await expect(
provider.redact({
path: clip,
redactions: [],
outPath: path.join(outside, "escaped.mp4"),
}),
).rejects.toThrow();
} finally {
await rm(outside, { recursive: true, force: true });
}
});
it("redacts only inside the box", async () => {
const box = { x: 0.25, y: 0.25, w: 0.25, h: 0.25 };
const pixels = { x: 80, y: 44, w: 80, h: 46 };
for (const mode of ["solid", "blur"] as const) {
const outPath = path.join(root, `redacted-${mode}.mp4`);
const result = await provider.redact({
path: clip,
outPath,
redactions: [
{
keyframes: [
{ tMs: 0, box },
{ tMs: 2000, box },
],
mode,
paddingPct: 0,
featherPct: 0,
color: "#102030",
},
],
});
expect(result.framesProcessed).toBeGreaterThan(0);
const source = await extractRgbFrame(clip, 1000);
const redacted = await extractRgbFrame(result.outPath, 1000);
expect(redacted.length).toBe(WIDTH * HEIGHT * 3);
const diff = regionDiff(source, redacted, pixels);
expect(diff.inside, mode).toBeGreaterThan(8);
expect(diff.outside, mode).toBeLessThan(1.5);
}
});
it("honours AbortSignal", async () => {
const aborted = new AbortController();
aborted.abort();
await expect(
provider.detectScenes({ path: clip, signal: aborted.signal }),
).rejects.toMatchObject({ name: "AbortError" });
await expect(provider.capabilities(aborted.signal)).rejects.toMatchObject({
name: "AbortError",
});
const long = await renderTestClip(path.join(root, "long.mp4"), {
seconds: 20,
width: 1280,
height: 720,
});
const controller = new AbortController();
const pending = provider.redact({
path: long,
outPath: path.join(root, "aborted.mp4"),
redactions: [
{
keyframes: [
{ tMs: 0, box: { x: 0.1, y: 0.1, w: 0.3, h: 0.3 } },
{ tMs: 20_000, box: { x: 0.5, y: 0.5, w: 0.3, h: 0.3 } },
],
mode: "blur",
paddingPct: 10,
featherPct: 0,
},
],
signal: controller.signal,
});
setTimeout(() => controller.abort(), 150);
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
});
});
}

View File

@ -0,0 +1,66 @@
import { spawnSync } from "node:child_process";
import { runMediaTool } from "./media-folder-source-probe.js";
/*
* Synthetic test clips (ffmpeg testsrc2 + sine) for media tests across the
* codebase. Name the output with media-emulator markers to steer emulator
* results, e.g. `clip__faces-1__.mp4`.
*/
export function ffmpegAvailable(ffmpegPath = "ffmpeg"): boolean {
return spawnSync(ffmpegPath, ["-version"], { stdio: "ignore" }).status === 0;
}
export interface TestClipOptions {
seconds?: number;
width?: number;
height?: number;
fps?: number;
audio?: boolean;
ffmpegPath?: string;
signal?: AbortSignal;
}
export async function renderTestClip(outPath: string, options: TestClipOptions = {}) {
const { seconds = 2, width = 320, height = 180, fps = 25, audio = true } = options;
const args = [
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
`testsrc2=s=${width}x${height}:r=${fps}:d=${seconds}`,
];
if (audio)
args.push("-f", "lavfi", "-i", `sine=f=440:sample_rate=48000:d=${seconds}`, "-shortest");
args.push("-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p");
if (audio) args.push("-c:a", "aac");
args.push(outPath);
await runMediaTool(options.ffmpegPath ?? "ffmpeg", args, { signal: options.signal });
return outPath;
}
/** Decode one frame at `tMs` as packed RGB24 bytes. */
export async function extractRgbFrame(
file: string,
tMs: number,
options: { ffmpegPath?: string } = {},
): Promise<Buffer> {
const { stdout } = await runMediaTool(options.ffmpegPath ?? "ffmpeg", [
"-v",
"error",
"-i",
file,
"-ss",
(tMs / 1000).toFixed(3),
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
]);
return stdout;
}

View File

@ -0,0 +1,208 @@
import { randomUUID } from "node:crypto";
import { mkdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import type {
MediaSourceItem,
MediaSourceListRequest,
MediaSourceListResult,
MediaSourceProvider,
} from "@rakazo/adapter-kit";
import { PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import { z } from "zod";
import type { MediaToolPaths } from "./media-folder-source-probe.js";
import { runMediaTool } from "./media-folder-source-probe.js";
/*
* Deterministic synthetic library for tests and the scripted runtime. Config
* `{ count, seed }` describes `count` assets split into three capture sessions
* on separate days in three cities (more than 2 km apart), alternating two
* visual themes (`__theme-a__` testsrc2, `__theme-b__` SMPTE bars). Every fifth
* asset is a photo. File names carry media-emulator markers:
* index % 10 == 0 __faces-1____person-a__
* index % 10 == 1 __faces-1____person-b__
* index == 2 __nsfw-explicit_nudity-0.9__
* index % 4 == 3 __speech__ (videos only)
* index % 10 == 6 __cut-2000__ (videos only)
* `resolve` renders the clip on first use (4 s, 320x180, ffmpeg testsrc2 or
* smptehdbars plus a sine tone) and caches it under `fixturesDir/<seed>/`.
*/
const ConfigSchema = z.object({
count: z.number().int().min(1).max(100_000),
seed: z.number().int().min(0).max(2_147_483_647),
});
const CLIP_MS = 4000;
const CLIP_SIZE = { width: 320, height: 180, fps: 25 };
const SESSIONS = [
{ lat: 52.52, lon: 13.405 },
{ lat: 53.5511, lon: 9.9937 },
{ lat: 48.1374, lon: 11.5755 },
] as const;
export interface EmulatorAsset {
index: number;
fileName: string;
kind: "video" | "photo";
theme: "a" | "b";
session: number;
capturedAt: Date;
gpsLat: number;
gpsLon: number;
}
/** The deterministic description of asset `index` in an emulator library. */
export function emulatorAsset(seed: number, count: number, index: number): EmulatorAsset {
const kind = index % 5 === 4 ? "photo" : "video";
const theme = index % 2 === 0 ? "a" : "b";
const session = Math.min(SESSIONS.length - 1, Math.floor((index * SESSIONS.length) / count));
const firstInSession = Math.ceil((session * count) / SESSIONS.length);
const day = Date.UTC(2026, 4, 2 + (seed % 28) + session * 2, 8 + session);
const capturedAt = new Date(day + (index - firstInSession) * 3 * 60_000);
const place = SESSIONS[session] ?? SESSIONS[0];
const jitter = ((index * 7919 + seed) % 100) / 100_000;
const markers = [`__theme-${theme}__`];
if (index % 10 === 0) markers.push("__faces-1____person-a__");
if (index % 10 === 1) markers.push("__faces-1____person-b__");
if (index === 2) markers.push("__nsfw-explicit_nudity-0.9__");
if (kind === "video" && index % 4 === 3) markers.push("__speech__");
if (kind === "video" && index % 10 === 6) markers.push("__cut-2000__");
const ext = kind === "photo" ? "jpg" : "mp4";
return {
index,
fileName: `emu-${seed}-${String(index).padStart(5, "0")}${markers.join("")}.${ext}`,
kind,
theme,
session,
capturedAt,
gpsLat: Math.round((place.lat + jitter) * 1e5) / 1e5,
gpsLon: Math.round((place.lon + jitter) * 1e5) / 1e5,
};
}
function iso6709(lat: number, lon: number): string {
const sign = (value: number, width: number) =>
`${value < 0 ? "-" : "+"}${Math.abs(value).toFixed(4).padStart(width, "0")}`;
return `${sign(lat, 7)}${sign(lon, 8)}/`;
}
export interface EmulatorMediaSourceOptions extends MediaToolPaths {
/** Cache for rendered clips; must lie inside the media root to be analysable. */
fixturesDir: string;
}
export class EmulatorMediaSourceProvider implements MediaSourceProvider {
readonly kind = "emulator" as const;
constructor(private readonly options: EmulatorMediaSourceOptions) {}
async list(request: MediaSourceListRequest): Promise<MediaSourceListResult> {
request.signal?.throwIfAborted();
const { count, seed } = ConfigSchema.parse(request.config);
const start = request.cursor ? decodeIndex(request.cursor) : 0;
const end = Math.min(count, start + Math.max(1, Math.floor(request.limit)));
const items: MediaSourceItem[] = [];
for (let index = start; index < end; index++) {
const asset = emulatorAsset(seed, count, index);
items.push({
externalId: asset.fileName,
kind: asset.kind,
capturedAt: asset.capturedAt,
durationMs: asset.kind === "video" ? CLIP_MS : null,
width: CLIP_SIZE.width,
height: CLIP_SIZE.height,
fps: asset.kind === "video" ? CLIP_SIZE.fps : null,
gpsLat: asset.gpsLat,
gpsLon: asset.gpsLon,
deviceModel: "Emulator",
albumNames: [],
isFavorite: index % 9 === 0,
fileName: asset.fileName,
sizeBytes: null,
revision: `emulator-v1-${seed}`,
});
}
return {
items,
nextCursor: end < count ? Buffer.from(String(end)).toString("base64url") : null,
};
}
async resolve(config: Record<string, unknown>, externalId: string): Promise<string> {
const { count, seed } = ConfigSchema.parse(config);
const match = /^emu-(\d+)-(\d{5})/.exec(externalId);
const index = match ? Number(match[2]) : -1;
if (!match || Number(match[1]) !== seed || index >= count)
throw new PathOutsideRootError(externalId);
const asset = emulatorAsset(seed, count, index);
if (asset.fileName !== externalId) throw new PathOutsideRootError(externalId);
await mkdir(path.join(this.options.fixturesDir, String(seed)), { recursive: true });
const target = await resolveInside(this.options.fixturesDir, `${seed}/${asset.fileName}`);
if (await exists(target)) return target;
const partial = path.join(
path.dirname(target),
`.partial-${randomUUID()}.${asset.kind === "photo" ? "jpg" : "mp4"}`,
);
try {
await runMediaTool(this.options.ffmpegPath ?? "ffmpeg", renderArgs(asset, partial));
await rename(partial, target);
} finally {
await rm(partial, { force: true });
}
return target;
}
}
function renderArgs(asset: EmulatorAsset, out: string): string[] {
const { width, height, fps } = CLIP_SIZE;
const pattern = asset.theme === "a" ? "testsrc2" : "smptehdbars";
const video = `${pattern}=s=${width}x${height}:r=${fps}:d=${CLIP_MS / 1000}`;
const metadata = [
"-metadata",
`creation_time=${asset.capturedAt.toISOString()}`,
"-metadata",
`location=${iso6709(asset.gpsLat, asset.gpsLon)}`,
];
if (asset.kind === "photo")
return ["-v", "error", "-y", "-f", "lavfi", "-i", video, "-frames:v", "1", out];
return [
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
video,
"-f",
"lavfi",
"-i",
`sine=f=${220 + (asset.index % 12) * 40}:sample_rate=48000:d=${CLIP_MS / 1000}`,
"-shortest",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
...metadata,
"-movflags",
"+faststart",
out,
];
}
function decodeIndex(cursor: string): number {
const value = Number(Buffer.from(cursor, "base64url").toString("utf8"));
if (!Number.isInteger(value) || value < 0) throw new Error("invalid emulator cursor");
return value;
}
async function exists(file: string): Promise<boolean> {
try {
return (await stat(file)).isFile();
} catch {
return false;
}
}

View File

@ -0,0 +1,335 @@
import { copyFile, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
import { cosineSimilarity, haversineKm } from "@rakazo/media";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
buildRedactionGraph,
EmulatorMediaInferenceProvider,
parseEmulatorMarkers,
redactionRegions,
} from "./media-emulator.js";
import { ffmpegAvailable, renderTestClip } from "./media-emulator-clips.js";
import { EmulatorMediaSourceProvider, emulatorAsset } from "./media-emulator-source.js";
import { probeMedia } from "./media-folder-source-probe.js";
const hasFfmpeg = ffmpegAvailable();
let root: string;
let provider: EmulatorMediaInferenceProvider;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), "media-emulator-"));
provider = new EmulatorMediaInferenceProvider({ root });
});
afterAll(async () => {
await rm(root, { recursive: true, force: true });
});
/** A non-media file: the emulator falls back to a 10 s clip, so no ffmpeg is needed. */
async function fixture(name: string, content = "synthetic bytes") {
const file = path.join(root, name);
await writeFile(file, content);
return file;
}
describe("parseEmulatorMarkers", () => {
it("reads every documented marker", () => {
expect(
parseEmulatorMarkers(
"/x/clip__faces-2____person-a____noface-gap-1000-2500____nsfw-explicit_nudity-0.9____nsfw-violence-0.4____cut-1500____cut-3000____speech____theme-b____inpaintq-0.3__.mp4",
),
).toEqual({
faces: 2,
person: "a",
noFaceGaps: [{ startMs: 1000, endMs: 2500 }],
nsfw: { explicit_nudity: 0.9, violence: 0.4 },
cutsMs: [1500, 3000],
speech: true,
theme: "b",
inpaintQuality: 0.3,
});
expect(parseEmulatorMarkers("__nsfw-unknown-0.5____nsfw-violence-7__.mp4").nsfw).toEqual({});
});
});
describe("redaction regions", () => {
it("holds the union of neighbouring keyframes and bounds the graph", () => {
const regions = redactionRegions(
{
keyframes: [
{ tMs: 1000, box: { x: 0.5, y: 0.5, w: 0.1, h: 0.1 } },
{ tMs: 0, box: { x: 0.1, y: 0.1, w: 0.1, h: 0.1 } },
],
mode: "blur",
paddingPct: 0,
featherPct: 0,
},
1000,
1000,
);
expect(regions).toEqual([
{ startMs: 0, endMs: 1000, box: { x: 100, y: 100, w: 500, h: 500 } },
{ startMs: 1000, endMs: 1100, box: { x: 500, y: 500, w: 100, h: 100 } },
]);
const keyframes = Array.from({ length: 5000 }, (_, i) => ({
tMs: i * 40,
box: { x: (i % 50) / 100, y: 0.2, w: 0.1, h: 0.1 },
}));
const many = redactionRegions(
{ keyframes, mode: "pixelate", paddingPct: 25, featherPct: 0 },
1920,
1080,
);
expect(many.length).toBeLessThanOrEqual(400);
expect(many[0]?.startMs).toBe(0);
expect(many.at(-1)?.endMs).toBe(4999 * 40 + 100);
const graph = buildRedactionGraph(
[{ keyframes, mode: "solid", paddingPct: 0, featherPct: 0, color: "#ff0000" }],
1920,
1080,
);
expect(graph.filter).toContain("color=0xff0000@1:t=fill");
});
});
describe("EmulatorMediaInferenceProvider", () => {
it("is quiet without markers", async () => {
const file = await fixture("plain.mp4");
const scenes = await provider.detectScenes({ path: file });
expect(scenes.scenes).toEqual([{ startMs: 0, endMs: 10_000 }]);
expect(scenes.shots).toHaveLength(1);
expect((await provider.trackFaces({ path: file, sampleFps: 2 })).tracks).toEqual([]);
const faces = await provider.detectFaces({ path: file, sampleFps: 1 });
expect(faces.frames).toHaveLength(10);
expect(faces.frames.every((frame) => frame.faces.length === 0)).toBe(true);
const classified = await provider.classifyContent({ path: file, sampleFps: 2 });
expect(classified.frames).toHaveLength(20);
for (const frame of classified.frames)
expect(Object.values(frame.scores).every((score) => score === 0.01)).toBe(true);
expect(await provider.transcribe({ path: file })).toEqual({ language: null, words: [] });
});
it("forces faces, gaps, cuts, classifier scores and speech from the file name", async () => {
const file = await fixture(
"rich__faces-2____noface-gap-2000-3000____cut-4000____nsfw-explicit_nudity-0.9____speech__.mp4",
);
const tracks = await provider.trackFaces({ path: file, sampleFps: 2 });
expect(tracks.tracks.map((track) => track.trackId)).toEqual(["track-1", "track-2"]);
const times = tracks.tracks[0]?.keyframes.map((keyframe) => keyframe.tMs) ?? [];
expect(times).toContain(1500);
expect(times).not.toContain(2000);
expect(times).not.toContain(2500);
expect(times).toContain(3000);
const gapFrame = await provider.detectFaces({
path: file,
sampleFps: 1,
timesMs: [2500, 3500],
});
expect(gapFrame.frames.map((frame) => frame.faces.length)).toEqual([0, 2]);
expect((await provider.detectScenes({ path: file })).scenes).toEqual([
{ startMs: 0, endMs: 4000 },
{ startMs: 4000, endMs: 10_000 },
]);
const classified = await provider.classifyContent({
path: file,
sampleFps: 0.5,
extraTimesMs: [4000, 4001],
});
expect(classified.frames.map((frame) => frame.tMs)).toEqual([0, 2000, 4000, 4001, 6000, 8000]);
for (const frame of classified.frames) {
expect(frame.scores.explicit_nudity).toBe(0.9);
expect(frame.scores.violence).toBe(0.01);
}
const english = await provider.transcribe({ path: file });
const german = await provider.transcribe({ path: file, language: "de-DE" });
expect(english.language).toBe("en");
expect(english.words.map((word) => word.text).slice(0, 2)).toEqual(["welcome", "back"]);
expect(german.language).toBe("de");
expect(german.words[0]).toEqual({
startMs: 400,
endMs: 720,
text: "willkommen",
confidence: 0.95,
});
});
it("is deterministic per file content and keeps boxes stable", async () => {
const file = await fixture("same__faces-1__.mp4");
const copy = path.join(root, "copy__faces-1__.mp4");
await copyFile(file, copy);
const a = await provider.detectFaces({ path: file, sampleFps: 4 });
const b = await provider.detectFaces({ path: file, sampleFps: 4 });
const c = await provider.detectFaces({ path: copy, sampleFps: 4 });
expect(a).toEqual(b);
expect(a).toEqual(c);
const boxes = a.frames.map((frame) => frame.faces[0]?.box);
expect(Math.abs((boxes.at(-1)?.x ?? 0) - (boxes[0]?.x ?? 0))).toBeLessThan(0.05);
});
it("embeds people and themes consistently", async () => {
const personA1 = await fixture("one__faces-1____person-a__.mp4", "first");
const personA2 = await fixture("two__faces-1____person-a__.mp4", "second");
const personB = await fixture("three__faces-1____person-b__.mp4", "third");
const embed = async (file: string) =>
(await provider.trackFaces({ path: file, sampleFps: 1 })).tracks[0]?.embedding ?? [];
expect(cosineSimilarity(await embed(personA1), await embed(personA2))).toBeCloseTo(1, 5);
expect(cosineSimilarity(await embed(personA1), await embed(personB))).toBeLessThan(0.8);
const themeA1 = await fixture("x__theme-a__.mp4", "x");
const themeA2 = await fixture("y__theme-a__.mp4", "y");
const themeB = await fixture("z__theme-b__.mp4", "z");
const mean = async (file: string) =>
(await provider.embedFrames({ path: file, timesMs: [0, 1000] })).vectors[0] ?? [];
expect(cosineSimilarity(await mean(themeA1), await mean(themeA2))).toBeGreaterThan(0.95);
expect(cosineSimilarity(await mean(themeA1), await mean(themeB))).toBeLessThan(0.85);
});
it("never synthesizes identities", async () => {
const file = await fixture("synth.mp4");
const capabilities = await provider.capabilities();
expect(capabilities.operations.synthesizeIdentity).toMatchObject({
available: false,
license: null,
});
expect(capabilities.operations.inpaintVideo).toMatchObject({
available: true,
experimental: true,
});
await expect(
provider.synthesizeIdentity({
path: file,
track: [],
seed: 1,
outPath: path.join(root, "s.mp4"),
}),
).rejects.toBeInstanceOf(UnsupportedMediaOperationError);
});
it("rejects paths outside its root", async () => {
await expect(provider.detectScenes({ path: "/etc/hostname" })).rejects.toThrow(
"outside the allowed root",
);
await expect(provider.detectScenes({ path: "relative.mp4" })).rejects.toThrow();
});
it.skipIf(!hasFfmpeg)("writes masks and inpaints with the reported quality", async () => {
const clip = await renderTestClip(path.join(root, "mark__inpaintq-0.4__.mp4"), {
seconds: 1,
audio: false,
});
const masks = await provider.segmentPrompted({
path: clip,
prompt: { tMs: 0, point: { x: 0.5, y: 0.5 } },
propagate: true,
outDir: path.join(root, "marks/m1"),
});
expect((await readdir(masks.maskDir)).sort()).toEqual(["0.png", "500.png"]);
expect(masks.keyframes[0]?.box).toEqual({ x: 0.44, y: 0.44, w: 0.12, h: 0.12 });
const inpainted = await provider.inpaintVideo({
path: clip,
maskDir: masks.maskDir,
outPath: path.join(root, "marks/m1.mp4"),
});
expect(inpainted.quality.score).toBe(0.4);
expect((await probeMedia(inpainted.outPath)).durationMs).toBeGreaterThan(900);
});
it.skipIf(!hasFfmpeg)("renders very large redaction graphs through a script file", async () => {
const clip = await renderTestClip(path.join(root, "busy.mp4"), { seconds: 1, audio: false });
const instruction = (offset: number) => ({
keyframes: Array.from({ length: 2000 }, (_, i) => ({
tMs: i,
box: { x: offset + (i % 40) / 400, y: 0.1, w: 0.1, h: 0.1 },
})),
mode: "blur" as const,
paddingPct: 0,
featherPct: 0,
});
const instructions = [instruction(0), instruction(0.3), instruction(0.6)];
expect(buildRedactionGraph(instructions, 320, 180).filter.length).toBeGreaterThan(100_000);
const result = await provider.redact({
path: clip,
redactions: instructions,
outPath: path.join(root, "busy-redacted.mp4"),
});
expect(result.framesProcessed).toBe(25);
});
it.skipIf(!hasFfmpeg)("measures loudness with ebur128", async () => {
const clip = await renderTestClip(path.join(root, "tone.mp4"), { seconds: 2 });
const features = await provider.audioFeatures({ path: clip });
expect(features.integratedLufs).toBeLessThan(-10);
expect(features.integratedLufs).toBeGreaterThan(-40);
expect(features.energy.length).toBeGreaterThan(0);
expect(features.beatsMs.length).toBeGreaterThan(0);
const silent = await renderTestClip(path.join(root, "mute.mp4"), { seconds: 1, audio: false });
expect(await provider.audioFeatures({ path: silent })).toMatchObject({
integratedLufs: null,
silences: [{ startMs: 0, endMs: 1000 }],
});
});
});
describe("EmulatorMediaSourceProvider", () => {
const config = { count: 30, seed: 4 };
it("describes three sessions, two themes and the marker assets", async () => {
const source = new EmulatorMediaSourceProvider({ fixturesDir: path.join(root, "library") });
const first = await source.list({ config, cursor: null, limit: 12 });
const second = await source.list({ config, cursor: first.nextCursor, limit: 100 });
expect(second.nextCursor).toBeNull();
const items = [...first.items, ...second.items];
expect(items).toHaveLength(30);
expect(new Set(items.map((item) => item.externalId)).size).toBe(30);
expect(await source.list({ config, cursor: null, limit: 12 })).toEqual(first);
const assets = items.map((_, index) => emulatorAsset(config.seed, config.count, index));
expect(new Set(assets.map((asset) => asset.session))).toEqual(new Set([0, 1, 2]));
expect(new Set(assets.map((asset) => asset.theme))).toEqual(new Set(["a", "b"]));
const sessionStarts = [0, 1, 2].map(
(session) => assets.find((asset) => asset.session === session)!,
);
expect(
haversineKm(
{ lat: sessionStarts[0]!.gpsLat, lon: sessionStarts[0]!.gpsLon },
{ lat: sessionStarts[1]!.gpsLat, lon: sessionStarts[1]!.gpsLon },
),
).toBeGreaterThan(2);
const names = items.map((item) => item.externalId).join(" ");
expect(names).toContain("__faces-1____person-a__");
expect(names).toContain("__faces-1____person-b__");
expect(names).toContain("__nsfw-explicit_nudity-0.9__");
expect(names).toContain("__speech__");
expect(items.some((item) => item.kind === "photo")).toBe(true);
});
it("rejects unknown external ids", async () => {
const source = new EmulatorMediaSourceProvider({ fixturesDir: path.join(root, "library") });
for (const bad of [
"../etc/passwd",
"emu-4-00099__theme-b__.mp4",
"emu-5-00000__theme-a__.mp4",
"emu-4-00000.mp4",
])
await expect(source.resolve(config, bad), bad).rejects.toThrow();
});
it.skipIf(!hasFfmpeg)("renders and caches clips on demand", async () => {
const source = new EmulatorMediaSourceProvider({ fixturesDir: path.join(root, "library") });
const asset = emulatorAsset(config.seed, config.count, 0);
const file = await source.resolve(config, asset.fileName);
expect(path.basename(file)).toBe(asset.fileName);
const probed = await probeMedia(file);
expect(probed).toMatchObject({ durationMs: 4000, width: 320, height: 180, hasAudio: true });
expect(probed.capturedAt?.toISOString()).toBe(asset.capturedAt.toISOString());
expect(await source.resolve(config, asset.fileName)).toBe(file);
const tracks = await provider.trackFaces({ path: file, sampleFps: 1 });
expect(tracks.tracks).toHaveLength(1);
const photo = emulatorAsset(config.seed, config.count, 4);
expect(photo.kind).toBe("photo");
expect((await probeMedia(await source.resolve(config, photo.fileName))).width).toBe(320);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
import { homedir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
createMediaInferenceProvider,
createMediaSourceProvider,
mediaConfigFromEnv,
resolveMediaInferenceKind,
} from "./media-factory.js";
const worker = {
MEDIA_WORKER_URL: "http://127.0.0.1:8790",
MEDIA_WORKER_TOKEN: "placeholder-token-placeholder-token-00",
};
describe("media factory", () => {
it("reads one generic worker connection and derives paths", () => {
expect(mediaConfigFromEnv({ DATA_DIR: "/srv/evoo", ...worker })).toEqual({
enabled: true,
workerUrl: worker.MEDIA_WORKER_URL,
workerToken: worker.MEDIA_WORKER_TOKEN,
profilesDir: path.join(homedir(), ".evoo/media-profiles"),
mediaRoot: "/srv/evoo/media",
});
expect(
mediaConfigFromEnv({ DATA_DIR: "/srv/evoo", MEDIA_PROFILES_DIR: "/etc/evoo/profiles" }),
).toMatchObject({ enabled: false, workerUrl: null, profilesDir: "/etc/evoo/profiles" });
});
it("selects the sidecar only with URL and token", () => {
expect(resolveMediaInferenceKind(worker)).toBe("sidecar");
expect(createMediaInferenceProvider(worker)?.id).toBe("sidecar");
expect(resolveMediaInferenceKind({ MEDIA_WORKER_URL: worker.MEDIA_WORKER_URL })).toBeNull();
expect(createMediaInferenceProvider({})).toBeNull();
});
it("pins the emulator under the scripted runtime", () => {
expect(resolveMediaInferenceKind({ ...worker, AGENT_RUNTIME: "scripted" })).toBe("emulator");
expect(createMediaInferenceProvider({ AGENT_RUNTIME: "scripted" })?.id).toBe("emulator");
expect(mediaConfigFromEnv({ AGENT_RUNTIME: "scripted" }).enabled).toBe(true);
// pin-test-env sets AGENT_RUNTIME=scripted for every unit test.
expect(createMediaInferenceProvider()?.id).toBe("emulator");
});
it("creates server-side sources only", () => {
expect(createMediaSourceProvider("folder", {}).kind).toBe("folder");
expect(createMediaSourceProvider("emulator", { AGENT_RUNTIME: "scripted" }).kind).toBe(
"emulator",
);
expect(() => createMediaSourceProvider("emulator", {})).toThrow();
expect(() => createMediaSourceProvider("apple_photos", {})).toThrow();
});
});

View File

@ -0,0 +1,90 @@
import { homedir } from "node:os";
import path from "node:path";
import type { MediaInferenceProvider, MediaSourceProvider } from "@rakazo/adapter-kit";
import { expandHome } from "@rakazo/media/node";
import { EmulatorMediaInferenceProvider } from "./media-emulator.js";
import { EmulatorMediaSourceProvider } from "./media-emulator-source.js";
import { FolderMediaSourceProvider } from "./media-folder-source.js";
import { SidecarMediaInferenceProvider } from "./media-sidecar.js";
export interface MediaConfig {
/** Media Studio runs: a sidecar is configured or the scripted runtime uses the emulator. */
enabled: boolean;
workerUrl: string | null;
workerToken: string | null;
profilesDir: string;
/** `${DATA_DIR}/media`, absolute. Every derived file and sidecar path lives here. */
mediaRoot: string;
}
export type MediaInferenceKind = "sidecar" | "emulator";
/** Emulators replace the sidecar whenever the scripted runtime is pinned (tests, e2e). */
export function mediaEmulatorEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.AGENT_RUNTIME === "scripted";
}
export function mediaConfigFromEnv(env: NodeJS.ProcessEnv = process.env): MediaConfig {
const workerUrl = env.MEDIA_WORKER_URL?.trim() || null;
const workerToken = env.MEDIA_WORKER_TOKEN?.trim() || null;
return {
enabled: mediaEmulatorEnabled(env) || Boolean(workerUrl && workerToken),
workerUrl,
workerToken,
profilesDir: path.resolve(
expandHome(env.MEDIA_PROFILES_DIR?.trim() || "~/.evoo/media-profiles", homedir()),
),
mediaRoot: path.resolve(env.DATA_DIR ?? "./data", "media"),
};
}
export function resolveMediaInferenceKind(
env: NodeJS.ProcessEnv = process.env,
): MediaInferenceKind | null {
if (mediaEmulatorEnabled(env)) return "emulator";
const config = mediaConfigFromEnv(env);
return config.workerUrl && config.workerToken ? "sidecar" : null;
}
/** The configured inference provider, or null when Media Studio is not set up. */
export function createMediaInferenceProvider(
env: NodeJS.ProcessEnv = process.env,
): MediaInferenceProvider | null {
const config = mediaConfigFromEnv(env);
switch (resolveMediaInferenceKind(env)) {
case "emulator":
return new EmulatorMediaInferenceProvider({ root: config.mediaRoot });
case "sidecar":
return new SidecarMediaInferenceProvider({
baseUrl: config.workerUrl as string,
token: config.workerToken as string,
});
default:
return null;
}
}
/** Emulator clips are cached inside the media root so inference can read them. */
export function emulatorFixturesDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(mediaConfigFromEnv(env).mediaRoot, "emulator-library");
}
/**
* Server-side source adapter for a persisted MediaSource kind. Client-pushed
* kinds (apple_photos, ios_library) have no server adapter.
*/
export function createMediaSourceProvider(
kind: string,
env: NodeJS.ProcessEnv = process.env,
): MediaSourceProvider {
switch (kind) {
case "folder":
return new FolderMediaSourceProvider();
case "emulator":
if (!mediaEmulatorEnabled(env)) break;
return new EmulatorMediaSourceProvider({ fixturesDir: emulatorFixturesDir(env) });
default:
break;
}
throw new Error(`No server-side media source for "${kind}". Use folder.`);
}

View File

@ -0,0 +1,194 @@
import { execFile } from "node:child_process";
/*
* Local ffmpeg/ffprobe helpers shared by the folder source and the media
* emulator. Every call is an argv array without a shell; nothing here builds a
* command string.
*/
export class MediaToolMissingError extends Error {
constructor(readonly tool: string) {
super(`${tool} is not installed or not on PATH; install ffmpeg to use local media processing`);
this.name = "MediaToolMissingError";
}
}
export interface MediaToolPaths {
ffmpegPath?: string;
ffprobePath?: string;
}
/** Run a media tool and collect stdout (binary) and stderr. Rejects on non-zero exit. */
export function runMediaTool(
bin: string,
args: string[],
options: { signal?: AbortSignal; maxBuffer?: number } = {},
): Promise<{ stdout: Buffer; stderr: string }> {
options.signal?.throwIfAborted();
return new Promise((resolve, reject) => {
execFile(
bin,
args,
{
encoding: "buffer",
maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
signal: options.signal,
shell: false,
windowsHide: true,
},
(error, stdout, stderr) => {
if (options.signal?.aborted) {
reject(options.signal.reason);
return;
}
if (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
reject(new MediaToolMissingError(bin));
return;
}
const tail = stderr.toString("utf8").trim().split("\n").slice(-3).join(" ");
reject(new Error(`${bin} failed: ${tail || error.message}`));
return;
}
resolve({ stdout, stderr: stderr.toString("utf8") });
},
);
});
}
export interface ProbedMedia {
durationMs: number | null;
width: number | null;
height: number | null;
fps: number | null;
codec: string | null;
rotation: number;
hasAudio: boolean;
hasVideo: boolean;
capturedAt: Date | null;
gpsLat: number | null;
gpsLon: number | null;
deviceModel: string | null;
}
interface FfprobeStream {
codec_type?: string;
codec_name?: string;
width?: number;
height?: number;
avg_frame_rate?: string;
r_frame_rate?: string;
duration?: string;
tags?: Record<string, string>;
side_data_list?: Array<{ rotation?: number }>;
}
interface FfprobeOutput {
streams?: FfprobeStream[];
format?: { duration?: string; tags?: Record<string, string> };
frames?: Array<{ tags?: Record<string, string> }>;
}
/** Probe a local file. Throws when ffprobe is missing or cannot read the file. */
export async function probeMedia(
file: string,
options: { ffprobePath?: string; signal?: AbortSignal; frameTags?: boolean } = {},
): Promise<ProbedMedia> {
const args = ["-v", "error", "-print_format", "json", "-show_format", "-show_streams"];
if (options.frameTags) args.push("-show_frames", "-read_intervals", "%+#1");
args.push("--", file);
const { stdout } = await runMediaTool(options.ffprobePath ?? "ffprobe", args, {
signal: options.signal,
});
return parseFfprobe(JSON.parse(stdout.toString("utf8")) as FfprobeOutput);
}
export function parseFfprobe(output: FfprobeOutput): ProbedMedia {
const streams = output.streams ?? [];
const video = streams.find((stream) => stream.codec_type === "video");
const tags = collectTags(output);
const rotation = video ? streamRotation(video) : 0;
const quarterTurn = Math.abs(rotation) % 180 === 90;
const durationSeconds = Number(output.format?.duration ?? video?.duration);
const gps = parseIso6709(
tags.get("com.apple.quicktime.location.iso6709") ?? tags.get("location"),
);
return {
durationMs:
Number.isFinite(durationSeconds) && durationSeconds > 0
? Math.round(durationSeconds * 1000)
: null,
width: (quarterTurn ? video?.height : video?.width) ?? null,
height: (quarterTurn ? video?.width : video?.height) ?? null,
fps: video ? (parseRate(video.avg_frame_rate) ?? parseRate(video.r_frame_rate)) : null,
codec: video?.codec_name ?? null,
rotation,
hasAudio: streams.some((stream) => stream.codec_type === "audio"),
hasVideo: Boolean(video),
capturedAt: parseCaptureDate(
tags.get("com.apple.quicktime.creationdate") ??
tags.get("datetimeoriginal") ??
tags.get("creation_time") ??
tags.get("datetime"),
),
gpsLat: gps?.lat ?? null,
gpsLon: gps?.lon ?? null,
deviceModel: tags.get("com.apple.quicktime.model") ?? tags.get("model") ?? null,
};
}
/** Format, stream and first-frame tags merged with lowercase keys; format wins. */
function collectTags(output: FfprobeOutput): Map<string, string> {
const merged = new Map<string, string>();
const sources = [
output.format?.tags,
...(output.streams ?? []).map((stream) => stream.tags),
...(output.frames ?? []).map((frame) => frame.tags),
];
for (const source of sources) {
for (const [key, value] of Object.entries(source ?? {})) {
const normalized = key.toLowerCase();
if (!merged.has(normalized) && typeof value === "string" && value.trim())
merged.set(normalized, value.trim());
}
}
return merged;
}
function streamRotation(stream: FfprobeStream): number {
const side = stream.side_data_list?.find((entry) => typeof entry.rotation === "number");
if (side?.rotation !== undefined) return side.rotation;
const tagged = Number(stream.tags?.rotate);
return Number.isFinite(tagged) ? tagged : 0;
}
function parseRate(rate: string | undefined): number | null {
if (!rate) return null;
const [num, den] = rate.split("/").map(Number);
if (!num || !Number.isFinite(num)) return null;
const value = den ? num / den : num;
return Number.isFinite(value) && value > 0 ? Math.round(value * 1000) / 1000 : null;
}
/** ISO 8601 or EXIF ("2026:05:01 09:00:00") timestamps; null when unparseable. */
export function parseCaptureDate(value: string | undefined): Date | null {
if (!value) return null;
const exif = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(value);
const date = exif
? new Date(`${exif[1]}-${exif[2]}-${exif[3]}T${exif[4]}:${exif[5]}:${exif[6]}Z`)
: new Date(value);
const time = date.getTime();
return Number.isFinite(time) && time > 0 ? date : null;
}
/** "+52.5200+013.4050+034.000/" → { lat, lon }; null when absent or out of range. */
export function parseIso6709(value: string | undefined): { lat: number; lon: number } | null {
if (!value) return null;
const match = /^([+-]\d+(?:\.\d+)?)([+-]\d+(?:\.\d+)?)/.exec(value.trim());
if (!match) return null;
const lat = Number(match[1]);
const lon = Number(match[2]);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
return { lat, lon };
}

View File

@ -0,0 +1,177 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { MediaSourceItem } from "@rakazo/adapter-kit";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ffmpegAvailable } from "./media-emulator-clips.js";
import { FolderMediaSourceProvider } from "./media-folder-source.js";
import { parseIso6709, runMediaTool } from "./media-folder-source-probe.js";
let root: string;
const provider = new FolderMediaSourceProvider();
async function touch(relative: string, content = "") {
const file = path.join(root, relative);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, content);
}
async function listAll(limit: number, cursor: string | null = null) {
const pages: MediaSourceItem[][] = [];
let next = cursor;
do {
const page = await provider.list({ config: { path: root }, cursor: next, limit });
expect(page.items.length).toBeLessThanOrEqual(limit);
pages.push(page.items);
next = page.nextCursor;
} while (next);
return pages.flat().map((item) => item.externalId);
}
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), "media-folder-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
describe("FolderMediaSourceProvider", () => {
it("lists media in stable code-unit order across pages", async () => {
for (const name of ["b/c.mp4", "b-d.jpg", "a.MOV", "b/a/z.webm", "c.png", "notes.txt"])
await touch(name);
const ids = await listAll(2);
expect(ids).toEqual(["a.MOV", "b-d.jpg", "b/a/z.webm", "b/c.mp4", "c.png"]);
});
it("keeps the cursor stable when files are added mid-sync", async () => {
for (const name of ["a.mp4", "c.mp4", "e.mp4", "g.mp4"]) await touch(name);
const first = await provider.list({ config: { path: root }, cursor: null, limit: 2 });
expect(first.items.map((item) => item.externalId)).toEqual(["a.mp4", "c.mp4"]);
await touch("b.mp4"); // before the cursor: picked up by the next full sync
await touch("d.mp4"); // after the cursor: part of this sync
const rest = await listAll(2, first.nextCursor);
expect(rest).toEqual(["d.mp4", "e.mp4", "g.mp4"]);
expect(await listAll(10)).toEqual(["a.mp4", "b.mp4", "c.mp4", "d.mp4", "e.mp4", "g.mp4"]);
});
it("skips hidden entries and symlinks without following them", async () => {
await touch("keep/clip.mp4");
await touch(".hidden.mp4");
await touch(".cache/inside.mp4");
await touch("keep/.DS_Store.jpg");
const outside = await mkdtemp(path.join(tmpdir(), "media-folder-outside-"));
try {
await writeFile(path.join(outside, "secret.mp4"), "");
await symlink(path.join(outside, "secret.mp4"), path.join(root, "link.mp4"));
await symlink(outside, path.join(root, "linked-dir"));
await symlink(path.join(root, "keep"), path.join(root, "keep-alias"));
expect(await listAll(10)).toEqual(["keep/clip.mp4"]);
await expect(provider.resolve({ path: root }, "link.mp4")).rejects.toThrow();
await expect(provider.resolve({ path: root }, "linked-dir/secret.mp4")).rejects.toThrow();
await expect(provider.resolve({ path: root }, "keep-alias/clip.mp4")).rejects.toThrow();
} finally {
await rm(outside, { recursive: true, force: true });
}
});
it("resolves inside the root and rejects traversal", async () => {
await touch("keep/clip.mp4");
await touch(".hidden.mp4");
await expect(provider.resolve({ path: root }, "keep/clip.mp4")).resolves.toBe(
path.join(root, "keep/clip.mp4"),
);
for (const bad of [
"../x",
"../x.mp4",
"keep/../../x.mp4",
"/etc/passwd",
".hidden.mp4",
"keep",
])
await expect(provider.resolve({ path: root }, bad), bad).rejects.toThrow();
});
it("rejects relative roots and forged cursors", async () => {
await expect(
provider.list({ config: { path: "media" }, cursor: null, limit: 5 }),
).rejects.toThrow();
await expect(
provider.list({ config: { path: root }, cursor: "not base64url!", limit: 5 }),
).rejects.toThrow();
});
it("pages a 1,000-file folder without duplicates or gaps", async () => {
const expected: string[] = [];
for (let dir = 0; dir < 10; dir++) {
for (let file = 0; file < 100; file++) {
const relative = `d${dir}/f${String(file).padStart(3, "0")}.${file % 3 === 0 ? "jpg" : "mp4"}`;
// Unreadable media must not abort a page; most files are empty and skip probing.
await touch(relative, file % 50 === 7 ? "garbage, not a video" : "");
expected.push(relative);
}
}
await touch("d3/ignore.txt");
const pages: MediaSourceItem[][] = [];
let cursor: string | null = null;
do {
const page = await provider.list({ config: { path: root }, cursor, limit: 137 });
pages.push(page.items);
cursor = page.nextCursor;
} while (cursor);
const items = pages.flat();
expect(pages.length).toBe(Math.ceil(1000 / 137));
expect(items.map((item) => item.externalId)).toEqual(expected.sort());
expect(new Set(items.map((item) => item.externalId)).size).toBe(1000);
const garbage = items.find((item) => item.externalId === "d0/f007.mp4");
expect(garbage).toMatchObject({ durationMs: null, width: null, sizeBytes: 20, kind: "video" });
expect(garbage?.capturedAt).toBeInstanceOf(Date);
expect(garbage?.revision).toMatch(/^\d+-20$/);
expect(items.find((item) => item.externalId === "d0/f000.jpg")?.kind).toBe("photo");
});
it.skipIf(!ffmpegAvailable())(
"reads duration, size, capture time and GPS with ffprobe",
async () => {
await mkdir(path.join(root, "trip"), { recursive: true });
await runMediaTool("ffmpeg", [
"-v",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=s=320x180:r=25:d=1",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-metadata",
"creation_time=2026-05-02T08:00:00Z",
"-metadata",
"location=+52.5200+013.4050/",
path.join(root, "trip/clip.mp4"),
]);
const [item] = (await provider.list({ config: { path: root }, cursor: null, limit: 5 }))
.items;
expect(item).toMatchObject({
externalId: "trip/clip.mp4",
kind: "video",
durationMs: 1000,
width: 320,
height: 180,
fps: 25,
gpsLat: 52.52,
gpsLon: 13.405,
albumNames: ["trip"],
});
expect(item?.capturedAt?.toISOString()).toBe("2026-05-02T08:00:00.000Z");
},
);
it("parses ISO 6709 locations", () => {
expect(parseIso6709("+52.5200+013.4050+034.000/")).toEqual({ lat: 52.52, lon: 13.405 });
expect(parseIso6709("-33.8688+151.2093/")).toEqual({ lat: -33.8688, lon: 151.2093 });
expect(parseIso6709("+95.0+013.0/")).toBeNull();
expect(parseIso6709("nowhere")).toBeNull();
});
});

View File

@ -0,0 +1,221 @@
import type { Dirent } from "node:fs";
import { lstat, readdir } from "node:fs/promises";
import path from "node:path";
import type {
MediaSourceItem,
MediaSourceListRequest,
MediaSourceListResult,
MediaSourceProvider,
} from "@rakazo/adapter-kit";
import type { MediaAssetKind } from "@rakazo/contracts";
import { PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import { z } from "zod";
import { probeMedia } from "./media-folder-source-probe.js";
/*
* Server-side folder source. The deployment owner picks an absolute root; the
* walk lists media files below it in a stable order (relative POSIX paths,
* code-unit sorted) so the opaque cursor (the last relative path) stays valid
* while files are added. Hidden entries and symlinks are skipped and never
* followed. Only the directories needed for one page are read.
*/
export const FOLDER_MEDIA_EXTENSIONS: Readonly<Record<string, MediaAssetKind>> = {
mp4: "video",
mov: "video",
m4v: "video",
mkv: "video",
webm: "video",
avi: "video",
jpg: "photo",
jpeg: "photo",
png: "photo",
heic: "photo",
heif: "photo",
webp: "photo",
};
const FolderConfigSchema = z.object({
path: z.string().min(1).refine(path.isAbsolute, "folder path must be absolute"),
});
const MAX_PAGE = 1000;
const PROBE_CONCURRENCY = 4;
export interface FolderMediaSourceOptions {
ffprobePath?: string;
}
export function encodeFolderCursor(relativePath: string): string {
return Buffer.from(relativePath, "utf8").toString("base64url");
}
export function decodeFolderCursor(cursor: string | null): string | null {
if (!cursor) return null;
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
if (!decoded || decoded.includes("\0") || encodeFolderCursor(decoded) !== cursor)
throw new Error("invalid folder cursor");
return decoded;
}
function mediaKindOf(name: string): MediaAssetKind | null {
const ext = path.extname(name).slice(1).toLowerCase();
return FOLDER_MEDIA_EXTENSIONS[ext] ?? null;
}
/** Sort key that makes a depth-first walk yield relative paths in code-unit order. */
function entryKey(entry: Dirent): string {
return entry.isDirectory() ? `${entry.name}/` : entry.name;
}
function compareKeys(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* Yield relative paths of media files strictly after `after`, in order.
* Directories whose whole subtree sorts before the cursor are not read.
*/
async function* walkAfter(
root: string,
relativeDir: string,
after: string | null,
signal?: AbortSignal,
): AsyncGenerator<string> {
signal?.throwIfAborted();
let entries: Dirent[];
try {
entries = await readdir(path.join(root, relativeDir), { withFileTypes: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
// A directory removed or locked mid-walk is skipped; the root itself must exist.
if (relativeDir && (code === "ENOENT" || code === "EACCES" || code === "ENOTDIR")) return;
throw error;
}
const visible = entries
.filter((entry) => !entry.name.startsWith(".") && !entry.isSymbolicLink())
.filter((entry) => entry.isDirectory() || (entry.isFile() && mediaKindOf(entry.name)))
.map((entry) => ({ entry, key: entryKey(entry) }))
.sort((a, b) => compareKeys(a.key, b.key));
for (const { entry, key } of visible) {
const relative = relativeDir ? `${relativeDir}/${key}` : key;
if (entry.isDirectory()) {
// Subtree entirely before the cursor: every path in it starts with `relative`.
if (after !== null && relative < after && !after.startsWith(relative)) continue;
yield* walkAfter(root, relative.slice(0, -1), after, signal);
} else if (after === null || relative > after) {
yield relative;
}
}
}
async function mapLimited<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const out = new Array<R>(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next++;
out[index] = await fn(items[index] as T);
}
});
await Promise.all(workers);
return out;
}
export class FolderMediaSourceProvider implements MediaSourceProvider {
readonly kind = "folder" as const;
constructor(private readonly options: FolderMediaSourceOptions = {}) {}
async list(request: MediaSourceListRequest): Promise<MediaSourceListResult> {
const { path: root } = FolderConfigSchema.parse(request.config);
const limit = Math.max(1, Math.min(MAX_PAGE, Math.floor(request.limit)));
const after = decodeFolderCursor(request.cursor);
const relatives: string[] = [];
let more = false;
for await (const relative of walkAfter(root, "", after, request.signal)) {
if (relatives.length === limit) {
more = true;
break;
}
relatives.push(relative);
}
const items = await mapLimited(relatives, PROBE_CONCURRENCY, (relative) =>
this.describe(root, relative, request.signal),
);
const present = items.filter((item): item is MediaSourceItem => item !== null);
const last = relatives.at(-1);
return { items: present, nextCursor: more && last ? encodeFolderCursor(last) : null };
}
async resolve(config: Record<string, unknown>, externalId: string): Promise<string> {
const { path: root } = FolderConfigSchema.parse(config);
const segments = externalId.split("/");
if (segments.some((segment) => segment.startsWith(".")) || !mediaKindOf(externalId))
throw new PathOutsideRootError(externalId);
const absolute = await resolveInside(root, externalId);
// Symlinks are never followed, at any depth below the root.
let current = await resolveInside(root, ".");
for (const segment of segments) {
current = path.join(current, segment);
if ((await lstat(current)).isSymbolicLink()) throw new PathOutsideRootError(externalId);
}
if (!(await lstat(absolute)).isFile()) throw new PathOutsideRootError(externalId);
return absolute;
}
/**
* Stat and probe one file. Probe failures yield null metadata, never a failed
* page. The parent folder name doubles as an album for rule-based labels.
*/
private async describe(
root: string,
relative: string,
signal?: AbortSignal,
): Promise<MediaSourceItem | null> {
const absolute = path.join(root, relative);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(absolute);
} catch {
return null; // Removed between walk and stat.
}
if (!stats.isFile()) return null;
const kind = mediaKindOf(relative) ?? "video";
let probed: Awaited<ReturnType<typeof probeMedia>> | null = null;
if (stats.size > 0) {
try {
probed = await probeMedia(absolute, {
ffprobePath: this.options.ffprobePath,
signal,
frameTags: kind === "photo",
});
} catch {
// Unreadable, unsupported, or no ffprobe on this host: keep the file with null metadata.
signal?.throwIfAborted();
probed = null;
}
}
return {
externalId: relative,
kind,
capturedAt: probed?.capturedAt ?? new Date(Math.trunc(stats.mtimeMs)),
durationMs: kind === "video" ? (probed?.durationMs ?? null) : null,
width: probed?.width ?? null,
height: probed?.height ?? null,
fps: kind === "video" ? (probed?.fps ?? null) : null,
gpsLat: probed?.gpsLat ?? null,
gpsLon: probed?.gpsLon ?? null,
deviceModel: probed?.deviceModel ?? null,
albumNames: relative.includes("/") ? [path.posix.basename(path.posix.dirname(relative))] : [],
isFavorite: false,
fileName: path.posix.basename(relative),
sizeBytes: stats.size,
revision: `${Math.trunc(stats.mtimeMs)}-${stats.size}`,
};
}
}

View File

@ -0,0 +1,222 @@
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
import { MediaInferenceOperationSchema } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import { MediaSidecarError, SidecarMediaInferenceProvider } from "./media-sidecar.js";
const TOKEN = "test-token-0123456789abcdef0123456789abcdef";
const SCENES = {
shots: [{ startMs: 0, endMs: 1000, score: 1 }],
scenes: [{ startMs: 0, endMs: 1000 }],
};
interface Call {
url: string;
init: RequestInit;
}
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function fakeFetch(responses: Array<Response | ((init: RequestInit) => Promise<Response>)>) {
const calls: Call[] = [];
const impl = (async (input: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(input), init: init ?? {} });
const next = responses.shift();
if (!next) throw new Error("unexpected request");
return typeof next === "function" ? next(init ?? {}) : next;
}) as typeof fetch;
return { impl, calls };
}
function client(fetchImpl: typeof fetch, extra: { retryBaseMs?: number } = {}) {
return new SidecarMediaInferenceProvider({
baseUrl: "http://127.0.0.1:8800/",
token: TOKEN,
fetch: fetchImpl,
retryBaseMs: 0,
...extra,
});
}
describe("SidecarMediaInferenceProvider", () => {
it("posts camelCase JSON with bearer auth and without the signal", async () => {
const { impl, calls } = fakeFetch([json(200, SCENES)]);
const controller = new AbortController();
const result = await client(impl).detectScenes({
path: "/data/media/a.mp4",
signal: controller.signal,
});
expect(result).toEqual(SCENES);
expect(calls).toHaveLength(1);
const [call] = calls;
expect(call?.url).toBe("http://127.0.0.1:8800/v1/detectScenes");
expect(call?.init.method).toBe("POST");
const headers = call?.init.headers as Record<string, string>;
expect(headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(headers["content-type"]).toBe("application/json");
expect(JSON.parse(String(call?.init.body))).toEqual({ path: "/data/media/a.mp4" });
expect(call?.init.signal).toBeInstanceOf(AbortSignal);
});
it("reads capabilities with GET", async () => {
const capabilities = {
configured: true,
operations: Object.fromEntries(
MediaInferenceOperationSchema.options.map((operation) => [
operation,
{
available: operation !== "synthesizeIdentity",
experimental: false,
model: "m",
license: "Apache-2.0",
commercialUse: true,
},
]),
),
accelerator: "cpu",
hardwareEncoders: [],
};
const { impl, calls } = fakeFetch([json(200, capabilities)]);
await expect(client(impl).capabilities()).resolves.toEqual(capabilities);
expect(calls[0]?.url).toBe("http://127.0.0.1:8800/v1/capabilities");
expect(calls[0]?.init.method).toBe("GET");
});
it("maps 501 to UnsupportedMediaOperationError", async () => {
const { impl } = fakeFetch([
json(501, {
error: { code: "unsupported", message: "no licensed model", operation: "inpaintVideo" },
}),
]);
const error = await client(impl)
.inpaintVideo({ path: "/m/a.mp4", maskDir: "/m/masks", outPath: "/m/out.mp4" })
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(UnsupportedMediaOperationError);
expect((error as UnsupportedMediaOperationError).operation).toBe("inpaintVideo");
expect((error as Error).message).toContain("no licensed model");
});
it("retries 503 busy with backoff, at most three times", async () => {
const busy = () => json(503, { error: { code: "busy", message: "queue full" } });
const recovered = fakeFetch([busy(), busy(), json(200, SCENES)]);
await expect(client(recovered.impl).detectScenes({ path: "/m/a.mp4" })).resolves.toEqual(
SCENES,
);
expect(recovered.calls).toHaveLength(3);
const exhausted = fakeFetch([busy(), busy(), busy(), busy(), busy()]);
const error = await client(exhausted.impl)
.detectScenes({ path: "/m/a.mp4" })
.catch((caught: unknown) => caught);
expect(exhausted.calls).toHaveLength(4);
expect(error).toBeInstanceOf(MediaSidecarError);
expect((error as MediaSidecarError).code).toBe("busy");
expect((error as MediaSidecarError).status).toBe(503);
});
it("maps typed sidecar errors", async () => {
const { impl } = fakeFetch([
json(400, { error: { code: "invalid_path", message: "path is outside MEDIA_ROOT" } }),
json(500, "not json-shaped"),
]);
const provider = client(impl);
await expect(provider.detectScenes({ path: "/etc/passwd" })).rejects.toMatchObject({
name: "MediaSidecarError",
code: "invalid_path",
status: 400,
operation: "detectScenes",
});
await expect(provider.detectScenes({ path: "/m/a.mp4" })).rejects.toMatchObject({
code: "http_error",
status: 500,
});
});
it("passes caller aborts through as AbortError", async () => {
const controller = new AbortController();
const { impl } = fakeFetch([
(init) =>
new Promise<Response>((_, reject) => {
init.signal?.addEventListener("abort", () => reject(init.signal?.reason));
setTimeout(() => controller.abort(), 10);
}),
]);
await expect(
client(impl).trackFaces({ path: "/m/a.mp4", sampleFps: 5, signal: controller.signal }),
).rejects.toMatchObject({ name: "AbortError" });
const already = new AbortController();
already.abort();
const idle = fakeFetch([]);
await expect(
client(idle.impl).detectScenes({ path: "/m/a.mp4", signal: already.signal }),
).rejects.toMatchObject({ name: "AbortError" });
expect(idle.calls).toHaveLength(0);
});
it("reports a timeout as a typed error", async () => {
const { impl } = fakeFetch([
(init) =>
new Promise<Response>((_, reject) => {
init.signal?.addEventListener("abort", () => reject(init.signal?.reason));
}),
]);
const provider = new SidecarMediaInferenceProvider({
baseUrl: "http://127.0.0.1:8800",
token: TOKEN,
fetch: impl,
timeoutsMs: { detectScenes: 20 },
});
await expect(provider.detectScenes({ path: "/m/a.mp4" })).rejects.toMatchObject({
code: "timeout",
});
});
it("rejects malformed responses", async () => {
const { impl } = fakeFetch([
json(200, { shots: [{ startMs: -1, endMs: 10, score: 1 }], scenes: [] }),
json(200, {
frames: [{ tMs: 0, faces: [{ box: { x: 2, y: 0, w: 0.1, h: 0.1 }, confidence: 1 }] }],
}),
json(200, { model: "m", dim: 3, vectors: [[1, 2]] }),
new Response("<html>", { status: 200 }),
]);
const provider = client(impl);
await expect(provider.detectScenes({ path: "/m/a.mp4" })).rejects.toMatchObject({
code: "malformed_response",
});
await expect(provider.detectFaces({ path: "/m/a.mp4", sampleFps: 1 })).rejects.toMatchObject({
code: "malformed_response",
});
await expect(provider.embedFrames({ path: "/m/a.mp4", timesMs: [0] })).rejects.toMatchObject({
code: "malformed_response",
});
await expect(provider.detectScenes({ path: "/m/a.mp4" })).rejects.toMatchObject({
code: "malformed_response",
});
});
it("never puts the token into error messages", async () => {
const { impl } = fakeFetch([
json(401, { error: { code: "unauthorized", message: `bad token ${TOKEN}` } }),
json(500, { error: { code: "internal", message: `crashed with Bearer ${TOKEN}` } }),
async () => {
throw new TypeError(`connect ECONNREFUSED (auth ${TOKEN})`);
},
]);
const provider = client(impl);
for (let i = 0; i < 3; i++) {
const error = (await provider
.detectScenes({ path: "/m/a.mp4" })
.catch((caught: unknown) => caught)) as Error;
expect(error).toBeInstanceOf(MediaSidecarError);
expect(error.message).not.toContain(TOKEN);
expect(JSON.stringify(error)).not.toContain(TOKEN);
expect(String(error.stack)).not.toContain(TOKEN);
}
});
});

View File

@ -0,0 +1,410 @@
import type {
AudioFeaturesResult,
ClassifyContentResult,
DetectFacesResult,
DetectScenesResult,
EmbedFramesResult,
FaceLandmarksResult,
InpaintVideoResult,
MediaInferenceProvider,
SaliencyResult,
SegmentPromptedResult,
TrackFacesResult,
TranscribeResult,
} from "@rakazo/adapter-kit";
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
import type { MediaCapabilities, MediaInferenceOperation } from "@rakazo/contracts";
import {
BoxKeyframeSchema,
ContentCategorySchema,
MediaCapabilitiesSchema,
NormalizedBoxSchema,
NormalizedPointSchema,
} from "@rakazo/contracts";
import { z } from "zod";
/*
* HTTP client for the local media sidecar (infra/media-worker, API v1).
* Bearer auth, JSON bodies, per-operation timeouts, bounded retry on 503 busy,
* 501 → UnsupportedMediaOperationError, every other failure → MediaSidecarError.
* Responses are validated before they reach the caller. The token never
* appears in an error message.
*/
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
const LONG_TIMEOUT_MS = 4 * 60 * 60_000;
const MAX_BUSY_RETRIES = 3;
export type MediaSidecarErrorCode =
| "bad_request"
| "invalid_path"
| "unauthorized"
| "too_large"
| "busy"
| "internal"
| "timeout"
| "unreachable"
| "malformed_response"
| "http_error";
export class MediaSidecarError extends Error {
constructor(
readonly code: MediaSidecarErrorCode,
message: string,
readonly status: number | null,
readonly operation: MediaInferenceOperation | "capabilities",
) {
super(message);
this.name = "MediaSidecarError";
}
}
// ---------------------------------------------------------------------------
// Response schemas (shapes mirror the adapter-kit result types)
// ---------------------------------------------------------------------------
const Ms = z.number().int().min(0);
const Unit = z.number().min(0).max(1);
const Span = z.object({ startMs: Ms, endMs: Ms });
const ScoredKeyframe = BoxKeyframeSchema.extend({ confidence: Unit });
const schemas = {
detectScenes: z.object({
shots: z.array(Span.extend({ score: z.number() })),
scenes: z.array(Span),
}) satisfies z.ZodType<DetectScenesResult>,
transcribe: z.object({
language: z.string().nullable(),
words: z.array(Span.extend({ text: z.string(), confidence: Unit })),
}) satisfies z.ZodType<TranscribeResult>,
detectFaces: z.object({
frames: z.array(
z.object({
tMs: Ms,
faces: z.array(
z.object({
box: NormalizedBoxSchema,
confidence: Unit,
landmarks: z.array(NormalizedPointSchema).optional(),
}),
),
}),
),
}) satisfies z.ZodType<DetectFacesResult>,
trackFaces: z.object({
sampleFps: z.number().positive(),
tracks: z.array(
z.object({
trackId: z.string().min(1),
keyframes: z.array(ScoredKeyframe),
embedding: z.array(z.number()).nullable(),
}),
),
}) satisfies z.ZodType<TrackFacesResult>,
faceLandmarks: z.object({
frames: z.array(
z.object({
tMs: Ms,
faces: z.array(
z.object({ box: NormalizedBoxSchema, landmarks: z.array(NormalizedPointSchema) }),
),
}),
),
}) satisfies z.ZodType<FaceLandmarksResult>,
embedFrames: z
.object({
model: z.string(),
dim: z.number().int().positive(),
vectors: z.array(z.array(z.number())),
})
.refine((value) => value.vectors.every((vector) => vector.length === value.dim), {
message: "vector length does not match dim",
}) satisfies z.ZodType<EmbedFramesResult>,
classifyContent: z.object({
model: z.string(),
frames: z.array(z.object({ tMs: Ms, scores: z.partialRecord(ContentCategorySchema, Unit) })),
}) satisfies z.ZodType<ClassifyContentResult>,
segmentPrompted: z.object({
maskDir: z.string().min(1),
keyframes: z.array(ScoredKeyframe),
score: Unit,
}) satisfies z.ZodType<SegmentPromptedResult>,
inpaintVideo: z.object({
outPath: z.string().min(1),
quality: z.object({ temporalStability: Unit, edgeQuality: Unit, score: Unit }),
}) satisfies z.ZodType<InpaintVideoResult>,
redact: z.object({
outPath: z.string().min(1),
framesProcessed: z.number().int().min(0),
}) satisfies z.ZodType<{ outPath: string; framesProcessed: number }>,
synthesizeIdentity: z.object({ outPath: z.string().min(1) }) satisfies z.ZodType<{
outPath: string;
}>,
audioFeatures: z.object({
integratedLufs: z.number().nullable(),
truePeakDb: z.number().nullable(),
tempoBpm: z.number().nullable(),
beatsMs: z.array(Ms),
energy: z.array(z.object({ tMs: Ms, value: z.number() })),
silences: z.array(Span),
}) satisfies z.ZodType<AudioFeaturesResult>,
saliency: z.object({
points: z.array(z.object({ tMs: Ms, cx: Unit, cy: Unit, strength: z.number() })),
}) satisfies z.ZodType<SaliencyResult>,
} as const satisfies Record<MediaInferenceOperation, z.ZodType>;
export const MEDIA_SIDECAR_RESPONSE_SCHEMAS = schemas;
const ErrorBodySchema = z.object({
error: z.object({ code: z.string(), message: z.string().optional() }),
});
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
export interface SidecarMediaInferenceOptions {
baseUrl: string;
token: string;
fetch?: typeof fetch;
/** Per-operation timeout overrides in ms. */
timeoutsMs?: Partial<Record<MediaInferenceOperation | "capabilities", number>>;
/** First busy-retry delay; doubles per attempt. */
retryBaseMs?: number;
}
type Request<K extends MediaInferenceOperation> = Parameters<MediaInferenceProvider[K]>[0];
type Result<K extends MediaInferenceOperation> = Awaited<ReturnType<MediaInferenceProvider[K]>>;
const KNOWN_CODES = new Set<string>([
"bad_request",
"invalid_path",
"unauthorized",
"too_large",
"busy",
"internal",
]);
export class SidecarMediaInferenceProvider implements MediaInferenceProvider {
readonly id = "sidecar";
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
constructor(private readonly options: SidecarMediaInferenceOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
this.fetchImpl = options.fetch ?? fetch;
}
async capabilities(signal?: AbortSignal): Promise<MediaCapabilities> {
return this.call("capabilities", "GET", "/v1/capabilities", undefined, signal, (body) =>
MediaCapabilitiesSchema.parse(body),
);
}
detectScenes(request: Request<"detectScenes">) {
return this.operation("detectScenes", request);
}
transcribe(request: Request<"transcribe">) {
return this.operation("transcribe", request);
}
detectFaces(request: Request<"detectFaces">) {
return this.operation("detectFaces", request);
}
trackFaces(request: Request<"trackFaces">) {
return this.operation("trackFaces", request);
}
faceLandmarks(request: Request<"faceLandmarks">) {
return this.operation("faceLandmarks", request);
}
embedFrames(request: Request<"embedFrames">) {
return this.operation("embedFrames", request);
}
classifyContent(request: Request<"classifyContent">) {
return this.operation("classifyContent", request);
}
segmentPrompted(request: Request<"segmentPrompted">) {
return this.operation("segmentPrompted", request);
}
inpaintVideo(request: Request<"inpaintVideo">) {
return this.operation("inpaintVideo", request);
}
redact(request: Request<"redact">) {
return this.operation("redact", request);
}
synthesizeIdentity(request: Request<"synthesizeIdentity">) {
return this.operation("synthesizeIdentity", request);
}
audioFeatures(request: Request<"audioFeatures">) {
return this.operation("audioFeatures", request);
}
saliency(request: Request<"saliency">) {
return this.operation("saliency", request);
}
private operation<K extends MediaInferenceOperation>(
operation: K,
request: Request<K>,
): Promise<Result<K>> {
const { signal, ...body } = request;
const schema = schemas[operation] as z.ZodType;
return this.call(operation, "POST", `/v1/${operation}`, body, signal, (json) => {
return schema.parse(json) as Result<K>;
});
}
private timeoutFor(operation: MediaInferenceOperation | "capabilities"): number {
const override = this.options.timeoutsMs?.[operation];
if (override !== undefined) return override;
return operation === "inpaintVideo" || operation === "synthesizeIdentity"
? LONG_TIMEOUT_MS
: DEFAULT_TIMEOUT_MS;
}
private async call<T>(
operation: MediaInferenceOperation | "capabilities",
method: "GET" | "POST",
route: string,
body: unknown,
signal: AbortSignal | undefined,
parse: (json: unknown) => T,
): Promise<T> {
signal?.throwIfAborted();
const timeout = AbortSignal.timeout(this.timeoutFor(operation));
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
const retryBase = this.options.retryBaseMs ?? 500;
for (let attempt = 0; ; attempt++) {
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}${route}`, {
method,
headers: {
authorization: `Bearer ${this.options.token}`,
accept: "application/json",
...(body === undefined ? {} : { "content-type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: combined,
});
} catch (error) {
throw this.transportError(error, operation, signal, timeout);
}
if (response.status === 503 && attempt < MAX_BUSY_RETRIES) {
await response.body?.cancel().catch(() => undefined);
try {
await delay(retryBase * 2 ** attempt, combined);
} catch (error) {
throw this.transportError(error, operation, signal, timeout);
}
continue;
}
let json: unknown;
let text: string;
try {
text = await response.text();
} catch (error) {
throw this.transportError(error, operation, signal, timeout);
}
try {
json = text ? JSON.parse(text) : null;
} catch {
json = undefined;
}
if (!response.ok) throw this.httpError(response.status, json, operation);
if (json === undefined)
throw new MediaSidecarError(
"malformed_response",
`media sidecar returned invalid JSON for ${operation}`,
response.status,
operation,
);
try {
return parse(json);
} catch (error) {
const detail =
error instanceof z.ZodError
? error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
.join("; ")
: "unexpected shape";
throw new MediaSidecarError(
"malformed_response",
this.scrub(`media sidecar returned a malformed ${operation} response: ${detail}`),
response.status,
operation,
);
}
}
}
private httpError(
status: number,
json: unknown,
operation: MediaInferenceOperation | "capabilities",
): Error {
const parsed = ErrorBodySchema.safeParse(json);
const message = this.scrub(
parsed.success && parsed.data.error.message
? parsed.data.error.message
: `media sidecar responded ${status}`,
);
if (status === 501 && operation !== "capabilities")
return new UnsupportedMediaOperationError(operation, message);
const serverCode = parsed.success ? parsed.data.error.code : "";
const code: MediaSidecarErrorCode = KNOWN_CODES.has(serverCode)
? (serverCode as MediaSidecarErrorCode)
: status === 401
? "unauthorized"
: status === 503
? "busy"
: "http_error";
return new MediaSidecarError(code, `${operation}: ${message}`, status, operation);
}
private transportError(
error: unknown,
operation: MediaInferenceOperation | "capabilities",
signal: AbortSignal | undefined,
timeout: AbortSignal,
): unknown {
if (signal?.aborted) return signal.reason;
if (timeout.aborted)
return new MediaSidecarError(
"timeout",
`media sidecar ${operation} timed out`,
null,
operation,
);
const detail = error instanceof Error ? error.message : String(error);
return new MediaSidecarError(
"unreachable",
this.scrub(`media sidecar is unreachable: ${detail}`),
null,
operation,
);
}
/** Strip the bearer token from any text that may end up in logs or errors. */
private scrub(text: string): string {
const token = this.options.token;
return token ? text.split(token).join("[redacted]") : text;
}
}
function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
signal.addEventListener("abort", onAbort, { once: true });
});
}