diff --git a/README.md b/README.md index 231fc77..a203cdd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/load_image_from_path.py b/load_image_from_path.py index c6efc01..8d73682 100644 --- a/load_image_from_path.py +++ b/load_image_from_path.py @@ -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)}') \ No newline at end of file diff --git a/nodes.py b/nodes.py index 244e332..551f5c8 100644 --- a/nodes.py +++ b/nodes.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 790ef08..7758996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/web/js/load_image_from_path.js b/web/js/load_image_from_path.js index 312cec8..e06654e 100644 --- a/web/js/load_image_from_path.js +++ b/web/js/load_image_from_path.js @@ -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": 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?.();