From e1adac9396c691cdd7dae60cca1983e0ff802b16 Mon Sep 17 00:00:00 2001 From: Marco Date: Tue, 10 Mar 2026 23:09:44 -0300 Subject: [PATCH] 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. --- __init__.py | 11 +++++ batch_image_list.py | 28 ++++++++++++ js/load_gif_frames.js | 79 +++++++++++++++++++++++++++++++++ load_gif_frames.py | 101 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 batch_image_list.py create mode 100644 js/load_gif_frames.js create mode 100644 load_gif_frames.py diff --git a/__init__.py b/__init__.py index bd90fcf..0bcfa88 100644 --- a/__init__.py +++ b/__init__.py @@ -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 diff --git a/batch_image_list.py b/batch_image_list.py new file mode 100644 index 0000000..e317830 --- /dev/null +++ b/batch_image_list.py @@ -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])],) diff --git a/js/load_gif_frames.js b/js/load_gif_frames.js new file mode 100644 index 0000000..8a8a0d3 --- /dev/null +++ b/js/load_gif_frames.js @@ -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; + }; + }, +}); diff --git a/load_gif_frames.py b/load_gif_frames.py new file mode 100644 index 0000000..0d050f9 --- /dev/null +++ b/load_gif_frames.py @@ -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))