Complete V1 performance and OOM release gates
This commit is contained in:
272
web/tests/e2e/long-media-performance.spec.ts
Normal file
272
web/tests/e2e/long-media-performance.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const imagePath = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-frame.png");
|
||||
const audioPath = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-silence.wav");
|
||||
|
||||
test("indexes, seeks, cancels and reopens a bounded one-million-frame media timeline", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const image = fs.readFileSync(imagePath);
|
||||
const audio = fs.readFileSync(audioPath);
|
||||
const result = await page.evaluate(async ({ imageBytes, audioBytes }) => {
|
||||
const [mediaModule, storageModule] = await Promise.all([
|
||||
import("/src/sequencer/LongMediaTimeline.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
]);
|
||||
const {
|
||||
buildLongMediaTimelineIndex,
|
||||
deserializeLongMediaSessionManifest,
|
||||
gateSequencerCodec,
|
||||
parseLongMediaSessionManifest,
|
||||
sequencerRuntimeCapabilities,
|
||||
serializeLongMediaSessionManifest,
|
||||
} = mediaModule;
|
||||
const { StorageClient } = storageModule;
|
||||
const started = performance.now();
|
||||
const projectId = `long-media-${Date.now()}`;
|
||||
const cacheMaxBytes = 20 * 1024;
|
||||
const frameCount = 1_000_000;
|
||||
const stripFrames = 100;
|
||||
const imageStripCount = frameCount / stripFrames;
|
||||
const timeline = {
|
||||
schemaVersion: 1 as const,
|
||||
id: "sequencer:long-media",
|
||||
revision: 1,
|
||||
frameStart: 0,
|
||||
frameEnd: frameCount,
|
||||
fpsNumerator: 24_000,
|
||||
fpsDenominator: 1_001,
|
||||
strips: [
|
||||
{
|
||||
id: "strip:long-audio", name: "Long Audio", type: "SOUND" as const, channel: 1,
|
||||
frameStart: 0, frameEnd: frameCount, sourceStart: 0, sourceEnd: frameCount,
|
||||
speed: 1, muted: false, locked: false, sourceId: "media:audio", sourcePath: "//media/sequencer-silence.wav", mimeType: "audio/wav",
|
||||
},
|
||||
...Array.from({ length: imageStripCount }, (_, index) => ({
|
||||
id: `strip:image:${index}`, name: `Image ${index}`, type: "IMAGE" as const, channel: 2,
|
||||
frameStart: index * stripFrames, frameEnd: (index + 1) * stripFrames, sourceStart: 0, sourceEnd: 1,
|
||||
speed: 1, muted: false, locked: false, sourceId: "media:image", sourcePath: "//media/sequencer-frame.png", mimeType: "image/png",
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
const writer = new StorageClient();
|
||||
const storedImage = await writer.putAsset(projectId, imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength), "image/png", "media/sequencer-frame.png");
|
||||
const storedAudio = await writer.putAsset(projectId, audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength), "audio/wav", "media/sequencer-silence.wav");
|
||||
|
||||
const cancellation = new AbortController();
|
||||
const cancelledBuild = buildLongMediaTimelineIndex(timeline, cancellation.signal)
|
||||
.then(() => "unexpected-success")
|
||||
.catch((error: unknown) => error instanceof Error ? error.message.split(":", 1)[0] : String(error));
|
||||
setTimeout(() => cancellation.abort(), 0);
|
||||
const indexCancelCode = await cancelledBuild;
|
||||
const indexStarted = performance.now();
|
||||
const directIndex = await buildLongMediaTimelineIndex(timeline, new AbortController().signal);
|
||||
const indexBuildMs = Math.round(performance.now() - indexStarted);
|
||||
const seekCancellation = new AbortController();
|
||||
seekCancellation.abort();
|
||||
let seekCancelCode = "";
|
||||
try { directIndex.resolve(0, seekCancellation.signal); }
|
||||
catch (error) { seekCancelCode = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
|
||||
interface WorkerClient {
|
||||
worker: Worker;
|
||||
init(timelineValue: unknown, assets: Array<{ sourceId: string; data: ArrayBuffer }>): Promise<{ stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number }>;
|
||||
seek(frame: number, decodeDelayMs?: number): Promise<import("/src/sequencer/LongMediaTimeline.ts").LongMediaSeekResultIR>;
|
||||
cancel(): void;
|
||||
dispose(): Promise<number>;
|
||||
terminate(): void;
|
||||
}
|
||||
const createWorkerClient = (): WorkerClient => {
|
||||
const worker = new Worker("/src/workers/long-media.worker.ts", { type: "module" });
|
||||
let counter = 0;
|
||||
let readyResolve: ((value: { stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number }) => void) | undefined;
|
||||
let readyReject: ((reason: Error) => void) | undefined;
|
||||
let disposeResolve: ((bytes: number) => void) | undefined;
|
||||
const pending = new Map<string, { resolve: (value: import("/src/sequencer/LongMediaTimeline.ts").LongMediaSeekResultIR) => void; reject: (reason: Error) => void }>();
|
||||
worker.onmessage = (event: MessageEvent<any>) => {
|
||||
const message = event.data;
|
||||
if (message.type === "ready") { readyResolve?.(message.index); readyResolve = undefined; readyReject = undefined; return; }
|
||||
if (message.type === "disposed") { disposeResolve?.(message.cacheBytes); disposeResolve = undefined; return; }
|
||||
if (message.type === "error") {
|
||||
if (message.requestId) { pending.get(message.requestId)?.reject(new Error(message.message)); pending.delete(message.requestId); }
|
||||
else { readyReject?.(new Error(message.message)); readyResolve = undefined; readyReject = undefined; }
|
||||
return;
|
||||
}
|
||||
if (message.type === "seekResult") {
|
||||
pending.get(message.requestId)?.resolve(message.result);
|
||||
pending.delete(message.requestId);
|
||||
}
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
const error = new Error(event.message);
|
||||
readyReject?.(error);
|
||||
for (const request of pending.values()) request.reject(error);
|
||||
pending.clear();
|
||||
};
|
||||
return {
|
||||
worker,
|
||||
init: (timelineValue, assets) => new Promise((resolve, reject) => {
|
||||
readyResolve = resolve;
|
||||
readyReject = reject;
|
||||
worker.postMessage({ type: "init", timeline: timelineValue, assets, cacheMaxBytes }, assets.map((asset) => asset.data));
|
||||
}),
|
||||
seek: (frame, decodeDelayMs = 0) => new Promise((resolve, reject) => {
|
||||
const requestId = `seek-${++counter}`;
|
||||
pending.set(requestId, { resolve, reject });
|
||||
worker.postMessage({ type: "seek", requestId, frame, decodeDelayMs });
|
||||
}),
|
||||
cancel: () => worker.postMessage({ type: "cancel" }),
|
||||
dispose: () => new Promise((resolve) => { disposeResolve = resolve; worker.postMessage({ type: "dispose" }); }),
|
||||
terminate: () => worker.terminate(),
|
||||
};
|
||||
};
|
||||
|
||||
const sourceImage = await writer.readAsset(projectId, storedImage.sha256);
|
||||
const sourceAudio = await writer.readAsset(projectId, storedAudio.sha256);
|
||||
const client = createWorkerClient();
|
||||
const workerIndex = await client.init(timeline, [
|
||||
{ sourceId: "media:image", data: sourceImage.data },
|
||||
{ sourceId: "media:audio", data: sourceAudio.data },
|
||||
]);
|
||||
const coldStarted = performance.now();
|
||||
const cold = await client.seek(123_450);
|
||||
const coldSeekMs = Math.round(performance.now() - coldStarted);
|
||||
const hotStarted = performance.now();
|
||||
const hot = await client.seek(123_450);
|
||||
const hotSeekMs = Math.round(performance.now() - hotStarted);
|
||||
const endpoints = await Promise.all([0, 500_000, 999_999].map((frame) => client.seek(frame)));
|
||||
const randomFrames = Array.from({ length: 64 }, (_, index) => (index * 104_729) % frameCount);
|
||||
const randomStarted = performance.now();
|
||||
const random = [];
|
||||
for (const frame of randomFrames) random.push(await client.seek(frame));
|
||||
const randomSeekMs = Math.round(performance.now() - randomStarted);
|
||||
|
||||
const supersededPromise = client.seek(250_000, 50);
|
||||
const latestPromise = client.seek(765_432, 0);
|
||||
const [superseded, latest] = await Promise.all([supersededPromise, latestPromise]);
|
||||
const cancelledSeekPromise = client.seek(333_333, 100);
|
||||
setTimeout(() => client.cancel(), 0);
|
||||
const cancelledSeek = await cancelledSeekPromise;
|
||||
|
||||
const manifest = parseLongMediaSessionManifest({
|
||||
schemaVersion: 1,
|
||||
timeline,
|
||||
currentFrame: latest.frame,
|
||||
cacheMaxBytes,
|
||||
assets: [
|
||||
{ sourceId: "media:image", sha256: storedImage.sha256, mimeType: "image/png" },
|
||||
{ sourceId: "media:audio", sha256: storedAudio.sha256, mimeType: "audio/wav" },
|
||||
],
|
||||
});
|
||||
const serialized = serializeLongMediaSessionManifest(manifest);
|
||||
const manifestSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", serialized.slice(0))), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
const storedSession = await writer.putAsset(projectId, serialized, "application/vnd.blender.long-media+json", "cache/long-media-session.json");
|
||||
const cacheBytesBeforeDispose = await client.dispose();
|
||||
client.terminate();
|
||||
writer.terminate();
|
||||
|
||||
const reader = new StorageClient();
|
||||
const reopenedSessionAsset = await reader.readAsset(projectId, storedSession.sha256);
|
||||
const reopenedManifest = deserializeLongMediaSessionManifest(reopenedSessionAsset.data);
|
||||
const reopenedImage = await reader.readAsset(projectId, reopenedManifest.assets.find((asset) => asset.sourceId === "media:image")!.sha256);
|
||||
const reopenedAudio = await reader.readAsset(projectId, reopenedManifest.assets.find((asset) => asset.sourceId === "media:audio")!.sha256);
|
||||
reader.terminate();
|
||||
const restarted = createWorkerClient();
|
||||
const restartedIndex = await restarted.init(reopenedManifest.timeline, [
|
||||
{ sourceId: "media:image", data: reopenedImage.data },
|
||||
{ sourceId: "media:audio", data: reopenedAudio.data },
|
||||
]);
|
||||
const reopenedSeek = await restarted.seek(reopenedManifest.currentFrame);
|
||||
const restartedCacheBytes = await restarted.dispose();
|
||||
restarted.terminate();
|
||||
|
||||
const codec = gateSequencerCodec("video/mp4", new Set(["image/png", "audio/wav"]));
|
||||
const runtime = sequencerRuntimeCapabilities();
|
||||
let corruptManifestCode = "";
|
||||
try { parseLongMediaSessionManifest({ ...manifest, assets: [{ ...manifest.assets[0], sha256: "bad" }, manifest.assets[1]] }); }
|
||||
catch (error) { corruptManifestCode = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
|
||||
return {
|
||||
frameCount,
|
||||
stripCount: timeline.strips.length,
|
||||
localBytes: { image: imageBytes.byteLength, audio: audioBytes.byteLength, session: reopenedSessionAsset.data.byteLength },
|
||||
indexCancelCode,
|
||||
seekCancelCode,
|
||||
indexBuildMs,
|
||||
directIndex: directIndex.stats,
|
||||
workerIndex,
|
||||
restartedIndex,
|
||||
cold: { status: cold.status, strips: cold.strips.length, cache: cold.cache, elapsedMs: coldSeekMs },
|
||||
hot: { status: hot.status, strips: hot.strips.length, cache: hot.cache, elapsedMs: hotSeekMs },
|
||||
endpoints: endpoints.map((item) => [item.frame, item.status, item.strips.length]),
|
||||
randomCompleted: random.filter((item) => item.status === "COMPLETED").length,
|
||||
randomCache: random.at(-1)?.cache,
|
||||
randomSeekMs,
|
||||
latest: [superseded.status, latest.status, latest.frame],
|
||||
cancelledSeek: cancelledSeek.status,
|
||||
cacheBytesBeforeDispose,
|
||||
restartedCacheBytes,
|
||||
manifestSha256,
|
||||
storedSessionSha256: storedSession.sha256,
|
||||
reopenedFrame: reopenedSeek.frame,
|
||||
reopenedStatus: reopenedSeek.status,
|
||||
codec: { status: codec.status, code: codec.issues[0]?.code },
|
||||
localEncoding: runtime.localEncoding,
|
||||
corruptManifestCode,
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
};
|
||||
}, { imageBytes: new Uint8Array(image), audioBytes: new Uint8Array(audio) });
|
||||
|
||||
console.log("long-media-performance", JSON.stringify({
|
||||
frameCount: result.frameCount,
|
||||
stripCount: result.stripCount,
|
||||
referenceCount: result.workerIndex.referenceCount,
|
||||
indexBuildMs: result.indexBuildMs,
|
||||
coldSeekMs: result.cold.elapsedMs,
|
||||
hotSeekMs: result.hot.elapsedMs,
|
||||
randomSeekMs: result.randomSeekMs,
|
||||
cacheBytes: result.randomCache?.bytes,
|
||||
evictions: result.randomCache?.evictions,
|
||||
manifestSha256: result.manifestSha256,
|
||||
elapsedMs: result.elapsedMs,
|
||||
}));
|
||||
expect(result).toMatchObject({
|
||||
frameCount: 1_000_000,
|
||||
stripCount: 10_001,
|
||||
indexCancelCode: "SEQUENCER_CANCELLED",
|
||||
seekCancelCode: "SEQUENCER_CANCELLED",
|
||||
latest: ["SUPERSEDED", "COMPLETED", 765_432],
|
||||
cancelledSeek: "CANCELLED",
|
||||
reopenedFrame: 765_432,
|
||||
reopenedStatus: "COMPLETED",
|
||||
codec: { status: "BLOCKED", code: "SEQUENCER_CODEC_UNSUPPORTED" },
|
||||
localEncoding: "BLOCKED",
|
||||
corruptManifestCode: "SEQUENCER_SCHEMA_INVALID",
|
||||
});
|
||||
expect(result.localBytes).toMatchObject({ image: 261, audio: 16_044 });
|
||||
expect(result.localBytes.session).toBeGreaterThan(1_000_000);
|
||||
expect(result.directIndex).toEqual(result.workerIndex);
|
||||
expect(result.restartedIndex).toEqual(result.workerIndex);
|
||||
expect(result.workerIndex.stripCount).toBe(10_001);
|
||||
expect(result.workerIndex.referenceCount).toBeLessThan(25_000);
|
||||
expect(result.workerIndex.estimatedBytes).toBeLessThan(256 * 1024);
|
||||
expect(result.indexBuildMs).toBeLessThan(10_000);
|
||||
expect(result.cold).toMatchObject({ status: "COMPLETED", strips: 2 });
|
||||
expect(result.hot).toMatchObject({ status: "COMPLETED", strips: 2 });
|
||||
expect(result.hot.cache.hits).toBeGreaterThan(result.cold.cache.hits);
|
||||
expect(result.hot.cache.bytes).toBeLessThanOrEqual(result.hot.cache.maxBytes);
|
||||
expect(result.endpoints).toEqual([[0, "COMPLETED", 2], [500_000, "COMPLETED", 2], [999_999, "COMPLETED", 2]]);
|
||||
expect(result.randomCompleted).toBe(64);
|
||||
expect(result.randomCache?.bytes).toBeLessThanOrEqual(20 * 1024);
|
||||
expect(result.randomCache?.evictions).toBeGreaterThan(0);
|
||||
expect(result.randomCache?.keys).toEqual(expect.arrayContaining(["media:audio:597927", "media:image:1"]));
|
||||
expect(result.randomSeekMs).toBeLessThan(15_000);
|
||||
expect(result.cacheBytesBeforeDispose).toBeLessThanOrEqual(20 * 1024);
|
||||
expect(result.restartedCacheBytes).toBeLessThanOrEqual(20 * 1024);
|
||||
expect(result.manifestSha256).toBe(result.storedSessionSha256);
|
||||
expect(result.manifestSha256).toBe("d2e7e3c5ed9ae4fda6fe358cebb474f5774f1d2037dff3df9b6dfde9e0bebd2d");
|
||||
expect(result.elapsedMs).toBeLessThan(30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user