diff --git a/__init__.py b/__init__.py index ef098d5..8c8ade8 100644 --- a/__init__.py +++ b/__init__.py @@ -10,6 +10,8 @@ __version__ = "0.3.0" import os +from collections import OrderedDict +from typing import Any from aiohttp.web_request import Request @@ -247,7 +249,7 @@ def classname_to_wiki(s: str): 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() @@ -362,29 +364,132 @@ async def get_home(request: Request): 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) - 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 + try: if img_cache: - img_cache[cache_key] = img.getvalue() - return img_cache[cache_key] + cached_value = img_cache.get(cache_key) + if cached_value is not None: + return cached_value + + 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) - return img.getvalue() + # 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): image_format, quality, width = preview_params @@ -437,41 +542,44 @@ async def get_image_response( # to load workflows in the sidebar @PromptServer.instance.routes.get("/mtb/view") async def view_image(request: Request): - import folder_paths + try: + import folder_paths - filename = request.rel_url.query.get("filename") - if not filename: - return web.Response(status=404) + await rate_limiter.acquire() - filename, output_dir = folder_paths.annotated_filepath(filename) - if filename[0] == "/" or ".." in filename: - return web.Response(status=400) + filename = request.rel_url.query.get("filename") + if not filename: + return web.Response(status=404) - if output_dir is None: - rtype = request.rel_url.query.get("type", "output") - output_dir = folder_paths.get_directory_by_type(rtype) + filename, output_dir = folder_paths.annotated_filepath(filename) + if filename[0] == "/" or ".." in filename: + return web.Response(status=400) - if output_dir is None: - 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 "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 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"] ) - != output_dir - ): - return web.Response(status=403) - output_dir = full_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 - 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") @@ -509,9 +617,13 @@ async def view_image(request: Request): 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) + return await get_image_response( + file, filename, preview_info, channel + ) + finally: + rate_limiter.release() @PromptServer.instance.routes.get("/mtb/server-info") async def get_debug(request: Request): diff --git a/endpoint.py b/endpoint.py index 307a44d..7f3d1f3 100644 --- a/endpoint.py +++ b/endpoint.py @@ -74,7 +74,12 @@ 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": 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( diff --git a/web/comfy_shared.js b/web/comfy_shared.js index a777b69..67cb052 100644 --- a/web/comfy_shared.js +++ b/web/comfy_shared.js @@ -12,6 +12,9 @@ import { app } from '../../scripts/app.js' import { api } from '../../scripts/api.js' +if (!window.MTB) { + window.MTB = {} +} // #region base utils // - crude uuid @@ -276,6 +279,10 @@ 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) @@ -1068,6 +1075,66 @@ 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 /** @@ -1142,6 +1209,8 @@ 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() diff --git a/web/mtb_input_output_sidebar.js b/web/mtb_input_output_sidebar.js index a980c30..64df7f1 100644 --- a/web/mtb_input_output_sidebar.js +++ b/web/mtb_input_output_sidebar.js @@ -2,6 +2,7 @@ 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' @@ -12,32 +13,140 @@ 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' -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 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) => { - if (IMAGE_NODES.includes(node.type)) { - const w = node.widgets?.find((w) => w.name === 'image') - if (w) { - w.value = image - w.callback() + 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 + } + 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 } - } else if (VIDEO_NODES.includes(node.type)) { - const w = node.widgets?.find((w) => w.name === 'video') - if (w) { - node.updateParameters({ filename: image }, true) + 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 }) => { + if (currentAbortController) { + currentAbortController.abort() + } + infoLogger('getting images from urls', urls) + + currentAbortController = new AbortController() + const { signal } = currentAbortController const imgs = [] - if (urls === undefined) { - return 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` } - const elem = currentMode === 'video' ? 'video' : 'img' - - 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 + 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 } - 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, + // 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, }) - return + }) + + 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) + } } - for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) { - updateImage(node, key) + loadedCount++ + updateLoadingStatus() + + return elem + } catch (error) { + if (error.name === 'AbortError') { + console.log('Fetch aborted') + return null } + console.error('Error loading image:', error) + return null } - } 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 - } + } + 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)), + ) - 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, - }) + const validImages = loadedImages.filter((img) => img !== null) + imgs.push(...validImages) + + if (target) { + target.append(...validImages) } - } 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 + } + + 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) + for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) { + updateImage(node, key) + } } } + imgs.push(a) } - imgs.push(a) - } - if (target !== undefined) { - if (options.prepend) target.prepend(...imgs) + 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 () => { @@ -205,7 +485,7 @@ const getModes = async () => { } const getUrls = async (subfolder) => { const count = (await api.getSetting('mtb.io-sidebar.count')) || 1000 - console.log('Sidebar count', count) + console.debug('Sidebar count', count) if (currentMode === 'video') { const output = await shared.runAction( 'getUserVideos', @@ -228,6 +508,99 @@ 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 @@ -247,8 +620,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() @@ -320,81 +693,9 @@ if (window?.__COMFYUI_FRONTEND_VERSION__) { handle = undefined } - if (el.parentNode) { - el.parentNode.style.overflowY = 'clip' + if (!loaded_images) { + 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 () => { if (currentMode !== 'output') return updateOutputsGrid() diff --git a/web/mtb_ui.js b/web/mtb_ui.js index 73b626c..51b2bed 100644 --- a/web/mtb_ui.js +++ b/web/mtb_ui.js @@ -174,16 +174,101 @@ 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 *