evOo-local/packages/adapters/src/media-emulator-source.ts
Claude 270ea8068a
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
2026-10-03 17:52:17 +00:00

209 lines
7.2 KiB
TypeScript

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