293 lines
15 KiB
TypeScript
293 lines
15 KiB
TypeScript
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<{ releasedCacheBytes: number; cacheBytesAfter: 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: ((status: { releasedCacheBytes: number; cacheBytesAfter: 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?.({ releasedCacheBytes: message.releasedCacheBytes, cacheBytesAfter: message.cacheBytesAfter }); 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 disposed = 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 restartedDisposed = await restarted.dispose();
|
|
restarted.terminate();
|
|
|
|
const codecRequest = {
|
|
schemaVersion: 1 as const,
|
|
stripType: "MOVIE" as const,
|
|
mimeType: "video/mp4",
|
|
byteLength: 1,
|
|
sourceSha256: "0".repeat(64),
|
|
};
|
|
const codec = gateSequencerCodec(codecRequest, {
|
|
...codecRequest,
|
|
status: "BLOCKED",
|
|
backend: null,
|
|
reason: "RUNTIME_UNAVAILABLE",
|
|
decoded: null,
|
|
});
|
|
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,
|
|
disposed,
|
|
restartedDisposed,
|
|
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,
|
|
releasedCacheBytes: result.disposed.releasedCacheBytes,
|
|
cacheBytesAfterDispose: result.disposed.cacheBytesAfter,
|
|
restartedReleasedCacheBytes: result.restartedDisposed.releasedCacheBytes,
|
|
restartedCacheBytesAfterDispose: result.restartedDisposed.cacheBytesAfter,
|
|
workerRestartRecovered: result.reopenedStatus === "COMPLETED",
|
|
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.disposed.releasedCacheBytes).toBeLessThanOrEqual(20 * 1024);
|
|
expect(result.disposed.cacheBytesAfter).toBe(0);
|
|
expect(result.restartedDisposed.releasedCacheBytes).toBeLessThanOrEqual(20 * 1024);
|
|
expect(result.restartedDisposed.cacheBytesAfter).toBe(0);
|
|
expect(result.manifestSha256).toBe(result.storedSessionSha256);
|
|
expect(result.manifestSha256).toBe("d2e7e3c5ed9ae4fda6fe358cebb474f5774f1d2037dff3df9b6dfde9e0bebd2d");
|
|
expect(result.elapsedMs).toBeLessThan(30_000);
|
|
});
|