Advance M8-M11 parity workflows
This commit is contained in:
232
web/app/src/sequencer/SequencerAudioSession.ts
Normal file
232
web/app/src/sequencer/SequencerAudioSession.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
parseSequencerAudioSessionReport,
|
||||
SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
type SequencerAudioSessionIssueCode,
|
||||
type SequencerAudioSessionReportIR,
|
||||
} from "../../../protocol/sequencer-audio-session";
|
||||
|
||||
interface AudioParamLike {
|
||||
value: number;
|
||||
cancelScheduledValues?(startTime: number): void;
|
||||
setValueAtTime(value: number, startTime: number): unknown;
|
||||
}
|
||||
|
||||
interface GainNodeLike {
|
||||
readonly gain: AudioParamLike;
|
||||
connect(destination: unknown): unknown;
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
export interface AudioContextLike {
|
||||
readonly state: "suspended" | "running" | "closed" | string;
|
||||
readonly currentTime: number;
|
||||
readonly destination: unknown;
|
||||
createGain(): GainNodeLike;
|
||||
resume(): Promise<void>;
|
||||
suspend(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SequencerAudioSessionOptions {
|
||||
scope?: object;
|
||||
contextFactory?: () => AudioContextLike;
|
||||
outputGain?: number;
|
||||
}
|
||||
|
||||
type AudioContextConstructor = new () => AudioContextLike;
|
||||
|
||||
export class SequencerAudioSession {
|
||||
private readonly scope: object;
|
||||
private readonly contextFactory?: () => AudioContextLike;
|
||||
private readonly nominalGain: number;
|
||||
private context: AudioContextLike | null = null;
|
||||
private output: GainNodeLike | null = null;
|
||||
private mutedValue = false;
|
||||
private disposed = false;
|
||||
private revisionValue = 0;
|
||||
private issueCode: SequencerAudioSessionIssueCode | null = null;
|
||||
|
||||
constructor(options: SequencerAudioSessionOptions = {}) {
|
||||
const outputGain = options.outputGain ?? 1;
|
||||
if (typeof outputGain !== "number" || !Number.isFinite(outputGain) || outputGain <= 0 || outputGain > 1) {
|
||||
const error = new Error("SEQUENCER_AUDIO_CONTEXT_INVALID: outputGain must be in (0, 1]") as Error & { code: string };
|
||||
error.code = "SEQUENCER_AUDIO_CONTEXT_INVALID";
|
||||
throw error;
|
||||
}
|
||||
this.scope = options.scope ?? globalThis;
|
||||
this.contextFactory = options.contextFactory;
|
||||
this.nominalGain = outputGain;
|
||||
}
|
||||
|
||||
snapshot(): SequencerAudioSessionReportIR {
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async initialize(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (this.disposed) return this.report();
|
||||
if (!this.context || this.context.state === "closed") await this.openContext();
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async resume(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (this.disposed) return this.report();
|
||||
if (!this.context || this.context.state === "closed") await this.openContext();
|
||||
if (!this.context) return this.report();
|
||||
try {
|
||||
if (this.context.state !== "running") await this.context.resume();
|
||||
if (this.context.state !== "running") throw new Error("AudioContext did not enter running state");
|
||||
this.issueCode = null;
|
||||
this.applyGain();
|
||||
}
|
||||
catch {
|
||||
this.issueCode = "SEQUENCER_AUDIO_RESUME_FAILED";
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async suspend(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (this.disposed) return this.report();
|
||||
if (!this.context) {
|
||||
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
|
||||
return this.report();
|
||||
}
|
||||
try {
|
||||
if (this.context.state === "running") await this.context.suspend();
|
||||
if (this.context.state !== "suspended") throw new Error("AudioContext did not enter suspended state");
|
||||
this.issueCode = null;
|
||||
}
|
||||
catch {
|
||||
this.issueCode = "SEQUENCER_AUDIO_SUSPEND_FAILED";
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): SequencerAudioSessionReportIR {
|
||||
if (typeof muted !== "boolean") {
|
||||
const error = new Error("SEQUENCER_AUDIO_CONTEXT_INVALID: muted must be boolean") as Error & { code: string };
|
||||
error.code = "SEQUENCER_AUDIO_CONTEXT_INVALID";
|
||||
throw error;
|
||||
}
|
||||
this.revisionValue += 1;
|
||||
if (!this.disposed) {
|
||||
this.mutedValue = muted;
|
||||
this.applyGain();
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async recoverDevice(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (this.disposed) return this.report();
|
||||
await this.releaseContext();
|
||||
this.issueCode = null;
|
||||
await this.openContext();
|
||||
return this.report();
|
||||
}
|
||||
|
||||
async close(): Promise<SequencerAudioSessionReportIR> {
|
||||
this.revisionValue += 1;
|
||||
if (!this.disposed) {
|
||||
this.disposed = true;
|
||||
await this.releaseContext();
|
||||
this.issueCode = null;
|
||||
}
|
||||
return this.report();
|
||||
}
|
||||
|
||||
private createContext(): AudioContextLike | null {
|
||||
if (this.contextFactory) return this.contextFactory();
|
||||
const constructor = (this.scope as { AudioContext?: unknown }).AudioContext;
|
||||
if (typeof constructor !== "function") return null;
|
||||
return new (constructor as AudioContextConstructor)();
|
||||
}
|
||||
|
||||
private async openContext(): Promise<void> {
|
||||
try {
|
||||
const context = this.createContext();
|
||||
if (!context || context.state === "closed") {
|
||||
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
|
||||
return;
|
||||
}
|
||||
const output = context.createGain();
|
||||
output.connect(context.destination);
|
||||
this.context = context;
|
||||
this.output = output;
|
||||
this.issueCode = null;
|
||||
this.applyGain();
|
||||
}
|
||||
catch {
|
||||
this.context = null;
|
||||
this.output = null;
|
||||
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
|
||||
}
|
||||
}
|
||||
|
||||
private applyGain(): void {
|
||||
if (!this.context || !this.output || this.context.state === "closed") return;
|
||||
const value = this.mutedValue ? 0 : this.nominalGain;
|
||||
this.output.gain.cancelScheduledValues?.(this.context.currentTime);
|
||||
this.output.gain.setValueAtTime(value, this.context.currentTime);
|
||||
}
|
||||
|
||||
private async releaseContext(): Promise<void> {
|
||||
const context = this.context;
|
||||
const output = this.output;
|
||||
this.context = null;
|
||||
this.output = null;
|
||||
if (context && output) {
|
||||
try {
|
||||
output.gain.cancelScheduledValues?.(context.currentTime);
|
||||
output.gain.setValueAtTime(0, context.currentTime);
|
||||
}
|
||||
catch { /* Context loss can invalidate AudioParam before release. */ }
|
||||
try { output.disconnect(); }
|
||||
catch { /* A disconnected node is already released. */ }
|
||||
}
|
||||
if (context && context.state !== "closed") {
|
||||
try { await context.close(); }
|
||||
catch { /* The session still drops all references on close failure. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private report(): SequencerAudioSessionReportIR {
|
||||
if (this.disposed) {
|
||||
return parseSequencerAudioSessionReport({
|
||||
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
revision: this.revisionValue,
|
||||
contextState: "CLOSED",
|
||||
outputState: "SILENT",
|
||||
muted: this.mutedValue,
|
||||
outputGain: 0,
|
||||
issueCode: null,
|
||||
});
|
||||
}
|
||||
if (!this.context || !this.output || this.context.state === "closed") {
|
||||
return parseSequencerAudioSessionReport({
|
||||
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
revision: this.revisionValue,
|
||||
contextState: "UNAVAILABLE",
|
||||
outputState: "BLOCKED",
|
||||
muted: this.mutedValue,
|
||||
outputGain: 0,
|
||||
issueCode: "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
const contextState = this.context.state === "running" ? "RUNNING" : "SUSPENDED";
|
||||
const outputGain = this.mutedValue ? 0 : this.nominalGain;
|
||||
return parseSequencerAudioSessionReport({
|
||||
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
revision: this.revisionValue,
|
||||
contextState,
|
||||
outputState: contextState === "RUNNING" && !this.mutedValue && this.issueCode === null ? "ENABLED" : "SILENT",
|
||||
muted: this.mutedValue,
|
||||
outputGain,
|
||||
issueCode: this.issueCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type { SequencerAudioSessionReportIR };
|
||||
145
web/app/src/sequencer/SequencerCodecProbe.ts
Normal file
145
web/app/src/sequencer/SequencerCodecProbe.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
SEQUENCER_CODEC_PROBE_SCHEMA,
|
||||
parseSequencerCodecProbeRequest,
|
||||
type SequencerCodecProbeBackend,
|
||||
type SequencerCodecProbeBlockReason,
|
||||
type SequencerCodecProbeRequestIR,
|
||||
type SequencerCodecProbeResultIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
|
||||
async function sha256(data: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function blocked(
|
||||
request: SequencerCodecProbeRequestIR,
|
||||
backend: SequencerCodecProbeBackend | null,
|
||||
reason: SequencerCodecProbeBlockReason,
|
||||
): SequencerCodecProbeResultIR {
|
||||
return { ...request, status: "BLOCKED", backend, reason, decoded: null };
|
||||
}
|
||||
|
||||
function waitForMedia(video: HTMLVideoElement, eventName: "loadeddata" | "seeked", timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
|
||||
const finish = (error?: Error): void => {
|
||||
window.clearTimeout(timeout);
|
||||
video.removeEventListener(eventName, ready);
|
||||
video.removeEventListener("error", failed);
|
||||
if (error) reject(error); else resolve();
|
||||
};
|
||||
const ready = (): void => finish();
|
||||
const failed = (): void => finish(new Error("decode"));
|
||||
video.addEventListener(eventName, ready, { once: true });
|
||||
video.addEventListener("error", failed, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function probeImage(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
||||
if (typeof createImageBitmap !== "function") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
||||
try {
|
||||
const bitmap = await createImageBitmap(new Blob([data], { type: request.mimeType }));
|
||||
try {
|
||||
if (bitmap.width < 1 || bitmap.height < 1) return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED");
|
||||
return { ...request, status: "READY", backend: "IMAGE_BITMAP", reason: null, decoded: { width: bitmap.width, height: bitmap.height } };
|
||||
}
|
||||
finally { bitmap.close(); }
|
||||
}
|
||||
catch { return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED"); }
|
||||
}
|
||||
|
||||
async function probeSound(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
||||
if (typeof OfflineAudioContext === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
||||
const context = new OfflineAudioContext(1, 1, 48_000);
|
||||
try {
|
||||
const decoded = await context.decodeAudioData(data.slice(0));
|
||||
if (decoded.sampleRate < 1 || decoded.numberOfChannels < 1 || decoded.length < 1) return blocked(request, "WEB_AUDIO", "DECODE_FAILED");
|
||||
return {
|
||||
...request,
|
||||
status: "READY",
|
||||
backend: "WEB_AUDIO",
|
||||
reason: null,
|
||||
decoded: { sampleRate: decoded.sampleRate, channels: decoded.numberOfChannels, durationFrames: decoded.length },
|
||||
};
|
||||
}
|
||||
catch { return blocked(request, "WEB_AUDIO", "DECODE_FAILED"); }
|
||||
}
|
||||
|
||||
async function probeMovie(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
||||
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
||||
const video = document.createElement("video");
|
||||
if (video.canPlayType(request.mimeType) === "") return blocked(request, "HTML_MEDIA", "MIME_UNSUPPORTED");
|
||||
const url = URL.createObjectURL(new Blob([data], { type: request.mimeType }));
|
||||
video.preload = "auto";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.style.display = "none";
|
||||
document.body.append(video);
|
||||
try {
|
||||
video.src = url;
|
||||
video.load();
|
||||
await waitForMedia(video, "loadeddata", 10_000);
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0 || video.videoWidth < 1 || video.videoHeight < 1) {
|
||||
return blocked(request, "HTML_MEDIA", "DECODE_FAILED");
|
||||
}
|
||||
const target = Math.min(video.duration / 2, Math.max(0, video.duration - 0.001));
|
||||
if (target > 0) {
|
||||
video.currentTime = target;
|
||||
await waitForMedia(video, "seeked", 10_000);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return blocked(request, "HTML_MEDIA", "RUNTIME_UNAVAILABLE");
|
||||
context.drawImage(video, 0, 0, 1, 1);
|
||||
context.getImageData(0, 0, 1, 1);
|
||||
return {
|
||||
...request,
|
||||
status: "READY",
|
||||
backend: "HTML_MEDIA",
|
||||
reason: null,
|
||||
decoded: {
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
durationMicros: Math.max(1, Math.round(video.duration * 1_000_000)),
|
||||
},
|
||||
};
|
||||
}
|
||||
catch { return blocked(request, "HTML_MEDIA", "DECODE_FAILED"); }
|
||||
finally {
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeSequencerCodec(
|
||||
requestValue: unknown,
|
||||
source: ArrayBuffer,
|
||||
): Promise<SequencerCodecProbeResultIR> {
|
||||
const request = parseSequencerCodecProbeRequest(requestValue);
|
||||
if (!(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await sha256(source) !== request.sourceSha256) {
|
||||
return blocked(request, null, "SOURCE_IDENTITY_MISMATCH");
|
||||
}
|
||||
if (request.stripType === "IMAGE") return probeImage(request, source);
|
||||
if (request.stripType === "SOUND") return probeSound(request, source);
|
||||
return probeMovie(request, source);
|
||||
}
|
||||
|
||||
export function createSequencerCodecProbeRequest(
|
||||
stripType: SequencerCodecProbeRequestIR["stripType"],
|
||||
mimeType: string,
|
||||
byteLength: number,
|
||||
sourceSha256: string,
|
||||
): SequencerCodecProbeRequestIR {
|
||||
return parseSequencerCodecProbeRequest({
|
||||
schemaVersion: SEQUENCER_CODEC_PROBE_SCHEMA,
|
||||
stripType,
|
||||
mimeType,
|
||||
byteLength,
|
||||
sourceSha256,
|
||||
});
|
||||
}
|
||||
18
web/app/src/sequencer/SequencerFinalExport.ts
Normal file
18
web/app/src/sequencer/SequencerFinalExport.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
routeSequencerFinalExport,
|
||||
type SequencerFinalExportRequestIR,
|
||||
type SequencerFinalExportRouteIR,
|
||||
} from "../../../protocol/sequencer-export";
|
||||
|
||||
export async function routeSequencerFinalExportInBrowser(
|
||||
request: SequencerFinalExportRequestIR,
|
||||
serverExportAvailable: boolean,
|
||||
scope: object = globalThis,
|
||||
): Promise<SequencerFinalExportRouteIR> {
|
||||
return routeSequencerFinalExport(request, {
|
||||
serverExportAvailable,
|
||||
browserVideoEncoderAvailable: "VideoEncoder" in scope,
|
||||
});
|
||||
}
|
||||
|
||||
export type { SequencerFinalExportRequestIR, SequencerFinalExportRouteIR };
|
||||
201
web/app/src/sequencer/SequencerMediaProxyCache.ts
Normal file
201
web/app/src/sequencer/SequencerMediaProxyCache.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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 waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number): 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);
|
||||
if (error) reject(error); else resolve();
|
||||
};
|
||||
const ready = (): void => finish();
|
||||
const failed = (): void => finish(new Error("decode"));
|
||||
video.addEventListener("loadeddata", ready, { once: true });
|
||||
video.addEventListener("error", failed, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateInitialSequencerMovieProxyFrame(
|
||||
sourceValue: unknown,
|
||||
capabilityValue: unknown,
|
||||
profileValue: unknown,
|
||||
sourceData: ArrayBuffer,
|
||||
): Promise<SequencerGeneratedProxyFrameIR> {
|
||||
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");
|
||||
}
|
||||
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);
|
||||
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);
|
||||
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 };
|
||||
76
web/app/src/sequencer/SequencerMediaRevisionGate.ts
Normal file
76
web/app/src/sequencer/SequencerMediaRevisionGate.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
gateSequencerMediaRevision,
|
||||
parseSequencerMediaRevisionRequest,
|
||||
parseSequencerMediaRevisionState,
|
||||
SequencerMediaRevisionValidationError,
|
||||
type SequencerMediaOperation,
|
||||
type SequencerMediaRevisionDecisionIR,
|
||||
type SequencerMediaRevisionRequestIR,
|
||||
type SequencerMediaRevisionResultIR,
|
||||
type SequencerMediaRevisionStateIR,
|
||||
} from "../../../protocol/sequencer-media-revision";
|
||||
|
||||
export class SequencerMediaRevisionGate {
|
||||
private stateValue: SequencerMediaRevisionStateIR;
|
||||
|
||||
constructor(timelineId: string, timelineRevision: number) {
|
||||
this.stateValue = parseSequencerMediaRevisionState({
|
||||
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
timelineId,
|
||||
timelineRevision,
|
||||
latestRequestRevision: 0,
|
||||
});
|
||||
}
|
||||
|
||||
state(): SequencerMediaRevisionStateIR {
|
||||
return { ...this.stateValue };
|
||||
}
|
||||
|
||||
begin(operation: SequencerMediaOperation, frame: number): SequencerMediaRevisionRequestIR {
|
||||
this.stateValue = {
|
||||
...this.stateValue,
|
||||
latestRequestRevision: this.stateValue.latestRequestRevision + 1,
|
||||
};
|
||||
return parseSequencerMediaRevisionRequest({
|
||||
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
requestId: `media:${this.stateValue.latestRequestRevision}`,
|
||||
timelineId: this.stateValue.timelineId,
|
||||
timelineRevision: this.stateValue.timelineRevision,
|
||||
requestRevision: this.stateValue.latestRequestRevision,
|
||||
operation,
|
||||
frame,
|
||||
});
|
||||
}
|
||||
|
||||
replaceTimeline(timelineId: string, timelineRevision: number): SequencerMediaRevisionStateIR {
|
||||
if (timelineId === this.stateValue.timelineId && timelineRevision <= this.stateValue.timelineRevision) {
|
||||
throw new SequencerMediaRevisionValidationError("REVISION_CONFLICT", "Sequencer timeline revision must advance monotonically");
|
||||
}
|
||||
this.stateValue = parseSequencerMediaRevisionState({
|
||||
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
|
||||
timelineId,
|
||||
timelineRevision,
|
||||
latestRequestRevision: this.stateValue.latestRequestRevision + 1,
|
||||
});
|
||||
return this.state();
|
||||
}
|
||||
|
||||
resolve(
|
||||
request: SequencerMediaRevisionRequestIR,
|
||||
result: SequencerMediaRevisionResultIR,
|
||||
publish: (result: SequencerMediaRevisionResultIR) => void,
|
||||
): SequencerMediaRevisionDecisionIR {
|
||||
const decision = gateSequencerMediaRevision(request, this.stateValue, result);
|
||||
if (decision.status === "PUBLISH") publish(result);
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
SequencerMediaOperation,
|
||||
SequencerMediaRevisionDecisionIR,
|
||||
SequencerMediaRevisionRequestIR,
|
||||
SequencerMediaRevisionResultIR,
|
||||
SequencerMediaRevisionStateIR,
|
||||
};
|
||||
@@ -8,7 +8,54 @@ export {
|
||||
sequencerSourceFrame,
|
||||
} from "../../../protocol/sequencer";
|
||||
export type {
|
||||
SequencerCodecProbeRequestIR,
|
||||
SequencerCodecProbeResultIR,
|
||||
SequencerFrameStripIR,
|
||||
SequencerTimelineIR,
|
||||
SequencerTransitionFrameIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
export {
|
||||
computeSequencerMediaCacheIdentity,
|
||||
createSequencerMediaCacheManifest,
|
||||
parseSequencerMediaCacheManifest,
|
||||
parseSequencerMediaProxyProfile,
|
||||
sequencerMediaCacheKey,
|
||||
verifySequencerMediaCacheEntry,
|
||||
} from "../../../protocol/sequencer-media-cache";
|
||||
export {
|
||||
gateSequencerMediaRevision,
|
||||
parseSequencerMediaRevisionRequest,
|
||||
parseSequencerMediaRevisionResult,
|
||||
parseSequencerMediaRevisionState,
|
||||
} from "../../../protocol/sequencer-media-revision";
|
||||
export {
|
||||
parseSequencerFinalExportEnvironment,
|
||||
parseSequencerFinalExportRequest,
|
||||
routeSequencerFinalExport,
|
||||
} from "../../../protocol/sequencer-export";
|
||||
export type {
|
||||
SequencerFinalExportEnvironmentIR,
|
||||
SequencerFinalExportRequestIR,
|
||||
SequencerFinalExportRouteIR,
|
||||
} from "../../../protocol/sequencer-export";
|
||||
export {
|
||||
parseSequencerAudioSessionReport,
|
||||
SEQUENCER_AUDIO_SESSION_SCHEMA,
|
||||
} from "../../../protocol/sequencer-audio-session";
|
||||
export type {
|
||||
SequencerAudioContextState,
|
||||
SequencerAudioOutputState,
|
||||
SequencerAudioSessionIssueCode,
|
||||
SequencerAudioSessionReportIR,
|
||||
} from "../../../protocol/sequencer-audio-session";
|
||||
export type {
|
||||
SequencerMediaOperation,
|
||||
SequencerMediaRevisionDecisionIR,
|
||||
SequencerMediaRevisionRequestIR,
|
||||
SequencerMediaRevisionResultIR,
|
||||
SequencerMediaRevisionStateIR,
|
||||
} from "../../../protocol/sequencer-media-revision";
|
||||
export type {
|
||||
SequencerMediaCacheManifestIR,
|
||||
SequencerMediaProxyProfileIR,
|
||||
} from "../../../protocol/sequencer-media-cache";
|
||||
|
||||
Reference in New Issue
Block a user