Files

488 lines
15 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
chainCallback,
configureDOMWidget,
observeWidgetValue,
setWidgetHidden,
} from '../../src/timelineanim_nodes/web/js/shared.js';
import {
createNative3D,
seekNative3D,
timelineFrameToSeconds,
} from '../../src/timelineanim_nodes/web/js/native_3d_bridge.js';
import {
modelViewURL,
Timeline3DViewerController,
} from '../../src/timelineanim_nodes/web/js/timeline_3d_viewer.js';
import { app } from './stub-app.mjs';
function makeGlb(document, name = 'model.glb', extraJsonDocuments = []) {
const chunks = [document, ...extraJsonDocuments].map((item) => {
const json = new TextEncoder().encode(JSON.stringify(item));
const data = new Uint8Array(Math.ceil(json.length / 4) * 4);
data.fill(0x20);
data.set(json);
return data;
});
const byteLength = 12 + chunks.reduce((total, chunk) => total + 8 + chunk.length, 0);
const bytes = new Uint8Array(byteLength);
const header = new DataView(bytes.buffer);
header.setUint32(0, 0x46546c67, true);
header.setUint32(4, 2, true);
header.setUint32(8, bytes.byteLength, true);
let offset = 12;
for (const chunk of chunks) {
header.setUint32(offset, chunk.length, true);
header.setUint32(offset + 4, 0x4e4f534a, true);
bytes.set(chunk, offset + 8);
offset += 8 + chunk.length;
}
return new File([bytes], name, { type: 'model/gltf-binary' });
}
test('hidden and DOM widget state covers Nodes 1.0 and 2.0', () => {
const hidden = { options: {}, _state: { options: {} } };
setWidgetHidden(hidden);
assert.equal(hidden.hidden, true);
assert.equal(hidden.options.hidden, true);
assert.equal(hidden._state.options.hidden, true);
assert.deepEqual(hidden.computeSize(), [0, -4]);
const dom = { options: {} };
configureDOMWidget(dom, 400);
assert.equal(dom.serialize, false);
assert.equal(dom.options.serialize, false);
assert.equal(dom.options.getMinHeight(), 400);
assert.deepEqual(dom.computeSize(720), [720, 400]);
});
test('value observer delegates through Nodes 2.0 reactive accessor', () => {
class BaseWidget {
constructor() {
this._state = { value: 'a', options: {} };
}
get value() { return this._state.value; }
set value(value) { this._state.value = value; }
}
const widget = new BaseWidget();
const changes = [];
const stop = observeWidgetValue(
widget,
(next, previous) => changes.push([previous, next]),
);
widget.value = 'b';
assert.equal(widget._state.value, 'b');
assert.deepEqual(changes, [['a', 'b']]);
stop();
widget.value = 'c';
assert.equal(widget._state.value, 'c');
assert.deepEqual(changes, [['a', 'b']]);
assert.equal(Object.hasOwn(widget, 'value'), false);
});
test('chainCallback ignores non-function placeholders', () => {
const object = { hook: null };
let called = 0;
chainCallback(object, 'hook', () => { called += 1; });
object.hook();
assert.equal(called, 1);
});
test('native 3D time mapping follows animation start and fps', () => {
assert.equal(timelineFrameToSeconds(48, 24, 24), 1);
assert.equal(timelineFrameToSeconds(0, 24, 24), 0);
assert.equal(timelineFrameToSeconds(48, 24, 0), 0);
assert.equal(timelineFrameToSeconds(Number.NaN, 0, 24), 0);
let applied = null;
const viewer = {
hasAnimations: () => true,
getAnimationDuration: () => 2,
setAnimationTime: (seconds) => { applied = seconds; },
};
assert.equal(seekNative3D(viewer, 240, 0, 24), 2);
assert.equal(applied, 2);
});
test('native 3D model URL preserves the LoaderManager query contract', () => {
const resource = modelViewURL('3d/shot folder/hero.glb');
const url = new URL(resource.url, 'http://localhost');
assert.equal(url.pathname, '/view');
assert.equal(url.searchParams.get('filename'), 'hero.glb');
assert.equal(url.searchParams.get('subfolder'), '3d/shot folder');
assert.equal(url.searchParams.get('type'), 'input');
assert.ok(url.searchParams.get('rand'));
assert.throws(
() => modelViewURL('3d/bad#name.glb'),
/cannot contain/,
);
});
test('folder upload packs local GLTF companions as data URLs', async () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller._uploadAbort = new AbortController();
controller._setStatus = () => {};
controller._fileDataURL = async (file, mime) => (
`data:${mime || file.type || 'application/octet-stream'};base64,PACKED`
);
const gltf = new File([
JSON.stringify({
asset: { version: '2.0' },
buffers: [{ uri: 'data/mesh.bin', byteLength: 4 }],
images: [{ uri: '../textures/albedo.png', mimeType: 'image/png' }],
}),
], 'scene.gltf', { type: 'model/gltf+json' });
const mesh = new File(['mesh'], 'mesh.bin');
const image = new File([
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
], 'albedo.png', { type: 'image/png' });
Object.defineProperty(gltf, 'webkitRelativePath', {
value: 'bundle/scenes/scene.gltf',
});
Object.defineProperty(mesh, 'webkitRelativePath', {
value: 'bundle/scenes/data/mesh.bin',
});
Object.defineProperty(image, 'webkitRelativePath', {
value: 'bundle/textures/albedo.png',
});
const entries = [gltf, mesh, image].map((file) => ({
file,
parts: controller._bundleParts(file),
}));
const packed = await controller._inlineGltfResources(entries, entries[0]);
const json = JSON.parse(await packed.text());
assert.equal(
json.buffers[0].uri,
'data:application/octet-stream;base64,PACKED',
);
assert.equal(json.images[0].uri, 'data:image/png;base64,PACKED');
assert.equal(packed.name, 'scene.gltf');
});
test('folder upload refuses remote or missing GLTF resources', async () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller._uploadAbort = new AbortController();
controller._setStatus = () => {};
controller._fileDataURL = async () => 'data:application/octet-stream;base64,AA==';
const remote = new File([
JSON.stringify({
asset: { version: '2.0' },
buffers: [{ uri: 'https://example.invalid/mesh.bin', byteLength: 1 }],
}),
], 'remote.gltf');
Object.defineProperty(remote, 'webkitRelativePath', {
value: 'bundle/remote.gltf',
});
const remoteEntry = {
file: remote,
parts: controller._bundleParts(remote),
};
await assert.rejects(
controller._inlineGltfResources([remoteEntry], remoteEntry),
/External URL is not allowed/,
);
const missing = new File([
JSON.stringify({
asset: { version: '2.0' },
buffers: [{ uri: 'missing.bin', byteLength: 1 }],
}),
], 'missing.gltf');
Object.defineProperty(missing, 'webkitRelativePath', {
value: 'bundle/missing.gltf',
});
const missingEntry = {
file: missing,
parts: controller._bundleParts(missing),
};
await assert.rejects(
controller._inlineGltfResources([missingEntry], missingEntry),
/Missing GLTF companion file/,
);
});
test('folder upload rejects unsupported codecs and hostile resource paths', async () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller._uploadAbort = new AbortController();
controller._setStatus = () => {};
controller._fileDataURL = async () => 'data:application/octet-stream;base64,AA==';
const unsupported = new File([
JSON.stringify({
asset: { version: '2.0' },
extensionsUsed: ['KHR_draco_mesh_compression'],
}),
], 'unsupported.gltf');
Object.defineProperty(unsupported, 'webkitRelativePath', {
value: 'bundle/unsupported.gltf',
});
const unsupportedEntry = {
file: unsupported,
parts: controller._bundleParts(unsupported),
};
await assert.rejects(
controller._inlineGltfResources([unsupportedEntry], unsupportedEntry),
/does not decode KHR_draco_mesh_compression/,
);
for (const uri of [
'../../escape.bin',
'mesh%2fsecret.bin',
'mesh.bin?token=secret',
'C:/mesh.bin',
]) {
assert.throws(
() => controller._resolveBundleReference(['scenes'], uri),
/GLTF resource|External URL/,
);
}
});
test('folder upload encodes case variants once by canonical companion', async () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller._uploadAbort = new AbortController();
controller._setStatus = () => {};
let reads = 0;
controller._fileDataURL = async () => {
reads += 1;
return 'data:application/octet-stream;base64,AA==';
};
const gltf = new File([
JSON.stringify({
asset: { version: '2.0' },
buffers: [
{ uri: 'mesh.bin', byteLength: 1 },
{ uri: 'MESH.BIN', byteLength: 1 },
],
}),
], 'scene.gltf');
const buffer = new File(['x'], 'mesh.bin');
Object.defineProperty(gltf, 'webkitRelativePath', {
value: 'bundle/scene.gltf',
});
Object.defineProperty(buffer, 'webkitRelativePath', {
value: 'bundle/mesh.bin',
});
const entries = [gltf, buffer].map((file) => ({
file,
parts: controller._bundleParts(file),
}));
await controller._inlineGltfResources(entries, entries[0]);
assert.equal(reads, 1);
});
test('folder upload counts every repeated packed reference toward its limit', async () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller._uploadAbort = new AbortController();
controller._setStatus = () => {};
let reads = 0;
controller._fileDataURL = async () => {
reads += 1;
return 'data:application/octet-stream;base64,AA==';
};
const gltf = new File([
JSON.stringify({
asset: { version: '2.0' },
buffers: [
{ uri: 'mesh.bin', byteLength: 1 },
{ uri: 'mesh.bin', byteLength: 1 },
],
}),
], 'scene.gltf', { type: 'model/gltf+json' });
const entries = [
{ file: gltf, parts: ['scene.gltf'] },
{
file: {
name: 'mesh.bin',
size: 48 * 1024 * 1024,
type: 'application/octet-stream',
},
parts: ['mesh.bin'],
},
];
await assert.rejects(
controller._inlineGltfResources(entries, entries[0]),
/Packed GLTF exceeds 100 MiB/,
);
assert.equal(reads, 1);
});
test('single GLTF with companion files is not uploaded raw', async () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller._disposed = false;
controller._uploadAbort = new AbortController();
controller._setUploadBusy = () => {};
const statuses = [];
controller._setStatus = (status) => statuses.push(status);
controller.modelWidget = {
value: '3d/original.glb',
options: { values: ['3d/original.glb'] },
};
let uploads = 0;
controller._uploadFile = async () => {
uploads += 1;
return '3d/unsafe.gltf';
};
await controller._uploadSingleModel(new File([
JSON.stringify({
asset: { version: '2.0' },
buffers: [{ uri: 'mesh.bin', byteLength: 1 }],
}),
], 'unsafe.gltf', { type: 'model/gltf+json' }));
assert.equal(uploads, 0);
assert.equal(statuses.at(-1), 'This GLTF has companion files; use Upload folder');
});
test('single GLB validation blocks external resources before upload', async () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller._disposed = false;
controller._uploadAbort = new AbortController();
controller._setUploadBusy = () => {};
const statuses = [];
controller._setStatus = (status) => statuses.push(status);
controller.modelWidget = {
value: '3d/original.glb',
options: { values: ['3d/original.glb'] },
};
let uploads = 0;
controller._uploadFile = async () => {
uploads += 1;
return '3d/unsafe.glb';
};
const external = makeGlb({
asset: { version: '2.0' },
images: [{ uri: 'https://example.invalid/tracker.png' }],
}, 'unsafe.glb');
await controller._uploadSingleModel(external);
assert.equal(uploads, 0);
assert.match(statuses.at(-1), /GLB must be self-contained/);
const embedded = makeGlb({ asset: { version: '2.0' } }, 'safe.glb');
assert.equal(await controller._validateGlbSelfContained(embedded), embedded);
const duplicateJson = makeGlb(
{ asset: { version: '2.0' } },
'duplicate.glb',
[{
asset: { version: '2.0' },
images: [{ uri: 'https://example.invalid/tracker.png' }],
}],
);
await assert.rejects(
controller._validateGlbSelfContained(duplicateJson),
/exactly one first JSON chunk/,
);
await assert.rejects(
controller._validateGlbSelfContained(makeGlb({
asset: { version: '2.0' },
images: [{
uri: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
}],
}, 'unsafe-image.glb')),
/base64 PNG, JPEG, or WebP/,
);
});
test('completed upload does not override a newer combo selection', async () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller._disposed = false;
controller._setStatus = () => {};
controller._setUploadBusy = () => {};
controller.modelWidget = {
value: '3d/original.glb',
options: { values: ['3d/original.glb'] },
};
let uploadedFile;
let uploadFolder;
controller._uploadFile = async (file, folder) => {
uploadedFile = file;
uploadFolder = folder;
controller.modelWidget.value = '3d/user-choice.glb';
return '3d/uploaded.obj';
};
let selected = false;
controller._setWidgetValue = () => {
selected = true;
return true;
};
await controller._uploadSingleModel(
new File(['o hero'], 'upload#ed.obj'),
);
assert.equal(selected, false);
assert.equal(uploadedFile.name, 'upload-ed.obj');
assert.match(uploadFolder, /^3d\/timeline_uploads\//);
assert.equal(controller.modelWidget.value, '3d/user-choice.glb');
assert.ok(controller.modelWidget.options.values.includes('3d/uploaded.obj'));
});
test('Nodes 2.0 reactive model options form the load allowlist', () => {
const controller = Object.create(Timeline3DViewerController.prototype);
controller.modelWidget = {
value: 'none',
options: { values: ['none'] },
_state: { options: {} },
};
controller._addModelOption('3d/safe.glb');
assert.deepEqual(
controller.modelWidget._state.options.values,
['none', '3d/safe.glb'],
);
assert.equal(controller._isKnownModelOption('3d/safe.glb'), true);
assert.equal(controller._isKnownModelOption('3d/stale-workflow.glb'), false);
});
test('native 3D factory activates the fresh-session core lazy bundle', async () => {
let activated = 0;
const viewer = {
loadModel() {},
getCurrentModel() {},
setAnimationTime() {},
getAnimationDuration() {},
updateSelectedAnimation() {},
toggleAnimation() {},
handleResize() {},
remove() {},
hasAnimations() {},
clearModel() {},
emitModelReady() {},
updateStatusMouseOnViewer() {},
whenLoadIdle() { return Promise.resolve(); },
addEventListener() {},
removeEventListener() {},
};
window.comfyAPI = {};
app.extensions = [{
name: 'Comfy.Load3DLazy',
async beforeRegisterNodeDef(_nodeType, nodeData) {
activated += 1;
assert.equal(nodeData.name, 'Load3D');
window.comfyAPI.createLoad3d = {
createLoad3d: () => viewer,
};
},
}];
assert.equal(await createNative3D({}), viewer);
assert.equal(activated, 1);
});