271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
import {
|
|
gateSequencerCodec,
|
|
parseSequencerCodecProbeRequest,
|
|
parseSequencerCodecProbeResult,
|
|
type SequencerCodecProbeRequestIR,
|
|
type SequencerCodecProbeResultIR,
|
|
} from "./sequencer";
|
|
|
|
export const SEQUENCER_MEDIA_CACHE_SCHEMA = 1 as const;
|
|
export const SEQUENCER_MEDIA_PROXY_MAX_BYTES = 64 * 1024 * 1024;
|
|
|
|
export interface SequencerMediaProxyProfileIR {
|
|
kind: "MOVIE_RGBA8_FRAME";
|
|
width: number;
|
|
height: number;
|
|
colorSpace: "SRGB8";
|
|
alphaMode: "STRAIGHT";
|
|
}
|
|
|
|
export interface SequencerMediaCacheManifestIR {
|
|
schemaVersion: typeof SEQUENCER_MEDIA_CACHE_SCHEMA;
|
|
source: SequencerCodecProbeRequestIR;
|
|
decodeCapability: SequencerCodecProbeResultIR;
|
|
profile: SequencerMediaProxyProfileIR;
|
|
sourceFrame: number;
|
|
identitySha256: string;
|
|
payloadByteLength: number;
|
|
payloadSha256: string;
|
|
}
|
|
|
|
export class SequencerMediaCacheValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
|
|
constructor(code: ErrorCode, message: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "SequencerMediaCacheValidationError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const MANIFEST_KEYS = new Set([
|
|
"schemaVersion", "source", "decodeCapability", "profile", "sourceFrame",
|
|
"identitySha256", "payloadByteLength", "payloadSha256",
|
|
]);
|
|
const PROFILE_KEYS = new Set(["kind", "width", "height", "colorSpace", "alphaMode"]);
|
|
|
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>, label: string): void {
|
|
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
|
|
if (unexpected.length > 0) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} contains undeclared fields: ${unexpected.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
function integer(value: unknown, label: string, minimum: number, maximum: number): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is outside the bounded range`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function digest(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || !SHA256.test(value)) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be a lowercase SHA-256 digest`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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 canonicalSource(source: SequencerCodecProbeRequestIR): Record<string, unknown> {
|
|
return {
|
|
schemaVersion: source.schemaVersion,
|
|
stripType: source.stripType,
|
|
mimeType: source.mimeType,
|
|
byteLength: source.byteLength,
|
|
sourceSha256: source.sourceSha256,
|
|
};
|
|
}
|
|
|
|
function canonicalCapability(capability: SequencerCodecProbeResultIR): Record<string, unknown> {
|
|
const decoded = capability.decoded === null ? null : capability.stripType === "IMAGE" ? {
|
|
width: capability.decoded.width,
|
|
height: capability.decoded.height,
|
|
} : capability.stripType === "SOUND" ? {
|
|
sampleRate: capability.decoded.sampleRate,
|
|
channels: capability.decoded.channels,
|
|
durationFrames: capability.decoded.durationFrames,
|
|
} : {
|
|
width: capability.decoded.width,
|
|
height: capability.decoded.height,
|
|
durationMicros: capability.decoded.durationMicros,
|
|
};
|
|
return {
|
|
...canonicalSource(capability),
|
|
status: capability.status,
|
|
backend: capability.backend,
|
|
reason: capability.reason,
|
|
decoded,
|
|
};
|
|
}
|
|
|
|
function canonicalProfile(profile: SequencerMediaProxyProfileIR): Record<string, unknown> {
|
|
return {
|
|
kind: profile.kind,
|
|
width: profile.width,
|
|
height: profile.height,
|
|
colorSpace: profile.colorSpace,
|
|
alphaMode: profile.alphaMode,
|
|
};
|
|
}
|
|
|
|
function sameJson(left: unknown, right: unknown): boolean {
|
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
}
|
|
|
|
function parseReadyMovieCapability(
|
|
sourceValue: unknown,
|
|
capabilityValue: unknown,
|
|
): { source: SequencerCodecProbeRequestIR; capability: SequencerCodecProbeResultIR } {
|
|
const source = parseSequencerCodecProbeRequest(sourceValue);
|
|
const capability = parseSequencerCodecProbeResult(capabilityValue);
|
|
if (source.stripType !== "MOVIE" || gateSequencerCodec(source, capability).status !== "READY") {
|
|
throw new SequencerMediaCacheValidationError(
|
|
"SEQUENCER_CODEC_UNSUPPORTED",
|
|
"Movie proxy cache requires a source-bound READY runtime decode receipt",
|
|
);
|
|
}
|
|
return { source, capability };
|
|
}
|
|
|
|
export function parseSequencerMediaProxyProfile(value: unknown): SequencerMediaProxyProfileIR {
|
|
const profile = record(value, "Sequencer media proxy profile");
|
|
exactKeys(profile, PROFILE_KEYS, "Sequencer media proxy profile");
|
|
if (profile.kind !== "MOVIE_RGBA8_FRAME" || profile.colorSpace !== "SRGB8" || profile.alphaMode !== "STRAIGHT") {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media proxy profile is unsupported");
|
|
}
|
|
const width = integer(profile.width, "profile.width", 1, 16_384);
|
|
const height = integer(profile.height, "profile.height", 1, 16_384);
|
|
if (width * height * 4 > SEQUENCER_MEDIA_PROXY_MAX_BYTES) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer media proxy frame exceeds the RGBA8 budget");
|
|
}
|
|
return { kind: "MOVIE_RGBA8_FRAME", width, height, colorSpace: "SRGB8", alphaMode: "STRAIGHT" };
|
|
}
|
|
|
|
export async function computeSequencerMediaCacheIdentity(
|
|
sourceValue: unknown,
|
|
capabilityValue: unknown,
|
|
profileValue: unknown,
|
|
sourceFrameValue: unknown,
|
|
): Promise<string> {
|
|
const { source, capability } = parseReadyMovieCapability(sourceValue, capabilityValue);
|
|
const profile = parseSequencerMediaProxyProfile(profileValue);
|
|
const sourceFrame = integer(sourceFrameValue, "sourceFrame", 0, 1_000_000);
|
|
const identity = JSON.stringify({
|
|
schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA,
|
|
source: canonicalSource(source),
|
|
decodeCapability: canonicalCapability(capability),
|
|
profile: canonicalProfile(profile),
|
|
sourceFrame,
|
|
});
|
|
return sha256(new TextEncoder().encode(identity).buffer as ArrayBuffer);
|
|
}
|
|
|
|
export function parseSequencerMediaCacheManifest(value: unknown): SequencerMediaCacheManifestIR {
|
|
const manifest = record(value, "Sequencer media cache manifest");
|
|
exactKeys(manifest, MANIFEST_KEYS, "Sequencer media cache manifest");
|
|
if (manifest.schemaVersion !== SEQUENCER_MEDIA_CACHE_SCHEMA) {
|
|
throw new SequencerMediaCacheValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media cache schema");
|
|
}
|
|
const { source, capability } = parseReadyMovieCapability(manifest.source, manifest.decodeCapability);
|
|
const profile = parseSequencerMediaProxyProfile(manifest.profile);
|
|
if (capability.decoded === null || capability.decoded.width === undefined || capability.decoded.height === undefined ||
|
|
profile.width > capability.decoded.width || profile.height > capability.decoded.height) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy profile exceeds the runtime decoded movie dimensions");
|
|
}
|
|
const sourceFrame = integer(manifest.sourceFrame, "sourceFrame", 0, 1_000_000);
|
|
const payloadByteLength = integer(manifest.payloadByteLength, "payloadByteLength", 1, SEQUENCER_MEDIA_PROXY_MAX_BYTES);
|
|
if (payloadByteLength !== profile.width * profile.height * 4) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy payload length does not match its RGBA8 profile");
|
|
}
|
|
return {
|
|
schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA,
|
|
source,
|
|
decodeCapability: capability,
|
|
profile,
|
|
sourceFrame,
|
|
identitySha256: digest(manifest.identitySha256, "identitySha256"),
|
|
payloadByteLength,
|
|
payloadSha256: digest(manifest.payloadSha256, "payloadSha256"),
|
|
};
|
|
}
|
|
|
|
export async function createSequencerMediaCacheManifest(
|
|
sourceValue: unknown,
|
|
capabilityValue: unknown,
|
|
profileValue: unknown,
|
|
sourceFrameValue: unknown,
|
|
payload: ArrayBuffer,
|
|
): Promise<SequencerMediaCacheManifestIR> {
|
|
if (!(payload instanceof ArrayBuffer) || payload.byteLength === 0 || payload.byteLength > SEQUENCER_MEDIA_PROXY_MAX_BYTES) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer media proxy payload exceeds the byte budget");
|
|
}
|
|
const { source, capability } = parseReadyMovieCapability(sourceValue, capabilityValue);
|
|
const profile = parseSequencerMediaProxyProfile(profileValue);
|
|
if (capability.decoded === null || capability.decoded.width === undefined || capability.decoded.height === undefined ||
|
|
profile.width > capability.decoded.width || profile.height > capability.decoded.height) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy profile exceeds the runtime decoded movie dimensions");
|
|
}
|
|
const sourceFrame = integer(sourceFrameValue, "sourceFrame", 0, 1_000_000);
|
|
const manifest = {
|
|
schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA,
|
|
source,
|
|
decodeCapability: capability,
|
|
profile,
|
|
sourceFrame,
|
|
identitySha256: await computeSequencerMediaCacheIdentity(source, capability, profile, sourceFrame),
|
|
payloadByteLength: payload.byteLength,
|
|
payloadSha256: await sha256(payload),
|
|
} satisfies SequencerMediaCacheManifestIR;
|
|
return parseSequencerMediaCacheManifest(manifest);
|
|
}
|
|
|
|
export async function verifySequencerMediaCacheEntry(
|
|
manifestValue: unknown,
|
|
payload: ArrayBuffer,
|
|
currentSourceValue: unknown,
|
|
currentCapabilityValue: unknown,
|
|
): Promise<SequencerMediaCacheManifestIR> {
|
|
const manifest = parseSequencerMediaCacheManifest(manifestValue);
|
|
const { source: currentSource, capability: currentCapability } = parseReadyMovieCapability(
|
|
currentSourceValue,
|
|
currentCapabilityValue,
|
|
);
|
|
if (!sameJson(canonicalSource(manifest.source), canonicalSource(currentSource))) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Proxy cache source identity is stale");
|
|
}
|
|
if (!sameJson(canonicalCapability(manifest.decodeCapability), canonicalCapability(currentCapability))) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_CAPABILITY_MISMATCH", "Proxy cache decode capability is stale");
|
|
}
|
|
const identitySha256 = await computeSequencerMediaCacheIdentity(
|
|
manifest.source,
|
|
manifest.decodeCapability,
|
|
manifest.profile,
|
|
manifest.sourceFrame,
|
|
);
|
|
if (identitySha256 !== manifest.identitySha256) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_IDENTITY_MISMATCH", "Proxy cache identity hash is invalid");
|
|
}
|
|
if (!(payload instanceof ArrayBuffer) || payload.byteLength !== manifest.payloadByteLength || await sha256(payload) !== manifest.payloadSha256) {
|
|
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_HASH_MISMATCH", "Proxy cache payload failed SHA-256 verification");
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
export function sequencerMediaCacheKey(manifestValue: unknown): string {
|
|
const manifest = parseSequencerMediaCacheManifest(manifestValue);
|
|
return `sequencer-media-cache:v${manifest.schemaVersion}:${manifest.identitySha256}`;
|
|
}
|