From 95119c7b48c63c1141b91d51223e13d75889f306 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:41:28 +0300 Subject: [PATCH] Added an RS Upscaler node --- Example/RS Upscaler.json | 1 + Rayko_Upscaler.py | 102 ++++++++ __init__.py | 2 +- package.json | 3 +- pyproject.toml | 2 +- web/rs_upscaler.js | 519 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 Example/RS Upscaler.json create mode 100644 Rayko_Upscaler.py create mode 100644 web/rs_upscaler.js diff --git a/Example/RS Upscaler.json b/Example/RS Upscaler.json new file mode 100644 index 0000000..ab608a6 --- /dev/null +++ b/Example/RS Upscaler.json @@ -0,0 +1 @@ +{"id":"92112d97-bb64-4b44-86f2-ea5691ef8f6e","revision":0,"last_node_id":209,"last_link_id":247,"nodes":[{"id":209,"type":"RSUpscaler","pos":[16430,4120],"size":[300,130],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":246},{"localized_name":"upscale_model","name":"upscale_model","type":"COMBO","widget":{"name":"upscale_model"},"link":null},{"localized_name":"upscale_method","name":"upscale_method","type":"COMBO","widget":{"name":"upscale_method"},"link":null},{"localized_name":"upscale_x","name":"upscale_x","type":"FLOAT","widget":{"name":"upscale_x"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[247]}],"properties":{"cnr_id":"ComfyUI_RaykoStudio","ver":"e1fd5d43cc62c5f43993f11288d1398219bf91cb","Node name for S&R":"RSUpscaler","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["4x_Foolhardy_Remacri.pth","nearest-exact",3,""],"widgets_values_named":{"upscale_model":"4x_Foolhardy_Remacri.pth","upscale_method":"nearest-exact","upscale_x":3,"rs_custom_widgets":""}},{"id":80,"type":"SaveImage","pos":[16750,4120],"size":[230,340],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":247},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"SaveImage","enableTabs":false,"tabWidth":65,"tabXOffset":10,"hasSecondTab":false,"secondTabText":"Send Back","secondTabOffset":80,"secondTabWidth":65,"ue_properties":{"version":"7.5.1","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":["ZZZZZ/img"],"widgets_values_named":{"filename_prefix":"ZZZZZ/img"}},{"id":82,"type":"LoadImage","pos":[16170,4120],"size":[240,340],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","slot_index":0,"links":[246]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"LoadImage","enableTabs":false,"tabWidth":65,"tabXOffset":10,"hasSecondTab":false,"secondTabText":"Send Back","secondTabOffset":80,"secondTabWidth":65,"ue_properties":{"version":"7.5.1","widget_ue_connectable":{},"input_ue_unconnectable":{}}},"widgets_values":["ComfyUI_00039.png","image"],"widgets_values_named":{"image":"ComfyUI_00039.png","upload":"image"}}],"links":[[246,82,0,209,0,"IMAGE"],[247,209,0,80,0,"IMAGE"]],"groups":[],"config":{},"extra":{"frontendVersion":"1.32.9","workflowRendererVersion":"LG","ue_links":[],"links_added_by_ue":[],"groupNodes":{"postiveguid":{"nodes":[{"id":-1,"type":"CLIPTextEncode","pos":[-8905.3916015625,10545.6640625],"size":[422.84503173828125,164.31304931640625],"flags":{},"order":303,"mode":0,"inputs":[{"name":"clip","type":"CLIP","link":null,"localized_name":"clip"},{"name":"text","localized_name":"text","type":"STRING","widget":{"name":"text"},"boundingRect":{"0":0,"1":0,"2":0,"3":0},"link":null}],"outputs":[{"name":"CONDITIONING","type":"CONDITIONING","links":[],"slot_index":0,"localized_name":"CONDITIONING"}],"title":"CLIP Text Encode (Positive Prompt)","properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":["Sydney Sweeney"],"color":"#232","bgcolor":"#353","index":0},{"id":-1,"type":"FluxGuidance","pos":[-8388.3935546875,10610.8876953125],"size":[317.4000244140625,58],"flags":{},"order":327,"mode":0,"inputs":[{"name":"conditioning","type":"CONDITIONING","link":null,"localized_name":"conditioning"},{"name":"guidance","localized_name":"guidance","type":"FLOAT","widget":{"name":"guidance"},"boundingRect":{"0":0,"1":0,"2":0,"3":0},"link":null}],"outputs":[{"name":"CONDITIONING","type":"CONDITIONING","links":[],"slot_index":0,"localized_name":"CONDITIONING"}],"properties":{"Node name for S&R":"FluxGuidance"},"widgets_values":[30],"index":1}],"links":[[null,1,0,0,1255,"CLIP"],[0,0,1,0,1256,"CONDITIONING"]],"external":[[1,0,"CONDITIONING"]]},"positive prompt":{"nodes":[{"id":-1,"type":"CLIPTextEncode","pos":[-8905.3916015625,10545.6640625],"size":[422.84503173828125,164.31304931640625],"flags":{},"order":303,"mode":0,"inputs":[{"name":"clip","type":"CLIP","link":null,"localized_name":"clip"},{"name":"text","localized_name":"text","type":"STRING","widget":{"name":"text"},"boundingRect":{"0":0,"1":0,"2":0,"3":0},"link":null}],"outputs":[{"name":"CONDITIONING","type":"CONDITIONING","links":[],"slot_index":0,"localized_name":"CONDITIONING"}],"title":"CLIP Text Encode (Positive Prompt)","properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":["Sydney Sweeney"],"color":"#232","bgcolor":"#353","index":0},{"id":-1,"type":"FluxGuidance","pos":[-8388.3935546875,10610.8876953125],"size":[317.4000244140625,58],"flags":{},"order":327,"mode":0,"inputs":[{"name":"conditioning","type":"CONDITIONING","link":null,"localized_name":"conditioning"},{"name":"guidance","localized_name":"guidance","type":"FLOAT","widget":{"name":"guidance"},"boundingRect":{"0":0,"1":0,"2":0,"3":0},"link":null}],"outputs":[{"name":"CONDITIONING","type":"CONDITIONING","links":[],"slot_index":0,"localized_name":"CONDITIONING"}],"properties":{"Node name for S&R":"FluxGuidance"},"widgets_values":[30],"index":1}],"links":[[null,1,0,0,1255,"CLIP"],[0,0,1,0,1256,"CONDITIONING"]],"external":[[1,0,"CONDITIONING"]]}},"VHS_latentpreview":false,"VHS_latentpreviewrate":0,"VHS_MetadataImage":true,"VHS_KeepIntermediate":true,"qgn_navigation_groups":[],"qgn_locked":false,"ds":{"scale":1.865,"offset":[-15897.868632707774,-3971.514745308311]}},"version":0.4} \ No newline at end of file diff --git a/Rayko_Upscaler.py b/Rayko_Upscaler.py new file mode 100644 index 0000000..61cc3d9 --- /dev/null +++ b/Rayko_Upscaler.py @@ -0,0 +1,102 @@ +import torch +import comfy.utils +import comfy.model_management +import folder_paths + + +class RSUpscaler: + """ + 🦊 RS Upscaler + Комбинирует загрузку модели и апскейл в одной ноде. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + # Эти виджеты будут скрыты JS и заменены на кастомные + "upscale_model": (folder_paths.get_filename_list("upscale_models"),), + "upscale_method": (["nearest-exact", "bilinear", "area", "bicubic", "lanczos"],), + "upscale_x": ("FLOAT", {"default": 2.0, "min": 0.1, "max": 8.0, "step": 0.05}), + }, + "hidden": { + # Маркер для JS-расширения + "rs_node_type": ("STRING", {"default": "RSUpscaler"}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "upscale" + CATEGORY = "🦊 RaykoStudio" + + def upscale(self, image, upscale_model, upscale_method, upscale_x, rs_node_type=None): + # 1. Загрузка модели + model_path = folder_paths.get_full_path("upscale_models", upscale_model) + + try: + sd = comfy.utils.load_torch_file(model_path, safe_load=True) + from comfy_extras.chainner_models import model_loading + + is_chainner = False + for key in sd.keys(): + if "module.layers" in key or "model.0.weight" in key or "conv_first.weight" in key: + is_chainner = True + break + + if is_chainner: + upscale_model_obj = model_loading.load_state_dict(sd).eval() + else: + raise ValueError("Не удалось определить архитектуру модели") + + except Exception as e: + raise ValueError( + f"Ошибка загрузки модели '{upscale_model}': {str(e)}\n" + f"Убедитесь, что файл является корректной upscale-моделью" + ) + + # 2. Применение модели с тайлингом + device = comfy.model_management.get_torch_device() + upscale_model_obj.to(device) + + in_img = image.movedim(-1, -3).to(device) + scale = upscale_model_obj.scale + + upscaled_tensor = comfy.utils.tiled_scale( + in_img, + lambda a: upscale_model_obj(a), + tile_x=192, + tile_y=192, + overlap=8, + upscale_amount=scale + ) + + upscale_model_obj.cpu() + comfy.model_management.soft_empty_cache() + + upscaled = torch.clamp(upscaled_tensor.movedim(-3, -1), min=0, max=1.0) + + # 3. Финальный ресайз + orig_height = image.shape[1] + orig_width = image.shape[2] + + target_width = int(orig_width * upscale_x) + target_height = int(orig_height * upscale_x) + + if upscaled.shape[2] != target_width or upscaled.shape[1] != target_height: + samples = upscaled.movedim(-1, 1) + s = comfy.utils.common_upscale(samples, target_width, target_height, upscale_method, crop="disabled") + result = s.movedim(1, -1) + else: + result = upscaled + + return (result,) + + +NODE_CLASS_MAPPINGS = { + "RSUpscaler": RSUpscaler, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RSUpscaler": "🦊 RS Upscaler", +} \ No newline at end of file diff --git a/__init__.py b/__init__.py index 79db726..9c6fae4 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.36.4" +__version__ = "0.37.0" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index 3c76658..9bef63e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.36.4", + "version": "0.37.0", "description": "Set of custom nodes for ComfyUI providing additional image processing capabilities", "author": "Raykosan", "license": "Apache License 2.0", @@ -39,6 +39,7 @@ "Rayko_Spline_Mask.py" "Rayko_Styles_Loader.py" "Rayko_Text_Overlay_Pro.py" + "Rayko_Upscaler.py" "Rayko_VAE_Save_Image.py" "settings_manager_api.py" ] diff --git a/pyproject.toml b/pyproject.toml index f730579..1a8e585 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.36.4" +version = "0.37.0" license = { file = "LICENSE" } dependencies = ["torch", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"] diff --git a/web/rs_upscaler.js b/web/rs_upscaler.js new file mode 100644 index 0000000..0d99f12 --- /dev/null +++ b/web/rs_upscaler.js @@ -0,0 +1,519 @@ +import { app } from "../../scripts/app.js"; + +const RS_UPSCALER_NODE_TYPE = "RSUpscaler"; +const DEFAULT_SCALE = 2.0; +const SCALE_MIN = 0.1; +const SCALE_MAX = 8.0; +const SCALE_STEP = 0.05; +const NODE_WIDTH = 300; +const NODE_HEIGHT = 130; + +function getScopedStyles(scopeId) { + return ` + /* === Контейнер === */ + .${scopeId} .rs-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + } + .${scopeId} .rs-row label { + flex-shrink: 0; + font-size: 11px; + color: #aaa; + white-space: nowrap; + min-width: 40px; + font-family: sans-serif; + } + .${scopeId} .rs-row select { + flex: 1; + min-width: 0; + background: #222; + color: #fff; + border: 1px solid #444; + border-radius: 4px; + padding: 5px 8px; + font-size: 11px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + appearance: auto; + font-family: sans-serif; + } + .${scopeId} .rs-row select:hover { border-color: #ff9800; } + .${scopeId} .rs-row select:focus { border-color: #ff9800; box-shadow: 0 0 0 1px rgba(255,152,0,0.3); } + + /* === Слайдер-строка === */ + .${scopeId} .rs-slider-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + } + .${scopeId} .rs-slider-row label { + flex-shrink: 0; + font-size: 11px; + color: #aaa; + white-space: nowrap; + min-width: 40px; + font-family: sans-serif; + } + + /* Слайдер: зелёный fill + белый хэндл */ + .${scopeId} .rs-slider-row input[type="range"] { + flex: 1; + min-width: 0; + height: 4px; + border-radius: 2px; + outline: none; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + background: linear-gradient(to right, + #4CAF50 0%, #4CAF50 var(--rs-fill, 25%), + #444 var(--rs-fill, 25%), #444 100%); + } + .${scopeId} .rs-slider-row input[type="range"]::-webkit-slider-runnable-track { + height: 4px; + background: transparent; + border-radius: 2px; + } + .${scopeId} .rs-slider-row input[type="range"]::-moz-range-track { + height: 4px; + background: transparent; + border-radius: 2px; + } + .${scopeId} .rs-slider-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; + border: none; + cursor: pointer; + margin-top: -4px; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } + .${scopeId} .rs-slider-row input[type="range"]::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; + border: none; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } + + /* === Кнопки +/- === */ + .${scopeId} .rs-step-btn { + width: 20px; + height: 26px; + background: #252525; + color: #888; + border: 1px solid #444; + border-radius: 4px; + cursor: pointer; + font-size: 9px; + display: flex; + align-items: center; + justify-content: center; + transition: 0.15s; + flex-shrink: 0; + user-select: none; + line-height: 1; + padding: 0; + } + .${scopeId} .rs-step-btn:hover { + background: #2a2a2a; + color: #fff; + border-color: #ff9800; + } + .${scopeId} .rs-step-btn:active { + background: #333; + } + + /* === Value input === */ + .${scopeId} .rs-value-input { + width: 35px; + min-width: 35px; + max-width: 35px; + height: 26px; + text-align: center; + background: #252525; + color: #4CAF50; + border: 1px solid #444; + border-radius: 4px; + padding: 0; + font-size: 11px; + cursor: pointer; + font-weight: 600; + transition: 0.15s; + font-family: sans-serif; + font-variant-numeric: tabular-nums; + flex-shrink: 0; + overflow: hidden; + white-space: nowrap; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + } + .${scopeId} .rs-value-input:hover { + border-color: #ff9800; + color: #fff; + } + `; +} + +// Popup стили — глобальные (popup рендерится в document.body вне контейнера ноды) +const POPUP_STYLES = ` + .rs-value-popup { + position: fixed; + z-index: 10004; + background: #1a1a1a; + border: 1px solid #444; + border-radius: 6px; + padding: 8px 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.5); + display: flex; + align-items: center; + gap: 8px; + } + .rs-value-popup input { + width: 80px; + background: #222; + color: #fff; + border: 1px solid #444; + border-radius: 4px; + padding: 6px 10px; + font-size: 12px; + outline: none; + font-family: sans-serif; + } + .rs-value-popup input:focus { + border-color: #ff9800; + } + .rs-value-popup button { + background: #4CAF50; + color: #fff; + border: none; + border-radius: 4px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + font-family: sans-serif; + } + .rs-value-popup button:hover { + background: #43A047; + } +`; + +let popupStylesInjected = false; +function injectPopupStyles() { + if (popupStylesInjected) return; + const style = document.createElement("style"); + style.textContent = POPUP_STYLES; + document.head.appendChild(style); + popupStylesInjected = true; +} + +let upscaleModelsCache = null; +async function fetchUpscaleModels() { + if (upscaleModelsCache) return upscaleModelsCache; + try { + const resp = await fetch("/api/object_info/RSUpscaler"); + const data = await resp.json(); + upscaleModelsCache = data.RSUpscaler.input.required.upscale_model[0]; + } catch (e) { + console.warn("[RS Upscaler] Failed to fetch models list:", e); + upscaleModelsCache = []; + } + return upscaleModelsCache; +} + +function clampScale(v) { + return Math.max(SCALE_MIN, Math.min(SCALE_MAX, v)); +} + +function formatScale(v) { + return parseFloat(v).toFixed(2); +} + +app.registerExtension({ + name: "RaykoStudio.RSUpscaler", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== RS_UPSCALER_NODE_TYPE) return; + + injectPopupStyles(); + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + + // === УНИКАЛЬНЫЙ SCOPE ID ДЛЯ ЭТОЙ НОДЫ === + const scopeId = `rs-upscaler-${this.id}`; + + // Инжектируем скоупированные стили один раз для этого ID + if (!document.getElementById(`style-${scopeId}`)) { + const style = document.createElement("style"); + style.id = `style-${scopeId}`; + style.textContent = getScopedStyles(scopeId); + document.head.appendChild(style); + } + + // === РАЗМЕР НОДЫ === + this.setSize([NODE_WIDTH, NODE_HEIGHT]); + + this.onResize = function (size) { + if (size[0] < NODE_WIDTH) size[0] = NODE_WIDTH; + if (size[1] < NODE_HEIGHT) size[1] = NODE_HEIGHT; + }; + + // === АГРЕССИВНОЕ СКРЫТИЕ НАТИВНЫХ ВИДЖЕТОВ === + const widgetsToHide = ["upscale_model", "upscale_method", "upscale_x"]; + this.widgets.forEach((w) => { + if (widgetsToHide.includes(w.name)) { + w.hidden = true; + w.tooltip = ""; + w.type = "hidden"; + if (w.element) { + w.element.style.display = "none"; + w.element.style.pointerEvents = "none"; + } + w.computeSize = () => [0, 0]; + } + }); + + // === КОНТЕЙНЕР С УНИКАЛЬНЫМ SCOPE === + const container = document.createElement("div"); + container.className = scopeId; + + // --- Строка 1: Model --- + const row1 = document.createElement("div"); + row1.className = "rs-row"; + const modelLabel = document.createElement("label"); + modelLabel.textContent = "Model"; + row1.appendChild(modelLabel); + const modelSelect = document.createElement("select"); + row1.appendChild(modelSelect); + container.appendChild(row1); + + fetchUpscaleModels().then((models) => { + models.forEach((m) => { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m.replace(/\.(pth|bin|pt)$/i, ""); + modelSelect.appendChild(opt); + }); + const currentModel = this.widgets.find((w) => w.name === "upscale_model")?.value; + if (currentModel) modelSelect.value = currentModel; + }); + + // --- Строка 2: Method --- + const row2 = document.createElement("div"); + row2.className = "rs-row"; + const methodLabel = document.createElement("label"); + methodLabel.textContent = "Method"; + row2.appendChild(methodLabel); + const methodSelect = document.createElement("select"); + const methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]; + methods.forEach((m) => { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m.charAt(0).toUpperCase() + m.slice(1); + methodSelect.appendChild(opt); + }); + row2.appendChild(methodSelect); + container.appendChild(row2); + + // --- Строка 3: [Label][Slider][◀][Value][▶] --- + const row3 = document.createElement("div"); + row3.className = "rs-slider-row"; + + const sliderLabel = document.createElement("label"); + sliderLabel.textContent = "Scale"; + row3.appendChild(sliderLabel); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = String(SCALE_MIN); + slider.max = String(SCALE_MAX); + slider.step = String(SCALE_STEP); + slider.value = String(DEFAULT_SCALE); + row3.appendChild(slider); + + const minusBtn = document.createElement("button"); + minusBtn.className = "rs-step-btn"; + minusBtn.textContent = "◀"; + minusBtn.title = "Decrease by " + SCALE_STEP; + row3.appendChild(minusBtn); + + const valueDisplay = document.createElement("div"); + valueDisplay.className = "rs-value-input"; + valueDisplay.textContent = formatScale(DEFAULT_SCALE); + row3.appendChild(valueDisplay); + + const plusBtn = document.createElement("button"); + plusBtn.className = "rs-step-btn"; + plusBtn.textContent = "▶"; + plusBtn.title = "Increase by " + SCALE_STEP; + row3.appendChild(plusBtn); + + container.appendChild(row3); + + // === ОБНОВЛЕНИЕ ЗЕЛЁНОГО FILL === + const updateFill = () => { + const pct = ((parseFloat(slider.value) - SCALE_MIN) / (SCALE_MAX - SCALE_MIN)) * 100; + slider.style.setProperty("--rs-fill", pct + "%"); + }; + + // === ОБНОВЛЕНИЕ ЗНАЧЕНИЙ === + const setValue = (rawVal) => { + const v = clampScale(Math.round(parseFloat(rawVal) / SCALE_STEP) * SCALE_STEP); + slider.value = String(v); + valueDisplay.textContent = formatScale(v); + updateFill(); + syncToNative(); + }; + + const updateFromSlider = () => { + valueDisplay.textContent = formatScale(slider.value); + updateFill(); + syncToNative(); + }; + + slider.addEventListener("input", updateFromSlider); + slider.addEventListener("change", updateFromSlider); + + minusBtn.addEventListener("click", (e) => { + e.stopPropagation(); + setValue(parseFloat(slider.value) - SCALE_STEP); + }); + + plusBtn.addEventListener("click", (e) => { + e.stopPropagation(); + setValue(parseFloat(slider.value) + SCALE_STEP); + }); + + // === РУЧНОЙ ВВОД ПО КЛИКУ НА VALUE === + valueDisplay.addEventListener("click", (e) => { + e.stopPropagation(); + + const existingPopup = document.querySelector(".rs-value-popup"); + if (existingPopup) existingPopup.remove(); + + const popup = document.createElement("div"); + popup.className = "rs-value-popup"; + + const input = document.createElement("input"); + input.type = "number"; + input.value = formatScale(slider.value); + input.min = String(SCALE_MIN); + input.max = String(SCALE_MAX); + input.step = String(SCALE_STEP); + + const saveBtn = document.createElement("button"); + saveBtn.textContent = "OK"; + + const doSave = () => { + let num = parseFloat(input.value); + if (isNaN(num)) num = DEFAULT_SCALE; + setValue(num); + popup.remove(); + }; + + saveBtn.onclick = (ev) => { ev.stopPropagation(); ev.preventDefault(); doSave(); }; + input.onkeydown = (ev) => { if (ev.key === "Enter") { ev.preventDefault(); doSave(); } }; + + popup.appendChild(input); + popup.appendChild(saveBtn); + document.body.appendChild(popup); + + const rect = valueDisplay.getBoundingClientRect(); + const popupWidth = popup.offsetWidth || 160; + const popupHeight = popup.offsetHeight || 40; + let leftPos = rect.right + 8; + let topPos = rect.top + (rect.height - popupHeight) / 2; + + if (leftPos + popupWidth > window.innerWidth - 10) { + leftPos = rect.left - popupWidth - 8; + } + if (topPos < 10) topPos = 10; + if (topPos + popupHeight > window.innerHeight - 10) { + topPos = window.innerHeight - popupHeight - 10; + } + + popup.style.left = leftPos + "px"; + popup.style.top = topPos + "px"; + + setTimeout(() => { input.focus(); input.select(); }, 50); + + setTimeout(() => { + const closeHandler = (ev) => { + if (!popup.contains(ev.target)) { + popup.remove(); + document.removeEventListener("mousedown", closeHandler); + } + }; + document.addEventListener("mousedown", closeHandler); + }, 100); + }); + + // === DOM-ВИДЖЕТ === + this.addDOMWidget("rs_custom_widgets", "div", container); + + // === СИНХРОНИЗАЦИЯ === + const syncToNative = () => { + const mw = this.widgets.find((w) => w.name === "upscale_model"); + const mtw = this.widgets.find((w) => w.name === "upscale_method"); + const xw = this.widgets.find((w) => w.name === "upscale_x"); + if (mw) mw.value = modelSelect.value; + if (mtw) mtw.value = methodSelect.value; + if (xw) xw.value = parseFloat(slider.value); + this.graph?.setDirtyCanvas(true, true); + }; + + modelSelect.addEventListener("change", syncToNative); + methodSelect.addEventListener("change", syncToNative); + + setTimeout(() => { + syncToNative(); + updateFill(); + }, 100); + + // === ВОССТАНОВЛЕНИЕ ПРИ ЗАГРУЗКЕ WORKFLOW === + const onConfigure = this.onConfigure; + this.onConfigure = function (info) { + const r = onConfigure?.apply(this, arguments); + setTimeout(() => { + const mw = this.widgets.find((w) => w.name === "upscale_model"); + const mtw = this.widgets.find((w) => w.name === "upscale_method"); + const xw = this.widgets.find((w) => w.name === "upscale_x"); + if (mw?.value) modelSelect.value = mw.value; + if (mtw?.value) methodSelect.value = mtw.value; + if (xw?.value != null) { + const v = clampScale(parseFloat(xw.value)); + slider.value = String(v); + valueDisplay.textContent = formatScale(v); + } else { + slider.value = String(DEFAULT_SCALE); + valueDisplay.textContent = formatScale(DEFAULT_SCALE); + } + updateFill(); + syncToNative(); + }, 50); + return r; + }; + + // === ОЧИСТКА СТИЛЕЙ ПРИ УДАЛЕНИИ НОДЫ === + const onRemoved = this.onRemoved; + this.onRemoved = function () { + const styleEl = document.getElementById(`style-${scopeId}`); + if (styleEl) styleEl.remove(); + if (onRemoved) onRemoved.apply(this, arguments); + }; + + return result; + }; + }, +}); \ No newline at end of file