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
209 lines
7.2 KiB
TypeScript
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;
|
|
}
|
|
}
|