Changing the operating method of the RS Outpaint node

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