Vendor the viewer JavaScript into javascript/ and drop the comfy-3d-viewers dependency. The viewers were previously copied into web/ at every startup by prestartup_script.py, which made the served frontend depend on a second package resolving at runtime. Bundles are vendored as .mjs so ComfyUI's **/*.js auto-import glob skips them: they are iframe-only and must never enter the shared main browser realm. Only the widget entry points stay .js. Also replace comfy_env.copy_files with a local helper. Its two extra features (frame-derived source dir, silent no-op on a missing source) were unused here, and the asset seeding now resolves the input directory through folder_paths.get_input_directory() so it follows ComfyUI Desktop and --input-directory instead of writing into the code tree. comfy-test.toml moves to the current schema: [test.workflows] cuda (there is no "gpu" key -- the accelerator is named by backend, and the old spelling parsed to an empty list, so CUDA runners silently ran the full unfiltered set), an explicit [test.platforms] platforms allowlist (per-platform booleans are no longer supported), and the javascript level. Restrict FBX_PREVIEW_NODES to this pack's own preview node. The shared upstream copy of mesh_preview_fbx.js listed every sibling pack's node too, so installing two of them registered the same extension names twice and double-attached the widget. Guard the message listener on event.source.
316 lines
14 KiB
JavaScript
316 lines
14 KiB
JavaScript
/**
|
|
* ComfyUI FBX Rigged Mesh Preview Widget
|
|
*
|
|
* Generic widget for FBX preview nodes. Loads viewer from file (not inline).
|
|
* Used by UniRig, SAM3DBody, and other extensions that preview FBX files.
|
|
*
|
|
* This file auto-registers for all known FBX preview node types when loaded.
|
|
* Extensions copy this file from the comfy-3d-viewers package at startup.
|
|
*/
|
|
|
|
import { app } from "../../../../scripts/app.js";
|
|
|
|
// Auto-register for all known FBX preview node types
|
|
// Only this pack's OWN preview nodes. The shared upstream copy of this file
|
|
// listed every sibling pack's node too, so installing two of them registered
|
|
// the same extension names twice and double-attached the widget.
|
|
const FBX_PREVIEW_NODES = [
|
|
{
|
|
nodeName: "SAM3DBodyPreviewRiggedMesh",
|
|
extensionName: "sam3dbody.meshpreview",
|
|
logPrefix: "[SAM3DBody]"
|
|
// Note: SAM3DBody doesn't have FBX export API
|
|
}
|
|
];
|
|
|
|
/**
|
|
* Detect the extension folder name from the current script URL
|
|
*/
|
|
function detectExtensionFolder() {
|
|
try {
|
|
// Try to detect from import.meta.url if available
|
|
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
|
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
}
|
|
|
|
// Fallback: look at currently loading script
|
|
const scripts = document.getElementsByTagName('script');
|
|
for (let i = scripts.length - 1; i >= 0; i--) {
|
|
const src = scripts[i].src;
|
|
if (src) {
|
|
const match = src.match(/\/extensions\/([^\/]+)\//);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[FBX Preview] Could not detect extension folder:', e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the viewer URL for the FBX viewer HTML file
|
|
* @param {string} extensionFolder - The extension folder name
|
|
* @returns {string} The URL to the viewer HTML
|
|
*/
|
|
function getViewerUrl(extensionFolder, viewerFile = "viewer_fbx.html") {
|
|
// Add cache-busting timestamp
|
|
return `/extensions/${extensionFolder}/${viewerFile}?v=` + Date.now();
|
|
}
|
|
|
|
/**
|
|
* Create and register an FBX preview extension
|
|
* @param {Object} config - Configuration object
|
|
* @param {string} config.extensionName - Unique extension name (e.g., "unirig.fbxpreview")
|
|
* @param {string} config.nodeName - Node type name to hook (e.g., "UniRigPreviewRiggedMesh")
|
|
* @param {string} config.logPrefix - Log prefix for console messages (e.g., "[UniRig]")
|
|
* @param {string} [config.extensionFolder] - Override extension folder detection
|
|
* @param {string} [config.fbxExportApiPath] - API path for FBX export (e.g., "/unirig/export_posed_fbx")
|
|
*/
|
|
export function createFBXPreviewExtension(config) {
|
|
const {
|
|
extensionName,
|
|
nodeName,
|
|
logPrefix,
|
|
extensionFolder: configExtensionFolder,
|
|
fbxExportApiPath
|
|
} = config;
|
|
|
|
console.log(`${logPrefix} Loading FBX mesh preview extension...`);
|
|
|
|
// Detect extension folder if not provided
|
|
const extensionFolder = configExtensionFolder || detectExtensionFolder();
|
|
if (!extensionFolder) {
|
|
console.error(`${logPrefix} Could not detect extension folder`);
|
|
return;
|
|
}
|
|
console.log(`${logPrefix} Detected extension folder: ${extensionFolder}`);
|
|
|
|
app.registerExtension({
|
|
name: extensionName,
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
|
if (nodeData.name === nodeName) {
|
|
console.log(`${logPrefix} Registering Preview Rigged Mesh node`);
|
|
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function() {
|
|
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
|
|
|
console.log(`${logPrefix} Node created, adding FBX viewer widget`);
|
|
|
|
// Create iframe for FBX viewer
|
|
const iframe = document.createElement("iframe");
|
|
iframe.style.width = "100%";
|
|
iframe.style.flex = "1 1 0";
|
|
iframe.style.minHeight = "0";
|
|
iframe.style.border = "none";
|
|
iframe.style.backgroundColor = "#2a2a2a";
|
|
|
|
// Load viewer from file URL (not inline)
|
|
const viewerUrl = getViewerUrl(extensionFolder);
|
|
iframe.src = viewerUrl;
|
|
console.log(`${logPrefix} Setting iframe src to: ${viewerUrl}`);
|
|
|
|
// Add load event listener
|
|
iframe.onload = () => {
|
|
console.log(`${logPrefix} Iframe loaded successfully`);
|
|
};
|
|
iframe.onerror = (e) => {
|
|
console.error(`${logPrefix} Iframe failed to load:`, e);
|
|
};
|
|
|
|
// Add widget
|
|
const widget = this.addDOMWidget("preview", "FBX_PREVIEW", iframe, {
|
|
getValue() { return ""; },
|
|
setValue(v) { }
|
|
});
|
|
|
|
console.log(`${logPrefix} Widget created:`, widget);
|
|
|
|
// Set widget size - allow flexible height
|
|
widget.computeSize = function(width) {
|
|
const w = width || 512;
|
|
const h = w * 1.5; // Taller than wide to accommodate controls
|
|
return [w, h];
|
|
};
|
|
|
|
widget.element = iframe;
|
|
|
|
// Store iframe reference
|
|
this.fbxViewerIframe = iframe;
|
|
this.fbxViewerReady = false;
|
|
|
|
// Listen for ready message from iframe
|
|
const onMessage = (event) => {
|
|
// Provenance guard: the 'message' event is window-wide, so without
|
|
// this we also handle every OTHER pack's iframe messages.
|
|
if (event.source !== iframe.contentWindow) return;
|
|
if (event.data && event.data.type === 'VIEWER_READY') {
|
|
console.log(`${logPrefix} Viewer iframe is ready!`);
|
|
this.fbxViewerReady = true;
|
|
}
|
|
};
|
|
window.addEventListener('message', onMessage.bind(this));
|
|
|
|
const notifyIframeResize = () => {
|
|
if (iframe.contentWindow) {
|
|
const rect = iframe.getBoundingClientRect();
|
|
iframe.contentWindow.postMessage({
|
|
type: 'RESIZE',
|
|
width: rect.width,
|
|
height: rect.height
|
|
}, '*');
|
|
}
|
|
};
|
|
|
|
this.onResize = function(size) {
|
|
const isVueNodes = iframe.closest('[data-node-id]') !== null ||
|
|
document.querySelector('.vue-graph-canvas') !== null;
|
|
|
|
if (!isVueNodes && size && size[1]) {
|
|
const nodeHeight = size[1];
|
|
const headerHeight = 70;
|
|
const availableHeight = Math.max(200, nodeHeight - headerHeight);
|
|
iframe.style.height = availableHeight + 'px';
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
notifyIframeResize();
|
|
});
|
|
};
|
|
|
|
let resizeTimeout = null;
|
|
let lastSize = { width: 0, height: 0 };
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
const newWidth = entry.contentRect.width;
|
|
const newHeight = entry.contentRect.height;
|
|
|
|
if (Math.abs(newWidth - lastSize.width) < 1 && Math.abs(newHeight - lastSize.height) < 1) {
|
|
return;
|
|
}
|
|
lastSize = { width: newWidth, height: newHeight };
|
|
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout);
|
|
}
|
|
resizeTimeout = setTimeout(() => {
|
|
notifyIframeResize();
|
|
}, 50);
|
|
});
|
|
resizeObserver.observe(iframe);
|
|
|
|
const originalOnRemoved = this.onRemoved;
|
|
this.onRemoved = function() {
|
|
resizeObserver.disconnect();
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout);
|
|
}
|
|
window.removeEventListener('message', onMessage);
|
|
if (originalOnRemoved) {
|
|
originalOnRemoved.apply(this, arguments);
|
|
}
|
|
};
|
|
|
|
// Set initial node size (taller to accommodate controls)
|
|
this.setSize([512, 768]);
|
|
|
|
// Handle execution
|
|
const onExecuted = this.onExecuted;
|
|
this.onExecuted = function(message) {
|
|
console.log(`${logPrefix} onExecuted called with message:`, message);
|
|
onExecuted?.apply(this, arguments);
|
|
|
|
// The message contains the FBX file path
|
|
if (message?.fbx_file && message.fbx_file[0]) {
|
|
const filename = message.fbx_file[0];
|
|
console.log(`${logPrefix} Loading FBX: ${filename}`);
|
|
|
|
// Try different path formats based on filename
|
|
let filepath;
|
|
let folderType = 'output'; // default
|
|
|
|
// Detect folder type from path
|
|
if (filename.includes('/input/') || filename.includes('\\input\\')) {
|
|
folderType = 'input';
|
|
}
|
|
|
|
// Extract basename if full path provided
|
|
const basename = filename.includes('/') || filename.includes('\\')
|
|
? filename.split(/[/\\]/).pop()
|
|
: filename;
|
|
|
|
filepath = `${window.location.origin}/view?filename=${encodeURIComponent(basename)}&type=${folderType}&subfolder=`;
|
|
|
|
// Send message to iframe
|
|
const sendMessage = () => {
|
|
if (iframe.contentWindow) {
|
|
console.log(`${logPrefix} Sending postMessage to iframe: ${filepath}`);
|
|
const messageData = {
|
|
type: "LOAD_FBX",
|
|
filepath: filepath,
|
|
timestamp: Date.now()
|
|
};
|
|
// Include custom FBX export API path if configured
|
|
if (fbxExportApiPath) {
|
|
messageData.fbxExportApiPath = fbxExportApiPath;
|
|
}
|
|
iframe.contentWindow.postMessage(messageData, "*");
|
|
} else {
|
|
console.error(`${logPrefix} Iframe contentWindow not available`);
|
|
}
|
|
};
|
|
|
|
// Wait for iframe to be ready, or use timeout as fallback
|
|
if (this.fbxViewerReady) {
|
|
sendMessage();
|
|
} else {
|
|
const checkReady = setInterval(() => {
|
|
if (this.fbxViewerReady) {
|
|
clearInterval(checkReady);
|
|
sendMessage();
|
|
}
|
|
}, 50);
|
|
|
|
// Fallback timeout after 2 seconds
|
|
setTimeout(() => {
|
|
clearInterval(checkReady);
|
|
if (!this.fbxViewerReady) {
|
|
console.warn(`${logPrefix} Iframe not ready after 2s, sending anyway`);
|
|
sendMessage();
|
|
}
|
|
}, 2000);
|
|
}
|
|
} else {
|
|
console.log(`${logPrefix} No fbx_file in message data. Keys:`, Object.keys(message || {}));
|
|
}
|
|
};
|
|
|
|
return r;
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log(`${logPrefix} FBX mesh preview extension registered`);
|
|
}
|
|
|
|
// Export detectExtensionFolder for use by other modules
|
|
export { detectExtensionFolder, getViewerUrl };
|
|
|
|
// Self-executing: register all known FBX preview node types
|
|
console.log("[FBX Preview] Auto-registering for all known FBX preview node types...");
|
|
FBX_PREVIEW_NODES.forEach(config => {
|
|
try {
|
|
createFBXPreviewExtension(config);
|
|
} catch (e) {
|
|
// Extension already registered by another custom node copy - skip
|
|
}
|
|
});
|