Fixed every known issue with multi image loader and added text output to speech length calculator

Fixed every known issue with multi image loader and added text output to speech length calculator
This commit is contained in:
WhatDreamsCost
2026-05-06 00:18:09 -05:00
parent 1a1174eaa0
commit 31e27fceac
2 changed files with 293 additions and 202 deletions

View File

@@ -6,10 +6,28 @@ app.registerExtension({
async nodeCreated(node) { async nodeCreated(node) {
if (node.comfyClass !== "MultiImageLoader") return; if (node.comfyClass !== "MultiImageLoader") return;
// Helper to detect if we are in the new Nodes 2.0 / V3 Web Component frontend
let v3NodeElement = null;
function checkIsV3() {
if (v3NodeElement) return true;
let el = container.parentElement;
while (el) {
if ((el.tagName && el.tagName.toLowerCase().includes('comfy-node')) ||
(el.classList && el.classList.contains('comfy-node'))) {
v3NodeElement = el;
return true;
}
el = el.parentElement || (el.getRootNode ? el.getRootNode().host : null);
}
return false;
}
// --- 1. UI Setup: Main Container --- // --- 1. UI Setup: Main Container ---
const container = document.createElement("div"); const container = document.createElement("div");
container.style.cssText = ` container.style.cssText = `
width: 100%; width: 100%;
min-height: 250px;
min-width: 100px; /* Reduced from 400px to allow thin resizing in V3 */
background: #222222; background: #222222;
border: 1px solid #353545; border: 1px solid #353545;
border-radius: 4px; border-radius: 4px;
@@ -25,7 +43,8 @@ app.registerExtension({
// Top Bar for Actions // Top Bar for Actions
const topBar = document.createElement("div"); const topBar = document.createElement("div");
topBar.style.cssText = "display: flex; justify-content: flex-start; align-items: center; width: 100%; gap: 8px;"; // Added flex-wrap: wrap so buttons stack if the node gets extremely thin
topBar.style.cssText = "display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: center; width: 100%; gap: 8px;";
const uploadBtn = document.createElement("button"); const uploadBtn = document.createElement("button");
uploadBtn.innerText = "Upload Images"; uploadBtn.innerText = "Upload Images";
@@ -51,18 +70,26 @@ app.registerExtension({
topBar.appendChild(removeAllBtn); topBar.appendChild(removeAllBtn);
container.appendChild(topBar); container.appendChild(topBar);
// The Grid Area - Setup to center the dynamically packed square blocks const gridWrapper = document.createElement("div");
gridWrapper.style.cssText = `
position: relative;
flex-grow: 1;
width: 100%;
min-height: 0;
`;
const grid = document.createElement("div"); const grid = document.createElement("div");
grid.style.cssText = ` grid.style.cssText = `
position: relative; /* Crucial for anti-flicker offset calculations */ position: absolute;
flex-grow: 1; top: 0; left: 0; right: 0; bottom: 0;
display: grid; display: grid;
gap: 8px; gap: 8px;
width: 100%;
justify-content: center; justify-content: center;
align-content: center; align-content: center;
`; `;
container.appendChild(grid);
gridWrapper.appendChild(grid);
container.appendChild(gridWrapper);
const fileInput = document.createElement("input"); const fileInput = document.createElement("input");
fileInput.type = "file"; fileInput.type = "file";
@@ -74,60 +101,45 @@ app.registerExtension({
// Add the Widget to the Node // Add the Widget to the Node
const galleryWidget = node.addDOMWidget("Gallery", "html_gallery", container, { serialize: false }); const galleryWidget = node.addDOMWidget("Gallery", "html_gallery", container, { serialize: false });
// Permanently neutralize the DOM widget's built-in computeSize to prevent infinite LiteGraph loops galleryWidget.computeSize = function() {
galleryWidget.computeSize = () => [0, 0]; const galleryY = this.last_y || 40;
const minOutputsHeight = (node.outputs ? node.outputs.length : 1) * 20;
const requiredGalleryHeight = Math.max(250, minOutputsHeight + 40 - galleryY);
return [150, requiredGalleryHeight]; // Changed minimum theoretical widget width
};
// --- BUG FIX --- // --- SAFELY HIDE THE IMAGE_PATHS WIDGET ---
// Find the paths widget and properly hide it from LiteGraph
const pathsWidget = node.widgets.find(w => w.name === "image_paths"); const pathsWidget = node.widgets.find(w => w.name === "image_paths");
if (pathsWidget) { if (pathsWidget) {
// Tell LiteGraph to ignore this widget for hit-testing and drawing // Forcefully lock the hidden state against V3's reactive redraws
pathsWidget.hidden = true; Object.defineProperty(pathsWidget, 'hidden', {
get: () => true,
set: () => {} // Ignore attempts by V3 to unhide it
});
Object.defineProperty(pathsWidget, 'type', {
get: () => "hidden",
set: () => {} // Ignore attempts by V3 to reset the type
});
// Return -4 to absorb LiteGraph's default 4px vertical margin between widgets, pathsWidget.computeSize = function() {
// entirely eliminating its invisible "ghost" hitbox. return [0, 0];
pathsWidget.computeSize = () => [0, -4]; };
// Aggressively neutralize the actual DOM element AND its ComfyUI wrapper using a global stylesheet // Catch for V3 delayed DOM rendering to ensure no stubborn inputs appear
// ComfyUI's background render loop continually overwrites inline styles (like display: none). const hideInterval = setInterval(() => {
// By assigning unique IDs and injecting a global !important CSS rule, we guarantee it remains dead.
if (pathsWidget.element) { if (pathsWidget.element) {
const uid = node.id || Math.random().toString(36).substring(2, 9); pathsWidget.element.style.display = "none";
pathsWidget.element.id = `hidden-paths-textarea-${uid}`;
if (pathsWidget.element.parentElement) {
pathsWidget.element.parentElement.id = `hidden-paths-wrapper-${uid}`;
}
// Inject the shield-killer style into the document head if it doesn't exist yet
if (!document.getElementById("multi-image-loader-shield-killer")) {
const style = document.createElement("style");
style.id = "multi-image-loader-shield-killer";
style.innerHTML = `
[id^="hidden-paths-textarea-"],
[id^="hidden-paths-wrapper-"] {
display: none !important;
pointer-events: none !important;
position: absolute !important;
width: 0px !important;
height: 0px !important;
opacity: 0 !important;
z-index: -9999 !important;
overflow: hidden !important;
}
`;
document.head.appendChild(style);
}
} }
}, 50);
setTimeout(() => clearInterval(hideInterval), 1000);
} }
const oldCallback = pathsWidget?.callback; const oldCallback = pathsWidget?.callback;
// Centralized helper to prevent infinite loops when updating values internally
function setWidgetValue(newPathsArray, isRearranging = false) { function setWidgetValue(newPathsArray, isRearranging = false) {
if (!pathsWidget) return; if (!pathsWidget) return;
const val = newPathsArray.join("\n"); const val = newPathsArray.join("\n");
// Temporarily silence the main callback
const tempCallback = pathsWidget.callback; const tempCallback = pathsWidget.callback;
pathsWidget.callback = null; pathsWidget.callback = null;
@@ -139,32 +151,29 @@ app.registerExtension({
} }
// --- 2. Logic: Output Syncing & Dynamic Packing --- // --- 2. Logic: Output Syncing & Dynamic Packing ---
// Manages image_N outputs (slots 1+) while always preserving multi_output at slot 0
function syncOutputs(count) { function syncOutputs(count) {
if (!node.outputs) return; if (!node.outputs) return;
let changed = false; let changed = false;
// Target = multi_output (slot 0) + count image outputs
const targetTotal = count + 1; const targetTotal = count + 1;
// Remove excess outputs from the end, but NEVER remove slot 0 (multi_output) const wasFresh = node.outputs.length >= 50;
while (node.outputs.length > targetTotal && node.outputs.length > 1) { while (node.outputs.length > targetTotal && node.outputs.length > 1) {
node.removeOutput(node.outputs.length - 1); node.removeOutput(node.outputs.length - 1);
changed = true; changed = true;
} }
// Add missing image_N outputs after multi_output
for (let i = node.outputs.length; i < targetTotal; i++) { for (let i = node.outputs.length; i < targetTotal; i++) {
node.addOutput(`image_${i}`, "IMAGE"); node.addOutput(`image_${i}`, "IMAGE");
changed = true; changed = true;
} }
if (changed) { if (changed || wasFresh) {
updateLayout(); updateLayout(wasFresh);
} }
} }
// Push-based notification: broadcast image count to all connected nodes
function notifyConnectedNodes(imageCount) { function notifyConnectedNodes(imageCount) {
if (!node.outputs) return; if (!node.outputs) return;
for (const output of node.outputs) { for (const output of node.outputs) {
@@ -180,8 +189,7 @@ app.registerExtension({
} }
} }
// 2D Square Packing Algorithm: Calculates the optimal grid sizes to fill empty space function optimizeGrid(gridW, gridH) {
function optimizeGrid(nodeW, containerH) {
const paths = (pathsWidget?.value || "").split(/\n|,/).map(s => s.trim()).filter(s => s); const paths = (pathsWidget?.value || "").split(/\n|,/).map(s => s.trim()).filter(s => s);
const N = paths.length; const N = paths.length;
@@ -191,128 +199,186 @@ app.registerExtension({
return; return;
} }
// Approximate the available internal working space
const gridW = nodeW - 22; // Container padding + border
const gridH = containerH - 60; // Top bar height + container padding + gap
if (gridW <= 0 || gridH <= 0) return; if (gridW <= 0 || gridH <= 0) return;
let bestS = 0; let bestS = 0;
let bestCols = 1; let bestCols = 1;
// Test every possible column combination to find the one that yields the largest squares
for (let c = 1; c <= N; c++) { for (let c = 1; c <= N; c++) {
const r = Math.ceil(N / c); const r = Math.ceil(N / c);
const maxW = (gridW - (c - 1) * 8) / c; // Math.max guarantees we don't end up with negative max width in ultra-thin layouts
const maxH = (gridH - (r - 1) * 8) / r; const maxW = Math.max(5, (gridW - (c - 1) * 8) / c);
const maxH = Math.max(5, (gridH - (r - 1) * 8) / r);
const size = Math.min(maxW, maxH); const size = Math.min(maxW, maxH);
if (size > bestS) { // By using >= instead of strict > (with a tiny 0.1 buffer for float precision),
// if multiple column counts yield the exact same optimal cell size
// (which happens when height is the bottleneck), we aggressively pack
// more items horizontally onto the row to fill empty space.
if (size >= bestS - 0.1) {
bestS = size; bestS = size;
bestCols = c; bestCols = c;
} }
} }
bestS = Math.max(75, Math.floor(bestS)); // Keep a minimum size floor // Allow grid cells to shrink down to 10px instead of 15 to prevent horizontal overflow in V3
bestS = Math.max(10, Math.floor(bestS));
// Force the grid to perfectly adopt the optimal maximum square scale
grid.style.gridTemplateColumns = `repeat(${bestCols}, ${bestS}px)`; grid.style.gridTemplateColumns = `repeat(${bestCols}, ${bestS}px)`;
grid.style.gridAutoRows = `${bestS}px`; grid.style.gridAutoRows = `${bestS}px`;
} }
// Centralized measurement function shared by automatic updates and manual resizes let v3EventsAttached = false;
function getGalleryHeights() {
const baseHeight = node.computeSize()[1];
// Temporarily force natural height measurement using minimum settings function enforceV3CSS() {
const oldHeight = container.style.height; const isV3 = checkIsV3();
const oldCols = grid.style.gridTemplateColumns; if (isV3 && v3NodeElement) {
const oldRows = grid.style.gridAutoRows; const paddingBottom = 15;
const galleryY = galleryWidget.last_y || 40;
const minOutputsHeight = (node.outputs ? node.outputs.length : 1) * 20;
const absoluteMinHeight = Math.max(galleryY + 250 + paddingBottom, minOutputsHeight + 40);
container.style.height = 'fit-content'; // For Nodes 2.0 (V3), we remove the min-width constraint completely.
grid.style.gridTemplateColumns = `repeat(auto-fit, minmax(75px, 1fr))`; // We leave min-height so outputs don't bleed out vertically.
grid.style.gridAutoRows = `max-content`; v3NodeElement.style.removeProperty('min-width');
v3NodeElement.style.setProperty('min-height', absoluteMinHeight + 'px', 'important');
const naturalGalleryHeight = container.offsetHeight || 100; // Attach drag & drop to the entire V3 Web Component
const minNodeHeight = baseHeight + naturalGalleryHeight + 15; if (!v3EventsAttached) {
v3EventsAttached = true;
// Restore styles instantly v3NodeElement.addEventListener("dragover", (e) => {
container.style.height = oldHeight; e.preventDefault();
grid.style.gridTemplateColumns = oldCols; });
grid.style.gridAutoRows = oldRows; v3NodeElement.addEventListener("drop", (e) => {
if (e.dataTransfer && e.dataTransfer.files) {
return { baseHeight, minNodeHeight }; const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
if (files.length > 0) {
e.preventDefault();
e.stopPropagation();
handleFiles(files);
}
}
});
}
}
} }
let isLayouting = false; let isLayouting = false;
let isFirstLayout = true;
function updateLayout(forceHeight = null) { function updateLayout(forceShrink = false) {
if (isLayouting) return; if (isLayouting) return;
isLayouting = true; isLayouting = true;
const { baseHeight, minNodeHeight } = getGalleryHeights(); const isV3 = checkIsV3();
const minW = isV3 ? 100 : 200; // 100 in V3, 440 in V1
const paddingBottom = isV3 ? 15 : 25; // Apply extra 20px pad on V1
let targetW = Math.max(node.size[0], 240); const galleryY = galleryWidget.last_y || 40;
let targetH = forceHeight !== null ? forceHeight : node.size[1]; const minOutputsHeight = (node.outputs ? node.outputs.length : 1) * 20;
const absoluteMinHeight = Math.max(galleryY + 250 + paddingBottom, minOutputsHeight + 40);
if (isFirstLayout) { node.min_size = [minW, absoluteMinHeight];
targetH = 0; // Force shrink to minimum bounds on initial load enforceV3CSS();
isFirstLayout = false;
}
// Enforce minimum height let targetW = Math.max(node.size[0], minW);
targetH = Math.max(targetH, minNodeHeight); let targetH = forceShrink ? absoluteMinHeight : node.size[1];
targetH = Math.max(targetH, absoluteMinHeight);
if (node.size[0] !== targetW || node.size[1] !== targetH) { if (node.size[0] !== targetW || node.size[1] !== targetH) {
node.setSize([targetW, targetH]); node.setSize([targetW, targetH]);
app.graph.setDirtyCanvas(true, true); app.graph.setDirtyCanvas(true, true);
} }
const availableGalleryHeight = targetH - baseHeight - 15; const availableGalleryHeight = targetH - galleryY - paddingBottom;
container.style.height = availableGalleryHeight + "px"; container.style.height = availableGalleryHeight + "px";
// Recalculate and stretch image squares using the new node space
optimizeGrid(targetW, availableGalleryHeight);
isLayouting = false; isLayouting = false;
} }
// Intercept user dragging the node corner to strictly enforce size constraints // --- OVERRIDE LOGIC FOR RESIZING ---
const origOnResize = node.onResize; const origOnResize = node.onResize;
node.onResize = function(size) { node.onResize = function(size) {
const isV3 = checkIsV3();
const minW = isV3 ? 100 : 220; // Adjust limits based on frontend
const paddingBottom = isV3 ? 15 : 25; // Apply extra 20px pad on V1
const galleryY = galleryWidget.last_y || 40;
const minOutputsHeight = (this.outputs ? this.outputs.length : 1) * 20;
const absoluteMinHeight = Math.max(galleryY + 250 + paddingBottom, minOutputsHeight + 40);
size[0] = Math.max(size[0], minW);
size[1] = Math.max(size[1], absoluteMinHeight);
if (origOnResize) origOnResize.call(this, size); if (origOnResize) origOnResize.call(this, size);
if (isLayouting) return; // Prevent duplicate cycles if (isLayouting) return;
const { baseHeight, minNodeHeight } = getGalleryHeights(); node.min_size = [minW, absoluteMinHeight];
enforceV3CSS();
// Prevent user from making node smaller than the minimum content const availableGalleryHeight = size[1] - galleryY - paddingBottom;
size[0] = Math.max(size[0], 240);
size[1] = Math.max(size[1], minNodeHeight);
// Immediately apply the fluid heights for smooth dragging
const availableGalleryHeight = size[1] - baseHeight - 15;
container.style.height = availableGalleryHeight + "px"; container.style.height = availableGalleryHeight + "px";
// Expand the image slots instantly as you drag
optimizeGrid(size[0], availableGalleryHeight);
}; };
// Auto-adjust layout if dimensions change programmatically (e.g., undo/redo wrap reflows) const origComputeSize = node.computeSize;
let lastWidth = -1; node.computeSize = function(out) {
let lastHeight = -1; const isV3 = checkIsV3();
const resizeObserver = new ResizeObserver((entries) => { const minW = isV3 ? 100 : 220;
for (const entry of entries) { const paddingBottom = isV3 ? 15 : 25;
const currentWidth = entry.contentRect.width;
const currentHeight = entry.contentRect.height; let res = origComputeSize ? origComputeSize.apply(this, arguments) : [minW, 250];
if ((lastWidth !== -1 && Math.abs(currentWidth - lastWidth) > 2) || const galleryY = galleryWidget.last_y || 40;
(lastHeight !== -1 && Math.abs(currentHeight - lastHeight) > 2)) { const minOutputsHeight = (this.outputs ? this.outputs.length : 1) * 20;
requestAnimationFrame(() => updateLayout()); const absoluteMinHeight = Math.max(galleryY + 250 + paddingBottom, minOutputsHeight + 40);
this.min_size = [minW, absoluteMinHeight];
res[0] = Math.max(res[0], minW);
res[1] = Math.max(res[1], absoluteMinHeight);
enforceV3CSS();
return res;
};
const origSetSize = node.setSize;
node.setSize = function(size) {
const isV3 = checkIsV3();
const minW = isV3 ? 100 : 220;
const paddingBottom = isV3 ? 15 : 25;
const galleryY = galleryWidget.last_y || 40;
const minOutputsHeight = (this.outputs ? this.outputs.length : 1) * 20;
const absoluteMinHeight = Math.max(galleryY + 250 + paddingBottom, minOutputsHeight + 40);
size[0] = Math.max(size[0], minW);
size[1] = Math.max(size[1], absoluteMinHeight);
if (origSetSize) {
origSetSize.call(this, size);
} else {
this.size = size;
}
enforceV3CSS();
};
let lastObservedWidth = 0;
let lastObservedHeight = 0;
const resizeObserver = new ResizeObserver((entries) => {
enforceV3CSS();
for (const entry of entries) {
const w = Math.round(entry.contentRect.width);
const h = Math.round(entry.contentRect.height);
if (Math.abs(w - lastObservedWidth) > 1 || Math.abs(h - lastObservedHeight) > 1) {
lastObservedWidth = w;
lastObservedHeight = h;
if (h > 0) {
optimizeGrid(w, h);
}
} }
lastWidth = currentWidth;
lastHeight = currentHeight;
} }
}); });
resizeObserver.observe(container); resizeObserver.observe(gridWrapper);
// --- 3. Logic: Gallery Rendering --- // --- 3. Logic: Gallery Rendering ---
let draggedNode = null; let draggedNode = null;
@@ -327,14 +393,12 @@ app.registerExtension({
if (!isRearranging) { if (!isRearranging) {
syncOutputs(paths.length); syncOutputs(paths.length);
} }
// Cache image count on the node for easy access by connected nodes
node._imageCount = paths.length; node._imageCount = paths.length;
// Push notification: immediately tell connected nodes about the new count
notifyConnectedNodes(paths.length); notifyConnectedNodes(paths.length);
paths.forEach((path, index) => { paths.forEach((path, index) => {
const item = document.createElement("div"); const item = document.createElement("div");
item.dataset.path = path; // Store path on the node for easy retrieval item.dataset.path = path;
item.draggable = true; item.draggable = true;
item.style.cssText = ` item.style.cssText = `
position: relative; position: relative;
@@ -349,14 +413,14 @@ app.registerExtension({
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
will-change: transform;
`; `;
const img = document.createElement("img"); const img = document.createElement("img");
img.src = `/api/view?filename=${encodeURIComponent(path)}&type=input`; img.src = `/api/view?filename=${encodeURIComponent(path)}&type=input`;
img.style.cssText = "max-width: 100%; max-height: 100%; object-fit: contain; pointer-events: none; display: block;"; // Allow pointer-events so context menu interacts directly with the image
img.style.cssText = "max-width: 100%; max-height: 100%; object-fit: contain; pointer-events: auto; display: block;";
img.draggable = false; // Prevent native browser ghost dragging on the image itself
// Delete Button
const del = document.createElement("div"); const del = document.createElement("div");
del.style.cssText = ` del.style.cssText = `
position: absolute; top: 0; right: 0; position: absolute; top: 0; right: 0;
@@ -381,7 +445,6 @@ app.registerExtension({
setWidgetValue(newPaths, false); setWidgetValue(newPaths, false);
}; };
// Number Badge
const numBadge = document.createElement("div"); const numBadge = document.createElement("div");
numBadge.style.cssText = ` numBadge.style.cssText = `
position: absolute; bottom: 0; left: 0; position: absolute; bottom: 0; left: 0;
@@ -392,27 +455,37 @@ app.registerExtension({
`; `;
numBadge.innerText = (index + 1).toString(); numBadge.innerText = (index + 1).toString();
// Dynamic Animated Drag & Drop Events // Prevent LiteGraph context menu and instead show standard browser context menu (Copy, Save, Open)
item.addEventListener("contextmenu", (e) => {
e.stopPropagation();
});
item.ondragstart = (e) => { item.ondragstart = (e) => {
draggedNode = item; draggedNode = item;
// Delay opacity drop so the browser capture image remains opaque
e.dataTransfer.setData('text/plain', path);
e.dataTransfer.effectAllowed = "move";
setTimeout(() => { setTimeout(() => {
if (draggedNode === item) { if (draggedNode === item) {
item.style.opacity = "0.4"; // Style as an empty dashed placeholder
item.style.pointerEvents = "none"; // Prevent drag ghost from capturing events item.style.background = "transparent";
item.style.border = "2px dashed #666";
// Hide the visual children (image, delete button, badge)
Array.from(item.children).forEach(c => c.style.opacity = "0");
} }
}, 0); }, 0);
e.dataTransfer.effectAllowed = "move";
}; };
item.ondragend = () => { item.ondragend = () => {
if (draggedNode) { if (draggedNode) {
draggedNode.style.opacity = "1"; // Restore original appearance
draggedNode.style.pointerEvents = "auto"; draggedNode.style.background = "#000000";
draggedNode.style.border = "1px solid #444";
Array.from(draggedNode.children).forEach(c => c.style.opacity = "1");
} }
draggedNode = null; draggedNode = null;
// The DOM visually reorders during dragover. Here we finalize it to data.
const newPaths = Array.from(grid.children).map(n => n.dataset.path); const newPaths = Array.from(grid.children).map(n => n.dataset.path);
const currentVal = (pathsWidget?.value || "").trim(); const currentVal = (pathsWidget?.value || "").trim();
if (newPaths.join("\n") !== currentVal) { if (newPaths.join("\n") !== currentVal) {
@@ -422,34 +495,20 @@ app.registerExtension({
item.ondragover = (e) => { item.ondragover = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Stop ComfyUI canvas listener from grabbing this internally e.stopPropagation();
if (!draggedNode || draggedNode === item) return; if (!draggedNode || draggedNode === item) return;
// ANTI-FLICKER 1: Prevent rapid swaps if the mouse hasn't moved physically.
// Reduced delay from 300ms to 50ms and distance to 5px to drastically improve responsiveness
// while still absorbing the immediate CSS transform shock.
const distMoved = Math.hypot(e.clientX - lastSwapX, e.clientY - lastSwapY); const distMoved = Math.hypot(e.clientX - lastSwapX, e.clientY - lastSwapY);
if (Date.now() - lastSwapTime < 50 && distMoved < 5) { if (Date.now() - lastSwapTime < 50 && distMoved < 5) {
return; return;
} }
// ANTI-FLICKER 2: True logical target boundaries. const itemRect = item.getBoundingClientRect();
// Reduced the buffer from 25% down to 10%, meaning 80% of the target square const bufferX = itemRect.width * 0.25;
// is now an active drop zone, making it much easier to trigger a swap. const bufferY = itemRect.height * 0.25;
const gridRect = grid.getBoundingClientRect();
const mouseX = e.clientX - gridRect.left;
const mouseY = e.clientY - gridRect.top;
const left = item.offsetLeft; if (e.clientX < itemRect.left + bufferX || e.clientX > itemRect.right - bufferX ||
const top = item.offsetTop; e.clientY < itemRect.top + bufferY || e.clientY > itemRect.bottom - bufferY) {
const width = item.offsetWidth;
const height = item.offsetHeight;
const bufferX = width * 0.10;
const bufferY = height * 0.10;
if (mouseX < left + bufferX || mouseX > left + width - bufferX ||
mouseY < top + bufferY || mouseY > top + height - bufferY) {
return; return;
} }
@@ -457,39 +516,13 @@ app.registerExtension({
const draggedIdx = items.indexOf(draggedNode); const draggedIdx = items.indexOf(draggedNode);
const targetIdx = items.indexOf(item); const targetIdx = items.indexOf(item);
// FLIP Animation Step 1: Record old positions // Instantly snap the placeholder to its new position, moving items aside
const rects = new Map();
items.forEach(node => rects.set(node, node.getBoundingClientRect()));
// Physically move the DOM element to the new slot
if (draggedIdx < targetIdx) { if (draggedIdx < targetIdx) {
grid.insertBefore(draggedNode, item.nextSibling); grid.insertBefore(draggedNode, item.nextSibling);
} else { } else {
grid.insertBefore(draggedNode, item); grid.insertBefore(draggedNode, item);
} }
// FLIP Animation Step 2: Calculate difference and animate
items.forEach(node => {
const oldRect = rects.get(node);
const newRect = node.getBoundingClientRect();
const dx = oldRect.left - newRect.left;
const dy = oldRect.top - newRect.top;
if (dx !== 0 || dy !== 0) {
// Instantly shift it visually back to the old spot
node.style.transition = 'none';
node.style.transform = `translate(${dx}px, ${dy}px)`;
// Force browser layout recalculation
node.offsetWidth;
// Turn on transition and remove transform so it glides to its real new position
node.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)';
node.style.transform = '';
}
});
// Log the swap to lock out rapid feedback triggers
lastSwapX = e.clientX; lastSwapX = e.clientX;
lastSwapY = e.clientY; lastSwapY = e.clientY;
lastSwapTime = Date.now(); lastSwapTime = Date.now();
@@ -497,8 +530,7 @@ app.registerExtension({
item.ondrop = (e) => { item.ondrop = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Stop ComfyUI canvas listener e.stopPropagation();
// Finalization is handled safely in ondragend
}; };
item.appendChild(img); item.appendChild(img);
@@ -508,7 +540,10 @@ app.registerExtension({
}); });
if (!isRearranging) { if (!isRearranging) {
requestAnimationFrame(() => updateLayout()); requestAnimationFrame(() => {
updateLayout();
if (gridWrapper.offsetWidth > 0) optimizeGrid(gridWrapper.offsetWidth, gridWrapper.offsetHeight);
});
} }
} }
@@ -535,29 +570,61 @@ app.registerExtension({
} }
} }
// Apply drag & drop to LiteGraph node container bounds (V1)
const origOnDragDrop = node.onDragDrop;
node.onDragDrop = function(e) {
let handled = false;
if (e.dataTransfer && e.dataTransfer.files) {
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
if (files.length > 0) {
e.preventDefault();
handleFiles(files);
handled = true;
}
}
if (!handled && origOnDragDrop) {
return origOnDragDrop.apply(this, arguments);
}
return handled;
};
const origOnDragOver = node.onDragOver;
node.onDragOver = function(e) {
if (e.dataTransfer && e.dataTransfer.items) {
const hasImage = Array.from(e.dataTransfer.items).some(f => f.kind === 'file' && f.type.startsWith('image/'));
if (hasImage) {
e.preventDefault();
return true;
}
}
if (origOnDragOver) {
return origOnDragOver.apply(this, arguments);
}
return false;
};
uploadBtn.onclick = () => fileInput.click(); uploadBtn.onclick = () => fileInput.click();
fileInput.onchange = (e) => handleFiles(e.target.files); fileInput.onchange = (e) => handleFiles(e.target.files);
container.ondragover = (e) => { container.ondragover = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from seeing the drag event over this node e.stopPropagation();
container.style.borderColor = "#4CAF50"; container.style.borderColor = "#4CAF50";
}; };
container.ondragleave = (e) => { container.ondragleave = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from seeing the drag event leave e.stopPropagation();
container.style.borderColor = "#353545"; container.style.borderColor = "#353545";
}; };
container.ondrop = (e) => { container.ondrop = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Crucial: Stop ComfyUI from capturing the dropped file and making a LoadImage node! e.stopPropagation();
container.style.borderColor = "#353545"; container.style.borderColor = "#353545";
if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files); if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
}; };
// --- 5. Logic: Paste Handling --- // --- 5. Logic: Paste Handling ---
const pasteHandler = (e) => { const pasteHandler = (e) => {
// Only capture the paste if THIS specific node is currently selected in the graph
if (app.canvas.selected_nodes && app.canvas.selected_nodes[node.id]) { if (app.canvas.selected_nodes && app.canvas.selected_nodes[node.id]) {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
if (!items) return; if (!items) return;
@@ -571,23 +638,21 @@ app.registerExtension({
if (files.length > 0) { if (files.length > 0) {
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); // Crucial: Stops ComfyUI from turning the pasted image into a "Load Image" node e.stopImmediatePropagation();
handleFiles(files); handleFiles(files);
} }
} }
}; };
// Use capture: true to intercept the event BEFORE ComfyUI's default global paste listener triggers
document.addEventListener("paste", pasteHandler, { capture: true }); document.addEventListener("paste", pasteHandler, { capture: true });
// Clean up the global event listener if the node is deleted
const origOnRemoved = node.onRemoved; const origOnRemoved = node.onRemoved;
node.onRemoved = function() { node.onRemoved = function() {
document.removeEventListener("paste", pasteHandler, { capture: true }); document.removeEventListener("paste", pasteHandler, { capture: true });
resizeObserver.disconnect();
if (origOnRemoved) origOnRemoved.apply(this, arguments); if (origOnRemoved) origOnRemoved.apply(this, arguments);
}; };
// Hooks the main callback for external state loads (e.g., undo/redo or initial graph load)
if (pathsWidget) { if (pathsWidget) {
pathsWidget.callback = (v) => { pathsWidget.callback = (v) => {
if (oldCallback) oldCallback.apply(pathsWidget, [v]); if (oldCallback) oldCallback.apply(pathsWidget, [v]);
@@ -595,6 +660,29 @@ app.registerExtension({
}; };
} }
// Run immediately to trim blank outputs before LiteGraph does its first layout calculations
refreshGallery();
// Enforce the tightest possible packing explicitly upon being dropped into the graph (V1 specifically)
const origOnAdded = node.onAdded;
node.onAdded = function() {
if (origOnAdded) origOnAdded.apply(this, arguments);
const isV3 = checkIsV3();
if (!isV3) {
requestAnimationFrame(() => {
const galleryY = galleryWidget.last_y || 40;
const minOutputsHeight = (this.outputs ? this.outputs.length : 1) * 20;
const paddingBottom = 25; // Apply extra 20px pad on V1
const absoluteMinHeight = Math.max(galleryY + 250 + paddingBottom, minOutputsHeight + 40);
// Force the node to snap to its absolute minimum size on initial drop
if (this.size && this.size[1] > absoluteMinHeight + 5) {
this.setSize([this.size[0], absoluteMinHeight]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
});
}
};
setTimeout(() => refreshGallery(), 100); setTimeout(() => refreshGallery(), 100);
} }
}); });

View File

@@ -15,8 +15,10 @@ class SpeechLengthCalculator:
} }
} }
RETURN_TYPES = ("INT", "INT", "INT") # Added "STRING" to RETURN_TYPES
RETURN_NAMES = ("slow_frame_count", "average_frame_count", "fast_frame_count") RETURN_TYPES = ("INT", "INT", "INT", "STRING")
# Added "text" to RETURN_NAMES
RETURN_NAMES = ("slow_frame_count", "average_frame_count", "fast_frame_count", "text")
FUNCTION = "calculate_speech" FUNCTION = "calculate_speech"
CATEGORY = "text/speech" CATEGORY = "text/speech"
@@ -45,4 +47,5 @@ class SpeechLengthCalculator:
avg_frames = calc_frames(130) avg_frames = calc_frames(130)
fast_frames = calc_frames(160) fast_frames = calc_frames(160)
return (slow_frames, avg_frames, fast_frames) # Added active_text as the 4th returned value
return (slow_frames, avg_frames, fast_frames, active_text)