From fb6fddc8ad68ce7f6c4a78402cad88a3e785a810 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:41:25 +0300 Subject: [PATCH] Display of the image grid in the batch mode of the RS Decode Save Image node has been changed --- web/rs_vae_save_image.js | 220 ++++++++++++++++++--------------------- 1 file changed, 103 insertions(+), 117 deletions(-) diff --git a/web/rs_vae_save_image.js b/web/rs_vae_save_image.js index 020a922..5420645 100644 --- a/web/rs_vae_save_image.js +++ b/web/rs_vae_save_image.js @@ -1,8 +1,8 @@ import { app } from "../../scripts/app.js"; const NODE_TYPE = "RS_VAE_Decode_Save"; -const MIN_WIDTH = 320; -const MIN_HEIGHT = 500; +const MIN_WIDTH = 240; +const MIN_HEIGHT = 320; const PREVIEW_GAP = 5; const CLOSE_BTN_SIZE = 24; @@ -22,14 +22,12 @@ app.registerExtension({ const self = this; - // Скрываем стандартные виджеты ComfyUI if (this.widgets) { for (let i = 0; i < this.widgets.length; i++) { this.widgets[i].hidden = true; } } - // Инициализация состояния this.rs_data = { save_path: "", file_prefix: "img", format: "png" }; const dataW = this.widgets?.find(w => w.name === "node_data"); @@ -37,6 +35,15 @@ app.registerExtension({ const prefixW = this.widgets?.find(w => w.name === "file_prefix"); const formatW = this.widgets?.find(w => w.name === "format"); + this.activePopup = null; + + this.closeActivePopup = function() { + if (this.activePopup) { + this.activePopup.remove(); + this.activePopup = null; + } + }; + this.applyState = function() { if (pathW) pathW.value = self.rs_data.save_path; if (prefixW) prefixW.value = self.rs_data.file_prefix; @@ -60,26 +67,22 @@ app.registerExtension({ return path; }; - // Настройки размеров и отступов this.rowHeight = 24; this.padding = 20; this.labelWidth = 70; this.clickZones = []; this.widgetsHeight = 0; - // Хранилище для всех кадров батча this.imgs = []; this.imageIndex = 0; - this.previewMode = 'grid'; // 'grid' или 'view' + this.previewMode = 'grid'; this.outputFolders = []; this.foldersLoaded = false; - // Устанавливаем дефолтный И минимальный размер ноды this.setSize([MIN_WIDTH, MIN_HEIGHT]); this.min_size = [MIN_WIDTH, MIN_HEIGHT]; - // Защита от сжатия ниже минимума this.onResize = function() { if (this.size[0] < MIN_WIDTH) this.size[0] = MIN_WIDTH; if (this.size[1] < MIN_HEIGHT) this.size[1] = MIN_HEIGHT; @@ -101,7 +104,6 @@ app.registerExtension({ }; this.loadOutputFolders(); - // Обработка данных от сервера (ПОЛНЫЙ БАТЧ) const onExecuted = this.onExecuted; this.onExecuted = function (message) { const r = onExecuted ? onExecuted.apply(this, arguments) : undefined; @@ -109,16 +111,13 @@ app.registerExtension({ if (message?.images && message.images.length > 0) { this.imgs = []; this.imageIndex = 0; - this.previewMode = 'grid'; // Сброс в режим сетки при новом батче + this.previewMode = 'grid'; - // Загружаем ВСЕ изображения из батча for (const image of message.images) { const img = new Image(); - img.onload = () => { if (this.graph) this.graph.setDirtyCanvas(true, true); }; - img.onerror = () => {}; img.src = `/view?filename=${encodeURIComponent(image.filename)}&type=${image.type}&subfolder=${encodeURIComponent(image.subfolder || '')}`; this.imgs.push(img); @@ -127,7 +126,29 @@ app.registerExtension({ return r; }; - // Отрисовка превью НА ЗАДНЕМ ПЛАНЕ + function calcGrid(count, availW, availH) { + if (count <= 1) { + return { cols: 1, rows: 1, cellSize: Math.min(availW, availH) }; + } + const maxCols = Math.min(count, Math.floor((availW + PREVIEW_GAP) / (1 + PREVIEW_GAP))); + let bestCols = 1; + let bestSize = 0; + for (let c = 1; c <= maxCols; c++) { + const rows = Math.ceil(count / c); + const cellW = (availW - PREVIEW_GAP * (c - 1)) / c; + const cellH = (availH - PREVIEW_GAP * (rows - 1)) / rows; + const size = Math.min(cellW, cellH); + if (size > bestSize) { + bestSize = size; + bestCols = c; + } else if (size === bestSize && c > bestCols) { + bestCols = c; + } + } + const rows = Math.ceil(count / bestCols); + return { cols: bestCols, rows: rows, cellSize: bestSize }; + } + this.onDrawBackground = function(ctx) { ctx.save(); try { @@ -139,62 +160,47 @@ app.registerExtension({ if (availableW <= 0 || availableH <= 0) return; - // --- ПРОСТОЕ ПРЕВЬЮ ДЛЯ ОДНОГО ИЗОБРАЖЕНИЯ --- if (this.imgs.length === 1) { const img = this.imgs[0]; if (!img || !img.complete) return; - // Масштабируем изображение под доступную зону const scale = Math.min(availableW / img.width, availableH / img.height); const drawW = img.width * scale; const drawH = img.height * scale; const offsetX = (availableW - drawW) / 2; const offsetY = (availableH - drawH) / 2; - const imgX = this.padding + offsetX; - const imgY = startY + offsetY; - - // Рисуем изображение (без рамок ячеек и без крестика) try { - ctx.drawImage(img, imgX, imgY, drawW, drawH); + ctx.drawImage(img, this.padding + offsetX, startY + offsetY, drawW, drawH); } catch (e) {} - return; // Выходим, дальше идет логика только для батча + return; } - // --- ЛОГИКА ТОЛЬКО ДЛЯ ДВУХ И БОЛЕЕ ИЗОБРАЖЕНИЙ --- if (this.previewMode === 'grid') { - // --- АДАПТИВНАЯ СЕТКА (формула из RS Image Selector) --- const count = this.imgs.length; - const approxSide = Math.sqrt((availableW * availableH) / count); - let cols = Math.floor(availableW / approxSide); - if (cols < 1) cols = 1; - const rows = Math.ceil(count / cols); + const grid = calcGrid(count, availableW, availableH); + const cols = grid.cols; + const cellSize = grid.cellSize; - const cellW = (availableW - PREVIEW_GAP * (cols - 1)) / cols; - const cellH = (availableH - PREVIEW_GAP * (rows - 1)) / rows; + const totalWidth = cols * cellSize + (cols - 1) * PREVIEW_GAP; + const offsetX_extra = (availableW - totalWidth) / 2; for (let i = 0; i < this.imgs.length; i++) { const img = this.imgs[i]; const col = i % cols; const row = Math.floor(i / cols); - const x = this.padding + col * (cellW + PREVIEW_GAP); - const y = startY + row * (cellH + PREVIEW_GAP); + const x = this.padding + offsetX_extra + col * (cellSize + PREVIEW_GAP); + const y = startY + row * (cellSize + PREVIEW_GAP); - // Фон ячейки - ctx.fillStyle = "#232323"; - ctx.fillRect(x, y, cellW, cellH); - ctx.strokeStyle = "#444"; - ctx.lineWidth = 1; - ctx.strokeRect(x, y, cellW, cellH); - - // Рисуем миниатюру с сохранением пропорций (letterbox) if (img.complete && img.naturalWidth > 0) { - const scale = Math.min(cellW / img.width, cellH / img.height); + const maxDim = Math.max(img.width, img.height); + const scale = cellSize / maxDim; const drawW = img.width * scale; const drawH = img.height * scale; - const offsetX = (cellW - drawW) / 2; - const offsetY = (cellH - drawH) / 2; + + const offsetX = (cellSize - drawW) / 2; + const offsetY = (cellSize - drawH) / 2; try { ctx.drawImage(img, x + offsetX, y + offsetY, drawW, drawH); @@ -202,36 +208,27 @@ app.registerExtension({ } } } else { - // --- РЕЖИМ ПРОСМОТРА (только для батча) --- const img = this.imgs[this.imageIndex]; if (!img || !img.complete) return; - // Масштабируем изображение под доступную зону const scale = Math.min(availableW / img.width, availableH / img.height); const drawW = img.width * scale; const drawH = img.height * scale; const offsetX = (availableW - drawW) / 2; const offsetY = (availableH - drawH) / 2; - const imgX = this.padding + offsetX; - const imgY = startY + offsetY; - - // Рисуем изображение try { - ctx.drawImage(img, imgX, imgY, drawW, drawH); + ctx.drawImage(img, this.padding + offsetX, startY + offsetY, drawW, drawH); } catch (e) {} - // Рисуем кнопку закрытия [×] в правом верхнем углу зоны превью const btnX = this.size[0] - this.padding - CLOSE_BTN_SIZE; const btnY = startY; - // Фон кнопки для кликабельности ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; ctx.beginPath(); ctx.arc(btnX + CLOSE_BTN_SIZE/2, btnY + CLOSE_BTN_SIZE/2, CLOSE_BTN_SIZE/2, 0, Math.PI * 2); ctx.fill(); - // Сам крестик ctx.strokeStyle = "#fff"; ctx.lineWidth = 2; ctx.lineCap = "round"; @@ -248,7 +245,6 @@ app.registerExtension({ } }; - // Отрисовка виджетов НА ПЕРЕДНЕМ ПЛАНЕ const origODF = this.onDrawForeground; this.onDrawForeground = function (ctx, vr) { ctx.save(); @@ -286,7 +282,6 @@ app.registerExtension({ } }; - // Вспомогательные методы отрисовки UI this.drawLabel = function (ctx, t, x, y, w, h) { ctx.fillStyle = "#aaa"; ctx.font = "11px sans-serif"; @@ -334,33 +329,28 @@ app.registerExtension({ ctx.fill(); }; - // Обработка кликов this.onMouseDown = function (e, pos, canvas) { const availableW = this.size[0] - this.padding * 2; const availableH = this.size[1] - this.widgetsHeight - this.padding * 2; const startY = this.widgetsHeight + this.padding; - // Для одного изображения никаких действий с превью не требуется - if (this.imgs.length === 1) { - // Просто проверяем виджеты - for (const z of this.clickZones) { - if (pos[0] >= z.x && pos[0] <= z.x + z.w && - pos[1] >= z.y && pos[1] <= z.y + z.h) { - if (z.type === "path") { self.showPathInput(e); return true; } - if (z.type === "browse") { self.showFolderSelector(e); return true; } - if (z.type === "prefix") { self.showPrefixInput(e); return true; } - if (z.type === "format") { self.showFormatSelector(e); return true; } - } + for (const z of this.clickZones) { + if (pos[0] >= z.x && pos[0] <= z.x + z.w && + pos[1] >= z.y && pos[1] <= z.y + z.h) { + if (z.type === "path") { self.showPathInput(e); return true; } + if (z.type === "browse") { self.showFolderSelector(e); return true; } + if (z.type === "prefix") { self.showPrefixInput(e); return true; } + if (z.type === "format") { self.showFormatSelector(e); return true; } } + } + + if (this.imgs.length === 1) { return false; } - // --- ЛОГИКА ТОЛЬКО ДЛЯ ДВУХ И БОЛЕЕ ИЗОБРАЖЕНИЙ --- if (this.previewMode === 'view' && this.imgs.length > 0) { - // Проверка клика по кнопке закрытия [×] const btnX = this.size[0] - this.padding - CLOSE_BTN_SIZE; const btnY = startY; - const centerX = btnX + CLOSE_BTN_SIZE / 2; const centerY = btnY + CLOSE_BTN_SIZE / 2; const dist = Math.sqrt(Math.pow(pos[0] - centerX, 2) + Math.pow(pos[1] - centerY, 2)); @@ -373,25 +363,23 @@ app.registerExtension({ } if (this.previewMode === 'grid' && this.imgs.length > 0) { - // Проверка клика по ячейке сетки (та же формула, что и в отрисовке) const count = this.imgs.length; - const approxSide = Math.sqrt((availableW * availableH) / count); - let cols = Math.floor(availableW / approxSide); - if (cols < 1) cols = 1; - const rows = Math.ceil(count / cols); + const grid = calcGrid(count, availableW, availableH); + const cols = grid.cols; + const cellSize = grid.cellSize; - const cellW = (availableW - PREVIEW_GAP * (cols - 1)) / cols; - const cellH = (availableH - PREVIEW_GAP * (rows - 1)) / rows; + const totalWidth = cols * cellSize + (cols - 1) * PREVIEW_GAP; + const offsetX_extra = (availableW - totalWidth) / 2; for (let i = 0; i < this.imgs.length; i++) { const col = i % cols; const row = Math.floor(i / cols); - const x = this.padding + col * (cellW + PREVIEW_GAP); - const y = startY + row * (cellH + PREVIEW_GAP); + const x = this.padding + offsetX_extra + col * (cellSize + PREVIEW_GAP); + const y = startY + row * (cellSize + PREVIEW_GAP); - if (pos[0] >= x && pos[0] <= x + cellW && - pos[1] >= y && pos[1] <= y + cellH) { + if (pos[0] >= x && pos[0] <= x + cellSize && + pos[1] >= y && pos[1] <= y + cellSize) { this.imageIndex = i; this.previewMode = 'view'; if (this.graph) this.graph.setDirtyCanvas(true, true); @@ -400,21 +388,12 @@ app.registerExtension({ } } - // Проверка зон виджетов - for (const z of this.clickZones) { - if (pos[0] >= z.x && pos[0] <= z.x + z.w && - pos[1] >= z.y && pos[1] <= z.y + z.h) { - if (z.type === "path") { self.showPathInput(e); return true; } - if (z.type === "browse") { self.showFolderSelector(e); return true; } - if (z.type === "prefix") { self.showPrefixInput(e); return true; } - if (z.type === "format") { self.showFormatSelector(e); return true; } - } - } return false; }; - // Меню выбора папки this.showFolderSelector = function (ev) { + self.closeActivePopup(); + if (!self.foldersLoaded) { self.loadOutputFolders().then(() => self.showFolderSelector(ev)); return; @@ -422,6 +401,7 @@ app.registerExtension({ const menu = document.createElement("div"); menu.style.cssText = 'position:fixed;background:#1a1a1a;border:1px solid #444;border-radius:6px;overflow:hidden;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:200px;max-height:350px;overflow-y:auto;'; + self.activePopup = menu; const rootItem = document.createElement("div"); rootItem.textContent = "ComfyUI"; @@ -435,7 +415,7 @@ app.registerExtension({ rootItem.onclick = (e) => { e.stopPropagation(); e.preventDefault(); self.rs_data.save_path = ""; - self.persistState(); self.updateUI(); menu.remove(); + self.persistState(); self.updateUI(); self.closeActivePopup(); }; menu.appendChild(rootItem); @@ -446,7 +426,7 @@ app.registerExtension({ customItem.onmouseout = () => customItem.style.background = "#1a1a1a"; customItem.onclick = (e) => { e.stopPropagation(); e.preventDefault(); - menu.remove(); + self.closeActivePopup(); self.showPathInput(ev); }; menu.appendChild(customItem); @@ -474,7 +454,7 @@ app.registerExtension({ item.onclick = (e) => { e.stopPropagation(); e.preventDefault(); self.rs_data.save_path = folder; - self.persistState(); self.updateUI(); menu.remove(); + self.persistState(); self.updateUI(); self.closeActivePopup(); }; menu.appendChild(item); }); @@ -487,23 +467,22 @@ app.registerExtension({ document.body.appendChild(menu); setTimeout(() => { - const closeHandler = (e) => { if (!menu.contains(e.target)) { cleanup(); } }; - const mouseLeaveHandler = () => { cleanup(); }; - const cleanup = () => { - menu.remove(); - document.removeEventListener("mousedown", closeHandler); - menu.removeEventListener("mouseleave", mouseLeaveHandler); + const closeHandler = (e) => { + if (self.activePopup === menu && !menu.contains(e.target)) { + self.closeActivePopup(); + } }; document.addEventListener("mousedown", closeHandler); - menu.addEventListener("mouseleave", mouseLeaveHandler); }, 100); }; - // Поле ввода пути this.showPathInput = function (ev) { + self.closeActivePopup(); const cv = self.rs_data.save_path || ''; const pop = document.createElement('div'); pop.style.cssText = 'position:fixed;z-index:10002;background:#1a1a1a;border:1px solid #444;border-radius:6px;padding:8px 12px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:8px;'; + self.activePopup = pop; + const inp = document.createElement('input'); inp.type = 'text'; inp.value = cv; @@ -520,19 +499,23 @@ app.registerExtension({ document.body.appendChild(pop); setTimeout(() => { inp.focus(); if (cv.length) inp.select(); }, 50); - const save = () => { self.rs_data.save_path = inp.value; self.persistState(); self.updateUI(); cleanup(); }; - const cleanup = () => { pop.remove(); document.removeEventListener("mousedown", cl); }; + const save = () => { self.rs_data.save_path = inp.value; self.persistState(); self.updateUI(); self.closeActivePopup(); }; btn.onclick = (e) => { e.stopPropagation(); e.preventDefault(); save(); }; inp.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); save(); } }; - const cl = (e) => { if (!pop.contains(e.target)) { cleanup(); } }; - setTimeout(() => { document.addEventListener("mousedown", cl); }, 50); + + setTimeout(() => { + const cl = (e) => { if (self.activePopup === pop && !pop.contains(e.target)) { self.closeActivePopup(); } }; + document.addEventListener("mousedown", cl); + }, 50); }; - // Поле ввода префикса this.showPrefixInput = function (ev) { + self.closeActivePopup(); const cv = self.rs_data.file_prefix || 'img'; const pop = document.createElement('div'); pop.style.cssText = 'position:fixed;z-index:10002;background:#1a1a1a;border:1px solid #444;border-radius:6px;padding:8px 12px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;align-items:center;gap:8px;'; + self.activePopup = pop; + const inp = document.createElement('input'); inp.type = 'text'; inp.value = cv; @@ -548,19 +531,23 @@ app.registerExtension({ document.body.appendChild(pop); setTimeout(() => { inp.focus(); if (cv.length) inp.select(); }, 50); - const save = () => { self.rs_data.file_prefix = inp.value; self.persistState(); self.updateUI(); cleanup(); }; - const cleanup = () => { pop.remove(); document.removeEventListener("mousedown", cl); }; + const save = () => { self.rs_data.file_prefix = inp.value; self.persistState(); self.updateUI(); self.closeActivePopup(); }; btn.onclick = (e) => { e.stopPropagation(); e.preventDefault(); save(); }; inp.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); save(); } }; - const cl = (e) => { if (!pop.contains(e.target)) { cleanup(); } }; - setTimeout(() => { document.addEventListener("mousedown", cl); }, 50); + + setTimeout(() => { + const cl = (e) => { if (self.activePopup === pop && !pop.contains(e.target)) { self.closeActivePopup(); } }; + document.addEventListener("mousedown", cl); + }, 50); }; - // Выбор формата this.showFormatSelector = function (ev) { + self.closeActivePopup(); const FMTS = ["png", "jpg", "webp"]; const menu = document.createElement("div"); menu.style.cssText = 'position:fixed;background:#1a1a1a;border:1px solid #444;border-radius:6px;overflow:hidden;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:120px;'; + self.activePopup = menu; + FMTS.forEach(f => { const it = document.createElement("div"); it.textContent = f.toUpperCase(); @@ -573,14 +560,14 @@ app.registerExtension({ self.rs_data.format = f; self.persistState(); self.updateUI(); - menu.remove(); + self.closeActivePopup(); }; menu.appendChild(it); }); if (ev) { menu.style.left = (ev.clientX + 8) + "px"; menu.style.top = (ev.clientY + 8) + "px"; } document.body.appendChild(menu); setTimeout(() => { - const cl = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener("mousedown", cl); } }; + const cl = (e) => { if (self.activePopup === menu && !menu.contains(e.target)) { self.closeActivePopup(); } }; document.addEventListener("mousedown", cl); }, 100); }; @@ -589,7 +576,6 @@ app.registerExtension({ if (self.graph) self.graph.setDirtyCanvas(true, true); }; - // Восстановление состояния при загрузке воркфлоу const originalOnConfigure = this.onConfigure; this.onConfigure = function(info) { const r = originalOnConfigure ? originalOnConfigure.apply(this, arguments) : undefined;