Advance M8-M11 parity workflows
This commit is contained in:
270
web/protocol/sequencer-media-cache.ts
Normal file
270
web/protocol/sequencer-media-cache.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user