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:
parent
7ccc96ac11
commit
270ea8068a
281
packages/adapters/src/media-conformance.test.ts
Normal file
281
packages/adapters/src/media-conformance.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
66
packages/adapters/src/media-emulator-clips.ts
Normal file
66
packages/adapters/src/media-emulator-clips.ts
Normal 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;
|
||||||
|
}
|
||||||
208
packages/adapters/src/media-emulator-source.ts
Normal file
208
packages/adapters/src/media-emulator-source.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
335
packages/adapters/src/media-emulator.test.ts
Normal file
335
packages/adapters/src/media-emulator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1003
packages/adapters/src/media-emulator.ts
Normal file
1003
packages/adapters/src/media-emulator.ts
Normal file
File diff suppressed because it is too large
Load Diff
53
packages/adapters/src/media-factory.test.ts
Normal file
53
packages/adapters/src/media-factory.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
90
packages/adapters/src/media-factory.ts
Normal file
90
packages/adapters/src/media-factory.ts
Normal 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.`);
|
||||||
|
}
|
||||||
194
packages/adapters/src/media-folder-source-probe.ts
Normal file
194
packages/adapters/src/media-folder-source-probe.ts
Normal 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 };
|
||||||
|
}
|
||||||
177
packages/adapters/src/media-folder-source.test.ts
Normal file
177
packages/adapters/src/media-folder-source.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
221
packages/adapters/src/media-folder-source.ts
Normal file
221
packages/adapters/src/media-folder-source.ts
Normal 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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
222
packages/adapters/src/media-sidecar.test.ts
Normal file
222
packages/adapters/src/media-sidecar.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
410
packages/adapters/src/media-sidecar.ts
Normal file
410
packages/adapters/src/media-sidecar.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user