Add files via upload
This commit is contained in:
+11
@@ -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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user