This commit is contained in:
justumen
2025-02-16 20:58:39 +01:00
parent dfcf429e5c
commit 3ebd5cbb92
22 changed files with 967 additions and 132 deletions

5
.gitignore vendored
View File

@@ -7,4 +7,7 @@ speakers
*.text
web/js/*.txt
ScriptsPerso/
civitai/NSFW_*
civitai/NSFW_*
pickme.py
web/js/pickme.js
todo.py

View File

@@ -1,6 +1,6 @@
# 🔗 Comfyui : Bjornulf_custom_nodes v0.70 🔗
# 🔗 Comfyui : Bjornulf_custom_nodes v0.71 🔗
A list of 128 custom nodes for Comfyui : Display, manipulate, create and edit text, images, videos, loras, generate characters and more.
A list of 133 custom nodes for Comfyui : Display, manipulate, create and edit text, images, videos, loras, generate characters 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.
# Watch Video Intro (Quick overview 28 minutes) :
@@ -27,12 +27,12 @@ Support me and my work : ❤️❤️❤️ <https://ko-fi.com/bjornulf> ❤️
`73.` [👁 Show (String/Text)](#73----show-stringtext)
`74.` [👁 Show (JSON)](#74----show-json)
`126.` [📒 Note](#126----note)
`127.` [🖼📒 Image Note](#127----image-note)
`127.` [🖼📒 Image Note (Load image)](#127)
`128.` [🖼👁 Preview (first) image](#128----preview-first-image)
## ✒ Text ✒
`2.` [✒ Write Text](#2----write-text)
`3.` [✒🗔 Advanced Write Text (+ 🎲 random selection and 🅰️ variables)](#3----advanced-write-text---random-selection-and-🅰%EF%B8%8F-variables)
`3.` [✒🗔🅰️ Advanced Write Text (+ 🎲 random option)](#3)
`4.` [🔗 Combine Texts](#4----combine-texts)
`15.` [💾 Save Text](#15----save-text)
`26.` [🎲 Random line from input](#26----random-line-from-input)
@@ -46,6 +46,7 @@ Support me and my work : ❤️❤️❤️ <https://ko-fi.com/bjornulf> ❤️
`111.` [✨➜🔢 Anything to Int](#111----anything-to-int)
`112.` [✨➜🔢 Anything to Float](#112----anything-to-float)
`113.` [📝🔪 Text split in 5](#113----text-split-in-5)
`.` [📝🔪 Text split in 10](#1)
`115.` [📥 Load Text From Bjornulf Folder](115----load-text-from-bjornulf-folder)
`116.` [📥 Load Text From Path](#116----load-text-from-path)
`117.` [📝👈 Line selector (🎲 or ♻ or ♻📑)](#117)
@@ -80,7 +81,7 @@ Support me and my work : ❤️❤️❤️ <https://ko-fi.com/bjornulf> ❤️
`27.` [♻ Loop (All Lines from input)](#27----loop-all-lines-from-input)
`33.` [♻ Loop (All Lines from input 🔗 combine by lines)](#33----loop-all-lines-from-input--combine-by-lines)
`38.` [♻🖼 Loop (Images)](#38----loop-images)
`39.` [♻ Loop (✒🗔 Advanced Write Text + 🅰️ variables)](#39----loop--advanced-write-text)
`39.` [♻ Loop (✒🗔🅰️ Advanced Write Text)](#39)
`42.` [♻ Loop (Model+Clip+Vae) - aka Checkpoint / Model](#42----loop-modelclipvae---aka-checkpoint--model)
`53.` [♻ Loop Load checkpoint (Model Selector)](#53----loop-load-checkpoint-model-selector)
`54.` [♻👑 Loop Lora Selector](#54----loop-lora-selector)
@@ -95,7 +96,7 @@ Support me and my work : ❤️❤️❤️ <https://ko-fi.com/bjornulf> ❤️
`96.` [♻👗 List Looper (Text Generator Outfits Female)](#8)
## 🎲 Randomization 🎲
`3.` [✒🗔 Advanced Write Text (+ 🎲 random selection and 🅰️ variables)](#3----advanced-write-text---random-selection-and-🅰%EF%B8%8F-variables)
`3.` [✒🗔🅰️ Advanced Write Text (+ 🎲 random option)](#3)
`5.` [🎲 Random (Texts)](#5----random-texts)
`26.` [🎲 Random line from input](#26----random-line-from-input)
`28.` [🔢🎲 Text with random Seed](#28----text-with-random-seed)
@@ -104,19 +105,19 @@ Support me and my work : ❤️❤️❤️ <https://ko-fi.com/bjornulf> ❤️
`41.` [🎲 Random Load checkpoint (Model Selector)](#41----random-load-checkpoint-model-selector)
`48.` [🔀🎲 Text scrambler (🧑 Character)](#48----text-scrambler--character)
`55.` [🎲👑 Random Lora Selector](#55----random-lora-selector)
`117.` [📝👈 Line selector (🎲 or ♻ or ♻📑)](#117----line-selector--or--or-)
`117.` [📝👈🅰️ Line selector (🎲 or ♻ or ♻📑)](#117)
## 🖼💾 Save Image / Text 💾🖼
`16.` [💾🖼💬 Save image for Bjornulf LobeChat](#16----save-image-for-bjornulf-lobechat-for-my-custom-lobe-chat)
`17.` [💾🖼 Save image as `tmp_api.png` Temporary API](#17----save-image-as-tmp_apipng-temporary-api-%EF%B8%8F)
`18.` [💾🖼📁 Save image to a chosen folder name](#18----save-image-to-a-chosen-folder-name)
`14.` [💾🖼 Save Exact name](#1314------resize-and-save-exact-name-%EF%B8%8F)
`123.` [💾 Save Global Variables](#123----save-global-variables)
`123.` [💾🅰️ Save Global Variables](#123)
## 🖼📥 Load Image / Text 📥🖼
`29.` [📥🖼 Load Image with Transparency ▢](#29----load-image-with-transparency-)
`43.` [📥🖼📂 Load Images from output folder](#43----load-images-from-output-folder)
`124.` [📥 Load Global Variables](#124----load-global-variables)
`124.` [📥🅰️ Load Global Variables](#124)
## 🖼 Image - others 🖼
`13.` [📏 Resize Image](#1314------resize-and-save-exact-name-%EF%B8%8F)
@@ -138,6 +139,11 @@ Support me and my work : ❤️❤️❤️ <https://ko-fi.com/bjornulf> ❤️
`70.` [📏 Resize Image Percentage](#70----resize-image-percentage)
`80.` [🩷 Empty Latent Selector](#80----empty-latent-selector)
## 🅰️ Variables 🅰️
`117.` [📝👈🅰️ Line selector (🎲 or ♻ or ♻📑)](#117)
`123.` [💾🅰️ Save Global Variables](#123)
`124.` [📥🅰️ Load Global Variables](#124)
## 🚀 Load checkpoints 🚀
`40.` [🎲 Random (Model+Clip+Vae) - aka Checkpoint / Model](#40----random-modelclipvae---aka-checkpoint--model)
`41.` [🎲 Random Load checkpoint (Model Selector)](#41----random-load-checkpoint-model-selector)
@@ -211,7 +217,7 @@ Support me and my work : ❤️❤️❤️ <https://ko-fi.com/bjornulf> ❤️
## 🧍 Manual user Control 🧍
`35.` [⏸️ Paused. Resume or Stop, Pick 👇](#35---%EF%B8%8F-paused-resume-or-stop-)
`36.` [⏸️ Paused. Select input, Pick 👇](#36---%EF%B8%8F-paused-select-input-pick-one)
`117.` [📝👈 Line selector (🎲 or ♻ or ♻📑)](#117----line-selector--or--or-)
`117.` [📝👈🅰️ Line selector (🎲 or ♻ or ♻📑)](#117)
## 🧠 Logic / Conditional Operations 🧠
`45.` [🔀 If-Else (input / compare_with)](#45----if-else-input--compare_with)
@@ -388,7 +394,8 @@ cd /where/you/installed/ComfyUI && python main.py
Text replace now have multine option for regex. (https://github.com/justUmen/Bjornulf_custom_nodes/issues/17) - can remove <think> tag from ollama.
8 new nodes : "🖼👁 Preview (first) image", "💾 Huggingface Downloader", "👑 Combine Loras, Lora stack", "📥 Load Global Variables", "💾 Save Global Variables", "📝👈 Model-Clip-Vae selector (🎲 or ♻ or ♻📑)", "📒 Note", "🖼📒 Image Note".
Fix a lot of code everywhere, a little better logging system, etc...
WIP : Rewrite of all my ffmpeg nodes. (Still need improvements and fixes, will do that in 0.71) Maybe don't use them yet...
WIP : Rewrite of all my ffmpeg nodes. (Still need improvements and fixes, will do that in 0.71?) Maybe don't use them yet...
- **0.71**: ❗Breaking changes for Global variable nodes. (add to global variable system a "filename", which is a a separate global variable file.) bug fix speech to text node, 5 new nodes 129-133. combine text limit raised to 100. improve Save image in folder node.
# 📝 Nodes descriptions
@@ -410,7 +417,7 @@ Simple node to write text.
![write Text](screenshots/write.png)
## 3 - ✒🗔 Advanced Write Text (+ 🎲 random selection and 🅰️ variables)
## 3 - ✒🗔🅰️ Advanced Write Text (+ 🎲 random option)
**Description:**
Advanced Write Text node allows for special syntax to accept random variants, like `{hood|helmet}` will randomly choose between hood or helmet.
@@ -852,7 +859,7 @@ Loop over a list of images.
Usage example : You have a list of images, and you want to apply the same process to all of them.
Above is an example of the loop images node sending them to an Ipadapter workflow. (Same seed of course.)
### 39 - ♻ Loop (✒🗔 Advanced Write Text)
### 39 - ♻ Loop (✒🗔🅰️ Advanced Write Text)
**Description:**
If you need a quick loop but you don't want something too complex with a loop node, you can use this combined write text + loop.
@@ -1680,7 +1687,7 @@ If you want, with `Load Text From Path` you can also recover the elements in "Bj
![Load Text](screenshots/load_text_PATH.png)
#### 117 - 📝👈 Line selector (🎲 or ♻ or ♻📑)
#### 117 - 📝👈🅰️ Line selector (🎲 or ♻ or ♻📑)
**Description:**
@@ -1734,7 +1741,7 @@ If you want to have multiple loras in a single node, well this is it.
![Lora stack](screenshots/lora_stacks.png)
#### 123 - 💾 Save Global Variables
#### 123 - 💾🅰️ Save Global Variables
**Description:**
So if you know how to use variables with my nodes, this node gives you the opportunity to create global variables.
@@ -1742,7 +1749,7 @@ This node is very simple, it will just append (or overwrite) the file : `Bjornul
![Global Save](screenshots/global_save.png)
#### 124 - 📥 Load Global Variables
#### 124 - 📥🅰️ Load Global Variables
**Description:**
This node will load the global variables as text from the file `Bjornulf/GlobalVariables.txt`.
@@ -1775,6 +1782,7 @@ You can use this node to have it show a previously generated image and some cust
![Image note](screenshots/image_note.png)
You can use the text to display the prompt used to generate the image for example.
It's behavior is like a "Preview image" node. (See node 130 if you want a behavior similar to "Load image")
Sometimes I want to display an image to explain what something specific is doing visually. (For example a stack of loras will have a specific style.)
Here is a complex example on how i use that, for a list of loras stacks. (I then "select" a style by using node `125 - Model-Clip-Vae selector`)
@@ -1792,3 +1800,51 @@ Very useful for testing when working with videos.
Below is a visual example of what I just said :
![First image preview](screenshots/first_image_preview.png)
#### 129 - 📌🅰️ Set Variable from Text
**Description:**
This node will just quickly transform a text in another text which can be quickly used for all my variables nodes.
Here is an example below with "Advanced write text", but you can use with all of them, global variables, etc...
![text_to_variable](screenshots/text_to_variable.png)
#### 130 - 📥🖼📒 Image Note (Load image)
**Description:**
This node is quite similar to the node 127. But this one uses LoadImage instead of a preview system.
So if you want to have a "preview" before you launch workflow one time, you can use this one.
It's behaviour is like a "Load image" node.
![Image note Load](screenshots/note_load_image.png)
#### 131 - ✒👉 Write Pick Me Chain
**Description:**
So this is a new "write text" node.
But with a twist. You can connect them to each other and when clicking on the PICK ME button, it will disable all other write text node of the chain and activate only the one you click on. (It will turn green.)
So with this node, you can switch from one prompt to another by the click of a button !!
Not limited to one line, you can use list, variables, etc... but below is a simple example :
![write pick me chain](screenshots/write_pick_me_chain.png)
#### 132 - 📝🔪 Text split in 10
**Description:**
Same as node 113, but split in 10 parts.
One day I had 6, and got stuck with the split in 5 node, so i guess it can be useful sometimes, let's make one with 10...
![text split 10](screenshots/text_split_10.png)
#### 133 - 🖼👁 Preview 1-4 images (compare)
**Description:**
Cool node that you can use to compare several images.
The middle is a cursor that you can move wherever you want by just clicking on the image.
Below is an example, you can see that at this size/resolution, 25% is almost as good as the initial image.
![four previews](screenshots/four_preview.png)
Here is a zoom on the same image :
![four previews](screenshots/four_preview_zoom.png)

View File

@@ -92,11 +92,11 @@ from .API_falAI import APIGenerateFalAI
from .latent_resolution_selector import LatentResolutionSelector
from .loader_lora_with_path import LoaderLoraWithPath
from .load_text import LoadTextFromFolder, LoadTextFromPath
from .string_splitter import TextSplitin5
from .string_splitter import TextSplitin5, TextSplitin10
from .line_selector import LineSelector
from .text_to_speech_kokoro import KokoroTTS
from .note_text import DisplayNote
from .note_image import ImageNote
from .note_image import ImageNote, ImageNoteLoadImage
from .model_clip_vae_selector import ModelClipVaeSelector
from .global_variables import LoadGlobalVariables, SaveGlobalVariables
from .lora_stacks import AllLoraSelector
@@ -105,7 +105,18 @@ from .preview_first_image import PreviewFirstImage
# from .video_latent import VideoLatentResolutionSelector
# from .empty_latent_video import EmptyVideoLatentWithSingle
# from .text_generator_t2v import TextGeneratorText2Video
from .images_compare import FourImageViewer
# from .pickme import WriteTextPickMe, PickMe
from .write_pickme_chain import WriteTextPickMeChain
# from .todo import ToDoList
from .text_to_variable import TextToVariable
NODE_CLASS_MAPPINGS = {
"Bjornulf_TextToVariable": TextToVariable,
# "Bjornulf_ToDoList": ToDoList,
# "Bjornulf_WriteTextPickMe": WriteTextPickMe,
"Bjornulf_WriteTextPickMeChain": WriteTextPickMeChain,
# "Bjornulf_PickMe": PickMe,
"Bjornulf_FourImageViewer": FourImageViewer,
"Bjornulf_PreviewFirstImage": PreviewFirstImage,
"Bjornulf_HuggingFaceDownloader": HuggingFaceDownloader,
# "Bjornulf_VideoLatentResolutionSelector": VideoLatentResolutionSelector,
@@ -115,6 +126,7 @@ NODE_CLASS_MAPPINGS = {
"Bjornulf_ModelClipVaeSelector": ModelClipVaeSelector,
"Bjornulf_DisplayNote": DisplayNote,
"Bjornulf_ImageNote": ImageNote,
"Bjornulf_ImageNoteLoadImage": ImageNoteLoadImage,
"Bjornulf_LineSelector": LineSelector,
# "Bjornulf_EmptyVideoLatentWithSingle": EmptyVideoLatentWithSingle,
"Bjornulf_XTTSConfig": XTTSConfig,
@@ -125,6 +137,7 @@ NODE_CLASS_MAPPINGS = {
"Bjornulf_LoadTextFromPath": LoadTextFromPath,
"Bjornulf_LoadTextFromFolder": LoadTextFromFolder,
"Bjornulf_TextSplitin5": TextSplitin5,
"Bjornulf_TextSplitin10": TextSplitin10,
"Bjornulf_APIGenerateFlux": APIGenerateFlux,
"Bjornulf_APIGenerateFalAI": APIGenerateFalAI,
"Bjornulf_APIGenerateStability": APIGenerateStability,
@@ -244,14 +257,21 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Bjornulf_TextToVariable": "📌🅰️ Set Variable from Text",
# "Bjornulf_ToDoList": "ToDoList",
# "Bjornulf_WriteTextPickMe": "✒👉 Write Pick Me",
"Bjornulf_WriteTextPickMeChain": "✒👉 Write Pick Me Chain",
# "Bjornulf_PickMe": "✋ Recover Pick Me ! ✋",
"Bjornulf_FourImageViewer": "🖼👁 Preview 1-4 images (compare)",
"Bjornulf_PreviewFirstImage": "🖼👁 Preview (first) image",
"Bjornulf_HuggingFaceDownloader": "💾 Huggingface Downloader",
"Bjornulf_AllLoraSelector": "👑 Combine Loras, Lora stack",
"Bjornulf_LoadGlobalVariables": "📥 Load Global Variables",
"Bjornulf_SaveGlobalVariables": "💾 Save Global Variables",
"Bjornulf_LoadGlobalVariables": "📥🅰️ Load Global Variables",
"Bjornulf_SaveGlobalVariables": "💾🅰️ Save Global Variables",
"Bjornulf_ModelClipVaeSelector": "📝👈 Model-Clip-Vae selector (🎲 or ♻ or ♻📑)",
"Bjornulf_DisplayNote": "📒 Note",
"Bjornulf_ImageNote": "🖼📒 Image Note",
"Bjornulf_ImageNoteLoadImage": "📥🖼📒 Image Note (Load image)",
# "Bjornulf_VideoLatentResolutionSelector": "🩷📹 Empty Video Latent Selector",
# "Bjornulf_EmptyVideoLatentWithSingle": "Bjornulf_EmptyVideoLatentWithSingle",
"Bjornulf_XTTSConfig": "🔊 TTS Configuration ⚙",
@@ -261,10 +281,11 @@ NODE_DISPLAY_NAME_MAPPINGS = {
# "Bjornulf_APIHiResCivitAI": "🎨➜🎨 API Image hires fix (CivitAI)",
# "Bjornulf_CivitAILoraSelector": "lora Civit",
"Bjornulf_KokoroTTS": "📝➜🔊 Kokoro - Text to Speech",
"Bjornulf_LineSelector": "📝👈 Line selector (🎲 or ♻ or ♻📑)",
"Bjornulf_LineSelector": "📝👈🅰️ Line selector (🎲 or ♻ or ♻📑)",
"Bjornulf_LoaderLoraWithPath": "📥👑 Load Lora with Path",
# "Bjornulf_TextGeneratorText2Video": "🔥📝📹 Text Generator for text to video 📹📝🔥",
"Bjornulf_TextSplitin5": "📝🔪 Text split in 5",
"Bjornulf_TextSplitin10": "📝🔪 Text split in 10",
"Bjornulf_LatentResolutionSelector": "🩷 Empty Latent Selector",
"Bjornulf_CivitAIModelSelectorSD15": "📥 Load checkpoint SD1.5 (+Download from CivitAi)",
"Bjornulf_CivitAIModelSelectorSDXL": "📥 Load checkpoint SDXL (+Download from CivitAi)",
@@ -334,8 +355,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"Bjornulf_VideoToImagesList": "📹➜🖼 Video Path to Images (Load video)",
"Bjornulf_AudioVideoSync": "🔊📹 Audio Video Sync",
"Bjornulf_ScramblerCharacter": "🔀🎲 Text scrambler (🧑 Character)",
"Bjornulf_WriteTextAdvanced": "✒🗔 Advanced Write Text",
"Bjornulf_LoopWriteText": "♻ Loop (✒🗔 Advanced Write Text)",
"Bjornulf_WriteTextAdvanced": "✒🗔🅰️ Advanced Write Text",
"Bjornulf_LoopWriteText": "♻ Loop (✒🗔🅰️ Advanced Write Text)",
"Bjornulf_LoopModelClipVae": "♻ Loop (Model+Clip+Vae)",
"Bjornulf_LoopImages": "♻🖼 Loop (Images)",
"Bjornulf_CombineTextsByLines": "♻ Loop (All Lines from input 🔗 combine by lines)",

View File

@@ -3,11 +3,11 @@ class CombineTexts:
def INPUT_TYPES(cls):
return {
"required": {
"number_of_inputs": ("INT", {"default": 2, "min": 2, "max": 50, "step": 1}),
"delimiter": (["newline", "comma", "space", "slash", "nothing"], {"default": "newline"}),
"number_of_inputs": ("INT", {"default": 2, "min": 2, "max": 100, "step": 1}),
"delimiter": (["newline", "comma", "space", "slash", "backslash", "nothing"], {"default": "newline"}),
},
"hidden": {
**{f"text_{i}": ("STRING", {"forceInput": True}) for i in range(1, 51)}
**{f"text_{i}": ("STRING", {"forceInput": True}) for i in range(1, 101)}
}
}
@@ -42,6 +42,8 @@ class CombineTexts:
return " "
elif delimiter == "slash":
return "/"
elif delimiter == "backslash":
return "\\"
elif delimiter == "nothing":
return ""
else:

View File

@@ -1,12 +1,20 @@
import os
import re
import folder_paths
import logging
class Everything(str):
def __ne__(self, __value: object) -> bool:
return False
# Define VAR_PATTERN at module level if not already defined
VAR_PATTERN = re.compile(r'^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$')
class SaveGlobalVariables:
def __init__(self):
self.output_dir = os.path.join(folder_paths.base_path, 'Bjornulf')
self.filename = os.path.join(self.output_dir, 'GlobalVariables.txt')
os.makedirs(self.output_dir, exist_ok=True)
self.base_dir = os.path.join(folder_paths.base_path, 'Bjornulf')
self.global_vars_dir = os.path.join(self.base_dir, 'GlobalVariables')
os.makedirs(self.global_vars_dir, exist_ok=True)
@classmethod
def INPUT_TYPES(cls):
@@ -15,63 +23,138 @@ class SaveGlobalVariables:
"variables": ("STRING", {"multiline": True, "default": ""}),
"mode": (["append", "overwrite"], {"default": "append"}),
},
"optional": {
"filename": ("STRING", {"default": ""}),
},
}
RETURN_TYPES = ()
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("connect_to_workflow",)
FUNCTION = "save_variables"
OUTPUT_NODE = True
CATEGORY = "Bjornulf"
def save_variables(self, variables, mode):
# Clean and validate input
new_lines = set(line.strip() for line in variables.split('\n') if line.strip())
def save_variables(self, variables, mode, filename=""):
# Determine target file path
if filename.strip():
filename_clean = os.path.basename(filename.strip())
if not filename_clean.endswith('.txt'):
filename_clean += '.txt'
file_path = os.path.join(self.global_vars_dir, filename_clean)
else:
file_path = os.path.join(self.base_dir, 'GlobalVariables.txt')
if mode == "overwrite":
with open(self.filename, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines) + '\n')
else: # append mode
if os.path.exists(self.filename):
with open(self.filename, 'r', encoding='utf-8') as f:
existing_lines = set(line.strip() for line in f.readlines() if line.strip())
# Validate and parse input variables
valid_vars = {}
errors = []
for line in variables.split('\n'):
line = line.strip()
if not line:
continue
match = VAR_PATTERN.match(line)
if match:
var_name, var_value = match.groups()
logging.info(f"VALID syntax for Variable : {line}")
valid_vars[var_name] = line
else:
existing_lines = set()
logging.info(f"Invalid syntax for Variable : {line}")
errors.append(f"Invalid syntax: {line}")
# Add only new unique lines
unique_lines = new_lines - existing_lines
if unique_lines:
with open(self.filename, 'a', encoding='utf-8') as f:
f.write('\n'.join(unique_lines) + '\n')
if errors:
print("\n".join(errors))
# Merge based on mode
if mode == "append":
# Always read existing variables first
existing_vars = {}
if os.path.exists(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if match := VAR_PATTERN.match(line):
var_name = match.group(1)
existing_vars[var_name] = line
except Exception as e:
logging.info(f"Error reading existing file: {e}")
merged_vars = {**existing_vars, **valid_vars}
else: # overwrite
merged_vars = valid_vars
return ()
# Ensure directory exists before writing
os.makedirs(os.path.dirname(file_path), exist_ok=True)
try:
with open(file_path, 'w', encoding='utf-8') as f:
if merged_vars:
f.write('\n'.join(merged_vars.values()) + '\n')
else:
f.write('') # Create empty file if no variables
except Exception as e:
logging.info(f"Error writing to file: {e}")
return ("",)
class LoadGlobalVariables:
def __init__(self):
self.output_dir = os.path.join(folder_paths.base_path, 'Bjornulf')
self.filename = os.path.join(self.output_dir, 'GlobalVariables.txt')
os.makedirs(self.output_dir, exist_ok=True)
self.base_dir = os.path.join(folder_paths.base_path, 'Bjornulf')
self.global_vars_dir = os.path.join(self.base_dir, 'GlobalVariables')
os.makedirs(self.global_vars_dir, exist_ok=True)
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"seed": ("INT", {
"default": -1,
"min": -1,
"max": 0x7FFFFFFFFFFFFFFF
}),
}}
var_files = []
try:
var_files = [f[:-4] for f in os.listdir(cls.global_vars_dir())
if f.endswith('.txt') and os.path.isfile(os.path.join(cls.global_vars_dir(), f))]
var_files.sort()
except FileNotFoundError:
pass
return {
"required": {
"seed": ("INT", {"default": -1, "min": -1, "max": 0x7FFFFFFFFFFFFFFF}),
},
"optional": {
"filename": ("STRING", {"default": ""}),
"file_list": (["default"] + var_files, {"default": "default"}),
"connect_to_workflow": (Everything("*"), {"forceInput": True}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("variables",)
FUNCTION = "load_variables"
CATEGORY = "Bjornulf"
def load_variables(self, seed):
if not os.path.exists(self.filename):
@classmethod
def global_vars_dir(cls):
return os.path.join(folder_paths.base_path, 'Bjornulf', 'GlobalVariables')
def load_variables(self, seed, connect_to_workflow="", filename="", file_list="default"):
# First check if filename is provided and not empty
if filename.strip():
target_file = filename.strip()
else:
# If filename is empty, use file_list
target_file = file_list.strip()
if target_file and target_file != "default":
# Ensure .txt extension
if not target_file.endswith('.txt'):
target_file += '.txt'
# Load from GlobalVariables subdirectory
file_path = os.path.join(self.global_vars_dir, target_file)
else:
# Load default GlobalVariables.txt from base directory
file_path = os.path.join(self.base_dir, 'GlobalVariables.txt')
# Return empty string if file doesn't exist
if not os.path.exists(file_path):
return ("",)
with open(self.filename, 'r', encoding='utf-8', errors='ignore') as f:
# Read and return file contents
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read().strip()
os.sync() # Ensures that any pending file writes are flushed to disk
return (content,)
return (content,)

33
images_compare.py Normal file
View File

@@ -0,0 +1,33 @@
from nodes import PreviewImage
class FourImageViewer(PreviewImage):
"""A node that compares four images in the UI."""
NAME = 'Four Image Comparer'
CATEGORY = "Bjornulf"
FUNCTION = "compare_images"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"image_1": ("IMAGE",),
"image_2": ("IMAGE",),
"image_3": ("IMAGE",),
"image_4": ("IMAGE",),
}
}
def compare_images(self, **kwargs):
result = {"ui": {}}
for i in range(1, 5):
image_key = f"image_{i}"
image_data = kwargs.get(image_key)
if image_data is not None and len(image_data) > 0:
saved_images = self.save_images(image_data)
result["ui"][f"images_{i}"] = saved_images["ui"]["images"]
return result

View File

@@ -1,19 +1,14 @@
import random
import os
import hashlib
# import logging
import numpy as np
import torch
from nodes import SaveImage
import random
from PIL import Image, ImageOps, ImageSequence
import torch
import folder_paths
from PIL import Image
from server import PromptServer
import node_helpers
from aiohttp import web
# Configure logging
# logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
# logger = logging.getLogger("ImageNote")
class ImageNote(SaveImage):
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
@@ -106,3 +101,94 @@ class ImageNote(SaveImage):
self.last_output_images = output_images
return super().save_images(images=output_images, prompt=prompt, extra_pnginfo=extra_pnginfo)
class ImageNoteLoadImage:
@classmethod
def INPUT_TYPES(s):
base_input_dir = folder_paths.get_input_directory() # Get base input directory
input_dir = os.path.join(base_input_dir, "Bjornulf", "imagenote_images") # Specify subdirectory
# Create the directory if it doesn't exist
if not os.path.exists(input_dir):
os.makedirs(input_dir, exist_ok=True) # Create directory and parents if needed
# Filter for image files only
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')
files = [f for f in os.listdir(input_dir) if
os.path.isfile(os.path.join(input_dir, f)) and
f.lower().endswith(valid_extensions)]
if not files:
# Provide a default option if no files are found
files = ["none"]
return {"required":
{
"image": (sorted(files), {"image_upload": True}),
# "note": ("STRING", {"default": ""}), # Added multiline option FAILURE
"note": ("STRING", {"multiline": True, "lines": 10})
}
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING") # Added note to return types
RETURN_NAMES = ("image", "mask", "image_path", "note") # Added note to return names
FUNCTION = "load_image_alpha"
CATEGORY = "Bjornulf"
def load_image_alpha(self, image, note): # Added note parameter
image_path = folder_paths.get_annotated_filepath(image)
img = node_helpers.pillow(Image.open, image_path)
output_images = []
output_masks = []
w, h = None, None
excluded_formats = ['MPO']
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
image_converted = i.convert("RGBA") # Renamed to avoid shadowing
if len(output_images) == 0:
w = image_converted.size[0]
h = image_converted.size[1]
if image_converted.size[0] != w or image_converted.size[1] != h:
continue
image_np = np.array(image_converted).astype(np.float32) / 255.0 # Renamed to avoid shadowing
image_tensor = torch.from_numpy(image_np)[None,] # Renamed to avoid shadowing
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image_tensor) # Renamed to avoid shadowing
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1 and img.format not in excluded_formats:
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
else:
output_image = output_images[0]
output_mask = output_masks[0]
return (output_image, output_mask, image_path, note) # Added note to return tuple
@classmethod
def IS_CHANGED(s, image, note): # Added note to IS_CHANGED
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
return m.digest().hex() + str(note) # Include note in hash
@classmethod
def VALIDATE_INPUTS(s, image):
if not folder_paths.exists_annotated_filepath(image):
return "Invalid image file: {}".format(image)
return True

View File

@@ -1,7 +1,7 @@
[project]
name = "bjornulf_custom_nodes"
description = "128 ComfyUI nodes : Display, manipulate, and edit text, images, videos, loras, generate characters and more. Manage looping operations, generate randomized content, use logical conditions and work with external AI tools, like Ollama or Text To Speech Kokoro, etc..."
version = "0.70"
description = "133 ComfyUI nodes : Display, manipulate, and edit text, images, videos, loras, generate characters and more. Manage looping operations, generate randomized content, use logical conditions and work with external AI tools, like Ollama or Text To Speech Kokoro, etc..."
version = "0.71"
license = {file = "LICENSE"}
[project.urls]

View File

@@ -1,10 +1,8 @@
import os
import numpy as np
from PIL import Image
import json
from PIL.PngImagePlugin import PngInfo
import folder_paths
from nodes import SaveImage
class SaveImageToFolder:
class SaveImageToFolder(SaveImage):
@classmethod
def INPUT_TYPES(cls):
return {
@@ -15,40 +13,20 @@ class SaveImageToFolder:
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
FUNCTION = "save_image_to_folder"
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "save_images"
CATEGORY = "Bjornulf"
OUTPUT_NODE = True
def save_image_to_folder(self, images, folder_name, prompt=None, extra_pnginfo=None):
output_dir = os.path.join("./output", folder_name)
os.makedirs(output_dir, exist_ok=True)
results = []
def save_images(self, images, folder_name, prompt=None, extra_pnginfo=None):
# Create the custom folder within the output directory
custom_folder = os.path.join(folder_paths.get_output_directory(), folder_name)
os.makedirs(custom_folder, exist_ok=True)
# Find the highest existing file number
existing_files = [f for f in os.listdir(output_dir) if f.endswith('.png') and f[:5].isdigit()]
if existing_files:
highest_num = max(int(f[:5]) for f in existing_files)
counter = highest_num + 1
else:
counter = 1
for image in images:
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for k, v in extra_pnginfo.items():
metadata.add_text(k, json.dumps(v))
filename = os.path.join(output_dir, f"{counter:05d}.png")
img.save(filename, format="PNG", pnginfo=metadata)
print(f"Image saved as: {filename}")
results.append({"filename": filename})
counter += 1
return {"ui": {"images": [{"filename": filename, "type": "output"}]}}
# Call the parent's save_images with filename_prefix set to "folder_name/"
# This will make the parent class save to the custom folder
return super().save_images(
images=images,
filename_prefix=f"{folder_name}/_",
prompt=prompt,
extra_pnginfo=extra_pnginfo
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -4,6 +4,7 @@ import os
import numpy as np
import tempfile
import wave
import subprocess # Added for ffmpeg
try:
import faster_whisper
@@ -25,6 +26,7 @@ class SpeechToText:
"optional": {
"AUDIO": ("AUDIO",),
"audio_path": ("STRING", {"default": None, "forceInput": True}),
"video_path": ("STRING", {"default": None, "forceInput": True}),
}
}
@@ -35,26 +37,25 @@ class SpeechToText:
def tensor_to_wav(self, audio_tensor, sample_rate):
"""Convert audio tensor to temporary WAV file"""
# Convert tensor to numpy array
audio_data = audio_tensor.squeeze().numpy()
# Create temporary file
if audio_data.ndim == 2:
audio_data = np.mean(audio_data, axis=0)
elif audio_data.ndim > 2:
raise ValueError(f"Unsupported audio tensor shape: {audio_data.shape}")
temp_file = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
# Write WAV file
with wave.open(temp_file.name, 'wb') as wav_file:
wav_file.setnchannels(1) # Mono audio
wav_file.setsampwidth(2) # 2 bytes per sample
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
# Convert float32 to int16
audio_data = (audio_data * 32767).astype(np.int16)
wav_file.writeframes(audio_data.tobytes())
return temp_file.name
def load_local_model(self, model_size):
"""Load the local Whisper model if not already loaded"""
if not WHISPER_AVAILABLE:
return False, "faster-whisper not installed. Install with: pip install faster-whisper"
@@ -68,7 +69,6 @@ class SpeechToText:
return False, f"Error loading model: {str(e)}"
def transcribe_local(self, audio_path, model_size):
"""Transcribe audio using local Whisper model"""
success, message = self.load_local_model(model_size)
if not success:
return False, message, None
@@ -83,23 +83,47 @@ class SpeechToText:
except Exception as e:
return False, f"Error during local transcription: {str(e)}", None
def transcribe_audio(self, model_size, AUDIO=None, audio_path=None):
def transcribe_audio(self, model_size, AUDIO=None, audio_path=None, video_path=None):
transcript = "No valid audio input provided"
detected_language = ""
temp_wav_path = None
temp_audio_path = None
try:
# Determine which audio source to use
if AUDIO is not None:
# Convert tensor audio data to WAV file
# Check video input first
if video_path and os.path.exists(video_path):
try:
# Create temp file for extracted audio
temp_audio = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
temp_audio.close()
temp_audio_path = temp_audio.name
# FFmpeg command to extract audio
command = [
'ffmpeg',
'-i', video_path,
'-vn',
'-acodec', 'pcm_s16le',
'-ar', '16000',
'-ac', '1',
'-y',
temp_audio_path
]
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
audio_to_process = temp_audio_path
except subprocess.CalledProcessError as e:
return (f"FFmpeg error: {e.stderr.decode()}", "", "")
except Exception as e:
return (f"Error extracting audio: {str(e)}", "", "")
elif AUDIO is not None:
waveform = AUDIO['waveform']
sample_rate = AUDIO['sample_rate']
temp_wav_path = self.tensor_to_wav(waveform, sample_rate)
audio_to_process = temp_wav_path
elif audio_path is not None and os.path.exists(audio_path):
elif audio_path and os.path.exists(audio_path):
audio_to_process = audio_path
else:
return ("No valid audio input provided", "")
return ("No valid audio input provided", "", "")
if audio_to_process:
success, result, lang = self.transcribe_local(audio_to_process, model_size)
@@ -107,11 +131,12 @@ class SpeechToText:
detected_language = lang if success else ""
finally:
# Clean up temporary file if it was created
# Cleanup temporary files
if temp_wav_path and os.path.exists(temp_wav_path):
os.unlink(temp_wav_path)
if temp_audio_path and os.path.exists(temp_audio_path):
os.unlink(temp_audio_path)
#Create detected_language_name based on detected_language, en = English, es = Spanish, fr = French, de = German, etc...
language_map = {
"ar": "Arabic", "cs": "Czech", "de": "German", "en": "English",
"es": "Spanish", "fr": "French", "hi": "Hindi", "hu": "Hungarian",
@@ -121,4 +146,4 @@ class SpeechToText:
}
detected_language_name = language_map.get(detected_language, "Unknown")
return (transcript, detected_language,detected_language_name)
return (transcript, detected_language, detected_language_name)

View File

@@ -45,5 +45,55 @@ class TextSplitin5:
# If no more parts, append empty string
result.append("")
# Convert to tuple and return all 5 parts
return tuple(result)
class TextSplitin10:
DELIMITER_NEWLINE = "\\n" # Literal string "\n" for display
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_string": ("STRING", {
"multiline": True,
"forceInput": True
}),
"delimiter": ("STRING", {
"default": s.DELIMITER_NEWLINE, # Show "\n" in widget
"multiline": False
}),
"ignore_before_equals": ("BOOLEAN", {
"default": False
}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING","STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("part1", "part2", "part3", "part4", "part5", "part6", "part7", "part8", "part9", "part10")
FUNCTION = "split_string"
CATEGORY = "Bjornulf"
def split_string(self, input_string, delimiter, ignore_before_equals):
# Handle the special case for newline delimiter
actual_delimiter = "\n" if delimiter == self.DELIMITER_NEWLINE else delimiter
# Split the string using the delimiter
parts = input_string.split(actual_delimiter)
# Ensure we always return exactly 5 parts
result = []
for i in range(10):
if i < len(parts):
part = parts[i].strip()
# If ignore_before_equals is True and there's an equals sign
if ignore_before_equals and '=' in part:
# Take only what's after the equals sign and strip whitespace
part = part.split('=', 1)[1].strip()
result.append(part)
else:
# If no more parts, append empty string
result.append("")
# Convert to tuple and return all 5 parts
return tuple(result)

14
text_to_variable.py Normal file
View File

@@ -0,0 +1,14 @@
class TextToVariable:
@classmethod
def INPUT_TYPES(s):
return {"required": {"variable_name": ("STRING", {"default": "variable_name"}),
"text_value": ("STRING", {"forceInput": True})}}
RETURN_TYPES = ("STRING",)
FUNCTION = "process"
CATEGORY = "Custom"
def process(self, variable_name, text_value):
text_value = text_value.replace("\n", ";")
output_string = f"{variable_name} = {text_value}"
return (output_string,)

210
web/js/images_compare.js Normal file
View File

@@ -0,0 +1,210 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
function imageDataToUrl(data) {
return api.apiURL(
`/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}&subfolder=${data.subfolder}${app.getPreviewFormatParam()}`
);
}
app.registerExtension({
name: "Bjornulf.FourImageViewer",
async nodeCreated(node) {
if (node.comfyClass !== "Bjornulf_FourImageViewer") return;
const marginTop = 90;
const verticalOffset = -30;
const minSize = 256; // Minimum size for the node
const maxSize = 2048; // Maximum size for the node
node.size = [512, 512 + marginTop];
node.images = new Array(4).fill(null);
const padding = 10;
// Add resize handles
node.flags |= LiteGraph.RESIZABLE;
node.onResize = function(size) {
// Ensure square aspect ratio (excluding marginTop)
const minDimension = Math.max(minSize, Math.min(size[0], size[1] - marginTop));
const maxDimension = Math.min(maxSize, minDimension);
size[0] = maxDimension;
size[1] = maxDimension + marginTop;
// Update slider positions proportionally
const fullImgWidth = size[0] - 3 * padding;
const fullImgHeight = size[1] - padding - marginTop;
// Only update sliders if they exist (node has been initialized)
if (node.hasOwnProperty('sliderX')) {
const oldWidth = node.size[0] - 3 * padding;
const oldHeight = node.size[1] - padding - marginTop;
// Calculate relative positions (0 to 1)
const relativeX = (node.sliderX - padding) / oldWidth;
const relativeY = (node.sliderY - marginTop) / oldHeight;
// Update slider positions
node.sliderX = padding + (fullImgWidth * relativeX);
node.sliderY = marginTop + (fullImgHeight * relativeY);
} else {
// Initial slider positions
node.sliderX = padding + fullImgWidth / 2;
node.sliderY = marginTop + fullImgHeight / 2;
}
node.size = size;
return size;
};
// Full area where images get drawn
const fullImgWidth = node.size[0] - 3 * padding;
const fullImgHeight = node.size[1] - padding - marginTop;
node.sliderX = padding + fullImgWidth / 2;
node.sliderY = marginTop + fullImgHeight / 2;
node.onMouseDown = function(e) {
const rect = node.getBounding();
const [clickX, clickY] = [
e.canvasX - rect[0],
e.canvasY - rect[1] + verticalOffset
];
const imgWidth = rect[2] - 3 * padding;
const imgHeight = rect[3] - padding - marginTop;
const xStart = padding;
const xEnd = xStart + imgWidth;
const yStart = marginTop;
const yEnd = yStart + imgHeight;
if (clickX >= xStart && clickX <= xEnd && clickY >= yStart && clickY <= yEnd) {
const hasImage2 = node.images[1] !== null;
const hasImage3 = node.images[2] !== null;
const hasImage4 = node.images[3] !== null;
if (hasImage2 && !hasImage3 && !hasImage4) {
node.sliderY = yEnd;
}
node.sliderX = Math.max(xStart, Math.min(clickX, xEnd));
node.sliderY = hasImage3 || hasImage4
? Math.max(yStart, Math.min(clickY, yEnd))
: yEnd;
app.graph.setDirtyCanvas(true, true);
return true;
}
return false;
};
node.onExecuted = async function(message) {
node.images = new Array(4).fill(null);
for (let i = 1; i <= 4; i++) {
const images = message[`images_${i}`] || [];
if (images.length) {
const imgData = images[0];
const img = new Image();
img.src = imageDataToUrl(imgData);
await new Promise(resolve => (img.onload = img.onerror = resolve));
node.images[i - 1] = img;
}
}
app.graph.setDirtyCanvas(true, true);
};
node.onDrawForeground = function(ctx) {
const padding = 10;
const xStart = padding;
const xEnd = xStart + (node.size[0] - 3 * padding);
const yStart = marginTop;
const yEnd = yStart + (node.size[1] - padding - marginTop);
const fullImgWidth = node.size[0] - 3 * padding;
const fullImgHeight = node.size[1] - padding - marginTop;
function getFittedDestRect(dx, dy, dWidth, dHeight, targetRatio) {
let newWidth = dWidth;
let newHeight = dWidth / targetRatio;
if (newHeight > dHeight) {
newHeight = dHeight;
newWidth = dHeight * targetRatio;
}
const offsetX = dx + (dWidth - newWidth) / 2;
const offsetY = dy + (dHeight - newHeight) / 2;
return [offsetX, offsetY, newWidth, newHeight];
}
function drawCroppedImage(img, dx, dy, dWidth, dHeight) {
if (!img) return;
let targetRatio = dWidth / dHeight;
if (node.images[0] && node.images[0].naturalWidth && node.images[0].naturalHeight) {
targetRatio = node.images[0].naturalWidth / node.images[0].naturalHeight;
}
const [ndx, ndy, ndWidth, ndHeight] = getFittedDestRect(dx, dy, dWidth, dHeight, targetRatio);
const imgRatio = img.naturalWidth / img.naturalHeight;
let sx = 0, sy = 0, sWidth = img.naturalWidth, sHeight = img.naturalHeight;
if (imgRatio > targetRatio) {
sWidth = img.naturalHeight * targetRatio;
sx = (img.naturalWidth - sWidth) / 2;
} else if (imgRatio < targetRatio) {
sHeight = img.naturalWidth / targetRatio;
sy = (img.naturalHeight - sHeight) / 2;
}
ctx.drawImage(img, sx, sy, sWidth, sHeight, ndx, ndy, ndWidth, ndHeight);
}
const connectedImages = node.images.slice(1).filter(img => img !== null).length;
if (connectedImages === 0) {
if (node.images[0]) {
drawCroppedImage(node.images[0], xStart, yStart, fullImgWidth, fullImgHeight);
}
} else if (connectedImages === 1 && node.images[1]) {
const splitX = node.sliderX;
ctx.save();
ctx.beginPath();
ctx.rect(xStart, yStart, splitX - xStart, fullImgHeight);
ctx.clip();
drawCroppedImage(node.images[0], xStart, yStart, fullImgWidth, fullImgHeight);
ctx.restore();
ctx.save();
ctx.beginPath();
ctx.rect(splitX, yStart, xEnd - splitX, fullImgHeight);
ctx.clip();
drawCroppedImage(node.images[1], xStart, yStart, fullImgWidth, fullImgHeight);
ctx.restore();
} else {
const drawQuadrant = (imgIndex, clipX, clipY, clipW, clipH) => {
if (!node.images[imgIndex]) return;
ctx.save();
ctx.beginPath();
ctx.rect(clipX, clipY, clipW, clipH);
ctx.clip();
drawCroppedImage(node.images[imgIndex], xStart, yStart, fullImgWidth, fullImgHeight);
ctx.restore();
};
drawQuadrant(0, xStart, yStart, node.sliderX - xStart, node.sliderY - yStart);
drawQuadrant(1, node.sliderX, yStart, xEnd - node.sliderX, node.sliderY - yStart);
if (node.images[3] === null) {
drawQuadrant(2, xStart, node.sliderY, xEnd - xStart, yEnd - node.sliderY);
} else {
drawQuadrant(2, xStart, node.sliderY, node.sliderX - xStart, yEnd - node.sliderY);
drawQuadrant(3, node.sliderX, node.sliderY, xEnd - node.sliderX, yEnd - node.sliderY);
}
}
ctx.strokeStyle = "#FFF";
ctx.lineWidth = 1;
if (connectedImages > 0) {
ctx.beginPath();
ctx.moveTo(node.sliderX, yStart);
ctx.lineTo(node.sliderX, yEnd);
if (connectedImages >= 2) {
ctx.moveTo(xStart, node.sliderY);
ctx.lineTo(xEnd, node.sliderY);
}
ctx.stroke();
}
};
},
});

80
web/js/note_image.js Normal file
View File

@@ -0,0 +1,80 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "Bjornulf.ImageNoteLoadImage",
async nodeCreated(node) {
if (node.comfyClass !== "Bjornulf_ImageNoteLoadImage") return;
setTimeout(() => {
// Update widget positions
node.onResize(node.size);
// Refresh all widgets
node.widgets.forEach(w => {
if (w.onShow?.(true)) {
w.onShow?.(false);
}
});
app.graph.setDirtyCanvas(true, true);
}, 500);
}
});
// app.registerExtension({
// name: "Bjornulf.ImageNote",
// async nodeCreated(node) {
// if (node.comfyClass !== "Bjornulf_ImageNote") return;
// // Add Save Note button
// node.addWidget("button", "Save Note", null, () => {
// const imagePathWidget = node.widgets.find(w => w.name === "image_path");
// const noteTextWidget = node.widgets.find(w => w.name === "note_text");
// if (!imagePathWidget?.value) {
// return;
// }
// fetch("/save_note", {
// method: "POST",
// body: JSON.stringify({
// image_path: imagePathWidget.value,
// note_text: noteTextWidget?.value || ""
// }),
// headers: { "Content-Type": "application/json" }
// })
// .then(response => response.json())
// .catch(error => {
// console.error("Error saving note:", error);
// });
// });
// // Add Load Note button
// node.addWidget("button", "Load Note", null, () => {
// const imagePathWidget = node.widgets.find(w => w.name === "image_path");
// if (!imagePathWidget?.value) {
// return;
// }
// fetch("/load_note", {
// method: "POST",
// body: JSON.stringify({ image_path: imagePathWidget.value }),
// headers: { "Content-Type": "application/json" }
// })
// .then(response => response.json())
// .then(data => {
// if (data.success) {
// const noteTextWidget = node.widgets.find(w => w.name === "note_text");
// if (noteTextWidget) {
// noteTextWidget.value = data.note_text;
// // Trigger widget changed event to update UI
// app.graph.setDirtyCanvas(true);
// }
// }
// })
// .catch(error => {
// console.error("Error loading note:", error);
// });
// });
// }
// });

View File

@@ -0,0 +1,172 @@
import { app } from "../../../scripts/app.js";
// Helper function to clean up widget DOM elements
function cleanupWidgetDOM(widget) {
if (widget && widget.inputEl) {
if (widget.inputEl.parentElement) {
widget.inputEl.parentElement.remove();
} else {
widget.inputEl.remove();
}
}
}
function getChainNodes(startNode) {
const nodes = [];
let currentNode = startNode;
// First traverse upstream to find the root node
while (true) {
const input = currentNode.inputs.find(i => i.name === "pickme_chain");
if (input?.link) {
const link = app.graph.links[input.link];
const prevNode = app.graph.getNodeById(link.origin_id);
if (prevNode?.comfyClass === "Bjornulf_WriteTextPickMeChain") {
currentNode = prevNode;
} else {
break;
}
} else {
break;
}
}
// Now traverse downstream from root
while (currentNode) {
nodes.push(currentNode);
const output = currentNode.outputs.find(o => o.name === "chain_text");
if (output?.links) {
let nextNode = null;
for (const linkId of output.links) {
const link = app.graph.links[linkId];
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode?.comfyClass === "Bjornulf_WriteTextPickMeChain") {
nextNode = targetNode;
break;
}
}
currentNode = nextNode;
} else {
break;
}
}
return nodes;
}
function pickNode(node) {
const chainNodes = getChainNodes(node);
chainNodes.forEach(n => {
const pickedWidget = n.widgets.find(w => w.name === "picked");
if (pickedWidget) {
const isPicked = n === node;
pickedWidget.value = isPicked;
n.color = isPicked ? "#006400" : "";
}
});
app.graph.setDirtyCanvas(true, true);
}
// Rest of the code remains the same as previous working version
function findAndPickNext(removedNode) {
const chainNodes = getChainNodes(removedNode);
const remaining = chainNodes.filter(n => n.id !== removedNode.id);
if (remaining.length) pickNode(remaining[0]);
}
app.registerExtension({
name: "Bjornulf.WriteTextPickMeChain",
async nodeCreated(node) {
if (node.comfyClass === "Bjornulf_WriteTextPickMeChain") {
// Store original onRemoved if it exists
const origOnRemoved = node.onRemoved;
// Create widgets in specific order to maintain layout
// const textWidget = node.widgets.find(w => w.name === "text");
// if (textWidget) {
// textWidget.computeSize = function() {
// return [node.size[0] - 20, 150];
// };
// }
// Handle picked widget
let pickedWidget = node.widgets.find(w => w.name === "picked");
if (!pickedWidget) {
pickedWidget = node.addWidget("BOOLEAN", "picked", false, null);
}
pickedWidget.visible = false;
// Add button after textarea
const buttonWidget = node.addWidget("button", "PICK ME", null, () => pickNode(node));
buttonWidget.computeSize = function() {
return [node.size[0] - 20, 30];
};
// Set initial node size
// node.size = [node.size[0], 200];
// node.size = [200, 200];
setTimeout(() => {
// Update widget positions
node.onResize(node.size);
// Refresh all widgets
node.widgets.forEach(w => {
if (w.onShow?.(true)) {
w.onShow?.(false);
}
});
app.graph.setDirtyCanvas(true, true);
}, 10);
// Enhanced cleanup on node removal
node.onRemoved = function() {
// Call original onRemoved if it exists
if (origOnRemoved) {
origOnRemoved.call(this);
}
// Handle chain updates
if (this.widgets.find(w => w.name === "picked")?.value) {
findAndPickNext(this);
}
// Clean up all widgets
for (const widget of this.widgets) {
cleanupWidgetDOM(widget);
}
// Force DOM cleanup and canvas update
if (this.domElement) {
this.domElement.remove();
}
app.graph.setDirtyCanvas(true, true);
};
const updateColors = () => {
const picked = node.widgets.find(w => w.name === "picked")?.value;
node.color = picked ? "#006400" : "";
};
const origSetNodeState = node.setNodeState;
node.setNodeState = function(state) {
origSetNodeState?.apply(this, arguments);
if (state.picked !== undefined) {
const widget = this.widgets.find(w => w.name === "picked");
if (widget) widget.value = state.picked;
}
updateColors();
};
const origGetNodeState = node.getNodeState;
node.getNodeState = function() {
const state = origGetNodeState?.apply(this, arguments) || {};
state.picked = this.widgets.find(w => w.name === "picked")?.value ?? false;
return state;
};
// Force initial layout update
app.graph.setDirtyCanvas(true, true);
}
}
});

22
write_pickme_chain.py Normal file
View File

@@ -0,0 +1,22 @@
class WriteTextPickMeChain:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"picked": ("BOOLEAN", {"default": False}),
"text": ("STRING", {"multiline": True, "lines": 10})
},
"optional": {
"pickme_chain": ("STRING", {"forceInput": True}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text", "chain_text")
FUNCTION = "write_text"
OUTPUT_NODE = True
CATEGORY = "Bjornulf"
def write_text(self, text, picked, pickme_chain="", **kwargs):
chain_output = text if picked else pickme_chain
return (text, chain_output)