feat: sync latest local version as authoritative codebase

Complete rewrite/sync of comfyui_o1key custom nodes.
Treat this commit as the current canonical version.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
This commit is contained in:
o1key
2026-04-03 16:18:45 +08:00
co-authored by Claude Sonnet 4.5
commit 9ab209b2b7
46 changed files with 16757 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
"""
文件处理工具模块
提供文件夹图片加载、智能命名、图片配对等功能
"""
import os
import re
import uuid
import time
import random
from datetime import datetime
from itertools import product
from pathlib import Path
from typing import List, Tuple, Optional, NamedTuple
from PIL import Image
def _get_server_port() -> Optional[int]:
"""获取当前 ComfyUI 实例的端口号,失败返回 None"""
try:
import comfy.cli_args
port = getattr(comfy.cli_args.args, 'port', None) or getattr(comfy.cli_args, 'server_port', None) or getattr(comfy.cli_args, 'port', None)
if port is not None:
return int(port)
except Exception:
pass
# 备用:从 listen 环境变量或命令行参数尝试
try:
import sys
for arg in sys.argv:
if '--port' in arg or '--listen-port' in arg:
parts = arg.split('=')
if len(parts) == 2:
return int(parts[1].strip())
elif arg in ('--port', '--listen-port'):
idx = sys.argv.index(arg)
if idx + 1 < len(sys.argv):
return int(sys.argv[idx + 1])
except Exception:
pass
return None
def _get_port_suffix() -> str:
"""
返回非默认端口的后缀字符串(如 "_8189"),默认端口 8188 或获取失败时返回空字符串。
"""
try:
port = _get_server_port()
if port is not None and port != 8188:
return f"_{port}"
except Exception:
pass
return ""
# 支持的图片格式
SUPPORTED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif'}
class ImageInfo(NamedTuple):
"""图片信息结构"""
image: Image.Image
filename: str # 不含扩展名的文件名
extension: str # 扩展名(如 .png
source_path: str # 原始文件路径
def load_images_from_folder(
folder_path: str,
recursive: bool = False
) -> List[ImageInfo]:
"""
从文件夹加载所有图片
Args:
folder_path: 文件夹路径
recursive: 是否递归加载子文件夹
Returns:
ImageInfo 列表,包含图片和元数据
Raises:
ValueError: 文件夹不存在或为空
Example:
>>> images = load_images_from_folder("D:/images")
>>> for info in images:
... print(f"{info.filename}: {info.image.size}")
"""
folder_path = folder_path.strip()
if not folder_path:
return []
path = Path(folder_path)
if not path.exists():
raise ValueError(f"文件夹不存在: {folder_path}")
if not path.is_dir():
raise ValueError(f"路径不是文件夹: {folder_path}")
images = []
# 获取文件列表
if recursive:
files = list(path.rglob("*"))
else:
files = list(path.iterdir())
# 按文件名排序,确保顺序一致
files = sorted(files, key=lambda x: x.name.lower())
for file_path in files:
if not file_path.is_file():
continue
ext = file_path.suffix.lower()
if ext not in SUPPORTED_IMAGE_EXTENSIONS:
continue
try:
img = Image.open(file_path)
img.load() # 确保图片完全加载
# 转换为 RGB 模式
if img.mode != 'RGB':
img = img.convert('RGB')
images.append(ImageInfo(
image=img,
filename=file_path.stem,
extension=ext,
source_path=str(file_path)
))
except Exception as e:
print(f"警告: 无法加载图片 {file_path}: {e}")
continue
return images
def pair_images_indexed(
*image_lists: List[ImageInfo]
) -> List[Tuple[ImageInfo, ...]]:
"""
1:1 索引配对
按索引位置配对多个图片列表,以最短列表长度为准。
Args:
*image_lists: 多个 ImageInfo 列表
Returns:
配对后的元组列表
Example:
>>> list_a = [a1, a2, a3]
>>> list_b = [b1, b2, b3]
>>> pairs = pair_images_indexed(list_a, list_b)
>>> # [(a1, b1), (a2, b2), (a3, b3)]
"""
if not image_lists:
return []
# 过滤空列表
non_empty_lists = [lst for lst in image_lists if lst]
if not non_empty_lists:
return []
# 使用 zip 进行索引配对(以最短列表为准)
return list(zip(*non_empty_lists))
def pair_images_by_name(
*image_lists: List[ImageInfo]
) -> List[Tuple[ImageInfo, ...]]:
"""
按文件名配对(同名匹配)
取所有文件夹中文件名(不含扩展名)的交集,按文件名字母升序排列后配对。
只有在所有文件夹中都存在同名文件,该文件名才会被纳入配对。
扩展名不同的文件(如 1.jpg 与 1.png)视为同名。
Args:
*image_lists: 多个 ImageInfo 列表
Returns:
配对后的元组列表,按文件名字母升序排列
Raises:
ValueError: 所有文件夹之间没有任何相同文件名时抛出
Example:
>>> list_a = [ImageInfo(filename="1", ...), ImageInfo(filename="2", ...)]
>>> list_b = [ImageInfo(filename="1", ...), ImageInfo(filename="3", ...)]
>>> pairs = pair_images_by_name(list_a, list_b)
>>> # [(list_a[0], list_b[0])] # 只有 "1" 匹配
"""
if not image_lists:
return []
non_empty_lists = [lst for lst in image_lists if lst]
if not non_empty_lists:
return []
# 单文件夹直接返回(无需配对)
if len(non_empty_lists) == 1:
return [(img,) for img in non_empty_lists[0]]
# 为每个文件夹建立 filenamestem-> ImageInfo 的映射
name_maps = [
{img.filename: img for img in lst}
for lst in non_empty_lists
]
# 取所有文件夹文件名的交集
common_names = set(name_maps[0].keys())
for nm in name_maps[1:]:
common_names &= set(nm.keys())
if not common_names:
# 收集各文件夹的文件名示例,帮助用户排查问题
folder_samples = []
for i, nm in enumerate(name_maps):
sample = sorted(nm.keys())[:3]
sample_str = "".join(f'"{n}"' for n in sample)
folder_samples.append(f"文件夹{i + 1}{sample_str}")
samples_info = "\n".join(folder_samples)
raise ValueError(
f"所有文件夹中没有找到任何同名图片,无法进行配对!\n"
f"请确保各文件夹内存在文件名相同的图片后重试。\n"
f"(文件名比较不含扩展名,例如「1.jpg」与「1.png」视为同名)\n\n"
f"各文件夹当前文件名示例:\n{samples_info}"
)
# 按文件名字母升序排列,保证顺序稳定
sorted_names = sorted(common_names, key=lambda x: x.lower())
return [
tuple(nm[name] for nm in name_maps)
for name in sorted_names
]
def pair_images_cartesian(
*image_lists: List[ImageInfo]
) -> List[Tuple[ImageInfo, ...]]:
"""
笛卡尔积配对
生成多个图片列表的所有组合。
Args:
*image_lists: 多个 ImageInfo 列表
Returns:
配对后的元组列表
Example:
>>> list_a = [a1, a2]
>>> list_b = [b1, b2]
>>> pairs = pair_images_cartesian(list_a, list_b)
>>> # [(a1, b1), (a1, b2), (a2, b1), (a2, b2)]
"""
if not image_lists:
return []
# 过滤空列表
non_empty_lists = [lst for lst in image_lists if lst]
if not non_empty_lists:
return []
# 使用 itertools.product 生成笛卡尔积
return list(product(*non_empty_lists))
def generate_timestamp_filename(output_folder: str, prefix: str = "", extension: str = ".png", port_suffix: str = "") -> str:
"""
生成基于时间戳的文件名,确保按文件名排序 = 按生成时间排序。
格式:{prefix}{HHMMSS_YYYYMMDD_mmm}{port_suffix}{extension}
例如:161700_20260322_001.png 或 去除ai_161700_20260322_001.png
Args:
output_folder: 输出目录
prefix: 文件名前缀(如 "去除ai_"
extension: 文件扩展名(如 ".png"
port_suffix: 端口后缀(如 "_8189"),为空时自动获取
Returns:
完整文件路径
"""
Path(output_folder).mkdir(parents=True, exist_ok=True)
if not port_suffix:
port_suffix = _get_port_suffix()
date_part = datetime.now().strftime("%Y%m%d")
time_part = datetime.now().strftime("%H%M%S")
ms = random.randint(0, 999)
while True:
filename = f"{prefix}{time_part}_{date_part}_{ms:03d}{port_suffix}{extension}"
full_path = Path(output_folder) / filename
if not full_path.exists():
return str(full_path)
ms = (ms + 1) % 1000
def save_image(
image: Image.Image,
output_path: str,
quality: int = 95
) -> str:
"""
保存图片到指定路径
Args:
image: PIL Image 对象
output_path: 输出文件路径
quality: JPEG 质量(仅对 JPEG 格式有效)
Returns:
实际保存的文件路径
"""
# 确保目录存在
output_dir = Path(output_path).parent
output_dir.mkdir(parents=True, exist_ok=True)
# 根据扩展名选择保存参数
ext = Path(output_path).suffix.lower()
if ext in {'.jpg', '.jpeg'}:
# 转换为 RGBJPEG 不支持 alpha 通道)
if image.mode != 'RGB':
image = image.convert('RGB')
image.save(output_path, quality=quality)
elif ext == '.webp':
image.save(output_path, quality=quality)
else:
image.save(output_path)
return output_path
def get_folder_image_count(folder_path: str) -> int:
"""
获取文件夹中的图片数量(不加载图片)
Args:
folder_path: 文件夹路径
Returns:
图片数量
"""
folder_path = folder_path.strip()
if not folder_path:
return 0
path = Path(folder_path)
if not path.exists() or not path.is_dir():
return 0
count = 0
for file_path in path.iterdir():
if file_path.is_file() and file_path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS:
count += 1
return count