diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index 672faf1f..033bba03 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - python: ["3.10", "3.11"] + python: ["3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index c94929d1..578ad93d 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - python-version: ["3.10", "3.11"] + python-version: ["3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python diff --git a/README.md b/README.md index b689a7e7..6816c88f 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Backend persistence is currently limited to `probeBackend.mode` (stored in the S ## Compatibility & CI -This project officially supports Python 3.10 and 3.11. GitHub Actions runs both `ci-python.yml` and `python-tests.yml` on `ubuntu-22.04` using `actions/setup-python` so the workflow environment matches the supported interpreter pair. +This project supports Python 3.10 through 3.12, with upstream ComfyUI guidance recommending Python 3.13 (3.12 as the stable fallback). GitHub Actions now runs both `ci-python.yml` and `python-tests.yml` on `ubuntu-22.04` with `actions/setup-python@v4` targeting Python 3.12 so the runner closely matches the ComfyUI best-practice environment. Run tests across multiple Python versions locally with `tox` (requires those Python versions installed): diff --git a/backend/adapters/db/sqlite.py b/backend/adapters/db/sqlite.py index 9a810e06..aee0f027 100644 --- a/backend/adapters/db/sqlite.py +++ b/backend/adapters/db/sqlite.py @@ -100,6 +100,37 @@ _KNOWN_COLUMNS = { } _KNOWN_COLUMNS_LOWER = {c.lower(): c for c in _KNOWN_COLUMNS} +def _populate_known_columns_from_schema(db_path: Path) -> None: + global _KNOWN_COLUMNS, _KNOWN_COLUMNS_LOWER + try: + if not db_path.exists(): + return + tables = { + "assets": "a", + "asset_metadata": "m", + "scan_journal": None, + "metadata_cache": None, + } + with sqlite3.connect(str(db_path)) as conn: + cursor = conn.cursor() + for table, alias in tables.items(): + try: + cursor.execute(f"PRAGMA table_info({table})") + for row in cursor.fetchall() or []: + if not row or len(row) < 2: + continue + col = str(row[1]).strip() + if not col: + continue + _KNOWN_COLUMNS.add(col) + if alias: + _KNOWN_COLUMNS.add(f"{alias}.{col}") + except Exception: + pass + _KNOWN_COLUMNS_LOWER = {c.lower(): c for c in _KNOWN_COLUMNS} + except Exception as exc: + logger.warning("Failed to refresh known columns from schema: %s", exc) + _IN_QUERY_FORBIDDEN = re.compile( r"(--|/\*|\*/|;|\bpragma\b|\battach\b|\bdetach\b|\bvacuum\b|\balter\b|\bdrop\b|\binsert\b|\bupdate\b|\bdelete\b)", re.IGNORECASE, @@ -319,6 +350,7 @@ class Sqlite: self.db_path.parent.mkdir(parents=True, exist_ok=True) self._init_db() + _populate_known_columns_from_schema(self.db_path) def _is_locked_error(self, exc: Exception) -> bool: try: diff --git a/backend/features/index/scanner.py b/backend/features/index/scanner.py index 920b61c4..6a8fc4ae 100644 --- a/backend/features/index/scanner.py +++ b/backend/features/index/scanner.py @@ -7,6 +7,7 @@ import os import time import threading import asyncio +import sqlite3 from concurrent.futures import ThreadPoolExecutor from pathlib import Path from queue import Empty, Queue @@ -51,6 +52,14 @@ except Exception: _EXT_TO_KIND = {} +def _is_fatal_db_error(exc: Exception) -> bool: + if not isinstance(exc, sqlite3.DatabaseError): + return False + if isinstance(exc, sqlite3.BusyError): + return False + return True + + class IndexScanner: """ Handles directory scanning and file indexing operations. @@ -825,6 +834,13 @@ class IndexScanner: if not tx.ok: raise RuntimeError(tx.error or "Commit failed") except Exception as batch_error: + if _is_fatal_db_error(batch_error): + logger.error( + "Batch transaction failed with fatal database error (%s); aborting fallback.", + type(batch_error).__name__, + exc_info=batch_error, + ) + raise # If the entire batch transaction fails, fall back to processing items individually batch_failed = True logger.warning("Batch transaction failed: %s. Falling back to individual processing.", str(batch_error)) @@ -1443,72 +1459,106 @@ class IndexScanner: existing_mtime = 0 existing_id = 0 - if incremental and existing_mtime == mtime and existing_id: - refreshed = False - # Keep write transactions short: metadata extraction is already done. - async with self.db.atransaction(mode="immediate") as tx: - if not tx.ok: - return Result.Err("DB_ERROR", tx.error or "Failed to begin transaction") - refreshed = await MetadataHelpers.refresh_metadata_if_needed( - self.db, - existing_id, - metadata_result, - filepath, - base_dir, - state_hash, - mtime, - size, - self._write_scan_journal_entry, - ) - if refreshed: - await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) - if not tx.ok: - return Result.Err("DB_ERROR", tx.error or "Commit failed") - action = "skipped_refresh" if refreshed else "skipped" - return Result.Ok({"action": action}) + if incremental and existing_mtime == mtime and existing_id: + refreshed = False + tx_state = None + try: + async with self.db.lock_for_asset(existing_id): + try: + async with self.db.atransaction(mode="immediate") as tx: + tx_state = tx + if not tx_state.ok: + return Result.Err("DB_ERROR", tx_state.error or "Failed to begin transaction") + refreshed = await MetadataHelpers.refresh_metadata_if_needed( + self.db, + existing_id, + metadata_result, + filepath, + base_dir, + state_hash, + mtime, + size, + self._write_scan_journal_entry, + ) + if refreshed: + await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) + except sqlite3.BusyError as exc: + logger.warning("Database busy while refreshing metadata (asset=%s): %s", existing_id, exc) + stats["errors"] += 1 + raise + except sqlite3.BusyError: + return Result.Err("DB_BUSY", "Database busy while refreshing metadata") + if not tx_state or not tx_state.ok: + return Result.Err("DB_ERROR", tx_state.error or "Commit failed") + action = "skipped_refresh" if refreshed else "skipped" + return Result.Ok({"action": action}) # Update existing asset if existing_id: - async with self.db.atransaction(mode="immediate") as tx: - if not tx.ok: - return Result.Err("DB_ERROR", tx.error or "Failed to begin transaction") - result = await self._update_asset( - existing_id, - file_path, - mtime, - size, - metadata_result, - source=source, - root_id=root_id, - write_metadata=(not fast), - ) - if result.ok: - await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) - if not tx.ok: - return Result.Err("DB_ERROR", tx.error or "Commit failed") + tx_state = None + try: + async with self.db.lock_for_asset(existing_id): + try: + async with self.db.atransaction(mode="immediate") as tx: + tx_state = tx + if not tx_state.ok: + return Result.Err("DB_ERROR", tx_state.error or "Failed to begin transaction") + result = await self._update_asset( + existing_id, + file_path, + mtime, + size, + metadata_result, + source=source, + root_id=root_id, + write_metadata=False, + skip_lock=True, + ) + if result.ok: + await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) + except sqlite3.BusyError as exc: + logger.warning("Database busy while updating asset %s: %s", existing_id, exc) + stats["errors"] += 1 + raise + except sqlite3.BusyError: + return Result.Err("DB_BUSY", "Database busy while updating asset") + if not tx_state or not tx_state.ok: + return Result.Err("DB_ERROR", tx_state.error or "Commit failed") + if not fast: + await self._write_metadata_row(existing_id, metadata_result, filepath=str(file_path)) return result # Add new asset - async with self.db.atransaction(mode="immediate") as tx: - if not tx.ok: - return Result.Err("DB_ERROR", tx.error or "Failed to begin transaction") - result = await self._add_asset( - filename, - subfolder, - filepath, - kind, - mtime, - size, - file_path, - metadata_result, - source=source, - root_id=root_id, - write_metadata=True, - ) - if result.ok: - await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) - if not tx.ok: - return Result.Err("DB_ERROR", tx.error or "Commit failed") + tx_state = None + try: + async with self.db.atransaction(mode="immediate") as tx: + tx_state = tx + if not tx_state.ok: + return Result.Err("DB_ERROR", tx_state.error or "Failed to begin transaction") + result = await self._add_asset( + filename, + subfolder, + filepath, + kind, + mtime, + size, + file_path, + metadata_result, + source=source, + root_id=root_id, + write_metadata=False, + skip_lock=True, + ) + if result.ok: + await self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) + except sqlite3.BusyError as exc: + logger.warning("Database busy while inserting asset %s: %s", filepath, exc) + stats["errors"] += 1 + return Result.Err("DB_BUSY", "Database busy while inserting asset") + if not tx_state or not tx_state.ok: + return Result.Err("DB_ERROR", tx_state.error or "Commit failed") + if result.ok: + await self._write_metadata_row(result.data.get("asset_id"), metadata_result, filepath=filepath) return result async def _add_asset( @@ -1524,6 +1574,7 @@ class IndexScanner: source: str = "output", root_id: Optional[str] = None, write_metadata: bool = True, + skip_lock: bool = False, ) -> Result[Dict[str, str]]: """ Add new asset to database. @@ -1567,22 +1618,21 @@ class IndexScanner: asset_id = insert_result.data if insert_result.ok else None if not asset_id: return Result.Err("INSERT_FAILED", "Failed to get inserted asset ID") - if write_metadata: - async with self.db.lock_for_asset(asset_id): - metadata_write = await MetadataHelpers.write_asset_metadata_row( - self.db, - asset_id, - metadata_result, - filepath=filepath, + if write_metadata and not skip_lock: + metadata_write = await MetadataHelpers.write_asset_metadata_row( + self.db, + asset_id, + metadata_result, + filepath=filepath, + ) + if not metadata_write.ok: + self._log_scan_event( + logging.WARNING, + "Failed to insert metadata row", + asset_id=asset_id, + error=metadata_write.error, + stage="metadata_write" ) - if not metadata_write.ok: - self._log_scan_event( - logging.WARNING, - "Failed to insert metadata row", - asset_id=asset_id, - error=metadata_write.error, - stage="metadata_write" - ) return Result.Ok({"action": "added", "asset_id": asset_id}) @@ -1596,6 +1646,7 @@ class IndexScanner: source: str = "output", root_id: Optional[str] = None, write_metadata: bool = True, + skip_lock: bool = False, ) -> Result[Dict[str, str]]: """ Update existing asset in database. @@ -1610,8 +1661,7 @@ class IndexScanner: height = meta.get("height") duration = meta.get("duration") - async with self.db.lock_for_asset(asset_id): - # Update assets table (NO workflow fields) + async def _run_update(): update_result = await self.db.aexecute( """ UPDATE assets @@ -1629,7 +1679,7 @@ class IndexScanner: if not update_result.ok: return Result.Err("UPDATE_FAILED", update_result.error) - if write_metadata: + if write_metadata and not skip_lock: metadata_write = await MetadataHelpers.write_asset_metadata_row( self.db, asset_id, @@ -1644,6 +1694,13 @@ class IndexScanner: error=metadata_write.error, stage="metadata_write" ) + return Result.Ok({"action": "updated", "asset_id": asset_id}) + + if skip_lock: + return await _run_update() + + async with self.db.lock_for_asset(asset_id): + return await _run_update() return Result.Ok({"action": "updated", "asset_id": asset_id}) @@ -1654,3 +1711,26 @@ class IndexScanner: def _log_scan_event(self, level: int, message: str, **context): log_structured(logger, level, message, **self._scan_context(**context)) + + async def _write_metadata_row( + self, + asset_id: int, + metadata_result: Result[Dict[str, Any]], + filepath: Optional[str] = None, + ) -> None: + if not metadata_result.ok: + return + metadata_write = await MetadataHelpers.write_asset_metadata_row( + self.db, + asset_id, + metadata_result, + filepath=filepath, + ) + if not metadata_write.ok: + self._log_scan_event( + logging.WARNING, + "Failed to write metadata row after transaction", + asset_id=asset_id, + error=metadata_write.error, + stage="metadata_write" + ) diff --git a/backend/routes/handlers/filesystem.py b/backend/routes/handlers/filesystem.py index 3e20fec3..dab674c6 100644 --- a/backend/routes/handlers/filesystem.py +++ b/backend/routes/handlers/filesystem.py @@ -27,9 +27,10 @@ logger = get_logger(__name__) # Locks for async concurrency safety _BACKGROUND_SCAN_LOCK = asyncio.Lock() -_BACKGROUND_SCAN_LAST: dict[str, dict[str, Any]] = {} +_BACKGROUND_SCAN_LAST: OrderedDict[str, dict[str, Any]] = OrderedDict() _BACKGROUND_SCAN_FAILURES: list[dict[str, Any]] = [] _MAX_FAILURE_HISTORY = int(BG_SCAN_FAILURE_HISTORY_MAX) +_BACKGROUND_SCAN_HISTORY_MAX = 1000 _SCAN_PENDING_LOCK = asyncio.Lock() _SCAN_PENDING: OrderedDict[str, dict[str, Any]] = OrderedDict() @@ -128,7 +129,7 @@ async def _kickoff_background_scan( _SCAN_PENDING.popitem(last=False) # Only mark as enqueued once we've successfully updated _SCAN_PENDING. - _BACKGROUND_SCAN_LAST[key] = {"last": now, "in_progress": False} + _set_background_entry(key, {"last": now, "in_progress": False}) except Exception: return @@ -141,6 +142,16 @@ def _ensure_worker() -> None: _SCAN_TASK = asyncio.create_task(_worker_loop()) +def _set_background_entry(key: str, value: dict[str, Any]) -> None: + _BACKGROUND_SCAN_LAST[key] = value + try: + _BACKGROUND_SCAN_LAST.move_to_end(key) + except Exception: + pass + while len(_BACKGROUND_SCAN_LAST) > _BACKGROUND_SCAN_HISTORY_MAX: + _BACKGROUND_SCAN_LAST.popitem(last=False) + + async def _worker_loop() -> None: while True: task = None @@ -226,12 +237,13 @@ def _record_scan_failure(directory: str, source: str, code: str, error: str) -> def _ensure_background_entry(key: str) -> dict[str, Any]: entry = _BACKGROUND_SCAN_LAST.get(key) if isinstance(entry, dict): + _set_background_entry(key, entry) return entry if isinstance(entry, (int, float)): entry = {"last": float(entry), "in_progress": False} else: entry = {"last": 0.0, "in_progress": False} - _BACKGROUND_SCAN_LAST[key] = entry + _set_background_entry(key, entry) return entry @@ -242,6 +254,55 @@ def _build_task_key(task: dict[str, Any]) -> str: return f"{source}|{root_id}|{directory}" +def _collect_filesystem_entries( + target_dir_resolved: Path, + base: Path, + asset_type: str, + root_id: Optional[str], +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for entry in target_dir_resolved.iterdir(): + if not entry.is_file(): + continue + filename = entry.name + kind = classify_file(filename) + if kind == "unknown": + continue + try: + stat = entry.stat() + except OSError: + continue + + try: + rel_to_root = entry.parent.relative_to(base) + sub = "" if str(rel_to_root) == "." else str(rel_to_root).replace("\\", "/") + except ValueError: + sub = "" + + entries.append( + { + "id": None, + "filename": filename, + "subfolder": sub, + "filepath": str(entry), + "kind": kind, + "ext": entry.suffix.lower(), + "size": stat.st_size, + "mtime": int(stat.st_mtime), + "width": None, + "height": None, + "duration": None, + "rating": 0, + "tags": [], + "has_workflow": None, + "has_generation_data": None, + "type": asset_type, + "root_id": root_id, + } + ) + return entries + + async def _fs_cache_get_or_build( base: Path, target_dir_resolved: Path, @@ -273,50 +334,14 @@ async def _fs_cache_get_or_build( pass _FS_LIST_CACHE.move_to_end(cache_key) - entries: list[dict[str, Any]] = [] try: - # Note: os.scandir/iterdir is synchronous I/O. - # For large directories, this will block the loop. - # Ideally this should run in a thread executor, but for now we keep it simple. - for entry in target_dir_resolved.iterdir(): - if not entry.is_file(): - continue - filename = entry.name - kind = classify_file(filename) - if kind == "unknown": - continue - try: - stat = entry.stat() - except OSError: - continue - - try: - rel_to_root = entry.parent.relative_to(base) - sub = "" if str(rel_to_root) == "." else str(rel_to_root).replace("\\", "/") - except ValueError: - sub = "" - - entries.append( - { - "id": None, - "filename": filename, - "subfolder": sub, - "filepath": str(entry), - "kind": kind, - "ext": entry.suffix.lower(), - "size": stat.st_size, - "mtime": int(stat.st_mtime), - "width": None, - "height": None, - "duration": None, - "rating": 0, - "tags": [], - "has_workflow": None, - "has_generation_data": None, - "type": asset_type, - "root_id": root_id, - } - ) + entries = await asyncio.to_thread( + _collect_filesystem_entries, + target_dir_resolved, + base, + asset_type, + root_id, + ) except OSError as exc: return Result.Err("LIST_FAILED", f"Failed to list directory: {exc}") diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9a1bf377..7bd7d3b7 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -7,7 +7,7 @@ This guide provides detailed instructions for installing and configuring the Maj ### System Requirements - ComfyUI installation -- Python 3.10 or 3.11 (3.11 recommended; the extension is tested on both versions) +- Python 3.10–3.12 (ComfyUI upstream recommends 3.13 with 3.12 as the stable fallback, so running 3.12 keeps the Assets Manager aligned with the official guidance). - At least 500MB free disk space for the extension and dependencies - Administrator privileges (for installation and optional tool installations) diff --git a/js/components/Viewer.js b/js/components/Viewer.js index a583c5f5..9bd082b3 100644 --- a/js/components/Viewer.js +++ b/js/components/Viewer.js @@ -67,6 +67,9 @@ export function createViewer() { const state = createDefaultViewerState(); state.mode = VIEWER_MODES.SINGLE; + const IMAGE_PRELOAD_EXTENSIONS = new Set([ + "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "avif", "heic", "hdr", "svg", "apng" + ]); let panzoom = null; let mediaFactory = null; @@ -1398,6 +1401,7 @@ export function createViewer() { // Render current asset renderAsset(); + preloadAdjacentAssets(); // Mount/unmount the video player bar depending on current media. syncPlayerBar(); try { @@ -1466,6 +1470,77 @@ export function createViewer() { updatePanCursor(); } + function getPreloadKey(asset) { + if (!asset) return null; + if (asset.id != null) return `id:${asset.id}`; + const fallback = asset.filepath || asset.path || asset.filename; + if (fallback) return `path:${fallback}`; + return null; + } + + function isImageAsset(asset) { + if (!asset) return false; + const kind = String(asset.kind || "").toLowerCase(); + if (kind === "image" || kind.startsWith("image/")) return true; + const filename = String(asset.filepath || asset.path || asset.filename || ""); + const ext = filename.split(".").pop()?.toLowerCase() || ""; + return IMAGE_PRELOAD_EXTENSIONS.has(ext); + } + + function trackPreloadRef(img) { + if (!img) return; + try { + state._preloadRefs = state._preloadRefs || new Set(); + state._preloadRefs.add(img); + const cleanup = () => { + try { + state._preloadRefs?.delete?.(img); + } catch {} + }; + img.addEventListener("load", cleanup, { once: true, passive: true }); + img.addEventListener("error", cleanup, { once: true, passive: true }); + } catch {} + } + + function preloadImageForAsset(asset, url) { + if (!asset || !url) return; + if (!isImageAsset(asset)) return; + const key = getPreloadKey(asset) || url; + if (key) { + state._preloadedAssetKeys = state._preloadedAssetKeys || new Set(); + if (state._preloadedAssetKeys.has(key)) return; + state._preloadedAssetKeys.add(key); + if (state._preloadedAssetKeys.size > 250) { + try { + state._preloadedAssetKeys.clear(); + } catch {} + } + } + try { + const img = new Image(); + img.decoding = "async"; + try { + img.loading = "lazy"; + } catch {} + img.alt = ""; + img.src = url; + trackPreloadRef(img); + } catch {} + } + + function preloadAdjacentAssets() { + const assets = Array.isArray(state.assets) ? state.assets : []; + if (!assets.length) return; + const candidates = [state.currentIndex - 1, state.currentIndex + 1]; + for (const idx of candidates) { + if (idx < 0 || idx >= assets.length) continue; + const asset = assets[idx]; + if (!asset) continue; + const url = buildAssetViewURL(asset); + preloadImageForAsset(asset, url); + } + } + const destroyPlayerBar = () => { try { if (state._videoControlsDestroy) state._videoControlsDestroy(); @@ -2353,6 +2428,12 @@ export function createViewer() { try { overlay._mjrViewerUnsubs = []; } catch {} + try { + state._preloadRefs?.clear?.(); + } catch {} + try { + state._preloadedAssetKeys?.clear?.(); + } catch {} try { overlay.remove?.(); diff --git a/js/components/sidebar/SidebarView.js b/js/components/sidebar/SidebarView.js index a3d43844..b7d2dabe 100644 --- a/js/components/sidebar/SidebarView.js +++ b/js/components/sidebar/SidebarView.js @@ -40,7 +40,7 @@ export function createSidebar(position = "right") { const unsubs = []; const ac = typeof AbortController !== "undefined" ? new AbortController() : null; sidebar._mjrAbortController = ac; - sidebar._dispose = () => { + const disposeSidebar = () => { try { ac?.abort?.(); } catch {} @@ -51,6 +51,8 @@ export function createSidebar(position = "right") { unsubs.length = 0; } catch {} }; + sidebar.dispose = disposeSidebar; + sidebar._dispose = disposeSidebar; const matchesCurrent = (assetId) => { const id = sidebar._currentAsset?.id ?? sidebar._currentFullAsset?.id ?? null; diff --git a/js/features/grid/GridView.js b/js/features/grid/GridView.js index 58072809..d0af0a4f 100644 --- a/js/features/grid/GridView.js +++ b/js/features/grid/GridView.js @@ -2198,44 +2198,74 @@ export function refreshGrid(gridContainer) { } // [ISSUE 1] Real-time Refresh Implementation -// Listens for the event dispatched by entry.js (relayed from backend) -try { - const onScanComplete = (e) => { - const detail = e.detail; - - // Find all active grids +// Manages the global scan-complete handlers so we can teardown on panel unload. +const GRID_SCAN_EVENT_NAMES = ["mjr-scan-complete", "mjr:scan-complete"]; +let _gridScanListenersBound = false; + +const _onScanComplete = (e) => { + try { + if (typeof document === "undefined" || !document.querySelectorAll) return; const grids = document.querySelectorAll(".mjr-grid-container"); for (const grid of grids) { try { - if (!grid.isConnected) continue; - - // Get state + if (!grid?.isConnected) continue; + const state = GRID_STATE.get(grid); if (!state) continue; - - // Debounce refresh + if (grid._mjrRefreshTimer) clearTimeout(grid._mjrRefreshTimer); - + grid._mjrRefreshTimer = setTimeout(() => { grid._mjrRefreshTimer = null; if (!grid.isConnected) return; - - // Trigger reload with reset to ensure we get newest items at top - // Only reload if user hasn't scrolled deep? - // Actually, for "Issue 1", update > perfect UX. loadAssets(grid, state.query, { reset: true }); }, 500); - } catch (err) { console.warn("[Majoor] Auto-refresh error:", err); } } - }; + } catch { + // swallow to avoid leaking errors into the global listener + } +}; - // Canonical event name: mjr-scan-complete (keep legacy alias for safety). - window.addEventListener("mjr-scan-complete", onScanComplete); - window.addEventListener("mjr:scan-complete", onScanComplete); -} catch (e) { - console.warn("Failed to register global scan listener:", e); +const _attachGridScanListeners = () => { + if (_gridScanListenersBound) return; + if (typeof window === "undefined") return; + + try { + for (const name of GRID_SCAN_EVENT_NAMES) { + window.addEventListener(name, _onScanComplete); + } + _gridScanListenersBound = true; + } catch (err) { + console.warn("Failed to register global scan listener:", err); + } +}; + +const _detachGridScanListeners = () => { + if (!_gridScanListenersBound) return; + if (typeof window === "undefined") { + _gridScanListenersBound = false; + return; + } + + try { + for (const name of GRID_SCAN_EVENT_NAMES) { + window.removeEventListener(name, _onScanComplete); + } + } catch (err) { + console.warn("Failed to unregister global scan listener:", err); + } finally { + _gridScanListenersBound = false; + } +}; + +export function bindGridScanListeners() { + _attachGridScanListeners(); +} + +export function disposeGridScanListeners() { + _detachGridScanListeners(); } diff --git a/js/features/grid/VirtualGrid.js b/js/features/grid/VirtualGrid.js index 28d1749c..ebc9ad1b 100644 --- a/js/features/grid/VirtualGrid.js +++ b/js/features/grid/VirtualGrid.js @@ -61,6 +61,9 @@ export class VirtualGrid { this.rafId = 0; this.lastWidth = 0; this._resizeDebounce = 0; + this._lastLayoutWidth = 0; + this._lastLayoutHeight = 0; + this._layoutGuardThreshold = 12; // Observer for container width changes const scheduleResize = (width) => { @@ -139,10 +142,12 @@ export class VirtualGrid { // Reset measurement state in case layout settings changed (e.g. badges affecting height) this.measured = false; this.measureSamples = []; + this._lastLayoutWidth = 0; + this._lastLayoutHeight = 0; } this.items = items || []; - this.updateLayout(); + this.updateLayout(force); } /** @@ -254,7 +259,7 @@ export class VirtualGrid { * Updates container height and triggers render. * @private */ - updateLayout() { + updateLayout(force = false) { if (!this.items.length) { this.container.style.height = "0px"; return; @@ -264,6 +269,19 @@ export class VirtualGrid { const totalRows = Math.ceil(this.items.length / this.columnCount); const totalHeight = totalRows * this.rowHeight + (totalRows - 1) * gap; + const containerWidth = this.container.clientWidth || this.container.offsetWidth || this.lastWidth || 0; + if (!force && containerWidth > 0) { + const widthDelta = Math.abs(containerWidth - this._lastLayoutWidth); + const heightDelta = Math.abs(totalHeight - this._lastLayoutHeight); + const threshold = this._layoutGuardThreshold; + if (widthDelta <= threshold && heightDelta <= threshold) { + // Skip heavy recalculation when size didn't change significantly. + return; + } + } + + this._lastLayoutWidth = containerWidth; + this._lastLayoutHeight = totalHeight; this.container.style.height = `${totalHeight}px`; this.render(); } @@ -293,15 +311,15 @@ export class VirtualGrid { this.metaHeight = sorted[mid]; if (this.metaHeight < 20) this.metaHeight = 50; - this.rowHeight = this.itemWidth + this.metaHeight; - this.measured = true; - this.updateLayout(); + this.rowHeight = this.itemWidth + this.metaHeight; + this.measured = true; + this.updateLayout(true); } else if (this.measureSamples.length === 1) { // Initial update to ensure grid has some height let tempMeta = meta; if (tempMeta < 20) tempMeta = 50; this.rowHeight = this.itemWidth + tempMeta; - this.updateLayout(); + this.updateLayout(true); } } } catch {} diff --git a/js/features/panel/AssetsManagerPanel.js b/js/features/panel/AssetsManagerPanel.js index 43fd647d..413e298a 100644 --- a/js/features/panel/AssetsManagerPanel.js +++ b/js/features/panel/AssetsManagerPanel.js @@ -2,7 +2,15 @@ import { comfyConfirm, comfyPrompt } from "../../app/dialogs.js"; import { comfyToast } from "../../app/toast.js"; import { createStatusIndicator, setupStatusPolling, triggerScan } from "../status/StatusDot.js"; -import { createGridContainer, loadAssets, loadAssetsFromList, disposeGrid, refreshGrid } from "../grid/GridView.js"; +import { + createGridContainer, + loadAssets, + loadAssetsFromList, + disposeGrid, + refreshGrid, + bindGridScanListeners, + disposeGridScanListeners, +} from "../grid/GridView.js"; import { get, post, getCollectionAssets, toggleWatcher, setWatcherScope } from "../../api/client.js"; import { ENDPOINTS } from "../../api/endpoints.js"; import { createSidebar, showAssetInSidebar, closeSidebar } from "../../components/SidebarView.js"; @@ -164,6 +172,9 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } gridContainer = createGridContainer(); gridWrapper.appendChild(gridContainer); _activeGridContainer = gridContainer; + try { + bindGridScanListeners(); + } catch {} // Bind grid context menu bindGridContextMenu({ @@ -571,7 +582,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } searchTimeout = null; } catch {} try { - sidebar?._dispose?.(); + sidebar?.dispose?.(); } catch {} try { sidebarController?.dispose?.(); @@ -579,6 +590,9 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } try { window.removeEventListener?.("mjr-settings-changed", onSettingsChanged); } catch {} + try { + disposeGridScanListeners(); + } catch {} try { if (gridContainer) disposeGrid(gridContainer); } catch {}