diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..72a4b7b --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 [VOTRE NOM OU PSEUDO] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..22dd68e --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +\# ComfyUI Video Compressor Node + + + +Un node personnalisé pour ComfyUI qui permet de compresser des fichiers vidéo en utilisant FFmpeg, en visant une taille de fichier cible spécifique. + + + +  + + + +\## 🌟 Fonctionnalités + + + +\- \*\*Entrée Vidéo Simple\*\* : Prend en entrée un chemin vers un fichier vidéo (parfait pour être chaîné après un node "Video Combine"). + +\- \*\*Contrôle de la Taille\*\* : Un slider simple pour définir le poids final souhaité de la vidéo en Mégaoctets (MB). + +\- \*\*Choix du Codec\*\* : Choisissez entre `libx264` (H.264, très compatible) et `libx265` (H.265/HEVC, plus efficace). + +\- \*\*Sortie Organisée\*\* : Les vidéos compressées sont sauvegardées dans `ComfyUI/output/compressed\_videos/`. + + + +\## ⚙️ Installation + + + +\### 1. Prérequis : FFmpeg + + + +\*\*Ce node nécessite que FFmpeg soit installé sur votre système et accessible depuis le terminal.\*\* + + + +\- \*\*Windows\*\* : Téléchargez depuis \[gyan.dev](https://www.gyan.dev/ffmpeg/builds/) et suivez un tutoriel pour ajouter le dossier `bin` à votre variable d'environnement PATH. + +\- \*\*MacOS\*\* : Installez avec Homebrew : `brew install ffmpeg` + +\- \*\*Linux\*\* : Utilisez votre gestionnaire de paquets : `sudo apt update \&\& sudo apt install ffmpeg` + + + +Pour vérifier que l'installation a réussi, ouvrez un terminal et tapez `ffmpeg -version`. Vous devriez voir les informations de version s'afficher. + + + +\### 2. Installation du Node + + + +La méthode la plus simple est d'utiliser `git`. + + + +1\. Ouvrez un terminal. + +2\. Naviguez jusqu'au répertoire des nodes personnalisés de ComfyUI : `cd ComfyUI/custom\_nodes/` + +3\. Clonez ce dépôt : `git clone https://github.com/VOTRE\_NOM\_UTILISATEUR/ComfyUI-VideoCompressor.git` + +4\. Redémarrez ComfyUI. + + + +Le node devrait maintenant apparaître dans le menu "Add Node" sous la catégorie `🎥Video Utils`. + + + +\## 🚀 Utilisation + + + +1\. Générez une séquence d'images et combinez-les en une vidéo avec un node comme "Video Combine". + +2\. Connectez la sortie `video` (le chemin du fichier) de ce node à l'entrée `video` du node `🎬 Compress Video (FFmpeg)`. + +3\. Ajustez le slider `target\_mb` à la taille désirée (par ex. 8 MB pour Discord). + +4\. Lancez le workflow. La vidéo compressée sera disponible à la sortie `compressed\_video\_path` et sera enregistrée sur votre disque. + + + +\## 📄 Licence + + + +Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails. + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3d67219 --- /dev/null +++ b/__init__.py @@ -0,0 +1,14 @@ +# On importe le nom correct de la classe : "VideoCompressorUnified" +from .compress_video_node import VideoCompressorUnified + +NODE_CLASS_MAPPINGS = { + # On utilise le même nom ici + "VideoCompressorUnified": VideoCompressorUnified +} + +NODE_DISPLAY_NAME_MAPPINGS = { + # Et ici, pour le nom affiché dans le menu + "VideoCompressorUnified": "🎬 Compress Video (Unified)" +} + +print("✅ ComfyUI-VideoCompressor: Node unifié chargé.") \ No newline at end of file diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..cd1d7c3 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/compress_video_node.cpython-312.pyc b/__pycache__/compress_video_node.cpython-312.pyc new file mode 100644 index 0000000..687437a Binary files /dev/null and b/__pycache__/compress_video_node.cpython-312.pyc differ diff --git a/compress_video_node.py b/compress_video_node.py new file mode 100644 index 0000000..91bb9e4 --- /dev/null +++ b/compress_video_node.py @@ -0,0 +1,151 @@ +import os +import subprocess +import folder_paths +import torch +import numpy as np +from PIL import Image +import shutil +import datetime +import platform + +class VideoCompressorUnified: + """ + Node de compression vidéo unifié : + - Accepte images ou vidéo + - Trois modes : fast_crf, 2pass, gpu + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "mode": (["fast_crf", "2pass", "gpu"], {"default": "2pass"}), + "target_mb": ("FLOAT", {"min": 0.5, "max": 2000.0, "step": 0.5, "default": 8.0}), + "codec": (["libx264", "libx265", "h264_nvenc", "hevc_nvenc"], {"default": "libx264"}), + }, + "optional": { + "images": ("IMAGE",), + "video_path_in": ("STRING", {"forceInput": True}), + "fps": ("INT", {"default": 24, "min": 1, "max": 60}), + "crf": ("INT", {"default": 23, "min": 10, "max": 40}), + "preset": ([ + "ultrafast","superfast","veryfast","faster", + "fast","medium","slow","slower","veryslow" + ], {"default": "fast"}), + } + } + + RETURN_TYPES = ("STRING", "IMAGE") + RETURN_NAMES = ("video_path_out", "images") + FUNCTION = "process" + CATEGORY = "🎥Video Utils" + + def _get_video_info(self, video_path): + command = [ + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=r_frame_rate,duration", + "-of", "csv=p=0", video_path + ] + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + parts = result.stdout.strip().split(',') + if len(parts) < 2: + raise ValueError("Impossible d'analyser la vidéo.") + fps_fraction = parts[0].split('/') + fps = float(fps_fraction[0]) / float(fps_fraction[1]) if len(fps_fraction) == 2 else 30.0 + duration = float(parts[1]) + return {"duration": duration, "fps": fps} + + def _load_video_to_tensor(self, video_path, temp_dir): + frames_dir = os.path.join(temp_dir, "loaded_frames") + os.makedirs(frames_dir, exist_ok=True) + subprocess.run( + ["ffmpeg", "-i", video_path, os.path.join(frames_dir, "frame_%05d.png")], + check=True, capture_output=True + ) + frame_files = sorted([os.path.join(frames_dir, f) for f in os.listdir(frames_dir) if f.endswith('.png')]) + images = [torch.from_numpy(np.array(Image.open(f).convert("RGB")).astype(np.float32) / 255.0).unsqueeze(0) for f in frame_files] + if not images: + return torch.empty(0) + return torch.cat(images, 0) + + def process(self, mode, target_mb, codec, images=None, video_path_in=None, fps=24, crf=23, preset="fast"): + if images is None and video_path_in is None: + return ("", torch.empty(0)) + + temp_dir = os.path.join(folder_paths.get_temp_directory(), "compressor_unified") + shutil.rmtree(temp_dir, ignore_errors=True) + os.makedirs(temp_dir, exist_ok=True) + + pass_log_file = os.path.join(temp_dir, "ffmpeg_passlog") + + try: + if images is not None: + output_images = images + for i, img_tensor in enumerate(images): + Image.fromarray((255. * img_tensor.cpu().numpy()).astype(np.uint8)).save(os.path.join(temp_dir, f"frame_{i:05d}.png")) + initial_video_path = os.path.join(temp_dir, "initial_from_images.mp4") + subprocess.run( + ["ffmpeg", "-y", "-framerate", str(fps), "-i", os.path.join(temp_dir, "frame_%05d.png"), + "-c:v", "libx264", "-pix_fmt", "yuv420p", initial_video_path], + check=True, capture_output=True + ) + else: + if not os.path.exists(video_path_in): + raise FileNotFoundError(f"Fichier vidéo non trouvé: {video_path_in}") + initial_video_path = video_path_in + output_images = self._load_video_to_tensor(video_path_in, temp_dir) + + output_dir = os.path.join(folder_paths.get_output_directory(), "compressed_videos") + os.makedirs(output_dir, exist_ok=True) + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + filename = f"compressed_{timestamp}.mp4" + final_video_path = os.path.join(output_dir, filename) + + info = self._get_video_info(initial_video_path) + duration_s = info["duration"] + if duration_s <= 0: + raise ValueError("Durée de la vidéo nulle ou invalide.") + + # ----- Choix du mode ----- + if mode == "2pass": + # Calcule le bitrate cible + target_size_bits = target_mb * 1024 * 1024 * 8 + audio_bitrate_bits = 128 * 1000 + video_bitrate_bits = (target_size_bits / duration_s) - audio_bitrate_bits + if video_bitrate_bits < 100 * 1000: + video_bitrate_bits = 100 * 1000 + video_bitrate_k = int(video_bitrate_bits / 1000) + + null_output = "NUL" if platform.system() == "Windows" else "/dev/null" + + common_args = ["-y", "-i", initial_video_path, "-c:v", codec, "-b:v", f"{video_bitrate_k}k", "-preset", preset, "-threads", "0"] + + # Première passe + subprocess.run(["ffmpeg"] + common_args + ["-pass", "1", "-an", "-f", "mp4", "-passlogfile", pass_log_file, null_output], check=True, capture_output=True) + # Deuxième passe + subprocess.run(["ffmpeg"] + common_args + ["-pass", "2", "-c:a", "aac", "-b:a", "128k", "-passlogfile", pass_log_file, final_video_path], check=True, capture_output=True) + + elif mode == "fast_crf": + subprocess.run([ + "ffmpeg", "-y", "-i", initial_video_path, + "-c:v", codec, "-preset", preset, "-crf", str(crf), + "-c:a", "aac", "-b:a", "128k", "-threads", "0", + final_video_path + ], check=True, capture_output=True) + + elif mode == "gpu": + # Ici on force un CRF rapide avec encodeur GPU + subprocess.run([ + "ffmpeg", "-y", "-i", initial_video_path, + "-c:v", codec, "-preset", preset, "-cq", str(crf), + "-c:a", "aac", "-b:a", "128k", "-threads", "0", + final_video_path + ], check=True, capture_output=True) + + else: + raise ValueError(f"Mode inconnu: {mode}") + + return (final_video_path, output_images) + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/example_wf/Compress video.json b/example_wf/Compress video.json new file mode 100644 index 0000000..8f1dfd1 --- /dev/null +++ b/example_wf/Compress video.json @@ -0,0 +1 @@ +{"id":"219e2ee3-b5f4-4a9e-9c4c-ed0988e62ddd","revision":0,"last_node_id":19,"last_link_id":19,"nodes":[{"id":12,"type":"LoadVideo","pos":[610.8833618164062,452.2430114746094],"size":[274.080078125,232.12315368652344],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"file","name":"file","type":"COMBO","widget":{"name":"file"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"VIDEO","name":"VIDEO","type":"VIDEO","links":[11]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.60","Node name for S&R":"LoadVideo"},"widgets_values":["elf.mp4","image"]},{"id":10,"type":"VideoCompressorUnified","pos":[1169.6812744140625,466.9794921875],"size":[336.7310485839844,227.04510498046875],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"images","name":"images","shape":7,"type":"IMAGE","link":12},{"localized_name":"video_path_in","name":"video_path_in","shape":7,"type":"STRING","link":null},{"localized_name":"mode","name":"mode","type":"COMBO","widget":{"name":"mode"},"link":null},{"localized_name":"target_mb","name":"target_mb","type":"FLOAT","widget":{"name":"target_mb"},"link":null},{"localized_name":"codec","name":"codec","type":"COMBO","widget":{"name":"codec"},"link":null},{"localized_name":"fps","name":"fps","shape":7,"type":"INT","widget":{"name":"fps"},"link":14},{"localized_name":"crf","name":"crf","shape":7,"type":"INT","widget":{"name":"crf"},"link":null},{"localized_name":"preset","name":"preset","shape":7,"type":"COMBO","widget":{"name":"preset"},"link":null}],"outputs":[{"localized_name":"video_path_out","name":"video_path_out","type":"STRING","links":null},{"localized_name":"images","name":"images","type":"IMAGE","links":[19]}],"properties":{"Node name for S&R":"VideoCompressorUnified"},"widgets_values":["2pass",8,"libx264",24,23,"fast"]},{"id":16,"type":"CreateVideo","pos":[1557.419921875,473.8299255371094],"size":[270,78],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":19},{"localized_name":"audio","name":"audio","shape":7,"type":"AUDIO","link":18},{"localized_name":"fps","name":"fps","type":"FLOAT","widget":{"name":"fps"},"link":17}],"outputs":[{"localized_name":"VIDEO","name":"VIDEO","type":"VIDEO","links":[15]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.59","Node name for S&R":"CreateVideo"},"widgets_values":[16]},{"id":17,"type":"SaveVideo","pos":[1873.565185546875,481.1685485839844],"size":[600,698],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"video","name":"video","type":"VIDEO","link":15},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"localized_name":"format","name":"format","type":"COMBO","widget":{"name":"format"},"link":null},{"localized_name":"codec","name":"codec","type":"COMBO","widget":{"name":"codec"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.59","Node name for S&R":"SaveVideo"},"widgets_values":["video/ComfyUI","auto","auto"]},{"id":13,"type":"GetVideoComponents","pos":[934.1969604492188,459.9700012207031],"size":[190,70],"flags":{"collapsed":false},"order":1,"mode":0,"inputs":[{"localized_name":"video","name":"video","type":"VIDEO","link":11}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":[12]},{"localized_name":"audio","name":"audio","type":"AUDIO","links":[18]},{"localized_name":"fps","name":"fps","type":"FLOAT","links":[13]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.59","Node name for S&R":"GetVideoComponents"},"widgets_values":[]},{"id":15,"type":"JWFloatToInteger","pos":[926.1368408203125,594.1709594726562],"size":[270,82],"flags":{"collapsed":true},"order":2,"mode":0,"inputs":[{"localized_name":"value","name":"value","type":"FLOAT","widget":{"name":"value"},"link":13},{"localized_name":"mode","name":"mode","type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[{"localized_name":"INT","name":"INT","type":"INT","links":[14,16]}],"properties":{"cnr_id":"comfyui-various","ver":"5bd85aaf7616878471469c4ec7e11bbd0cef3bf2","Node name for S&R":"JWFloatToInteger"},"widgets_values":[0,"round"]},{"id":18,"type":"JWIntegerToFloat","pos":[926.7423706054688,653.9115600585938],"size":[270,58],"flags":{"collapsed":true},"order":4,"mode":0,"inputs":[{"localized_name":"value","name":"value","type":"INT","widget":{"name":"value"},"link":16}],"outputs":[{"localized_name":"FLOAT","name":"FLOAT","type":"FLOAT","links":[17]}],"properties":{"cnr_id":"comfyui-various","ver":"5bd85aaf7616878471469c4ec7e11bbd0cef3bf2","Node name for S&R":"JWIntegerToFloat"},"widgets_values":[0]}],"links":[[11,12,0,13,0,"VIDEO"],[12,13,0,10,0,"IMAGE"],[13,13,2,15,0,"FLOAT"],[14,15,0,10,5,"INT"],[15,16,0,17,0,"VIDEO"],[16,15,0,18,0,"INT"],[17,18,0,16,2,"FLOAT"],[18,13,1,16,1,"AUDIO"],[19,10,1,16,0,"IMAGE"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.7054683529670174,"offset":[-167.09790165645276,-120.18208316056918]}},"version":0.4} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29