test!: 🚨 bringing back io edits
very wip but should improve the panel a lot
This commit is contained in:
+156
-44
@@ -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 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
with contextlib.suppress(ImportError):
|
||||
from cachetools import TTLCache
|
||||
|
||||
img_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||
# img_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||
prompt_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||
|
||||
node_dependency_mapping = get_node_dependencies()
|
||||
@@ -362,29 +364,132 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from asyncio import Semaphore
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
image_thread_pool = ThreadPoolExecutor(
|
||||
max_workers=4, thread_name_prefix="img_worker"
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_image_with_timeout(
|
||||
file_path, preview_params=None, channel=None, timeout=10
|
||||
):
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
image_thread_pool,
|
||||
get_cached_image,
|
||||
file_path,
|
||||
preview_params,
|
||||
channel,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
yield result
|
||||
except asyncio.TimeoutError:
|
||||
print(f"Image processing timed out for {file_path}")
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error processing image {file_path}: {str(e)}")
|
||||
raise
|
||||
|
||||
async def get_image_response(
|
||||
file, filename: str, preview_info=None, channel=None
|
||||
):
|
||||
try:
|
||||
async with get_image_with_timeout(
|
||||
file, preview_info, channel
|
||||
) as img:
|
||||
return web.Response(
|
||||
body=img,
|
||||
content_type="image/webp" if preview_info else "image/png",
|
||||
headers={"Content-Disposition": f'filename="{filename}"'},
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return web.Response(status=504, text="Image processing timed out")
|
||||
except Exception as e:
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
class LRUCache:
|
||||
def __init__(self, capacity: int):
|
||||
self.cache = OrderedDict()
|
||||
self.capacity = capacity
|
||||
|
||||
def get(self, key) -> Any:
|
||||
if key not in self.cache:
|
||||
return None
|
||||
self.cache.move_to_end(key)
|
||||
return self.cache[key]
|
||||
|
||||
def put(self, key, value: Any) -> None:
|
||||
if key in self.cache:
|
||||
self.cache.move_to_end(key)
|
||||
self.cache[key] = value
|
||||
if len(self.cache) > self.capacity:
|
||||
self.cache.popitem(last=False)
|
||||
|
||||
img_cache = LRUCache(capacity=100)
|
||||
|
||||
def get_cached_image(file_path: str, preview_params=None, channel=None):
|
||||
cache_key = (file_path, preview_params, channel)
|
||||
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
|
||||
|
||||
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):
|
||||
image_format, quality, width = preview_params
|
||||
@@ -437,41 +542,44 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
# to load workflows in the sidebar
|
||||
@PromptServer.instance.routes.get("/mtb/view")
|
||||
async def view_image(request: Request):
|
||||
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 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
width = request.rel_url.query.get("width")
|
||||
preview_info = (image_format, quality, width)
|
||||
|
||||
channel = request.rel_url.query.get("channel")
|
||||
channel = request.rel_url.query.get("channel")
|
||||
|
||||
return await get_image_response(file, filename, preview_info, channel)
|
||||
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):
|
||||
|
||||
+6
-1
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
+465
-164
@@ -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
|
||||
}
|
||||
} else if (VIDEO_NODES.includes(node.type)) {
|
||||
const w = node.widgets?.find((w) => w.name === 'video')
|
||||
if (w) {
|
||||
node.updateParameters({ filename: image }, true)
|
||||
case 'VHS_LoadImagePath': {
|
||||
let value = image
|
||||
|
||||
if (!userDirectories?.output) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'User output directory not resolved',
|
||||
detail: "We couldn't resolve the image full path.",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (subfolder && subfolder !== '') {
|
||||
value = `${subfolder}/${image}`
|
||||
}
|
||||
value = `${userDirectories.output}/${value}`
|
||||
|
||||
const w = node.widgets?.find((w) => w.name === 'image')
|
||||
if (w) {
|
||||
console.log(w)
|
||||
w.value = value
|
||||
// TODO: VHS needs explicity value passsed here
|
||||
w.callback(value)
|
||||
}
|
||||
break
|
||||
}
|
||||
case VIDEO_NODES.includes(node.type): {
|
||||
const w = node.widgets?.find((w) => w.name === 'video')
|
||||
if (w) {
|
||||
node.updateParameters({ filename: image }, true)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.warn('No method to update', node.type)
|
||||
}
|
||||
} else {
|
||||
console.warn('No method to update', node.type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,94 +218,265 @@ const updateOutputsGrid = async () => {
|
||||
}
|
||||
|
||||
const getImgsFromUrls = (urls, target, options = { prepend: false }) => {
|
||||
const imgs = []
|
||||
if (urls === undefined) {
|
||||
return imgs
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort()
|
||||
}
|
||||
const elem = currentMode === 'video' ? 'video' : 'img'
|
||||
infoLogger('getting images from urls', urls)
|
||||
|
||||
for (const [key, url] of Object.entries(urls)) {
|
||||
const a = makeElement(elem)
|
||||
a.src = url
|
||||
a.width = currentWidth
|
||||
if (currentMode === 'input') {
|
||||
a.onclick = (_e) => {
|
||||
if (subfolder !== '') {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Subfolder not supported',
|
||||
detail: "The LoadImage node doesn't support subfolders",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const selected = app.canvas.selected_nodes
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
currentAbortController = new AbortController()
|
||||
const { signal } = currentAbortController
|
||||
const imgs = []
|
||||
if (!urls) return imgs
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
} else if (currentMode === 'output') {
|
||||
a.onclick = (_e) => {
|
||||
// window.MTB?.notify?.("Output import isn't supported yet...", 5000)
|
||||
if (subfolder !== '') {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Subfolder not supported',
|
||||
detail: "The LoadImage node doesn't support subfolders",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const loadingIndicator = document.createElement('div')
|
||||
loadingIndicator.className = 'mtb-loading-indicator'
|
||||
if (target) target.appendChild(loadingIndicator)
|
||||
|
||||
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 totalImages = Object.keys(urls).length
|
||||
let loadedCount = 0
|
||||
const updateLoadingStatus = () => {
|
||||
loadingIndicator.textContent = `Loaded ${loadedCount} of ${totalImages} images`
|
||||
}
|
||||
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
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
imgs.push(a)
|
||||
}
|
||||
if (target !== undefined) {
|
||||
if (options.prepend) target.prepend(...imgs)
|
||||
const BATCH_SIZE = 20
|
||||
for (let i = 0; i < Object.entries(urls).length; i += BATCH_SIZE) {
|
||||
const batch = Object.entries(urls).slice(i, i + BATCH_SIZE)
|
||||
const loadedImages = await Promise.all(
|
||||
batch.map(([key, url]) => loadImage(key, url)),
|
||||
)
|
||||
|
||||
const validImages = loadedImages.filter((img) => img !== null)
|
||||
imgs.push(...validImages)
|
||||
|
||||
if (target) {
|
||||
target.append(...validImages)
|
||||
}
|
||||
}
|
||||
|
||||
return imgs
|
||||
|
||||
// return
|
||||
// const elem = currentMode === 'video' ? 'video' : 'img'
|
||||
|
||||
for (const [key, url] of Object.entries(urls)) {
|
||||
const a = makeElement(elem)
|
||||
a.src = url
|
||||
a.width = currentWidth
|
||||
|
||||
const selected = app.canvas.selected_nodes
|
||||
|
||||
if (currentMode === 'input') {
|
||||
a.onclick = (_e) => {
|
||||
// if (subfolder !== '') {
|
||||
// app.extensionManager.toast.add({
|
||||
// severity: 'warn',
|
||||
// summary: 'Subfolder not supported',
|
||||
// detail: "The LoadImage node doesn't support subfolders",
|
||||
// life: 5000,
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
} else if (currentMode === 'output') {
|
||||
a.onclick = (_e) => {
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
return
|
||||
}
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
|
||||
// window.MTB?.notify?.("Output import isn't supported yet...", 5000)
|
||||
// if (subfolder !== '') {
|
||||
// app.extensionManager.toast.add({
|
||||
// severity: 'warn',
|
||||
// summary: 'Subfolder not supported',
|
||||
// detail: "The LoadImage node doesn't support subfolders",
|
||||
// life: 5000,
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// app.extensionManager.toast.add({
|
||||
// severity: 'warn',
|
||||
// summary: 'Outputs not supported',
|
||||
// detail:
|
||||
// 'For now only inputs can be clicked to load the image on the active LoadImage node.',
|
||||
// life: 5000,
|
||||
// })
|
||||
}
|
||||
} else {
|
||||
a.autoplay = true
|
||||
|
||||
a.muted = true
|
||||
a.loop = true
|
||||
a.onclick = (_e) => {
|
||||
const selected = app.canvas.selected_nodes
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
"For now the only action when clicking videos in the sidebar is to set the video on all selected 'Load Video (Upload)' nodes.",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
imgs.push(a)
|
||||
}
|
||||
if (target !== undefined) {
|
||||
if (options.prepend) target.prepend(...imgs)
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user