Add files via upload

This commit is contained in:
domg73
2026-08-31 21:09:24 +02:00
committed by GitHub
parent 045902d3d9
commit a69d569ca0
3 changed files with 824 additions and 0 deletions
+11
View File
@@ -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"
+149
View File
@@ -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
+664
View File
@@ -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);
}
})();