217 lines
8.1 KiB
TypeScript
217 lines
8.1 KiB
TypeScript
import {
|
|
SEQUENCER_MEDIA_PROXY_MAX_BYTES,
|
|
createSequencerMediaCacheManifest,
|
|
computeSequencerMediaCacheIdentity,
|
|
parseSequencerMediaProxyProfile,
|
|
sequencerMediaCacheKey,
|
|
verifySequencerMediaCacheEntry,
|
|
SequencerMediaCacheValidationError,
|
|
type SequencerMediaCacheManifestIR,
|
|
type SequencerMediaProxyProfileIR,
|
|
} from "../../../protocol/sequencer-media-cache";
|
|
import {
|
|
gateSequencerCodec,
|
|
parseSequencerCodecProbeRequest,
|
|
parseSequencerCodecProbeResult,
|
|
type SequencerCodecProbeRequestIR,
|
|
type SequencerCodecProbeResultIR,
|
|
} from "../../../protocol/sequencer";
|
|
|
|
export interface SequencerMediaProxyCacheStatsIR {
|
|
entries: number;
|
|
bytes: number;
|
|
maxBytes: number;
|
|
hits: number;
|
|
misses: number;
|
|
evictions: number;
|
|
keys: string[];
|
|
}
|
|
|
|
export interface SequencerGeneratedProxyFrameIR {
|
|
manifest: SequencerMediaCacheManifestIR;
|
|
data: ArrayBuffer;
|
|
}
|
|
|
|
interface CacheEntry {
|
|
manifest: SequencerMediaCacheManifestIR;
|
|
data: ArrayBuffer;
|
|
}
|
|
|
|
async function sha256(data: ArrayBuffer): Promise<string> {
|
|
const result = await crypto.subtle.digest("SHA-256", data);
|
|
return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function cancelled(signal: AbortSignal | undefined): void {
|
|
if (signal?.aborted) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CANCELLED", "Movie proxy generation was cancelled");
|
|
}
|
|
}
|
|
|
|
function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number, signal?: AbortSignal): Promise<void> {
|
|
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
|
|
const finish = (error?: Error): void => {
|
|
window.clearTimeout(timeout);
|
|
video.removeEventListener("loadeddata", ready);
|
|
video.removeEventListener("error", failed);
|
|
signal?.removeEventListener("abort", aborted);
|
|
if (error) reject(error); else resolve();
|
|
};
|
|
const ready = (): void => finish();
|
|
const failed = (): void => finish(new Error("decode"));
|
|
const aborted = (): void => finish(new SequencerMediaCacheValidationError("SEQUENCER_CANCELLED", "Movie proxy generation was cancelled"));
|
|
video.addEventListener("loadeddata", ready, { once: true });
|
|
video.addEventListener("error", failed, { once: true });
|
|
signal?.addEventListener("abort", aborted, { once: true });
|
|
if (signal?.aborted) aborted();
|
|
});
|
|
}
|
|
|
|
export async function generateInitialSequencerMovieProxyFrame(
|
|
sourceValue: unknown,
|
|
capabilityValue: unknown,
|
|
profileValue: unknown,
|
|
sourceData: ArrayBuffer,
|
|
signal?: AbortSignal,
|
|
): Promise<SequencerGeneratedProxyFrameIR> {
|
|
cancelled(signal);
|
|
const source = parseSequencerCodecProbeRequest(sourceValue);
|
|
const capability = parseSequencerCodecProbeResult(capabilityValue);
|
|
const profile = parseSequencerMediaProxyProfile(profileValue);
|
|
if (source.stripType !== "MOVIE" || capability.status !== "READY" || capability.stripType !== "MOVIE" ||
|
|
gateSequencerCodec(source, capability).status !== "READY") {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy generation requires a READY movie receipt");
|
|
}
|
|
if (!(sourceData instanceof ArrayBuffer) || sourceData.byteLength !== source.byteLength || await sha256(sourceData) !== source.sourceSha256) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Movie proxy source bytes failed identity verification");
|
|
}
|
|
cancelled(signal);
|
|
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "HTML media proxy generation is unavailable");
|
|
}
|
|
|
|
const video = document.createElement("video");
|
|
video.muted = true;
|
|
video.playsInline = true;
|
|
video.preload = "auto";
|
|
const url = URL.createObjectURL(new Blob([sourceData], { type: source.mimeType }));
|
|
try {
|
|
video.src = url;
|
|
video.load();
|
|
await waitForLoadedFrame(video, 10_000, signal);
|
|
cancelled(signal);
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = profile.width;
|
|
canvas.height = profile.height;
|
|
const context = canvas.getContext("2d", { alpha: true, willReadFrequently: true });
|
|
if (!context || video.videoWidth < 1 || video.videoHeight < 1) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy frame could not be decoded");
|
|
}
|
|
context.clearRect(0, 0, profile.width, profile.height);
|
|
context.drawImage(video, 0, 0, profile.width, profile.height);
|
|
cancelled(signal);
|
|
const pixels = context.getImageData(0, 0, profile.width, profile.height).data;
|
|
const data = pixels.buffer.slice(pixels.byteOffset, pixels.byteOffset + pixels.byteLength);
|
|
return {
|
|
manifest: await createSequencerMediaCacheManifest(source, capability, profile, 0, data),
|
|
data,
|
|
};
|
|
}
|
|
catch (error) {
|
|
if (error instanceof SequencerMediaCacheValidationError) throw error;
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy frame decode failed");
|
|
}
|
|
finally {
|
|
video.pause();
|
|
video.removeAttribute("src");
|
|
video.load();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
export class SequencerMediaProxyCache {
|
|
readonly maxBytes: number;
|
|
private readonly entries = new Map<string, CacheEntry>();
|
|
private currentBytes = 0;
|
|
private hitCount = 0;
|
|
private missCount = 0;
|
|
private evictionCount = 0;
|
|
|
|
constructor(maxBytes: number) {
|
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > SEQUENCER_MEDIA_PROXY_MAX_BYTES) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer proxy cache budget is invalid");
|
|
}
|
|
this.maxBytes = maxBytes;
|
|
}
|
|
|
|
async put(
|
|
manifestValue: unknown,
|
|
data: ArrayBuffer,
|
|
currentSource: SequencerCodecProbeRequestIR,
|
|
currentCapability: SequencerCodecProbeResultIR,
|
|
): Promise<string> {
|
|
const manifest = await verifySequencerMediaCacheEntry(manifestValue, data, currentSource, currentCapability);
|
|
if (data.byteLength > this.maxBytes) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer proxy entry exceeds the cache budget");
|
|
}
|
|
const key = sequencerMediaCacheKey(manifest);
|
|
const previous = this.entries.get(key);
|
|
if (previous) {
|
|
this.currentBytes -= previous.data.byteLength;
|
|
this.entries.delete(key);
|
|
}
|
|
while (this.currentBytes + data.byteLength > this.maxBytes) {
|
|
const oldest = this.entries.entries().next().value as [string, CacheEntry] | undefined;
|
|
if (!oldest) break;
|
|
this.entries.delete(oldest[0]);
|
|
this.currentBytes -= oldest[1].data.byteLength;
|
|
this.evictionCount++;
|
|
}
|
|
this.entries.set(key, { manifest, data: data.slice(0) });
|
|
this.currentBytes += data.byteLength;
|
|
return key;
|
|
}
|
|
|
|
async get(
|
|
sourceValue: unknown,
|
|
capabilityValue: unknown,
|
|
profileValue: unknown,
|
|
sourceFrame: number,
|
|
): Promise<SequencerGeneratedProxyFrameIR | undefined> {
|
|
const source = parseSequencerCodecProbeRequest(sourceValue);
|
|
const capability = parseSequencerCodecProbeResult(capabilityValue);
|
|
const profile = parseSequencerMediaProxyProfile(profileValue);
|
|
const identitySha256 = await computeSequencerMediaCacheIdentity(source, capability, profile, sourceFrame);
|
|
const key = `sequencer-media-cache:v1:${identitySha256}`;
|
|
const entry = this.entries.get(key);
|
|
if (!entry) { this.missCount++; return undefined; }
|
|
this.hitCount++;
|
|
this.entries.delete(key);
|
|
this.entries.set(key, entry);
|
|
return { manifest: entry.manifest, data: entry.data.slice(0) };
|
|
}
|
|
|
|
clear(): number {
|
|
const released = this.currentBytes;
|
|
this.entries.clear();
|
|
this.currentBytes = 0;
|
|
return released;
|
|
}
|
|
|
|
stats(): SequencerMediaProxyCacheStatsIR {
|
|
return {
|
|
entries: this.entries.size,
|
|
bytes: this.currentBytes,
|
|
maxBytes: this.maxBytes,
|
|
hits: this.hitCount,
|
|
misses: this.missCount,
|
|
evictions: this.evictionCount,
|
|
keys: [...this.entries.keys()],
|
|
};
|
|
}
|
|
}
|
|
|
|
export type { SequencerMediaCacheManifestIR, SequencerMediaProxyProfileIR };
|