1.6.2
This commit is contained in:
@@ -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
@@ -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)}')
|
||||
@@ -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
@@ -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
@@ -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?.();
|
||||
|
||||
Reference in New Issue
Block a user