Complete V1 RC deployment capability gates

This commit is contained in:
mes123456
2026-08-15 01:01:23 -04:00
parent a3f3071c03
commit 17ab961485
37 changed files with 2031 additions and 184 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202124" />
<title>Blender Web Editor</title>
<title>Web Blender Modeler V1</title>
</head>
<body>
<div id="root"></div>

View File

@@ -302,7 +302,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
<div className="axis-gizmo" aria-hidden="true"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span></div>
{viewportError || !snapshot || snapshot.nodes.length === 0 ? <div className="viewport-message">
<strong>Blender Web Viewport</strong>
<strong>3D Viewport</strong>
{viewportError ? <span>WebGL {viewportError}</span> : <span>Three.js WebGL2 </span>}
</div> : null}
<div className="viewport-toolbar" aria-label="视口工具">
@@ -1344,7 +1344,7 @@ export function App() {
return (
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
<header className="topbar">
<div className="brand"><span className="brand-mark" aria-hidden="true"></span><span>Blender Web</span></div>
<div className="brand"><span className="brand-mark" aria-hidden="true"></span><span>Web Blender Modeler V1</span></div>
<nav className="menu-bar" aria-label="主菜单"><button type="button" onClick={() => fileInputRef.current?.click()}></button><button type="button"></button><button type="button"></button><button type="button"></button><button type="button"></button></nav>
<nav className="workspace-tabs" aria-label="工作区">
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
@@ -1360,7 +1360,7 @@ export function App() {
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch commands={operatorCommands} onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
<footer className="status-bar"><span>Blender Web 0.1.0</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
</main>
);
}

View File

@@ -1,9 +1,15 @@
export interface BrowserCapabilities {
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "../../../protocol/capability-gates";
export interface WasmThreadingCapabilities {
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
}
export interface BrowserCapabilities extends WasmThreadingCapabilities {
webgl2: boolean;
offscreenCanvas: boolean;
opfs: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
wasm: boolean;
wasmSimd: boolean;
wasmThreads: boolean;
@@ -11,6 +17,34 @@ export interface BrowserCapabilities {
indexedDb: boolean;
}
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
const issues = [];
if (!capabilities.crossOriginIsolated) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Cross-origin isolation is required for the pthread WASM engine",
"crossOriginIsolated",
));
}
if (!capabilities.sharedArrayBuffer) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"SharedArrayBuffer is required for the pthread WASM engine",
"sharedArrayBuffer",
));
}
if (!capabilities.worker) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Worker is required for the pthread WASM engine",
"worker",
));
}
return issues.length > 0
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
}
function supportsWasmFeature(feature: "simd" | "threads"): boolean {
if (typeof WebAssembly === "undefined") return false;
@@ -40,6 +74,7 @@ export function detectBrowserCapabilities(): BrowserCapabilities {
const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
return {
crossOriginIsolated: globalThis.crossOriginIsolated === true,
webgl2,
offscreenCanvas: typeof OffscreenCanvas !== "undefined",
opfs: Boolean(storage?.getDirectory),

View File

@@ -135,6 +135,10 @@ export class StorageClient {
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
}
getPendingRequestCount(): number {
return this.pending.size;
}
private request(command: StorageRequest["command"], transfer: Transferable[] = []): Promise<NonNullable<StorageResponse["result"]>> {
const requestId = `storage-${++this.counter}`;
const request: StorageRequest = { requestId, command };

View File

@@ -18,7 +18,7 @@ type Request =
type Response =
| { type: "ready"; index: { stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number } }
| { type: "seekResult"; requestId: string; result: LongMediaSeekResultIR }
| { type: "disposed"; cacheBytes: number }
| { type: "disposed"; releasedCacheBytes: number; cacheBytesAfter: number }
| { type: "error"; requestId?: string; message: string };
const scope = self as unknown as {
@@ -71,9 +71,9 @@ scope.onmessage = (event): void => {
return;
}
if (message.type === "cancel") { session?.cancel(); return; }
const cacheBytes = session?.cache.stats().bytes ?? 0;
const releasedCacheBytes = session?.cache.stats().bytes ?? 0;
session?.dispose();
session = null;
assets.clear();
scope.postMessage({ type: "disposed", cacheBytes });
scope.postMessage({ type: "disposed", releasedCacheBytes, cacheBytesAfter: 0 });
};

View File

@@ -4,12 +4,13 @@ import react from "@vitejs/plugin-react";
import fs from "node:fs";
// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types.
import path from "node:path";
// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types.
import { fileURLToPath } from "node:url";
const isolationHeaders = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Resource-Policy": "same-origin",
};
const deploymentContract = JSON.parse(
fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../docs/web/deployment-contract.json"), "utf8"),
) as { responseHeaders: { allResponses: Record<string, string> } };
const isolationHeaders = deploymentContract.responseHeaders.allResponses;
function preserveIsolationHeaders(): Plugin {
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string) => void }, next: () => void) => void) => void } }) => {

View File

@@ -1,6 +1,10 @@
cmake_minimum_required(VERSION 3.24)
project(blender_web_engine LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(WITH_WEB ON CACHE BOOL "Build the browser Blender engine")
option(WEB_ENGINE_THREADS "Build the pthread variant" OFF)
include("${CMAKE_CURRENT_LIST_DIR}/../../blender-5.2.0/build_files/cmake/platform/web.cmake")
@@ -16,6 +20,7 @@ add_library(blender_web_engine_core STATIC
)
target_include_directories(blender_web_engine_core PUBLIC
"${CMAKE_CURRENT_LIST_DIR}/../../blender-5.2.0/source/blender/web_engine"
"${CMAKE_CURRENT_LIST_DIR}/../../blender-5.2.0/extern/json/include"
)
add_executable(web_engine web_engine_smoke.cpp)

View File

@@ -64,6 +64,7 @@ bool web_engine_blend_main_create_primitive(WebBlendMainState *,
bool web_engine_blend_main_duplicate_object(WebBlendMainState *,
const char *,
const std::vector<float> &,
bool,
std::string &,
std::string &error)
{
@@ -98,6 +99,270 @@ bool web_engine_blend_main_translate_vertices(WebBlendMainState *,
return false;
}
namespace {
bool native_main_unavailable(std::string &error)
{
error = "native smoke Main stub";
return false;
}
} // namespace
#define WEB_NATIVE_MAIN_STUB(name, ...) \
bool name(__VA_ARGS__, std::string &error) \
{ \
return native_main_unavailable(error); \
}
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_modifier_visibility,
WebBlendMainState *,
const char *,
const char *,
bool,
bool,
bool,
bool)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_sculpt_attributes,
WebBlendMainState *,
const char *,
const std::vector<float> &,
const std::vector<uint32_t> &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_sculpt_stroke, WebBlendMainState *, const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_mesh_edit,
WebBlendMainState *,
const char *,
const char *,
const char *,
const std::vector<uint32_t> &,
const std::vector<float> &,
float,
int)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_create_uv_map, WebBlendMainState *, const char *, const char *)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_set_active_uv_map, WebBlendMainState *, const char *, const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_unwrap_uv,
WebBlendMainState *,
const char *,
const std::vector<uint32_t> &,
const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_add_material_slot,
WebBlendMainState *,
const char *,
const char *,
std::string &)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_remove_material_slot, WebBlendMainState *, const char *, int)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_assign_material_faces,
WebBlendMainState *,
const char *,
const std::vector<uint32_t> &,
int)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_material_principled,
WebBlendMainState *,
const char *,
const std::vector<float> &,
float,
float,
const std::vector<float> &,
float,
float,
float,
float,
float,
float,
float)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_import_image,
WebBlendMainState *,
const char *,
const char *,
int,
int,
const char *,
std::string &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_material_image,
WebBlendMainState *,
const char *,
const char *,
const char *,
const char *)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_set_shader_graph, WebBlendMainState *, const char *, const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_insert_keyframe,
WebBlendMainState *,
const char *,
int,
const char *,
const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_delete_keyframe,
WebBlendMainState *,
const char *,
int,
const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_fcurve_interpolation,
WebBlendMainState *,
const char *,
const char *,
const char *)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_set_active_action, WebBlendMainState *, const char *, const char *)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_set_nla_stack, WebBlendMainState *, const char *, const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_constraint,
WebBlendMainState *,
const char *,
const char *,
bool,
float)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_set_parent, WebBlendMainState *, const char *, const char *, bool)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_create_collection,
WebBlendMainState *,
const char *,
const char *,
std::string &)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_move_to_collection, WebBlendMainState *, const char *, const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_rename_id,
WebBlendMainState *,
const char *,
const char *,
std::string &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_join_objects,
WebBlendMainState *,
const char *,
const std::vector<std::string> &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_separate_faces,
WebBlendMainState *,
const char *,
const std::vector<uint32_t> &,
const char *,
std::string &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_apply_transform, WebBlendMainState *, const char *)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_set_origin, WebBlendMainState *, const char *, const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_create_curve,
WebBlendMainState *,
const char *,
const char *,
const std::vector<float> &,
bool,
int,
std::string &,
std::string &)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_delete_non_mesh_data, WebBlendMainState *, const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_curve_control_points,
WebBlendMainState *,
const char *,
const std::vector<float> &,
const std::vector<uint32_t> &,
int)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_curve_handle,
WebBlendMainState *,
const char *,
uint32_t,
bool,
const std::vector<float> &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_curve_topology,
WebBlendMainState *,
const char *,
const WebCurveTopologyEdit &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_curve_splines,
WebBlendMainState *,
const char *,
const std::vector<WebCurveSplineEdit> &,
const std::vector<float> &,
const std::vector<float> &,
const std::vector<int> &,
const std::vector<float> &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_surface_topology,
WebBlendMainState *,
const char *,
const std::vector<WebSurfaceSplineEdit> &,
const std::vector<float> &,
const std::vector<float> &)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_set_font_body, WebBlendMainState *, const char *, const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_font_properties,
WebBlendMainState *,
const char *,
const WebFontPropertiesEdit &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_font_advanced,
WebBlendMainState *,
const char *,
const std::vector<WebFontCharacterEdit> &,
const std::vector<WebFontTextBoxEdit> &,
int)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_font_links,
WebBlendMainState *,
const char *,
const std::array<std::string, 4> &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_volume_properties,
WebBlendMainState *,
const char *,
const WebVolumePropertiesEdit &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_metaball_elements,
WebBlendMainState *,
const char *,
const std::vector<WebMetaballElementEdit> &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_grease_pencils_json,
WebBlendMainState *,
std::string &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_physics_simulation_json,
WebBlendMainState *,
std::string &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_create_grease_pencil_layer,
WebBlendMainState *,
const char *,
const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_remove_grease_pencil_layer,
WebBlendMainState *,
const char *,
const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_move_grease_pencil_layer,
WebBlendMainState *,
const char *,
const char *,
const char *)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_insert_grease_pencil_frame,
WebBlendMainState *,
const char *,
const char *,
int,
int)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_remove_grease_pencil_frame,
WebBlendMainState *,
const char *,
const char *,
int)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_grease_pencil_strokes,
WebBlendMainState *,
const char *,
const char *,
int,
const std::vector<WebGreasePencilStrokeEdit> &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_vertex_colors,
WebBlendMainState *,
const char *,
const char *,
const char *,
const std::vector<uint32_t> &,
const std::vector<float> &)
WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_vertex_weights,
WebBlendMainState *,
const char *,
const char *,
const std::vector<uint32_t> &,
const std::vector<float> &,
bool,
bool)
WEB_NATIVE_MAIN_STUB(
web_engine_blend_main_set_render_properties, WebBlendMainState *, const char *, const char *)
#undef WEB_NATIVE_MAIN_STUB
bool web_engine_blend_main_write(WebBlendMainState *, std::vector<uint8_t> &, std::string &error)
{
error = "native smoke Main stub";

6
web/package-lock.json generated
View File

@@ -1533,9 +1533,9 @@
"dev": true
},
"node_modules/nanoid": {
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{

View File

@@ -84,7 +84,11 @@
"release:evidence-long-media": "node ../tools/web/collect-release-evidence.mjs --record-long-media",
"release:evidence-oom": "node ../tools/web/collect-release-evidence.mjs --record-oom-recovery",
"test:release-evidence": "node ../tools/web/check-release-evidence.mjs",
"test:v1-acceptance-coverage": "node ../tools/web/check-v1-acceptance-coverage.mjs",
"test:v1-acceptance-evidence": "node ../tools/web/check-v1-acceptance-evidence.mjs",
"test:v1-acceptance-release-binding": "node ../tools/web/check-v1-acceptance-release-binding.mjs",
"test:status-consistency": "node ../tools/web/check-status-consistency.mjs",
"release:v1-acceptance": "node ../tools/web/run-v1-acceptance.mjs",
"release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs",
"diagnose:depsgraph": "node ../tools/web/diagnose-depsgraph.mjs"
},

View File

@@ -8,7 +8,7 @@ export interface CapabilityIssue {
}
export interface CapabilityGateResult {
taskId: "N-011" | "N-012" | "N-013" | "N-014" | "N-015" | "N-018" | "N-020" | "N-021" | "N-022" | "N-023" | "N-024" | "N-025" | "N-026" | "PBR-007" | "PBR-008" | "PBR-009" | "PBR-010" | "PBR-011" | "PBR-012";
taskId: "M6-02" | "N-011" | "N-012" | "N-013" | "N-014" | "N-015" | "N-018" | "N-020" | "N-021" | "N-022" | "N-023" | "N-024" | "N-025" | "N-026" | "PBR-007" | "PBR-008" | "PBR-009" | "PBR-010" | "PBR-011" | "PBR-012";
capability: string;
status: "READY" | "BLOCKED";
issues: CapabilityIssue[];

View File

@@ -319,11 +319,13 @@ test("streams ten-million-triangle SceneIR ranges into a bounded interactive LOD
const recoveryVisible = visiblePixels(recovery);
recovery.dispose();
recoveryCanvas.remove();
const afterHeapBytes = heap();
return {
elapsedMs: Math.round(performance.now() - started),
baselineHeapBytes,
peakHeapBytes,
afterHeapBytes,
wasmAllocatedBytes: wasm.allocatedBytes,
invalidCode,
cancelled,
@@ -342,6 +344,11 @@ test("streams ten-million-triangle SceneIR ranges into a bounded interactive LOD
chunksAtFirstInteractive: result.completed.viewport?.chunksAtFirstInteractive,
estimatedGpuBytes: result.completed.viewport?.estimatedGpuBytes,
wasmAllocatedBytes: result.wasmAllocatedBytes,
baselineHeapBytes: result.baselineHeapBytes,
peakHeapBytes: result.peakHeapBytes,
afterHeapBytes: result.afterHeapBytes,
cacheDeleted: result.completed.viewport?.cache.deleted,
recoveryVisible: result.recoveryVisible,
cancelLatencyMs: result.cancelled.cancelLatencyMs,
elapsedMs: result.elapsedMs,
}));

View File

@@ -76,7 +76,7 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
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>;
dispose(): Promise<{ releasedCacheBytes: number; cacheBytesAfter: number }>;
terminate(): void;
}
const createWorkerClient = (): WorkerClient => {
@@ -84,12 +84,12 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
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;
let disposeResolve: ((status: { releasedCacheBytes: number; cacheBytesAfter: number }) => void) | undefined;
const pending = new Map<string, { resolve: (value: import("/src/sequencer/LongMediaTimeline.ts").LongMediaSeekResultIR) => void; reject: (reason: Error) => void }>();
worker.onmessage = (event: MessageEvent<any>) => {
const message = event.data;
if (message.type === "ready") { readyResolve?.(message.index); readyResolve = undefined; readyReject = undefined; return; }
if (message.type === "disposed") { disposeResolve?.(message.cacheBytes); disposeResolve = undefined; return; }
if (message.type === "disposed") { disposeResolve?.({ releasedCacheBytes: message.releasedCacheBytes, cacheBytesAfter: message.cacheBytesAfter }); disposeResolve = undefined; return; }
if (message.type === "error") {
if (message.requestId) { pending.get(message.requestId)?.reject(new Error(message.message)); pending.delete(message.requestId); }
else { readyReject?.(new Error(message.message)); readyResolve = undefined; readyReject = undefined; }
@@ -164,7 +164,7 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
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();
const disposed = await client.dispose();
client.terminate();
writer.terminate();
@@ -180,7 +180,7 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
{ sourceId: "media:audio", data: reopenedAudio.data },
]);
const reopenedSeek = await restarted.seek(reopenedManifest.currentFrame);
const restartedCacheBytes = await restarted.dispose();
const restartedDisposed = await restarted.dispose();
restarted.terminate();
const codec = gateSequencerCodec("video/mp4", new Set(["image/png", "audio/wav"]));
@@ -207,8 +207,8 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
randomSeekMs,
latest: [superseded.status, latest.status, latest.frame],
cancelledSeek: cancelledSeek.status,
cacheBytesBeforeDispose,
restartedCacheBytes,
disposed,
restartedDisposed,
manifestSha256,
storedSessionSha256: storedSession.sha256,
reopenedFrame: reopenedSeek.frame,
@@ -230,6 +230,11 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
randomSeekMs: result.randomSeekMs,
cacheBytes: result.randomCache?.bytes,
evictions: result.randomCache?.evictions,
releasedCacheBytes: result.disposed.releasedCacheBytes,
cacheBytesAfterDispose: result.disposed.cacheBytesAfter,
restartedReleasedCacheBytes: result.restartedDisposed.releasedCacheBytes,
restartedCacheBytesAfterDispose: result.restartedDisposed.cacheBytesAfter,
workerRestartRecovered: result.reopenedStatus === "COMPLETED",
manifestSha256: result.manifestSha256,
elapsedMs: result.elapsedMs,
}));
@@ -264,8 +269,10 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
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.disposed.releasedCacheBytes).toBeLessThanOrEqual(20 * 1024);
expect(result.disposed.cacheBytesAfter).toBe(0);
expect(result.restartedDisposed.releasedCacheBytes).toBeLessThanOrEqual(20 * 1024);
expect(result.restartedDisposed.cacheBytesAfter).toBe(0);
expect(result.manifestSha256).toBe(result.storedSessionSha256);
expect(result.manifestSha256).toBe("d2e7e3c5ed9ae4fda6fe358cebb474f5774f1d2037dff3df9b6dfde9e0bebd2d");
expect(result.elapsedMs).toBeLessThan(30_000);

