Complete V1 performance and OOM release gates
This commit is contained in:
375
web/tests/e2e/geometry-10m-performance.spec.ts
Normal file
375
web/tests/e2e/geometry-10m-performance.spec.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("streams ten-million-triangle SceneIR ranges into a bounded interactive LOD", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const [largeGeometry, viewportModule, offscreenModule, storageModule, engineModule] = await Promise.all([
|
||||
import("/src/performance/large-geometry.ts"),
|
||||
import("/src/three-adapter/viewport.ts"),
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
]);
|
||||
const { MeshGeometryStreamValidator, parseMeshGeometryStreamRequest, buildLODCacheKey, encodeLODGeometry, decodeLODGeometry } = largeGeometry;
|
||||
const { ViewportRenderer } = viewportModule;
|
||||
const { OffscreenViewportRenderer } = offscreenModule;
|
||||
const { StorageClient } = storageModule;
|
||||
const { WebEngineClient } = engineModule;
|
||||
const worker = new Worker("/src/workers/geometry-stream.worker.ts", { type: "module" });
|
||||
const started = performance.now();
|
||||
const heap = (): number => Number((performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory?.usedJSHeapSize ?? 0);
|
||||
const baselineHeapBytes = heap();
|
||||
let peakHeapBytes = baselineHeapBytes;
|
||||
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const wasm = await engine.init();
|
||||
|
||||
const createCanvas = (): HTMLCanvasElement => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 240;
|
||||
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px";
|
||||
document.body.append(canvas);
|
||||
return canvas;
|
||||
};
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 30_000): Promise<void> => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (performance.now() > deadline) throw new Error("GEOMETRY_STREAM_BUDGET_EXCEEDED: viewport did not become interactive");
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
};
|
||||
const visiblePixels = (renderer: InstanceType<typeof ViewportRenderer>): number => {
|
||||
const gl = renderer.renderer.getContext();
|
||||
const pixels = new Uint8Array(64 * 64 * 4);
|
||||
gl.readPixels(
|
||||
Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)),
|
||||
Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)),
|
||||
64,
|
||||
64,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
pixels,
|
||||
);
|
||||
let visible = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 80) visible++;
|
||||
}
|
||||
return visible;
|
||||
};
|
||||
const snapshot = (meshId: string, triangleCount: number) => ({
|
||||
schemaVersion: 1 as const,
|
||||
revision: 1,
|
||||
sceneId: "scene:geometry-10m",
|
||||
source: { kind: "mock" as const },
|
||||
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
||||
activeObjectId: "object:geometry-10m",
|
||||
frame: { current: 1, start: 1, end: 250 },
|
||||
nodes: [{
|
||||
id: "object:geometry-10m", name: "Ten Million Triangle Mesh", type: "MESH" as const, parentId: null, dataId: meshId,
|
||||
visible: true, selectable: true,
|
||||
localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 },
|
||||
}],
|
||||
meshes: [{
|
||||
id: meshId, name: "Ten Million Triangle Mesh", vertexCount: 5_100_000, edgeCount: 0, faceCount: triangleCount,
|
||||
cornerCount: triangleCount * 3, triangleCount, geometryStatus: "binary" as const, geometryBufferId: meshId,
|
||||
bounds: { min: [-2, -2, -0.1] as [number, number, number], max: [2, 2, 0.1] as [number, number, number] },
|
||||
}],
|
||||
materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [],
|
||||
scenes: [{ id: "scene:geometry-10m", name: "Geometry 10M" }],
|
||||
});
|
||||
|
||||
type Geometry = import("/src/performance/large-geometry.ts").MeshGeometryStreamChunkIR;
|
||||
type Report = import("/src/performance/large-geometry.ts").MeshGeometryStreamReportIR;
|
||||
interface SessionResult {
|
||||
report: Report;
|
||||
lod?: Geometry;
|
||||
acceptedChunks: number;
|
||||
detachedTransfers: number;
|
||||
lateChunks: number;
|
||||
validator?: Awaited<ReturnType<InstanceType<typeof MeshGeometryStreamValidator>["finish"]>>;
|
||||
viewport?: Awaited<ReturnType<typeof renderLOD>>;
|
||||
cancelLatencyMs?: number;
|
||||
}
|
||||
|
||||
const renderLOD = async (geometry: Geometry, acceptedChunks: () => number) => {
|
||||
const meshId = "mesh:geometry-10m";
|
||||
const lodMeshId = `${meshId}:lod:0`;
|
||||
const geometryBuffer = {
|
||||
schemaVersion: 1 as const,
|
||||
meshId: lodMeshId,
|
||||
byteLength: geometry.byteLength,
|
||||
positions: geometry.positions,
|
||||
indices: geometry.indices,
|
||||
};
|
||||
const sourceBuffer = { ...geometryBuffer, meshId };
|
||||
const level = {
|
||||
level: 0,
|
||||
meshId: lodMeshId,
|
||||
triangleBudget: geometry.triangleCount,
|
||||
outputTriangleCount: geometry.triangleCount,
|
||||
outputVertexCount: geometry.vertexCount,
|
||||
geometryBuffers: [geometryBuffer],
|
||||
};
|
||||
const lowColumns = 32;
|
||||
const lowRows = 32;
|
||||
const lowPositions = new Float32Array((lowColumns + 1) * (lowRows + 1) * 3);
|
||||
for (let y = 0; y <= lowRows; y++) for (let x = 0; x <= lowColumns; x++) {
|
||||
const offset = (y * (lowColumns + 1) + x) * 3;
|
||||
lowPositions.set([x / lowColumns * 4 - 2, y / lowRows * 4 - 2, 0], offset);
|
||||
}
|
||||
const lowIndices = new Uint32Array(lowColumns * lowRows * 6);
|
||||
for (let cell = 0; cell < lowColumns * lowRows; cell++) {
|
||||
const x = cell % lowColumns;
|
||||
const y = Math.floor(cell / lowColumns);
|
||||
const a = y * (lowColumns + 1) + x;
|
||||
lowIndices.set([a, a + 1, a + lowColumns + 2, a, a + lowColumns + 2, a + lowColumns + 1], cell * 6);
|
||||
}
|
||||
const lowGeometryBuffer = {
|
||||
schemaVersion: 1 as const,
|
||||
meshId: `${meshId}:lod:1`,
|
||||
byteLength: lowPositions.byteLength + lowIndices.byteLength,
|
||||
positions: lowPositions.buffer,
|
||||
indices: lowIndices.buffer,
|
||||
};
|
||||
const lowLevel = {
|
||||
level: 1,
|
||||
meshId: lowGeometryBuffer.meshId,
|
||||
triangleBudget: lowColumns * lowRows * 2,
|
||||
outputTriangleCount: lowColumns * lowRows * 2,
|
||||
outputVertexCount: lowPositions.length / 3,
|
||||
geometryBuffers: [lowGeometryBuffer],
|
||||
};
|
||||
const cacheKey = buildLODCacheKey({ objectId: "object:geometry-10m", meshRevision: 1, modifierStackHash: "none", profileHash: "geometry-10m-lod-v1", poseOrRestState: "REST" });
|
||||
const encoded = encodeLODGeometry([level, lowLevel]);
|
||||
const encodedBytes = encoded.byteLength;
|
||||
const projectId = `geometry-10m-${Date.now()}`;
|
||||
const manifest = {
|
||||
schemaVersion: 1 as const,
|
||||
meshId,
|
||||
sourceMeshRevision: 1,
|
||||
levels: [
|
||||
{ level: 0, sourceMeshRevision: 1, triangleBudget: geometry.triangleCount, meshId: lodMeshId },
|
||||
{ level: 1, sourceMeshRevision: 1, triangleBudget: lowLevel.triangleBudget, meshId: lowLevel.meshId },
|
||||
],
|
||||
cacheKey,
|
||||
objectId: "object:geometry-10m",
|
||||
modifierStackHash: "none",
|
||||
profileHash: "geometry-10m-lod-v1",
|
||||
poseOrRestState: "REST" as const,
|
||||
generatedAt: "2026-08-14T00:00:00.000Z",
|
||||
byteLength: encodedBytes,
|
||||
};
|
||||
const writer = new StorageClient();
|
||||
const saved = await writer.saveLOD(projectId, cacheKey, encoded);
|
||||
await writer.putLODManifest(projectId, manifest);
|
||||
writer.terminate();
|
||||
const reader = new StorageClient();
|
||||
const reopenedManifest = await reader.getLODManifest(projectId, cacheKey);
|
||||
const reopened = await reader.readLOD(projectId, cacheKey);
|
||||
const decoded = decodeLODGeometry(reopened.data);
|
||||
|
||||
let selectedObjectId = "";
|
||||
const mainCanvas = createCanvas();
|
||||
const main = new ViewportRenderer(mainCanvas, (objectId) => { selectedObjectId = objectId; });
|
||||
main.setSnapshot(snapshot(meshId, 10_000_000), [sourceBuffer]);
|
||||
main.installLODLevels(meshId, [level, lowLevel]);
|
||||
main.camera.position.set(0.1, -0.1, 0.1);
|
||||
const nearLOD = main.updateLODSelection().get(meshId)?.level;
|
||||
main.camera.position.set(100, -100, 100);
|
||||
const farLOD = main.updateLODSelection().get(meshId)?.level;
|
||||
main.camera.position.set(4.5, -4.5, 3.5);
|
||||
main.updateLODSelection();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const mainVisible = visiblePixels(main);
|
||||
const bounds = mainCanvas.getBoundingClientRect();
|
||||
mainCanvas.dispatchEvent(new MouseEvent("click", { clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2, bubbles: true }));
|
||||
mainCanvas.dispatchEvent(new WheelEvent("wheel", { deltaY: -20, bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const mainGeometries = main.renderer.info.memory.geometries;
|
||||
|
||||
const offscreenCanvas = createCanvas();
|
||||
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreen.setSnapshot(snapshot(meshId, 10_000_000), [sourceBuffer]);
|
||||
await waitFor(() => Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
|
||||
const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0);
|
||||
offscreenCanvas.dispatchEvent(new WheelEvent("wheel", { deltaY: 20, bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const firstInteractiveMs = Math.round(performance.now() - started);
|
||||
const chunksAtFirstInteractive = acceptedChunks();
|
||||
const estimatedGpuBytes = geometry.byteLength * 3 + lowGeometryBuffer.byteLength;
|
||||
main.dispose();
|
||||
offscreen.dispose();
|
||||
mainCanvas.remove();
|
||||
offscreenCanvas.remove();
|
||||
const deleted = await reader.deleteLOD(projectId, cacheKey);
|
||||
const missing = await reader.getLODManifest(projectId, cacheKey);
|
||||
reader.terminate();
|
||||
|
||||
return {
|
||||
firstInteractiveMs,
|
||||
chunksAtFirstInteractive,
|
||||
mainVisible,
|
||||
offscreenVisible,
|
||||
selectedObjectId,
|
||||
lodSelections: [nearLOD, farLOD],
|
||||
mainGeometries,
|
||||
estimatedGpuBytes,
|
||||
cache: {
|
||||
key: cacheKey,
|
||||
savedBytes: saved.bytes,
|
||||
encodedBytes,
|
||||
reopenedBytes: reopened.bytes,
|
||||
persisted: reopenedManifest.persisted,
|
||||
decodedTriangles: decoded.map((item) => item.outputTriangleCount),
|
||||
deleted: deleted.persisted && !missing.persisted,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const runSession = (
|
||||
streamId: string,
|
||||
cancelAfterChunks?: number,
|
||||
onLOD?: (geometry: Geometry, acceptedChunks: () => number) => Promise<Awaited<ReturnType<typeof renderLOD>>>,
|
||||
): Promise<SessionResult> => {
|
||||
const request = parseMeshGeometryStreamRequest({
|
||||
schemaVersion: 1,
|
||||
streamId,
|
||||
meshId: "mesh:geometry-10m",
|
||||
triangleCount: 10_000_000,
|
||||
chunkTriangleCount: 250_000,
|
||||
lodTriangleCount: 20_000,
|
||||
});
|
||||
const validator = new MeshGeometryStreamValidator(request);
|
||||
let acceptedChunks = 0;
|
||||
let detachedTransfers = 0;
|
||||
let lateChunks = 0;
|
||||
let cancellationRequested = false;
|
||||
let cancellationStarted = 0;
|
||||
let lod: Geometry | undefined;
|
||||
let viewport: Promise<Awaited<ReturnType<typeof renderLOD>>> | undefined;
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.onerror = (event) => reject(new Error(event.message));
|
||||
worker.onmessage = async (event: MessageEvent<import("/src/performance/large-geometry.ts").MeshGeometryStreamWorkerResponse>) => {
|
||||
try {
|
||||
const message = event.data;
|
||||
if (message.type === "error") throw new Error(`${message.code}: ${message.message}`);
|
||||
if (message.type === "detached") {
|
||||
if (message.positionsByteLength !== 0 || message.indicesByteLength !== 0) throw new Error("GEOMETRY_STREAM_BUDGET_EXCEEDED: sender retained transferred buffers");
|
||||
detachedTransfers++;
|
||||
return;
|
||||
}
|
||||
if (message.type === "lod") {
|
||||
lod = message.geometry;
|
||||
if (onLOD) viewport = onLOD(lod, () => acceptedChunks);
|
||||
worker.postMessage({ type: "ack", streamId, chunkIndex: -1 });
|
||||
return;
|
||||
}
|
||||
if (message.type === "chunk") {
|
||||
if (cancellationRequested) { lateChunks++; return; }
|
||||
await validator.accept(message.chunk);
|
||||
acceptedChunks++;
|
||||
peakHeapBytes = Math.max(peakHeapBytes, heap());
|
||||
if (cancelAfterChunks !== undefined && acceptedChunks >= cancelAfterChunks) {
|
||||
cancellationRequested = true;
|
||||
cancellationStarted = performance.now();
|
||||
worker.postMessage({ type: "cancel", streamId });
|
||||
}
|
||||
else worker.postMessage({ type: "ack", streamId, chunkIndex: message.chunk.chunkIndex });
|
||||
return;
|
||||
}
|
||||
if (message.type === "cancelled") {
|
||||
await new Promise((done) => setTimeout(done, 50));
|
||||
resolve({ report: message.report, lod, acceptedChunks, detachedTransfers, lateChunks, cancelLatencyMs: Math.round(performance.now() - cancellationStarted) });
|
||||
return;
|
||||
}
|
||||
if (message.type === "complete") {
|
||||
const validated = await validator.finish();
|
||||
const rendered = await viewport;
|
||||
resolve({ report: message.report, lod, acceptedChunks, detachedTransfers, lateChunks, validator: validated, viewport: rendered });
|
||||
}
|
||||
}
|
||||
catch (error) { reject(error); }
|
||||
};
|
||||
worker.postMessage({ type: "start", request });
|
||||
});
|
||||
};
|
||||
|
||||
const cancelled = await runSession("geometry-10m-cancel", 2);
|
||||
const completed = await runSession("geometry-10m-complete", undefined, renderLOD);
|
||||
const invalidCode = await new Promise<string>((resolve, reject) => {
|
||||
worker.onerror = (event) => reject(new Error(event.message));
|
||||
worker.onmessage = (event: MessageEvent<import("/src/performance/large-geometry.ts").MeshGeometryStreamWorkerResponse>) => {
|
||||
if (event.data.type === "error") resolve(event.data.code);
|
||||
};
|
||||
worker.postMessage({ type: "start", request: { schemaVersion: 1, streamId: "geometry-invalid", meshId: "mesh:invalid", triangleCount: 0, chunkTriangleCount: 1, lodTriangleCount: 1 } });
|
||||
});
|
||||
worker.terminate();
|
||||
engine.terminate();
|
||||
|
||||
const recoveryCanvas = createCanvas();
|
||||
const recovery = new ViewportRenderer(recoveryCanvas);
|
||||
const recoveryPositions = new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]).buffer;
|
||||
const recoveryIndices = new Uint32Array([0, 1, 2]).buffer;
|
||||
recovery.setSnapshot(snapshot("mesh:geometry-10m", 1), [{ schemaVersion: 1, meshId: "mesh:geometry-10m", byteLength: recoveryPositions.byteLength + recoveryIndices.byteLength, positions: recoveryPositions, indices: recoveryIndices }]);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const recoveryVisible = visiblePixels(recovery);
|
||||
recovery.dispose();
|
||||
recoveryCanvas.remove();
|
||||
|
||||
return {
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
baselineHeapBytes,
|
||||
peakHeapBytes,
|
||||
wasmAllocatedBytes: wasm.allocatedBytes,
|
||||
invalidCode,
|
||||
cancelled,
|
||||
completed,
|
||||
recoveryVisible,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("geometry-10m-performance", JSON.stringify({
|
||||
triangleCount: result.completed.report.triangleCount,
|
||||
chunkCount: result.completed.report.chunkCount,
|
||||
transferredBytes: result.completed.report.transferredBytes,
|
||||
peakWorkingSetBytes: result.completed.report.peakWorkingSetBytes,
|
||||
manifestSha256: result.completed.report.manifestSha256,
|
||||
firstInteractiveMs: result.completed.viewport?.firstInteractiveMs,
|
||||
chunksAtFirstInteractive: result.completed.viewport?.chunksAtFirstInteractive,
|
||||
estimatedGpuBytes: result.completed.viewport?.estimatedGpuBytes,
|
||||
wasmAllocatedBytes: result.wasmAllocatedBytes,
|
||||
cancelLatencyMs: result.cancelled.cancelLatencyMs,
|
||||
elapsedMs: result.elapsedMs,
|
||||
}));
|
||||
|
||||
expect(result.invalidCode).toBe("GEOMETRY_STREAM_INVALID");
|
||||
expect(result.cancelled.report).toMatchObject({ status: "CANCELLED", chunkCount: 2, triangleCount: 500_000 });
|
||||
expect(result.cancelled.acceptedChunks).toBe(2);
|
||||
expect(result.cancelled.lateChunks).toBe(0);
|
||||
expect(result.cancelled.cancelLatencyMs).toBeLessThan(2_000);
|
||||
expect(result.completed.report).toMatchObject({ status: "COMPLETED", triangleCount: 10_000_000, chunkCount: 40 });
|
||||
expect(result.completed.acceptedChunks).toBe(40);
|
||||
expect(result.completed.detachedTransfers).toBe(41);
|
||||
expect(result.completed.report.transferredBytes).toBe(result.completed.validator?.transferredBytes);
|
||||
expect(result.completed.report.peakWorkingSetBytes).toBeLessThanOrEqual(8 * 1024 * 1024);
|
||||
expect(result.completed.report.manifestSha256).toBe(result.completed.validator?.manifestSha256);
|
||||
expect(result.completed.report.manifestSha256).toBe("224738e0c3aa28ae42167bf8974455a3d0f940f59f19eb51c5768f435c66b24f");
|
||||
expect(result.completed.viewport?.firstInteractiveMs).toBeLessThan(60_000);
|
||||
expect(result.completed.viewport?.chunksAtFirstInteractive).toBeLessThan(40);
|
||||
expect(result.completed.viewport?.mainVisible).toBeGreaterThan(100);
|
||||
expect(result.completed.viewport?.offscreenVisible).toBeGreaterThan(10);
|
||||
expect(result.completed.viewport?.selectedObjectId).toBe("object:geometry-10m");
|
||||
expect(result.completed.viewport?.lodSelections).toEqual([0, 1]);
|
||||
expect(result.completed.viewport?.estimatedGpuBytes).toBeLessThan(16 * 1024 * 1024);
|
||||
expect(result.completed.viewport?.cache).toMatchObject({ persisted: true, decodedTriangles: [20_000, 2_048], deleted: true });
|
||||
expect(result.completed.viewport?.cache.savedBytes).toBe(result.completed.viewport?.cache.encodedBytes);
|
||||
expect(result.completed.viewport?.cache.reopenedBytes).toBe(result.completed.viewport?.cache.encodedBytes);
|
||||
expect(result.wasmAllocatedBytes).toBeLessThan(512 * 1024 * 1024);
|
||||
if (result.baselineHeapBytes > 0) expect(result.peakHeapBytes - result.baselineHeapBytes).toBeLessThan(768 * 1024 * 1024);
|
||||
expect(result.recoveryVisible).toBeGreaterThan(100);
|
||||
expect(result.elapsedMs).toBeLessThan(150_000);
|
||||
});
|
||||
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);
|
||||
});
|
||||
91
web/tests/e2e/oom-recovery.spec.ts
Normal file
91
web/tests/e2e/oom-recovery.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
test("recovers deterministically from WASM, OPFS, GPU and NanoVDB allocation faults", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const bytes = fs.readFileSync(basicBlend);
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { runOOMRecoveryScenarios, parseOOMRecoverySuite } = await import("/src/testing/oom-recovery-scenarios.ts");
|
||||
const reports = await runOOMRecoveryScenarios(Uint8Array.from(input).buffer);
|
||||
let rejectsMappingDrift = false;
|
||||
try {
|
||||
const invalid = structuredClone(reports);
|
||||
invalid[0].faults[0].code = "INVALID_ARGUMENT";
|
||||
parseOOMRecoverySuite(invalid);
|
||||
}
|
||||
catch {
|
||||
rejectsMappingDrift = true;
|
||||
}
|
||||
return { reports, rejectsMappingDrift };
|
||||
}, Array.from(bytes));
|
||||
|
||||
expect(result.rejectsMappingDrift).toBe(true);
|
||||
expect(result.reports.map((report) => report.scenario)).toEqual([
|
||||
"WASM_MAIN",
|
||||
"OPFS_STAGING",
|
||||
"GPU_RESOURCES",
|
||||
"NANOVDB_RESIDENT",
|
||||
]);
|
||||
|
||||
for (const report of result.reports) {
|
||||
expect(report.schemaVersion).toBe(1);
|
||||
expect(report.faults.length).toBeGreaterThan(0);
|
||||
expect(report.memory.beforeBytes).toBeGreaterThanOrEqual(0);
|
||||
expect(report.memory.peakBytes).toBeGreaterThanOrEqual(report.memory.beforeBytes);
|
||||
expect(report.memory.peakBytes).toBeGreaterThanOrEqual(report.memory.afterBytes);
|
||||
expect(report.state.temporaryResourcesAfter).toBe(0);
|
||||
expect(report.recovery.recovered).toBe(true);
|
||||
expect(report.recovery.tokenIsolated).toBe(true);
|
||||
expect(report.recovery.sameSession || report.recovery.restartedSession).toBe(true);
|
||||
expect(report.checks.length).toBeGreaterThanOrEqual(4);
|
||||
}
|
||||
|
||||
const wasm = result.reports.find((report) => report.scenario === "WASM_MAIN")!;
|
||||
expect(wasm.faults.map((fault) => [fault.point, fault.code, fault.stage])).toEqual([
|
||||
["WASM_MAIN_OPEN_INPUT", "WASM_OUT_OF_MEMORY", "WASM_OPEN_INPUT"],
|
||||
["WASM_MAIN_EDIT_COMMAND", "WASM_OUT_OF_MEMORY", "MAIN_EDIT_COMMAND"],
|
||||
["WASM_MAIN_SAVE_RESULT", "WASM_OUT_OF_MEMORY", "WASM_SAVE_RESULT"],
|
||||
]);
|
||||
expect(wasm.state.revisionAfter).toBe(wasm.state.revisionBefore);
|
||||
expect(wasm.state.hashAfter).toBe(wasm.state.hashBefore);
|
||||
expect(wasm.memory.releasedBytes).toBeGreaterThan(0);
|
||||
expect(wasm.recovery.sameSession).toBe(true);
|
||||
expect(wasm.recovery.restartedSession).toBe(true);
|
||||
expect(wasm.checks).toEqual(expect.arrayContaining(["undo-redo-stable", "worker-reopen"]));
|
||||
|
||||
const opfs = result.reports.find((report) => report.scenario === "OPFS_STAGING")!;
|
||||
expect(opfs.faults[0]).toMatchObject({ point: "OPFS_STAGING_WRITE", code: "STORAGE_QUOTA", stage: "OPFS_STAGING_WRITE" });
|
||||
expect(opfs.state.revisionBefore).toBe(7);
|
||||
expect(opfs.state.revisionAfter).toBe(7);
|
||||
expect(opfs.state.hashAfter).toBe(opfs.state.hashBefore);
|
||||
expect(opfs.memory.releasedBytes).toBeGreaterThan(0);
|
||||
expect(opfs.state.temporaryResourcesPeak).toBeGreaterThan(0);
|
||||
expect(opfs.checks).toEqual(expect.arrayContaining(["journal-not-committed", "temporary-files-removed", "next-save-succeeds"]));
|
||||
|
||||
const gpu = result.reports.find((report) => report.scenario === "GPU_RESOURCES")!;
|
||||
expect(gpu.faults[0]).toMatchObject({ point: "GPU_TEXTURE_UPLOAD", code: "GPU_TEXTURE_BUDGET_EXCEEDED", stage: "GPU_TEXTURE_UPLOAD" });
|
||||
expect(gpu.state.hashAfter).toBe(gpu.state.hashBefore);
|
||||
expect(gpu.memory.releasedBytes).toBeGreaterThan(0);
|
||||
expect(gpu.state.temporaryResourcesPeak).toBe(1);
|
||||
expect(gpu.checks).toEqual(expect.arrayContaining(["partial-object-not-published", "small-scene-renders"]));
|
||||
|
||||
const nanoVdb = result.reports.find((report) => report.scenario === "NANOVDB_RESIDENT")!;
|
||||
expect(nanoVdb.faults[0]).toMatchObject({ point: "NANOVDB_PAGE_TABLE", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" });
|
||||
expect(nanoVdb.memory.releasedBytes).toBe(64 * 1024);
|
||||
expect(nanoVdb.state.temporaryResourcesPeak).toBe(1);
|
||||
expect(nanoVdb.checks).toEqual(expect.arrayContaining(["resident-buffer-destroyed-once", "same-device-recovers", "lru-eviction-recovers"]));
|
||||
|
||||
console.log("oom-recovery", JSON.stringify(result.reports.map((report) => ({
|
||||
scenario: report.scenario,
|
||||
errors: report.faults.map((fault) => `${fault.code}@${fault.stage}`),
|
||||
revision: [report.state.revisionBefore, report.state.revisionAfter],
|
||||
hashStable: report.state.hashBefore === report.state.hashAfter,
|
||||
releasedBytes: report.memory.releasedBytes,
|
||||
temporaryResourcesAfter: report.state.temporaryResourcesAfter,
|
||||
recovered: report.recovery.recovered,
|
||||
}))));
|
||||
});
|
||||
@@ -1230,6 +1230,7 @@ test("commits and reopens a hash-bound NanoVDB project through OPFS", async ({ p
|
||||
});
|
||||
|
||||
test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/vdb-webgpu-test.worker.ts", { type: "module" });
|
||||
@@ -1250,17 +1251,18 @@ test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({
|
||||
});
|
||||
expect(result.visiblePixels).toBeGreaterThan(500);
|
||||
expect(result.alphaSum).toBeGreaterThan(10_000);
|
||||
expect(result.imageSha256).toBe("7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978");
|
||||
expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]);
|
||||
expect(result.imageSha256).toBe("718e824bda68197e5bf63e2261396f47afe35efbc3ec84d3b38e4afe5b07e33f");
|
||||
expect(result.viewAxis).toBe("X");
|
||||
expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "COLOR_GRID", "TEMPERATURE_GRID_BLACKBODY", "EMISSION_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]);
|
||||
expect(result.materialMapping.material).toMatchObject({ interpolation: "LINEAR", color: [0.7, 0.8, 0.95], emissionColor: [1, 0.35, 0.1] });
|
||||
expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual([
|
||||
"VOLUME_COLOR_GRID_UNSUPPORTED",
|
||||
"VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED",
|
||||
"VOLUME_VELOCITY_RENDER_UNSUPPORTED",
|
||||
]);
|
||||
expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual(["VOLUME_VELOCITY_RENDER_UNSUPPORTED"]);
|
||||
expect(result.emissionMapping.material.emissionGrid).toBe("temperature");
|
||||
expect(result.emissionMapping.losses).toEqual([]);
|
||||
expect(result.emissionVisiblePixels).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test("renders a real NanoVDB volume through both production viewport backends", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const [{ loadNanoVDBViewportAsset }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([
|
||||
@@ -1269,6 +1271,11 @@ test("renders a real NanoVDB volume through both production viewport backends",
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
]);
|
||||
const asset = await loadNanoVDBViewportAsset("volume:ViewportSmoke", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
||||
asset.material = {
|
||||
...asset.manifest.material,
|
||||
interpolation: "NEAREST",
|
||||
emissionGrid: asset.manifest.material.temperatureGrid,
|
||||
};
|
||||
const snapshot: any = {
|
||||
schemaVersion: 1, revision: 1, sceneId: "scene:Volume", source: { kind: "mock" },
|
||||
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
|
||||
@@ -1281,7 +1288,7 @@ test("renders a real NanoVDB volume through both production viewport backends",
|
||||
nonMeshData: [{ id: asset.dataId, name: "Viewport Volume", type: "VOLUME", geometryStatus: "blocked", pointCount: 0, splineCount: 0, sourcePath: "//volumes/generated-smoke.vdb", resourceKind: "OPENVDB" }],
|
||||
activeObjectId: "object:Volume", frame: { current: 1, start: 1, end: 250 },
|
||||
};
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 20_000): Promise<void> => {
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 80_000): Promise<void> => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (performance.now() > deadline) throw new Error("viewport volume timed out");
|
||||
@@ -1299,7 +1306,8 @@ test("renders a real NanoVDB volume through both production viewport backends",
|
||||
const main = new ViewportRenderer(mainCanvas);
|
||||
main.setSnapshot(snapshot);
|
||||
main.setVolumeAssets([asset]);
|
||||
await waitFor(() => mainCanvas.dataset.volumeStatus === "ready");
|
||||
await waitFor(() => ["ready", "blocked"].includes(mainCanvas.dataset.volumeStatus ?? ""));
|
||||
if (mainCanvas.dataset.volumeStatus !== "ready") throw new Error(`main viewport volume ${mainCanvas.dataset.volumeErrorCode ?? "blocked"}`);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
let mainVolumeObjects = 0;
|
||||
main.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) mainVolumeObjects++; });
|
||||
@@ -1314,7 +1322,9 @@ test("renders a real NanoVDB volume through both production viewport backends",
|
||||
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreen.setSnapshot(snapshot);
|
||||
offscreen.setVolumeAssets([asset]);
|
||||
await waitFor(() => offscreenCanvas.dataset.volumeStatus === "ready" && Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
|
||||
await waitFor(() => ["ready", "blocked"].includes(offscreenCanvas.dataset.volumeStatus ?? ""));
|
||||
if (offscreenCanvas.dataset.volumeStatus !== "ready") throw new Error(`offscreen viewport volume ${offscreenCanvas.dataset.volumeErrorCode ?? "blocked"}`);
|
||||
await waitFor(() => Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
|
||||
const offscreenResult = { status: offscreenCanvas.dataset.volumeStatus, count: Number(offscreenCanvas.dataset.volumeCount), visible: Number(offscreenCanvas.dataset.rendererPixels) };
|
||||
offscreen.dispose();
|
||||
offscreenCanvas.remove();
|
||||
@@ -1355,9 +1365,25 @@ test("recovers NanoVDB paging from network, Worker and WebGPU device faults", as
|
||||
expect(gpu.network.outOfOrderResponse).toContain("NANOVDB_STREAM_INCOMPLETE");
|
||||
expect(gpu.lru).toMatchObject({ residentPages: 2, residentBytes: 128 * 1024, evictions: 1, keys: ["page-a", "page-c"] });
|
||||
expect(gpu.oom).toContain("NANOVDB_GPU_BUDGET_EXCEEDED");
|
||||
expect(gpu.demandPaging.pageCount).toBeGreaterThan(gpu.demandPaging.residentPageCapacity);
|
||||
expect(gpu.demandPaging.initialVirtualPages).toEqual([]);
|
||||
expect(gpu.demandPaging.residentBytes).toBe(512 * 1024);
|
||||
expect(gpu.demandPaging.maxResidentBytes).toBe(512 * 1024);
|
||||
expect(gpu.demandPaging.evictions).toBe(1);
|
||||
expect(gpu.demandPaging.requestedPageResident).toBe(true);
|
||||
expect(gpu.demandPaging.touchedPageResident).toBe(true);
|
||||
expect(gpu.demandPaging.evictedPageResident).toBe(false);
|
||||
expect(gpu.demandPaging.residentVirtualPages).toContain(gpu.demandPaging.pageCount - 1);
|
||||
expect(gpu.demandPaging.pageRanges.length).toBeGreaterThanOrEqual(3);
|
||||
expect(gpu.demandPaging.pageRanges.every((range: string) => /^bytes=\d+-\d+$/.test(range))).toBe(true);
|
||||
expect(gpu.demandPaging.demandWords).toEqual(gpu.demandPaging.expectedWords);
|
||||
expect(gpu.demandPaging.incompletePage).toContain("NANOVDB_STREAM_INCOMPLETE");
|
||||
expect(gpu.globalResidency.totalResidentBytes).toBeLessThanOrEqual(gpu.globalResidency.maxResidentBytes);
|
||||
expect(Object.values(gpu.globalResidency.gridResidentBytes)).toEqual([256 * 1024, 256 * 1024, 256 * 1024]);
|
||||
expect(gpu.paging.pageCount).toBeGreaterThan(1);
|
||||
expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount);
|
||||
expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1);
|
||||
expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.demandPaging.expectedWords.slice(0, 2));
|
||||
expect(gpu.samplesStable).toBe(true);
|
||||
|
||||
const interrupted = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
|
||||
165
web/tests/e2e/v1-user-loop.spec.ts
Normal file
165
web/tests/e2e/v1-user-loop.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
test("completes the V1 import-edit-save-restart-reopen-GLB loop", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const [{ WebEngineClient }, { StorageClient }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/three-adapter/viewport.ts"),
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
]);
|
||||
const projectId = `v1-loop-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const digestGeometry = async (buffers: Array<{ meshId: string; positions: ArrayBuffer; indices: ArrayBuffer }>, meshId: string): Promise<string> => {
|
||||
const geometry = buffers.find((candidate) => candidate.meshId === meshId);
|
||||
if (!geometry) throw new Error(`missing geometry ${meshId}`);
|
||||
const bytes = new Uint8Array(geometry.positions.byteLength + geometry.indices.byteLength);
|
||||
bytes.set(new Uint8Array(geometry.positions));
|
||||
bytes.set(new Uint8Array(geometry.indices), geometry.positions.byteLength);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
const materialByName = (snapshot: { materials: Array<{ name: string; roughness: number; metallic: number; baseColor: number[] }> }, name: string) =>
|
||||
snapshot.materials.find((material) => material.name === name);
|
||||
|
||||
const firstEngine = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const firstStorage = new StorageClient();
|
||||
let editedRevision = 0;
|
||||
let objectId = "";
|
||||
let meshId = "";
|
||||
let materialId = "";
|
||||
let editedGeometrySha256 = "";
|
||||
let storedRevision = 0;
|
||||
try {
|
||||
const opened = await firstEngine.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
const created = await firstEngine.applyCommand({ type: "createPrimitive", primitive: "CUBE", name: "V1LoopCube", location: [2, 0, 0] });
|
||||
const object = created.snapshot.nodes.find((node) => node.name === "V1LoopCube");
|
||||
if (!object?.dataId) throw new Error("V1 object creation did not return stable object/mesh IDs");
|
||||
objectId = object.id;
|
||||
meshId = object.dataId;
|
||||
await firstEngine.applyCommand({ type: "setObjectTransform", objectId, translation: [2, 1, 0.5], rotationEuler: [0.1, 0.2, 0.3], scale: [1.25, 0.75, 1.5] });
|
||||
await firstEngine.applyCommand({ type: "translateMeshVertices", meshId, vertexIndices: [0, 1], offset: [0, 0, 0.25] });
|
||||
const slotted = await firstEngine.applyCommand({ type: "addMaterialSlot", objectId, name: "V1LoopMaterial" });
|
||||
const material = materialByName(slotted.snapshot, "V1LoopMaterial");
|
||||
if (!material) throw new Error("V1 material slot did not create a material");
|
||||
materialId = (material as { id?: string }).id ?? "";
|
||||
if (!materialId) throw new Error("V1 material has no stable ID");
|
||||
const materialEdited = await firstEngine.applyCommand({
|
||||
type: "setMaterialPrincipled",
|
||||
materialId,
|
||||
baseColor: [0.12, 0.34, 0.56, 1],
|
||||
roughness: 0.23,
|
||||
metallic: 0.67,
|
||||
});
|
||||
const undone = await firstEngine.applyCommand({ type: "undo" });
|
||||
const undoMaterial = materialByName(undone.snapshot, "V1LoopMaterial");
|
||||
const redone = await firstEngine.applyCommand({ type: "redo" });
|
||||
const redoMaterial = materialByName(redone.snapshot, "V1LoopMaterial");
|
||||
if (!undoMaterial || !redoMaterial || undoMaterial.roughness === redoMaterial.roughness) throw new Error("V1 material undo/redo did not change Main state");
|
||||
if (materialEdited.snapshot.revision >= redone.snapshot.revision) throw new Error("V1 history revisions did not advance");
|
||||
editedRevision = redone.snapshot.revision;
|
||||
editedGeometrySha256 = await digestGeometry(redone.geometryBuffers, meshId);
|
||||
const unsupported = await firstEngine.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["UNSUPPORTED"] });
|
||||
if (unsupported.status !== "BLOCKED" || unsupported.issues[0]?.code !== "SHADER_NODE_UNSUPPORTED") throw new Error("V1 unsupported shader gate drifted");
|
||||
const saved = await firstEngine.saveBlend();
|
||||
const stored = await firstStorage.saveProject(projectId, editedRevision, saved.slice(0));
|
||||
await firstStorage.saveSnapshot(projectId, editedRevision, saved.slice(0));
|
||||
storedRevision = stored.revision;
|
||||
}
|
||||
finally {
|
||||
firstEngine.terminate();
|
||||
firstStorage.terminate();
|
||||
}
|
||||
|
||||
const restartedStorage = new StorageClient();
|
||||
const stored = await restartedStorage.readProject(projectId);
|
||||
restartedStorage.terminate();
|
||||
const restartedEngine = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const reopened = await restartedEngine.openBlend(stored.buffer);
|
||||
restartedEngine.terminate();
|
||||
const reopenedObject = reopened.snapshot.nodes.find((node) => node.id === objectId);
|
||||
const reopenedMaterial = reopened.snapshot.materials.find((material) => material.id === materialId);
|
||||
const reopenedGeometrySha256 = await digestGeometry(reopened.geometryBuffers, meshId);
|
||||
if (!reopenedObject || !reopenedMaterial) throw new Error("V1 reopened scene lost edited IDs");
|
||||
|
||||
const createCanvas = (): HTMLCanvasElement => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 240;
|
||||
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px";
|
||||
document.body.append(canvas);
|
||||
return canvas;
|
||||
};
|
||||
const mainCanvas = createCanvas();
|
||||
const main = new ViewportRenderer(mainCanvas);
|
||||
main.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const gl = main.renderer.getContext();
|
||||
const mainPixels = new Uint8Array(32 * 32 * 4);
|
||||
gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels);
|
||||
const mainVisible = Array.from({ length: 32 * 32 }, (_, index) => mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2] > 30).filter(Boolean).length;
|
||||
main.dispose();
|
||||
mainCanvas.remove();
|
||||
|
||||
const offscreenCanvas = createCanvas();
|
||||
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreen.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []);
|
||||
const deadline = performance.now() + 30_000;
|
||||
while (Number(offscreenCanvas.dataset.rendererPixels ?? 0) === 0 && !offscreenCanvas.dataset.rendererError) {
|
||||
if (performance.now() > deadline) throw new Error("V1 Offscreen viewport timed out");
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0);
|
||||
const offscreenError = offscreenCanvas.dataset.rendererError;
|
||||
offscreen.dispose();
|
||||
offscreenCanvas.remove();
|
||||
|
||||
const glb = await new Promise<{ ok: boolean; byteLength: number; roundTrip?: { compatible: boolean; mismatches: string[] }; pbrSummary?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number }; error?: string }>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
const transfer: Transferable[] = [];
|
||||
for (const geometry of reopened.geometryBuffers) {
|
||||
for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value);
|
||||
}
|
||||
worker.postMessage({ snapshot: reopened.snapshot, geometryBuffers: reopened.geometryBuffers, assetBuffers: [] }, transfer);
|
||||
});
|
||||
return {
|
||||
editedRevision,
|
||||
storedRevision,
|
||||
persistedRevision: stored.revision,
|
||||
reopenedRevision: reopened.snapshot.revision,
|
||||
objectTransform: reopenedObject.transform,
|
||||
material: reopenedMaterial,
|
||||
geometry: [editedGeometrySha256, reopenedGeometrySha256],
|
||||
viewport: { mainVisible, offscreenVisible, offscreenError },
|
||||
glb,
|
||||
};
|
||||
}, new Uint8Array(bytes));
|
||||
|
||||
expect(result.editedRevision).toBeGreaterThan(1);
|
||||
expect(result.storedRevision).toBe(result.editedRevision);
|
||||
expect(result.persistedRevision).toBe(result.editedRevision);
|
||||
expect(result.reopenedRevision).toBe(1);
|
||||
expect(result.objectTransform.translation).toEqual([2, 1, 0.5]);
|
||||
expect(result.objectTransform.scale).toEqual([1.25, 0.75, 1.5]);
|
||||
[0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.material.baseColor[index]).toBeCloseTo(value, 6));
|
||||
expect(result.material.roughness).toBeCloseTo(0.23);
|
||||
expect(result.material.metallic).toBeCloseTo(0.67);
|
||||
expect(result.geometry[0]).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.geometry[1]).toBe(result.geometry[0]);
|
||||
expect(result.viewport.mainVisible).toBeGreaterThan(0);
|
||||
expect(result.viewport.offscreenVisible).toBeGreaterThan(0);
|
||||
expect(result.viewport.offscreenError).toBeUndefined();
|
||||
expect(result.glb.ok).toBe(true);
|
||||
expect(result.glb.byteLength).toBeGreaterThan(1_000);
|
||||
expect(result.glb.roundTrip).toEqual({ compatible: true, mismatches: [] });
|
||||
[0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.glb.pbrSummary?.baseColorFactor?.[index]).toBeCloseTo(value, 6));
|
||||
expect(result.glb.pbrSummary?.roughnessFactor).toBeCloseTo(0.23);
|
||||
expect(result.glb.pbrSummary?.metallicFactor).toBeCloseTo(0.67);
|
||||
});
|
||||
Reference in New Issue
Block a user