Complete V1 performance and OOM release gates

This commit is contained in:
mes123456
2026-08-14 22:32:09 -04:00
parent 3ea9974eee
commit a3f3071c03
45 changed files with 4206 additions and 276 deletions

View 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);
});

View 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);
});

View 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,
}))));
});

View File

@@ -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) => {

View 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);
});