1.6.0
This commit is contained in:
@@ -28,6 +28,7 @@ For the custom text integration, there is a variable (can be specified by the us
|
||||
- **custom_text** is the custom text that will be inserted into the item's prompt text.
|
||||
- **Advanced Usage**
|
||||

|
||||
|
||||
- **Adding new items**
|
||||
To add a new item, we have to write it in the `custom_text` input, using the following format:
|
||||
```
|
||||
@@ -53,10 +54,46 @@ For the custom text integration, there is a variable (can be specified by the us
|
||||
|
||||
---
|
||||
## Load Image (from path)
|
||||

|
||||
A node lets you load an image from any path in your computer, either by pasting an address, or by using a file picker to browse your drives.
|
||||
It also displays the image in the node.
|
||||
It's an enhanced version of the "[Load Image From Path (Enhanced)](https://github.com/Chaoses-Ib/ComfyUI_Ib_CustomNodes#load-image-from-path-enhanced)" from [`ComfyUI_Ib_CustomNodes`](ComfyUI_Ib_CustomNodes).
|
||||

|
||||
Load an image from **any path on your computer**. Paste an absolute path, use an annotated path (`input/file.png` for `[input]`/`[output]`/`[temp]`), or click the **Browse** dialog to pick a file from your drives.
|
||||
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.
|
||||
|
||||
### Interactive crop
|
||||
|
||||
The node shows a live preview. You can crop directly on it:
|
||||
|
||||
- **Drag** on the image to draw a crop rectangle
|
||||
- **Drag inside** the selection to move the crop rectangle
|
||||
- **Drag a corner** to resize it
|
||||
- **Click** (without dragging) outside the rectangle to clear it
|
||||
|
||||
With no crop drawn, the **full image** is output. The crop is stored in the workflow as normalized coordinates, so it survives save/reload. Changing the path clears the crop.
|
||||
|
||||
The output (cropped or full) will be downscaled only (not upscaled), by the value in the `max_megapixels` field.
|
||||
|
||||
Outputs match the stock Load Image node: **IMAGE**, **MASK** (from the alpha channel when present), plus the original **path** string.
|
||||
|
||||
|
||||
|
||||
- **Controls**
|
||||
- **image**: Paste an absolute path, (or a relative one with a prefix input/, or output/, or temp/), to an image file.
|
||||
- **max_megapixels**: Cap the output (crop, or full image if uncropped) to this many megapixels, downscaling only if it's bigger.
|
||||
Smaller images are left untouched. 1.0 = 1024x1024 px. 0 disables the cap.
|
||||
- **Browse...**: to open an image file from your drives.
|
||||
|
||||
- **Inputs/Outputs**
|
||||
- **Width/Height** inputs: Force the output width in px (upscale or downscale), center-cropping first if the aspect ratio differs.
|
||||
Leave disconnected (None) to keep natural width.
|
||||
Only applies if BOTH width and height are connected, and when set (not 0). It overrides the `max_megapixels` value.
|
||||
- **IMAGE/MASK**: The final, processed image/mask.
|
||||
- **path**: A string with the image's path.
|
||||
|
||||
|
||||
|
||||
**Credits:**
|
||||
Built as a much enhanced version of [Load Image From Path (Enhanced)](https://github.com/Chaoses-Ib/ComfyUI_Ib_CustomNodes#load-image-from-path-enhanced) from [ComfyUI_Ib_CustomNodes](https://github.com/Chaoses-Ib/ComfyUI_Ib_CustomNodes), with parts of the interactive crop UI inspired from [Load Image & Crop](https://github.com/obvpm/comfyui-obvpm#load-image--crop) in [comfyui-obvpm](https://github.com/obvpm/comfyui-obvpm).
|
||||
|
||||
---
|
||||
## Resolution Scale
|
||||
@@ -86,7 +123,6 @@ No quality loss, like when trying to concatenate encoded videos.
|
||||
- (Optional), the current generated AV latent to be added as the last part of the stitching
|
||||
|
||||
- **Controls**
|
||||
|
||||
- **folder** is the folder containing clip_00001.safetensors, clip_00002.safetensors, etc.
|
||||
Absolute paths and paths relative to ComfyUI/output are accepted.
|
||||
- **pattern** is the filename glob. The final five-digit number is treated as the clip index.
|
||||
|
||||
+155
-34
@@ -30,8 +30,8 @@ def _pillow(fn, arg):
|
||||
|
||||
|
||||
def _pil_to_image_mask(img: 'Image.Image | Iterable[Image.Image]',
|
||||
output_image: 'list[torch.Tensor] | None',
|
||||
output_mask: 'list[torch.Tensor] | None'):
|
||||
output_image: 'list[torch.Tensor] | None',
|
||||
output_mask: 'list[torch.Tensor] | None'):
|
||||
output_images = []
|
||||
output_masks = []
|
||||
w, h = None, None
|
||||
@@ -92,7 +92,7 @@ def _pil_to_image_mask(img: 'Image.Image | Iterable[Image.Image]',
|
||||
|
||||
|
||||
def _parse_crop(crop, width, height):
|
||||
"""Return (x0, y0, x1, y1) pixel box, or None for full image.
|
||||
""" Return (x0, y0, x1, y1) pixel box, or None for full image.
|
||||
Crop is JSON with normalized coords: {"x","y","w","h"} in 0..1.
|
||||
Empty / invalid crop means no crop.
|
||||
"""
|
||||
@@ -112,17 +112,60 @@ def _parse_crop(crop, width, height):
|
||||
y1 = max(y0 + 1, min(height, round((y + h) * height)))
|
||||
if x0 == 0 and y0 == 0 and x1 == width and y1 == height:
|
||||
return None
|
||||
return (x0, y0, x1, y1)
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
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.
|
||||
1.0 megapixels == 1024 x 1024 px, matching ComfyUI's ImageScaleToTotalPixels
|
||||
convention. A max_megapixels of 0 (or falsy) disables the cap entirely.
|
||||
"""
|
||||
try:
|
||||
max_megapixels = float(max_megapixels)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if max_megapixels <= 0:
|
||||
return None
|
||||
max_pixels = max_megapixels * 1024 * 1024
|
||||
current_pixels = width * height
|
||||
if current_pixels <= max_pixels:
|
||||
return None
|
||||
scale = (max_pixels / current_pixels) ** 0.5
|
||||
new_w = max(1, round(width * scale))
|
||||
new_h = max(1, round(height * scale))
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
def _compute_center_crop_size(src_w, src_h, dst_w, dst_h):
|
||||
""" Return (crop_w, crop_h) — the aspect-fit central box of the source that,
|
||||
when resized to exactly (dst_w, dst_h), forces the output size with a center
|
||||
crop around the middle. Returns None when forcing is disabled (either target
|
||||
dim non-positive) or the source is already exactly the target size.
|
||||
"""
|
||||
if dst_w <= 0 or dst_h <= 0:
|
||||
return None # forcing disabled — both width and height must be > 0
|
||||
if src_w == dst_w and src_h == dst_h:
|
||||
return None # already exact size — no-op
|
||||
src_ratio = src_w / src_h
|
||||
dst_ratio = dst_w / dst_h
|
||||
if src_ratio > dst_ratio:
|
||||
# Source is wider than the target ratio → crop the sides, keep full height.
|
||||
crop_h = src_h
|
||||
crop_w = max(1, round(src_h * dst_ratio))
|
||||
else:
|
||||
# Source is taller → crop the top/bottom, keep full width.
|
||||
crop_w = src_w
|
||||
crop_h = max(1, round(src_w / dst_ratio))
|
||||
return crop_w, crop_h
|
||||
|
||||
|
||||
class LoadImageFromPath:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required":
|
||||
{"image": ("STRING",
|
||||
{"default": r"ComfyUI_00001_-assets\ComfyUI_00001_.png "
|
||||
r"[output]"})},
|
||||
}
|
||||
return {"required": {
|
||||
"image": ("STRING", {"default": r"ComfyUI_00001_-assets\ComfyUI_00001_.png [output]"})},
|
||||
}
|
||||
|
||||
CATEGORY = "noEmbryo"
|
||||
|
||||
@@ -186,34 +229,72 @@ _clipspace_mappings = {} # Maps expected filename -> actual filename
|
||||
class LoadImageFromPathEnhanced(LoadImageFromPath):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("STRING", {
|
||||
"default": "",
|
||||
"tooltip": "Absolute path, or relative with [input]/[output]/[temp]. Use Browse to pick a file.",
|
||||
}),
|
||||
"crop": ("STRING", {
|
||||
"default": "",
|
||||
"tooltip": "Managed by the crop editor on the node — no need to edit by hand.",
|
||||
}),
|
||||
},
|
||||
}
|
||||
return {"required": {"image": ("STRING", {"default": "",
|
||||
"tooltip": 'Paste an absolute path, (or a relative one with a prefix '
|
||||
'input/, or output/, or temp/) to an image file. '
|
||||
'Or use "Browse" to pick a file.', }),
|
||||
"crop": ("STRING",
|
||||
{"default": "",
|
||||
"tooltip": "Managed by the crop editor on the node"
|
||||
" — no need to edit by hand.",}),
|
||||
"max_megapixels": ("FLOAT",
|
||||
{"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.01,
|
||||
"tooltip": "Cap the output (crop, or full image if uncropped) to this many "
|
||||
"megapixels, downscaling only if it's bigger.\nSmaller images are "
|
||||
"left untouched.\n1.0 = 1024x1024 px. 0 disables the cap.",}), },
|
||||
"optional": {"width": ("INT", {"forceInput": True, "min": 0, "max": 100000,
|
||||
"step": 1,
|
||||
"tooltip": "Force the output width in px (upscale or downscale), "
|
||||
"center-cropping first if the aspect ratio differs.\n"
|
||||
"Leave disconnected (None) to keep natural width.\n"
|
||||
"Only applies if BOTH width and height are connected, and when "
|
||||
"set (not 0), and it overrides max_megapixels.", }),
|
||||
"height": ("INT", {"forceInput": True, "min": 0, "max": 100000, "step": 1,
|
||||
"tooltip": "Force the output height in px (upscale or"
|
||||
" downscale), center-cropping first if the aspect"
|
||||
" ratio differs.\nLeave disconnected (None) to keep"
|
||||
" natural height.\nOnly takes effect if BOTH width"
|
||||
" and height are connected, when set (not 0), and"
|
||||
" it overrides max_megapixels.", }), }, }
|
||||
|
||||
CATEGORY = "image"
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
|
||||
RETURN_NAMES = ("IMAGE", "MASK", "path")
|
||||
FUNCTION = "load_image_enhanced"
|
||||
DESCRIPTION = ("Load an image from any path (paste or Browse). 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.")
|
||||
DESCRIPTION = (
|
||||
" Load an image from any path (paste or Browse). 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"
|
||||
" 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"
|
||||
" If both width and height are connected, the output (crop or full"
|
||||
" image) is forced to exactly that size — upscaling or downscaling, with"
|
||||
" a center crop first if the aspect ratio differs. This overrides"
|
||||
" max_megapixels.")
|
||||
|
||||
def load_image_enhanced(self, image, crop="", max_megapixels=0.0, width=None,
|
||||
height=None):
|
||||
# Optional inputs arrive as None when unconnected — treat as "disabled".
|
||||
width = 0 if width is None else int(width)
|
||||
height = 0 if height is None else int(height)
|
||||
|
||||
def load_image_enhanced(self, image, crop=""):
|
||||
# Get the full path
|
||||
image_path = LoadImageFromPath._resolve_path(image)
|
||||
|
||||
# Call the parent class's load_image method
|
||||
image_tensor, mask = super().load_image(image)
|
||||
|
||||
# When there's no alpha channel, load_image's fallback mask is a fixed
|
||||
# 64x64 "null mask" (ComfyUI's usual convention) — it does NOT match
|
||||
# the image's pixel dimensions. Cropping/resizing below index into the
|
||||
# mask using the image's own coordinates, so normalize it to the
|
||||
# image's size first (still an all-zero mask, just the right shape).
|
||||
img_h, img_w = image_tensor.shape[1], image_tensor.shape[2]
|
||||
if mask.shape[1] != img_h or mask.shape[2] != img_w:
|
||||
mask = torch.zeros((mask.shape[0], img_h, img_w), dtype=mask.dtype,
|
||||
device=mask.device)
|
||||
|
||||
# Apply interactive crop (normalized coords from the frontend)
|
||||
box = _parse_crop(crop, image_tensor.shape[2], image_tensor.shape[1])
|
||||
if box is not None:
|
||||
@@ -221,6 +302,40 @@ class LoadImageFromPathEnhanced(LoadImageFromPath):
|
||||
image_tensor = image_tensor[:, y0:y1, x0:x1, :]
|
||||
mask = mask[:, y0:y1, x0:x1]
|
||||
|
||||
# If BOTH width and height are > 0, the output is forced to exactly that
|
||||
# size — upscaling or downscaling, with a center crop first if the aspect
|
||||
# ratio differs. This overrides max_megapixels (the user's explicit
|
||||
# target size IS the final size).
|
||||
out_h, out_w = image_tensor.shape[1], image_tensor.shape[2]
|
||||
force = _compute_center_crop_size(out_w, out_h, width, height)
|
||||
if force is not None:
|
||||
crop_w, crop_h = force
|
||||
# Center-crop the source to the target aspect ratio.
|
||||
x0 = max(0, (out_w - crop_w) // 2)
|
||||
y0 = max(0, (out_h - crop_h) // 2)
|
||||
image_tensor = image_tensor[:, y0:y0 + crop_h, x0:x0 + crop_w, :]
|
||||
mask = mask[:, y0:y0 + crop_h, x0:x0 + crop_w]
|
||||
# Resize the center-cropped box to exactly width x height.
|
||||
new_w, new_h = int(width), int(height)
|
||||
image_tensor = torch.nn.functional.interpolate(
|
||||
image_tensor.permute(0, 3, 1, 2), size=(new_h, new_w), mode="bilinear",
|
||||
antialias=True, ).permute(0, 2, 3, 1).clamp(0.0, 1.0)
|
||||
mask = torch.nn.functional.interpolate(mask.unsqueeze(1), size=(new_h, new_w),
|
||||
mode="bilinear", antialias=True, ).squeeze(1).clamp(0.0, 1.0)
|
||||
else:
|
||||
# Cap the output (crop, or full image if uncropped) to max_megapixels.
|
||||
# Downscale-only: images already at or under the cap pass through
|
||||
# untouched. Skipped entirely when size forcing is active above.
|
||||
target = _compute_downscaled_size(out_w, out_h, max_megapixels)
|
||||
if target is not None:
|
||||
new_w, new_h = target
|
||||
image_tensor = torch.nn.functional.interpolate(
|
||||
image_tensor.permute(0, 3, 1, 2), size=(new_h, new_w),
|
||||
mode="bilinear", antialias=True, ).permute(0, 2, 3, 1).clamp(0.0, 1.0)
|
||||
mask = torch.nn.functional.interpolate(mask.unsqueeze(1),
|
||||
size=(new_h, new_w), mode="bilinear", antialias=True, ).squeeze(
|
||||
1).clamp(0.0, 1.0)
|
||||
|
||||
# Register this image in our cache for mask editor support
|
||||
filename = os.path.basename(str(image_path))
|
||||
_image_path_cache[filename] = str(image_path)
|
||||
@@ -230,23 +345,27 @@ class LoadImageFromPathEnhanced(LoadImageFromPath):
|
||||
return image_tensor, mask, image
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, image, crop=""):
|
||||
def IS_CHANGED(cls, image, crop="", max_megapixels=0.0, width=0, height=0):
|
||||
image_path = LoadImageFromPath._resolve_path(image)
|
||||
m = hashlib.sha256()
|
||||
with open(image_path, 'rb') as f:
|
||||
m.update(f.read())
|
||||
m.update(str(crop).encode("utf-8"))
|
||||
m.update(str(max_megapixels).encode("utf-8"))
|
||||
m.update(str(width).encode("utf-8"))
|
||||
m.update(str(height).encode("utf-8"))
|
||||
return m.digest().hex()
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, image, crop=""):
|
||||
def VALIDATE_INPUTS(cls, image, crop="", max_megapixels=0.0, width=0, height=0):
|
||||
return LoadImageFromPath.VALIDATE_INPUTS(image)
|
||||
|
||||
|
||||
# Middleware to handle clipspace file resolution
|
||||
@web.middleware
|
||||
async def clipspace_resolver_middleware(request, handler):
|
||||
""" Middleware to intercept /api/view requests and resolve clipspace filename mismatches.
|
||||
""" Middleware to intercept /api/view requests and resolve clipspace filename
|
||||
mismatches.
|
||||
This fixes the issue where mask editor looks for 'clipspace-mask-X.png' but
|
||||
the actual file is 'clipspace-painted-masked-X.png'
|
||||
"""
|
||||
@@ -302,8 +421,8 @@ async def browse_directory(request):
|
||||
os.path.exists(f"{d}:\\")]
|
||||
return web.json_response(
|
||||
{'directories': drives, 'files': [], 'current_path': '',
|
||||
'parent_path': None, # Up stays disabled on the drive list
|
||||
'sort_method': sort_method})
|
||||
'parent_path': None, # Up stays disabled on the drive list
|
||||
'sort_method': sort_method})
|
||||
else: # Unix-like
|
||||
path = os.path.expanduser('~')
|
||||
|
||||
@@ -340,7 +459,7 @@ async def browse_directory(request):
|
||||
continue
|
||||
|
||||
items.append({'name': item, 'type': item_type, 'path': item_path,
|
||||
'modified': stat.st_mtime})
|
||||
'modified': stat.st_mtime})
|
||||
|
||||
# Apply sorting
|
||||
if sort_method == 'name_asc':
|
||||
@@ -373,7 +492,7 @@ async def browse_directory(request):
|
||||
|
||||
return web.json_response(
|
||||
{'directories': directories, 'files': files, 'current_path': path,
|
||||
'parent_path': parent_path, 'sort_method': sort_method})
|
||||
'parent_path': parent_path, 'sort_method': sort_method})
|
||||
|
||||
except Exception as e:
|
||||
return web.json_response({'error': str(e)}, status=500)
|
||||
@@ -391,6 +510,8 @@ async def get_image_preview(request):
|
||||
img = _pillow(Image.open, image_path)
|
||||
img = _pillow(ImageOps.exif_transpose, img)
|
||||
|
||||
original_width, original_height = img.size # ← capture BEFORE downscale
|
||||
|
||||
max_size = (512, 512)
|
||||
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
|
||||
@@ -402,8 +523,8 @@ async def get_image_preview(request):
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
return web.json_response(
|
||||
{'preview': f'data:image/png;base64,{img_str}', 'width': img.width,
|
||||
'height': img.height})
|
||||
{'preview': f'data:image/png;base64,{img_str}', 'width': original_width,
|
||||
'height': original_height})
|
||||
|
||||
except Exception as e:
|
||||
return web.json_response({'error': str(e)}, status=500)
|
||||
|
||||
@@ -7,14 +7,14 @@ from .load_image_from_path import LoadImageFromPathEnhanced
|
||||
from .stitcher import H3MotionContextClipStitcher
|
||||
|
||||
MANIFEST = {"name": "noEmbryo Nodes",
|
||||
"version": (1, 5, 0),
|
||||
"version": (1, 6, 0),
|
||||
"author": "noEmbryo",
|
||||
"project": "https://github.com/noembryo/ComfyUI-noEmbryo",
|
||||
"description": "Nodes for ComfyUI",
|
||||
"license": "MIT",
|
||||
}
|
||||
__author__ = "noEmbryo"
|
||||
__version__ = "1.5.0"
|
||||
__version__ = "1.6.0"
|
||||
|
||||
LISTS_PATH = join(dirname(realpath(__file__)), "TermLists")
|
||||
|
||||
|
||||
+10
-9
@@ -1,16 +1,17 @@
|
||||
[project]
|
||||
name = "comfyui-noembryo"
|
||||
description = """
|
||||
A collection of nodes for ComyUI.\n
|
||||
* "Load Image (from path)" is a node lets you load an image from any path in your computer, and also displays the image in the node.\n
|
||||
* "H3 Motion Context Clip Stitcher" is a node that concatenates H3 Motion Context clip archives.\n
|
||||
* "JsonPromptLoader" is a node that enhances a propt using a .json file with `item:prompt` pairs.\n
|
||||
* "Resolution Scale" is a node that resizes an image using pixels or scale factor.\n
|
||||
* "Regex Text Chopper" is a node that cuts text based on regex.\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
|
||||
A collection of nodes for ComyUI. \n
|
||||
* "Load Image (from path)" is a node lets you load an image from any path in your computer, and a selection rectangle at the preview to crop it. It can also
|
||||
limit the output's size in megapixels. \n
|
||||
* "H3 Motion Context Clip Stitcher" is a node that concatenates H3 Motion Context clip archives. \n
|
||||
* "JsonPromptLoader" is a node that enhances a propt using a .json file with `item:prompt` pairs. \n
|
||||
* "Resolution Scale" is a node that resizes an image using pixels or scale factor. \n
|
||||
* "Regex Text Chopper" is a node that cuts text based on regex. \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.5.0"
|
||||
version = "1.6.0"
|
||||
license = {text = "MIT License"}
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// noinspection JSUnresolvedReference
|
||||
|
||||
import {app} from "../../scripts/app.js";
|
||||
import {api} from "../../scripts/api.js";
|
||||
|
||||
@@ -484,7 +486,8 @@ class FileBrowserDialog {
|
||||
const hasNext = idx >= 0 && idx < (this.fileList.length - 1);
|
||||
|
||||
previewArea.innerHTML = `
|
||||
<div style="display:flex; align-items:center; gap:6px;">
|
||||
<!--suppress ALL -->
|
||||
<div style="display:flex; align-items:center; gap:6px;">
|
||||
<button id="prevImg" ${hasPrev ? '' : 'disabled'}
|
||||
style="background:#444; border:none; color:white; width:28px; height:28px;
|
||||
border-radius:3px; cursor:${hasPrev ? 'pointer' : 'not-allowed'};
|
||||
@@ -521,9 +524,23 @@ class FileBrowserDialog {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive crop editor (adapted from obvpm/comfyui-obvpm, Apache-2.0)
|
||||
// No max_megapixels — crop only.
|
||||
// Crop, plus a max_megapixels downscale cap (downscale-only, aspect preserved).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return [newW, newH] if (w, h) exceeds maxMp megapixels, else null.
|
||||
* Downscale-only, aspect-preserving. 1.0 MP == 1024x1024 px. maxMp <= 0 disables it. */
|
||||
function computeDownscaledSize(w, h, maxMp) {
|
||||
const mp = parseFloat(maxMp);
|
||||
if (!mp || mp <= 0 || !w || !h) return null;
|
||||
const maxPixels = mp * 1024 * 1024;
|
||||
const current = w * h;
|
||||
if (current <= maxPixels) return null;
|
||||
const scale = Math.sqrt(maxPixels / current);
|
||||
const nw = Math.max(1, Math.round(w * scale));
|
||||
const nh = Math.max(1, Math.round(h * scale));
|
||||
return [nw, nh];
|
||||
}
|
||||
|
||||
const MARGIN = 10;
|
||||
const HANDLE = 8;
|
||||
const MIN_SEL = 6;
|
||||
@@ -546,6 +563,26 @@ app.registerExtension({
|
||||
|
||||
const imageWidget = node.widgets.find((w) => w.name === "image");
|
||||
const cropWidget = node.widgets.find((w) => w.name === "crop");
|
||||
// Looked up fresh (not cached) wherever it's read — if ComfyUI ever
|
||||
// replaces node.widgets rather than mutating it in place (reconfigure,
|
||||
// resize, Vue/Nodes 2.0 re-render), a cached reference here would go
|
||||
// stale and silently keep reading a detached widget forever.
|
||||
function getMaxMegapixels() {
|
||||
const w = node.widgets?.find((x) => x.name === "max_megapixels");
|
||||
return w ? parseFloat(w.value) : 0;
|
||||
}
|
||||
|
||||
// --- TEMPORARY DIAGNOSTIC ---------------------------------------
|
||||
// Logs once per distinct (dims, maxMp, result) combo to the browser
|
||||
// console so we can see exactly what the widget/math report at
|
||||
// draw time. Safe to delete once the label issue is confirmed fixed.
|
||||
let _lastDebugKey = null;
|
||||
function debugDownscale(label, w, h, maxMp, result) {
|
||||
const key = `${label}|${w}x${h}|${maxMp}|${result ? result.join("x") : "none"}`;
|
||||
if (key === _lastDebugKey) return;
|
||||
_lastDebugKey = key;
|
||||
}
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Hide the crop JSON widget (canvas + Nodes 2.0)
|
||||
if (cropWidget) {
|
||||
@@ -744,8 +781,17 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
const [pw, ph] = cropDims();
|
||||
const _maxMp1 = getMaxMegapixels();
|
||||
const cropDown = computeDownscaledSize(pw, ph, _maxMp1);
|
||||
debugDownscale("crop-pill", pw, ph, _maxMp1, cropDown);
|
||||
const cropSegments = cropDown
|
||||
? [
|
||||
["Downscaled to: ", "#fff"],
|
||||
[`${cropDown[0]} x ${cropDown[1]}`, "#4af"],
|
||||
]
|
||||
: [[`${pw} x ${ph}`, "#fff"]];
|
||||
drawPill(
|
||||
[[`${pw} x ${ph}`, "#fff"]],
|
||||
cropSegments,
|
||||
sy > y + pillH + 2 ? sy - 3 : sy + pillH - 1
|
||||
);
|
||||
}
|
||||
@@ -760,6 +806,20 @@ app.registerExtension({
|
||||
["Full: ", true],
|
||||
[`${iw} x ${ih}`, false],
|
||||
];
|
||||
// With no crop drawn, the full image IS the output — show the
|
||||
// megapixel cap here too, same as the per-crop pill does.
|
||||
if (!state.rect) {
|
||||
const _maxMp2 = getMaxMegapixels();
|
||||
const fullDown = computeDownscaledSize(iw, ih, _maxMp2);
|
||||
debugDownscale("full-label", iw, ih, _maxMp2, fullDown);
|
||||
if (fullDown) {
|
||||
segments.push([" — Downscaled to: ", true]);
|
||||
segments.push([
|
||||
`${fullDown[0]} x ${fullDown[1]}`,
|
||||
false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
ctx.font = `${u.font}px sans-serif`;
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.textAlign = "left";
|
||||
@@ -769,7 +829,14 @@ app.registerExtension({
|
||||
(sum, s) => sum + ctx.measureText(s[0]).width,
|
||||
0
|
||||
);
|
||||
let cx = x + (w - total) / 2;
|
||||
// Clamp instead of pure-centering (mirrors the crop pill's
|
||||
// clamp) — with the downscale text appended this line can
|
||||
// exceed the available width, and an unclamped center
|
||||
// pushes cx negative, drawing it clipped off the node.
|
||||
let cx = Math.max(
|
||||
x,
|
||||
Math.min(x + (w - total) / 2, x + w - total)
|
||||
);
|
||||
const prevAlpha = ctx.globalAlpha;
|
||||
for (const [text, muted] of segments) {
|
||||
ctx.globalAlpha = muted ? prevAlpha * MUTED_ALPHA : prevAlpha;
|
||||
@@ -1007,6 +1074,20 @@ app.registerExtension({
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
// Redraw the preview labels when the megapixel cap changes
|
||||
const maxMpWidgetForCallback = node.widgets.find(
|
||||
(w) => w.name === "max_megapixels"
|
||||
);
|
||||
if (maxMpWidgetForCallback) {
|
||||
const prevMaxMpCallback = maxMpWidgetForCallback.callback;
|
||||
maxMpWidgetForCallback.callback = function () {
|
||||
const r = prevMaxMpCallback?.apply(this, arguments);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
editorWidget.triggerDraw?.();
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
// Path typed / changed → clear crop and reload preview
|
||||
if (imageWidget) {
|
||||
const prevCallback = imageWidget.callback;
|
||||
|
||||
Reference in New Issue
Block a user