diff --git a/nodes/FV_LoadImageAdvanced.py b/nodes/FV_LoadImageAdvanced.py new file mode 100644 index 0000000..aff5d37 --- /dev/null +++ b/nodes/FV_LoadImageAdvanced.py @@ -0,0 +1,221 @@ +import os +import numpy as np +import torch +from PIL import Image +import folder_paths + +RATIOS = ["1:1", "5:4", "4:3", "3:2", "16:9", "2:1", "21:9", "32:9"] + + +def ensure_multiple_of_32(img): + """Ajuste les dimensions d'une image PIL pour qu'elles soient multiples de 32.""" + w, h = img.size + if w % 32 != 0 or h % 32 != 0: + w_new = max(32, int(round(w / 32.0) * 32)) + h_new = max(32, int(round(h / 32.0) * 32)) + return img.resize((w_new, h_new), Image.Resampling.LANCZOS) + return img + + +def calculate_dimensions(ratio_str, portrait, target_megapixel): + """Calcule les dimensions (largeur, hauteur) arrondies à un multiple de 32.""" + try: + w_ratio, h_ratio = map(float, ratio_str.split(":")) + except ValueError: + w_ratio, h_ratio = 1.0, 1.0 + + if portrait: + w_ratio, h_ratio = h_ratio, w_ratio + + aspect_ratio = w_ratio / h_ratio + target_pixels = target_megapixel * 1_000_000 + + h = np.sqrt(target_pixels / aspect_ratio) + w = aspect_ratio * h + + w = max(32, int(round(w / 32.0) * 32)) + h = max(32, int(round(h / 32.0) * 32)) + return w, h + + +def scale_to_megapixel(img, target_megapixel): + """Redimensionne une image PIL selon un nombre de mégapixels cible, multiple de 32.""" + w_orig, h_orig = img.size + aspect_ratio = w_orig / h_orig + target_pixels = target_megapixel * 1_000_000 + + h = np.sqrt(target_pixels / aspect_ratio) + w = aspect_ratio * h + + w = max(32, int(round(w / 32.0) * 32)) + h = max(32, int(round(h / 32.0) * 32)) + return img.resize((w, h), Image.Resampling.LANCZOS) + + +class Load_Image_Advanced: + @classmethod + def INPUT_TYPES(cls): + input_dir = folder_paths.get_input_directory() + files = [ + f + for f in os.listdir(input_dir) + if os.path.isfile(os.path.join(input_dir, f)) + ] + return { + "required": { + "image_file": (sorted(files), {"image_upload": True}), + "enable": ( + "BOOLEAN", + { + "default": True, + "label_on": "Enabled 🟢", + "label_off": "Disabled 🔴", + }, + ), + "scale_image": ("BOOLEAN", {"default": False}), + "megapixel": ( + "FLOAT", + {"default": 1.0, "min": 0.1, "max": 16.0, "step": 0.1}, + ), + "ratio": (RATIOS, {"default": "1:1"}), + "orientation": ( + "BOOLEAN", + { + "default": False, + "label_on": "Portrait ▯", + "label_off": "Landscape ▭", + }, + ), + } + } + + RETURN_TYPES = ("IMAGE_PARAMS",) + RETURN_NAMES = ("_",) + FUNCTION = "run" + CATEGORY = "Fictiverse/Image" + + def run(self, image_file, enable, scale_image, megapixel, ratio, orientation): + if enable: + image_path = folder_paths.get_annotated_filepath(image_file) + img = Image.open(image_path) + img = img.convert("RGB") + + if scale_image: + img = scale_to_megapixel(img, megapixel) + else: + img = ensure_multiple_of_32(img) + + w, h = img.size + else: + w, h = calculate_dimensions(ratio, orientation, megapixel) + img = Image.new("RGB", (w, h), (0, 0, 0)) + + image_tensor = torch.from_numpy( + np.array(img).astype(np.float32) / 255.0 + )[None,] + + # Compactage de tous les paramètres et de l'image + params = { + "image": image_tensor, + "width": w, + "height": h, + "megapixel": megapixel, + "enabled": enable, + } + + return (params,) + + + +class Load_Image_Bypass: + @classmethod + def INPUT_TYPES(cls): + input_dir = folder_paths.get_input_directory() + + # Extensions d'images et de GIFs supportées par PIL + valid_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.gif', '.apng'} + + files = [ + f for f in os.listdir(input_dir) + if os.path.isfile(os.path.join(input_dir, f)) + and os.path.splitext(f)[1].lower() in valid_extensions + ] + + return { + "required": { + "image_file": (sorted(files), {"image_upload": True}), + "enable": ( + "BOOLEAN", + { + "default": True, + "label_on": "Active 🟢", + "label_off": "Disabled 🔴", + }, + ), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "run" + CATEGORY = "Fictiverse/Image" + + def run(self, image_file, enable): + # Si désactivé, on retourne (None,) pour interrompre la branche dans ComfyUI + if not enable: + return (None,) + + image_path = folder_paths.get_annotated_filepath(image_file) + img = Image.open(image_path) + img = img.convert("RGB") + img = ensure_multiple_of_32(img) + + image_tensor = torch.from_numpy( + np.array(img).astype(np.float32) / 255.0 + )[None,] + + return (image_tensor,) + + +class Unpack_Image_Params: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "params": ("IMAGE_PARAMS",), + } + } + + RETURN_TYPES = ("IMAGE", "INT", "INT", "FLOAT", "BOOLEAN") + RETURN_NAMES = ("image", "width", "height", "megapixel", "enabled") + FUNCTION = "unpack" + CATEGORY = "Fictiverse/Image" + + def unpack(self, params): + image = params.get("image") + if image is None: + image = torch.zeros((1, 512, 512, 3), dtype=torch.float32) + + return ( + image, + params.get("width", 512), + params.get("height", 512), + params.get("megapixel", 1.0), + params.get("enabled", True), + ) + + +# ========================================== +# MAPPINGS COMFYUI +# ========================================== +NODE_CLASS_MAPPINGS = { + "LoadImageAdvanced": Load_Image_Advanced, + "LoadImageBypass": Load_Image_Bypass, + "UnpackImageParams": Unpack_Image_Params, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadImageAdvanced": "Load Image Advanced", + "LoadImageBypass": "Load Image Bypass", + "UnpackImageParams": "Unpack Image Params", +} \ No newline at end of file diff --git a/nodes/FV_VideoParams.py b/nodes/FV_VideoParams.py index fbca5dc..de1cdc4 100644 --- a/nodes/FV_VideoParams.py +++ b/nodes/FV_VideoParams.py @@ -1,78 +1,122 @@ -#Code inspired by sv-nodes, thanks to him. - import math +# ========================================== +# CLASSSES PARAMS VIDÉO +# ========================================== + + class VideoParams: RATIOS = ["1:1", "5:4", "4:3", "3:2", "16:9", "21:9"] - - def __init__(self): - pass - + @classmethod - def INPUT_TYPES(s): + def INPUT_TYPES(cls): return { "required": { - "base": ("INT", {"default": 768, "min": 128, "max": 4096, "step": 128}), - "ratio": (VideoParams.RATIOS,), + "base": ( + "INT", + {"default": 768, "min": 128, "max": 4096, "step": 128}, + ), + "ratio": (cls.RATIOS,), "orientation": ("BOOLEAN", {"default": False, "label_on": "Portrait ▯", "label_off": "Landscape ▭"}), - "Frames": ("INT", {"min": 1, "max": 600, "step": 1, "default": 81}), - "FPS": ("INT", {"min": 1, "max": 120, "step": 1, "default": 16}) + "duration": ( + "FLOAT", + {"default": 10.0, "min": 1.0, "max": 60.0, "step": 1.0}, + ), } } - + RETURN_TYPES = ("VParams",) RETURN_NAMES = ("_",) - FUNCTION = "run" CATEGORY = "Fictiverse/Params" - - def run(self, base, ratio, orientation, Frames, FPS,): - - ratio = ratio.split(":") - if len(ratio) != 2: - raise ValueError("Invalid ratio") - - ratio = math.sqrt(float(ratio[0]) / float(ratio[1])) - - width = math.floor(base * ratio / 64) * 64 - height = math.floor(base / ratio / 64) * 64 - - #Frames = max(7, (math.floor(Frames / 6) * 6) + 1) - megapixels = (base*base)/1000000 + def run(self, base, ratio, orientation, duration): + ratio_parts = ratio.split(":") + if len(ratio_parts) != 2: + raise ValueError("Invalid ratio format") + ratio_val = math.sqrt(float(ratio_parts[0]) / float(ratio_parts[1])) + + # Calcul des dimensions multiples de 32 + width = math.floor(base * ratio_val / 32) * 32 + height = math.floor(base / ratio_val / 32) * 32 if orientation: width, height = height, width - return ((width, height, Frames, float(FPS), megapixels),) -NODE_CLASS_MAPPINGS = { - "Video Params": VideoParams, -} + # Calcul plus précis des mégapixels réels en sortie + megapixels = (width * height) / 1_000_000.0 + + return ((width, height, duration, megapixels),) + class VideoParamsExpand: - def __init__(self): - pass - @classmethod - def INPUT_TYPES(s): + def INPUT_TYPES(cls): + return {"required": {"_": ("VParams",)}} + + RETURN_TYPES = ("INT", "INT", "FLOAT", "FLOAT") + RETURN_NAMES = ("width", "height", "duration", "Megapixels") + FUNCTION = "run" + CATEGORY = "Fictiverse/Params" + + def run(self, _): + if not isinstance(_, (tuple, list)): + raise TypeError("Invalid packet input type") + if len(_) != 4: + raise ValueError( + f"Invalid packet length (expected 4, got {len(_)})" + ) + return _ + + +# ========================================== +# NOUVEAU NODE : DURATION TO FRAMES +# ========================================== + + +class DurationToFrames: + @classmethod + def INPUT_TYPES(cls): return { "required": { - "_": ("VParams",) + "duration": ( + "FLOAT", + {"default": 10.0, "min": 0.1, "max": 1000.0, "step": 0.1}, + ), + "fps": ( + "INT", + {"default": 24, "min": 1, "max": 240, "step": 1}, + ), } } - RETURN_TYPES = ("INT", "INT", "INT", "FLOAT", "FLOAT") - RETURN_NAMES = ("width", "height", "frames", "fps", "Megapixels") - + RETURN_TYPES = ("INT", "FLOAT") + RETURN_NAMES = ("int", "float") FUNCTION = "run" CATEGORY = "Fictiverse/Params" - - def run(self, _): - if not isinstance(_, tuple): - raise TypeError("Invalid packet input type") - if len(_) != 5: - raise ValueError("Invalid packet length") - return _ -NODE_CLASS_MAPPINGS["Video Params Expand"] = VideoParamsExpand \ No newline at end of file + def run(self, duration, fps): + # Traduction de l'expression : + # a -> duration + # 24 -> fps + raw_frames = max(5, round(duration * fps)) + frame_count = raw_frames + (5 - (raw_frames % 17)) % 17 + + return (frame_count, float(frame_count)) + + +# ========================================== +# MAPPINGS COMFYUI +# ========================================== +NODE_CLASS_MAPPINGS = { + "Video Params": VideoParams, + "Video Params Expand": VideoParamsExpand, + "Duration To Frames": DurationToFrames, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Video Params": "Video Params", + "Video Params Expand": "Video Params Expand", + "Duration To Frames": "Duration To Frames", +} \ No newline at end of file