From a69d569ca017eea9e4bac7cdd1367c4ff9e27705 Mon Sep 17 00:00:00 2001 From: domg73 Date: Mon, 31 Aug 2026 21:09:24 +0200 Subject: [PATCH] Add files via upload --- __init__.py | 11 + load_image_crop.py | 149 +++++++++ web/js/loadImageCrop.js | 664 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 824 insertions(+) create mode 100644 __init__.py create mode 100644 load_image_crop.py create mode 100644 web/js/loadImageCrop.js diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..db5dfc8 --- /dev/null +++ b/__init__.py @@ -0,0 +1,11 @@ +from .load_image_crop import LoadImageCrop + +NODE_CLASS_MAPPINGS = { + "LoadImageCrop": LoadImageCrop, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadImageCrop": "Load Image + Crop", +} + +WEB_DIRECTORY = "./web" diff --git a/load_image_crop.py b/load_image_crop.py new file mode 100644 index 0000000..10ab9d7 --- /dev/null +++ b/load_image_crop.py @@ -0,0 +1,149 @@ +import os +import hashlib +import numpy as np +import torch +from PIL import Image, ImageOps, ImageSequence + +import folder_paths +import comfy +import node_helpers + +try: # current import location + from comfy_api.latest import InputImpl +except Exception: + try: # older import location + from comfy.api.latest import InputImpl + except Exception: + InputImpl = None + +ASPECT_RATIOS = [ + "1:1 (Square)", + "2:3 (Portrait Photo)", + "3:2 (Photo)", + "3:4 (Portrait Standard)", + "4:3 (Standard)", + "9:16 (Portrait Widescreen)", + "16:9 (Widescreen)", + "21:9 (Ultrawide)", +] + + +class LoadImageCrop: + """Like the official Load Image node, plus: + - a visual, mouse-draggable crop area (aspect-ratio locked) in the frontend preview + - a paste-from-clipboard entry in the node context menu + + The crop rectangle (crop_x, crop_y, crop_w, crop_h) arrives as normalized [0..1] + fractions of the source image size, computed by the frontend. + """ + + CATEGORY = "image" + SEARCH_ALIASES = ["load image", "crop", "crop image", "paste", "clipboard"] + + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("IMAGE", "MASK") + FUNCTION = "load_image" + OUTPUT_NODE = False + + @classmethod + def INPUT_TYPES(s): + input_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] + files = folder_paths.filter_files_content_types(files, ["image"]) + return { + "required": { + "image": (sorted(files), {"image_upload": True}), + "aspect_ratio": (ASPECT_RATIOS,), + "crop_x": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.0001}), + "crop_y": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.0001}), + "crop_w": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.0001}), + "crop_h": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.0001}), + } + } + + def load_image(self, image, aspect_ratio, crop_x, crop_y, crop_w, crop_h): + image_path = folder_paths.get_annotated_filepath(image) + + dtype = comfy.model_management.intermediate_dtype() + device = comfy.model_management.intermediate_device() + + if InputImpl is not None: + components = InputImpl.VideoFromFile(image_path).get_components() + if components.images.shape[0] > 0: + output_image = components.images.to(device=device, dtype=dtype) + if components.alpha is not None: + output_mask = (1.0 - components.alpha[..., -1]).to(device=device, dtype=dtype) + else: + output_mask = torch.zeros((output_image.shape[0], 64, 64), dtype=dtype, device=device) + return self._crop(output_image, output_mask, crop_x, crop_y, crop_w, crop_h) + + # Fallback (also used when InputImpl is unavailable): plain image loading, + # the same path the official node uses for animated webp. + img = node_helpers.pillow(Image.open, image_path) + output_images, output_masks = [], [] + w, h = None, None + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + rgb = i.convert("RGB") + if len(output_images) == 0: + w, h = rgb.size[0], rgb.size[1] + if rgb.size[0] != w or rgb.size[1] != h: + continue + arr = np.array(rgb).astype(np.float32) / 255.0 + output_images.append(torch.from_numpy(arr)[None,].to(dtype=dtype)) + if "A" in i.getbands(): + mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0 + mask = 1.0 - torch.from_numpy(mask) + else: + mask = torch.zeros((64, 64), dtype=torch.float32) + output_masks.append(mask.unsqueeze(0).to(dtype=dtype)) + output_image = torch.cat(output_images, dim=0).to(device=device, dtype=dtype) + output_mask = torch.cat(output_masks, dim=0).to(device=device, dtype=dtype) + + return self._crop(output_image, output_mask, crop_x, crop_y, crop_w, crop_h) + + def _crop(self, output_image, output_mask, crop_x, crop_y, crop_w, crop_h): + # guard against degenerate values (API/workflow input): an empty or + # out-of-range crop is returned uncropped + if not (crop_w > 0.001 and crop_h > 0.001 and crop_x < 0.999 and crop_y < 0.999): + return (output_image, output_mask) + + H, W = output_image.shape[1], output_image.shape[2] + mh, mw = output_mask.shape[1], output_mask.shape[2] + + def clampi(v, lo, hi): + return max(lo, min(hi, int(round(v)))) + + # crop the image + x0 = clampi(crop_x * W, 0, W - 1) + y0 = clampi(crop_y * H, 0, H - 1) + x1 = clampi((crop_x + crop_w) * W, x0 + 1, W) + y1 = clampi((crop_y + crop_h) * H, y0 + 1, H) + output_image = output_image[:, y0:y1, x0:x1, :] + + # crop the mask proportionally (its size may differ from the image size) + mx0 = clampi(crop_x * mw, 0, mw - 1) + my0 = clampi(crop_y * mh, 0, mh - 1) + mx1 = clampi((crop_x + crop_w) * mw, mx0 + 1, mw) + my1 = clampi((crop_y + crop_h) * mh, my0 + 1, mh) + output_mask = output_mask[:, my0:my1, mx0:mx1] + + return (output_image, output_mask) + + @classmethod + def IS_CHANGED(s, image, aspect_ratio, crop_x, crop_y, crop_w, crop_h): + # file identity via stat (size + mtime) instead of hashing the whole file + image_path = folder_paths.get_annotated_filepath(image) + st = os.stat(image_path) + m = hashlib.sha256() + m.update(str(st.st_size).encode()) + m.update(str(st.st_mtime_ns).encode()) + # full-precision crop values (repr) so any change forces a re-run + m.update(f"|{aspect_ratio}|{crop_x!r},{crop_y!r},{crop_w!r},{crop_h!r}".encode()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, image, aspect_ratio=None, crop_x=None, crop_y=None, crop_w=None, crop_h=None): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + return True diff --git a/web/js/loadImageCrop.js b/web/js/loadImageCrop.js new file mode 100644 index 0000000..3035f0c --- /dev/null +++ b/web/js/loadImageCrop.js @@ -0,0 +1,664 @@ +/* + * 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 (clicks on the preview are + * consumed by the widget, so the node-level onMouseDown never fires + * there); node.onMouseMove / node.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. + */ +const LIRC = { + NODE_NAME: "LoadImageCrop", + PREVIEW_WIDGET: "$$canvas-image-preview", + CROP_KEYS: ["crop_x", "crop_y", "crop_w", "crop_h"], + 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 inside image, {cx, cy} crop origin at grab + lastKey: null, + }; + } + return node.__lirc; +} + +function ratioOf(node) { + const w = widgetOf(node, "aspect_ratio"); + const v = w ? String(w.value) : "1:1"; + // 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) }; +} + +function resetCrop(node) { + const st = getState(node); + const n = naturalSize(node); + if (!n) return; + const fr = maxFitRatio(ratioOf(node), 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, keeping the ratio (height-driven). */ +function fitCrop(node, c) { + const n = naturalSize(node); + if (!n) return c; + const ratio = ratioOf(node); + // 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); + 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 (x === 0 && y === 0 && w === 1 && h === 1) { + // Python defaults == "no crop saved yet" -> derive from the ratio + resetCrop(node); + writeCrop(node); + } else { + st.crop = { x, y, w, h }; + st.cropFromWorkflow = true; + } +} + +function writeCrop(node) { + const st = getState(node); + if (!st.crop) return; + 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 = st.crop[prop]; + } +} + +function markDirty(node) { + try { + if (node.graph && typeof node.graph.setDirtyCanvas === "function") { + node.graph.setDirtyCanvas(true); + } + } catch (_) { /* ignore */ } +} + +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; } + 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; + 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 { + fitCrop(node, st.crop); + 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)); + // 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; + fitCrop(node, s.crop); + writeCrop(node); + markDirty(node); + } + try { + const c = lircApp && lircApp.canvas && lircApp.canvas.canvas; + if (c) c.style.cursor = ""; + } catch (_) { /* ignore */ } +} + +/** Move the crop so its grab point follows 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; + s.crop.x = (p[0] - r.x) / r.w - s.dragOff.nx + s.dragOff.cx; + s.crop.y = (p[1] - r.y) / r.h - s.dragOff.ny + s.dragOff.cy; + fitCrop(node, s.crop); + writeCrop(node); + markDirty(node); +} + +/* --------------------------- 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); + if (st.rect && st.crop && p && pointInRect(p, st.rect)) { + // 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 }; + // 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 { + if (canvas && canvas.canvas) canvas.canvas.style.cursor = "move"; + } catch (_) { /* ignore */ } + return true; + } + // pointer outside the image area -> keep core behavior + if (origPointerDown) return origPointerDown(pointer, nodeArg, canvas); + return true; + }; +} + +/* --------------------------- 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) + for (const key of LIRC.CROP_KEYS) { + const w = widgetOf(node, key); + if (w) w.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); + if (n) { + const fr = maxFitRatio(ratioOf(node), 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); + 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.callback = (v) => { + if (typeof prev === "function") prev(v); + // let the core load the image; crop resets when the overlay sees a + // new image key (handled in scheduleOverlay) + }; + } + // 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; + // new top-left = pointer - grab offset, in normalized image space + const nx = (pos[0] - r.x) / r.w - s.dragOff.nx + s.dragOff.cx; + const ny = (pos[1] - r.y) / r.h - s.dragOff.ny + s.dragOff.cy; + s.crop.x = nx; + s.crop.y = ny; + fitCrop(node, s.crop); + writeCrop(node); + markDirty(node); + } else if (s.rect) { + const inside = pointInRect(pos, s.rect); + try { + const c = lircApp && lircApp.canvas && lircApp.canvas.canvas; + if (c) c.style.cursor = inside ? "move" : ""; + } 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: paste from clipboard + node.getExtraMenuOptions = (canvas, options) => { + const items = [ + { + content: "Paste image from clipboard", + callback: () => pasteFromClipboard(node) + } + ]; + return items.concat(options || []); + }; + initCropFromWidgets(node); + } + wrapPreviewWidget(node); + } catch (e) { + console.warn(LIRC.TAG, "initNode", e); + } +} + +/* --------------------------- 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; + const local = [pos[0] - n.pos[0], pos[1] - n.pos[1]]; + 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 fr = maxFitRatio(ratioOf(n), 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 = ratioOf(n) * (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 ------------------------------------ */ + +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(); + let blob = null; + for (const item of items) { + const type = item.types.find((t) => t.startsWith("image/")); + if (type) { + blob = await item.getType(type); + break; + } + } + if (!blob) { + alert("No image found in the clipboard."); + return; + } + const fname = `paste_${Date.now()}.png`; + const fd = new FormData(); + fd.append("image", blob, fname); + fd.append("overwrite", "true"); + const res = await fetch("/upload/image", { method: "POST", body: fd }); + if (!res.ok) throw new Error(`upload failed: ${res.status}`); + const data = await res.json(); + const filename = data.name; + const w = widgetOf(node, "image"); + if (w) { + if (!w.options) w.options = { values: [] }; + if (!Array.isArray(w.options.values)) w.options.values = []; + if (!w.options.values.includes(filename)) w.options.values.push(filename); + const old = w.value; + w.value = filename; + w.callback?.(filename); + try { node.onWidgetChanged?.("image", filename, old, w); } catch (_) { /* ignore */ } + } + } 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); + 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); + } +})();