View File

@@ -54,6 +54,7 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
const saved = await writer.saveProject(projectId, 1, sourceBlend.slice(0));
const stored = await writer.putSimulationCache(projectId, manifest, payload);
writer.terminate();
const writerPendingAfterTerminate = writer.getPendingRequestCount();
const storedAt = performance.now();
const reader = new StorageClient();
@@ -87,6 +88,11 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
const playbackResult = await playback.play();
const finished = performance.now();
reader.terminate();
const readerPendingAfterTerminate = reader.getPendingRequestCount();
const recovery = new StorageClient();
const recovered = await recovery.readSimulationCacheFrame(projectId, stored.cacheKey, frameCount);
recovery.terminate();
const recoveryPendingAfterTerminate = recovery.getPendingRequestCount();
return {
backend: saved.backend,
frameCount,
@@ -99,12 +105,18 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
storeMs: Math.round(storedAt - started),
playbackMs: Math.round(finished - storedAt),
elapsedMs: Math.round(finished - started),
peakCacheBytes: manifest.byteLength,
pendingRequestsAfterTerminate: writerPendingAfterTerminate + readerPendingAfterTerminate + recoveryPendingAfterTerminate,
workerRestartRecovered: recovered.data.byteLength === frameBytes,
};
});
console.log("simulation-cache-performance", JSON.stringify(result));
expect(result.backend).toBe("opfs");
expect(result).toMatchObject({ frameCount: 600, byteLength: 52_800, status: "COMPLETED", appliedFrames: 600, lastFrame: 600, publishedFrames: 600 });
expect(result.lastTranslation).toBeCloseTo(60, 5);
expect(result.storeMs).toBeLessThan(30_000);
expect(result.playbackMs).toBeLessThan(30_000);
expect(result.elapsedMs).toBeLessThan(30_000);
expect(result.pendingRequestsAfterTerminate).toBe(0);
expect(result.workerRestartRecovered).toBe(true);
});

