v1.0.7: pre-crop flip (Horizontal/Vertical)
- New 'flip' input (None/Horizontal/Vertical, default None).
- Selecting a flip mirrors the image in the browser (canvas), saves it
to input/ as {name}-flipH.png / {name}-flipV.png, and switches the
node's image input to the flipped file.
- The flipped image is used everywhere (preview, mask editor, output);
the crop rectangle is framed on the flipped image (WYSIWYG).
- 'None' reverts to the original image.
- No canvas rendering tricks: the flip is a real file in input/, so it
is consistent across all contexts.
Files changed:
- load_image_crop.py: FLIP_OPTIONS + flip input in INPUT_TYPES
- web/js/loadImageCrop.js: applyFlip() + flip widget binding
- pyproject.toml: version 1.0.7
- CHANGELOG.md: v1.0.7 entry
- README.md: Flip section updated
This commit is contained in:
+6
-1
@@ -1,6 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.6 (2026-09-07)
|
||||
## v1.0.7 (2026-09-29)
|
||||
|
||||
**Added**
|
||||
- Optional **pre-crop flip** (`flip` input: `None` / `Horizontal` / `Vertical`, default `None`). Selecting a flip mirrors the image in the browser (canvas), saves the result to `input/` as `{name}-flipH.png` / `{name}-flipV.png`, and switches the node's image input to the flipped file. The preview shows the flipped image, so the crop rectangle is framed on it (WYSIWYG). `None` reverts to the original image. A flip does not change the image size, only mirrors it.
|
||||
|
||||
## v1.0.6 (2026-09-28)
|
||||
|
||||
**Fixed**
|
||||
- Context menu duplication: the `getExtraMenuOptions` override previously returned `items.concat(base)`, concatenating the base menu list. On ComfyUI nightly/dev builds the base node no longer implements `getExtraMenuOptions` (its menu entries are provided by the Vue frontend), so the canvas' `options = extra.concat(options)` duplicated the base menu list, making the whole context menu appear twice. The override now returns only its own "Paste Image from Clipboard" entry, which is robust across all ComfyUI builds (stable and nightly, classic and Vue nodes).
|
||||
|
||||
@@ -15,6 +15,7 @@ Drop the folder into `ComfyUI/custom_nodes/` and restart ComfyUI. No extra depen
|
||||
The node looks and behaves like the official Load Image: same file picker, drag & drop and preview.
|
||||
|
||||
- **Aspect Ratio** — default **Original**: the image passes through **uncropped** (W×H, no resample), no crop area is shown and the node behaves exactly like the official Load Image. The other ratios are locked: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `9:16`, `16:9`, `21:9`. The **Free (Custom)** option lifts the lock: any crop rectangle — in Free mode the four corners of the box show small resize handles (drag one to resize freely, the opposite corner stays fixed), the middle still moves the box, and the wheel zooms it at its drawn ratio.
|
||||
- **Flip** — default **None** (no flip). `Horizontal` or `Vertical` mirrors the image and saves it as a new file in `input/` (`{name}-flipH.png` / `{name}-flipV.png`); the node's image input is switched to that file, so the flipped image is used everywhere (preview, mask editor, output). The crop rectangle is framed on the flipped image (WYSIWYG). A flip does not change the image size, only mirrors it. `None` reverts to the original image.
|
||||
- With any locked ratio, a crop rectangle appears on the preview:
|
||||
- **Move it**: click and drag the rectangle (the drag starts only inside the box, so the rest of the node behaves exactly like the official Load Image).
|
||||
- **Zoom it**: keep the mouse pointer over the crop area and turn the mouse wheel — wheel up enlarges, wheel down shrinks, always around the pointer. While the pointer is over the crop area the wheel is captured by the crop, so the canvas behind does not pan/zoom; outside it the wheel works normally.
|
||||
|
||||
+11
-4
@@ -29,6 +29,12 @@ ASPECT_RATIOS = [
|
||||
"21:9 (Ultrawide)",
|
||||
]
|
||||
|
||||
FLIP_OPTIONS = [
|
||||
"None",
|
||||
"Horizontal",
|
||||
"Vertical",
|
||||
]
|
||||
|
||||
|
||||
class LoadImageCrop:
|
||||
"""Like the official Load Image node, plus:
|
||||
@@ -60,6 +66,7 @@ class LoadImageCrop:
|
||||
"required": {
|
||||
"image": (sorted(files), {"image_upload": True}),
|
||||
"aspect_ratio": (ASPECT_RATIOS,),
|
||||
"flip": (FLIP_OPTIONS, {"default": "None"}),
|
||||
"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}),
|
||||
@@ -67,7 +74,7 @@ class LoadImageCrop:
|
||||
}
|
||||
}
|
||||
|
||||
def load_image(self, image, aspect_ratio, crop_x, crop_y, crop_w, crop_h):
|
||||
def load_image(self, image, aspect_ratio, flip, crop_x, crop_y, crop_w, crop_h):
|
||||
output_image, output_mask = self._load(image)
|
||||
if aspect_ratio == "Original":
|
||||
# the image is returned as-is, regardless of the crop values
|
||||
@@ -149,7 +156,7 @@ class LoadImageCrop:
|
||||
return (output_image, output_mask)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, image, aspect_ratio, crop_x, crop_y, crop_w, crop_h):
|
||||
def IS_CHANGED(s, image, aspect_ratio, flip, 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)
|
||||
@@ -157,11 +164,11 @@ class LoadImageCrop:
|
||||
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())
|
||||
m.update(f"|{aspect_ratio}|{flip}|{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):
|
||||
def VALIDATE_INPUTS(s, image, aspect_ratio=None, flip=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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI-LoadImageCrop"
|
||||
description = "An extended Load Image node with an aspect-ratio-locked, WYSIWYG crop rectangle that outputs the cropped IMAGE and MASK."
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
license = "MIT"
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -44,6 +44,7 @@ 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]"
|
||||
};
|
||||
|
||||
@@ -69,11 +70,91 @@ function getState(node) {
|
||||
// {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 = flip === "Vertical" ? img.height : img.width;
|
||||
canvas.height = flip === "Vertical" ? img.width : 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) {
|
||||
@@ -610,12 +691,30 @@ function initNode(node) {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user