mirror of
https://github.com/justUmen/Bjornulf_custom_nodes.git
synced 2026-03-21 12:42:11 -03:00
0.51
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,5 +3,5 @@ SaveText/
|
||||
API_example/
|
||||
clear_vram.py
|
||||
web/js/clear_vram.js
|
||||
*.txt
|
||||
speakers
|
||||
speakers
|
||||
*.text
|
||||
@@ -1,11 +0,0 @@
|
||||
class CustomStringType:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {"value": ("STRING", {"multiline": True})}}
|
||||
|
||||
RETURN_TYPES = ("CUSTOM_STRING",)
|
||||
FUNCTION = "passthrough"
|
||||
CATEGORY = "Bjornulf"
|
||||
|
||||
def passthrough(self, value):
|
||||
return (value,)
|
||||
28
README.md
28
README.md
@@ -1,6 +1,6 @@
|
||||
# 🔗 Comfyui : Bjornulf_custom_nodes v0.50 🔗
|
||||
# 🔗 Comfyui : Bjornulf_custom_nodes v0.51 🔗
|
||||
|
||||
A list of 59 custom nodes for Comfyui : Display, manipulate, and edit text, images, videos, loras and more.
|
||||
A list of 61 custom nodes for Comfyui : Display, manipulate, and edit text, images, videos, loras 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
|
||||
@@ -82,6 +82,8 @@ You can manage looping operations, generate randomized content, trigger logical
|
||||
`44.` [🖼👈 Select an Image, Pick](#44----select-an-image-pick)
|
||||
`46.` [🖼🔍 Image Details](#46----image-details)
|
||||
`47.` [🖼 Combine Images](#47----combine-images)
|
||||
`60.` [🖼🖼 Merge Images/Videos 📹📹 (Horizontally)](#60)
|
||||
`61.` [🖼🖼 Merge Images/Videos 📹📹 (Vertically)](#61)
|
||||
|
||||
## 🚀 Load checkpoints 🚀
|
||||
`40.` [🎲 Random (Model+Clip+Vae) - aka Checkpoint / Model](#40----random-modelclipvae---aka-checkpoint--model)
|
||||
@@ -102,6 +104,8 @@ You can manage looping operations, generate randomized content, trigger logical
|
||||
`52.` [🔊📹 Audio Video Sync](#52----audio-video-sync)
|
||||
`58.` [📹🔗 Concat Videos](#58----concat-videos)
|
||||
`59.` [📹🔊 Combine Video + Audio](#59----combine-video--audio)
|
||||
`60.` [🖼🖼 Merge Images/Videos 📹📹 (Horizontally)](#60)
|
||||
`61.` [🖼🖼 Merge Images/Videos 📹📹 (Vertically)](#61)
|
||||
|
||||
## 🤖 AI 🤖
|
||||
`19.` [🦙 Ollama](#19----ollama)
|
||||
@@ -256,6 +260,7 @@ cd /where/you/installed/ComfyUI && python main.py
|
||||
- **v0.48**: Two new nodes for loras : Random Lora Selector and Loop Lora Selector.
|
||||
- **v0.49**: New node : Loop Sequential (Integer) - Loop through a range of integer values. (But once per workflow run), audio sync is smarter and adapt the video duration to the audio duration. add requirements.txt
|
||||
- **v0.50**: allow audio in Images to Video path (tmp video). Add three new nodes : Concat Videos, combine video/audio and Loop Sequential (input Lines). save text changes to write inside COmfyui folder. Fix random line from input outputing LIST. ❗ Breaking change to audio/video sync node, allowing different types as input.
|
||||
- **v0.50**: Fix some issues with audio/video sync node. Add two new nodes : merge images/videos vertical and horizontal.
|
||||
|
||||
# 📝 Nodes descriptions
|
||||
|
||||
@@ -823,6 +828,7 @@ Display the details of an image. (width, height, has_transparency, orientation,
|
||||
|
||||
**Description:**
|
||||
Combine multiple images (A single image or a list of images.)
|
||||
If you want to merge several images into a single image, check node 60 or 61.
|
||||
|
||||
There are two types of logic to "combine images". With "all_in_one" enabled, it will combine all the images into one tensor.
|
||||
Otherwise it will send the images one by one. (check examples below) :
|
||||
@@ -978,3 +984,21 @@ Video : Use list of images or video path.
|
||||
Audio : Use audio path or audio type.
|
||||
|
||||

|
||||
|
||||
### 60 - 🖼🖼 Merge Images/Videos 📹📹 (Horizontally)
|
||||
|
||||
**Description:**
|
||||
Merge images or videos horizontally.
|
||||
|
||||

|
||||
|
||||
Here is on possible example for videos with node 60 and 61 :
|
||||
|
||||

|
||||
|
||||
### 61 - 🖼🖼 Merge Images/Videos 📹📹 (Vertically)
|
||||
|
||||
**Description:**
|
||||
Merge images or videos vertically.
|
||||
|
||||

|
||||
@@ -62,9 +62,13 @@ from .loop_sequential_integer import LoopIntegerSequential
|
||||
from .loop_lines_sequential import LoopLinesSequential
|
||||
from .concat_videos import ConcatVideos
|
||||
from .combine_video_audio import CombineVideoAudio
|
||||
from .images_merger_horizontal import MergeImagesHorizontally
|
||||
from .images_merger_vertical import MergeImagesVertically
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"Bjornulf_ollamaLoader": ollamaLoader,
|
||||
"Bjornulf_MergeImagesHorizontally": MergeImagesHorizontally,
|
||||
"Bjornulf_MergeImagesVertically": MergeImagesVertically,
|
||||
"Bjornulf_CombineVideoAudio": CombineVideoAudio,
|
||||
"Bjornulf_ConcatVideos": ConcatVideos,
|
||||
"Bjornulf_LoopLinesSequential": LoopLinesSequential,
|
||||
@@ -128,6 +132,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Bjornulf_WriteText": "✒ Write Text",
|
||||
"Bjornulf_MergeImagesHorizontally": "🖼🖼 Merge Images/Videos 📹📹 (Horizontally)",
|
||||
"Bjornulf_MergeImagesVertically": "🖼🖼 Merge Images/Videos 📹📹 (Vertically)",
|
||||
"Bjornulf_CombineVideoAudio": "📹🔊 Combine Video + Audio",
|
||||
"Bjornulf_ConcatVideos": "📹🔗 Concat Videos",
|
||||
"Bjornulf_LoopLinesSequential": "♻📝 Loop Sequential (input Lines)",
|
||||
|
||||
@@ -231,12 +231,13 @@ class AudioVideoSync:
|
||||
def process_audio(self, audio_tensor, sample_rate, target_duration, original_duration,
|
||||
max_speedup, max_slowdown):
|
||||
"""Process audio to match video duration."""
|
||||
if audio_tensor.dim() == 3:
|
||||
audio_tensor = audio_tensor.squeeze(0)
|
||||
elif audio_tensor.dim() == 1:
|
||||
# Ensure audio tensor has correct dimensions
|
||||
if audio_tensor.dim() == 2:
|
||||
audio_tensor = audio_tensor.unsqueeze(0)
|
||||
elif audio_tensor.dim() == 1:
|
||||
audio_tensor = audio_tensor.unsqueeze(0).unsqueeze(0)
|
||||
|
||||
current_duration = audio_tensor.shape[1] / sample_rate
|
||||
current_duration = audio_tensor.shape[-1] / sample_rate
|
||||
|
||||
# Calculate synchronized video duration
|
||||
if target_duration > original_duration:
|
||||
@@ -256,17 +257,17 @@ class AudioVideoSync:
|
||||
# Adjust audio length
|
||||
if current_duration < sync_duration:
|
||||
silence_samples = int((sync_duration - current_duration) * sample_rate)
|
||||
silence = torch.zeros(audio_tensor.shape[0], silence_samples)
|
||||
processed_audio = torch.cat([audio_tensor, silence], dim=1)
|
||||
silence = torch.zeros(audio_tensor.shape[0], audio_tensor.shape[1], silence_samples)
|
||||
processed_audio = torch.cat([audio_tensor, silence], dim=-1)
|
||||
else:
|
||||
required_samples = int(sync_duration * sample_rate)
|
||||
processed_audio = audio_tensor[:, :required_samples]
|
||||
processed_audio = audio_tensor[..., :required_samples]
|
||||
|
||||
return processed_audio, sync_duration
|
||||
|
||||
def save_audio(self, audio_tensor, sample_rate, target_duration, original_duration,
|
||||
max_speedup, max_slowdown):
|
||||
"""Save processed audio to file."""
|
||||
"""Save processed audio to file and return consistent AUDIO format."""
|
||||
timestamp = self.generate_timestamp()
|
||||
output_path = os.path.join(self.sync_audio_dir, f"sync_audio_{timestamp}.wav")
|
||||
|
||||
@@ -275,12 +276,29 @@ class AudioVideoSync:
|
||||
max_speedup, max_slowdown
|
||||
)
|
||||
|
||||
torchaudio.save(output_path, processed_audio, sample_rate)
|
||||
return os.path.abspath(output_path)
|
||||
# Save with proper format
|
||||
torchaudio.save(output_path, processed_audio.squeeze(0), sample_rate)
|
||||
|
||||
# Return consistent AUDIO format
|
||||
return {
|
||||
'waveform': processed_audio,
|
||||
'sample_rate': sample_rate
|
||||
}
|
||||
|
||||
def load_audio_from_path(self, audio_path):
|
||||
"""Load audio from file path."""
|
||||
"""Load audio from file path and format it consistently with AUDIO input."""
|
||||
waveform, sample_rate = torchaudio.load(audio_path)
|
||||
|
||||
# Ensure waveform has 3 dimensions (batch, channels, samples) like AUDIO input
|
||||
if waveform.dim() == 2:
|
||||
waveform = waveform.unsqueeze(0) # Add batch dimension
|
||||
|
||||
# Convert to float32 and normalize to range [0, 1] if needed
|
||||
if waveform.dtype != torch.float32:
|
||||
waveform = waveform.float()
|
||||
if waveform.max() > 1.0:
|
||||
waveform = waveform / 32768.0 # Normalize 16-bit audio
|
||||
|
||||
return {'waveform': waveform, 'sample_rate': sample_rate}
|
||||
|
||||
def extract_frames(self, video_path):
|
||||
@@ -297,7 +315,10 @@ class AudioVideoSync:
|
||||
# Load frames and convert to tensor
|
||||
frames = []
|
||||
frame_files = sorted(os.listdir(temp_dir))
|
||||
transform = transforms.Compose([transforms.ToTensor()])
|
||||
transform = transforms.Compose([
|
||||
transforms.ToTensor(),
|
||||
transforms.Lambda(lambda x: x * 255) # Scale to 0-255 range
|
||||
])
|
||||
|
||||
for frame_file in frame_files:
|
||||
image = Image.open(os.path.join(temp_dir, frame_file))
|
||||
@@ -307,6 +328,13 @@ class AudioVideoSync:
|
||||
# Stack frames into a single tensor
|
||||
frames_tensor = torch.stack(frames)
|
||||
|
||||
# Ensure the tensor is in the correct format (B, C, H, W)
|
||||
if frames_tensor.dim() == 3:
|
||||
frames_tensor = frames_tensor.unsqueeze(0)
|
||||
|
||||
# Convert to uint8
|
||||
frames_tensor = frames_tensor.byte()
|
||||
|
||||
# Clean up temporary directory
|
||||
for frame_file in frame_files:
|
||||
os.remove(os.path.join(temp_dir, frame_file))
|
||||
@@ -350,25 +378,35 @@ class AudioVideoSync:
|
||||
sync_video_path = self.create_sync_video(
|
||||
video_path, original_duration, audio_duration, max_speedup, max_slowdown
|
||||
)
|
||||
sync_audio_path = self.save_audio(
|
||||
|
||||
# Process and save audio, getting consistent AUDIO format back
|
||||
sync_audio = self.save_audio(
|
||||
AUDIO['waveform'], AUDIO['sample_rate'], audio_duration,
|
||||
original_duration, max_speedup, max_slowdown
|
||||
)
|
||||
|
||||
# Get sync_audio_path separately
|
||||
sync_audio_path = os.path.join(self.sync_audio_dir, f"sync_audio_{self.generate_timestamp()}.wav")
|
||||
torchaudio.save(sync_audio_path, sync_audio['waveform'].squeeze(0), sync_audio['sample_rate'])
|
||||
|
||||
# Get final properties
|
||||
sync_video_duration, _, sync_frame_count = self.get_video_info(sync_video_path)
|
||||
sync_audio_duration = torchaudio.info(sync_audio_path).num_frames / AUDIO['sample_rate']
|
||||
sync_audio_duration = sync_audio['waveform'].shape[-1] / sync_audio['sample_rate']
|
||||
|
||||
video_frames = self.extract_frames(sync_video_path)
|
||||
|
||||
# Convert video_frames to the format expected by ComfyUI
|
||||
video_frames = video_frames.float() / 255.0
|
||||
video_frames = video_frames.permute(0, 2, 3, 1)
|
||||
|
||||
return (
|
||||
video_frames,
|
||||
AUDIO,
|
||||
sync_audio, # Now returns consistent AUDIO format
|
||||
sync_audio_path,
|
||||
sync_video_path,
|
||||
original_duration, # input_video_duration
|
||||
original_duration,
|
||||
sync_video_duration,
|
||||
audio_duration, # input_audio_duration
|
||||
audio_duration,
|
||||
sync_audio_duration,
|
||||
sync_frame_count
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
import random
|
||||
import hashlib
|
||||
|
||||
class TextToStringAndSeed:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_NAMES = ("text","random_seed")
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "utils"
|
||||
|
||||
def process(self, text):
|
||||
# Generate a hash from the input text
|
||||
text_hash = hashlib.md5(text.encode()).hexdigest()
|
||||
|
||||
# Use the hash to seed the random number generator
|
||||
random.seed(text_hash)
|
||||
|
||||
# Generate a random seed (integer)
|
||||
random_seed = random.randint(0, 2**32 - 1)
|
||||
|
||||
# Reset the random seed to ensure randomness in subsequent calls
|
||||
random.seed()
|
||||
|
||||
return (text, random_seed)
|
||||
56
images_merger_horizontal.py
Normal file
56
images_merger_horizontal.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
class MergeImagesHorizontally:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image1": ("IMAGE",),
|
||||
"image2": ("IMAGE",),
|
||||
},
|
||||
"optional": {
|
||||
"image3": ("IMAGE",),
|
||||
"image4": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "combine_images"
|
||||
|
||||
CATEGORY = "Bjornulf"
|
||||
|
||||
def combine_images(self, image1, image2, image3=None, image4=None):
|
||||
# Collect all provided images
|
||||
images = [image1, image2]
|
||||
if image3 is not None:
|
||||
images.append(image3)
|
||||
if image4 is not None:
|
||||
images.append(image4)
|
||||
|
||||
# Calculate the total width and maximum height
|
||||
total_width = sum(img.shape[2] for img in images) # Sum of widths
|
||||
max_height = max(img.shape[1] for img in images) # Maximum height
|
||||
|
||||
# Create a new tensor for the combined image
|
||||
combined_image = torch.zeros((images[0].shape[0], max_height, total_width, 3), dtype=images[0].dtype, device=images[0].device)
|
||||
|
||||
# Paste images side by side
|
||||
current_x = 0
|
||||
for img in images:
|
||||
b, h, w, c = img.shape
|
||||
combined_image[:, :h, current_x:current_x+w, :] = img
|
||||
|
||||
# Blend the edge pixels if it's not the last image
|
||||
# if current_x + w < total_width:
|
||||
# combined_image[:, :h, current_x+w-1:current_x+w+1, :] = torch.mean(
|
||||
# torch.stack([
|
||||
# combined_image[:, :h, current_x+w-1:current_x+w, :],
|
||||
# combined_image[:, :h, current_x+w:current_x+w+1, :]
|
||||
# ]), dim=0
|
||||
# )
|
||||
|
||||
current_x += w
|
||||
|
||||
return (combined_image,)
|
||||
46
images_merger_vertical.py
Normal file
46
images_merger_vertical.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
class MergeImagesVertically:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image1": ("IMAGE",),
|
||||
"image2": ("IMAGE",),
|
||||
},
|
||||
"optional": {
|
||||
"image3": ("IMAGE",),
|
||||
"image4": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "combine_images"
|
||||
|
||||
CATEGORY = "Bjornulf"
|
||||
|
||||
def combine_images(self, image1, image2, image3=None, image4=None):
|
||||
# Collect all provided images
|
||||
images = [image1, image2]
|
||||
if image3 is not None:
|
||||
images.append(image3)
|
||||
if image4 is not None:
|
||||
images.append(image4)
|
||||
|
||||
# Calculate the total width and maximum height
|
||||
total_width = sum(img.shape[1] for img in images)
|
||||
max_height = max(img.shape[2] for img in images)
|
||||
|
||||
# Create a new tensor for the combined image
|
||||
combined_image = torch.zeros((images[0].shape[0], total_width, max_height, 3), dtype=images[0].dtype, device=images[0].device)
|
||||
|
||||
# Paste images side by side
|
||||
current_x = 0
|
||||
for img in images:
|
||||
w, h = img.shape[1:3]
|
||||
combined_image[:, current_x:current_x+w, :h, :] = img[:, :, :, :]
|
||||
current_x += w
|
||||
|
||||
return (combined_image,)
|
||||
1
ollama_ip.txt
Normal file
1
ollama_ip.txt
Normal file
@@ -0,0 +1 @@
|
||||
http://0.0.0.0:11434
|
||||
@@ -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.50"
|
||||
version = "0.51"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
[project.urls]
|
||||
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
ollama
|
||||
pydub
|
||||
BIN
screenshots/merge_images_h.png
Normal file
BIN
screenshots/merge_images_h.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
BIN
screenshots/merge_images_v.png
Normal file
BIN
screenshots/merge_images_v.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
screenshots/merge_videos.png
Normal file
BIN
screenshots/merge_videos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 425 KiB |
@@ -1,29 +0,0 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Bjornulf.CustomBjornulfType",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "Bjornulf_WriteImageCharacters") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
const myInput = this.inputs.find(input => input.name === "BJORNULF_CHARACTER");
|
||||
if (myInput) {
|
||||
myInput.type = "BJORNULF_CHARACTER";
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (nodeData.name === "Bjornulf_WriteImageCharacter") {
|
||||
|
||||
}
|
||||
},
|
||||
async setup(app) {
|
||||
app.registerCustomNodeType("BJORNULF_CHARACTER", (value) => {
|
||||
return {
|
||||
type: "BJORNULF_CHARACTER",
|
||||
data: { value: value || "" },
|
||||
name: "BJORNULF_CHARACTER"
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Bjornulf.CustomStringType",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "Bjornulf_WriteImageAllInOne") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
const locationInput = this.inputs.find(input => input.name === "location");
|
||||
if (locationInput) {
|
||||
locationInput.type = "CUSTOM_STRING";
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
async setup(app) {
|
||||
app.registerCustomNodeType("CUSTOM_STRING", (value) => {
|
||||
return {
|
||||
type: "CustomStringType",
|
||||
data: { value: value || "" },
|
||||
name: "CustomStringType"
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Override the default onConnectionCreated method
|
||||
const originalOnConnectionCreated = LGraphCanvas.prototype.onConnectionCreated;
|
||||
LGraphCanvas.prototype.onConnectionCreated = function(connection, e, node_for_click) {
|
||||
if (node_for_click && node_for_click.type === "WriteImageAllInOne" && connection.targetInput.name === "location") {
|
||||
// Check if the connected node is not already a CustomString
|
||||
if (connection.origin_node.type !== "CustomString") {
|
||||
// Create a new CustomString node
|
||||
const customStringNode = LiteGraph.createNode("CustomString");
|
||||
// Position the new node
|
||||
customStringNode.pos = [connection.origin_node.pos[0] + 200, connection.origin_node.pos[1]];
|
||||
this.graph.add(customStringNode);
|
||||
|
||||
// Connect the new CustomString node
|
||||
connection.origin_node.connect(connection.origin_slot, customStringNode, 0);
|
||||
customStringNode.connect(0, node_for_click, connection.target_slot);
|
||||
|
||||
// Remove the original connection
|
||||
connection.origin_node.disconnectOutput(connection.origin_slot, node_for_click);
|
||||
|
||||
return true; // Prevent the original connection
|
||||
}
|
||||
}
|
||||
return originalOnConnectionCreated.apply(this, arguments);
|
||||
};
|
||||
Reference in New Issue
Block a user