View File

@@ -48,7 +48,7 @@ test("opens the Blender-style editor and initializes workers", async ({ page })
await page.goto("/");
appOrigin = new URL(page.url()).origin;
await expect(page.getByText("Blender Web", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Web Blender Modeler V1", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Layout" })).toHaveClass(/active/);
await expect(page.locator(".status-bar")).toContainText("Manifest: verified r1");
await expect(page.locator(".status-bar")).toContainText("Engine: ready, open a .blend file", { timeout: 20_000 });
@@ -2291,7 +2291,7 @@ test("undoes and redoes native scene commands without a Three.js shadow state",
expect(result.redoRevision).toBe(4);
});
test("wires Blender Web top-bar undo and redo to native history", async ({ page }) => {
test("wires Web Blender Modeler top-bar undo and redo to native history", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();

View File

@@ -23,15 +23,27 @@ test("decodes, uploads and renders a validated 4K texture in Chromium", async ({
const renderer = new WebGLRenderer({ canvas, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false);
const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
const material = new MeshBasicMaterial({ map: store.get("image:4k", "BASE_COLOR") });
scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera);
const geometry = new PlaneGeometry(2, 2);
scene.add(new Mesh(geometry, material)); renderer.render(scene, camera);
const gl = renderer.getContext(); const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60) colored += 1;
const elapsedMs = Math.round(performance.now() - started);
material.dispose(); renderer.dispose(); store.dispose(); canvas.remove();
return { status, byteLength: data.byteLength, colored, elapsedMs };
material.dispose(); geometry.dispose(); renderer.dispose(); store.dispose(); canvas.remove();
const released = store.get("image:4k", "BASE_COLOR") === undefined;
const recoveryCanvas = document.createElement("canvas"); recoveryCanvas.width = 16; recoveryCanvas.height = 16; document.body.append(recoveryCanvas);
const recoveryRenderer = new WebGLRenderer({ canvas: recoveryCanvas, preserveDrawingBuffer: true }); recoveryRenderer.setSize(16, 16, false);
const recoveryScene = new Scene(); const recoveryMaterial = new MeshBasicMaterial({ color: 0x33cc66 }); const recoveryGeometry = new PlaneGeometry(2, 2);
recoveryScene.add(new Mesh(recoveryGeometry, recoveryMaterial)); recoveryRenderer.render(recoveryScene, camera);
const recoveryPixels = new Uint8Array(16 * 16 * 4); recoveryRenderer.getContext().readPixels(0, 0, 16, 16, recoveryRenderer.getContext().RGBA, recoveryRenderer.getContext().UNSIGNED_BYTE, recoveryPixels);
const recoveryVisible = recoveryPixels.some((value, index) => index % 4 !== 3 && value > 40);
recoveryMaterial.dispose(); recoveryGeometry.dispose(); recoveryRenderer.dispose(); recoveryCanvas.remove();
return { width: 4096, height: 4096, status, byteLength: data.byteLength, estimatedPeakGpuBytes: 4096 * 4096 * 4, released, recoveryVisible, colored, elapsedMs };
});
console.log("texture-4k-performance", JSON.stringify(result));
expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength });
expect(result.byteLength).toBeGreaterThan(0);
expect(result.colored).toBeGreaterThan(3_000);
expect(result.elapsedMs).toBeLessThan(30_000);
expect(result.released).toBe(true);
expect(result.recoveryVisible).toBe(true);
});

