feat: 新增Show Any (UTK)节点和Extract Video Frames (UTK)节点
- 新增Show Any (UTK)节点:支持显示任意类型数据,参考comfyui-easy-use实现 - 新增Extract Video Frames (UTK)节点:支持从视频或图片序列中智能抽取帧 - 更新版本号至1.4.9 - 添加WEB_DIRECTORY导出以支持JavaScript文件加载
This commit is contained in:
+35
-2
@@ -8,13 +8,22 @@ A comprehensive toolkit for ComfyUI that provides various utility nodes for imag
|
|||||||
:license: MIT, see LICENSE for more details.
|
:license: MIT, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "1.4.8"
|
__version__ = "1.4.9"
|
||||||
__author__ = "CyberDickLang"
|
__author__ = "CyberDickLang"
|
||||||
__email__ = "286878701@qq.com"
|
__email__ = "286878701@qq.com"
|
||||||
__url__ = "https://github.com/whmc76"
|
__url__ = "https://github.com/whmc76"
|
||||||
|
|
||||||
# 更新日志
|
# 更新日志
|
||||||
CHANGELOG = {
|
CHANGELOG = {
|
||||||
|
"1.4.9": [
|
||||||
|
"新增Show Any (UTK)节点:",
|
||||||
|
"- 参考comfyui-easy-use的showAnything节点实现",
|
||||||
|
"- 支持显示任意类型的数据(string、int、float、json、list、torch.Tensor等)",
|
||||||
|
"- 在节点内部文本框中显示数据类型和内容",
|
||||||
|
"- 支持数据透传,不做任何修改",
|
||||||
|
"- 支持工作流保存和加载时恢复显示内容",
|
||||||
|
"- 分类:UniversalToolkit/Tools",
|
||||||
|
],
|
||||||
"1.4.8": [
|
"1.4.8": [
|
||||||
"版本更新和代码优化:",
|
"版本更新和代码优化:",
|
||||||
"- 更新插件版本号为 1.4.8",
|
"- 更新插件版本号为 1.4.8",
|
||||||
@@ -777,6 +786,14 @@ try:
|
|||||||
NODE_CLASS_MAPPINGS as GET_IMAGE_RANGE_MAPPINGS
|
NODE_CLASS_MAPPINGS as GET_IMAGE_RANGE_MAPPINGS
|
||||||
from .nodes.tools.get_image_range_from_batch import \
|
from .nodes.tools.get_image_range_from_batch import \
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as GET_IMAGE_RANGE_DISPLAY
|
NODE_DISPLAY_NAME_MAPPINGS as GET_IMAGE_RANGE_DISPLAY
|
||||||
|
from .nodes.tools.load_video_frames import \
|
||||||
|
NODE_CLASS_MAPPINGS as EXTRACT_VIDEO_FRAMES_MAPPINGS
|
||||||
|
from .nodes.tools.load_video_frames import \
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS as EXTRACT_VIDEO_FRAMES_DISPLAY
|
||||||
|
from .nodes.tools.show_any import \
|
||||||
|
NODE_CLASS_MAPPINGS as SHOW_ANY_MAPPINGS
|
||||||
|
from .nodes.tools.show_any import \
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS as SHOW_ANY_DISPLAY
|
||||||
from .nodes.tools.optimal_context_window_node import \
|
from .nodes.tools.optimal_context_window_node import \
|
||||||
NODE_CLASS_MAPPINGS as BEST_CONTEXT_WINDOW_MAPPINGS
|
NODE_CLASS_MAPPINGS as BEST_CONTEXT_WINDOW_MAPPINGS
|
||||||
from .nodes.tools.optimal_context_window_node import \
|
from .nodes.tools.optimal_context_window_node import \
|
||||||
@@ -789,9 +806,14 @@ try:
|
|||||||
NODE_CLASS_MAPPINGS as RESIZE_VER_KJ_MAPPINGS
|
NODE_CLASS_MAPPINGS as RESIZE_VER_KJ_MAPPINGS
|
||||||
from .nodes.image.resize_image_ver_kj import \
|
from .nodes.image.resize_image_ver_kj import \
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as RESIZE_VER_KJ_DISPLAY
|
NODE_DISPLAY_NAME_MAPPINGS as RESIZE_VER_KJ_DISPLAY
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
print(f"[UniversalToolkit] 导入错误: {e}")
|
||||||
GET_IMAGE_RANGE_MAPPINGS = {}
|
GET_IMAGE_RANGE_MAPPINGS = {}
|
||||||
GET_IMAGE_RANGE_DISPLAY = {}
|
GET_IMAGE_RANGE_DISPLAY = {}
|
||||||
|
EXTRACT_VIDEO_FRAMES_MAPPINGS = {}
|
||||||
|
EXTRACT_VIDEO_FRAMES_DISPLAY = {}
|
||||||
|
SHOW_ANY_MAPPINGS = {}
|
||||||
|
SHOW_ANY_DISPLAY = {}
|
||||||
BEST_CONTEXT_WINDOW_MAPPINGS = {}
|
BEST_CONTEXT_WINDOW_MAPPINGS = {}
|
||||||
BEST_CONTEXT_WINDOW_DISPLAY = {}
|
BEST_CONTEXT_WINDOW_DISPLAY = {}
|
||||||
BLOCKIFY_MASK_MAPPINGS = {}
|
BLOCKIFY_MASK_MAPPINGS = {}
|
||||||
@@ -839,6 +861,8 @@ NODE_CLASS_MAPPINGS.update(COLOR_TO_MASK_MAPPINGS)
|
|||||||
NODE_CLASS_MAPPINGS.update(LAZY_SWITCH_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(LAZY_SWITCH_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(TEXT_TRANSLATOR_API_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(TEXT_TRANSLATOR_API_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(GET_IMAGE_RANGE_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(GET_IMAGE_RANGE_MAPPINGS)
|
||||||
|
NODE_CLASS_MAPPINGS.update(EXTRACT_VIDEO_FRAMES_MAPPINGS)
|
||||||
|
NODE_CLASS_MAPPINGS.update(SHOW_ANY_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(BEST_CONTEXT_WINDOW_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(BEST_CONTEXT_WINDOW_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(BLOCKIFY_MASK_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(BLOCKIFY_MASK_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(RESIZE_VER_KJ_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(RESIZE_VER_KJ_MAPPINGS)
|
||||||
@@ -882,6 +906,8 @@ NODE_DISPLAY_NAME_MAPPINGS.update(COLOR_TO_MASK_DISPLAY)
|
|||||||
NODE_DISPLAY_NAME_MAPPINGS.update(LAZY_SWITCH_DISPLAY)
|
NODE_DISPLAY_NAME_MAPPINGS.update(LAZY_SWITCH_DISPLAY)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(TEXT_TRANSLATOR_API_DISPLAY)
|
NODE_DISPLAY_NAME_MAPPINGS.update(TEXT_TRANSLATOR_API_DISPLAY)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(GET_IMAGE_RANGE_DISPLAY)
|
NODE_DISPLAY_NAME_MAPPINGS.update(GET_IMAGE_RANGE_DISPLAY)
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS.update(EXTRACT_VIDEO_FRAMES_DISPLAY)
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS.update(SHOW_ANY_DISPLAY)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(BEST_CONTEXT_WINDOW_DISPLAY)
|
NODE_DISPLAY_NAME_MAPPINGS.update(BEST_CONTEXT_WINDOW_DISPLAY)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(BLOCKIFY_MASK_DISPLAY)
|
NODE_DISPLAY_NAME_MAPPINGS.update(BLOCKIFY_MASK_DISPLAY)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(RESIZE_VER_KJ_DISPLAY)
|
NODE_DISPLAY_NAME_MAPPINGS.update(RESIZE_VER_KJ_DISPLAY)
|
||||||
@@ -926,16 +952,23 @@ NODE_CATEGORIES = {
|
|||||||
"APIImageGenerator_UTK",
|
"APIImageGenerator_UTK",
|
||||||
"TextTranslatorAPI_UTK",
|
"TextTranslatorAPI_UTK",
|
||||||
"GetImageRangeFromBatch_UTK",
|
"GetImageRangeFromBatch_UTK",
|
||||||
|
"Extract_Video_Frames_UTK",
|
||||||
|
"ShowAny_UTK",
|
||||||
"BestContextWindow_UTK",
|
"BestContextWindow_UTK",
|
||||||
"BlockifyMask_UTK",
|
"BlockifyMask_UTK",
|
||||||
"ResizeImageVerKJ_UTK",
|
"ResizeImageVerKJ_UTK",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 导出WEB_DIRECTORY以便ComfyUI加载JavaScript文件
|
||||||
|
import os
|
||||||
|
WEB_DIRECTORY = os.path.join(os.path.dirname(__file__), "web")
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NODE_CLASS_MAPPINGS",
|
"NODE_CLASS_MAPPINGS",
|
||||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||||
"NODE_CATEGORIES",
|
"NODE_CATEGORIES",
|
||||||
|
"WEB_DIRECTORY",
|
||||||
"__version__",
|
"__version__",
|
||||||
"__author__",
|
"__author__",
|
||||||
"__email__",
|
"__email__",
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""
|
||||||
|
Load Video Frames Node for ComfyUI Universal Toolkit
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Load frames from video files or image sequences and intelligently sample frames
|
||||||
|
based on target frame count and sampling mode.
|
||||||
|
|
||||||
|
:copyright: (c) 2024 by May
|
||||||
|
:license: MIT, see LICENSE for more details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import cv2
|
||||||
|
import torch
|
||||||
|
from typing import Optional, Tuple, List
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from ..image.image_converters import pil2tensor
|
||||||
|
|
||||||
|
|
||||||
|
class Extract_Video_Frames_UTK:
|
||||||
|
"""
|
||||||
|
从视频文件或图片序列中智能抽取帧
|
||||||
|
|
||||||
|
支持多种抽取模式:
|
||||||
|
- 平均抽取:均匀分布在整个视频/序列中
|
||||||
|
- 前面较多:前半部分抽取更多帧
|
||||||
|
- 后面较多:后半部分抽取更多帧
|
||||||
|
- 中间较多:中间部分抽取更多帧
|
||||||
|
- 两端较多:开头和结尾抽取更多帧
|
||||||
|
"""
|
||||||
|
|
||||||
|
CATEGORY = "UniversalToolkit/Tools"
|
||||||
|
RETURN_TYPES = ("IMAGE", "INT")
|
||||||
|
RETURN_NAMES = ("images", "frames_count")
|
||||||
|
FUNCTION = "load_frames"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"video_path": ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"tooltip": "视频文件路径(支持mp4, avi, mov等格式)"
|
||||||
|
}),
|
||||||
|
"target_frames": ("INT", {
|
||||||
|
"default": 8,
|
||||||
|
"min": 1,
|
||||||
|
"max": 1000,
|
||||||
|
"step": 1,
|
||||||
|
"tooltip": "目标抽取的帧数"
|
||||||
|
}),
|
||||||
|
"mode": (["average", "front_heavy", "back_heavy", "middle_heavy", "ends_heavy"], {
|
||||||
|
"default": "average",
|
||||||
|
"tooltip": "Frame extraction mode"
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"images": ("IMAGE", {
|
||||||
|
"tooltip": "图片序列输入(如果提供,将优先使用图片序列而不是视频)"
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def calculate_frame_indices(self, total_frames: int, target_frames: int, mode: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
根据模式和目标帧数计算要抽取的帧索引
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_frames: 总帧数
|
||||||
|
target_frames: 目标抽取帧数
|
||||||
|
mode: 抽取模式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
帧索引列表
|
||||||
|
"""
|
||||||
|
if total_frames <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if target_frames >= total_frames:
|
||||||
|
# 如果目标帧数大于等于总帧数,返回所有帧
|
||||||
|
return list(range(total_frames))
|
||||||
|
|
||||||
|
indices = []
|
||||||
|
|
||||||
|
if mode == "average":
|
||||||
|
# 均匀分布
|
||||||
|
step = total_frames / target_frames
|
||||||
|
indices = [int(i * step) for i in range(target_frames)]
|
||||||
|
# 确保最后一个索引不超过总帧数
|
||||||
|
indices[-1] = min(indices[-1], total_frames - 1)
|
||||||
|
|
||||||
|
elif mode == "front_heavy":
|
||||||
|
# 前半部分抽取60%,后半部分抽取40%
|
||||||
|
front_count = int(target_frames * 0.6)
|
||||||
|
back_count = target_frames - front_count
|
||||||
|
|
||||||
|
# 前半部分均匀抽取
|
||||||
|
if front_count > 0:
|
||||||
|
front_step = (total_frames // 2) / front_count
|
||||||
|
front_indices = [int(i * front_step) for i in range(front_count)]
|
||||||
|
else:
|
||||||
|
front_indices = []
|
||||||
|
|
||||||
|
# 后半部分均匀抽取
|
||||||
|
if back_count > 0:
|
||||||
|
back_start = total_frames // 2
|
||||||
|
back_step = (total_frames - back_start) / back_count
|
||||||
|
back_indices = [back_start + int(i * back_step) for i in range(back_count)]
|
||||||
|
else:
|
||||||
|
back_indices = []
|
||||||
|
|
||||||
|
indices = front_indices + back_indices
|
||||||
|
|
||||||
|
elif mode == "back_heavy":
|
||||||
|
# 前半部分抽取40%,后半部分抽取60%
|
||||||
|
front_count = int(target_frames * 0.4)
|
||||||
|
back_count = target_frames - front_count
|
||||||
|
|
||||||
|
# 前半部分均匀抽取
|
||||||
|
if front_count > 0:
|
||||||
|
front_step = (total_frames // 2) / front_count
|
||||||
|
front_indices = [int(i * front_step) for i in range(front_count)]
|
||||||
|
else:
|
||||||
|
front_indices = []
|
||||||
|
|
||||||
|
# 后半部分均匀抽取
|
||||||
|
if back_count > 0:
|
||||||
|
back_start = total_frames // 2
|
||||||
|
back_step = (total_frames - back_start) / back_count
|
||||||
|
back_indices = [back_start + int(i * back_step) for i in range(back_count)]
|
||||||
|
else:
|
||||||
|
back_indices = []
|
||||||
|
|
||||||
|
indices = front_indices + back_indices
|
||||||
|
|
||||||
|
elif mode == "middle_heavy":
|
||||||
|
# 开头20%,中间60%,结尾20%
|
||||||
|
start_count = int(target_frames * 0.2)
|
||||||
|
middle_count = int(target_frames * 0.6)
|
||||||
|
end_count = target_frames - start_count - middle_count
|
||||||
|
|
||||||
|
# 开头部分
|
||||||
|
if start_count > 0:
|
||||||
|
start_step = (total_frames // 4) / max(start_count, 1)
|
||||||
|
start_indices = [int(i * start_step) for i in range(start_count)]
|
||||||
|
else:
|
||||||
|
start_indices = []
|
||||||
|
|
||||||
|
# 中间部分
|
||||||
|
if middle_count > 0:
|
||||||
|
middle_start = total_frames // 4
|
||||||
|
middle_end = total_frames * 3 // 4
|
||||||
|
middle_step = (middle_end - middle_start) / max(middle_count, 1)
|
||||||
|
middle_indices = [middle_start + int(i * middle_step) for i in range(middle_count)]
|
||||||
|
else:
|
||||||
|
middle_indices = []
|
||||||
|
|
||||||
|
# 结尾部分
|
||||||
|
if end_count > 0:
|
||||||
|
end_start = total_frames * 3 // 4
|
||||||
|
end_step = (total_frames - end_start) / max(end_count, 1)
|
||||||
|
end_indices = [end_start + int(i * end_step) for i in range(end_count)]
|
||||||
|
else:
|
||||||
|
end_indices = []
|
||||||
|
|
||||||
|
indices = start_indices + middle_indices + end_indices
|
||||||
|
|
||||||
|
elif mode == "ends_heavy":
|
||||||
|
# 开头40%,中间20%,结尾40%
|
||||||
|
start_count = int(target_frames * 0.4)
|
||||||
|
middle_count = int(target_frames * 0.2)
|
||||||
|
end_count = target_frames - start_count - middle_count
|
||||||
|
|
||||||
|
# 开头部分
|
||||||
|
if start_count > 0:
|
||||||
|
start_step = (total_frames // 3) / max(start_count, 1)
|
||||||
|
start_indices = [int(i * start_step) for i in range(start_count)]
|
||||||
|
else:
|
||||||
|
start_indices = []
|
||||||
|
|
||||||
|
# 中间部分
|
||||||
|
if middle_count > 0:
|
||||||
|
middle_start = total_frames // 3
|
||||||
|
middle_end = total_frames * 2 // 3
|
||||||
|
middle_step = (middle_end - middle_start) / max(middle_count, 1)
|
||||||
|
middle_indices = [middle_start + int(i * middle_step) for i in range(middle_count)]
|
||||||
|
else:
|
||||||
|
middle_indices = []
|
||||||
|
|
||||||
|
# 结尾部分
|
||||||
|
if end_count > 0:
|
||||||
|
end_start = total_frames * 2 // 3
|
||||||
|
end_step = (total_frames - end_start) / max(end_count, 1)
|
||||||
|
end_indices = [end_start + int(i * end_step) for i in range(end_count)]
|
||||||
|
else:
|
||||||
|
end_indices = []
|
||||||
|
|
||||||
|
indices = start_indices + middle_indices + end_indices
|
||||||
|
|
||||||
|
# 去重并排序
|
||||||
|
indices = sorted(list(set(indices)))
|
||||||
|
# 确保索引在有效范围内
|
||||||
|
indices = [idx for idx in indices if 0 <= idx < total_frames]
|
||||||
|
|
||||||
|
# 如果去重后数量不足,补充帧
|
||||||
|
while len(indices) < target_frames and len(indices) < total_frames:
|
||||||
|
# 找到最大的间隔并补充
|
||||||
|
if len(indices) == 0:
|
||||||
|
indices.append(0)
|
||||||
|
elif len(indices) == 1:
|
||||||
|
if indices[0] < total_frames - 1:
|
||||||
|
indices.append(total_frames - 1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
max_gap = 0
|
||||||
|
insert_pos = 0
|
||||||
|
for i in range(len(indices) - 1):
|
||||||
|
gap = indices[i + 1] - indices[i]
|
||||||
|
if gap > max_gap:
|
||||||
|
max_gap = gap
|
||||||
|
insert_pos = i + 1
|
||||||
|
insert_value = (indices[i] + indices[i + 1]) // 2
|
||||||
|
|
||||||
|
if max_gap > 1:
|
||||||
|
indices.insert(insert_pos, insert_value)
|
||||||
|
indices.sort()
|
||||||
|
else:
|
||||||
|
# 如果没有大间隔,在两端补充
|
||||||
|
if indices[0] > 0:
|
||||||
|
indices.insert(0, indices[0] - 1)
|
||||||
|
elif indices[-1] < total_frames - 1:
|
||||||
|
indices.append(min(indices[-1] + 1, total_frames - 1))
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return indices[:target_frames]
|
||||||
|
|
||||||
|
def load_video_frames(self, video_path: str, indices: List[int]) -> List[torch.Tensor]:
|
||||||
|
"""
|
||||||
|
从视频文件中加载指定索引的帧
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: 视频文件路径
|
||||||
|
indices: 要加载的帧索引列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
帧张量列表
|
||||||
|
"""
|
||||||
|
# 路径预处理
|
||||||
|
video_path = video_path.strip().strip('"').strip("'")
|
||||||
|
video_path = video_path.replace("\\", "/")
|
||||||
|
|
||||||
|
if not os.path.isfile(video_path):
|
||||||
|
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise RuntimeError(f"无法打开视频文件: {video_path}")
|
||||||
|
|
||||||
|
# 为了保持原始顺序,先按索引顺序读取并存储到字典中
|
||||||
|
frame_dict = {}
|
||||||
|
sorted_indices = sorted(set(indices)) # 去重并排序以提高效率
|
||||||
|
|
||||||
|
for target_idx in sorted_indices:
|
||||||
|
# 跳转到目标帧
|
||||||
|
cap.set(cv2.CAP_PROP_POS_FRAMES, target_idx)
|
||||||
|
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if not ret:
|
||||||
|
print(f"警告: 无法读取第 {target_idx} 帧")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 转换BGR到RGB
|
||||||
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
# 转换为PIL图像
|
||||||
|
pil_image = Image.fromarray(frame_rgb)
|
||||||
|
# 转换为tensor
|
||||||
|
tensor = pil2tensor(pil_image)
|
||||||
|
frame_dict[target_idx] = tensor
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
if len(frame_dict) == 0:
|
||||||
|
raise RuntimeError("未能从视频中加载任何帧")
|
||||||
|
|
||||||
|
# 按照原始indices顺序返回帧
|
||||||
|
frames = [frame_dict[idx] for idx in indices if idx in frame_dict]
|
||||||
|
|
||||||
|
return frames
|
||||||
|
|
||||||
|
def load_image_sequence_frames(self, images: torch.Tensor, indices: List[int]) -> List[torch.Tensor]:
|
||||||
|
"""
|
||||||
|
从图片序列中提取指定索引的帧
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images: 图片批次张量 [batch, height, width, channels]
|
||||||
|
indices: 要提取的帧索引列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
帧张量列表
|
||||||
|
"""
|
||||||
|
frames = []
|
||||||
|
for idx in indices:
|
||||||
|
if 0 <= idx < len(images):
|
||||||
|
frames.append(images[idx])
|
||||||
|
else:
|
||||||
|
print(f"警告: 索引 {idx} 超出图片序列范围 [0, {len(images)-1}]")
|
||||||
|
|
||||||
|
return frames
|
||||||
|
|
||||||
|
def load_frames(
|
||||||
|
self,
|
||||||
|
video_path: str,
|
||||||
|
target_frames: int,
|
||||||
|
mode: str,
|
||||||
|
images: Optional[torch.Tensor] = None
|
||||||
|
) -> Tuple[torch.Tensor]:
|
||||||
|
"""
|
||||||
|
加载并抽取帧
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: 视频文件路径
|
||||||
|
target_frames: 目标帧数
|
||||||
|
mode: 抽取模式
|
||||||
|
images: 可选的图片序列输入
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
抽取的帧批次张量
|
||||||
|
"""
|
||||||
|
# 优先使用图片序列输入
|
||||||
|
if images is not None:
|
||||||
|
total_frames = len(images)
|
||||||
|
print(f"📸 从图片序列中抽取帧: 总帧数={total_frames}, 目标帧数={target_frames}, 模式={mode}")
|
||||||
|
|
||||||
|
indices = self.calculate_frame_indices(total_frames, target_frames, mode)
|
||||||
|
print(f"📊 计算得到的帧索引: {indices}")
|
||||||
|
|
||||||
|
# 按照indices的顺序提取帧(保持计算出的顺序)
|
||||||
|
frame_tensors = self.load_image_sequence_frames(images, indices)
|
||||||
|
|
||||||
|
elif video_path and video_path.strip():
|
||||||
|
# 使用视频文件
|
||||||
|
video_path = video_path.strip().strip('"').strip("'")
|
||||||
|
|
||||||
|
# 先获取视频总帧数
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise RuntimeError(f"无法打开视频文件: {video_path}")
|
||||||
|
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
print(f"🎬 从视频中抽取帧: 文件={video_path}, 总帧数={total_frames}, FPS={fps:.2f}, 目标帧数={target_frames}, 模式={mode}")
|
||||||
|
|
||||||
|
indices = self.calculate_frame_indices(total_frames, target_frames, mode)
|
||||||
|
print(f"📊 计算得到的帧索引: {indices}")
|
||||||
|
|
||||||
|
frame_tensors = self.load_video_frames(video_path, indices)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError("必须提供视频路径或图片序列输入")
|
||||||
|
|
||||||
|
if len(frame_tensors) == 0:
|
||||||
|
raise RuntimeError("未能加载任何帧")
|
||||||
|
|
||||||
|
# 将所有帧堆叠成批次
|
||||||
|
batch_tensor = torch.stack(frame_tensors, dim=0)
|
||||||
|
frames_count = len(frame_tensors)
|
||||||
|
|
||||||
|
print(f"✅ 成功加载 {frames_count} 帧,输出形状: {batch_tensor.shape}")
|
||||||
|
|
||||||
|
return (batch_tensor, frames_count)
|
||||||
|
|
||||||
|
|
||||||
|
# 节点映射
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"Extract_Video_Frames_UTK": Extract_Video_Frames_UTK
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"Extract_Video_Frames_UTK": "Extract Video Frames (UTK)"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
Show Any Node for ComfyUI Universal Toolkit
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Display any type of input data in a text field, showing data type and content.
|
||||||
|
Acts as a passthrough node for debugging and inspection.
|
||||||
|
|
||||||
|
Based on comfyui-easy-use's showAnything node implementation.
|
||||||
|
|
||||||
|
:copyright: (c) 2024 by May
|
||||||
|
:license: MIT, see LICENSE for more details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
# Import AnyType for accepting any input type
|
||||||
|
try:
|
||||||
|
from comfy.comfy_types.node_typing import IO
|
||||||
|
ANY_TYPE = IO.ANY
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from comfy_extras.nodes_custom_sampler import AnyType
|
||||||
|
ANY_TYPE = AnyType("*")
|
||||||
|
except ImportError:
|
||||||
|
from .any_type import AnyType
|
||||||
|
ANY_TYPE = AnyType("*")
|
||||||
|
|
||||||
|
|
||||||
|
class ShowAny_UTK:
|
||||||
|
"""
|
||||||
|
显示任意类型数据的节点
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 接受任何类型的输入数据
|
||||||
|
- 在文本框中显示数据类型和数据内容
|
||||||
|
- 直接输出输入的数据(不做任何修改)
|
||||||
|
- 用于调试和查看数据流
|
||||||
|
|
||||||
|
参考实现:comfyui-easy-use 的 showAnything 节点
|
||||||
|
"""
|
||||||
|
|
||||||
|
CATEGORY = "UniversalToolkit/Tools"
|
||||||
|
RETURN_TYPES = (ANY_TYPE,)
|
||||||
|
RETURN_NAMES = ("data",)
|
||||||
|
FUNCTION = "show_any"
|
||||||
|
INPUT_IS_LIST = True
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {},
|
||||||
|
"optional": {
|
||||||
|
"data": (ANY_TYPE, {
|
||||||
|
"tooltip": "输入任意类型的数据"
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"hidden": {
|
||||||
|
"unique_id": "UNIQUE_ID",
|
||||||
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def format_data(self, data: Any) -> str:
|
||||||
|
"""
|
||||||
|
格式化数据为可读的字符串
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 要格式化的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化后的字符串
|
||||||
|
"""
|
||||||
|
# 获取数据类型
|
||||||
|
data_type = type(data).__name__
|
||||||
|
|
||||||
|
# 根据不同类型格式化数据
|
||||||
|
if data is None:
|
||||||
|
return f"Type: NoneType\nValue: None"
|
||||||
|
|
||||||
|
elif isinstance(data, str):
|
||||||
|
return f"Type: string\nValue: {data}"
|
||||||
|
|
||||||
|
elif isinstance(data, (int, float)):
|
||||||
|
return f"Type: {data_type}\nValue: {data}"
|
||||||
|
|
||||||
|
elif isinstance(data, bool):
|
||||||
|
return f"Type: boolean\nValue: {data}"
|
||||||
|
|
||||||
|
elif isinstance(data, (list, tuple)):
|
||||||
|
# 列表或元组
|
||||||
|
try:
|
||||||
|
# 尝试转换为JSON格式
|
||||||
|
json_str = json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
return f"Type: {data_type}\nLength: {len(data)}\nValue:\n{json_str}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# 如果无法序列化为JSON,显示repr
|
||||||
|
return f"Type: {data_type}\nLength: {len(data)}\nValue: {repr(data)}"
|
||||||
|
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
# 字典
|
||||||
|
try:
|
||||||
|
json_str = json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
return f"Type: dict\nKeys: {len(data)}\nValue:\n{json_str}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return f"Type: dict\nKeys: {len(data)}\nValue: {repr(data)}"
|
||||||
|
|
||||||
|
elif isinstance(data, torch.Tensor):
|
||||||
|
# PyTorch Tensor
|
||||||
|
shape = list(data.shape)
|
||||||
|
dtype = str(data.dtype)
|
||||||
|
device = str(data.device)
|
||||||
|
min_val = float(data.min().item()) if data.numel() > 0 else None
|
||||||
|
max_val = float(data.max().item()) if data.numel() > 0 else None
|
||||||
|
|
||||||
|
info = f"Type: torch.Tensor\nShape: {shape}\nDtype: {dtype}\nDevice: {device}"
|
||||||
|
if min_val is not None and max_val is not None:
|
||||||
|
info += f"\nMin: {min_val:.6f}\nMax: {max_val:.6f}"
|
||||||
|
info += f"\nNumel: {data.numel()}"
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
elif isinstance(data, np.ndarray):
|
||||||
|
# NumPy Array
|
||||||
|
shape = data.shape
|
||||||
|
dtype = str(data.dtype)
|
||||||
|
min_val = float(data.min()) if data.size > 0 else None
|
||||||
|
max_val = float(data.max()) if data.size > 0 else None
|
||||||
|
|
||||||
|
info = f"Type: numpy.ndarray\nShape: {shape}\nDtype: {dtype}"
|
||||||
|
if min_val is not None and max_val is not None:
|
||||||
|
info += f"\nMin: {min_val:.6f}\nMax: {max_val:.6f}"
|
||||||
|
info += f"\nSize: {data.size}"
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 其他类型,尝试使用repr
|
||||||
|
try:
|
||||||
|
repr_str = repr(data)
|
||||||
|
# 限制长度,避免过长
|
||||||
|
if len(repr_str) > 500:
|
||||||
|
repr_str = repr_str[:500] + "..."
|
||||||
|
return f"Type: {data_type}\nValue: {repr_str}"
|
||||||
|
except Exception:
|
||||||
|
return f"Type: {data_type}\nValue: <无法显示>"
|
||||||
|
|
||||||
|
def show_any(self, unique_id=None, extra_pnginfo=None, **kwargs) -> dict:
|
||||||
|
"""
|
||||||
|
显示任意类型的数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unique_id: 节点的唯一ID(用于保存工作流)
|
||||||
|
extra_pnginfo: 额外的PNG信息(用于保存工作流)
|
||||||
|
**kwargs: 输入的数据(任意类型)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 包含ui显示和结果的字典
|
||||||
|
"""
|
||||||
|
values = []
|
||||||
|
if "data" in kwargs:
|
||||||
|
for val in kwargs['data']:
|
||||||
|
try:
|
||||||
|
if isinstance(val, str):
|
||||||
|
values.append(val)
|
||||||
|
elif isinstance(val, list):
|
||||||
|
values = val
|
||||||
|
elif isinstance(val, (int, float, bool)):
|
||||||
|
values.append(str(val))
|
||||||
|
elif isinstance(val, torch.Tensor):
|
||||||
|
# 处理torch.Tensor(IMAGE类型)
|
||||||
|
shape = list(val.shape)
|
||||||
|
values.append(f"torch.Tensor(shape={shape}, dtype={val.dtype})")
|
||||||
|
else:
|
||||||
|
val = json.dumps(val)
|
||||||
|
values.append(str(val))
|
||||||
|
except Exception:
|
||||||
|
values.append(str(val))
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 保存到工作流中(用于加载工作流时恢复显示)
|
||||||
|
if not extra_pnginfo:
|
||||||
|
pass
|
||||||
|
elif not isinstance(extra_pnginfo, list) or len(extra_pnginfo) == 0:
|
||||||
|
pass
|
||||||
|
elif (not isinstance(extra_pnginfo[0], dict) or "workflow" not in extra_pnginfo[0]):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
workflow = extra_pnginfo[0]["workflow"]
|
||||||
|
if unique_id and isinstance(unique_id, list) and len(unique_id) > 0:
|
||||||
|
node = next((x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])), None)
|
||||||
|
if node:
|
||||||
|
node["widgets_values"] = [values]
|
||||||
|
|
||||||
|
# 返回结果(完全按照comfyui-easy-use格式)
|
||||||
|
if isinstance(values, list) and len(values) == 1:
|
||||||
|
return {"ui": {"text": values}, "result": (values[0],)}
|
||||||
|
else:
|
||||||
|
return {"ui": {"text": values}, "result": (values,)}
|
||||||
|
|
||||||
|
|
||||||
|
# 节点映射
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"ShowAny_UTK": ShowAny_UTK
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"ShowAny_UTK": "Show Any (UTK)"
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "universaltoolkit"
|
name = "universaltoolkit"
|
||||||
description = "A comprehensive toolkit based on ComfyUI, providing image, mask, audio, and tools nodes, fully modular and v3 compatible."
|
description = "A comprehensive toolkit based on ComfyUI, providing image, mask, audio, and tools nodes, fully modular and v3 compatible."
|
||||||
version = "1.4.8"
|
version = "1.4.9"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"torch>=1.9.0",
|
"torch>=1.9.0",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { ComfyWidgets } from "../../scripts/widgets.js";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "ShowAny_UTK",
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
|
if (nodeData.name === "ShowAny_UTK") {
|
||||||
|
function populate(text) {
|
||||||
|
if (!text || !Array.isArray(text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.widgets) {
|
||||||
|
const pos = this.widgets.findIndex((w) => w.name === "text");
|
||||||
|
if (pos !== -1) {
|
||||||
|
for (let i = pos; i < this.widgets.length; i++) {
|
||||||
|
this.widgets[i].onRemove?.();
|
||||||
|
}
|
||||||
|
this.widgets.length = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const list of text) {
|
||||||
|
const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget;
|
||||||
|
w.inputEl.readOnly = true;
|
||||||
|
w.inputEl.style.opacity = 0.6;
|
||||||
|
w.value = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const sz = this.computeSize();
|
||||||
|
if (sz[0] < this.size[0]) {
|
||||||
|
sz[0] = this.size[0];
|
||||||
|
}
|
||||||
|
if (sz[1] < this.size[1]) {
|
||||||
|
sz[1] = this.size[1];
|
||||||
|
}
|
||||||
|
this.onResize?.(sz);
|
||||||
|
app.graph.setDirtyCanvas(true, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the node is executed we will be sent the input text, display this in the widget
|
||||||
|
const onExecuted = nodeType.prototype.onExecuted;
|
||||||
|
nodeType.prototype.onExecuted = function (message) {
|
||||||
|
onExecuted?.apply(this, arguments);
|
||||||
|
if (message && message.text) {
|
||||||
|
populate.call(this, message.text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfigure = nodeType.prototype.onConfigure;
|
||||||
|
nodeType.prototype.onConfigure = function () {
|
||||||
|
onConfigure?.apply(this, arguments);
|
||||||
|
if (this.widgets_values?.length) {
|
||||||
|
populate.call(this, this.widgets_values);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user