Compare commits

..
11 Commits
Author SHA1 Message Date
Mel Massadian 90d96366c8 docs: 📄 add note+ screenshot 2023-12-02 19:16:35 +01:00
melMass 605c8db320 feat: 📝 add note plus
future ideas:

- add a markdown mode
- add a separate css tab on the edit dialog
2023-12-02 19:07:32 +01:00
melMass cf965727e8 feat: 🚧 add playlist nodes
very basic for now
2023-12-01 00:50:50 +01:00
melMass 12b134ab4c fix: 💄 register the COLOR type even for external extensions
Since I register the widgets, anyone using a COLOR type in their extension
and mixing it with mtb will end up with widgets, before this commit they would not have the
option to turn it back and forth between input <-> widgets.

related to:
https://github.com/melMass/comfy_mtb/discussions/131

draft pr in bmad here:
https://github.com/bmad4ever/comfyui_bmad_nodes/pull/6
2023-11-30 20:58:29 +01:00
melMass dd27f990c7 chore: 📝 update node list 2023-11-29 15:41:47 +01:00
melMass 16c1a59312 feat: 🚨 add missing node
- pickfrombatch: exctract X images from either ends of your batch
- added alignement to TextToImage
2023-11-29 15:40:57 +01:00
melMass 59a361af58 fix: ✨ mask crop output
Follow up of #124
Thanks to @Yurchikian
2023-11-14 21:39:09 +01:00
melMass e4da832b99 fix: 🚑️ thread font loading
This created a huge lag on first opening of the browser.
The small drawback is that the webpage needs a reload
for fonts to be available
2023-11-14 20:53:44 +01:00
Artem YurchenkoandmelMass 14ee9e23c0 fix: 📦 changed way of creating bbox from mask (#124)
* fix: changed way of creating bbox from mask

changed code according to
https://stackoverflow.com/questions/31400769/bounding-box-of-numpy-array
Also fixed processing of mask input

* fix: 🐛 ambigous check

---------

Co-authored-by: melMass <mel@melmassadian.com>
2023-11-14 20:46:26 +01:00
melMass 53cb503866 fix: ✨ expose invert of bboxfrommask
changes the default to false
related to #124
2023-11-14 18:02:04 +01:00
melMass d5c4c5f264 fix: ✨ less strict csv parsing
closes #122
2023-11-08 18:28:27 +01:00
16 changed files with 510 additions and 414 deletions
-8
View File
@@ -322,14 +322,6 @@ if hasattr(PromptServer, "instance"):
return await endpoint.do_action(request) return await endpoint.do_action(request)
@PromptServer.instance.routes.get("/mtb/audio")
async def get_audio(request):
from . import endpoint
reload(endpoint)
return await endpoint.get_audio(request)
# - WAS Dictionary # - WAS Dictionary
MANIFEST = { MANIFEST = {
+4 -55
View File
@@ -3,16 +3,7 @@ import csv
from aiohttp import web from aiohttp import web
from .log import mklog from .log import mklog
from .utils import ( from .utils import backup_file, here, import_install, reqs_map, run_command, styles_dir
audioInputDir,
backup_file,
comfy_dir,
here,
import_install,
reqs_map,
run_command,
styles_dir,
)
endlog = mklog("mtb endpoint") endlog = mklog("mtb endpoint")
@@ -24,29 +15,6 @@ from pathlib import Path
import_install("requirements") import_install("requirements")
def ACTIONS_loadAudio(args):
if not audioInputDir.exists():
audioInputDir.mkdir()
endlog.debug(f"Received Load Audio request for {args}")
if not args.file:
return web.Response(status=400)
filename = args.filename
if not filename:
return web.Response(status=400)
target = audioInputDir / filename
if target.exists():
target.unlink()
with target.open("wb") as f:
f.write(args.file.read())
return {"name": filename}
def ACTIONS_installDependency(dependency_names=None): def ACTIONS_installDependency(dependency_names=None):
if dependency_names is None: if dependency_names is None:
return {"error": "No dependency name provided"} return {"error": "No dependency name provided"}
@@ -120,7 +88,7 @@ def ACTIONS_saveStyle(data):
async def do_action(request) -> web.Response: async def do_action(request) -> web.Response:
endlog.debug("Init action request") endlog.debug("Init action request")
request_data = await request.post() request_data = await request.json()
name = request_data.get("name") name = request_data.get("name")
args = request_data.get("args") args = request_data.get("args")
@@ -132,33 +100,14 @@ async def do_action(request) -> web.Response:
if callable(method): if callable(method):
result = method(args) if args else method() result = method(args) if args else method()
endlog.debug(f"Action result: {result}") endlog.debug(f"Action result: {result}")
return web.json_response({"result": result}, status=200) return web.json_response({"result": result})
available_methods = [ available_methods = [
attr[len("ACTIONS_") :] for attr in globals() if attr.startswith("ACTIONS_") attr[len("ACTIONS_") :] for attr in globals() if attr.startswith("ACTIONS_")
] ]
return web.json_response( return web.json_response(
{"error": "Invalid method name.", "available_methods": available_methods}, {"error": "Invalid method name.", "available_methods": available_methods}
status=400,
)
async def get_audio(request):
name = request.rel_url.query.get("filename")
if not name:
return web.json_response(
{"error": "No filename provided as url query."}, status=400
)
target = audioInputDir / name
if not target.exists():
return web.json_response(
{"error": f"File {name} (in {audioInputDir}) not found..."}, status=404
)
return web.FileResponse(
target, headers={"Content-Disposition": f'filename="{name}"'}
) )
+2 -1
View File
@@ -42,13 +42,14 @@
"Mask To Image (mtb)": "Converts a mask (alpha) to an RGB image with a color and background", "Mask To Image (mtb)": "Converts a mask (alpha) to an RGB image with a color and background",
"Math Expression (mtb)": "Node to evaluate a simple math expression string", "Math Expression (mtb)": "Node to evaluate a simple math expression string",
"Model Patch Seamless (mtb)": "Uses the stable diffusion 'hack' to infer seamless images by setting the model layers padding mode to circular (experimental)", "Model Patch Seamless (mtb)": "Uses the stable diffusion 'hack' to infer seamless images by setting the model layers padding mode to circular (experimental)",
"Pick From Batch (mtb)": "Pick a specific number of images from a batch, either from the start or end.",
"Qr Code (mtb)": "Basic QR Code generator", "Qr Code (mtb)": "Basic QR Code generator",
"Restore Face (mtb)": "Uses GFPGan to restore faces", "Restore Face (mtb)": "Uses GFPGan to restore faces",
"Save Gif (mtb)": "Save the images from the batch as a GIF", "Save Gif (mtb)": "Save the images from the batch as a GIF",
"Save Image Grid (mtb)": "Save all the images in the input batch as a grid of images.", "Save Image Grid (mtb)": "Save all the images in the input batch as a grid of images.",
"Save Image Sequence (mtb)": "Save an image sequence to a folder. The current frame is used to determine which image to save.\n\n This is merely a wrapper around the `save_images` function with formatting for the output folder and filename.\n ", "Save Image Sequence (mtb)": "Save an image sequence to a folder. The current frame is used to determine which image to save.\n\n This is merely a wrapper around the `save_images` function with formatting for the output folder and filename.\n ",
"Save Tensors (mtb)": "Save torch tensors (image, mask or latent) to disk, useful to debug things outside comfy", "Save Tensors (mtb)": "Save torch tensors (image, mask or latent) to disk, useful to debug things outside comfy",
"Sharpen (mtb)": "Sharpens an image using a Gaussian kernel.", "Sharpen (mtb)": "Sharpens an image using a Gaussian kernel.",
"Smart Step (mtb)": "Utils to control the steps start/stop of the KAdvancedSampler in percentage", "Smart Step (mtb)": "Utils to control the steps start/stop of the KAdvancedSampler in percentage",
"Stack Images (mtb)": "Stack the input images horizontally or vertically", "Stack Images (mtb)": "Stack the input images horizontally or vertically",
"String Replace (mtb)": "Basic string replacement", "String Replace (mtb)": "Basic string replacement",
+5 -56
View File
@@ -1,20 +1,17 @@
from io import BytesIO import math
import os
from pathlib import Path
from typing import List
import cv2 import cv2
import torchaudio import folder_paths
import numpy as np import numpy as np
import torch import torch
from PIL import Image
from ..log import log from ..log import log
from ..utils import apply_easing, pil2tensor from ..utils import apply_easing, pil2tensor
from .transform import TransformImage from .transform import TransformImage
try:
import librosa
except ImportError:
log.warning("librosa not installed. Batch Audio features will not be available.")
def hex_to_rgb(hex_color, bgr=False): def hex_to_rgb(hex_color, bgr=False):
hex_color = hex_color.lstrip("#") hex_color = hex_color.lstrip("#")
@@ -616,57 +613,9 @@ class BatchShake:
return (shaken_images, x_translations, y_translations, rotations) return (shaken_images, x_translations, y_translations, rotations)
class BatchFloatsFromSound:
"""Extracts a list of floats based on audio frequency band peaks."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
"sensitivity": ("FLOAT", {"default": 1.0}),
"low_freq": ("FLOAT", {"default": 100.0}),
"high_freq": ("FLOAT", {"default": 2000.0}),
"hop_length": ("INT", {"default": 512}),
},
}
RETURN_TYPES = ("FLOATS",)
RETURN_NAMES = ("float_data",)
FUNCTION = "process_audio"
CATEGORY = "mtb/audio"
def process_audio(
self,
audio,
sensitivity=1.0,
low_freq=100,
high_freq=2000,
hop_length=512,
):
# audio_data, _ = librosa.load(audio_file_path, sr=sample_rate)
# audio_data_tensor = audio.squeeze(1) # Remove the channel dimension if present
# audio_tensor = audio_data_tensor.float()
audio_data = audio.to(device=torchaudio.transforms.Spectrogram().window.device)
hop_length = 512
stft = torchaudio.transforms.Spectrogram()(audio_data)
freqs = torchaudio.transforms.FrequencyMasking(low_freq, high_freq)(stft)
band_energy = torch.sum(freqs, dim=1)
min_val = torch.min(band_energy)
max_val = torch.max(band_energy)
normalized_peaks = (band_energy - min_val) / (max_val - min_val)
scaled_peaks = normalized_peaks * sensitivity
return (scaled_peaks.tolist(),)
__nodes__ = [ __nodes__ = [
BatchFloat, BatchFloat,
Batch2dTransform, Batch2dTransform,
BatchFloatsFromSound,
BatchShape, BatchShape,
BatchMake, BatchMake,
BatchFloatAssemble, BatchFloatAssemble,
+13 -6
View File
@@ -1,5 +1,4 @@
import csv import csv, shutil
import shutil
from pathlib import Path from pathlib import Path
import folder_paths import folder_paths
@@ -157,13 +156,21 @@ class StylesLoader:
for file in files: for file in files:
with open(file, "r", encoding="utf8") as f: with open(file, "r", encoding="utf8") as f:
parsed = csv.reader(f) parsed = csv.reader(f)
for row in parsed: for i, row in enumerate(parsed):
log.debug(f"Adding style {row[0]}") log.debug(f"Adding style {row[0]}")
try: try:
cls.options[row[0]] = (row[1], row[2]) name, positive, negative = (row + [None] * 3)[:3]
except Exception: positive = positive or ""
negative = negative or ""
if name is not None:
cls.options[name] = (positive, negative)
else:
# Handle the case where 'name' is None
log.warning(f"Missing 'name' in row {i}.")
except Exception as e:
log.warning( log.warning(
f"There was an error while parsing {file}, make sure it respects A1111 format, i.e 3 columns name, positive, negative" f"There was an error while parsing {file}, make sure it respects A1111 format, i.e 3 columns name, positive, negative:\n{e}"
) )
continue continue
+11 -7
View File
@@ -44,6 +44,7 @@ class BboxFromMask:
return { return {
"required": { "required": {
"mask": ("MASK",), "mask": ("MASK",),
"invert": ("BOOLEAN", {"default": False}),
}, },
"optional": { "optional": {
"image": ("IMAGE",), "image": ("IMAGE",),
@@ -61,7 +62,7 @@ class BboxFromMask:
FUNCTION = "extract_bounding_box" FUNCTION = "extract_bounding_box"
CATEGORY = "mtb/crop" CATEGORY = "mtb/crop"
def extract_bounding_box(self, mask: torch.Tensor, image=None): def extract_bounding_box(self, mask: torch.Tensor, invert: bool, image=None):
# if image != None: # if image != None:
# if mask.size(0) != image.size(0): # if mask.size(0) != image.size(0):
# if mask.size(0) != 1: # if mask.size(0) != 1:
@@ -73,9 +74,8 @@ class BboxFromMask:
# f"Batch count mismatch for mask and image, it can either be 1 mask for X images, or X masks for X images (mask: {mask.shape} | image: {image.shape})" # f"Batch count mismatch for mask and image, it can either be 1 mask for X images, or X masks for X images (mask: {mask.shape} | image: {image.shape})"
# ) # )
_mask = tensor2pil(1.0 - mask)[0]
# we invert it # we invert it
_mask = tensor2pil(1.0 - mask)[0] if invert else tensor2pil(mask)[0]
alpha_channel = np.array(_mask) alpha_channel = np.array(_mask)
non_zero_indices = np.nonzero(alpha_channel) non_zero_indices = np.nonzero(alpha_channel)
@@ -141,19 +141,23 @@ class Crop:
self, image: torch.Tensor, mask=None, x=0, y=0, width=256, height=256, bbox=None self, image: torch.Tensor, mask=None, x=0, y=0, width=256, height=256, bbox=None
): ):
image = image.numpy() image = image.numpy()
if mask: if mask is not None:
mask = mask.numpy() mask = mask.numpy()
if bbox != None: if bbox is not None:
x, y, width, height = bbox x, y, width, height = bbox
cropped_image = image[:, y : y + height, x : x + width, :] cropped_image = image[:, y : y + height, x : x + width, :]
cropped_mask = mask[y : y + height, x : x + width] if mask != None else None cropped_mask = None
if mask is not None:
cropped_mask = (
mask[:, y : y + height, x : x + width] if mask is not None else None
)
crop_data = (x, y, width, height) crop_data = (x, y, width, height)
return ( return (
torch.from_numpy(cropped_image), torch.from_numpy(cropped_image),
torch.from_numpy(cropped_mask) if mask != None else None, torch.from_numpy(cropped_mask) if cropped_mask is not None else None,
crop_data, crop_data,
) )
+62 -24
View File
@@ -1,9 +1,11 @@
import qrcode import threading
from ..utils import pil2tensor
from ..utils import comfy_dir
from typing import cast from typing import cast
import qrcode
from PIL import Image from PIL import Image
from ..log import log from ..log import log
from ..utils import comfy_dir, pil2tensor
# class MtbExamples: # class MtbExamples:
# """MTB Example Images""" # """MTB Example Images"""
@@ -74,9 +76,10 @@ class UnsplashImage:
CATEGORY = "mtb/generate" CATEGORY = "mtb/generate"
def do_unsplash_image(self, width, height, random_seed, keyword=None): def do_unsplash_image(self, width, height, random_seed, keyword=None):
import requests
import io import io
import requests
base_url = "https://source.unsplash.com/random/" base_url = "https://source.unsplash.com/random/"
if width and height: if width and height:
@@ -201,12 +204,13 @@ class TextToImage:
for font in fonts: for font in fonts:
log.debug(f"Adding font {font}") log.debug(f"Adding font {font}")
cls.fonts[font.stem] = font.as_posix() TextToImage.fonts[font.stem] = font.as_posix()
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
if not cls.fonts: if not cls.fonts:
cls.CACHE_FONTS() thread = threading.Thread(target=cls.CACHE_FONTS)
thread.start()
else: else:
log.debug(f"Using cached fonts (count: {len(cls.fonts)})") log.debug(f"Using cached fonts (count: {len(cls.fonts)})")
return { return {
@@ -232,7 +236,6 @@ class TextToImage:
"INT", "INT",
{"default": 512, "min": 1, "max": 8096, "step": 1}, {"default": 512, "min": 1, "max": 8096, "step": 1},
), ),
# "position": (["INT"], {"default": 0, "min": 0, "max": 100, "step": 1}),
"color": ( "color": (
"COLOR", "COLOR",
{"default": "black"}, {"default": "black"},
@@ -241,6 +244,8 @@ class TextToImage:
"COLOR", "COLOR",
{"default": "white"}, {"default": "white"},
), ),
"h_align": (("left", "center", "right"), {"default": "left"}),
"v_align": (("top", "center", "bottom"), {"default": "top"}),
} }
} }
@@ -250,29 +255,62 @@ class TextToImage:
CATEGORY = "mtb/generate" CATEGORY = "mtb/generate"
def text_to_image( def text_to_image(
self, text, font, wrap, font_size, width, height, color, background self,
text,
font,
wrap,
font_size,
width,
height,
color,
background,
h_align="left",
v_align="top",
): ):
from PIL import Image, ImageDraw, ImageFont
import textwrap import textwrap
font = self.fonts[font] from PIL import Image, ImageDraw, ImageFont
font = cast(ImageFont.FreeTypeFont, ImageFont.truetype(font, font_size))
if wrap == 0:
wrap = width / font_size
lines = textwrap.wrap(text, width=wrap)
log.debug(f"Lines: {lines}")
line_height = bbox_dim(font.getbbox("hg"))[1]
img_height = height # line_height * len(lines)
img_width = width # max(font.getsize(line)[0] for line in lines)
img = Image.new("RGBA", (img_width, img_height), background) font_path = self.fonts[font]
# Handle word wrapping
if wrap:
lines = textwrap.wrap(text, width=wrap)
else:
lines = [text]
font = ImageFont.truetype(font_path, font_size)
# font = ImageFont.truetype(font_path, font_size)
# if wrap == 0:
# wrap = width / font_size
log.debug(f"Lines: {lines}")
img = Image.new("RGBA", (width, height), background)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
y_text = 0
# - bbox is [left, upper, right, lower] text_height = sum(font.getsize(line)[1] for line in lines)
# Vertical alignment
if v_align == "top":
y_text = 0
elif v_align == "center":
y_text = (height - text_height) // 2
else: # bottom
y_text = height - text_height
# Draw each line of text
for line in lines: for line in lines:
width, height = bbox_dim(font.getbbox(line)) line_width, line_height = font.getsize(line)
draw.text((0, y_text), line, color, font=font)
y_text += height # Horizontal alignment
if h_align == "left":
x_text = 0
elif h_align == "center":
x_text = (width - line_width) // 2
else: # right
x_text = width - line_width
draw.text((x_text, y_text), line, color, font=font)
y_text += line_height
# img.save(os.path.join(folder_paths.base_path, f'{str(uuid.uuid4())}.png')) # img.save(os.path.join(folder_paths.base_path, f'{str(uuid.uuid4())}.png'))
return (pil2tensor(img),) return (pil2tensor(img),)
+1 -4
View File
@@ -1,7 +1,4 @@
import io import io, json, urllib.parse, urllib.request
import json
import urllib.parse
import urllib.request
import numpy as np import numpy as np
import torch import torch
+36 -1
View File
@@ -38,4 +38,39 @@ class StackImages:
return (stacked_tensor,) return (stacked_tensor,)
__nodes__ = [StackImages] class PickFromBatch:
"""Pick a specific number of images from a batch, either from the start or end."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"from_direction": (["end", "start"], {"default": "start"}),
"count": ("INT", {"default": 1}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "pick_from_batch"
CATEGORY = "mtb/image utils"
def pick_from_batch(self, image, from_direction, count):
batch_size = image.size(0)
# Limit count to the available number of images in the batch
count = min(count, batch_size)
if count < batch_size:
log.warning(
f"Requested {count} images, but only {batch_size} are available."
)
if from_direction == "end":
selected_tensors = image[-count:]
else:
selected_tensors = image[:count]
return (selected_tensors,)
__nodes__ = [StackImages, PickFromBatch]
+134 -55
View File
@@ -1,6 +1,4 @@
import json import json, subprocess, uuid
import subprocess
import uuid
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -8,42 +6,102 @@ import comfy.model_management as model_management
import folder_paths import folder_paths
import numpy as np import numpy as np
import torch import torch
from comfy.model_management import get_torch_device
from PIL import Image from PIL import Image
from ..log import log from ..log import log
from ..utils import PIL_FILTER_MAP, audioInputDir, tensor2np from ..utils import PIL_FILTER_MAP, output_dir, session_id, tensor2np
try:
import librosa
except ImportError:
log.warning("librosa not installed. I/O Audio features will not be available.")
class LoadAudio_: def get_playlist_path(playlist_name: str, persistant_playlist=False):
"""Load an audio file from the input folder (supports upload)""" if persistant_playlist:
return output_dir / "playlists" / f"{playlist_name}.json"
return output_dir / "playlists" / session_id / f"{playlist_name}.json"
class ReadPlaylist:
"""Read a playlist"""
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"audio": ("AUDIO_UPLOAD",), "enable": ("BOOLEAN", {"default": True}),
"sample_rate": ("INT", {"default": 44100}), "persistant_playlist": ("BOOLEAN", {"default": False}),
"playlist_name": ("STRING", {"default": "playlist_{index:04d}"}),
"index": ("INT", {"default": 0, "min": 0}),
} }
} }
RETURN_TYPES = ("AUDIO",) RETURN_TYPES = ("PLAYLIST",)
RETURN_NAMES = ("audio",) FUNCTION = "read_playlist"
FUNCTION = "load_audio" CATEGORY = "mtb/IO"
CATEGORY = "mtb/audio"
def load_audio(self, audio: str, sample_rate: int): def read_playlist(
log.debug(f"Audio file: {audio}") self, enable: bool, persistant_playlist: bool, playlist_name: str, index: int
audio_file_path = audioInputDir / audio ):
log.debug(f"Loading audio file: {audio_file_path}") playlist_name = playlist_name.format(index=index)
audio_data, _ = librosa.load(audio_file_path.as_posix(), sr=sample_rate) playlist_path = get_playlist_path(playlist_name, persistant_playlist)
audio_tensor = torch.from_numpy(audio_data).to(get_torch_device()) if not enable:
return (audio_tensor.unsqueeze(0).float(),) return (None,)
if not playlist_path.exists():
log.warning(f"Playlist {playlist_path} does not exist, skipping")
return (None,)
log.debug(f"Reading playlist {playlist_path}")
return (json.loads(playlist_path.read_text(encoding="utf-8")),)
class AddToPlaylist:
"""Add a video to the playlist"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"relative_paths": ("BOOLEAN", {"default": False}),
"persistant_playlist": ("BOOLEAN", {"default": False}),
"playlist_name": ("STRING", {"default": "playlist_{index:04d}"}),
"index": ("INT", {"default": 0, "min": 0}),
}
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "add_to_playlist"
CATEGORY = "mtb/IO"
def add_to_playlist(
self,
relative_paths: bool,
persistant_playlist: bool,
playlist_name: str,
index: int,
**kwargs,
):
playlist_name = playlist_name.format(index=index)
playlist_path = get_playlist_path(playlist_name, persistant_playlist)
if not playlist_path.parent.exists():
playlist_path.parent.mkdir(parents=True, exist_ok=True)
playlist = []
if not playlist_path.exists():
playlist_path.write_text("[]")
else:
playlist = json.loads(playlist_path.read_text())
log.debug(f"Playlist {playlist_path} has {len(playlist)} items")
for video in kwargs.values():
if relative_paths:
video = Path(video).relative_to(output_dir).as_posix()
log.debug(f"Adding {video} to playlist")
playlist.append(video)
log.debug(f"Writing playlist {playlist_path}")
playlist_path.write_text(json.dumps(playlist), encoding="utf-8")
return ()
class ExportWithFfmpeg: class ExportWithFfmpeg:
@@ -52,8 +110,11 @@ class ExportWithFfmpeg:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "optional": {
"images": ("IMAGE",), "images": ("IMAGE",),
"playlist": ("PLAYLIST",),
},
"required": {
# "frames": ("FRAMES",), # "frames": ("FRAMES",),
"fps": ("FLOAT", {"default": 24, "min": 1}), "fps": ("FLOAT", {"default": 24, "min": 1}),
"prefix": ("STRING", {"default": "export"}), "prefix": ("STRING", {"default": "export"}),
@@ -63,7 +124,6 @@ class ExportWithFfmpeg:
{"default": "prores_ks"}, {"default": "prores_ks"},
), ),
}, },
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
} }
RETURN_TYPES = ("VIDEO",) RETURN_TYPES = ("VIDEO",)
@@ -73,32 +133,61 @@ class ExportWithFfmpeg:
def export_prores( def export_prores(
self, self,
images: torch.Tensor,
fps: float, fps: float,
prefix: str, prefix: str,
format: str, format: str,
codec: str, codec: str,
prompt=None, images: Optional[torch.Tensor] = None,
extra_pnginfo=None, playlist: Optional[List[str]] = None,
): ):
metadata = {}
if images.size(0) == 0:
return ("",)
if extra_pnginfo is not None:
metadata["extra"] = {}
for x in extra_pnginfo:
metadata["extra"][x] = json.dumps(extra_pnginfo[x])
if prompt is not None:
metadata["prompt"] = json.dumps(prompt)
output_dir = Path(folder_paths.get_output_directory())
pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p" pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p"
file_ext = format file_ext = format
file_id = f"{prefix}_{uuid.uuid4()}.{file_ext}" file_id = f"{prefix}_{uuid.uuid4()}.{file_ext}"
log.debug(f"Exporting to {output_dir / file_id}") if playlist is not None and images is not None:
log.info(f"Exporting to {output_dir / file_id}")
if playlist is not None:
if len(playlist) == 0:
log.debug("Playlist is empty, skipping")
return ("",)
temp_playlist_path = output_dir / f"temp_playlist_{uuid.uuid4()}.txt"
log.debug(
f"Create a temporary file to list the videos for concatenation to {temp_playlist_path}"
)
with open(temp_playlist_path, "w") as f:
for video_path in playlist:
f.write(f"file '{video_path}'\n")
out_path = (output_dir / file_id).as_posix()
# Prepare the FFmpeg command for concatenating videos from the playlist
command = [
"ffmpeg",
"-f",
"concat",
"-safe",
"0",
"-i",
temp_playlist_path.as_posix(),
"-c",
"copy",
"-y",
out_path,
]
log.debug(f"Executing {command}")
subprocess.run(command)
temp_playlist_path.unlink()
return (out_path,)
if (
images is None or images.size(0) == 0
): # the is None check is just for the type checker
return ("",)
frames = tensor2np(images) frames = tensor2np(images)
log.debug(f"Frames type {type(frames[0])}") log.debug(f"Frames type {type(frames[0])}")
@@ -110,15 +199,6 @@ class ExportWithFfmpeg:
out_path = (output_dir / file_id).as_posix() out_path = (output_dir / file_id).as_posix()
metadata_cmd = []
if metadata:
for k, v in metadata.items():
metadata_cmd += [
"-metadata:s:v",
f"{k}='{v if isinstance(v,str) else json.dumps(v)}'",
]
# Prepare the FFmpeg command # Prepare the FFmpeg command
command = [ command = [
"ffmpeg", "ffmpeg",
@@ -137,7 +217,6 @@ class ExportWithFfmpeg:
"-", "-",
"-c:v", "-c:v",
codec, codec,
*metadata_cmd,
"-r", "-r",
str(fps), str(fps),
"-y", "-y",
@@ -250,4 +329,4 @@ class SaveGif:
return {"ui": {"gif": results}} return {"ui": {"gif": results}}
__nodes__ = [SaveGif, ExportWithFfmpeg, LoadAudio_] __nodes__ = [SaveGif, ExportWithFfmpeg, AddToPlaylist, ReadPlaylist]
+1 -3
View File
@@ -5,6 +5,4 @@ requirements-parser
rembg rembg
imageio_ffmpeg imageio_ffmpeg
rich rich
rich_argparse rich_argparse
librosa
torchaudio
+3 -12
View File
@@ -1,13 +1,4 @@
import contextlib import contextlib, functools, math, os, shlex, shutil, socket, subprocess, sys, uuid
import functools
import math
import os
import shlex
import shutil
import socket
import subprocess
import sys
import uuid
from pathlib import Path from pathlib import Path
from typing import List, Optional, Union from typing import List, Optional, Union
@@ -238,9 +229,9 @@ here = Path(__file__).parent.absolute()
# - Construct the absolute path to the ComfyUI directory # - Construct the absolute path to the ComfyUI directory
comfy_dir = Path(folder_paths.base_path) comfy_dir = Path(folder_paths.base_path)
models_dir = Path(folder_paths.models_dir) models_dir = Path(folder_paths.models_dir)
output_dir = Path(folder_paths.output_directory)
styles_dir = comfy_dir / "styles" styles_dir = comfy_dir / "styles"
audioInputDir = comfy_dir / "input" / "audio" session_id = str(uuid.uuid4())
# - Construct the path to the font file # - Construct the path to the font file
font_path = here / "font.ttf" font_path = here / "font.ttf"
+4
View File
@@ -13,6 +13,10 @@ data otherwise:
![debug](https://github.com/melMass/comfy_mtb/assets/7041726/1f4393e4-1c3d-4807-9501-fe8888bfae25) ![debug](https://github.com/melMass/comfy_mtb/assets/7041726/1f4393e4-1c3d-4807-9501-fe8888bfae25)
**note +**
A basic HTML note mainly to add better looking notes/instructions for workflow makers:
![image](https://github.com/melMass/comfy_mtb/assets/7041726/2ba1f832-0044-4bad-974c-e6387981af57)
## Standalone ## Standalone
These scripts can be taken and placed independently of `comfy_mtb` or any other files, mimicking what pythongosss did for their These scripts can be taken and placed independently of `comfy_mtb` or any other files, mimicking what pythongosss did for their
+40
View File
@@ -121,6 +121,9 @@ export const dynamic_connection = (
connectionType = 'PSDLAYER', connectionType = 'PSDLAYER',
nameArray = [] nameArray = []
) => { ) => {
if (!node.inputs[index].name.startsWith(connectionPrefix)) {
return
}
// remove all non connected inputs // remove all non connected inputs
if (!connected && node.inputs.length > 1) { if (!connected && node.inputs.length > 1) {
log(`Removing input ${index} (${node.inputs[index].name})`) log(`Removing input ${index} (${node.inputs[index].name})`)
@@ -331,6 +334,43 @@ function getBrightness(rgbObj) {
} }
//- HTML / CSS UTILS //- HTML / CSS UTILS
export const loadScript = (
FILE_URL,
async = true,
type = 'text/javascript'
) => {
return new Promise((resolve, reject) => {
try {
// Check if the script already exists
const existingScript = document.querySelector(`script[src="${FILE_URL}"]`)
if (existingScript) {
resolve({ status: true, message: 'Script already loaded' })
return
}
const scriptEle = document.createElement('script')
scriptEle.type = type
scriptEle.async = async
scriptEle.src = FILE_URL
scriptEle.addEventListener('load', (ev) => {
resolve({ status: true })
})
scriptEle.addEventListener('error', (ev) => {
reject({
status: false,
message: `Failed to load the script ${FILE_URL}`,
})
})
document.body.appendChild(scriptEle)
} catch (error) {
reject(error)
}
})
}
export function defineClass(className, classStyles) { export function defineClass(className, classStyles) {
const styleSheets = document.styleSheets const styleSheets = document.styleSheets
+13 -182
View File
@@ -7,6 +7,8 @@
* *
*/ */
// TODO: Use the builtin addDOMWidget everywhere appropriate
import { app } from '../../scripts/app.js' import { app } from '../../scripts/app.js'
import { api } from '../../scripts/api.js' import { api } from '../../scripts/api.js'
@@ -14,7 +16,7 @@ import parseCss from './extern/parse-css.js'
import * as shared from './comfy_shared.js' import * as shared from './comfy_shared.js'
import { log } from './comfy_shared.js' import { log } from './comfy_shared.js'
const newTypes = [, /*'BOOL'*/ 'COLOR', 'BBOX', 'AUDIO_UPLOAD'] const newTypes = [, /*'BOOL'*/ 'COLOR', 'BBOX']
const withFont = (ctx, font, cb) => { const withFont = (ctx, font, cb) => {
const oldFont = ctx.font const oldFont = ctx.font
@@ -45,54 +47,6 @@ const calculateTextDimensions = (ctx, value, width, fontSize = 16) => {
) )
return { textHeight, maxLineWidth } return { textHeight, maxLineWidth }
} }
function addPlaybackWidget(node, name, url) {
let isTick = true
const audio = new Audio(url)
const slider = node.addWidget(
'slider',
'loading',
0,
(v) => {
if (!isTick) {
audio.currentTime = v
}
isTick = false
},
{
min: 0,
max: 0,
}
)
const button = node.addWidget('button', `Play ${name}`, 'play', () => {
try {
if (audio.paused) {
audio.play()
button.name = `Pause ${name}`
} else {
audio.pause()
button.name = `Play ${name}`
}
} catch (error) {
alert(error)
}
app.canvas.setDirty(true)
})
audio.addEventListener('timeupdate', () => {
isTick = true
slider.value = audio.currentTime
app.canvas.setDirty(true)
})
audio.addEventListener('ended', () => {
button.name = `Play ${name}`
app.canvas.setDirty(true)
})
audio.addEventListener('loadedmetadata', () => {
slider.options.max = audio.duration
slider.name = `(${audio.duration})`
app.canvas.setDirty(true)
})
}
export const MtbWidgets = { export const MtbWidgets = {
BBOX: (key, val) => { BBOX: (key, val) => {
@@ -440,119 +394,6 @@ export const MtbWidgets = {
return w return w
}, },
AUDIO_UPLOAD: function (name, val) {
const w = {
name,
type: 'audio_upload',
value: val,
draw: function (ctx, node, widgetWidth, widgetY, height) {
const [cw, ch] = this.computeSize(widgetWidth)
shared.offsetDOMWidget(this, ctx, node, widgetWidth, widgetY, ch)
},
computeSize: function (width) {
if (width) {
return [width, 64]
}
return [128, 128]
},
onRemoved: function () {
if (this.inputEl) {
this.inputEl.remove()
}
},
}
const uploadFile = async (file, node) => {
try {
const body = new FormData()
body.append('name', 'loadAudio')
body.append('args', file)
const loadAudio = await api.fetchApi('/mtb/actions', {
method: 'POST',
body,
})
if (loadAudio.status === 200) {
const { result } = await loadAudio.json()
console.log('received from server', result)
console.log(
`Getting file /mtb/audio?filename=${encodeURIComponent(
result.name
)}`
)
w.value = result.name
addPlaybackWidget(
node,
result.name,
`/mtb/audio?filename=${encodeURIComponent(result.name)}`
)
} else {
alert(loadAudio.status + ' -' + loadAudio.statusText)
}
// if (resp.status === 200) {
// const { name } = await resp.json()
// pathWidget.value = name
// addPlaybackWidget(
// node,
// name,
// `/samplediffusion/audio?filename=${encodeURIComponent(name)}`
// )
// } else {
// alert(resp.status + ' - ' + resp.statusText)
// }
} catch (error) {
alert(error)
throw error
}
}
w.inputEl = document.createElement('div')
const hidden_input = document.createElement('input')
const label = document.createElement('label')
const uniqueId = 'input_' + Date.now()
Object.assign(hidden_input, {
type: 'file',
accept: 'audio/mpeg,audio/wav,audio/x-wav',
id: uniqueId,
style: `
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
`,
onchange: async () => {
if (hidden_input.files.length) {
console.log(hidden_input.files[0])
await uploadFile(hidden_input.files[0], this)
}
},
})
Object.assign(label, {
htmlFor: uniqueId,
})
label.textContent = 'Upload Audio File'
label.style = `
font-size: 1.25em;
font-weight: 700;
font-family: monospace;
padding:0.5em;
border-radius: 5px;
color: white;
background-color: #1e1e1e;
display: inline-block;
`
document.body.appendChild(w.inputEl)
w.inputEl.appendChild(hidden_input)
w.inputEl.appendChild(label)
return w
},
} }
/** /**
@@ -636,20 +477,6 @@ const mtb_widgets = {
minHeight: 30, minHeight: 30,
} }
}, },
AUDIO_UPLOAD: (node, inputName, inputData, app) => {
console.debug('Registering audio')
return {
widget: node.addCustomWidget(
MtbWidgets.AUDIO_UPLOAD.bind(node)(
inputName,
inputData[1]?.default || ''
)
),
minWidth: 150,
minHeight: 30,
}
},
// BBOX: (node, inputName, inputData, app) => { // BBOX: (node, inputName, inputData, app) => {
// console.debug("Registering bbox") // console.debug("Registering bbox")
// return { // return {
@@ -669,10 +496,6 @@ const mtb_widgets = {
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
// const rinputs = nodeData.input?.required // const rinputs = nodeData.input?.required
if (!nodeData.name.endsWith('(mtb)')) {
return
}
let has_custom = false let has_custom = false
if (nodeData.input && nodeData.input.required) { if (nodeData.input && nodeData.input.required) {
for (const i of Object.keys(nodeData.input.required)) { for (const i of Object.keys(nodeData.input.required)) {
@@ -740,6 +563,10 @@ const mtb_widgets = {
} }
} }
if (!nodeData.name.endsWith('(mtb)')) {
return
}
//- Extending Python Nodes //- Extending Python Nodes
switch (nodeData.name) { switch (nodeData.name) {
case 'Psd Save (mtb)': { case 'Psd Save (mtb)': {
@@ -1056,14 +883,17 @@ const mtb_widgets = {
break break
} }
case 'Add To Playlist (mtb)': {
shared.setupDynamicConnections(nodeType, 'video', 'VIDEO')
break
}
case 'Stack Images (mtb)': case 'Stack Images (mtb)':
case 'Concat Images (mtb)': { case 'Concat Images (mtb)': {
shared.setupDynamicConnections(nodeType, 'image', 'IMAGE') shared.setupDynamicConnections(nodeType, 'image', 'IMAGE')
break break
} }
case 'Batch Float Assemble (mtb)': case 'Batch Float Assemble (mtb)': {
case 'Plot Batch Float (mtb)': {
shared.setupDynamicConnections(nodeType, 'floats', 'FLOATS') shared.setupDynamicConnections(nodeType, 'floats', 'FLOATS')
break break
} }
@@ -1072,6 +902,7 @@ const mtb_widgets = {
break break
} }
// TODO: remove this, recommend pythongoss's version that is much better
case 'Math Expression (mtb)': { case 'Math Expression (mtb)': {
const onNodeCreated = nodeType.prototype.onNodeCreated const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
+181
View File
@@ -0,0 +1,181 @@
import { app } from '../../scripts/app.js'
import { api } from '../../scripts/api.js'
import * as shared from './comfy_shared.js'
class NotePlus extends LiteGraph.LGraphNode {
title = 'Note+ (mtb)'
category = 'mtb/utils'
constructor() {
super()
this.isVirtualNode = true
this.serialize_widgets = true
this.editing = false
this.live = true
this.rawVal = "<p style='color:red;font-family:monospace'\n> Note+\n</p>"
this.calculated_height = 36
const inner = document.createElement('div')
inner.style.margin = '0'
inner.style.padding = '0'
this.html_widget = this.addDOMWidget('HTML', 'html', inner, {
setValue: (v) => {
// update our widget preview
this.html_widget.element.innerHTML = v
// calculate height
this.calculated_height = this.html_widget.element.scrollHeight + 36
},
getValue: () => this.rawVal,
getMinHeight: () => this.calculated_height, // (the edit button),
})
// console.log(`Value of HTML: ${this.html_widget.value}`)
this.html_widget.element.innerHTML = this.html_widget.value
//- ace based editor
this.addWidget('button', 'Edit', 'Edit', () => {
const container = document.createElement('div')
Object.assign(container.style, {
display: 'flex',
gap: '10px',
})
dialog.show('')
dialog.textElement.append(container)
const value = document.createElement('div')
value.id = 'noteplus-editor'
Object.assign(value.style, {
width: '300px',
height: '200px',
backgroundColor: 'rgb(30,30,30)',
color: 'whitesmoke',
})
container.append(value)
const live_edit = document.createElement('input')
live_edit.type = 'checkbox'
live_edit.checked = this.live
live_edit.onchange = () => {
this.live = live_edit.checked
}
const live_edit_label = document.createElement('label')
live_edit_label.textContent = 'Live Edit'
live_edit_label.append(live_edit)
value.after(live_edit_label)
this.setupEditor()
this.editor.setValue(this.html_widget.element.innerHTML)
})
const dialog = new app.ui.dialog.constructor()
dialog.element.classList.add('comfy-settings')
const closeButton = dialog.element.querySelector('button')
closeButton.textContent = 'CANCEL'
const saveButton = document.createElement('button')
saveButton.textContent = 'SAVE'
saveButton.onclick = () => {
this.updateHTML(this.editor.getValue())
this.editor.destroy()
this.editor.container.remove()
dialog.close()
}
closeButton.before(saveButton)
shared
.loadScript(
'https://cdn.jsdelivr.net/npm/ace-builds@1.16.0/src-min-noconflict/ace.min.js'
)
.catch((e) => {
console.error(e)
})
}
setupEditor() {
this.editor = ace.edit('noteplus-editor')
this.editor.setTheme('ace/theme/dracula')
this.editor.session.setMode('ace/mode/html')
this.editor.setShowPrintMargin(false)
this.editor.session.setUseWrapMode(true)
this.editor.renderer.setShowGutter(false)
this.editor.session.setTabSize(4)
this.editor.session.setUseSoftTabs(true)
this.editor.setFontSize(14)
this.editor.setReadOnly(false)
this.editor.setHighlightActiveLine(false)
this.editor.setShowFoldWidgets(true)
this.editor.session.on('change', (delta) => {
// delta.start, delta.end, delta.lines, delta.action
if (this.live) {
this.updateHTML(this.editor.getValue())
}
})
}
updateHTML(val) {
// if (CONTAINER_HTML.includes('${html}')) {
// console.log('found template')
// val = CONTAINER_HTML.replace('${html}', val)
// }
this.html_widget.value = val
this.rawVal = val
this.calculated_height = this.html_widget.element.scrollHeight
this.setSize(this.computeSize())
}
// // onRemoved() {
// // console.log('Removing', this)
// // for (const w of this.widgets) {
// // console.log('Removing', w)
// // w.onRemove?.()
// // w.onRemoved?.()
// // }
// // }
}
app.registerExtension({
name: 'mtb.noteplus',
setup() {
// app.ui.settings.addSetting({
// id: "mtb.noteplus.Container",
// name: "📦 HTML container",
// type: "text",
// defaultValue: "<div>${html}</div>",
// tooltip:
// "This defines the wrapper for the noteplus html content, use '${html}' to define the location of the placeholder",
// attrs: {
// style: {
// fontFamily: "monospace",
// },
// },
// onChange(value) {
// if (!value) {
// CONTAINER_HTML = null;
// return;
// }
// console.log(`NOTEPLUS| value changed: ${value}`)
// CONTAINER_HTML = value
// },
// });
},
registerCustomNodes() {
LiteGraph.registerNodeType('Note Plus (mtb)', NotePlus)
},
})