Compare commits

..
Author SHA1 Message Date
Mel Massadian 7fb27804e1 chore!: 🧹 bump version 2025-05-23 01:34:56 +02:00
Mel Massadian 9a7e022df1 chore!: 🧹 bump version 2025-05-22 23:00:01 +02:00
Mel Massadian 2c483fd1d2 ci: 🤖 finally fix the registry issue
The upstream action was overwritting the checkout: https://github.com/Comfy-Org/publish-node-action/blob/d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9/action.yml#L16
2025-05-22 22:58:10 +02:00
Mel Massadian 0967d439f5 chore!: 🧹 bump version
closes #230
2025-05-22 22:02:39 +02:00
Mel Massadian 319c02d658 fix: 🐛 ascii encoding only for whisper chunks
fixes #251
2025-05-22 21:52:44 +02:00
Mel Massadian 265cb953ec feat: ✨ rework extract points
Make use of both inputs if provided, more efficient point drawing
2025-05-18 20:56:41 +02:00
11 changed files with 332 additions and 836 deletions
+7
View File
@@ -0,0 +1,7 @@
**/GFPGAN/inputs/**
**/GFPGAN/tests/**
**/frame_interpolation/photos/*
moment.gif
node.zip
.DS_Store
+1
View File
@@ -21,4 +21,5 @@ jobs:
- name: 📦 Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
skip_checkout: "true"
personal_access_token: ${{ secrets.COMFY_REGISTRY_TOKEN }}
+4
View File
@@ -1,11 +1,15 @@
__pycache__
*.py[cod]
*.onnx
wheels/
node_modules/
compose.yaml
comfy_mtb.wsb
Dockerfile
.DS_Store
node.zip
# I store the gh-pages worktrees (src & build) there
.worktrees
+46 -158
View File
@@ -3,15 +3,13 @@
# File: __init__.py
# Project: comfy_mtb
# Author: Mel Massadian
# Copyright (c) 2023 Mel Massadian
# Copyright (c) 2023-2025 Mel Massadian
#
###
__version__ = "0.3.0"
__version__ = "0.5.4"
import os
from collections import OrderedDict
from typing import Any
from aiohttp.web_request import Request
@@ -249,7 +247,7 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
with contextlib.suppress(ImportError):
from cachetools import TTLCache
# img_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
img_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
prompt_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
node_dependency_mapping = get_node_dependencies()
@@ -364,132 +362,29 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
import asyncio
import os
import time
from asyncio import Semaphore
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from io import BytesIO
from aiohttp import web
from PIL import Image
image_thread_pool = ThreadPoolExecutor(
max_workers=4, thread_name_prefix="img_worker"
)
@asynccontextmanager
async def get_image_with_timeout(
file_path, preview_params=None, channel=None, timeout=10
):
try:
result = await asyncio.wait_for(
asyncio.get_event_loop().run_in_executor(
image_thread_pool,
get_cached_image,
file_path,
preview_params,
channel,
),
timeout=timeout,
)
yield result
except asyncio.TimeoutError:
print(f"Image processing timed out for {file_path}")
raise
except Exception as e:
print(f"Error processing image {file_path}: {str(e)}")
raise
async def get_image_response(
file, filename: str, preview_info=None, channel=None
):
try:
async with get_image_with_timeout(
file, preview_info, channel
) as img:
return web.Response(
body=img,
content_type="image/webp" if preview_info else "image/png",
headers={"Content-Disposition": f'filename="{filename}"'},
)
except asyncio.TimeoutError:
return web.Response(status=504, text="Image processing timed out")
except Exception as e:
return web.Response(status=500, text=str(e))
class LRUCache:
def __init__(self, capacity: int):
self.cache = OrderedDict()
self.capacity = capacity
def get(self, key) -> Any:
if key not in self.cache:
return None
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key, value: Any) -> None:
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False)
img_cache = LRUCache(capacity=100)
def get_cached_image(file_path: str, preview_params=None, channel=None):
cache_key = (file_path, preview_params, channel)
try:
if img_cache and (cache_key in img_cache):
return img_cache[cache_key]
with Image.open(file_path) as img:
info = img.info
if preview_params:
img = process_preview(img, preview_params)
if channel:
img = process_channel(img, channel)
if prompt_cache:
prompt_cache[cache_key] = info
if img_cache:
cached_value = img_cache.get(cache_key)
if cached_value is not None:
return cached_value
img_cache[cache_key] = img.getvalue()
return img_cache[cache_key]
with Image.open(file_path) as img:
info = img.info
if preview_params:
img = process_preview(img, preview_params)
if channel:
img = process_channel(img, channel)
result = img.getvalue()
try:
if prompt_cache:
prompt_cache[cache_key] = info
if img_cache:
img_cache.put(cache_key, result)
except Exception as e:
print(
f"Warning: Failed to cache image {file_path}: {str(e)}"
)
return result
except Exception as e:
print(f"Error processing image {file_path}: {str(e)}")
raise
class RateLimiter:
def __init__(self, requests_per_second):
self.requests_per_second = requests_per_second
self.semaphore = Semaphore(requests_per_second)
self.timestamps = []
async def acquire(self):
await self.semaphore.acquire()
now = time.time()
self.timestamps.append(now)
# Remove old timestamps
self.timestamps = [t for t in self.timestamps if now - t < 1.0]
if len(self.timestamps) >= self.requests_per_second:
await asyncio.sleep(1.0)
def release(self):
self.semaphore.release()
rate_limiter = RateLimiter(requests_per_second=10)
return img.getvalue()
def process_preview(img: Image.Image, preview_params):
image_format, quality, width = preview_params
@@ -542,44 +437,41 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
# to load workflows in the sidebar
@PromptServer.instance.routes.get("/mtb/view")
async def view_image(request: Request):
try:
import folder_paths
import folder_paths
await rate_limiter.acquire()
filename = request.rel_url.query.get("filename")
if not filename:
return web.Response(status=404)
filename = request.rel_url.query.get("filename")
if not filename:
return web.Response(status=404)
filename, output_dir = folder_paths.annotated_filepath(filename)
if filename[0] == "/" or ".." in filename:
return web.Response(status=400)
filename, output_dir = folder_paths.annotated_filepath(filename)
if filename[0] == "/" or ".." in filename:
return web.Response(status=400)
if output_dir is None:
rtype = request.rel_url.query.get("type", "output")
output_dir = folder_paths.get_directory_by_type(rtype)
if output_dir is None:
rtype = request.rel_url.query.get("type", "output")
output_dir = folder_paths.get_directory_by_type(rtype)
if output_dir is None:
return web.Response(status=400)
if output_dir is None:
return web.Response(status=400)
if "subfolder" in request.rel_url.query:
full_output_dir = os.path.join(
output_dir, request.rel_url.query["subfolder"]
if "subfolder" in request.rel_url.query:
full_output_dir = os.path.join(
output_dir, request.rel_url.query["subfolder"]
)
if (
os.path.commonpath(
(os.path.abspath(full_output_dir), output_dir)
)
if (
os.path.commonpath(
(os.path.abspath(full_output_dir), output_dir)
)
!= output_dir
):
return web.Response(status=403)
output_dir = full_output_dir
!= output_dir
):
return web.Response(status=403)
output_dir = full_output_dir
filename = os.path.basename(filename)
file = os.path.join(output_dir, filename)
filename = os.path.basename(filename)
file = os.path.join(output_dir, filename)
if not os.path.isfile(file):
return web.Response(status=404)
if not os.path.isfile(file):
return web.Response(status=404)
ret_workflow = request.rel_url.query.get("workflow")
@@ -617,13 +509,9 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
width = request.rel_url.query.get("width")
preview_info = (image_format, quality, width)
channel = request.rel_url.query.get("channel")
channel = request.rel_url.query.get("channel")
return await get_image_response(
file, filename, preview_info, channel
)
finally:
rate_limiter.release()
return await get_image_response(file, filename, preview_info, channel)
@PromptServer.instance.routes.get("/mtb/server-info")
async def get_debug(request: Request):
+1 -6
View File
@@ -74,12 +74,7 @@ def ACTIONS_getUserImageFolders():
input_subdirs = [x.name for x in input_dir.iterdir() if x.is_dir()]
output_subdirs = [x.name for x in output_dir.iterdir() if x.is_dir()]
return {
"input_root": input_dir.as_posix(),
"input": input_subdirs,
"output": output_subdirs,
"output_root": output_dir.as_posix(),
}
return {"input": input_subdirs, "output": output_subdirs}
def ACTIONS_getUserVideos(
+4 -4
View File
@@ -343,9 +343,7 @@ by default it fallsback to a default font.
def render_text(text_to_render, alpha=None):
if trim:
text_to_render = (
text_to_render.encode("ascii", "ignore").decode().strip()
)
text_to_render = text_to_render.strip()
if wrap:
wrap_width = (((width / 100) * h_coverage) / font_size) * 2
lines = textwrap.wrap(text_to_render, width=wrap_width)
@@ -418,7 +416,9 @@ by default it fallsback to a default font.
active_chunks.append((chunk["text"], alpha))
for chunk_text, alpha in active_chunks:
chunk_img = render_text(chunk_text, alpha)
chunk_img = render_text(
chunk_text.encode("ascii", "ignore").decode(), alpha
)
frame = Image.alpha_composite(frame, chunk_img)
frames.append(frame)
+103 -47
View File
@@ -3,11 +3,12 @@ import json
import math
import os
import comfy.model_management as model_management
import comfy.utils
import folder_paths
import numpy as np
import torch
import torch.nn.functional as F
from comfy import model_management
from PIL import Image, ImageOps
from PIL.PngImagePlugin import PngInfo
from skimage.filters import gaussian
@@ -74,7 +75,10 @@ class MTB_ExtractCoordinatesFromImage:
def INPUT_TYPES(cls):
return {
"required": {
"threshold": ("FLOAT",),
"threshold": (
"FLOAT",
{"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01},
),
"max_points": ("INT", {"default": 50, "min": 0}),
},
"optional": {"image": ("IMAGE",), "mask": ("MASK",)},
@@ -87,72 +91,124 @@ class MTB_ExtractCoordinatesFromImage:
image: torch.Tensor | None = None,
mask: torch.Tensor | None = None,
) -> tuple[list[list[tuple[int, int]]], torch.Tensor]:
if image is not None:
batch_count, height, width, channel_count = image.shape
imgs = image
else:
if mask is None:
raise ValueError("Must provide either image or mask")
batch_count, height, width = mask.shape
channel_count = 1
imgs = mask
if image is None and mask is None:
raise ValueError("Must provide either image or mask")
if channel_count not in [1, 2, 3, 4]:
raise ValueError(f"Incorrect channel count: {channel_count}")
if image is not None:
batch_count, height, width, _channel_count = image.shape
input_device = image.device
if mask is not None:
if mask.ndim == 2:
mask = mask.unsqueeze(0)
if mask.ndim != 3:
raise ValueError(
f"Mask has unexpected ndim: {mask.ndim}. Expected 2 or 3."
)
b_mask, h_mask, w_mask = mask.shape
if not (h_mask == height and w_mask == width):
raise ValueError(
f"Image dimensions ({height}x{width}) and mask dimensions ({h_mask}x{w_mask}) are spatially incompatible."
)
if b_mask == 1 and batch_count > 1:
mask = mask.expand(batch_count, height, width)
elif b_mask != batch_count:
raise ValueError(
f"Image batch size ({batch_count}) and mask batch size ({b_mask}) are incompatible and mask cannot be broadcast."
)
else:
if mask.ndim == 2:
mask = mask.unsqueeze(0)
if mask.ndim != 3:
raise ValueError(
f"Mask has unexpected ndim: {mask.ndim} when image is not provided. Expected 2 or 3."
)
batch_count, height, width = mask.shape
input_device = mask.device
all_points: list[list[tuple[int, int]]] = []
debug_images = torch.zeros(
(batch_count, height, width, 3),
dtype=torch.uint8,
device=imgs.device,
device=input_device,
)
for i, img in enumerate(imgs):
if channel_count == 1:
alpha_channel = img if len(img.shape) == 2 else img[:, :, 0]
elif channel_count == 2:
alpha_channel = img[:, :, 1]
elif channel_count == 4:
alpha_channel = img[:, :, 3]
points_tensor = torch.tensor(
[255, 255, 255], dtype=torch.uint8, device=input_device
)
for i in range(batch_count):
value_threshold: torch.Tensor
if image is not None:
img_slice = image[i]
img_channels = img_slice.shape[2]
if img_channels == 1 or img_channels == 2:
value_threshold = img_slice[:, :, 0]
elif img_channels == 3 or img_channels == 4:
value_threshold = img_slice[:, :, :3].max(dim=2)[0]
else:
raise ValueError(
f"Unsupported image channel count: {img_channels} for image at batch index {i}"
)
else:
# get intensity
alpha_channel = img[:, :, :3].max(dim=2)[0]
mask_slice = mask[i]
value_threshold = mask_slice
points = (alpha_channel > threshold).nonzero(as_tuple=False)
condition = value_threshold > threshold
if image is not None and mask is not None:
mask_slice = mask[i]
mask_active_condition = mask_slice > 0.0
condition = condition & mask_active_condition
if len(points) > max_points:
indices = torch.randperm(points.size(0), device=img.device)[
:max_points
]
points = points[indices]
points_yx = condition.nonzero(as_tuple=False)
points = [(int(y.item()), int(x.item())) for x, y in points]
all_points.append(points)
if points_yx.size(0) > max_points:
# shuffle and pick max_points randomly
indices = torch.randperm(
points_yx.size(0), device=input_device
)[:max_points]
points_yx = points_yx[indices]
elif max_points == 0:
points_yx = torch.empty(
(0, 2), dtype=torch.long, device=input_device
)
for x, y in points:
self._draw_circle(debug_images[i], (x, y), 5)
current_points = [
(int(p[1].item()), int(p[0].item())) for p in points_yx
]
all_points.append(current_points)
for x_coord, y_coord in current_points:
self._draw_circle(
debug_images[i],
(x_coord, y_coord),
radius=5,
color_tensor=points_tensor,
)
return (all_points, debug_images)
@staticmethod
def _draw_circle(
image: torch.Tensor, center: tuple[int, int], radius: int
image: torch.Tensor,
center: tuple[int, int],
radius: int,
color_tensor: torch.Tensor,
):
"""Draw a 5px circle on the image."""
x0, y0 = center
for x in range(-radius, radius + 1):
for y in range(-radius, radius + 1):
in_radius = x**2 + y**2 <= radius**2
in_bounds = (
0 <= x0 + x < image.shape[1]
and 0 <= y0 + y < image.shape[0]
)
if in_radius and in_bounds:
image[y0 + y, x0 + x] = torch.tensor(
[255, 255, 255],
dtype=torch.uint8,
device=image.device,
)
h, w, _ = image.shape
min_x_bbox = max(0, x0 - radius)
max_x_bbox = min(w - 1, x0 + radius)
min_y_bbox = max(0, y0 - radius)
max_y_bbox = min(h - 1, y0 + radius)
for py in range(min_y_bbox, max_y_bbox + 1):
for px in range(min_x_bbox, max_x_bbox + 1):
if (px - x0) ** 2 + (py - y0) ** 2 <= radius**2:
image[py, px] = color_tensor
class MTB_ColorCorrectGPU:
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "comfy-mtb"
version = "0.3.0"
version = "0.5.4"
description = "Animation oriented nodes pack for ComfyUI."
license = { text = "MIT" }
readme = "README.md"
@@ -63,7 +63,7 @@ DisplayName = "comfy-mtb"
Icon = "https://avatars.githubusercontent.com/u/7041726?v=4"
[tool.bumpversion]
current_version = "0.3.0"
current_version = "0.5.1"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
-69
View File
@@ -12,9 +12,6 @@
import { app } from '../../scripts/app.js'
import { api } from '../../scripts/api.js'
if (!window.MTB) {
window.MTB = {}
}
// #region base utils
// - crude uuid
@@ -279,10 +276,6 @@ export const getNamedWidget = (node, ...names) => {
* @returns {{to:LGraphNode, from:LGraphNode, type:'error' | 'incoming' | 'outgoing'}}
*/
export const nodesFromLink = (node, link) => {
if (typeof link === 'number') {
console.log('Resolving link from id', link)
link = app.graph.links[link]
}
const fromNode = app.graph.getNodeById(link.origin_id)
const toNode = app.graph.getNodeById(link.target_id)
@@ -1075,66 +1068,6 @@ export const addDocumentation = (
// #endregion
// #region canvas / drawing
// calculate convex hull (Graham)
export function getConvexHull(points) {
if (points.length < 3) return points
// find the bottommost point (and leftmost if tied)
let bottom = 0
for (let i = 1; i < points.length; i++) {
if (
points[i][1] < points[bottom][1] ||
(points[i][1] === points[bottom][1] && points[i][0] < points[bottom][0])
) {
bottom = i
}
}
// swap bottom point to first position
;[points[0], points[bottom]] = [points[bottom], points[0]]
// sort points by polar angle with respect to base point
const basePoint = points[0]
points.sort((a, b) => {
if (a === basePoint) return -1
if (b === basePoint) return 1
const angleA = Math.atan2(a[1] - basePoint[1], a[0] - basePoint[0])
const angleB = Math.atan2(b[1] - basePoint[1], b[0] - basePoint[0])
if (angleA < angleB) return -1
if (angleA > angleB) return 1
// if angles are equal, sort by distance
const distA = (a[0] - basePoint[0]) ** 2 + (a[1] - basePoint[1]) ** 2
const distB = (b[0] - basePoint[0]) ** 2 + (b[1] - basePoint[1]) ** 2
return distA - distB
})
// build convex hull
const stack = [points[0], points[1]]
for (let i = 2; i < points.length; i++) {
while (
stack.length > 1 &&
!isLeftTurn(stack[stack.length - 2], stack[stack.length - 1], points[i])
) {
stack.pop()
}
stack.push(points[i])
}
return stack
}
function isLeftTurn(p1, p2, p3) {
return (
(p2[0] - p1[0]) * (p3[1] - p1[1]) - (p2[1] - p1[1]) * (p3[0] - p1[0]) > 0
)
}
// #endregion
// #region node extensions
/**
@@ -1209,8 +1142,6 @@ export const runAction = async (name, ...args) => {
const res = await req.json()
return res.result
}
window.MTB.run = runAction
export const getServerInfo = async () => {
const res = await api.fetchApi('/mtb/server-info')
return await res.json()
+164 -465
View File
@@ -2,7 +2,6 @@
import { app } from '../../scripts/app.js'
import { api } from '../../scripts/api.js'
import { infoLogger, successLogger, errorLogger } from './comfy_shared.js'
import * as shared from './comfy_shared.js'
@@ -13,140 +12,32 @@ import {
makeSelect,
makeSlider,
renderSidebar,
ContextMenu,
} from './mtb_ui.js'
let currentAbortController = null
/** cursor/offset of where we are at */
const offset = 0
/** width of the images in the grid */
let currentWidth = 200
let currentMode = 'input'
let subfolder = ''
let currentSort = 'None'
let clientOnce = false
/** reference to the dom element receiving the images */
let imgGrid = undefined
/** currently loaded image (as object urls) */
let loaded_images = undefined
/**
* stores the user's full local path to input/output directory
* This is then used to feed VHS Load Image (from path)
*/
let userDirectories = undefined
// const IMAGE_NODES = ['LoadImage', 'VHS_LoadImagePath']
const IMAGE_NODES = ['LoadImage', 'VHS_LoadImagePath']
const VIDEO_NODES = ['VHS_LoadVideo']
const PROCESSED_PROMPT_IDS = new Set()
let contextMenu = undefined
function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
infoLogger('Debouncing method')
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
const debouncedGetUrls = async (ms = 250) => {
if (loaded_images === undefined) {
return await getUrls(subfolder)
}
debounce(async (subfolder) => {
const urls = await getUrls(subfolder)
infoLogger('Loaded URLs (debounced): ', urls)
if (urls) {
loaded_images = await getImgsFromUrls(urls, imgGrid)
infoLogger('Loaded Images (debounced): ', loaded_images)
}
}, ms)
return loaded_images
}
/** Callback on clicking an image in the grid */
const updateImage = (node, image) => {
switch (node.type) {
case 'LoadImage': {
if (subfolder && subfolder !== '') {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'Subfolder not supported',
detail: "The LoadImage node doesn't support subfolders",
life: 5000,
})
return
}
if (currentMode === 'output') {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'Outputs not supported',
detail:
"The LoadImage node doesn't support loading outputs, use VHS Load Image Path and I'll resolve the full path.",
life: 5000,
})
return
}
// if (IMAGE_NODES.includes(node.type)) {
const w = node.widgets?.find((w) => w.name === 'image')
if (w) {
w.value = image
w.callback()
}
//}
break
if (IMAGE_NODES.includes(node.type)) {
const w = node.widgets?.find((w) => w.name === 'image')
if (w) {
w.value = image
w.callback()
}
case 'VHS_LoadImagePath': {
let value = image
if (!userDirectories?.output) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'User output directory not resolved',
detail: "We couldn't resolve the image full path.",
life: 5000,
})
return
}
if (subfolder && subfolder !== '') {
value = `${subfolder}/${image}`
}
value = `${userDirectories.output}/${value}`
const w = node.widgets?.find((w) => w.name === 'image')
if (w) {
console.log(w)
w.value = value
// TODO: VHS needs explicity value passsed here
w.callback(value)
}
break
}
case VIDEO_NODES.includes(node.type): {
const w = node.widgets?.find((w) => w.name === 'video')
if (w) {
node.updateParameters({ filename: image }, true)
}
break
}
default: {
console.warn('No method to update', node.type)
} else if (VIDEO_NODES.includes(node.type)) {
const w = node.widgets?.find((w) => w.name === 'video')
if (w) {
node.updateParameters({ filename: image }, true)
}
} else {
console.warn('No method to update', node.type)
}
}
@@ -218,265 +109,94 @@ const updateOutputsGrid = async () => {
}
const getImgsFromUrls = (urls, target, options = { prepend: false }) => {
if (currentAbortController) {
currentAbortController.abort()
}
infoLogger('getting images from urls', urls)
currentAbortController = new AbortController()
const { signal } = currentAbortController
const imgs = []
if (!urls) return imgs
const loadingIndicator = document.createElement('div')
loadingIndicator.className = 'mtb-loading-indicator'
if (target) target.appendChild(loadingIndicator)
const totalImages = Object.keys(urls).length
let loadedCount = 0
const updateLoadingStatus = () => {
loadingIndicator.textContent = `Loaded ${loadedCount} of ${totalImages} images`
if (urls === undefined) {
return imgs
}
updateLoadingStatus()
const elem = currentMode === 'video' ? 'video' : 'img'
try {
const loadImage = async (key, url) => {
try {
const response = await fetch(url, { signal })
if (!response.ok) {
console.warn(`Failed to fetch ${key}: ${response.status}`)
return null
}
// throw new Error(`HTTP error! status: ${response.status}`)
const blob = await response.blob()
const imgUrl = URL.createObjectURL(blob)
const elem = makeElement(currentMode === 'video' ? 'video' : 'img')
elem.src = imgUrl
elem.width = currentWidth
// cleanup
elem.onload = () => URL.revokeObjectURL(imgUrl)
elem.onerror = () => URL.revokeObjectURL(imgUrl)
// Add click handler for input mode
// if (currentMode === 'input') {
// elem.onclick = (_e) => {
// Your existing click handler code
// }
// }
// Add context menu
elem.addEventListener('contextmenu', (e) => {
e.preventDefault()
const contextMenuItems = [
{
label: 'Add Node with Image',
icon: '🖼',
action: () => {
const node = app.graph.createNode('LoadImage')
updateImage(node, key)
},
},
{
label: 'Load Workflow from Image',
icon: '📋',
action: async () => {
try {
const response = await fetch(url)
const data = await response.blob()
// Assuming you have a function to extract workflow from image metadata
const workflow = await extractWorkflowFromImage(data)
if (workflow) {
app.loadGraphData(workflow)
}
} catch (error) {
app.extensionManager.toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to load workflow from image',
life: 3000,
})
}
},
},
{
label: 'View Full Image',
icon: '🔍',
action: () => {
window.open(url, '_blank')
},
},
]
contextMenu.show(e.pageX, e.pageY, contextMenuItems, {
elem,
key,
url,
for (const [key, url] of Object.entries(urls)) {
const a = makeElement(elem)
a.src = url
a.width = currentWidth
if (currentMode === 'input') {
a.onclick = (_e) => {
if (subfolder !== '') {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'Subfolder not supported',
detail: "The LoadImage node doesn't support subfolders",
life: 5000,
})
return
}
const selected = app.canvas.selected_nodes
if (selected && Object.keys(selected).length === 0) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No node selected!',
detail:
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
life: 5000,
})
return
}
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
updateImage(node, key)
}
}
} else if (currentMode === 'output') {
a.onclick = (_e) => {
// window.MTB?.notify?.("Output import isn't supported yet...", 5000)
if (subfolder !== '') {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'Subfolder not supported',
detail: "The LoadImage node doesn't support subfolders",
life: 5000,
})
return
}
app.extensionManager.toast.add({
severity: 'warn',
summary: 'Outputs not supported',
detail:
'For now only inputs can be clicked to load the image on the active LoadImage node.',
life: 5000,
})
}
} else {
a.autoplay = true
elem.onclick = (_e) => {
const selected = app.canvas.selected_nodes
if (!selected || Object.keys(selected).length === 0) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No node selected!',
detail: 'Please select a node first.',
life: 5000,
})
return
}
for (const [_id, node] of Object.entries(selected)) {
updateImage(node, key)
}
a.muted = true
a.loop = true
a.onclick = (_e) => {
const selected = app.canvas.selected_nodes
if (selected && Object.keys(selected).length === 0) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No node selected!',
detail:
"For now the only action when clicking videos in the sidebar is to set the video on all selected 'Load Video (Upload)' nodes.",
life: 5000,
})
return
}
loadedCount++
updateLoadingStatus()
return elem
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted')
return null
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
updateImage(node, key)
}
console.error('Error loading image:', error)
return null
}
}
const BATCH_SIZE = 20
for (let i = 0; i < Object.entries(urls).length; i += BATCH_SIZE) {
const batch = Object.entries(urls).slice(i, i + BATCH_SIZE)
const loadedImages = await Promise.all(
batch.map(([key, url]) => loadImage(key, url)),
)
const validImages = loadedImages.filter((img) => img !== null)
imgs.push(...validImages)
if (target) {
target.append(...validImages)
}
}
return imgs
// return
// const elem = currentMode === 'video' ? 'video' : 'img'
for (const [key, url] of Object.entries(urls)) {
const a = makeElement(elem)
a.src = url
a.width = currentWidth
const selected = app.canvas.selected_nodes
if (currentMode === 'input') {
a.onclick = (_e) => {
// if (subfolder !== '') {
// app.extensionManager.toast.add({
// severity: 'warn',
// summary: 'Subfolder not supported',
// detail: "The LoadImage node doesn't support subfolders",
// life: 5000,
// })
// return
// }
if (selected && Object.keys(selected).length === 0) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No node selected!',
detail:
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
life: 5000,
})
return
}
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
updateImage(node, key)
}
}
} else if (currentMode === 'output') {
a.onclick = (_e) => {
if (selected && Object.keys(selected).length === 0) {
return
}
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
updateImage(node, key)
}
// window.MTB?.notify?.("Output import isn't supported yet...", 5000)
// if (subfolder !== '') {
// app.extensionManager.toast.add({
// severity: 'warn',
// summary: 'Subfolder not supported',
// detail: "The LoadImage node doesn't support subfolders",
// life: 5000,
// })
// return
// }
//
// app.extensionManager.toast.add({
// severity: 'warn',
// summary: 'Outputs not supported',
// detail:
// 'For now only inputs can be clicked to load the image on the active LoadImage node.',
// life: 5000,
// })
}
} else {
a.autoplay = true
a.muted = true
a.loop = true
a.onclick = (_e) => {
const selected = app.canvas.selected_nodes
if (selected && Object.keys(selected).length === 0) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No node selected!',
detail:
"For now the only action when clicking videos in the sidebar is to set the video on all selected 'Load Video (Upload)' nodes.",
life: 5000,
})
return
}
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
updateImage(node, key)
}
}
}
imgs.push(a)
}
if (target !== undefined) {
if (options.prepend) target.prepend(...imgs)
imgs.push(a)
}
if (target !== undefined) {
if (options.prepend) target.prepend(...imgs)
else target.append(...imgs)
}
return imgs
} finally {
// Keep loading indicator visible for a moment after completion
setTimeout(() => {
if (target && loadingIndicator.parentNode === target) {
loadingIndicator.remove()
}
}, 2000)
}
}
// Helper function to extract workflow from image metadata
async function extractWorkflowFromImage(blob) {
// Implementation depends on how the workflow data is stored in the image
// This is just a placeholder
try {
// You might need to use ExifReader or similar library to extract metadata
return null
} catch (error) {
console.error('Failed to extract workflow:', error)
return null
}
return imgs
}
const getModes = async () => {
@@ -485,7 +205,7 @@ const getModes = async () => {
}
const getUrls = async (subfolder) => {
const count = (await api.getSetting('mtb.io-sidebar.count')) || 1000
console.debug('Sidebar count', count)
console.log('Sidebar count', count)
if (currentMode === 'video') {
const output = await shared.runAction(
'getUserVideos',
@@ -508,99 +228,6 @@ const getUrls = async (subfolder) => {
return output || {}
}
const build_ui = async (el) => {
if (el.parentNode) {
el.parentNode.style.overflowY = 'clip'
}
const allModes = await getModes()
const input_modes = allModes.input.map((m) => `input - ${m}`)
const output_modes = allModes.output.map((m) => `output - ${m}`)
if (!userDirectories) {
userDirectories = {
input: allModes.input_root,
output: allModes.output_root,
}
infoLogger('User directories', userDirectories)
}
// const urls = await getUrls()
// const urls = await debouncedGetUrls(subfolder)
const cont = makeElement('div.mtb_sidebar')
contextMenu = new ContextMenu(cont)
imgGrid = makeElement('div.mtb_img_grid')
const selector = makeSelect(
['input', 'output', 'video', ...output_modes, ...input_modes],
currentMode,
)
selector.addEventListener('change', async (e) => {
let newMode = e.target.value
let changed = false
let newSub = ''
if (newMode !== 'input' && newMode !== 'output') {
if (newMode.startsWith('input - ')) {
newSub = newMode.replace('input - ', '')
newMode = 'input'
} else if (newMode.startsWith('output - ')) {
newSub = newMode.replace('output - ', '')
newMode = 'output'
}
}
changed = newMode !== currentMode || newSub !== subfolder
currentMode = newMode
subfolder = newSub
if (changed) {
imgGrid.innerHTML = ''
// const urls = await getUrls(subfolder)
debouncedGetUrls(subfolder)
// if (urls) {
// loaded_images = getImgsFromUrls(urls, imgGrid)
// }
}
})
const imgTools = makeElement('div.mtb_tools')
const orderSelect = makeSelect(
['None', 'Modified', 'Modified-Reverse', 'Name', 'Name-Reverse'],
currentSort,
)
orderSelect.addEventListener('change', async (e) => {
const newSort = e.target.value
const changed = newSort !== currentSort
currentSort = newSort
if (changed) {
imgGrid.innerHTML = ''
// const urls = await getUrls(subfolder)
// const urls = debouncedGetUrls(subfolder)
// const urls = await getUrls(subfolder)
debouncedGetUrls(subfolder)
// if (urls) {
// loaded_images = getImgsFromUrls(urls, imgGrid)
// }
}
})
const sizeSlider = makeSlider(64, 1024, currentWidth, 1)
imgTools.appendChild(orderSelect)
imgTools.appendChild(sizeSlider)
loaded_images = getImgsFromUrls(urls, imgGrid)
// infoLogger({ loaded_images })
sizeSlider.addEventListener('input', (e) => {
currentWidth = e.target.value
for (const img of loaded_images) {
img.style.width = `${e.target.value}px`
}
})
handle = renderSidebar(el, cont, [selector, imgGrid, imgTools])
}
//NOTE: do not load if using the old ui
if (window?.__COMFYUI_FRONTEND_VERSION__) {
// NOTE: removed this for now since I'm not actually exposing anything a client
@@ -620,8 +247,8 @@ if (window?.__COMFYUI_FRONTEND_VERSION__) {
// },
init: () => {
let handle
// const version = window?.__COMFYUI_FRONTEND_VERSION__
// console.log(`%c ${version}`, 'background: orange; color: white;')
const version = window?.__COMFYUI_FRONTEND_VERSION__
console.log(`%c ${version}`, 'background: orange; color: white;')
ensureMTBStyles()
@@ -693,9 +320,81 @@ if (window?.__COMFYUI_FRONTEND_VERSION__) {
handle = undefined
}
if (!loaded_images) {
await build_ui(el)
if (el.parentNode) {
el.parentNode.style.overflowY = 'clip'
}
const allModes = await getModes()
const input_modes = allModes.input.map((m) => `input - ${m}`)
const output_modes = allModes.output.map((m) => `output - ${m}`)
const urls = await getUrls()
let imgs = {}
const cont = makeElement('div.mtb_sidebar')
const imgGrid = makeElement('div.mtb_img_grid')
const selector = makeSelect(
['input', 'output', 'video', ...output_modes, ...input_modes],
currentMode,
)
selector.addEventListener('change', async (e) => {
let newMode = e.target.value
let changed = false
let newSub = ''
if (newMode !== 'input' && newMode !== 'output') {
if (newMode.startsWith('input - ')) {
newSub = newMode.replace('input - ', '')
newMode = 'input'
} else if (newMode.startsWith('output - ')) {
newSub = newMode.replace('output - ', '')
newMode = 'output'
}
}
changed = newMode !== currentMode || newSub !== subfolder
currentMode = newMode
subfolder = newSub
if (changed) {
imgGrid.innerHTML = ''
const urls = await getUrls(subfolder)
if (urls) {
imgs = getImgsFromUrls(urls, imgGrid)
}
}
})
const imgTools = makeElement('div.mtb_tools')
const orderSelect = makeSelect(
['None', 'Modified', 'Modified-Reverse', 'Name', 'Name-Reverse'],
currentSort,
)
orderSelect.addEventListener('change', async (e) => {
const newSort = e.target.value
const changed = newSort !== currentSort
currentSort = newSort
if (changed) {
imgGrid.innerHTML = ''
const urls = await getUrls(subfolder)
if (urls) {
imgs = getImgsFromUrls(urls, imgGrid)
}
}
})
const sizeSlider = makeSlider(64, 1024, currentWidth, 1)
imgTools.appendChild(orderSelect)
imgTools.appendChild(sizeSlider)
imgs = getImgsFromUrls(urls, imgGrid)
sizeSlider.addEventListener('input', (e) => {
currentWidth = e.target.value
for (const img of imgs) {
img.style.width = `${e.target.value}px`
}
})
handle = renderSidebar(el, cont, [selector, imgGrid, imgTools])
app.api.addEventListener('status', async () => {
if (currentMode !== 'output') return
updateOutputsGrid()
-85
View File
@@ -174,101 +174,16 @@ export const ensureMTBStyles = () => {
.mtb_slider[type="range"]:active::-webkit-slider-thumb {
background-color: ${S.accent};
}
`
const contextMenus = `
.mtb_context_menu {
position: fixed;
background: var(--comfy-input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 0;
min-width: 150px;
z-index: 1000;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.mtb-context-menu-item {
padding: 6px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.mtb-context-menu-item:hover {
background: var(--comfy-input-hover);
}
.mtb-loading-indicator {
position: sticky;
bottom: 0;
left: 0;
right: 0;
background: var(--comfy-input-bg);
padding: 8px;
text-align: center;
border-top: 1px solid var(--border-color);
z-index: 100;
}
`
addNamedStyleSheet(
'mtb_ui',
`
${common}
${inputs}
${contextMenus}
`,
)
}
export class ContextMenu {
constructor(parent) {
this.menu = makeElement('div.mtb_context_menu', { display: 'none' })
const body = parent || document.body
body.appendChild(this.menu)
document.addEventListener('click', (e) => {
if (!this.menu.contains(e.target)) {
this.hide()
}
})
}
show(x, y, items, context) {
this.menu.innerHTML = ''
for (const item of items) {
const menuItem = makeElement('div.mtb-context-menu-item')
if (item.icon) {
const icon = makeElement(`i.${item.icon}`)
menuItem.appendChild(icon)
}
menuItem.appendChild(document.createTextNode(item.label))
menuItem.onclick = () => {
item.action(context)
this.hide()
}
this.menu.appendChild(menuItem)
}
this.menu.style.display = 'block'
const rect = this.menu.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
x = Math.min(x, viewportWidth - rect.width)
y = Math.min(y, viewportHeight - rect.height)
this.menu.style.left = `${x}px`
this.menu.style.top = `${y}px`
}
hide() {
this.menu.style.display = 'none'
}
}
/**
* Wrap an element with a div
*