This commit is contained in:
justumen
2024-11-01 09:31:56 +01:00
parent 06da237179
commit 7df528d1d9
16 changed files with 195 additions and 145 deletions

4
.gitignore vendored
View File

@@ -3,5 +3,5 @@ SaveText/
API_example/ API_example/
clear_vram.py clear_vram.py
web/js/clear_vram.js web/js/clear_vram.js
*.txt speakers
speakers *.text

View File

@@ -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,)

View File

@@ -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. 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 # 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) `44.` [🖼👈 Select an Image, Pick](#44----select-an-image-pick)
`46.` [🖼🔍 Image Details](#46----image-details) `46.` [🖼🔍 Image Details](#46----image-details)
`47.` [🖼 Combine Images](#47----combine-images) `47.` [🖼 Combine Images](#47----combine-images)
`60.` [🖼🖼 Merge Images/Videos 📹📹 (Horizontally)](#60)
`61.` [🖼🖼 Merge Images/Videos 📹📹 (Vertically)](#61)
## 🚀 Load checkpoints 🚀 ## 🚀 Load checkpoints 🚀
`40.` [🎲 Random (Model+Clip+Vae) - aka Checkpoint / Model](#40----random-modelclipvae---aka-checkpoint--model) `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) `52.` [🔊📹 Audio Video Sync](#52----audio-video-sync)
`58.` [📹🔗 Concat Videos](#58----concat-videos) `58.` [📹🔗 Concat Videos](#58----concat-videos)
`59.` [📹🔊 Combine Video + Audio](#59----combine-video--audio) `59.` [📹🔊 Combine Video + Audio](#59----combine-video--audio)
`60.` [🖼🖼 Merge Images/Videos 📹📹 (Horizontally)](#60)
`61.` [🖼🖼 Merge Images/Videos 📹📹 (Vertically)](#61)
## 🤖 AI 🤖 ## 🤖 AI 🤖
`19.` [🦙 Ollama](#19----ollama) `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.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.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**: 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 # 📝 Nodes descriptions
@@ -823,6 +828,7 @@ Display the details of an image. (width, height, has_transparency, orientation,
**Description:** **Description:**
Combine multiple images (A single image or a list of images.) 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. 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) : 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. Audio : Use audio path or audio type.
![combine video audio](screenshots/combine_video_audio.png) ![combine video audio](screenshots/combine_video_audio.png)
### 60 - 🖼🖼 Merge Images/Videos 📹📹 (Horizontally)
**Description:**
Merge images or videos horizontally.
![merge images](screenshots/merge_images_h.png)
Here is on possible example for videos with node 60 and 61 :
![merge videos](screenshots/merge_videos.png)
### 61 - 🖼🖼 Merge Images/Videos 📹📹 (Vertically)
**Description:**
Merge images or videos vertically.
![merge images](screenshots/merge_images_v.png)

View File

@@ -62,9 +62,13 @@ from .loop_sequential_integer import LoopIntegerSequential
from .loop_lines_sequential import LoopLinesSequential from .loop_lines_sequential import LoopLinesSequential
from .concat_videos import ConcatVideos from .concat_videos import ConcatVideos
from .combine_video_audio import CombineVideoAudio from .combine_video_audio import CombineVideoAudio
from .images_merger_horizontal import MergeImagesHorizontally
from .images_merger_vertical import MergeImagesVertically
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"Bjornulf_ollamaLoader": ollamaLoader, "Bjornulf_ollamaLoader": ollamaLoader,
"Bjornulf_MergeImagesHorizontally": MergeImagesHorizontally,
"Bjornulf_MergeImagesVertically": MergeImagesVertically,
"Bjornulf_CombineVideoAudio": CombineVideoAudio, "Bjornulf_CombineVideoAudio": CombineVideoAudio,
"Bjornulf_ConcatVideos": ConcatVideos, "Bjornulf_ConcatVideos": ConcatVideos,
"Bjornulf_LoopLinesSequential": LoopLinesSequential, "Bjornulf_LoopLinesSequential": LoopLinesSequential,
@@ -128,6 +132,8 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"Bjornulf_WriteText": "✒ Write Text", "Bjornulf_WriteText": "✒ Write Text",
"Bjornulf_MergeImagesHorizontally": "🖼🖼 Merge Images/Videos 📹📹 (Horizontally)",
"Bjornulf_MergeImagesVertically": "🖼🖼 Merge Images/Videos 📹📹 (Vertically)",
"Bjornulf_CombineVideoAudio": "📹🔊 Combine Video + Audio", "Bjornulf_CombineVideoAudio": "📹🔊 Combine Video + Audio",
"Bjornulf_ConcatVideos": "📹🔗 Concat Videos", "Bjornulf_ConcatVideos": "📹🔗 Concat Videos",
"Bjornulf_LoopLinesSequential": "♻📝 Loop Sequential (input Lines)", "Bjornulf_LoopLinesSequential": "♻📝 Loop Sequential (input Lines)",

View File

@@ -231,12 +231,13 @@ class AudioVideoSync:
def process_audio(self, audio_tensor, sample_rate, target_duration, original_duration, def process_audio(self, audio_tensor, sample_rate, target_duration, original_duration,
max_speedup, max_slowdown): max_speedup, max_slowdown):
"""Process audio to match video duration.""" """Process audio to match video duration."""
if audio_tensor.dim() == 3: # Ensure audio tensor has correct dimensions
audio_tensor = audio_tensor.squeeze(0) if audio_tensor.dim() == 2:
elif audio_tensor.dim() == 1:
audio_tensor = audio_tensor.unsqueeze(0) 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 # Calculate synchronized video duration
if target_duration > original_duration: if target_duration > original_duration:
@@ -256,17 +257,17 @@ class AudioVideoSync:
# Adjust audio length # Adjust audio length
if current_duration < sync_duration: if current_duration < sync_duration:
silence_samples = int((sync_duration - current_duration) * sample_rate) silence_samples = int((sync_duration - current_duration) * sample_rate)
silence = torch.zeros(audio_tensor.shape[0], silence_samples) silence = torch.zeros(audio_tensor.shape[0], audio_tensor.shape[1], silence_samples)
processed_audio = torch.cat([audio_tensor, silence], dim=1) processed_audio = torch.cat([audio_tensor, silence], dim=-1)
else: else:
required_samples = int(sync_duration * sample_rate) required_samples = int(sync_duration * sample_rate)
processed_audio = audio_tensor[:, :required_samples] processed_audio = audio_tensor[..., :required_samples]
return processed_audio, sync_duration return processed_audio, sync_duration
def save_audio(self, audio_tensor, sample_rate, target_duration, original_duration, def save_audio(self, audio_tensor, sample_rate, target_duration, original_duration,
max_speedup, max_slowdown): max_speedup, max_slowdown):
"""Save processed audio to file.""" """Save processed audio to file and return consistent AUDIO format."""
timestamp = self.generate_timestamp() timestamp = self.generate_timestamp()
output_path = os.path.join(self.sync_audio_dir, f"sync_audio_{timestamp}.wav") output_path = os.path.join(self.sync_audio_dir, f"sync_audio_{timestamp}.wav")
@@ -275,12 +276,29 @@ class AudioVideoSync:
max_speedup, max_slowdown max_speedup, max_slowdown
) )
torchaudio.save(output_path, processed_audio, sample_rate) # Save with proper format
return os.path.abspath(output_path) 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): 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) 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} return {'waveform': waveform, 'sample_rate': sample_rate}
def extract_frames(self, video_path): def extract_frames(self, video_path):
@@ -297,7 +315,10 @@ class AudioVideoSync:
# Load frames and convert to tensor # Load frames and convert to tensor
frames = [] frames = []
frame_files = sorted(os.listdir(temp_dir)) 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: for frame_file in frame_files:
image = Image.open(os.path.join(temp_dir, frame_file)) image = Image.open(os.path.join(temp_dir, frame_file))
@@ -307,6 +328,13 @@ class AudioVideoSync:
# Stack frames into a single tensor # Stack frames into a single tensor
frames_tensor = torch.stack(frames) 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 # Clean up temporary directory
for frame_file in frame_files: for frame_file in frame_files:
os.remove(os.path.join(temp_dir, frame_file)) os.remove(os.path.join(temp_dir, frame_file))
@@ -350,25 +378,35 @@ class AudioVideoSync:
sync_video_path = self.create_sync_video( sync_video_path = self.create_sync_video(
video_path, original_duration, audio_duration, max_speedup, max_slowdown 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, AUDIO['waveform'], AUDIO['sample_rate'], audio_duration,
original_duration, max_speedup, max_slowdown 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 # Get final properties
sync_video_duration, _, sync_frame_count = self.get_video_info(sync_video_path) 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) 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 ( return (
video_frames, video_frames,
AUDIO, sync_audio, # Now returns consistent AUDIO format
sync_audio_path, sync_audio_path,
sync_video_path, sync_video_path,
original_duration, # input_video_duration original_duration,
sync_video_duration, sync_video_duration,
audio_duration, # input_audio_duration audio_duration,
sync_audio_duration, sync_audio_duration,
sync_frame_count sync_frame_count
) )

View File

@@ -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)

View 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
View 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
View File

@@ -0,0 +1 @@
http://0.0.0.0:11434

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "bjornulf_custom_nodes" 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, ..." 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"} license = {file = "LICENSE"}
[project.urls] [project.urls]

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
ollama
pydub

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

View File

@@ -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"
};
});
}
});

View File

@@ -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);
};