From 8c6a6f3dbda26b532392f63ce6d02b45c2c88ec6 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:30:51 +0300 Subject: [PATCH] Changing the operating method of the RS Outpaint node --- Rayko_Outpaint.py | 166 +++++++++++++++++------- __init__.py | 2 +- package.json | 2 +- pyproject.toml | 2 +- web/rs_outpaint.js | 313 ++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 402 insertions(+), 83 deletions(-) diff --git a/Rayko_Outpaint.py b/Rayko_Outpaint.py index 4252b59..4be4ecf 100644 --- a/Rayko_Outpaint.py +++ b/Rayko_Outpaint.py @@ -35,6 +35,7 @@ _DEFAULT_PAD_FACTOR = 0.3 _frame_cache = {} _PENDING_DECISIONS = {} _SAVED_CROP_PRESETS = {} +_APPROVED_CROPS = {} # {node_id: {src_hash: crop_state}} CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) PRESET_DIR = os.path.join(CURRENT_DIR, "presets") @@ -113,33 +114,67 @@ class RSOutpaint: cached.get("width") != src_w or cached.get("height") != src_h) - saved_preset = _SAVED_CROP_PRESETS.get(unique_id, {}) - batch_mode_active = saved_preset.get("active", False) + # Проверяем, есть ли утверждённый кроп для этого изображения + approved_crop = _APPROVED_CROPS.get(unique_id, {}).get(src_hash) - if not batch_mode_active: - if unique_id in _SAVED_CROP_PRESETS: - del _SAVED_CROP_PRESETS[unique_id] - if unique_id in _PENDING_DECISIONS: - del _PENDING_DECISIONS[unique_id] + if approved_crop and not is_new_image: + # Используем сохранённый кроп, пропускаем ожидание + crop_state = approved_crop + + # Но всё равно отправляем событие на фронтенд, чтобы показать Reset + try: + i = 255. * src.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + filename = f"rsoutpaint_{unique_id}.png" + subfolder = "rsoutpaint" + full_output_folder = os.path.join(folder_paths.get_temp_directory(), subfolder) + os.makedirs(full_output_folder, exist_ok=True) + img.save(os.path.join(full_output_folder, filename)) - if batch_mode_active and saved_preset.get("crop_state"): - crop_state = _adapt_crop_to_new_size(saved_preset["crop_state"], src_w, src_h) - - if is_new_image: - if unique_id in _frame_cache: del _frame_cache[unique_id] - if not batch_mode_active: + server.PromptServer.instance.send_sync("rs_outpaint.show", { + "node_id": unique_id, + "image_url": f"/view?filename={filename}&type=temp&subfolder={subfolder}", + "image_width": img.width, + "image_height": img.height, + "is_new_image": is_new_image, + "src_hash": src_hash, + "already_approved": True # <-- НОВЫЙ ФЛАГ + }) + except Exception as e: + pass + else: + if is_new_image: + if unique_id in _frame_cache: + del _frame_cache[unique_id] + if unique_id in _APPROVED_CROPS: + del _APPROVED_CROPS[unique_id] + if unique_id in _SAVED_CROP_PRESETS: + del _SAVED_CROP_PRESETS[unique_id] crop_state = "" - - _frame_cache[unique_id] = {"width": src_w, "height": src_h, "src_hash": src_hash, "image": _tensor_to_jpeg(src)} - if not batch_mode_active: - if unique_id not in _PENDING_DECISIONS: - _PENDING_DECISIONS[unique_id] = { - "status": "pending", - "crop_state": crop_state, - "last_heartbeat": time.time() - } - + # Обновляем кеш + _frame_cache[unique_id] = { + "width": src_w, + "height": src_h, + "src_hash": src_hash, + "image": _tensor_to_jpeg(src) + } + + saved_preset = _SAVED_CROP_PRESETS.get(unique_id, {}) + batch_mode_active = saved_preset.get("active", False) + + if not batch_mode_active: + if unique_id not in _PENDING_DECISIONS: + _PENDING_DECISIONS[unique_id] = { + "status": "pending", + "crop_state": crop_state, + "last_heartbeat": time.time() + } + else: + _PENDING_DECISIONS[unique_id]["status"] = "pending" + _PENDING_DECISIONS[unique_id]["crop_state"] = crop_state + _PENDING_DECISIONS[unique_id]["last_heartbeat"] = time.time() + try: i = 255. * src.cpu().numpy() img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) @@ -153,36 +188,42 @@ class RSOutpaint: "node_id": unique_id, "image_url": f"/view?filename={filename}&type=temp&subfolder={subfolder}", "image_width": img.width, - "image_height": img.height + "image_height": img.height, + "is_new_image": is_new_image, + "src_hash": src_hash }) except Exception as e: pass - while True: - state = _PENDING_DECISIONS.get(unique_id, {}) - current_status = state.get("status", "pending") - current_crop_state = state.get("crop_state", crop_state) - last_hb = state.get("last_heartbeat", time.time()) - - if current_status == "approved": - crop_state = current_crop_state - break + while True: + state = _PENDING_DECISIONS.get(unique_id, {}) + current_status = state.get("status", "pending") + current_crop_state = state.get("crop_state", crop_state) + last_hb = state.get("last_heartbeat", time.time()) - elif current_status == "cancelled": - return (torch.zeros((1, src_h, src_w, 3)), torch.ones((1, src_h, src_w)), torch.zeros((1, src_h, src_w, 3)), 0, 0) - - elif current_status == "removed": - return (image, torch.ones((1, src_h, src_w)), image, src_w, src_h) - - elif time.time() - last_hb > 10.0: - return (image, torch.ones((1, src_h, src_w)), image, src_w, src_h) - - elif current_status == "rejected": - _PENDING_DECISIONS[unique_id]["status"] = "pending" - crop_state = current_crop_state - - time.sleep(0.2) + if current_status == "approved": + crop_state = current_crop_state + if unique_id not in _APPROVED_CROPS: + _APPROVED_CROPS[unique_id] = {} + _APPROVED_CROPS[unique_id][src_hash] = crop_state + break + elif current_status == "cancelled": + return (torch.zeros((1, src_h, src_w, 3)), torch.ones((1, src_h, src_w)), torch.zeros((1, src_h, src_w, 3)), 0, 0) + elif current_status == "removed": + return (image, torch.ones((1, src_h, src_w)), image, src_w, src_h) + elif time.time() - last_hb > 10.0: + return (image, torch.ones((1, src_h, src_w)), image, src_w, src_h) + elif current_status == "rejected": + _PENDING_DECISIONS[unique_id]["status"] = "pending" + crop_state = current_crop_state + + time.sleep(0.2) + else: + if saved_preset.get("crop_state"): + crop_state = _adapt_crop_to_new_size(saved_preset["crop_state"], src_w, src_h) + + # --- Формирование результата (без изменений) --- crop_x = crop_y = crop_w = crop_h = 0 output_width = output_height = 0 use_crop_state = False @@ -286,6 +327,8 @@ async def rs_outpaint_decision(request): decision = data.get("decision") crop_state = data.get("crop_state") batch_mode = data.get("batch_mode", False) + src_hash = data.get("src_hash") + if node_id in _PENDING_DECISIONS: if decision == "approve": _PENDING_DECISIONS[node_id]["status"] = "approved" @@ -294,8 +337,18 @@ async def rs_outpaint_decision(request): _PENDING_DECISIONS[node_id]["batch_mode"] = batch_mode if batch_mode and crop_state: _SAVED_CROP_PRESETS[node_id] = {"crop_state": crop_state, "active": True} + + if src_hash: + if node_id not in _APPROVED_CROPS: + _APPROVED_CROPS[node_id] = {} + _APPROVED_CROPS[node_id][src_hash] = crop_state + elif decision == "cancel": _PENDING_DECISIONS[node_id]["status"] = "cancelled" + if node_id in _APPROVED_CROPS and src_hash: + _APPROVED_CROPS[node_id].pop(src_hash, None) + if not _APPROVED_CROPS[node_id]: + del _APPROVED_CROPS[node_id] if node_id in _SAVED_CROP_PRESETS: del _SAVED_CROP_PRESETS[node_id] return web.Response(status=200, text="OK") @@ -303,6 +356,20 @@ async def rs_outpaint_decision(request): except Exception as e: return web.Response(status=500, text=str(e)) +@server.PromptServer.instance.routes.post("/rs_outpaint/unapprove") +async def rs_outpaint_unapprove(request): + try: + data = await request.json() + node_id = str(data.get("node_id")) + src_hash = data.get("src_hash") + if node_id in _APPROVED_CROPS and src_hash: + _APPROVED_CROPS[node_id].pop(src_hash, None) + if not _APPROVED_CROPS[node_id]: + del _APPROVED_CROPS[node_id] + return web.Response(status=200, text="OK") + except Exception as e: + return web.Response(status=500, text=str(e)) + @server.PromptServer.instance.routes.post("/rs_outpaint/cleanup") async def rs_outpaint_cleanup(request): try: @@ -311,6 +378,11 @@ async def rs_outpaint_cleanup(request): if node_id in _PENDING_DECISIONS: _PENDING_DECISIONS[node_id]["status"] = "removed" _PENDING_DECISIONS[node_id]["last_heartbeat"] = time.time() + if node_id in _APPROVED_CROPS: + del _APPROVED_CROPS[node_id] + # Добавляем очистку кэша фрейма для полной надёжности + if node_id in _frame_cache: + del _frame_cache[node_id] return web.Response(status=200, text="OK") except Exception as e: return web.Response(status=500, text=str(e)) diff --git a/__init__.py b/__init__.py index 83c163c..0a261b8 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.32.4" +__version__ = "0.33.0" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index 98b2a65..3322d81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.32.4", + "version": "0.33.0", "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 6d6cbd1..e8b8cc5 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.32.4" +version = "0.33.0" 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_outpaint.js b/web/rs_outpaint.js index 49b764d..2181906 100644 --- a/web/rs_outpaint.js +++ b/web/rs_outpaint.js @@ -22,8 +22,17 @@ function applyCanvasCr(canvasCr, st) { let s = crToSrc(canvasCr, st.sf, st.scale function toWorld(e, wrapEl, view) { const rect = wrapEl.getBoundingClientRect(); return { x: (e.clientX - rect.left - view.panX) / view.zoom, y: (e.clientY - rect.top - view.panY) / view.zoom }; } function defaultOut(_st) { return { w: 0, h: 0 }; } +// ТВОЙ ПЛАН: Добавлены approved и srcHash function createState() { - return { srcW: 1280, srcH: 720, scale: 1, sf: { x: 0, y: 0, w: 0, h: 0 }, cr: { x: 0, y: 0, w: 0, h: 0 }, cropAR: 16 / 9, arLocked: false, initialized: false, view: { zoom: 1.0, panX: 0, panY: 0 }, outW: 0, outH: 0, maskColor: "#ff0000", bgColor: "#141414", batchMode: false, hasPreset: false }; + return { + srcW: 1280, srcH: 720, scale: 1, sf: { x: 0, y: 0, w: 0, h: 0 }, + cr: { x: 0, y: 0, w: 0, h: 0 }, cropAR: 16 / 9, arLocked: false, + initialized: false, view: { zoom: 1.0, panX: 0, panY: 0 }, + outW: 0, outH: 0, maskColor: "#ff0000", bgColor: "#141414", + batchMode: false, hasPreset: false, + approved: false, + srcHash: null + }; } function setUIActive(dom, isActive) { @@ -55,9 +64,7 @@ function setUIActive(dom, isActive) { dom.arBtn.style.pointerEvents = pointerEvents; dom.arBtn.style.filter = filter; - dom.resetBtn.style.opacity = opacity; - dom.resetBtn.style.pointerEvents = pointerEvents; - dom.resetBtn.style.filter = filter; + // ResetBtn намеренно НЕ трогаем — он всегда активен dom.wInput.style.opacity = opacity; dom.wInput.style.pointerEvents = pointerEvents; @@ -84,35 +91,73 @@ function setUIActive(dom, isActive) { dom.batchBtn.style.filter = filter; } -function resetNodeState(st, dom, widgets) { +// ТВОЙ ПЛАН: Сброс approved, srcHash и скрытие кнопок +function resetNodeState(st, dom, widgets, nodeId) { // <-- Добавлен nodeId const savedMaskColor = st.maskColor; const savedBgColor = st.bgColor; - Object.assign(st, createState()); + // Сбрасываем утверждение на сервере перед локальным сбросом + if (st.srcHash && nodeId) { + fetch("/rs_outpaint/unapprove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(nodeId), src_hash: st.srcHash }) + }).catch(() => {}); + } + Object.assign(st, createState()); st.maskColor = savedMaskColor; st.bgColor = savedBgColor; + st.approved = false; + st.srcHash = null; st.arLocked = false; dom.arBtn.textContent = "🔓"; dom.arBtn.style.border = "1px solid #444"; dom.arBtn.style.background = "#2a2a2a"; dom.arBtn.style.color = "#bbb"; dom.arBtn._active = false; + + dom.waitingMsg.style.display = "none"; + dom.acceptBtn.style.display = "none"; + dom.cancelBtn.style.display = "none"; + dom.batchBtn.style.display = "none"; + if(dom.wInput) dom.wInput.value = ""; if(dom.hInput) dom.hInput.value = ""; if(dom.maskColorInput) { dom.maskColorInput.picker.value = st.maskColor; dom.maskColorInput.swatch.style.background = st.maskColor; dom.maskColorInput.hexInput.value = st.maskColor.toUpperCase(); } if(dom.bgColorInput) { dom.bgColorInput.picker.value = st.bgColor; dom.bgColorInput.swatch.style.background = st.bgColor; dom.bgColorInput.hexInput.value = st.bgColor.toUpperCase(); } - if(dom.waitingMsg) dom.waitingMsg.style.display = "none"; - if(dom.acceptBtn) { dom.acceptBtn.style.display = "none"; dom.acceptBtn.textContent = "✔️ ACCEPT"; dom.acceptBtn.disabled = false; } - if(dom.cancelBtn) dom.cancelBtn.style.display = "none"; - if(dom.batchBtn) { dom.batchBtn.style.display = "none"; dom.batchBtn.dataset.active = "false"; dom.batchBtn.style.borderColor = "#444"; dom.batchBtn.style.background = "#2a2a2a"; dom.batchBtn.style.color = "#bbb"; dom.batchBtn.textContent = "⚙️ BATCH"; } if(dom.presetNameInput) dom.presetNameInput.style.display = "none"; if(dom.presetListOverlay) dom.presetListOverlay.style.display = "none"; if(dom.noDataMsg) { dom.noDataMsg.style.display = "none"; dom.noDataMsg.textContent = ""; } if(dom.imageEl) { dom.imageEl.style.display = "none"; if (dom.imageEl.src && dom.imageEl.src.startsWith("blob:")) URL.revokeObjectURL(dom.imageEl.src); } + updateMaskColors(st, dom); st.initialized = false; syncWidgets(st, widgets, { graph: null }); - setUIActive(dom, false); } +// ТВОЙ ПЛАН: Функция для сброса флага approved при любом изменении кропа +function unapprove(st, dom, nodeId) { + if (st.approved) { + st.approved = false; + dom.waitingMsg.style.display = "flex"; + dom.acceptBtn.style.display = "block"; + dom.cancelBtn.style.display = "block"; + dom.batchBtn.style.display = "block"; + dom.acceptBtn.disabled = false; + dom.acceptBtn.textContent = "✔️ ACCEPT"; + setUIActive(dom, true); + + if (st.srcHash && nodeId) { + fetch("/rs_outpaint/unapprove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + node_id: String(nodeId), + src_hash: st.srcHash + }) + }).catch(err => console.error("Unapprove failed:", err)); + } + } +} + function initLayout(st, wrapEl) { const W = wrapEl.clientWidth; if (W <= 0) return false; @@ -395,7 +440,8 @@ function wireInteractions(st, dom, widgets, node, nodeId) { if (!st.arLocked) st.cropAR = s.w / s.h; fitCropInView(st, dom); render(st, dom); - syncWidgets(st, widgets, node); + syncWidgets(st, widgets, node); + unapprove(st, dom, nodeId); // ТВОЙ ПЛАН }); hInput.addEventListener("change", () => { @@ -411,15 +457,45 @@ function wireInteractions(st, dom, widgets, node, nodeId) { if (!st.arLocked) st.cropAR = s.w / s.h; fitCropInView(st, dom); render(st, dom); - syncWidgets(st, widgets, node); + syncWidgets(st, widgets, node); + unapprove(st, dom, nodeId); // ТВОЙ ПЛАН }); - resetBtn.addEventListener("click", () => { - initLayout(st, dom.wrap); - setArLocked(st, dom, false); - fitCropInView(st, dom); - render(st, dom); - syncWidgets(st, widgets, node); + resetBtn.addEventListener("click", async () => { + resetBtn.disabled = true; + resetBtn.textContent = "⏳ Resetting..."; + + try { + await fetch("/rs_outpaint/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(node.id) }) + }); + + // Сбрасываем локальное состояние + st.approved = false; + st.srcHash = null; + if (widgets.cropState) widgets.cropState.value = ""; + + initLayout(st, dom.wrap); + setArLocked(st, dom, false); + fitCropInView(st, dom); + render(st, dom); + syncWidgets(st, widgets, node); + + // Скрываем интерфейс редактирования + dom.waitingMsg.style.display = "none"; + dom.acceptBtn.style.display = "none"; + dom.cancelBtn.style.display = "none"; + dom.batchBtn.style.display = "none"; + dom.resetBtn.style.display = "none"; + + } catch (err) { + console.error("Reset failed:", err); + } finally { + resetBtn.disabled = false; + resetBtn.textContent = "🔄️ Reset"; + } }); for (const btn of chipBtns) { @@ -445,7 +521,8 @@ function wireInteractions(st, dom, widgets, node, nodeId) { fitCropInView(st, dom); render(st, dom); - syncWidgets(st, widgets, node); + syncWidgets(st, widgets, node); + unapprove(st, dom, nodeId); // ТВОЙ ПЛАН }); } @@ -465,7 +542,8 @@ function wireInteractions(st, dom, widgets, node, nodeId) { st.cr = srcToCr(s, st.sf, st.scale); if (!st.arLocked) st.cropAR = s.w / s.h; render(st, dom); - syncWidgets(st, widgets, node); + syncWidgets(st, widgets, node); + unapprove(st, dom, nodeId); // ТВОЙ ПЛАН }); } @@ -474,7 +552,7 @@ function wireInteractions(st, dom, widgets, node, nodeId) { let drag = null; let pan = null; wrap.addEventListener("pointerdown", e => { if (e.button === 1) { pan = { sx: e.clientX, sy: e.clientY, ox: st.view.panX, oy: st.view.panY }; wrap.setPointerCapture(e.pointerId); e.preventDefault(); return; } const handle = e.target.closest("[data-dir]"); const inBox = dom.cropBox.contains(e.target); if (!handle && !inBox) return; e.preventDefault(); wrap.setPointerCapture(e.pointerId); const wc = toWorld(e, wrap, st.view); drag = { type: handle ? "resize" : "move", dir: handle?.dataset.dir, sx: wc.x, sy: wc.y, sb: { ...st.cr }, ar: st.arLocked ? st.cropAR : null }; }); wrap.addEventListener("pointermove", e => { if (pan) { st.view.panX = pan.ox + (e.clientX - pan.sx); st.view.panY = pan.oy + (e.clientY - pan.sy); render(st, dom); return; } if (!drag) return; const wc = toWorld(e, wrap, st.view); const dx = wc.x - drag.sx; const dy = wc.y - drag.sy; const minPx = MIN_DRAG_PX; if (drag.type === "move") { applyCanvasCr({ x: drag.sb.x + dx, y: drag.sb.y + dy, w: drag.sb.w, h: drag.sb.h }, st); render(st, dom); return; } let { x, y, w, h } = { ...drag.sb }; const { dir, ar } = drag; if (dir === "br") { w = Math.max(minPx, w + dx); h = ar ? Math.round(w / ar) : Math.max(minPx, h + dy); } else if (dir === "bl") { const nw = Math.max(minPx, w - dx); x += w - nw; w = nw; h = ar ? Math.round(w / ar) : Math.max(minPx, h + dy); } else if (dir === "tr") { w = Math.max(minPx, w + dx); const nh = ar ? Math.round(w / ar) : Math.max(minPx, h - dy); y += h - nh; h = nh; } else if (dir === "tl") { const nw = Math.max(minPx, w - dx); x += w - nw; w = nw; const nh = ar ? Math.round(w / ar) : Math.max(minPx, h - dy); y += h - nh; h = nh; } else if (dir === "t") { const nh = Math.max(minPx, h - dy); const nw = ar ? Math.round(nh * ar) : w; if (ar) x += Math.round((w - nw) / 2); y += h - nh; h = nh; w = nw; } else if (dir === "b") { const nh = Math.max(minPx, h + dy); const nw = ar ? Math.round(nh * ar) : w; if (ar) x += Math.round((w - nw) / 2); h = nh; w = nw; } else if (dir === "l") { const nw = Math.max(minPx, w - dx); const nh = ar ? Math.round(nw / ar) : h; if (ar) y += Math.round((h - nh) / 2); x += w - nw; w = nw; h = nh; } else if (dir === "r") { const nw = Math.max(minPx, w + dx); const nh = ar ? Math.round(nw / ar) : h; if (ar) y += Math.round((h - nh) / 2); w = nw; h = nh; } applyCanvasCr({ x, y, w, h }, st); render(st, dom); }); - wrap.addEventListener("pointerup", e => { if (pan) { pan = null; return; } if (!drag) return; const type = drag.type; if (type === "resize" && st.arLocked) { let s = crToSrc(st.cr, st.sf, st.scale); s = quantizeSrc(s); s.h = Math.max(GRID, Math.round(s.w / st.cropAR / GRID) * GRID); s = clampToValid(s, st.srcW, st.srcH); st.cr = srcToCr(s, st.sf, st.scale); } if (type === "resize" && !st.arLocked) { const s = crToSrc(st.cr, st.sf, st.scale); st.cropAR = s.w / s.h; } if (type === "resize") fitCropInView(st, dom); drag = null; render(st, dom); syncWidgets(st, widgets, node); }); + wrap.addEventListener("pointerup", e => { if (pan) { pan = null; return; } if (!drag) return; const type = drag.type; if (type === "resize" && st.arLocked) { let s = crToSrc(st.cr, st.sf, st.scale); s = quantizeSrc(s); s.h = Math.max(GRID, Math.round(s.w / st.cropAR / GRID) * GRID); s = clampToValid(s, st.srcW, st.srcH); st.cr = srcToCr(s, st.sf, st.scale); } if (type === "resize" && !st.arLocked) { const s = crToSrc(st.cr, st.sf, st.scale); st.cropAR = s.w / s.h; } if (type === "resize") fitCropInView(st, dom); drag = null; render(st, dom); syncWidgets(st, widgets, node); unapprove(st, dom, nodeId); }); // ТВОЙ ПЛАН wrap.addEventListener("pointercancel", () => { pan = null; drag = null; }); } @@ -501,7 +579,7 @@ api.addEventListener("status", (e) => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }).catch(err => console.error("Failed to clear preset:", err)); - resetNodeState(st, dom, widgets); + resetNodeState(st, dom, widgets, node.id); dom.noDataMsg.textContent = "🔄 Batch complete. Node reset."; dom.noDataMsg.style.display = "flex"; setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 3000); @@ -539,19 +617,188 @@ app.registerExtension({ node.stopHeartbeat = function() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } }; allNodes.push({st, dom, widgets, node}); - const onShow = (event) => { const data = event.detail || event; const { node_id, image_width, image_height } = data; if (String(node_id) !== String(node.id)) return; const hasActivePreset = st.batchMode && st.hasPreset; resetNodeState(st, dom, widgets); st.srcW = image_width; st.srcH = image_height; if (initLayout(st, dom.wrap)) { if (hasActivePreset) { dom.waitingMsg.style.display = "none"; dom.noDataMsg.style.display = "flex"; dom.noDataMsg.textContent = `🔄 Applying saved crop to ${image_width}×${image_height}...`; dom.acceptBtn.style.display = "none"; dom.cancelBtn.style.display = "none"; dom.batchBtn.style.display = "none"; const tempUrl = api.apiURL(`/view?filename=rsoutpaint_${node_id}.png&type=temp&subfolder=rsoutpaint&t=${Date.now()}`); const img = new Image(); img.onload = () => { dom.imageEl.src = tempUrl; dom.imageEl.style.display = "block"; fitCropInView(st, dom); render(st, dom); (async () => { try { await fetch("/rs_outpaint/decision", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id), decision: "approve", crop_state: "", batch_mode: true }) }); node.startHeartbeat(); dom.noDataMsg.textContent = `✅ Batch: ${image_width}×${image_height} processed`; setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 1000); } catch (err) { console.error("Batch auto-approve failed:", err); } })(); }; img.src = tempUrl; node.startHeartbeat(); } else { dom.waitingMsg.style.display = "flex"; dom.noDataMsg.style.display = "none"; dom.acceptBtn.style.display = "block"; dom.cancelBtn.style.display = "block"; dom.batchBtn.style.display = "block"; dom.acceptBtn.disabled = false; dom.acceptBtn.textContent = "✔️ ACCEPT"; dom.batchBtn.textContent = "⚙️ BATCH"; dom.batchBtn.style.borderColor = "#444"; dom.batchBtn.style.background = "#2a2a2a"; dom.batchBtn.style.color = "#bbb"; dom.batchBtn.dataset.active = "false"; const tempUrl = api.apiURL(`/view?filename=rsoutpaint_${node_id}.png&type=temp&subfolder=rsoutpaint&t=${Date.now()}`); const img = new Image(); img.onload = () => { dom.imageEl.src = tempUrl; dom.imageEl.style.display = "block"; fitCropInView(st, dom); render(st, dom); }; img.src = tempUrl; node.startHeartbeat(); } + const onShow = (event) => { + const data = event.detail || event; + const { node_id, image_width, image_height, is_new_image, src_hash } = data; + if (String(node_id) !== String(node.id)) return; - setUIActive(dom, true); - } }; + const isSameImage = st.initialized && st.srcHash === src_hash; + + if (isSameImage) { + // То же изображение – обновляем картинку + const tempUrl = api.apiURL(`/view?filename=rsoutpaint_${node_id}.png&type=temp&subfolder=rsoutpaint&t=${Date.now()}`); + const img = new Image(); + img.onload = () => { + dom.imageEl.src = tempUrl; + dom.imageEl.style.display = "block"; + + if (st.approved) { + dom.waitingMsg.style.display = "none"; + dom.acceptBtn.style.display = "none"; + dom.cancelBtn.style.display = "none"; + dom.batchBtn.style.display = "none"; + dom.resetBtn.style.display = "block"; + + setUIActive(dom, false); + dom.resetBtn.style.opacity = "1"; + dom.resetBtn.style.pointerEvents = "auto"; + dom.resetBtn.style.filter = "none"; + } else { + dom.waitingMsg.style.display = "flex"; + dom.acceptBtn.style.display = "block"; + dom.cancelBtn.style.display = "block"; + dom.batchBtn.style.display = "block"; + dom.acceptBtn.disabled = false; + dom.acceptBtn.textContent = "✔️ ACCEPT"; + dom.resetBtn.style.display = "none"; + } + fitCropInView(st, dom); + render(st, dom); + if (!st.approved) { + setUIActive(dom, true); + } + }; + img.src = tempUrl; + return; + } + + // Новое изображение – полный сброс + resetNodeState(st, dom, widgets); + st.srcW = image_width; + st.srcH = image_height; + st.srcHash = src_hash; + + const hasActivePreset = st.batchMode && st.hasPreset; + if (initLayout(st, dom.wrap)) { + if (hasActivePreset) { + dom.waitingMsg.style.display = "none"; + dom.noDataMsg.style.display = "flex"; + dom.noDataMsg.textContent = `🔄 Applying saved crop to ${image_width}×${image_height}...`; + dom.acceptBtn.style.display = "none"; + dom.cancelBtn.style.display = "none"; + dom.batchBtn.style.display = "none"; + dom.resetBtn.style.display = "none"; + + const tempUrl = api.apiURL(`/view?filename=rsoutpaint_${node_id}.png&type=temp&subfolder=rsoutpaint&t=${Date.now()}`); + const img = new Image(); + img.onload = () => { + dom.imageEl.src = tempUrl; + dom.imageEl.style.display = "block"; + fitCropInView(st, dom); + render(st, dom); + (async () => { + try { + await fetch("/rs_outpaint/decision", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(node.id), decision: "approve", crop_state: "", batch_mode: true }) + }); + node.startHeartbeat(); + dom.noDataMsg.textContent = `✅ Batch: ${image_width}×${image_height} processed`; + setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 1000); + } catch (err) { + console.error("Batch auto-approve failed:", err); + } + })(); + }; + img.src = tempUrl; + node.startHeartbeat(); + } else { + dom.waitingMsg.style.display = "flex"; + dom.noDataMsg.style.display = "none"; + dom.acceptBtn.style.display = "block"; + dom.cancelBtn.style.display = "block"; + dom.batchBtn.style.display = "block"; + dom.acceptBtn.disabled = false; + dom.acceptBtn.textContent = "✔️ ACCEPT"; + dom.batchBtn.textContent = "⚙️ BATCH"; + dom.batchBtn.style.borderColor = "#444"; + dom.batchBtn.style.background = "#2a2a2a"; + dom.batchBtn.style.color = "#bbb"; + dom.batchBtn.dataset.active = "false"; + dom.resetBtn.style.display = "none"; + + const tempUrl = api.apiURL(`/view?filename=rsoutpaint_${node_id}.png&type=temp&subfolder=rsoutpaint&t=${Date.now()}`); + const img = new Image(); + img.onload = () => { + dom.imageEl.src = tempUrl; + dom.imageEl.style.display = "block"; + fitCropInView(st, dom); + render(st, dom); + }; + img.src = tempUrl; + node.startHeartbeat(); + } + setUIActive(dom, true); + } + }; api.addEventListener("rs_outpaint.show", onShow); - dom.acceptBtn.addEventListener("click", async () => { const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); const mR = parseInt(st.maskColor.slice(1,3), 16), mG = parseInt(st.maskColor.slice(3,5), 16), mB = parseInt(st.maskColor.slice(5,7), 16); const cropState = `${s.x},${s.y},${s.w},${s.h},0,0,${mR},${mG},${mB}`; dom.acceptBtn.textContent = "⏳ Sending..."; dom.acceptBtn.disabled = true; try { const resp = await fetch("/rs_outpaint/decision", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id), decision: "approve", crop_state: cropState, batch_mode: st.batchMode }) }); if (resp.ok) { node.stopHeartbeat(); if (st.batchMode) { st.hasPreset = true; dom.waitingMsg.style.display = "none"; dom.acceptBtn.style.display = "none"; dom.cancelBtn.style.display = "none"; dom.batchBtn.style.display = "none"; dom.noDataMsg.textContent = "💾 Crop preset saved! Ready for batch."; dom.noDataMsg.style.display = "flex"; setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 2000); } else { resetNodeState(st, dom, widgets); dom.noDataMsg.textContent = "✅ Approved! Continuing..."; dom.noDataMsg.style.display = "flex"; setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 1500); fetch("/rs_outpaint/clear_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }).catch(()=>{}); } - + + dom.acceptBtn.addEventListener("click", async () => { + const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); + const mR = parseInt(st.maskColor.slice(1,3), 16), mG = parseInt(st.maskColor.slice(3,5), 16), mB = parseInt(st.maskColor.slice(5,7), 16); + const cropState = `${s.x},${s.y},${s.w},${s.h},0,0,${mR},${mG},${mB}`; + dom.acceptBtn.textContent = "⏳ Sending..."; + dom.acceptBtn.disabled = true; + try { + const resp = await fetch("/rs_outpaint/decision", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + node_id: String(node.id), + decision: "approve", + crop_state: cropState, + batch_mode: st.batchMode, + src_hash: st.srcHash // <-- ДОБАВЛЕНО + }) + }); + if (resp.ok) { + node.stopHeartbeat(); + st.approved = true; + + if (st.batchMode) { + st.hasPreset = true; + dom.waitingMsg.style.display = "none"; + dom.acceptBtn.style.display = "none"; + dom.cancelBtn.style.display = "none"; + dom.batchBtn.style.display = "none"; + dom.noDataMsg.textContent = "💾 Crop preset saved! Ready for batch."; + dom.noDataMsg.style.display = "flex"; + setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 2000); + } else { + dom.acceptBtn.style.display = "none"; + dom.cancelBtn.style.display = "none"; + dom.waitingMsg.style.display = "none"; + dom.batchBtn.style.display = "none"; + dom.resetBtn.style.display = "block"; // <-- ДОБАВИТЬ + + dom.noDataMsg.textContent = "✅ Crop applied!"; + dom.noDataMsg.style.display = "flex"; + setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 1500); + } + setUIActive(dom, false); + } + } catch (err) { + console.error("Accept failed:", err); + } + }); + + dom.cancelBtn.addEventListener("click", async () => { + node.stopHeartbeat(); + resetNodeState(st, dom, widgets, node.id); // ТВОЙ ПЛАН: сброс при отмене + try { + await fetch("/rs_outpaint/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(node.id) }) + }); + } catch(e){} + try { + await api.interrupt(); + } catch(e){} setUIActive(dom, false); - } } catch (err) { console.error("Accept failed:", err); } }); - dom.cancelBtn.addEventListener("click", async () => { node.stopHeartbeat(); resetNodeState(st, dom, widgets); try { await fetch("/rs_outpaint/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }); } catch(e){} try { await api.interrupt(); } catch(e){} - - setUIActive(dom, false); - }); + }); + wireInteractions(st, dom, widgets, node, String(node.id)); requestAnimationFrame(() => { requestAnimationFrame(() => { if (!st.initialized && initLayout(st, dom.wrap)) { fitCropInView(st, dom); render(st, dom); syncWidgets(st, widgets, node); } }); });