import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import createEngine from '../build_web_blender6/bin/web_engine.js'; const publishDiagnostic = value => { const routes = { fcurve_explicit_handles_persist: ['setFCurveKeyframes', 'web_engine_blend_main_set_fcurve_keyframes'], driver_is_registered_on_object: ['setDriverExpression', 'web_engine_blend_main_set_driver_expression'], action_group_survives_desktop_read: ['setActionGroups', 'web_engine_blend_main_set_action_groups'], keying_path_owns_object_property: ['setKeyingSet', 'web_engine_blend_main_set_keying_set'], bake_samples_evaluated_animation: ['bakeAnimation', 'web_engine_blend_main_bake_animation'], }; if (process.env.WBV2_RECEIPT_DIR) { fs.mkdirSync(process.env.WBV2_RECEIPT_DIR, { recursive: true }); const findings = (value.checks ?? []).filter(check => !check.ok).map(check => ({ assertion: check.name, message: check.message, sourceUnit: routes[check.name] ? 'web-command:' + routes[check.name][0] : null, sourcePath: 'blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp', symbol: routes[check.name]?.[1], })); if (!value.ok && !findings.length) findings.push({ assertion: 'native_execution', message: value.error }); fs.writeFileSync(path.join(process.env.WBV2_RECEIPT_DIR, 'native-business-diagnostic.json'), JSON.stringify({ schemaVersion: 1, job: 'ANIMATION-FCURVE-DRIVER-COVERAGE', checkId: 'native-animation', status: value.ok ? 'PASS' : 'FAIL', findings, stages: value.stages, reproduce: { cwd: '.', argv: ['node', 'my-tools/diagnose_local_animation_authoring.mjs', '--indexed-path'] }, }, null, 2) + '\n'); } console.log(JSON.stringify(value)); }; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wbv2-animation-native-')); const stages = []; const record = (stage, started) => stages.push({ stage, seconds: (performance.now() - started) / 1000 }); const log = (message) => fs.appendFileSync(path.join(directory, 'native.log'), `${message}\n`); const started = performance.now(); const engine = await createEngine({ wasmBinary: fs.readFileSync(path.join(root, 'build_web_blender6/bin/web_engine.wasm')), print: log, printErr: log, }); record('instantiate', started); const handle = engine._web_engine_create(); const pointers = engine._malloc(8); const output = () => { const pointer = engine.HEAPU32[pointers >> 2]; const length = engine.HEAPU32[(pointers + 4) >> 2]; return engine.HEAPU8.slice(pointer, pointer + length); }; const call = (stage, data) => { const begin = performance.now(); const pointer = data ? engine._malloc(data.length) : 0; try { if (data) engine.HEAPU8.set(data, pointer); const code = stage === 'open' ? engine._web_engine_open_blend(handle, pointer, data.length) : stage === 'snapshot' ? engine._web_engine_get_scene_snapshot(handle, pointers, pointers + 4) : engine._web_engine_call_json(handle, pointer, data.length, pointers, pointers + 4); if (code !== 0) throw new Error(`${stage}: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`); return stage === 'open' ? null : JSON.parse(new TextDecoder().decode(output())); } finally { if (pointer) engine._free(pointer); record(stage, begin); } }; const edit = (command, expected = true) => { const result = call(command.type, Buffer.from(JSON.stringify({ cmd: 'applyCommand', params: { command } }))); assert.equal(result.ok, expected, `${command.type}: ${JSON.stringify(result.error ?? result)}`); return result; }; const save = (name) => { const begin = performance.now(); assert.equal(engine._web_engine_save_blend(handle, pointers, pointers + 4), 0); const file = path.join(directory, `${name}.blend`); fs.writeFileSync(file, output()); engine._web_engine_free_buffer(engine.HEAPU32[pointers >> 2]); record(`save:${name}`, begin); return file; }; try { call('open', fs.readFileSync(path.join(root, 'tests/files/web/animation_scene.blend'))); const initial = call('snapshot'); if (process.argv.includes('--inspect')) { publishDiagnostic({ ok: true, directory, stages, animations: initial.animations, scenes: initial.scenes?.map(({ id, name, keyingSets }) => ({ id, name, keyingSets })), objects: initial.nodes?.map(({ id, name, animationId }) => ({ id, name, animationId })) }); } else { const animation = initial.animations.find((item) => item.targetId === 'object:AnimatedObject'); assert(animation); const scene = initial.scenes.find((item) => item.name === 'Scene'); const files = { initial: save('initial') }; const command = { type: 'setFCurveKeyframes', animationId: animation.id, path: 'location[0]', points: [ { frame: 1, value: 0, handleLeft: [0, -1], handleRight: [2, 1], interpolation: 'BEZIER' }, { frame: 5, value: 4, handleLeft: [4, 3], handleRight: [6, 5], interpolation: 'BEZIER' }, ], }; edit(command); files.points = save('points'); edit({ type: 'setDriverExpression', animationId: animation.id, path: 'location[1]', expression: 'frame * 0.5' }); edit({ type: "setFrame", frame: 3 }); assert.equal(call("snapshot").nodes.find(node => node.name === "AnimatedObject").transform.translation[1], 1.5, "setFrame must publish evaluated driver values"); edit({ type: "setFrame", frame: 1 }); edit({ type: "setDriverExpression", animationId: animation.id, path: "location[1]", expression: "frame * 0.25" }); assert.equal(call("snapshot").nodes.find(node => node.name === "AnimatedObject").transform.translation[1], 0.25, "edited driver expressions must invalidate cached evaluation"); edit({ type: "setDriverExpression", animationId: animation.id, path: "location[1]", expression: "frame * 0.5" }); files.driver = save('driver'); edit({ type: 'setActionGroups', animationId: animation.id, groups: [{ name: 'Web Motion', paths: ['location[0]', 'location[1]'] }] }); files.groups = save('groups'); edit({ type: 'setKeyingSet', sceneId: scene.id, name: 'Web Location', paths: [{ path: (process.argv.includes("--indexed-path") ? "location[0]" : "location"), arrayIndex: 0 }], active: true }); files.keying = save('keying'); edit({ type: 'bakeAnimation', objectId: animation.targetId, frameStart: 1, frameEnd: 5, step: 2 }); files.bake = save('bake'); const negative = [ { ...command, path: 'missing_property[0]' }, { type: 'setDriverExpression', animationId: animation.id, path: 'location[1]', expression: 'x'.repeat(256) }, { type: 'setActionGroups', animationId: animation.id, groups: [{ name: 'Invalid', paths: ['missing_property[0]'] }] }, { type: 'setKeyingSet', sceneId: scene.id, name: 'Invalid', paths: [{ path: 17, arrayIndex: 0 }], active: true }, { type: 'bakeAnimation', objectId: animation.targetId, frameStart: 5, frameEnd: 1, step: 1 }, ]; const accepted = call('snapshot'); for (const rejected of negative) { edit(rejected, false); assert.deepEqual(call('snapshot'), accepted, `${rejected.type} rejected without mutation`); } files.negative = save('negative'); const request = { files, output: path.join(directory, 'main-snapshots.json') }; const requestPath = path.join(directory, 'readback-request.json'); fs.writeFileSync(requestPath, JSON.stringify(request)); const begin = performance.now(); const reader = spawnSync(process.env.BLENDER_BIN || path.join(root, 'build_blender_native/bin/blender'), [ '--background', '--factory-startup', '--python-exit-code', '1', '--python', path.join(root, 'web/tests/fixtures/animation-authoring-main-snapshots.py'), '--', requestPath, ], { cwd: root, encoding: 'utf8', timeout: 20000 }); fs.writeFileSync(path.join(directory, 'desktop.log'), `${reader.stdout || ''}\n${reader.stderr || ''}`); record('desktop_readback', begin); assert.equal(reader.status, 0, 'Desktop Main reader failed; details remain in desktop.log'); const main = JSON.parse(fs.readFileSync(request.output, 'utf8')); assert.deepEqual(main.negative, main.bake, 'Rejected commands preserve saved native Main'); const checks = []; const verify = (name, fn) => { try { fn(); checks.push({ name, ok: true }); } catch (error) { checks.push({ name, ok: false, message: error.message }); } }; const curve = (label, index) => main[label].actions.find((item) => item.name === animation.name) ?.curves.find((item) => item.path === 'location' && item.index === index); verify('fcurve_explicit_handles_persist', () => { const point = curve('points', 0).keys.find((key) => key.co[0] === 5); assert.deepEqual(point.co, [5, 4]); assert.deepEqual(point.left, [4, 3]); assert.deepEqual(point.right, [6, 5]); assert.equal(point.leftType, 'FREE'); assert.equal(point.rightType, 'FREE'); }); verify('driver_is_registered_on_object', () => { const driver = main.driver.objects.find((item) => item.name === 'AnimatedObject').drivers .find((item) => item.path === 'location' && item.index === 1); assert.equal(driver?.driver.expression, 'frame * 0.5'); }); verify('action_group_survives_desktop_read', () => { assert.equal(curve('groups', 0).group, 'Web Motion'); assert.equal(curve('groups', 1).group, 'Web Motion'); }); verify('keying_path_owns_object_property', () => { const keyingSet = main.keying.scenes[0].keyingSets.find((item) => item.name === 'Web Location'); assert.equal(keyingSet?.paths[0].id, 'AnimatedObject'); assert.equal(keyingSet?.paths[0].idType, 'OBJECT'); assert.equal(keyingSet?.isPathAbsolute, true); }); verify('bake_samples_evaluated_animation', () => { for (const sample of main.keying.evaluatedSamples) { const object = sample.objects.find((item) => item.name === 'AnimatedObject'); for (let index = 0; index < 3; index++) { const key = curve('bake', index).keys.find((item) => item.co[0] === sample.frame); assert(Math.abs(key.co[1] - object.location[index]) < 1e-5, `frame ${sample.frame} location[${index}] expected evaluated ${object.location[index]}, received ${key.co[1]}`); } } }); const ok = checks.every((check) => check.ok); publishDiagnostic({ ok, directory, stages, checks, files, main: request.output }); if (!ok) process.exitCode = 1; } } catch (error) { log(error.stack); publishDiagnostic({ ok: false, directory, error: error.message, frames: String(error.stack).split("\n").filter(line => line.includes("wasm-function")).slice(0, 12), stages }); process.exitCode = 1; } finally { engine._free(pointers); engine._web_engine_destroy(handle); }