Compare commits

...
Author SHA1 Message Date
Mel Massadian 2000af6ca7 test!: 🚨 bringing back io edits
very wip but should improve the panel a lot
2025-05-08 11:05:34 +02:00
5 changed files with 781 additions and 209 deletions
+156 -44
View File
@@ -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
View File
@@ -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(
+69
View File
@@ -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
View File
@@ -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()
+85
View File
@@ -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
* *