Compare commits

...
51 Commits
Author SHA1 Message Date
m0rtus59 36ab9e1946 Merge pull request #6 from m0rtus59/chore/cleanup-prompt-search
Clean up leftovers from prompt search change
2026-11-11 07:03:00 +05:00
Claude 2db8592267 Remove unused prompt scan helper and stale test reference 2026-11-10 20:06:51 +00:00
M0rtus59 9cb8f7faba version bump 2026-11-10 16:52:03 +05:00
m0rtus59 a95d2ca1b7 Merge pull request #5 from m0rtus59/fix/queue-prompt-search-sticky-header
Use shared prompt parser for Queue search and fix sticky headers
2026-11-10 16:46:40 +05:00
m0rtus59 f5f5016346 Remove leftover prompt search test file 2026-11-10 16:40:28 +05:00
m0rtus59 e9c7205e25 Match Queue sticky header reveal behavior to Gallery 2026-11-10 16:40:24 +05:00
m0rtus59 6b06b18bca Tidy shared prompt search documentation 2026-11-10 16:17:52 +05:00
m0rtus59 1c6886712a Correct newline expectation in prompt search test 2026-11-10 16:17:27 +05:00
m0rtus59 2286083b9b Bound Queue prompt extraction request sizes 2026-11-10 16:16:56 +05:00
m0rtus59 a23ba4fae2 Test shared Queue prompt extraction helper 2026-11-10 16:16:54 +05:00
m0rtus59 09677772c6 Share graph extraction between Queue and Gallery 2026-11-10 16:16:51 +05:00
m0rtus59 37522e484a Make Queue search header sticky and frosted on scroll 2026-11-10 16:16:06 +05:00
m0rtus59 270616a744 Keep gallery frosted header state while searching 2026-11-10 16:16:03 +05:00
m0rtus59 2941e48879 Populate Queue prompt search text with shared Python parser 2026-11-10 16:15:51 +05:00
m0rtus59 f7436011c4 Search extracted generation prompts in Queue cards 2026-11-10 16:15:33 +05:00
m0rtus59 e783422e7c Persist extracted prompt search text 2026-11-10 16:15:30 +05:00
m0rtus59 41d2e38f2f Add cached prompt search text to card state 2026-11-10 16:15:28 +05:00
m0rtus59 56247cfa8b Retain Queue API prompt graph 2026-11-10 16:15:25 +05:00
m0rtus59 328915a7ee Add Queue prompt extraction endpoint 2026-11-10 16:15:22 +05:00
m0rtus59 543e9e999a Reuse graph parser for Queue search 2026-11-10 16:15:11 +05:00
m0rtus59 421361edb8 Merge pull request #4 from m0rtus59/feat/prompt-only-search
Improve gallery prompt search while retaining filename search
2026-11-10 15:54:54 +05:00
m0rtus59 647c95a5df Revert locale-only search label changes 2026-11-10 15:42:06 +05:00
m0rtus59 7421dfa756 Revert locale-only search label changes 2026-11-10 15:42:03 +05:00
m0rtus59 60883fdf63 Revert locale-only search label changes 2026-11-10 15:41:48 +05:00
m0rtus59 b6143a3abf Revert locale-only search label changes 2026-11-10 15:41:45 +05:00
m0rtus59 828d510891 Revert locale-only search label changes 2026-11-10 15:41:43 +05:00
m0rtus59 024309c050 Revert locale-only search label changes 2026-11-10 15:41:40 +05:00
m0rtus59 2e09cf3963 Revert locale-only search label changes 2026-11-10 15:41:37 +05:00
m0rtus59 ef424be4b1 Revert locale-only search label changes 2026-11-10 15:41:35 +05:00
m0rtus59 a1fa872e2e Revert locale-only search label changes 2026-11-10 15:41:32 +05:00
m0rtus59 d31852e5ab Revert locale-only search label changes 2026-11-10 15:41:26 +05:00
m0rtus59 bb98171982 Revert locale-only search label changes 2026-11-10 15:41:23 +05:00
m0rtus59 004744e8e2 Revert locale-only search label changes 2026-11-10 15:41:21 +05:00
m0rtus59 94933a0246 Revert locale-only search label changes 2026-11-10 15:41:19 +05:00
m0rtus59 c942976c02 Revert locale-only search label changes 2026-11-10 15:41:17 +05:00
m0rtus59 0d19394c8f Document filename and prompt metadata search 2026-11-10 15:41:00 +05:00
m0rtus59 4bca1435a6 Restore filename and prompt search labels 2026-11-10 15:40:57 +05:00
m0rtus59 4eb67f25f0 Keep filename matching alongside prompt search 2026-11-10 15:40:55 +05:00
m0rtus59 40a9cf3db9 Merge pull request #3 from m0rtus59/fix/gallery-teardown-leaks
fix: gallery teardown listener cleanup + idempotent overlay.destroy()
2026-11-10 15:03:12 +05:00
Claude ff0466957e feat(gallery): search only generation prompts; scan off the event loop; cache results
- New prompt_search.py (no ComfyUI imports): reads only the PNG 'prompt' chunk (tEXt/zTXt/iTXt), skips the large 'workflow' chunk, and extracts only text that feeds text-encoder nodes (following links to string/primitive/concat nodes). Model names, sampler names, filenames and other widgets are no longer matched, and filenames are no longer searched.
- Per-file results are cached by (mtime, size); repeat searches only touch new/changed files.
- gallery/list runs in a worker thread so large searches don't block ComfyUI's server; the scan stops when the client disconnects.
- Frontend aborts superseded search requests and ignores stale results.
- UI strings/locales and README updated; unit tests in tests/.
2026-11-10 09:49:31 +00:00
Claude 294e610eb1 fix: clean up gallery key/resize listeners on teardown; guard overlay.destroy()
- galleryView: hoist Ctrl/Meta key handlers to module scope and remove them (plus a new blur reset) in teardownGalleryView(); disconnect the ResizeObserver; clear the ctrl-active body class; reset header-reveal state so a re-initialised gallery starts clean.
- overlay: make destroy() idempotent and make updateOverlayBounds() a no-op afterwards, so a pending rAF/ResizeObserver callback can't re-observe elements after disconnect().
2026-11-10 09:32:57 +00:00
M0rtus59 a33052141a overlay jitter fix 2026-11-10 14:25:34 +05:00
M0rtus59 e2a3c0c596 fixes 2026-11-10 14:18:59 +05:00
m0rtus59 557d910ab4 Merge pull request #2 from m0rtus59/feat/layout-independent-hotkeys
Feat/layout independent hotkeys
2026-11-09 18:27:58 +05:00
Claude be5c8c41c9 i18n: add 'Fix non-ASCII keybinds' setting strings to the remaining locales 2026-11-09 13:20:43 +00:00
Claude 408517273a docs/settings: rename toggle to 'Fix non-ASCII keybinds' and document layout-independent hotkeys in the README 2026-11-09 09:06:39 +00:00
Claude e8b6622919 feat: make hotkeys work on any keyboard layout
ComfyUI matches keybindings on KeyboardEvent.key, which follows the active layout (physical Q is 'й' on a Russian layout). Re-dispatch non-ASCII keys as the US-QWERTY key of the same physical position (event.code) in the window capture phase, before the frontend handler runs.

Skips text fields, IME composition, named keys and layouts that already report ASCII. Toggle: 'Hotkeys work on any keyboard layout' (on by default). en/ru strings added.
2026-11-09 08:51:43 +00:00
Claude 4001848e37 refactor: bind Q through extension keybindings instead of a global keydown handler
Makes the shortcut visible and rebindable in the Keybinding panel. Ctrl+Q and Alt+R keep using the keydown handler for now.
2026-11-09 08:51:43 +00:00
Claude 0733d001f3 fix(i18n): remove duplicate 'Classic Layout' key in ru settings (the later entry already won) 2026-11-09 08:51:43 +00:00
m0rtus59 33878502af Merge pull request #1 from m0rtus59/fix-delete-stale-subfolder
Fix delete-file removing the wrong file on stale subfolder
2026-11-09 02:38:44 +05:00
Claude 2c9ded2cbf Fix delete-file removing the wrong file on stale subfolder
resolve_existing_path fell back to <root>/<filename> when
<root>/<subfolder>/<filename> did not exist, so deleting a file that was
already gone could trash a same-named file in the output root. It also
ignored the requested `type`, so type=temp could resolve into output.

