Add GIF frame loading and deduplication nodes

- LoadGifFrames: loads GIF via Pillow (no OpenCV/FFmpeg), deduplicates
  identical frames using MD5 hash, outputs unique_frames batch + frame_map
  JSON for reconstruction. Alpha channel composited over white background.
- RemapGifFrames: decompressor node that expands deduplicated processed
  frames back to full sequence using frame_map.
- BatchToImageList: splits IMAGE batch [N,H,W,C] into list of N individual
  frames so ComfyUI iterates the pipeline per-frame, avoiding RAM spikes
  on VAE encode/decode with large GIF batches.
- js/load_gif_frames.js: upload widget for LoadGifFrames accepting .gif only.
This commit is contained in:
Marco
2026-03-10 23:09:44 -03:00
parent 6ca03b227e
commit e1adac9396
4 changed files with 219 additions and 0 deletions
+11
View File
@@ -42,6 +42,9 @@ if HAS_NEW_VIDEO_API:
from .acestep_loader import AceStepLoKrLoader
from .audio_waveform_slicer import AudioWaveformSlicer
from .audio_slice_selector import AudioSliceSelector
from .audio_concatenate import AudioConcatenate
from .load_gif_frames import LoadGifFrames, RemapGifFrames
from .batch_image_list import BatchToImageList
from . import server_routes # Register Custom API Routes
NODE_CLASS_MAPPINGS = {
@@ -74,6 +77,10 @@ NODE_CLASS_MAPPINGS = {
"AceStepLoKrLoader": AceStepLoKrLoader,
"AudioWaveformSlicer": AudioWaveformSlicer,
"AudioSliceSelector": AudioSliceSelector,
"AudioConcatenate": AudioConcatenate,
"LoadGifFrames": LoadGifFrames,
"RemapGifFrames": RemapGifFrames,
"BatchToImageList": BatchToImageList,
}
# Add V3 nodes if the new API is available
@@ -109,6 +116,10 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"AceStepLoKrLoader": "AceStep LoKr Loader",
"AudioWaveformSlicer": "Audio Waveform Slicer",
"AudioSliceSelector": "Audio Slice Selector",
"AudioConcatenate": "Audio Concatenate",
"LoadGifFrames": "Load GIF Frames (Raw)",
"RemapGifFrames": "Remap GIF Frames",
"BatchToImageList": "Batch to Image List",
}
# Add V3 display names if available
+28
View File
@@ -0,0 +1,28 @@
import torch
class BatchToImageList:
"""Splits an IMAGE batch [N,H,W,C] into a list of N individual frames.
ComfyUI will iterate over the list, running each frame through the pipeline
independently — avoiding RAM spikes from large batches in VAE encode/decode.
Use ImageListToBatch+ downstream to collect results back into a batch.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "split"
CATEGORY = "image"
TITLE = "Batch to Image List"
def split(self, images):
# images: [N, H, W, C] → list of N tensors [1, H, W, C]
return ([images[i:i+1] for i in range(images.shape[0])],)
+79
View File
@@ -0,0 +1,79 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "AnotherUtils.LoadGifFrames",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "LoadGifFrames") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const node = this;
const gifWidget = this.widgets.find((w) => w.name === "gif");
// Hidden file input that only accepts GIF
const fileInput = document.createElement("input");
Object.assign(fileInput, {
type: "file",
accept: ".gif,image/gif",
style: "display: none",
onchange: async () => {
const file = fileInput.files[0];
if (!file) return;
// Upload to ComfyUI input directory
const body = new FormData();
body.append("image", new File([file], file.name, { type: file.type, lastModified: file.lastModified }));
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status !== 200) {
alert("GIF upload failed: " + resp.statusText);
return;
}
const data = await resp.json();
const filename = data.name;
// Add to dropdown options and select it
if (gifWidget) {
if (!gifWidget.options.values.includes(filename)) {
gifWidget.options.values.push(filename);
}
gifWidget.value = filename;
if (gifWidget.callback) gifWidget.callback(filename);
}
},
});
document.body.append(fileInput);
// Upload button
const uploadBtn = this.addWidget("button", "Upload GIF", "gif_upload", () => {
app.canvas.node_widget = null;
fileInput.click();
});
uploadBtn.options.serialize = false;
// Drag & drop support
this.onDragOver = (e) => !!e?.dataTransfer?.types?.includes?.("Files");
this.onDragDrop = async (e) => {
if (!e?.dataTransfer?.types?.includes?.("Files")) return false;
const file = e.dataTransfer?.files?.[0];
if (!file || file.type !== "image/gif") return false;
fileInput.files = e.dataTransfer.files;
fileInput.dispatchEvent(new Event("change"));
return true;
};
// Remove fileInput when node is deleted
const onRemoved = this.onRemoved;
this.onRemoved = function () {
fileInput?.remove();
return onRemoved ? onRemoved.apply(this, arguments) : undefined;
};
return r;
};
},
});
+101
View File
@@ -0,0 +1,101 @@
import os
import hashlib
import json
import numpy as np
import torch
from PIL import Image, ImageSequence
import folder_paths
class LoadGifFrames:
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if f.lower().endswith('.gif')]
return {
"required": {
"gif": (sorted(files), {"image_upload": True}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "INT", "INT")
RETURN_NAMES = ("unique_frames", "frame_map", "unique_count", "total_frames")
FUNCTION = "load_gif"
CATEGORY = "image/loaders"
TITLE = "Load GIF Frames (Raw)"
def load_gif(self, gif):
gif_path = folder_paths.get_annotated_filepath(gif)
img = Image.open(gif_path)
all_frames = []
for frame in ImageSequence.Iterator(img):
frame_rgba = frame.convert('RGBA')
background = Image.new('RGB', frame_rgba.size, (255, 255, 255))
background.paste(frame_rgba, mask=frame_rgba.split()[3])
frame_np = np.array(background, dtype=np.float32) / 255.0
all_frames.append(frame_np)
# Deduplicate: hash each frame to find identical ones
unique_frames = []
frame_map = [] # frame_map[i] = index in unique_frames
hash_to_index = {}
for frame_np in all_frames:
h = hashlib.md5(frame_np.tobytes()).hexdigest()
if h not in hash_to_index:
hash_to_index[h] = len(unique_frames)
unique_frames.append(frame_np)
frame_map.append(hash_to_index[h])
unique_batch = torch.stack([torch.from_numpy(f) for f in unique_frames]) # [U, H, W, 3]
return (unique_batch, json.dumps(frame_map), len(unique_frames), len(all_frames))
@classmethod
def IS_CHANGED(cls, gif):
gif_path = folder_paths.get_annotated_filepath(gif)
m = hashlib.sha256()
with open(gif_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(cls, gif):
if not folder_paths.exists_annotated_filepath(gif):
return f"GIF file not found: {gif}"
return True
class RemapGifFrames:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"processed_frames": ("IMAGE",),
"frame_map": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("frames", "total_frames")
FUNCTION = "remap"
CATEGORY = "image/loaders"
TITLE = "Remap GIF Frames"
def remap(self, processed_frames, frame_map):
mapping = json.loads(frame_map) # e.g. [0, 0, 1, 2, 2, 1]
unique_count = processed_frames.shape[0]
required = max(mapping) + 1
if unique_count < required:
raise ValueError(
f"RemapGifFrames: processed_frames tem {unique_count} frame(s), mas frame_map exige {required}. "
f"O pipeline entre LoadGifFrames e RemapGifFrames deve preservar todos os {required} unique_frames como batch — "
f"verifique se algum nó no meio está selecionando ou descartando frames."
)
# Reconstruct full sequence by indexing into processed_frames
reordered = torch.stack([processed_frames[i] for i in mapping]) # [N, H, W, C]
return (reordered, len(mapping))