This commit is contained in:
noembryo
2026-08-25 20:10:49 +03:00
parent 7b623df087
commit 9c3e1cce43
5 changed files with 173 additions and 17 deletions
+3
View File
@@ -59,6 +59,9 @@ Load an image from **any path on your computer or a URL**. Paste an absolute pat
The file is read from its **original location**; it is **not** copied into ComfyUI’s `input` folder.
Use a selection rectangle at the preview to crop it.
Limit (downscale) the output's size in megapixels or pixels.
Click the ↻ button (top-right, mouse over the preview), to rotate the image 90° clockwise.
### Interactive crop
+32 -2
View File
@@ -129,6 +129,23 @@ def _parse_crop(crop, width, height):
return x0, y0, x1, y1
def _parse_rotation(crop):
""" Return the clockwise 90°-step rotation (0..3) stored inside the crop JSON.
The rotation is an extra "rotation" field holding degrees (0, 90, 180, 270 —
clockwise), managed by the ↻ button on the node. Absent / invalid => 0.
"""
if not crop:
return 0
try:
data = json.loads(crop)
r = int(data.get("rotation", 0))
except (ValueError, AttributeError, TypeError):
return 0
if r < 0:
return 0
return (r // 90) % 4
def _compute_downscaled_size(width, height, max_megapixels):
""" Return (new_w, new_h) if (width, height) exceeds max_megapixels, else None.
Downscale-only (never upscales) and aspect-preserving.
@@ -292,6 +309,8 @@ class LoadImageFromPathEnhanced(LoadImageFromPath):
" Load an image from any path (paste or Browse) or URL. Drag on the preview to"
" crop; drag inside to move; drag corners to resize; click outside the"
" selection to clear. With no crop drawn, the full image is output.\n"
" Hover the preview and click the ↻ button (top-right) to rotate the image"
" 90° clockwise. Rotation is preserved with the workflow.\n"
" If max_megapixels is greater than 0, the output (crop or full image)"
" is downscaled to fit within it, aspect ratio preserved; images already"
" at or under the cap are left untouched. A value of 0 disables the cap.\n"
@@ -322,7 +341,18 @@ class LoadImageFromPathEnhanced(LoadImageFromPath):
mask = torch.zeros((mask.shape[0], img_h, img_w), dtype=mask.dtype,
device=mask.device)
# Apply interactive crop (normalized coords from the frontend)
# Apply clockwise rotation stored inside the crop JSON (↻ button on node).
# rot = 1 => 90° clockwise, 2 => 180°, 3 => 270°.
rot = _parse_rotation(crop)
if rot:
# torch.rot90(k negative) rotates clockwise; image is (N, H, W, C),
# mask is (N, H, W) — rotate over the H/W dims.
image_tensor = torch.rot90(image_tensor, k=-rot, dims=(1, 2))
mask = torch.rot90(mask, k=-rot, dims=(1, 2))
# Apply interactive crop (normalized coords from the frontend).
# Crop coords are drawn on the ROTATED preview, so they now match the
# rotated tensor dims.
box = _parse_crop(crop, image_tensor.shape[2], image_tensor.shape[1])
if box is not None:
x0, y0, x1, y1 = box
@@ -586,4 +616,4 @@ async def serve_image(request):
return response
except Exception as e:
return web.Response(status=500, text=f'Error serving image: {str(e)}')
return web.Response(status=500, text=f'Error serving image: {str(e)}')
+2 -2
View File
@@ -7,14 +7,14 @@ from .load_image_from_path import LoadImageFromPathEnhanced
from .stitcher import H3MotionContextClipStitcher
MANIFEST = {"name": "noEmbryo Nodes",
"version": (1, 6, 1),
"version": (1, 6, 2),
"author": "noEmbryo",
"project": "https://github.com/noembryo/ComfyUI-noEmbryo",
"description": "Nodes for ComfyUI",
"license": "MIT",
}
__author__ = "noEmbryo"
__version__ = "1.6.1"
__version__ = "1.6.2"
LISTS_PATH = join(dirname(realpath(__file__)), "TermLists")
+1 -1
View File
@@ -11,7 +11,7 @@ limit the output's size in megapixels. \n
* "PromptTermList (1-6)" nodes to help with the creation of Prompts. \n
* "Auto Save Workflow" can save the current workflow to a .json file automatically. \n
"""
version = "1.6.1"
version = "1.6.2"
license = {text = "MIT License"}
[project.urls]
+135 -12
View File
@@ -552,7 +552,7 @@ const MIN_SEL = 6;
const MIN_EDITOR_H = 80;
const RESIZE_ZONE = 15;
const PREVIEW_TOOLTIP =
"Drag to crop · Drag inside to move · Drag corners to resize · Click outside selection to clear";
"Drag to crop · Drag inside to move · Drag corners to resize · Click outside selection to clear · Click ↻ to rotate 90°";
app.registerExtension({
name: "noEmbryo.LoadImageFromPath",
@@ -614,6 +614,9 @@ app.registerExtension({
rect: null, // normalized {x,y,w,h} or null = full image
drag: null,
box: null,
hoverPreview: false, // pointer currently over the image box
rotateHover: false, // pointer currently over the ↻ button
rotateBtn: null, // {cx, cy, r} of the ↻ button, if any
};
try {
@@ -625,23 +628,83 @@ app.registerExtension({
function syncCrop() {
if (!cropWidget) return;
let value = "";
const parts = [];
if (state.rect && state.rect.w > 0.001 && state.rect.h > 0.001) {
const r = state.rect;
if (!(r.x < 0.002 && r.y < 0.002 && r.w > 0.996 && r.h > 0.996)) {
value = JSON.stringify({
x: +r.x.toFixed(4),
y: +r.y.toFixed(4),
w: +r.w.toFixed(4),
h: +r.h.toFixed(4),
});
parts.push(`"x":${+r.x.toFixed(4)}`);
parts.push(`"y":${+r.y.toFixed(4)}`);
parts.push(`"w":${+r.w.toFixed(4)}`);
parts.push(`"h":${+r.h.toFixed(4)}`);
}
}
const rot = getRotation();
if (rot) parts.push(`"rotation":${rot}`);
const value = parts.length ? `{${parts.join(",")}}` : "";
if (cropWidget.value !== value) {
cropWidget.value = value;
}
}
// --- Rotation (↻ button) ---------------------------------------
// Rotation lives inside the hidden crop widget's JSON as a
// "rotation":<degrees> field (0/90/180/270 clockwise), so no new
// widget/input is needed and it persists with the workflow.
function getRotation() {
try {
const v = cropWidget?.value;
if (!v) return 0;
const r = parseInt(JSON.parse(v).rotation, 10);
return isNaN(r) || r < 0 ? 0 : r % 360;
} catch (e) {
return 0;
}
}
function setCropRotation(deg) {
if (!cropWidget) return;
let data = {};
const v = cropWidget.value;
if (v) {
try { data = JSON.parse(v) || {}; } catch (e) { data = {}; }
}
if (deg) data.rotation = deg;
else delete data.rotation;
const keys = Object.keys(data);
cropWidget.value = keys.length ? JSON.stringify(data) : "";
}
// Clockwise rotation of an Image/canvas for the preview. Returns a
// canvas matching the backend's torch.rot90(k<0) clockwise result.
function rotateForPreview(src, deg) {
const d = ((deg % 360) + 360) % 360;
if (!d) return src;
const c = document.createElement("canvas");
const q = (d / 90) % 4;
if (q % 2 === 1) {
c.width = src.height;
c.height = src.width;
} else {
c.width = src.width;
c.height = src.height;
}
const ctx = c.getContext("2d");
ctx.translate(c.width / 2, c.height / 2);
ctx.rotate((d * Math.PI) / 180); // positive = clockwise (y-down)
ctx.drawImage(src, -src.width / 2, -src.height / 2);
return c;
}
function rotate90() {
const rot = (getRotation() + 90) % 360;
state.rect = null; // existing crop is stale after rotating
setCropRotation(rot);
syncCrop();
loadImageFromPath();
node.setDirtyCanvas?.(true, true);
editorWidget.triggerDraw?.();
}
function previewHeight(width) {
if (!state.img) return 100;
return Math.round(width * (state.img.height / state.img.width));
@@ -851,6 +914,30 @@ app.registerExtension({
ctx.globalAlpha = prevAlpha;
}
// Hover-only ↻ rotate button (top-right of the preview)
state.rotateBtn = null;
if (state.img && state.hoverPreview) {
const rbR = 10;
const rbMargin = 8;
const rcx = bx + bw - rbMargin - rbR;
const rcy = by + rbMargin + rbR;
state.rotateBtn = { cx: rcx, cy: rcy, r: rbR };
ctx.beginPath();
ctx.arc(rcx, rcy, rbR, 0, Math.PI * 2);
ctx.fillStyle = state.rotateHover
? "rgba(255,255,255,0.92)"
: "rgba(30,30,30,0.72)";
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = state.rotateHover ? "#111" : "#fff";
ctx.stroke();
ctx.fillStyle = state.rotateHover ? "#111" : "#fff";
ctx.font = `bold ${rbR}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("↻", rcx, rcy + 1);
}
ctx.restore();
},
@@ -864,6 +951,15 @@ app.registerExtension({
const clampY = (v) => Math.max(by, Math.min(by + bh, v));
if (t === "pointerdown" || t === "mousedown") {
const rb = state.rotateBtn;
if (rb) {
const dx = px - rb.cx;
const dy = py - rb.cy;
if (dx * dx + dy * dy <= rb.r * rb.r) {
rotate90();
return true;
}
}
if (px < bx || px > bx + bw || py < by || py > by + bh) {
return false;
}
@@ -1006,17 +1102,40 @@ app.registerExtension({
prevMouseMove?.apply(this, arguments);
const el = graphCanvas?.canvas || app.canvas?.canvas;
if (!el) return;
if (!state.drag) el.style.cursor = cursorFor(pos[0], pos[1]);
// Native tooltip while the pointer is over the image preview
const x = pos[0], y = pos[1];
const prevHover = state.hoverPreview;
const prevRotHover = state.rotateHover;
if (state.img && state.box) {
const { bx, by, bw, bh } = state.box;
const x = pos[0], y = pos[1];
const over =
x >= bx && x <= bx + bw && y >= by && y <= by + bh;
state.hoverPreview = over;
el.title = over ? PREVIEW_TOOLTIP : "";
let overRotate = false;
if (over && state.rotateBtn) {
const dx = x - state.rotateBtn.cx;
const dy = y - state.rotateBtn.cy;
overRotate =
dx * dx + dy * dy <=
state.rotateBtn.r * state.rotateBtn.r;
}
state.rotateHover = overRotate;
if (!state.drag) {
el.style.cursor = overRotate
? "pointer"
: cursorFor(x, y);
}
} else {
state.hoverPreview = false;
state.rotateHover = false;
el.title = "";
}
if (
state.hoverPreview !== prevHover ||
state.rotateHover !== prevRotHover
) {
node.setDirtyCanvas?.(true, true);
}
};
const prevMouseLeave = node.onMouseLeave;
node.onMouseLeave = function () {
@@ -1026,6 +1145,9 @@ app.registerExtension({
el.style.cursor = "";
el.title = "";
}
state.hoverPreview = false;
state.rotateHover = false;
node.setDirtyCanvas?.(true, true);
};
Object.defineProperty(editorWidget, "computedHeight", {
@@ -1071,7 +1193,8 @@ app.registerExtension({
const img = new Image();
img.onload = () => {
if (seq !== loadSeq) return;
state.img = img;
// Apply the stored clockwise rotation (matches the backend).
state.img = rotateForPreview(img, getRotation());
// Never resize the node to the image ratio — letterbox only.
node.setDirtyCanvas?.(true, true);
editorWidget.triggerDraw?.();