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 = "