From 98d7ae4fda72e189740b6e1f06087d818a6cc22b Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:46:12 +0300 Subject: [PATCH] Adding a mask color to RS Outpaint --- Exemple/RS Outpaint.json | 1 + Rayko_Outpaint.py | 142 ++++++++++++------- web/rs_outpaint.js | 297 +++++++++++++++++++++++++++------------ 3 files changed, 305 insertions(+), 135 deletions(-) create mode 100644 Exemple/RS Outpaint.json diff --git a/Exemple/RS Outpaint.json b/Exemple/RS Outpaint.json new file mode 100644 index 0000000..764621a --- /dev/null +++ b/Exemple/RS Outpaint.json @@ -0,0 +1 @@ +{"id":"a93fd918-480f-444c-abaa-e1ba712b5e4d","revision":0,"last_node_id":289,"last_link_id":1771,"nodes":[{"id":279,"type":"VAELoader","pos":[380,-610],"size":[330,60],"flags":{},"order":0,"mode":0,"inputs":[{"label":"vae","localized_name":"vae_name","name":"vae_name","type":"COMBO","widget":{"name":"vae_name"},"link":null}],"outputs":[{"label":"VAE","localized_name":"VAE","name":"VAE","type":"VAE","links":[1731,1740]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"VAELoader","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["flux2-vae.safetensors"]},{"id":280,"type":"CLIPLoader","pos":[380,-790],"size":[330,110],"flags":{},"order":1,"mode":0,"inputs":[{"label":"clip","localized_name":"clip_name","name":"clip_name","type":"COMBO","widget":{"name":"clip_name"},"link":null},{"label":"type","localized_name":"type","name":"type","type":"COMBO","widget":{"name":"type"},"link":null},{"label":"device","localized_name":"device","name":"device","shape":7,"type":"COMBO","widget":{"name":"device"},"link":null}],"outputs":[{"label":"CLIP","localized_name":"CLIP","name":"CLIP","type":"CLIP","links":[1732]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"CLIPLoader","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["KLEIN\\qwen_3_8b_fp8mixed.safetensors","flux2","default"]},{"id":281,"type":"UNETLoader","pos":[380,-950],"size":[330,90],"flags":{},"order":2,"mode":0,"inputs":[{"label":"model","localized_name":"unet_name","name":"unet_name","type":"COMBO","widget":{"name":"unet_name"},"link":null},{"label":"weight_dtype","localized_name":"weight_dtype","name":"weight_dtype","type":"COMBO","widget":{"name":"weight_dtype"},"link":null}],"outputs":[{"label":"MODEL","localized_name":"MODEL","name":"MODEL","type":"MODEL","links":[1734]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"UNETLoader","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["KLEIN\\F2 Klein 9b.safetensors","default"]},{"id":275,"type":"VAEEncode","pos":[1600,-550],"size":[270,50],"flags":{"collapsed":true},"order":10,"mode":0,"inputs":[{"label":"pixels","localized_name":"pixels","name":"pixels","type":"IMAGE","link":1762},{"label":"vae","localized_name":"vae","name":"vae","type":"VAE","link":1731}],"outputs":[{"label":"LATENT","localized_name":"LATENT","name":"LATENT","type":"LATENT","links":[1726]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"VAEEncode","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":286,"type":"EmptyLatentImage","pos":[1520,-400],"size":[270,110],"flags":{"collapsed":true},"order":9,"mode":0,"inputs":[{"localized_name":"width","name":"width","type":"INT","widget":{"name":"width"},"link":1757},{"localized_name":"height","name":"height","type":"INT","widget":{"name":"height"},"link":1758},{"localized_name":"batch_size","name":"batch_size","type":"INT","widget":{"name":"batch_size"},"link":null}],"outputs":[{"localized_name":"LATENT","name":"LATENT","type":"LATENT","links":[1751]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"EmptyLatentImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[512,512,1]},{"id":277,"type":"ReferenceLatent","pos":[1770,-650],"size":[280,70],"flags":{"collapsed":true},"order":11,"mode":0,"inputs":[{"label":"conditioning","localized_name":"conditioning","name":"conditioning","type":"CONDITIONING","link":1725},{"label":"latent","localized_name":"latent","name":"latent","shape":7,"type":"LATENT","link":1726}],"outputs":[{"label":"CONDITIONING","localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":[1735]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"ReferenceLatent","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":278,"type":"ConditioningZeroOut","pos":[1400,-650],"size":[250,50],"flags":{"collapsed":true},"order":7,"mode":0,"inputs":[{"label":"conditioning","localized_name":"conditioning","name":"conditioning","type":"CONDITIONING","link":1728}],"outputs":[{"label":"CONDITIONING","localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":[1736]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"ConditioningZeroOut","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":282,"type":"KSampler","pos":[1990,-700],"size":[290,270],"flags":{},"order":12,"mode":0,"inputs":[{"label":"model","localized_name":"model","name":"model","type":"MODEL","link":1734},{"label":"positive","localized_name":"positive","name":"positive","type":"CONDITIONING","link":1735},{"label":"negative","localized_name":"negative","name":"negative","type":"CONDITIONING","link":1736},{"label":"latent_image","localized_name":"latent_image","name":"latent_image","type":"LATENT","link":1751},{"label":"seed","localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"label":"steps","localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"label":"cfg","localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"label":"sampler","localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"label":"scheduler","localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"label":"denoise","localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"label":"LATENT","localized_name":"LATENT","name":"LATENT","type":"LATENT","links":[1739]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"KSampler","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[902103777754885,"randomize",4,1,"euler","simple",1],"color":"#2a363b","bgcolor":"#3f5159"},{"id":283,"type":"VAEDecode","pos":[2310,-740],"size":[170,50],"flags":{"collapsed":true},"order":13,"mode":0,"inputs":[{"label":"samples","localized_name":"samples","name":"samples","type":"LATENT","link":1739},{"label":"vae","localized_name":"vae","name":"vae","type":"VAE","link":1740}],"outputs":[{"label":"IMAGE","localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1741]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"VAEDecode","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":272,"type":"SaveImage","pos":[2460,-770],"size":[410,530],"flags":{},"order":14,"mode":0,"inputs":[{"label":"images","localized_name":"images","name":"images","type":"IMAGE","link":1741},{"label":"filename_prefix","localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"SaveImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["ComfyUI"]},{"id":288,"type":"Note","pos":[840,-340],"size":[410,110],"flags":{},"order":3,"mode":0,"inputs":[],"outputs":[],"properties":{"ue_properties":{"widget_ue_connectable":{},"version":"7.8","input_ue_unconnectable":{}}},"widgets_values":["When changing the mask color in the RS Outpaint node, replace the color name in the prompt.\n\nПри смене цвета маски в ноде RS Outpaint замените название цвета в промпте."],"color":"#af3535","bgcolor":"rgba(24,24,27,.9)"},{"id":276,"type":"CLIPTextEncode","pos":[890,-720],"size":[370,90],"flags":{},"order":5,"mode":0,"inputs":[{"label":"clip","localized_name":"clip","name":"clip","type":"CLIP","link":1732},{"label":"prompt","localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"label":"CONDITIONING","localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":[1725,1728]}],"title":"CLIP Text Encode","properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"CLIPTextEncode","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["Remove the red area. Leave the main image, color, and brightness unchanged."]},{"id":289,"type":"15863217-eb94-4632-a975-f2fa31fcb628","pos":[1150,-520],"size":[190,70],"flags":{},"order":8,"mode":0,"inputs":[{"label":"destination","localized_name":"destination","name":"destination","type":"IMAGE","link":1761},{"label":"mask","name":"alpha","type":"MASK","link":1769},{"label":"mask_image","name":"image","type":"IMAGE","link":1770}],"outputs":[{"label":"IMAGE","localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1762]}],"properties":{"proxyWidgets":[],"cnr_id":"comfy-core","ver":"0.19.0","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":273,"type":"LoadImage","pos":[-240,-500],"size":[490,670],"flags":{},"order":4,"mode":0,"inputs":[{"label":"image","localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"label":"upload","localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"label":"IMAGE","localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1752]},{"label":"MASK","localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"LoadImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["bench_00052_.png","image"]},{"id":287,"type":"RSOutpaint","pos":[290,-500],"size":[520,680],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":1752}],"outputs":[{"localized_name":"control_image","name":"control_image","type":"IMAGE","links":[1761]},{"localized_name":"control_mask","name":"control_mask","type":"MASK","links":[1769]},{"localized_name":"mask_image","name":"mask_image","type":"IMAGE","links":[1770]},{"localized_name":"width","name":"width","type":"INT","links":[1757]},{"localized_name":"height","name":"height","type":"INT","links":[1758]}],"properties":{"aux_id":"Raykosan/ComfyUI_RaykoStudio","ver":"226d6cc51ddfb02b821fd68c0a353c675a51a5d3","Node name for S&R":"RSOutpaint","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["","-16,-112,1104,1232,0,0,255,0,0"]}],"links":[[1725,276,0,277,0,"CONDITIONING"],[1726,275,0,277,1,"LATENT"],[1728,276,0,278,0,"CONDITIONING"],[1731,279,0,275,1,"VAE"],[1732,280,0,276,0,"CLIP"],[1734,281,0,282,0,"MODEL"],[1735,277,0,282,1,"CONDITIONING"],[1736,278,0,282,2,"CONDITIONING"],[1739,282,0,283,0,"LATENT"],[1740,279,0,283,1,"VAE"],[1741,283,0,272,0,"IMAGE"],[1751,286,0,282,3,"LATENT"],[1752,273,0,287,0,"IMAGE"],[1757,287,3,286,0,"INT"],[1758,287,4,286,1,"INT"],[1761,287,0,289,0,"IMAGE"],[1762,289,0,275,0,"IMAGE"],[1769,287,1,289,1,"MASK"],[1770,287,2,289,2,"IMAGE"]],"groups":[],"definitions":{"subgraphs":[{"id":"15863217-eb94-4632-a975-f2fa31fcb628","version":1,"state":{"lastGroupId":0,"lastNodeId":289,"lastLinkId":1771,"lastRerouteId":0},"revision":0,"config":{},"name":"New Subgraph","inputNode":{"id":-10,"bounding":[570,-580,128,108]},"outputNode":{"id":-20,"bounding":[1590,-540,128,68]},"inputs":[{"id":"64df3c6a-d3e1-4e52-80c6-98bc3f42f5a4","name":"destination","type":"IMAGE","linkIds":[1771],"localized_name":"destination","label":"destination","pos":[674,-556]},{"id":"86df585e-456f-4523-a6a6-7d973a2c46db","name":"alpha","type":"MASK","linkIds":[1765,1766],"label":"mask","pos":[674,-536]},{"id":"7c3315a6-18f7-4c2b-bfb6-3dc662a3fb8b","name":"image","type":"IMAGE","linkIds":[1767],"label":"mask_image","pos":[674,-516]}],"outputs":[{"id":"17619a1e-4d9c-4ad4-be59-104afb4e719c","name":"IMAGE","type":"IMAGE","linkIds":[1719],"localized_name":"IMAGE","label":"IMAGE","pos":[1614,-516]}],"widgets":[],"nodes":[{"id":285,"type":"ImageCompositeMasked","pos":[1110,-530],"size":[210,150],"flags":{"collapsed":false},"order":2,"mode":0,"inputs":[{"label":"destination","localized_name":"destination","name":"destination","type":"IMAGE","link":1771},{"label":"source","localized_name":"source","name":"source","type":"IMAGE","link":1746},{"label":"mask","localized_name":"mask","name":"mask","shape":7,"type":"MASK","link":1766},{"label":"x","localized_name":"x","name":"x","type":"INT","widget":{"name":"x"},"link":null},{"label":"y","localized_name":"y","name":"y","type":"INT","widget":{"name":"y"},"link":null},{"label":"resize_source","localized_name":"resize_source","name":"resize_source","type":"BOOLEAN","widget":{"name":"resize_source"},"link":null}],"outputs":[{"label":"IMAGE","localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1748]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"ImageCompositeMasked","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[0,0,false]},{"id":274,"type":"FluxKontextImageScale","pos":[1350,-530],"size":[200,30],"flags":{"collapsed":false},"order":0,"mode":0,"inputs":[{"label":"image","localized_name":"image","name":"image","type":"IMAGE","link":1748}],"outputs":[{"label":"IMAGE","localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1719]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"FluxKontextImageScale","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":284,"type":"JoinImageWithAlpha","pos":[810,-490],"size":[190,50],"flags":{"collapsed":false},"order":1,"mode":0,"inputs":[{"label":"image","localized_name":"image","name":"image","type":"IMAGE","link":1767},{"label":"alpha","localized_name":"alpha","name":"alpha","type":"MASK","link":1765}],"outputs":[{"label":"IMAGE","localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1746]}],"properties":{"cnr_id":"comfy-core","ver":"0.19.0","Node name for S&R":"JoinImageWithAlpha","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]}],"groups":[],"links":[{"id":1746,"origin_id":284,"origin_slot":0,"target_id":285,"target_slot":1,"type":"IMAGE"},{"id":1748,"origin_id":285,"origin_slot":0,"target_id":274,"target_slot":0,"type":"IMAGE"},{"id":1719,"origin_id":274,"origin_slot":0,"target_id":-20,"target_slot":0,"type":"IMAGE"},{"id":1765,"origin_id":-10,"origin_slot":1,"target_id":284,"target_slot":1,"type":"MASK"},{"id":1766,"origin_id":-10,"origin_slot":1,"target_id":285,"target_slot":2,"type":"MASK"},{"id":1767,"origin_id":-10,"origin_slot":2,"target_id":284,"target_slot":0,"type":"IMAGE"},{"id":1771,"origin_id":-10,"origin_slot":0,"target_id":285,"target_slot":0,"type":"IMAGE"}],"extra":{"ue_links":[],"links_added_by_ue":[]}}]},"config":{},"extra":{"workflowRendererVersion":"LG","ue_links":[],"links_added_by_ue":[],"qgn_navigation_groups":[],"qgn_locked":false,"ds":{"scale":0.4980109732444196,"offset":[1361.8261153952492,1072.4860567387088]}},"version":0.4} \ No newline at end of file diff --git a/Rayko_Outpaint.py b/Rayko_Outpaint.py index 975471b..758cb3c 100644 --- a/Rayko_Outpaint.py +++ b/Rayko_Outpaint.py @@ -19,6 +19,14 @@ _DEFAULT_PAD_FACTOR = 0.3 _frame_cache = {} _PENDING_DECISIONS = {} +def _tensor_hash(tensor): + try: + t = tensor[0] if tensor.dim() == 4 else tensor + arr = (t.cpu().numpy() * 255).clip(0, 255).astype(np.uint8) + return hash(arr.tobytes()) + 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) @@ -34,7 +42,7 @@ class RSOutpaint: "image": ("IMAGE", {"tooltip": "Source image."}), "crop_state": ("STRING", { "default": "", - "tooltip": "Canvas-managed crop state: 'x,y,w,h[,ow,oh]' in source pixels. Set by the interactive widget.", + "tooltip": "State: 'x,y,w,h,outW,outH,r,g,b'", }), }, "hidden": { @@ -42,8 +50,9 @@ class RSOutpaint: }, } - RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT") - RETURN_NAMES = ("control_image", "control_mask", "width", "height") + # ✅ ДОБАВЛЕН mask_image + RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "INT", "INT") + RETURN_NAMES = ("control_image", "control_mask", "mask_image", "width", "height") FUNCTION = "outpaint_prep" OUTPUT_NODE = True CATEGORY = "🦊 RaykoStudio" @@ -58,26 +67,34 @@ class RSOutpaint: src_h, src_w, _ = src.shape if src_w < _MIN_DIM or src_h < _MIN_DIM: - raise ValueError(f"[RS Outpaint] Input image dimensions ({src_w}×{src_h}) are too small to be valid.") + raise ValueError(f"[RS Outpaint] Input image too small ({src_w}×{src_h}).") unique_id = str(unique_id) - - # ✅ ПАУЗА: Если нода еще не ждет решения, запускаем процесс паузы + src_hash = _tensor_hash(src) + cached = _frame_cache.get(unique_id, {}) + + if cached.get("src_hash") != src_hash or cached.get("width") != src_w or cached.get("height") != src_h: + if unique_id in _frame_cache: + del _frame_cache[unique_id] + if unique_id in _PENDING_DECISIONS: + del _PENDING_DECISIONS[unique_id] + crop_state = "" + print(f"🦊 [RS Outpaint] New image detected for node {unique_id}, state reset") + if unique_id not in _PENDING_DECISIONS: _PENDING_DECISIONS[unique_id] = { "status": "pending", "crop_state": crop_state } - # Сохраняем изображение для превью _frame_cache[unique_id] = { "width": src_w, "height": src_h, + "src_hash": src_hash, "image": _tensor_to_jpeg(src), } print(f"🦊 [RS Outpaint] Waiting for approval for node {unique_id}") - # Отправляем событие для показа интерфейса с размерами try: i = 255. * src.cpu().numpy() img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) @@ -97,7 +114,6 @@ class RSOutpaint: except Exception as e: print(f"🦊 [RS Outpaint] Error saving preview: {e}") - # Ждем решения пользователя while True: state = _PENDING_DECISIONS.get(unique_id, {}) current_status = state.get("status", "pending") @@ -107,7 +123,7 @@ class RSOutpaint: crop_state = current_crop_state if unique_id in _PENDING_DECISIONS: del _PENDING_DECISIONS[unique_id] - print(f"🦊 [RS Outpaint] Approved! Continuing generation for node {unique_id}") + print(f"🦊 [RS Outpaint] Approved! Continuing for node {unique_id}") break if current_status == "cancelled": @@ -116,12 +132,12 @@ class RSOutpaint: print(f"🦊 [RS Outpaint] Cancelled for node {unique_id}") empty_img = torch.zeros((1, src_h, src_w, 3)) empty_mask = torch.ones((1, src_h, src_w)) - return (empty_img, empty_mask, 0, 0) + return (empty_img, empty_mask, empty_img, 0, 0) if current_status == "removed": if unique_id in _PENDING_DECISIONS: del _PENDING_DECISIONS[unique_id] - return (image, torch.ones((1, src_h, src_w)), src_w, src_h) + return (image, torch.ones((1, src_h, src_w)), image, src_w, src_h) if current_status == "rejected": _PENDING_DECISIONS[unique_id]["status"] = "pending" @@ -129,70 +145,101 @@ class RSOutpaint: time.sleep(0.3) - # ✅ Парсинг crop_state после получения решения 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(",")] if len(parts) >= 4: - crop_x, crop_y, crop_w, crop_h = parts[:4] + cx, cy, cw, ch = parts[:4] + if cw >= _GRID and ch >= _GRID: + crop_x, crop_y, crop_w, crop_h = cx, cy, cw, ch + use_crop_state = True if len(parts) >= 6: output_width, output_height = parts[4], parts[5] + # ✅ ПАРСИНГ ЦВЕТОВ (RGB) + if len(parts) >= 9: + color_r, color_g, color_b = parts[6], parts[7], parts[8] + # Если есть и цвета фона (для полноты, хотя используем 3 для маски) + if len(parts) >= 12: + bg_r, bg_g, bg_b = parts[9], parts[10], parts[11] except ValueError: pass - # Дефолтные значения, если кроп не задан - if crop_w < _GRID or crop_h < _GRID: + if not use_crop_state: pad = max(_GRID, round(src_h * _DEFAULT_PAD_FACTOR / _GRID) * _GRID) crop_x = 0 crop_y = -pad crop_w = round(src_w / _GRID) * _GRID crop_h = round((src_h + pad) / _GRID) * _GRID - out_w, out_h = crop_w, crop_h + eff_w = output_width if output_width >= _GRID else crop_w + eff_h = output_height if output_height >= _GRID else crop_h - # Обновляем кэш _frame_cache[unique_id] = { "width": src_w, "height": src_h, + "src_hash": src_hash, "image": _tensor_to_jpeg(src), } time.sleep(0.05) src_np = src.cpu().numpy() - out = np.full((out_h, out_w, 3), 0.5, dtype=np.float32) - mask = np.ones((out_h, out_w), dtype=np.float32) + out = np.full((eff_h, eff_w, 3), 0.5, dtype=np.float32) + mask = np.ones((eff_h, eff_w), dtype=np.float32) - dst_x = max(0, -crop_x) - dst_y = max(0, -crop_y) - src_x = max(0, crop_x) - src_y = max(0, crop_y) + src_x_start = max(0, crop_x) + src_y_start = max(0, crop_y) + src_x_end = min(src_w, crop_x + crop_w) + src_y_end = min(src_h, crop_y + crop_h) - copy_w = min(src_w - src_x, out_w - dst_x) - copy_h = min(src_h - src_y, out_h - dst_y) + src_copy_w = max(0, src_x_end - src_x_start) + src_copy_h = max(0, src_y_end - src_y_start) - if copy_w > 0 and copy_h > 0: - out[dst_y:dst_y + copy_h, dst_x:dst_x + copy_w] = src_np[src_y:src_y + copy_h, src_x:src_x + copy_w] - mask[dst_y:dst_y + copy_h, dst_x:dst_x + copy_w] = 0.0 + if src_copy_w > 0 and src_copy_h > 0: + crop_offset_x = (eff_w - crop_w) // 2 + crop_offset_y = (eff_h - crop_h) // 2 + + img_offset_in_crop_x = max(0, -crop_x) + img_offset_in_crop_y = max(0, -crop_y) + + final_dst_x = crop_offset_x + img_offset_in_crop_x + final_dst_y = crop_offset_y + img_offset_in_crop_y + + copy_w = min(src_copy_w, eff_w - final_dst_x) + copy_h = min(src_copy_h, eff_h - final_dst_y) + + if copy_w > 0 and copy_h > 0: + out[final_dst_y:final_dst_y + copy_h, final_dst_x:final_dst_x + copy_w] = \ + src_np[src_y_start:src_y_start + copy_h, src_x_start:src_x_start + copy_w] + mask[final_dst_y:final_dst_y + copy_h, final_dst_x:final_dst_x + copy_w] = 0.0 control_image = torch.from_numpy(out).unsqueeze(0) control_mask = torch.from_numpy(mask).unsqueeze(0) - eff_w = output_width if output_width >= _GRID else out_w - eff_h = output_height if output_height >= _GRID else out_h + # ✅ ГЕНЕРАЦИЯ MASK_IMAGE (RGB) + # control_mask shape: (1, H, W) + # Нам нужно (1, H, W, 3) + + # Нормализуем цвета 0-255 -> 0.0-1.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) + + # Расширяем маску до (1, H, W, 1) для вещания + mask_exp = control_mask.unsqueeze(3) + + # Формула: mask_color * mask_value + bg_color * (1 - mask_value) + mask_image = mask_exp * norm_color + (1.0 - mask_exp) * norm_bg + mask_image = torch.clamp(mask_image, 0.0, 1.0) - if eff_w != out_w or eff_h != out_h: - cv = control_image.permute(0, 3, 1, 2) - cv = F.interpolate(cv, size=(eff_h, eff_w), mode="bilinear", align_corners=False) - control_image = cv.permute(0, 2, 3, 1) - cm = control_mask.unsqueeze(1).float() - cm = F.interpolate(cm, size=(eff_h, eff_w), mode="nearest") - control_mask = cm.squeeze(1) - - return (control_image, control_mask, eff_w, eff_h) + return (control_image, control_mask, mask_image, eff_w, eff_h) NODE_CLASS_MAPPINGS = {"RSOutpaint": RSOutpaint} NODE_DISPLAY_NAME_MAPPINGS = {"RSOutpaint": "🦊 RS Outpaint"} @@ -211,12 +258,10 @@ if server.PromptServer: _PENDING_DECISIONS[node_id]["status"] = "approved" if crop_state: _PENDING_DECISIONS[node_id]["crop_state"] = crop_state - print(f"🦊 [RS Outpaint] Received crop_state: {crop_state}") elif decision == "cancel": _PENDING_DECISIONS[node_id]["status"] = "cancelled" return web.Response(status=200, text="Decision recorded") - else: - return web.Response(status=404, text=f"Node {node_id} not waiting") + return web.Response(status=4404, text=f"Node {node_id} not waiting") except Exception as e: return web.Response(status=500, text=str(e)) @@ -226,9 +271,10 @@ if server.PromptServer: data = await request.json() node_id = str(data.get("node_id")) if node_id in _PENDING_DECISIONS: - _PENDING_DECISIONS[node_id]["status"] = "removed" - return web.Response(status=200, text="Cleanup recorded") - return web.Response(status=200, text="Node not found") + del _PENDING_DECISIONS[node_id] + if node_id in _frame_cache: + del _frame_cache[node_id] + return web.Response(status=200, text="Cleanup recorded") except Exception as e: return web.Response(status=500, text=str(e)) @@ -237,7 +283,7 @@ async def rs_outpaint_info(request): node_id = request.query.get("node_id", "") cache_key = str(node_id).strip() if not cache_key or cache_key not in _frame_cache: - return web.Response(status=404, text="No cached image data for this node.") + return web.Response(status=404, text="No cached image data.") cache = _frame_cache[cache_key] return web.json_response({ "width": cache["width"], @@ -250,6 +296,6 @@ async def rs_outpaint_image(request): node_id = request.query.get("node_id", "") cache_key = str(node_id).strip() if not cache_key or cache_key not in _frame_cache: - return web.Response(status=404, text="No cached image data for this node.") + return web.Response(status=404, text="No cached image data.") cache = _frame_cache[cache_key] return web.Response(body=cache["image"], content_type="image/jpeg") \ No newline at end of file diff --git a/web/rs_outpaint.js b/web/rs_outpaint.js index 7839f5e..ec931d0 100644 --- a/web/rs_outpaint.js +++ b/web/rs_outpaint.js @@ -2,19 +2,16 @@ console.log("[SPLINE 🦊] rs_outpaint.js LOADED!"); import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -// ── Constants ───────────────────────────────────────────────────────── const NODE_CLASS = "RSOutpaint"; const API_PREFIX = "/rs_outpaint"; const CANVAS_H = 320; const MARGIN = 22; const GRID = 16; const OVERHANG = 1; -const CTRL_H = 200; // Высота панели управления +// ✅ УВЕЛИЧЕНА ВЫСОТА БЛОКА УПРАВЛЕНИЯ (было 200) +const CTRL_H = 240; -// ── Geometry helpers ────────────────────────────────────────────────── function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } - -// Конвертация Canvas -> Source (с учетом масштаба) function crToSrc(cr, sf, scale) { return { x: Math.round((cr.x - sf.x) / scale), @@ -23,8 +20,6 @@ function crToSrc(cr, sf, scale) { h: Math.round(cr.h / scale), }; } - -// Конвертация Source -> Canvas function srcToCr(s, sf, scale) { return { x: sf.x + s.x * scale, @@ -33,7 +28,6 @@ function srcToCr(s, sf, scale) { h: s.h * scale, }; } - function quantizeSrc(s) { return { x: Math.round(s.x / GRID) * GRID, @@ -42,7 +36,6 @@ function quantizeSrc(s) { h: Math.max(GRID, Math.round(s.h / GRID) * GRID), }; } - function syncOutToAR(st, preserveArea = false) { if (st.outW >= GRID && st.outH >= GRID) { if (preserveArea) { @@ -52,9 +45,7 @@ function syncOutToAR(st, preserveArea = false) { st.outH = Math.max(GRID, Math.round(st.outW / st.cropAR / GRID) * GRID); } } - function defaultOut(_st) { return { w: 1280, h: 720 }; } - function clampToValid(s, srcW, srcH) { const c = { ...s }; if (c.x >= srcW - OVERHANG) c.x = srcW - OVERHANG; @@ -63,7 +54,6 @@ function clampToValid(s, srcW, srcH) { 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); @@ -71,9 +61,19 @@ function applyCanvasCr(canvasCr, st) { st.cr = srcToCr(s, st.sf, st.scale); } -// ── Per-node state ──────────────────────────────────────────────────── 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: true, initialized: false, view: { zoom: 1.0, panX: 0, panY: 0 }, outW: 0, outH: 0 }; + return { + srcW: 1280, srcH: 720, + _lastW: 1280, _lastH: 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: true, + initialized: false, + view: { zoom: 1.0, panX: 0, panY: 0 }, + outW: 0, outH: 0, + maskColor: "#ff0000", + bgColor: "#141414" + }; } 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 initLayout(st, wrapEl) { @@ -82,37 +82,65 @@ function initLayout(st, wrapEl) { 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), sfY = Math.max(MARGIN, Math.round((wrapEl.clientHeight - sfH) / 2)); 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 = 1280, defH = 720; const defX = Math.round((st.srcW - defW) / (2 * GRID)) * GRID; const defY = Math.round((st.srcH - defH) / (2 * GRID)) * GRID; + const defW = Math.max(GRID, Math.round(st.srcW * 0.8 / GRID) * GRID); + const defH = Math.max(GRID, Math.round(st.srcH * 0.8 / GRID) * GRID); + const defX = Math.round((st.srcW - defW) / 2 / GRID) * GRID; + const defY = Math.round((st.srcH - defH) / 2 / GRID) * GRID; const def = clampToValid({ x: defX, y: defY, w: defW, h: defH }, st.srcW, st.srcH); - st.cr = srcToCr(def, st.sf, st.scale); st.cropAR = st.cr.w / st.cr.h; st.initialized = true; return true; + st.cr = srcToCr(def, st.sf, st.scale); st.cropAR = def.w / def.h; st.initialized = true; return true; } + function restoreCropFromWidgets(st, widgets, overrideVal) { - const val = overrideVal !== undefined ? overrideVal : (widgets.cropState?.value ?? ""); if (!val) return; - const parts = val.split(",").map(Number); if (parts.length < 4 || parts.some(isNaN)) return; - const [cx, cy, cw, ch] = parts; if (cw >= GRID && ch >= GRID) { st.cr = srcToCr({ x: cx, y: cy, w: cw, h: ch }, st.sf, st.scale); st.cropAR = cw / ch; } + const val = overrideVal !== undefined ? overrideVal : (widgets.cropState?.value ?? ""); + if (!val) return; + const parts = val.split(",").map(Number); + if (parts.length < 4 || parts.some(isNaN)) return; + const [cx, cy, cw, ch] = parts; + if (cw >= GRID && ch >= GRID && cx + cw > 0 && cy + ch > 0 && cx < st.srcW && cy < st.srcH) { + st.cr = srcToCr({ x: cx, y: cy, w: cw, h: ch }, st.sf, st.scale); + st.cropAR = cw / ch; + } if (parts.length >= 6) { st.outW = parts[4] || 0; st.outH = parts[5] || 0; } + if (parts.length >= 9) { + st.maskColor = `#${((parts[6] << 16) + (parts[7] << 8) + parts[8]).toString(16).padStart(6, '0')}`; + } } -// ── DOM builder ─────────────────────────────────────────────────────── 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; + + const colorPicker = mkEl("input", "width:0;height:0;opacity:0;position:absolute;pointer-events:none;", { type: "color", value: defaultColor }); + const swatch = mkEl("div", `width:22px;height:22px;border-radius:4px;border:1px solid #444;cursor:pointer;background:${defaultColor};`); + const hexInput = mkEl("input", "width:56px;padding:2px 4px;font-size:10px;font-family:monospace;background:#1e1e1e;color:#ccc;border:1px solid #444;border-radius:4px;text-align:center;", { value: defaultColor.toUpperCase() }); + + swatch.onclick = () => colorPicker.click(); + + row.append(lbl, swatch, colorPicker, hexInput); + return { row, picker: colorPicker, hexInput, swatch }; +} + function buildUI() { const root = mkEl("div", "width:100%;box-sizing:border-box;padding:6px 8px 10px;font-family:system-ui,sans-serif;font-size:12px;color:#ccc;"); const wrap = mkEl("div", `position:relative;min-height:${CANVAS_H}px;background:#181818;border:1px solid #3a3a3a;border-radius:6px;overflow:hidden;user-select:none;touch-action:none;cursor:default;`); const sfEl = mkEl("div", "position:absolute;background:#222;overflow:hidden;border:1px solid #333;"); 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 = "Run node once to display image"; + 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..."; 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 mkMask = () => mkEl("div", "position:absolute;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);"); 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;") }; 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 PAD_CSS = "position:absolute;font-family:monospace;font-size:10px;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;") }; @@ -130,7 +158,7 @@ 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: 720 }); const arBtn = mkEl("button", "padding:3px 9px;font-size:11px;border:1px solid #99c0ee;border-radius:5px;background:#1a3a5a;color:#aadaff;cursor:pointer;"); arBtn.textContent = "🔒"; arBtn._active = true; arBtn.title = "Lock aspect ratio"; - const resetBtn = mkBtn("reset"); resetBtn.title = "Reset to default 1280×720 centered crop"; + const resetBtn = mkBtn("reset"); resetBtn.title = "Reset to default crop"; 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_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;"; @@ -149,16 +177,43 @@ function buildUI() { outSizeRow.append(outLabel, outWInput, outXLabel, outHInput); ctrl.append(cropSizeRow, presetRow, snapRow, outSizeRow); - // ✅ КНОПКА ACCEPT в едином стиле ноды, внизу панели - 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;margin-top:4px;width:100%;text-align:center;display:none;"); + // ✅ УБРАН MARGIN-TOP ДЛЯ ПЛОТНОСТИ + 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); + + // ✅ УБРАН MARGIN-TOP ДЛЯ ПЛОТНОСТИ + 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;"); acceptBtn.textContent = "✔️ ACCEPT"; ctrl.appendChild(acceptBtn); 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, outWInput, outHInput, acceptBtn }; + 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, outWInput, outHInput, acceptBtn, maskColorInput, bgColorInput }; +} + +function updateMaskColors(st, dom) { + if (!dom) return; + const maskColor = st.maskColor; + const alpha = "45"; + + const applyColor = (el) => { + if(el) el.style.backgroundColor = maskColor + alpha; + }; + + applyColor(dom.maskTop); + applyColor(dom.maskBot); + applyColor(dom.maskLeft); + applyColor(dom.maskRight); + + const textColor = maskColor; + dom.padLabelT.style.color = textColor; + dom.padLabelB.style.color = textColor; + dom.padLabelL.style.color = textColor; + dom.padLabelR.style.color = textColor; } -// ── Render ────────────────────────────────────────────────────────── function render(st, dom) { if (!st.initialized) return; const { sf, cr, srcW, srcH, view } = st; @@ -169,7 +224,7 @@ function render(st, dom) { dom.cropBox.style.left = cr.x + "px"; dom.cropBox.style.top = cr.y + "px"; dom.cropBox.style.width = cr.w + "px"; dom.cropBox.style.height = cr.h + "px"; 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"; } }; + const setMask = (el, x, y, w, h) => { if (w > 0 && h > 0) { el.style.cssText = `position:absolute;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); @@ -191,6 +246,19 @@ function render(st, dom) { if (document.activeElement !== dom.outHInput) dom.outHInput.value = st.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"; } + + 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) { @@ -205,7 +273,11 @@ function fitCropInView(st, dom) { function syncWidgets(st, widgets, node) { const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); - if (widgets.cropState) widgets.cropState.value = `${s.x},${s.y},${s.w},${s.h},${st.outW},${st.outH}`; + const mR = parseInt(st.maskColor.slice(1,3), 16); + const mG = parseInt(st.maskColor.slice(3,5), 16); + const mB = parseInt(st.maskColor.slice(5,7), 16); + + if (widgets.cropState) widgets.cropState.value = `${s.x},${s.y},${s.w},${s.h},${st.outW},${st.outH},${mR},${mG},${mB}`; if (node.graph) node.graph.setDirtyCanvas(true, true); } @@ -218,9 +290,7 @@ function setArLocked(st, dom, locked) { dom.arBtn._active = locked; } -// ── Image fetching WITH POLLING ──────────────────────────────── let _pollingIntervals = {}; - async function fetchInfoWithRetry(nodeId, maxRetries = 5, delay = 300) { for (let i = 0; i < maxRetries; i++) { if (i > 0) await new Promise(resolve => setTimeout(resolve, delay * i)); @@ -233,7 +303,6 @@ async function fetchInfoWithRetry(nodeId, maxRetries = 5, delay = 300) { } return null; } - async function fetchImageWithRetry(nodeId, dom, maxRetries = 3, delay = 200) { for (let i = 0; i < maxRetries; i++) { if (i > 0) await new Promise(resolve => setTimeout(resolve, delay * i)); @@ -254,7 +323,6 @@ async function fetchImageWithRetry(nodeId, dom, maxRetries = 3, delay = 200) { } return false; } - function startPolling(nodeId, dom, applyImageData, widgets, st) { if (_pollingIntervals[nodeId]) { clearInterval(_pollingIntervals[nodeId]); delete _pollingIntervals[nodeId]; } let attempts = 0; const maxAttempts = 20; const interval = 500; @@ -267,7 +335,6 @@ function startPolling(nodeId, dom, applyImageData, widgets, st) { } function stopPolling(nodeId) { if (_pollingIntervals[nodeId]) { clearInterval(_pollingIntervals[nodeId]); delete _pollingIntervals[nodeId]; } } -// ── Interaction wiring ──────────────────────────────────────────────── function applyCropDim(st, dom, widgets, node, axis, rawVal) { const isW = axis === "w"; const r = Math.max(GRID, Math.round(parseInt(rawVal, 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; @@ -281,6 +348,32 @@ function applyOutDim(st, dom, widgets, node, axis, rawVal) { else { const r = Math.round(v / GRID) * GRID; if (axis === "w") { st.outW = r; st.outH = Math.max(GRID, Math.round(r / st.cropAR / GRID) * GRID); } else { st.outH = r; st.outW = Math.max(GRID, Math.round(r * st.cropAR / GRID) * GRID); } } render(st, dom); syncWidgets(st, widgets, node); } + +function wireColors(st, dom) { + 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, { cropState: { value: "" } }, { graph: null }); + }); + 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, { cropState: { value: "" } }, { graph: null }); + } + }); + }; + bind(dom.maskColorInput, 'maskColor'); + bind(dom.bgColorInput, 'bgColor'); +} + function wireInteractions(st, dom, widgets, node, nodeId) { const { wrap, arBtn, snapBtns, wInput, hInput, resetBtn, chipBtns, outWInput, outHInput } = dom; arBtn.addEventListener("click", () => setArLocked(st, dom, !st.arLocked)); @@ -307,7 +400,6 @@ function setupResizeObserver(st, dom) { ro.observe(dom.wrap); return ro; } -// ── Node extension ──────────────────────────────────────────────────── app.registerExtension({ name: "RSOutpaint", async beforeRegisterNodeDef(nodeType, nodeData) { @@ -318,91 +410,124 @@ app.registerExtension({ const node = this; const widgets = { cropState: node.widgets?.find(w => w.name === "crop_state") }; if (node.inputs) { for (let i = node.inputs.length - 1; i >= 0; i--) { if (node.inputs[i].name === "crop_state") node.removeInput(i); } } - const st = createState(), dom = buildUI(); + + const st = createState(); + const dom = buildUI(); + wireColors(st, dom); + const domWidget = node.addDOMWidget("rs_outpaint_canvas", "custom", dom.root, { serialize: false, hideOnZoom: false }); domWidget.computeSize = () => [440, CANVAS_H + CTRL_H]; + // ✅ УВЕЛИЧЕНА МИНИМАЛЬНАЯ ВЫСОТА НОДЫ node.setSize([Math.max(node.size[0], 520), Math.max(node.size[1], CANVAS_H + CTRL_H + 72)]); + let _prevNodeX = node.pos[0]; node.onResize = function(size) { if (size[0] < 520) { if (this.pos[0] !== _prevNodeX) this.pos[0] += size[0] - 520; size[0] = 520; } _prevNodeX = this.pos[0]; const h = Math.max(CANVAS_H, size[1] - CTRL_H - 72); dom.wrap.style.height = h + "px"; }; if (node.widgets) { const cs = node.widgets.find(w => w.name === "crop_state"); node.widgets = [domWidget, ...node.widgets.filter(w => w !== domWidget && w !== cs)]; if (cs) { node.widgets.push(cs); cs.computeSize = () => [0, -4]; cs.hidden = true; } } const origOnConfigure = node.onConfigure; node.onConfigure = function (info) { if (origOnConfigure) origOnConfigure.call(this, info); if (widgets.cropState) st._latchedCropState = widgets.cropState.value; }; + const applyImageData = (data, preserveCrop) => { - const val = widgets.cropState?.value ?? ""; - const hadCrop = preserveCrop && val !== "" && !val.split(",").some(isNaN); - const prevSrc = (hadCrop && st.initialized) ? crToSrc(st.cr, st.sf, st.scale) : null; - st.srcW = data.width; st.srcH = data.height; - initLayout(st, dom.wrap); - if (prevSrc) { const s = clampToValid(quantizeSrc(prevSrc), st.srcW, st.srcH); st.cr = srcToCr(s, st.sf, st.scale); st.cropAR = s.w / s.h; } - else if (hadCrop) restoreCropFromWidgets(st, widgets); - const d = defaultOut(st); st.outW = d.w; st.outH = d.h; + const dimsChanged = st.initialized && (st._lastW !== data.width || st._lastH !== data.height); + const shouldReset = dimsChanged || !preserveCrop || !st.initialized; + + st.srcW = data.width; + st.srcH = data.height; + if (!initLayout(st, dom.wrap)) return; + + if (shouldReset) { + console.log(`🦊 [RS Outpaint] Auto-reset (${dimsChanged ? 'new image' : 'fresh init'}) ${st.srcW}x${st.srcH}`); + const defW = Math.max(GRID, Math.round(st.srcW * 0.8 / GRID) * GRID); + const defH = Math.max(GRID, Math.round(st.srcH * 0.8 / GRID) * GRID); + const defX = Math.round((st.srcW - defW) / 2 / GRID) * GRID; + const defY = Math.round((st.srcH - defH) / 2 / GRID) * GRID; + st.cr = srcToCr({x: defX, y: defY, w: defW, h: defH}, st.sf, st.scale); + st.cropAR = defW / defH; + st.outW = 0; + st.outH = 0; + } else { + const val = widgets.cropState?.value ?? ""; + if (val) restoreCropFromWidgets(st, widgets, val); + } + + st._lastW = st.srcW; + st._lastH = st.srcH; + fetchImageWithRetry(String(node.id), dom); - dom.imageEl.style.display = "block"; dom.noDataMsg.style.display = "none"; - fitCropInView(st, dom); render(st, dom); syncWidgets(st, widgets, node); + dom.imageEl.style.display = "block"; + dom.noDataMsg.style.display = "none"; + fitCropInView(st, dom); + render(st, dom); + syncWidgets(st, widgets, node); }; + requestAnimationFrame(() => { requestAnimationFrame(() => { const nodeId = String(node.id); - // ✅ FIX: Проверяем инициализацию. Если onShow уже сработал, skip. if (!st.initialized) { - if (!initLayout(st, dom.wrap)) return; - restoreCropFromWidgets(st, widgets, st._latchedCropState); - st._latchedCropState = undefined; - if (st.outW < GRID || st.outH < GRID) { const d = defaultOut(st); st.outW = d.w; st.outH = d.h; } - else syncOutToAR(st); + if (initLayout(st, dom.wrap)) { + restoreCropFromWidgets(st, widgets, st._latchedCropState); + st._latchedCropState = undefined; + if (st.outW < GRID || st.outH < GRID) { const d = defaultOut(st); st.outW = d.w; st.outH = d.h; } + else syncOutToAR(st); + st._lastW = st.srcW; + st._lastH = st.srcH; + fitCropInView(st, dom); + render(st, dom); + syncWidgets(st, widgets, node); + } } - fitCropInView(st, dom); wireInteractions(st, dom, widgets, node, nodeId); - render(st, dom); setupResizeObserver(st, dom); + wireInteractions(st, dom, widgets, node, nodeId); + setupResizeObserver(st, dom); - // ✅ POLLING const onExecutionStart = () => { startPolling(nodeId, dom, applyImageData, widgets, st); }; api.addEventListener("executing", onExecutionStart); const onInterrupt = () => { stopPolling(nodeId); }; api.addEventListener("interrupt", onInterrupt); api.addEventListener("disconnected", onInterrupt); - // ✅ КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Инициализируем геометрию сразу при получении события const onShow = (event) => { const { node_id, image_width, image_height } = event.detail; - if (String(node_id) === nodeId) { - // Обновляем размеры и пересчитываем масштаб + if (String(node_id) !== nodeId) return; + + dom.waitingMsg.style.display = "flex"; + dom.noDataMsg.style.display = "none"; + dom.acceptBtn.style.display = "block"; + dom.acceptBtn.textContent = "✔️ ACCEPT"; + dom.acceptBtn.disabled = false; + + if (!st.initialized || st._lastW !== image_width || st._lastH !== image_height) { st.srcW = image_width; st.srcH = image_height; + initLayout(st, dom.wrap); - dom.waitingMsg.style.display = "flex"; - dom.noDataMsg.style.display = "none"; - dom.acceptBtn.style.display = "block"; - dom.acceptBtn.textContent = "✔️ ACCEPT"; - dom.acceptBtn.disabled = false; - - // Инициализируем layout только если еще не сделано - if (!st.initialized && initLayout(st, dom.wrap)) { - const val = widgets.cropState?.value; - if (val) { - restoreCropFromWidgets(st, widgets, val); - } else { - // Дефолтный кроп - const defW = Math.max(GRID, Math.round(st.srcW * 0.8 / GRID) * GRID); - const defH = Math.max(GRID, Math.round(st.srcH * 0.8 / GRID) * GRID); - const defX = Math.round((st.srcW - defW) / 2 / GRID) * GRID; - const defY = Math.round((st.srcH - defH) / 2 / GRID) * GRID; - st.cr = srcToCr({x:defX, y:defY, w:defW, h:defH}, st.sf, st.scale); - st.cropAR = defW / defH; - } - fitCropInView(st, dom); - render(st, dom); - syncWidgets(st, widgets, node); - } + const defW = Math.max(GRID, Math.round(st.srcW * 0.8 / GRID) * GRID); + const defH = Math.max(GRID, Math.round(st.srcH * 0.8 / GRID) * GRID); + const defX = Math.round((st.srcW - defW) / 2 / GRID) * GRID; + const defY = Math.round((st.srcH - defH) / 2 / GRID) * GRID; + st.cr = srcToCr({x: defX, y: defY, w: defW, h: defH}, st.sf, st.scale); + st.cropAR = defW / defH; + st.outW = 0; st.outH = 0; + + st._lastW = st.srcW; + st._lastH = st.srcH; + st.initialized = true; + + fitCropInView(st, dom); + render(st, dom); + syncWidgets(st, widgets, node); + console.log(`🦊 [RS Outpaint] onShow reset ${st.srcW}x${st.srcH}`); } }; api.addEventListener("rs_outpaint.show", onShow); - // ✅ Обработчик кнопки ACCEPT dom.acceptBtn.addEventListener("click", async () => { const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); - const cropState = `${s.x},${s.y},${s.w},${s.h},${st.outW},${st.outH}`; + const mR = parseInt(st.maskColor.slice(1,3), 16); + const mG = parseInt(st.maskColor.slice(3,5), 16); + const mB = parseInt(st.maskColor.slice(5,7), 16); + const cropState = `${s.x},${s.y},${s.w},${s.h},${st.outW},${st.outH},${mR},${mG},${mB}`; dom.acceptBtn.textContent = "⏳ Sending..."; dom.acceptBtn.disabled = true; @@ -427,7 +552,6 @@ app.registerExtension({ } }); - // Очистка node._outpaintCleanup = () => { api.removeEventListener("executing", onExecutionStart); api.removeEventListener("interrupt", onInterrupt); @@ -441,7 +565,6 @@ app.registerExtension({ }).catch(() => {}); }; - // ✅ Загрузка при старте (fallback) fetchInfoWithRetry(nodeId, 3, 200).then(data => { if (data) applyImageData(data, true); });