Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2000af6ca7 |
+156
-44
@@ -10,6 +10,8 @@
|
|||||||
__version__ = "0.3.0"
|
__version__ = "0.3.0"
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp.web_request import Request
|
from aiohttp.web_request import Request
|
||||||
|
|
||||||
@@ -247,7 +249,7 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
|||||||
with contextlib.suppress(ImportError):
|
with contextlib.suppress(ImportError):
|
||||||
from cachetools import TTLCache
|
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
|
prompt_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||||
|
|
||||||
node_dependency_mapping = get_node_dependencies()
|
node_dependency_mapping = get_node_dependencies()
|
||||||
@@ -362,29 +364,132 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
from asyncio import Semaphore
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from PIL import Image
|
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):
|
def get_cached_image(file_path: str, preview_params=None, channel=None):
|
||||||
cache_key = (file_path, preview_params, channel)
|
cache_key = (file_path, preview_params, channel)
|
||||||
if img_cache and (cache_key in img_cache):
|
try:
|
||||||
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:
|
if img_cache:
|
||||||
img_cache[cache_key] = img.getvalue()
|
cached_value = img_cache.get(cache_key)
|
||||||
return img_cache[cache_key]
|
if cached_value is not None:
|
||||||
|
return cached_value
|
||||||
|
|
||||||
return img.getvalue()
|
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)
|
||||||
|
|
||||||
def process_preview(img: Image.Image, preview_params):
|
def process_preview(img: Image.Image, preview_params):
|
||||||
image_format, quality, width = preview_params
|
image_format, quality, width = preview_params
|
||||||
@@ -437,41 +542,44 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
|||||||
# to load workflows in the sidebar
|
# to load workflows in the sidebar
|
||||||
@PromptServer.instance.routes.get("/mtb/view")
|
@PromptServer.instance.routes.get("/mtb/view")
|
||||||
async def view_image(request: Request):
|
async def view_image(request: Request):
|
||||||
import folder_paths
|
try:
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
filename = request.rel_url.query.get("filename")
|
await rate_limiter.acquire()
|
||||||
if not filename:
|
|
||||||
return web.Response(status=404)
|
|
||||||
|
|
||||||
filename, output_dir = folder_paths.annotated_filepath(filename)
|
filename = request.rel_url.query.get("filename")
|
||||||
if filename[0] == "/" or ".." in filename:
|
if not filename:
|
||||||
return web.Response(status=400)
|
return web.Response(status=404)
|
||||||
|
|
||||||
if output_dir is None:
|
filename, output_dir = folder_paths.annotated_filepath(filename)
|
||||||
rtype = request.rel_url.query.get("type", "output")
|
if filename[0] == "/" or ".." in filename:
|
||||||
output_dir = folder_paths.get_directory_by_type(rtype)
|
return web.Response(status=400)
|
||||||
|
|
||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
return web.Response(status=400)
|
rtype = request.rel_url.query.get("type", "output")
|
||||||
|
output_dir = folder_paths.get_directory_by_type(rtype)
|
||||||
|
|
||||||
if "subfolder" in request.rel_url.query:
|
if output_dir is None:
|
||||||
full_output_dir = os.path.join(
|
return web.Response(status=400)
|
||||||
output_dir, request.rel_url.query["subfolder"]
|
|
||||||
)
|
if "subfolder" in request.rel_url.query:
|
||||||
if (
|
full_output_dir = os.path.join(
|
||||||
os.path.commonpath(
|
output_dir, request.rel_url.query["subfolder"]
|
||||||
(os.path.abspath(full_output_dir), output_dir)
|
|
||||||
)
|
)
|
||||||
!= output_dir
|
if (
|
||||||
):
|
os.path.commonpath(
|
||||||
return web.Response(status=403)
|
(os.path.abspath(full_output_dir), output_dir)
|
||||||
output_dir = full_output_dir
|
)
|
||||||
|
!= output_dir
|
||||||
|
):
|
||||||
|
return web.Response(status=403)
|
||||||
|
output_dir = full_output_dir
|
||||||
|
|
||||||
filename = os.path.basename(filename)
|
filename = os.path.basename(filename)
|
||||||
file = os.path.join(output_dir, filename)
|
file = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
if not os.path.isfile(file):
|
if not os.path.isfile(file):
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
|
|
||||||
ret_workflow = request.rel_url.query.get("workflow")
|
ret_workflow = request.rel_url.query.get("workflow")
|
||||||
|
|
||||||
@@ -509,9 +617,13 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
|||||||
width = request.rel_url.query.get("width")
|
width = request.rel_url.query.get("width")
|
||||||
preview_info = (image_format, quality, 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)
|
return await get_image_response(
|
||||||
|
file, filename, preview_info, channel
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
rate_limiter.release()
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/mtb/server-info")
|
@PromptServer.instance.routes.get("/mtb/server-info")
|
||||||
async def get_debug(request: Request):
|
async def get_debug(request: Request):
|
||||||
|
|||||||
+6
-1
@@ -74,7 +74,12 @@ def ACTIONS_getUserImageFolders():
|
|||||||
input_subdirs = [x.name for x in input_dir.iterdir() if x.is_dir()]
|
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()]
|
output_subdirs = [x.name for x in output_dir.iterdir() if x.is_dir()]
|
||||||
|
|
||||||
return {"input": input_subdirs, "output": output_subdirs}
|
return {
|
||||||
|
"input_root": input_dir.as_posix(),
|
||||||
|
"input": input_subdirs,
|
||||||
|
"output": output_subdirs,
|
||||||
|
"output_root": output_dir.as_posix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def ACTIONS_getUserVideos(
|
def ACTIONS_getUserVideos(
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
import { app } from '../../scripts/app.js'
|
import { app } from '../../scripts/app.js'
|
||||||
import { api } from '../../scripts/api.js'
|
import { api } from '../../scripts/api.js'
|
||||||
|
|
||||||
|
if (!window.MTB) {
|
||||||
|
window.MTB = {}
|
||||||
|
}
|
||||||
// #region base utils
|
// #region base utils
|
||||||
|
|
||||||
// - crude uuid
|
// - crude uuid
|
||||||
@@ -276,6 +279,10 @@ export const getNamedWidget = (node, ...names) => {
|
|||||||
* @returns {{to:LGraphNode, from:LGraphNode, type:'error' | 'incoming' | 'outgoing'}}
|
* @returns {{to:LGraphNode, from:LGraphNode, type:'error' | 'incoming' | 'outgoing'}}
|
||||||
*/
|
*/
|
||||||
export const nodesFromLink = (node, link) => {
|
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 fromNode = app.graph.getNodeById(link.origin_id)
|
||||||
const toNode = app.graph.getNodeById(link.target_id)
|
const toNode = app.graph.getNodeById(link.target_id)
|
||||||
|
|
||||||
@@ -1068,6 +1075,66 @@ export const addDocumentation = (
|
|||||||
|
|
||||||
// #endregion
|
// #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
|
// #region node extensions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1142,6 +1209,8 @@ export const runAction = async (name, ...args) => {
|
|||||||
const res = await req.json()
|
const res = await req.json()
|
||||||
return res.result
|
return res.result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.MTB.run = runAction
|
||||||
export const getServerInfo = async () => {
|
export const getServerInfo = async () => {
|
||||||
const res = await api.fetchApi('/mtb/server-info')
|
const res = await api.fetchApi('/mtb/server-info')
|
||||||
return await res.json()
|
return await res.json()
|
||||||
|
|||||||
+465
-164
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { app } from '../../scripts/app.js'
|
import { app } from '../../scripts/app.js'
|
||||||
import { api } from '../../scripts/api.js'
|
import { api } from '../../scripts/api.js'
|
||||||
|
import { infoLogger, successLogger, errorLogger } from './comfy_shared.js'
|
||||||
|
|
||||||
import * as shared from './comfy_shared.js'
|
import * as shared from './comfy_shared.js'
|
||||||
|
|
||||||
@@ -12,32 +13,140 @@ import {
|
|||||||
makeSelect,
|
makeSelect,
|
||||||
makeSlider,
|
makeSlider,
|
||||||
renderSidebar,
|
renderSidebar,
|
||||||
|
ContextMenu,
|
||||||
} from './mtb_ui.js'
|
} from './mtb_ui.js'
|
||||||
|
|
||||||
|
let currentAbortController = null
|
||||||
|
|
||||||
|
/** cursor/offset of where we are at */
|
||||||
const offset = 0
|
const offset = 0
|
||||||
|
|
||||||
|
/** width of the images in the grid */
|
||||||
let currentWidth = 200
|
let currentWidth = 200
|
||||||
|
|
||||||
let currentMode = 'input'
|
let currentMode = 'input'
|
||||||
let subfolder = ''
|
let subfolder = ''
|
||||||
let currentSort = 'None'
|
let currentSort = 'None'
|
||||||
|
|
||||||
const IMAGE_NODES = ['LoadImage', 'VHS_LoadImagePath']
|
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 VIDEO_NODES = ['VHS_LoadVideo']
|
const VIDEO_NODES = ['VHS_LoadVideo']
|
||||||
const PROCESSED_PROMPT_IDS = new Set()
|
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) => {
|
const updateImage = (node, image) => {
|
||||||
if (IMAGE_NODES.includes(node.type)) {
|
switch (node.type) {
|
||||||
const w = node.widgets?.find((w) => w.name === 'image')
|
case 'LoadImage': {
|
||||||
if (w) {
|
if (subfolder && subfolder !== '') {
|
||||||
w.value = image
|
app.extensionManager.toast.add({
|
||||||
w.callback()
|
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
|
||||||
}
|
}
|
||||||
} else if (VIDEO_NODES.includes(node.type)) {
|
case 'VHS_LoadImagePath': {
|
||||||
const w = node.widgets?.find((w) => w.name === 'video')
|
let value = image
|
||||||
if (w) {
|
|
||||||
node.updateParameters({ filename: image }, true)
|
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 {
|
|
||||||
console.warn('No method to update', node.type)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,94 +218,265 @@ const updateOutputsGrid = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getImgsFromUrls = (urls, target, options = { prepend: false }) => {
|
const getImgsFromUrls = (urls, target, options = { prepend: false }) => {
|
||||||
const imgs = []
|
if (currentAbortController) {
|
||||||
if (urls === undefined) {
|
currentAbortController.abort()
|
||||||
return imgs
|
|
||||||
}
|
}
|
||||||
const elem = currentMode === 'video' ? 'video' : 'img'
|
infoLogger('getting images from urls', urls)
|
||||||
|
|
||||||
for (const [key, url] of Object.entries(urls)) {
|
currentAbortController = new AbortController()
|
||||||
const a = makeElement(elem)
|
const { signal } = currentAbortController
|
||||||
a.src = url
|
const imgs = []
|
||||||
a.width = currentWidth
|
if (!urls) return imgs
|
||||||
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)) {
|
const loadingIndicator = document.createElement('div')
|
||||||
updateImage(node, key)
|
loadingIndicator.className = 'mtb-loading-indicator'
|
||||||
}
|
if (target) target.appendChild(loadingIndicator)
|
||||||
}
|
|
||||||
} 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({
|
const totalImages = Object.keys(urls).length
|
||||||
severity: 'warn',
|
let loadedCount = 0
|
||||||
summary: 'Outputs not supported',
|
const updateLoadingStatus = () => {
|
||||||
detail:
|
loadingIndicator.textContent = `Loaded ${loadedCount} of ${totalImages} images`
|
||||||
'For now only inputs can be clicked to load the image on the active LoadImage node.',
|
}
|
||||||
life: 5000,
|
updateLoadingStatus()
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
|
||||||
} else {
|
|
||||||
a.autoplay = true
|
|
||||||
|
|
||||||
a.muted = true
|
elem.onclick = (_e) => {
|
||||||
a.loop = true
|
const selected = app.canvas.selected_nodes
|
||||||
a.onclick = (_e) => {
|
if (!selected || Object.keys(selected).length === 0) {
|
||||||
const selected = app.canvas.selected_nodes
|
app.extensionManager.toast.add({
|
||||||
if (selected && Object.keys(selected).length === 0) {
|
severity: 'warn',
|
||||||
app.extensionManager.toast.add({
|
summary: 'No node selected!',
|
||||||
severity: 'warn',
|
detail: 'Please select a node first.',
|
||||||
summary: 'No node selected!',
|
life: 5000,
|
||||||
detail:
|
})
|
||||||
"For now the only action when clicking videos in the sidebar is to set the video on all selected 'Load Video (Upload)' nodes.",
|
return
|
||||||
life: 5000,
|
}
|
||||||
})
|
|
||||||
return
|
for (const [_id, node] of Object.entries(selected)) {
|
||||||
|
updateImage(node, key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
loadedCount++
|
||||||
updateImage(node, key)
|
updateLoadingStatus()
|
||||||
|
|
||||||
|
return elem
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
console.log('Fetch aborted')
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
console.error('Error loading image:', error)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
imgs.push(a)
|
const BATCH_SIZE = 20
|
||||||
}
|
for (let i = 0; i < Object.entries(urls).length; i += BATCH_SIZE) {
|
||||||
if (target !== undefined) {
|
const batch = Object.entries(urls).slice(i, i + BATCH_SIZE)
|
||||||
if (options.prepend) target.prepend(...imgs)
|
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)
|
||||||
else target.append(...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 () => {
|
const getModes = async () => {
|
||||||
@@ -205,7 +485,7 @@ const getModes = async () => {
|
|||||||
}
|
}
|
||||||
const getUrls = async (subfolder) => {
|
const getUrls = async (subfolder) => {
|
||||||
const count = (await api.getSetting('mtb.io-sidebar.count')) || 1000
|
const count = (await api.getSetting('mtb.io-sidebar.count')) || 1000
|
||||||
console.log('Sidebar count', count)
|
console.debug('Sidebar count', count)
|
||||||
if (currentMode === 'video') {
|
if (currentMode === 'video') {
|
||||||
const output = await shared.runAction(
|
const output = await shared.runAction(
|
||||||
'getUserVideos',
|
'getUserVideos',
|
||||||
@@ -228,6 +508,99 @@ const getUrls = async (subfolder) => {
|
|||||||
return output || {}
|
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
|
//NOTE: do not load if using the old ui
|
||||||
if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
||||||
// NOTE: removed this for now since I'm not actually exposing anything a client
|
// NOTE: removed this for now since I'm not actually exposing anything a client
|
||||||
@@ -247,8 +620,8 @@ if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
|||||||
// },
|
// },
|
||||||
init: () => {
|
init: () => {
|
||||||
let handle
|
let handle
|
||||||
const version = window?.__COMFYUI_FRONTEND_VERSION__
|
// const version = window?.__COMFYUI_FRONTEND_VERSION__
|
||||||
console.log(`%c ${version}`, 'background: orange; color: white;')
|
// console.log(`%c ${version}`, 'background: orange; color: white;')
|
||||||
|
|
||||||
ensureMTBStyles()
|
ensureMTBStyles()
|
||||||
|
|
||||||
@@ -320,81 +693,9 @@ if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
|||||||
handle = undefined
|
handle = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el.parentNode) {
|
if (!loaded_images) {
|
||||||
el.parentNode.style.overflowY = 'clip'
|
await build_ui(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 () => {
|
app.api.addEventListener('status', async () => {
|
||||||
if (currentMode !== 'output') return
|
if (currentMode !== 'output') return
|
||||||
updateOutputsGrid()
|
updateOutputsGrid()
|
||||||
|
|||||||
@@ -174,16 +174,101 @@ export const ensureMTBStyles = () => {
|
|||||||
.mtb_slider[type="range"]:active::-webkit-slider-thumb {
|
.mtb_slider[type="range"]:active::-webkit-slider-thumb {
|
||||||
background-color: ${S.accent};
|
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(
|
addNamedStyleSheet(
|
||||||
'mtb_ui',
|
'mtb_ui',
|
||||||
`
|
`
|
||||||
${common}
|
${common}
|
||||||
${inputs}
|
${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
|
* Wrap an element with a div
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user