Advance Blender 5.2 web parity through M12-03D
This commit is contained in:
351
web/app/src/testing/render-compositor-media-recovery.ts
Normal file
351
web/app/src/testing/render-compositor-media-recovery.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
parseRenderCompositorMediaRecoverySuite,
|
||||
type RenderCompositorMediaRecoveryEvidenceIR,
|
||||
} from "../../../protocol/render-compositor-media-recovery";
|
||||
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import { createGPUTextureAsset } from "../../../protocol/render-assets";
|
||||
import { WebEngineClient } from "../engine-client/WebEngineClient";
|
||||
import { ViewportRenderer } from "../three-adapter/viewport";
|
||||
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
||||
import {
|
||||
CompositorFrameCache,
|
||||
CompositorValidationError,
|
||||
executeCompositorGraph,
|
||||
executeCompositorGraphCached,
|
||||
type CompositorGraphIR,
|
||||
type CompositorImageBuffer,
|
||||
} from "../compositor/CompositorExecutor";
|
||||
import { createSequencerCodecProbeRequest, probeSequencerCodec } from "../sequencer/SequencerCodecProbe";
|
||||
import { generateInitialSequencerMovieProxyFrame, SequencerMediaProxyCache } from "../sequencer/SequencerMediaProxyCache";
|
||||
|
||||
export interface RenderCompositorMediaRecoveryInput {
|
||||
renderBlend: ArrayBuffer;
|
||||
compositorBlend: ArrayBuffer;
|
||||
movie: {
|
||||
mimeType: string;
|
||||
data: ArrayBuffer;
|
||||
};
|
||||
texturePng: ArrayBuffer;
|
||||
}
|
||||
|
||||
function stable(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
||||
const source = value as Record<string, unknown>;
|
||||
return `{${Object.keys(source).sort().map((key) => `${JSON.stringify(key)}:${stable(source[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
async function sha256(value: string | ArrayBuffer): Promise<string> {
|
||||
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value);
|
||||
const result = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(result), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function code(error: unknown): string {
|
||||
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code;
|
||||
if (error instanceof Error) return error.message.split(":", 1)[0];
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function requireInput(value: ArrayBuffer, label: string): ArrayBuffer {
|
||||
if (!(value instanceof ArrayBuffer) || value.byteLength === 0) throw new Error(`M11_RECOVERY_INPUT_INVALID: ${label}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderIdentity(snapshot: SceneSnapshotIR): Promise<string> {
|
||||
return sha256(stable({
|
||||
revision: snapshot.revision,
|
||||
sceneId: snapshot.sceneId,
|
||||
activeObjectId: snapshot.activeObjectId,
|
||||
nodes: snapshot.nodes,
|
||||
meshes: snapshot.meshes,
|
||||
materials: snapshot.materials,
|
||||
cameras: snapshot.cameras,
|
||||
lights: snapshot.lights,
|
||||
worlds: snapshot.worlds,
|
||||
scenes: snapshot.scenes,
|
||||
}));
|
||||
}
|
||||
|
||||
async function renderSnapshot(
|
||||
snapshot: SceneSnapshotIR,
|
||||
geometryBuffers: Parameters<ViewportRenderer["setSnapshot"]>[1],
|
||||
nonMeshGeometryBuffers: Parameters<ViewportRenderer["setSnapshot"]>[2],
|
||||
): Promise<{ sha256: string; byteLength: number; visibleUnits: number }> {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.width = "256px";
|
||||
canvas.style.height = "256px";
|
||||
canvas.style.position = "fixed";
|
||||
canvas.style.left = "-10000px";
|
||||
document.body.append(canvas);
|
||||
const renderer = new ViewportRenderer(canvas);
|
||||
try {
|
||||
const renderable = structuredClone(snapshot);
|
||||
renderable.activeObjectId = null;
|
||||
renderer.setSnapshot(renderable, geometryBuffers ?? [], nonMeshGeometryBuffers ?? []);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const gl = renderer.renderer.getContext();
|
||||
const width = gl.drawingBufferWidth;
|
||||
const height = gl.drawingBufferHeight;
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let visibleUnits = 0;
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
if (pixels[offset] + pixels[offset + 1] + pixels[offset + 2] > 12) visibleUnits++;
|
||||
}
|
||||
return { sha256: await sha256(pixels.buffer), byteLength: pixels.byteLength, visibleUnits };
|
||||
}
|
||||
finally {
|
||||
renderer.dispose();
|
||||
canvas.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function runRenderRecovery(input: RenderCompositorMediaRecoveryInput): Promise<RenderCompositorMediaRecoveryEvidenceIR> {
|
||||
const blend = requireInput(input.renderBlend, "renderBlend");
|
||||
const texturePng = requireInput(input.texturePng, "texturePng");
|
||||
const sourceSha256 = await sha256(blend);
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const textureStore = new GPUTextureStore("THREE_WEBGL2");
|
||||
let released = { releasedTextures: 0, releasedPayloadBytes: 0, releasedGPUBytes: 0, resourcesAfter: 0 as const };
|
||||
try {
|
||||
const first = await client.openBlend(blend.slice(0));
|
||||
const identityBefore = await renderIdentity(first.snapshot);
|
||||
const outputBefore = await renderSnapshot(first.snapshot, first.geometryBuffers, first.nonMeshGeometryBuffers);
|
||||
|
||||
const cancellation = new AbortController();
|
||||
cancellation.abort();
|
||||
let cancellationCode = "";
|
||||
try { await client.openBlend(blend.slice(0), undefined, cancellation.signal); }
|
||||
catch (error) { cancellationCode = code(error); }
|
||||
const afterCancellation = await client.snapshot();
|
||||
if (await renderIdentity(afterCancellation.snapshot) !== identityBefore) {
|
||||
throw new Error("M11_RENDER_CANCELLATION_MUTATED_STATE");
|
||||
}
|
||||
|
||||
await client.restart();
|
||||
const second = await client.openBlend(blend.slice(0));
|
||||
const identityAfter = await renderIdentity(second.snapshot);
|
||||
const outputAfter = await renderSnapshot(second.snapshot, second.geometryBuffers, second.nonMeshGeometryBuffers);
|
||||
|
||||
const textureAsset = await createGPUTextureAsset({
|
||||
assetId: "asset:m11-recovery-texture",
|
||||
imageId: "image:m11-recovery-texture",
|
||||
mimeType: "image/png",
|
||||
width: 8,
|
||||
height: 8,
|
||||
usage: "BASE_COLOR",
|
||||
colorSpace: "SRGB",
|
||||
}, texturePng.slice(0));
|
||||
const accepted = await textureStore.upload([textureAsset]);
|
||||
if (accepted.loaded !== 1) throw new Error("M11_RENDER_TEXTURE_UPLOAD_FAILED");
|
||||
const retainedBeforeBudget = textureStore.stats();
|
||||
const overflow = Array.from({ length: 257 }, (_, index) => ({
|
||||
...textureAsset,
|
||||
assetId: `asset:m11-recovery-overflow:${index}`,
|
||||
imageId: `image:m11-recovery-overflow:${index}`,
|
||||
data: textureAsset.data.slice(0),
|
||||
}));
|
||||
const blocked = await textureStore.upload(overflow);
|
||||
const retainedAfterBudget = textureStore.stats();
|
||||
if (stable(retainedBeforeBudget) !== stable(retainedAfterBudget)) {
|
||||
throw new Error("M11_RENDER_BUDGET_MUTATED_TEXTURES");
|
||||
}
|
||||
released = textureStore.dispose();
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
domain: "RENDER",
|
||||
source: { byteLength: blend.byteLength, sha256: sourceSha256 },
|
||||
cancellation: { status: "CANCELLED", code: cancellationCode as "OPEN_CANCELLED", publishedResults: 0, temporaryResourcesAfter: 0 },
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore,
|
||||
identityAfter,
|
||||
outputSha256Before: outputBefore.sha256,
|
||||
outputSha256After: outputAfter.sha256,
|
||||
},
|
||||
budget: {
|
||||
status: "BLOCKED",
|
||||
code: blocked.errorCodes[0] as "GPU_TEXTURE_BUDGET_EXCEEDED",
|
||||
retainedIdentityHash: identityAfter,
|
||||
temporaryResourcesAfter: 0,
|
||||
},
|
||||
release: {
|
||||
status: "RELEASED",
|
||||
releasedBytes: released.releasedPayloadBytes,
|
||||
releasedResources: released.releasedTextures,
|
||||
resourcesAfter: released.resourcesAfter,
|
||||
},
|
||||
recovery: {
|
||||
status: "RECOVERED",
|
||||
identityHash: identityAfter,
|
||||
outputSha256: outputAfter.sha256,
|
||||
outputBytes: outputAfter.byteLength,
|
||||
visibleUnits: outputAfter.visibleUnits,
|
||||
},
|
||||
};
|
||||
}
|
||||
finally {
|
||||
if (released.releasedTextures === 0) textureStore.dispose();
|
||||
client.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
function compositorGraph(snapshot: SceneSnapshotIR): CompositorGraphIR {
|
||||
const graph = snapshot.scenes.find((scene) => scene.name === "M11 Chain")?.compositorGraph;
|
||||
if (!graph) throw new Error("M11_COMPOSITOR_GRAPH_MISSING");
|
||||
return graph;
|
||||
}
|
||||
|
||||
async function compositorOutput(image: CompositorImageBuffer): Promise<{ sha256: string; byteLength: number; visibleUnits: number }> {
|
||||
const bytes = image.data.buffer.slice(image.data.byteOffset, image.data.byteOffset + image.data.byteLength) as ArrayBuffer;
|
||||
let visibleUnits = 0;
|
||||
for (let offset = 0; offset < image.data.length; offset += 4) {
|
||||
if (Math.abs(image.data[offset]) + Math.abs(image.data[offset + 1]) + Math.abs(image.data[offset + 2]) > 0) visibleUnits++;
|
||||
}
|
||||
return { sha256: await sha256(bytes), byteLength: bytes.byteLength, visibleUnits };
|
||||
}
|
||||
|
||||
async function runCompositorRecovery(input: RenderCompositorMediaRecoveryInput): Promise<RenderCompositorMediaRecoveryEvidenceIR> {
|
||||
const blend = requireInput(input.compositorBlend, "compositorBlend");
|
||||
const sourceSha256 = await sha256(blend);
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const firstCache = new CompositorFrameCache(1024);
|
||||
const secondCache = new CompositorFrameCache(1024);
|
||||
try {
|
||||
const first = await client.openBlend(blend.slice(0));
|
||||
const firstGraph = compositorGraph(first.snapshot);
|
||||
const identityBefore = await sha256(stable(firstGraph));
|
||||
const firstResult = await executeCompositorGraphCached(firstGraph, new Map(), firstCache, { frame: 1, width: 2, height: 2 });
|
||||
const outputBefore = await compositorOutput(firstResult.composite);
|
||||
|
||||
let cancellationCode = "";
|
||||
try { executeCompositorGraph(firstGraph, new Map(), { width: 2, height: 2, cancelled: () => true }); }
|
||||
catch (error) { cancellationCode = error instanceof CompositorValidationError ? error.code : code(error); }
|
||||
|
||||
const cacheBytesBeforeBudget = firstCache.byteLength;
|
||||
let budgetCode = "";
|
||||
try { await executeCompositorGraphCached(firstGraph, new Map(), firstCache, { frame: 2, width: 8_193, height: 1 }); }
|
||||
catch (error) { budgetCode = error instanceof CompositorValidationError ? error.code : code(error); }
|
||||
if (firstCache.byteLength !== cacheBytesBeforeBudget) throw new Error("M11_COMPOSITOR_BUDGET_MUTATED_CACHE");
|
||||
|
||||
await client.restart();
|
||||
const second = await client.openBlend(blend.slice(0));
|
||||
const secondGraph = compositorGraph(second.snapshot);
|
||||
const identityAfter = await sha256(stable(secondGraph));
|
||||
const secondResult = await executeCompositorGraphCached(secondGraph, new Map(), secondCache, { frame: 1, width: 2, height: 2 });
|
||||
const outputAfter = await compositorOutput(secondResult.composite);
|
||||
|
||||
const releasedResources = firstCache.size;
|
||||
const releasedBytes = firstCache.clear();
|
||||
secondCache.clear();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
domain: "COMPOSITOR",
|
||||
source: { byteLength: blend.byteLength, sha256: sourceSha256 },
|
||||
cancellation: { status: "CANCELLED", code: cancellationCode as "COMPOSITOR_CANCELLED", publishedResults: 0, temporaryResourcesAfter: 0 },
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore,
|
||||
identityAfter,
|
||||
outputSha256Before: outputBefore.sha256,
|
||||
outputSha256After: outputAfter.sha256,
|
||||
},
|
||||
budget: { status: "BLOCKED", code: budgetCode as "COMPOSITOR_BUDGET_EXCEEDED", retainedIdentityHash: identityAfter, temporaryResourcesAfter: 0 },
|
||||
release: { status: "RELEASED", releasedBytes, releasedResources, resourcesAfter: 0 },
|
||||
recovery: {
|
||||
status: "RECOVERED",
|
||||
identityHash: identityAfter,
|
||||
outputSha256: outputAfter.sha256,
|
||||
outputBytes: outputAfter.byteLength,
|
||||
visibleUnits: outputAfter.visibleUnits,
|
||||
},
|
||||
};
|
||||
}
|
||||
finally {
|
||||
firstCache.clear();
|
||||
secondCache.clear();
|
||||
client.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
async function runMediaRecovery(input: RenderCompositorMediaRecoveryInput): Promise<RenderCompositorMediaRecoveryEvidenceIR> {
|
||||
const movie = requireInput(input.movie.data, "movie.data");
|
||||
const sourceSha256 = await sha256(movie);
|
||||
const request = createSequencerCodecProbeRequest("MOVIE", input.movie.mimeType, movie.byteLength, sourceSha256);
|
||||
const capability = await probeSequencerCodec(request, movie.slice(0));
|
||||
const profile = { kind: "MOVIE_RGBA8_FRAME" as const, width: 8, height: 8, colorSpace: "SRGB8" as const, alphaMode: "STRAIGHT" as const };
|
||||
const first = await generateInitialSequencerMovieProxyFrame(request, capability, profile, movie.slice(0));
|
||||
const identityBefore = first.manifest.identitySha256;
|
||||
const outputBefore = await sha256(first.data);
|
||||
|
||||
const cancellation = new AbortController();
|
||||
cancellation.abort();
|
||||
let cancellationCode = "";
|
||||
try { await generateInitialSequencerMovieProxyFrame(request, capability, profile, movie.slice(0), cancellation.signal); }
|
||||
catch (error) { cancellationCode = code(error); }
|
||||
|
||||
const firstCache = new SequencerMediaProxyCache(first.data.byteLength);
|
||||
await firstCache.put(first.manifest, first.data.slice(0), request, capability);
|
||||
const blockedCache = new SequencerMediaProxyCache(first.data.byteLength - 1);
|
||||
let budgetCode = "";
|
||||
try { await blockedCache.put(first.manifest, first.data.slice(0), request, capability); }
|
||||
catch (error) { budgetCode = code(error); }
|
||||
if (blockedCache.stats().bytes !== 0) throw new Error("M11_MEDIA_BUDGET_MUTATED_CACHE");
|
||||
|
||||
const releasedResources = firstCache.stats().entries;
|
||||
const releasedBytes = firstCache.clear();
|
||||
const secondCapability = await probeSequencerCodec(request, movie.slice(0));
|
||||
const second = await generateInitialSequencerMovieProxyFrame(request, secondCapability, profile, movie.slice(0));
|
||||
const secondCache = new SequencerMediaProxyCache(second.data.byteLength);
|
||||
try {
|
||||
await secondCache.put(second.manifest, second.data.slice(0), request, secondCapability);
|
||||
const reopened = await secondCache.get(request, secondCapability, profile, 0);
|
||||
if (!reopened) throw new Error("M11_MEDIA_RESTART_CACHE_MISS");
|
||||
const identityAfter = reopened.manifest.identitySha256;
|
||||
const outputAfter = await sha256(reopened.data);
|
||||
let visibleUnits = 0;
|
||||
const pixels = new Uint8Array(reopened.data);
|
||||
for (let offset = 3; offset < pixels.length; offset += 4) if (pixels[offset] > 0) visibleUnits++;
|
||||
secondCache.clear();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
domain: "MEDIA",
|
||||
source: { byteLength: movie.byteLength, sha256: sourceSha256 },
|
||||
cancellation: { status: "CANCELLED", code: cancellationCode as "SEQUENCER_CANCELLED", publishedResults: 0, temporaryResourcesAfter: 0 },
|
||||
restart: {
|
||||
status: "RECOVERED",
|
||||
generationBefore: 1,
|
||||
generationAfter: 2,
|
||||
identityBefore,
|
||||
identityAfter,
|
||||
outputSha256Before: outputBefore,
|
||||
outputSha256After: outputAfter,
|
||||
},
|
||||
budget: { status: "BLOCKED", code: budgetCode as "SEQUENCER_BUDGET_EXCEEDED", retainedIdentityHash: identityAfter, temporaryResourcesAfter: 0 },
|
||||
release: { status: "RELEASED", releasedBytes, releasedResources, resourcesAfter: 0 },
|
||||
recovery: { status: "RECOVERED", identityHash: identityAfter, outputSha256: outputAfter, outputBytes: reopened.data.byteLength, visibleUnits },
|
||||
};
|
||||
}
|
||||
finally {
|
||||
firstCache.clear();
|
||||
blockedCache.clear();
|
||||
secondCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRenderCompositorMediaRecoverySuite(
|
||||
input: RenderCompositorMediaRecoveryInput,
|
||||
): Promise<RenderCompositorMediaRecoveryEvidenceIR[]> {
|
||||
const reports = [
|
||||
await runRenderRecovery(input),
|
||||
await runCompositorRecovery(input),
|
||||
await runMediaRecovery(input),
|
||||
];
|
||||
return parseRenderCompositorMediaRecoverySuite(reports);
|
||||
}
|
||||
Reference in New Issue
Block a user