Initial release

This commit is contained in:
haoming
2026-05-08 18:34:55 +08:00
commit 039277b8ee
6 changed files with 982 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.DS_Store
*.log
*.tmp
.venv/
venv/
env/
output/
input/
temp/
View File
+218
View File
@@ -0,0 +1,218 @@
# ComfyUI-LoopGif
A simple ComfyUI custom node for creating looping GIFs from VIDEO inputs.
This node is mainly designed for short AI-generated videos, especially image-to-video workflows where the first and last frames are expected to be similar. It can trim frames from the beginning and end, apply mild loop blending, and optionally compensate for gradual color drift before exporting a GIF.
## Features
- Convert ComfyUI `VIDEO` input to looping GIF
- Supports video file based inputs
- Supports in-memory video components, such as videos created inside a workflow
- Output GIF path as `STRING`
- Output processed frames as `IMAGE`
- Output GIF frame rate as `FLOAT`
- Optional head/tail frame trimming
- Optional loop seam blending
- Optional gradual color drift correction
- Automatically cleans temporary working files after execution
## Installation
Clone this repository into your ComfyUI `custom_nodes` folder:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/HM1579/ComfyUI-LoopGif.git
```
Then install dependencies into your ComfyUI Python environment.
If you are using a normal ComfyUI installation with a virtual environment, activate it first:
```bash
cd ComfyUI
source .venv/bin/activate
cd custom_nodes/ComfyUI-LoopGif
python -m pip install -r requirements.txt
```
If you are using ComfyUI Desktop or another packaged ComfyUI version, use the Python executable that ComfyUI actually runs with. You can find it in the ComfyUI startup log, for example:
```text
** Python executable: /path/to/ComfyUI/.venv/bin/python
```
Then run:
```bash
/path/to/ComfyUI/.venv/bin/python -m pip install -r requirements.txt
```
Restart ComfyUI after installation.
## Requirements
This node requires:
- numpy
- Pillow
- imageio-ffmpeg
The node will try to use system `ffmpeg` first. If system `ffmpeg` is not found, it will fall back to `imageio-ffmpeg`.
On macOS, the node also checks common Homebrew paths:
```text
/opt/homebrew/bin/ffmpeg
/usr/local/bin/ffmpeg
```
## Node
### Loop GIF
Category:
```text
video/gif
```
Inputs:
| Name | Type | Description |
|---|---|---|
| video | VIDEO | Input video from ComfyUI workflow |
| filename_prefix | STRING | Output GIF filename prefix |
| delete_ratio | FLOAT | Ratio of frames to remove from the beginning and end |
| gif_fps | FLOAT | GIF frame rate |
| blend_frames | INT | Number of frames near the loop seam to blend |
| blend_strength | FLOAT | Strength of loop seam blending |
| color_drift_strength | FLOAT | Strength of gradual color drift correction |
Outputs:
| Name | Type | Description |
|---|---|---|
| gif_path | STRING | Saved GIF file path |
| images | IMAGE | Processed frame sequence |
| frame_rate | FLOAT | Output frame rate |
## Parameter Guide
### delete_ratio
Controls how many frames are removed from the beginning and end of the video.
This is usually the most important parameter for making a short video loop better.
Recommended range:
```text
0.05 - 0.15
```
Suggested default:
```text
0.10
```
If the loop has a long pause near the seam, increase this value slightly.
If the loop feels too jumpy, reduce it slightly.
### gif_fps
Controls the playback speed of the exported GIF.
Recommended values:
```text
18 - 24
```
Higher FPS may look smoother, but also produces larger GIF files.
### blend_frames
Controls how many frames near the loop seam are blended.
Recommended range:
```text
0 - 2
```
This is a minor helper parameter. It may reduce small seam flickers, but high values can cause ghosting or unnatural transitions.
### blend_strength
Controls the strength of the loop seam blending.
Recommended range:
```text
0.05 - 0.20
```
Suggested default:
```text
0.10
```
Too high values may introduce visible ghosting.
### color_drift_strength
Compensates for gradual color drift across the video.
This can help when the final frame has a different overall color tone from the first frame, causing a visible flash when the GIF loops.
Recommended range:
```text
0.0 - 1.5
```
Suggested default:
```text
0.0
```
This parameter is experimental. It may help in some cases, but it is not a universal fix.
## Notes
This node is designed as a lightweight practical tool rather than a complex video post-processing system.
For most workflows, the recommended tuning order is:
1. Adjust `delete_ratio`
2. Adjust `gif_fps`
3. Optionally enable small `blend_frames`
4. Optionally adjust `color_drift_strength` if there is visible color drift
In many cases, `delete_ratio` alone is enough.
## Temporary Files
The node creates a temporary hidden working folder during execution, such as:
```text
.output_name_work
```
This folder is automatically removed after execution, whether the process succeeds or fails.
## Limitations
- GIF output does not preserve audio
- Large videos may consume more memory when outputting processed frames as `IMAGE`
- Color drift correction is experimental
- Loop quality still depends heavily on the source video and prompt quality
## License
MIT License
+1
View File
@@ -0,0 +1 @@
from .loop_gif import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
+743
View File
@@ -0,0 +1,743 @@
import re
import shutil
import subprocess
from pathlib import Path
import numpy as np
import torch
from PIL import Image
import folder_paths
def safe_name(text: str) -> str:
text = (text or "").strip()
text = re.sub(r"[^\w\-.一-龥 ]+", "_", text)
text = text.replace(" ", "_")
return text or "gif"
def format_frame_numbers(start: int, end: int) -> str:
"""
把帧号范围格式化成逗号分隔:
1..3 -> 1,2,3
59..61 -> 59,60,61
"""
if start > end:
return "none"
return ",".join(str(i) for i in range(start, end + 1))
def find_ffmpeg():
"""
优先使用系统 ffmpeg。
macOS App 启动时可能拿不到 /opt/homebrew/bin,所以手动补常见路径。
如果都找不到,再尝试 imageio-ffmpeg。
"""
candidates = [
shutil.which("ffmpeg"),
"/opt/homebrew/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
]
for ffmpeg in candidates:
if ffmpeg and Path(ffmpeg).exists():
return ffmpeg
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
raise RuntimeError(
"找不到 ffmpeg。请先安装 ffmpeg,或在 ComfyUI 的 Python 环境里安装 imageio-ffmpeg。"
)
def run_cmd(cmd):
"""
不使用 shell=True,避免 shell 字符串解释。
"""
cmd = [str(x) for x in cmd]
print("[LoopGif] RUN:", " ".join(cmd))
try:
return subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
shell=False,
)
except subprocess.CalledProcessError as e:
print("[LoopGif] ffmpeg stdout:")
print(e.stdout)
print("[LoopGif] ffmpeg stderr:")
print(e.stderr)
raise
def images_to_tensor(image_paths):
"""
把 picked 帧读入内存,转换为 ComfyUI IMAGE batch。
输出形状:[N, H, W, 3],float32,范围 0~1。
"""
tensors = []
for path in image_paths:
img = Image.open(path).convert("RGB")
arr = np.asarray(img).astype(np.float32) / 255.0
tensors.append(torch.from_numpy(arr))
if not tensors:
raise RuntimeError("没有可输出的 picked images。")
return torch.stack(tensors, dim=0)
def tensor_images_to_png_files(images, out_dir: Path):
"""
把内存中的 IMAGE / 视频帧 tensor 写成 png 序列。
兼容 [N,H,W,C] 和 [H,W,C]。
返回写出的文件路径列表。
"""
if images is None:
raise RuntimeError("Video components 中没有 images。")
if not isinstance(images, torch.Tensor):
images = torch.as_tensor(images)
if images.ndim == 3:
images = images.unsqueeze(0)
if images.ndim != 4:
raise RuntimeError(f"images 维度不正确,期望 [N,H,W,C],实际为 {tuple(images.shape)}")
files = []
images = images.detach().cpu().float().clamp(0.0, 1.0)
for i in range(images.shape[0]):
arr = (images[i].numpy() * 255.0).round().clip(0, 255).astype(np.uint8)
img = Image.fromarray(arr)
path = out_dir / f"frame_{i+1:03d}.png"
img.save(path)
files.append(path)
return files
def apply_color_drift_correction(image_paths, color_drift_strength: float):
"""
循环颜色漂移补偿。
作用:
- 在删除首尾帧之后,对最终参与循环的帧序列做颜色漂移校正
- 不管 delete_ratio 是否为 0,只要 color_drift_strength > 0 就会生效
- 主要用于修正“最后一帧整体偏色,跳回第一帧时闪一下”的问题
做法:
- 计算第一帧和最后一帧的 RGB 平均颜色差
- 从第一帧到最后一帧逐渐增加修正量
- 第一帧不动
- 越靠后的帧修正越多
- correction 内部带 4.0 放大系数,方便让面板参数更容易看到效果
"""
try:
strength = float(color_drift_strength)
except Exception:
strength = 0.0
# 只限制不能为负,不限制最大值
strength = max(0.0, strength)
if strength <= 0:
return 0.0
count = len(image_paths)
if count < 2:
return 0.0
first_img = Image.open(image_paths[0]).convert("RGB")
last_img = Image.open(image_paths[-1]).convert("RGB")
first_arr = np.asarray(first_img).astype(np.float32)
last_arr = np.asarray(last_img).astype(np.float32)
first_mean = first_arr.reshape(-1, 3).mean(axis=0)
last_mean = last_arr.reshape(-1, 3).mean(axis=0)
# 需要逐渐补偿的颜色差
drift = first_mean - last_mean
print(
"[LoopGif] color_drift_strength=",
strength,
"first_mean=",
first_mean,
"last_mean=",
last_mean,
"drift=",
drift,
"effective_last_correction=",
drift * strength * 4.0,
)
for i, path in enumerate(image_paths):
if count == 1:
t = 0.0
else:
t = float(i) / float(count - 1)
correction = drift * t * strength * 4.0
img = Image.open(path).convert("RGB")
arr = np.asarray(img).astype(np.float32)
arr = arr + correction.reshape(1, 1, 3)
arr = np.clip(arr, 0, 255).astype(np.uint8)
Image.fromarray(arr).save(path)
return strength
def apply_loop_blend(image_paths, blend_frames: int, blend_strength: float):
"""
在删除首尾帧之后,对新的首尾几帧做对称式循环叠化。
做法:
- 不增加帧数
- 不删除帧
- 同时修改头部 blend_frames 帧和尾部 blend_frames 帧
- 越靠近循环接缝,混合越强
- 越远离循环接缝,混合越弱
"""
try:
blend_frames = int(blend_frames)
except Exception:
blend_frames = 0
try:
blend_strength = float(blend_strength)
except Exception:
blend_strength = 0.0
blend_strength = max(0.0, min(blend_strength, 1.0))
if blend_frames <= 0 or blend_strength <= 0:
return 0
count = len(image_paths)
if count < 4:
return 0
# 避免叠化范围过大,最多只处理一半以内
blend_count = min(blend_frames, count // 2)
if blend_count <= 0:
return 0
print(f"[LoopGif] blend_frames={blend_count}, blend_strength={blend_strength}")
# 先把参与叠化的原始头尾帧读进内存,避免前面改了以后影响后面计算
head_images = []
tail_images = []
for i in range(blend_count):
head_images.append(Image.open(image_paths[i]).convert("RGB"))
tail_images.append(Image.open(image_paths[count - blend_count + i]).convert("RGB"))
# 修改头部:
for i in range(blend_count):
head_img = head_images[i]
tail_img = tail_images[blend_count - 1 - i]
alpha = blend_strength * float(blend_count - i) / float(blend_count)
blended = Image.blend(head_img, tail_img, alpha)
blended.save(image_paths[i])
# 修改尾部:
for i in range(blend_count):
tail_path_index = count - blend_count + i
tail_img = tail_images[i]
head_img = head_images[blend_count - 1 - i]
alpha = blend_strength * float(i + 1) / float(blend_count)
blended = Image.blend(tail_img, head_img, alpha)
blended.save(image_paths[tail_path_index])
return blend_count
def resolve_video_components(video):
"""
尝试从 ComfyUI VIDEO 对象里提取内存视频组件:
- VideoFromComponents
- 其他带 components / images / frame_rate 的对象
"""
for method_name in ["get_components"]:
method = getattr(video, method_name, None)
if callable(method):
try:
comps = method()
if comps is not None and hasattr(comps, "images"):
print(f"[LoopGif] {method_name}() -> components found")
return comps
except Exception as e:
print(f"[LoopGif] {method_name}() failed:", repr(e))
for attr_name in ["components", "_components"]:
if hasattr(video, attr_name):
try:
comps = getattr(video, attr_name)
if comps is not None and hasattr(comps, "images"):
print(f"[LoopGif] attr {attr_name} -> components found")
return comps
except Exception as e:
print(f"[LoopGif] attr {attr_name} failed:", repr(e))
obj_dict = getattr(video, "__dict__", None)
if isinstance(obj_dict, dict):
private_key = "_VideoFromComponents__components"
if private_key in obj_dict:
comps = obj_dict[private_key]
if comps is not None and hasattr(comps, "images"):
print(f"[LoopGif] __dict__ {private_key} -> components found")
return comps
return None
def resolve_video_path(video):
"""
兼容常见 ComfyUI VIDEO 对象:
- str / Path 路径
- dict: path / fullpath / video_path / file_path
- dict: filename + subfolder + type
- object: path / filename / file / video_path 等属性
- object: get_stream_source() / get_path() / get_file() 等方法
注意:
这里只解析“能落到磁盘路径”的 VIDEO。
如果是 VideoFromComponents,应走 resolve_video_components。
"""
def as_existing_path(value):
if value is None:
return None
if isinstance(value, (bytes, bytearray)):
return None
try:
p = Path(str(value)).expanduser()
if p.exists():
return str(p.resolve())
except Exception:
pass
return None
def resolve_from_filename_info(filename, subfolder="", file_type="output"):
if not filename:
return None
if file_type == "input":
base = folder_paths.get_input_directory()
elif file_type == "temp":
base = folder_paths.get_temp_directory()
else:
base = folder_paths.get_output_directory()
p = Path(base) / str(subfolder) / str(filename)
if p.exists():
return str(p.resolve())
return None
print("[LoopGif] video object type:", type(video))
print("[LoopGif] video object repr:", repr(video))
path = as_existing_path(video)
if path:
return path
if isinstance(video, dict):
print("[LoopGif] video dict keys:", list(video.keys()))
for key in ["path", "fullpath", "video_path", "file_path", "filename"]:
path = as_existing_path(video.get(key))
if path:
return path
filename = video.get("filename")
subfolder = video.get("subfolder", "")
file_type = video.get("type", "output")
path = resolve_from_filename_info(filename, subfolder, file_type)
if path:
return path
for value in video.values():
path = as_existing_path(value)
if path:
return path
if isinstance(value, dict):
filename = value.get("filename")
subfolder = value.get("subfolder", "")
file_type = value.get("type", "output")
path = resolve_from_filename_info(filename, subfolder, file_type)
if path:
return path
if isinstance(video, (list, tuple)):
for item in video:
try:
return resolve_video_path(item)
except Exception:
pass
for method_name in [
"get_stream_source",
"get_path",
"get_file",
"get_filename",
"get_video_path",
]:
method = getattr(video, method_name, None)
if callable(method):
try:
value = method()
print(f"[LoopGif] {method_name}() ->", repr(value))
path = as_existing_path(value)
if path:
return path
if isinstance(value, dict):
return resolve_video_path(value)
except Exception as e:
print(f"[LoopGif] {method_name}() failed:", repr(e))
for attr_name in [
"path",
"fullpath",
"file_path",
"video_path",
"filename",
"file",
"name",
"_path",
"_file",
"_filename",
"__file",
]:
if hasattr(video, attr_name):
try:
value = getattr(video, attr_name)
print(f"[LoopGif] attr {attr_name} ->", repr(value))
path = as_existing_path(value)
if path:
return path
except Exception as e:
print(f"[LoopGif] attr {attr_name} failed:", repr(e))
obj_dict = getattr(video, "__dict__", None)
if isinstance(obj_dict, dict):
print("[LoopGif] video object __dict__ keys:", list(obj_dict.keys()))
for key, value in obj_dict.items():
print(f"[LoopGif] __dict__ {key} ->", repr(value))
path = as_existing_path(value)
if path:
return path
if isinstance(value, dict):
try:
return resolve_video_path(value)
except Exception:
pass
raise RuntimeError(
"无法解析视频路径。当前 VIDEO 输入对象不兼容,且未找到可直接读取的文件路径。"
)
def next_output_path(output_dir: Path, prefix: str) -> Path:
"""
模仿 ComfyUI 保存节点的简洁命名:
gif_00001_.gif
gif_00002_.gif
"""
pattern = re.compile(rf"^{re.escape(prefix)}_(\d{{5}})_\.gif$")
max_index = 0
for f in output_dir.glob(f"{prefix}_*.gif"):
m = pattern.match(f.name)
if m:
max_index = max(max_index, int(m.group(1)))
index = max_index + 1
return output_dir / f"{prefix}_{index:05d}_.gif"
class LoopGif:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video": ("VIDEO",),
"filename_prefix": ("STRING", {
"default": "gif",
"multiline": False,
}),
"delete_ratio": ("FLOAT", {
"default": 0.10,
"min": 0.0,
"max": 0.8,
"step": 0.01,
"display": "number",
}),
"gif_fps": ("FLOAT", {
"default": 24.0,
"min": 1.0,
"max": 60.0,
"step": 0.01,
"display": "number",
}),
"blend_frames": ("INT", {
"default": 2,
"min": 0,
"max": 8,
"step": 1,
}),
"blend_strength": ("FLOAT", {
"default": 0.10,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "number",
}),
"color_drift_strength": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 3.0,
"step": 0.01,
"display": "number",
}),
},
}
@classmethod
def IS_CHANGED(
cls,
video,
filename_prefix,
delete_ratio,
gif_fps,
blend_frames,
blend_strength,
color_drift_strength,
):
import time
return time.time()
RETURN_TYPES = ("STRING", "IMAGE", "FLOAT")
RETURN_NAMES = ("gif_path", "images", "frame_rate")
FUNCTION = "make_gif"
CATEGORY = "video/gif"
OUTPUT_NODE = True
def make_gif(
self,
video,
filename_prefix,
delete_ratio,
gif_fps,
blend_frames,
blend_strength,
color_drift_strength,
):
ffmpeg = find_ffmpeg()
prefix = safe_name(filename_prefix)
output_dir = Path(folder_paths.get_output_directory())
output_path = next_output_path(output_dir, prefix)
work_dir = output_dir / f".{output_path.stem}_work"
frames_dir = work_dir / "frames"
picked_dir = work_dir / "picked"
palette_path = work_dir / "palette.png"
if work_dir.exists():
shutil.rmtree(work_dir, ignore_errors=True)
frames_dir.mkdir(parents=True, exist_ok=True)
picked_dir.mkdir(parents=True, exist_ok=True)
picked_count = 0
picked_images = None
actual_blend_frames = 0
actual_color_drift_strength = 0.0
try:
components = resolve_video_components(video)
input_path = None
if components is not None and hasattr(components, "images"):
print("[LoopGif] using VideoFromComponents / in-memory frames")
frame_files = tensor_images_to_png_files(components.images, frames_dir)
print(f"[LoopGif] frames from components: {len(frame_files)}")
else:
input_path = resolve_video_path(video)
print(f"[LoopGif] input: {input_path}")
print(f"[LoopGif] output: {output_path}")
run_cmd([
ffmpeg,
"-y",
"-i", input_path,
"-fps_mode", "passthrough",
str(frames_dir / "frame_%03d.png"),
])
frame_files = sorted(frames_dir.glob("frame_*.png"))
print(f"[LoopGif] output: {output_path}")
count = len(frame_files)
if count < 4:
raise RuntimeError(f"视频帧数太少:{count},无法生成循环 GIF。")
delete_ratio = float(delete_ratio)
# 按 delete_ratio 删除首尾帧,不做首尾调换
# delete_ratio 近似为 0 时,不删帧
if delete_ratio <= 0.0001:
del_total = 0
del_head = 0
del_tail = 0
else:
del_total = round(count * delete_ratio)
del_total = max(1, del_total)
del_total = min(del_total, count - 3)
del_head = del_total // 2
del_tail = del_total - del_head
start_index = del_head
end_index = count - del_tail
picked = frame_files[start_index:end_index]
print(
f"[LoopGif] delete info: "
f"source_frames={count}, "
f"delete_ratio={delete_ratio}, "
f"delete_total={del_total}, "
f"delete_head={del_head}, "
f"delete_tail={del_tail}, "
f"kept_original_index={start_index + 1}..{end_index}, "
f"kept_frames={len(picked)}"
)
if del_head > 0:
print(
"[LoopGif] deleted head original frames:",
format_frame_numbers(1, del_head),
)
else:
print("[LoopGif] deleted head original frames: none")
if del_tail > 0:
print(
"[LoopGif] deleted tail original frames:",
format_frame_numbers(count - del_tail + 1, count),
)
else:
print("[LoopGif] deleted tail original frames: none")
picked_output_files = []
for i, src in enumerate(picked, start=1):
dst = picked_dir / f"pick_{i:03d}.png"
shutil.copy2(src, dst)
picked_output_files.append(dst)
picked_count = len(picked_output_files)
# 删除首尾帧之后,对最终参与循环的帧做颜色漂移补偿
# 不管 delete_ratio 是否为 0,只要 color_drift_strength > 0 就会生效
actual_color_drift_strength = apply_color_drift_correction(
picked_output_files,
color_drift_strength,
)
# 只有发生首尾删除后,才对新的首尾帧做对称式渐进叠化
# delete_ratio=0 时不做叠化,避免破坏原始首尾结构
if del_total > 0:
actual_blend_frames = apply_loop_blend(
picked_output_files,
blend_frames,
blend_strength,
)
else:
actual_blend_frames = 0
picked_images = images_to_tensor(picked_output_files)
run_cmd([
ffmpeg,
"-y",
"-framerate", str(float(gif_fps)),
"-i", str(picked_dir / "pick_%03d.png"),
"-vf", "palettegen",
"-frames:v", "1",
"-update", "1",
str(palette_path),
])
run_cmd([
ffmpeg,
"-y",
"-framerate", str(float(gif_fps)),
"-i", str(picked_dir / "pick_%03d.png"),
"-i", str(palette_path),
"-lavfi", "[0:v][1:v]paletteuse",
"-loop", "0",
str(output_path),
])
finally:
shutil.rmtree(work_dir, ignore_errors=True)
print(f"[LoopGif] done: {output_path}")
frame_rate = float(gif_fps)
return {
"ui": {
"text": [
f"GIF saved: {output_path.name}",
f"delete_ratio={delete_ratio}",
f"gif_fps={frame_rate}",
f"picked_frames={picked_count}",
f"blend_frames={actual_blend_frames}",
f"blend_strength={blend_strength}",
f"color_drift_strength={actual_color_drift_strength}",
],
},
"result": (str(output_path), picked_images, frame_rate),
}
NODE_CLASS_MAPPINGS = {
"LoopGif": LoopGif,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LoopGif": "Loop GIF"
}
+3
View File
@@ -0,0 +1,3 @@
numpy
Pillow
imageio-ffmpeg