v3.0.6 Add Mask Repeat, Get File Count, Load Image From Folder, and Load Video From Folder nodes
This commit is contained in:
+9
-1
@@ -21,6 +21,10 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
|
||||
|
||||
## 📜 更新日志
|
||||
|
||||
**v3.0.6**
|
||||
- feat(mask): 添加 `Mask Repeat` 节点,支持遮罩批量重复与反转
|
||||
- feat(io): 添加 `Get File Count`、`Load Image From Folder`、`Load Video From Folder` 节点,优化文件加载逻辑与稳定性
|
||||
|
||||
**v3.0.5**
|
||||
- feat(image_tile): 合并 `Image Tile Split` 和 `Image Tile Split Preset` 节点为统一的 `Image Tile Split` 节点
|
||||
|
||||
@@ -388,6 +392,7 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
|
||||
| Mask Fill Hole | 填充遮罩中的封闭区域孔洞,支持批量处理 |
|
||||
| Mask Crop by BBox Mask | 基于蒙版区域的遮罩边界框裁剪 |
|
||||
| Mask Paste by BBox Mask | 简化遮罩粘贴,支持自动基础遮罩创建和边界框检测 |
|
||||
| Mask Repeat | 批量重复遮罩,支持反转功能 |
|
||||
|
||||
### 🔍 检测节点
|
||||
| 节点名称 | 功能描述 |
|
||||
@@ -470,9 +475,12 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
|
||||
|---------|----------|
|
||||
| Text Encode QwenImageEdit Keep Size | Qwen 图文编辑条件编码,支持多图视觉编码、尺寸保持策略与参考潜空间 |
|
||||
|
||||
### 💾 保存节点
|
||||
### 📁 IO 节点
|
||||
| 节点名称 | 功能描述 |
|
||||
|---------|----------|
|
||||
| Get File Count | 获取文件夹中指定类型(图像/视频)的文件数量,支持子文件夹递归 |
|
||||
| Load Image From Folder | 从文件夹加载指定索引的图像,支持批量处理和子文件夹 |
|
||||
| Load Video From Folder | 从文件夹加载指定索引的视频,返回 VIDEO 对象 |
|
||||
| Save Video | 保存视频到输出;支持空值输入,容器与编码器自动选择 |
|
||||
| Save Video RGBA | 将 images 保存为视频;支持 Alpha 预览与导出、可选音频、FPS 设置 |
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
|
||||
|
||||
## 📜 Changelog
|
||||
|
||||
**v3.0.6**
|
||||
- feat(mask): Add `Mask Repeat` node, supporting batch mask repetition and inversion
|
||||
- feat(io): Add `Get File Count`, `Load Image From Folder`, and `Load Video From Folder` nodes to optimize file loading logic and stability
|
||||
|
||||
**v3.0.5**
|
||||
- feat(image_tile): Merged `Image Tile Split` and `Image Tile Split Preset` nodes into a unified `Image Tile Split` node
|
||||
|
||||
@@ -388,6 +392,7 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
|
||||
| Mask Fill Hole | Fill holes in enclosed areas of masks with batch processing support |
|
||||
| Mask Crop by BBox Mask | Mask bounding box cropping based on mask regions |
|
||||
| Mask Paste by BBox Mask | Simplified mask pasting with automatic base mask creation and bounding box detection |
|
||||
| Mask Repeat | Batch repeat masks with optional inversion support |
|
||||
|
||||
### 🔍 Detection Nodes
|
||||
| Node Name | Description |
|
||||
@@ -470,9 +475,12 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
|
||||
|-----------|-------------|
|
||||
| Text Encode QwenImageEdit Keep Size | Qwen image-edit conditioning encoder combining vision inputs and text, supports size preservation modes and reference latents |
|
||||
|
||||
### 💾 Save Nodes
|
||||
### 📁 IO Nodes
|
||||
| Node Name | Description |
|
||||
|-----------|-------------|
|
||||
| Get File Count | Get file count of specified type (image/video) in folder, supports recursive subfolder search |
|
||||
| Load Image From Folder | Load image at specified index from folder, supports batch processing and subfolders |
|
||||
| Load Video From Folder | Load video at specified index from folder, returns VIDEO object |
|
||||
| Save Video | Save VIDEO to output; supports optional input, auto container/codec selection |
|
||||
| Save Video RGBA | Save images to video; supports alpha preview/output, optional audio, configurable FPS |
|
||||
|
||||
|
||||
@@ -24,21 +24,20 @@ class StringCoordinateToBBoxMask(io.ComfyNode):
|
||||
batch_size, height, width, channels = image.shape
|
||||
if not coordinates_string.strip():
|
||||
return io.NodeOutput(torch.zeros((batch_size, height, width), dtype=torch.float32))
|
||||
lines = coordinates_string.strip().split("\n")
|
||||
bbox_lines = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
# New parsing logic to support JSON-like multi-line formats and flattened lists
|
||||
cleaned_string = coordinates_string.replace("[", " ").replace("]", " ").replace("(", " ").replace(")", " ").replace(",", " ")
|
||||
parts = cleaned_string.split()
|
||||
all_coords = []
|
||||
for part in parts:
|
||||
try:
|
||||
all_coords.append(int(float(part)))
|
||||
except ValueError:
|
||||
continue
|
||||
line = line.replace("[", "").replace("]", "").replace("(", "").replace(")", "")
|
||||
coords = []
|
||||
for part in line.replace(",", " ").split():
|
||||
try:
|
||||
coords.append(int(float(part)))
|
||||
except ValueError:
|
||||
continue
|
||||
if len(coords) >= 4:
|
||||
bbox_lines.append(coords[:4])
|
||||
|
||||
bbox_lines = []
|
||||
for i in range(0, len(all_coords), 4):
|
||||
if i + 4 <= len(all_coords):
|
||||
bbox_lines.append(all_coords[i:i+4])
|
||||
if not bbox_lines:
|
||||
return io.NodeOutput(torch.zeros((batch_size, height, width), dtype=torch.float32))
|
||||
if output_mode == "separate":
|
||||
|
||||
@@ -20,21 +20,22 @@ class StringCoordinateToBBoxes(io.ComfyNode):
|
||||
def execute(cls, coordinates_string: str) -> io.NodeOutput:
|
||||
if not coordinates_string.strip():
|
||||
return io.NodeOutput([[]])
|
||||
lines = coordinates_string.strip().split("\n")
|
||||
bboxes = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
|
||||
# New parsing logic to support JSON-like multi-line formats and flattened lists
|
||||
cleaned_string = coordinates_string.replace("[", " ").replace("]", " ").replace("(", " ").replace(")", " ").replace(",", " ")
|
||||
parts = cleaned_string.split()
|
||||
all_coords = []
|
||||
for part in parts:
|
||||
try:
|
||||
all_coords.append(int(float(part)))
|
||||
except ValueError:
|
||||
continue
|
||||
line = line.replace("[", "").replace("]", "").replace("(", "").replace(")", "")
|
||||
coords = []
|
||||
for part in line.replace(",", " ").split():
|
||||
try:
|
||||
coords.append(int(float(part)))
|
||||
except ValueError:
|
||||
continue
|
||||
if len(coords) >= 4:
|
||||
bboxes.append(coords[:4])
|
||||
|
||||
bboxes = []
|
||||
for i in range(0, len(all_coords), 4):
|
||||
if i + 4 <= len(all_coords):
|
||||
bboxes.append(all_coords[i:i+4])
|
||||
|
||||
if not bboxes:
|
||||
return io.NodeOutput([[]])
|
||||
sam2_bboxes = [bboxes]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from comfy_api.latest import io, ui
|
||||
from comfy_api.latest import io
|
||||
import asyncio
|
||||
import numpy as np
|
||||
import os
|
||||
@@ -97,7 +97,7 @@ class ImageBBoxOverlayByMask(io.ComfyNode):
|
||||
|
||||
out_t = torch.stack(out_imgs, dim=0).to(torch.float32).clamp(0.0, 1.0)
|
||||
out_t = out_t.to(image.device)
|
||||
return io.NodeOutput(out_t, ui=ui.PreviewImage(out_t, cls=cls))
|
||||
return io.NodeOutput(out_t)
|
||||
|
||||
@staticmethod
|
||||
def draw_bbox(draw, x_min, y_min, x_max, y_max, color, stroke_width, fill):
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
from comfy_api.latest import io
|
||||
import math
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
class ImageResizeJimeng(io.ComfyNode):
|
||||
PRESET_RESOLUTIONS = [
|
||||
# 1k_ratios
|
||||
("[1k] 2016×864 (21:9)", 2016, 864),
|
||||
("[1k] 1664×936 (16:9)", 1664, 936),
|
||||
("[1k] 1584×1056 (3:2)", 1584, 1056),
|
||||
("[1k] 1472×1104 (4:3)", 1472, 1104),
|
||||
("[1k] 1328×1328 (1:1)", 1328, 1328),
|
||||
("[1k] 1104×1472 (3:4)", 1104, 1472),
|
||||
("[1k] 1056×1584 (2:3)", 1056, 1584),
|
||||
("[1k] 936×1664 (9:16)", 936, 1664),
|
||||
# 2k_ratios
|
||||
("[2k] 3024×1296 (21:9)", 3024, 1296),
|
||||
("[2k] 2560×1440 (16:9)", 2560, 1440),
|
||||
("[2k] 2496×1664 (3:2)", 2496, 1664),
|
||||
("[2k] 2304×1728 (4:3)", 2304, 1728),
|
||||
("[2k] 2048×2048 (1:1)", 2048, 2048),
|
||||
("[2k] 1728×2304 (3:4)", 1728, 2304),
|
||||
("[2k] 1664×2496 (2:3)", 1664, 2496),
|
||||
("[2k] 1440×2560 (9:16)", 1440, 2560),
|
||||
# 4k_ratios
|
||||
("[4k] 6197×2656 (21:9)", 6197, 2656),
|
||||
("[4k] 5404×3040 (16:9)", 5404, 3040),
|
||||
("[4k] 4992×3328 (3:2)", 4992, 3328),
|
||||
("[4k] 4693×3520 (4:3)", 4693, 3520),
|
||||
("[4k] 4096×4096 (1:1)", 4096, 4096),
|
||||
("[4k] 3520×4693 (3:4)", 3520, 4693),
|
||||
("[4k] 3328×4992 (2:3)", 3328, 4992),
|
||||
("[4k] 3040×5404 (9:16)", 3040, 5404),
|
||||
# 2.0_pro_ratios
|
||||
("[2.0_pro] 1195×512 (21:9)", 1195, 512),
|
||||
("[2.0_pro] 1024×576 (16:9)", 1024, 576),
|
||||
("[2.0_pro] 1024×682 (3:2)", 1024, 682),
|
||||
("[2.0_pro] 1024×768 (4:3)", 1024, 768),
|
||||
("[2.0_pro] 1024×1024 (1:1)", 1024, 1024),
|
||||
("[2.0_pro] 768×1024 (3:4)", 768, 1024),
|
||||
("[2.0_pro] 682×1024 (2:3)", 682, 1024),
|
||||
("[2.0_pro] 576×1024 (9:16)", 576, 1024),
|
||||
]
|
||||
PRESET_OPTIONS = ["auto", "auto (1k | 2k)", "auto (2k | 4k)"] + [name for name, _, _ in PRESET_RESOLUTIONS]
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="1hew_ImageResizeJimeng",
|
||||
display_name="Image Resize Jimeng",
|
||||
category="1hewNodes/image",
|
||||
inputs=[
|
||||
io.Combo.Input("preset_size", options=cls.PRESET_OPTIONS, default="auto (2k | 4k)"),
|
||||
io.Combo.Input("fit", options=["crop", "pad", "stretch"], default="crop"),
|
||||
io.String.Input("pad_color", default="1.0"),
|
||||
io.Image.Input("image", optional=True),
|
||||
io.Mask.Input("mask", optional=True),
|
||||
],
|
||||
outputs=[
|
||||
io.Image.Output(display_name="image"),
|
||||
io.Mask.Output(display_name="mask"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_inputs(
|
||||
cls,
|
||||
preset_size: str,
|
||||
fit: str,
|
||||
pad_color: str,
|
||||
image: torch.Tensor | None = None,
|
||||
mask: torch.Tensor | None = None,
|
||||
):
|
||||
if preset_size not in cls.PRESET_OPTIONS:
|
||||
return "invalid preset_size"
|
||||
if fit not in ("crop", "pad", "stretch"):
|
||||
return "invalid fit"
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def fingerprint_inputs(
|
||||
cls,
|
||||
preset_size: str,
|
||||
fit: str,
|
||||
pad_color: str,
|
||||
image: torch.Tensor | None = None,
|
||||
mask: torch.Tensor | None = None,
|
||||
):
|
||||
ib = int(image.shape[0]) if isinstance(image, torch.Tensor) else 0
|
||||
ih = int(image.shape[1]) if isinstance(image, torch.Tensor) else 0
|
||||
iw = int(image.shape[2]) if isinstance(image, torch.Tensor) else 0
|
||||
mb = int(mask.shape[0]) if isinstance(mask, torch.Tensor) else 0
|
||||
mh = int(mask.shape[1]) if isinstance(mask, torch.Tensor) else 0
|
||||
mw = int(mask.shape[2]) if isinstance(mask, torch.Tensor) else 0
|
||||
return f"{preset_size}|fit={fit}|pad={pad_color}|img={ib}x{ih}x{iw}|mask={mb}x{mh}x{mw}"
|
||||
|
||||
@classmethod
|
||||
def _find_best_resolution(cls, iw: int, ih: int, candidates: list) -> tuple[int, int]:
|
||||
ar = iw / ih
|
||||
|
||||
# Calculate aspect ratio difference in log space
|
||||
ar_candidates = []
|
||||
for _, w, h in candidates:
|
||||
ar_diff = abs(math.log(ar) - math.log(w / h))
|
||||
ar_candidates.append((ar_diff, w, h))
|
||||
|
||||
# Find best aspect ratio matches within tolerance
|
||||
min_ar_diff = min(c[0] for c in ar_candidates)
|
||||
TOLERANCE = 0.02 # Allow small tolerance for aspect ratio matching
|
||||
best_ar_candidates = [c for c in ar_candidates if c[0] <= min_ar_diff + TOLERANCE]
|
||||
|
||||
# Among best AR matches, find closest area
|
||||
area_in = iw * ih
|
||||
best_match = min(best_ar_candidates, key=lambda x: abs(math.log(area_in) - math.log(x[1] * x[2])))
|
||||
return best_match[1], best_match[2]
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
preset_size: str,
|
||||
fit: str,
|
||||
pad_color: str,
|
||||
image: torch.Tensor | None = None,
|
||||
mask: torch.Tensor | None = None,
|
||||
) -> io.NodeOutput:
|
||||
if preset_size in ("auto", "auto (1k | 2k)", "auto (2k | 4k)"):
|
||||
if isinstance(image, torch.Tensor):
|
||||
iw = max(int(image.shape[2]), 1)
|
||||
ih = max(int(image.shape[1]), 1)
|
||||
|
||||
resolutions = cls.PRESET_RESOLUTIONS
|
||||
if preset_size == "auto (1k | 2k)":
|
||||
resolutions = [r for r in cls.PRESET_RESOLUTIONS if r[0].startswith("[1k]") or r[0].startswith("[2k]")]
|
||||
elif preset_size == "auto (2k | 4k)":
|
||||
resolutions = [r for r in cls.PRESET_RESOLUTIONS if r[0].startswith("[2k]") or r[0].startswith("[4k]")]
|
||||
|
||||
width, height = cls._find_best_resolution(iw, ih, resolutions)
|
||||
|
||||
elif isinstance(mask, torch.Tensor):
|
||||
iw = max(int(mask.shape[2]), 1)
|
||||
ih = max(int(mask.shape[1]), 1)
|
||||
|
||||
resolutions = cls.PRESET_RESOLUTIONS
|
||||
if preset_size == "auto (1k | 2k)":
|
||||
resolutions = [r for r in cls.PRESET_RESOLUTIONS if r[0].startswith("[1k]") or r[0].startswith("[2k]")]
|
||||
elif preset_size == "auto (2k | 4k)":
|
||||
resolutions = [r for r in cls.PRESET_RESOLUTIONS if r[0].startswith("[2k]") or r[0].startswith("[4k]")]
|
||||
|
||||
width, height = cls._find_best_resolution(iw, ih, resolutions)
|
||||
else:
|
||||
width, height = 1328, 1328
|
||||
else:
|
||||
found = [p for p in cls.PRESET_RESOLUTIONS if p[0] == preset_size]
|
||||
if found:
|
||||
_, width, height = found[0]
|
||||
else:
|
||||
width, height = 1328, 1328
|
||||
|
||||
device = None
|
||||
if isinstance(image, torch.Tensor):
|
||||
device = image.device
|
||||
elif isinstance(mask, torch.Tensor):
|
||||
device = mask.device
|
||||
|
||||
tw, th = int(width), int(height)
|
||||
pc = cls._parse_pad_color(pad_color)
|
||||
|
||||
if not isinstance(image, torch.Tensor) and not isinstance(mask, torch.Tensor):
|
||||
rgb = (1.0, 1.0, 1.0) if isinstance(pc, str) else pc
|
||||
base = torch.ones((1, th, tw, 3), dtype=torch.float32)
|
||||
color_t = torch.tensor(rgb, dtype=torch.float32)
|
||||
out_img = base * color_t.view(1, 1, 1, 3)
|
||||
out_msk = torch.ones((1, th, tw), dtype=torch.float32)
|
||||
if device is not None:
|
||||
out_img = out_img.to(device)
|
||||
out_msk = out_msk.to(device)
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
|
||||
if isinstance(image, torch.Tensor) and (not isinstance(mask, torch.Tensor)):
|
||||
b, h, w, _ = image.shape
|
||||
if fit == "stretch":
|
||||
img_nchw = image.permute(0, 3, 1, 2)
|
||||
resized = F.interpolate(img_nchw, size=(th, tw), mode="bicubic", align_corners=False).permute(0, 2, 3, 1)
|
||||
out_img = torch.clamp(resized, 0.0, 1.0).to(torch.float32)
|
||||
out_msk = torch.ones((b, th, tw), dtype=torch.float32, device=image.device)
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
if fit == "pad":
|
||||
sw = tw / max(w, 1)
|
||||
sh = th / max(h, 1)
|
||||
scale = min(sw, sh)
|
||||
new_w = max(int(round(w * scale)), 1)
|
||||
new_h = max(int(round(h * scale)), 1)
|
||||
img_nchw = image.permute(0, 3, 1, 2)
|
||||
resized = F.interpolate(img_nchw, size=(new_h, new_w), mode="bicubic", align_corners=False).permute(0, 2, 3, 1)
|
||||
out_img = cls._pad_to_rgb(resized, th, tw, pc)
|
||||
top = max((th - new_h) // 2, 0)
|
||||
left = max((tw - new_w) // 2, 0)
|
||||
out_msk = torch.zeros((b, th, tw), dtype=torch.float32, device=out_img.device)
|
||||
out_msk[:, top : top + new_h, left : left + new_w] = 1.0
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
ta = tw / max(th, 1)
|
||||
oa = w / max(h, 1)
|
||||
if oa > ta:
|
||||
cw = max(int(round(h * ta)), 1)
|
||||
ch = h
|
||||
else:
|
||||
ch = max(int(round(w / ta)), 1)
|
||||
cw = w
|
||||
left = max((w - cw) // 2, 0)
|
||||
top = max((h - ch) // 2, 0)
|
||||
cropped = image[:, top : top + ch, left : left + cw, :]
|
||||
img_nchw = cropped.permute(0, 3, 1, 2)
|
||||
resized = F.interpolate(img_nchw, size=(th, tw), mode="bicubic", align_corners=False).permute(0, 2, 3, 1)
|
||||
out_img = torch.clamp(resized, 0.0, 1.0).to(torch.float32)
|
||||
out_msk = torch.zeros((b, h, w), dtype=torch.float32, device=image.device)
|
||||
out_msk[:, top : top + ch, left : left + cw] = 1.0
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
|
||||
if (not isinstance(image, torch.Tensor)) and isinstance(mask, torch.Tensor):
|
||||
mb, mh, mw = int(mask.shape[0]), int(mask.shape[1]), int(mask.shape[2])
|
||||
if fit == "stretch":
|
||||
m4 = mask.unsqueeze(1)
|
||||
out_msk = F.interpolate(m4, size=(th, tw), mode="nearest").squeeze(1)
|
||||
out_msk = torch.clamp(out_msk, 0.0, 1.0).to(torch.float32)
|
||||
rgb = (1.0, 1.0, 1.0) if isinstance(pc, str) else pc
|
||||
base = torch.ones((1, th, tw, 3), dtype=torch.float32)
|
||||
color_t = torch.tensor(rgb, dtype=torch.float32)
|
||||
out_img = base * color_t.view(1, 1, 1, 3)
|
||||
if device is not None:
|
||||
out_img = out_img.to(device)
|
||||
out_msk = out_msk.to(device)
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
if fit == "pad":
|
||||
sw = tw / max(mw, 1)
|
||||
sh = th / max(mh, 1)
|
||||
scale = min(sw, sh)
|
||||
new_w = max(int(round(mw * scale)), 1)
|
||||
new_h = max(int(round(mh * scale)), 1)
|
||||
m4 = mask.unsqueeze(1)
|
||||
resized_m = F.interpolate(m4, size=(new_h, new_w), mode="nearest").squeeze(1)
|
||||
top = max((th - new_h) // 2, 0)
|
||||
left = max((tw - new_w) // 2, 0)
|
||||
out_msk = torch.zeros((mb, th, tw), dtype=torch.float32, device=resized_m.device)
|
||||
out_msk[:, top : top + new_h, left : left + new_w] = torch.clamp(resized_m, 0.0, 1.0)
|
||||
rgb = (1.0, 1.0, 1.0) if isinstance(pc, str) else pc
|
||||
base = torch.ones((1, th, tw, 3), dtype=torch.float32)
|
||||
color_t = torch.tensor(rgb, dtype=torch.float32)
|
||||
out_img = base * color_t.view(1, 1, 1, 3)
|
||||
if device is not None:
|
||||
out_img = out_img.to(device)
|
||||
out_msk = out_msk.to(device)
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
ta = tw / max(th, 1)
|
||||
oa = mw / max(mh, 1)
|
||||
if oa > ta:
|
||||
cw = max(int(round(mh * ta)), 1)
|
||||
ch = mh
|
||||
else:
|
||||
ch = max(int(round(mw / ta)), 1)
|
||||
cw = mw
|
||||
left = max((mw - cw) // 2, 0)
|
||||
top = max((mh - ch) // 2, 0)
|
||||
rgb = (1.0, 1.0, 1.0) if isinstance(pc, str) else pc
|
||||
base = torch.ones((1, th, tw, 3), dtype=torch.float32)
|
||||
color_t = torch.tensor(rgb, dtype=torch.float32)
|
||||
out_img = base * color_t.view(1, 1, 1, 3)
|
||||
cropped_m = mask[:, top : top + ch, left : left + cw]
|
||||
m4 = cropped_m.unsqueeze(1)
|
||||
out_msk = F.interpolate(m4, size=(th, tw), mode="nearest").squeeze(1)
|
||||
out_msk = torch.clamp(out_msk, 0.0, 1.0).to(torch.float32)
|
||||
if device is not None:
|
||||
out_img = out_img.to(device)
|
||||
out_msk = out_msk.to(device)
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
|
||||
b, h, w, _ = image.shape
|
||||
m = cls._ensure_mask_3d(mask)
|
||||
if fit == "stretch":
|
||||
img_nchw = image.permute(0, 3, 1, 2)
|
||||
out_img = F.interpolate(img_nchw, size=(th, tw), mode="bicubic", align_corners=False).permute(0, 2, 3, 1)
|
||||
out_img = torch.clamp(out_img, 0.0, 1.0).to(torch.float32)
|
||||
if isinstance(m, torch.Tensor):
|
||||
m4 = m.unsqueeze(1)
|
||||
out_msk = F.interpolate(m4, size=(th, tw), mode="nearest").squeeze(1)
|
||||
out_msk = torch.clamp(out_msk, 0.0, 1.0).to(torch.float32)
|
||||
else:
|
||||
out_msk = torch.ones((b, th, tw), dtype=torch.float32, device=image.device)
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
if fit == "pad":
|
||||
sw = tw / max(w, 1)
|
||||
sh = th / max(h, 1)
|
||||
scale = min(sw, sh)
|
||||
new_w = max(int(round(w * scale)), 1)
|
||||
new_h = max(int(round(h * scale)), 1)
|
||||
img_nchw = image.permute(0, 3, 1, 2)
|
||||
resized = F.interpolate(img_nchw, size=(new_h, new_w), mode="bicubic", align_corners=False).permute(0, 2, 3, 1)
|
||||
out_img = cls._pad_to_rgb(resized, th, tw, pc)
|
||||
top = max((th - new_h) // 2, 0)
|
||||
left = max((tw - new_w) // 2, 0)
|
||||
out_msk = torch.zeros((b, th, tw), dtype=torch.float32, device=out_img.device)
|
||||
if isinstance(m, torch.Tensor):
|
||||
m4 = m.unsqueeze(1)
|
||||
m_res = F.interpolate(m4, size=(new_h, new_w), mode="nearest").squeeze(1)
|
||||
out_msk[:, top : top + new_h, left : left + new_w] = torch.clamp(m_res, 0.0, 1.0)
|
||||
else:
|
||||
out_msk[:, top : top + new_h, left : left + new_w] = 1.0
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
ta = tw / max(th, 1)
|
||||
oa = w / max(h, 1)
|
||||
if oa > ta:
|
||||
cw = max(int(round(h * ta)), 1)
|
||||
ch = h
|
||||
else:
|
||||
ch = max(int(round(w / ta)), 1)
|
||||
cw = w
|
||||
left = max((w - cw) // 2, 0)
|
||||
top = max((h - ch) // 2, 0)
|
||||
cropped_img = image[:, top : top + ch, left : left + cw, :]
|
||||
img_nchw = cropped_img.permute(0, 3, 1, 2)
|
||||
out_img = F.interpolate(img_nchw, size=(th, tw), mode="bicubic", align_corners=False).permute(0, 2, 3, 1)
|
||||
out_img = torch.clamp(out_img, 0.0, 1.0).to(torch.float32)
|
||||
if isinstance(m, torch.Tensor):
|
||||
cropped_m = m[:, top : top + ch, left : left + cw]
|
||||
m4 = cropped_m.unsqueeze(1)
|
||||
out_msk = F.interpolate(m4, size=(th, tw), mode="nearest").squeeze(1)
|
||||
out_msk = torch.clamp(out_msk, 0.0, 1.0).to(torch.float32)
|
||||
else:
|
||||
out_msk = torch.ones((b, th, tw), dtype=torch.float32, device=image.device)
|
||||
return io.NodeOutput(out_img, out_msk)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_mask_3d(mask):
|
||||
if mask is None:
|
||||
return None
|
||||
if mask.dim() == 4:
|
||||
if mask.shape[1] == 1:
|
||||
return mask.squeeze(1)
|
||||
return mask[:, 0, :, :]
|
||||
if mask.dim() == 2:
|
||||
return mask.unsqueeze(0)
|
||||
return mask
|
||||
|
||||
@staticmethod
|
||||
def _parse_pad_color(color_str):
|
||||
if color_str is None:
|
||||
return (1.0, 1.0, 1.0)
|
||||
text = str(color_str).strip().lower()
|
||||
if text in ("edge", "e"):
|
||||
return "edge"
|
||||
if text in ("extend", "ex"):
|
||||
return "extend"
|
||||
if text in ("mirror", "mr"):
|
||||
return "mirror"
|
||||
if text in ("average", "a"):
|
||||
return "average"
|
||||
if text.startswith("(") and text.endswith(")"):
|
||||
text = text[1:-1].strip()
|
||||
single = {
|
||||
"r": "red",
|
||||
"g": "green",
|
||||
"b": "blue",
|
||||
"c": "cyan",
|
||||
"m": "magenta",
|
||||
"y": "yellow",
|
||||
"k": "black",
|
||||
"w": "white",
|
||||
"o": "orange",
|
||||
"p": "purple",
|
||||
"n": "brown",
|
||||
"s": "silver",
|
||||
"l": "lime",
|
||||
"i": "indigo",
|
||||
"v": "violet",
|
||||
"t": "turquoise",
|
||||
"q": "aqua",
|
||||
"f": "fuchsia",
|
||||
"h": "hotpink",
|
||||
"d": "darkblue",
|
||||
}
|
||||
if len(text) == 1 and text in single:
|
||||
text = single[text]
|
||||
try:
|
||||
v = float(text)
|
||||
if 0.0 <= v <= 1.0:
|
||||
return (v, v, v)
|
||||
except Exception:
|
||||
pass
|
||||
if "," in text:
|
||||
try:
|
||||
parts = [p.strip() for p in text.split(",")]
|
||||
if len(parts) >= 3:
|
||||
r, g, b = [float(parts[i]) for i in range(3)]
|
||||
if max(r, g, b) <= 1.0:
|
||||
return (r, g, b)
|
||||
return (r / 255.0, g / 255.0, b / 255.0)
|
||||
except Exception:
|
||||
pass
|
||||
if text.startswith("#") and len(text) in (4, 7):
|
||||
try:
|
||||
hex_str = text[1:]
|
||||
if len(hex_str) == 3:
|
||||
hex_str = "".join(ch * 2 for ch in hex_str)
|
||||
r = int(hex_str[0:2], 16) / 255.0
|
||||
g = int(hex_str[2:4], 16) / 255.0
|
||||
b = int(hex_str[4:6], 16) / 255.0
|
||||
return (r, g, b)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from PIL import ImageColor
|
||||
rgb = ImageColor.getrgb(text)
|
||||
return (rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0)
|
||||
except Exception:
|
||||
return (1.0, 1.0, 1.0)
|
||||
|
||||
@staticmethod
|
||||
def _pad_to_rgb(img, target_h, target_w, fill_rgb):
|
||||
b, h, w, c = img.shape
|
||||
top = max((target_h - h) // 2, 0)
|
||||
left = max((target_w - w) // 2, 0)
|
||||
h_end = min(top + h, target_h)
|
||||
w_end = min(left + w, target_w)
|
||||
if isinstance(fill_rgb, str) and fill_rgb == "extend":
|
||||
pad_top = top
|
||||
pad_bottom = max(target_h - (top + h), 0)
|
||||
pad_left = left
|
||||
pad_right = max(target_w - (left + w), 0)
|
||||
nchw = img.permute(0, 3, 1, 2)
|
||||
padded = F.pad(nchw, (pad_left, pad_right, pad_top, pad_bottom), mode="replicate")
|
||||
return padded.permute(0, 2, 3, 1)
|
||||
if isinstance(fill_rgb, str) and fill_rgb == "mirror":
|
||||
pad_top = top
|
||||
pad_bottom = max(target_h - (top + h), 0)
|
||||
pad_left = left
|
||||
pad_right = max(target_w - (left + w), 0)
|
||||
nchw = img.permute(0, 3, 1, 2)
|
||||
while pad_top > 0 or pad_bottom > 0 or pad_left > 0 or pad_right > 0:
|
||||
hh = int(nchw.shape[2])
|
||||
ww = int(nchw.shape[3])
|
||||
st = min(pad_top, max(hh - 1, 0))
|
||||
sb = min(pad_bottom, max(hh - 1, 0))
|
||||
sl = min(pad_left, max(ww - 1, 0))
|
||||
sr = min(pad_right, max(ww - 1, 0))
|
||||
nchw = F.pad(nchw, (sl, sr, st, sb), mode="reflect")
|
||||
pad_top -= st
|
||||
pad_bottom -= sb
|
||||
pad_left -= sl
|
||||
pad_right -= sr
|
||||
return nchw.permute(0, 2, 3, 1)
|
||||
out = torch.zeros((b, target_h, target_w, c), dtype=img.dtype, device=img.device)
|
||||
if isinstance(fill_rgb, str) and fill_rgb == "edge":
|
||||
top_row = img[:, 0:1, :, :].mean(dim=(1, 2))
|
||||
top_color = top_row.view(b, 1, 1, c)
|
||||
bottom_row = img[:, -1:, :, :].mean(dim=(1, 2))
|
||||
bottom_color = bottom_row.view(b, 1, 1, c)
|
||||
if top > 0:
|
||||
out[:, :top, :, :] = top_color.expand(b, top, target_w, c)
|
||||
if (target_h - h_end) > 0:
|
||||
bottom_pad = target_h - h_end
|
||||
out[:, h_end:, :, :] = bottom_color.expand(b, bottom_pad, target_w, c)
|
||||
left_col = img[:, :, 0:1, :].mean(dim=(1, 2))
|
||||
left_color = left_col.view(b, 1, 1, c)
|
||||
right_col = img[:, :, -1:, :].mean(dim=(1, 2))
|
||||
right_color = right_col.view(b, 1, 1, c)
|
||||
if left > 0:
|
||||
out[:, :, :left, :] = left_color.expand(b, target_h, left, c)
|
||||
if (target_w - w_end) > 0:
|
||||
right_pad = target_w - w_end
|
||||
out[:, :, w_end:, :] = right_color.expand(b, target_h, right_pad, c)
|
||||
elif isinstance(fill_rgb, str) and fill_rgb == "average":
|
||||
avg = img.mean(dim=(1, 2))
|
||||
avg_color = avg.view(b, 1, 1, c)
|
||||
out[:] = avg_color.expand(b, target_h, target_w, c)
|
||||
else:
|
||||
fill_t = torch.tensor(fill_rgb, dtype=img.dtype, device=img.device)
|
||||
out[:] = fill_t
|
||||
out[:, top:h_end, left:w_end, :] = img[:, : h_end - top, : w_end - left, :]
|
||||
return out
|
||||
@@ -18,7 +18,8 @@ class ImageGridSplit(io.ComfyNode):
|
||||
io.Image.Input("image"),
|
||||
io.Int.Input("rows", default=2, min=1, max=10, step=1),
|
||||
io.Int.Input("columns", default=2, min=1, max=10, step=1),
|
||||
io.Int.Input("output_index", default=0, min=0, max=100, step=1),
|
||||
io.Int.Input("index", default=0, min=-100, max=100, step=1),
|
||||
io.Boolean.Input("all", default=False),
|
||||
],
|
||||
outputs=[io.Image.Output(display_name="image")],
|
||||
)
|
||||
@@ -29,7 +30,8 @@ class ImageGridSplit(io.ComfyNode):
|
||||
image: torch.Tensor,
|
||||
rows: int,
|
||||
columns: int,
|
||||
output_index: int,
|
||||
index: int,
|
||||
all: bool,
|
||||
) -> io.NodeOutput:
|
||||
"""
|
||||
将图片按宫格分割
|
||||
@@ -38,8 +40,8 @@ class ImageGridSplit(io.ComfyNode):
|
||||
image: 输入图片张量 (batch, height, width, channels)
|
||||
rows: 分割行数
|
||||
columns: 分割列数
|
||||
output_index: 输出索引,0表示所有分割后的图片按批次输出,
|
||||
1表示第1张,2表示第2张,以此类推(横向优先)
|
||||
index: 输出索引,支持 Python 风格索引(如 0 为第一个,-1 为最后一个)
|
||||
all: 是否输出所有分割后的图片。若为 True,则 index 参数无效。
|
||||
|
||||
Returns:
|
||||
分割后的图片张量
|
||||
@@ -54,10 +56,6 @@ class ImageGridSplit(io.ComfyNode):
|
||||
# 总的网格数量
|
||||
total_grids = rows * columns
|
||||
|
||||
# 验证输出索引
|
||||
if output_index > total_grids:
|
||||
raise ValueError(f"输出索引 {output_index} 超出范围,最大值为 {total_grids}")
|
||||
|
||||
async def _split_one(batch_idx):
|
||||
def _do():
|
||||
cur = image[batch_idx]
|
||||
@@ -75,19 +73,28 @@ class ImageGridSplit(io.ComfyNode):
|
||||
parts = await asyncio.gather(*[_split_one(b) for b in range(batch_size)])
|
||||
all_split_images = [img for sub in parts for img in sub]
|
||||
|
||||
# 根据输出索引返回结果
|
||||
if output_index == 0:
|
||||
if all:
|
||||
# 返回所有分割后的图片
|
||||
result = torch.stack(all_split_images, dim=0)
|
||||
else:
|
||||
# 处理索引
|
||||
if index < 0:
|
||||
index += total_grids
|
||||
|
||||
if index < 0 or index >= total_grids:
|
||||
raise ValueError(f"索引 {index} 超出范围,有效范围为 0 到 {total_grids - 1} (或 -{total_grids} 到 -1)")
|
||||
|
||||
selected_images = []
|
||||
for batch_idx in range(batch_size):
|
||||
grid_idx = (output_index - 1) % total_grids
|
||||
actual_idx = batch_idx * total_grids + grid_idx
|
||||
# 计算当前批次中对应的网格索引
|
||||
actual_idx = batch_idx * total_grids + index
|
||||
if actual_idx < len(all_split_images):
|
||||
selected_images.append(all_split_images[actual_idx])
|
||||
else:
|
||||
# 理论上不应该走到这里,因为上面已经做了范围检查
|
||||
selected_images.append(all_split_images[0])
|
||||
result = torch.stack(selected_images, dim=0)
|
||||
|
||||
return io.NodeOutput(result)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -11,7 +11,7 @@ class IntImageSideLength(io.ComfyNode):
|
||||
category="1hewNodes/int",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
io.Combo.Input("mode", options=["longest", "shortest", "width", "height"], default="longest"),
|
||||
io.Combo.Input("mode", options=["longest", "shortest", "width", "height"], default="shortest"),
|
||||
],
|
||||
outputs=[io.Int.Output(display_name="int")],
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ class IntMaskSideLength(io.ComfyNode):
|
||||
category="1hewNodes/int",
|
||||
inputs=[
|
||||
io.Mask.Input("mask"),
|
||||
io.Combo.Input("mode", options=["longest", "shortest", "width", "height"], default="longest"),
|
||||
io.Combo.Input("mode", options=["longest", "shortest", "width", "height"], default="shortest"),
|
||||
],
|
||||
outputs=[io.Int.Output(display_name="int")],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import hashlib
|
||||
from comfy_api.latest import io
|
||||
|
||||
class GetFileCount(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="1hew_GetFileCount",
|
||||
display_name="Get File Count",
|
||||
category="1hewNodes/io",
|
||||
inputs=[
|
||||
io.String.Input("folder", default=""),
|
||||
io.Combo.Input("type", default="image", options=["image", "video"]),
|
||||
io.Boolean.Input("include_subfolder", default=True),
|
||||
],
|
||||
outputs=[
|
||||
io.Int.Output(display_name="count"),
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_paths(folder, include_subfolder, extensions):
|
||||
if not os.path.isdir(folder):
|
||||
return []
|
||||
|
||||
paths = []
|
||||
if include_subfolder:
|
||||
for root, dirs, files in os.walk(folder):
|
||||
for file in files:
|
||||
if os.path.splitext(file)[1].lower() in extensions:
|
||||
paths.append(os.path.join(root, file))
|
||||
else:
|
||||
for file in os.listdir(folder):
|
||||
path = os.path.join(folder, file)
|
||||
if os.path.isfile(path) and os.path.splitext(file)[1].lower() in extensions:
|
||||
paths.append(path)
|
||||
|
||||
# Ensure consistent order
|
||||
paths.sort(key=lambda x: x.lower())
|
||||
return paths
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, folder: str, type: str, include_subfolder: bool, **kwargs):
|
||||
if not os.path.isdir(folder):
|
||||
return float("nan")
|
||||
|
||||
if type == "image":
|
||||
extensions = {'.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.webp'}
|
||||
elif type == "video":
|
||||
extensions = {'.webm', '.mp4', '.mkv', '.gif', '.mov', '.avi'}
|
||||
else:
|
||||
extensions = set()
|
||||
|
||||
paths = cls.get_paths(folder, include_subfolder, extensions)
|
||||
m = hashlib.sha256()
|
||||
# Only hash paths (existence), not mtime, because count only changes if files are added/removed
|
||||
# But if we want to be safe, maybe hash paths is enough.
|
||||
# However, if a file is replaced by another with same name, count is same.
|
||||
# If file is renamed, count is same.
|
||||
# But usually GetFileCount is used to know how many files to iterate.
|
||||
# So maybe just returning count is enough for IS_CHANGED?
|
||||
# No, IS_CHANGED return value is compared. If it returns the same count, the node is NOT re-executed.
|
||||
# But execute is trivial (len(paths)).
|
||||
# The important thing is if downstream nodes rely on this.
|
||||
# Actually, if count is same, output is same. So re-executing doesn't change output.
|
||||
# So IS_CHANGED returning count is actually optimal?
|
||||
# Wait, if I replace "a.jpg" with "b.jpg", count is same. Downstream might be iterating 0..N.
|
||||
# If downstream uses LoadImageFromFolder with index, and LoadImageFromFolder has its own IS_CHANGED, then it handles it.
|
||||
# So GetFileCount only needs to update if count changes?
|
||||
# Yes, technically correct.
|
||||
# But let's be safe and detect content changes too in case someone uses it for triggering.
|
||||
# I'll stick to full path hashing.
|
||||
|
||||
for path in paths:
|
||||
m.update(path.encode())
|
||||
|
||||
return m.hexdigest()
|
||||
|
||||
@classmethod
|
||||
def execute(cls, folder: str, type: str, include_subfolder: bool) -> io.NodeOutput:
|
||||
if type == "image":
|
||||
# ComfyUI typically supports these image formats
|
||||
extensions = {'.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.webp'}
|
||||
elif type == "video":
|
||||
# ComfyUI typically supports these video formats
|
||||
extensions = {'.webm', '.mp4', '.mkv', '.mov', '.avi'}
|
||||
else:
|
||||
extensions = set()
|
||||
|
||||
paths = cls.get_paths(folder, include_subfolder, extensions)
|
||||
return io.NodeOutput(len(paths))
|
||||
@@ -0,0 +1,356 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
import torch.nn.functional as F
|
||||
from PIL import Image, ImageOps
|
||||
from comfy_api.latest import io, ui
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import math
|
||||
from io import BytesIO
|
||||
|
||||
import hashlib
|
||||
|
||||
class LoadImageFromFolder(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="1hew_LoadImageFromFolder",
|
||||
display_name="Load Image From Folder",
|
||||
category="1hewNodes/io",
|
||||
inputs=[
|
||||
io.Image.Input("get_image_size", optional=True),
|
||||
io.String.Input("folder", default=""),
|
||||
io.Int.Input("index", default=0, min=-8192, max=8192, step=1),
|
||||
io.Boolean.Input("include_subfolder", default=True),
|
||||
io.Boolean.Input("all", default=False),
|
||||
],
|
||||
outputs=[
|
||||
io.Image.Output(display_name="image"),
|
||||
io.Mask.Output(display_name="mask"),
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def load_image(path):
|
||||
img = Image.open(path)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
|
||||
mask = None
|
||||
if 'A' in img.getbands():
|
||||
mask_np = np.array(img.getchannel('A')).astype(np.float32) / 255.0
|
||||
mask = torch.from_numpy(mask_np)
|
||||
else:
|
||||
mask = torch.ones((img.height, img.width), dtype=torch.float32)
|
||||
|
||||
img = img.convert("RGB")
|
||||
return img, mask
|
||||
|
||||
@staticmethod
|
||||
def pil2tensor(image):
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
@staticmethod
|
||||
def tensor2pil(image):
|
||||
return Image.fromarray(np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||||
|
||||
@staticmethod
|
||||
def crop_and_resize(tensor, target_h, target_w):
|
||||
"""
|
||||
先按比例裁剪(Center Crop),再缩放到目标尺寸。
|
||||
输入 tensor shape: [1, H, W, C]
|
||||
"""
|
||||
if tensor.shape[1] == target_h and tensor.shape[2] == target_w:
|
||||
return tensor
|
||||
|
||||
# 转换为 PIL 以利用 Pillow 的处理逻辑,或者手动计算 tensor 操作
|
||||
# 这里为了简单复用逻辑,可以手动计算 tensor 的切片
|
||||
|
||||
curr_h, curr_w = tensor.shape[1], tensor.shape[2]
|
||||
curr_ratio = curr_w / curr_h
|
||||
target_ratio = target_w / target_h
|
||||
|
||||
# Permute to [B, C, H, W] for processing
|
||||
t = tensor.permute(0, 3, 1, 2)
|
||||
|
||||
if curr_ratio > target_ratio:
|
||||
# 原图更宽,以高为基准,裁剪宽度
|
||||
# new_w = curr_h * target_ratio
|
||||
# 实际上我们需要保留的高度就是 curr_h
|
||||
# 需要保留的宽度
|
||||
crop_w = int(curr_h * target_ratio)
|
||||
crop_h = curr_h
|
||||
else:
|
||||
# 原图更高,以宽为基准,裁剪高度
|
||||
crop_w = curr_w
|
||||
crop_h = int(curr_w / target_ratio)
|
||||
|
||||
# Center Crop
|
||||
start_x = (curr_w - crop_w) // 2
|
||||
start_y = (curr_h - crop_h) // 2
|
||||
|
||||
# [B, C, H, W]
|
||||
t = t[:, :, start_y:start_y+crop_h, start_x:start_x+crop_w]
|
||||
|
||||
# Resize
|
||||
t = F.interpolate(t, size=(target_h, target_w), mode="bilinear", align_corners=False)
|
||||
|
||||
# Permute back to [B, H, W, C]
|
||||
return t.permute(0, 2, 3, 1)
|
||||
|
||||
@staticmethod
|
||||
def get_image_paths(folder, include_subfolder):
|
||||
if not os.path.isdir(folder):
|
||||
return []
|
||||
|
||||
valid_extensions = {'.png', '.jpg', '.jpeg', '.bmp', '.webp', '.tiff', '.gif'}
|
||||
image_paths = []
|
||||
|
||||
if include_subfolder:
|
||||
for root, dirs, files in os.walk(folder):
|
||||
for file in files:
|
||||
if os.path.splitext(file)[1].lower() in valid_extensions:
|
||||
image_paths.append(os.path.join(root, file))
|
||||
else:
|
||||
for file in os.listdir(folder):
|
||||
path = os.path.join(folder, file)
|
||||
if os.path.isfile(path) and os.path.splitext(file)[1].lower() in valid_extensions:
|
||||
image_paths.append(path)
|
||||
|
||||
# Case-insensitive sort for cross-platform consistency
|
||||
image_paths.sort(key=lambda x: x.lower())
|
||||
return image_paths
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, folder, include_subfolder, **kwargs):
|
||||
if not os.path.isdir(folder):
|
||||
return float("nan")
|
||||
|
||||
image_paths = cls.get_image_paths(folder, include_subfolder)
|
||||
m = hashlib.sha256()
|
||||
for path in image_paths:
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
m.update(f"{path}:{mtime}".encode())
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return m.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def crop_and_resize_pil(pil_img, target_w, target_h):
|
||||
"""
|
||||
PIL 版本的 crop_and_resize,保持与 tensor 版本逻辑一致。
|
||||
"""
|
||||
curr_w, curr_h = pil_img.size
|
||||
if curr_w == target_w and curr_h == target_h:
|
||||
return pil_img
|
||||
|
||||
curr_ratio = curr_w / curr_h
|
||||
target_ratio = target_w / target_h
|
||||
|
||||
if curr_ratio > target_ratio:
|
||||
# 原图更宽,裁剪宽度
|
||||
crop_w = int(curr_h * target_ratio)
|
||||
crop_h = curr_h
|
||||
else:
|
||||
# 原图更高,裁剪高度
|
||||
crop_w = curr_w
|
||||
crop_h = int(curr_w / target_ratio)
|
||||
|
||||
# Center Crop
|
||||
left = (curr_w - crop_w) // 2
|
||||
top = (curr_h - crop_h) // 2
|
||||
right = left + crop_w
|
||||
bottom = top + crop_h
|
||||
|
||||
img_cropped = pil_img.crop((left, top, right, bottom))
|
||||
img_resized = img_cropped.resize((target_w, target_h), Image.Resampling.BILINEAR)
|
||||
return img_resized
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, folder: str, index: int, all: bool, include_subfolder: bool, get_image_size: torch.Tensor | None = None) -> io.NodeOutput:
|
||||
image_paths = cls.get_image_paths(folder, include_subfolder)
|
||||
count = len(image_paths)
|
||||
|
||||
if count == 0:
|
||||
return io.NodeOutput(None, None)
|
||||
|
||||
target_h = 0
|
||||
target_w = 0
|
||||
|
||||
# 如果提供了参考图片,优先使用参考图片的尺寸
|
||||
if get_image_size is not None:
|
||||
target_h, target_w = get_image_size.shape[1:3]
|
||||
|
||||
if all:
|
||||
images_tensors = []
|
||||
masks_tensors = []
|
||||
|
||||
# 如果没有参考图片,加载第一张图片来确定基准尺寸
|
||||
start_idx = 0
|
||||
if target_h == 0 or target_w == 0:
|
||||
try:
|
||||
first_img, first_mask = cls.load_image(image_paths[0])
|
||||
first_tensor = cls.pil2tensor(first_img)
|
||||
first_mask_tensor = first_mask.unsqueeze(0).unsqueeze(-1) # [1, H, W, 1]
|
||||
|
||||
target_h, target_w = first_tensor.shape[1:3]
|
||||
images_tensors.append(first_tensor)
|
||||
masks_tensors.append(first_mask_tensor)
|
||||
start_idx = 1
|
||||
except Exception as e:
|
||||
print(f"Error loading first image {image_paths[0]}: {e}")
|
||||
return io.NodeOutput(None, None)
|
||||
|
||||
for path in image_paths[start_idx:]:
|
||||
try:
|
||||
img, mask = cls.load_image(path)
|
||||
tensor = cls.pil2tensor(img)
|
||||
mask_tensor = mask.unsqueeze(0).unsqueeze(-1)
|
||||
|
||||
# 裁剪并缩放
|
||||
tensor = cls.crop_and_resize(tensor, target_h, target_w)
|
||||
mask_tensor = cls.crop_and_resize(mask_tensor, target_h, target_w)
|
||||
|
||||
images_tensors.append(tensor)
|
||||
masks_tensors.append(mask_tensor)
|
||||
except Exception as e:
|
||||
print(f"Error loading image {path}: {e}")
|
||||
|
||||
if not images_tensors:
|
||||
return io.NodeOutput(None, None)
|
||||
|
||||
output_image = torch.cat(images_tensors, dim=0)
|
||||
output_mask = torch.cat(masks_tensors, dim=0).squeeze(-1) # [B, H, W]
|
||||
|
||||
else:
|
||||
idx = index % count
|
||||
path = image_paths[idx]
|
||||
try:
|
||||
img, mask = cls.load_image(path)
|
||||
tensor = cls.pil2tensor(img)
|
||||
mask_tensor = mask.unsqueeze(0).unsqueeze(-1)
|
||||
|
||||
# 如果有参考尺寸,单张图片也进行裁剪缩放
|
||||
if target_h > 0 and target_w > 0:
|
||||
tensor = cls.crop_and_resize(tensor, target_h, target_w)
|
||||
mask_tensor = cls.crop_and_resize(mask_tensor, target_h, target_w)
|
||||
|
||||
output_image = tensor
|
||||
output_mask = mask_tensor.squeeze(-1) # [1, H, W]
|
||||
except Exception as e:
|
||||
print(f"Error loading image {path}: {e}")
|
||||
return io.NodeOutput(None, None)
|
||||
|
||||
return io.NodeOutput(output_image, output_mask, ui=ui.PreviewImage(output_image, cls=cls))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/1hew/view_image_from_folder")
|
||||
async def view_image_from_folder(request):
|
||||
folder = request.query.get("folder")
|
||||
index_str = request.query.get("index", "0")
|
||||
include_subfolder = request.query.get("include_subfolder", "true").lower() == "true"
|
||||
all_images = request.query.get("all", "false").lower() == "true"
|
||||
return_list = request.query.get("return_list", "false").lower() == "true"
|
||||
|
||||
if not folder or not os.path.isdir(folder):
|
||||
return web.Response(status=404)
|
||||
|
||||
try:
|
||||
index = int(index_str)
|
||||
except ValueError:
|
||||
index = 0
|
||||
|
||||
image_paths = LoadImageFromFolder.get_image_paths(folder, include_subfolder)
|
||||
if not image_paths:
|
||||
return web.Response(status=404)
|
||||
|
||||
if return_list:
|
||||
# 返回图片列表信息
|
||||
return web.json_response({
|
||||
"count": len(image_paths),
|
||||
"paths": image_paths
|
||||
})
|
||||
|
||||
if all_images:
|
||||
# 批量预览模式:生成网格图
|
||||
# 限制预览数量,避免过慢
|
||||
max_preview = 200 # 增加到 200 张
|
||||
paths_to_show = image_paths[:max_preview]
|
||||
|
||||
if not paths_to_show:
|
||||
return web.Response(status=404)
|
||||
|
||||
images = []
|
||||
|
||||
# 确定基准尺寸:读取第一张图
|
||||
try:
|
||||
first_img, _ = LoadImageFromFolder.load_image(paths_to_show[0])
|
||||
# 计算预览用的目标尺寸,限制最大边长,但保持长宽比
|
||||
base_w, base_h = first_img.size
|
||||
max_side = 256
|
||||
scale = min(max_side / base_w, max_side / base_h)
|
||||
# 至少为 1
|
||||
if scale > 1: scale = 1
|
||||
|
||||
target_w = int(base_w * scale)
|
||||
target_h = int(base_h * scale)
|
||||
|
||||
# 处理第一张图
|
||||
images.append(LoadImageFromFolder.crop_and_resize_pil(first_img, target_w, target_h))
|
||||
|
||||
# 处理后续图片
|
||||
for p in paths_to_show[1:]:
|
||||
try:
|
||||
img, _ = LoadImageFromFolder.load_image(p)
|
||||
# 使用与 execute 相同的 crop_and_resize 逻辑
|
||||
processed_img = LoadImageFromFolder.crop_and_resize_pil(img, target_w, target_h)
|
||||
images.append(processed_img)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Preview error: {e}")
|
||||
return web.Response(status=404)
|
||||
|
||||
if not images:
|
||||
return web.Response(status=404)
|
||||
|
||||
# 计算网格行列
|
||||
count = len(images)
|
||||
cols = math.ceil(math.sqrt(count))
|
||||
rows = math.ceil(count / cols)
|
||||
|
||||
# 创建网格背景
|
||||
# 既然所有图片都 resize 到了 target_w, target_h,这里直接用
|
||||
cell_w = target_w
|
||||
cell_h = target_h
|
||||
# ComfyUI 原生 Preview Image 如果是 batch,通常是紧密排列或有很小间距
|
||||
# 这里为了对齐效果,我们不加额外大间距,或者只加一点点
|
||||
spacing = 0
|
||||
|
||||
grid_w = cols * cell_w + (cols - 1) * spacing
|
||||
grid_h = rows * cell_h + (rows - 1) * spacing
|
||||
|
||||
# 使用黑色背景,类似 ComfyUI
|
||||
grid_img = Image.new('RGB', (grid_w, grid_h), (0, 0, 0))
|
||||
|
||||
for i, img in enumerate(images):
|
||||
r = i // cols
|
||||
c = i % cols
|
||||
x = c * (cell_w + spacing)
|
||||
y = r * (cell_h + spacing)
|
||||
grid_img.paste(img, (x, y))
|
||||
|
||||
# 保存到内存流
|
||||
stream = BytesIO()
|
||||
grid_img.save(stream, format="JPEG", quality=85)
|
||||
return web.Response(body=stream.getvalue(), content_type='image/jpeg')
|
||||
|
||||
# 单张预览模式
|
||||
idx = index % len(image_paths)
|
||||
path = image_paths[idx]
|
||||
|
||||
return web.FileResponse(path)
|
||||
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import hashlib
|
||||
import torch
|
||||
import numpy as np
|
||||
import av
|
||||
from PIL import Image
|
||||
import torch.nn.functional as F
|
||||
from comfy_api.latest import io, ui
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import shutil
|
||||
|
||||
class VideoComponents:
|
||||
def __init__(self, images, audio, frame_rate):
|
||||
self.images = images
|
||||
self.audio = audio
|
||||
self.frame_rate = frame_rate
|
||||
|
||||
class VideoFromFile:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def get_dimensions(self):
|
||||
try:
|
||||
container = av.open(self.path)
|
||||
if len(container.streams.video) > 0:
|
||||
stream = container.streams.video[0]
|
||||
return stream.width, stream.height
|
||||
except Exception:
|
||||
pass
|
||||
return 0, 0
|
||||
|
||||
def save_to(self, path, format="auto", codec="auto", metadata=None):
|
||||
# Since it's already a video file, we can just copy it
|
||||
# Note: format/codec conversion is not implemented here for simplicity
|
||||
# If format conversion is strictly required, we would need to transcode
|
||||
shutil.copy2(self.path, path)
|
||||
|
||||
def get_components(self):
|
||||
path = self.path
|
||||
try:
|
||||
container = av.open(path)
|
||||
except Exception as e:
|
||||
print(f"Error opening video {path}: {e}")
|
||||
return None
|
||||
|
||||
# Video
|
||||
frames = []
|
||||
video_info = {}
|
||||
|
||||
if len(container.streams.video) > 0:
|
||||
stream = container.streams.video[0]
|
||||
stream.thread_type = "AUTO"
|
||||
|
||||
# Metadata
|
||||
try:
|
||||
fps = float(stream.average_rate)
|
||||
except Exception:
|
||||
fps = 0.0
|
||||
|
||||
video_info = {
|
||||
"source_fps": float(stream.average_rate),
|
||||
"source_frame_count": int(stream.frames),
|
||||
"source_duration": float(stream.duration * stream.time_base) if stream.duration else 0,
|
||||
"fps": fps,
|
||||
"width": stream.width,
|
||||
"height": stream.height
|
||||
}
|
||||
|
||||
# Decoding
|
||||
try:
|
||||
for frame in container.decode(stream):
|
||||
# Convert
|
||||
# We always use RGB for consistency with standard LoadVideo
|
||||
img_np = frame.to_ndarray(format='rgb24')
|
||||
frames.append(img_np)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error decoding video frames {path}: {e}")
|
||||
|
||||
if not frames:
|
||||
return None
|
||||
|
||||
video_tensor = torch.from_numpy(np.stack(frames)).float() / 255.0
|
||||
|
||||
# Audio
|
||||
audio = None
|
||||
if len(container.streams.audio) > 0:
|
||||
stream = container.streams.audio[0]
|
||||
try:
|
||||
# Seek to start for audio decoding
|
||||
container.seek(0)
|
||||
audio_frames = []
|
||||
for frame in container.decode(stream):
|
||||
# Convert to float32 planar
|
||||
# PyAV's AudioFrame.to_ndarray does not accept 'format' argument like VideoFrame
|
||||
# It returns numpy array in the frame's native format
|
||||
data = frame.to_ndarray()
|
||||
|
||||
# Convert to float32 if needed and handle layout
|
||||
if data.dtype != np.float32:
|
||||
data = data.astype(np.float32)
|
||||
|
||||
# If format is planar (e.g. fltp), data is [planes, samples]
|
||||
# If format is packed (e.g. flt), data is [samples, channels]
|
||||
# We need [channels, samples]
|
||||
|
||||
if stream.format.is_planar:
|
||||
# Already [channels, samples] or close to it
|
||||
pass
|
||||
else:
|
||||
# [samples, channels] -> [channels, samples]
|
||||
if len(data.shape) > 1:
|
||||
data = data.T
|
||||
else:
|
||||
# Mono [samples] -> [1, samples]
|
||||
data = data[None, :]
|
||||
|
||||
audio_frames.append(data)
|
||||
|
||||
if audio_frames:
|
||||
# Concatenate along samples (axis 1)
|
||||
audio_data = np.concatenate(audio_frames, axis=1)
|
||||
|
||||
# Convert to tensor: [channels, samples]
|
||||
waveform = torch.from_numpy(audio_data).float()
|
||||
|
||||
# Add batch dimension: [1, channels, samples]
|
||||
waveform = waveform.unsqueeze(0)
|
||||
|
||||
audio = {
|
||||
"waveform": waveform,
|
||||
"sample_rate": stream.rate
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error decoding audio {path}: {e}")
|
||||
|
||||
# Return VideoComponents object instead of dict
|
||||
return VideoComponents(
|
||||
images=video_tensor,
|
||||
audio=audio,
|
||||
frame_rate=video_info["fps"]
|
||||
)
|
||||
|
||||
class LoadVideoFromFolder(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="1hew_LoadVideoFromFolder",
|
||||
display_name="Load Video From Folder",
|
||||
category="1hewNodes/io",
|
||||
inputs=[
|
||||
io.String.Input("folder", default=""),
|
||||
io.Int.Input("index", default=0, min=-8192, max=8192, step=1),
|
||||
io.Boolean.Input("include_subfolder", default=True),
|
||||
],
|
||||
outputs=[
|
||||
io.Video.Output(display_name="VIDEO"),
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_video_paths(folder, include_subfolder):
|
||||
if not os.path.isdir(folder):
|
||||
return []
|
||||
|
||||
valid_extensions = {'.webm', '.mp4', '.mkv', '.mov', '.avi'}
|
||||
video_paths = []
|
||||
|
||||
if include_subfolder:
|
||||
for root, dirs, files in os.walk(folder):
|
||||
for file in files:
|
||||
if os.path.splitext(file)[1].lower() in valid_extensions:
|
||||
video_paths.append(os.path.join(root, file))
|
||||
else:
|
||||
for file in os.listdir(folder):
|
||||
path = os.path.join(folder, file)
|
||||
if os.path.isfile(path) and os.path.splitext(file)[1].lower() in valid_extensions:
|
||||
video_paths.append(path)
|
||||
|
||||
# Case-insensitive sort for cross-platform consistency
|
||||
video_paths.sort(key=lambda x: x.lower())
|
||||
return video_paths
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, folder, include_subfolder, **kwargs):
|
||||
if not os.path.isdir(folder):
|
||||
return float("nan")
|
||||
|
||||
video_paths = cls.get_video_paths(folder, include_subfolder)
|
||||
m = hashlib.sha256()
|
||||
for path in video_paths:
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
m.update(f"{path}:{mtime}".encode())
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return m.hexdigest()
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, folder: str, index: int, include_subfolder: bool) -> io.NodeOutput:
|
||||
video_paths = cls.get_video_paths(folder, include_subfolder)
|
||||
count = len(video_paths)
|
||||
|
||||
if count == 0:
|
||||
return io.NodeOutput(None)
|
||||
|
||||
idx = index % count
|
||||
path = video_paths[idx]
|
||||
|
||||
return io.NodeOutput(VideoFromFile(path))
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/1hew/view_video_from_folder")
|
||||
async def view_video_from_folder(request):
|
||||
folder = request.query.get("folder")
|
||||
index_str = request.query.get("index", "0")
|
||||
include_subfolder = request.query.get("include_subfolder", "true").lower() == "true"
|
||||
|
||||
if not folder or not os.path.isdir(folder):
|
||||
return web.Response(status=404)
|
||||
|
||||
try:
|
||||
index = int(index_str)
|
||||
except ValueError:
|
||||
index = 0
|
||||
|
||||
video_paths = LoadVideoFromFolder.get_video_paths(folder, include_subfolder)
|
||||
if not video_paths:
|
||||
return web.Response(status=404)
|
||||
|
||||
idx = index % len(video_paths)
|
||||
path = video_paths[idx]
|
||||
|
||||
return web.FileResponse(path)
|
||||
|
||||
@@ -17,7 +17,7 @@ class SaveVideo(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="1hew_SaveVideo",
|
||||
display_name="Save Video",
|
||||
category="1hewNodes/save",
|
||||
category="1hewNodes/io",
|
||||
description="保存视频到输出;支持空值输入。",
|
||||
inputs=[
|
||||
io.Video.Input("video", optional=True, tooltip="要保存的视频;为空时节点直接通过。"),
|
||||
@@ -21,7 +21,7 @@ class SaveVideoRGBA(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="1hew_SaveVideoRGBA",
|
||||
display_name="Save Video RGBA",
|
||||
category="1hewNodes/save",
|
||||
category="1hewNodes/io",
|
||||
inputs=[
|
||||
io.Image.Input("images"),
|
||||
io.Float.Input(
|
||||
@@ -0,0 +1,34 @@
|
||||
import torch
|
||||
from comfy_api.latest import io
|
||||
|
||||
|
||||
class MaskRepeat(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="1hew_MaskRepeat",
|
||||
display_name="Mask Repeat",
|
||||
category="1hewNodes/mask",
|
||||
inputs=[
|
||||
io.Mask.Input("mask"),
|
||||
io.Boolean.Input("invert", default=False),
|
||||
io.Int.Input("count", default=1, min=1, max=4096),
|
||||
],
|
||||
outputs=[io.Mask.Output(display_name="mask")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, mask: torch.Tensor, count: int, invert: bool):
|
||||
if mask.dim() == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
|
||||
if invert:
|
||||
mask = 1.0 - mask
|
||||
|
||||
s = mask.shape
|
||||
# [B, H, W] -> [1, B, H, W] -> [count, B, H, W]
|
||||
repeated = mask.unsqueeze(0).repeat(count, 1, 1, 1)
|
||||
# -> [count*B, H, W]
|
||||
output = repeated.reshape((-1, s[1], s[2]))
|
||||
|
||||
return io.NodeOutput(output)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ComfyUI-1hewNodes"
|
||||
description = "This is a custom node collection for ComfyUI that provides some utility nodes."
|
||||
version = "3.0.5"
|
||||
version = "3.0.6"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Get File Count
|
||||
|
||||
**Node Purpose:** Counts the number of files of a specific type (Image or Video) in a folder.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Port | Type | Default | Range | Description |
|
||||
| ---- | ---- | ---- | ------- | ----- | ----------- |
|
||||
| `folder` | - | STRING | "" | - | The absolute path to the folder to check. |
|
||||
| `type` | - | COMBO | "image" | "image" / "video" | The type of files to count. |
|
||||
| `include_subfolder` | - | BOOLEAN | True | - | Whether to search in subfolders. |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `count` | INT | The total count of matching files found. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Format Filtering**: Counts files based on selected type:
|
||||
- **Image**: `.png`, `.jpg`, `.jpeg`, `.bmp`, `.tiff`, `.webp`
|
||||
- **Video**: `.webm`, `.mp4`, `.mkv`, `.gif`, `.mov`, `.avi`
|
||||
- **Utility**: Useful for iterating through datasets or validating folder contents before processing.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Get File Count - 获取文件数量
|
||||
|
||||
**节点功能:** 统计文件夹中指定类型(图像或视频)的文件数量。
|
||||
|
||||
## 输入
|
||||
|
||||
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
|
||||
| -------- | -------- | -------- | ------ | -------- | ---- |
|
||||
| `folder` | - | STRING | "" | - | 要检查的文件夹绝对路径。 |
|
||||
| `type` | - | COMBO | "image" | "image" / "video" | 要统计的文件类型。 |
|
||||
| `include_subfolder` | - | BOOLEAN | True | - | 是否搜索子文件夹。 |
|
||||
|
||||
## 输出
|
||||
|
||||
| 输出名称 | 数据类型 | 描述 |
|
||||
|---------|----------|------|
|
||||
| `count` | INT | 找到的匹配文件总数。 |
|
||||
|
||||
## 功能说明
|
||||
|
||||
- **格式过滤**:根据选择的类型统计文件:
|
||||
- **图像 (Image)**:`.png`, `.jpg`, `.jpeg`, `.bmp`, `.tiff`, `.webp`
|
||||
- **视频 (Video)**:`.webm`, `.mp4`, `.mkv`, `.gif`, `.mov`, `.avi`
|
||||
- **用途**:适用于遍历数据集或在处理前验证文件夹内容。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Image Grid Split - Grid-Based Image Tiling
|
||||
# Image Grid Split - Grid-Based Image Tiling
|
||||
|
||||
**Node Purpose:** `Image Grid Split` divides each image into a grid of `rows × columns` tiles and outputs either all tiles as a batch or a single tile per image based on `output_index`.
|
||||
**Node Purpose:** `Image Grid Split` divides each image into a grid of `rows × columns` tiles. You can output all tiles as a batch or select a specific tile per image using `index`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
| `image` | - | IMAGE | - | - | Input image batch. |
|
||||
| `rows` | - | INT | 2 | 1–10 | Number of rows to split. |
|
||||
| `columns` | - | INT | 2 | 1–10 | Number of columns to split. |
|
||||
| `output_index` | - | INT | 0 | 0–100 | 0: output all tiles. >0: output the Nth tile per image (row-major order).
|
||||
| `index` | - | INT | 0 | -100–100 | Python-style index to select a specific tile per image (e.g., 0 for first, -1 for last). Ignored if `all` is True. |
|
||||
| `all` | - | BOOL | False | - | If True, outputs all tiles from the split. If False, outputs only the tile selected by `index`. |
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -20,15 +21,16 @@
|
||||
## Features
|
||||
|
||||
- Deterministic tiling: computes tile sizes via integer division.
|
||||
- Row-major ordering: tiles are ordered by rows, then columns; `output_index=1` selects the first tile.
|
||||
- Batch handling: for `output_index=0`, all tiles across the batch are stacked; otherwise one tile per input is returned.
|
||||
- Row-major ordering: tiles are ordered by rows, then columns.
|
||||
- Python-style indexing: Use `index` to select tiles (0-based, negative indexing supported).
|
||||
- Batch handling: Toggle `all` to True to stack all tiles across the batch; otherwise one tile per input is returned.
|
||||
|
||||
## Typical Usage
|
||||
|
||||
- Patch processing: split large images into manageable tiles for model inference; set `output_index=0` to process all.
|
||||
- Targeted tile: set `output_index` to a specific tile index when only a particular region is needed per image.
|
||||
- Patch processing: split large images into manageable tiles for model inference; set `all=True` to process all.
|
||||
- Targeted tile: set `all=False` and specify `index` to retrieve a particular region from each image.
|
||||
|
||||
## Notes & Tips
|
||||
|
||||
- Ensure image dimensions are divisible by `rows` and `columns` for exact tiling; otherwise the last pixels in each axis are dropped due to integer division.
|
||||
- RGBA inputs are converted to RGB tiles internally to ensure consistent shape.
|
||||
- RGBA inputs are converted to RGB tiles internally to ensure consistent shape.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Image Grid Split - 图片宫格分割器
|
||||
# Image Grid Split - 图片宫格分割器
|
||||
|
||||
**节点功能:** `Image Grid Split` 将图像按 `rows × columns` 宫格分割,并根据 `output_index` 输出全部子图批次或每张图的单个子图。
|
||||
**节点功能:** `Image Grid Split` 将图像按 `rows × columns` 宫格分割。你可以通过开启 `all` 输出所有子图,或者通过 `index` 选择每张图的特定子图。
|
||||
|
||||
## 输入
|
||||
|
||||
@@ -9,14 +9,28 @@
|
||||
| `image` | - | IMAGE | - | - | 输入图像批次 |
|
||||
| `rows` | - | INT | 2 | 1–10 | 分割行数 |
|
||||
| `columns` | - | INT | 2 | 1–10 | 分割列数 |
|
||||
| `output_index` | - | INT | 0 | 0–100 | 0:输出所有子图;>0:每张图输出第 N 个子图。
|
||||
| `index` | - | INT | 0 | -100–100 | 输出索引,支持 Python 风格索引(如 0 为第一个,-1 为最后一个)。当 `all` 为 True 时此参数无效。 |
|
||||
| `all` | - | BOOL | False | - | 是否输出所有子图。若为 True,则输出分割后的所有图片;若为 False,则仅输出由 `index` 指定的图片。 |
|
||||
|
||||
## 输出
|
||||
|
||||
| 名称 | 类型 | 描述 |
|
||||
|------|------|-------------|
|
||||
| `image` | IMAGE | 分割后的子图批次,或每张输入图像对应的选定子图。
|
||||
|
||||
## 特性
|
||||
|
||||
- 确定性分割:通过整数除法计算网格尺寸。
|
||||
- 行优先排序:子图按先行后列的顺序排列。
|
||||
- Python 风格索引:使用 `index` 选择子图(支持 0 为起始,支持负数索引)。
|
||||
- 批次处理:开启 `all` 可堆叠输出所有子图;否则每张输入图仅返回一张选定子图。
|
||||
|
||||
## 典型用法
|
||||
|
||||
- Patch 推理:将大图拆分为网格子块以进行模型推理;设 `output_index=0` 输出全部。
|
||||
- 定位子块:当仅需每张图某个区域时,设定 `output_index` 为该子图索引。
|
||||
- Patch 推理:将大图拆分为网格子块以进行模型推理;设 `all=True` 输出全部。
|
||||
- 定位子块:设 `all=False` 并指定 `index`,以获取每张图的特定区域。
|
||||
|
||||
## 注意与建议
|
||||
|
||||
- 为获得严格对齐,建议确保图像尺寸可被 `rows` 与 `columns` 整除;否则每轴末尾像素会因整数除法被舍去。
|
||||
- RGBA 输入会在内部转换为 RGB 子图以统一形状。
|
||||
- RGBA 输入会在内部转换为 RGB 子图以统一形状。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Image Resize Jimeng - Jimeng Standard Resizer
|
||||
|
||||
**Node Purpose:** `Image Resize Jimeng` is designed to resize images and masks to standard resolutions commonly used by Jimeng and other generation models (1k, 2k, 4k, 2.0 pro). It simplifies the workflow by providing preset resolutions and intelligent auto-matching based on input aspect ratios.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Port | Type | Default | Range | Description |
|
||||
| ---- | ---- | ---- | ------- | ----- | ----------- |
|
||||
| `preset_size` | - | COMBO | `auto (2k \| 4k)` | `auto` / `1k` / `2k` / `4k` / `2.0_pro` presets | Target resolution preset. `auto` modes match input aspect ratio to the closest standard resolution. |
|
||||
| `fit` | - | COMBO | `crop` | `crop` / `pad` / `stretch` | Fit mode: determines how the image adapts to the target resolution. |
|
||||
| `pad_color` | - | STRING | `1.0` | float / hex / rgb | Padding background color used when `fit` is set to `pad`. |
|
||||
| `image` | optional | IMAGE | - | - | Input image batch to be resized. |
|
||||
| `mask` | optional | MASK | - | - | Input mask batch to be resized. |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `image` | IMAGE | Resized image batch. |
|
||||
| `mask` | MASK | Resized mask batch. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Standard Presets**: Built-in support for 1k, 2k, 4k, and 2.0 pro resolution standards, covering common aspect ratios (21:9, 16:9, 3:2, 4:3, 1:1, etc.).
|
||||
- **Auto Matching**:
|
||||
- `auto`: Matches input to the closest resolution across all presets.
|
||||
- `auto (1k | 2k)`: Restricts matching to 1k and 2k presets.
|
||||
- `auto (2k | 4k)`: Restricts matching to 2k and 4k presets.
|
||||
- **Fit Modes**:
|
||||
- `crop`: Center crops the image to fill the target resolution, preserving aspect ratio.
|
||||
- `pad`: Scales the image to fit within the target resolution and pads the empty space, preserving aspect ratio.
|
||||
- `stretch`: Stretches the image to exactly match the target resolution, ignoring aspect ratio.
|
||||
- **Padding Control**: Custom `pad_color` support (e.g., `1.0` for white, `0.0` for black, or specific color codes) ensures flexibility for different model requirements.
|
||||
|
||||
## Typical Usage
|
||||
|
||||
- **Preparing for Generation**: Select a specific preset like `[2k] 2048x2048 (1:1)` to ensure your input image strictly matches the model's preferred training resolution.
|
||||
- **Batch Processing**: Use `auto` mode to process a batch of images with varying aspect ratios, automatically resizing each to its nearest standard equivalent.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Image Resize Jimeng - 即梦标准缩放器
|
||||
|
||||
**节点功能:** `Image Resize Jimeng` 专为将图像和遮罩调整为即梦(Jimeng)及其他生成模型常用的标准分辨率(1k、2k、4k、2.0 pro)而设计。它通过提供分辨率预设和基于输入宽高比的智能自动匹配,简化了工作流。
|
||||
|
||||
## 输入
|
||||
|
||||
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
|
||||
| -------- | -------- | -------- | ------ | -------- | ---- |
|
||||
| `preset_size` | - | COMBO | `auto (2k \| 4k)` | `auto` / `1k` / `2k` / `4k` / `2.0_pro` 预设 | 目标分辨率预设。`auto` 模式会将输入宽高比匹配到最接近的标准分辨率。 |
|
||||
| `fit` | - | COMBO | `crop` | `crop` / `pad` / `stretch` | 适应模式:决定图像如何适应目标分辨率。 |
|
||||
| `pad_color` | - | STRING | `1.0` | 浮点数 / hex / rgb | 当 `fit` 设置为 `pad` 时使用的填充背景颜色。 |
|
||||
| `image` | 可选 | IMAGE | - | - | 待缩放的输入图像批次。 |
|
||||
| `mask` | 可选 | MASK | - | - | 待缩放的输入遮罩批次。 |
|
||||
|
||||
## 输出
|
||||
|
||||
| 输出名称 | 数据类型 | 描述 |
|
||||
|---------|----------|------|
|
||||
| `image` | IMAGE | 缩放后的图像批次。 |
|
||||
| `mask` | MASK | 缩放后的遮罩批次。 |
|
||||
|
||||
## 功能说明
|
||||
|
||||
- **标准预设**:内置支持 1k、2k、4k 和 2.0 pro 分辨率标准,覆盖常用宽高比(21:9, 16:9, 3:2, 4:3, 1:1 等)。
|
||||
- **自动匹配**:
|
||||
- `auto`:在所有预设中匹配最接近输入宽高比的分辨率。
|
||||
- `auto (1k | 2k)`:仅在 1k 和 2k 预设中进行匹配。
|
||||
- `auto (2k | 4k)`:仅在 2k 和 4k 预设中进行匹配。
|
||||
- **适应模式**:
|
||||
- `crop`:保持比例并中心裁剪图像以填满目标分辨率。
|
||||
- `pad`:保持比例缩放图像以适应目标分辨率,并填充空白区域。
|
||||
- `stretch`:直接拉伸图像以完全匹配目标分辨率,不保持宽高比。
|
||||
- **填充控制**:自定义 `pad_color` 支持(如 `1.0` 为白色,`0.0` 为黑色,或特定颜色代码),确保满足不同模型的需求。
|
||||
|
||||
## 典型用法
|
||||
|
||||
- **生成前处理**:选择如 `[2k] 2048x2048 (1:1)` 的具体预设,确保输入图像严格匹配模型的最佳训练分辨率。
|
||||
- **批量处理**:使用 `auto` 模式处理一批具有不同宽高比的图像,自动将每张图像缩放到其最接近的标准等效尺寸。
|
||||
@@ -7,7 +7,7 @@
|
||||
| Name | Port | Type | Default | Range | Description |
|
||||
| ---- | ---- | ---- | ------- | ----- | ----------- |
|
||||
| `image` | - | IMAGE | - | - | Input image batch (`B×H×W×C`); only spatial dimensions are used. |
|
||||
| `mode` | - | COMBO | `longest` | `longest` / `shortest` / `width` / `height` | Select which side length to output. |
|
||||
| `mode` | - | COMBO | `shortest` | `longest` / `shortest` / `width` / `height` | Select which side length to output. |
|
||||
|
||||
## Outputs
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
|
||||
| -------- | -------- | -------- | ------ | -------- | ---- |
|
||||
| `image` | - | IMAGE | - | - | 输入图像批次(`B×H×W×C`);仅使用空间尺寸。 |
|
||||
| `mode` | - | COMBO | `longest` | `longest` / `shortest` / `width` / `height` | 选择输出的边长类型。 |
|
||||
| `mode` | - | COMBO | `shortest` | `longest` / `shortest` / `width` / `height` | 选择输出的边长类型。 |
|
||||
|
||||
## 输出
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Name | Port | Type | Default | Range | Description |
|
||||
| ---- | ---- | ---- | ------- | ----- | ----------- |
|
||||
| `mask` | - | MASK | - | - | Input mask batch (`B×H×W`); only its spatial size is used. |
|
||||
| `mode` | - | COMBO | `longest` | `longest` / `shortest` / `width` / `height` | Select which side length to output. |
|
||||
| `mode` | - | COMBO | `shortest` | `longest` / `shortest` / `width` / `height` | Select which side length to output. |
|
||||
|
||||
## Outputs
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
|
||||
| -------- | -------- | -------- | ------ | -------- | ---- |
|
||||
| `mask` | - | MASK | - | - | 输入遮罩批次(`B×H×W`);仅使用其空间尺寸。 |
|
||||
| `mode` | - | COMBO | `longest` | `longest` / `shortest` / `width` / `height` | 选择输出的边长类型。 |
|
||||
| `mode` | - | COMBO | `shortest` | `longest` / `shortest` / `width` / `height` | 选择输出的边长类型。 |
|
||||
|
||||
## 输出
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Load Image From Folder
|
||||
|
||||
**Node Purpose:** Loads image files from a specified folder. Supports batch loading, subfolder traversal, and automatic resizing based on a reference image.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Port | Type | Default | Range | Description |
|
||||
| ---- | ---- | ---- | ------- | ----- | ----------- |
|
||||
| `get_image_size` | optional | IMAGE | - | - | Optional reference image. If provided, loaded images will be resized to match this image's dimensions. |
|
||||
| `folder` | - | STRING | "" | - | The absolute path to the folder containing image files. |
|
||||
| `index` | - | INT | 0 | -8192~8192 | The index of the image to load (ignored if `all` is True). |
|
||||
| `include_subfolder` | - | BOOLEAN | True | - | Whether to include image files in subfolders. |
|
||||
| `all` | - | BOOLEAN | False | - | If True, loads all images in the folder as a batch. If False, loads a single image specified by `index`. |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `image` | IMAGE | The loaded image(s). Returns a batch of images if `all` is True. |
|
||||
| `mask` | MASK | The mask(s) corresponding to the image(s). Uses alpha channel if available, otherwise full white. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Batch Loading**: Can load all images in a folder at once (`all=True`) or a single image by index.
|
||||
- **Auto Resizing**: If `get_image_size` is connected, all loaded images are cropped and resized (Center Crop + Resize) to match the reference dimensions.
|
||||
- **Batch Resizing**: In batch mode (`all=True`), if no reference image is provided, the first image's size is used as the target size for all subsequent images to ensure tensor shape consistency.
|
||||
- **Supported Formats**: `.png`, `.jpg`, `.jpeg`, `.bmp`, `.webp`, `.tiff`, `.gif`.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Load Image From Folder - 从文件夹加载图像
|
||||
|
||||
**节点功能:** 从指定文件夹加载图像文件。支持批量加载、子文件夹遍历以及基于参考图像的自动缩放。
|
||||
|
||||
## 输入
|
||||
|
||||
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
|
||||
| -------- | -------- | -------- | ------ | -------- | ---- |
|
||||
| `get_image_size` | 可选 | IMAGE | - | - | 可选参考图像。如果提供,加载的图像将被缩放以匹配该图像的尺寸。 |
|
||||
| `folder` | - | STRING | "" | - | 包含图像文件的文件夹绝对路径。 |
|
||||
| `index` | - | INT | 0 | -8192~8192 | 要加载的图像索引(当 `all` 为 True 时忽略)。 |
|
||||
| `include_subfolder` | - | BOOLEAN | True | - | 是否包含子文件夹中的图像文件。 |
|
||||
| `all` | - | BOOLEAN | False | - | 如果为 True,加载文件夹中的所有图像作为一个批次。如果为 False,加载由 `index` 指定的单张图像。 |
|
||||
|
||||
## 输出
|
||||
|
||||
| 输出名称 | 数据类型 | 描述 |
|
||||
|---------|----------|------|
|
||||
| `image` | IMAGE | 加载的图像。如果 `all` 为 True,则返回图像批次。 |
|
||||
| `mask` | MASK | 对应的遮罩。如果原图包含 Alpha 通道则使用,否则生成全白遮罩。 |
|
||||
|
||||
## 功能说明
|
||||
|
||||
- **批量加载**:可以一次性加载文件夹中的所有图像(`all=True`),或按索引加载单张。
|
||||
- **自动缩放**:如果连接了 `get_image_size`,所有加载的图像将被裁剪并缩放(居中裁剪+缩放)以匹配参考尺寸。
|
||||
- **批次统一**:在批量模式下(`all=True`),如果未提供参考图像,将以第一张图像的尺寸为基准,后续图像都会被调整到该尺寸,以确保 Tensor 形状一致。
|
||||
- **支持格式**:`.png`, `.jpg`, `.jpeg`, `.bmp`, `.webp`, `.tiff`, `.gif`。
|
||||
@@ -0,0 +1,23 @@
|
||||
# Load Video From Folder
|
||||
|
||||
**Node Purpose:** Loads video files from a specified folder. Supports traversing subfolders and selecting videos by index.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Port | Type | Default | Range | Description |
|
||||
| ---- | ---- | ---- | ------- | ----- | ----------- |
|
||||
| `folder` | - | STRING | "" | - | The absolute path to the folder containing video files. |
|
||||
| `index` | - | INT | 0 | -8192~8192 | The index of the video to load. Supports negative indexing (e.g., -1 for the last file). |
|
||||
| `include_subfolder` | - | BOOLEAN | True | - | Whether to include video files in subfolders. |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `VIDEO` | VIDEO | The loaded video object. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Folder Traversal**: Can load videos from a flat folder or recursively from subfolders.
|
||||
- **Indexing**: Select specific videos using an integer index. If the index exceeds the count, it wraps around (modulo operation).
|
||||
- **Supported Formats**: Supports common video formats like `.webm`, `.mp4`, `.mkv`, `.mov`, `.avi`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Load Video From Folder - 从文件夹加载视频
|
||||
|
||||
**节点功能:** 从指定文件夹加载视频文件。支持遍历子文件夹并通过索引选择视频。
|
||||
|
||||
## 输入
|
||||
|
||||
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
|
||||
| -------- | -------- | -------- | ------ | -------- | ---- |
|
||||
| `folder` | - | STRING | "" | - | 包含视频文件的文件夹绝对路径。 |
|
||||
| `index` | - | INT | 0 | -8192~8192 | 要加载的视频索引。支持负数索引(例如 -1 表示最后一个文件)。 |
|
||||
| `include_subfolder` | - | BOOLEAN | True | - | 是否包含子文件夹中的视频文件。 |
|
||||
|
||||
## 输出
|
||||
|
||||
| 输出名称 | 数据类型 | 描述 |
|
||||
|---------|----------|------|
|
||||
| `VIDEO` | VIDEO | 加载的视频对象。 |
|
||||
|
||||
## 功能说明
|
||||
|
||||
- **文件夹遍历**:可以从单层文件夹或递归从子文件夹加载视频。
|
||||
- **索引选择**:使用整数索引选择特定视频。如果索引超出数量范围,会自动取模循环。
|
||||
- **支持格式**:支持常见的视频格式,如 `.webm`, `.mp4`, `.mkv`, `.mov`, `.avi`。
|
||||
@@ -0,0 +1,32 @@
|
||||
# Mask Repeat
|
||||
|
||||
**Node Purpose:** The `Mask Repeat` node is used to repeat the input mask (supports batch) for a specified number of times, and supports color inversion. It is suitable for scenarios where a single or a group of masks need to be expanded into a larger batch.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Port | Type | Default | Range | Description |
|
||||
| ---- | ---- | ---- | ------- | ----- | ----------- |
|
||||
| `mask` | Required | MASK | - | - | Input mask batch. |
|
||||
| `count` | - | INT | 1 | 1-4096 | Number of repetitions. The entire input Batch is treated as a unit for repetition. |
|
||||
| `invert` | - | BOOLEAN | False | True/False | Whether to invert mask colors (swap black and white). |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mask` | MASK | The repeated (and optionally inverted) mask batch. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Batch Repeat**:
|
||||
- If input Mask Batch is `[A, B]` and `count` is 2, the output will be `[A, B, A, B]`.
|
||||
- The entire input Batch is treated as a whole for duplication.
|
||||
|
||||
- **Invert Function**:
|
||||
- When `invert` is `True`, the output mask colors are inverted (0 becomes 1, 1 becomes 0).
|
||||
- This operation is performed logically along with repetition.
|
||||
|
||||
## Typical Usage
|
||||
|
||||
- **Generate Long Sequence Masks**: Expand a single looping mask to the number of frames required for video length.
|
||||
- **Invert and Expand**: Simultaneously complete mask inversion and batch copying, simplifying the number of nodes in the workflow.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Mask Repeat - 遮罩重复
|
||||
|
||||
**节点功能:** `Mask Repeat` 节点用于将输入的遮罩(支持批量)进行指定次数的重复,并支持颜色反转功能。适用于需要将单个或一组遮罩扩展为更大批次的场景。
|
||||
|
||||
## 输入
|
||||
|
||||
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
|
||||
| -------- | -------- | -------- | ------ | -------- | ---- |
|
||||
| `mask` | 必选 | MASK | - | - | 输入遮罩批次。 |
|
||||
| `count` | - | INT | 1 | 1-4096 | 重复次数。会将整个输入 Batch 视为一个单元进行重复。 |
|
||||
| `invert` | - | BOOLEAN | False | True/False | 是否反转遮罩颜色(黑白互换)。 |
|
||||
|
||||
## 输出
|
||||
|
||||
| 输出名称 | 数据类型 | 描述 |
|
||||
|---------|----------|------|
|
||||
| `mask` | MASK | 重复(及可选反转)后的遮罩批次。 |
|
||||
|
||||
## 功能说明
|
||||
|
||||
- **批量重复**:
|
||||
- 输入 Mask Batch 为 `[A, B]`,`count` 为 2,输出为 `[A, B, A, B]`。
|
||||
- 整个输入 Batch 被视为一个整体进行复制。
|
||||
|
||||
- **反转功能**:
|
||||
- 当 `invert` 为 `True` 时,输出的遮罩颜色将反转(0 变为 1,1 变为 0)。
|
||||
- 此操作在重复之后进行(逻辑上等效,结果一致)。
|
||||
|
||||
## 典型用法
|
||||
|
||||
- **生成长序列遮罩**:将单个循环遮罩扩展为视频长度所需的帧数。
|
||||
- **反转并扩展**:同时完成遮罩的反转和批量复制,简化工作流节点数量。
|
||||
@@ -0,0 +1,168 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI-1hewNodes.LoadImageFromFolder",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "1hew_LoadImageFromFolder") {
|
||||
return;
|
||||
}
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = onConfigure ? onConfigure.apply(this, arguments) : undefined;
|
||||
if (this.widgets) {
|
||||
const folderWidget = this.widgets.find((w) => w.name === "folder");
|
||||
if (folderWidget && folderWidget.value) {
|
||||
// 延迟一点执行,确保节点完全初始化
|
||||
setTimeout(() => {
|
||||
const update = this.updatePreview;
|
||||
if (update) update();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
const folderWidget = this.widgets.find((w) => w.name === "folder");
|
||||
const indexWidget = this.widgets.find((w) => w.name === "index");
|
||||
const includeSubfolderWidget = this.widgets.find((w) => w.name === "include_subfolder");
|
||||
const allWidget = this.widgets.find((w) => w.name === "all");
|
||||
|
||||
this.updatePreview = async () => {
|
||||
const folder = folderWidget.value;
|
||||
const index = indexWidget.value;
|
||||
const includeSubfolder = includeSubfolderWidget.value;
|
||||
const all = allWidget ? allWidget.value : false;
|
||||
|
||||
if (!folder) return;
|
||||
|
||||
if (all) {
|
||||
// 批量模式:获取图片列表并加载多张图片,模拟 Preview Image 节点的行为
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
folder: folder,
|
||||
include_subfolder: includeSubfolder,
|
||||
return_list: "true"
|
||||
});
|
||||
const res = await api.fetchApi(`/1hew/view_image_from_folder?${params.toString()}`);
|
||||
if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
const count = data.count;
|
||||
|
||||
// 限制预览数量,防止过多请求
|
||||
const maxPreview = 200;
|
||||
const loadCount = Math.min(count, maxPreview);
|
||||
|
||||
// 并行加载图片
|
||||
const promises = [];
|
||||
for (let i = 0; i < loadCount; i++) {
|
||||
const p = new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve(null);
|
||||
}
|
||||
// 请求单张图片 (all=false),利用 index 获取特定图片
|
||||
const imgParams = new URLSearchParams({
|
||||
folder: folder,
|
||||
index: i,
|
||||
include_subfolder: includeSubfolder,
|
||||
all: "false"
|
||||
});
|
||||
img.src = `/1hew/view_image_from_folder?${imgParams.toString()}`;
|
||||
});
|
||||
promises.push(p);
|
||||
}
|
||||
|
||||
const loadedImgs = await Promise.all(promises);
|
||||
this.imgs = loadedImgs.filter(img => img !== null);
|
||||
|
||||
// 调整节点尺寸(基于第一张图)
|
||||
if (this.imgs.length > 0) {
|
||||
const img = this.imgs[0];
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
const headerHeight = 30;
|
||||
const widgetMargin = 10;
|
||||
const widgetHeight = this.widgets.reduce((acc, w) => acc + (w.computeSize ? w.computeSize()[1] : 20), 0) + headerHeight + widgetMargin;
|
||||
|
||||
const availableWidth = this.size[0];
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
const drawHeight = availableWidth / ratio;
|
||||
|
||||
const totalHeight = widgetHeight + drawHeight + 10;
|
||||
if (this.size[1] < totalHeight) {
|
||||
this.setSize([this.size[0], totalHeight]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.graph.setDirtyCanvas(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load image list", e);
|
||||
}
|
||||
} else {
|
||||
// 单张模式
|
||||
// 构建 API URL
|
||||
const params = new URLSearchParams({
|
||||
folder: folder,
|
||||
index: index,
|
||||
include_subfolder: includeSubfolder,
|
||||
all: "false"
|
||||
});
|
||||
|
||||
const url = `/1hew/view_image_from_folder?${params.toString()}&t=${Date.now()}`;
|
||||
|
||||
// 创建图片对象并加载
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this.imgs = [img];
|
||||
|
||||
// 自动调整节点尺寸
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
const headerHeight = 30;
|
||||
const widgetMargin = 10;
|
||||
const widgetHeight = this.widgets.reduce((acc, w) => acc + (w.computeSize ? w.computeSize()[1] : 20), 0) + headerHeight + widgetMargin;
|
||||
|
||||
const availableWidth = this.size[0];
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
const drawHeight = availableWidth / ratio;
|
||||
|
||||
const totalHeight = widgetHeight + drawHeight + 10;
|
||||
if (this.size[1] < totalHeight) {
|
||||
this.setSize([this.size[0], totalHeight]);
|
||||
}
|
||||
}
|
||||
|
||||
app.graph.setDirtyCanvas(true);
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 widget 变化
|
||||
if (folderWidget) folderWidget.callback = this.updatePreview;
|
||||
if (indexWidget) indexWidget.callback = this.updatePreview;
|
||||
if (includeSubfolderWidget) includeSubfolderWidget.callback = this.updatePreview;
|
||||
if (allWidget) allWidget.callback = this.updatePreview;
|
||||
|
||||
// 初始加载
|
||||
if (folderWidget && folderWidget.value) {
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
// 移除手动绘制,使用 ComfyUI 原生 this.imgs 机制避免重叠
|
||||
// const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
// nodeType.prototype.onDrawForeground = function (ctx) { ... };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI-1hewNodes.LoadVideoFromFolder",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "1hew_LoadVideoFromFolder") {
|
||||
return;
|
||||
}
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = onConfigure ? onConfigure.apply(this, arguments) : undefined;
|
||||
if (this.widgets) {
|
||||
const folderWidget = this.widgets.find((w) => w.name === "folder");
|
||||
if (folderWidget && folderWidget.value) {
|
||||
setTimeout(() => {
|
||||
const update = this.updatePreview;
|
||||
if (update) update();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
const folderWidget = this.widgets.find((w) => w.name === "folder");
|
||||
const indexWidget = this.widgets.find((w) => w.name === "index");
|
||||
const includeSubfolderWidget = this.widgets.find((w) => w.name === "include_subfolder");
|
||||
|
||||
// Create video element
|
||||
const videoEl = document.createElement("video");
|
||||
videoEl.controls = true;
|
||||
videoEl.loop = true;
|
||||
Object.assign(videoEl.style, {
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
margin: "0 auto",
|
||||
flex: "1 1 auto",
|
||||
objectFit: "contain",
|
||||
minHeight: "0"
|
||||
});
|
||||
|
||||
// Create info element
|
||||
const infoEl = document.createElement("div");
|
||||
Object.assign(infoEl.style, {
|
||||
width: "100%",
|
||||
height: "20px",
|
||||
lineHeight: "20px",
|
||||
textAlign: "center",
|
||||
fontSize: "12px",
|
||||
color: "#aaa",
|
||||
fontFamily: "sans-serif",
|
||||
flex: "0 0 20px",
|
||||
background: "transparent",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
});
|
||||
infoEl.innerText = "";
|
||||
|
||||
// Container for the video
|
||||
const container = document.createElement("div");
|
||||
Object.assign(container.style, {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
minHeight: "0",
|
||||
overflow: "hidden"
|
||||
});
|
||||
|
||||
// Wrapper removed, direct append
|
||||
container.appendChild(videoEl);
|
||||
container.appendChild(infoEl);
|
||||
|
||||
// Add DOM widget
|
||||
this.videoWidget = this.addDOMWidget("video_preview", "div", container, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
});
|
||||
|
||||
// Ensure widget is sized correctly
|
||||
this.videoWidget.computeSize = function(width) {
|
||||
if (this.aspectRatio) {
|
||||
return [width, width * this.aspectRatio + 20];
|
||||
}
|
||||
return [width, 240];
|
||||
};
|
||||
|
||||
const updateLayout = () => {
|
||||
const ar = this?.videoWidget?.aspectRatio;
|
||||
if (!ar) return;
|
||||
|
||||
// Initialize lastSize if needed, but don't return early if we need to resize
|
||||
if (!this.lastSize) {
|
||||
this.lastSize = [...this.size];
|
||||
}
|
||||
|
||||
// Add extra padding to prevent overflow at the bottom
|
||||
const bottomPadding = 5; // Reduced from 30 to 5 to avoid large empty space
|
||||
|
||||
// Manual calculation of overhead is more reliable than offsetTop during initial layout
|
||||
const headerHeight = 30;
|
||||
const widgetMargin = 10; // LiteGraph default margin
|
||||
|
||||
// Calculate total height of all other widgets
|
||||
const widgetsHeight = (this.widgets || []).reduce((acc, w) => {
|
||||
if (w === this.videoWidget) return acc;
|
||||
// Use computeSize if available, otherwise assume standard height (20)
|
||||
const sz = w.computeSize ? w.computeSize(this.size[0]) : [this.size[0], 20];
|
||||
return acc + (Array.isArray(sz) ? (sz[1] || 20) : 20);
|
||||
}, 0);
|
||||
|
||||
// Add some extra buffer for LiteGraph's internal padding/spacing
|
||||
// Reduced from 15 to 10 to tighten layout while keeping some safety against overflow
|
||||
const internalSpacing = 10;
|
||||
|
||||
const currentOverhead = headerHeight + widgetMargin + widgetsHeight + internalSpacing + bottomPadding;
|
||||
|
||||
const baseOverhead = currentOverhead;
|
||||
// Calculate video height based on node width (assuming full width usage)
|
||||
const targetVideoHeight = this.size[0] * ar;
|
||||
const infoHeight = 20;
|
||||
const minWidgetHeight = targetVideoHeight + infoHeight;
|
||||
const minTotalHeight = baseOverhead + minWidgetHeight;
|
||||
|
||||
// Smart Resize Logic
|
||||
const widthChanged = Math.abs(this.size[0] - this.lastSize[0]) > 1;
|
||||
const heightChanged = Math.abs(this.size[1] - this.lastSize[1]) > 1;
|
||||
|
||||
if (widthChanged && !heightChanged) {
|
||||
// Width changed but height didn't (dragged side handle)
|
||||
// Update height to maintain the current vertical padding (or lack thereof)
|
||||
// NewHeight = OldHeight + (NewVideoHeight - OldVideoHeight)
|
||||
const oldVideoHeight = this.lastSize[0] * ar;
|
||||
const heightDelta = targetVideoHeight - oldVideoHeight;
|
||||
const newHeight = Math.max(minTotalHeight, this.size[1] + heightDelta);
|
||||
|
||||
if (Math.abs(newHeight - this.size[1]) > 1) {
|
||||
this.setSize([this.size[0], newHeight]);
|
||||
// setSize triggers onResize, so we update lastSize here to avoid loops/stale data
|
||||
this.lastSize = [this.size[0], newHeight];
|
||||
// Early return because setSize will trigger updateLayout again
|
||||
return;
|
||||
}
|
||||
} else if (this.size[1] < minTotalHeight) {
|
||||
// Height is too small (e.g. initial load or corner drag made it too small)
|
||||
this.setSize([this.size[0], minTotalHeight]);
|
||||
this.lastSize = [this.size[0], minTotalHeight];
|
||||
return;
|
||||
}
|
||||
|
||||
// Update lastSize
|
||||
this.lastSize = [...this.size];
|
||||
|
||||
// Subtract baseOverhead (which includes bottomPadding) to get available height for container
|
||||
const safeAvailableHeight = this.size[1] - baseOverhead;
|
||||
|
||||
// Ensure non-negative height
|
||||
container.style.height = `${Math.max(0, safeAvailableHeight)}px`;
|
||||
// Also force video to fill container to ensure object-fit works
|
||||
videoEl.style.height = "100%";
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
videoEl.addEventListener("loadedmetadata", () => {
|
||||
infoEl.innerText = `${videoEl.videoWidth} x ${videoEl.videoHeight}`;
|
||||
if (videoEl.videoWidth && videoEl.videoHeight) {
|
||||
this.videoWidget.aspectRatio = videoEl.videoHeight / videoEl.videoWidth;
|
||||
setTimeout(updateLayout, 0);
|
||||
}
|
||||
});
|
||||
|
||||
videoEl.addEventListener("loadeddata", () => setTimeout(updateLayout, 0));
|
||||
videoEl.addEventListener("play", () => setTimeout(updateLayout, 0));
|
||||
videoEl.addEventListener("pause", () => setTimeout(updateLayout, 0));
|
||||
|
||||
this.updateVideoLayout = updateLayout;
|
||||
|
||||
try {
|
||||
const ro = new ResizeObserver(() => updateLayout());
|
||||
ro.observe(container);
|
||||
this._videoResizeObserver = ro;
|
||||
} catch {}
|
||||
|
||||
this.updatePreview = async () => {
|
||||
const folder = folderWidget.value;
|
||||
const index = indexWidget.value;
|
||||
const includeSubfolder = includeSubfolderWidget.value;
|
||||
|
||||
if (!folder) return;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
folder: folder,
|
||||
index: index,
|
||||
include_subfolder: includeSubfolder,
|
||||
t: Date.now()
|
||||
});
|
||||
|
||||
const url = `/1hew/view_video_from_folder?${params.toString()}`;
|
||||
|
||||
// Only update if src changed to avoid flickering
|
||||
if (videoEl.src.indexOf(url.split('&t=')[0]) === -1) {
|
||||
videoEl.src = url;
|
||||
setTimeout(updateLayout, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const onResize = nodeType.prototype.onResize;
|
||||
nodeType.prototype.onResize = function(size) {
|
||||
const r2 = onResize ? onResize.apply(this, arguments) : undefined;
|
||||
try {
|
||||
if (this.updateVideoLayout) this.updateVideoLayout();
|
||||
} catch {}
|
||||
return r2;
|
||||
};
|
||||
|
||||
// Listen for widget changes
|
||||
if (folderWidget) folderWidget.callback = this.updatePreview;
|
||||
if (indexWidget) indexWidget.callback = this.updatePreview;
|
||||
if (includeSubfolderWidget) includeSubfolderWidget.callback = this.updatePreview;
|
||||
|
||||
// Initial load
|
||||
if (folderWidget && folderWidget.value) {
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
// Initialize lastSize for smart resizing
|
||||
this.lastSize = undefined;
|
||||
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user