Add full source parity validation workflow
This commit is contained in:
77
web/app/src/sequencer/FFmpegMovieProbe.ts
Normal file
77
web/app/src/sequencer/FFmpegMovieProbe.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
parseSequencerCodecProbeRequest,
|
||||
type SequencerCodecProbeRequestIR,
|
||||
type SequencerCodecProbeResultIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
|
||||
interface WorkerResponse {
|
||||
requestId: string;
|
||||
status: "READY" | "BLOCKED";
|
||||
reason: "RUNTIME_UNAVAILABLE" | "DECODE_FAILED" | null;
|
||||
decoded: { width: number; height: number; durationMicros: number } | null;
|
||||
data?: ArrayBuffer;
|
||||
}
|
||||
|
||||
const digest = async (source: ArrayBuffer): Promise<string> => {
|
||||
const result = await crypto.subtle.digest("SHA-256", source);
|
||||
return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
export interface FFmpegMovieFrameIR {
|
||||
decoded: { width: number; height: number; durationMicros: number };
|
||||
rgba: ArrayBuffer;
|
||||
}
|
||||
|
||||
function blocked(request: SequencerCodecProbeRequestIR, reason: "RUNTIME_UNAVAILABLE" | "DECODE_FAILED"): SequencerCodecProbeResultIR {
|
||||
return { ...request, status: "BLOCKED", backend: "FFMPEG_WASM", reason, decoded: null };
|
||||
}
|
||||
|
||||
async function requestFFmpeg(
|
||||
request: SequencerCodecProbeRequestIR,
|
||||
source: ArrayBuffer,
|
||||
type: "probe" | "frame",
|
||||
options: { width?: number; height?: number; signal?: AbortSignal } = {},
|
||||
): Promise<WorkerResponse> {
|
||||
const worker = new Worker(new URL("./ffmpeg-movie-probe.worker.ts", import.meta.url), { type: "module" });
|
||||
const requestId = `ffmpeg-movie:${crypto.randomUUID()}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
worker.onmessage = null;
|
||||
worker.onerror = null;
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
worker.terminate();
|
||||
};
|
||||
const abort = (): void => { cleanup(); reject(new DOMException("FFmpeg movie probe cancelled", "AbortError")); };
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
if (event.data.requestId !== requestId) return;
|
||||
cleanup();
|
||||
resolve(event.data);
|
||||
};
|
||||
worker.onerror = (event) => { cleanup(); reject(new Error(event.message || "FFmpeg movie worker failed")); };
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
if (options.signal?.aborted) { abort(); return; }
|
||||
const payload = source.slice(0);
|
||||
worker.postMessage({ requestId, type, bytes: payload, mimeType: request.mimeType, width: options.width, height: options.height }, [payload]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeMovieWithFFmpeg(requestValue: unknown, source: ArrayBuffer, signal?: AbortSignal): Promise<SequencerCodecProbeResultIR> {
|
||||
const request = parseSequencerCodecProbeRequest(requestValue);
|
||||
if (request.stripType !== "MOVIE" || !(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await digest(source) !== request.sourceSha256) return blocked(request, "DECODE_FAILED");
|
||||
try {
|
||||
const result = await requestFFmpeg(request, source, "probe", { signal });
|
||||
return result.status === "READY" && result.decoded ? { ...request, status: "READY", backend: "FFMPEG_WASM", reason: null, decoded: result.decoded } : blocked(request, result.reason ?? "DECODE_FAILED");
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
return blocked(request, "RUNTIME_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodeMovieFrameWithFFmpeg(requestValue: unknown, source: ArrayBuffer, width: number, height: number, signal?: AbortSignal): Promise<FFmpegMovieFrameIR> {
|
||||
const request = parseSequencerCodecProbeRequest(requestValue);
|
||||
if (request.stripType !== "MOVIE" || !(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await digest(source) !== request.sourceSha256) throw new Error("SEQUENCER_CACHE_SOURCE_MISMATCH");
|
||||
const result = await requestFFmpeg(request, source, "frame", { width, height, signal });
|
||||
if (result.status !== "READY" || !result.decoded || !result.data) throw new Error(result.reason ?? "SEQUENCER_CODEC_UNSUPPORTED");
|
||||
return { decoded: result.decoded, rgba: result.data };
|
||||
}
|
||||
89
web/app/src/sequencer/ffmpeg-movie-probe.worker.ts
Normal file
89
web/app/src/sequencer/ffmpeg-movie-probe.worker.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||
|
||||
interface ProbeRequest {
|
||||
type: "probe" | "frame";
|
||||
requestId: string;
|
||||
bytes: ArrayBuffer;
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface ProbeResponse {
|
||||
requestId: string;
|
||||
status: "READY" | "BLOCKED";
|
||||
reason: "RUNTIME_UNAVAILABLE" | "DECODE_FAILED" | null;
|
||||
decoded: { width: number; height: number; durationMicros: number } | null;
|
||||
data?: ArrayBuffer;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
let loaded = false;
|
||||
const workerScope = self as unknown as { postMessage: (message: unknown, transfer?: Transferable[]) => void };
|
||||
|
||||
function response(requestId: string, patch: Omit<ProbeResponse, "requestId">): void {
|
||||
workerScope.postMessage({ requestId, ...patch }, patch.data ? [patch.data] : []);
|
||||
}
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
const vendorRoot = new URL(self.location.href).pathname.startsWith("/src/") ? "/src/vendor/ffmpeg" : "/vendor/ffmpeg";
|
||||
await ffmpeg.load({
|
||||
coreURL: `${vendorRoot}/ffmpeg-core.js`,
|
||||
wasmURL: `${vendorRoot}/ffmpeg-core.wasm`,
|
||||
});
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function readText(path: string): Promise<string> {
|
||||
const value = await ffmpeg.readFile(path, "utf8");
|
||||
return typeof value === "string" ? value : new TextDecoder().decode(value);
|
||||
}
|
||||
|
||||
async function probe(request: ProbeRequest): Promise<ProbeResponse["decoded"]> {
|
||||
await ensureLoaded();
|
||||
await ffmpeg.writeFile("input.movie", new Uint8Array(request.bytes));
|
||||
await ffmpeg.ffprobe([
|
||||
"-v", "error", "-show_entries", "stream=width,height:format=duration", "-of", "json", "input.movie", "-o", "probe.json",
|
||||
]);
|
||||
const parsed = JSON.parse(await readText("probe.json")) as { streams?: Array<{ width?: number; height?: number }>; format?: { duration?: string } };
|
||||
const stream = parsed.streams?.find((candidate) => Number.isFinite(candidate.width) && Number.isFinite(candidate.height));
|
||||
const width = Math.trunc(stream?.width ?? 0);
|
||||
const height = Math.trunc(stream?.height ?? 0);
|
||||
const duration = Number(parsed.format?.duration ?? 0);
|
||||
if (width < 1 || height < 1 || !Number.isFinite(duration) || duration <= 0) return null;
|
||||
return { width, height, durationMicros: Math.max(1, Math.round(duration * 1_000_000)) };
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<ProbeRequest>) => {
|
||||
const request = event.data;
|
||||
void (async () => {
|
||||
try {
|
||||
const decoded = await probe(request);
|
||||
if (!decoded) {
|
||||
response(request.requestId, { status: "BLOCKED", reason: "DECODE_FAILED", decoded: null });
|
||||
return;
|
||||
}
|
||||
if (request.type === "probe") {
|
||||
response(request.requestId, { status: "READY", reason: null, decoded });
|
||||
return;
|
||||
}
|
||||
const width = Math.max(1, Math.min(4096, Math.trunc(request.width ?? decoded.width)));
|
||||
const height = Math.max(1, Math.min(4096, Math.trunc(request.height ?? decoded.height)));
|
||||
await ffmpeg.exec([
|
||||
"-i", "input.movie", "-vf", `scale=${width}:${height}`, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgba", "frame.rgba",
|
||||
]);
|
||||
const data = await ffmpeg.readFile("frame.rgba");
|
||||
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
||||
if (bytes.byteLength !== width * height * 4) {
|
||||
response(request.requestId, { status: "BLOCKED", reason: "DECODE_FAILED", decoded: null });
|
||||
return;
|
||||
}
|
||||
response(request.requestId, { status: "READY", reason: null, decoded: { width, height, durationMicros: decoded.durationMicros }, data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer });
|
||||
}
|
||||
catch (error) {
|
||||
response(request.requestId, { status: "BLOCKED", reason: "RUNTIME_UNAVAILABLE", decoded: null, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
})();
|
||||
};
|
||||
Reference in New Issue
Block a user