Advance Blender WebEngine N-015 through N-022 parity

This commit is contained in:
mes123456
2026-08-12 13:26:34 -04:00
parent 9fd26010f6
commit b3cefaeec5
51 changed files with 1809 additions and 171 deletions

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const fixture = fs.readFileSync(new URL("tests/files/web/compositor_scene.blend", root));
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function snapshot(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const scene = snapshot(engine, handle).scenes.find((candidate) => candidate.name === "CompositorScene");
assert.equal(scene?.compositorStatus, "AVAILABLE");
const graph = scene.compositorGraph;
assert.equal(graph.schemaVersion, 1);
assert.equal(graph.nodes.length, 4);
assert.equal(graph.links.length, 2);
const color = graph.nodes.find((node) => node.name === "WebConstantColor");
assert.equal(color.type, "CONSTANT_COLOR");
assert.deepEqual(color.properties.color, [0.125, 0.25, 0.5, 0.75]);
assert.equal(graph.nodes.find((node) => node.name === "WebViewer").type, "VIEWER");
const composite = graph.nodes.find((node) => node.name === "WebComposite");
assert.equal(composite.type, "COMPOSITE");
assert.equal(graph.outputNodeId, composite.id);
const unsupported = graph.nodes.find((node) => node.name === "PreservedUnsupportedGlare");
assert.equal(unsupported.type, "UNSUPPORTED");
assert.equal(unsupported.blenderType, "CompositorNodeGlare");
assert.ok(graph.links.every((link) => graph.nodes.some((node) => node.id === link.fromNodeId) &&
graph.nodes.some((node) => node.id === link.toNodeId)));
engine._web_engine_destroy(handle);
process.stdout.write("compositor-main-reader-ok graph-structure=passed socket-links=passed unsupported-preserved=passed\n");

View File

@@ -93,8 +93,22 @@ function assertEdited(scene) {
const light = scene.lights.find((item) => item.id === "light:Area");
const world = scene.worlds.find((item) => item.id === "world:World");
const definition = scene.scenes.find((item) => item.id === "scene:Scene");
assert.equal(camera.projection, "PERSPECTIVE");
assert.equal(camera.depthOfField.enabled, false);
assert.equal(camera.projection, "ORTHOGRAPHIC");
close(camera.lensMm, 35, "camera lens");
close(camera.sensorWidthMm, 32, "camera sensor width");
close(camera.sensorHeightMm, 18, "camera sensor height");
assert.equal(camera.sensorFit, 1);
close(camera.shift[0], 0.1, "camera shift x");
close(camera.shift[1], -0.2, "camera shift y");
close(camera.near, 0.2, "camera near");
close(camera.far, 500, "camera far");
close(camera.orthoScale, 8, "camera ortho scale");
assert.equal(camera.depthOfField.enabled, true);
close(camera.depthOfField.focusDistance, 4.5, "camera focus distance");
close(camera.depthOfField.apertureFStop, 1.8, "camera aperture f-stop");
assert.equal(camera.depthOfField.apertureBlades, 7);
close(camera.depthOfField.apertureRotation, 0.25, "camera aperture rotation");
close(camera.depthOfField.apertureRatio, 1.2, "camera aperture ratio");
close(light.energy, 400, "light energy");
close(light.exposure, 1, "light exposure");
assert.equal(light.castsShadow, false);
@@ -120,6 +134,21 @@ open(engine, handle, fixture);
const before = assertLighting(snapshot(engine, handle));
command(engine, handle, { type: "setObjectVisibility", objectId: "object:BasicCube", visible: true });
assert.equal(snapshot(engine, handle).scenes.find((item) => item.id === before.definition.id).colorManagement.whiteBalanceStatus, "AVAILABLE");
reject(engine, handle, { type: "setCameraProperties", dataId: before.camera.id, properties: { near: 10, far: 1 } }, "RENDER_PROPERTY_INVALID");
assert.equal(snapshot(engine, handle).cameras.find((item) => item.id === before.camera.id).near, before.camera.near);
command(engine, handle, { type: "setCameraProperties", dataId: before.camera.id, properties: {
projection: "ORTHOGRAPHIC", lensMm: 35, sensorWidthMm: 32, sensorHeightMm: 18,
sensorFit: 1, shift: [0.1, -0.2], near: 0.2, far: 500, orthoScale: 8,
depthOfField: { enabled: true, focusDistance: 4.5, apertureFStop: 1.8, apertureBlades: 7, apertureRotation: 0.25, apertureRatio: 1.2 },
} });
let cameraEdited = snapshot(engine, handle);
assert.equal(cameraEdited.scenes.find((item) => item.id === before.definition.id).colorManagement.whiteBalanceStatus, "BLOCKED");
assert.equal(cameraEdited.cameras.find((item) => item.id === before.camera.id).projection, "ORTHOGRAPHIC");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).cameras.find((item) => item.id === before.camera.id).projection, "PERSPECTIVE");
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
cameraEdited = snapshot(engine, handle);
assert.equal(cameraEdited.cameras.find((item) => item.id === before.camera.id).projection, "ORTHOGRAPHIC");
command(engine, handle, { type: "setLightProperties", dataId: before.light.id, properties: {
color: [0.25, 0.5, 0.75], energy: 400, exposure: 1, castsShadow: false,
temperature: 5000, useTemperature: true,
@@ -143,4 +172,4 @@ open(engine, reopened, saved);
assertEdited(snapshot(engine, reopened));
engine._web_engine_destroy(reopened);
process.stdout.write("lighting-roundtrip-ok camera-dof=passed light-shadow-exposure=passed world-mist=passed color-management-reader=passed white-balance-gate=passed undo-redo=passed save-reopen=passed\n");
process.stdout.write("lighting-roundtrip-ok camera-writer-dof=passed light-shadow-exposure=passed world-mist=passed color-management-reader=passed white-balance-gate=passed undo-redo=passed save-reopen=passed\n");

View File

@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const fixture = fs.readFileSync(new URL("tests/files/web/mask_scene.blend", root));
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function snapshot(engine, handle) {
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally { engine._free(dataOut); engine._free(lengthOut); }
}
const close = (actual, expected, label) => assert.ok(Math.abs(actual - expected) < 1e-6,
`${label}: ${actual} != ${expected}`);
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const scene = snapshot(engine, handle);
assert.equal(scene.trackingMaskStatus, "AVAILABLE");
const project = scene.trackingMasks;
assert.equal(project.schemaVersion, 1);
assert.equal(project.clips.length, 0);
assert.equal(project.bindings.length, 0);
assert.equal(project.masks.length, 1);
const mask = project.masks[0];
assert.equal(mask.id, "mask:WebMask");
assert.equal(mask.layers.length, 1);
const layer = mask.layers[0];
assert.equal(layer.name, "WebMaskLayer");
assert.equal(layer.locked, true);
close(layer.opacity, 0.625, "layer opacity");
const spline = layer.splines[0];
assert.equal(spline.cyclic, true);
assert.equal(spline.fill, false);
assert.equal(spline.points.length, 3);
assert.deepEqual(spline.points.map((point) => point.handleType), ["ALIGNED", "VECTOR", "FREE"]);
for (const [label, actual, expected] of [
["point co", spline.points[0].co, [0.1, 0.2]],
["left handle", spline.points[0].handleLeft, [0.2, 0.2]],
["right handle", spline.points[0].handleRight, [0, 0.2]],
]) {
actual.forEach((value, index) => close(value, expected[index], `${label}[${index}]`));
}
close(spline.points[2].feather, 0.75, "point feather");
assert.deepEqual(spline.points.map((point) => point.selected), [true, false, true]);
engine._web_engine_destroy(handle);
process.stdout.write("mask-main-reader-ok layers-splines=passed handles-feather=passed selection=passed\n");

View File

@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const fixture = fs.readFileSync(new URL("tests/files/web/sequencer_scene.blend", root));
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function snapshot(engine, handle) {
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally { engine._free(dataOut); engine._free(lengthOut); }
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const scene = snapshot(engine, handle).scenes.find((candidate) => candidate.name === "SequencerScene");
assert.equal(scene?.sequencerStatus, "AVAILABLE");
const timeline = scene.sequencerTimeline;
assert.equal(timeline.schemaVersion, 1);
assert.equal(timeline.fpsNumerator, 24000);
assert.equal(timeline.fpsDenominator, 1001);
assert.equal(timeline.strips.length, 4);
const sound = timeline.strips.find((strip) => strip.name === "WebSound");
assert.equal(sound.type, "SOUND");
assert.equal(sound.sourcePath, "//media/sequencer-silence.wav");
assert.equal(sound.muted, true);
assert.equal(sound.locked, true);
const image = timeline.strips.find((strip) => strip.name === "WebImage");
assert.equal(image.type, "IMAGE");
assert.equal(image.sourcePath, "//media/sequencer-frame.png");
const cross = timeline.strips.find((strip) => strip.name === "WebCross");
assert.equal(cross.type, "EFFECT");
assert.equal(cross.effectType, "CROSS");
assert.equal(cross.inputStripIds.length, 2);
assert.ok(cross.inputStripIds.every((id) => timeline.strips.some((strip) => strip.id === id)));
engine._web_engine_destroy(handle);
process.stdout.write("sequencer-main-reader-ok media-paths=passed fps=passed effect-dependencies=passed\n");

View File

@@ -0,0 +1,39 @@
import pathlib
import sys
import bpy
def main(output_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.name = "CompositorScene"
tree = bpy.data.node_groups.new("WebCompositorTree", "CompositorNodeTree")
scene.compositing_node_group = tree
tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor")
color = tree.nodes.new("CompositorNodeRGB")
color.name = "WebConstantColor"
color.outputs["Color"].default_value = (0.125, 0.25, 0.5, 0.75)
viewer = tree.nodes.new("CompositorNodeViewer")
viewer.name = "WebViewer"
composite = tree.nodes.new("NodeGroupOutput")
composite.name = "WebComposite"
glare = tree.nodes.new("CompositorNodeGlare")
glare.name = "PreservedUnsupportedGlare"
tree.links.new(color.outputs["Color"], viewer.inputs["Image"])
tree.links.new(color.outputs["Color"], composite.inputs["Image"])
path = pathlib.Path(output_path).resolve()
path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
print(f"compositor-fixture-generated path={path} nodes={len(tree.nodes)} links={len(tree.links)}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python generate-compositor-fixture.py -- output.blend")
main(arguments[0])

View File

@@ -0,0 +1,42 @@
import pathlib
import sys
import bpy
def main(output_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
mask = bpy.data.masks.new("WebMask")
layer = mask.layers.new(name="WebMaskLayer")
layer.alpha = 0.625
layer.hide_select = True
spline = layer.splines.new()
spline.use_cyclic = True
spline.use_fill = False
spline.points.add(2)
values = [
((0.1, 0.2), (0.0, 0.2), (0.2, 0.2), "ALIGNED", 0.25, True),
((0.5, 0.7), (0.4, 0.7), (0.6, 0.7), "VECTOR", 0.5, False),
((0.8, 0.3), (0.7, 0.3), (0.9, 0.3), "FREE", 0.75, True),
]
for point, value in zip(spline.points, values):
co, left, right, handle_type, weight, selected = value
point.co = co
point.handle_left = left
point.handle_right = right
point.handle_type = handle_type
point.weight = weight
point.select = selected
path = pathlib.Path(output_path).resolve()
path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
print(f"mask-fixture-generated path={path} points={len(spline.points)}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python generate-mask-fixture.py -- output.blend")
main(arguments[0])

View File

@@ -0,0 +1,65 @@
import pathlib
import sys
import wave
import bpy
def write_silence(path):
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as stream:
stream.setnchannels(1)
stream.setsampwidth(2)
stream.setframerate(8000)
stream.writeframes(b"\0\0" * 8000)
def main(output_path):
output = pathlib.Path(output_path).resolve()
media = output.parent / "media"
audio_path = media / "sequencer-silence.wav"
image_path = media / "sequencer-frame.png"
write_silence(audio_path)
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("SequencerFrame", width=8, height=8, alpha=True)
image.generated_color = (0.2, 0.4, 0.8, 1.0)
image.filepath_raw = str(image_path)
image.file_format = "PNG"
image.save()
bpy.data.images.remove(image)
scene = bpy.context.scene
scene.name = "SequencerScene"
scene.render.fps = 24
scene.render.fps_base = 1.001
editor = scene.sequence_editor_create()
sound = editor.strips.new_sound("WebSound", str(audio_path), channel=1, frame_start=10)
sound.mute = True
sound.lock = True
sound.sound.filepath = "//media/sequencer-silence.wav"
image_strip = editor.strips.new_image("WebImage", str(image_path), channel=2, frame_start=12)
image_strip.directory = "//media/"
image_strip.elements[0].filename = "sequencer-frame.png"
image_strip.frame_final_duration = 20
second_image = editor.strips.new_image("WebImageB", str(image_path), channel=3, frame_start=12)
second_image.directory = "//media/"
second_image.elements[0].filename = "sequencer-frame.png"
second_image.frame_final_duration = 20
cross = editor.strips.new_effect(
"WebCross", "CROSS", channel=4, frame_start=12, length=20, input1=image_strip, input2=second_image)
cross.mute = True
output.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=False)
print(f"sequencer-fixture-generated path={output} strips={len(editor.strips_all)}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python generate-sequencer-fixture.py -- output.blend")
main(arguments[0])