import torch import os import kornia import pathlib import typing import cv2 import numpy as np import tempfile import shutil import warnings import logging from functools import cmp_to_key from packaging import version from vfi_utils import load_file_from_github_release, preprocess_frames, postprocess_frames, generic_frame_loop, InterpolationStateList from comfy.model_management import get_torch_device from insightface.app import FaceAnalysis from insightface.model_zoo import get_model from urllib.request import urlretrieve import sys # ------------------------------- # Logging & warning setup # ------------------------------- DEBUG = False os.environ["ORT_LOG_LEVEL"] = "3" # ONNX only shows errors os.environ["ORT_CUDA_MEMORY_LIMIT"] = "0" warnings.simplefilter("ignore", category=FutureWarning) warnings.simplefilter("ignore", category=DeprecationWarning) warnings.simplefilter("ignore", category=UserWarning) logging.getLogger("insightface").setLevel(logging.ERROR) logging.getLogger("urllib3").setLevel(logging.ERROR) logging.getLogger("onnxruntime").setLevel(logging.ERROR) warnings.filterwarnings("ignore") # suppress all remaining warnings logging.basicConfig(level=logging.ERROR) # ------------------------------- # Context manager to suppress stdout/stderr temporarily # ------------------------------- class suppress_stdout_stderr: """Suppress all console output temporarily""" def __enter__(self): self._stdout = sys.stdout self._stderr = sys.stderr sys.stdout = open(os.devnull, "w") sys.stderr = open(os.devnull, "w") def __exit__(self, exc_type, exc_val, exc_tb): sys.stdout.close() sys.stderr.close() sys.stdout = self._stdout sys.stderr = self._stderr def log(msg, progress=None): """Unified log function""" if DEBUG: print(msg) if progress is not None: # Optionally integrate with ComfyUI progress here pass # ------------------------------- # RIFE VFI Node # ------------------------------- MODEL_TYPE = pathlib.Path(__file__).parent.name CKPT_NAME_VER_DICT = { "rife40.pth": "4.0", "rife41.pth": "4.0", "rife42.pth": "4.2", "rife43.pth": "4.3", "rife44.pth": "4.3", "rife45.pth": "4.5", "rife46.pth": "4.6", "rife47.pth": "4.7", "rife48.pth": "4.7", "rife49.pth": "4.7" } class RIFE_VFI: @classmethod def INPUT_TYPES(s): return { "required": { "ckpt_name": ( sorted(list(CKPT_NAME_VER_DICT.keys()), key=lambda ckpt_name: version.parse(CKPT_NAME_VER_DICT[ckpt_name])), {"default": "rife47.pth"} ), "frames": ("IMAGE", ), "clear_cache_after_n_frames": ("INT", {"default": 10, "min": 1, "max": 1000}), "multiplier": ("INT", {"default": 2, "min": 1}), "fast_mode": ("BOOLEAN", {"default":True}), "ensemble": ("BOOLEAN", {"default":True}), "scale_factor": ([0.25, 0.5, 1.0, 2.0, 4.0], {"default": 1.0}) }, "optional": { "optional_interpolation_states": ("INTERPOLATION_STATES", ) } } RETURN_TYPES = ("IMAGE", ) FUNCTION = "vfi" CATEGORY = "bmgjet" def vfi( self, ckpt_name: typing.AnyStr, frames: torch.Tensor, clear_cache_after_n_frames = 10, multiplier: typing.SupportsInt = 2, fast_mode = False, ensemble = False, scale_factor = 1.0, optional_interpolation_states: InterpolationStateList = None, **kwargs ): from .rife_arch import IFNet model_path = load_file_from_github_release(MODEL_TYPE, ckpt_name) arch_ver = CKPT_NAME_VER_DICT[ckpt_name] interpolation_model = IFNet(arch_ver=arch_ver) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") state = torch.load(model_path, map_location=device) interpolation_model.load_state_dict(state) interpolation_model = interpolation_model.to(device).eval() frames = preprocess_frames(frames).to(device) def return_middle_frame(frame_0, frame_1, timestep, model, scale_list, in_fast_mode, in_ensemble): return model(frame_0, frame_1, timestep, scale_list, in_fast_mode, in_ensemble) scale_list = [8 / scale_factor, 4 / scale_factor, 2 / scale_factor, 1 / scale_factor] args = [interpolation_model, scale_list, fast_mode, ensemble] out = postprocess_frames( generic_frame_loop(type(self).__name__, frames, clear_cache_after_n_frames, multiplier, return_middle_frame, *args, interpolation_states=optional_interpolation_states, dtype=torch.float32) ) return (out,) # ------------------------------- # InputSwitch Node # ------------------------------- class InputSwitch: @classmethod def INPUT_TYPES(cls): return { "required": { "mode": (["Image", "Video"],), "max_width": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 2}), "max_height": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 2}), }, "optional": { "Image": ("IMAGE",), "Video": ("IMAGE",), }, } RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "IMAGE") RETURN_NAMES = ("image", "width", "height", "frame_count", "images") FUNCTION = "load_media" CATEGORY = "bmgjet" def load_media(self, mode, max_width, max_height, Image=None, Video=None, on_progress=None): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") def log(msg, progress=None): if on_progress: on_progress(msg, progress) else: print("[InputSwitch]", msg) def round2(x): return x - (x % 2) max_width = round2(max_width) max_height = round2(max_height) # Helper resize function def resize_if_needed(tensor): if tensor is None: raise ValueError("[InputSwitch] Received None tensor for resizing") is_video = (tensor.ndim == 4) if is_video: N, H, W, C = tensor.shape else: H, W, C = tensor.shape N = 1 if W <= max_width and H <= max_height: return tensor, H, W scale = min(max_width / W, max_height / H) new_w = round2(int(W * scale)) new_h = round2(int(H * scale)) if is_video: t = tensor.permute(0, 3, 1, 2) else: t = tensor.unsqueeze(0).permute(0, 3, 1, 2) t_resized = kornia.geometry.transform.resize( t, (new_h, new_w), interpolation="bicubic", antialias=True ) if is_video: out = t_resized.permute(0, 2, 3, 1) else: out = t_resized.permute(0, 2, 3, 1).squeeze(0) return out, new_h, new_w if mode == "Image": if Image is None: raise ValueError("No image provided for 'Image' mode.") Image = Image.to(device) log("Resizing image...", 0.0) Image, h_new, w_new = resize_if_needed(Image) Image = Image.unsqueeze(0) if Image.ndim == 3 else Image Image_dup = torch.cat([Image, Image.clone()], dim=0) log(f"Width={w_new}, Height={h_new}, Frames=1", 1.0) return (Image, w_new, h_new, 1, Image_dup) if mode == "Video": if Video is None: raise ValueError("No video input provided for 'Video' mode.") if isinstance(Video, torch.Tensor): if Video.ndim == 3: Video = Video.unsqueeze(0) Video = Video.to(device) elif isinstance(Video, (list, tuple)): Video = torch.stack([v.to(device) if isinstance(v, torch.Tensor) else torch.tensor(v, device=device) for v in Video]) else: raise TypeError("Unsupported video input type.") resized_frames = [] total_frames = Video.shape[0] for idx, f in enumerate(Video): f_resized, h_new, w_new = resize_if_needed(f) resized_frames.append(f_resized) if on_progress: on_progress(f"Resizing frame {idx+1}/{total_frames}", (idx+1)/total_frames) Video = torch.stack(resized_frames) frame_count = Video.shape[0] last_frame = Video[-1:].clone() video_without_last = Video[:-1].clone() log(f"Width={w_new}, Height={h_new}, Frames={frame_count}", 1.0) if last_frame.ndim == 3: last_frame = last_frame.unsqueeze(0) if video_without_last.ndim == 3: video_without_last = video_without_last.unsqueeze(0) return (last_frame, w_new, h_new, frame_count, video_without_last) class MoveToDevice: @classmethod def INPUT_TYPES(cls): # Accepts ANY ComfyUI input type return { "required": { "data": ("IMAGE",), } } RETURN_TYPES = ("IMAGE",) FUNCTION = "move" CATEGORY = "bmgjet" def move(self, data): device = get_torch_device() def move_any(x): if isinstance(x, torch.Tensor): return x.to(device) if isinstance(x, list): return [move_any(v) for v in x] if isinstance(x, tuple): return tuple(move_any(v) for v in x) if isinstance(x, dict): return {k: move_any(v) for k, v in x.items()} return x return (move_any(data),) class RoundToEven: @classmethod def INPUT_TYPES(cls): return { "required": { "value": ("INT", {"default": 1, "min": 2, "max": 999999}), } } RETURN_TYPES = ("INT",) FUNCTION = "round_even" CATEGORY = "bmgjet" def round_even(self, value): # Round to nearest number divisible by 2 # (i.e., nearest even number) rounded = int(2 * round(value / 2)) return (rounded,) class ColorMatch: @classmethod def INPUT_TYPES(cls): return { "required": { "images": ("IMAGE",), "reference_image": ("IMAGE",), "match_strength": ( "FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01} ) } } RETURN_TYPES = ("IMAGE",) FUNCTION = "match_color" CATEGORY = "bmgjet" def match_color(self, images, reference_image, match_strength, on_progress=None): import torch import kornia import os import sys import time device = torch.device("cuda" if torch.cuda.is_available() else "cpu") images = images.to(device) reference_image = reference_image.to(device) images = images.permute(0, 3, 1, 2) reference_image = reference_image.permute(0, 3, 1, 2) ref_lab = kornia.color.rgb_to_lab(reference_image) ref_mean = ref_lab.mean(dim=[2, 3], keepdim=True) ref_std = ref_lab.std(dim=[2, 3], keepdim=True) + 1e-5 num_threads = os.cpu_count() or 4 batch_size = min(128, max(4, num_threads)) outputs = [] total_batches = (images.shape[0] + batch_size - 1) // batch_size bar_length = 30 start_time = time.time() for batch_idx, start in enumerate(range(0, images.shape[0], batch_size)): batch = images[start:start + batch_size] img_lab = kornia.color.rgb_to_lab(batch) img_mean = img_lab.mean(dim=[2, 3], keepdim=True) img_std = img_lab.std(dim=[2, 3], keepdim=True) + 1e-5 matched_lab = (img_lab - img_mean) / img_std * ref_std + ref_mean blended_lab = match_strength * matched_lab + (1.0 - match_strength) * img_lab output = kornia.color.lab_to_rgb(blended_lab) outputs.append(output) # ------------------------------- # Console progress bar (single-line) # ------------------------------- progress = (batch_idx + 1) / total_batches filled = int(bar_length * progress) bar = "█" * filled + "-" * (bar_length - filled) elapsed = time.time() - start_time avg_time = elapsed / (batch_idx + 1) remaining = avg_time * (total_batches - batch_idx - 1) msg = f"[ColorMatch] Progress: |{bar}| {batch_idx + 1}/{total_batches} ETA: {remaining:.1f}s" if on_progress: on_progress(msg, progress) else: print(f"\r{msg}", end="") sys.stdout.flush() output = torch.cat(outputs, dim=0).clamp(0.0, 1.0) output = output.permute(0, 2, 3, 1) output = output.to(device) if on_progress is None: print() # move to next line after completion print("[ColorMatch] Color matching complete.") else: on_progress("[ColorMatch] Color matching complete.", 1.0) return (output,) class FaceSwapNode: face_app = None swapper_model = None cached_src_face = None cached_src_image = None # store last source image for comparison @classmethod def INPUT_TYPES(cls): return { "required": { "source_image": ("IMAGE",), "target_images": ("IMAGE",), } } RETURN_TYPES = ("IMAGE",) FUNCTION = "swap_faces" CATEGORY = "bmgjet" def swap_faces(self, source_image, target_images): import os import numpy as np import torch import time import sys from urllib.request import urlretrieve from insightface.app import FaceAnalysis from insightface.model_zoo import get_model device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # ------------------------------- # Helpers # ------------------------------- def ensure_rgb(img): if img is None: raise ValueError("[FaceSwapNode] Received None image input") if isinstance(img, torch.Tensor): img = img.detach().cpu().numpy() if img.ndim == 4: img = img[0] if img.ndim == 3 and img.shape[0] in (1,3): img = np.transpose(img, (1,2,0)) img = (img*255).astype(np.uint8) if img.ndim == 2: img = np.stack([img]*3, axis=-1) elif img.ndim == 3 and img.shape[2] == 1: img = np.repeat(img, 3, axis=2) return np.ascontiguousarray(img) def to_tensor(img: np.ndarray): img = ensure_rgb(img) img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) img = cv2.flip(img, 1) # 1 = horizontal flip img = np.ascontiguousarray(img) t = torch.from_numpy(img).permute(1,0,2).float() / 255.0 return t.to(device) def images_equal(img1, img2): if img1 is None or img2 is None: return False return np.array_equal(img1, img2) # ------------------------------- # Load inswapper and FaceAnalysis models silently # ------------------------------- INSWAPPER_URL = "https://huggingface.co/ezioruan/inswapper_128.onnx/resolve/main/inswapper_128.onnx" base_folder = os.path.join(os.path.dirname(__file__), "Models") os.makedirs(base_folder, exist_ok=True) model_path = os.path.join(base_folder, "inswapper_128.onnx") if not os.path.exists(model_path): print("[FaceSwapNode] Downloading inswapper_128.onnx...") urlretrieve(INSWAPPER_URL, model_path) with suppress_stdout_stderr(): if FaceSwapNode.swapper_model is None: FaceSwapNode.swapper_model = get_model(model_path, download=False) if FaceSwapNode.face_app is None: ctx = 0 if torch.cuda.is_available() else -1 FaceSwapNode.face_app = FaceAnalysis(name="buffalo_l") FaceSwapNode.face_app.prepare(ctx_id=ctx, det_size=(640,640)) app = FaceSwapNode.face_app swapper = FaceSwapNode.swapper_model # ------------------------------- # Prepare source face # ------------------------------- src_img = ensure_rgb(source_image) if FaceSwapNode.cached_src_face is None or not images_equal(FaceSwapNode.cached_src_image, src_img): with suppress_stdout_stderr(): faces = app.get(src_img) or [] FaceSwapNode.cached_src_face = faces[0] if faces else None FaceSwapNode.cached_src_image = src_img src_face = FaceSwapNode.cached_src_face # ------------------------------- # Prepare target images # ------------------------------- tgt_list = [] if isinstance(target_images, torch.Tensor): if target_images.ndim == 3: tgt_list = [target_images] elif target_images.ndim == 4: tgt_list = [target_images[i] for i in range(target_images.shape[0])] elif isinstance(target_images, (list, tuple)): tgt_list = [t for t in target_images if t is not None] else: tgt_list = [target_images] tgt_list = [ensure_rgb(t) for t in tgt_list if t is not None] if len(tgt_list) == 0: raise ValueError("[FaceSwapNode] No valid target images to process.") # ------------------------------- # Face swapping loop # ------------------------------- swapped_list = [] total = len(tgt_list) bar_length = 30 start_time = time.time() for idx, tgt in enumerate(tgt_list, 1): with suppress_stdout_stderr(): if src_face is None: swapped_list.append(tgt) else: tgt_faces = app.get(tgt) or [] if not tgt_faces: swapped_list.append(tgt) else: swapped_list.append(swapper.get(tgt, tgt_faces[0], src_face, paste_back=True)) # Progress bar (single line) elapsed = time.time() - start_time avg_time = elapsed / idx remaining = avg_time * (total - idx) filled = int(bar_length * idx // total) bar = "█" * filled + "-" * (bar_length - filled) print(f"\r[FaceSwapNode] Progress: |{bar}| {idx}/{total} ETA: {remaining:.1f}s", end="") sys.stdout.flush() print("\n[FaceSwapNode] Face swapping complete.") # Convert swapped images to tensor swapped_tensors = [to_tensor(img) for img in swapped_list] video_tensor = torch.stack(swapped_tensors, dim=0) return (video_tensor,) # ------------------------------- # Basic Math Nodes # ------------------------------- class MathAdd: @classmethod def INPUT_TYPES(cls): return { "required": { "a": ("FLOAT", {"default": 0.0}), "b": ("FLOAT", {"default": 0.0}), } } RETURN_TYPES = ("FLOAT",) FUNCTION = "add" CATEGORY = "bmgjet/math" def add(self, a, b): return (float(a + b),) class MathSubtract: @classmethod def INPUT_TYPES(cls): return { "required": { "a": ("FLOAT", {"default": 0.0}), "b": ("FLOAT", {"default": 0.0}), } } RETURN_TYPES = ("FLOAT",) FUNCTION = "subtract" CATEGORY = "bmgjet/math" def subtract(self, a, b): return (float(a - b),) class MathMultiply: @classmethod def INPUT_TYPES(cls): return { "required": { "a": ("FLOAT", {"default": 0.0}), "b": ("FLOAT", {"default": 0.0}), } } RETURN_TYPES = ("FLOAT",) FUNCTION = "multiply" CATEGORY = "bmgjet/math" def multiply(self, a, b): return (float(a * b),) class MathDivide: @classmethod def INPUT_TYPES(cls): return { "required": { "a": ("FLOAT", {"default": 0.0}), "b": ("FLOAT", {"default": 1.0}), } } RETURN_TYPES = ("FLOAT",) FUNCTION = "divide" CATEGORY = "bmgjet/math" def divide(self, a, b): if b == 0: return (0.0,) return (float(a / b),) # ------------------------------- # Float → Int Node # ------------------------------- class FloatToInt: @classmethod def INPUT_TYPES(cls): return { "required": { "value": ("FLOAT", {"default": 0.0}), } } RETURN_TYPES = ("INT",) FUNCTION = "convert" CATEGORY = "bmgjet/math" def convert(self, value): return (int(value),) # Node registration NODE_CLASS_MAPPINGS = {"InputSwitch": InputSwitch, "ColorMatch": ColorMatch, "RIFE_VFI": RIFE_VFI, "RoundToEven": RoundToEven,"MoveToDevice": MoveToDevice, "FaceSwapNode": FaceSwapNode, "MathAdd": MathAdd, "MathSubtract": MathSubtract, "MathMultiply": MathMultiply, "MathDivide": MathDivide, "FloatToInt": FloatToInt,} NODE_DISPLAY_NAME_MAPPINGS = {"InputSwitch": "Input Switch", "ColorMatch": "Color Match", "RIFE VFI": "RIFE VFI", "RoundToEven": "Round To Even","MoveToDevice": "Move To Device","FaceSwapNode": "Face Swap", "MathAdd": "Math Add", "MathSubtract": "Math Subtract", "MathMultiply": "Math Multiply", "MathDivide": "Math Divide", "FloatToInt": "Float To Int",}