From 4258a565b56545f67dbcf8d27532803aa2da1759 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:09:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1hanged=20the=20logic=20of=20the=20RS=20De?= =?UTF-8?q?code=20Save=20Image=20node=20in=20batch=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rayko_VAE_Save_Image.py | 38 ++--- __init__.py | 2 +- package.json | 2 +- pyproject.toml | 2 +- web/rs_vae_save_image.js | 293 ++++++++++++++++++++++++++++++++++----- 5 files changed, 274 insertions(+), 63 deletions(-) diff --git a/Rayko_VAE_Save_Image.py b/Rayko_VAE_Save_Image.py index 1f5f5b0..2886d1e 100644 --- a/Rayko_VAE_Save_Image.py +++ b/Rayko_VAE_Save_Image.py @@ -126,7 +126,7 @@ class RS_VAE_Decode_Save: return now = time.time() - cutoff = now - 7200 + cutoff = now - 7200 for filename in os.listdir(temp_dir): if filename.startswith("rs_prev_"): @@ -151,11 +151,9 @@ class RS_VAE_Decode_Save: if is_absolute: target_dir = os.path.normpath(clean_path) - is_internal = False else: safe_path = self._sanitize_path_component(clean_path) target_dir = os.path.join(self.output_dir, safe_path) if safe_path else self.output_dir - is_internal = True try: os.makedirs(target_dir, exist_ok=True) @@ -208,28 +206,18 @@ class RS_VAE_Decode_Save: img.save(filepath_main, **save_kwargs) - if is_internal: - rel_subfolder = os.path.relpath(target_dir, self.output_dir) - if rel_subfolder == ".": rel_subfolder = "" - - saved_files.append({ - "filename": filename_main, - "subfolder": rel_subfolder, - "type": "output" - }) - else: - temp_name = f"rs_prev_{int(time.time() * 1000)}_{i}.png" - filepath_temp = os.path.join(temp_dir, temp_name) - img.save(filepath_temp, compress_level=1) - - if i == batch_size - 1: - self.last_temp_file = temp_name + temp_name = f"rs_prev_{int(time.time() * 1000)}_{i}.png" + filepath_temp = os.path.join(temp_dir, temp_name) + img.save(filepath_temp, compress_level=1) + + if i == batch_size - 1: + self.last_temp_file = temp_name - saved_files.append({ - "filename": temp_name, - "subfolder": "", - "type": "temp" - }) + saved_files.append({ + "filename": temp_name, + "subfolder": "", + "type": "temp" + }) return {"ui": {"images": saved_files}, "result": (images,)} @@ -256,5 +244,5 @@ NODE_CLASS_MAPPINGS = { "RS_VAE_Decode_Save": RS_VAE_Decode_Save, } NODE_DISPLAY_NAME_MAPPINGS = { - "RS_VAE_Decode_Save": "🦊 RS Decode Save Image", + "RS_VAE_Decode_Save": "🦊 RS Decode Save", } \ No newline at end of file diff --git a/__init__.py b/__init__.py index ba632fd..eb7ea74 100644 --- a/__init__.py +++ b/__init__.py @@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'} REQUIRED_MODULES: Set[str] = set() PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio" -__version__ = "0.44.1" +__version__ = "0.44.2" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index 3c54986..c83ae31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.44.1", + "version": "0.44.2", "description": "Set of custom nodes for ComfyUI providing additional image processing capabilities", "author": "Raykosan", "license": "Apache License 2.0", diff --git a/pyproject.toml b/pyproject.toml index 28ec24d..dd22ded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI_RaykoStudio" description = "Set of custom nodes for ComfyUI providing additional image processing capabilities" -version = "0.44.1" +version = "0.44.2" license = { file = "LICENSE" } dependencies = ["torch", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"] diff --git a/web/rs_vae_save_image.js b/web/rs_vae_save_image.js index d976c62..020a922 100644 --- a/web/rs_vae_save_image.js +++ b/web/rs_vae_save_image.js @@ -1,6 +1,10 @@ import { app } from "../../scripts/app.js"; const NODE_TYPE = "RS_VAE_Decode_Save"; +const MIN_WIDTH = 320; +const MIN_HEIGHT = 500; +const PREVIEW_GAP = 5; +const CLOSE_BTN_SIZE = 24; app.registerExtension({ name: "RaykoStudio.VAESaveImage", @@ -18,12 +22,14 @@ 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"); @@ -40,9 +46,7 @@ app.registerExtension({ }; this.persistState = function () { - if (dataW) { - dataW.value = JSON.stringify(self.rs_data); - } + if (dataW) dataW.value = JSON.stringify(self.rs_data); if (pathW) pathW.value = self.rs_data.save_path; if (prefixW) prefixW.value = self.rs_data.file_prefix; if (formatW) formatW.value = self.rs_data.format; @@ -56,19 +60,31 @@ app.registerExtension({ return path; }; + // Настройки размеров и отступов this.rowHeight = 24; - this.padding = 10; + this.padding = 20; this.labelWidth = 70; - this.targetWidth = 320; this.clickZones = []; this.widgetsHeight = 0; + + // Хранилище для всех кадров батча this.imgs = []; this.imageIndex = 0; + this.previewMode = 'grid'; // 'grid' или 'view' + this.outputFolders = []; this.foldersLoaded = false; - this.setSize([this.targetWidth, 300]); - this.widgets_start_y = 131; + // Устанавливаем дефолтный И минимальный размер ноды + 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; + this.setDirtyCanvas(true, true); + }; this.loadOutputFolders = async function () { if (self.foldersLoaded) return; @@ -85,56 +101,192 @@ app.registerExtension({ }; this.loadOutputFolders(); + // Обработка данных от сервера (ПОЛНЫЙ БАТЧ) const onExecuted = this.onExecuted; this.onExecuted = function (message) { const r = onExecuted ? onExecuted.apply(this, arguments) : undefined; - if (message?.images) { + + if (message?.images && message.images.length > 0) { this.imgs = []; this.imageIndex = 0; + this.previewMode = 'grid'; // Сброс в режим сетки при новом батче + + // Загружаем ВСЕ изображения из батча for (const image of message.images) { const img = new Image(); + img.onload = () => { - this.imgs.push(img); if (this.graph) this.graph.setDirtyCanvas(true, true); }; - img.onerror = () => {}; + + img.onerror = () => {}; img.src = `/view?filename=${encodeURIComponent(image.filename)}&type=${image.type}&subfolder=${encodeURIComponent(image.subfolder || '')}`; + this.imgs.push(img); } } return r; }; - const origODF = this.onDrawForeground; - this.onDrawForeground = function (ctx, vr) { - if (origODF) origODF.apply(this, arguments); - this.clickZones = []; - const p = this.padding, lW = this.labelWidth, rH = this.rowHeight; - const iW = this.size[0] - p * 2 - lW; - let y = 45; + // Отрисовка превью НА ЗАДНЕМ ПЛАНЕ + this.onDrawBackground = function(ctx) { + ctx.save(); + try { + if (this.imgs.length === 0) return; - const btnW = 30; - const fieldW = iW - btnW - 4; + const availableW = this.size[0] - this.padding * 2; + const availableH = this.size[1] - this.widgetsHeight - this.padding * 2; + const startY = this.widgetsHeight + this.padding; - this.drawLabel(ctx, "PATH", p, y, lW, rH); - this.drawStringField(ctx, this.getDisplayPath(), p + lW, y, fieldW, rH); - this.drawBrowseButton(ctx, p + lW + fieldW + 4, y, btnW, rH); - this.clickZones.push({ type: "path", x: p + lW, y, w: fieldW, h: rH }); - this.clickZones.push({ type: "browse", x: p + lW + fieldW + 4, y, w: btnW, h: rH }); - y += rH + 4; + if (availableW <= 0 || availableH <= 0) return; - this.drawLabel(ctx, "PREFIX", p, y, lW, rH); - this.drawStringField(ctx, this.rs_data.file_prefix, p + lW, y, iW, rH); - this.clickZones.push({ type: "prefix", x: p + lW, y, w: iW, h: rH }); - y += rH + 4; + // --- ПРОСТОЕ ПРЕВЬЮ ДЛЯ ОДНОГО ИЗОБРАЖЕНИЯ --- + if (this.imgs.length === 1) { + const img = this.imgs[0]; + if (!img || !img.complete) return; - this.drawLabel(ctx, "FORMAT", p, y, lW, rH); - this.drawComboField(ctx, this.rs_data.format.toUpperCase(), p + lW, y, iW, rH); - this.clickZones.push({ type: "format", x: p + lW, y, w: iW, h: rH }); - y += rH + 10; + // Масштабируем изображение под доступную зону + 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; - this.widgetsHeight = y; + const imgX = this.padding + offsetX; + const imgY = startY + offsetY; + + // Рисуем изображение (без рамок ячеек и без крестика) + try { + ctx.drawImage(img, imgX, imgY, drawW, drawH); + } catch (e) {} + 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 cellW = (availableW - PREVIEW_GAP * (cols - 1)) / cols; + const cellH = (availableH - PREVIEW_GAP * (rows - 1)) / rows; + + 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); + + // Фон ячейки + 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 drawW = img.width * scale; + const drawH = img.height * scale; + const offsetX = (cellW - drawW) / 2; + const offsetY = (cellH - drawH) / 2; + + try { + ctx.drawImage(img, x + offsetX, y + offsetY, drawW, drawH); + } catch (e) {} + } + } + } 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); + } 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"; + const pad = 6; + ctx.beginPath(); + ctx.moveTo(btnX + pad, btnY + pad); + ctx.lineTo(btnX + CLOSE_BTN_SIZE - pad, btnY + CLOSE_BTN_SIZE - pad); + ctx.moveTo(btnX + CLOSE_BTN_SIZE - pad, btnY + pad); + ctx.lineTo(btnX + pad, btnY + CLOSE_BTN_SIZE - pad); + ctx.stroke(); + } + } finally { + ctx.restore(); + } }; + // Отрисовка виджетов НА ПЕРЕДНЕМ ПЛАНЕ + const origODF = this.onDrawForeground; + this.onDrawForeground = function (ctx, vr) { + ctx.save(); + try { + if (origODF) origODF.apply(this, arguments); + + this.clickZones = []; + const p = this.padding, lW = this.labelWidth, rH = this.rowHeight; + const iW = this.size[0] - p * 2 - lW; + let y = 45; + + const btnW = 30; + const fieldW = iW - btnW - 4; + + this.drawLabel(ctx, "PATH", p, y, lW, rH); + this.drawStringField(ctx, this.getDisplayPath(), p + lW, y, fieldW, rH); + this.drawBrowseButton(ctx, p + lW + fieldW + 4, y, btnW, rH); + this.clickZones.push({ type: "path", x: p + lW, y, w: fieldW, h: rH }); + this.clickZones.push({ type: "browse", x: p + lW + fieldW + 4, y, w: btnW, h: rH }); + y += rH + 4; + + this.drawLabel(ctx, "PREFIX", p, y, lW, rH); + this.drawStringField(ctx, this.rs_data.file_prefix, p + lW, y, iW, rH); + this.clickZones.push({ type: "prefix", x: p + lW, y, w: iW, h: rH }); + y += rH + 4; + + this.drawLabel(ctx, "FORMAT", p, y, lW, rH); + this.drawComboField(ctx, this.rs_data.format.toUpperCase(), p + lW, y, iW, rH); + this.clickZones.push({ type: "format", x: p + lW, y, w: iW, h: rH }); + y += rH + 10; + + this.widgetsHeight = y; + } finally { + ctx.restore(); + } + }; + + // Вспомогательные методы отрисовки UI this.drawLabel = function (ctx, t, x, y, w, h) { ctx.fillStyle = "#aaa"; ctx.font = "11px sans-serif"; @@ -182,7 +334,73 @@ 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; } + } + } + 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)); + + if (dist <= CLOSE_BTN_SIZE / 2) { + this.previewMode = 'grid'; + if (this.graph) this.graph.setDirtyCanvas(true, true); + return true; + } + } + + 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 cellW = (availableW - PREVIEW_GAP * (cols - 1)) / cols; + const cellH = (availableH - PREVIEW_GAP * (rows - 1)) / rows; + + 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); + + if (pos[0] >= x && pos[0] <= x + cellW && + pos[1] >= y && pos[1] <= y + cellH) { + this.imageIndex = i; + this.previewMode = 'view'; + if (this.graph) this.graph.setDirtyCanvas(true, true); + 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) { @@ -195,6 +413,7 @@ app.registerExtension({ return false; }; + // Меню выбора папки this.showFolderSelector = function (ev) { if (!self.foldersLoaded) { self.loadOutputFolders().then(() => self.showFolderSelector(ev)); @@ -221,7 +440,7 @@ app.registerExtension({ menu.appendChild(rootItem); const customItem = document.createElement("div"); - customItem.textContent = "✏️ Custom path..."; + customItem.textContent = "️ Custom path..."; customItem.style.cssText = 'padding:8px 15px;cursor:pointer;color:#aaa;font-size:12px;border-bottom:1px solid #333;'; customItem.onmouseover = () => customItem.style.background = "#333"; customItem.onmouseout = () => customItem.style.background = "#1a1a1a"; @@ -280,6 +499,7 @@ app.registerExtension({ }, 100); }; + // Поле ввода пути this.showPathInput = function (ev) { const cv = self.rs_data.save_path || ''; const pop = document.createElement('div'); @@ -308,6 +528,7 @@ app.registerExtension({ setTimeout(() => { document.addEventListener("mousedown", cl); }, 50); }; + // Поле ввода префикса this.showPrefixInput = function (ev) { const cv = self.rs_data.file_prefix || 'img'; const pop = document.createElement('div'); @@ -335,6 +556,7 @@ app.registerExtension({ setTimeout(() => { document.addEventListener("mousedown", cl); }, 50); }; + // Выбор формата this.showFormatSelector = function (ev) { const FMTS = ["png", "jpg", "webp"]; const menu = document.createElement("div"); @@ -367,6 +589,7 @@ 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;