Initial release
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
.DS_Store
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
output/
|
||||
input/
|
||||
temp/
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from .loop_gif import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
+743
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
numpy
|
||||
Pillow
|
||||
imageio-ffmpeg
|
||||
Reference in New Issue
Block a user