Add a strict mode used only by delete-file: exact subfolder match, no root
fallback, and only the root matching the requested type is considered.
Other callers (open-folder, promote-output) keep the old behavior.
2026-11-08 21:18:42 +00:00
31 changed files with 831 additions and 202 deletions
+8 -1
View File
@@ -18,7 +18,9 @@ A compact and powerful layout overhaul for ComfyUI that makes generation results
<img src="assets/compare.png" alt="ComfySidebar compare" width="100%">
* **[NEW] Output Gallery sidebar:** Browse files and subfolders directly from your ComfyUI `output` directory without leaving the interface. Features dedicated cards for images, videos, audio, and 3D assets (`.glb`/`.gltf`), breadcrumb navigation, prompt metadata search across subfolders, and persistent sorting.
* **[NEW] Output Gallery sidebar:** Browse files and subfolders directly from your ComfyUI `output` directory without leaving the interface. Features dedicated cards for images, videos, audio, and 3D assets (`.glb`/`.gltf`), breadcrumb navigation, filename and prompt metadata search across subfolders, and persistent sorting.
* **Hotkeys on any keyboard layout:** Shortcuts such as `Q`, `Ctrl+Q` and `Alt+R` keep working while a non-Latin layout (for example Russian) is active. Non-Latin keys are treated as the Latin key in the same physical position, and typing in text fields is not affected. Can be turned off with **Fix non-ASCII keybinds** in the settings.
* **Runtime preview mode** combined with workflow overview makes ComfyUI practical for running and monitoring workflows in a more streamlined, queue-oriented way.
@@ -61,6 +63,7 @@ Open **Settings → Comfy Sidebar** to configure:
* Unified top bar layout
* Permanent deletion behavior
* Output Gallery sidebar toggle
* Fix non-ASCII keybinds (hotkeys on any keyboard layout)
## ⌨️ Controls
@@ -73,12 +76,16 @@ Open **Settings → Comfy Sidebar** to configure:
| `Shift+Click` | Compare another result |
| `Ctrl+Click` | Open the file location / alternative action |
`Q` is a regular ComfyUI keybinding, so you can see and change it in **Settings → Keybinding**.
You can also use the available actions from result cards, the sidebar header, and the node/canvas context menus.
## 📝 Notes
ComfySidebar integrates with the ComfyUI frontend and depends on parts of its UI structure. Frontend changes in ComfyUI may require updates to this extension.
**Keyboard layouts:** the *Fix non-ASCII keybinds* option only re-maps keys that produce a non-ASCII character, so QWERTY, AZERTY and Dvorak users are not affected. It cannot help with layouts that type a *different ASCII* symbol on a key (for example, `+` where a US layout has `=`). ComfyUI's "edit keybinding" dialog still records the typed character, so switch to an English layout while rebinding a key.
## License
See [LICENSE](LICENSE).
+164 -124
View File
@@ -1,11 +1,14 @@
import asyncio
import os
import platform
import subprocess
import json
import threading
from aiohttp import web
from server import PromptServer
import folder_paths
from . import prompt_search
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
WEB_DIRECTORY = "./js"
@@ -61,10 +64,35 @@ def get_all_candidate_roots(subfolder="", folder_type="output"):
return unique
def resolve_existing_path(filename="", subfolder="", folder_type="output", candidate_roots=None):
"""Finds the actual location of a file or folder across all candidate root directories."""
def resolve_existing_path(filename="", subfolder="", folder_type="output", candidate_roots=None, strict=False):
"""Finds the actual location of a file or folder across all candidate root directories.
strict=True is for destructive operations: the file must exist at exactly
<root>/<subfolder>/<filename> (no fallback to a same-named file in the root),
and only the root matching `folder_type` (output/input/temp) is considered.
"""
candidates = candidate_roots if candidate_roots is not None else get_all_candidate_roots(subfolder, folder_type)
if strict:
type_dirs = {
"output": folder_paths.get_output_directory(),
"input": folder_paths.get_input_directory(),
"temp": folder_paths.get_temp_directory(),
}
wanted = type_dirs.get(folder_type)
if wanted is None:
return None, None
wanted = os.path.realpath(wanted)
if not filename:
return None, None
for root in candidates:
if os.path.realpath(root) != wanted:
continue
p = os.path.join(root, subfolder, filename) if subfolder else os.path.join(root, filename)
if os.path.isfile(p) and is_path_safe(root, p):
return p, root
return None, None
if filename:
for root in candidates:
if subfolder:
@@ -118,41 +146,104 @@ def open_file_in_os(target_path):
raise RuntimeError("Headless Linux server detected (no desktop GUI available).")
def check_png_prompt_match(filepath, search_term):
"""Checks PNG prompt metadata strictly for text inputs matching search."""
try:
with open(filepath, "rb") as f:
if f.read(8) != b"\x89PNG\r\n\x1a\n":
return False
while True:
len_b = f.read(4)
if len(len_b) < 4:
break
length = int.from_bytes(len_b, "big")
ctype = f.read(4)
if ctype in (b"tEXt", b"iTXt"):
data = f.read(length)
if data.startswith(b"prompt\x00") or b'"inputs"' in data:
text = data.decode("utf-8", errors="ignore")
idx = text.find("{")
if idx != -1:
try:
prompt_dict = json.loads(text[idx:])
for node in prompt_dict.values():
if isinstance(node, dict) and "inputs" in node:
for val in node["inputs"].values():
if isinstance(val, str) and search_term in val.lower():
return True
except Exception:
pass
elif ctype in (b"IDAT", b"IEND"):
break
else:
f.seek(length, 1)
f.seek(4, 1)
return False
except Exception:
return False
_GALLERY_EXTS = {
".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif",
".mp4", ".webm", ".mov", ".mkv",
".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac",
".glb", ".gltf", ".obj", ".fbx", ".stl",
".json"
}
def _relative_subfolder(directory, output_dir):
rel_sub = os.path.relpath(directory, output_dir).replace("\\", "/")
return "" if rel_sub == "." else rel_sub
def _list_gallery(subfolder, search, cancel):
"""Blocking directory listing / prompt search. Runs in a worker thread.
Returns (http_status, json_payload).
"""
output_dir = folder_paths.get_output_directory()
target_dir = os.path.realpath(os.path.join(output_dir, subfolder)) if subfolder else os.path.realpath(output_dir)
if not is_path_safe(output_dir, target_dir):
return 403, {"error": "Unsafe path"}
if not os.path.exists(target_dir) or not os.path.isdir(target_dir):
return 200, {"subfolder": subfolder, "folders": [], "files": []}
folders = []
files = []
if search:
# Recursive filename + prompt-text search. Prompt metadata is only read for PNGs.
for root, dirs, fnames in os.walk(target_dir):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for fname in fnames:
if cancel.is_set():
return 200, {
"subfolder": subfolder.replace("\\", "/"),
"folders": folders,
"files": files
}
if fname.startswith("."):
continue
ext = os.path.splitext(fname)[1].lower()
if ext not in _GALLERY_EXTS:
continue
fpath = os.path.join(root, fname)
matched = search in fname.lower()
if not matched and ext == ".png":
matched = search in prompt_search.get_prompt_search_text(fpath)
if matched:
try:
stat = os.stat(fpath)
except (PermissionError, FileNotFoundError):
continue
files.append({
"filename": fname,
"subfolder": _relative_subfolder(root, output_dir),
"type": "output",
"mtime": stat.st_mtime,
"size": stat.st_size
})
else:
# Instant single-directory listing without reading files from disk
with os.scandir(target_dir) as entries:
for entry in entries:
if entry.name.startswith("."):
continue
try:
if entry.is_dir(follow_symlinks=False):
folders.append({
"name": entry.name,
"subfolder": os.path.relpath(entry.path, output_dir).replace("\\", "/"),
"mtime": entry.stat().st_mtime
})
elif entry.is_file(follow_symlinks=False):
if os.path.splitext(entry.name)[1].lower() in _GALLERY_EXTS:
stat = entry.stat()
files.append({
"filename": entry.name,
"subfolder": _relative_subfolder(os.path.dirname(entry.path), output_dir),
"type": "output",
"mtime": stat.st_mtime,
"size": stat.st_size
})
except (PermissionError, FileNotFoundError):
continue
folders.sort(key=lambda x: x["name"].lower())
files.sort(key=lambda x: x["mtime"], reverse=True)
return 200, {
"subfolder": subfolder.replace("\\", "/"),
"folders": folders,
"files": files
}
@PromptServer.instance.routes.get("/comfy-sidebar/gallery/list")
@@ -160,96 +251,45 @@ async def gallery_list_handler(request):
try:
subfolder = request.query.get("subfolder", "").strip().strip("/\\")
search = request.query.get("search", "").strip().lower()
output_dir = folder_paths.get_output_directory()
target_dir = os.path.realpath(os.path.join(output_dir, subfolder)) if subfolder else os.path.realpath(output_dir)
if not is_path_safe(output_dir, target_dir):
return web.json_response({"error": "Unsafe path"}, status=403)
if not os.path.exists(target_dir) or not os.path.isdir(target_dir):
return web.json_response({"subfolder": subfolder, "folders": [], "files": []})
folders = []
files = []
supported_exts = {
".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif",
".mp4", ".webm", ".mov", ".mkv",
".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac",
".glb", ".gltf", ".obj", ".fbx", ".stl",
".json"
}
if search:
# Recursive search in current directory and all subfolders
for root, dirs, fnames in os.walk(target_dir):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for fname in fnames:
if fname.startswith("."):
continue
ext = os.path.splitext(fname)[1].lower()
if ext in supported_exts:
fpath = os.path.join(root, fname)
matched = search in fname.lower()
if not matched and ext == ".png":
matched = check_png_prompt_match(fpath, search)
if matched:
rel_sub = os.path.relpath(root, output_dir).replace("\\", "/")
if rel_sub == ".":
rel_sub = ""
stat = os.stat(fpath)
files.append({
"filename": fname,
"subfolder": rel_sub,
"type": "output",
"mtime": stat.st_mtime,
"size": stat.st_size
})
else:
# Instant single-directory listing without reading files from disk
with os.scandir(target_dir) as entries:
for entry in entries:
if entry.name.startswith("."):
continue
try:
if entry.is_dir(follow_symlinks=False):
rel_sub = os.path.relpath(entry.path, output_dir).replace("\\", "/")
folders.append({
"name": entry.name,
"subfolder": rel_sub,
"mtime": entry.stat().st_mtime
})
elif entry.is_file(follow_symlinks=False):
ext = os.path.splitext(entry.name)[1].lower()
if ext in supported_exts:
rel_sub = os.path.relpath(os.path.dirname(entry.path), output_dir).replace("\\", "/")
if rel_sub == ".":
rel_sub = ""
stat = entry.stat()
files.append({
"filename": entry.name,
"subfolder": rel_sub,
"type": "output",
"mtime": stat.st_mtime,
"size": stat.st_size
})
except (PermissionError, FileNotFoundError):
continue
folders.sort(key=lambda x: x["name"].lower())
files.sort(key=lambda x: x["mtime"], reverse=True)
return web.json_response({
"subfolder": subfolder.replace("\\", "/"),
"folders": folders,
"files": files
})
# Scanning can touch thousands of files: keep it off the event loop so
# ComfyUI's server (websocket, queue updates) stays responsive.
cancel = threading.Event()
loop = asyncio.get_running_loop()
try:
status, payload = await loop.run_in_executor(None, _list_gallery, subfolder, search, cancel)
except asyncio.CancelledError:
cancel.set() # client went away / sent a newer search: stop scanning
raise
return web.json_response(payload, status=status)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/comfy-sidebar/queue/prompt-search")
async def queue_prompt_search_handler(request):
"""Extract searchable prompt text from Queue API-format prompt graphs."""
try:
data = await request.json()
prompts = data.get("prompts", [])
if not isinstance(prompts, list) or len(prompts) > 32:
return web.json_response({"error": "Expected up to 32 prompt graphs"}, status=400)
texts = {}
for item in prompts:
if not isinstance(item, dict):
continue
pid = item.get("pid")
graph = item.get("prompt")
if pid is None or not isinstance(graph, dict):
continue
texts[str(pid)] = prompt_search.get_prompt_search_text_from_graph(graph)
return web.json_response({"texts": texts})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@PromptServer.instance.routes.post("/comfy-sidebar/open-folder")
async def open_folder_handler(request):
try:
@@ -286,7 +326,7 @@ async def delete_file_handler(request):
]
delete_roots = [os.path.realpath(r) for r in delete_roots if r and os.path.exists(r)]
target_file, root = resolve_existing_path(filename, subfolder, folder_type, candidate_roots=delete_roots)
target_file, root = resolve_existing_path(filename, subfolder, folder_type, candidate_roots=delete_roots, strict=True)
if not target_file or not os.path.isfile(target_file) or not is_path_safe(root, target_file):
return web.json_response({"error": "File not found or outside output directory"}, status=404)
+1
View File
@@ -25,6 +25,7 @@ export const SettingIds = Object.freeze({
OVERRIDE_STOCK_HISTORY: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
HIDE_GRAPH_BUTTON: "Comfy Sidebar.Hide Junk.Graph Button",
CLASSIC_LAYOUT: "Comfy Sidebar.Comfy Layout",
LATIN_HOTKEYS: "Comfy Sidebar.Latin Hotkeys On Any Layout",
MAX_HISTORY_ITEMS: "Comfy.Queue.MaxHistoryItems",
DOCK_QUEUE: "Comfy.Queue.QPOV2",
SHOW_PROGRESS_BAR: "Comfy.Queue.ShowRunProgressBar",
+1
View File
@@ -32,6 +32,7 @@ export const PersistenceAdapter = {
status: state.status,
images: cleanedImages,
texts: state.texts || [],
promptSearchText: state.promptSearchText,
nodeOutputs: state.nodeOutputs || {},
nodeTitles: state.nodeTitles || {}, // Always preserved across restarts
workflow: includeWorkflow ? state.workflow : null,
+1
View File
@@ -6,6 +6,7 @@ export function createPromptState(pid, initial = {}) {
status: initial.status || PromptStatus.PENDING,
images: Array.isArray(initial.images) ? [...initial.images] : [],
texts: Array.isArray(initial.texts) ? [...initial.texts] : [],
promptSearchText: typeof initial.promptSearchText === "string" ? initial.promptSearchText : null,
nodeOutputs: initial.nodeOutputs ? { ...initial.nodeOutputs } : {},
nodeTitles: initial.nodeTitles ? { ...initial.nodeTitles } : {},
progress: typeof initial.progress === "number" ? initial.progress : 0,
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "افتراضي",
"Dock": "إرساء",
"Hide": "إخفاء",
"Enable Output Gallery Sidebar": "تمكين الشريط الجانبي لمعرض المخرجات"
"Enable Output Gallery Sidebar": "تمكين الشريط الجانبي لمعرض المخرجات",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "إصلاح اختصارات المفاتيح غير ASCII (اختصارات تعمل على أي تخطيط للوحة المفاتيح)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "يعامل الأحرف غير اللاتينية (مثل السيريلية) على أنها المفتاح اللاتيني في الموضع الفعلي نفسه، فتعمل الاختصارات مثل Q أو Ctrl+Z عند تفعيل تخطيط آخر. ولا تتأثر الكتابة في حقول النص."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "Default",
"Dock": "Dock",
"Hide": "Hide",
"Enable Output Gallery Sidebar": "Enable Output Gallery Sidebar"
"Enable Output Gallery Sidebar": "Enable Output Gallery Sidebar",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Fix non-ASCII keybinds (hotkeys on any keyboard layout)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "Por defecto",
"Dock": "Acoplar",
"Hide": "Ocultar",
"Enable Output Gallery Sidebar": "Habilitar barra lateral de galería de salidas"
"Enable Output Gallery Sidebar": "Habilitar barra lateral de galería de salidas",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Corregir atajos con caracteres no ASCII (atajos en cualquier distribución de teclado)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Trata las letras no latinas (p. ej., cirílico) como la tecla latina en la misma posición física, de modo que atajos como Q o Ctrl+Z funcionen con otra distribución activa. No afecta a la escritura en campos de texto."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "پیش‌فرض",
"Dock": "اتصال",
"Hide": "پنهان",
"Enable Output Gallery Sidebar": "فعال‌سازی نوار کناری گالری خروجی‌ها"
"Enable Output Gallery Sidebar": "فعال‌سازی نوار کناری گالری خروجی‌ها",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "رفع مشکل کلیدهای میانبر غیر ASCII (میانبرها در هر چیدمان صفحه‌کلید)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "حروف غیر لاتین (مثلاً سیریلیک) را معادل کلید لاتین در همان موقعیت فیزیکی در نظر می‌گیرد تا میانبرهایی مانند Q یا Ctrl+Z هنگام فعال بودن چیدمان دیگر نیز کار کنند. تایپ در فیلدهای متنی تحت تأثیر قرار نمی‌گیرد."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "Par défaut",
"Dock": "Ancrer",
"Hide": "Masquer",
"Enable Output Gallery Sidebar": "Activer la barre latérale de la galerie de sorties"
"Enable Output Gallery Sidebar": "Activer la barre latérale de la galerie de sorties",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Corriger les raccourcis non ASCII (raccourcis sur toutes les dispositions de clavier)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Traite les lettres non latines (p. ex. cyrillique) comme la touche latine située au même emplacement physique, afin que des raccourcis comme Q ou Ctrl+Z fonctionnent avec une autre disposition active. La saisie dans les champs de texte n'est pas affectée."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "ברירת מחדל",
"Dock": "הצמד",
"Hide": "הסתר",
"Enable Output Gallery Sidebar": "הפעל סרגל צד של גלריית תוצרים"
"Enable Output Gallery Sidebar": "הפעל סרגל צד של גלריית תוצרים",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "תיקון קיצורי מקשים שאינם ASCII (קיצורים בכל פריסת מקלדת)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "מתייחס לאותיות שאינן לטיניות (למשל קירילית) כמקש הלטיני באותו מיקום פיזי, כך שקיצורים כמו Q או Ctrl+Z פועלים גם כשפריסה אחרת פעילה. ההקלדה בשדות טקסט אינה מושפעת."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "Predefinito",
"Dock": "Aggancia",
"Hide": "Nascondi",
"Enable Output Gallery Sidebar": "Abilita barra laterale galleria di output"
"Enable Output Gallery Sidebar": "Abilita barra laterale galleria di output",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Correggi le scorciatoie non ASCII (scorciatoie con qualsiasi layout di tastiera)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Considera le lettere non latine (ad es. cirillico) come il tasto latino nella stessa posizione fisica, così scorciatoie come Q o Ctrl+Z funzionano anche con un altro layout attivo. La digitazione nei campi di testo non è interessata."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "デフォルト",
"Dock": "ドック",
"Hide": "非表示",
"Enable Output Gallery Sidebar": "出力ギャラリーのサイドバーを有効化"
"Enable Output Gallery Sidebar": "出力ギャラリーのサイドバーを有効化",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "非ASCIIのキーバインドを修正(どのキーボード配列でもショートカットを使用)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "ラテン文字以外の文字(例:キリル文字)を同じ物理位置のラテン文字キーとして扱い、別の配列が有効な状態でも Q や Ctrl+Z などのショートカットが動作するようにします。テキスト入力欄での入力には影響しません。"
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "기본값",
"Dock": "도킹",
"Hide": "숨기기",
"Enable Output Gallery Sidebar": "출력 갤러리 사이드바 활성화"
"Enable Output Gallery Sidebar": "출력 갤러리 사이드바 활성화",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "비ASCII 단축키 수정(모든 키보드 배열에서 단축키 사용)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "라틴 문자가 아닌 글자(예: 키릴 문자)를 같은 물리적 위치의 라틴 키로 처리하여, 다른 자판 배열이 활성화되어 있어도 Q, Ctrl+Z 같은 단축키가 작동합니다. 텍스트 입력란에서의 입력에는 영향을 주지 않습니다."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "Padrão",
"Dock": "Fixar",
"Hide": "Ocultar",
"Enable Output Gallery Sidebar": "Ativar barra lateral da galeria de saídas"
"Enable Output Gallery Sidebar": "Ativar barra lateral da galeria de saídas",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Corrigir atalhos não ASCII (atalhos em qualquer layout de teclado)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Trata letras não latinas (por exemplo, cirílico) como a tecla latina na mesma posição física, para que atalhos como Q ou Ctrl+Z funcionem com outro layout ativo. A digitação em campos de texto não é afetada."
}
+3 -2
View File
@@ -22,7 +22,6 @@
"Hide floating 'Graph' (Workflow/Node Map) button": "Скрыть плавающую кнопку 'Граф' (карта нод)",
"Hide Avatar": "Скрыть аватар",
"Floating Canvas Controls": "Плавающие элементы управления",
"Classic Layout": "Классический интерфейс",
"Places the controls and the open workflow tabs on a single unified top bar": "Разместить элементы управления и открытые вкладки на единой верхней панели",
"Comfy Layout": "Компактный интерфейс",
"Classic Layout": "Компактный интерфейс",
@@ -30,5 +29,7 @@
"Default": "По умолчанию",
"Dock": "Закрепить",
"Hide": "Скрыть",
"Enable Output Gallery Sidebar": "Включить боковую панель галереи результатов"
"Enable Output Gallery Sidebar": "Включить боковую панель галереи результатов",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "Исправить не-ASCII горячие клавиши (работа в любой раскладке)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Нелатинские буквы (например, кириллица) считаются латинской клавишей в том же физическом положении, поэтому горячие клавиши вроде Q или Ctrl+Z работают при любой активной раскладке. Ввод текста в полях не затрагивается."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "Varsayılan",
"Dock": "Sabitle",
"Hide": "Gizle",
"Enable Output Gallery Sidebar": "Çıktı Galerisi Kenar Çubuğunu Etkinleştir"
"Enable Output Gallery Sidebar": "Çıktı Galerisi Kenar Çubuğunu Etkinleştir",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "ASCII olmayan kısayolları düzelt (her klavye düzeninde kısayollar)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "Latin olmayan harfleri (örn. Kiril) aynı fiziksel konumdaki Latin tuş olarak kabul eder; böylece başka bir düzen etkinken Q veya Ctrl+Z gibi kısayollar çalışır. Metin alanlarına yazma etkilenmez."
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "預設",
"Dock": "停靠",
"Hide": "隱藏",
"Enable Output Gallery Sidebar": "啟用輸出圖庫側邊欄"
"Enable Output Gallery Sidebar": "啟用輸出圖庫側邊欄",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "修正非 ASCII 快速鍵(任何鍵盤配置下皆可使用)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "將非拉丁字母(如西里爾字母)視為相同實體位置的拉丁按鍵,因此在使用其他鍵盤配置時,Q 或 Ctrl+Z 等快速鍵也能正常運作。在文字欄位中輸入不受影響。"
}
+3 -1
View File
@@ -29,5 +29,7 @@
"Default": "默认",
"Dock": "停靠",
"Hide": "隐藏",
"Enable Output Gallery Sidebar": "启用输出图库侧边栏"
"Enable Output Gallery Sidebar": "启用输出图库侧边栏",
"Fix non-ASCII keybinds (hotkeys on any keyboard layout)": "修复非 ASCII 快捷键(任意键盘布局下可用)",
"Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected.": "将非拉丁字母(如西里尔字母)视为同一物理位置的拉丁按键,这样在启用其他键盘布局时,Q 或 Ctrl+Z 等快捷键也能正常使用。在文本框中输入不受影响。"
}
+2 -1
View File
@@ -25,7 +25,8 @@ export function normalizeQueueItem(p, defaultIndex = 0) {
pid,
seq,
workflow: parseWorkflow(rawWorkflow),
outputs
outputs,
promptGraph: Array.isArray(p) ? (p[2] || null) : (p.prompt || null)
};
}
+48
View File
@@ -27,6 +27,46 @@ export function extractPromptOutputs(outputs, workflow, promptGraph = null) {
};
}
async function updatePromptSearchTexts(entries) {
const pending = entries.filter(({ pid, promptGraph }) => {
const state = store.getPrompt(pid);
return promptGraph && typeof promptGraph === "object" && state && state.promptSearchText === null;
});
if (pending.length === 0) return;
// Keep each request comfortably below aiohttp's default 1 MB body limit.
const batches = [];
let batch = [];
let batchSize = 0;
for (const entry of pending) {
const itemSize = JSON.stringify(entry).length * 3; // Conservative UTF-8 byte estimate
if (batch.length > 0 && (batch.length >= 32 || batchSize + itemSize > 512 * 1024)) {
batches.push(batch);
batch = [];
batchSize = 0;
}
batch.push(entry);
batchSize += itemSize;
}
if (batch.length > 0) batches.push(batch);
for (const prompts of batches) {
try {
const response = await api.fetchApi("/comfy-sidebar/queue/prompt-search", {
method: "POST",
body: JSON.stringify({ prompts: prompts.map(({ pid, promptGraph }) => ({ pid, prompt: promptGraph })) })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
for (const [pid, text] of Object.entries(data.texts || {})) {
if (store.hasPrompt(pid)) store.updatePrompt(pid, { promptSearchText: String(text || "") });
}
} catch (err) {
console.warn("Comfy Sidebar: Failed to extract Queue prompt search text", err);
}
}
}
export async function syncQueue() {
try {
const q = await api.getQueue();
@@ -35,12 +75,14 @@ export async function syncQueue() {
const runningIds = new Set();
const pendingIds = new Set();
const promptGraphs = [];
runningList.forEach((raw, idx) => {
const item = normalizeQueueItem(raw, idx);
if (!item.pid) return;
runningIds.add(item.pid);
if (item.promptGraph) promptGraphs.push({ pid: item.pid, promptGraph: item.promptGraph });
store.setActivePromptId(item.pid);
const existing = store.getPrompt(item.pid);
@@ -84,6 +126,7 @@ export async function syncQueue() {
normalizedPending.forEach((item, index) => {
const number = index + 1;
pendingIds.add(item.pid);
if (item.promptGraph) promptGraphs.push({ pid: item.pid, promptGraph: item.promptGraph });
const existing = store.getPrompt(item.pid);
if (!existing) {
@@ -108,6 +151,8 @@ export async function syncQueue() {
}
});
await updatePromptSearchTexts(promptGraphs);
for (const prompt of store.getAllPrompts()) {
if (prompt.status === PromptStatus.PENDING && !pendingIds.has(prompt.pid)) {
store.deletePrompt(prompt.pid);
@@ -153,6 +198,7 @@ export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED)
state.status = norm.status;
const promptGraph = hItem[key].prompt?.[2] || null;
await updatePromptSearchTexts([{ pid: key, promptGraph }]);
const { images: primaryImgs, texts: fetchedTexts, nodeTitles: titles } = extractPromptOutputs(norm.outputs, state.workflow, promptGraph);
Object.assign(state.nodeTitles, titles);
@@ -257,6 +303,8 @@ export async function initSessionAndHistory() {
store.setPrompt(item.pid, newState);
});
await updatePromptSearchTexts(normalized.map(({ norm, promptGraph }) => ({ pid: norm.pid, promptGraph })));
const maxItems = getSetting(SettingIds.MAX_HISTORY_ITEMS, 64);
store.pruneHistory(maxItems);
await syncQueue();
+19 -13
View File
@@ -2,8 +2,9 @@ import { app } from "/scripts/app.js";
import { SettingIds } from "./core/constants.js";
import { injectStyles } from "./utils/styles.js";
import { setupDragAndDrop } from "./utils/dragdrop.js";
import { setupLatinHotkeys } from "./utils/latinHotkeys.js";
import { renumberNodesTopologically, setManualNodeId } from "./utils/nodeRenumber.js";
import { findOurSidebarButton, applySidebarOverride } from "./comfy/adapter.js";
import { applySidebarOverride } from "./comfy/adapter.js";
import { syncAllNodeBadges, setupVueNodeObserver, toggleIgnoreActiveNode } from "./comfy/nodes.js";
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./comfy/layout.js";
import { initSessionAndHistory, syncQueue } from "./queue/queueService.js";
@@ -30,18 +31,6 @@ function setupKeydownShortcuts() {
activeEl.tagName === "SELECT"
)) return;
// Q: Toggle Queue Sidebar
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
if (app.extensionManager?.command?.hasCommand?.("Workspace.ToggleSidebarTab.classic-comfy-sidebar")) {
app.extensionManager.command.execute("Workspace.ToggleSidebarTab.classic-comfy-sidebar");
} else {
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
}
// Ctrl+Q / Cmd+Q: Toggle Ignore Selected Node
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
@@ -183,6 +172,14 @@ function registerSettings() {
}
});
app.ui.settings.addSetting({
id: SettingIds.LATIN_HOTKEYS,
name: t("Fix non-ASCII keybinds (hotkeys on any keyboard layout)"),
tooltip: t("Treats non-Latin letters (e.g. Cyrillic) as the Latin key in the same physical position, so hotkeys like Q or Ctrl+Z work while another layout is active. Typing in text fields is not affected."),
type: "boolean",
defaultValue: true
});
app.ui.settings.addSetting({
id: SettingIds.CLASSIC_LAYOUT,
name: t("Places the controls and the open workflow tabs on a single unified top bar"),
@@ -293,6 +290,14 @@ app.registerExtension({
registerSettings();
},
// Plain Q toggles the Queue tab through the frontend keybinding system (rebindable in Settings > Keybinding).
keybindings: [
{
combo: { key: "q" },
commandId: "Workspace.ToggleSidebarTab.classic-comfy-sidebar"
}
],
commands: [
{
id: "ComfySidebar.ToggleIgnoreNode",
@@ -371,6 +376,7 @@ app.registerExtension({
cleanupFns.push(setupWorkflowTabsPositionWatcher());
cleanupFns.push(setupDragAndDrop());
cleanupFns.push(setupLatinHotkeys());
cleanupFns.push(setupVueNodeObserver());
cleanupFns.push(setupExecutionTracker((pid, progress, nodeName) => {
+70 -23
View File
@@ -17,6 +17,16 @@ let stickyHeaderGroupEl = null;
let lastScrollTop = 0;
let scrollDeltaAccumulator = 0;
let isHeaderHidden = false;
let galleryResizeObserver = null;
// Module-level so teardownGalleryView() can remove the exact same references
const handleCtrlDown = (e) => {
if (e.key === "Control" || e.key === "Meta") document.body.classList.add("comfy-sidebar-ctrl-active");
};
const handleCtrlUp = (e) => {
if (e.key === "Control" || e.key === "Meta") document.body.classList.remove("comfy-sidebar-ctrl-active");
};
const handleCtrlBlur = () => document.body.classList.remove("comfy-sidebar-ctrl-active");
// Persistent sort order
let sortOrder = "newest";
@@ -45,6 +55,14 @@ function updateScrollTopVisibility() {
}
const scrollEl = getScrollContainer(cardStackEl);
if (scrollEl) {
const viewportEl = scrollEl.parentElement || galleryContainerEl;
if (viewportEl && scrollToTopBtnEl && scrollToTopBtnEl.parentNode !== viewportEl) {
if (window.getComputedStyle(viewportEl).position === "static") {
viewportEl.style.position = "relative";
}
viewportEl.appendChild(scrollToTopBtnEl);
}
if (scrollToTopBtnEl) {
scrollToTopBtnEl.style.display = scrollEl.scrollTop > 200 ? "flex" : "none";
}
@@ -61,15 +79,21 @@ function updateScrollTopVisibility() {
function handleHeaderScroll(scrollEl) {
if (!stickyHeaderGroupEl) return;
// Never hide header if search bar is currently active
const currentScroll = scrollEl.scrollTop;
const searchContainer = stickyHeaderGroupEl.querySelector(".comfy-sidebar-search-container");
if (currentSearchQuery || (searchContainer && searchContainer.style.display !== "none")) {
const searchIsOpen = currentSearchQuery || (searchContainer && searchContainer.style.display !== "none");
// Keep the frosted surface in sync even while search forces the header to stay visible.
stickyHeaderGroupEl.classList.toggle("is-scrolled", currentScroll > 35);
if (searchIsOpen) {
lastScrollTop = currentScroll;
scrollDeltaAccumulator = 0;
stickyHeaderGroupEl.style.transform = "translateY(0)";
isHeaderHidden = false;
return;
}
const currentScroll = scrollEl.scrollTop;
const delta = currentScroll - lastScrollTop;
lastScrollTop = currentScroll;
@@ -107,14 +131,23 @@ function handleHeaderScroll(scrollEl) {
}
}
let galleryFetchController = null;
// Returns false only when the request was superseded by a newer one (aborted),
// so callers can skip rendering stale results.
async function fetchGalleryData(subfolder = "", searchQuery = "") {
// A newer request (e.g. the next keystroke in the search box) cancels the in-flight one;
// the server stops scanning as soon as the client disconnects.
if (galleryFetchController) galleryFetchController.abort();
const controller = new AbortController();
galleryFetchController = controller;
try {
isLoading = true;
const normalized = subfolder.replace(/\\/g, "/");
const queryParams = new URLSearchParams({ subfolder: normalized });
if (searchQuery) queryParams.set("search", searchQuery);
const res = await fetch(`/comfy-sidebar/gallery/list?${queryParams.toString()}`);
const res = await fetch(`/comfy-sidebar/gallery/list?${queryParams.toString()}`, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
cachedFolders = data.folders || [];
@@ -123,11 +156,16 @@ async function fetchGalleryData(subfolder = "", searchQuery = "") {
applySorting();
displayedFilesCount = 0;
} catch (e) {
if (e?.name === "AbortError") return false;
console.error("[ComfySidebar] Failed to load gallery", e);
showNotification(t("Failed to load gallery from disk."), "error");
} finally {
isLoading = false;
if (galleryFetchController === controller) {
galleryFetchController = null;
isLoading = false;
}
}
return true;
}
function applySorting() {
@@ -588,6 +626,7 @@ export function setupGalleryView() {
};
let searchDebounce = null;
let searchSeq = 0;
const closeSearch = () => {
const hadFilter = Boolean(currentSearchQuery || sInput.value.trim());
sInput.value = "";
@@ -618,12 +657,15 @@ export function setupGalleryView() {
sIcon.className = "pi pi-spin pi-spinner";
const seq = ++searchSeq;
searchDebounce = setTimeout(async () => {
let applied = false;
try {
await fetchGalleryData(currentSubfolder, currentSearchQuery);
renderGallery();
applied = (await fetchGalleryData(currentSubfolder, currentSearchQuery)) !== false;
if (applied) renderGallery();
} finally {
sIcon.className = "pi pi-search";
// Only the latest search may stop the spinner
if (seq === searchSeq) sIcon.className = "pi pi-search";
}
}, 350);
};
@@ -636,19 +678,13 @@ export function setupGalleryView() {
const cardStack = document.createElement("div");
cardStackEl = cardStack;
Object.assign(cardStack.style, {
flex: "1", overflowY: "visible", display: "flex", flexDirection: "column", paddingBottom: "28px"
flex: "1", overflowY: "visible", display: "flex", flexDirection: "column", paddingBottom: "46px"
});
container.appendChild(cardStack);
scrollToTopBtnEl = document.createElement("button");
scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
scrollToTopBtnEl.title = t("Scroll to Top");
Object.assign(scrollToTopBtnEl.style, {
position: "sticky",
bottom: "14px",
marginLeft: "auto",
zIndex: "30"
});
scrollToTopBtnEl.onclick = (e) => {
e.stopPropagation();
const scrollEl = getScrollContainer(cardStackEl);
@@ -656,28 +692,24 @@ export function setupGalleryView() {
};
container.appendChild(scrollToTopBtnEl);
const handleCtrlDown = (e) => {
if (e.key === "Control" || e.key === "Meta") document.body.classList.add("comfy-sidebar-ctrl-active");
};
const handleCtrlUp = (e) => {
if (e.key === "Control" || e.key === "Meta") document.body.classList.remove("comfy-sidebar-ctrl-active");
};
window.addEventListener("keydown", handleCtrlDown);
window.addEventListener("keyup", handleCtrlUp);
window.addEventListener("blur", handleCtrlBlur);
// Reveal Spotlight: track cursor position over Gallery card and folder borders
setupSpotlightTracker(container, ".comfy-sidebar-card, .comfy-sidebar-folder-card");
document.addEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true });
new ResizeObserver((entries) => {
galleryResizeObserver = new ResizeObserver((entries) => {
const threshold = getSetting(SettingIds.GRID_COLUMNS_THRESHOLD, 350);
const cols = calculateGridColumns(entries[0].contentRect.width, threshold);
if (cols !== currentColumnCount) {
currentColumnCount = cols;
renderGallery();
}
}).observe(container);
});
galleryResizeObserver.observe(container);
fetchGalleryData("").then(() => renderGallery());
@@ -686,10 +718,25 @@ export function setupGalleryView() {
export function teardownGalleryView() {
document.removeEventListener("scroll", updateScrollTopVisibility, { capture: true, passive: true });
window.removeEventListener("keydown", handleCtrlDown);
window.removeEventListener("keyup", handleCtrlUp);
window.removeEventListener("blur", handleCtrlBlur);
document.body.classList.remove("comfy-sidebar-ctrl-active");
if (galleryResizeObserver) {
galleryResizeObserver.disconnect();
galleryResizeObserver = null;
}
// Reset header-reveal state so a re-opened gallery starts with a visible header
lastScrollTop = 0;
scrollDeltaAccumulator = 0;
isHeaderHidden = false;
cachedFolders = [];
cachedFiles = [];
stickyHeaderGroupEl = null;
galleryContainerEl = null;
cardStackEl = null;
if (scrollToTopBtnEl && scrollToTopBtnEl.parentNode) {
scrollToTopBtnEl.parentNode.removeChild(scrollToTopBtnEl);
}
scrollToTopBtnEl = null;
}
+69 -2
View File
@@ -17,6 +17,10 @@ let globalClickRegistered = false;
let renderAnimationFrameId = null;
let activeTimerInterval = null;
let currentColumnCount = 1;
let stickyHeaderGroupEl = null;
let lastScrollTop = 0;
let scrollDeltaAccumulator = 0;
let isHeaderHidden = false;
function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
if (!cardStack) return;
@@ -91,6 +95,58 @@ function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
}
}
function handleQueueHeaderScroll(scrollEl) {
if (!stickyHeaderGroupEl) return;
const currentScroll = scrollEl.scrollTop;
const searchContainer = stickyHeaderGroupEl.querySelector(".comfy-sidebar-search-container");
const searchIsOpen = searchContainer && searchContainer.style.display !== "none";
// Keep the frosted surface visible while search is open, matching Gallery.
stickyHeaderGroupEl.classList.toggle("is-scrolled", currentScroll > 35);
if (searchIsOpen) {
lastScrollTop = currentScroll;
scrollDeltaAccumulator = 0;
stickyHeaderGroupEl.style.transform = "translateY(0)";
isHeaderHidden = false;
return;
}
const delta = currentScroll - lastScrollTop;
lastScrollTop = currentScroll;
if (currentScroll <= 35) {
stickyHeaderGroupEl.classList.remove("is-scrolled");
if (isHeaderHidden) {
stickyHeaderGroupEl.style.transform = "translateY(0)";
isHeaderHidden = false;
}
scrollDeltaAccumulator = 0;
return;
}
stickyHeaderGroupEl.classList.add("is-scrolled");
if (delta > 0) {
if (scrollDeltaAccumulator < 0) scrollDeltaAccumulator = 0;
scrollDeltaAccumulator += delta;
if (scrollDeltaAccumulator > 25 && !isHeaderHidden) {
stickyHeaderGroupEl.style.transform = "translateY(-100%)";
isHeaderHidden = true;
}
} else if (delta < 0) {
if (scrollDeltaAccumulator > 0) scrollDeltaAccumulator = 0;
scrollDeltaAccumulator += delta;
if (scrollDeltaAccumulator < -15 && isHeaderHidden) {
stickyHeaderGroupEl.style.transform = "translateY(0)";
isHeaderHidden = false;
}
}
}
function updateScrollTopBtnVisibility() {
if (!scrollToTopBtnEl) return;
if (!store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) {
@@ -112,6 +168,8 @@ function updateScrollTopBtnVisibility() {
} else {
scrollToTopBtnEl.style.display = "none";
}
handleQueueHeaderScroll(scrollEl);
}
}
@@ -305,7 +363,12 @@ export function setupSidebarView() {
header.appendChild(standardHeader);
header.appendChild(searchContainer);
container.appendChild(header);
const stickyHeaderGroup = document.createElement("div");
stickyHeaderGroup.className = "comfy-sidebar-sticky-header-group";
stickyHeaderGroupEl = stickyHeaderGroup;
stickyHeaderGroup.appendChild(header);
container.appendChild(stickyHeaderGroup);
searchIcon.addEventListener("click", (e) => {
e.stopPropagation();
@@ -336,7 +399,7 @@ export function setupSidebarView() {
overflowY: "visible",
display: "flex",
flexDirection: "column",
paddingBottom: "28px"
paddingBottom: "46px"
});
container.appendChild(cardStack);
@@ -997,4 +1060,8 @@ export function teardownSidebarView() {
renderAnimationFrameId = null;
}
cardPool.clear();
if (scrollToTopBtnEl && scrollToTopBtnEl.parentNode) {
scrollToTopBtnEl.parentNode.removeChild(scrollToTopBtnEl);
}
scrollToTopBtnEl = null;
}
+19 -6
View File
@@ -328,6 +328,7 @@ function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () =>
const createMediaElement = (src, muted = false) => {
const isVideo = isVideoFormat(src);
const el = isVideo ? document.createElement("video") : document.createElement("img");
el.className = "comfy-sidebar-comparison-media";
if (isVideo) {
el.muted = muted;
el.playsInline = true;
@@ -345,7 +346,10 @@ const createMediaElement = (src, muted = false) => {
pointerEvents: isVideo ? "none" : "auto",
userSelect: "none",
webkitUserSelect: "none",
display: "block"
display: "block",
border: "none",
outline: "none",
boxShadow: "none"
});
el.draggable = false;
el.ondragstart = (e) => e.preventDefault();
@@ -576,21 +580,30 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
}
const slider = document.createElement("div");
slider.className = "comfy-sidebar-comparison-slider";
Object.assign(slider.style, {
position: "absolute", top: "0", bottom: "0", left: "50%",
width: "2px", background: "#fff", cursor: "ew-resize",
width: "2px",
background: "var(--foreground, #ffffff)",
cursor: "ew-resize",
zIndex: "25", display: "none", pointerEvents: "auto",
boxShadow: "0 0 8px rgba(0,0,0,0.5)"
boxShadow: "0 0 6px rgba(0,0,0,0.6)"
});
const sliderButton = document.createElement("div");
sliderButton.className = "comfy-sidebar-comparison-handle";
Object.assign(sliderButton.style, {
position: "absolute", top: "50%", left: "50%",
transform: "translate(-50%, -50%)", width: "32px", height: "32px",
borderRadius: "50%", background: "#fff", color: "#333",
borderRadius: "50%",
background: "var(--card, var(--secondary, rgba(30, 30, 30, 0.9)))",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
color: "var(--foreground, #eee)",
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", pointerEvents: "auto"
boxShadow: "0 4px 12px rgba(0,0,0,0.5)", pointerEvents: "auto",
cursor: "ew-resize",
transition: "background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, color 0.15s ease-in-out"
});
sliderButton.innerHTML = `<span class="pi pi-arrows-h" style="font-size: 12px;"></span>`;
sliderButton.innerHTML = `<span class="pi pi-arrows-h" style="font-size: 13px; pointer-events: none;"></span>`;
slider.appendChild(sliderButton);
wrapper.appendChild(slider);
+126
View File
@@ -0,0 +1,126 @@
import { SettingIds } from "../core/constants.js";
import { getSetting } from "../core/settings.js";
/**
* Makes hotkeys work regardless of the active keyboard layout.
*
* ComfyUI's keybinding system matches on `KeyboardEvent.key`, which follows the active layout:
* with a Russian layout, physical Q produces "й", so a binding on "q" never fires.
* `KeyboardEvent.code` is the physical key ("KeyQ") and does not change with the layout.
*
* Strategy: in the capture phase on `window` (which runs before ComfyUI's own `keydown` listener),
* if `key` is a single non-ASCII character, swallow the event and re-dispatch an identical one
* whose `key` is the US-QWERTY character for that physical key. Everything downstream (keybindings,
* LiteGraph shortcuts, other extensions) then sees plain Latin keys.
*
* Deliberately NOT touched:
* - layouts that already report ASCII keys (QWERTY, AZERTY, Dvorak...), so their users keep
* their own mapping;
* - text fields (input / textarea / select / contenteditable / LiteGraph property values),
* so typing in any language keeps working;
* - IME composition;
* - named keys such as "Enter", "Escape", "Dead".
*/
// Unshifted / shifted characters of the US-QWERTY layout for non-letter keys.
const SYMBOL_KEYS = {
Backquote: ["`", "~"],
Minus: ["-", "_"],
Equal: ["=", "+"],
BracketLeft: ["[", "{"],
BracketRight: ["]", "}"],
Backslash: ["\\", "|"],
Semicolon: [";", ":"],
Quote: ["'", "\""],
Comma: [",", "<"],
Period: [".", ">"],
Slash: ["/", "?"],
Digit1: ["1", "!"],
Digit2: ["2", "@"],
Digit3: ["3", "#"],
Digit4: ["4", "$"],
Digit5: ["5", "%"],
Digit6: ["6", "^"],
Digit7: ["7", "&"],
Digit8: ["8", "*"],
Digit9: ["9", "("],
Digit0: ["0", ")"]
};
/** Returns the US-QWERTY `key` for the physical key of this event, or null if unknown. */
export function latinKeyFromEvent(e) {
const letter = /^Key([A-Z])$/.exec(e.code || "");
if (letter) return e.shiftKey ? letter[1] : letter[1].toLowerCase();
const pair = SYMBOL_KEYS[e.code];
if (pair) return e.shiftKey ? pair[1] : pair[0];
return null;
}
function isSingleNonAsciiChar(key) {
if (typeof key !== "string") return false;
const chars = Array.from(key);
return chars.length === 1 && chars[0].codePointAt(0) > 127;
}
function isTextEntryTarget(el) {
if (!el || el.nodeType !== 1) return false;
const tag = el.tagName;
return (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
el.isContentEditable ||
// LiteGraph's inline value editors are spans
(tag === "SPAN" && el.classList.contains("property_value"))
);
}
export function setupLatinHotkeys() {
// Events we dispatched ourselves; used to avoid handling them again.
const synthetic = new WeakSet();
const handler = (e) => {
if (synthetic.has(e)) return;
if (e.isComposing || e.keyCode === 229) return;
if (!isSingleNonAsciiChar(e.key)) return;
if (!getSetting(SettingIds.LATIN_HOTKEYS, true)) return;
const latinKey = latinKeyFromEvent(e);
if (!latinKey) return;
const target = e.composedPath?.()[0] ?? e.target;
if (isTextEntryTarget(target)) return;
const clone = new KeyboardEvent(e.type, {
key: latinKey,
code: e.code,
location: e.location,
repeat: e.repeat,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
metaKey: e.metaKey,
keyCode: e.keyCode,
which: e.which,
bubbles: true,
cancelable: true,
composed: true,
view: window
});
synthetic.add(clone);
e.stopImmediatePropagation();
const notPrevented = (target || document).dispatchEvent(clone);
if (!notPrevented) e.preventDefault();
};
window.addEventListener("keydown", handler, true);
window.addEventListener("keyup", handler, true);
return () => {
window.removeEventListener("keydown", handler, true);
window.removeEventListener("keyup", handler, true);
};
}
+13 -1
View File
@@ -83,7 +83,13 @@ export class SidebarOverlay {
// Live observer for opening/closing panels or layout changes
if (window.MutationObserver) {
let updateScheduled = false;
const mo = new MutationObserver(() => {
const mo = new MutationObserver((mutations) => {
// Skip mutations caused purely by the sticky header's scroll animation
const relevant = mutations.some((m) => {
const el = m.target.nodeType === 1 ? m.target : m.target.parentElement;
return !el?.closest?.(".comfy-sidebar-sticky-header-group");
});
if (!relevant) return;
if (!updateScheduled) {
updateScheduled = true;
requestAnimationFrame(() => {
@@ -118,6 +124,8 @@ export class SidebarOverlay {
}
updateOverlayBounds() {
// A pending rAF/ResizeObserver callback can fire after destroy(); don't re-attach observers
if (this.destroyed) return;
let leftOffset = 0;
let rightOffset = 0;
let topOffset = 0;
@@ -129,6 +137,8 @@ export class SidebarOverlay {
// Dynamically detect any top header/tab bar docked to the top of the screen
const potentialTopBars = document.querySelectorAll("header, .comfyui-header, .comfyui-menu, .comfy-menu, [class*='tab-bar'], [class*='workflow-tab'], [class*='workspace-tab'], [class*='header']");
for (const bar of potentialTopBars) {
// Ignore our own sliding gallery/queue header (it animates via translateY)
if (bar.closest(".comfy-sidebar-sticky-header-group, .comfy-sidebar-header-root")) continue;
// Must be an element sitting at the top of the viewport
const r = bar.getBoundingClientRect();
if (r.top <= 2 && r.bottom > 20 && r.bottom < 120 && r.width > 200) {
@@ -178,6 +188,8 @@ export class SidebarOverlay {
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
document.body.classList.remove("comfy-sidebar-overlay-active");
for (const fn of this.cleanupFns.splice(0)) {
+47 -15
View File
@@ -614,26 +614,58 @@ export function injectStyles() {
-webkit-backdrop-filter: blur(10px) !important;
}
/* Round (X) Close Button across all preview overlays (matches button, div, or span) */
body.comfy-sidebar-overlay-active [style*="50%"],
.comfy-sidebar-comparison-overlay [style*="50%"],
.comfy-sidebar-3d-overlay-root [style*="50%"],
div[class*="sidebar-overlay"] [style*="50%"] {
background: var(--card, var(--secondary, rgba(28, 28, 35, 0.9))) !important;
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.2))) !important;
color: var(--muted-foreground, #cbd5e1) !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5) !important;
/* Comparison Media: Ensures no borders or outlines appear on compared images */
.comfy-sidebar-comparison-media {
border: none !important;
outline: none !important;
box-shadow: none !important;
}
/* Comparison Slider Line & Handle */
.comfy-sidebar-comparison-slider {
background: var(--foreground, #ffffff) !important;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.6) !important;
}
.comfy-sidebar-comparison-handle {
width: 32px !important;
height: 32px !important;
border-radius: 50% !important;
background: var(--card, var(--secondary, rgba(30, 30, 30, 0.9))) !important;
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15))) !important;
color: var(--foreground, #eee) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important;
cursor: ew-resize !important;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, color 0.15s ease-in-out !important;
}
.comfy-sidebar-comparison-handle:hover {
background: var(--primary, var(--p-primary-color, #ea580c)) !important;
border-color: var(--primary, var(--p-primary-color, #ea580c)) !important;
color: #ffffff !important;
}
/* Round (X) Close Button across all preview overlays (matches buttons, ignores images/sliders) */
body.comfy-sidebar-overlay-active button[style*="50%"],
body.comfy-sidebar-overlay-active [style*="border-radius: 50%"]:not(img):not(video):not(.comfy-sidebar-comparison-handle):not(.comfy-sidebar-comparison-slider):not(.comfy-sidebar-comparison-media),
body.comfy-sidebar-overlay-active [style*="border-radius:50%"]:not(img):not(video):not(.comfy-sidebar-comparison-handle):not(.comfy-sidebar-comparison-slider):not(.comfy-sidebar-comparison-media),
.comfy-sidebar-comparison-overlay button[style*="50%"],
div[class*="sidebar-overlay"] button[style*="50%"] {
background: var(--card, var(--secondary, rgba(30, 30, 30, 0.9))) !important;
border: 1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15))) !important;
color: var(--foreground, #eee) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important;
transition: all 0.15s ease-in-out !important;
}
body.comfy-sidebar-overlay-active [style*="50%"]:hover,
.comfy-sidebar-comparison-overlay [style*="50%"]:hover,
.comfy-sidebar-3d-overlay-root [style*="50%"]:hover,
div[class*="sidebar-overlay"] [style*="50%"]:hover {
body.comfy-sidebar-overlay-active button[style*="50%"]:hover,
body.comfy-sidebar-overlay-active [style*="border-radius: 50%"]:not(img):not(video):not(.comfy-sidebar-comparison-handle):not(.comfy-sidebar-comparison-slider):not(.comfy-sidebar-comparison-media):hover,
body.comfy-sidebar-overlay-active [style*="border-radius:50%"]:not(img):not(video):not(.comfy-sidebar-comparison-handle):not(.comfy-sidebar-comparison-slider):not(.comfy-sidebar-comparison-media):hover,
.comfy-sidebar-comparison-overlay button[style*="50%"]:hover,
div[class*="sidebar-overlay"] button[style*="50%"]:hover {
color: #ffffff !important;
border-color: var(--primary, var(--p-primary-color, #ea580c)) !important;
background: color-mix(in srgb, var(--primary, var(--p-primary-color, #ea580c)) 25%, var(--card, var(--secondary, #222)) 75%) !important;
transform: scale(1.08) !important;
background: var(--primary, var(--p-primary-color, #ea580c)) !important;
}
body.comfy-sidebar-overlay-active [style*="50%"] svg,
+1
View File
@@ -426,6 +426,7 @@ export function matchesFilter(state, query) {
if (state.pid && String(state.pid).toLowerCase().includes(q)) return true;
if (state.texts && state.texts.some(t => String(t).toLowerCase().includes(q))) return true;
if (state.promptSearchText && state.promptSearchText.includes(q)) return true;
if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true;
if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true;
+199
View File
@@ -0,0 +1,199 @@
"""Extract searchable prompt text from ComfyUI prompt graphs and PNG outputs.
This module has no ComfyUI imports, so it can be used and tested on its own.
How it works
------------
* Only the PNG ``prompt`` text chunk is read. Other chunks (notably the often
multi-megabyte ``workflow`` chunk) are skipped with a seek, never parsed.
* From API-format prompt graphs (whether supplied by Queue or read from PNGs),
collect only the strings that actually
feed a text-encoder node (``CLIPTextEncode`` and friends), following links to
upstream string/primitive/concat nodes. Model names, sampler names, file
names and other string widgets are ignored.
* Extracted text is cached per file (keyed by mtime + size), so repeated
searches only pay for files that are new or changed.
"""
import json
import os
import threading
import zlib
from collections import OrderedDict
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
# Input names that can carry prompt text (substring match, lower-case).
# Deliberately does not match plain "clip", "model", "vae", "image", etc.
_PROMPT_KEY_HINTS = (
"text", "prompt", "positive", "negative", "caption",
"string", "value", "t5xxl", "clip_l", "clip_g",
)
_MAX_LINK_DEPTH = 6
_MAX_PROMPT_CHUNK_BYTES = 16 * 1024 * 1024
_MAX_TEXT_CHARS_PER_FILE = 50_000
_CACHE_MAX_ENTRIES = 20_000
_cache = OrderedDict() # path -> ((mtime_ns, size), lowercased prompt text)
_cache_lock = threading.Lock()
# --------------------------------------------------------------------------- #
# Prompt graph -> text
# --------------------------------------------------------------------------- #
def _is_prompt_key(name):
lowered = str(name).lower()
return any(hint in lowered for hint in _PROMPT_KEY_HINTS)
def _is_encoder(class_type):
lowered = str(class_type or "").lower()
return "textencode" in lowered or "prompt" in lowered
def _is_link(value):
# API-format link: [source_node_id, output_slot]
return isinstance(value, list) and len(value) == 2 and isinstance(value[1], int)
def extract_prompt_texts(prompt):
"""Return the prompt strings found in an API-format ComfyUI prompt dict."""
if not isinstance(prompt, dict):
return []
texts = []
seen = set()
def visit(node_id, depth):
key = str(node_id)
if key in seen or depth > _MAX_LINK_DEPTH:
return
seen.add(key)
node = prompt.get(key)
inputs = node.get("inputs") if isinstance(node, dict) else None
if not isinstance(inputs, dict):
return
for name, value in inputs.items():
if not _is_prompt_key(name):
continue
if isinstance(value, str):
if value.strip():
texts.append(value)
elif _is_link(value):
visit(value[0], depth + 1)
starts = [
node_id for node_id, node in prompt.items()
if isinstance(node, dict) and _is_encoder(node.get("class_type"))
]
if not starts:
# Unusual graph with no recognisable text-encoder node: fall back to
# every node, still restricted to prompt-like input names.
starts = list(prompt)
for node_id in starts:
visit(node_id, 0)
return texts
def get_prompt_search_text_from_graph(prompt):
"""Return lower-cased text-encoder prompt text from an API-format graph."""
return "\n".join(extract_prompt_texts(prompt)).lower()[:_MAX_TEXT_CHARS_PER_FILE]
# --------------------------------------------------------------------------- #
# PNG chunk reading
# --------------------------------------------------------------------------- #
def _inflate(data, limit=_MAX_PROMPT_CHUNK_BYTES):
# Bounded decompress: refuses to expand beyond `limit` bytes.
return zlib.decompressobj().decompress(data, limit)
def _decode_text_chunk(chunk_type, data, nul):
"""Decode tEXt / zTXt / iTXt chunk data (keyword already located at `nul`)."""
if chunk_type == b"tEXt":
return data[nul + 1:].decode("latin-1")
if chunk_type == b"zTXt":
if data[nul + 1] != 0: # only compression method 0 (zlib) exists
return None
return _inflate(data[nul + 2:]).decode("latin-1")
# iTXt: keyword\0 compression_flag compression_method language\0 translated\0 text
flag = data[nul + 1]
rest = data[nul + 3:]
lang_end = rest.index(b"\x00")
translated_end = rest.index(b"\x00", lang_end + 1)
body = rest[translated_end + 1:]
if flag == 1:
body = _inflate(body)
return body.decode("utf-8", errors="replace")
def read_png_prompt(fileobj):
"""Return the parsed ``prompt`` JSON from an open PNG file, or None."""
if fileobj.read(8) != _PNG_SIGNATURE:
return None
while True:
header = fileobj.read(8)
if len(header) < 8:
return None
length = int.from_bytes(header[:4], "big")
chunk_type = header[4:]
if chunk_type in (b"IDAT", b"IEND"):
return None # ComfyUI writes its text chunks before the image data
if chunk_type in (b"tEXt", b"zTXt", b"iTXt"):
head = fileobj.read(min(length, 80)) # keyword is at most 79 bytes
nul = head.find(b"\x00")
if nul != -1 and head[:nul] == b"prompt":
if length > _MAX_PROMPT_CHUNK_BYTES:
return None
data = head + fileobj.read(length - len(head))
text = _decode_text_chunk(chunk_type, data, nul)
return json.loads(text) if text else None
fileobj.seek(length - len(head) + 4, 1) # rest of data + CRC
else:
fileobj.seek(length + 4, 1)
# --------------------------------------------------------------------------- #
# Cached per-file text
# --------------------------------------------------------------------------- #
def get_prompt_search_text(path):
"""Lower-cased prompt text of a PNG ('' if none). Cached by mtime + size."""
try:
st = os.stat(path)
except OSError:
return ""
signature = (st.st_mtime_ns, st.st_size)
with _cache_lock:
hit = _cache.get(path)
if hit and hit[0] == signature:
_cache.move_to_end(path)
return hit[1]
text = ""
try:
with open(path, "rb") as f:
prompt = read_png_prompt(f)
if prompt is not None:
text = get_prompt_search_text_from_graph(prompt)
except Exception:
# Corrupt / truncated / non-ComfyUI PNG: treat as "no prompt".
text = ""
with _cache_lock:
_cache[path] = (signature, text)
_cache.move_to_end(path)
while len(_cache) > _CACHE_MAX_ENTRIES:
_cache.popitem(last=False)
return text
def clear_cache():
with _cache_lock:
_cache.clear()
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfy-sidebar"
description = "A compact and powerful layout overhaul for ComfyUI that makes generation results easier to browse, preview, compare, and manage."
version = "1.8.2"
version = "1.8.3"
license = { file = "LICENSE" }
dependencies = [
"send2trash>=1.8.0",