Files
workinf_Blender_Wasm/my-tools/diagnose_local_animation_authoring.mjs
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

196 lines
11 KiB
JavaScript

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