import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const NODE_CLASS = "RSOutpaint"; const CANVAS_H = 320; const MARGIN = 22; const GRID = 16; const OVERHANG = 1; const CTRL_H = 240; const MIN_DRAG_PX = GRID; let queueWasRunning = false; let batchResetTimer = null; let allNodes = []; function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function crToSrc(cr, sf, scale) { return { x: (cr.x - sf.x) / scale, y: (cr.y - sf.y) / scale, w: cr.w / scale, h: cr.h / scale }; } function srcToCr(s, sf, scale) { return { x: sf.x + s.x * scale, y: sf.y + s.y * scale, w: s.w * scale, h: s.h * scale }; } function quantizeSrc(s) { return { x: Math.round(s.x / GRID) * GRID, y: Math.round(s.y / GRID) * GRID, w: Math.max(GRID, Math.round(s.w / GRID) * GRID), h: Math.max(GRID, Math.round(s.h / GRID) * GRID) }; } function clampToValid(s, srcW, srcH) { const c = { ...s }; if (c.x >= srcW - OVERHANG) c.x = srcW - OVERHANG; if (c.y >= srcH - OVERHANG) c.y = srcH - OVERHANG; if (c.x + c.w <= OVERHANG) c.x = OVERHANG - c.w; if (c.y + c.h <= OVERHANG) c.y = OVERHANG - c.h; return c; } function applyCanvasCr(canvasCr, st) { let s = crToSrc(canvasCr, st.sf, st.scale); s = quantizeSrc(s); s = clampToValid(s, st.srcW, st.srcH); st.cr = srcToCr(s, st.sf, st.scale); } function toWorld(e, wrapEl, view) { const rect = wrapEl.getBoundingClientRect(); return { x: (e.clientX - rect.left - view.panX) / view.zoom, y: (e.clientY - rect.top - view.panY) / view.zoom }; } function 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, approved: false, srcHash: null }; } function setUIActive(dom, isActive) { const opacity = isActive ? "1" : "0.4"; const pointerEvents = isActive ? "auto" : "none"; const filter = isActive ? "none" : "grayscale(100%)"; dom.savePresetBtn.style.opacity = opacity; dom.savePresetBtn.style.pointerEvents = pointerEvents; dom.savePresetBtn.style.filter = filter; dom.applyPresetBtn.style.opacity = opacity; dom.applyPresetBtn.style.pointerEvents = pointerEvents; dom.applyPresetBtn.style.filter = filter; for (const btn of dom.chipBtns) { btn.style.opacity = opacity; btn.style.pointerEvents = pointerEvents; btn.style.filter = filter; } for (const btn of dom.snapBtns) { btn.style.opacity = opacity; btn.style.pointerEvents = pointerEvents; btn.style.filter = filter; } dom.arBtn.style.opacity = opacity; dom.arBtn.style.pointerEvents = pointerEvents; dom.arBtn.style.filter = filter; dom.wInput.style.opacity = opacity; dom.wInput.style.pointerEvents = pointerEvents; dom.hInput.style.opacity = opacity; dom.hInput.style.pointerEvents = pointerEvents; dom.maskColorInput.picker.style.opacity = opacity; dom.maskColorInput.picker.style.pointerEvents = pointerEvents; dom.maskColorInput.hexInput.style.opacity = opacity; dom.maskColorInput.hexInput.style.pointerEvents = pointerEvents; dom.maskColorInput.swatch.style.opacity = opacity; dom.maskColorInput.swatch.style.pointerEvents = pointerEvents; dom.bgColorInput.picker.style.opacity = opacity; dom.bgColorInput.picker.style.pointerEvents = pointerEvents; dom.bgColorInput.hexInput.style.opacity = opacity; dom.bgColorInput.hexInput.style.pointerEvents = pointerEvents; dom.bgColorInput.swatch.style.opacity = opacity; dom.bgColorInput.swatch.style.pointerEvents = pointerEvents; dom.batchSingleBtn.style.opacity = opacity; dom.batchSingleBtn.style.pointerEvents = pointerEvents; dom.batchSingleBtn.style.filter = filter; dom.batchMultiBtn.style.opacity = opacity; dom.batchMultiBtn.style.pointerEvents = pointerEvents; dom.batchMultiBtn.style.filter = filter; } function resetNodeState(st, dom, widgets, nodeId) { const savedMaskColor = st.maskColor; const savedBgColor = st.bgColor; const savedBatchMode = st.batchMode; const savedHasPreset = st.hasPreset; if (st.srcHash && nodeId) { fetch("/rs_outpaint/unapprove", { method: "POST", 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.batchMode = savedBatchMode; st.hasPreset = savedHasPreset; st.arLocked = false; dom.arBtn.textContent = "🔓"; dom.arBtn.style.border = "1px solid #444"; dom.arBtn.style.background = "#2a2a2a"; dom.arBtn.style.color = "#bbb"; dom.arBtn._active = false; if (dom.wInput) dom.wInput.value = ""; if (dom.hInput) dom.hInput.value = ""; if (dom.maskColorInput) { dom.maskColorInput.picker.value = st.maskColor; dom.maskColorInput.swatch.style.background = st.maskColor; dom.maskColorInput.hexInput.value = st.maskColor.toUpperCase(); } if (dom.bgColorInput) { dom.bgColorInput.picker.value = st.bgColor; dom.bgColorInput.swatch.style.background = st.bgColor; dom.bgColorInput.hexInput.value = st.bgColor.toUpperCase(); } if (dom.waitingMsg) dom.waitingMsg.style.display = "none"; if (dom.acceptBtn) { dom.acceptBtn.style.display = "none"; dom.acceptBtn.textContent = "✔️ ACCEPT"; dom.acceptBtn.disabled = false; } if (dom.cancelBtn) dom.cancelBtn.style.display = "none"; if (dom.presetNameInput) dom.presetNameInput.style.display = "none"; if (dom.presetListOverlay) dom.presetListOverlay.style.display = "none"; if (dom.noDataMsg) { dom.noDataMsg.style.display = "none"; dom.noDataMsg.textContent = ""; } if (dom.imageEl) { dom.imageEl.style.display = "none"; if (dom.imageEl.src && dom.imageEl.src.startsWith("blob:")) URL.revokeObjectURL(dom.imageEl.src); } updateMaskColors(st, dom); st.initialized = false; syncWidgets(st, widgets, { graph: null }); setUIActive(dom, false); } 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.batchSingleBtn.style.display = "block"; dom.batchMultiBtn.style.display = "block"; dom.acceptBtn.disabled = false; dom.acceptBtn.textContent = "✔️ ACCEPT"; setUIActive(dom, true); if (st.srcHash && nodeId) { fetch("/rs_outpaint/unapprove", { method: "POST", 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; const maxW = W - MARGIN * 2; const maxH = wrapEl.clientHeight - MARGIN * 2; const ar = st.srcW / st.srcH; let sfW, sfH; if (maxW / ar <= maxH) { sfW = maxW; sfH = Math.round(maxW / ar); } else { sfH = maxH; sfW = Math.round(maxH * ar); } const sfX = Math.round((W - sfW) / 2); const sfY = Math.max(MARGIN, Math.round((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 def = clampToValid({ x: 0, y: 0, w: st.srcW, h: st.srcH }, st.srcW, st.srcH); st.cr = srcToCr(def, st.sf, st.scale); st.cropAR = st.srcW / st.srcH; st.outW = 0; st.outH = 0; st.arLocked = false; st.initialized = true; return true; } function mkEl(tag, css, extra) { const el = document.createElement(tag); if (css) el.style.cssText = css; if (extra) Object.assign(el, extra); return el; } function 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;appearance:none;-webkit-appearance:none;padding:0;border:0;", { 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:76px;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;position:relative;"); 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 = "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 [maskTop, maskBot, maskLeft, maskRight] = [mkMask(), mkMask(), mkMask(), mkMask()]; const cropBox = mkEl("div", "position:absolute;cursor:move;border:2px solid rgba(80,150,255,0.9);background:rgba(50,120,200,0.08);will-change:left,top,width,height;display:none;"); const HBASE = "position:absolute;width:11px;height:11px;background:#ddd;border:1.5px solid rgba(60,120,200,0.85);border-radius:2px;"; const corners = { tl: mkEl("div", HBASE + "top:0;left:0;transform:translate(-50%,-50%);cursor:nw-resize;"), tr: mkEl("div", HBASE + "top:0;right:0;transform:translate(50%,-50%);cursor:ne-resize;"), bl: mkEl("div", HBASE + "bottom:0;left:0;transform:translate(-50%,50%);cursor:sw-resize;"), br: mkEl("div", HBASE + "bottom:0;right:0;transform:translate(50%,50%);cursor:se-resize;") }; for (const [dir, el] of Object.entries(corners)) { el.dataset.dir = dir; cropBox.appendChild(el); } const sizeLabel = mkEl("div", "position:absolute;font-family:monospace;font-size:11px;font-weight:600;color:rgba(255,255,255,0.85);background:rgba(0,0,0,0.45);padding:1px 6px;border-radius:3px;pointer-events:none;white-space:nowrap;transform:translateX(-50%);"); const PAD_CSS = "position:absolute;font-family:monospace;font-size:10px;color:rgba(255,200,200,0.80);pointer-events:none;white-space:nowrap;"; const padLabelT = mkEl("div", PAD_CSS), padLabelB = mkEl("div", PAD_CSS), padLabelL = mkEl("div", PAD_CSS), padLabelR = mkEl("div", PAD_CSS); const EBASE = "position:absolute;background:#ddd;border:1.5px solid rgba(60,120,200,0.85);border-radius:2px;pointer-events:auto;"; const edges = { t: mkEl("div", EBASE + "width:18px;height:7px;top:0;left:50%;transform:translate(-50%,-50%);cursor:n-resize;"), b: mkEl("div", EBASE + "width:18px;height:7px;bottom:0;left:50%;transform:translate(-50%,50%);cursor:s-resize;"), l: mkEl("div", EBASE + "width:7px;height:18px;left:0;top:50%;transform:translate(-50%,-50%);cursor:w-resize;"), r: mkEl("div", EBASE + "width:7px;height:18px;right:0;top:50%;transform:translate(50%,-50%);cursor:e-resize;") }; for (const [dir, el] of Object.entries(edges)) { el.dataset.dir = dir; cropBox.appendChild(el); } const viewport = mkEl("div", "position:absolute;inset:0;transform-origin:0 0;"); viewport.append(sfEl, maskTop, maskBot, maskLeft, maskRight, cropBox, sizeLabel, padLabelT, padLabelB, padLabelL, padLabelR); wrap.appendChild(viewport); const zoomIndicator = mkEl("button", "position:absolute;bottom:6px;left:7px;padding:2px 6px;font-size:10px;font-family:monospace;background:rgba(0,0,0,0.55);color:#888;border:1px solid #444;border-radius:3px;cursor:pointer;z-index:10;"); zoomIndicator.title = "Click to reset zoom (scroll to zoom, middle-drag to pan)"; zoomIndicator.textContent = "100%"; wrap.appendChild(zoomIndicator); const ctrl = mkEl("div", "display:flex;flex-direction:column;gap:4px;margin-top:7px;"); const presetBtnRow = mkEl("div", "display:flex;align-items:center;gap:6px;justify-content:center;width:100%;margin-bottom:2px;position:relative;"); const savePresetBtn = mkEl("button", "padding:3px 9px;font-size:11px;border:1px solid #99c0ee;border-radius:5px;background:#1a3a5a;color:#aadaff;cursor:pointer;flex:1;"); savePresetBtn.textContent = "💾 Save preset"; savePresetBtn.title = "Save current crop as preset"; const applyPresetBtn = mkEl("button", "padding:3px 9px;font-size:11px;border:1px solid #99c0ee;border-radius:5px;background:#1a3a5a;color:#aadaff;cursor:pointer;flex:1;"); applyPresetBtn.textContent = "📂 Select preset"; applyPresetBtn.title = "Load saved preset"; presetBtnRow.append(savePresetBtn, applyPresetBtn); ctrl.appendChild(presetBtnRow); const presetListOverlay = mkEl("div", "position:absolute;display:none;top:50%;left:50%;transform:translate(-50%, -50%);flex-direction:column;max-height:200px;overflow-y:auto;background:#2a2a2a;border:1px solid #5090cc;border-radius:6px;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.8);min-width:180px;padding:5px;"); root.appendChild(presetListOverlay); const presetNameInput = mkEl("div", "position:absolute;display:none;top:50%;left:50%;transform:translate(-50%, -50%);background:#2a2a2a;padding:10px;border:1px solid #5090cc;border-radius:6px;z-index:9999;box-shadow:0 4px 15px rgba(0,0,0,0.7);width:220px;text-align:center;"); const inputField = mkEl("input", "width:100%;padding:5px;background:#111;color:#fff;border:1px solid #444;border-radius:3px;margin-bottom:5px;font-size:12px;", { placeholder: "Preset name..." }); const inputBtns = mkEl("div", "display:flex;gap:5px;"); const inputOk = mkEl("button", "flex:1;padding:4px;background:#1a3a5a;color:#aadaff;border:1px solid #5090cc;border-radius:3px;cursor:pointer;font-size:11px;"); inputOk.textContent = "OK"; const inputCancel = mkEl("button", "flex:1;padding:4px;background:#2a2a2a;color:#ccc;border:1px solid #444;border-radius:3px;cursor:pointer;font-size:11px;"); inputCancel.textContent = "Cancel"; inputBtns.append(inputOk, inputCancel); presetNameInput.append(inputField, inputBtns); root.appendChild(presetNameInput); const cropSizeRow = mkEl("div", "display:flex;align-items:center;gap:5px;flex-wrap:wrap;"); const INPUT_CSS = "width:58px;padding:2px 5px;font-size:11px;font-family:monospace;background:#1e1e1e;color:#ccc;border:1px solid #444;border-radius:4px;text-align:right;"; const wLabel = mkEl("span", "font-size:10px;color:#999;"); wLabel.textContent = "W"; const wInput = mkEl("input", INPUT_CSS, { type: "number", min: GRID, step: GRID, value: "" }); const hLabel = mkEl("span", "font-size:10px;color:#999;"); hLabel.textContent = "H"; const hInput = mkEl("input", INPUT_CSS, { type: "number", min: GRID, step: GRID, value: "" }); const arBtn = mkEl("button", "padding:3px 9px;font-size:11px;border:1px solid #444;border-radius:5px;background:#2a2a2a;color:#bbb;cursor:pointer;"); arBtn.textContent = "🔓"; arBtn._active = false; const resetBtn = mkEl("button", "padding:3px 9px;font-size:11px;border:1px solid #444;border-radius:5px;background:#2a2a2a;color:#bbb;cursor:pointer;"); resetBtn.textContent = "🔄️ Reset"; resetBtn.onmouseenter = () => { resetBtn.style.background = "#3a3a3a"; }; resetBtn.onmouseleave = () => { resetBtn.style.background = "#2a2a2a"; }; cropSizeRow.append(wLabel, wInput, hLabel, hInput, arBtn, resetBtn); const CHIP_PRESETS = [["16:9", 1280, 720], ["9:16", 720, 1280], ["21:9", 1344, 576], ["9:21", 576, 1344], ["4:3", 960, 720], ["3:4", 720, 960], ["1:1", 960, 960], ["3:2", 1080, 720], ["2:3", 720, 1080]]; const CHIP_CSS = "padding:2px 7px;font-size:10px;font-family:monospace;border:1px solid #444;border-radius:12px;background:#222;color:#999;cursor:pointer;white-space:nowrap;flex-shrink:0;"; const presetRow = mkEl("div", "display:flex;gap:4px;overflow-x:auto;padding-bottom:2px;flex-wrap:nowrap;"); const chipBtns = CHIP_PRESETS.map(([label, w, h]) => { const btn = mkEl("button", CHIP_CSS); btn.textContent = label; btn.dataset.chipW = w; btn.dataset.chipH = h; btn.dataset.chipAR = w / h; presetRow.appendChild(btn); return btn; }); const snapRow = mkEl("div", "display:flex;align-items:center;gap:0;"); const snapLabel = mkEl("span", "font-size:10px;color:#999;margin-right:4px;"); snapLabel.textContent = "snap:"; const SNAP_DEFS = [["center", "center"], ["top", "top"], ["bottom", "bottom"], ["left", "left"], ["right", "right"], ["fitW", "fit W"], ["fitH", "fit H"]]; const snapBtns = SNAP_DEFS.map(([key, label], i) => { const isFirst = i === 0, isLast = i === SNAP_DEFS.length - 1; const radius = isFirst ? "4px 0 0 4px" : (isLast ? "0 4px 4px 0" : "0"); const bl = isFirst ? "1px solid #444" : "none"; const b = mkEl("button", `padding:2px 7px;font-size:10px;border:1px solid #444;border-left:${bl};border-radius:${radius};background:#2a2a2a;color:#bbb;cursor:pointer;white-space:nowrap;`); b.textContent = label; b.dataset.snap = key; return b; }); snapRow.append(snapLabel, ...snapBtns); ctrl.append(cropSizeRow, presetRow, snapRow); const maskColorInput = mkColorInput("mask:", "#ff0000"); const bgColorInput = mkColorInput("bg:", "#141414"); const colorRow = mkEl("div", "display:flex;align-items:center;gap:8px;flex-wrap:wrap;"); colorRow.append(maskColorInput.row, bgColorInput.row); ctrl.appendChild(colorRow); const batchRow = mkEl("div", "display:flex;gap:4px;margin-top:4px;"); const batchSingleBtn = mkEl("button", "flex:1;padding:4px 2px;font-size:10px;font-weight:600;border:1px solid #444;border-radius:4px;background:#2a2a2a;color:#bbb;cursor:pointer;text-align:center;display:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"); batchSingleBtn.textContent = "️ BATCH SINGLE IMAGE"; batchSingleBtn.dataset.active = "false"; const batchMultiBtn = mkEl("button", "flex:1;padding:4px 2px;font-size:10px;font-weight:600;border:1px solid #444;border-radius:4px;background:#2a2a2a;color:#bbb;cursor:pointer;text-align:center;display:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"); batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE"; batchMultiBtn.dataset.active = "false"; batchRow.append(batchSingleBtn, batchMultiBtn); ctrl.appendChild(batchRow); const acceptBtn = mkEl("button", "padding:4px 12px;font-size:11px;font-weight:600;border:1px solid #28a745;border-radius:4px;background:#1a2a1a;color:#aaffaa;cursor:pointer;width:100%;text-align:center;display:none;margin-top:4px;"); acceptBtn.textContent = "✔️ ACCEPT"; ctrl.appendChild(acceptBtn); const cancelBtn = mkEl("button", "padding:4px 12px;font-size:11px;font-weight:600;border:1px solid #884444;border-radius:4px;background:#2a1a1a;color:#ffaaaa;cursor:pointer;width:100%;text-align:center;display:none;margin-top:2px;"); cancelBtn.textContent = " CANCEL"; ctrl.appendChild(cancelBtn); root.append(wrap, ctrl); return { root, wrap, viewport, zoomIndicator, sfEl, imageEl, srcLabel, noDataMsg, waitingMsg, maskTop, maskBot, maskLeft, maskRight, cropBox, arBtn, snapBtns, wInput, hInput, resetBtn, chipBtns, sizeLabel, padLabelT, padLabelB, padLabelL, padLabelR, edges, acceptBtn, cancelBtn, batchSingleBtn, batchMultiBtn, presetListOverlay, savePresetBtn, applyPresetBtn, presetNameInput, inputField, inputOk, inputCancel, maskColorInput, bgColorInput }; } function updateMaskColors(st, dom) { if (!dom) return; const maskColor = st.maskColor, alpha = "45"; const applyColor = (el) => { if (el) el.style.backgroundColor = maskColor + alpha; }; applyColor(dom.maskTop); applyColor(dom.maskBot); applyColor(dom.maskLeft); applyColor(dom.maskRight); dom.padLabelT.style.color = maskColor; dom.padLabelB.style.color = maskColor; dom.padLabelL.style.color = maskColor; dom.padLabelR.style.color = maskColor; } function render(st, dom) { if (!st.initialized) return; const { sf, cr, srcW, srcH, view } = st; dom.viewport.style.transform = `translate(${view.panX}px,${view.panY}px) scale(${view.zoom})`; dom.zoomIndicator.textContent = Math.round(view.zoom * 100) + "%"; dom.sfEl.style.left = sf.x + "px"; dom.sfEl.style.top = sf.y + "px"; dom.sfEl.style.width = sf.w + "px"; dom.sfEl.style.height = sf.h + "px"; dom.srcLabel.textContent = `${srcW} × ${srcH}`; dom.cropBox.style.display = "block"; 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"; } }; setMask(dom.maskTop, cr.x, cr.y, cr.w, Math.max(0, iy1 - cr.y)); setMask(dom.maskBot, cr.x, iy2, cr.w, Math.max(0, cr.y + cr.h - iy2)); setMask(dom.maskLeft, cr.x, iy1, Math.max(0, ix1 - cr.x), iy2 - iy1); setMask(dom.maskRight, ix2, iy1, Math.max(0, cr.x + cr.w - ix2), iy2 - iy1); const padT = Math.max(0, -s.y), padB = Math.max(0, s.y + s.h - srcH), padL = Math.max(0, -s.x), padR = Math.max(0, s.x + s.w - srcW); const outW = Math.round(s.w), outH = Math.round(s.h); dom.sizeLabel.textContent = `${outW} × ${outH}`; dom.sizeLabel.style.display = "block"; dom.sizeLabel.style.left = (cr.x + cr.w / 2) + "px"; dom.sizeLabel.style.top = (cr.y + cr.h - 18) + "px"; const showPad = (el, text, show, cx, cy) => { el.textContent = text; el.style.display = show ? "block" : "none"; if (show) { el.style.left = cx + "px"; el.style.top = cy + "px"; el.style.transform = "translate(-50%,-50%)"; } }; showPad(dom.padLabelT, `▲ ${Math.round(padT)}`, padT > 0.5, cr.x + cr.w / 2, cr.y + (padT * st.scale) / 2); showPad(dom.padLabelB, `▼ ${Math.round(padB)}`, padB > 0.5, cr.x + cr.w / 2, cr.y + cr.h - (padB * st.scale) / 2); showPad(dom.padLabelL, `◀ ${Math.round(padL)}`, padL > 0.5, cr.x + (padL * st.scale) / 2, cr.y + cr.h / 2); showPad(dom.padLabelR, `▶ ${Math.round(padR)}`, padR > 0.5, cr.x + cr.w - (padR * st.scale) / 2, cr.y + cr.h / 2); if (document.activeElement !== dom.wInput) dom.wInput.value = outW || ""; if (document.activeElement !== dom.hInput) dom.hInput.value = outH || ""; 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) { const wrapW = dom.wrap.clientWidth, wrapH = dom.wrap.clientHeight; if (wrapW <= 0 || wrapH <= 0) return; const pad = MARGIN; const bx1 = Math.min(st.sf.x, st.cr.x), by1 = Math.min(st.sf.y, st.cr.y), bx2 = Math.max(st.sf.x + st.sf.w, st.cr.x + st.cr.w), by2 = Math.max(st.sf.y + st.sf.h, st.cr.y + st.cr.h); const bw = bx2 - bx1, bh = by2 - by1; const fitZoom = Math.min((wrapW - pad * 2) / bw, (wrapH - pad * 2) / bh); const needsZoomOut = fitZoom < st.view.zoom; const z = needsZoomOut ? Math.max(0.15, fitZoom) : st.view.zoom; const scL = bx1 * z + st.view.panX, scT = by1 * z + st.view.panY, scR = bx2 * z + st.view.panX, scB = by2 * z + st.view.panY; const outOfBounds = scL < 0 || scT < 0 || scR > wrapW || scB > wrapH; if (!needsZoomOut && !outOfBounds) return; st.view.zoom = z; st.view.panX = (wrapW - bw * z) / 2 - bx1 * z; st.view.panY = (wrapH - bh * z) / 2 - by1 * z; } function syncWidgets(st, widgets, node) { const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); const hex = st.maskColor.replace("#", ""); const mR = parseInt(hex.substring(0, 2), 16), mG = parseInt(hex.substring(2, 4), 16), mB = parseInt(hex.substring(4, 6), 16); if (widgets.cropState) widgets.cropState.value = `${s.x},${s.y},${s.w},${s.h},0,0,${mR},${mG},${mB}`; if (node.graph) node.graph.setDirtyCanvas(true, true); } function setArLocked(st, dom, locked) { st.arLocked = locked; if (locked && st.cr.h > 0) st.cropAR = st.cr.w / st.cr.h; dom.arBtn.textContent = locked ? "" : "🔓"; dom.arBtn.style.border = `1px solid ${locked ? "#99c0ee" : "#444"}`; dom.arBtn.style.background = locked ? "#1a3a5a" : "#2a2a2a"; dom.arBtn.style.color = locked ? "#aadaff" : "#bbb"; dom.arBtn._active = locked; } function wireColors(st, dom, widgets, node) { const bind = (inputObj, stateKey) => { inputObj.picker.addEventListener("input", (e) => { st[stateKey] = e.target.value; inputObj.swatch.style.background = e.target.value; inputObj.hexInput.value = e.target.value.toUpperCase(); updateMaskColors(st, dom); syncWidgets(st, widgets, node); }); inputObj.hexInput.addEventListener("change", (e) => { let val = e.target.value; if (!val.startsWith("#")) val = "#" + val; if (/^#[0-9A-F]{6}$/i.test(val)) { st[stateKey] = val; inputObj.picker.value = val; inputObj.swatch.style.background = val; updateMaskColors(st, dom); syncWidgets(st, widgets, node); } }); }; bind(dom.maskColorInput, 'maskColor'); bind(dom.bgColorInput, 'bgColor'); } function calcSizeByRatio(srcW, srcH, targetAR) { let newW, newH; let tryH = srcW / targetAR; if (tryH >= srcH) { newW = srcW; newH = tryH; } else { newH = srcH; newW = srcH * targetAR; } newW = Math.max(GRID, Math.ceil(newW / GRID) * GRID); newH = Math.max(GRID, Math.ceil(newH / GRID) * GRID); return { w: newW, h: newH }; } function wireInteractions(st, dom, widgets, node, nodeId) { const { wrap, arBtn, snapBtns, wInput, hInput, resetBtn, chipBtns, batchSingleBtn, batchMultiBtn, savePresetBtn, applyPresetBtn, presetListOverlay, presetNameInput, inputField, inputOk, inputCancel } = dom; savePresetBtn.addEventListener("click", () => { dom.presetListOverlay.style.display = "none"; dom.presetNameInput.style.display = "block"; dom.inputField.value = ""; dom.inputField.focus(); }); const performSave = () => { const name = dom.inputField.value.trim(); if (!name) return; const s = quantizeSrc(crToSrc(st.cr, st.sf, st.scale)); const hex = st.maskColor.replace("#", ""); const mR = parseInt(hex.substring(0, 2), 16), mG = parseInt(hex.substring(2, 4), 16), mB = parseInt(hex.substring(4, 6), 16); const presetData = { crop_state: `${s.x},${s.y},${s.w},${s.h},0,0,${mR},${mG},${mB}`, maskColor: st.maskColor, bgColor: st.bgColor, outW: 0, outH: 0, cropAR: st.cropAR, arLocked: st.arLocked }; fetch("/rs_outpaint/save_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, preset_data: presetData }) }).then(r => { dom.presetNameInput.style.display = "none"; dom.noDataMsg.textContent = r.ok ? `✅ Preset "${name}" saved!` : "❌ Save failed"; dom.noDataMsg.style.display = "flex"; setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 2000); }).catch(e => { dom.noDataMsg.textContent = "❌ Error: " + e.message; dom.noDataMsg.style.display = "flex"; setTimeout(() => { dom.noDataMsg.style.display = "none"; }, 2000); }); }; inputOk.addEventListener("click", performSave); inputCancel.addEventListener("click", () => { dom.presetNameInput.style.display = "none"; }); inputField.addEventListener("keydown", (e) => { if (e.key === "Enter") performSave(); if (e.key === "Escape") dom.presetNameInput.style.display = "none"; }); applyPresetBtn.addEventListener("click", async () => { dom.presetNameInput.style.display = "none"; if (dom.presetListOverlay.style.display === "flex") { dom.presetListOverlay.style.display = "none"; return; } dom.presetListOverlay.innerHTML = "