Complete V1 performance and OOM release gates
This commit is contained in:
323
web/app/src/sequencer/LongMediaTimeline.ts
Normal file
323
web/app/src/sequencer/LongMediaTimeline.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
SequencerValidationError,
|
||||
parseSequencerTimeline,
|
||||
sequencerSourceFrame,
|
||||
type SequencerFrameStripIR,
|
||||
type SequencerStripIR,
|
||||
type SequencerTimelineIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
|
||||
export const LONG_MEDIA_SCHEMA = 1 as const;
|
||||
export const LONG_MEDIA_INDEX_BUCKET_FRAMES = 1_024;
|
||||
export const LONG_MEDIA_MAX_INDEX_REFERENCES = 2_000_000;
|
||||
export const LONG_MEDIA_MAX_CACHE_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
export interface LongMediaIndexStatsIR {
|
||||
stripCount: number;
|
||||
bucketCount: number;
|
||||
referenceCount: number;
|
||||
estimatedBytes: number;
|
||||
}
|
||||
|
||||
export interface LongMediaCacheStatsIR {
|
||||
entries: number;
|
||||
bytes: number;
|
||||
maxBytes: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
evictions: number;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface LongMediaSeekResultIR {
|
||||
status: "COMPLETED" | "SUPERSEDED" | "CANCELLED";
|
||||
frame: number;
|
||||
strips: SequencerFrameStripIR[];
|
||||
previewBytes: number;
|
||||
cache: LongMediaCacheStatsIR;
|
||||
}
|
||||
|
||||
export interface LongMediaSessionAssetIR {
|
||||
sourceId: string;
|
||||
sha256: string;
|
||||
mimeType: "image/png" | "audio/wav";
|
||||
}
|
||||
|
||||
export interface LongMediaSessionManifestIR {
|
||||
schemaVersion: typeof LONG_MEDIA_SCHEMA;
|
||||
timeline: SequencerTimelineIR;
|
||||
currentFrame: number;
|
||||
cacheMaxBytes: number;
|
||||
assets: LongMediaSessionAssetIR[];
|
||||
}
|
||||
|
||||
export interface LongMediaPreviewSource {
|
||||
load(strip: SequencerStripIR, sourceFrame: number, signal: AbortSignal): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
function cancelled(signal: AbortSignal, stage: string): void {
|
||||
if (signal.aborted) throw new SequencerValidationError("SEQUENCER_CANCELLED", `Long media ${stage} was cancelled`);
|
||||
}
|
||||
|
||||
function cacheBudget(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > LONG_MEDIA_MAX_CACHE_BYTES) {
|
||||
throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media cache byte budget is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function frameInTimeline(timeline: SequencerTimelineIR, frame: number): number {
|
||||
if (!Number.isSafeInteger(frame) || frame < timeline.frameStart || frame > timeline.frameEnd) {
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media frame ${frame} is outside the timeline`);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
export class LongMediaTimelineIndex {
|
||||
readonly timeline: SequencerTimelineIR;
|
||||
readonly stats: LongMediaIndexStatsIR;
|
||||
private readonly buckets: ReadonlyMap<number, readonly number[]>;
|
||||
|
||||
private constructor(timeline: SequencerTimelineIR, buckets: Map<number, number[]>, referenceCount: number) {
|
||||
this.timeline = timeline;
|
||||
this.buckets = buckets;
|
||||
this.stats = {
|
||||
stripCount: timeline.strips.length,
|
||||
bucketCount: buckets.size,
|
||||
referenceCount,
|
||||
estimatedBytes: referenceCount * Uint32Array.BYTES_PER_ELEMENT + buckets.size * 16,
|
||||
};
|
||||
}
|
||||
|
||||
static async build(value: unknown, signal: AbortSignal): Promise<LongMediaTimelineIndex> {
|
||||
const timeline = parseSequencerTimeline(value);
|
||||
const buckets = new Map<number, number[]>();
|
||||
let referenceCount = 0;
|
||||
for (let index = 0; index < timeline.strips.length; index++) {
|
||||
if (index % 256 === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
cancelled(signal, "index build");
|
||||
}
|
||||
const strip = timeline.strips[index];
|
||||
const first = Math.floor(strip.frameStart / LONG_MEDIA_INDEX_BUCKET_FRAMES);
|
||||
const last = Math.floor((strip.frameEnd - 1) / LONG_MEDIA_INDEX_BUCKET_FRAMES);
|
||||
for (let bucket = first; bucket <= last; bucket++) {
|
||||
referenceCount++;
|
||||
if (referenceCount > LONG_MEDIA_MAX_INDEX_REFERENCES) {
|
||||
throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media index reference budget exceeded");
|
||||
}
|
||||
const entries = buckets.get(bucket) ?? [];
|
||||
entries.push(index);
|
||||
buckets.set(bucket, entries);
|
||||
}
|
||||
}
|
||||
cancelled(signal, "index build");
|
||||
return new LongMediaTimelineIndex(timeline, buckets, referenceCount);
|
||||
}
|
||||
|
||||
resolve(frameValue: number, signal: AbortSignal): SequencerFrameStripIR[] {
|
||||
const frame = frameInTimeline(this.timeline, frameValue);
|
||||
cancelled(signal, "seek");
|
||||
const bucket = Math.floor(frame / LONG_MEDIA_INDEX_BUCKET_FRAMES);
|
||||
const candidates = this.buckets.get(bucket) ?? [];
|
||||
const active = candidates
|
||||
.map((index) => this.timeline.strips[index])
|
||||
.filter((strip) => !strip.muted && frame >= strip.frameStart && frame < strip.frameEnd);
|
||||
const activeIds = new Set(active.map((strip) => strip.id));
|
||||
const hiddenByMeta = new Set(active.filter((strip) => strip.type === "META").flatMap((strip) => strip.childStripIds ?? []));
|
||||
const result = active.filter((strip) => !hiddenByMeta.has(strip.id)).map((strip): SequencerFrameStripIR => {
|
||||
const dependencies = [...(strip.inputStripIds ?? []), ...(strip.childStripIds ?? [])];
|
||||
if (dependencies.some((id) => !activeIds.has(id))) {
|
||||
throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has an inactive long-media dependency`);
|
||||
}
|
||||
return { stripId: strip.id, channel: strip.channel, sourceFrame: sequencerSourceFrame(strip, frame), dependencyStripIds: dependencies };
|
||||
});
|
||||
cancelled(signal, "seek");
|
||||
return result.sort((left, right) => left.channel - right.channel || left.stripId.localeCompare(right.stripId));
|
||||
}
|
||||
}
|
||||
|
||||
export class LongMediaPreviewCache {
|
||||
readonly maxBytes: number;
|
||||
private readonly entries = new Map<string, ArrayBuffer>();
|
||||
private currentBytes = 0;
|
||||
private hitCount = 0;
|
||||
private missCount = 0;
|
||||
private evictionCount = 0;
|
||||
|
||||
constructor(maxBytes: number) {
|
||||
this.maxBytes = cacheBudget(maxBytes);
|
||||
}
|
||||
|
||||
get(key: string): ArrayBuffer | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) { this.missCount++; return undefined; }
|
||||
this.hitCount++;
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, entry);
|
||||
return entry.slice(0);
|
||||
}
|
||||
|
||||
set(key: string, data: ArrayBuffer): void {
|
||||
if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > this.maxBytes) {
|
||||
throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media preview exceeds the cache byte budget");
|
||||
}
|
||||
const copy = data.slice(0);
|
||||
const previous = this.entries.get(key);
|
||||
if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); }
|
||||
while (this.currentBytes + copy.byteLength > this.maxBytes) {
|
||||
const oldest = this.entries.entries().next().value as [string, ArrayBuffer] | undefined;
|
||||
if (!oldest) break;
|
||||
this.entries.delete(oldest[0]);
|
||||
this.currentBytes -= oldest[1].byteLength;
|
||||
this.evictionCount++;
|
||||
}
|
||||
this.entries.set(key, copy);
|
||||
this.currentBytes += copy.byteLength;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.currentBytes = 0;
|
||||
}
|
||||
|
||||
stats(): LongMediaCacheStatsIR {
|
||||
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 class LongMediaTimelineSession {
|
||||
private generation = 0;
|
||||
private controller: AbortController | null = null;
|
||||
private explicitlyCancelledGeneration: number | null = null;
|
||||
readonly cache: LongMediaPreviewCache;
|
||||
|
||||
constructor(
|
||||
private readonly index: LongMediaTimelineIndex,
|
||||
private readonly source: LongMediaPreviewSource,
|
||||
maxCacheBytes: number,
|
||||
private readonly publish: (result: LongMediaSeekResultIR) => void = () => undefined,
|
||||
) {
|
||||
this.cache = new LongMediaPreviewCache(maxCacheBytes);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (!this.controller) return;
|
||||
this.explicitlyCancelledGeneration = this.generation;
|
||||
this.controller.abort();
|
||||
}
|
||||
|
||||
async seek(frame: number): Promise<LongMediaSeekResultIR> {
|
||||
this.controller?.abort();
|
||||
const generation = ++this.generation;
|
||||
this.explicitlyCancelledGeneration = null;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
try {
|
||||
const strips = this.index.resolve(frame, controller.signal);
|
||||
let previewBytes = 0;
|
||||
for (const resolved of strips) {
|
||||
const strip = this.index.timeline.strips.find((candidate) => candidate.id === resolved.stripId)!;
|
||||
if (!strip.sourceId || !["IMAGE", "SOUND", "MOVIE"].includes(strip.type)) continue;
|
||||
const key = `${strip.sourceId}:${resolved.sourceFrame}`;
|
||||
let preview = this.cache.get(key);
|
||||
if (!preview) {
|
||||
preview = await this.source.load(strip, resolved.sourceFrame, controller.signal);
|
||||
if (!this.isCurrent(generation, controller)) return this.interrupted(frame, generation);
|
||||
this.cache.set(key, preview);
|
||||
}
|
||||
previewBytes += preview.byteLength;
|
||||
}
|
||||
if (!this.isCurrent(generation, controller)) return this.interrupted(frame, generation);
|
||||
const result: LongMediaSeekResultIR = { status: "COMPLETED", frame, strips, previewBytes, cache: this.cache.stats() };
|
||||
this.publish(result);
|
||||
return result;
|
||||
}
|
||||
catch (error) {
|
||||
if (controller.signal.aborted || error instanceof SequencerValidationError && error.code === "SEQUENCER_CANCELLED") {
|
||||
return this.interrupted(frame, generation);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
if (this.generation === generation) this.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.cancel();
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
private isCurrent(generation: number, controller: AbortController): boolean {
|
||||
return generation === this.generation && this.controller === controller && !controller.signal.aborted;
|
||||
}
|
||||
|
||||
private interrupted(frame: number, generation: number): LongMediaSeekResultIR {
|
||||
return {
|
||||
status: this.explicitlyCancelledGeneration === generation ? "CANCELLED" : "SUPERSEDED",
|
||||
frame,
|
||||
strips: [],
|
||||
previewBytes: 0,
|
||||
cache: this.cache.stats(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export function parseLongMediaSessionManifest(value: unknown): LongMediaSessionManifestIR {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Long media session manifest must be an object");
|
||||
const input = value as Record<string, unknown>;
|
||||
if (input.schemaVersion !== LONG_MEDIA_SCHEMA || !Array.isArray(input.assets)) throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported long media session schema");
|
||||
const timeline = parseSequencerTimeline(input.timeline);
|
||||
const currentFrame = frameInTimeline(timeline, input.currentFrame as number);
|
||||
const cacheMaxBytes = cacheBudget(input.cacheMaxBytes as number);
|
||||
const sourceIds = new Set<string>();
|
||||
const assets = input.assets.map((value, index): LongMediaSessionAssetIR => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media asset ${index} is invalid`);
|
||||
const asset = value as Record<string, unknown>;
|
||||
if (typeof asset.sourceId !== "string" || asset.sourceId.length === 0 || asset.sourceId.length > 128 || sourceIds.has(asset.sourceId) ||
|
||||
typeof asset.sha256 !== "string" || !SHA256.test(asset.sha256) || (asset.mimeType !== "image/png" && asset.mimeType !== "audio/wav")) {
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media asset ${index} is invalid`);
|
||||
}
|
||||
sourceIds.add(asset.sourceId);
|
||||
return { sourceId: asset.sourceId, sha256: asset.sha256, mimeType: asset.mimeType };
|
||||
});
|
||||
for (const strip of timeline.strips) {
|
||||
if (["IMAGE", "SOUND"].includes(strip.type) && strip.sourceId && !sourceIds.has(strip.sourceId)) {
|
||||
throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has no persisted long media asset`);
|
||||
}
|
||||
}
|
||||
return { schemaVersion: LONG_MEDIA_SCHEMA, timeline, currentFrame, cacheMaxBytes, assets };
|
||||
}
|
||||
|
||||
export function serializeLongMediaSessionManifest(value: LongMediaSessionManifestIR): ArrayBuffer {
|
||||
const parsed = parseLongMediaSessionManifest(value);
|
||||
return new TextEncoder().encode(JSON.stringify(parsed)).buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
export function deserializeLongMediaSessionManifest(data: ArrayBuffer): LongMediaSessionManifestIR {
|
||||
if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > 64 * 1024 * 1024) {
|
||||
throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media session manifest byte size is invalid");
|
||||
}
|
||||
try { return parseLongMediaSessionManifest(JSON.parse(new TextDecoder().decode(data))); }
|
||||
catch (error) {
|
||||
if (error instanceof SequencerValidationError) throw error;
|
||||
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Long media session manifest JSON is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildLongMediaTimelineIndex(value: unknown, signal: AbortSignal): Promise<LongMediaTimelineIndex> {
|
||||
return LongMediaTimelineIndex.build(value, signal);
|
||||
}
|
||||
|
||||
export { gateSequencerCodec, sequencerRuntimeCapabilities } from "../../../protocol/sequencer";
|
||||
Reference in New Issue
Block a user