/* * LoadImageCrop - visual crop for the official Load Image preview. * * Architecture: * - No static ES imports: the frontend serves /scripts/app.js as a shim that * reads window.comfyAPI at evaluation time, so it is imported dynamically * (absolute path) with a retry loop. * - The official image preview is the canvas-drawn custom widget * "$$canvas-image-preview". We wrap its drawWidget (called with * (ctx, {width, previewImages}) and reading widget.y / widget.computedHeight) * to: * 1. let the core draw the image (it defers the drawImage to a * queueMicrotask, so a synchronous overlay would be covered); * 2. queue our own microtask AFTER the core's, and draw the crop * overlay (dim outside + red border + output size) using the same * transform the core captured -> pixel-perfect alignment. * - Interaction: * * crop drag -> wraps widget.onPointerDown: the drag starts only when * the pointer is INSIDE the crop box (clicks elsewhere keep the core * canvas behavior); in Free mode a corner hit resizes the box instead * of moving it. The pointer session's onDrag plus the node-level * onMouseMove / onMouseUp track the drag. * * wheel zoom -> capture-phase "wheel" listener on document with a * manual hit-test (there is no node-level onWheel hook), so the * canvas pan/zoom is preempted only while the cursor is on the preview. * - The crop is stored as normalized floats in the (hidden) crop_x/y/w/h * widgets so it serializes into the workflow and is usable via API. * - "Original" aspect ratio (default): no crop area is drawn, drag/zoom are * disabled and the (hidden) crop widgets hold the full frame, so the image * passes through uncropped. * - "Free (Custom)" aspect ratio: the box can be any size. Drag a corner * handle to resize it freely (the opposite corner stays fixed), drag * inside to move it; the wheel zooms keeping the box's own ratio. * - 2.0 / Vue nodes mode (LiteGraph.vueNodesMode): the canvas preview * widget does not exist there - the image is a DOM (object-contain) * inside the node's [data-node-id] element. The overlay is a positioned * div on top of that image and pointer/wheel events are handled directly * on the DOM element (the node's DOM is pointer-events-none, so without * this the canvas pan/zoom would swallow the interaction). The same * normalized crop state and the same crop_* widgets are used, so the * behavior is identical in both modes. */ const LIRC = { NODE_NAME: "LoadImageCrop", PREVIEW_WIDGET: "$$canvas-image-preview", CROP_KEYS: ["crop_x", "crop_y", "crop_w", "crop_h"], FLIP_WIDGET: "flip", TAG: "[LoadImageCrop]" }; let lircApp = null; /* ------------------------------ helpers ------------------------------ */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function widgetOf(node, name) { return node.widgets ? node.widgets.find((w) => w.name === name) : null; } function getState(node) { if (!node.__lirc) { node.__lirc = { inited: false, crop: null, // {x,y,w,h} normalized 0..1 in image space cropFromWorkflow: false, rect: null, // cached displayed image rect, node-local {x,y,w,h} dragging: false, dragOff: null, // grab state: {nx, ny} pointer offset, {cx, cy, cw, ch} box at grab, // {corner} "nw"/"ne"/"sw"/"se" while resizing (Free mode) lastKey: null, lastSrc: null, // last displayed image src (file-change detection) originalImage: null, // the original image filename (before any flip) }; } return node.__lirc; } /** Current flip mode: "None", "Horizontal", or "Vertical". */ function flipOf(node) { const w = widgetOf(node, LIRC.FLIP_WIDGET); return w ? w.value : "None"; } /** * Flip the image via the backend endpoint and change the node's image input * to the flipped file. The core then reloads the node with the flipped image, * so the preview shows the flipped image and the crop works normally. */ async function applyFlip(node, flip) { const st = getState(node); const imgW = widgetOf(node, "image"); if (!imgW) return; // Track the original image (before any flip). if (!st.originalImage) st.originalImage = imgW.value; if (flip === "None") { // Revert to the original image. const origCb = imgW.__lircOrigCallback; imgW.value = st.originalImage; if (typeof origCb === "function") origCb(st.originalImage); markDirty(node); try { if (lircApp && lircApp.canvas) lircApp.canvas.setDirtyCanvas(true); if (lircApp && lircApp.graph) lircApp.graph.setDirtyCanvas(true); } catch (_) { /* ignore */ } return; } // Flip the image in the browser (canvas), upload via ComfyUI's // /upload/image endpoint, then change the image input to the flipped file. try { // Get the image already loaded by the core (node.imgs). const src = (node.imgs && node.imgs[0]) || null; if (!src) throw new Error("no loaded image (node.imgs empty)"); const img = await createImageBitmap(src); // Flip via canvas. const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; const cctx = canvas.getContext("2d"); if (!cctx) throw new Error("no 2d context"); if (flip === "Horizontal") { cctx.translate(canvas.width, 0); cctx.scale(-1, 1); } else { cctx.translate(0, canvas.height); cctx.scale(1, -1); } cctx.drawImage(img, 0, 0); // Convert to a PNG blob. const pngBlob = await new Promise((r) => canvas.toBlob(r, "image/png")); if (!pngBlob) throw new Error("toBlob failed"); // Build the destination filename: {stem}-flipH.png / {stem}-flipV.png const stem = st.originalImage.replace(/\.[^.]*$/, ""); const suffix = flip === "Horizontal" ? "flipH" : "flipV"; const destName = `${stem}-${suffix}.png`; // Upload via ComfyUI's /upload/image endpoint. const fd = new FormData(); fd.append("image", pngBlob, destName); fd.append("overwrite", "true"); const upResp = await fetch("/upload/image", { method: "POST", body: fd }); const upData = await upResp.json(); if (upData.name == null) throw new Error("upload failed"); // Change the image input to the flipped file and trigger the core's // original callback (the one that updates the preview). const origCb = imgW.__lircOrigCallback; imgW.value = upData.name; if (typeof origCb === "function") origCb(upData.name); markDirty(node); try { if (lircApp && lircApp.canvas) lircApp.canvas.setDirtyCanvas(true); if (lircApp && lircApp.graph) lircApp.graph.setDirtyCanvas(true); } catch (_) { /* ignore */ } } catch (e) { console.warn(LIRC.TAG, "flip failed:", e); } } /** Aspect-ratio mode: null ("Original" = no crop), "free" (user-shaped * box), or a positive number for the preset "W:H" entries. */ function ratioOf(node) { const w = widgetOf(node, "aspect_ratio"); const v = w ? String(w.value) : "Original"; // "Original" (or no ratio match) -> no crop at all if (/^\s*original\b/i.test(v)) return null; // "Free (Custom)" -> user-drawn box, any shape if (/^\s*free\b/i.test(v)) return "free"; // values look like "3:2 (Photo)" -> take the leading "W:H" const m = v.match(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/); if (m) { const r = parseFloat(m[1]) / parseFloat(m[2]); if (isFinite(r) && r > 0) return r; } return 1; } function imgOf(node) { const imgs = node.imgs; if (!Array.isArray(imgs) || !imgs.length) return null; const idx = Number.isInteger(node.imageIndex) ? node.imageIndex : 0; return imgs[idx] || imgs[0]; } function naturalSize(node) { const img = imgOf(node); if (!img) return null; return { w: img.naturalWidth || img.width, h: img.naturalHeight || img.height, src: img.src }; } /** Largest rect (normalized) with the given aspect ratio fitting the image. */ function maxFitRatio(ratio, imgW, imgH) { if (ratio <= imgW / imgH) return { w: (ratio * imgH) / imgW, h: 1 }; return { w: 1, h: imgW / (ratio * imgH) }; } /** The default Free box: a centered 90x90% area. With a full-frame box the * corner handles would sit exactly on the image corners, i.e. on the node * border, where the core canvas grabs the pointer for a node drag instead of * letting us start the resize. Dragging a handle to the edge still reaches * the exact full-frame crop (the clamping allows it). */ function freeCrop() { return { x: 0.05, y: 0.05, w: 0.9, h: 0.9 }; } function resetCrop(node) { const st = getState(node); const m = ratioOf(node); if (m == null) { // "Original": no crop st.crop = null; st.cropFromWorkflow = false; return; } const n = naturalSize(node); if (!n) return; if (m === "free") { // Free keeps the current box (e.g. left over from a preset) or starts // with the default Free box; the user then shapes it with the handles. st.crop = st.crop || freeCrop(); fitCrop(node, st.crop); st.cropFromWorkflow = false; return; } const fr = maxFitRatio(m, n.w, n.h); st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h }; st.cropFromWorkflow = false; } /** Clamp crop to image bounds. Presets keep the ratio (height-driven); * "free" keeps the drawn shape (min size + bounds only). */ function fitCrop(node, c) { const n = naturalSize(node); if (!n) return c; const ratio = ratioOf(node); if (ratio === "free") { c.w = Math.min(Math.max(c.w, 0.02), 1); c.h = Math.min(Math.max(c.h, 0.02), 1); c.x = Math.min(Math.max(0, c.x), Math.max(0, 1 - c.w)); c.y = Math.min(Math.max(0, c.y), Math.max(0, 1 - c.h)); return c; } // normalized image space: w/h = ratio * (imgH/imgW) const k = ratio * (n.h / n.w); const fr = maxFitRatio(ratio, n.w, n.h); c.h = Math.min(Math.max(c.h, 0.02), fr.h); c.w = c.h * k; c.x = Math.min(Math.max(0, c.x), Math.max(0, 1 - c.w)); c.y = Math.min(Math.max(0, c.y), Math.max(0, 1 - c.h)); return c; } function initCropFromWidgets(node) { const st = getState(node); const m = ratioOf(node); if (m == null) { // "Original": ignore any saved crop, always full frame st.crop = null; writeCrop(node); return; } if (st.crop) return; const g = (k) => { const w = widgetOf(node, k); return w && Number.isFinite(w.value) ? w.value : undefined; }; let x = g("crop_x"), y = g("crop_y"), w = g("crop_w"), h = g("crop_h"); if (x == null || y == null || w == null || h == null) { resetCrop(node); writeCrop(node); } else if (m !== "free" && x === 0 && y === 0 && w === 1 && h === 1) { // Python defaults == "no crop saved yet" -> derive from the ratio // (a full-frame Free box is a legitimate saved shape, keep it) resetCrop(node); writeCrop(node); } else { st.crop = { x, y, w, h }; st.cropFromWorkflow = true; fitCrop(node, st.crop); } } function writeCrop(node) { const st = getState(node); // no crop yet: full frame for "Original"/presets, default Free box for Free const c = st.crop || (ratioOf(node) === "free" ? freeCrop() : { x: 0, y: 0, w: 1, h: 1 }); // "Original" -> write the full frame so a run without frontend interaction // returns the image as-is const map = { crop_x: "x", crop_y: "y", crop_w: "w", crop_h: "h" }; for (const [key, prop] of Object.entries(map)) { const w = widgetOf(node, key); if (w) w.value = c[prop]; } } function markDirty(node) { try { if (node.graph && typeof node.graph.setDirtyCanvas === "function") { node.graph.setDirtyCanvas(true); } } catch (_) { /* ignore */ } } /** Is the node-local point p inside the displayed crop box (rect + crop)? */ function insideCropBox(p, st) { const r = st.rect, c = st.crop; if (!r || !c) return false; const nx = (p[0] - r.x) / r.w; const ny = (p[1] - r.y) / r.h; return nx >= c.x && nx <= c.x + c.w && ny >= c.y && ny <= c.y + c.h; } /** Free mode: the crop-box corner the node-local point p is near (within a * screen-pixel threshold, so it works at any zoom), or null. */ function cornerAt(p, node) { const st = node.__lirc; const nat = naturalSize(node); const r = st && st.rect, c = st && st.crop; if (!r || !c || !nat) return null; // screen px -> node-local units through the canvas zoom const z = (lircApp && lircApp.canvas && lircApp.canvas.ds && lircApp.canvas.ds.scale) || 1; const th = 18 / z; // 18 screen px, in node-local units const cx = r.x + c.x * r.w, cy = r.y + c.y * r.h; const cw = c.w * r.w, ch = c.h * r.h; const pts = { nw: [cx, cy], ne: [cx + cw, cy], sw: [cx, cy + ch], se: [cx + cw, cy + ch] }; let best = th, hit = null; for (const k of Object.keys(pts)) { const d = Math.hypot(p[0] - pts[k][0], p[1] - pts[k][1]); if (d < best) { best = d; hit = k; } } return hit; } function pointInRect(p, r) { return p[0] >= r.x && p[0] <= r.x + r.w && p[1] >= r.y && p[1] <= r.y + r.h; } function nodeLocalPos(node, canvas) { // graph_mouse is in the same coordinate space as node.pos (the core // compares graph_mouse against node.pos + offsets) const gm = canvas && canvas.graph_mouse; if (!gm) return null; return [gm[0] - node.pos[0], gm[1] - node.pos[1]]; } /** * Node-local position of a DOM pointer/mouse event. Uses clientX/clientY * (no assumptions about which element received the event), converted with * the canvas' viewport math. Returns [x, y] in node-local canvas units. */ function eventToNodeLocal(e, node, canvas) { if (!e || !canvas || typeof canvas.clientPosToCanvasPos !== "function") { return null; } try { const p = canvas.clientPosToCanvasPos([e.clientX, e.clientY]); if (!p) return null; return [p[0] - node.pos[0], p[1] - node.pos[1]]; } catch (_) { return null; } } /* --------------------------- overlay drawing -------------------------- */ /** * Called from the wrapped widget.drawWidget, AFTER the core's renderPreview * ran. Mirrors the core's single-image fit math exactly: * dw = options.width, dh = computedHeight - d, d = 15 when the * "Comfy.Node.AllowImageSizeDraw" setting is on (default), 0 otherwise. * scale = min(dw/nw, dh/nh, 1); x = (dw-w)/2; y = (dh-h)/2 + widget.y */ function sizeDrawOffset() { try { // The setting store is not exposed globally; default is true (15). // If a future build exposes it, honor it. const st = window.comfyAPI?.settingStore; if (st && typeof st.get === "function") { return st.get("Comfy.Node.AllowImageSizeDraw") === false ? 0 : 15; } } catch (_) { /* ignore */ } return 15; } function scheduleOverlay(node, ctx, widget, width, imgsArg) { const st = getState(node); const imgs = imgsArg || node.imgs; if (!Array.isArray(imgs) || !imgs.length || !node.size) { st.rect = null; return; } let idx = node.imageIndex; if (imgs.length === 1 && !idx) idx = 0; const img = imgs[idx]; if (!img) { st.rect = null; return; } // Keep the node visible to the core isImageNode() gate so the // "Open in MaskEditor | Image Canvas" context menu entry stays available. node.previewMediaType = "image"; const nw = img.naturalWidth || img.width; const nh = img.naturalHeight || img.height; if (!nw || !nh) { st.rect = null; return; } // grid mode (multiple images, no selection) -> the core draws a grid we // don't model; skip the overlay until the user picks one cell if (node.imageIndex == null) { st.rect = null; return; } const dw = width; const dh = (widget.computedHeight || 0) - sizeDrawOffset(); if (!(dw > 0) || !(dh > 0)) { st.rect = null; return; } const scale = Math.min(dw / nw, dh / nh, 1); const w = nw * scale; const h = nh * scale; const x = (dw - w) / 2; const y = (dh - h) / 2 + (widget.y || 0); st.rect = { x, y, w, h }; // If the displayed image or the ratio changed -> (re)initialize the crop. const key = `${img.src}|${ratioOf(node)}`; if (st.lastKey !== key) { st.lastKey = key; // a Free crop starts full-frame on a new image if (ratioOf(node) === "free" && st.lastSrc !== img.src) st.crop = null; st.lastSrc = img.src; if (!st.cropFromWorkflow) { resetCrop(node); // write the crop into the (hidden) widgets so a run without mouse // interaction crops exactly what the overlay shows writeCrop(node); } else { if (st.crop) { fitCrop(node, st.crop); } else { resetCrop(node); } writeCrop(node); } st.cropFromWorkflow = false; } const transform = ctx.getTransform(); const filter = ctx.filter; // Queued after the core's deferred drawImage microtask (FIFO) -> the // overlay is painted over the image, before the browser paints the frame. queueMicrotask(() => drawOverlay(node, ctx, transform, filter)); } function drawOverlay(node, ctx, transform, filter) { const st = node.__lirc; if (!st || !st.rect || !st.crop) return; const { x: px, y: py, w: pw, h: ph } = st.rect; const c = st.crop; const rx = px + c.x * pw; const ry = py + c.y * ph; const rw = c.w * pw; const rh = c.h * ph; try { ctx.save(); ctx.setTransform(transform); ctx.filter = filter; // dim everything outside the crop, clipped to the image rect ctx.save(); ctx.beginPath(); ctx.rect(px, py, pw, ph); ctx.clip(); ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(px, py, pw, ry - py); ctx.fillRect(px, ry + rh, pw, py + ph - ry - rh); ctx.fillRect(px, ry, rx - px, rh); ctx.fillRect(rx + rw, ry, px + pw - rx - rw, rh); ctx.restore(); // crop border ctx.strokeStyle = "#ff4040"; ctx.lineWidth = 2; ctx.strokeRect(rx + 1, ry + 1, Math.max(2, rw - 2), Math.max(2, rh - 2)); // Free-mode corner handles (white squares on the box corners) if (ratioOf(node) === "free") { const hs = 7; const corners = [ [rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh] ]; for (const [hx, hy] of corners) { ctx.fillStyle = "#ffffff"; ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs); ctx.strokeRect(hx - hs / 2, hy - hs / 2, hs, hs); } } // output size label const img = imgOf(node); if (img) { const ow = Math.round(c.w * (img.naturalWidth || img.width)); const oh = Math.round(c.h * (img.naturalHeight || img.height)); ctx.font = "11px monospace"; ctx.textAlign = "left"; ctx.textBaseline = "alphabetic"; const label = `${ow} × ${oh}`; let ly = ry - 6; if (ly < py + 12) ly = ry + 14; ctx.fillStyle = "rgba(0,0,0,0.8)"; const tw = ctx.measureText(label).width; ctx.fillRect(rx + 4, ly - 11, tw + 8, 14); ctx.fillStyle = "#ffffff"; ctx.fillText(label, rx + 8, ly); } ctx.restore(); } catch (e) { console.warn(LIRC.TAG, "overlay draw failed", e); } } /* --------------------------- crop drag -------------------------------- */ /** Shared drag-end: finalize the crop (also called from the pointer session). */ function endCropDrag(node) { const s = getState(node); if (s.dragging) { s.dragging = false; s.dragOff = null; if (s.crop) { fitCrop(node, s.crop); writeCrop(node); } markDirty(node); } try { const c = lircApp && lircApp.canvas && lircApp.canvas.canvas; if (c) c.style.cursor = ""; } catch (_) { /* ignore */ } } /** Apply one drag step: move the box (grab point follows the pointer), or * resize it from a corner when corner is set (the opposite corner stays * fixed). p is the pointer in normalized image space. */ function stepCropDrag(node, off, p) { const s = getState(node); if (!s.crop) return; if (off.corner) { const fx = off.corner === "nw" || off.corner === "sw" ? off.cx + off.cw : off.cx; const fy = off.corner === "nw" || off.corner === "ne" ? off.cy + off.ch : off.cy; s.crop.x = Math.min(fx, p[0]); s.crop.y = Math.min(fy, p[1]); s.crop.w = Math.abs(p[0] - fx); s.crop.h = Math.abs(p[1] - fy); } else { s.crop.x = p[0] - off.nx + off.cx; s.crop.y = p[1] - off.ny + off.cy; } fitCrop(node, s.crop); writeCrop(node); markDirty(node); } /** Move (or resize, in Free mode) the crop toward the DOM pointer event. */ function dragCropTo(e, node, canvas) { const s = getState(node); if (!s.dragging || !s.rect || !s.crop || !s.dragOff) return; const p = eventToNodeLocal(e, node, canvas) || nodeLocalPos(node, canvas); if (!p) return; const r = s.rect; stepCropDrag(node, s.dragOff, [(p[0] - r.x) / r.w, (p[1] - r.y) / r.h]); } /* --------------------------- widget wrapping -------------------------- */ function wrapPreviewWidget(node) { const w = widgetOf(node, LIRC.PREVIEW_WIDGET); if (!w || w.__lircWrapped) return; w.__lircWrapped = true; const origDraw = w.drawWidget ? w.drawWidget.bind(w) : null; w.drawWidget = (ctx, options) => { try { if (origDraw) origDraw(ctx, options); } catch (e) { console.warn(LIRC.TAG, "core preview draw", e); } try { scheduleOverlay(node, ctx, w, options && options.width, options && options.previewImages); } catch (e) { console.warn(LIRC.TAG, "overlay schedule", e); } }; const origPointerDown = w.onPointerDown ? w.onPointerDown.bind(w) : null; w.onPointerDown = (pointer, nodeArg, canvas) => { const st = getState(node); // Prefer the DOM event (clientX/Y -> viewport math): the mini-canvas // pointer path in the Vue frontend overwrites graph_mouse with // widget-row-relative offsets, so graph_mouse is not reliable here. const ev = pointer && pointer.eDown; const p = (ev ? eventToNodeLocal(ev, node, canvas) : null) || nodeLocalPos(node, canvas); // Free mode: a hit on a box corner resizes the box (opposite corner // fixed); elsewhere inside the box it moves as before const corner = st.rect && st.crop && p && ratioOf(node) === "free" ? cornerAt(p, node) : null; if (st.rect && st.crop && p && (corner || insideCropBox(p, st))) { // consume: crop drag instead of node drag; remember where (in // normalized image space) the grab happened so the rectangle follows // the pointer without jumping const nx = (p[0] - st.rect.x) / st.rect.w; const ny = (p[1] - st.rect.y) / st.rect.h; st.dragging = true; st.dragOff = { nx, ny, cx: st.crop.x, cy: st.crop.y, cw: st.crop.w, ch: st.crop.h, corner }; // Drive the drag from the pointer session itself. In the Vue frontend // the widget's mini-canvas captures the pointer and stops propagation, // so the node-level onMouseDown/onMouseMove are never fired there; // the session's onDrag is the only reliable path (legacy mode keeps // node.onMouseMove as a fallback, same absolute math). if (pointer) { pointer.onDragStart = () => {}; pointer.onDrag = (e) => dragCropTo(e, node, canvas); pointer.onDragEnd = () => endCropDrag(node); } try { const cur = corner ? (corner === "nw" || corner === "se" ? "nwse-resize" : "nesw-resize") : "move"; if (canvas && canvas.canvas) canvas.canvas.style.cursor = cur; } catch (_) { /* ignore */ } return true; } // outside the crop box -> keep the core canvas behavior (node // select/move), same as the official Load Image if (origPointerDown) return origPointerDown(pointer, nodeArg, canvas); return false; }; } /* --------------------------- node init/hooking ------------------------ */ function initNode(node) { try { if (!node || node.type !== LIRC.NODE_NAME) return; const st = getState(node); // remove unlinked FLOAT slots: this node has no linkable inputs const inputs = node.inputs; if (Array.isArray(inputs)) { for (let i = inputs.length - 1; i >= 0; i--) { const slot = inputs[i]; if (slot && slot.type === "FLOAT" && slot.link == null) { try { if (typeof node.removeInput === "function") node.removeInput(slot); } catch (_) { /* ignore */ } if (Array.isArray(node.inputs)) { const j = node.inputs.indexOf(slot); if (j !== -1) node.inputs.splice(j, 1); } } } } if (!st.inited) { st.inited = true; // hide the crop_* fields on the node (they still serialize); the 2.0 // renderer filters on options.hidden, classic litegraph on hidden for (const key of LIRC.CROP_KEYS) { const w = widgetOf(node, key); if (!w) continue; w.hidden = true; if (!w.options || typeof w.options !== "object") w.options = {}; w.options.hidden = true; } // keep the aspect_ratio widget's change in sync with the crop const ar = widgetOf(node, "aspect_ratio"); if (ar && !ar.__lircBound) { ar.__lircBound = true; const prev = ar.callback; ar.callback = (v) => { const n = naturalSize(node); const r = ratioOf(node); if (r == null) { // "Original": drop the crop area entirely st.crop = null; } else if (r === "free") { // Free keeps the box the user has drawn (or the default Free box) if (n) { st.crop = st.crop || freeCrop(); fitCrop(node, st.crop); } } else if (n) { const fr = maxFitRatio(r, n.w, n.h); st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h }; } st.cropFromWorkflow = false; writeCrop(node); if (typeof prev === "function") prev(v); if (isVueMode()) layoutVueOverlay(node); markDirty(node); }; } // image change -> refresh combo values are handled by the core; we just // re-sync the crop when the displayed image changes (done in overlay). const imgW = widgetOf(node, "image"); if (imgW && !imgW.__lircBound) { imgW.__lircBound = true; const prev = imgW.callback; imgW.__lircOrigCallback = prev; imgW.callback = (v) => { if (typeof prev === "function") prev(v); // Update originalImage when the user picks a non-flipped image // (i.e. the value does not match our flip filename pattern). const st = getState(node); if (!/-flip[HV]\.(png|jpg|jpeg|webp)$/i.test(String(v))) { st.originalImage = v; } // let the core load the image; crop resets when the overlay sees a // new image key (handled in scheduleOverlay) }; } // flip widget: when it changes, flip the image via the backend and // change the image input to the flipped file. const flipW = widgetOf(node, LIRC.FLIP_WIDGET); if (flipW && !flipW.__lircBound) { flipW.__lircBound = true; const prev = flipW.callback; flipW.callback = (v) => { if (typeof prev === "function") prev(v); applyFlip(node, v); }; } // mouse tracking (node-level; fallback for non-Vue frontends plus // cursor feedback whenever the mouse is over the node) node.onMouseMove = (e, pos) => { const s = getState(node); if (s.dragging && s.rect && s.crop && s.dragOff) { const r = s.rect; // pointer in normalized image space stepCropDrag(node, s.dragOff, [(pos[0] - r.x) / r.w, (pos[1] - r.y) / r.h]); } else if (s.rect) { let cur = ""; if (s.crop) { const corner = ratioOf(node) === "free" ? cornerAt(pos, node) : null; if (corner) { cur = corner === "nw" || corner === "se" ? "nwse-resize" : "nesw-resize"; } else if (insideCropBox(pos, s)) { cur = "move"; } } try { const c = lircApp && lircApp.canvas && lircApp.canvas.canvas; if (c) c.style.cursor = cur; } catch (_) { /* ignore */ } } }; node.onMouseUp = () => endCropDrag(node); node.onMouseLeave = () => endCropDrag(node); // the core adds the preview widget asynchronously (when the image has // loaded), so re-check every frame and wrap it lazily const prevDrawFg = node.onDrawForeground; node.onDrawForeground = (ctx, canvas, canvasEl) => { try { wrapPreviewWidget(node); } catch (_) { /* ignore */ } if (typeof prevDrawFg === "function") prevDrawFg.call(node, ctx, canvas, canvasEl); }; // context menu: the 2.0 menu already contains the core "Paste Image" // entry, so ours is added only in classic mode; both entries drive // the native node.pasteFile / node.pasteFiles methods. // Chain into the core implementation so its entries (Open Image, // Save Image, Bypass, Clipspace, Open in MaskEditor | Image Canvas, // ...) are preserved on this node. The core mutates `options` (the // base list) in place, so we return ONLY our own additions — the // canvas already owns the base list. Concatenating it here would // duplicate every base entry on builds whose core returns the // mutated list (e.g. ComfyUI nightly/dev). const coreExtraMenu = node.getExtraMenuOptions; node.getExtraMenuOptions = (canvas, options) => { const items = []; if (!isVueMode()) { items.push({ content: "Paste Image from Clipboard", callback: () => pasteFromClipboard(node) }); } if (typeof coreExtraMenu === "function") { try { coreExtraMenu.call(node, canvas, options || []); } catch (_) { /* ignore */ } } return items; }; initCropFromWidgets(node); } wrapPreviewWidget(node); } catch (e) { console.warn(LIRC.TAG, "initNode", e); } } /* --------------------- 2.0 / Vue nodes mode (DOM) -------------------- */ function isVueMode() { try { return typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode === true; } catch (_) { return false; } } /** Locate the 2.0 preview DOM: panel + inside the node element. */ function vuePartsOf(node) { const root = document.querySelector(`[data-node-id="${node.id}"]`); if (!root) return null; const img = root.querySelector('div[class*="image-preview"] img'); if (!img) return null; const panel = img.closest('div[class*="group/panel"]') || img.parentElement; return { img, panel }; } function removeVueOverlay(node) { const st = node.__lirc; if (st && st.vue && st.vue.ov && st.vue.ov.parentElement) { st.vue.ov.parentElement.removeChild(st.vue.ov); } if (st && st.vue && st.vue.ro) { try { st.vue.ro.disconnect(); } catch (_) { /* ignore */ } } if (st) st.vue = null; } /** * Re-sync the DOM overlay: (re)create it when the panel or the changed * under us (Vue re-renders replace DOM elements), re-init the crop on image * or ratio change, and position the crop box over the object-contain area. */ function layoutVueOverlay(node) { const st = getState(node); const parts = vuePartsOf(node); if (!parts || !parts.img.complete || !parts.img.naturalWidth) return; // Keep the node visible to the core image gates (isImageNode, mask // editor, open/copy image) which read node.imgs / previewMediaType. if (!Array.isArray(node.imgs) || !node.imgs.length) { node.imgs = [parts.img]; } node.previewMediaType = "image"; const vw = parts.img.naturalWidth; const vh = parts.img.naturalHeight; const key = `${parts.img.src}|${ratioOf(node)}|${vw}x${vh}`; if (st.lastKey !== key) { st.lastKey = key; const r = ratioOf(node); if (r == null) { st.crop = null; } else if (r === "free") { // Free: the default Free box on a new image, keep the drawn box on a // ratio-only change; clamp to bounds + min size if (st.lastSrc !== parts.img.src || !st.crop) st.crop = freeCrop(); st.crop.w = Math.min(Math.max(st.crop.w, 0.02), 1); st.crop.h = Math.min(Math.max(st.crop.h, 0.02), 1); st.crop.x = Math.min(Math.max(0, st.crop.x), Math.max(0, 1 - st.crop.w)); st.crop.y = Math.min(Math.max(0, st.crop.y), Math.max(0, 1 - st.crop.h)); } else if (!st.cropFromWorkflow) { const fr = maxFitRatio(r, vw, vh); st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h }; } else if (st.crop) { // same clamp as the classic path (fitCrop): keep the saved size and // position, clamped to the image bounds const k = r * (vh / vw); const fr = maxFitRatio(r, vw, vh); const h = Math.min(Math.max(st.crop.h, 0.02), fr.h); const w = h * k; st.crop = { x: Math.min(Math.max(0, st.crop.x), Math.max(0, 1 - w)), y: Math.min(Math.max(0, st.crop.y), Math.max(0, 1 - h)), w, h, }; } else { // no saved box (e.g. the ratio was Original before): derive the default const fr = maxFitRatio(r, vw, vh); st.crop = { x: (1 - fr.w) / 2, y: (1 - fr.h) / 2, w: fr.w, h: fr.h }; } st.lastSrc = parts.img.src; st.cropFromWorkflow = false; writeCrop(node); } const same = st.vue && st.vue.panel === parts.panel && st.vue.img === parts.img; if (!same) buildVueOverlay(node, parts); if (!st.vue) return; const ov = st.vue.ov; const box = st.vue.box; const label = st.vue.label; const c = st.crop; if (!c) { ov.style.display = "none"; return; } ov.style.display = ""; // displayed image area inside the panel (object-contain) const pw = parts.panel.clientWidth || 1; const ph = parts.panel.clientHeight || 1; const s = Math.min(pw / vw, ph / vh); const dw = vw * s; const dh = vh * s; const ox = (pw - dw) / 2; const oy = (ph - dh) / 2; const l = ((ox + c.x * dw) / pw) * 100; const t = ((oy + c.y * dh) / ph) * 100; const ww = (c.w * dw) / pw * 100; const hh = (c.h * dh) / ph * 100; box.style.left = l + "%"; box.style.top = t + "%"; box.style.width = ww + "%"; box.style.height = hh + "%"; const ow = Math.round(c.w * vw); const oh = Math.round(c.h * vh); label.textContent = `${ow} × ${oh}`; // room for the label inside the box? label.style.display = box.offsetHeight > 18 ? "" : "none"; // corner handles are only useful in Free mode (presets keep their ratio) const hs = st.vue.handles; if (hs) for (const h of hs) h.style.display = ratioOf(node) === "free" ? "" : "none"; } function buildVueOverlay(node, parts) { const st = getState(node); if (st.vue) removeVueOverlay(node); const ov = document.createElement("div"); ov.dataset.lircOverlay = "1"; ov.style.cssText = "position:absolute;inset:0;z-index:1;touch-action:none;cursor:default;"; const box = document.createElement("div"); box.style.cssText = "position:absolute;box-sizing:border-box;border:2px solid #ff4040;box-shadow:0 0 0 4000px rgba(0,0,0,0.55);"; const label = document.createElement("div"); label.style.cssText = "position:absolute;left:4px;top:2px;font:11px monospace;color:#fff;background:rgba(0,0,0,0.8);padding:0 4px;white-space:nowrap;"; box.appendChild(label); // Free-mode corner handles (shown only while aspect_ratio is Free): // dragging one resizes the box freely, the opposite corner stays fixed const handles = []; for (const corner of ["nw", "ne", "sw", "se"]) { const h = document.createElement("div"); h.style.cssText = "position:absolute;box-sizing:border-box;width:12px;height:12px;" + "background:#fff;border:1px solid #ff4040;display:none;" + (corner.startsWith("n") ? "top:-6px;" : "bottom:-6px;") + (corner.endsWith("w") ? "left:-6px;" : "right:-6px;"); box.appendChild(h); handles.push(h); } ov.appendChild(box); parts.panel.appendChild(ov); // pointer in normalized image space, mapped the same way as the overlay // box (object-contain inside the panel). Using the element rect // directly drifts when the element doesn't exactly cover the contained // image area (the dragged corner then lags the pointer). const imgPos = (e) => { const pr = parts.panel.getBoundingClientRect(); const vw = parts.img.naturalWidth || 1; const vh = parts.img.naturalHeight || 1; const pw = pr.width || 1; const ph = pr.height || 1; const s = Math.min(pw / vw, ph / vh); const dw = vw * s, dh = vh * s; const ox = (pw - dw) / 2, oy = (ph - dh) / 2; return { nx: (e.clientX - pr.left - ox) / (dw || 1), ny: (e.clientY - pr.top - oy) / (dh || 1) }; }; // displayed image width in screen px (for screen-pixel paddings) const imgScreenW = () => { const pr = parts.panel.getBoundingClientRect(); const s = Math.min((pr.width || 1) / (parts.img.naturalWidth || 1), (pr.height || 1) / (parts.img.naturalHeight || 1)); return (parts.img.naturalWidth || 1) * s; }; // inside the crop box; in Free mode the test is padded (screen px) so // the corner handles stay grabbable from outside the box const inBox = (nx, ny, c) => { if (!c) return false; // Free: pad the hit area by a few screen px so the corner handles stay // grabbable from outside the box const m = ratioOf(node) === "free" ? 8 / (imgScreenW() || 1) : 0; return nx >= c.x - m && nx <= c.x + c.w + m && ny >= c.y - m && ny <= c.y + c.h + m; }; // the crop-box corner near the client point (Free mode), or null const cornerAtClient = (cx, cy) => { const br = box.getBoundingClientRect(); const pts = { nw: [br.left, br.top], ne: [br.right, br.top], sw: [br.left, br.bottom], se: [br.right, br.bottom] }; let best = 18, hit = null; for (const k of Object.keys(pts)) { const d = Math.hypot(cx - pts[k][0], cy - pts[k][1]); if (d < best) { best = d; hit = k; } } return hit; }; let drag = null; ov.addEventListener("pointerdown", (e) => { const s = getState(node); const { nx, ny } = imgPos(e); if (!s.crop || !inBox(nx, ny, s.crop)) return; // consume only inside the crop box: outside, let the event reach the // panel (2.0 opens the mask editor on preview click) e.preventDefault(); e.stopPropagation(); // Free mode: a hit on a box corner resizes it (opposite corner fixed) const corner = ratioOf(node) === "free" ? cornerAtClient(e.clientX, e.clientY) : null; drag = { nx, ny, cx: s.crop.x, cy: s.crop.y, cw: s.crop.w, ch: s.crop.h, corner }; s.dragging = true; try { ov.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ } ov.style.cursor = corner ? (corner === "nw" || corner === "se" ? "nwse-resize" : "nesw-resize") : "move"; }); ov.addEventListener("pointermove", (e) => { const s = getState(node); const { nx, ny } = imgPos(e); if (drag && s.crop) { if (drag.corner) { // resize: the opposite corner (from the box at drag start) stays // fixed, the box grows/shrinks toward the pointer const fx = drag.corner === "nw" || drag.corner === "sw" ? drag.cx + drag.cw : drag.cx; const fy = drag.corner === "nw" || drag.corner === "ne" ? drag.cy + drag.ch : drag.cy; s.crop.x = Math.min(fx, nx); s.crop.y = Math.min(fy, ny); s.crop.w = Math.abs(nx - fx); s.crop.h = Math.abs(ny - fy); } else { s.crop.x = nx - drag.nx + drag.cx; s.crop.y = ny - drag.ny + drag.cy; } fitCrop(node, s.crop); writeCrop(node); layoutVueOverlay(node); } else { let cur = "default"; if (inBox(nx, ny, s.crop)) { cur = "move"; if (ratioOf(node) === "free" && s.crop) { const cn = cornerAtClient(e.clientX, e.clientY); if (cn) cur = cn === "nw" || cn === "se" ? "nwse-resize" : "nesw-resize"; } } ov.style.cursor = cur; } }); const endDrag = (e) => { if (!drag) return; drag = null; const s = getState(node); s.dragging = false; if (s.crop) { fitCrop(node, s.crop); writeCrop(node); } try { ov.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ } ov.style.cursor = "default"; }; ov.addEventListener("pointerup", endDrag); ov.addEventListener("pointercancel", endDrag); ov.addEventListener("wheel", (e) => { const s = getState(node); if (!s.crop) return; e.preventDefault(); e.stopPropagation(); const n = { w: parts.img.naturalWidth, h: parts.img.naturalHeight }; const r = ratioOf(node); if (r == null) return; // Free: zoom keeps the box's own (drawn) ratio const rr = r === "free" ? (s.crop.w * n.w) / (s.crop.h * n.h) : r; const fr = maxFitRatio(rr, n.w, n.h); const c = s.crop; const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08; let newH = c.h * factor; if (newH > fr.h) newH = fr.h; if (newH < 0.02) newH = 0.02; if (Math.abs(newH - c.h) <= 1e-6) return; const k = rr * (n.h / n.w); const newW = newH * k; const { nx, ny } = imgPos(e); const cx = nx - c.x; const cy = ny - c.y; c.x = c.x + (c.w - newW) * cx; c.y = c.y + (c.h - newH) * cy; c.w = newW; c.h = newH; fitCrop(node, c); writeCrop(node); layoutVueOverlay(node); }, { passive: false }); let ro = null; try { ro = new ResizeObserver(() => layoutVueOverlay(node)); ro.observe(parts.panel); } catch (_) { /* ResizeObserver optional */ } st.vue = { ov, box, label, handles, panel: parts.panel, img: parts.img, ro }; layoutVueOverlay(node); } /** * 2.0 has no canvas redraw we can hook into, so a light timer keeps the * overlays in sync with the DOM (image load, ratio change, Vue re-render). */ let vuePollerStarted = false; function startVuePoller(app) { if (vuePollerStarted) return; vuePollerStarted = true; setInterval(() => { try { if (!app || !app.graph) return; const vue = isVueMode(); for (const n of app.graph.nodes || []) { if (!n || n.type !== LIRC.NODE_NAME || !n.__lirc || !n.__lirc.inited) continue; if (vue) { layoutVueOverlay(n); } else if (n.__lirc.vue) { // the 2.0 setting was switched off at runtime: drop the DOM // overlay so the classic canvas path takes over again removeVueOverlay(n); } } } catch (_) { /* ignore */ } }, 250); } /* --------------------------- wheel zoom ------------------------------- */ let wheelInstalled = false; function installWheelInterceptor(app) { if (wheelInstalled) return; wheelInstalled = true; document.addEventListener( "wheel", (e) => { try { if (!app || !app.graph || !app.canvas) return; // find a LoadImageCrop node whose preview contains the pointer const pos = app.clientPosToCanvasPos ? app.clientPosToCanvasPos([e.clientX, e.clientY]) : null; if (!pos) return; const nodes = app.graph.nodes || []; for (const n of nodes) { if (n.type !== LIRC.NODE_NAME || !n.__lirc) continue; const st = n.__lirc; // 2.0: the wheel may never reach the DOM overlay (the Vue layer // forwards it to the canvas), so the zoom is handled here, at // document capture time, before anything else sees the event. if (isVueMode()) { if (!st.vue || !st.vue.ov) continue; const boxEl = st.vue.box; const imgEl = st.vue.img; if (!boxEl || !imgEl) continue; const br = boxEl.getBoundingClientRect(); const overBox = e.clientX >= br.left && e.clientX <= br.right && e.clientY >= br.top && e.clientY <= br.bottom; if (!overBox) continue; // outside the crop: normal canvas behavior if (st.crop) { e.preventDefault(); e.stopPropagation(); // same object-contain mapping as the overlay layout (the // element rect can drift from the contained image area) const pr = (st.vue.panel || imgEl.parentElement).getBoundingClientRect(); const nat = { w: imgEl.naturalWidth, h: imgEl.naturalHeight }; const r = ratioOf(n); if (r == null) return; // Free: zoom keeps the box's own (drawn) ratio const rr = r === "free" ? (st.crop.w * nat.w) / (st.crop.h * nat.h) : r; const fr = maxFitRatio(rr, nat.w, nat.h); const c = st.crop; const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08; let newH = c.h * factor; if (newH > fr.h) newH = fr.h; if (newH < 0.02) newH = 0.02; if (Math.abs(newH - c.h) > 1e-6) { const k = rr * (nat.h / nat.w); const newW = newH * k; const zs = Math.min((pr.width || 1) / nat.w, (pr.height || 1) / nat.h); const dw = nat.w * zs, dh = nat.h * zs; const ox = (pr.width - dw) / 2, oy = (pr.height - dh) / 2; const nx = (e.clientX - pr.left - ox) / (dw || 1); const ny = (e.clientY - pr.top - oy) / (dh || 1); c.x = c.x + (c.w - newW) * (nx - c.x); c.y = c.y + (c.h - newH) * (ny - c.y); c.w = newW; c.h = newH; fitCrop(n, c); writeCrop(n); layoutVueOverlay(n); } } return; } const local = [pos[0] - n.pos[0], pos[1] - n.pos[1]]; if (st.rect && pointInRect(local, st.rect) && !st.crop) { // "Original": no crop to zoom, keep the core canvas behavior return; } if (st.rect && pointInRect(local, st.rect) && st.crop) { // always consume the wheel over the preview (no canvas zoom/pan) e.preventDefault(); e.stopPropagation(); const img = naturalSize(n); if (img) { const c = st.crop; const rm = ratioOf(n); // Free: zoom keeps the box's own (drawn) ratio const rr = rm === "free" ? (c.w * img.w) / (c.h * img.h) : rm; const fr = maxFitRatio(rr, img.w, img.h); const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08; // clamp the TARGET size first: at a limit the size stays the // same, so the rectangle must not move let newH = c.h * factor; if (newH > fr.h) newH = fr.h; if (newH < 0.02) newH = 0.02; if (Math.abs(newH - c.h) > 1e-6) { const k = rr * (img.h / img.w); const newW = newH * k; const r = st.rect; // zoom around the cursor const cx = (local[0] - r.x) / r.w; const cy = (local[1] - r.y) / r.h; c.x = c.x + (c.w - newW) * cx; c.y = c.y + (c.h - newH) * cy; c.w = newW; c.h = newH; fitCrop(n, c); writeCrop(n); markDirty(n); } } return; } } } catch (err) { console.warn(LIRC.TAG, "wheel", err); } }, { passive: false, capture: true } ); } /* --------------------------- paste ------------------------------------ */ /** * Context-menu paste for classic nodes: the frontend already handles * Ctrl+V natively in both designs, but classic exposes no menu entry for * it. This mirrors the core "Paste Image" entry: read the clipboard and * hand the image to the node's native paste methods (node.pasteFile / * node.pasteFiles), the same path the core uses. Like the core entry it * only covers clipboard images (screenshots); OS file copies work via * Ctrl+V. */ async function pasteFromClipboard(node) { if (!navigator.clipboard || !navigator.clipboard.read) { alert("Clipboard access is not available in this browser. Use Chrome/Edge over http://localhost and allow clipboard access."); return; } try { const items = await navigator.clipboard.read(); for (const item of items) { const type = item.types.find((t) => t.startsWith("image/")); if (type) { const blob = await item.getType(type); const ext = (type.split("/")[1] || "png").split(";")[0]; const file = new File([blob], `pasted-image.${ext}`, { type }); if (typeof node.pasteFile === "function") node.pasteFile(file); if (typeof node.pasteFiles === "function") node.pasteFiles([file]); return; } } alert("No image found in the clipboard."); } catch (e) { console.error(LIRC.TAG, "paste failed", e); alert("Paste from clipboard failed: " + (e && e.message ? e.message : e)); } } /* --------------------------- registration ----------------------------- */ function register(app) { lircApp = app; app.registerExtension({ name: "LoadImageCrop", nodeCreated: (node) => initNode(node), loadedGraphNode: (node) => initNode(node), beforeRegisterNodeDef: (nodeType, nodeData) => { if (nodeData && nodeData.name !== LIRC.NODE_NAME) return; const proto = nodeType.prototype; const prevConfigure = proto.onConfigure; proto.onConfigure = function (data) { const r = prevConfigure ? prevConfigure.call(this, data) : undefined; initNode(this); return r; }; const prevAdded = proto.onAdded; proto.onAdded = function (graph) { const r = prevAdded ? prevAdded.call(this, graph) : undefined; initNode(this); return r; }; } }); installWheelInterceptor(app); startVuePoller(app); console.log(LIRC.TAG, "extension registered"); } (async function boot() { let mod = null; for (let i = 0; i < 100; i++) { try { mod = await import("/scripts/app.js"); if (mod && mod.app) break; mod = null; } catch (_) { /* retry */ } await sleep(50); } if (!mod || !mod.app) { console.error(LIRC.TAG, "could not load /scripts/app.js - extension not started"); return; } try { register(mod.app); } catch (e) { console.error(LIRC.TAG, "register failed", e); } })();