View File

@@ -21,16 +21,27 @@ test("decodes, uploads and renders a validated 8K texture in Chromium", async ({
const asset = await createGPUTextureAsset({ assetId: "asset:8k", imageId: "image:8k", mimeType: "image/png", width: 8192, height: 8192, usage: "BASE_COLOR", colorSpace: "SRGB" }, data);
const started = performance.now(); const store = new GPUTextureStore(); const status = await store.upload([asset]);
const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
const material = new MeshBasicMaterial({ map: store.get("image:8k", "BASE_COLOR") }); scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera);
const material = new MeshBasicMaterial({ map: store.get("image:8k", "BASE_COLOR") }); const geometry = new PlaneGeometry(2, 2); scene.add(new Mesh(geometry, material)); renderer.render(scene, camera);
const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60 || pixels[index + 2] > 60) colored += 1;
const elapsedMs = Math.round(performance.now() - started);
material.dispose(); renderer.dispose(); store.dispose(); target.remove();
return { supported: true, maxTextureSize, status, byteLength: data.byteLength, colored, elapsedMs };
material.dispose(); geometry.dispose(); renderer.dispose(); store.dispose(); target.remove();
const released = store.get("image:8k", "BASE_COLOR") === undefined;
const recoveryCanvas = document.createElement("canvas"); recoveryCanvas.width = 16; recoveryCanvas.height = 16; document.body.append(recoveryCanvas);
const recoveryRenderer = new WebGLRenderer({ canvas: recoveryCanvas, preserveDrawingBuffer: true }); recoveryRenderer.setSize(16, 16, false);
const recoveryScene = new Scene(); const recoveryMaterial = new MeshBasicMaterial({ color: 0x3366cc }); const recoveryGeometry = new PlaneGeometry(2, 2);
recoveryScene.add(new Mesh(recoveryGeometry, recoveryMaterial)); recoveryRenderer.render(recoveryScene, camera);
const recoveryPixels = new Uint8Array(16 * 16 * 4); recoveryRenderer.getContext().readPixels(0, 0, 16, 16, recoveryRenderer.getContext().RGBA, recoveryRenderer.getContext().UNSIGNED_BYTE, recoveryPixels);
const recoveryVisible = recoveryPixels.some((value, index) => index % 4 !== 3 && value > 40);
recoveryMaterial.dispose(); recoveryGeometry.dispose(); recoveryRenderer.dispose(); recoveryCanvas.remove();
return { supported: true, maxTextureSize, width: 8192, height: 8192, status, byteLength: data.byteLength, estimatedPeakGpuBytes: 8192 * 8192 * 4, released, recoveryVisible, colored, elapsedMs };
});
console.log("texture-8k-performance", JSON.stringify(result));
expect(result.supported, `WebGL MAX_TEXTURE_SIZE=${result.maxTextureSize}`).toBe(true);
expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength });
expect(result.byteLength).toBeGreaterThan(0);
expect(result.colored).toBeGreaterThan(3_000);
expect(result.elapsedMs).toBeLessThan(45_000);
expect(result.released).toBe(true);
expect(result.recoveryVisible).toBe(true);
});

