Files

1008 lines
62 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "<div style='padding:8px;color:#999;text-align:center;'>Loading...</div>";
dom.presetListOverlay.style.display = "flex";
try {
const res = await fetch("/rs_outpaint/list_presets", { method: "POST", headers: { "Content-Type": "application/json" } });
const list = await res.json();
dom.presetListOverlay.innerHTML = "";
if (!list.length) { dom.presetListOverlay.textContent = "No presets found"; return; }
list.forEach(name => {
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-bottom:1px solid #333;";
const nameSpan = document.createElement("span");
nameSpan.textContent = name; nameSpan.style.cssText = "flex:1;cursor:pointer;color:#ccc;font-size:12px;";
nameSpan.onmouseenter = () => nameSpan.style.background = "#3a3a3a"; nameSpan.onmouseleave = () => nameSpan.style.background = "transparent";
nameSpan.onclick = async () => {
dom.presetListOverlay.style.display = "none";
if (st.srcW < 32 || st.srcH < 32) { dom.noDataMsg.textContent = "️ Wait for image to load first"; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); return; }
try {
const res2 = await fetch("/rs_outpaint/load_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
if (!res2.ok) throw new Error("Load failed");
const data = await res2.json();
st.maskColor = data.maskColor || "#ff0000"; st.bgColor = data.bgColor || "#141414"; st.outW = 0; st.outH = 0; st.cropAR = data.cropAR || 16 / 9; st.arLocked = data.arLocked !== undefined ? data.arLocked : true;
setArLocked(st, dom, st.arLocked);
dom.maskColorInput.picker.value = st.maskColor; dom.maskColorInput.swatch.style.background = st.maskColor; dom.maskColorInput.hexInput.value = st.maskColor.toUpperCase();
dom.bgColorInput.picker.value = st.bgColor; dom.bgColorInput.swatch.style.background = st.bgColor; dom.bgColorInput.hexInput.value = st.bgColor.toUpperCase();
if (data.crop_state) {
const parts = data.crop_state.split(',').map(Number);
if (parts.length >= 4) {
let s = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
s = quantizeSrc(s);
s = clampToValid(s, st.srcW, st.srcH);
st.cr = srcToCr(s, st.sf, st.scale);
}
}
updateMaskColors(st, dom); fitCropInView(st, dom); render(st, dom); syncWidgets(st, widgets, node);
} catch (e) { dom.noDataMsg.textContent = "❌ " + e.message; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); }
};
const deleteBtn = document.createElement("span");
deleteBtn.textContent = "❌"; deleteBtn.style.cssText = "cursor:pointer;margin-left:8px;font-size:14px;opacity:0.7;";
deleteBtn.onmouseenter = () => { deleteBtn.style.opacity = "1"; deleteBtn.style.transform = "scale(1.2)"; };
deleteBtn.onmouseleave = () => { deleteBtn.style.opacity = "0.7"; deleteBtn.style.transform = "scale(1)"; };
deleteBtn.onclick = async (e) => {
e.stopPropagation();
try {
const res3 = await fetch("/rs_outpaint/delete_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
if (res3.ok) { applyPresetBtn.click(); dom.noDataMsg.textContent = `🗑️ Preset "${name}" deleted`; dom.noDataMsg.style.display = "flex"; setTimeout(() => dom.noDataMsg.style.display = "none", 2000); }
else { dom.noDataMsg.textContent = "❌ Delete 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); }
};
row.appendChild(nameSpan); row.appendChild(deleteBtn);
dom.presetListOverlay.appendChild(row);
});
} catch (e) { dom.presetListOverlay.textContent = "Error loading"; }
});
document.addEventListener("click", (e) => { if (!dom.presetListOverlay?.contains(e.target) && !dom.applyPresetBtn?.contains(e.target)) dom.presetListOverlay.style.display = "none"; if (!dom.presetNameInput?.contains(e.target) && e.target !== dom.savePresetBtn) dom.presetNameInput.style.display = "none"; });
batchSingleBtn.addEventListener("click", async () => {
if (dom.batchMultiBtn.dataset.active === "true") {
dom.batchMultiBtn.dataset.active = "false";
dom.batchMultiBtn.style.borderColor = "#444";
dom.batchMultiBtn.style.background = "#2a2a2a";
dom.batchMultiBtn.style.color = "#bbb";
dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE";
try {
await fetch("/rs_outpaint/batch_toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id), batch_type: "multi", enabled: false }) });
} catch (e) {}
}
const nowActive = dom.batchSingleBtn.dataset.active !== "true";
dom.batchSingleBtn.dataset.active = nowActive ? "true" : "false";
if (nowActive) {
dom.batchSingleBtn.style.borderColor = "#28a745";
dom.batchSingleBtn.style.background = "#1a2a1a";
dom.batchSingleBtn.style.color = "#aaffaa";
dom.batchSingleBtn.textContent = "️ BATCH SINGLE IMAGE ON";
} else {
dom.batchSingleBtn.style.borderColor = "#444";
dom.batchSingleBtn.style.background = "#2a2a2a";
dom.batchSingleBtn.style.color = "#bbb";
dom.batchSingleBtn.textContent = "⚙️ BATCH SINGLE IMAGE";
}
try {
await fetch("/rs_outpaint/batch_toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(node.id), batch_type: "single", enabled: nowActive })
});
} catch (e) {}
});
batchMultiBtn.addEventListener("click", async () => {
if (dom.batchSingleBtn.dataset.active === "true") {
dom.batchSingleBtn.dataset.active = "false";
dom.batchSingleBtn.style.borderColor = "#444";
dom.batchSingleBtn.style.background = "#2a2a2a";
dom.batchSingleBtn.style.color = "#bbb";
dom.batchSingleBtn.textContent = "⚙️ BATCH SINGLE IMAGE";
try {
await fetch("/rs_outpaint/batch_toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id), batch_type: "single", enabled: false }) });
} catch (e) {}
}
const nowActive = dom.batchMultiBtn.dataset.active !== "true";
dom.batchMultiBtn.dataset.active = nowActive ? "true" : "false";
if (nowActive) {
dom.batchMultiBtn.style.borderColor = "#28a745";
dom.batchMultiBtn.style.background = "#1a2a1a";
dom.batchMultiBtn.style.color = "#aaffaa";
dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE ON";
} else {
dom.batchMultiBtn.style.borderColor = "#444";
dom.batchMultiBtn.style.background = "#2a2a2a";
dom.batchMultiBtn.style.color = "#bbb";
dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE";
}
try {
await fetch("/rs_outpaint/batch_toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(node.id), batch_type: "multi", enabled: nowActive })
});
} catch (e) {}
});
arBtn.addEventListener("click", () => setArLocked(st, dom, !st.arLocked));
wInput.addEventListener("change", () => {
const r = Math.max(GRID, Math.round(parseInt(wInput.value, 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;
s.w = r;
if (st.arLocked) s.h = Math.max(GRID, Math.round(r / st.cropAR / GRID) * GRID);
s.x = Math.round((cx - s.w / 2) / GRID) * GRID;
s.y = Math.round((cy - s.h / 2) / GRID) * GRID;
s = clampToValid(quantizeSrc(s), st.srcW, st.srcH);
st.cr = srcToCr(s, st.sf, st.scale);
if (!st.arLocked) st.cropAR = s.w / s.h;
fitCropInView(st, dom);
render(st, dom);
syncWidgets(st, widgets, node);
unapprove(st, dom, nodeId);
});
hInput.addEventListener("change", () => {
const r = Math.max(GRID, Math.round(parseInt(hInput.value, 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;
s.h = r;
if (st.arLocked) s.w = Math.max(GRID, Math.round(r * st.cropAR / GRID) * GRID);
s.x = Math.round((cx - s.w / 2) / GRID) * GRID;
s.y = Math.round((cy - s.h / 2) / GRID) * GRID;
s = clampToValid(quantizeSrc(s), st.srcW, st.srcH);
st.cr = srcToCr(s, st.sf, st.scale);
if (!st.arLocked) st.cropAR = s.w / s.h;
fitCropInView(st, dom);
render(st, dom);
syncWidgets(st, widgets, node);
unapprove(st, dom, nodeId);
});
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;
st.batchMode = false;
st.hasPreset = false;
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.batchSingleBtn.style.display = "none";
dom.batchMultiBtn.style.display = "none";
dom.resetBtn.style.display = "none";
dom.batchSingleBtn.dataset.active = "false";
dom.batchSingleBtn.style.borderColor = "#444";
dom.batchSingleBtn.style.background = "#2a2a2a";
dom.batchSingleBtn.style.color = "#bbb";
dom.batchSingleBtn.textContent = "️ BATCH SINGLE IMAGE";
dom.batchMultiBtn.dataset.active = "false";
dom.batchMultiBtn.style.borderColor = "#444";
dom.batchMultiBtn.style.background = "#2a2a2a";
dom.batchMultiBtn.style.color = "#bbb";
dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE";
} catch (err) {
console.error("Reset failed:", err);
} finally {
resetBtn.disabled = false;
resetBtn.textContent = "🔄️ Reset";
}
});
for (const btn of chipBtns) {
btn.addEventListener("click", () => {
const targetAR = parseFloat(btn.dataset.chipAR);
setArLocked(st, dom, true);
st.cropAR = targetAR;
const { w: newW, h: newH } = calcSizeByRatio(st.srcW, st.srcH, targetAR);
const centerX = st.srcW / 2;
const centerY = st.srcH / 2;
let s = {
x: Math.round((centerX - newW / 2) / GRID) * GRID,
y: Math.round((centerY - newH / 2) / GRID) * GRID,
w: newW,
h: newH
};
s = clampToValid(quantizeSrc(s), st.srcW, st.srcH);
st.cr = srcToCr(s, st.sf, st.scale);
fitCropInView(st, dom);
render(st, dom);
syncWidgets(st, widgets, node);
unapprove(st, dom, nodeId);
});
}
for (const btn of snapBtns) {
btn.addEventListener("click", () => {
const mode = btn.dataset.snap;
let s = crToSrc(st.cr, st.sf, st.scale);
const { srcW, srcH } = st;
if (mode === "center") { s.x = Math.round((srcW - s.w) / 2 / GRID) * GRID; s.y = Math.round((srcH - s.h) / 2 / GRID) * GRID; }
else if (mode === "top") s.y = 0;
else if (mode === "bottom") s.y = Math.round((srcH - s.h) / GRID) * GRID;
else if (mode === "fitW") { s.w = srcW; if (st.arLocked) s.h = Math.round(s.w / st.cropAR / GRID) * GRID; s.x = 0; s.y = Math.round((srcH - s.h) / 2 / GRID) * GRID; }
else if (mode === "fitH") { s.h = srcH; if (st.arLocked) s.w = Math.round(s.h * st.cropAR / GRID) * GRID; s.y = 0; s.x = Math.round((srcW - s.w) / 2 / GRID) * GRID; }
else if (mode === "left") s.x = 0;
else if (mode === "right") s.x = Math.round((srcW - s.w) / GRID) * GRID;
s = clampToValid(quantizeSrc(s), st.srcW, st.srcH);
st.cr = srcToCr(s, st.sf, st.scale);
if (!st.arLocked) st.cropAR = s.w / s.h;
render(st, dom);
syncWidgets(st, widgets, node);
unapprove(st, dom, nodeId);
});
}
wrap.addEventListener("wheel", e => { e.preventDefault(); const rect = wrap.getBoundingClientRect(); const sx = e.clientX - rect.left, sy = e.clientY - rect.top; const factor = e.deltaY < 0 ? 1.12 : (1 / 1.12); const newZoom = clamp(st.view.zoom * factor, 0.15, 5.0); st.view.panX = sx - (sx - st.view.panX) * (newZoom / st.view.zoom); st.view.panY = sy - (sy - st.view.panY) * (newZoom / st.view.zoom); st.view.zoom = newZoom; render(st, dom); }, { passive: false });
dom.zoomIndicator.addEventListener("click", () => { st.view = { zoom: Infinity, panX: 0, panY: 0 }; fitCropInView(st, dom); render(st, dom); });
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); unapprove(st, dom, nodeId); });
wrap.addEventListener("pointercancel", () => { pan = null; drag = null; });
}
api.addEventListener("status", (e) => {
const remaining = e.detail?.exec_info?.queue_remaining;
if (remaining > 0) {
queueWasRunning = true;
if (batchResetTimer) { clearTimeout(batchResetTimer); batchResetTimer = null; }
} else if (remaining === 0) {
if (queueWasRunning && !batchResetTimer) {
batchResetTimer = setTimeout(() => {
allNodes.forEach(({ st, dom, widgets, node }) => {
const singleBatchActive = dom.batchSingleBtn.dataset.active === "true";
const multiBatchActive = dom.batchMultiBtn.dataset.active === "true";
if (singleBatchActive || multiBatchActive) {
st.batchMode = false;
st.hasPreset = false;
dom.batchSingleBtn.dataset.active = "false";
dom.batchSingleBtn.style.borderColor = "#444";
dom.batchSingleBtn.style.background = "#2a2a2a";
dom.batchSingleBtn.style.color = "#bbb";
dom.batchSingleBtn.textContent = "⚙️ BATCH SINGLE IMAGE";
dom.batchMultiBtn.dataset.active = "false";
dom.batchMultiBtn.style.borderColor = "#444";
dom.batchMultiBtn.style.background = "#2a2a2a";
dom.batchMultiBtn.style.color = "#bbb";
dom.batchMultiBtn.textContent = "✨ BATCH MULTI IMAGE";
fetch("/rs_outpaint/clear_all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(node.id) })
}).catch(err => console.error("Failed to clear all caches:", err));
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);
}
});
batchResetTimer = null;
queueWasRunning = false;
}, 2000);
}
}
});
app.registerExtension({
name: "RSOutpaint",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_CLASS) return;
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = origOnNodeCreated?.apply(this, arguments);
const node = this;
const widgets = { cropState: node.widgets?.find(w => w.name === "crop_state") };
if (widgets.cropState) { widgets.cropState.hidden = true; widgets.cropState.computeSize = () => [0, -4]; }
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();
const dom = buildUI();
wireColors(st, dom, widgets, node);
setUIActive(dom, false);
const domWidget = node.addDOMWidget("rs_outpaint_canvas", "custom", dom.root, { serialize: false, hideOnZoom: false });
domWidget.computeSize = () => [500, CANVAS_H + CTRL_H];
node.setSize([500, Math.max(node.size[1], CANVAS_H + CTRL_H + 10)]);
node.heartbeatInterval = null;
node.startHeartbeat = function () { if (this.heartbeatInterval) clearInterval(this.heartbeatInterval); this.heartbeatInterval = setInterval(async () => { try { await fetch("/rs_outpaint/heartbeat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(this.id) }) }); } catch (e) {} }, 3000); };
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, auto_approved, applied_crop_state } = data;
if (String(node_id) !== String(node.id)) return;
const isSameImage = st.initialized && st.srcHash === (data.src_hash || null);
if (isSameImage) {
const W = dom.wrap.clientWidth;
if (W > 0) {
const maxW = W - MARGIN * 2;
const maxH = dom.wrap.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((dom.wrap.clientHeight - sfH) / 2));
st.sf = { x: sfX, y: sfY, w: sfW, h: sfH };
st.scale = sfW / st.srcW;
}
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 (auto_approved && applied_crop_state) {
const parts = applied_crop_state.split(',').map(Number);
if (parts.length >= 4) {
let s = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
s = quantizeSrc(s);
s = clampToValid(s, st.srcW, st.srcH);
st.cr = srcToCr(s, st.sf, st.scale);
}
fitCropInView(st, dom);
render(st, dom);
dom.waitingMsg.style.display = "none";
dom.acceptBtn.style.display = "none";
dom.cancelBtn.style.display = "none";
dom.batchSingleBtn.style.display = "none";
dom.batchMultiBtn.style.display = "none";
} else {
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);
}
fitCropInView(st, dom);
render(st, dom);
};
img.src = tempUrl;
return;
}
resetNodeState(st, dom, widgets, node.id);
st.srcW = image_width;
st.srcH = image_height;
st.srcHash = data.src_hash || null;
if (initLayout(st, dom.wrap)) {
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 (auto_approved && applied_crop_state) {
const parts = applied_crop_state.split(',').map(Number);
if (parts.length >= 4) {
let s = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
s = quantizeSrc(s);
s = clampToValid(s, st.srcW, st.srcH);
st.cr = srcToCr(s, st.sf, st.scale);
}
fitCropInView(st, dom);
render(st, dom);
dom.waitingMsg.style.display = "none";
dom.acceptBtn.style.display = "none";
dom.cancelBtn.style.display = "none";
dom.batchSingleBtn.style.display = "none";
dom.batchMultiBtn.style.display = "none";
} else {
dom.waitingMsg.style.display = "flex";
dom.noDataMsg.style.display = "none";
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";
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;
let batchType = false;
if (dom.batchSingleBtn.dataset.active === "true") batchType = "single";
else if (dom.batchMultiBtn.dataset.active === "true") batchType = "multi";
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: batchType !== false,
batch_type: batchType,
src_hash: st.srcHash
})
});
if (resp.ok) {
node.stopHeartbeat();
st.approved = true;
if (batchType) {
st.hasPreset = true;
dom.waitingMsg.style.display = "none";
dom.acceptBtn.style.display = "none";
dom.cancelBtn.style.display = "none";
dom.batchSingleBtn.style.display = "none";
dom.batchMultiBtn.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.batchSingleBtn.style.display = "none";
dom.batchMultiBtn.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);
});
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); } }); });
node._outpaintCleanup = () => {
api.removeEventListener("rs_outpaint.show", onShow);
node.stopHeartbeat();
allNodes = allNodes.filter(n => n.node !== node);
fetch("/rs_outpaint/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }).catch(() => { });
};
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
st.batchMode = false;
st.hasPreset = false;
fetch("/rs_outpaint/clear_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: String(node.id) }) }).catch(() => { });
node._outpaintCleanup?.();
if (origOnRemoved) origOnRemoved.call(this);
};
return result;
};
},
});