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:
+11
@@ -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
|
||||
|
||||
@@ -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])],)
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user