488 lines
15 KiB
JavaScript
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);
|
|
});
|