View File

@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
test("gates only the pthread WASM engine on its three platform requirements", async ({ page }) => {
await page.goto("/");
const gates = await page.evaluate(async () => {
const { gateWasmThreadingCapability } = await import("/src/platform/capabilities.ts");
return {
ready: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: true, worker: true }),
noIsolation: gateWasmThreadingCapability({ crossOriginIsolated: false, sharedArrayBuffer: true, worker: true }),
noSharedArrayBuffer: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: false, worker: true }),
noWorker: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: true, worker: false }),
none: gateWasmThreadingCapability({ crossOriginIsolated: false, sharedArrayBuffer: false, worker: false }),
};
});
expect(gates.ready).toEqual({
taskId: "M6-02",
capability: "WASM_PTHREAD_ENGINE",
status: "READY",
issues: [],
});
expect(gates.noIsolation.issues.map(({ code, path }) => ({ code, path }))).toEqual([
{ code: "PLATFORM_CAPABILITY_UNAVAILABLE", path: "crossOriginIsolated" },
]);
expect(gates.noSharedArrayBuffer.issues.map(({ code, path }) => ({ code, path }))).toEqual([
{ code: "PLATFORM_CAPABILITY_UNAVAILABLE", path: "sharedArrayBuffer" },
]);
expect(gates.noWorker.issues.map(({ code, path }) => ({ code, path }))).toEqual([
{ code: "PLATFORM_CAPABILITY_UNAVAILABLE", path: "worker" },
]);
expect(gates.none).toMatchObject({
taskId: "M6-02",
capability: "WASM_PTHREAD_ENGINE",
status: "BLOCKED",
});
expect(gates.none.issues.map(({ code, path }) => ({ code, path }))).toEqual([
{ code: "PLATFORM_CAPABILITY_UNAVAILABLE", path: "crossOriginIsolated" },
{ code: "PLATFORM_CAPABILITY_UNAVAILABLE", path: "sharedArrayBuffer" },
{ code: "PLATFORM_CAPABILITY_UNAVAILABLE", path: "worker" },
]);
});

