diff --git a/README.md b/README.md index 5b17bf8..61bf988 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ๐Ÿ”— Comfyui : Bjornulf_custom_nodes v0.46 ๐Ÿ”— -A list of 52 custom nodes for Comfyui : Display, manipulate, and edit text, images, videos, and more. +A list of 53 custom nodes for Comfyui : Display, manipulate, and edit text, images, videos, and more. You can manage looping operations, generate randomized content, trigger logical conditions, pause and manually control your workflows and even work with external AI tools, like Ollama or Text To Speech. # Coffee : โ˜•โ˜•โ˜•โ˜•โ˜• 5/5 @@ -42,6 +42,7 @@ You can manage looping operations, generate randomized content, trigger logical `38.` [โ™ป๐Ÿ–ผ Loop (Images)](#38----loop-images) `39.` [โ™ป Loop (โœ’๐Ÿ—” Advanced Write Text + ๐Ÿ…ฐ๏ธ variables)](#39----loop--advanced-write-text) `42.` [โ™ป Loop (Model+Clip+Vae) - aka Checkpoint / Model](#42----loop-modelclipvae---aka-checkpoint--model) +`53.` [โ™ป Loop Load checkpoint (Model Selector)](#53) ## ๐ŸŽฒ Randomization ๐ŸŽฒ `3.` [โœ’๐Ÿ—” Advanced Write Text (+ ๐ŸŽฒ random selection and ๐Ÿ…ฐ๏ธ variables)](#3----advanced-write-text---random-selection-and-๐Ÿ…ฐ%EF%B8%8F-variables) @@ -234,6 +235,7 @@ cd /where/you/installed/ComfyUI && python main.py - **v0.44**: Allow ollama to have a cusom url in the file `ollama_ip.txt` in the comfyui custom nodes folder. Minor changes, add details/updates to README. - **v0.45**: Add a new node : Text scrambler (Character), change text randomly using the file `scrambler/scrambler_character.json` in the comfyui custom nodes folder. - **v0.46**: โ— A lot of changes to Video nodes. Save to video is now using FLOAT for fps, not INT. (A lot of other custom nodes do that as well...) Add node to preview video, add node to convert a video path to a list of images. add node to convert a list of images to a temporary video + video_path. add node to synchronize duration of audio with video. (useful for MuseTalk) change TTS node with many new outputs ("audio_path", "full_path", "duration") to reuse with other nodes like MuseTalk, also TTS rename input to "connect_to_workflow", to avoid mistakes sending text to it. +- **v0.47**: New node : Loop Load checkpoint (Model Selector). # ๐Ÿ“ Nodes descriptions @@ -643,7 +645,7 @@ Details : - It will take more VRAM, but it will be faster to switch between checkpoints. - It can't give you the currently loaded checkpoint name's. -Check node number 41 before deciding which one to use. +Check node number 41 before deciding which one to use. ### 41 - ๐ŸŽฒ Random Load checkpoint (Model Selector) @@ -658,7 +660,8 @@ I always store my checkpoints in a folder with the type of the model like `SD1.5 Details : - Note that compared to node 40, you can't have separate configuration depending of the selected checkpoint. (For example `CLIP Set Last Layer` node set at -2 for a specific model, or a separate vae or clip.) Aka : All models are going to share the exact same workflow. -Check node number 40 before deciding which one to use. +Check node number 40 before deciding which one to use. +Node 53 is the loop version of this node. ### 42 - โ™ป Loop (Model+Clip+Vae) - aka Checkpoint / Model @@ -818,3 +821,14 @@ Here is an example without `Audio Video Sync` node (The duration of the video is Here is an example with `Audio Video Sync` node, notice that it is also convenient to recover the frames per second of the video, and send that to other nodes. : ![audio sync video](screenshots/audio_sync_video_with.png) + +### 53 - โ™ป Loop Load checkpoint (Model Selector) + +![loop model selector](screenshots/loop_model_selector.png) + +**Description:** +This is the loop version of node 41. (check there for similar details) +It will loop over all the selected checkpoints. + +โ— The big difference with 41 is that checkpoints are preloaded in memory. You can run them all faster all at once. +It is a good way to test multiple checkpoints quickly. \ No newline at end of file diff --git a/__init__.py b/__init__.py index 77a3a91..c0ec37b 100644 --- a/__init__.py +++ b/__init__.py @@ -55,9 +55,11 @@ from .audio_video_sync import AudioVideoSync from .video_path_to_images import VideoToImagesList from .images_to_video_path import ImagesListToVideo from .video_preview import VideoPreview +from .loop_model_selector import LoopModelSelector NODE_CLASS_MAPPINGS = { "Bjornulf_ollamaLoader": ollamaLoader, + "Bjornulf_LoopModelSelector": LoopModelSelector, "Bjornulf_VideoPreview": VideoPreview, "Bjornulf_ImagesListToVideo": ImagesListToVideo, "Bjornulf_VideoToImagesList": VideoToImagesList, @@ -114,6 +116,7 @@ NODE_CLASS_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = { "Bjornulf_WriteText": "โœ’ Write Text", + "Bjornulf_LoopModelSelector": "โ™ป Loop Load checkpoint (Model Selector)", "Bjornulf_VideoPreview": "๐Ÿ“น๐Ÿ‘ Video Preview", "Bjornulf_ImagesListToVideo": "๐Ÿ–ผโžœ๐Ÿ“น Images to Video path (tmp video)", "Bjornulf_VideoToImagesList": "๐Ÿ“นโžœ๐Ÿ–ผ Video Path to Images", diff --git a/loop_model_selector.py b/loop_model_selector.py new file mode 100644 index 0000000..8c7ebff --- /dev/null +++ b/loop_model_selector.py @@ -0,0 +1,67 @@ +import os +from folder_paths import get_filename_list, get_full_path +import comfy.sd + +class LoopModelSelector: + @classmethod + def INPUT_TYPES(cls): + model_list = get_filename_list("checkpoints") + optional_inputs = {} + + for i in range(1, 11): + optional_inputs[f"model_{i}"] = (model_list, {"default": model_list[min(i-1, len(model_list)-1)]}) + + return { + "required": { + "number_of_models": ("INT", {"default": 3, "min": 1, "max": 20, "step": 1}), + }, + "optional": optional_inputs + } + + RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING", "STRING", "STRING") + RETURN_NAMES = ("model", "clip", "vae", "model_path", "model_name", "model_folder") + FUNCTION = "select_models" + CATEGORY = "Bjornulf" + OUTPUT_IS_LIST = (True, True, True, True, True, True) + + def select_models(self, number_of_models, **kwargs): + # Collect available models from kwargs + available_models = [ + kwargs[f"model_{i}"] for i in range(1, number_of_models + 1) if f"model_{i}" in kwargs and kwargs[f"model_{i}"] + ] + + # Raise an error if no models are available + if not available_models: + raise ValueError("No models selected") + + models = [] + clips = [] + vaes = [] + model_paths = [] + model_names = [] + model_folders = [] + + for selected_model in available_models: + # Get the model name (without folders or extensions) + model_name = os.path.splitext(os.path.basename(selected_model))[0] + + # Get the full path to the selected model + model_path = get_full_path("checkpoints", selected_model) + + # Get the folder name where the model is located + model_folder = os.path.basename(os.path.dirname(model_path)) + + # Load the model using ComfyUI's checkpoint loader + loaded_objects = comfy.sd.load_checkpoint_guess_config(model_path) + + # Unpack only the values we need + model, clip, vae = loaded_objects[:3] + + models.append(model) + clips.append(clip) + vaes.append(vae) + model_paths.append(model_path) + model_names.append(model_name) + model_folders.append(model_folder) + + return (models, clips, vaes, model_paths, model_names, model_folders) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a900a23..f780624 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "bjornulf_custom_nodes" description = "Nodes: Ollama, Text to Speech, Combine Texts, Random Texts, Save image for Bjornulf LobeChat, Text with random Seed, Random line from input, Combine images, Image to grayscale (black & white), Remove image Transparency (alpha), Resize Image, ..." -version = "0.46" +version = "0.47" license = {file = "LICENSE"} [project.urls] diff --git a/screenshots/audio_sync_video_with.png b/screenshots/audio_sync_video_with.png index 54cb1ea..161ceac 100644 Binary files a/screenshots/audio_sync_video_with.png and b/screenshots/audio_sync_video_with.png differ diff --git a/screenshots/audio_sync_video_without.png b/screenshots/audio_sync_video_without.png index 4eebc29..38e7766 100644 Binary files a/screenshots/audio_sync_video_without.png and b/screenshots/audio_sync_video_without.png differ diff --git a/screenshots/loop_model_selector.png b/screenshots/loop_model_selector.png new file mode 100644 index 0000000..302e7b6 Binary files /dev/null and b/screenshots/loop_model_selector.png differ diff --git a/web/js/loop_model_selector.js b/web/js/loop_model_selector.js new file mode 100644 index 0000000..6ded470 --- /dev/null +++ b/web/js/loop_model_selector.js @@ -0,0 +1,98 @@ +import { app } from "../../../scripts/app.js"; + +app.registerExtension({ + name: "Bjornulf.LoopModelSelector", + async nodeCreated(node) { + if (node.comfyClass === "Bjornulf_LoopModelSelector") { + const updateModelInputs = () => { + const numModelsWidget = node.widgets.find(w => w.name === "number_of_models"); + if (!numModelsWidget) return; + + const numModels = numModelsWidget.value; + const checkpointsList = node.widgets.find(w => w.name === "model_1").options.values; + + // Remove excess model widgets + node.widgets = node.widgets.filter(w => !w.name.startsWith("model_") || parseInt(w.name.split("_")[1]) <= numModels); + + // Add new model widgets if needed + for (let i = 1; i <= numModels; i++) { + const widgetName = `model_${i}`; + if (!node.widgets.find(w => w.name === widgetName)) { + const defaultIndex = Math.min(i - 1, checkpointsList.length - 1); + node.addWidget("combo", widgetName, checkpointsList[defaultIndex], () => {}, { + values: checkpointsList + }); + } + } + + // Reorder widgets + node.widgets.sort((a, b) => { + if (a.name === "number_of_models") return -1; + if (b.name === "number_of_models") return 1; + if (a.name === "seed") return 1; + if (b.name === "seed") return -1; + if (a.name.startsWith("model_") && b.name.startsWith("model_")) { + return parseInt(a.name.split("_")[1]) - parseInt(b.name.split("_")[1]); + } + return a.name.localeCompare(b.name); + }); + + node.setSize(node.computeSize()); + }; + + // Set up number_of_models widget + const numModelsWidget = node.widgets.find(w => w.name === "number_of_models"); + if (numModelsWidget) { + numModelsWidget.callback = () => { + updateModelInputs(); + app.graph.setDirtyCanvas(true); + }; + } + + // Set seed widget to integer input + const seedWidget = node.widgets.find((w) => w.name === "seed"); + if (seedWidget) { + seedWidget.type = "HIDDEN"; // Hide seed widget after restoring saved state + } + + // Handle deserialization + const originalOnConfigure = node.onConfigure; + node.onConfigure = function(info) { + if (originalOnConfigure) { + originalOnConfigure.call(this, info); + } + + // Restore model widgets based on saved properties + const savedProperties = info.properties; + if (savedProperties) { + Object.keys(savedProperties).forEach(key => { + if (key.startsWith("model_")) { + const widgetName = key; + const widgetValue = savedProperties[key]; + const existingWidget = node.widgets.find(w => w.name === widgetName); + if (existingWidget) { + existingWidget.value = widgetValue; + } else { + node.addWidget("combo", widgetName, widgetValue, () => {}, { + values: node.widgets.find(w => w.name === "model_1").options.values + }); + } + } + }); + } + + // Ensure seed is a valid integer + const seedWidget = node.widgets.find(w => w.name === "seed"); + if (seedWidget && isNaN(parseInt(seedWidget.value))) { + seedWidget.value = 0; // Set a default value if invalid + } + + // Update model inputs after restoring saved state + updateModelInputs(); + }; + + // Initial update + updateModelInputs(); + } + } +});