From 274f9bf77c904413a015347f6cbe96c7e3e26153 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:14:49 +0300 Subject: [PATCH] Deep modification of the RS Outpaint node --- Rayko_Outpaint.py | 276 ++++++++------ __init__.py | 2 +- package.json | 2 +- pyproject.toml | 2 +- web/rs_outpaint.js | 914 +++++++++++++++++++++++++++------------------ 5 files changed, 710 insertions(+), 486 deletions(-) diff --git a/Rayko_Outpaint.py b/Rayko_Outpaint.py index 4be4ecf..da984f8 100644 --- a/Rayko_Outpaint.py +++ b/Rayko_Outpaint.py @@ -13,20 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -import base64 import io import json import numpy as np import server import torch -import torch.nn.functional as F from aiohttp import web from PIL import Image import time import os import folder_paths -import threading -import gc _GRID = 16 _MIN_DIM = 32 @@ -35,11 +31,12 @@ _DEFAULT_PAD_FACTOR = 0.3 _frame_cache = {} _PENDING_DECISIONS = {} _SAVED_CROP_PRESETS = {} -_APPROVED_CROPS = {} # {node_id: {src_hash: crop_state}} +_APPROVED_CROPS = {} CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) PRESET_DIR = os.path.join(CURRENT_DIR, "presets") + def _tensor_hash(tensor): try: t = tensor[0] if tensor.dim() == 4 else tensor @@ -48,6 +45,7 @@ def _tensor_hash(tensor): except Exception: return None + def _tensor_to_jpeg(image_tensor): arr = (image_tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8) img = Image.fromarray(arr) @@ -55,6 +53,7 @@ def _tensor_to_jpeg(image_tensor): img.save(buf, format="JPEG", quality=85) return buf.getvalue() + def _adapt_crop_to_new_size(crop_state, new_w, new_h): if not crop_state: return None @@ -67,7 +66,7 @@ def _adapt_crop_to_new_size(crop_state, new_w, new_h): cy = max(-new_h, min(cy, new_h - _GRID)) cw = max(_GRID, min(cw, new_w * 2)) ch = max(_GRID, min(ch, new_h * 2)) - + out_w = parts[4] if len(parts) >= 5 else 0 out_h = parts[5] if len(parts) >= 6 else 0 colors = parts[6:9] if len(parts) >= 9 else [255, 0, 0] @@ -76,6 +75,7 @@ def _adapt_crop_to_new_size(crop_state, new_w, new_h): except Exception: return None + class RSOutpaint: @classmethod def INPUT_TYPES(cls): @@ -101,27 +101,42 @@ class RSOutpaint: def outpaint_prep(self, image, crop_state, unique_id): src = image[0] if image.dim() == 4 else image src_h, src_w, _ = src.shape - + if src_w < _MIN_DIM or src_h < _MIN_DIM: raise ValueError(f"Input image too small ({src_w}×{src_h}).") unique_id = str(unique_id) - + try: src_hash = _tensor_hash(src) cached = _frame_cache.get(unique_id, {}) - is_new_image = (cached.get("src_hash") != src_hash or - cached.get("width") != src_w or + is_new_image = (cached.get("src_hash") != src_hash or + cached.get("width") != src_w or cached.get("height") != src_h) - # Проверяем, есть ли утверждённый кроп для этого изображения - approved_crop = _APPROVED_CROPS.get(unique_id, {}).get(src_hash) + saved_preset = _SAVED_CROP_PRESETS.get(unique_id, {}) + batch_mode_active = saved_preset.get("active", False) + batch_mode_type = saved_preset.get("mode", "single") + + auto_apply = False + if batch_mode_active and saved_preset.get("crop_state"): + if batch_mode_type == "multi": + auto_apply = True + elif batch_mode_type == "single" and not is_new_image: + auto_apply = True + + if not auto_apply: + approved_crop = _APPROVED_CROPS.get(unique_id, {}).get(src_hash) + if approved_crop and not is_new_image: + crop_state = approved_crop + auto_apply = True + + if auto_apply: + if not batch_mode_active: + pass + else: + crop_state = _adapt_crop_to_new_size(saved_preset["crop_state"], src_w, src_h) - 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)) @@ -138,98 +153,89 @@ class RSOutpaint: "image_height": img.height, "is_new_image": is_new_image, "src_hash": src_hash, - "already_approved": True # <-- НОВЫЙ ФЛАГ + "auto_approved": True, + "applied_crop_state": crop_state }) - except Exception as e: + except Exception: 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, + "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)) - 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)) - - 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 - }) - 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 - 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) - + if unique_id not in _PENDING_DECISIONS: + _PENDING_DECISIONS[unique_id] = { + "status": "pending", + "crop_state": crop_state, + "last_heartbeat": time.time() + } else: - if saved_preset.get("crop_state"): - crop_state = _adapt_crop_to_new_size(saved_preset["crop_state"], src_w, src_h) + _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)) + 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)) + + 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 + }) + except Exception: + 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 + if unique_id not in _APPROVED_CROPS: + _APPROVED_CROPS[unique_id] = {} + _APPROVED_CROPS[unique_id][src_hash] = crop_state + break + elif current_status == "cancelled": + if unique_id in _APPROVED_CROPS and src_hash: + _APPROVED_CROPS[unique_id].pop(src_hash, None) + if not _APPROVED_CROPS[unique_id]: + del _APPROVED_CROPS[unique_id] + 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) - # --- Формирование результата (без изменений) --- crop_x = crop_y = crop_w = crop_h = 0 output_width = output_height = 0 use_crop_state = False color_r, color_g, color_b = 255, 0, 0 bg_r, bg_g, bg_b = 20, 20, 20 - + if crop_state: try: parts = [int(v) for v in str(crop_state).split(",")] @@ -243,7 +249,7 @@ class RSOutpaint: if len(parts) >= 9: color_r, color_g, color_b = parts[6], parts[7], parts[8] if len(parts) >= 12: - bg_r, bg_g, bg_b = parts[9], parts[10], parts[11] + bg_r, bg_g, bg_b = parts[9], parts[10], parts[11] except ValueError: pass @@ -260,11 +266,13 @@ class RSOutpaint: eff_w = output_width if output_width >= _GRID else crop_w eff_h = output_height if output_height >= _GRID else crop_h - + eff_w = max(eff_w, _GRID) eff_h = max(eff_h, _GRID) - if eff_w < 32: eff_w = src_w - if eff_h < 32: eff_h = src_h + if eff_w < 32: + eff_w = src_w + if eff_h < 32: + eff_h = src_h src_np = src.cpu().numpy() out = np.full((eff_h, eff_w, 3), 0.5, dtype=np.float32) @@ -288,7 +296,7 @@ class RSOutpaint: final_dst_y = max(0, min(final_dst_y, eff_h - 1)) copy_w = max(0, min(src_copy_w, eff_w - final_dst_x)) copy_h = max(0, min(src_copy_h, eff_h - final_dst_y)) - + if copy_w > 0 and copy_h > 0: src_slice = src_np[src_y_start:src_y_start + copy_h, src_x_start:src_x_start + copy_w] dst_slice = out[final_dst_y:final_dst_y + copy_h, final_dst_x:final_dst_x + copy_w] @@ -304,8 +312,8 @@ class RSOutpaint: control_image = torch.from_numpy(out).unsqueeze(0) control_mask = torch.from_numpy(mask).unsqueeze(0) - norm_color = torch.tensor([color_r/255.0, color_g/255.0, color_b/255.0]).view(1, 1, 1, 3) - norm_bg = torch.tensor([bg_r/255.0, bg_g/255.0, bg_b/255.0]).view(1, 1, 1, 3) + norm_color = torch.tensor([color_r / 255.0, color_g / 255.0, color_b / 255.0]).view(1, 1, 1, 3) + norm_bg = torch.tensor([bg_r / 255.0, bg_g / 255.0, bg_b / 255.0]).view(1, 1, 1, 3) mask_exp = control_mask.unsqueeze(3) mask_image = mask_exp * norm_color + (1.0 - mask_exp) * norm_bg mask_image = torch.clamp(mask_image, 0.0, 1.0) @@ -316,9 +324,11 @@ class RSOutpaint: if unique_id in _PENDING_DECISIONS: del _PENDING_DECISIONS[unique_id] + NODE_CLASS_MAPPINGS = {"RSOutpaint": RSOutpaint} NODE_DISPLAY_NAME_MAPPINGS = {"RSOutpaint": "🦊 RS Outpaint"} + @server.PromptServer.instance.routes.post("/rs_outpaint/decision") async def rs_outpaint_decision(request): try: @@ -327,28 +337,22 @@ 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") - + batch_type = data.get("batch_type", "single") + if node_id in _PENDING_DECISIONS: if decision == "approve": _PENDING_DECISIONS[node_id]["status"] = "approved" - if crop_state: + if crop_state: _PENDING_DECISIONS[node_id]["crop_state"] = crop_state _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 - + _SAVED_CROP_PRESETS[node_id] = { + "crop_state": crop_state, + "active": True, + "mode": batch_type + } 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") @@ -356,6 +360,7 @@ 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: @@ -370,6 +375,7 @@ async def rs_outpaint_unapprove(request): 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: @@ -380,13 +386,15 @@ async def rs_outpaint_cleanup(request): _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] + if node_id in _SAVED_CROP_PRESETS: + del _SAVED_CROP_PRESETS[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/heartbeat") async def rs_outpaint_heartbeat(request): try: @@ -399,24 +407,34 @@ async def rs_outpaint_heartbeat(request): except Exception as e: return web.Response(status=500, text=str(e)) + @server.PromptServer.instance.routes.post("/rs_outpaint/batch_toggle") async def rs_outpaint_batch_toggle(request): try: data = await request.json() node_id = str(data.get("node_id")) - enabled = data.get("enabled", False) + batch_type = data.get("batch_type", "single") + enabled = data.get("enabled", True) + if node_id in _SAVED_CROP_PRESETS: _SAVED_CROP_PRESETS[node_id]["active"] = enabled + _SAVED_CROP_PRESETS[node_id]["mode"] = batch_type elif enabled and node_id in _PENDING_DECISIONS: crop_state = _PENDING_DECISIONS[node_id].get("crop_state") if crop_state: - _SAVED_CROP_PRESETS[node_id] = {"crop_state": crop_state, "active": True} + _SAVED_CROP_PRESETS[node_id] = { + "crop_state": crop_state, + "active": True, + "mode": batch_type + } 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/clear_preset") async def rs_outpaint_clear_preset(request): + """Очищает только батч-пресет. Используется в обычном режиме.""" try: data = await request.json() node_id = str(data.get("node_id")) @@ -426,19 +444,35 @@ async def rs_outpaint_clear_preset(request): except Exception as e: return web.Response(status=500, text=str(e)) + +@server.PromptServer.instance.routes.post("/rs_outpaint/clear_all") +async def rs_outpaint_clear_all(request): + """Очищает батч-пресет и кэш утверждённых кропов. Используется после завершения батча.""" + try: + data = await request.json() + node_id = str(data.get("node_id")) + if node_id in _SAVED_CROP_PRESETS: + del _SAVED_CROP_PRESETS[node_id] + if node_id in _APPROVED_CROPS: + 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/save_preset") async def rs_outpaint_save_preset(request): try: data = await request.json() name = data.get("name", "").strip() - if not name: + if not name: return web.Response(status=400, text="Name required") name = "".join(c for c in name if c.isalnum() or c in " _-").strip() - if not name: + if not name: return web.Response(status=400, text="Invalid name") - + os.makedirs(PRESET_DIR, exist_ok=True) - + filepath = os.path.join(PRESET_DIR, f"{name}.json") with open(filepath, 'w', encoding='utf-8') as f: json.dump(data.get("preset_data", {}), f, indent=2) @@ -446,6 +480,7 @@ async def rs_outpaint_save_preset(request): except Exception as e: return web.Response(status=500, text=str(e)) + @server.PromptServer.instance.routes.post("/rs_outpaint/list_presets") async def rs_outpaint_list_presets(request): try: @@ -456,6 +491,7 @@ async def rs_outpaint_list_presets(request): except Exception as e: return web.Response(status=500, text=str(e)) + @server.PromptServer.instance.routes.post("/rs_outpaint/load_preset") async def rs_outpaint_load_preset(request): try: @@ -469,12 +505,14 @@ async def rs_outpaint_load_preset(request): except Exception as e: return web.Response(status=500, text=str(e)) + @server.PromptServer.instance.routes.post("/rs_outpaint/delete_preset") async def rs_outpaint_delete_preset(request): try: data = await request.json() name = data.get("name") - if not name: return web.Response(status=400, text="Name required") + if not name: + return web.Response(status=400, text="Name required") filepath = os.path.join(PRESET_DIR, f"{name}.json") if os.path.exists(filepath): os.remove(filepath) diff --git a/__init__.py b/__init__.py index 0a261b8..c519fd1 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.33.0" +__version__ = "0.33.1" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index 3322d81..82c8852 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.33.0", + "version": "0.33.1", "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 e8b8cc5..6a66fce 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.33.0" +version = "0.33.1" 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 2181906..793f735 100644 --- a/web/rs_outpaint.js +++ b/web/rs_outpaint.js @@ -11,7 +11,7 @@ const MIN_DRAG_PX = GRID; let queueWasRunning = false; let batchResetTimer = null; -let allNodes = []; +let allNodes = []; function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function crToSrc(cr, sf, scale) { return { x: (cr.x - sf.x) / scale, y: (cr.y - sf.y) / scale, w: cr.w / scale, h: cr.h / scale }; } @@ -20,15 +20,13 @@ function quantizeSrc(s) { return { x: Math.round(s.x / GRID) * GRID, y: Math.rou function clampToValid(s, srcW, srcH) { const c = { ...s }; if (c.x >= srcW - OVERHANG) c.x = srcW - OVERHANG; if (c.y >= srcH - OVERHANG) c.y = srcH - OVERHANG; if (c.x + c.w <= OVERHANG) c.x = OVERHANG - c.w; if (c.y + c.h <= OVERHANG) c.y = OVERHANG - c.h; return c; } function applyCanvasCr(canvasCr, st) { let s = crToSrc(canvasCr, st.sf, st.scale); s = quantizeSrc(s); s = clampToValid(s, st.srcW, st.srcH); st.cr = srcToCr(s, 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", + 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 @@ -39,64 +37,66 @@ function setUIActive(dom, isActive) { const opacity = isActive ? "1" : "0.4"; const pointerEvents = isActive ? "auto" : "none"; const filter = isActive ? "none" : "grayscale(100%)"; - + dom.savePresetBtn.style.opacity = opacity; dom.savePresetBtn.style.pointerEvents = pointerEvents; dom.savePresetBtn.style.filter = filter; - + dom.applyPresetBtn.style.opacity = opacity; dom.applyPresetBtn.style.pointerEvents = pointerEvents; dom.applyPresetBtn.style.filter = filter; - + for (const btn of dom.chipBtns) { btn.style.opacity = opacity; btn.style.pointerEvents = pointerEvents; btn.style.filter = filter; } - + for (const btn of dom.snapBtns) { btn.style.opacity = opacity; btn.style.pointerEvents = pointerEvents; btn.style.filter = filter; } - + dom.arBtn.style.opacity = opacity; dom.arBtn.style.pointerEvents = pointerEvents; dom.arBtn.style.filter = filter; - - // ResetBtn намеренно НЕ трогаем — он всегда активен - + dom.wInput.style.opacity = opacity; dom.wInput.style.pointerEvents = pointerEvents; - + dom.hInput.style.opacity = opacity; dom.hInput.style.pointerEvents = pointerEvents; - + dom.maskColorInput.picker.style.opacity = opacity; dom.maskColorInput.picker.style.pointerEvents = pointerEvents; dom.maskColorInput.hexInput.style.opacity = opacity; dom.maskColorInput.hexInput.style.pointerEvents = pointerEvents; dom.maskColorInput.swatch.style.opacity = opacity; dom.maskColorInput.swatch.style.pointerEvents = pointerEvents; - + dom.bgColorInput.picker.style.opacity = opacity; dom.bgColorInput.picker.style.pointerEvents = pointerEvents; dom.bgColorInput.hexInput.style.opacity = opacity; dom.bgColorInput.hexInput.style.pointerEvents = pointerEvents; dom.bgColorInput.swatch.style.opacity = opacity; dom.bgColorInput.swatch.style.pointerEvents = pointerEvents; - - dom.batchBtn.style.opacity = opacity; - dom.batchBtn.style.pointerEvents = pointerEvents; - dom.batchBtn.style.filter = filter; + + dom.batchSingleBtn.style.opacity = opacity; + dom.batchSingleBtn.style.pointerEvents = pointerEvents; + dom.batchSingleBtn.style.filter = filter; + + dom.batchMultiBtn.style.opacity = opacity; + dom.batchMultiBtn.style.pointerEvents = pointerEvents; + dom.batchMultiBtn.style.filter = filter; } -// ТВОЙ ПЛАН: Сброс approved, srcHash и скрытие кнопок -function resetNodeState(st, dom, widgets, nodeId) { // <-- Добавлен nodeId +function resetNodeState(st, dom, widgets, nodeId) { const savedMaskColor = st.maskColor; const savedBgColor = st.bgColor; + const savedBatchMode = st.batchMode; + const savedHasPreset = st.hasPreset; - // Сбрасываем утверждение на сервере перед локальным сбросом if (st.srcHash && nodeId) { fetch("/rs_outpaint/unapprove", { method: "POST", @@ -106,45 +106,43 @@ function resetNodeState(st, dom, widgets, nodeId) { // <-- Добавлен node } Object.assign(st, createState()); + st.maskColor = savedMaskColor; st.bgColor = savedBgColor; - st.approved = false; - st.srcHash = null; + st.batchMode = savedBatchMode; + st.hasPreset = savedHasPreset; 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.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); } - + 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.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.batchSingleBtn.style.display = "block"; + dom.batchMultiBtn.style.display = "block"; dom.acceptBtn.disabled = false; dom.acceptBtn.textContent = "✔️ ACCEPT"; setUIActive(dom, true); - + if (st.srcHash && nodeId) { fetch("/rs_outpaint/unapprove", { method: "POST", @@ -163,7 +161,7 @@ function initLayout(st, wrapEl) { if (W <= 0) return false; const maxW = W - MARGIN * 2; const maxH = wrapEl.clientHeight - MARGIN * 2; - const ar = st.srcW / st.srcH; + const ar = st.srcW / st.srcH; let sfW, sfH; if (maxW / ar <= maxH) { sfW = maxW; sfH = Math.round(maxW / ar); } else { sfH = maxH; sfW = Math.round(maxH * ar); } const sfX = Math.round((W - sfW) / 2); @@ -171,26 +169,20 @@ function initLayout(st, wrapEl) { st.sf = { x: sfX, y: sfY, w: sfW, h: sfH }; st.scale = sfW / st.srcW; st.view = { zoom: 1.0, panX: 0, panY: 0 }; - - const defW = st.srcW; - const defH = st.srcH; - const defX = 0; - const defY = 0; - - const def = clampToValid({ x: defX, y: defY, w: defW, h: defH }, st.srcW, st.srcH); + + const def = clampToValid({ x: 0, y: 0, w: st.srcW, h: st.srcH }, st.srcW, st.srcH); st.cr = srcToCr(def, st.sf, st.scale); - st.cropAR = defW / defH; - + st.cropAR = st.srcW / st.srcH; + st.outW = 0; st.outH = 0; - st.arLocked = false; - + st.arLocked = false; + st.initialized = true; return true; } function mkEl(tag, css, extra) { const el = document.createElement(tag); if (css) el.style.cssText = css; if (extra) Object.assign(el, extra); return el; } -function mkBtn(label, css) { const b = mkEl("button", `padding:2px 7px;font-size:10px;border:1px solid #444;border-radius:4px;background:#2a2a2a;color:#bbb;cursor:pointer;${css||""}`); b.textContent = label; b.onmouseenter = () => { b.style.background = "#3a3a3a"; }; b.onmouseleave = () => { b.style.background = b._active ? "#1a3a5a" : "#2a2a2a"; }; return b; } function mkColorInput(label, defaultColor) { const row = mkEl("div", "display:flex;align-items:center;gap:6px;font-size:11px;color:#999;"); const lbl = mkEl("span", ""); lbl.textContent = label; @@ -209,19 +201,29 @@ function buildUI() { const imageEl = mkEl("img", "position:absolute;inset:0;width:100%;height:100%;object-fit:fill;display:none;"); imageEl.alt = ""; const srcLabel = mkEl("div", "position:absolute;bottom:5px;right:7px;font-size:9px;color:rgba(255,255,255,0.3);font-family:monospace;pointer-events:none;"); srcLabel.textContent = "source"; const noDataMsg = mkEl("div", "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:11px;color:rgba(255,255,255,0.2);pointer-events:none;text-align:center;padding:8px;"); noDataMsg.textContent = "1280×720 (default placeholder)"; - const waitingMsg = mkEl("div", "position:absolute;inset:0;display:none;align-items:center;justify-content:center;font-size:12px;color:rgba(255,200,100,0.8);pointer-events:none;text-align:center;padding:8px;font-weight:600;"); waitingMsg.textContent = "⏳ Adjust mask & click ACCEPT..."; + const waitingMsg = mkEl("div", "position:absolute;inset:0;display:none;align-items:center;justify-content:center;font-size:12px;color:rgba(255,200,100,0.8);pointer-events:none;text-align:center;padding:8px;font-weight:600;"); waitingMsg.textContent = " Adjust mask & click ACCEPT..."; sfEl.append(imageEl, srcLabel, noDataMsg, waitingMsg); const mkMask = () => mkEl("div", "position:absolute;background:rgba(210,70,70,0.28);pointer-events:none;display:none;"); const [maskTop, maskBot, maskLeft, maskRight] = [mkMask(), mkMask(), mkMask(), mkMask()]; const cropBox = mkEl("div", "position:absolute;cursor:move;border:2px solid rgba(80,150,255,0.9);background:rgba(50,120,200,0.08);will-change:left,top,width,height;display:none;"); const HBASE = "position:absolute;width:11px;height:11px;background:#ddd;border:1.5px solid rgba(60,120,200,0.85);border-radius:2px;"; - const corners = { tl: mkEl("div", HBASE+"top:0;left:0;transform:translate(-50%,-50%);cursor:nw-resize;"), tr: mkEl("div", HBASE+"top:0;right:0;transform:translate(50%,-50%);cursor:ne-resize;"), bl: mkEl("div", HBASE+"bottom:0;left:0;transform:translate(-50%,50%);cursor:sw-resize;"), br: mkEl("div", HBASE+"bottom:0;right:0;transform:translate(50%,50%);cursor:se-resize;") }; + const corners = { + tl: mkEl("div", HBASE + "top:0;left:0;transform:translate(-50%,-50%);cursor:nw-resize;"), + tr: mkEl("div", HBASE + "top:0;right:0;transform:translate(50%,-50%);cursor:ne-resize;"), + bl: mkEl("div", HBASE + "bottom:0;left:0;transform:translate(-50%,50%);cursor:sw-resize;"), + br: mkEl("div", HBASE + "bottom:0;right:0;transform:translate(50%,50%);cursor:se-resize;") + }; for (const [dir, el] of Object.entries(corners)) { el.dataset.dir = dir; cropBox.appendChild(el); } const sizeLabel = mkEl("div", "position:absolute;font-family:monospace;font-size:11px;font-weight:600;color:rgba(255,255,255,0.85);background:rgba(0,0,0,0.45);padding:1px 6px;border-radius:3px;pointer-events:none;white-space:nowrap;transform:translateX(-50%);"); const PAD_CSS = "position:absolute;font-family:monospace;font-size:10px;color:rgba(255,200,200,0.80);pointer-events:none;white-space:nowrap;"; const padLabelT = mkEl("div", PAD_CSS), padLabelB = mkEl("div", PAD_CSS), padLabelL = mkEl("div", PAD_CSS), padLabelR = mkEl("div", PAD_CSS); const EBASE = "position:absolute;background:#ddd;border:1.5px solid rgba(60,120,200,0.85);border-radius:2px;pointer-events:auto;"; - const edges = { t: mkEl("div", EBASE+"width:18px;height:7px;top:0;left:50%;transform:translate(-50%,-50%);cursor:n-resize;"), b: mkEl("div", EBASE+"width:18px;height:7px;bottom:0;left:50%;transform:translate(-50%,50%);cursor:s-resize;"), l: mkEl("div", EBASE+"width:7px;height:18px;left:0;top:50%;transform:translate(-50%,-50%);cursor:w-resize;"), r: mkEl("div", EBASE+"width:7px;height:18px;right:0;top:50%;transform:translate(50%,-50%);cursor:e-resize;") }; + const edges = { + t: mkEl("div", EBASE + "width:18px;height:7px;top:0;left:50%;transform:translate(-50%,-50%);cursor:n-resize;"), + b: mkEl("div", EBASE + "width:18px;height:7px;bottom:0;left:50%;transform:translate(-50%,50%);cursor:s-resize;"), + l: mkEl("div", EBASE + "width:7px;height:18px;left:0;top:50%;transform:translate(-50%,-50%);cursor:w-resize;"), + r: mkEl("div", EBASE + "width:7px;height:18px;right:0;top:50%;transform:translate(50%,-50%);cursor:e-resize;") + }; for (const [dir, el] of Object.entries(edges)) { el.dataset.dir = dir; cropBox.appendChild(el); } const viewport = mkEl("div", "position:absolute;inset:0;transform-origin:0 0;"); viewport.append(sfEl, maskTop, maskBot, maskLeft, maskRight, cropBox, sizeLabel, padLabelT, padLabelB, padLabelL, padLabelR); @@ -255,41 +257,63 @@ function buildUI() { const hLabel = mkEl("span", "font-size:10px;color:#999;"); hLabel.textContent = "H"; const hInput = mkEl("input", INPUT_CSS, { type: "number", min: GRID, step: GRID, value: "" }); const arBtn = mkEl("button", "padding:3px 9px;font-size:11px;border:1px solid #444;border-radius:5px;background:#2a2a2a;color:#bbb;cursor:pointer;"); arBtn.textContent = "🔓"; arBtn._active = false; - const resetBtn = mkEl("button", "padding:3px 9px;font-size:11px;border:1px solid #444;border-radius:5px;background:#2a2a2a;color:#bbb;cursor:pointer;"); + const resetBtn = mkEl("button", "padding:3px 9px;font-size:11px;border:1px solid #444;border-radius:5px;background:#2a2a2a;color:#bbb;cursor:pointer;"); resetBtn.textContent = "🔄️ Reset"; resetBtn.onmouseenter = () => { resetBtn.style.background = "#3a3a3a"; }; resetBtn.onmouseleave = () => { resetBtn.style.background = "#2a2a2a"; }; - + cropSizeRow.append(wLabel, wInput, hLabel, hInput, arBtn, resetBtn); - const CHIP_PRESETS = [["16:9",1280,720],["9:16",720,1280],["21:9",1344,576],["9:21",576,1344],["4:3",960,720],["3:4",720,960],["1:1",960,960],["3:2",1080,720],["2:3",720,1080]]; + const CHIP_PRESETS = [["16:9", 1280, 720], ["9:16", 720, 1280], ["21:9", 1344, 576], ["9:21", 576, 1344], ["4:3", 960, 720], ["3:4", 720, 960], ["1:1", 960, 960], ["3:2", 1080, 720], ["2:3", 720, 1080]]; const CHIP_CSS = "padding:2px 7px;font-size:10px;font-family:monospace;border:1px solid #444;border-radius:12px;background:#222;color:#999;cursor:pointer;white-space:nowrap;flex-shrink:0;"; const presetRow = mkEl("div", "display:flex;gap:4px;overflow-x:auto;padding-bottom:2px;flex-wrap:nowrap;"); - const chipBtns = CHIP_PRESETS.map(([label, w, h]) => { const btn = mkEl("button", CHIP_CSS); btn.textContent = label; btn.dataset.chipW = w; btn.dataset.chipH = h; btn.dataset.chipAR = w/h; presetRow.appendChild(btn); return btn; }); + const chipBtns = CHIP_PRESETS.map(([label, w, h]) => { const btn = mkEl("button", CHIP_CSS); btn.textContent = label; btn.dataset.chipW = w; btn.dataset.chipH = h; btn.dataset.chipAR = w / h; presetRow.appendChild(btn); return btn; }); const snapRow = mkEl("div", "display:flex;align-items:center;gap:0;"); const snapLabel = mkEl("span", "font-size:10px;color:#999;margin-right:4px;"); snapLabel.textContent = "snap:"; - const SNAP_DEFS = [["center","center"],["top","top"],["bottom","bottom"],["left","left"],["right","right"],["fitW","fit W"],["fitH","fit H"]]; - const snapBtns = SNAP_DEFS.map(([key, label], i) => { const isFirst = i===0, isLast = i===SNAP_DEFS.length-1; const radius = isFirst ? "4px 0 0 4px" : (isLast ? "0 4px 4px 0" : "0"); const bl = isFirst ? "1px solid #444" : "none"; const b = mkEl("button", `padding:2px 7px;font-size:10px;border:1px solid #444;border-left:${bl};border-radius:${radius};background:#2a2a2a;color:#bbb;cursor:pointer;white-space:nowrap;`); b.textContent = label; b.dataset.snap = key; return b; }); + const SNAP_DEFS = [["center", "center"], ["top", "top"], ["bottom", "bottom"], ["left", "left"], ["right", "right"], ["fitW", "fit W"], ["fitH", "fit H"]]; + const snapBtns = SNAP_DEFS.map(([key, label], i) => { const isFirst = i === 0, isLast = i === SNAP_DEFS.length - 1; const radius = isFirst ? "4px 0 0 4px" : (isLast ? "0 4px 4px 0" : "0"); const bl = isFirst ? "1px solid #444" : "none"; const b = mkEl("button", `padding:2px 7px;font-size:10px;border:1px solid #444;border-left:${bl};border-radius:${radius};background:#2a2a2a;color:#bbb;cursor:pointer;white-space:nowrap;`); b.textContent = label; b.dataset.snap = key; return b; }); snapRow.append(snapLabel, ...snapBtns); - + ctrl.append(cropSizeRow, presetRow, snapRow); - + const maskColorInput = mkColorInput("mask:", "#ff0000"); const bgColorInput = mkColorInput("bg:", "#141414"); const colorRow = mkEl("div", "display:flex;align-items:center;gap:8px;flex-wrap:wrap;"); colorRow.append(maskColorInput.row, bgColorInput.row); ctrl.appendChild(colorRow); - const batchBtn = mkEl("button", "padding:4px 12px;font-size:11px;font-weight:600;border:1px solid #444;border-radius:4px;background:#2a2a2a;color:#bbb;cursor:pointer;width:100%;text-align:center;display:none;margin-top:4px;"); - batchBtn.textContent = "⚙️ BATCH"; batchBtn.dataset.active = "false"; batchBtn.title = "Enable to reuse crop settings on new images"; - ctrl.appendChild(batchBtn); + + const batchRow = mkEl("div", "display:flex;gap:4px;margin-top:4px;"); + const batchSingleBtn = mkEl("button", "flex:1;padding:4px 2px;font-size:10px;font-weight:600;border:1px solid #444;border-radius:4px;background:#2a2a2a;color:#bbb;cursor:pointer;text-align:center;display:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"); + batchSingleBtn.textContent = "️ BATCH SINGLE IMAGE"; + batchSingleBtn.dataset.active = "false"; + const batchMultiBtn = mkEl("button", "flex:1;padding:4px 2px;font-size:10px;font-weight:600;border:1px solid #444;border-radius:4px;background:#2a2a2a;color:#bbb;cursor:pointer;text-align:center;display:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"); + batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE"; + batchMultiBtn.dataset.active = "false"; + batchRow.append(batchSingleBtn, batchMultiBtn); + ctrl.appendChild(batchRow); + const acceptBtn = mkEl("button", "padding:4px 12px;font-size:11px;font-weight:600;border:1px solid #28a745;border-radius:4px;background:#1a2a1a;color:#aaffaa;cursor:pointer;width:100%;text-align:center;display:none;margin-top:4px;"); acceptBtn.textContent = "✔️ ACCEPT"; ctrl.appendChild(acceptBtn); const cancelBtn = mkEl("button", "padding:4px 12px;font-size:11px;font-weight:600;border:1px solid #884444;border-radius:4px;background:#2a1a1a;color:#ffaaaa;cursor:pointer;width:100%;text-align:center;display:none;margin-top:2px;"); - cancelBtn.textContent = "❌ CANCEL"; ctrl.appendChild(cancelBtn); + cancelBtn.textContent = " CANCEL"; ctrl.appendChild(cancelBtn); root.append(wrap, ctrl); - return { root, wrap, viewport, zoomIndicator, sfEl, imageEl, srcLabel, noDataMsg, waitingMsg, maskTop, maskBot, maskLeft, maskRight, cropBox, arBtn, snapBtns, wInput, hInput, resetBtn, chipBtns, sizeLabel, padLabelT, padLabelB, padLabelL, padLabelR, edges, acceptBtn, cancelBtn, batchBtn, presetListOverlay, savePresetBtn, applyPresetBtn, presetNameInput, inputField, inputOk, inputCancel, maskColorInput, bgColorInput }; + return { + root, wrap, viewport, zoomIndicator, sfEl, imageEl, srcLabel, noDataMsg, waitingMsg, + maskTop, maskBot, maskLeft, maskRight, cropBox, arBtn, snapBtns, wInput, hInput, resetBtn, + chipBtns, sizeLabel, padLabelT, padLabelB, padLabelL, padLabelR, edges, + acceptBtn, cancelBtn, batchSingleBtn, batchMultiBtn, + presetListOverlay, savePresetBtn, applyPresetBtn, presetNameInput, inputField, inputOk, inputCancel, + maskColorInput, bgColorInput + }; } -function updateMaskColors(st, dom) { if (!dom) return; const maskColor = st.maskColor, alpha = "45"; const applyColor = (el) => { if(el) el.style.backgroundColor = maskColor + alpha; }; applyColor(dom.maskTop); applyColor(dom.maskBot); applyColor(dom.maskLeft); applyColor(dom.maskRight); dom.padLabelT.style.color = maskColor; dom.padLabelB.style.color = maskColor; dom.padLabelL.style.color = maskColor; dom.padLabelR.style.color = maskColor; } +function updateMaskColors(st, dom) { + if (!dom) return; + const maskColor = st.maskColor, alpha = "45"; + const applyColor = (el) => { if (el) el.style.backgroundColor = maskColor + alpha; }; + applyColor(dom.maskTop); applyColor(dom.maskBot); applyColor(dom.maskLeft); applyColor(dom.maskRight); + dom.padLabelT.style.color = maskColor; dom.padLabelB.style.color = maskColor; + dom.padLabelL.style.color = maskColor; dom.padLabelR.style.color = maskColor; +} function render(st, dom) { if (!st.initialized) return; @@ -303,31 +327,91 @@ function render(st, dom) { const s = crToSrc(cr, sf, st.scale); const ix1 = Math.max(cr.x, sf.x), iy1 = Math.max(cr.y, sf.y), ix2 = Math.min(cr.x + cr.w, sf.x + sf.w), iy2 = Math.min(cr.y + cr.h, sf.y + sf.h); const setMask = (el, x, y, w, h) => { if (w > 0 && h > 0) { el.style.cssText = `position:absolute;background:rgba(210,70,70,0.28);pointer-events:none;display:block;left:${x}px;top:${y}px;width:${w}px;height:${h}px;`; } else { el.style.display = "none"; } }; - setMask(dom.maskTop, cr.x, cr.y, cr.w, Math.max(0, iy1 - cr.y)); setMask(dom.maskBot, cr.x, iy2, cr.w, Math.max(0, cr.y + cr.h - iy2)); setMask(dom.maskLeft, cr.x, iy1, Math.max(0, ix1 - cr.x), iy2 - iy1); setMask(dom.maskRight, ix2, iy1, Math.max(0, cr.x + cr.w - ix2), iy2 - iy1); + setMask(dom.maskTop, cr.x, cr.y, cr.w, Math.max(0, iy1 - cr.y)); + setMask(dom.maskBot, cr.x, iy2, cr.w, Math.max(0, cr.y + cr.h - iy2)); + setMask(dom.maskLeft, cr.x, iy1, Math.max(0, ix1 - cr.x), iy2 - iy1); + setMask(dom.maskRight, ix2, iy1, Math.max(0, cr.x + cr.w - ix2), iy2 - iy1); const padT = Math.max(0, -s.y), padB = Math.max(0, s.y + s.h - srcH), padL = Math.max(0, -s.x), padR = Math.max(0, s.x + s.w - srcW); const outW = Math.round(s.w), outH = Math.round(s.h); dom.sizeLabel.textContent = `${outW} × ${outH}`; dom.sizeLabel.style.display = "block"; dom.sizeLabel.style.left = (cr.x + cr.w / 2) + "px"; dom.sizeLabel.style.top = (cr.y + cr.h - 18) + "px"; const showPad = (el, text, show, cx, cy) => { el.textContent = text; el.style.display = show ? "block" : "none"; if (show) { el.style.left = cx + "px"; el.style.top = cy + "px"; el.style.transform = "translate(-50%,-50%)"; } }; - showPad(dom.padLabelT, `▲ ${Math.round(padT)}`, padT > 0.5, cr.x + cr.w / 2, cr.y + (padT * st.scale) / 2); - showPad(dom.padLabelB, `▼ ${Math.round(padB)}`, padB > 0.5, cr.x + cr.w / 2, cr.y + cr.h - (padB * st.scale) / 2); - showPad(dom.padLabelL, `◀ ${Math.round(padL)}`, padL > 0.5, cr.x + (padL * st.scale) / 2, cr.y + cr.h / 2); + showPad(dom.padLabelT, `▲ ${Math.round(padT)}`, padT > 0.5, cr.x + cr.w / 2, cr.y + (padT * st.scale) / 2); + showPad(dom.padLabelB, `▼ ${Math.round(padB)}`, padB > 0.5, cr.x + cr.w / 2, cr.y + cr.h - (padB * st.scale) / 2); + showPad(dom.padLabelL, `◀ ${Math.round(padL)}`, padL > 0.5, cr.x + (padL * st.scale) / 2, cr.y + cr.h / 2); showPad(dom.padLabelR, `▶ ${Math.round(padR)}`, padR > 0.5, cr.x + cr.w - (padR * st.scale) / 2, cr.y + cr.h / 2); - - if (document.activeElement !== dom.wInput) dom.wInput.value = outW || ""; if (document.activeElement !== dom.hInput) dom.hInput.value = outH || ""; + + if (document.activeElement !== dom.wInput) dom.wInput.value = outW || ""; + if (document.activeElement !== dom.hInput) dom.hInput.value = outH || ""; const actualAR = st.arLocked ? st.cropAR : s.w / s.h; - for (const btn of dom.chipBtns) { const chipAR = parseFloat(btn.dataset.chipAR); const active = Math.abs(chipAR - actualAR) < 0.005; btn.style.borderColor = active ? "#5090cc" : "#444"; btn.style.color = active ? "#aadaff" : "#999"; btn.style.background = active ? "#1a3050" : "#222"; } + for (const btn of dom.chipBtns) { + const chipAR = parseFloat(btn.dataset.chipAR); + const active = Math.abs(chipAR - actualAR) < 0.005; + btn.style.borderColor = active ? "#5090cc" : "#444"; + btn.style.color = active ? "#aadaff" : "#999"; + btn.style.background = active ? "#1a3050" : "#222"; + } if (document.activeElement !== dom.maskColorInput.hexInput) { dom.maskColorInput.picker.value = st.maskColor; dom.maskColorInput.swatch.style.background = st.maskColor; dom.maskColorInput.hexInput.value = st.maskColor.toUpperCase(); } if (document.activeElement !== dom.bgColorInput.hexInput) { dom.bgColorInput.picker.value = st.bgColor; dom.bgColorInput.swatch.style.background = st.bgColor; dom.bgColorInput.hexInput.value = st.bgColor.toUpperCase(); } updateMaskColors(st, dom); } -function fitCropInView(st, dom) { const wrapW = dom.wrap.clientWidth, wrapH = dom.wrap.clientHeight; if (wrapW <= 0 || wrapH <= 0) return; const pad = MARGIN; const bx1 = Math.min(st.sf.x, st.cr.x), by1 = Math.min(st.sf.y, st.cr.y), bx2 = Math.max(st.sf.x + st.sf.w, st.cr.x + st.cr.w), by2 = Math.max(st.sf.y + st.sf.h, st.cr.y + st.cr.h); const bw = bx2 - bx1, bh = by2 - by1; const fitZoom = Math.min((wrapW - pad * 2) / bw, (wrapH - pad * 2) / bh); const needsZoomOut = fitZoom < st.view.zoom; const z = needsZoomOut ? Math.max(0.15, fitZoom) : st.view.zoom; const scL = bx1 * z + st.view.panX, scT = by1 * z + st.view.panY, scR = bx2 * z + st.view.panX, scB = by2 * z + st.view.panY; const outOfBounds = scL < 0 || scT < 0 || scR > wrapW || scB > wrapH; if (!needsZoomOut && !outOfBounds) return; st.view.zoom = z; st.view.panX = (wrapW - bw * z) / 2 - bx1 * z; st.view.panY = (wrapH - bh * z) / 2 - by1 * z; } -function syncWidgets(st, widgets, node) { const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); const hex = st.maskColor.replace("#", ""); const mR = parseInt(hex.substring(0,2), 16), mG = parseInt(hex.substring(2,4), 16), mB = parseInt(hex.substring(4,6), 16); - if (widgets.cropState) widgets.cropState.value = `${s.x},${s.y},${s.w},${s.h},0,0,${mR},${mG},${mB}`; - if (node.graph) node.graph.setDirtyCanvas(true, true); } -function setArLocked(st, dom, locked) { st.arLocked = locked; if (locked && st.cr.h > 0) st.cropAR = st.cr.w / st.cr.h; dom.arBtn.textContent = locked ? "🔒" : "🔓"; dom.arBtn.style.border = `1px solid ${locked ? "#99c0ee" : "#444"}`; dom.arBtn.style.background = locked ? "#1a3a5a" : "#2a2a2a"; dom.arBtn.style.color = locked ? "#aadaff" : "#bbb"; dom.arBtn._active = locked; } -function wireColors(st, dom, widgets, node) { const bind = (inputObj, stateKey) => { inputObj.picker.addEventListener("input", (e) => { st[stateKey] = e.target.value; inputObj.swatch.style.background = e.target.value; inputObj.hexInput.value = e.target.value.toUpperCase(); updateMaskColors(st, dom); syncWidgets(st, widgets, node); }); inputObj.hexInput.addEventListener("change", (e) => { let val = e.target.value; if (!val.startsWith("#")) val = "#" + val; if (/^#[0-9A-F]{6}$/i.test(val)) { st[stateKey] = val; inputObj.picker.value = val; inputObj.swatch.style.background = val; updateMaskColors(st, dom); syncWidgets(st, widgets, node); } }); }; bind(dom.maskColorInput, 'maskColor'); bind(dom.bgColorInput, 'bgColor'); } +function fitCropInView(st, dom) { + const wrapW = dom.wrap.clientWidth, wrapH = dom.wrap.clientHeight; + if (wrapW <= 0 || wrapH <= 0) return; + const pad = MARGIN; + const bx1 = Math.min(st.sf.x, st.cr.x), by1 = Math.min(st.sf.y, st.cr.y), bx2 = Math.max(st.sf.x + st.sf.w, st.cr.x + st.cr.w), by2 = Math.max(st.sf.y + st.sf.h, st.cr.y + st.cr.h); + const bw = bx2 - bx1, bh = by2 - by1; + const fitZoom = Math.min((wrapW - pad * 2) / bw, (wrapH - pad * 2) / bh); + const needsZoomOut = fitZoom < st.view.zoom; + const z = needsZoomOut ? Math.max(0.15, fitZoom) : st.view.zoom; + const scL = bx1 * z + st.view.panX, scT = by1 * z + st.view.panY, scR = bx2 * z + st.view.panX, scB = by2 * z + st.view.panY; + const outOfBounds = scL < 0 || scT < 0 || scR > wrapW || scB > wrapH; + if (!needsZoomOut && !outOfBounds) return; + st.view.zoom = z; + st.view.panX = (wrapW - bw * z) / 2 - bx1 * z; + st.view.panY = (wrapH - bh * z) / 2 - by1 * z; +} +function syncWidgets(st, widgets, node) { + const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); + const hex = st.maskColor.replace("#", ""); + const mR = parseInt(hex.substring(0, 2), 16), mG = parseInt(hex.substring(2, 4), 16), mB = parseInt(hex.substring(4, 6), 16); + if (widgets.cropState) widgets.cropState.value = `${s.x},${s.y},${s.w},${s.h},0,0,${mR},${mG},${mB}`; + if (node.graph) node.graph.setDirtyCanvas(true, true); +} +function setArLocked(st, dom, locked) { + st.arLocked = locked; + if (locked && st.cr.h > 0) st.cropAR = st.cr.w / st.cr.h; + dom.arBtn.textContent = locked ? "" : "🔓"; + dom.arBtn.style.border = `1px solid ${locked ? "#99c0ee" : "#444"}`; + dom.arBtn.style.background = locked ? "#1a3a5a" : "#2a2a2a"; + dom.arBtn.style.color = locked ? "#aadaff" : "#bbb"; + dom.arBtn._active = locked; +} +function wireColors(st, dom, widgets, node) { + const bind = (inputObj, stateKey) => { + inputObj.picker.addEventListener("input", (e) => { + st[stateKey] = e.target.value; + inputObj.swatch.style.background = e.target.value; + inputObj.hexInput.value = e.target.value.toUpperCase(); + updateMaskColors(st, dom); + syncWidgets(st, widgets, node); + }); + inputObj.hexInput.addEventListener("change", (e) => { + let val = e.target.value; + if (!val.startsWith("#")) val = "#" + val; + if (/^#[0-9A-F]{6}$/i.test(val)) { + st[stateKey] = val; + inputObj.picker.value = val; + inputObj.swatch.style.background = val; + updateMaskColors(st, dom); + syncWidgets(st, widgets, node); + } + }); + }; + bind(dom.maskColorInput, 'maskColor'); + bind(dom.bgColorInput, 'bgColor'); +} function calcSizeByRatio(srcW, srcH, targetAR) { let newW, newH; @@ -345,34 +429,34 @@ function calcSizeByRatio(srcW, srcH, targetAR) { } function wireInteractions(st, dom, widgets, node, nodeId) { - const { wrap, arBtn, snapBtns, wInput, hInput, resetBtn, chipBtns, batchBtn, savePresetBtn, applyPresetBtn, presetListOverlay, presetNameInput, inputField, inputOk, inputCancel } = dom; - + const { wrap, arBtn, snapBtns, wInput, hInput, resetBtn, chipBtns, batchSingleBtn, batchMultiBtn, savePresetBtn, applyPresetBtn, presetListOverlay, presetNameInput, inputField, inputOk, inputCancel } = dom; + savePresetBtn.addEventListener("click", () => { dom.presetListOverlay.style.display = "none"; dom.presetNameInput.style.display = "block"; dom.inputField.value = ""; dom.inputField.focus(); }); - const performSave = () => { - const name = dom.inputField.value.trim(); - if (!name) return; - const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); - const hex = st.maskColor.replace("#", ""); - const mR = parseInt(hex.substring(0,2), 16), mG = parseInt(hex.substring(2,4), 16), mB = parseInt(hex.substring(4,6), 16); - const presetData = { crop_state: `${s.x},${s.y},${s.w},${s.h},0,0,${mR},${mG},${mB}`, maskColor: st.maskColor, bgColor: st.bgColor, outW: 0, outH: 0, cropAR: st.cropAR, arLocked: st.arLocked }; - fetch("/rs_outpaint/save_preset", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, preset_data: presetData }) - }).then(r => { - dom.presetNameInput.style.display = "none"; - dom.noDataMsg.textContent = r.ok ? `✅ Preset "${name}" saved!` : "❌ Save failed"; - dom.noDataMsg.style.display = "flex"; - setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 2000); - }).catch(e => { - dom.noDataMsg.textContent = "❌ Error: " + e.message; - dom.noDataMsg.style.display = "flex"; - setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 2000); - }); + const performSave = () => { + const name = dom.inputField.value.trim(); + if (!name) return; + const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); + const hex = st.maskColor.replace("#", ""); + const mR = parseInt(hex.substring(0, 2), 16), mG = parseInt(hex.substring(2, 4), 16), mB = parseInt(hex.substring(4, 6), 16); + const presetData = { crop_state: `${s.x},${s.y},${s.w},${s.h},0,0,${mR},${mG},${mB}`, maskColor: st.maskColor, bgColor: st.bgColor, outW: 0, outH: 0, cropAR: st.cropAR, arLocked: st.arLocked }; + fetch("/rs_outpaint/save_preset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, preset_data: presetData }) + }).then(r => { + dom.presetNameInput.style.display = "none"; + dom.noDataMsg.textContent = r.ok ? `✅ Preset "${name}" saved!` : "❌ Save failed"; + dom.noDataMsg.style.display = "flex"; + setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 2000); + }).catch(e => { + dom.noDataMsg.textContent = "❌ Error: " + e.message; + dom.noDataMsg.style.display = "flex"; + setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 2000); + }); }; - inputOk.addEventListener("click", performSave); - inputCancel.addEventListener("click", () => { dom.presetNameInput.style.display = "none"; }); - inputField.addEventListener("keydown", (e) => { if(e.key === "Enter") performSave(); if(e.key === "Escape") dom.presetNameInput.style.display = "none"; }); + inputOk.addEventListener("click", performSave); + inputCancel.addEventListener("click", () => { dom.presetNameInput.style.display = "none"; }); + inputField.addEventListener("keydown", (e) => { if (e.key === "Enter") performSave(); if (e.key === "Escape") dom.presetNameInput.style.display = "none"; }); applyPresetBtn.addEventListener("click", async () => { dom.presetNameInput.style.display = "none"; @@ -380,7 +464,7 @@ function wireInteractions(st, dom, widgets, node, nodeId) { dom.presetListOverlay.innerHTML = "
Loading...
"; dom.presetListOverlay.style.display = "flex"; try { - const res = await fetch("/rs_outpaint/list_presets", { method: "POST", headers: {"Content-Type": "application/json"} }); + const res = await fetch("/rs_outpaint/list_presets", { method: "POST", headers: { "Content-Type": "application/json" } }); const list = await res.json(); dom.presetListOverlay.innerHTML = ""; if (!list.length) { dom.presetListOverlay.textContent = "No presets found"; return; } @@ -392,18 +476,26 @@ function wireInteractions(st, dom, widgets, node, nodeId) { nameSpan.onmouseenter = () => nameSpan.style.background = "#3a3a3a"; nameSpan.onmouseleave = () => nameSpan.style.background = "transparent"; nameSpan.onclick = async () => { dom.presetListOverlay.style.display = "none"; - if (st.srcW < 32 || st.srcH < 32) { dom.noDataMsg.textContent = "⚠️ Wait for image to load first"; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); return; } + if (st.srcW < 32 || st.srcH < 32) { dom.noDataMsg.textContent = "️ Wait for image to load first"; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); return; } try { - const res2 = await fetch("/rs_outpaint/load_preset", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ name }) }); + const res2 = await fetch("/rs_outpaint/load_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); if (!res2.ok) throw new Error("Load failed"); const data = await res2.json(); - st.maskColor = data.maskColor || "#ff0000"; st.bgColor = data.bgColor || "#141414"; st.outW = 0; st.outH = 0; st.cropAR = data.cropAR || 16/9; st.arLocked = data.arLocked !== undefined ? data.arLocked : true; + st.maskColor = data.maskColor || "#ff0000"; st.bgColor = data.bgColor || "#141414"; st.outW = 0; st.outH = 0; st.cropAR = data.cropAR || 16 / 9; st.arLocked = data.arLocked !== undefined ? data.arLocked : true; setArLocked(st, dom, st.arLocked); dom.maskColorInput.picker.value = st.maskColor; dom.maskColorInput.swatch.style.background = st.maskColor; dom.maskColorInput.hexInput.value = st.maskColor.toUpperCase(); dom.bgColorInput.picker.value = st.bgColor; dom.bgColorInput.swatch.style.background = st.bgColor; dom.bgColorInput.hexInput.value = st.bgColor.toUpperCase(); - if(data.crop_state) { const parts = data.crop_state.split(',').map(Number); if(parts.length >= 4) { let s = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }; s = quantizeSrc(s); s = clampToValid(s, st.srcW, st.srcH); st.cr = srcToCr(s, st.sf, st.scale); } } + if (data.crop_state) { + const parts = data.crop_state.split(',').map(Number); + if (parts.length >= 4) { + let s = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }; + s = quantizeSrc(s); + s = clampToValid(s, st.srcW, st.srcH); + st.cr = srcToCr(s, st.sf, st.scale); + } + } updateMaskColors(st, dom); fitCropInView(st, dom); render(st, dom); syncWidgets(st, widgets, node); - } catch(e) { dom.noDataMsg.textContent = "❌ " + e.message; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); } + } catch (e) { dom.noDataMsg.textContent = "❌ " + e.message; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); } }; const deleteBtn = document.createElement("span"); deleteBtn.textContent = "❌"; deleteBtn.style.cssText = "cursor:pointer;margin-left:8px;font-size:14px;opacity:0.7;"; @@ -412,84 +504,164 @@ function wireInteractions(st, dom, widgets, node, nodeId) { deleteBtn.onclick = async (e) => { e.stopPropagation(); try { - const res3 = await fetch("/rs_outpaint/delete_preset", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ name }) }); + const res3 = await fetch("/rs_outpaint/delete_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); if (res3.ok) { applyPresetBtn.click(); dom.noDataMsg.textContent = `🗑️ Preset "${name}" deleted`; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); } else { dom.noDataMsg.textContent = "❌ Delete failed"; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); } - } catch(e) { dom.noDataMsg.textContent = "❌ Error: " + e.message; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); } + } catch (e) { dom.noDataMsg.textContent = "❌ Error: " + e.message; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); } }; row.appendChild(nameSpan); row.appendChild(deleteBtn); dom.presetListOverlay.appendChild(row); }); - } catch(e) { dom.presetListOverlay.textContent = "Error loading"; } + } catch (e) { dom.presetListOverlay.textContent = "Error loading"; } }); - + document.addEventListener("click", (e) => { if (!dom.presetListOverlay?.contains(e.target) && !dom.applyPresetBtn?.contains(e.target)) dom.presetListOverlay.style.display = "none"; if (!dom.presetNameInput?.contains(e.target) && e.target !== dom.savePresetBtn) dom.presetNameInput.style.display = "none"; }); - batchBtn.addEventListener("click", async () => { st.batchMode = !st.batchMode; dom.batchBtn.dataset.active = st.batchMode ? "true" : "false"; if (st.batchMode) { dom.batchBtn.style.borderColor = "#28a745"; dom.batchBtn.style.background = "#1a2a1a"; dom.batchBtn.style.color = "#aaffaa"; dom.batchBtn.textContent = "⚙️ BATCH ON"; } else { dom.batchBtn.style.borderColor = "#444"; dom.batchBtn.style.background = "#2a2a2a"; dom.batchBtn.style.color = "#bbb"; dom.batchBtn.textContent = "⚙️ BATCH"; } try { await fetch("/rs_outpaint/batch_toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id), enabled: st.batchMode }) }); } catch(e) {} }); + + batchSingleBtn.addEventListener("click", async () => { + if (dom.batchMultiBtn.dataset.active === "true") { + dom.batchMultiBtn.dataset.active = "false"; + dom.batchMultiBtn.style.borderColor = "#444"; + dom.batchMultiBtn.style.background = "#2a2a2a"; + dom.batchMultiBtn.style.color = "#bbb"; + dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE"; + try { + await fetch("/rs_outpaint/batch_toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id), batch_type: "multi", enabled: false }) }); + } catch (e) {} + } + + const nowActive = dom.batchSingleBtn.dataset.active !== "true"; + dom.batchSingleBtn.dataset.active = nowActive ? "true" : "false"; + if (nowActive) { + dom.batchSingleBtn.style.borderColor = "#28a745"; + dom.batchSingleBtn.style.background = "#1a2a1a"; + dom.batchSingleBtn.style.color = "#aaffaa"; + dom.batchSingleBtn.textContent = "️ BATCH SINGLE IMAGE ON"; + } else { + dom.batchSingleBtn.style.borderColor = "#444"; + dom.batchSingleBtn.style.background = "#2a2a2a"; + dom.batchSingleBtn.style.color = "#bbb"; + dom.batchSingleBtn.textContent = "⚙️ BATCH SINGLE IMAGE"; + } + try { + await fetch("/rs_outpaint/batch_toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(node.id), batch_type: "single", enabled: nowActive }) + }); + } catch (e) {} + }); + + batchMultiBtn.addEventListener("click", async () => { + if (dom.batchSingleBtn.dataset.active === "true") { + dom.batchSingleBtn.dataset.active = "false"; + dom.batchSingleBtn.style.borderColor = "#444"; + dom.batchSingleBtn.style.background = "#2a2a2a"; + dom.batchSingleBtn.style.color = "#bbb"; + dom.batchSingleBtn.textContent = "⚙️ BATCH SINGLE IMAGE"; + try { + await fetch("/rs_outpaint/batch_toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id), batch_type: "single", enabled: false }) }); + } catch (e) {} + } + + const nowActive = dom.batchMultiBtn.dataset.active !== "true"; + dom.batchMultiBtn.dataset.active = nowActive ? "true" : "false"; + if (nowActive) { + dom.batchMultiBtn.style.borderColor = "#28a745"; + dom.batchMultiBtn.style.background = "#1a2a1a"; + dom.batchMultiBtn.style.color = "#aaffaa"; + dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE ON"; + } else { + dom.batchMultiBtn.style.borderColor = "#444"; + dom.batchMultiBtn.style.background = "#2a2a2a"; + dom.batchMultiBtn.style.color = "#bbb"; + dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE"; + } + try { + await fetch("/rs_outpaint/batch_toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(node.id), batch_type: "multi", enabled: nowActive }) + }); + } catch (e) {} + }); + arBtn.addEventListener("click", () => setArLocked(st, dom, !st.arLocked)); - - wInput.addEventListener("change", () => { - const r = Math.max(GRID, Math.round(parseInt(wInput.value, 10) / GRID) * GRID) || GRID; - let s = crToSrc(st.cr, st.sf, st.scale); - const cx = s.x + s.w / 2, cy = s.y + s.h / 2; - s.w = r; - if (st.arLocked) s.h = Math.max(GRID, Math.round(r / st.cropAR / GRID) * GRID); - s.x = Math.round((cx - s.w / 2) / GRID) * GRID; - s.y = Math.round((cy - s.h / 2) / GRID) * GRID; - s = clampToValid(quantizeSrc(s), st.srcW, st.srcH); - st.cr = srcToCr(s, st.sf, st.scale); - if (!st.arLocked) st.cropAR = s.w / s.h; - fitCropInView(st, dom); - render(st, dom); + + wInput.addEventListener("change", () => { + const r = Math.max(GRID, Math.round(parseInt(wInput.value, 10) / GRID) * GRID) || GRID; + let s = crToSrc(st.cr, st.sf, st.scale); + const cx = s.x + s.w / 2, cy = s.y + s.h / 2; + s.w = r; + if (st.arLocked) s.h = Math.max(GRID, Math.round(r / st.cropAR / GRID) * GRID); + s.x = Math.round((cx - s.w / 2) / GRID) * GRID; + s.y = Math.round((cy - s.h / 2) / GRID) * GRID; + s = clampToValid(quantizeSrc(s), st.srcW, st.srcH); + st.cr = srcToCr(s, st.sf, st.scale); + if (!st.arLocked) st.cropAR = s.w / s.h; + fitCropInView(st, dom); + render(st, dom); syncWidgets(st, widgets, node); - unapprove(st, dom, nodeId); // ТВОЙ ПЛАН + unapprove(st, dom, nodeId); }); - - hInput.addEventListener("change", () => { - const r = Math.max(GRID, Math.round(parseInt(hInput.value, 10) / GRID) * GRID) || GRID; - let s = crToSrc(st.cr, st.sf, st.scale); - const cx = s.x + s.w / 2, cy = s.y + s.h / 2; - s.h = r; - if (st.arLocked) s.w = Math.max(GRID, Math.round(r * st.cropAR / GRID) * GRID); - s.x = Math.round((cx - s.w / 2) / GRID) * GRID; - s.y = Math.round((cy - s.h / 2) / GRID) * GRID; - s = clampToValid(quantizeSrc(s), st.srcW, st.srcH); - st.cr = srcToCr(s, st.sf, st.scale); - if (!st.arLocked) st.cropAR = s.w / s.h; - fitCropInView(st, dom); - render(st, dom); + + hInput.addEventListener("change", () => { + const r = Math.max(GRID, Math.round(parseInt(hInput.value, 10) / GRID) * GRID) || GRID; + let s = crToSrc(st.cr, st.sf, st.scale); + const cx = s.x + s.w / 2, cy = s.y + s.h / 2; + s.h = r; + if (st.arLocked) s.w = Math.max(GRID, Math.round(r * st.cropAR / GRID) * GRID); + s.x = Math.round((cx - s.w / 2) / GRID) * GRID; + s.y = Math.round((cy - s.h / 2) / GRID) * GRID; + s = clampToValid(quantizeSrc(s), st.srcW, st.srcH); + st.cr = srcToCr(s, st.sf, st.scale); + if (!st.arLocked) st.cropAR = s.w / s.h; + fitCropInView(st, dom); + render(st, dom); syncWidgets(st, widgets, node); - unapprove(st, dom, nodeId); // ТВОЙ ПЛАН + unapprove(st, dom, nodeId); }); - + 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; + st.batchMode = false; + st.hasPreset = false; 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.batchSingleBtn.style.display = "none"; + dom.batchMultiBtn.style.display = "none"; dom.resetBtn.style.display = "none"; - + + dom.batchSingleBtn.dataset.active = "false"; + dom.batchSingleBtn.style.borderColor = "#444"; + dom.batchSingleBtn.style.background = "#2a2a2a"; + dom.batchSingleBtn.style.color = "#bbb"; + dom.batchSingleBtn.textContent = "️ BATCH SINGLE IMAGE"; + dom.batchMultiBtn.dataset.active = "false"; + dom.batchMultiBtn.style.borderColor = "#444"; + dom.batchMultiBtn.style.background = "#2a2a2a"; + dom.batchMultiBtn.style.color = "#bbb"; + dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE"; + } catch (err) { console.error("Reset failed:", err); } finally { @@ -497,88 +669,99 @@ function wireInteractions(st, dom, widgets, node, nodeId) { resetBtn.textContent = "🔄️ Reset"; } }); - - for (const btn of chipBtns) { - btn.addEventListener("click", () => { + + for (const btn of chipBtns) { + btn.addEventListener("click", () => { const targetAR = parseFloat(btn.dataset.chipAR); setArLocked(st, dom, true); st.cropAR = targetAR; - + const { w: newW, h: newH } = calcSizeByRatio(st.srcW, st.srcH, targetAR); - + const centerX = st.srcW / 2; const centerY = st.srcH / 2; - + let s = { x: Math.round((centerX - newW / 2) / GRID) * GRID, y: Math.round((centerY - newH / 2) / GRID) * GRID, w: newW, h: newH }; - + s = clampToValid(quantizeSrc(s), st.srcW, st.srcH); st.cr = srcToCr(s, st.sf, st.scale); - - fitCropInView(st, dom); - render(st, dom); + + fitCropInView(st, dom); + render(st, dom); syncWidgets(st, widgets, node); - unapprove(st, dom, nodeId); // ТВОЙ ПЛАН - }); + unapprove(st, dom, nodeId); + }); } - - for (const btn of snapBtns) { - btn.addEventListener("click", () => { - const mode = btn.dataset.snap; - let s = crToSrc(st.cr, st.sf, st.scale); - const { srcW, srcH } = st; - if (mode === "center") { s.x = Math.round((srcW - s.w) / 2 / GRID) * GRID; s.y = Math.round((srcH - s.h) / 2 / GRID) * GRID; } - else if (mode === "top") s.y = 0; - else if (mode === "bottom") s.y = Math.round((srcH - s.h) / GRID) * GRID; - else if (mode === "fitW") { s.w = srcW; if (st.arLocked) s.h = Math.round(s.w / st.cropAR / GRID) * GRID; s.x = 0; s.y = Math.round((srcH - s.h) / 2 / GRID) * GRID; } - else if (mode === "fitH") { s.h = srcH; if (st.arLocked) s.w = Math.round(s.h * st.cropAR / GRID) * GRID; s.y = 0; s.x = Math.round((srcW - s.w) / 2 / GRID) * GRID; } - else if (mode === "left") s.x = 0; - else if (mode === "right") s.x = Math.round((srcW - s.w) / GRID) * GRID; - s = clampToValid(quantizeSrc(s), st.srcW, st.srcH); - st.cr = srcToCr(s, st.sf, st.scale); - if (!st.arLocked) st.cropAR = s.w / s.h; - render(st, dom); + + for (const btn of snapBtns) { + btn.addEventListener("click", () => { + const mode = btn.dataset.snap; + let s = crToSrc(st.cr, st.sf, st.scale); + const { srcW, srcH } = st; + if (mode === "center") { s.x = Math.round((srcW - s.w) / 2 / GRID) * GRID; s.y = Math.round((srcH - s.h) / 2 / GRID) * GRID; } + else if (mode === "top") s.y = 0; + else if (mode === "bottom") s.y = Math.round((srcH - s.h) / GRID) * GRID; + else if (mode === "fitW") { s.w = srcW; if (st.arLocked) s.h = Math.round(s.w / st.cropAR / GRID) * GRID; s.x = 0; s.y = Math.round((srcH - s.h) / 2 / GRID) * GRID; } + else if (mode === "fitH") { s.h = srcH; if (st.arLocked) s.w = Math.round(s.h * st.cropAR / GRID) * GRID; s.y = 0; s.x = Math.round((srcW - s.w) / 2 / GRID) * GRID; } + else if (mode === "left") s.x = 0; + else if (mode === "right") s.x = Math.round((srcW - s.w) / GRID) * GRID; + s = clampToValid(quantizeSrc(s), st.srcW, st.srcH); + st.cr = srcToCr(s, st.sf, st.scale); + if (!st.arLocked) st.cropAR = s.w / s.h; + render(st, dom); syncWidgets(st, widgets, node); - unapprove(st, dom, nodeId); // ТВОЙ ПЛАН - }); + unapprove(st, dom, nodeId); + }); } - + wrap.addEventListener("wheel", e => { e.preventDefault(); const rect = wrap.getBoundingClientRect(); const sx = e.clientX - rect.left, sy = e.clientY - rect.top; const factor = e.deltaY < 0 ? 1.12 : (1 / 1.12); const newZoom = clamp(st.view.zoom * factor, 0.15, 5.0); st.view.panX = sx - (sx - st.view.panX) * (newZoom / st.view.zoom); st.view.panY = sy - (sy - st.view.panY) * (newZoom / st.view.zoom); st.view.zoom = newZoom; render(st, dom); }, { passive: false }); dom.zoomIndicator.addEventListener("click", () => { st.view = { zoom: Infinity, panX: 0, panY: 0 }; fitCropInView(st, dom); render(st, dom); }); 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); unapprove(st, dom, nodeId); }); // ТВОЙ ПЛАН + 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; }); } -api.addEventListener("status", (e) => { +api.addEventListener("status", (e) => { const remaining = e.detail?.exec_info?.queue_remaining; if (remaining > 0) { queueWasRunning = true; if (batchResetTimer) { clearTimeout(batchResetTimer); batchResetTimer = null; } - } else if (remaining === 0 && queueWasRunning) { - queueWasRunning = false; - if (!batchResetTimer) { + } else if (remaining === 0) { + if (queueWasRunning && !batchResetTimer) { batchResetTimer = setTimeout(() => { - allNodes.forEach(({st, dom, widgets, node}) => { - if (st.batchMode || st.hasPreset) { + allNodes.forEach(({ st, dom, widgets, node }) => { + const singleBatchActive = dom.batchSingleBtn.dataset.active === "true"; + const multiBatchActive = dom.batchMultiBtn.dataset.active === "true"; + + if (singleBatchActive || multiBatchActive) { st.batchMode = false; st.hasPreset = false; - dom.batchBtn.dataset.active = "false"; - dom.batchBtn.style.borderColor = "#444"; - dom.batchBtn.style.background = "#2a2a2a"; - dom.batchBtn.style.color = "#bbb"; - dom.batchBtn.textContent = "⚙️ BATCH"; - fetch("/rs_outpaint/clear_preset", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ node_id: String(node.id) }) - }).catch(err => console.error("Failed to clear preset:", err)); + + dom.batchSingleBtn.dataset.active = "false"; + dom.batchSingleBtn.style.borderColor = "#444"; + dom.batchSingleBtn.style.background = "#2a2a2a"; + dom.batchSingleBtn.style.color = "#bbb"; + dom.batchSingleBtn.textContent = "⚙️ BATCH SINGLE IMAGE"; + + dom.batchMultiBtn.dataset.active = "false"; + dom.batchMultiBtn.style.borderColor = "#444"; + dom.batchMultiBtn.style.background = "#2a2a2a"; + dom.batchMultiBtn.style.color = "#bbb"; + dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE"; + + fetch("/rs_outpaint/clear_all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(node.id) }) + }).catch(err => console.error("Failed to clear all caches:", err)); + resetNodeState(st, dom, widgets, node.id); dom.noDataMsg.textContent = "🔄 Batch complete. Node reset."; dom.noDataMsg.style.display = "flex"; @@ -586,9 +769,10 @@ api.addEventListener("status", (e) => { } }); batchResetTimer = null; - }, 2000); + queueWasRunning = false; + }, 2000); } - } + } }); app.registerExtension({ @@ -596,7 +780,7 @@ app.registerExtension({ async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_CLASS) return; const origOnNodeCreated = nodeType.prototype.onNodeCreated; - + nodeType.prototype.onNodeCreated = function () { const result = origOnNodeCreated?.apply(this, arguments); const node = this; @@ -606,215 +790,217 @@ app.registerExtension({ const st = createState(); const dom = buildUI(); wireColors(st, dom, widgets, node); - + setUIActive(dom, false); - + const domWidget = node.addDOMWidget("rs_outpaint_canvas", "custom", dom.root, { serialize: false, hideOnZoom: false }); - domWidget.computeSize = () => [520, CANVAS_H + CTRL_H]; - node.setSize([Math.max(node.size[0], 540), Math.max(node.size[1], CANVAS_H + CTRL_H + 10)]); + domWidget.computeSize = () => [500, CANVAS_H + CTRL_H]; + node.setSize([500, Math.max(node.size[1], CANVAS_H + CTRL_H + 10)]); node.heartbeatInterval = null; - node.startHeartbeat = function() { if (this.heartbeatInterval) clearInterval(this.heartbeatInterval); this.heartbeatInterval = setInterval(async () => { try { await fetch("/rs_outpaint/heartbeat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(this.id) }) }); } catch (e) {} }, 3000); }; - 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, is_new_image, src_hash } = data; - if (String(node_id) !== String(node.id)) return; - - const isSameImage = st.initialized && st.srcHash === src_hash; + node.startHeartbeat = function () { if (this.heartbeatInterval) clearInterval(this.heartbeatInterval); this.heartbeatInterval = setInterval(async () => { try { await fetch("/rs_outpaint/heartbeat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(this.id) }) }); } catch (e) {} }, 3000); }; + 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, auto_approved, applied_crop_state } = data; + if (String(node_id) !== String(node.id)) return; + + const isSameImage = st.initialized && st.srcHash === (data.src_hash || null); if (isSameImage) { - // То же изображение – обновляем картинку + const W = dom.wrap.clientWidth; + if (W > 0) { + const maxW = W - MARGIN * 2; + const maxH = dom.wrap.clientHeight - MARGIN * 2; + const ar = st.srcW / st.srcH; + let sfW, sfH; + if (maxW / ar <= maxH) { sfW = maxW; sfH = Math.round(maxW / ar); } else { sfH = maxH; sfW = Math.round(maxH * ar); } + const sfX = Math.round((W - sfW) / 2); + const sfY = Math.max(MARGIN, Math.round((dom.wrap.clientHeight - sfH) / 2)); + st.sf = { x: sfX, y: sfY, w: sfW, h: sfH }; + st.scale = sfW / st.srcW; + } + 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) { + + if (auto_approved && applied_crop_state) { + const parts = applied_crop_state.split(',').map(Number); + if (parts.length >= 4) { + let s = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }; + s = quantizeSrc(s); + s = clampToValid(s, st.srcW, st.srcH); + st.cr = srcToCr(s, st.sf, st.scale); + } + fitCropInView(st, dom); + render(st, dom); + 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"; + dom.batchSingleBtn.style.display = "none"; + dom.batchMultiBtn.style.display = "none"; } else { dom.waitingMsg.style.display = "flex"; dom.acceptBtn.style.display = "block"; dom.cancelBtn.style.display = "block"; - dom.batchBtn.style.display = "block"; + dom.batchSingleBtn.style.display = "block"; + dom.batchMultiBtn.style.display = "block"; dom.acceptBtn.disabled = false; dom.acceptBtn.textContent = "✔️ ACCEPT"; - dom.resetBtn.style.display = "none"; + setUIActive(dom, true); } fitCropInView(st, dom); render(st, dom); - if (!st.approved) { - setUIActive(dom, true); - } }; img.src = tempUrl; return; } - // Новое изображение – полный сброс - resetNodeState(st, dom, widgets); + resetNodeState(st, dom, widgets, node.id); st.srcW = image_width; st.srcH = image_height; - st.srcHash = src_hash; + st.srcHash = data.src_hash || null; - 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"; + 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 (auto_approved && applied_crop_state) { + const parts = applied_crop_state.split(',').map(Number); + if (parts.length >= 4) { + let s = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }; + s = quantizeSrc(s); + s = clampToValid(s, st.srcW, st.srcH); + st.cr = srcToCr(s, st.sf, st.scale); + } + 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"; + + dom.waitingMsg.style.display = "none"; + dom.acceptBtn.style.display = "none"; + dom.cancelBtn.style.display = "none"; + dom.batchSingleBtn.style.display = "none"; + dom.batchMultiBtn.style.display = "none"; + } else { + dom.waitingMsg.style.display = "flex"; + dom.noDataMsg.style.display = "none"; + dom.acceptBtn.style.display = "block"; + dom.cancelBtn.style.display = "block"; + dom.batchSingleBtn.style.display = "block"; + dom.batchMultiBtn.style.display = "block"; + dom.acceptBtn.disabled = false; + dom.acceptBtn.textContent = "✔️ ACCEPT"; + fitCropInView(st, dom); render(st, dom); - }; - img.src = tempUrl; - node.startHeartbeat(); - } + } + }; + 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, - src_hash: st.srcHash // <-- ДОБАВЛЕНО - }) - }); - if (resp.ok) { - node.stopHeartbeat(); + 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; + + let batchType = false; + if (dom.batchSingleBtn.dataset.active === "true") batchType = "single"; + else if (dom.batchMultiBtn.dataset.active === "true") batchType = "multi"; + + 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: batchType !== false, + batch_type: batchType, + 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 { + if (batchType) { + st.hasPreset = true; + dom.waitingMsg.style.display = "none"; + dom.acceptBtn.style.display = "none"; + dom.cancelBtn.style.display = "none"; + dom.batchSingleBtn.style.display = "none"; + dom.batchMultiBtn.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.batchSingleBtn.style.display = "none"; + dom.batchMultiBtn.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); - } + } + } 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){} + 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); }); 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); } }); }); - - node._outpaintCleanup = () => { - api.removeEventListener("rs_outpaint.show", onShow); - node.stopHeartbeat(); + + node._outpaintCleanup = () => { + api.removeEventListener("rs_outpaint.show", onShow); + node.stopHeartbeat(); allNodes = allNodes.filter(n => n.node !== node); - fetch("/rs_outpaint/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }).catch(() => {}); + fetch("/rs_outpaint/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }).catch(() => { }); }; const origOnRemoved = node.onRemoved; - node.onRemoved = function () { - st.batchMode = false; + node.onRemoved = function () { + st.batchMode = false; st.hasPreset = false; - fetch("/rs_outpaint/clear_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }).catch(() => {}); - node._outpaintCleanup?.(); - if (origOnRemoved) origOnRemoved.call(this); + fetch("/rs_outpaint/clear_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }).catch(() => { }); + node._outpaintCleanup?.(); + if (origOnRemoved) origOnRemoved.call(this); }; return result; };