View File

@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildAcceptancePlan, readAcceptanceInputs } from "../../../tools/web/v1-acceptance-lib.mjs";
const inputs = readAcceptanceInputs();
function clonedInputs() {
return {
ledger: structuredClone(inputs.ledger),
packageManifest: structuredClone(inputs.packageManifest),
};
}
test("V1 acceptance declarations resolve to executable Web commands", () => {
const plan = buildAcceptancePlan(inputs.ledger, inputs.packageManifest);
assert.deepEqual(
{ families: plan.familyCount, declarations: plan.declarationCount, unique: plan.uniqueCount },
{ families: 12, declarations: 50, unique: 49 },
);
});
test("V1 acceptance rejects an unknown namespace", () => {
const { ledger, packageManifest } = clonedInputs();
ledger.families[0].acceptance[0] = "desktop:test:unknown";
assert.throws(() => buildAcceptancePlan(ledger, packageManifest), /unsupported acceptance namespace/);
});
test("V1 acceptance rejects a missing package script", () => {
const { ledger, packageManifest } = clonedInputs();
delete packageManifest.scripts["test:nonmesh-roundtrip"];
assert.throws(() => buildAcceptancePlan(ledger, packageManifest), /package script test:nonmesh-roundtrip is missing/);
});
test("V1 acceptance rejects an empty E2E pattern", () => {
const { ledger, packageManifest } = clonedInputs();
ledger.families[0].acceptance[0] = "web:e2e: ";
assert.throws(() => buildAcceptancePlan(ledger, packageManifest), /E2E pattern must not be empty/);
});
test("V1 acceptance rejects duplicate declarations within one family", () => {
const { ledger, packageManifest } = clonedInputs();
ledger.families[0].acceptance.push(ledger.families[0].acceptance[0]);
assert.throws(() => buildAcceptancePlan(ledger, packageManifest), /duplicate acceptance token/);
});