From b1f30c24ee2ad267afcdb3a4cdf7fb88f5e4b7d2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 8 Jan 2026 02:21:14 +0100 Subject: [PATCH] v2.0 Architecture Refactor --- .claude/settings.local.json | 7 + .editorconfig | 23 - .github/workflows/python-tests.yml | 29 + .gitignore | 25 - LICENSE | 21 - README.md | 107 +- __init__.py | 151 +- backend/__init__.py | 1 + backend/adapters/__init__.py | 1 + backend/adapters/db/__init__.py | 5 + backend/adapters/db/schema.py | 487 ++++ backend/adapters/db/sqlite.py | 374 +++ backend/adapters/tools/__init__.py | 5 + backend/adapters/tools/exiftool.py | 519 ++++ backend/adapters/tools/ffprobe.py | 254 ++ backend/config.py | 108 + backend/custom_roots.py | 166 ++ backend/deps.py | 117 + backend/features/geninfo/__init__.py | 4 + backend/features/geninfo/parser.py | 1293 +++++++++ backend/features/health/__init__.py | 4 + backend/features/health/service.py | 286 ++ backend/features/index/__init__.py | 6 + backend/features/index/enricher.py | 202 ++ backend/features/index/metadata_helpers.py | 359 +++ backend/features/index/scanner.py | 1285 ++++++++ backend/features/index/searcher.py | 766 +++++ backend/features/index/service.py | 381 +++ backend/features/index/updater.py | 164 ++ backend/features/index/watcher.py | 103 + backend/features/metadata/__init__.py | 10 + backend/features/metadata/extractors.py | 897 ++++++ backend/features/metadata/service.py | 662 +++++ backend/features/tags/__init__.py | 9 + backend/features/tags/sync.py | 351 +++ backend/observability.py | 243 ++ backend/probe_router.py | 103 + backend/routes/__init__.py | 43 + backend/routes/core/__init__.py | 29 + backend/routes/core/paths.py | 160 + backend/routes/core/response.py | 44 + backend/routes/core/security.py | 88 + backend/routes/core/services.py | 89 + backend/routes/handlers/__init__.py | 18 + backend/routes/handlers/assets.py | 652 +++++ backend/routes/handlers/custom_roots.py | 111 + backend/routes/handlers/filesystem.py | 248 ++ backend/routes/handlers/health.py | 110 + backend/routes/handlers/metadata.py | 92 + backend/routes/handlers/scan.py | 885 ++++++ backend/routes/handlers/search.py | 622 ++++ backend/routes/registry.py | 90 + backend/routes_compat.py | 110 + backend/shared.py | 40 + backend/tool_detect.py | 155 + comfyui_extension.json | 14 +- js/api/client.js | 312 ++ js/api/endpoints.js | 121 + js/app/bootstrap.js | 69 + js/app/config.js | 60 + js/app/dialogs.js | 348 +++ js/app/events.js | 3 + js/app/settings.js | 199 ++ js/app/style.js | 28 + js/assets_data.js | 270 -- js/assets_filters.js | 70 - js/assets_state.js | 22 - js/components/Badges.js | 214 ++ js/components/Card.js | 391 +++ js/components/ContextMenu.js | 295 ++ js/components/RatingEditor.js | 175 ++ js/components/SidebarView.js | 3 + js/components/TagsEditor.js | 401 +++ js/components/Viewer.js | 1683 +++++++++++ js/components/sidebar/SidebarView.js | 221 ++ .../sidebar/parsers/a1111ParamsParser.js | 38 + .../sidebar/parsers/comfyWorkflowParser.js | 128 + .../sidebar/parsers/geninfoParser.js | 165 ++ .../sidebar/sections/GenerationSection.js | 134 + .../sidebar/sections/HeaderSection.js | 69 + .../sidebar/sections/PreviewSection.js | 88 + .../sidebar/sections/RatingTagsSection.js | 69 + .../sections/WorkflowMinimapSection.js | 344 +++ js/components/sidebar/utils/dom.js | 146 + js/components/sidebar/utils/format.js | 18 + js/components/sidebar/utils/minimap.js | 459 +++ js/entry.js | 151 + js/extension.js.old | 420 +++ js/features/contextmenu/GridContextMenu.js | 417 +++ js/features/dnd/DragDrop.js | 506 ++++ js/features/dnd/staging/stageToInput.js | 45 + js/features/dnd/targets/canvas.js | 23 + js/features/dnd/targets/node.js | 247 ++ js/features/dnd/utils/constants.js | 5 + js/features/dnd/utils/log.js | 13 + js/features/dnd/utils/payload.js | 22 + js/features/dnd/utils/syntheticFile.js | 31 + js/features/dnd/utils/video.js | 53 + js/features/grid/GridView.js | 874 ++++++ js/features/panel/AssetsManagerPanel.js | 376 +++ .../controllers/customRootsController.js | 94 + .../panel/controllers/filtersController.js | 15 + .../panel/controllers/gridController.js | 39 + .../controllers/panelHotkeysController.js | 143 + js/features/panel/controllers/query.js | 5 + .../controllers/ratingHotkeysController.js | 145 + .../panel/controllers/scopeController.js | 35 + .../panel/controllers/sidebarController.js | 457 +++ .../panel/controllers/sortController.js | 63 + js/features/panel/state/panelState.js | 14 + js/features/panel/views/customPopoverView.js | 46 + js/features/panel/views/filterPopoverView.js | 75 + js/features/panel/views/headerView.js | 56 + js/features/panel/views/iconButton.js | 11 + js/features/panel/views/popoverManager.js | 215 ++ js/features/panel/views/searchView.js | 43 + js/features/panel/views/sortPopoverView.js | 12 + js/features/panel/views/tabsView.js | 28 + js/features/status/StatusDot.js | 509 ++++ js/features/viewer/ViewerContextMenu.js | 319 ++ js/global_state.js | 4 - js/grid/events.js | 746 ----- js/grid/fetch.js | 369 --- js/grid/load.js | 63 - js/grid/render.js | 181 -- js/grid/state.js | 39 - js/majoor_assets_manager.js | 2 - js/theme/theme-comfy.css | 1066 +++++++ js/ui_bulk_bar.js | 673 ----- js/ui_cards.js | 459 --- js/ui_context_menu.js | 225 -- js/ui_grid.js | 1095 ------- js/ui_gridview.js | 464 --- js/ui_settings.js | 268 -- js/ui_sidebar.js | 660 ----- js/ui_status_dot.js | 182 -- js/ui_theme.js | 46 - js/ui_viewer.js | 1526 ---------- js/utils/debounce.js | 8 + js/utils/events.js | 19 + js/utils/extractOutputFiles.js | 20 + js/utils/fastUpload.js | 57 + js/viewer/interaction.js | 241 -- js/workflow_hash.js | 185 -- node_mapping.json | 21 - requirements.txt | 22 +- server/__init__.py | 8 - server/collections_store.py | 70 - server/config.py | 70 - server/emoji.py | 20 - server/hooks.py | 346 --- server/index_db.py | 1431 --------- server/logger.py | 62 - server/metadata/__init__.py | 27 - server/metadata/comfy_tracer.py | 886 ------ server/metadata/core.py | 44 - server/metadata/enhanced_extraction.py | 1247 -------- server/metadata/exif_extraction.py | 238 -- server/metadata/exif_usercomment.py | 313 -- server/metadata/png_inject.py | 587 ---- server/metadata/video_extraction.py | 526 ---- server/metadata/workflow_fingerprint.py | 394 --- server/metadata/workflow_normalize.py | 113 - server/metadata/workflow_reconstruct.py | 74 - server/metadata_generation.py | 2022 ------------- server/routes.py | 2577 ----------------- server/tmp_test.py | Bin 34 -> 0 bytes server/utils.py | 1190 -------- server/version.py | 1 - server/watcher.py | 212 -- server/workflow_hash.py | 224 -- shared/__init__.py | 22 + shared/log.py | 97 + shared/result.py | 51 + shared/time.py | 48 + shared/types.py | 67 + 176 files changed, 26194 insertions(+), 20592 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 .editorconfig create mode 100644 .github/workflows/python-tests.yml delete mode 100644 .gitignore delete mode 100644 LICENSE create mode 100644 backend/__init__.py create mode 100644 backend/adapters/__init__.py create mode 100644 backend/adapters/db/__init__.py create mode 100644 backend/adapters/db/schema.py create mode 100644 backend/adapters/db/sqlite.py create mode 100644 backend/adapters/tools/__init__.py create mode 100644 backend/adapters/tools/exiftool.py create mode 100644 backend/adapters/tools/ffprobe.py create mode 100644 backend/config.py create mode 100644 backend/custom_roots.py create mode 100644 backend/deps.py create mode 100644 backend/features/geninfo/__init__.py create mode 100644 backend/features/geninfo/parser.py create mode 100644 backend/features/health/__init__.py create mode 100644 backend/features/health/service.py create mode 100644 backend/features/index/__init__.py create mode 100644 backend/features/index/enricher.py create mode 100644 backend/features/index/metadata_helpers.py create mode 100644 backend/features/index/scanner.py create mode 100644 backend/features/index/searcher.py create mode 100644 backend/features/index/service.py create mode 100644 backend/features/index/updater.py create mode 100644 backend/features/index/watcher.py create mode 100644 backend/features/metadata/__init__.py create mode 100644 backend/features/metadata/extractors.py create mode 100644 backend/features/metadata/service.py create mode 100644 backend/features/tags/__init__.py create mode 100644 backend/features/tags/sync.py create mode 100644 backend/observability.py create mode 100644 backend/probe_router.py create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/core/__init__.py create mode 100644 backend/routes/core/paths.py create mode 100644 backend/routes/core/response.py create mode 100644 backend/routes/core/security.py create mode 100644 backend/routes/core/services.py create mode 100644 backend/routes/handlers/__init__.py create mode 100644 backend/routes/handlers/assets.py create mode 100644 backend/routes/handlers/custom_roots.py create mode 100644 backend/routes/handlers/filesystem.py create mode 100644 backend/routes/handlers/health.py create mode 100644 backend/routes/handlers/metadata.py create mode 100644 backend/routes/handlers/scan.py create mode 100644 backend/routes/handlers/search.py create mode 100644 backend/routes/registry.py create mode 100644 backend/routes_compat.py create mode 100644 backend/shared.py create mode 100644 backend/tool_detect.py create mode 100644 js/api/client.js create mode 100644 js/api/endpoints.js create mode 100644 js/app/bootstrap.js create mode 100644 js/app/config.js create mode 100644 js/app/dialogs.js create mode 100644 js/app/events.js create mode 100644 js/app/settings.js create mode 100644 js/app/style.js delete mode 100644 js/assets_data.js delete mode 100644 js/assets_filters.js delete mode 100644 js/assets_state.js create mode 100644 js/components/Badges.js create mode 100644 js/components/Card.js create mode 100644 js/components/ContextMenu.js create mode 100644 js/components/RatingEditor.js create mode 100644 js/components/SidebarView.js create mode 100644 js/components/TagsEditor.js create mode 100644 js/components/Viewer.js create mode 100644 js/components/sidebar/SidebarView.js create mode 100644 js/components/sidebar/parsers/a1111ParamsParser.js create mode 100644 js/components/sidebar/parsers/comfyWorkflowParser.js create mode 100644 js/components/sidebar/parsers/geninfoParser.js create mode 100644 js/components/sidebar/sections/GenerationSection.js create mode 100644 js/components/sidebar/sections/HeaderSection.js create mode 100644 js/components/sidebar/sections/PreviewSection.js create mode 100644 js/components/sidebar/sections/RatingTagsSection.js create mode 100644 js/components/sidebar/sections/WorkflowMinimapSection.js create mode 100644 js/components/sidebar/utils/dom.js create mode 100644 js/components/sidebar/utils/format.js create mode 100644 js/components/sidebar/utils/minimap.js create mode 100644 js/entry.js create mode 100644 js/extension.js.old create mode 100644 js/features/contextmenu/GridContextMenu.js create mode 100644 js/features/dnd/DragDrop.js create mode 100644 js/features/dnd/staging/stageToInput.js create mode 100644 js/features/dnd/targets/canvas.js create mode 100644 js/features/dnd/targets/node.js create mode 100644 js/features/dnd/utils/constants.js create mode 100644 js/features/dnd/utils/log.js create mode 100644 js/features/dnd/utils/payload.js create mode 100644 js/features/dnd/utils/syntheticFile.js create mode 100644 js/features/dnd/utils/video.js create mode 100644 js/features/grid/GridView.js create mode 100644 js/features/panel/AssetsManagerPanel.js create mode 100644 js/features/panel/controllers/customRootsController.js create mode 100644 js/features/panel/controllers/filtersController.js create mode 100644 js/features/panel/controllers/gridController.js create mode 100644 js/features/panel/controllers/panelHotkeysController.js create mode 100644 js/features/panel/controllers/query.js create mode 100644 js/features/panel/controllers/ratingHotkeysController.js create mode 100644 js/features/panel/controllers/scopeController.js create mode 100644 js/features/panel/controllers/sidebarController.js create mode 100644 js/features/panel/controllers/sortController.js create mode 100644 js/features/panel/state/panelState.js create mode 100644 js/features/panel/views/customPopoverView.js create mode 100644 js/features/panel/views/filterPopoverView.js create mode 100644 js/features/panel/views/headerView.js create mode 100644 js/features/panel/views/iconButton.js create mode 100644 js/features/panel/views/popoverManager.js create mode 100644 js/features/panel/views/searchView.js create mode 100644 js/features/panel/views/sortPopoverView.js create mode 100644 js/features/panel/views/tabsView.js create mode 100644 js/features/status/StatusDot.js create mode 100644 js/features/viewer/ViewerContextMenu.js delete mode 100644 js/global_state.js delete mode 100644 js/grid/events.js delete mode 100644 js/grid/fetch.js delete mode 100644 js/grid/load.js delete mode 100644 js/grid/render.js delete mode 100644 js/grid/state.js delete mode 100644 js/majoor_assets_manager.js create mode 100644 js/theme/theme-comfy.css delete mode 100644 js/ui_bulk_bar.js delete mode 100644 js/ui_cards.js delete mode 100644 js/ui_context_menu.js delete mode 100644 js/ui_grid.js delete mode 100644 js/ui_gridview.js delete mode 100644 js/ui_settings.js delete mode 100644 js/ui_sidebar.js delete mode 100644 js/ui_status_dot.js delete mode 100644 js/ui_theme.js delete mode 100644 js/ui_viewer.js create mode 100644 js/utils/debounce.js create mode 100644 js/utils/events.js create mode 100644 js/utils/extractOutputFiles.js create mode 100644 js/utils/fastUpload.js delete mode 100644 js/viewer/interaction.js delete mode 100644 js/workflow_hash.js delete mode 100644 node_mapping.json delete mode 100644 server/__init__.py delete mode 100644 server/collections_store.py delete mode 100644 server/config.py delete mode 100644 server/emoji.py delete mode 100644 server/hooks.py delete mode 100644 server/index_db.py delete mode 100644 server/logger.py delete mode 100644 server/metadata/__init__.py delete mode 100644 server/metadata/comfy_tracer.py delete mode 100644 server/metadata/core.py delete mode 100644 server/metadata/enhanced_extraction.py delete mode 100644 server/metadata/exif_extraction.py delete mode 100644 server/metadata/exif_usercomment.py delete mode 100644 server/metadata/png_inject.py delete mode 100644 server/metadata/video_extraction.py delete mode 100644 server/metadata/workflow_fingerprint.py delete mode 100644 server/metadata/workflow_normalize.py delete mode 100644 server/metadata/workflow_reconstruct.py delete mode 100644 server/metadata_generation.py delete mode 100644 server/routes.py delete mode 100644 server/tmp_test.py delete mode 100644 server/utils.py delete mode 100644 server/version.py delete mode 100644 server/watcher.py delete mode 100644 server/workflow_hash.py create mode 100644 shared/__init__.py create mode 100644 shared/log.py create mode 100644 shared/result.py create mode 100644 shared/time.py create mode 100644 shared/types.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..5707c58a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)" + ] + } +} diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 5c7f9292..00000000 --- a/.editorconfig +++ /dev/null @@ -1,23 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.py] -indent_style = space -indent_size = 4 - -[*.js] -indent_style = space -indent_size = 2 - -[*.json] -indent_style = space -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false - diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 00000000..5e3ce3f6 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,29 @@ +name: Python tests + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + - name: Run tests + run: | + python -m unittest discover -v + pytest -q || true diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 052a2aac..00000000 --- a/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# Python cache -__pycache__/ -**/__pycache__/ -*.pyc -*.pyo - -# Test files -test_*.py -verify_*.py - -# Documentation (generated) -*.md -!README.md - -# GitHub workflows -.github/ - -# Examples -examples/*_full.gif - -# Local settings -.claude/settings.local.json -node_mapping.json.example -js/nul -tests/debug_webp_extraction.py diff --git a/LICENSE b/LICENSE deleted file mode 100644 index b4b25c9c..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Ewald - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index f24d5e23..5d925794 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,11 @@ Sometimes you need custom nodes. Sometimes external logic makes sense. But jumpi ## đŸ“Ļ Installation -### Method 1: Manual +### Method 1: ComfyUI Manager (Recommended) + +Install directly from the ComfyUI Manager interface. + +### Method 2: Manual ```bash cd ComfyUI/custom_nodes @@ -91,7 +95,9 @@ Restart ComfyUI. ## ✅ Requirements -Python dependencies (auto-installed by ComfyUI Manager): +### Python Dependencies + +All Python dependencies are automatically installed via `requirements.txt`: - `aiohttp` (REST API server) - `pillow` (thumbnail generation) @@ -99,13 +105,20 @@ Python dependencies (auto-installed by ComfyUI Manager): - `watchdog` (real-time file system monitoring for automatic reindexing) - `pywin32` (Windows only; enables Windows Property System metadata) -**Strongly recommended** external tools: +### External Tools (Strongly Recommended) + +For full metadata capabilities, install these external tools: - 🧰 **ExifTool** (video metadata read/write, XMP/IPTC support) - Windows: download from [exiftool.org](https://exiftool.org) and add `exiftool.exe` to `PATH` - macOS: `brew install exiftool` - Linux (Debian/Ubuntu): `sudo apt install libimage-exiftool-perl` - đŸŽŦ **ffprobe** (improved video metadata extraction; usually bundled with FFmpeg) + - Windows: download FFmpeg from [ffmpeg.org](https://ffmpeg.org) and add to `PATH` + - macOS: `brew install ffmpeg` + - Linux (Debian/Ubuntu): `sudo apt install ffmpeg` + +The extension will automatically detect and use these tools if available. Without them, metadata extraction will fall back to Python-only parsers with reduced capabilities. --- @@ -197,14 +210,94 @@ Access settings via the ComfyUI settings panel (gear icon). --- +--- + +## đŸ—ī¸ Architecture + +The new codebase follows a clean, modular architecture: + +### Backend Structure + +- **`backend/`** - Core Python backend + - **`adapters/`** - External integrations (database, tools) + - `db/` - SQLite adapter with connection pooling + - `tools/` - ExifTool and FFProbe wrappers + - **`features/`** - Business logic modules + - `index/` - File scanning, searching, and indexing + - `metadata/` - Metadata extraction and enrichment + - `geninfo/` - Generation info parsing (A1111, ComfyUI) + - `tags/` - Rating and tags synchronization + - `health/` - System health checks + - `collections/` - Smart filter collections + - **`routes/`** - HTTP API endpoints + - `core/` - Path resolution and utilities + - `handlers/` - Request handlers (filesystem, metadata, search) + - `config.py` - Configuration and environment variables + - `deps.py` - Dependency injection container + - `observability.py` - Logging and performance tracing + +### Frontend Structure + +- **`js/`** - Modern JavaScript frontend + - **`api/`** - Backend API client + - **`app/`** - Application bootstrap and configuration + - **`components/`** - Reusable UI components (Card, Sidebar, Viewer) + - **`features/`** - Feature modules (grid, search, filters, viewer) + - **`state/`** - Global state management + - **`utils/`** - Helper utilities + - **`theme/`** - UI theming + - `entry.js` - Extension entry point + +### Shared Modules + +- **`shared/`** - Code shared between backend components + - `log.py` - Structured logging + - `result.py` - Result type for error handling + - `types.py` - Common type definitions + +--- + +## 🔧 Configuration + +The extension can be configured via environment variables: + +### Output Directory +- `MAJOOR_OUTPUT_DIRECTORY` - Override ComfyUI output directory + +### External Tools +- `MAJOOR_EXIFTOOL_PATH` - Path to ExifTool binary +- `MAJOOR_FFPROBE_PATH` - Path to FFProbe binary +- `MAJOOR_MEDIA_PROBE_BACKEND` - Metadata backend: `auto`, `exiftool`, `ffprobe`, or `both` (default: `auto`) + +### File Watcher +- `MAJOOR_ENABLE_FILE_WATCHER` - Enable automatic reindexing (default: `false`) +- `MAJOOR_WATCHER_INTERVAL` - Polling interval in seconds (default: `15.0`) +- `MAJOOR_WATCHER_PATHS` - Colon/semicolon-separated paths to watch + +### Performance Tuning +- `MAJOOR_DB_TIMEOUT` - Database connection timeout (default: `30.0`) +- `MAJOOR_DB_MAX_CONNECTIONS` - Connection pool size (default: `8`) +- `MAJOOR_EXIFTOOL_TIMEOUT` - ExifTool command timeout (default: `15`) +- `MAJOOR_FFPROBE_TIMEOUT` - FFProbe command timeout (default: `10`) + +--- + ## 📝 Notes & Tips -## 📌 Recent fixes -- Fixed a crash when querying metadata source info while sidecar files are disabled (safe-guard against missing sidecar paths). -- Improved ExifTool reads to use a robust subprocess pattern with timeouts and better error handling. -- Added unit tests for PNG injection, EXIF UserComment decoding, and metadata source detection. +### 📌 Recent Changes (v2.0 Architecture Refactor) +This version represents a **complete rewrite** with: +- Clean separation between backend (Python) and frontend (JavaScript) +- Modular feature-based architecture +- Improved dependency injection and testability +- Better error handling with Result types +- Enhanced observability and logging +- Optimized database connection pooling +- Auto-detection of external tools +### General Tips + +- **Index location**: The SQLite database is stored in `/_mjr_index/assets.sqlite` - **Collections folder**: Smart filters and saved collections are stored in `_mjr_collections` inside your ComfyUI output directory - **Workflow fingerprinting**: Uses SHA1 of canonicalized workflow JSON (excludes node IDs, positions, UI properties) for stable matching across workflow variations - **Index freshness**: The SQLite FTS5 index auto-detects staleness based on file modification times and triggers incremental reindexing diff --git a/__init__.py b/__init__.py index 7dcc276d..fcc74bfb 100644 --- a/__init__.py +++ b/__init__.py @@ -1,13 +1,138 @@ -""" -Majoor Assets Manager - ComfyUI extension -""" - -NODE_CLASS_MAPPINGS = {} -NODE_DISPLAY_NAME_MAPPINGS = {} -WEB_DIRECTORY = "./js" - -# Register API routes -from .server import routes # noqa: F401 -from .server import hooks # noqa: F401 - -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] +""" +ComfyUI-Majoor-AssetsManager +Advanced asset browser for ComfyUI with ratings, tags, and metadata management. +""" + +from __future__ import annotations + +import importlib +import os +import shutil +import subprocess +import sys +from typing import Optional +from pathlib import Path + +# ComfyUI extension metadata +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} +WEB_DIRECTORY = "./js" + +# ---- Windows safety guard ------------------------------------------------- +# ComfyUI loads every entry under `custom_nodes/`. On Windows, reserved device +# filenames like `NUL` can appear (accidentally created by tooling) and cause +# noisy import failures. We best-effort delete them when our extension loads so +# it can't break subsequent restarts. + + +def _windows_extended_path(path: str) -> str: + if path.startswith("\\\\?\\"): + return path + if path.startswith("\\\\"): + # UNC path: \\server\share\... -> \\?\UNC\server\share\... + return "\\\\?\\UNC\\" + path.lstrip("\\") + return "\\\\?\\" + path + + +def _try_delete_windows_path(path: str) -> None: + nt_path = _windows_extended_path(path) + try: + os.unlink(nt_path) + return + except IsADirectoryError: + pass + except FileNotFoundError: + return + except OSError: + # Fall through to directory removal / cmd fallback. + pass + + try: + shutil.rmtree(nt_path) + return + except FileNotFoundError: + return + except OSError: + pass + + # Last-resort fallback via cmd builtins (kept best-effort and silent). + try: + subprocess.run(["cmd", "/c", "del", "/f", "/q", nt_path], check=False, capture_output=True) + subprocess.run(["cmd", "/c", "rd", "/s", "/q", nt_path], check=False, capture_output=True) + except Exception: + pass + + +def _cleanup_windows_reserved_custom_nodes() -> Optional[str]: + try: + if os.name != "nt": + return None + custom_nodes_dir = Path(__file__).resolve().parent.parent + if not custom_nodes_dir.exists(): + return None + + try: + entries = os.listdir(str(custom_nodes_dir)) + except OSError: + return None + + for name in entries: + if name.strip().lower() != "nul": + continue + try: + victim = str((custom_nodes_dir / name).resolve()) + except Exception: + victim = str(custom_nodes_dir / name) + _try_delete_windows_path(victim) + return victim + except Exception: + return None + return None + + +try: + removed = _cleanup_windows_reserved_custom_nodes() + if removed: + print(f"Majoor Assets Manager: removed Windows reserved custom node entry: {removed}") +except Exception: + pass + +# Register API routes (importing `backend.routes` auto-registers via PromptServer decorators) +try: + # When loaded by ComfyUI as a proper package (preferred). + from .backend import routes # type: ignore # noqa: F401 +except Exception: + try: + # When ComfyUI loads custom nodes, it may load this file as a *module* (not a package), + # which breaks relative imports. Ensure our local `backend/` package is importable and + # does not get shadowed by any unrelated `backend` module on sys.path. + root = Path(__file__).resolve().parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + expected_backend_dir = (root / "backend").resolve() + existing_backend = sys.modules.get("backend") + if existing_backend is not None: + try: + existing_file = Path(getattr(existing_backend, "__file__", "")).resolve() + except Exception: + existing_file = None + + if not existing_file or expected_backend_dir not in existing_file.parents: + for name in list(sys.modules.keys()): + if name == "backend" or name.startswith("backend."): + sys.modules.pop(name, None) + + routes = importlib.import_module("backend.routes") # type: ignore # noqa: F401 + except Exception: + # Keep import side-effect-free when ComfyUI environment isn't available. + try: + import traceback + + print("Majoor Assets Manager: failed to import backend routes (API endpoints will be unavailable).") + traceback.print_exc() + except Exception: + pass + routes = None # type: ignore + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..65c1868d --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Server layer for Majoor Assets Manager.""" diff --git a/backend/adapters/__init__.py b/backend/adapters/__init__.py new file mode 100644 index 00000000..cc53d258 --- /dev/null +++ b/backend/adapters/__init__.py @@ -0,0 +1 @@ +"""Adapters for external tools and storage.""" diff --git a/backend/adapters/db/__init__.py b/backend/adapters/db/__init__.py new file mode 100644 index 00000000..9f57340c --- /dev/null +++ b/backend/adapters/db/__init__.py @@ -0,0 +1,5 @@ +"""Database adapters.""" +from .sqlite import Sqlite +from .schema import init_schema, migrate_schema, rebuild_fts + +__all__ = ["Sqlite", "init_schema", "migrate_schema", "rebuild_fts"] diff --git a/backend/adapters/db/schema.py b/backend/adapters/db/schema.py new file mode 100644 index 00000000..d6ee1ec9 --- /dev/null +++ b/backend/adapters/db/schema.py @@ -0,0 +1,487 @@ +""" +Database schema and migrations. +""" +import hashlib +import re +from pathlib import Path +from typing import List + +from ...shared import Result, get_logger, log_success + +logger = get_logger(__name__) + +CURRENT_SCHEMA_VERSION = 7 +# Schema version history (high-level): +# 1: initial assets + metadata tables +# 2-4: incremental columns and FTS/search support +# 5: workflow/generation flags, scan journal, and robustness fixes +# 6: asset sources (output/input/custom) + custom root id +# 7: metadata FTS (tags/metadata_raw) to improve search UX + +# Schema definition +SCHEMA_V1 = """ +-- Metadata table for schema versioning +CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- Assets table +CREATE TABLE IF NOT EXISTS assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + subfolder TEXT DEFAULT '', + filepath TEXT NOT NULL UNIQUE, + source TEXT DEFAULT 'output', -- output, input, custom + root_id TEXT, -- for source=custom + kind TEXT NOT NULL, -- image, video, audio, model3d + ext TEXT NOT NULL, + size INTEGER NOT NULL, -- File size in bytes + mtime INTEGER NOT NULL, -- File modification time (unix timestamp) + width INTEGER, -- Image/video width (NULL for non-visual assets) + height INTEGER, -- Image/video height (NULL for non-visual assets) + duration REAL, -- Video/audio duration in seconds (NULL for non-temporal assets) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Asset metadata (ratings, tags, workflow) +CREATE TABLE IF NOT EXISTS asset_metadata ( + asset_id INTEGER PRIMARY KEY, + rating INTEGER DEFAULT 0, + tags TEXT DEFAULT '', -- JSON array stored as string + tags_text TEXT DEFAULT '', -- Legacy text column + workflow_hash TEXT, + has_workflow BOOLEAN DEFAULT 0, + has_generation_data BOOLEAN DEFAULT 0, + metadata_quality TEXT DEFAULT 'none', -- full, partial, degraded, none + metadata_raw TEXT DEFAULT '{}', -- Full raw metadata as JSON + FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE +); + +-- Scan journal to track last-processed state per file +CREATE TABLE IF NOT EXISTS scan_journal ( + filepath TEXT PRIMARY KEY, + dir_path TEXT, + state_hash TEXT, + mtime INTEGER, + size INTEGER, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS metadata_cache ( + filepath TEXT PRIMARY KEY, + state_hash TEXT, + metadata_hash TEXT, + metadata_raw TEXT DEFAULT '{}', + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +""" + +# Migration from v1 to v2: Add metadata_raw column +COLUMN_DEFINITIONS = { + "assets": [ + # Contract columns used by services/UI (self-heal for partially/old-created DBs) + ("subfolder", "subfolder TEXT DEFAULT ''"), + ("source", "source TEXT DEFAULT 'output'"), + ("root_id", "root_id TEXT"), + ("width", "width INTEGER"), + ("height", "height INTEGER"), + ("duration", "duration REAL"), + ("created_at", "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"), + ("updated_at", "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"), + ("indexed_at", "indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"), + ], + "asset_metadata": [ + ("rating", "rating INTEGER DEFAULT 0"), + ("tags", "tags TEXT DEFAULT ''"), + ("tags_text", "tags_text TEXT DEFAULT ''"), + ("workflow_hash", "workflow_hash TEXT"), + ("has_workflow", "has_workflow BOOLEAN DEFAULT 0"), + ("has_generation_data", "has_generation_data BOOLEAN DEFAULT 0"), + ("metadata_quality", "metadata_quality TEXT DEFAULT 'none'"), + ("metadata_raw", "metadata_raw TEXT DEFAULT '{}'"), + ], + "scan_journal": [ + ("dir_path", "dir_path TEXT"), + ("state_hash", "state_hash TEXT"), + ("mtime", "mtime INTEGER"), + ("size", "size INTEGER"), + ("last_seen", "last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP"), + ], + "metadata_cache": [ + ("state_hash", "state_hash TEXT"), + ("metadata_hash", "metadata_hash TEXT"), + ("metadata_raw", "metadata_raw TEXT DEFAULT '{}'"), + ("last_updated", "last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP"), + ], +} + +INDEXES_AND_TRIGGERS = """ +-- Full-text search (FTS5) for filenames +CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5( + filename, + subfolder, + content='assets', + content_rowid='id' +); + +-- Full-text search (FTS5) for metadata/tags +-- Note: contentless table keyed by asset_id to avoid duplicating base rows. +-- Removed metadata_raw to reduce DB size and improve performance +CREATE VIRTUAL TABLE IF NOT EXISTS asset_metadata_fts USING fts5( + tags, + tags_text, + content='' +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_assets_filename ON assets(filename); +CREATE INDEX IF NOT EXISTS idx_assets_subfolder ON assets(subfolder); +CREATE INDEX IF NOT EXISTS idx_assets_kind ON assets(kind); +CREATE INDEX IF NOT EXISTS idx_assets_mtime ON assets(mtime); +CREATE INDEX IF NOT EXISTS idx_assets_source ON assets(source); +CREATE INDEX IF NOT EXISTS idx_assets_root_id ON assets(root_id); +CREATE INDEX IF NOT EXISTS idx_assets_source_root_id ON assets(source, root_id); +CREATE INDEX IF NOT EXISTS idx_metadata_rating ON asset_metadata(rating); +CREATE INDEX IF NOT EXISTS idx_metadata_workflow_hash ON asset_metadata(workflow_hash); +CREATE INDEX IF NOT EXISTS idx_scan_journal_dir ON scan_journal(dir_path); +CREATE INDEX IF NOT EXISTS idx_metadata_cache_state ON metadata_cache(state_hash); + +CREATE TRIGGER IF NOT EXISTS assets_fts_insert AFTER INSERT ON assets BEGIN + INSERT INTO assets_fts(rowid, filename, subfolder) + VALUES (new.id, new.filename, new.subfolder); +END; + +CREATE TRIGGER IF NOT EXISTS assets_fts_delete AFTER DELETE ON assets BEGIN + DELETE FROM assets_fts WHERE rowid = old.id; +END; + +CREATE TRIGGER IF NOT EXISTS assets_fts_update AFTER UPDATE ON assets BEGIN + UPDATE assets_fts SET filename = new.filename, subfolder = new.subfolder + WHERE rowid = new.id; +END; + +-- Keep metadata FTS in sync +CREATE TRIGGER IF NOT EXISTS asset_metadata_fts_insert AFTER INSERT ON asset_metadata BEGIN + INSERT INTO asset_metadata_fts(rowid, tags, tags_text) + VALUES (new.asset_id, COALESCE(new.tags, ''), COALESCE(new.tags_text, '')); +END; + +CREATE TRIGGER IF NOT EXISTS asset_metadata_fts_delete AFTER DELETE ON asset_metadata BEGIN + INSERT INTO asset_metadata_fts(asset_metadata_fts, rowid) VALUES('delete', old.asset_id); +END; + +CREATE TRIGGER IF NOT EXISTS asset_metadata_fts_update AFTER UPDATE ON asset_metadata BEGIN + INSERT INTO asset_metadata_fts(asset_metadata_fts, rowid) VALUES('delete', old.asset_id); + INSERT INTO asset_metadata_fts(rowid, tags, tags_text) + VALUES (new.asset_id, COALESCE(new.tags, ''), COALESCE(new.tags_text, '')); +END; +""" + +_SAFE_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _is_safe_identifier(value: str) -> bool: + return bool(value and isinstance(value, str) and _SAFE_IDENT_RE.match(value)) + + +def _get_table_columns(db, table_name: str) -> Result[List[str]]: + if not _is_safe_identifier(table_name): + return Result.Err("INVALID_INPUT", f"Invalid table name: {table_name}") + result = db.query(f"PRAGMA table_info('{table_name}')") + if not result.ok: + return Result.Err("PRAGMA_FAILED", f"Unable to inspect {table_name}: {result.error}") + return Result.Ok([row["name"] for row in result.data or []]) + + +def table_has_column(db, table_name: str, column_name: str) -> bool: + if not _is_safe_identifier(table_name) or not _is_safe_identifier(column_name): + logger.warning("Invalid identifier in table_has_column: %s.%s", table_name, column_name) + return False + columns_result = _get_table_columns(db, table_name) + if not columns_result.ok: + logger.warning( + "Unable to determine columns for %s.%s: %s", + table_name, + column_name, + columns_result.error + ) + return False + + return column_name in columns_result.data + + +def _ensure_column(db, table_name: str, column_name: str, definition: str) -> Result[bool]: + columns_result = _get_table_columns(db, table_name) + if not columns_result.ok: + return columns_result + + if column_name in columns_result.data: + return Result.Ok(True) + + logger.info("Adding missing column %s.%s", table_name, column_name) + alter_result = db.execute( + f"ALTER TABLE {table_name} ADD COLUMN {definition}" + ) + return alter_result + + +def ensure_columns_exist(db) -> Result[bool]: + for table, columns in COLUMN_DEFINITIONS.items(): + for column_name, definition in columns: + result = _ensure_column(db, table, column_name, definition) + if not result.ok: + logger.error("Failed to ensure column %s.%s: %s", table, column_name, result.error) + return result + return Result.Ok(True) + + +def ensure_tables_exist(db) -> Result[bool]: + logger.info("Ensuring tables exist...") + result = db.executescript(SCHEMA_V1) + if not result.ok: + logger.error("Failed to ensure base tables: %s", result.error) + return result + + +def ensure_indexes_and_triggers(db) -> Result[bool]: + logger.info("Ensuring indexes/triggers exist...") + result = db.executescript(INDEXES_AND_TRIGGERS) + if not result.ok: + logger.error("Failed to ensure indexes/triggers: %s", result.error) + return result + try: + _repair_asset_metadata_fts(db) + except Exception as exc: + logger.warning("Failed to repair asset_metadata_fts: %s", exc) + return result + + +def _fts_has_column(db, table: str, col: str) -> bool: + """Check if an FTS table has a specific column.""" + try: + r = db.query(f"PRAGMA table_info({table})") + if not r.ok or not r.data: + return False + return any((row.get("name") == col) for row in r.data) + except Exception: + return False + + +def _repair_asset_metadata_fts(db) -> None: + """ + Repair legacy/incorrect FTS definition for asset metadata. + + Older versions used `content_rowid='asset_id'` on a contentless table, which can + break updates with errors like "no such column: T.asset_id". + Also check for missing `tags_text` column in existing FTS tables. + """ + try: + table_row = db.query("SELECT sql FROM sqlite_master WHERE type='table' AND name='asset_metadata_fts' LIMIT 1") + ddl = "" + if table_row.ok and table_row.data: + ddl = str(table_row.data[0].get("sql") or "") + ddl_lower = ddl.lower() + + trig_row = db.query("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='asset_metadata_fts_update' LIMIT 1") + trig_sql = "" + if trig_row.ok and trig_row.data: + trig_sql = str(trig_row.data[0].get("sql") or "") + trig_lower = trig_sql.lower() + + needs_table_rebuild = ("content_rowid" in ddl_lower and "asset_id" in ddl_lower) + needs_trigger_rebuild = ("update asset_metadata_fts" in trig_lower) + missing_tags_text = not _fts_has_column(db, "asset_metadata_fts", "tags_text") + + if missing_tags_text: + needs_table_rebuild = True + + if not (needs_table_rebuild or needs_trigger_rebuild): + return + except Exception: + return + + logger.warning("Repairing asset_metadata_fts (schema/triggers)") + + if needs_table_rebuild: + db.executescript( + """ + DROP TRIGGER IF EXISTS asset_metadata_fts_insert; + DROP TRIGGER IF EXISTS asset_metadata_fts_delete; + DROP TRIGGER IF EXISTS asset_metadata_fts_update; + DROP TABLE IF EXISTS asset_metadata_fts; + """ + ) + db.executescript( + """ + CREATE VIRTUAL TABLE IF NOT EXISTS asset_metadata_fts USING fts5( + tags, + tags_text, + content='' + ); + """ + ) + else: + db.executescript( + """ + DROP TRIGGER IF EXISTS asset_metadata_fts_insert; + DROP TRIGGER IF EXISTS asset_metadata_fts_delete; + DROP TRIGGER IF EXISTS asset_metadata_fts_update; + """ + ) + + db.executescript( + """ + CREATE TRIGGER IF NOT EXISTS asset_metadata_fts_insert AFTER INSERT ON asset_metadata BEGIN + INSERT INTO asset_metadata_fts(rowid, tags, tags_text) + VALUES (new.asset_id, COALESCE(new.tags, ''), COALESCE(new.tags_text, '')); + END; + + CREATE TRIGGER IF NOT EXISTS asset_metadata_fts_delete AFTER DELETE ON asset_metadata BEGIN + INSERT INTO asset_metadata_fts(asset_metadata_fts, rowid) VALUES('delete', old.asset_id); + END; + + CREATE TRIGGER IF NOT EXISTS asset_metadata_fts_update AFTER UPDATE ON asset_metadata BEGIN + INSERT INTO asset_metadata_fts(asset_metadata_fts, rowid) VALUES('delete', old.asset_id); + INSERT INTO asset_metadata_fts(rowid, tags, tags_text) + VALUES (new.asset_id, COALESCE(new.tags, ''), COALESCE(new.tags_text, '')); + END; + """ + ) + + # Rebuild to ensure consistency after trigger/table repair. + db.executescript( + """ + DELETE FROM asset_metadata_fts; + INSERT INTO asset_metadata_fts(rowid, tags, tags_text) + SELECT asset_id, COALESCE(tags, ''), COALESCE(tags_text, '') + FROM asset_metadata; + """ + ) + + +def _schema_fingerprint() -> str: + """ + Store a stable fingerprint of the schema DDL so we can detect "exotic" DBs. + This is informational (self-heal is done via COLUMN_DEFINITIONS). + """ + ddl = f"{SCHEMA_V1}\n{INDEXES_AND_TRIGGERS}" + normalized = "\n".join(line.strip() for line in ddl.splitlines() if line.strip()) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def _ensure_schema_fingerprint(db) -> Result[bool]: + if not db.has_table("metadata"): + return Result.Ok(True) + + try: + fingerprint = _schema_fingerprint() + except Exception as exc: + logger.warning("Unable to compute schema fingerprint: %s", exc) + return Result.Ok(True) + + existing = db.execute( + "SELECT value FROM metadata WHERE key = 'schema_ddl_hash'", + fetch=True + ) + if existing.ok and existing.data: + try: + current = (existing.data[0] or {}).get("value") or "" + except Exception: + current = "" + if current and current != fingerprint: + logger.warning( + "Database schema fingerprint differs from expected (will self-heal columns anyway)" + ) + + return db.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_ddl_hash', ?)", + (fingerprint,) + ) + + +def _ensure_schema(db) -> Result[bool]: + result = ensure_tables_exist(db) + if not result.ok: + return result + + result = ensure_columns_exist(db) + if not result.ok: + return result + + result = ensure_indexes_and_triggers(db) + if not result.ok: + return result + + version_result = db.set_schema_version(CURRENT_SCHEMA_VERSION) + if not version_result.ok: + logger.error("Failed to set schema version: %s", version_result.error) + return version_result + + fp_result = _ensure_schema_fingerprint(db) + if not fp_result.ok: + logger.warning("Failed to store schema fingerprint: %s", fp_result.error) + + log_success(logger, f"Schema ensured (version {CURRENT_SCHEMA_VERSION})") + return Result.Ok(True) + + +def init_schema(db) -> Result[bool]: + """ + Initialize the schema (useful for tests or first-time installs). + """ + return _ensure_schema(db) + +def migrate_schema(db) -> Result[bool]: + """ + Repair schema to current version by ensuring expected tables, columns, + indexes, and triggers exist. + + Args: + db: Sqlite instance + + Returns: + Result with success boolean + """ + current_version = db.get_schema_version() + logger.info("Ensuring schema (current version %s -> target %s)", current_version, CURRENT_SCHEMA_VERSION) + + repair_result = _ensure_schema(db) + if not repair_result.ok: + return repair_result + + final_version = db.get_schema_version() + + if current_version == final_version: + logger.info("Schema already reported up to date (%s)", final_version) + else: + log_success(logger, f"Schema repaired from version {current_version} to {final_version}") + + return Result.Ok(True) + +def rebuild_fts(db) -> Result[bool]: + """ + Rebuild full-text search index. + + Args: + db: Sqlite instance + + Returns: + Result with success boolean + """ + logger.info("Rebuilding FTS index...") + + result = db.execute("INSERT INTO assets_fts(assets_fts) VALUES('rebuild')") + if not result.ok: + logger.error(f"Failed to rebuild assets_fts: {result.error}") + return result + + result = db.execute("INSERT INTO asset_metadata_fts(asset_metadata_fts) VALUES('rebuild')") + if not result.ok: + logger.error(f"Failed to rebuild asset_metadata_fts: {result.error}") + return result + + log_success(logger, "FTS index rebuilt") + return Result.Ok(True) diff --git a/backend/adapters/db/sqlite.py b/backend/adapters/db/sqlite.py new file mode 100644 index 00000000..60c26f36 --- /dev/null +++ b/backend/adapters/db/sqlite.py @@ -0,0 +1,374 @@ +""" +SQLite database connection manager. +Thread-safe with WAL mode + lightweight connection pooling for concurrency. +""" +import sqlite3 +import threading +import time +import random +from contextlib import contextmanager +from queue import Empty, Queue +from typing import Optional, Any, List, Dict, Tuple +from pathlib import Path + +from ...shared import Result, ErrorCode, get_logger +from ...config import DB_QUERY_TIMEOUT, DB_MAX_CONNECTIONS + +logger = get_logger(__name__) + + +class Sqlite: + """ + Connection pool manager for SQLite. + + Maintains a bounded pool of connections to keep WAL-friendly concurrency without + over-subscribing file handles. + """ + + def __init__(self, db_path: str, max_connections: Optional[int] = None, timeout: float = 30.0): + self.db_path = Path(db_path) + max_conn = int(max_connections) if max_connections is not None else int(DB_MAX_CONNECTIONS or 8) + max_conn = max(1, max_conn) + self._pool = Queue(maxsize=max_conn) + self._initialized = False + self._lock = threading.Lock() + self._sem = threading.BoundedSemaphore(max_conn) + self._timeout = timeout + self._query_timeout = float(DB_QUERY_TIMEOUT) if DB_QUERY_TIMEOUT is not None else 0.0 + self._local = threading.local() + self._lock_retry_attempts = 6 + self._lock_retry_base_seconds = 0.05 + self._lock_retry_max_seconds = 0.75 + + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + def _is_locked_error(self, exc: Exception) -> bool: + try: + msg = str(exc).lower() + except Exception: + return False + return ( + "database is locked" in msg + or "database table is locked" in msg + or "database schema is locked" in msg + or "busy" in msg + or "locked" in msg + ) + + def _sleep_backoff(self, attempt: int): + base = float(self._lock_retry_base_seconds) + max_s = float(self._lock_retry_max_seconds) + delay = min(max_s, base * (2 ** max(0, attempt))) + delay = delay + (random.random() * 0.03) + time.sleep(delay) + + def _apply_connection_pragmas(self, conn: sqlite3.Connection): + cursor = conn.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA cache_size=-64000") + cursor.execute("PRAGMA temp_store=MEMORY") + cursor.execute("PRAGMA busy_timeout = 5000") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + def _init_db(self): + if self._initialized: + return + + with self._lock: + if self._initialized: + return + + try: + with self._connection() as conn: + self._apply_connection_pragmas(conn) + self._initialized = True + logger.info(f"Database initialized: {self.db_path}") + except Exception as exc: + logger.error(f"Failed to initialize database: {exc}") + raise + + def _create_connection(self) -> sqlite3.Connection: + conn = sqlite3.connect( + str(self.db_path), + check_same_thread=False, + timeout=self._timeout + ) + conn.row_factory = sqlite3.Row + self._apply_connection_pragmas(conn) + return conn + + def _acquire_connection(self) -> sqlite3.Connection: + self._sem.acquire() + try: + try: + conn = self._pool.get_nowait() + except Empty: + conn = self._create_connection() + return conn + except Exception: + # Ensure we don't leak the semaphore if creating a new connection fails. + self._sem.release() + raise + + def _release_connection(self, conn: sqlite3.Connection): + try: + if not conn: + return + if not self._pool.full(): + self._pool.put(conn) + else: + conn.close() + finally: + self._sem.release() + + @contextmanager + def _connection(self): + conn = getattr(self._local, "conn", None) + if conn: + yield conn + return + conn = self._acquire_connection() + try: + yield conn + finally: + self._release_connection(conn) + + @contextmanager + def _query_timeout_guard(self, conn: sqlite3.Connection): + """ + Apply a per-statement timeout using SQLite's progress handler. + + This prevents individual long-running queries from blocking indefinitely. + """ + timeout = float(self._query_timeout or 0.0) + if timeout <= 0: + yield + return + + start = time.monotonic() + + def _progress_handler() -> int: + # Return non-zero to interrupt the currently running SQLite operation. + if (time.monotonic() - start) >= timeout: + return 1 + return 0 + + try: + conn.set_progress_handler(_progress_handler, 10_000) + yield + finally: + try: + conn.set_progress_handler(None, 0) + except Exception: + pass + + def execute( + self, + query: str, + params: Optional[tuple] = None, + fetch: bool = False + ) -> Result[Any]: + try: + for attempt in range(self._lock_retry_attempts + 1): + try: + with self._connection() as conn: + cursor = conn.cursor() + try: + with self._query_timeout_guard(conn): + if params: + cursor.execute(query, params) + else: + cursor.execute(query) + + if fetch: + rows = cursor.fetchall() + return Result.Ok([dict(row) for row in rows]) + # Commit unless we are inside the explicit transaction() context. + if getattr(self._local, "conn", None) is not conn: + conn.commit() + return Result.Ok(cursor.lastrowid if cursor.lastrowid else cursor.rowcount) + finally: + try: + cursor.close() + except Exception: + pass + except sqlite3.OperationalError as exc: + if self._is_locked_error(exc) and attempt < self._lock_retry_attempts: + self._sleep_backoff(attempt) + continue + raise + except sqlite3.IntegrityError as exc: + logger.warning(f"Integrity error: {exc}") + return Result.Err(ErrorCode.DB_ERROR, f"Integrity error: {exc}") + except sqlite3.OperationalError as exc: + if "interrupted" in str(exc).lower(): + return Result.Err(ErrorCode.TIMEOUT, "Database operation interrupted (query timeout)") + logger.error(f"Operational error: {exc}") + return Result.Err(ErrorCode.DB_ERROR, f"Operational error: {exc}") + except Exception as exc: + logger.error(f"Unexpected database error: {exc}") + return Result.Err(ErrorCode.DB_ERROR, str(exc)) + + def query( + self, + sql: str, + params: Optional[tuple] = None + ) -> Result[List[Dict[str, Any]]]: + return self.execute(sql, params, fetch=True) + + def executemany( + self, + query: str, + params_list: List[Tuple] + ) -> Result[int]: + try: + for attempt in range(self._lock_retry_attempts + 1): + try: + with self._connection() as conn: + cursor = conn.cursor() + try: + with self._query_timeout_guard(conn): + cursor.executemany(query, params_list) + if getattr(self._local, "conn", None) is not conn: + conn.commit() + return Result.Ok(cursor.rowcount) + finally: + try: + cursor.close() + except Exception: + pass + except sqlite3.OperationalError as exc: + if self._is_locked_error(exc) and attempt < self._lock_retry_attempts: + self._sleep_backoff(attempt) + continue + raise + except sqlite3.OperationalError as exc: + if "interrupted" in str(exc).lower(): + return Result.Err(ErrorCode.TIMEOUT, "Database operation interrupted (query timeout)") + logger.error(f"Batch execute error: {exc}") + return Result.Err(ErrorCode.DB_ERROR, str(exc)) + except Exception as exc: + logger.error(f"Batch execute error: {exc}") + return Result.Err(ErrorCode.DB_ERROR, str(exc)) + + def executescript(self, script: str) -> Result[bool]: + try: + for attempt in range(self._lock_retry_attempts + 1): + try: + with self._connection() as conn: + cursor = conn.cursor() + try: + with self._query_timeout_guard(conn): + cursor.executescript(script) + if getattr(self._local, "conn", None) is not conn: + conn.commit() + return Result.Ok(True) + finally: + try: + cursor.close() + except Exception: + pass + except sqlite3.OperationalError as exc: + if self._is_locked_error(exc) and attempt < self._lock_retry_attempts: + self._sleep_backoff(attempt) + continue + raise + except sqlite3.OperationalError as exc: + if "interrupted" in str(exc).lower(): + return Result.Err(ErrorCode.TIMEOUT, "Database operation interrupted (query timeout)") + logger.error(f"Script execution error: {exc}") + return Result.Err(ErrorCode.DB_ERROR, str(exc)) + except Exception as exc: + logger.error(f"Script execution error: {exc}") + return Result.Err(ErrorCode.DB_ERROR, str(exc)) + + def close(self): + while not self._pool.empty(): + conn = self._pool.get_nowait() + conn.close() + + @contextmanager + def transaction(self, mode: str = "immediate"): + conn = self._acquire_connection() + self._local.conn = conn + try: + begin_stmt = "BEGIN IMMEDIATE" + if isinstance(mode, str) and mode.lower() in ("deferred", "immediate", "exclusive"): + begin_stmt = f"BEGIN {mode.upper()}" + elif isinstance(mode, str) and mode.lower() == "": + begin_stmt = "BEGIN" + + for attempt in range(self._lock_retry_attempts + 1): + try: + conn.execute(begin_stmt) + break + except sqlite3.OperationalError as exc: + if self._is_locked_error(exc) and attempt < self._lock_retry_attempts: + self._sleep_backoff(attempt) + continue + raise + yield + for attempt in range(self._lock_retry_attempts + 1): + try: + conn.commit() + break + except sqlite3.OperationalError as exc: + if self._is_locked_error(exc) and attempt < self._lock_retry_attempts: + self._sleep_backoff(attempt) + continue + raise + except Exception: + try: + conn.rollback() + except Exception: + pass + raise + finally: + try: + if hasattr(self._local, "conn"): + del self._local.conn + finally: + self._release_connection(conn) + + def get_schema_version(self) -> int: + if not self.has_table("metadata"): + return 0 + + result = self.execute( + "SELECT value FROM metadata WHERE key = 'schema_version'", + fetch=True + ) + if result.ok and result.data and len(result.data) > 0: + try: + return int(result.data[0]["value"]) + except (ValueError, KeyError, TypeError): + logger.warning("Invalid schema_version value in database") + return 0 + return 0 + + def set_schema_version(self, version: int) -> Result[bool]: + return self.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?)", + (str(version),) + ) + + def vacuum(self) -> Result[bool]: + try: + with self._connection() as conn: + conn.execute("VACUUM") + logger.info("Database vacuumed successfully") + return Result.Ok(True) + except Exception as exc: + logger.error(f"Vacuum error: {exc}") + return Result.Err(ErrorCode.DB_ERROR, str(exc)) + + def has_table(self, table_name: str) -> bool: + result = self.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", + (table_name,), + fetch=True + ) + return bool(result.ok and result.data and len(result.data) > 0) diff --git a/backend/adapters/tools/__init__.py b/backend/adapters/tools/__init__.py new file mode 100644 index 00000000..a1632bc2 --- /dev/null +++ b/backend/adapters/tools/__init__.py @@ -0,0 +1,5 @@ +"""Tool adapters for external utilities.""" +from .exiftool import ExifTool +from .ffprobe import FFProbe + +__all__ = ["ExifTool", "FFProbe"] diff --git a/backend/adapters/tools/exiftool.py b/backend/adapters/tools/exiftool.py new file mode 100644 index 00000000..c1c56a6a --- /dev/null +++ b/backend/adapters/tools/exiftool.py @@ -0,0 +1,519 @@ +""" +ExifTool adapter for reading and writing metadata. +""" +import os +import subprocess +import json +import shutil +from typing import Optional, List, Dict, Any, Tuple +from pathlib import Path + +from ...config import EXIFTOOL_TIMEOUT +from ...shared import Result, ErrorCode, get_logger + +logger = get_logger(__name__) + +def _normalize_match_path(path_value: str) -> Optional[str]: + """ + Normalize a filesystem path string for robust equality matching. + + ExifTool's `SourceFile` may differ from input paths by: + - path separators + - drive letter / case (Windows) + - relative vs absolute forms + """ + if not path_value or "\x00" in str(path_value): + return None + try: + p = Path(str(path_value)).expanduser() + # Use strict=False so a best-effort normalization still works even if ExifTool + # returns a path format that isn't directly resolvable in the current context. + resolved = p.resolve(strict=False) + return os.path.normcase(os.path.normpath(str(resolved))) + except Exception: + try: + return os.path.normcase(os.path.normpath(str(path_value))) + except Exception: + return None + + +def _build_match_map(valid_paths: List[str]) -> Tuple[Dict[str, List[str]], List[str]]: + """ + Build: + - a map from normalized path key -> list of original input paths to fill + - a de-duplicated list of command paths (first occurrence per key) + """ + key_to_paths: Dict[str, List[str]] = {} + cmd_paths: List[str] = [] + + for original in valid_paths: + key = _normalize_match_path(original) or str(original) + if key not in key_to_paths: + key_to_paths[key] = [] + cmd_paths.append(original) + key_to_paths[key].append(original) + + return key_to_paths, cmd_paths + + +class ExifTool: + """ + ExifTool wrapper for metadata operations. + + Never raises exceptions - always returns Result. + """ + + def __init__(self, bin_name: str = "exiftool", timeout: Optional[float] = None): + """ + Initialize ExifTool adapter. + + Args: + bin_name: ExifTool binary name or path + timeout: Command timeout in seconds + """ + self.bin = bin_name + self.timeout = float(timeout) if timeout is not None else float(EXIFTOOL_TIMEOUT) + self._available = self._check_available() + + def _resolve_executable(self, bin_name: str) -> Optional[str]: + """ + Resolve and validate the exiftool executable. + + This defends against untrusted configuration values and ensures we're + invoking an actual exiftool binary (not an arbitrary command string). + """ + raw = (bin_name or "").strip() + if not raw: + return None + if "\x00" in raw or "\n" in raw or "\r" in raw: + return None + if any(ch in raw for ch in ("&", "|", ";", ">", "<")): + return None + + resolved = shutil.which(raw) + if not resolved: + try: + candidate = Path(raw) + if candidate.is_file(): + resolved = str(candidate.resolve(strict=True)) + except (OSError, RuntimeError, ValueError): + return None + + if not resolved: + return None + + try: + name = Path(resolved).name.lower() + except Exception: + name = str(resolved).lower() + + # Allow exiftool.exe, exiftool(-k).exe, etc. + if not name.startswith("exiftool"): + return None + + return resolved + + def _check_available(self) -> bool: + """Check if ExifTool is available in PATH.""" + resolved = self._resolve_executable(self.bin) + if not resolved: + return False + self.bin = resolved + return True + + def is_available(self) -> bool: + """Check if ExifTool is available.""" + return self._available + + def read(self, path: str, tags: Optional[List[str]] = None) -> Result[Dict[str, Any]]: + """ + Read metadata from file using ExifTool. + + Args: + path: File path + tags: Optional list of specific tags to read + + Returns: + Result with metadata dict or error + """ + if not self._available: + return Result.Err( + ErrorCode.TOOL_MISSING, + "ExifTool not found in PATH", + quality="none" + ) + + if not path or "\x00" in str(path): + return Result.Err(ErrorCode.INVALID_INPUT, "Invalid file path", quality="none") + try: + p = Path(str(path)) + except Exception: + return Result.Err(ErrorCode.INVALID_INPUT, "Invalid file path", quality="none") + if not p.exists() or not p.is_file(): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {path}", quality="none") + + def _run(cmd: List[str], stdin_input: Optional[str]) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=self.timeout, + encoding="utf-8", + errors="replace", + input=stdin_input, + ) + + try: + # Build command with comprehensive extraction flags + # -j: JSON output + # -G1: Group names with family 1 + # -ee: Extract embedded data (critical for ComfyUI workflows) + # -a: Allow duplicate tags + # -U: Extract unknown tags + # -s: Short tag names + cmd = [self.bin, "-j", "-G1", "-ee", "-a", "-U", "-s"] + if tags: + cmd.extend([f"-{tag}" for tag in tags]) + + # Windows Unicode compatibility: + # - Prefer argv (most robust with Win32 wide-char argv). + # - If ExifTool reports "File not found" (common when filename charset handling + # is buggy), retry via stdin argfile with explicit filename charset. + stdin_input: Optional[str] = None + if os.name == "nt": + cmd.extend(["-charset", "filename=utf8", str(path)]) + else: + cmd.append(path) + + # Run command + process = _run(cmd, stdin_input) + + if os.name == "nt" and process.returncode != 0: + stderr_msg = (process.stderr or "").strip() + # Retry only for the specific "file not found" failure mode; avoid + # masking other errors (invalid tags, parse errors, etc.). + if "file not found" in stderr_msg.lower(): + retry_cmd = [self.bin, "-j", "-G1", "-ee", "-a", "-U", "-s"] + if tags: + retry_cmd.extend([f"-{tag}" for tag in tags]) + retry_cmd.extend(["-charset", "filename=utf8", "-@", "-"]) + # Prefer CRLF on Windows to match typical ExifTool stdin parsing behavior. + retry_process = _run(retry_cmd, f"{path}\r\n") + if retry_process.returncode == 0: + process = retry_process + + stdout = process.stdout or "" + stderr = process.stderr or "" + had_replacements = ("\ufffd" in stdout) or ("\ufffd" in stderr) + if had_replacements: + logger.warning("ExifTool output contained decoding replacement characters for %s", path) + + if process.returncode != 0: + stderr_msg = stderr.strip() + logger.warning(f"ExifTool error for {path}: {stderr_msg}") + return Result.Err( + ErrorCode.EXIFTOOL_ERROR, + stderr_msg or "ExifTool command failed", + quality="degraded" + ) + + # Parse JSON output + if not stdout.strip(): + return Result.Err( + ErrorCode.PARSE_ERROR, + "ExifTool returned empty output", + quality="degraded" + ) + try: + data = json.loads(stdout) + except json.JSONDecodeError as e: + logger.error(f"ExifTool JSON parse error: {e}") + return Result.Err( + ErrorCode.PARSE_ERROR, + f"Failed to parse ExifTool output: {e}", + quality="degraded" + ) + if isinstance(data, list) and len(data) > 0: + return Result.Ok(data[0], quality="full" if not had_replacements else "degraded") + + return Result.Err( + ErrorCode.PARSE_ERROR, + "No metadata found", + quality="none" + ) + + except subprocess.TimeoutExpired: + logger.error(f"ExifTool timeout for {path}") + return Result.Err( + ErrorCode.TIMEOUT, + f"ExifTool timeout after {self.timeout}s", + quality="degraded" + ) + except json.JSONDecodeError as e: + logger.error(f"ExifTool JSON parse error: {e}") + return Result.Err( + ErrorCode.PARSE_ERROR, + f"Failed to parse ExifTool output: {e}", + quality="degraded" + ) + except Exception as e: + logger.error(f"ExifTool unexpected error: {e}") + return Result.Err( + ErrorCode.EXIFTOOL_ERROR, + str(e), + quality="degraded" + ) + + def read_batch(self, paths: List[str], tags: Optional[List[str]] = None) -> Dict[str, Result[Dict[str, Any]]]: + """ + Read metadata from multiple files using a single ExifTool invocation. + + This is dramatically faster than calling read() per file due to subprocess overhead. + + Args: + paths: List of file paths + tags: Optional list of specific tags to read + + Returns: + Dict mapping file path to Result with metadata + """ + if not self._available: + err = Result.Err(ErrorCode.TOOL_MISSING, "ExifTool not found in PATH", quality="none") + return {str(p): err for p in paths} + + if not paths: + return {} + + # Filter valid paths + valid_paths = [] + results = {} + + for path in paths: + if not path or "\x00" in str(path): + results[str(path)] = Result.Err(ErrorCode.INVALID_INPUT, "Invalid file path", quality="none") + continue + try: + p = Path(str(path)) + if not p.exists() or not p.is_file(): + results[str(path)] = Result.Err(ErrorCode.NOT_FOUND, f"File not found: {path}", quality="none") + continue + valid_paths.append(str(path)) + except Exception: + results[str(path)] = Result.Err(ErrorCode.INVALID_INPUT, "Invalid file path", quality="none") + + if not valid_paths: + return results + + key_to_paths, cmd_paths = _build_match_map(valid_paths) + + try: + # Build batch command + cmd = [self.bin, "-j", "-G1", "-ee", "-a", "-U", "-s"] + if tags: + cmd.extend([f"-{tag}" for tag in tags]) + + if os.name == "nt": + cmd.extend(["-charset", "filename=utf8"]) + + # Add all paths (de-duplicated by normalized key to avoid repeated work) + cmd.extend(cmd_paths) + + # Run command + process = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=self.timeout * len(cmd_paths), # Scale timeout with file count + encoding="utf-8", + errors="replace", + ) + + stdout = process.stdout or "" + stderr = process.stderr or "" + had_replacements = ("\ufffd" in stdout) or ("\ufffd" in stderr) + + if had_replacements: + logger.warning("ExifTool batch output contained decoding replacement characters") + + if process.returncode != 0: + stderr_msg = stderr.strip() + logger.warning(f"ExifTool batch error: {stderr_msg}") + # Partial failure - some files may have succeeded + + # Parse JSON output + if not stdout.strip(): + err = Result.Err(ErrorCode.PARSE_ERROR, "ExifTool returned empty output", quality="degraded") + for path in valid_paths: + results[path] = err + return results + + try: + data = json.loads(stdout) + except json.JSONDecodeError as e: + logger.error(f"ExifTool batch JSON parse error: {e}") + err = Result.Err(ErrorCode.PARSE_ERROR, f"Failed to parse ExifTool output: {e}", quality="degraded") + for path in valid_paths: + results[path] = err + return results + + if not isinstance(data, list): + err = Result.Err(ErrorCode.PARSE_ERROR, "ExifTool batch returned non-list", quality="degraded") + for path in valid_paths: + results[path] = err + return results + + # Match results to input paths using SourceFile field. + # ExifTool may skip corrupted files, so we CANNOT rely on array index matching. + for file_data in data: + if not isinstance(file_data, dict): + logger.warning(f"ExifTool batch returned non-dict item: {type(file_data)}") + continue + + # Get the source file from ExifTool result + source_file = file_data.get("SourceFile") + if not source_file: + logger.warning(f"ExifTool result missing SourceFile field") + continue + + source_key = _normalize_match_path(str(source_file)) + if not source_key: + logger.warning(f"Failed to normalize SourceFile path {source_file}") + continue + + matched_paths = key_to_paths.get(source_key) + if matched_paths: + for path in matched_paths: + results[path] = Result.Ok( + file_data, + quality="full" if not had_replacements else "degraded", + ) + else: + logger.warning(f"ExifTool returned data for unexpected file: {source_file}") + + # Handle files that didn't return results (corrupted, unsupported, etc.) + for path in valid_paths: + if path not in results: + results[path] = Result.Err(ErrorCode.PARSE_ERROR, "No metadata returned by ExifTool", quality="none") + + return results + + except subprocess.TimeoutExpired: + logger.error(f"ExifTool batch timeout for {len(valid_paths)} files") + err = Result.Err(ErrorCode.TIMEOUT, f"ExifTool batch timeout", quality="degraded") + for path in valid_paths: + if path not in results: + results[path] = err + return results + except Exception as e: + logger.error(f"ExifTool batch unexpected error: {e}") + err = Result.Err(ErrorCode.EXIFTOOL_ERROR, str(e), quality="degraded") + for path in valid_paths: + if path not in results: + results[path] = err + return results + + def write(self, path: str, metadata: dict, preserve_workflow: bool = True) -> Result[bool]: + """ + Write metadata to file using ExifTool. + + Args: + path: File path + metadata: Metadata key-value pairs to write + preserve_workflow: Copy original metadata fields before writing + + Returns: + Result with success boolean + """ + if not self._available: + return Result.Err( + ErrorCode.TOOL_MISSING, + "ExifTool not found in PATH" + ) + + if not path or "\x00" in str(path): + return Result.Err(ErrorCode.INVALID_INPUT, "Invalid file path") + try: + p = Path(str(path)) + except Exception: + return Result.Err(ErrorCode.INVALID_INPUT, "Invalid file path") + if not p.exists() or not p.is_file(): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {path}") + + try: + # Build command + cmd = [self.bin] + + # Preserve existing metadata if requested + if preserve_workflow: + cmd.extend(["-tagsFromFile", "@"]) + + # Add metadata tags + # - Scalars: `-Tag=Value` + # - Lists: clear then append (`-Tag=` then `-Tag+=Item`) + # This matches ExifTool's array semantics (XMP arrays, IPTC keyword lists, etc.). + for key, value in (metadata or {}).items(): + if value is None: + cmd.append(f"-{key}=") + continue + if isinstance(value, list): + cmd.append(f"-{key}=") + for item in value: + if item is None: + continue + text = str(item).strip() + if text: + cmd.append(f"-{key}+={text}") + continue + cmd.append(f"-{key}={value}") + + stdin_input = None + if os.name == "nt": + cmd.extend(["-charset", "filename=utf8", "-@", "-"]) + stdin_input = f"{path}\n" + cmd.append("-overwrite_original") + else: + # Overwrite original file + cmd.extend(["-overwrite_original", path]) + + # Run command + process = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=self.timeout, + encoding="utf-8", + errors="replace", + input=stdin_input, + ) + + stdout = process.stdout or "" + stderr = process.stderr or "" + if "\ufffd" in stdout or "\ufffd" in stderr: + logger.warning("ExifTool write output contained decoding replacement characters for %s", path) + + if process.returncode != 0: + stderr_msg = stderr.strip() + logger.warning(f"ExifTool write error for {path}: {stderr_msg}") + return Result.Err( + ErrorCode.EXIFTOOL_ERROR, + stderr_msg or "ExifTool write failed" + ) + + logger.debug(f"Metadata written to {path}") + return Result.Ok(True) + + except subprocess.TimeoutExpired: + logger.error(f"ExifTool write timeout for {path}") + return Result.Err( + ErrorCode.TIMEOUT, + f"ExifTool write timeout after {self.timeout}s" + ) + except Exception as e: + logger.error(f"ExifTool write error: {e}") + return Result.Err( + ErrorCode.EXIFTOOL_ERROR, + str(e) + ) diff --git a/backend/adapters/tools/ffprobe.py b/backend/adapters/tools/ffprobe.py new file mode 100644 index 00000000..2ba39d99 --- /dev/null +++ b/backend/adapters/tools/ffprobe.py @@ -0,0 +1,254 @@ +""" +FFprobe adapter for video metadata extraction. +""" +import subprocess +import json +import shutil +from pathlib import Path +from typing import Optional, List, Dict, Any + +from ...config import FFPROBE_TIMEOUT +from ...shared import Result, ErrorCode, get_logger + +logger = get_logger(__name__) + +class FFProbe: + """ + FFprobe wrapper for video metadata extraction. + + Never raises exceptions - always returns Result. + """ + + def __init__(self, bin_name: str = "ffprobe", timeout: Optional[float] = None): + """ + Initialize FFprobe adapter. + + Args: + bin_name: FFprobe binary name or path + timeout: Command timeout in seconds + """ + self.bin = bin_name + self.timeout = float(timeout) if timeout is not None else float(FFPROBE_TIMEOUT) + self._available = self._check_available() + + def _check_available(self) -> bool: + """Check if ffprobe is available in PATH.""" + return shutil.which(self.bin) is not None + + def is_available(self) -> bool: + """Check if ffprobe is available.""" + return self._available + + def read(self, path: str) -> Result[dict]: + """ + Read video metadata using ffprobe. + + Args: + path: Video file path + + Returns: + Result with metadata dict containing 'format' and 'streams' + """ + if not self._available: + return Result.Err( + ErrorCode.TOOL_MISSING, + "ffprobe not found in PATH", + quality="none" + ) + + try: + # Build command + cmd = [ + self.bin, + "-v", "error", # Hide verbose output + "-print_format", "json", # JSON output + "-show_format", # Show container format + "-show_streams", # Show stream info + path + ] + + # Run command + process = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=self.timeout + ) + + if process.returncode != 0: + stderr = process.stderr.strip() + logger.warning(f"ffprobe error for {path}: {stderr}") + return Result.Err( + ErrorCode.FFPROBE_ERROR, + stderr or "ffprobe command failed", + quality="degraded" + ) + + # Parse JSON output + if not process.stdout or not process.stdout.strip(): + logger.warning(f"ffprobe returned empty output for {path}") + return Result.Err( + ErrorCode.FFPROBE_ERROR, + "No ffprobe output", + quality="degraded" + ) + + data = json.loads(process.stdout) + + if not isinstance(data, dict): + return Result.Err( + ErrorCode.PARSE_ERROR, + "Invalid ffprobe output format", + quality="degraded" + ) + + # Extract useful metadata + result = { + "format": data.get("format", {}), + "streams": data.get("streams", []), + "video_stream": self._find_video_stream(data.get("streams", [])), + "audio_stream": self._find_audio_stream(data.get("streams", [])), + } + + return Result.Ok(result, quality="full") + + except subprocess.TimeoutExpired: + logger.error(f"ffprobe timeout for {path}") + return Result.Err( + ErrorCode.TIMEOUT, + f"ffprobe timeout after {self.timeout}s", + quality="degraded" + ) + except json.JSONDecodeError as e: + logger.error(f"ffprobe JSON parse error: {e}") + return Result.Err( + ErrorCode.PARSE_ERROR, + f"Failed to parse ffprobe output: {e}", + quality="degraded" + ) + except Exception as e: + logger.error(f"ffprobe unexpected error: {e}") + return Result.Err( + ErrorCode.FFPROBE_ERROR, + str(e), + quality="degraded" + ) + + def read_batch(self, paths: List[str]) -> Dict[str, Result[dict]]: + """ + Read metadata from multiple video files. + + Note: FFProbe doesn't have native batch mode, so we use concurrent.futures + for parallel execution to avoid sequential subprocess overhead. + + Args: + paths: List of video file paths + + Returns: + Dict mapping file path to Result with metadata + """ + if not self._available: + err = Result.Err(ErrorCode.TOOL_MISSING, "ffprobe not found in PATH", quality="none") + return {str(p): err for p in paths} + + if not paths: + return {} + + from concurrent.futures import ThreadPoolExecutor, as_completed + results = {} + + # Use thread pool for parallel execution (I/O bound) + max_workers = min(4, len(paths)) # Limit to 4 concurrent ffprobe processes + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_path = {executor.submit(self.read, path): path for path in paths} + + for future in as_completed(future_to_path): + path = future_to_path[future] + try: + results[str(path)] = future.result() + except Exception as e: + logger.error(f"FFProbe batch error for {path}: {e}") + results[str(path)] = Result.Err(ErrorCode.FFPROBE_ERROR, str(e), quality="degraded") + + return results + + def _find_video_stream(self, streams: list) -> dict: + """Find first video stream in streams list.""" + for stream in streams: + if stream.get("codec_type") == "video": + return stream + return {} + + def _find_audio_stream(self, streams: list) -> dict: + """Find first audio stream in streams list.""" + for stream in streams: + if stream.get("codec_type") == "audio": + return stream + return {} + + def get_duration(self, path: str) -> Result[float]: + """ + Get video duration in seconds. + + Args: + path: Video file path + + Returns: + Result with duration as float + """ + result = self.read(path) + if not result.ok: + return result + + # Try format duration first + format_info = result.data.get("format", {}) + duration_str = format_info.get("duration") + + if duration_str: + try: + return Result.Ok(float(duration_str)) + except ValueError: + pass + + # Try video stream duration + video_stream = result.data.get("video_stream", {}) + duration_str = video_stream.get("duration") + + if duration_str: + try: + return Result.Ok(float(duration_str)) + except ValueError: + pass + + return Result.Err( + ErrorCode.PARSE_ERROR, + "Duration not found in video metadata" + ) + + def get_resolution(self, path: str) -> Result[tuple[int, int]]: + """ + Get video resolution (width, height). + + Args: + path: Video file path + + Returns: + Result with (width, height) tuple + """ + result = self.read(path) + if not result.ok: + return result + + video_stream = result.data.get("video_stream", {}) + width = video_stream.get("width") + height = video_stream.get("height") + + if width and height: + return Result.Ok((int(width), int(height))) + + return Result.Err( + ErrorCode.PARSE_ERROR, + "Resolution not found in video metadata" + ) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 00000000..2df4e08b --- /dev/null +++ b/backend/config.py @@ -0,0 +1,108 @@ +""" +Configuration for Majoor Assets Manager. +""" +import os +import sys +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +def _resolve_output_root() -> Path: + env_path = os.getenv("MAJOOR_OUTPUT_DIRECTORY") + if env_path: + return Path(env_path).expanduser().resolve() + + try: + import folder_paths + return Path(folder_paths.get_output_directory()).resolve() + except (ImportError, ModuleNotFoundError, AttributeError): + # Fallback to ComfyUI root's output directory based on this file's location + comfy_root = Path(__file__).resolve().parents[3] + return (comfy_root / "output").resolve() + +OUTPUT_ROOT_PATH = _resolve_output_root() +OUTPUT_ROOT = str(OUTPUT_ROOT_PATH) + +# Platform detection +IS_WINDOWS = sys.platform == "win32" + +# SQLite index configuration +INDEX_DIR_PATH = OUTPUT_ROOT_PATH / "_mjr_index" +INDEX_DIR = str(INDEX_DIR_PATH) +INDEX_DB_PATH = INDEX_DIR_PATH / "assets.sqlite" +INDEX_DB = str(INDEX_DB_PATH) + +# Create index directory if it doesn't exist +os.makedirs(INDEX_DIR, exist_ok=True) + +# External tool overrides (portable vs. system-wide) +EXIFTOOL_BIN = os.getenv("MAJOOR_EXIFTOOL_PATH") or os.getenv("MAJOOR_EXIFTOOL_BIN") or "exiftool" +FFPROBE_BIN = os.getenv("MAJOOR_FFPROBE_PATH") or os.getenv("MAJOOR_FFPROBE_BIN") or "ffprobe" + +TOOL_LOCATIONS = { + "exiftool": EXIFTOOL_BIN, + "ffprobe": FFPROBE_BIN, +} + +def get_tool_paths(): + return TOOL_LOCATIONS.copy() + +# Media probe backend setting +# Options: "auto" (recommended), "exiftool", "ffprobe", "both" +MEDIA_PROBE_BACKEND = os.getenv("MAJOOR_MEDIA_PROBE_BACKEND", "auto") + +# Performance tuning defaults +def _env_bool(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.lower() in ("1", "true", "yes", "on") + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, default)) + except (TypeError, ValueError): + return default + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name, default)) + except (TypeError, ValueError): + return default + +ENABLE_FILE_WATCHER = _env_bool("MAJOOR_ENABLE_FILE_WATCHER", False) +WATCHER_INTERVAL_SECONDS = _env_float("MAJOOR_WATCHER_INTERVAL", 15.0) +WATCHER_JOIN_TIMEOUT = _env_float("MAJOOR_WATCHER_JOIN_TIMEOUT", 5.0) +WATCHER_PATHS = [] +for raw_path in os.getenv("MAJOOR_WATCHER_PATHS", OUTPUT_ROOT).split(os.pathsep): + cleaned = raw_path.strip() + if not cleaned: + continue + try: + WATCHER_PATHS.append(Path(cleaned).resolve()) + except (OSError, RuntimeError, ValueError): + logger.warning("Invalid path in MAJOOR_WATCHER_PATHS: %r", raw_path) + continue + +if not WATCHER_PATHS: + WATCHER_PATHS = [OUTPUT_ROOT_PATH] + +# Tool timeouts +EXIFTOOL_TIMEOUT = _env_int("MAJOOR_EXIFTOOL_TIMEOUT", 15) +FFPROBE_TIMEOUT = _env_int("MAJOOR_FFPROBE_TIMEOUT", 10) + +# Database tuning +DB_TIMEOUT = _env_float("MAJOOR_DB_TIMEOUT", 30.0) +DB_MAX_CONNECTIONS = _env_int("MAJOOR_DB_MAX_CONNECTIONS", 8) +DB_QUERY_TIMEOUT = _env_float("MAJOOR_DB_QUERY_TIMEOUT", 30.0) + +# Scanner batching (bounded transactions) +# Tweak only if you know your workload; larger batches reduce transaction overhead but increase lock time. +SCAN_BATCH_SMALL_THRESHOLD = _env_int("MAJOOR_SCAN_BATCH_SMALL_THRESHOLD", 100) +SCAN_BATCH_MED_THRESHOLD = _env_int("MAJOOR_SCAN_BATCH_MED_THRESHOLD", 1000) +SCAN_BATCH_LARGE_THRESHOLD = _env_int("MAJOOR_SCAN_BATCH_LARGE_THRESHOLD", 10000) +SCAN_BATCH_SMALL = _env_int("MAJOOR_SCAN_BATCH_SMALL", 10) +SCAN_BATCH_MED = _env_int("MAJOOR_SCAN_BATCH_MED", 50) +SCAN_BATCH_LARGE = _env_int("MAJOOR_SCAN_BATCH_LARGE", 100) +SCAN_BATCH_XL = _env_int("MAJOOR_SCAN_BATCH_XL", 200) diff --git a/backend/custom_roots.py b/backend/custom_roots.py new file mode 100644 index 00000000..6fad0ac8 --- /dev/null +++ b/backend/custom_roots.py @@ -0,0 +1,166 @@ +""" +Custom roots persistence and resolution. + +Custom roots allow browsing/staging files from arbitrary folders on disk. +Persistence is stored under the Majoor index directory so it survives restarts +without relying on ComfyUI internal userdata routes. +""" + +from __future__ import annotations + +import json +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from .shared import Result, get_logger +from .config import INDEX_DIR + +logger = get_logger(__name__) + +_LOCK = threading.Lock() +_STORE_PATH = Path(INDEX_DIR) / "custom_roots.json" + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _normalize_dir_path(path: str) -> Optional[Path]: + if not path or "\x00" in path: + return None + try: + return Path(path).expanduser().resolve() + except (OSError, RuntimeError, ValueError): + return None + + +def _read_store() -> Dict[str, Any]: + if not _STORE_PATH.exists(): + return {"version": 1, "roots": []} + try: + raw = _STORE_PATH.read_text(encoding="utf-8") + data = json.loads(raw) if raw else {} + if not isinstance(data, dict): + return {"version": 1, "roots": []} + roots = data.get("roots") + if not isinstance(roots, list): + roots = [] + return {"version": int(data.get("version") or 1), "roots": roots} + except Exception as exc: + logger.warning("Failed to read custom roots store: %s", exc) + return {"version": 1, "roots": []} + + +def _write_store(data: dict) -> Result[bool]: + try: + _STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + _STORE_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return Result.Ok(True) + except Exception as exc: + logger.warning("Failed to persist custom roots store: %s", exc) + return Result.Err("STORE_WRITE_FAILED", f"Failed to persist custom roots: {exc}") + + +def list_custom_roots() -> Result[List[Dict[str, Any]]]: + with _LOCK: + store = _read_store() + roots = store.get("roots") or [] + cleaned: List[Dict[str, Any]] = [] + for r in roots: + if not isinstance(r, dict): + continue + rid = str(r.get("id") or "").strip() + path = str(r.get("path") or "").strip() + if not rid or not path: + continue + cleaned.append( + { + "id": rid, + "path": path, + "label": str(r.get("label") or "").strip() or Path(path).name or path, + "created_at": r.get("created_at"), + } + ) + return Result.Ok(cleaned) + + +def add_custom_root(path: str, label: Optional[str] = None) -> Result[Dict[str, Any]]: + normalized = _normalize_dir_path(path) + if not normalized: + return Result.Err("INVALID_INPUT", "Invalid path") + if not normalized.exists(): + return Result.Err("DIR_NOT_FOUND", f"Directory not found: {normalized}") + if not normalized.is_dir(): + return Result.Err("NOT_A_DIRECTORY", f"Not a directory: {normalized}") + + resolved = str(normalized) + root_id = str(uuid4()) + created_at = _utc_now_iso() + safe_label = (label or "").strip() or normalized.name or resolved + + with _LOCK: + store = _read_store() + roots = store.get("roots") or [] + for r in roots: + if not isinstance(r, dict): + continue + existing_path = r.get("path") + if existing_path and str(existing_path) == resolved: + existing_id = str(r.get("id") or "") + return Result.Ok( + { + "id": existing_id or root_id, + "path": resolved, + "label": str(r.get("label") or safe_label), + "created_at": r.get("created_at") or created_at, + "already_exists": True, + } + ) + + roots.append({"id": root_id, "path": resolved, "label": safe_label, "created_at": created_at}) + store["roots"] = roots + write_result = _write_store(store) + if not write_result.ok: + return write_result # type: ignore[return-value] + + return Result.Ok({"id": root_id, "path": resolved, "label": safe_label, "created_at": created_at}) + + +def remove_custom_root(root_id: str) -> Result[bool]: + rid = str(root_id or "").strip() + if not rid: + return Result.Err("INVALID_INPUT", "Missing root_id") + + with _LOCK: + store = _read_store() + roots = store.get("roots") or [] + kept = [r for r in roots if not (isinstance(r, dict) and str(r.get("id") or "") == rid)] + if len(kept) == len(roots): + return Result.Err("NOT_FOUND", f"Custom root not found: {rid}") + store["roots"] = kept + write_result = _write_store(store) + if not write_result.ok: + return write_result + + return Result.Ok(True) + + +def resolve_custom_root(root_id: str) -> Result[Path]: + rid = str(root_id or "").strip() + if not rid: + return Result.Err("INVALID_INPUT", "Missing root_id") + + roots_result = list_custom_roots() + if not roots_result.ok: + return Result.Err(roots_result.code, roots_result.error) + for r in roots_result.data or []: + if str(r.get("id") or "") == rid: + normalized = _normalize_dir_path(str(r.get("path") or "")) + if not normalized: + return Result.Err("INVALID_INPUT", "Invalid stored path") + return Result.Ok(normalized) + return Result.Err("NOT_FOUND", f"Custom root not found: {rid}") diff --git a/backend/deps.py b/backend/deps.py new file mode 100644 index 00000000..9a54d76b --- /dev/null +++ b/backend/deps.py @@ -0,0 +1,117 @@ +""" +Dependency injection - builds services. +Simple, debug-friendly DI without framework magic. +""" + +import os + +from .shared import get_logger, log_success +from .adapters.db.sqlite import Sqlite +from .adapters.db.schema import init_schema, migrate_schema +from .adapters.tools import ExifTool, FFProbe +from .features.metadata import MetadataService +from .features.health import HealthService +from .features.index import IndexService +from .features.index.watcher import DirectoryWatcher +from .features.tags import RatingTagsSyncWorker +from .config import ( + INDEX_DB, + EXIFTOOL_BIN, + EXIFTOOL_TIMEOUT, + FFPROBE_BIN, + FFPROBE_TIMEOUT, + ENABLE_FILE_WATCHER, + WATCHER_PATHS, + WATCHER_INTERVAL_SECONDS, + WATCHER_JOIN_TIMEOUT, + DB_TIMEOUT, + DB_MAX_CONNECTIONS, +) + +logger = get_logger(__name__) + +def build_services(db_path: str = None) -> dict: + """ + Build all services (DI container). + + Args: + db_path: Path to SQLite database (default: from config.INDEX_DB) + + Returns: + Dict of service instances + """ + logger.info("Building services...") + + # Default database path from config + if db_path is None: + db_path = INDEX_DB + + # Initialize database + logger.info(f"Initializing database: {db_path}") + db = Sqlite(db_path, max_connections=DB_MAX_CONNECTIONS, timeout=DB_TIMEOUT) + + # Run migrations + migrate_result = migrate_schema(db) + if not migrate_result.ok: + logger.error(f"Schema migration failed: {migrate_result.error}") + raise RuntimeError(f"Failed to initialize database: {migrate_result.error}") + + # Initialize adapters + exiftool = ExifTool(bin_name=EXIFTOOL_BIN, timeout=EXIFTOOL_TIMEOUT) + ffprobe = FFProbe(bin_name=FFPROBE_BIN, timeout=FFPROBE_TIMEOUT) + + # Log tool availability + if exiftool.is_available(): + log_success(logger, "ExifTool is available") + else: + logger.warning("ExifTool not found - metadata extraction will be limited") + + if ffprobe.is_available(): + log_success(logger, "ffprobe is available") + else: + logger.warning("ffprobe not found - video metadata will be limited") + + # Build services + metadata_service = MetadataService( + exiftool=exiftool, + ffprobe=ffprobe + ) + + health_service = HealthService( + db=db, + exiftool=exiftool, + ffprobe=ffprobe + ) + + index_service = IndexService( + db=db, + metadata_service=metadata_service + ) + + services = { + "db": db, + "exiftool": exiftool, + "ffprobe": ffprobe, + "metadata": metadata_service, + "health": health_service, + "index": index_service, + } + + # Best-effort filesystem sync for rating/tags (sidecar / ExifTool). + try: + services["rating_tags_sync"] = RatingTagsSyncWorker(exiftool) + except Exception as exc: + logger.debug("RatingTagsSyncWorker disabled: %s", exc) + + if ENABLE_FILE_WATCHER and WATCHER_PATHS: + watcher = DirectoryWatcher( + index_service, + WATCHER_PATHS, + WATCHER_INTERVAL_SECONDS, + WATCHER_JOIN_TIMEOUT + ) + watcher.start() + services["watcher"] = watcher + + log_success(logger, "All services initialized") + return services diff --git a/backend/features/geninfo/__init__.py b/backend/features/geninfo/__init__.py new file mode 100644 index 00000000..fd9f9dd0 --- /dev/null +++ b/backend/features/geninfo/__init__.py @@ -0,0 +1,4 @@ +""" +Generation-info parsing (GenInfoNormalized). +""" + diff --git a/backend/features/geninfo/parser.py b/backend/features/geninfo/parser.py new file mode 100644 index 00000000..d9b4af9f --- /dev/null +++ b/backend/features/geninfo/parser.py @@ -0,0 +1,1293 @@ +""" +GenInfo parser (backend-first). + +Goal: deterministically extract generation parameters from a ComfyUI prompt-graph +without "guessing" across unrelated nodes. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Set, Tuple + +from ...shared import Result, get_logger + +logger = get_logger(__name__) + +_PROMPT_TEXT_RE = None + + +SINK_CLASS_TYPES: Set[str] = { + "saveimage", + "saveimagewebsocket", + "previewimage", + "vhs_savevideo", + "vhs_videocombine", + "saveanimatedwebp", + "savegif", + "savevideo", +} + + +def _sink_priority(node: Dict[str, Any]) -> Tuple[int, int]: + """ + Rank sinks so we pick the "real" output when multiple sinks exist. + + Video graphs often contain both PreviewImage and SaveVideo; PreviewImage can be + attached to intermediate nodes and does not reliably reflect the final render. + """ + ct = _lower(_node_type(node)) + # Prefer video sinks + if ct in ("savevideo", "vhs_savevideo", "vhs_videocombine"): + group = 0 + # Then image/gif sinks + elif ct in ("saveimage", "saveimagewebsocket", "saveanimatedwebp", "savegif"): + group = 1 + # Preview last + elif ct == "previewimage": + group = 2 + else: + group = 3 + + # Within group: prefer sinks that consume `images` (common for image outputs) + try: + has_images = 0 if _is_link(_inputs(node).get("images")) else 1 + except Exception: + has_images = 1 + return (group, has_images) + +_MODEL_EXTS: Tuple[str, ...] = ( + ".safetensors", + ".ckpt", + ".pt", + ".pth", + ".bin", + ".gguf", + ".json", +) + + +def _clean_model_id(value: Any) -> Optional[str]: + if value is None: + return None + s = str(value).strip() + if not s: + return None + s = s.replace("\\", "/") + s = s.split("/")[-1] + lower = s.lower() + for ext in _MODEL_EXTS: + if lower.endswith(ext): + return s[: -len(ext)] + return s + + +def _to_int(value: Any) -> Optional[int]: + try: + if value is None: + return None + return int(value) + except Exception: + return None + + +def _looks_like_node_id(value: Any) -> bool: + """ + Prompt-graph link node ids are usually integers, but some exporters encode ids like + "57:35" (multi-part numeric ids). Accept only digit-or-digit+colon patterns. + """ + if value is None: + return False + if isinstance(value, int): + return True + if not isinstance(value, str): + return False + s = value.strip() + if not s: + return False + parts = s.split(":") + return all(p.isdigit() for p in parts if p != "") + + +def _is_link(value: Any) -> bool: + if not isinstance(value, (list, tuple)) or len(value) != 2: + return False + a, b = value[0], value[1] + return _looks_like_node_id(a) and _to_int(b) is not None + + +def _resolve_link(value: Any) -> Optional[Tuple[str, int]]: + if not _is_link(value): + return None + a, b = value[0], value[1] + return str(a).strip(), int(_to_int(b) or 0) + + +def _node_type(node: Any) -> str: + if not isinstance(node, dict): + return "" + return str(node.get("class_type") or node.get("type") or "") + + +def _inputs(node: Any) -> Dict[str, Any]: + if not isinstance(node, dict): + return {} + ins = node.get("inputs") + return ins if isinstance(ins, dict) else {} + + +def _lower(s: Any) -> str: + return str(s or "").lower() + +def _looks_like_prompt_string(value: Any) -> bool: + """ + Conservative heuristic: accept human-ish prompt strings; reject numbers/gibberish. + """ + if not isinstance(value, str): + return False + s = value.strip() + if not s: + return False + if len(s) < 6: + return False + # Reject numeric-only payloads (common for some nodes that also have `text`). + if all(ch.isdigit() or ch.isspace() or ch in ".,+-" for ch in s): + return False + # Reject control-heavy strings (binary-ish). + bad = sum(1 for ch in s if ord(ch) < 9) + if bad > 0: + return False + # Require at least one letter (unicode-aware). + if not any(ch.isalpha() for ch in s): + return False + return True + + +def _is_reroute(node: Dict[str, Any]) -> bool: + ct = _lower(_node_type(node)) + return ct == "reroute" or "reroute" in ct + + +def _walk_passthrough( + nodes_by_id: Dict[str, Dict[str, Any]], + start_link: Any, + max_hops: int = 50, +) -> Optional[str]: + """ + Follow link through obvious pass-through nodes (Reroute). + Returns the final source node id (string) or None. + """ + link = _resolve_link(start_link) + if not link: + return None + node_id, _ = link + hops = 0 + while hops < max_hops: + hops += 1 + node = nodes_by_id.get(node_id) + if not isinstance(node, dict): + return node_id + if not _is_reroute(node): + return node_id + ins = _inputs(node) + # Reroute nodes typically have a single link input. + next_link = None + for v in ins.values(): + if _is_link(v): + next_link = v + break + if not next_link: + return node_id + resolved = _resolve_link(next_link) + if not resolved: + return node_id + node_id, _ = resolved + return node_id + + +@dataclass(frozen=True) +class _Field: + value: Any + confidence: str + source: str + + +def _field(value: Any, confidence: str, source: str) -> Optional[Dict[str, Any]]: + if value is None or value == "": + return None + return {"value": value, "confidence": confidence, "source": source} + + +def _field_name(name: Any, confidence: str, source: str) -> Optional[Dict[str, Any]]: + if not name: + return None + return {"name": name, "confidence": confidence, "source": source} + + +def _field_size(width: Any, height: Any, confidence: str, source: str) -> Optional[Dict[str, Any]]: + if width is None or height is None: + return None + return {"width": width, "height": height, "confidence": confidence, "source": source} + + +def _pick_sink_inputs(node: Dict[str, Any]) -> Optional[Any]: + ins = _inputs(node) + preferred = ["images", "image", "frames", "video", "samples", "latent", "latent_image"] + for k in preferred: + v = ins.get(k) + if _is_link(v): + return v + for v in ins.values(): + if _is_link(v): + return v + return None + + +def _find_candidate_sinks(nodes_by_id: Dict[str, Dict[str, Any]]) -> List[str]: + sinks: List[str] = [] + for node_id, node in nodes_by_id.items(): + ct = _lower(_node_type(node)) + if ct in SINK_CLASS_TYPES: + sinks.append(node_id) + return sinks + + +def _collect_upstream_nodes( + nodes_by_id: Dict[str, Dict[str, Any]], start_node_id: str, max_nodes: int = 5000 +) -> Dict[str, int]: + """ + BFS upstream from a node id, returning node->distance. + """ + dist: Dict[str, int] = {} + q: List[Tuple[str, int]] = [(start_node_id, 0)] + while q and len(dist) < max_nodes: + nid, d = q.pop(0) + if nid in dist: + continue + dist[nid] = d + node = nodes_by_id.get(nid) + if not isinstance(node, dict): + continue + for v in _inputs(node).values(): + if not _is_link(v): + continue + resolved = _resolve_link(v) + if not resolved: + continue + src_id, _ = resolved + q.append((src_id, d + 1)) + return dist + + +def _is_sampler(node: Dict[str, Any]) -> bool: + """ + Return True for sampler-like nodes that represent the core diffusion step. + + Historically this was only "KSampler*", but video/custom stacks (e.g. Wan) use + different sampler node names while still providing sampler parameters. + """ + ct = _lower(_node_type(node)) + if not ct: + return False + # KSamplerSelect is a sampler selector node, not a diffusion sampler. + if "ksampler" in ct and "select" in ct: + return False + if "ksampler" in ct: + return True + # WanVideoSampler / custom samplers + if ("sampler" in ct) and ("select" not in ct) and ("ksamplerselect" not in ct): + try: + ins = _inputs(node) + # Avoid matching unrelated nodes that happen to contain "sampler" in their name. + if _is_link(ins.get("model")): + return True + # Require at least one real sampling parameter (not just "sampler_name" selector nodes). + for k in ("steps", "cfg", "cfg_scale", "seed", "scheduler", "denoise"): + if ins.get(k) is not None: + return True + # Wan samplers often take `text_embeds` rather than conditioning. + if _is_link(ins.get("text_embeds")): + return True + except (TypeError, ValueError, KeyError): + return False + return False + + +def _is_advanced_sampler(node: Dict[str, Any]) -> bool: + """ + Flux/SD3 pipelines can use SamplerCustomAdvanced which orchestrates multiple nodes: + - noise -> RandomNoise(noise_seed) + - sigmas -> BasicScheduler(steps/scheduler/denoise/model) + - sampler -> KSamplerSelect(sampler_name) + - guider -> BasicGuider(conditioning/model) + """ + ct = _lower(_node_type(node)) + if not ct: + return False + if "samplercustom" in ct: + return True + try: + ins = _inputs(node) + # Heuristic: orchestration sampler has these link inputs. + keys = ("noise", "guider", "sampler", "sigmas") + return all(_is_link(ins.get(k)) for k in keys) + except (TypeError, ValueError, KeyError): + return False + + +def _extract_posneg_from_text_embeds( + nodes_by_id: Dict[str, Dict[str, Any]], text_embeds_link: Any +) -> Tuple[Optional[Tuple[str, str]], Optional[Tuple[str, str]]]: + """ + Wan/video stacks often encode prompts into "text_embeds" via nodes like + WanVideoTextEncode which keep positive/negative as plain string inputs. + """ + src_id = _walk_passthrough(nodes_by_id, text_embeds_link) + if not src_id: + return None, None + node = nodes_by_id.get(src_id) + if not isinstance(node, dict): + return None, None + ins = _inputs(node) + + def _get_str(*keys: str) -> Optional[str]: + for k in keys: + v = ins.get(k) + if isinstance(v, str) and v.strip(): + return v.strip() + return None + + pos = _get_str("positive", "prompt", "text", "text_g", "text_l") + neg = _get_str("negative", "negative_prompt") + + pos_val = (pos, f"{_node_type(node)}:{src_id}") if pos else None + neg_val = (neg, f"{_node_type(node)}:{src_id}") if neg else None + return pos_val, neg_val + + +def _select_primary_sampler( + nodes_by_id: Dict[str, Dict[str, Any]], sink_node_id: str +) -> Tuple[Optional[str], str]: + sink = nodes_by_id.get(sink_node_id) + if not isinstance(sink, dict): + return None, "none" + start_link = _pick_sink_inputs(sink) + start_src = _walk_passthrough(nodes_by_id, start_link) if start_link else None + if not start_src: + return None, "none" + dist = _collect_upstream_nodes(nodes_by_id, start_src) + samplers: List[Tuple[str, int]] = [] + for nid, d in dist.items(): + node = nodes_by_id.get(nid) + if not isinstance(node, dict): + continue + if _is_sampler(node): + samplers.append((nid, d)) + if not samplers: + return None, "none" + samplers.sort(key=lambda x: x[1]) + best_depth = samplers[0][1] + best = [nid for nid, d in samplers if d == best_depth] + if len(best) == 1: + return best[0], "high" + # Ambiguous: multiple samplers equally close to sink + return best[0], "medium" + + +def _select_advanced_sampler( + nodes_by_id: Dict[str, Dict[str, Any]], sink_node_id: str +) -> Tuple[Optional[str], str]: + sink = nodes_by_id.get(sink_node_id) + if not isinstance(sink, dict): + return None, "none" + start_link = _pick_sink_inputs(sink) + start_src = _walk_passthrough(nodes_by_id, start_link) if start_link else None + if not start_src: + return None, "none" + dist = _collect_upstream_nodes(nodes_by_id, start_src) + candidates: List[Tuple[str, int]] = [] + for nid, d in dist.items(): + node = nodes_by_id.get(nid) + if not isinstance(node, dict): + continue + if _is_advanced_sampler(node): + candidates.append((nid, d)) + if not candidates: + return None, "none" + candidates.sort(key=lambda x: x[1]) + best_depth = candidates[0][1] + best = [nid for nid, d in candidates if d == best_depth] + if len(best) == 1: + return best[0], "high" + return best[0], "medium" + + +def _select_any_sampler(nodes_by_id: Dict[str, Dict[str, Any]]) -> Tuple[Optional[str], str]: + """ + Last resort when sinks exist but are not linked to the generation branch. + Choose the "best" sampler-like node in the whole prompt graph. + """ + candidates: List[Tuple[int, int, str]] = [] + for nid, node in nodes_by_id.items(): + if not isinstance(node, dict): + continue + if not _is_sampler(node): + continue + ins = _inputs(node) + score = 0 + if _is_link(ins.get("model")): + score += 3 + if _is_link(ins.get("positive")) or _is_link(ins.get("text_embeds")): + score += 3 + for k in ("steps", "cfg", "cfg_scale", "seed", "denoise", "scheduler"): + if ins.get(k) is not None: + score += 1 + # Prefer numeric node ids (stable ordering). + try: + n_int = int(nid) + except Exception: + n_int = 10**9 + candidates.append((-score, n_int, nid)) + if not candidates: + return None, "none" + candidates.sort() + return candidates[0][2], "low" + + +def _trace_sampler_name(nodes_by_id: Dict[str, Dict[str, Any]], link: Any) -> Optional[Tuple[str, str]]: + src_id = _walk_passthrough(nodes_by_id, link) + if not src_id: + return None + node = nodes_by_id.get(src_id) + if not isinstance(node, dict): + return None + ins = _inputs(node) + val = _scalar(ins.get("sampler_name")) or _scalar(ins.get("sampler")) + if val is None: + return None + return str(val), f"{_node_type(node)}:{src_id}" + + +def _trace_noise_seed(nodes_by_id: Dict[str, Dict[str, Any]], link: Any) -> Optional[Tuple[Any, str]]: + src_id = _walk_passthrough(nodes_by_id, link) + if not src_id: + return None + node = nodes_by_id.get(src_id) + if not isinstance(node, dict): + return None + ins = _inputs(node) + for k in ("noise_seed", "seed", "value", "int", "number"): + v = _scalar(ins.get(k)) + if v is not None: + return v, f"{_node_type(node)}:{src_id}" + return None + + +def _trace_scheduler_sigmas( + nodes_by_id: Dict[str, Dict[str, Any]], link: Any +) -> Tuple[Optional[Any], Optional[Any], Optional[Any], Optional[Any], Optional[Tuple[str, str]]]: + """ + For advanced sampler pipelines, `sigmas` points to a scheduler node that carries steps/scheduler/denoise + and sometimes a `model` link. + """ + src_id = _walk_passthrough(nodes_by_id, link) + if not src_id: + return (None, None, None, None, None) + node = nodes_by_id.get(src_id) + if not isinstance(node, dict): + return (None, None, None, None, None) + ins = _inputs(node) + steps = _scalar(ins.get("steps")) + scheduler = _scalar(ins.get("scheduler")) + denoise = _scalar(ins.get("denoise")) + model_link = ins.get("model") if _is_link(ins.get("model")) else None + src = f"{_node_type(node)}:{src_id}" + return (steps, scheduler, denoise, model_link, (src_id, src)) + + +def _trace_guidance_from_conditioning(nodes_by_id: Dict[str, Dict[str, Any]], conditioning_link: Any) -> Optional[Tuple[Any, str]]: + start_id = _walk_passthrough(nodes_by_id, conditioning_link) + if not start_id: + return None + dist = _collect_upstream_nodes(nodes_by_id, start_id) + for nid, d in sorted(dist.items(), key=lambda x: x[1]): + node = nodes_by_id.get(nid) + if not isinstance(node, dict): + continue + ins = _inputs(node) + for k in ("guidance", "cfg", "cfg_scale"): + v = _scalar(ins.get(k)) + if v is not None: + return v, f"{_node_type(node)}:{nid}" + return None + + +def _scalar(value: Any) -> Optional[Any]: + if value is None: + return None + if isinstance(value, (int, float, str)): + return value + return None + + +def _resolve_scalar_from_link(nodes_by_id: Dict[str, Dict[str, Any]], value: Any) -> Optional[Any]: + src_id = _walk_passthrough(nodes_by_id, value) + if not src_id: + return None + node = nodes_by_id.get(src_id) + if not isinstance(node, dict): + return None + ins = _inputs(node) + for k in ("seed", "value", "number", "int", "float", "text"): + v = ins.get(k) + s = _scalar(v) + if s is not None: + return s + return None + + +def _extract_text(nodes_by_id: Dict[str, Dict[str, Any]], link: Any) -> Optional[Tuple[str, str]]: + node_id = _walk_passthrough(nodes_by_id, link) + if not node_id: + return None + node = nodes_by_id.get(node_id) + if not isinstance(node, dict): + return None + ins = _inputs(node) + # Text-encode nodes vary: SDXL often uses text_g/text_l, some nodes use "prompt". + candidates: List[str] = [] + for key in ("text", "prompt", "text_g", "text_l"): + v = ins.get(key) + if isinstance(v, str) and v.strip(): + candidates.append(v.strip()) + if candidates: + return "\n".join(candidates), f"{_node_type(node)}:{node_id}" + return None + + +def _looks_like_text_encoder(node: Dict[str, Any]) -> bool: + """ + Conservative signal: a node that has textual inputs AND a linked `clip` input. + This avoids "guessing" prompts from unrelated nodes. + """ + try: + ins = _inputs(node) + if not _is_link(ins.get("clip")): + return False + for key in ("text", "prompt", "text_g", "text_l"): + v = ins.get(key) + if isinstance(v, str) and v.strip(): + return True + if _is_link(v): + return True + return False + except Exception: + return False + + +def _looks_like_conditioning_text(node: Dict[str, Any]) -> bool: + """ + Some custom nodes output CONDITIONING directly without an explicit `clip` link + but still store the prompt text in a `text`/`prompt` field. Only accept nodes + that look clearly related to conditioning/prompt generation. + """ + try: + ct = _lower(_node_type(node)) + if "conditioning" not in ct and "prompt" not in ct and "textencode" not in ct: + return False + ins = _inputs(node) + for key in ("text", "prompt", "text_g", "text_l"): + v = ins.get(key) + if _looks_like_prompt_string(v): + return True + return False + except Exception: + return False + + +def _collect_text_encoder_nodes_from_conditioning( + nodes_by_id: Dict[str, Dict[str, Any]], start_link: Any, max_nodes: int = 200, branch: Optional[str] = None +) -> List[str]: + """ + DFS upstream from a conditioning link, collecting "text-encoder-like" node ids. + Traversal expands only through nodes that look like conditioning composition or + reroute-ish passthrough nodes. + """ + start_id = _walk_passthrough(nodes_by_id, start_link) + if not start_id: + return [] + + visited: Set[str] = set() + stack: List[str] = [start_id] + found: List[str] = [] + + def _should_expand(node: Dict[str, Any]) -> bool: + ct = _lower(_node_type(node)) + if _is_reroute(node): + return True + if "conditioning" in ct: + return True + ins = _inputs(node) + if any("conditioning" in str(k).lower() for k in ins.keys()): + return True + # Some pipelines (e.g. Wan/VHS wrappers) pass conditioning through nodes that + # expose `positive` / `negative` links but aren't named "Conditioning*". + try: + if branch in ("positive", "negative") and _is_link(ins.get(branch)): + return True + if _is_link(ins.get("positive")) or _is_link(ins.get("negative")): + return True + except (TypeError, ValueError, KeyError): + return False + return False + + while stack and len(visited) < max_nodes: + nid = stack.pop() + if nid in visited: + continue + visited.add(nid) + node = nodes_by_id.get(nid) + if not isinstance(node, dict): + continue + + if _looks_like_text_encoder(node): + found.append(nid) + continue + if _looks_like_conditioning_text(node): + found.append(nid) + continue + + if not _should_expand(node): + continue + + ins = _inputs(node) + + # If the caller requested a branch ("positive"/"negative") and this node has that + # explicit input, follow only that path to avoid mixing pos/neg prompts. + if branch in ("positive", "negative") and _is_link(ins.get(branch)): + v = ins.get(branch) + src_id = _walk_passthrough(nodes_by_id, v) + if src_id and src_id not in visited: + stack.append(src_id) + continue + + for v in ins.values(): + if _is_link(v): + src_id = _walk_passthrough(nodes_by_id, v) + if src_id and src_id not in visited: + stack.append(src_id) + + # Stable ordering: node id is usually numeric + def _nid_key(x: str) -> Tuple[int, str]: + try: + return int(x), x + except Exception: + return 10**9, x + + found.sort(key=_nid_key) + return found + + +def _collect_texts_from_conditioning( + nodes_by_id: Dict[str, Dict[str, Any]], start_link: Any, max_nodes: int = 200, branch: Optional[str] = None +) -> List[Tuple[str, str]]: + """ + Collect prompt text fragments from a conditioning link, returning (text, source). + Deterministic order; never invents text when none is present. + """ + node_ids = _collect_text_encoder_nodes_from_conditioning(nodes_by_id, start_link, max_nodes=max_nodes, branch=branch) + out: List[Tuple[str, str]] = [] + for nid in node_ids: + node = nodes_by_id.get(nid) + if not isinstance(node, dict): + continue + ins = _inputs(node) + candidates: List[str] = [] + for key in ("text", "prompt", "text_g", "text_l"): + v = ins.get(key) + if isinstance(v, str) and v.strip(): + candidates.append(v.strip()) + elif _is_link(v): + resolved = _resolve_scalar_from_link(nodes_by_id, v) + if _looks_like_prompt_string(resolved): + candidates.append(str(resolved).strip()) + if candidates: + out.append(("\n".join(candidates), f"{_node_type(node)}:{nid}")) + return out + + +def _trace_model_chain( + nodes_by_id: Dict[str, Dict[str, Any]], model_link: Any, confidence: str +) -> Tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]: + loras: List[Dict[str, Any]] = [] + models: Dict[str, Dict[str, Any]] = {} + + current_link = model_link + hops = 0 + while current_link is not None and hops < 80: + hops += 1 + node_id = _walk_passthrough(nodes_by_id, current_link) + if not node_id: + break + node = nodes_by_id.get(node_id) + if not isinstance(node, dict): + break + ct = _lower(_node_type(node)) + ins = _inputs(node) + + def _first_model_string() -> Optional[str]: + for k in ( + "ckpt_name", + "checkpoint", + "checkpoint_name", + "model_name", + "model", + "diffusion_name", + "diffusion", + "diffusion_model", + "unet_name", + "unet", + ): + v = ins.get(k) + if isinstance(v, str) and v.strip(): + return v.strip() + # Last resort: scan for any model-like filename. + for v in ins.values(): + if not isinstance(v, str): + continue + s = v.strip() + if not s: + continue + lower_s = s.lower().replace("\\", "/") + if any(lower_s.endswith(ext) for ext in _MODEL_EXTS): + return s + return None + + # Some custom LoRA loader nodes do not contain "lora" in their class_type, + # but expose `lora_name` + a linked `model` input. Treat these as LoRA loaders. + if ("lora" in ct) or (ins.get("lora_name") is not None and _is_link(ins.get("model"))): + # rgthree "Power Lora Loader" stores multiple LoRAs under lora_1/lora_2/... + # objects instead of a flat `lora_name`. + added_any = False + for k, v in ins.items(): + if not str(k).lower().startswith("lora_"): + continue + if not isinstance(v, dict): + continue + enabled = v.get("on") + if enabled is False: + continue + name = _clean_model_id(v.get("lora") or v.get("lora_name") or v.get("name")) + if not name: + continue + strength = v.get("strength") or v.get("strength_model") or v.get("weight") or v.get("lora_strength") + loras.append( + { + "name": name, + "strength_model": strength, + "strength_clip": v.get("strength_clip") or v.get("clip_strength"), + "confidence": confidence, + "source": f"{_node_type(node)}:{node_id}:{k}", + } + ) + added_any = True + + name = _clean_model_id(ins.get("lora_name") or ins.get("lora") or ins.get("name")) + if name: + strength_model = ins.get("strength_model") or ins.get("strength") or ins.get("weight") or ins.get("lora_strength") + strength_clip = ins.get("strength_clip") or ins.get("clip_strength") + loras.append( + { + "name": name, + "strength_model": strength_model, + "strength_clip": strength_clip, + "confidence": confidence, + "source": f"{_node_type(node)}:{node_id}", + } + ) + added_any = True + + # If this node is LoRA-ish but didn't yield any LoRA entries, still follow the chain. + current_link = ins.get("model") if _is_link(ins.get("model")) else None + if current_link is not None: + continue + # No model link to follow; stop here. + break + + # Model sampling / patch nodes are common in video stacks (e.g. ModelSamplingSD3). + # They typically just transform a model object; follow their `model` input. + if ("modelsampling" in ct or "model_sampling" in ct) and _is_link(ins.get("model")): + current_link = ins.get("model") + continue + + # Common diffusion model loaders (video stacks, gguf, unet-only, etc.) + if ( + "loaddiffusionmodel" in ct + or "diffusionmodel" in ct + or "unetloader" in ct + or "loadunet" in ct + or "unet" == ct + ): + unet = _clean_model_id(ins.get("unet_name") or ins.get("unet")) + diffusion = _clean_model_id(ins.get("diffusion_name") or ins.get("diffusion") or ins.get("model_name") or ins.get("ckpt_name")) + if unet and "unet" not in models: + models["unet"] = {"name": unet, "confidence": confidence, "source": f"{_node_type(node)}:{node_id}"} + if diffusion and "diffusion" not in models: + models["diffusion"] = {"name": diffusion, "confidence": confidence, "source": f"{_node_type(node)}:{node_id}"} + next_model = ins.get("model") + if _is_link(next_model): + current_link = next_model + continue + break + + # Generic "model loader" style custom nodes (e.g. WanVideoModelLoader) often expose + # only a model path/identifier without "ckpt_name" naming. + if "modelloader" in ct or ("model_loader" in ct or "model-loader" in ct): + raw = _first_model_string() + name = _clean_model_id(raw) + if name: + # Many video wrappers use a single diffusion model file; map it to checkpoint. + models.setdefault( + "checkpoint", + {"name": name, "confidence": confidence, "source": f"{_node_type(node)}:{node_id}"}, + ) + break + + is_checkpoint_loader = any( + s in ct + for s in ( + "checkpointloader", + "checkpoint_loader", + "loadcheckpoint", + "load_checkpoint", + ) + ) + if is_checkpoint_loader or ins.get("ckpt_name") is not None: + ckpt = _clean_model_id(ins.get("ckpt_name") or ins.get("model_name")) + if ckpt: + models.setdefault( + "checkpoint", + {"name": ckpt, "confidence": confidence, "source": f"{_node_type(node)}:{node_id}"}, + ) + break + + # Some graphs insert generic "switch"/"selector" nodes in the model chain (e.g. rgthree). + # Follow the single upstream link when present to reach the actual loader node. + if ("switch" in ct or "selector" in ct) and not _is_link(ins.get("model")): + links = [v for v in ins.values() if _is_link(v)] + if len(links) == 1: + current_link = links[0] + continue + + # Heuristic: if the node exposes a model-like filename, record it as checkpoint. + raw = _first_model_string() + name = _clean_model_id(raw) + if name and "checkpoint" not in models: + models["checkpoint"] = {"name": name, "confidence": confidence, "source": f"{_node_type(node)}:{node_id}"} + + # Generic model passthrough + next_model = ins.get("model") + if _is_link(next_model): + current_link = next_model + continue + break + + return models, loras + + +def _trace_named_loader(nodes_by_id: Dict[str, Dict[str, Any]], link: Any, keys: Tuple[str, ...], confidence: str) -> Optional[Dict[str, Any]]: + current_link = link + hops = 0 + while current_link is not None and hops < 80: + hops += 1 + node_id = _walk_passthrough(nodes_by_id, current_link) + if not node_id: + return None + node = nodes_by_id.get(node_id) + if not isinstance(node, dict): + return None + ins = _inputs(node) + # Special case: DualCLIPLoader-style nodes expose clip_name1/clip_name2. + if "clip_name1" in keys and "clip_name2" in keys: + c1 = _clean_model_id(ins.get("clip_name1")) + c2 = _clean_model_id(ins.get("clip_name2")) + if c1 and c2: + return { + "name": f"{c1} + {c2}", + "confidence": confidence, + "source": f"{_node_type(node)}:{node_id}", + } + for k in keys: + name = _clean_model_id(ins.get(k)) + if name: + return {"name": name, "confidence": confidence, "source": f"{_node_type(node)}:{node_id}"} + next_link = ins.get("clip") if _is_link(ins.get("clip")) else None + if _is_link(next_link): + current_link = next_link + continue + next_link = ins.get("vae") if _is_link(ins.get("vae")) else None + if _is_link(next_link): + current_link = next_link + continue + next_link = ins.get("model") if _is_link(ins.get("model")) else None + if _is_link(next_link): + current_link = next_link + continue + return None + return None + + +def _trace_vae_from_sink(nodes_by_id: Dict[str, Dict[str, Any]], sink_start_id: str, confidence: str) -> Optional[Dict[str, Any]]: + dist = _collect_upstream_nodes(nodes_by_id, sink_start_id) + candidates: List[Tuple[str, int]] = [] + for nid, d in dist.items(): + node = nodes_by_id.get(nid) + if not isinstance(node, dict): + continue + ct = _lower(_node_type(node)) + if "vaedecode" in ct: + candidates.append((nid, d)) + if not candidates: + return None + candidates.sort(key=lambda x: x[1]) + node_id = candidates[0][0] + node = nodes_by_id.get(node_id) or {} + ins = _inputs(node) + if _is_link(ins.get("vae")): + return _trace_named_loader(nodes_by_id, ins.get("vae"), ("vae_name", "name"), confidence) + return None + + +def _trace_clip_from_text_encoder(nodes_by_id: Dict[str, Dict[str, Any]], encoder_link: Any, confidence: str) -> Optional[Dict[str, Any]]: + encoder_id = _walk_passthrough(nodes_by_id, encoder_link) + if encoder_id: + node = nodes_by_id.get(encoder_id) + if isinstance(node, dict) and _is_link(_inputs(node).get("clip")): + return _trace_named_loader( + nodes_by_id, + _inputs(node).get("clip"), + ("clip_name", "clip_name1", "clip_name2", "clip_name_l", "clip_name_g", "name"), + confidence, + ) + + # Fallback: encoder_link might point to a Conditioning* node; collect upstream encoders. + encoders = _collect_text_encoder_nodes_from_conditioning(nodes_by_id, encoder_link, branch="positive") + if not encoders: + return None + node = nodes_by_id.get(encoders[0]) + if not isinstance(node, dict): + return None + clip_link = _inputs(node).get("clip") + if not _is_link(clip_link): + return None + return _trace_named_loader( + nodes_by_id, clip_link, ("clip_name", "clip_name1", "clip_name2", "clip_name_l", "clip_name_g", "name"), confidence + ) + + +def _trace_clip_skip(nodes_by_id: Dict[str, Dict[str, Any]], clip_link: Any, confidence: str) -> Optional[Dict[str, Any]]: + current_link = clip_link + hops = 0 + while current_link is not None and hops < 60: + hops += 1 + node_id = _walk_passthrough(nodes_by_id, current_link) + if not node_id: + return None + node = nodes_by_id.get(node_id) + if not isinstance(node, dict): + return None + ct = _lower(_node_type(node)) + ins = _inputs(node) + if "clipsetlastlayer" in ct or "clipsetlastlayer" in ct.replace("_", ""): + val = ins.get("stop_at_clip_layer") or ins.get("clip_stop_at_layer") or ins.get("clip_skip") + v = _scalar(val) + return _field(v, confidence, f"{_node_type(node)}:{node_id}") + next_clip = ins.get("clip") + if _is_link(next_clip): + current_link = next_clip + continue + return None + return None + + +def _trace_size(nodes_by_id: Dict[str, Dict[str, Any]], latent_link: Any, confidence: str) -> Optional[Dict[str, Any]]: + current_link = latent_link + hops = 0 + while current_link is not None and hops < 80: + hops += 1 + node_id = _walk_passthrough(nodes_by_id, current_link) + if not node_id: + return None + node = nodes_by_id.get(node_id) + if not isinstance(node, dict): + return None + ct = _lower(_node_type(node)) + ins = _inputs(node) + + if "emptylatentimage" in ct: + w = _scalar(ins.get("width")) + h = _scalar(ins.get("height")) + return _field_size(w, h, confidence, f"{_node_type(node)}:{node_id}") + + # Common resize/upscale latent nodes can carry explicit width/height + w = _scalar(ins.get("width")) + h = _scalar(ins.get("height")) + if w is not None and h is not None: + return _field_size(w, h, confidence, f"{_node_type(node)}:{node_id}") + + # Pass-through along latent/sample link + for key in ("samples", "latent", "latent_image", "image"): + v = ins.get(key) + if _is_link(v): + current_link = v + break + else: + return None + return None + + +def parse_geninfo_from_prompt(prompt_graph: Any, workflow: Any = None) -> Result[Optional[Dict[str, Any]]]: + """ + Parse generation information from a ComfyUI prompt graph (dict of nodes). + Returns Ok(None) when not enough information is available (do-not-lie). + """ + if not isinstance(prompt_graph, dict) or not prompt_graph: + return Result.Ok(None) + + nodes_by_id: Dict[str, Dict[str, Any]] = {} + for k, v in prompt_graph.items(): + if isinstance(v, dict): + nodes_by_id[str(k)] = v + + sinks = _find_candidate_sinks(nodes_by_id) + if not sinks: + return Result.Ok(None) + + # Prefer "real" sinks (SaveVideo over PreviewImage, etc.) + sinks.sort(key=lambda nid: _sink_priority(nodes_by_id.get(nid, {}) or {})) + + sink_id = sinks[0] + sampler_id, sampler_conf = _select_primary_sampler(nodes_by_id, sink_id) + sampler_mode = "primary" + if not sampler_id: + sampler_id, sampler_conf = _select_advanced_sampler(nodes_by_id, sink_id) + sampler_mode = "advanced" if sampler_id else sampler_mode + if not sampler_id: + sampler_id, sampler_conf = _select_any_sampler(nodes_by_id) + sampler_mode = "global" if sampler_id else sampler_mode + if not sampler_id: + return Result.Ok(None) + + # For secondary traces (VAE, etc.), compute a stable upstream set from the sink input. + sink = nodes_by_id.get(sink_id) or {} + sink_link = _pick_sink_inputs(sink) + sink_start_id = _walk_passthrough(nodes_by_id, sink_link) if sink_link else None + + sampler_node = nodes_by_id.get(sampler_id) or {} + sampler_source = f"{_node_type(sampler_node)}:{sampler_id}" + confidence = sampler_conf + + ins = _inputs(sampler_node) + + # Advanced sampler pipelines (Flux/SD3): derive fields from orchestrator dependencies. + advanced = _is_advanced_sampler(sampler_node) + + # Prompts + pos_val: Optional[Tuple[str, str]] = None + neg_val: Optional[Tuple[str, str]] = None + conditioning_link = None + if _is_link(ins.get("positive")): + items = _collect_texts_from_conditioning(nodes_by_id, ins.get("positive"), branch="positive") + if items: + text = "\n".join([t for t, _ in items]).strip() + srcs = [s for _, s in items] + source = srcs[0] if len(set(srcs)) <= 1 else f"{srcs[0]} (+{len(srcs)-1})" + if text: + pos_val = (text, source) + conditioning_link = ins.get("positive") + if _is_link(ins.get("negative")): + items = _collect_texts_from_conditioning(nodes_by_id, ins.get("negative"), branch="negative") + if items: + text = "\n".join([t for t, _ in items]).strip() + srcs = [s for _, s in items] + source = srcs[0] if len(set(srcs)) <= 1 else f"{srcs[0]} (+{len(srcs)-1})" + if text: + neg_val = (text, source) + + # Wan/video stacks: prompts are often encoded into text embeds rather than conditioning. + if (not pos_val and not neg_val) and _is_link(ins.get("text_embeds")): + p, n = _extract_posneg_from_text_embeds(nodes_by_id, ins.get("text_embeds")) + pos_val = pos_val or p + neg_val = neg_val or n + + # Flux-style guidance pipelines: conditioning is passed through a guider node. + if advanced and _is_link(ins.get("guider")): + guider_id = _walk_passthrough(nodes_by_id, ins.get("guider")) + guider_node = nodes_by_id.get(guider_id) if guider_id else None + if isinstance(guider_node, dict): + gins = _inputs(guider_node) + if _is_link(gins.get("conditioning")): + conditioning_link = gins.get("conditioning") + items = _collect_texts_from_conditioning(nodes_by_id, conditioning_link) + if items and not pos_val: + text = "\n".join([t for t, _ in items]).strip() + srcs = [s for _, s in items] + source = srcs[0] if len(set(srcs)) <= 1 else f"{srcs[0]} (+{len(srcs)-1})" + if text: + pos_val = (text, source) + + # Last-resort (still no guessing): some custom sampler nodes store prompt strings directly. + if not pos_val: + for k in ("positive_prompt", "prompt", "positive", "text", "text_g", "text_l"): + v = ins.get(k) + if _looks_like_prompt_string(v): + pos_val = (str(v).strip(), f"{_node_type(sampler_node)}:{sampler_id}:{k}") + break + if not neg_val: + for k in ("negative_prompt", "negative", "neg", "text_negative"): + v = ins.get(k) + if _looks_like_prompt_string(v): + neg_val = (str(v).strip(), f"{_node_type(sampler_node)}:{sampler_id}:{k}") + break + + # Sampler params + sampler_name = _scalar(ins.get("sampler_name")) or _scalar(ins.get("sampler")) + scheduler = _scalar(ins.get("scheduler")) + steps = _scalar(ins.get("steps")) + cfg = _scalar(ins.get("cfg")) or _scalar(ins.get("cfg_scale")) + denoise = _scalar(ins.get("denoise")) + seed_val = _scalar(ins.get("seed")) + if seed_val is None and _is_link(ins.get("seed")): + seed_val = _resolve_scalar_from_link(nodes_by_id, ins.get("seed")) + + # Advanced sampler: sampler_name/steps/scheduler/denoise/seed may be stored on linked nodes. + model_link_for_chain = ins.get("model") if _is_link(ins.get("model")) else None + if advanced: + if _is_link(ins.get("sampler")): + s = _trace_sampler_name(nodes_by_id, ins.get("sampler")) + if s and not sampler_name: + sampler_name = s[0] + if _is_link(ins.get("sigmas")): + st, sch, den, model_link, _src = _trace_scheduler_sigmas(nodes_by_id, ins.get("sigmas")) + if st is not None and steps is None: + steps = st + if sch is not None and not scheduler: + scheduler = sch + if den is not None and denoise is None: + denoise = den + if model_link and not model_link_for_chain: + model_link_for_chain = model_link + if _is_link(ins.get("noise")) and seed_val is None: + s = _trace_noise_seed(nodes_by_id, ins.get("noise")) + if s: + seed_val = s[0] + if conditioning_link and cfg is None: + g = _trace_guidance_from_conditioning(nodes_by_id, conditioning_link) + if g: + cfg = g[0] + + # Model chain + LoRAs + models: Dict[str, Dict[str, Any]] = {} + loras: List[Dict[str, Any]] = [] + if model_link_for_chain and _is_link(model_link_for_chain): + models, loras = _trace_model_chain(nodes_by_id, model_link_for_chain, confidence) + + # Size + size = None + if _is_link(ins.get("latent_image")): + size = _trace_size(nodes_by_id, ins.get("latent_image"), confidence) + + # Clip skip (from positive encoder's clip link) + clip_skip = None + encoder_id = None + if conditioning_link and _is_link(conditioning_link): + encoders = _collect_text_encoder_nodes_from_conditioning(nodes_by_id, conditioning_link, branch="positive") + encoder_id = encoders[0] if encoders else _walk_passthrough(nodes_by_id, conditioning_link) + pos_node = nodes_by_id.get(encoder_id) if encoder_id else None + if isinstance(pos_node, dict) and _is_link(_inputs(pos_node).get("clip")): + clip_skip = _trace_clip_skip(nodes_by_id, _inputs(pos_node).get("clip"), confidence) + + # CLIP model (from text encoder clip input) + clip = None + if conditioning_link and _is_link(conditioning_link): + clip = _trace_clip_from_text_encoder(nodes_by_id, conditioning_link, confidence) + + # VAE model (from VAE decode on the sink path) + vae = None + if sink_start_id: + vae = _trace_vae_from_sink(nodes_by_id, sink_start_id, confidence) + + out: Dict[str, Any] = { + "engine": { + "parser_version": "geninfo-v1", + "sink": str(_node_type(nodes_by_id.get(sink_id, {}))), + "sampler_mode": sampler_mode, + }, + } + + if pos_val: + out["positive"] = {"value": pos_val[0], "confidence": confidence, "source": pos_val[1]} + if neg_val: + out["negative"] = {"value": neg_val[0], "confidence": confidence, "source": neg_val[1]} + + # Backward compatible: keep top-level `checkpoint` (best-effort) + if models: + preferred = ( + models.get("checkpoint") + or models.get("unet") + or models.get("diffusion") + or None + ) + if preferred: + out["checkpoint"] = preferred + + if loras: + out["loras"] = loras + if clip: + out["clip"] = clip + if vae: + out["vae"] = vae + + if models or clip or vae: + merged: Dict[str, Dict[str, Any]] = {} + if models.get("checkpoint"): + merged["checkpoint"] = models["checkpoint"] + if models.get("unet"): + merged["unet"] = models["unet"] + if models.get("diffusion"): + merged["diffusion"] = models["diffusion"] + if clip: + merged["clip"] = clip + if vae: + merged["vae"] = vae + if merged: + out["models"] = merged + + sname = _field_name(sampler_name, confidence, sampler_source) + if sname: + out["sampler"] = sname + sch = _field_name(scheduler, confidence, sampler_source) + if sch: + out["scheduler"] = sch + + for key, val in ( + ("steps", steps), + ("cfg", cfg), + ("seed", seed_val), + ("denoise", denoise), + ): + f = _field(val, confidence, sampler_source) + if f: + out[key] = f + + if size: + out["size"] = size + + if clip_skip: + out["clip_skip"] = clip_skip + + # Do-not-lie: if nothing useful extracted besides engine, return None. + if len(out.keys()) <= 1: + return Result.Ok(None) + + return Result.Ok(out) diff --git a/backend/features/health/__init__.py b/backend/features/health/__init__.py new file mode 100644 index 00000000..67b3e470 --- /dev/null +++ b/backend/features/health/__init__.py @@ -0,0 +1,4 @@ +"""Health monitoring feature.""" +from .service import HealthService + +__all__ = ["HealthService"] diff --git a/backend/features/health/service.py b/backend/features/health/service.py new file mode 100644 index 00000000..cafe3293 --- /dev/null +++ b/backend/features/health/service.py @@ -0,0 +1,286 @@ +""" +Health service - system status and tool availability. +""" +import os +from pathlib import Path +from typing import Optional, Sequence, Tuple, List + +from ...shared import Result, get_logger +from ...adapters.db.sqlite import Sqlite +from ...adapters.tools import ExifTool, FFProbe +from ...config import get_tool_paths + +logger = get_logger(__name__) + +class HealthService: + """ + Health check service. + + Monitors tool availability and database status. + """ + + def __init__(self, db: Sqlite, exiftool: ExifTool, ffprobe: FFProbe): + """ + Initialize health service. + + Args: + db: SQLite database instance + exiftool: ExifTool adapter + ffprobe: FFProbe adapter + """ + self.db = db + self.exiftool = exiftool + self.ffprobe = ffprobe + + def status(self) -> Result[dict]: + """ + Get system health status. + + Returns: + Result with status dict containing: + - tools: Tool availability + - database: Database status + - overall: Overall health (healthy, degraded, unhealthy) + """ + try: + # Check tool availability + tools = { + "exiftool": { + "available": self.exiftool.is_available(), + "required": False, # Optional but recommended + }, + "ffprobe": { + "available": self.ffprobe.is_available(), + "required": False, # Optional but recommended + } + } + + # Check database + db_status = self._check_database() + + # Determine overall health + overall = self._determine_health(tools, db_status) + + status = { + "tools": tools, + "database": db_status, + "overall": overall + } + + return Result.Ok(status) + + except Exception as e: + logger.error(f"Health check failed: {e}") + return Result.Err("HEALTH_CHECK_ERROR", str(e)) + + def _check_database(self) -> dict: + """Check database status.""" + try: + if not self.db.has_table("metadata"): + return { + "available": False, + "schema_version": 0, + "error": "Schema metadata table missing" + } + + # Try a simple query + result = self.db.execute("SELECT COUNT(*) as count FROM metadata", fetch=True) + + if result.ok: + return { + "available": True, + "schema_version": self.db.get_schema_version(), + "error": None + } + else: + return { + "available": False, + "schema_version": 0, + "error": result.error + } + + except Exception as e: + logger.error(f"Database check failed: {e}") + return { + "available": False, + "schema_version": 0, + "error": str(e) + } + + def _determine_health(self, tools: dict, db_status: dict) -> str: + """ + Determine overall health status. + + Args: + tools: Tool availability dict + db_status: Database status dict + + Returns: + Health status: healthy, degraded, unhealthy + """ + # Database must be available + if not db_status.get("available"): + return "unhealthy" + + # Count available tools + tools_available = sum(1 for t in tools.values() if t["available"]) + tools_total = len(tools) + + if tools_available == tools_total: + return "healthy" + elif tools_available > 0: + return "degraded" # Some tools missing but functional + else: + return "degraded" # No external tools but DB works + + def _roots_where(self, roots: Sequence[str]) -> Tuple[str, List[str]]: + def _escape_like_pattern(pattern: str) -> str: + return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + clauses = [] + params: List[str] = [] + for root in roots: + if not root: + continue + try: + base = str(Path(str(root)).resolve(strict=False)) + except OSError: + continue + base_sep = base if base.endswith(os.sep) else base + os.sep + clauses.append("(a.filepath = ? OR a.filepath LIKE ? ESCAPE '\\')") + params.extend([base, _escape_like_pattern(base_sep) + "%"]) + if not clauses: + return "1=1", [] + return "(" + " OR ".join(clauses) + ")", params + + def get_counters(self, roots: Optional[Sequence[str]] = None) -> Result[dict]: + """ + Get database counters. + + Returns: + Result with counters dict containing asset counts + """ + try: + if roots: + where_sql, where_params = self._roots_where(roots) + # Count total assets + total_result = self.db.execute( + f"SELECT COUNT(*) as count FROM assets a WHERE {where_sql}", + tuple(where_params), + fetch=True + ) + + # Count by kind + kind_result = self.db.execute( + f"SELECT a.kind, COUNT(*) as count FROM assets a WHERE {where_sql} GROUP BY a.kind", + tuple(where_params), + fetch=True + ) + + # Count with ratings + rated_result = self.db.execute( + f""" + SELECT COUNT(*) as count + FROM asset_metadata m + JOIN assets a ON a.id = m.asset_id + WHERE m.rating > 0 AND {where_sql} + """, + tuple(where_params), + fetch=True + ) + + # Count with workflows + workflow_result = self.db.execute( + f""" + SELECT COUNT(*) as count + FROM asset_metadata m + JOIN assets a ON a.id = m.asset_id + WHERE m.has_workflow = 1 AND {where_sql} + """, + tuple(where_params), + fetch=True + ) + + # Count with generation data + generation_result = self.db.execute( + f""" + SELECT COUNT(*) as count + FROM asset_metadata m + JOIN assets a ON a.id = m.asset_id + WHERE m.has_generation_data = 1 AND {where_sql} + """, + tuple(where_params), + fetch=True + ) + else: + # Count total assets + total_result = self.db.execute( + "SELECT COUNT(*) as count FROM assets", + fetch=True + ) + + # Count by kind + kind_result = self.db.execute( + "SELECT kind, COUNT(*) as count FROM assets GROUP BY kind", + fetch=True + ) + + # Count with ratings + rated_result = self.db.execute( + "SELECT COUNT(*) as count FROM asset_metadata WHERE rating > 0", + fetch=True + ) + + # Count with workflows + workflow_result = self.db.execute( + "SELECT COUNT(*) as count FROM asset_metadata WHERE has_workflow = 1", + fetch=True + ) + + # Count with generation data + generation_result = self.db.execute( + "SELECT COUNT(*) as count FROM asset_metadata WHERE has_generation_data = 1", + fetch=True + ) + + last_scan_result = self.db.execute( + "SELECT value FROM metadata WHERE key = 'last_scan_end'", + fetch=True + ) + last_scan_end = last_scan_result.data[0]["value"] if last_scan_result.ok and last_scan_result.data else None + + # Get counts by kind + by_kind = {} + if kind_result.ok and kind_result.data and len(kind_result.data) > 0: + by_kind = {row["kind"]: row["count"] for row in kind_result.data} + + workflow_count = workflow_result.data[0]["count"] if workflow_result.ok and workflow_result.data else 0 + generation_count = generation_result.data[0]["count"] if generation_result.ok and generation_result.data else 0 + + counters = { + "total_assets": total_result.data[0]["count"] if total_result.ok and total_result.data else 0, + "images": by_kind.get("image", 0), + "videos": by_kind.get("video", 0), + "audio": by_kind.get("audio", 0), + "by_kind": by_kind, # Keep for backward compatibility + "rated": rated_result.data[0]["count"] if rated_result.ok and rated_result.data else 0, + "with_workflow": workflow_count, + "with_workflows": workflow_count, + "with_generation_data": generation_count, + "last_scan_end": last_scan_end, + "tool_availability": self._get_tool_capabilities(), + "tool_paths": get_tool_paths(), + } + + return Result.Ok(counters) + + except Exception as e: + logger.error(f"Failed to get counters: {e}") + return Result.Err("DB_ERROR", str(e)) + + def _get_tool_capabilities(self) -> dict: + """Report availability of required tooling (ExifTool/FFprobe).""" + return { + "exiftool": self.exiftool.is_available(), + "ffprobe": self.ffprobe.is_available() + } diff --git a/backend/features/index/__init__.py b/backend/features/index/__init__.py new file mode 100644 index 00000000..885de97e --- /dev/null +++ b/backend/features/index/__init__.py @@ -0,0 +1,6 @@ +""" +Index feature - file scanning and search. +""" +from .service import IndexService + +__all__ = ["IndexService"] diff --git a/backend/features/index/enricher.py b/backend/features/index/enricher.py new file mode 100644 index 00000000..940dd952 --- /dev/null +++ b/backend/features/index/enricher.py @@ -0,0 +1,202 @@ +""" +Metadata enricher - handles background metadata enrichment for assets. +""" +import os +import threading +from typing import List, Dict, Any, Tuple, Optional + +from ...shared import get_logger +from ...adapters.db.sqlite import Sqlite +from ..metadata import MetadataService + + +logger = get_logger(__name__) + + +class MetadataEnricher: + """ + Handles background metadata enrichment for assets. + + Manages a queue of files that need metadata enrichment and processes + them in the background using a worker thread. This is used for fast + scans that skip metadata extraction during the initial pass. + """ + + def __init__( + self, + db: Sqlite, + metadata_service: MetadataService, + scan_lock: threading.Lock, + compute_state_hash_fn, + prepare_metadata_fields_fn, + metadata_error_payload_fn, + ): + """ + Initialize metadata enricher. + + Args: + db: Database adapter instance + metadata_service: Metadata service for extraction + scan_lock: Shared lock for database writes + compute_state_hash_fn: Function to compute state hash + prepare_metadata_fields_fn: Function to prepare metadata fields + metadata_error_payload_fn: Function to create error payload + """ + self.db = db + self.metadata = metadata_service + self._scan_lock = scan_lock + self._compute_state_hash = compute_state_hash_fn + self._prepare_metadata_fields = prepare_metadata_fields_fn + self._metadata_error_payload = metadata_error_payload_fn + self._enrich_lock = threading.Lock() + self._enrich_queue: List[str] = [] + self._enrich_thread: Optional[threading.Thread] = None + self._enrich_running = False + + def start_enrichment(self, filepaths: List[str]) -> None: + """ + Enqueue files for background metadata enrichment. + + Args: + filepaths: List of file paths to enqueue + """ + cleaned = [str(p) for p in (filepaths or []) if p] + if not cleaned: + return + with self._enrich_lock: + existing = set(self._enrich_queue) + for fp in cleaned: + if fp in existing: + continue + self._enrich_queue.append(fp) + existing.add(fp) + if self._enrich_running and self._enrich_thread and self._enrich_thread.is_alive(): + return + self._enrich_running = True + self._enrich_thread = threading.Thread(target=self._enrichment_worker, daemon=True) + self._enrich_thread.start() + + def _enrichment_worker(self) -> None: + """Background worker that processes the enrichment queue.""" + try: + while True: + with self._enrich_lock: + if not self._enrich_queue: + self._enrich_running = False + return + chunk = self._enrich_queue[:64] + del self._enrich_queue[:64] + self._enrich_metadata_chunk(chunk) + except Exception: + with self._enrich_lock: + self._enrich_running = False + + def _enrich_metadata_chunk(self, filepaths: List[str]) -> None: + """ + Process a chunk of files for metadata enrichment. + + Args: + filepaths: List of file paths to process + """ + cleaned = [str(p) for p in (filepaths or []) if p] + if not cleaned: + return + + placeholders = ",".join(["?"] * len(cleaned)) + id_res = self.db.query(f"SELECT id, filepath FROM assets WHERE filepath IN ({placeholders})", tuple(cleaned)) + if not id_res.ok: + return + id_by_fp: Dict[str, int] = {} + for row in id_res.data or []: + if not isinstance(row, dict): + continue + fp = row.get("filepath") + try: + aid = int(row.get("id") or 0) + except Exception: + aid = 0 + if fp and aid: + id_by_fp[str(fp)] = aid + + asset_updates: List[Tuple[Any, Any, Any, int]] = [] + meta_updates: List[Tuple[int, int, str, str, int]] = [] + + for fp in cleaned: + asset_id = id_by_fp.get(fp) + if not asset_id: + continue + try: + stat = os.stat(fp) + except Exception: + continue + mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) + state_hash = self._compute_state_hash(fp, int(mtime_ns), int(stat.st_size)) + + # Try cache first to avoid tool work. + from .metadata_helpers import MetadataHelpers + metadata_result = MetadataHelpers.retrieve_cached_metadata(self.db, fp, state_hash) + if not (metadata_result and metadata_result.ok): + metadata_result = self.metadata.get_metadata(fp, scan_id=None) + if metadata_result.ok: + MetadataHelpers.store_metadata_cache(self.db, fp, state_hash, metadata_result) + if not metadata_result.ok: + metadata_result = self._metadata_error_payload(metadata_result, fp) + + width = None + height = None + duration = None + if metadata_result.ok and metadata_result.data: + meta = metadata_result.data + width = meta.get("width") + height = meta.get("height") + duration = meta.get("duration") + + has_workflow, has_generation_data, metadata_quality, metadata_raw_json = self._prepare_metadata_fields(metadata_result) + asset_updates.append((width, height, duration, asset_id)) + meta_updates.append( + ( + 1 if has_workflow else 0, + 1 if has_generation_data else 0, + metadata_quality, + metadata_raw_json, + asset_id, + ) + ) + + if not asset_updates and not meta_updates: + return + + # Keep DB writes serialized with scans/index_files to avoid lock contention. + with self._scan_lock: + with self.db.transaction(mode="immediate"): + if asset_updates: + self.db.executemany( + """ + UPDATE assets + SET width = COALESCE(?, width), + height = COALESCE(?, height), + duration = COALESCE(?, duration), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + asset_updates, + ) + if meta_updates: + # executemany() + triggers can be brittle across SQLite builds; keep this robust. + for (_, _, _, _, asset_id) in meta_updates: + self.db.execute( + "INSERT OR IGNORE INTO asset_metadata(asset_id, rating, tags) VALUES (?, 0, '[]')", + (asset_id,), + ) + for hw, hg, q, raw, asset_id in meta_updates: + self.db.execute( + """ + UPDATE asset_metadata + SET has_workflow = ?, + has_generation_data = ?, + metadata_quality = ?, + metadata_raw = ? + WHERE asset_id = ? + """, + (hw, hg, q, raw, asset_id), + ) diff --git a/backend/features/index/metadata_helpers.py b/backend/features/index/metadata_helpers.py new file mode 100644 index 00000000..5b8085d9 --- /dev/null +++ b/backend/features/index/metadata_helpers.py @@ -0,0 +1,359 @@ +""" +Metadata helpers - shared utilities for metadata operations. +""" +import hashlib +import json +from typing import Dict, Any, Tuple, Optional + +from ...shared import Result, ErrorCode +from ...adapters.db.sqlite import Sqlite + + +class MetadataHelpers: + """ + Shared utilities for metadata operations. + + Provides static methods for metadata caching, field preparation, + and error handling that are used across multiple components. + """ + + @staticmethod + def prepare_metadata_fields(metadata_result: Result[Dict[str, Any]]) -> Tuple[bool, bool, str, str]: + """ + Extract the workflow/generation metadata attributes and JSON payload. + + Args: + metadata_result: Result from metadata extraction + + Returns: + Tuple of (has_workflow, has_generation_data, metadata_quality, metadata_raw_json) + """ + has_workflow = False + has_generation_data = False + metadata_quality = "none" + metadata_raw_json = "{}" + + def _graph_has_sampler(graph: Any) -> bool: + try: + # Workflow export: dict with `nodes: []` and nodes have `type`. + if isinstance(graph, dict) and isinstance(graph.get("nodes"), list): + for node in graph.get("nodes") or []: + if not isinstance(node, dict): + continue + ct = str(node.get("type") or node.get("class_type") or "").lower() + if not ct: + continue + if "ksampler" in ct and "select" not in ct: + return True + if "samplercustom" in ct: + return True + if "sampler" in ct and "select" not in ct: + return True + return False + + # Prompt graph: dict of nodes with `class_type`. + if isinstance(graph, dict): + for node in graph.values(): + if not isinstance(node, dict): + continue + ct = str(node.get("class_type") or node.get("type") or "").lower() + if not ct: + continue + if "ksampler" in ct and "select" not in ct: + return True + if "samplercustom" in ct: + return True + if "sampler" in ct and "select" not in ct: + # Require a couple of sampling-ish keys to avoid false positives. + ins = node.get("inputs") + if isinstance(ins, dict) and any(k in ins for k in ("steps", "cfg", "cfg_scale", "seed", "denoise")): + return True + return False + except Exception: + return False + return False + + if metadata_result and metadata_result.ok and metadata_result.data: + meta = metadata_result.data + has_workflow = bool( + meta.get("workflow") or + meta.get("parameters") + ) + has_generation_data = bool( + meta.get("parameters") or + meta.get("geninfo") or + meta.get("model") or + meta.get("seed") or + _graph_has_sampler(meta.get("prompt")) or + _graph_has_sampler(meta.get("workflow")) + ) + metadata_quality = meta.get("quality", "none") + metadata_raw_json = json.dumps(meta) + + return has_workflow, has_generation_data, metadata_quality, metadata_raw_json + + @staticmethod + def metadata_error_payload(metadata_result: Result[Dict[str, Any]], filepath: str) -> Result[Dict[str, Any]]: + """ + Build a degraded metadata payload when extraction failed. + + Args: + metadata_result: Failed result from metadata extraction + filepath: File path for context + + Returns: + Result with degraded payload + """ + payload = { + "filepath": filepath, + "error": metadata_result.error or "Metadata extraction failed", + "code": metadata_result.code, + } + + for key, value in metadata_result.meta.items(): + if key == "quality" or value is None: + continue + payload[key] = value + + quality = metadata_result.meta.get("quality", "degraded") + return Result.Ok(payload, quality=quality) + + @staticmethod + def write_asset_metadata_row(db: Sqlite, asset_id: int, metadata_result: Result[Dict[str, Any]]) -> Result[Any]: + """ + Insert or update the asset_metadata row with the latest metadata flags. + + Args: + db: Database adapter instance + asset_id: Asset ID to update + metadata_result: Result from metadata extraction + + Returns: + Result from database operation + """ + has_workflow, has_generation_data, metadata_quality, metadata_raw_json = MetadataHelpers.prepare_metadata_fields(metadata_result) + + extracted_rating = 0 + extracted_tags_json = "[]" + extracted_tags_text = "" + if metadata_result and metadata_result.ok and metadata_result.data: + meta = metadata_result.data + try: + r = meta.get("rating") + if r is not None: + extracted_rating = max(0, min(5, int(r))) + except Exception: + extracted_rating = 0 + try: + t = meta.get("tags") + if isinstance(t, list): + cleaned = [] + seen = set() + for item in t: + if not isinstance(item, str): + continue + tag = item.strip() + if not tag or len(tag) > 100 or tag in seen: + continue + seen.add(tag) + cleaned.append(tag) + extracted_tags_json = json.dumps(cleaned, ensure_ascii=False) + extracted_tags_text = " ".join(cleaned) + except Exception: + extracted_tags_json = "[]" + extracted_tags_text = "" + + # Import existing OS/file metadata when DB has defaults, without overriding user edits. + # - rating: only set if current rating is 0 + # - tags: only set if current tags are empty ('[]' or '') + return db.execute( + """ + INSERT INTO asset_metadata + (asset_id, rating, tags, tags_text, has_workflow, has_generation_data, metadata_quality, metadata_raw) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(asset_id) DO UPDATE SET + rating = CASE + WHEN COALESCE(asset_metadata.rating, 0) = 0 THEN excluded.rating + ELSE asset_metadata.rating + END, + tags = CASE + WHEN COALESCE(asset_metadata.tags, '[]') IN ('[]', '') THEN excluded.tags + ELSE asset_metadata.tags + END, + tags_text = CASE + WHEN COALESCE(asset_metadata.tags, '[]') IN ('[]', '') THEN excluded.tags_text + ELSE asset_metadata.tags_text + END, + has_workflow = excluded.has_workflow, + has_generation_data = excluded.has_generation_data, + metadata_quality = excluded.metadata_quality, + metadata_raw = excluded.metadata_raw + """, + ( + asset_id, + extracted_rating, + extracted_tags_json, + extracted_tags_text, + 1 if has_workflow else 0, + 1 if has_generation_data else 0, + metadata_quality, + metadata_raw_json, + ), + ) + + @staticmethod + def refresh_metadata_if_needed( + db: Sqlite, + asset_id: int, + metadata_result: Result[Dict[str, Any]], + filepath: str, + base_dir: str, + state_hash: str, + mtime: int, + size: int, + write_journal_fn, + ) -> bool: + """ + Re-run metadata extraction for unchanged files if the metadata flags changed. + + Args: + db: Database adapter instance + asset_id: Asset ID to check + metadata_result: New metadata result + filepath: File path + base_dir: Base directory + state_hash: State hash for journaling + mtime: File modification time + size: File size + write_journal_fn: Function to write journal entry + + Returns: + True if metadata was refreshed, False otherwise + """ + result = db.query( + "SELECT has_workflow, has_generation_data, metadata_raw FROM asset_metadata WHERE asset_id = ?", + (asset_id,) + ) + if not result.ok: + return False + + current = result.data[0] if result.data else None + new_has_workflow, new_has_generation_data, _, new_metadata_raw = MetadataHelpers.prepare_metadata_fields(metadata_result) + + current_has_workflow = current["has_workflow"] if current else 0 + current_has_generation = current["has_generation_data"] if current else 0 + current_raw = current["metadata_raw"] if current else "" + + if (current_has_workflow == (1 if new_has_workflow else 0) and + current_has_generation == (1 if new_has_generation_data else 0) and + current_raw == new_metadata_raw): + return False + + write_result = MetadataHelpers.write_asset_metadata_row(db, asset_id, metadata_result) + if write_result.ok: + write_journal_fn(filepath, base_dir, state_hash, mtime, size) + return write_result.ok + + @staticmethod + def retrieve_cached_metadata(db: Sqlite, filepath: str, state_hash: str) -> Optional[Result[Dict[str, Any]]]: + """ + Retrieve metadata from cache if available. + + Args: + db: Database adapter instance + filepath: File path + state_hash: State hash to validate cache + + Returns: + Cached metadata result or None if not found + """ + if not state_hash: + return None + + result = db.query( + "SELECT metadata_raw FROM metadata_cache WHERE filepath = ? AND state_hash = ?", + (filepath, state_hash) + ) + + if not result.ok or not result.data: + return None + + raw = result.data[0].get("metadata_raw") + if not raw: + return None + + try: + payload = json.loads(raw) + return Result.Ok(payload, source="cache") + except json.JSONDecodeError: + return None + + @staticmethod + def store_metadata_cache(db: Sqlite, filepath: str, state_hash: str, metadata_result: Result[Dict[str, Any]]) -> Result[Any]: + """ + Store metadata in cache for future use. + + Args: + db: Database adapter instance + filepath: File path + state_hash: State hash for validation + metadata_result: Metadata result to cache + + Returns: + Result from database operation + """ + if not metadata_result.ok or not metadata_result.data: + return Result.Err("CACHE_SKIPPED", "No metadata to cache") + + metadata_raw = json.dumps( + metadata_result.data, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True + ) + metadata_hash = MetadataHelpers.compute_metadata_hash(metadata_raw) + + return db.execute( + """ + INSERT INTO metadata_cache + (filepath, state_hash, metadata_hash, metadata_raw) + VALUES (?, ?, ?, ?) + ON CONFLICT(filepath) DO UPDATE SET + state_hash = excluded.state_hash, + metadata_hash = excluded.metadata_hash, + metadata_raw = excluded.metadata_raw, + last_updated = CURRENT_TIMESTAMP + """, + (filepath, state_hash, metadata_hash, metadata_raw) + ) + + @staticmethod + def compute_metadata_hash(raw_json: str) -> str: + """ + Compute hash of metadata JSON. + + Args: + raw_json: JSON string to hash + + Returns: + MD5 hash of the JSON + """ + return hashlib.md5(raw_json.encode("utf-8")).hexdigest() + + @staticmethod + def set_metadata_value(db: Sqlite, key: str, value: str) -> Result[Any]: + """ + Persist a simple key/value in the metadata table. + + Args: + db: Database adapter instance + key: Metadata key + value: Metadata value + + Returns: + Result from database operation + """ + return db.execute( + "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)", + (key, value) + ) diff --git a/backend/features/index/scanner.py b/backend/features/index/scanner.py new file mode 100644 index 00000000..e0c56ec0 --- /dev/null +++ b/backend/features/index/scanner.py @@ -0,0 +1,1285 @@ +""" +Index scanner - handles directory scanning and file indexing operations. +""" +import hashlib +import logging +import os +import time +import threading +from pathlib import Path +from typing import List, Dict, Any, Iterable, Optional +from datetime import datetime +from uuid import uuid4 + +from ...shared import get_logger, Result, classify_file, FileKind, ErrorCode, log_structured, EXTENSIONS +from ...adapters.db.sqlite import Sqlite +from ...config import ( + SCAN_BATCH_SMALL_THRESHOLD, + SCAN_BATCH_MED_THRESHOLD, + SCAN_BATCH_LARGE_THRESHOLD, + SCAN_BATCH_SMALL, + SCAN_BATCH_MED, + SCAN_BATCH_LARGE, + SCAN_BATCH_XL, +) +from ..metadata import MetadataService +from .metadata_helpers import MetadataHelpers + + +logger = get_logger(__name__) + +_EXT_TO_KIND: Dict[str, FileKind] = {} +try: + for _kind, _exts in (EXTENSIONS or {}).items(): + for _ext in _exts or []: + _EXT_TO_KIND[str(_ext).lower()] = _kind # type: ignore[assignment] +except Exception: + _EXT_TO_KIND = {} + + +class IndexScanner: + """ + Handles directory scanning and file indexing operations. + + Provides recursive directory scanning, incremental updates based on mtime, + and integration with metadata extraction. + """ + + def __init__(self, db: Sqlite, metadata_service: MetadataService, scan_lock: threading.Lock): + """ + Initialize index scanner. + + Args: + db: Database adapter instance + metadata_service: Metadata service for extraction + scan_lock: Shared lock for database operations + """ + self.db = db + self.metadata = metadata_service + self._scan_lock = scan_lock + self._current_scan_id = None + + def scan_directory( + self, + directory: str, + recursive: bool = True, + incremental: bool = True, + source: str = "output", + root_id: Optional[str] = None, + fast: bool = False, + background_metadata: bool = False, + ) -> Result[Dict[str, Any]]: + """ + Scan a directory for asset files. + + Args: + directory: Path to scan + recursive: Scan subdirectories + incremental: Only update changed files (based on mtime) + source: Source identifier for the scan + root_id: Root identifier for the scan + fast: Skip metadata extraction during scan + background_metadata: Enable background metadata enrichment + + Returns: + Result with scan statistics + """ + to_enrich: List[str] = [] + with self._scan_lock: + dir_path = Path(directory) + + if not dir_path.exists(): + return Result.Err("DIR_NOT_FOUND", f"Directory not found: {directory}") + + if not dir_path.is_dir(): + return Result.Err("NOT_A_DIRECTORY", f"Not a directory: {directory}") + + scan_id = str(uuid4()) + self._current_scan_id = scan_id + scan_start = time.perf_counter() + + self._log_scan_event( + logging.INFO, + "Starting directory scan", + directory=directory, + recursive=recursive, + incremental=incremental, + files_root=str(dir_path) + ) + + stats = { + "scanned": 0, + "added": 0, + "updated": 0, + "skipped": 0, + "errors": 0, + "start_time": datetime.now().isoformat() + } + + # Use streaming approach to avoid loading all files in memory at once + file_generator = self._iter_files(dir_path, recursive) + files = list(file_generator) # For now, keeping the same interface + stats["scanned"] = len(files) + logger.debug(f"Found {len(files)} files to process") + + try: + for batch in self._chunk_file_batches(files): + if not batch: + continue + + # Prefetch existing rows and scan journal entries for the batch. + filepaths = [str(p) for p in batch] + journal_map = self._get_journal_entries(filepaths) if incremental and filepaths else {} + existing_map: Dict[str, Dict[str, Any]] = {} + + if filepaths: + placeholders = ",".join(["?"] * len(filepaths)) + existing_rows = self.db.query( + f"SELECT filepath, id, mtime FROM assets WHERE filepath IN ({placeholders})", + tuple(filepaths), + ) + if existing_rows.ok and existing_rows.data and len(existing_rows.data) > 0: + for row in existing_rows.data: + fp = row.get("filepath") + if fp: + existing_map[str(fp)] = row + + # Process the whole batch with a bounded transaction (huge perf win). + # This avoids BEGIN/COMMIT per file while keeping write locks short. + self._index_batch( + batch=batch, + base_dir=directory, + incremental=incremental, + source=source, + root_id=root_id, + fast=fast, + journal_map=journal_map, + existing_map=existing_map, + stats=stats, + to_enrich=to_enrich, + ) + finally: + stats["end_time"] = datetime.now().isoformat() + duration = time.perf_counter() - scan_start + + MetadataHelpers.set_metadata_value(self.db, "last_scan_end", stats["end_time"]) + + self._log_scan_event( + logging.INFO, + "Directory scan complete", + duration_seconds=duration, + scanned=stats["scanned"], + added=stats["added"], + updated=stats["updated"], + skipped=stats["skipped"], + errors=stats["errors"] + ) + self._current_scan_id = None + + # Return to_enrich list for background enrichment + if to_enrich: + stats["to_enrich"] = to_enrich + + logger.debug( + f"Scan complete: {stats['added']} added, " + f"{stats['updated']} updated, {stats['skipped']} skipped, " + f"{stats['errors']} errors" + ) + + return Result.Ok(stats) + + def index_paths( + self, + paths: List[Path], + base_dir: str, + incremental: bool = True, + source: str = "output", + root_id: Optional[str] = None, + ) -> Result[Dict[str, Any]]: + """ + Index a list of file paths (no directory scan). + + Args: + paths: List of file paths to index + base_dir: Base directory for relative path calculation + incremental: Skip if already indexed and unchanged + source: Source identifier for the index + root_id: Root identifier for the index + + Returns: + Result with indexing statistics + """ + with self._scan_lock: + scan_id = str(uuid4()) + self._current_scan_id = scan_id + scan_start = time.perf_counter() + + self._log_scan_event( + logging.INFO, + "Starting file list index", + file_count=len(paths), + base_dir=base_dir, + incremental=incremental + ) + + stats = { + "scanned": len(paths), + "added": 0, + "updated": 0, + "skipped": 0, + "errors": 0, + "start_time": datetime.now().isoformat() + } + + try: + for batch in self._chunk_file_batches(paths): + if not batch: + continue + + filepaths = [str(p) for p in batch] + journal_map = self._get_journal_entries(filepaths) if incremental and filepaths else {} + existing_map: Dict[str, Dict[str, Any]] = {} + + if filepaths: + placeholders = ",".join(["?"] * len(filepaths)) + existing_rows = self.db.query( + f"SELECT filepath, id, mtime FROM assets WHERE filepath IN ({placeholders})", + tuple(filepaths), + ) + if existing_rows.ok and existing_rows.data and len(existing_rows.data) > 0: + for row in existing_rows.data: + fp = row.get("filepath") + if fp: + existing_map[str(fp)] = row + + self._index_batch( + batch=batch, + base_dir=base_dir, + incremental=incremental, + source=source, + root_id=root_id, + fast=False, + journal_map=journal_map, + existing_map=existing_map, + stats=stats, + to_enrich=None, + ) + finally: + stats["end_time"] = datetime.now().isoformat() + duration = time.perf_counter() - scan_start + + # Do not update last_scan_end for targeted indexing (DnD, executed events, etc.). + # The UI uses last_scan_end to detect *full scans* and reload the grid; updating it + # for single-file indexing causes unnecessary grid reloads/flicker. + MetadataHelpers.set_metadata_value(self.db, "last_index_end", stats["end_time"]) + + self._log_scan_event( + logging.INFO, + "File list index complete", + duration_seconds=duration, + scanned=stats["scanned"], + added=stats["added"], + updated=stats["updated"], + skipped=stats["skipped"], + errors=stats["errors"] + ) + self._current_scan_id = None + + return Result.Ok(stats) + + def _index_batch( + self, + batch: List[Path], + base_dir: str, + incremental: bool, + source: str, + root_id: Optional[str], + fast: bool, + journal_map: Dict[str, str], + existing_map: Dict[str, Dict[str, Any]], + stats: Dict[str, Any], + to_enrich: Optional[List[str]] = None, + ) -> None: + """ + Index a batch of files using a single transaction for all DB writes. + + This drastically reduces SQLite transaction overhead on large scans (10k+ files) + while keeping the "do not crash UI" and Result patterns intact. + """ + + # Phase 1: Stat files and determine which need metadata extraction + prepared: List[Dict[str, Any]] = [] + needs_metadata: List[tuple[Path, str, int, int, str, Optional[int]]] = [] # (path, filepath, mtime, size, state_hash, existing_id) + + # Prefetch metadata cache and asset_metadata entries for the entire batch to avoid N+1 queries + filepaths = [str(p) for p in batch] + cache_map = {} + has_meta_set = set() + + if filepaths: + # Prefetch metadata_cache entries + placeholders = ",".join(["?"] * len(filepaths)) + cache_rows = self.db.query( + f"SELECT filepath, state_hash, metadata_raw FROM metadata_cache WHERE filepath IN ({placeholders})", + tuple(filepaths) + ) + if cache_rows.ok and cache_rows.data: + for row in cache_rows.data: + fp = row.get("filepath") + state_hash = row.get("state_hash") + if fp and state_hash: + cache_map[(fp, state_hash)] = row.get("metadata_raw") + + # Prefetch asset_metadata entries + asset_ids = [] + for fp in filepaths: + existing_state = existing_map.get(fp) + if existing_state and existing_state.get("id"): + try: + existing_id = int(existing_state.get("id") or 0) + if existing_id: + asset_ids.append(existing_id) + except Exception: + pass + + if asset_ids: + placeholders = ",".join(["?"] * len(asset_ids)) + meta_rows = self.db.query( + f"SELECT asset_id FROM asset_metadata WHERE asset_id IN ({placeholders})", + tuple(asset_ids) + ) + if meta_rows.ok and meta_rows.data: + for row in meta_rows.data: + asset_id = row.get("asset_id") + if asset_id: + try: + has_meta_set.add(int(asset_id)) + except Exception: + pass + + for file_path in batch: + fp = str(file_path) + existing_state: Optional[Dict[str, Any]] + if incremental and fp in journal_map: + existing_state = dict(existing_map.get(fp) or {}) + existing_state["journal_state_hash"] = journal_map.get(fp) + else: + existing_state = existing_map.get(fp) + + # Stat the file + stat_result = self._stat_with_retry(file_path) + if not stat_result[0]: + stats["errors"] += 1 + logger.warning("Failed to stat %s: %s", str(file_path), stat_result[1]) + continue + + stat = stat_result[1] + mtime_ns = stat.st_mtime_ns + mtime = int(stat.st_mtime) + size = int(stat.st_size) + filepath = str(file_path) + state_hash = self._compute_state_hash(filepath, mtime_ns, size) + + # Check journal skip + journal_state_hash = None + if isinstance(existing_state, dict) and "journal_state_hash" in existing_state: + journal_state_hash = existing_state.get("journal_state_hash") + + if incremental and journal_state_hash and str(journal_state_hash) == state_hash: + prepared.append({"action": "skipped_journal"}) + continue + + existing_id = 0 + existing_mtime = 0 + if isinstance(existing_state, dict) and existing_state.get("id") is not None: + try: + existing_id = int(existing_state.get("id") or 0) + existing_mtime = int(existing_state.get("mtime") or 0) + except Exception: + pass + + # Check cache skip using prefetched data + if incremental and existing_id and existing_mtime == mtime: + # Check if we have cached metadata for this filepath and state_hash + cached_raw = cache_map.get((filepath, state_hash)) + if cached_raw: + cached_result = Result.Ok({"metadata_raw": cached_raw}, source="cache") + prepared.append({ + "action": "refresh", + "asset_id": existing_id, + "metadata_result": cached_result, + "filepath": filepath, + "file_path": file_path, + "state_hash": state_hash, + "mtime": mtime, + "size": size, + "fast": fast, + "cache_store": False, + }) + continue + + # Check if asset has metadata using prefetched data + if existing_id in has_meta_set: + prepared.append({"action": "skipped"}) + continue + + # This file needs processing + if not fast: + needs_metadata.append((file_path, filepath, mtime, size, state_hash, existing_id if existing_id else None)) + else: + # Fast mode - no metadata + rel_path = self._safe_relative_path(file_path, base_dir) + prepared.append({ + "action": "updated" if existing_id else "added", + "asset_id": existing_id if existing_id else None, + "metadata_result": Result.Ok({}), + "filepath": filepath, + "file_path": file_path, + "state_hash": state_hash, + "mtime": mtime, + "size": size, + "fast": True, + "cache_store": False, + "filename": file_path.name if not existing_id else None, + "subfolder": str(rel_path.parent) if not existing_id and rel_path.parent != Path(".") else ("" if not existing_id else None), + "kind": classify_file(file_path.name) if not existing_id else None, + }) + + # Phase 2: Batch metadata extraction + if needs_metadata: + paths_to_extract = [item[0] for item in needs_metadata] + batch_metadata = self.metadata.get_metadata_batch([str(p) for p in paths_to_extract], scan_id=self._current_scan_id) + + for file_path, filepath, mtime, size, state_hash, existing_id in needs_metadata: + metadata_result = batch_metadata.get(str(file_path)) + if not metadata_result: + metadata_result = MetadataHelpers.metadata_error_payload(Result.Err("METADATA_MISSING", "No metadata returned"), filepath) + elif not metadata_result.ok: + metadata_result = MetadataHelpers.metadata_error_payload(metadata_result, filepath) + + cache_store = metadata_result.ok + + if existing_id: + prepared.append({ + "action": "updated", + "asset_id": existing_id, + "metadata_result": metadata_result, + "filepath": filepath, + "file_path": file_path, + "state_hash": state_hash, + "mtime": mtime, + "size": size, + "fast": fast, + "cache_store": cache_store, + }) + else: + rel_path = self._safe_relative_path(file_path, base_dir) + filename = file_path.name + subfolder = str(rel_path.parent) if rel_path.parent != Path(".") else "" + kind = classify_file(filename) + + prepared.append({ + "action": "added", + "filename": filename, + "subfolder": subfolder, + "filepath": filepath, + "kind": kind, + "metadata_result": metadata_result, + "file_path": file_path, + "state_hash": state_hash, + "mtime": mtime, + "size": size, + "fast": fast, + "cache_store": cache_store, + }) + + if not prepared: + return + + # Apply DB writes for the whole batch in one transaction. + with self.db.transaction(mode="immediate"): + for entry in prepared: + action = entry.get("action") + + if action in ("skipped", "skipped_journal"): + stats["skipped"] += 1 + continue + + if action == "refresh": + asset_id = entry.get("asset_id") + metadata_result = entry.get("metadata_result") + if not asset_id or not isinstance(metadata_result, Result): + stats["skipped"] += 1 + continue + try: + refreshed = MetadataHelpers.refresh_metadata_if_needed( + self.db, + int(asset_id), + metadata_result, + entry.get("filepath") or "", + base_dir, + entry.get("state_hash") or "", + int(entry.get("mtime") or 0), + int(entry.get("size") or 0), + self._write_scan_journal_entry, + ) + stats["skipped"] += 1 + if refreshed and entry.get("fast") and to_enrich is not None: + to_enrich.append(entry.get("filepath") or str(entry.get("file_path") or "")) + except Exception as exc: + stats["errors"] += 1 + logger.warning("Metadata refresh failed for asset_id=%s: %s", asset_id, exc) + continue + + if action == "updated": + asset_id = entry.get("asset_id") + metadata_result = entry.get("metadata_result") + if not asset_id or not isinstance(metadata_result, Result): + stats["errors"] += 1 + continue + cache_store = bool(entry.get("cache_store")) + if cache_store: + try: + MetadataHelpers.store_metadata_cache( + self.db, + entry.get("filepath") or "", + entry.get("state_hash") or "", + metadata_result, + ) + except Exception: + pass + res = self._update_asset( + int(asset_id), + entry.get("file_path"), + int(entry.get("mtime") or 0), + int(entry.get("size") or 0), + metadata_result, + source=source, + root_id=root_id, + write_metadata=not bool(entry.get("fast")), + ) + if res.ok: + self._write_scan_journal_entry( + entry.get("filepath") or "", + base_dir, + entry.get("state_hash") or "", + int(entry.get("mtime") or 0), + int(entry.get("size") or 0), + ) + stats["updated"] += 1 + if entry.get("fast") and to_enrich is not None: + to_enrich.append(entry.get("filepath") or str(entry.get("file_path") or "")) + else: + stats["errors"] += 1 + continue + + if action == "added": + metadata_result = entry.get("metadata_result") + if not isinstance(metadata_result, Result): + stats["errors"] += 1 + continue + cache_store = bool(entry.get("cache_store")) + res = self._add_asset( + entry.get("filename") or "", + entry.get("subfolder") or "", + entry.get("filepath") or "", + entry.get("kind"), + int(entry.get("mtime") or 0), + int(entry.get("size") or 0), + entry.get("file_path"), + metadata_result, + source=source, + root_id=root_id, + write_metadata=True, + ) + if res.ok: + if cache_store: + try: + MetadataHelpers.store_metadata_cache( + self.db, + entry.get("filepath") or "", + entry.get("state_hash") or "", + metadata_result, + ) + except Exception: + pass + self._write_scan_journal_entry( + entry.get("filepath") or "", + base_dir, + entry.get("state_hash") or "", + int(entry.get("mtime") or 0), + int(entry.get("size") or 0), + ) + stats["added"] += 1 + if entry.get("fast") and to_enrich is not None: + to_enrich.append(entry.get("filepath") or str(entry.get("file_path") or "")) + else: + stats["errors"] += 1 + continue + + # Unknown action: count as skipped to be safe. + stats["skipped"] += 1 + + def _prepare_index_entry( + self, + file_path: Path, + base_dir: str, + incremental: bool, + existing_state: Optional[Dict[str, Any]] = None, + source: str = "output", + root_id: Optional[str] = None, + fast: bool = False, + ) -> Result[Dict[str, Any]]: + """ + Prepare indexing work for a file (stat + incremental decision + metadata extraction), + but do not write to the DB. DB writes are applied in a batch transaction. + """ + stat_result = self._stat_with_retry(file_path) + if not stat_result[0]: + return Result.Err("STAT_FAILED", f"Failed to stat file: {stat_result[1]}") + stat = stat_result[1] + mtime_ns = stat.st_mtime_ns + mtime = int(stat.st_mtime) + size = int(stat.st_size) + + filepath = str(file_path) + state_hash = self._compute_state_hash(filepath, mtime_ns, size) + + journal_state_hash = None + if isinstance(existing_state, dict) and "journal_state_hash" in existing_state: + journal_state_hash = existing_state.get("journal_state_hash") + elif incremental: + journal_entry = self._get_journal_entry(filepath) + journal_state_hash = journal_entry.get("state_hash") if isinstance(journal_entry, dict) else None + + if incremental and journal_state_hash and str(journal_state_hash) == state_hash: + return Result.Ok({"action": "skipped_journal"}) + + existing_asset = existing_state if isinstance(existing_state, dict) and existing_state.get("id") is not None else None + existing_id = 0 + existing_mtime = 0 + if existing_asset is not None: + try: + existing_id = int(existing_asset.get("id") or 0) + existing_mtime = int(existing_asset.get("mtime") or 0) + except Exception: + existing_id = 0 + existing_mtime = 0 + + # If incremental and unchanged, try cached metadata first. + if incremental and existing_id and existing_mtime == mtime: + cached_metadata = MetadataHelpers.retrieve_cached_metadata(self.db, filepath, state_hash) + if cached_metadata and cached_metadata.ok: + return Result.Ok( + { + "action": "refresh", + "asset_id": existing_id, + "metadata_result": cached_metadata, + "filepath": filepath, + "file_path": file_path, + "state_hash": state_hash, + "mtime": mtime, + "size": size, + "fast": fast, + "cache_store": False, + } + ) + + meta_row = self.db.query("SELECT 1 FROM asset_metadata WHERE asset_id = ? LIMIT 1", (existing_id,)) + if meta_row.ok and meta_row.data: + return Result.Ok({"action": "skipped"}) + + # Compute metadata only when needed. + cache_store = False + if fast: + metadata_result: Result[Dict[str, Any]] = Result.Ok({}) + else: + metadata_result = self.metadata.get_metadata(filepath, scan_id=self._current_scan_id) + if metadata_result.ok: + cache_store = True + if not metadata_result.ok: + metadata_result = MetadataHelpers.metadata_error_payload(metadata_result, filepath) + + # For unchanged files, this is a metadata refresh attempt. + if incremental and existing_id and existing_mtime == mtime: + return Result.Ok( + { + "action": "refresh", + "asset_id": existing_id, + "metadata_result": metadata_result, + "filepath": filepath, + "file_path": file_path, + "state_hash": state_hash, + "mtime": mtime, + "size": size, + "fast": fast, + "cache_store": cache_store, + } + ) + + # Calculate relative path components (needed for inserts). + rel_path = self._safe_relative_path(file_path, base_dir) + filename = file_path.name + subfolder = str(rel_path.parent) if rel_path.parent != Path(".") else "" + kind = classify_file(filename) + + if existing_id: + return Result.Ok( + { + "action": "updated", + "asset_id": existing_id, + "metadata_result": metadata_result, + "filepath": filepath, + "file_path": file_path, + "state_hash": state_hash, + "mtime": mtime, + "size": size, + "fast": fast, + "cache_store": cache_store, + } + ) + + return Result.Ok( + { + "action": "added", + "filename": filename, + "subfolder": subfolder, + "filepath": filepath, + "kind": kind, + "metadata_result": metadata_result, + "file_path": file_path, + "state_hash": state_hash, + "mtime": mtime, + "size": size, + "fast": fast, + "cache_store": cache_store, + } + ) + + def _collect_files(self, directory: Path, recursive: bool) -> List[Path]: + """ + Collect all asset files from directory. + + Args: + directory: Directory to scan + recursive: Scan subdirectories + + Returns: + List of file paths + """ + files = [] + + def _is_supported(p: Path) -> bool: + try: + ext = p.suffix.lower() + except Exception: + ext = "" + if ext and _EXT_TO_KIND: + return _EXT_TO_KIND.get(ext, "unknown") != "unknown" + return classify_file(str(p)) != "unknown" + + if recursive: + for root, _, filenames in os.walk(directory): + for filename in filenames: + # Avoid Path allocation + classify loops when possible. + try: + ext = os.path.splitext(filename)[1].lower() + except Exception: + ext = "" + if ext and _EXT_TO_KIND and _EXT_TO_KIND.get(ext, "unknown") == "unknown": + continue + file_path = Path(root) / filename + if _is_supported(file_path): + files.append(file_path) + else: + for item in directory.iterdir(): + if item.is_file() and _is_supported(item): + files.append(item) + + return files + + def _iter_files(self, directory: Path, recursive: bool): + """ + Generator to iterate over all asset files from directory (streaming). + + Args: + directory: Directory to scan + recursive: Scan subdirectories + + Yields: + File paths one by one + """ + def _is_supported(p: Path) -> bool: + try: + ext = p.suffix.lower() + except Exception: + ext = "" + if ext and _EXT_TO_KIND: + return _EXT_TO_KIND.get(ext, "unknown") != "unknown" + return classify_file(str(p)) != "unknown" + + if recursive: + for root, _, filenames in os.walk(directory): + for filename in filenames: + # Avoid Path allocation + classify loops when possible. + try: + ext = os.path.splitext(filename)[1].lower() + except Exception: + ext = "" + if ext and _EXT_TO_KIND and _EXT_TO_KIND.get(ext, "unknown") == "unknown": + continue + file_path = Path(root) / filename + if _is_supported(file_path): + yield file_path + else: + for item in directory.iterdir(): + if item.is_file() and _is_supported(item): + yield item + + def _chunk_file_batches(self, files: List[Path]) -> Iterable[List[Path]]: + """Yield batches of files for bounded transactions.""" + total = len(files) + if total <= 0: + return + + # Dynamic batch sizing: fewer transactions for large scans, but still bounded. + if total < int(SCAN_BATCH_SMALL_THRESHOLD): + batch_size = int(SCAN_BATCH_SMALL) + elif total < int(SCAN_BATCH_MED_THRESHOLD): + batch_size = int(SCAN_BATCH_MED) + elif total < int(SCAN_BATCH_LARGE_THRESHOLD): + batch_size = int(SCAN_BATCH_LARGE) + else: + batch_size = int(SCAN_BATCH_XL) + + batch_size = max(1, min(500, int(batch_size))) + + for start in range(0, total, batch_size): + yield files[start:start + batch_size] + + def _safe_relative_path(self, file_path: Path, base_dir: str) -> Path: + try: + return file_path.relative_to(base_dir) + except Exception: + try: + rel = os.path.relpath(str(file_path), base_dir) + return Path(rel) + except Exception: + logger.warning( + "Could not compute relative path for %s from %s; using absolute path", + str(file_path), + str(base_dir), + ) + return file_path + + def _compute_state_hash(self, filepath: str, mtime_ns: int, size: int) -> str: + parts = [ + filepath.encode("utf-8"), + str(mtime_ns).encode("utf-8"), + str(size).encode("utf-8"), + ] + h = hashlib.sha256() + for part in parts: + h.update(part) + h.update(b"\x00") + return h.hexdigest() + + def _get_journal_entry(self, filepath: str) -> Optional[Dict[str, Any]]: + result = self.db.query( + "SELECT state_hash FROM scan_journal WHERE filepath = ?", + (filepath,) + ) + if not result.ok or not result.data: + return None + return result.data[0] + + def _get_journal_entries(self, filepaths: List[str]) -> Dict[str, str]: + """ + Batch lookup scan_journal state_hash for a list of filepaths. + Returns {filepath: state_hash}. + """ + cleaned = [str(p) for p in (filepaths or []) if p] + if not cleaned: + return {} + if len(cleaned) > 5000: + cleaned = cleaned[:5000] + placeholders = ",".join(["?"] * len(cleaned)) + res = self.db.query( + f"SELECT filepath, state_hash FROM scan_journal WHERE filepath IN ({placeholders})", + tuple(cleaned), + ) + if not res.ok: + return {} + out: Dict[str, str] = {} + for row in res.data or []: + if not isinstance(row, dict): + continue + fp = row.get("filepath") + sh = row.get("state_hash") + if fp and sh: + out[str(fp)] = str(sh) + return out + + def _write_scan_journal_entry( + self, + filepath: str, + base_dir: str, + state_hash: str, + mtime: int, + size: int + ) -> Result[Any]: + dir_path = str(Path(base_dir).resolve()) + return self.db.execute( + """ + INSERT OR REPLACE INTO scan_journal + (filepath, dir_path, state_hash, mtime, size, last_seen) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + (filepath, dir_path, state_hash, mtime, size) + ) + + def _stat_with_retry(self, file_path: Path): + for attempt in range(3): + try: + stat = file_path.stat() + return True, stat + except OSError as exc: + if attempt < 2: + time.sleep(0.15 * (attempt + 1)) + continue + logger.warning("Failed to stat %s after retries: %s", file_path, exc) + return False, exc + + def _index_file( + self, + file_path: Path, + base_dir: str, + incremental: bool, + existing_state: Optional[Dict[str, Any]] = None, + source: str = "output", + root_id: Optional[str] = None, + fast: bool = False, + ) -> Result[Dict[str, str]]: + """ + Index a single file. + + Args: + file_path: File to index + base_dir: Base directory for relative path calculation + incremental: Skip if already indexed and unchanged + existing_state: Pre-fetched existing state + source: Source identifier + root_id: Root identifier + fast: Skip metadata extraction + + Returns: + Result with action taken (added, updated, skipped) + """ + stat_result = self._stat_with_retry(file_path) + if not stat_result[0]: + return Result.Err("STAT_FAILED", f"Failed to stat file: {stat_result[1]}") + stat = stat_result[1] + mtime_ns = stat.st_mtime_ns + mtime = int(stat.st_mtime) + size = stat.st_size + + # Compute file fingerprint before any caching or journal logic (prevents use-before-set bugs) + filepath = str(file_path) + state_hash = self._compute_state_hash(filepath, mtime_ns, size) + + # Skip unchanged files as early as possible (before running ExifTool/FFProbe). + journal_state_hash = None + if isinstance(existing_state, dict) and "journal_state_hash" in existing_state: + journal_state_hash = existing_state.get("journal_state_hash") + else: + journal_entry = self._get_journal_entry(filepath) + journal_state_hash = journal_entry.get("state_hash") if isinstance(journal_entry, dict) else None + + if incremental and journal_state_hash and str(journal_state_hash) == state_hash: + return Result.Ok({"action": "skipped_journal"}) + + # Check if file already exists (allow caller to prefetch to avoid N+1 queries) + existing_asset = None + if isinstance(existing_state, dict) and existing_state.get("id") is not None: + existing_asset = existing_state + else: + existing = self.db.query( + "SELECT id, mtime FROM assets WHERE filepath = ?", + (filepath,) + ) + + if existing.ok and existing.data and len(existing.data) > 0: + existing_asset = existing.data[0] + + if existing_asset is not None: + try: + existing_mtime = int(existing_asset.get("mtime") or 0) + existing_id = int(existing_asset.get("id") or 0) + except Exception: + existing_mtime = 0 + existing_id = 0 + + if incremental and existing_mtime == mtime and existing_id: + # Prefer cached metadata; it's cheap and avoids rerunning tools. + cached_metadata = MetadataHelpers.retrieve_cached_metadata(self.db, filepath, state_hash) + if cached_metadata and cached_metadata.ok: + refreshed = False + with self.db.transaction(mode="immediate"): + refreshed = MetadataHelpers.refresh_metadata_if_needed( + self.db, + existing_id, + cached_metadata, + filepath, + base_dir, + state_hash, + mtime, + size, + self._write_scan_journal_entry, + ) + if refreshed: + self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) + action = "skipped_refresh" if refreshed else "skipped" + return Result.Ok({"action": action}) + + # If we already have a metadata row, don't rerun tools just for an incremental pass. + meta_row = self.db.query("SELECT 1 FROM asset_metadata WHERE asset_id = ? LIMIT 1", (existing_id,)) + if meta_row.ok and meta_row.data: + return Result.Ok({"action": "skipped"}) + + # Compute metadata (may run tools) only when needed. + if fast: + metadata_result = Result.Ok({}) + else: + metadata_result = self.metadata.get_metadata(filepath, scan_id=self._current_scan_id) + if metadata_result.ok: + quality = metadata_result.meta.get("quality", "full") + if quality in ("degraded", "partial"): + logger.debug( + "Metadata extraction completed with degraded quality for %s: %s", + file_path, + quality, + ) + MetadataHelpers.store_metadata_cache(self.db, filepath, state_hash, metadata_result) + + if not metadata_result.ok: + if metadata_result.code == ErrorCode.FFPROBE_ERROR: + self._log_scan_event( + logging.WARNING, + "FFprobe error during metadata extraction", + filepath=str(file_path), + tool="ffprobe", + error=metadata_result.error, + code=metadata_result.code + ) + else: + self._log_scan_event( + logging.WARNING, + "Metadata extraction issue", + filepath=str(file_path), + error=metadata_result.error, + code=metadata_result.code + ) + metadata_result = MetadataHelpers.metadata_error_payload(metadata_result, str(file_path)) + + # Calculate relative path components + rel_path = self._safe_relative_path(file_path, base_dir) + filename = file_path.name + subfolder = str(rel_path.parent) if rel_path.parent != Path(".") else "" + kind = classify_file(filename) + + if existing_asset is not None: + # Skip if incremental and unchanged + try: + existing_mtime = int(existing_asset.get("mtime") or 0) + existing_id = int(existing_asset.get("id") or 0) + except Exception: + 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. + with self.db.transaction(mode="immediate"): + refreshed = 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: + self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) + action = "skipped_refresh" if refreshed else "skipped" + return Result.Ok({"action": action}) + + # Update existing asset + if existing_id: + with self.db.transaction(mode="immediate"): + result = self._update_asset( + existing_id, + file_path, + mtime, + size, + metadata_result, + source=source, + root_id=root_id, + write_metadata=(not fast), + ) + if result.ok: + self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) + return result + + # Add new asset + with self.db.transaction(mode="immediate"): + result = 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: + self._write_scan_journal_entry(filepath, base_dir, state_hash, mtime, size) + return result + + def _add_asset( + self, + filename: str, + subfolder: str, + filepath: str, + kind: FileKind, + mtime: int, + size: int, + file_path: Path, + metadata_result: Result[Dict[str, Any]], + source: str = "output", + root_id: Optional[str] = None, + write_metadata: bool = True, + ) -> Result[Dict[str, str]]: + """ + Add new asset to database. + """ + width = None + height = None + duration = None + + if metadata_result.ok and metadata_result.data: + meta = metadata_result.data + width = meta.get("width") + height = meta.get("height") + duration = meta.get("duration") + + # Insert into assets table (NO workflow fields here) + insert_result = self.db.execute( + """ + INSERT INTO assets + (filename, subfolder, filepath, source, root_id, kind, ext, width, height, duration, size, mtime) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + filename, + subfolder, + filepath, + str(source or "output"), + str(root_id) if root_id else None, + kind, + Path(filename).suffix.lower(), + width, + height, + duration, + size, + mtime, + ) + ) + + if not insert_result.ok: + return Result.Err("INSERT_FAILED", insert_result.error) + + 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: + metadata_write = MetadataHelpers.write_asset_metadata_row(self.db, asset_id, metadata_result) + 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"}) + + def _update_asset( + self, + asset_id: int, + file_path: Path, + mtime: int, + size: int, + metadata_result: Result[Dict[str, Any]], + source: str = "output", + root_id: Optional[str] = None, + write_metadata: bool = True, + ) -> Result[Dict[str, str]]: + """ + Update existing asset in database. + """ + width = None + height = None + duration = None + + if metadata_result.ok and metadata_result.data: + meta = metadata_result.data + width = meta.get("width") + height = meta.get("height") + duration = meta.get("duration") + + # Update assets table (NO workflow fields) + update_result = self.db.execute( + """ + UPDATE assets + SET width = COALESCE(?, width), + height = COALESCE(?, height), + duration = COALESCE(?, duration), + size = ?, mtime = ?, + source = ?, root_id = ?, + indexed_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (width, height, duration, size, mtime, str(source or "output"), str(root_id) if root_id else None, asset_id) + ) + + if not update_result.ok: + return Result.Err("UPDATE_FAILED", update_result.error) + + if write_metadata: + metadata_write = MetadataHelpers.write_asset_metadata_row(self.db, asset_id, metadata_result) + if not metadata_write.ok: + self._log_scan_event( + logging.WARNING, + "Failed to update metadata row", + asset_id=asset_id, + error=metadata_write.error, + stage="metadata_write" + ) + + return Result.Ok({"action": "updated"}) + + def _scan_context(self, **kwargs) -> Dict[str, Any]: + context = {"scan_id": self._current_scan_id} if self._current_scan_id else {} + context.update(kwargs) + return context + + def _log_scan_event(self, level: int, message: str, **context): + log_structured(logger, level, message, **self._scan_context(**context)) diff --git a/backend/features/index/searcher.py b/backend/features/index/searcher.py new file mode 100644 index 00000000..239ab017 --- /dev/null +++ b/backend/features/index/searcher.py @@ -0,0 +1,766 @@ +""" +Index searcher - handles asset search and retrieval operations. +""" +import json +import logging +import os +import re +from pathlib import Path +from typing import Optional, List, Dict, Any, Tuple + +from ...shared import get_logger, Result +from ...adapters.db.sqlite import Sqlite + + +logger = get_logger(__name__) + +MAX_SEARCH_QUERY_LENGTH = 512 +MAX_SEARCH_TOKENS = 16 +MAX_TOKEN_LENGTH = 64 + + +class IndexSearcher: + """ + Handles asset search and retrieval operations. + + Provides FTS5 full-text search, scoped search, single asset lookup, + and bulk filepath lookups for asset enrichment. + """ + + def __init__(self, db: Sqlite, has_tags_text_column: bool): + """ + Initialize index searcher. + + Args: + db: Database adapter instance + has_tags_text_column: Whether the tags_text column exists in asset_metadata + """ + self.db = db + self._has_tags_text_column = has_tags_text_column + + def search( + self, + query: str, + limit: int = 50, + offset: int = 0, + filters: Optional[Dict[str, Any]] = None, + include_total: bool = True, + ) -> Result[Dict[str, Any]]: + """ + Search assets using FTS5 full-text search, or browse all if query is '*'. + + Args: + query: Search query (FTS5 syntax supported, or '*' to browse all) + limit: Max results to return + offset: Pagination offset + filters: Optional filters (kind, rating, tags, etc.) + + Returns: + Result with search results and metadata + """ + if not query or not query.strip(): + return Result.Err("EMPTY_QUERY", "Search query cannot be empty") + + validation = self._validate_search_input(query) + if validation and not validation.ok: + return validation + + logger.debug(f"Searching for: {query} (limit={limit}, offset={offset})") + metadata_tags_text_clause = self._build_tags_text_clause() + + # Check if this is a "browse all" request + is_browse_all = query.strip() == "*" + + if is_browse_all: + # Browse all assets (no FTS, just regular query) + sql_parts = [ + f""" + SELECT + a.id, a.filename, a.subfolder, a.filepath, a.kind, + a.source, a.root_id, + a.width, a.height, a.duration, a.size, a.mtime, + COALESCE(m.rating, 0) as rating, + COALESCE(m.tags, '[]') as tags, + {metadata_tags_text_clause} COALESCE(m.has_workflow, 0) as has_workflow, + COALESCE(m.has_generation_data, 0) as has_generation_metadata + FROM assets a + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE 1=1 + """ + ] + params = [] + + # Add filters + if filters: + if "kind" in filters: + sql_parts.append("AND a.kind = ?") + params.append(filters["kind"]) + if "min_rating" in filters: + sql_parts.append("AND COALESCE(m.rating, 0) >= ?") + params.append(filters["min_rating"]) + if "has_workflow" in filters: + sql_parts.append("AND COALESCE(m.has_workflow, 0) = ?") + params.append(1 if filters["has_workflow"] else 0) + + # Order by most recent first + sql_parts.append("ORDER BY a.mtime DESC") + sql_parts.append("LIMIT ? OFFSET ?") + params.extend([limit, offset]) + + sql = " ".join(sql_parts) + result = self.db.query(sql, tuple(params)) + + if not result.ok: + return Result.Err("SEARCH_FAILED", result.error) + + rows = result.data + + total = None + if include_total: + # Get total count + count_sql = "SELECT COUNT(*) as total FROM assets a LEFT JOIN asset_metadata m ON a.id = m.asset_id WHERE 1=1" + count_params = [] + + if filters: + if "kind" in filters: + count_sql += " AND a.kind = ?" + count_params.append(filters["kind"]) + if "min_rating" in filters: + count_sql += " AND COALESCE(m.rating, 0) >= ?" + count_params.append(filters["min_rating"]) + if "has_workflow" in filters: + count_sql += " AND COALESCE(m.has_workflow, 0) = ?" + count_params.append(1 if filters["has_workflow"] else 0) + + count_result = self.db.query(count_sql, tuple(count_params)) + total = count_result.data[0]["total"] if count_result.ok and count_result.data else 0 + + else: + # FTS5 search + fts_query = self._sanitize_fts_query(query) + + sql_parts = [ + f""" + WITH matches AS ( + SELECT rowid AS asset_id, bm25(assets_fts) AS rank + FROM assets_fts + WHERE assets_fts MATCH ? + + UNION ALL + + SELECT rowid AS asset_id, (bm25(asset_metadata_fts) + 8.0) AS rank + FROM asset_metadata_fts + WHERE asset_metadata_fts MATCH ? + ), + best AS ( + SELECT asset_id, MIN(rank) AS rank + FROM matches + GROUP BY asset_id + ) + SELECT + a.id, a.filename, a.subfolder, a.filepath, a.kind, + a.source, a.root_id, + a.width, a.height, a.duration, a.size, a.mtime, + COALESCE(m.rating, 0) as rating, + COALESCE(m.tags, '[]') as tags, + {metadata_tags_text_clause} COALESCE(m.has_workflow, 0) as has_workflow, + COALESCE(m.has_generation_data, 0) as has_generation_metadata, + best.rank as rank + FROM best + JOIN assets a ON best.asset_id = a.id + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE 1=1 + """ + ] + + params = [fts_query, fts_query] + + # Add filters + if filters: + if "kind" in filters: + sql_parts.append("AND a.kind = ?") + params.append(filters["kind"]) + + if "min_rating" in filters: + sql_parts.append("AND COALESCE(m.rating, 0) >= ?") + params.append(filters["min_rating"]) + + if "has_workflow" in filters: + sql_parts.append("AND COALESCE(m.has_workflow, 0) = ?") + params.append(1 if filters["has_workflow"] else 0) + + # Add ordering and pagination + sql_parts.append("ORDER BY rank") + sql_parts.append("LIMIT ? OFFSET ?") + params.extend([limit, offset]) + + sql = " ".join(sql_parts) + + # Execute search + result = self.db.query(sql, tuple(params)) + + if not result.ok: + return Result.Err("SEARCH_FAILED", result.error) + + rows = result.data + + total = None + if include_total: + # Get total count (without pagination) + count_sql = """ + WITH matches AS ( + SELECT rowid AS asset_id + FROM assets_fts + WHERE assets_fts MATCH ? + + UNION + + SELECT rowid AS asset_id + FROM asset_metadata_fts + WHERE asset_metadata_fts MATCH ? + ) + SELECT COUNT(*) as total + FROM (SELECT DISTINCT asset_id FROM matches) t + JOIN assets a ON t.asset_id = a.id + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE 1=1 + """ + count_params = [fts_query, fts_query] + + if filters: + if "kind" in filters: + count_sql += " AND a.kind = ?" + count_params.append(filters["kind"]) + if "min_rating" in filters: + count_sql += " AND COALESCE(m.rating, 0) >= ?" + count_params.append(filters["min_rating"]) + if "has_workflow" in filters: + count_sql += " AND COALESCE(m.has_workflow, 0) = ?" + count_params.append(1 if filters["has_workflow"] else 0) + + count_result = self.db.query(count_sql, tuple(count_params)) + total = count_result.data[0]["total"] if count_result.ok and count_result.data else 0 + + # Format results + assets = [] + for row in rows: + asset = dict(row) + # Parse tags from JSON string + if asset.get("tags"): + try: + asset["tags"] = json.loads(asset["tags"]) + except (ValueError, json.JSONDecodeError, TypeError): + asset["tags"] = [] + else: + asset["tags"] = [] + asset.setdefault("tags_text", "") + + assets.append(asset) + + logger.debug("Found %s results (total=%s)", len(assets), total if include_total else "skipped") + + payload: Dict[str, Any] = {"assets": assets, "limit": limit, "offset": offset, "query": query} + payload["total"] = int(total or 0) if include_total else None + return Result.Ok(payload) + + def search_scoped( + self, + query: str, + roots: List[str], + limit: int = 50, + offset: int = 0, + filters: Optional[Dict[str, Any]] = None, + include_total: bool = True, + ) -> Result[Dict[str, Any]]: + """ + Search assets but restrict results to files whose absolute filepath is under one of the provided roots. + + This is used for UI scopes like Outputs / Inputs / All without breaking the existing DB structure. + """ + cleaned_roots: List[str] = [] + for r in roots or []: + if not r: + continue + try: + cleaned_roots.append(str(Path(r).resolve())) + except Exception: + continue + + if not cleaned_roots: + return Result.Err("INVALID_INPUT", "Missing or invalid roots") + + # Reuse the existing search logic but inject a filepath prefix constraint. + if not query or not query.strip(): + return Result.Err("EMPTY_QUERY", "Search query cannot be empty") + + validation = self._validate_search_input(query) + if validation and not validation.ok: + return validation + + logger.debug(f"Searching (scoped) for: {query} (limit={limit}, offset={offset}, roots={len(cleaned_roots)})") + metadata_tags_text_clause = self._build_tags_text_clause() + + def _escape_like_pattern(pattern: str) -> str: + """Escape LIKE special characters (% and _) for safe prefix matching.""" + return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def _roots_where() -> Tuple[str, List[Any]]: + parts = [] + params: List[Any] = [] + for root in cleaned_roots: + prefix = root.rstrip(os.sep) + os.sep + escaped_prefix = _escape_like_pattern(prefix) + parts.append("(a.filepath = ? OR a.filepath LIKE ? ESCAPE '\\')") + params.extend([root, f"{escaped_prefix}%"]) + return "(" + " OR ".join(parts) + ")", params + + is_browse_all = query.strip() == "*" + + roots_clause, roots_params = _roots_where() + + if is_browse_all: + sql_parts = [ + f""" + SELECT + a.id, a.filename, a.subfolder, a.filepath, a.kind, + a.width, a.height, a.duration, a.size, a.mtime, + COALESCE(m.rating, 0) as rating, + COALESCE(m.tags, '[]') as tags, +{metadata_tags_text_clause} COALESCE(m.has_workflow, 0) as has_workflow, + COALESCE(m.has_generation_data, 0) as has_generation_metadata + FROM assets a + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE {roots_clause} + """ + ] + params: List[Any] = [] + params.extend(roots_params) + + if filters: + if "kind" in filters: + sql_parts.append("AND a.kind = ?") + params.append(filters["kind"]) + if "min_rating" in filters: + sql_parts.append("AND COALESCE(m.rating, 0) >= ?") + params.append(filters["min_rating"]) + if "has_workflow" in filters: + sql_parts.append("AND COALESCE(m.has_workflow, 0) = ?") + params.append(1 if filters["has_workflow"] else 0) + + sql_parts.append("ORDER BY a.mtime DESC") + sql_parts.append("LIMIT ? OFFSET ?") + params.extend([limit, offset]) + + sql = " ".join(sql_parts) + result = self.db.query(sql, tuple(params)) + if not result.ok: + return Result.Err("SEARCH_FAILED", result.error) + + rows = result.data + + total = None + if include_total: + count_sql = f""" + SELECT COUNT(*) as total + FROM assets a + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE {roots_clause} + """ + count_params: List[Any] = [] + count_params.extend(roots_params) + + if filters: + if "kind" in filters: + count_sql += " AND a.kind = ?" + count_params.append(filters["kind"]) + if "min_rating" in filters: + count_sql += " AND COALESCE(m.rating, 0) >= ?" + count_params.append(filters["min_rating"]) + if "has_workflow" in filters: + count_sql += " AND COALESCE(m.has_workflow, 0) = ?" + count_params.append(1 if filters["has_workflow"] else 0) + + count_result = self.db.query(count_sql, tuple(count_params)) + total = count_result.data[0]["total"] if count_result.ok and count_result.data else 0 + else: + fts_query = self._sanitize_fts_query(query) + sql_parts = [ + f""" + WITH matches AS ( + SELECT rowid AS asset_id, bm25(assets_fts) AS rank + FROM assets_fts + WHERE assets_fts MATCH ? + + UNION ALL + + SELECT rowid AS asset_id, (bm25(asset_metadata_fts) + 8.0) AS rank + FROM asset_metadata_fts + WHERE asset_metadata_fts MATCH ? + ), + best AS ( + SELECT asset_id, MIN(rank) AS rank + FROM matches + GROUP BY asset_id + ) + SELECT + a.id, a.filename, a.subfolder, a.filepath, a.kind, + a.width, a.height, a.duration, a.size, a.mtime, + COALESCE(m.rating, 0) as rating, + COALESCE(m.tags, '[]') as tags, +{metadata_tags_text_clause} COALESCE(m.has_workflow, 0) as has_workflow, + COALESCE(m.has_generation_data, 0) as has_generation_metadata, + best.rank as rank + FROM best + JOIN assets a ON best.asset_id = a.id + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE {roots_clause} + """ + ] + + params: List[Any] = [fts_query, fts_query] + params.extend(roots_params) + + if filters: + if "kind" in filters: + sql_parts.append("AND a.kind = ?") + params.append(filters["kind"]) + if "min_rating" in filters: + sql_parts.append("AND COALESCE(m.rating, 0) >= ?") + params.append(filters["min_rating"]) + if "has_workflow" in filters: + sql_parts.append("AND COALESCE(m.has_workflow, 0) = ?") + params.append(1 if filters["has_workflow"] else 0) + + sql_parts.append("ORDER BY rank") + sql_parts.append("LIMIT ? OFFSET ?") + params.extend([limit, offset]) + + sql = " ".join(sql_parts) + result = self.db.query(sql, tuple(params)) + if not result.ok: + return Result.Err("SEARCH_FAILED", result.error) + + rows = result.data + + total = None + if include_total: + count_sql = f""" + WITH matches AS ( + SELECT rowid AS asset_id + FROM assets_fts + WHERE assets_fts MATCH ? + + UNION + + SELECT rowid AS asset_id + FROM asset_metadata_fts + WHERE asset_metadata_fts MATCH ? + ) + SELECT COUNT(*) as total + FROM (SELECT DISTINCT asset_id FROM matches) t + JOIN assets a ON t.asset_id = a.id + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE {roots_clause} + """ + count_params: List[Any] = [fts_query, fts_query] + count_params.extend(roots_params) + + if filters: + if "kind" in filters: + count_sql += " AND a.kind = ?" + count_params.append(filters["kind"]) + if "min_rating" in filters: + count_sql += " AND COALESCE(m.rating, 0) >= ?" + count_params.append(filters["min_rating"]) + if "has_workflow" in filters: + count_sql += " AND COALESCE(m.has_workflow, 0) = ?" + count_params.append(1 if filters["has_workflow"] else 0) + + count_result = self.db.query(count_sql, tuple(count_params)) + total = count_result.data[0]["total"] if count_result.ok and count_result.data else 0 + + assets = [] + for row in rows: + asset = dict(row) + if asset.get("tags"): + try: + asset["tags"] = json.loads(asset["tags"]) + except Exception: + asset["tags"] = [] + else: + asset["tags"] = [] + asset.setdefault("tags_text", "") + assets.append(asset) + + payload: Dict[str, Any] = {"assets": assets, "limit": limit, "offset": offset, "query": query} + payload["total"] = int(total or 0) if include_total else None + return Result.Ok(payload) + + def has_assets_under_root(self, root: str) -> Result[bool]: + """ + Return True if the DB contains at least one asset whose filepath is exactly `root` + or is nested under it (prefix match). + """ + try: + resolved = str(Path(root).resolve()) + except Exception as exc: + return Result.Err("INVALID_INPUT", f"Invalid root: {exc}") + + def _escape_like_pattern(pattern: str) -> str: + return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + prefix = resolved.rstrip(os.sep) + os.sep + escaped_prefix = _escape_like_pattern(prefix) + sql = """ + SELECT 1 + FROM assets a + WHERE (a.filepath = ? OR a.filepath LIKE ? ESCAPE '\\') + LIMIT 1 + """ + result = self.db.query(sql, (resolved, f"{escaped_prefix}%")) + if not result.ok: + return Result.Err("DB_ERROR", result.error) + return Result.Ok(bool(result.data)) + + def get_asset(self, asset_id: int) -> Result[Optional[Dict[str, Any]]]: + """ + Get a single asset by ID. + + Args: + asset_id: Asset database ID + + Returns: + Result with asset data or None if not found + """ + result = self.db.query( + """ + SELECT + a.*, + COALESCE(m.rating, 0) AS rating, + COALESCE(m.tags, '') AS tags, + COALESCE(m.tags_text, '') AS tags_text, + m.workflow_hash, + COALESCE(m.has_workflow, 0) AS has_workflow, + COALESCE(m.has_generation_data, 0) AS has_generation_data, + COALESCE(m.metadata_quality, 'none') AS metadata_quality, + COALESCE(m.metadata_raw, '{}') AS metadata_raw + FROM assets a + LEFT JOIN asset_metadata m ON m.asset_id = a.id + WHERE a.id = ? + """, + (asset_id,), + ) + + if not result.ok: + return Result.Err("QUERY_FAILED", result.error) + + if not result.data: + return Result.Ok(None) + + asset = self._hydrate_asset_payload(dict(result.data[0])) + return Result.Ok(asset) + + def get_assets(self, asset_ids: List[int]) -> Result[List[Dict[str, Any]]]: + """ + Batch fetch assets by ID (single query). + + Intended for UI batching (viewer preloading) without triggering per-asset metadata extraction. + """ + cleaned: List[int] = [] + seen = set() + for raw in asset_ids or []: + try: + n = int(raw) + except Exception: + continue + if n <= 0: + continue + if n in seen: + continue + seen.add(n) + cleaned.append(n) + if len(cleaned) >= 200: + break + + if not cleaned: + return Result.Ok([]) + + placeholders = ",".join(["?"] * len(cleaned)) + result = self.db.query( + f""" + SELECT + a.*, + COALESCE(m.rating, 0) AS rating, + COALESCE(m.tags, '') AS tags, + COALESCE(m.tags_text, '') AS tags_text, + m.workflow_hash, + COALESCE(m.has_workflow, 0) AS has_workflow, + COALESCE(m.has_generation_data, 0) AS has_generation_data, + COALESCE(m.metadata_quality, 'none') AS metadata_quality, + COALESCE(m.metadata_raw, '{{}}') AS metadata_raw + FROM assets a + LEFT JOIN asset_metadata m ON m.asset_id = a.id + WHERE a.id IN ({placeholders}) + """, + tuple(cleaned), + ) + + if not result.ok: + return Result.Err("QUERY_FAILED", result.error) + + by_id: Dict[int, Dict[str, Any]] = {} + for row in result.data or []: + try: + asset = self._hydrate_asset_payload(dict(row)) + except Exception: + continue + try: + aid = int(asset.get("id")) + except Exception: + continue + by_id[aid] = asset + + # Preserve requested ordering. + out: List[Dict[str, Any]] = [] + for aid in cleaned: + item = by_id.get(aid) + if item: + out.append(item) + return Result.Ok(out) + + def _hydrate_asset_payload(self, asset: Dict[str, Any]) -> Dict[str, Any]: + # Parse tags JSON (stored as string in DB) + tags_raw = asset.get("tags") or "" + if tags_raw: + try: + asset["tags"] = json.loads(tags_raw) + except (ValueError, json.JSONDecodeError, TypeError): + asset["tags"] = [] + else: + asset["tags"] = [] + + # Parse metadata_raw JSON and expose common top-level fields for the UI. + metadata_raw_text = asset.get("metadata_raw") or "{}" + try: + metadata_obj = json.loads(metadata_raw_text) if isinstance(metadata_raw_text, str) else metadata_raw_text + except (ValueError, json.JSONDecodeError, TypeError): + metadata_obj = {} + + asset["metadata_raw"] = metadata_obj + if isinstance(metadata_obj, dict): + # Common nested fields produced by our extractors + asset.setdefault("prompt", metadata_obj.get("prompt")) + asset.setdefault("workflow", metadata_obj.get("workflow")) + asset.setdefault("exif", metadata_obj.get("exif") or metadata_obj.get("raw")) + asset.setdefault("geninfo", metadata_obj.get("geninfo")) + + return asset + + def lookup_assets_by_filepaths(self, filepaths: List[str]) -> Result[Dict[str, Dict[str, Any]]]: + """ + Lookup DB-enriched asset fields for a set of absolute filepaths. + + This is used to enrich filesystem listings (input/custom) without requiring + a full directory scan on every request. + """ + cleaned = [str(p) for p in (filepaths or []) if p] + if not cleaned: + return Result.Ok({}) + # Guard against overly large IN clauses + if len(cleaned) > 5000: + cleaned = cleaned[:5000] + + placeholders = ",".join(["?"] * len(cleaned)) + result = self.db.query( + f""" + SELECT + a.filepath, + a.id, + a.source, + a.root_id, + COALESCE(m.rating, 0) as rating, + COALESCE(m.tags, '[]') as tags, + COALESCE(m.has_workflow, 0) as has_workflow, + COALESCE(m.has_generation_data, 0) as has_generation_metadata + FROM assets a + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE a.filepath IN ({placeholders}) + """, + tuple(cleaned), + ) + if not result.ok: + return Result.Err("DB_ERROR", result.error) + + out: Dict[str, Dict[str, Any]] = {} + for row in result.data or []: + if not isinstance(row, dict): + continue + fp = row.get("filepath") + if not fp: + continue + item = dict(row) + tags_raw = item.get("tags") + if tags_raw: + try: + item["tags"] = json.loads(tags_raw) if isinstance(tags_raw, str) else tags_raw + except Exception: + item["tags"] = [] + else: + item["tags"] = [] + out[str(fp)] = item + return Result.Ok(out) + + def _sanitize_fts_query(self, query: str) -> str: + """ + Escape special characters for FTS5 and collapse whitespace. + """ + text = query.strip() + if not text: + return "*" + + # Replace FTS5 special characters with spaces + sanitized = re.sub(r"[\"'\-:&/\\|;@#*~()\[\]{}]+", " ", text) + # Replace non-printable / control chars + sanitized = re.sub(r"[^\x20-\x7E]+", " ", sanitized) + # Collapse whitespace + sanitized = re.sub(r"\s+", " ", sanitized).strip() + return sanitized or "*" + + def _validate_search_input(self, query: str) -> Optional[Result[Any]]: + trimmed = query.strip() + # Allow "browse all" queries. + if trimmed == "*" or (trimmed and all(token == "*" for token in trimmed.split())): + return None + if len(trimmed) > MAX_SEARCH_QUERY_LENGTH: + return Result.Err( + "QUERY_TOO_LONG", + f"Search queries must be at most {MAX_SEARCH_QUERY_LENGTH} characters" + ) + + tokens = trimmed.split() + if len(tokens) > MAX_SEARCH_TOKENS: + return Result.Err( + "QUERY_TOO_COMPLEX", + f"Use at most {MAX_SEARCH_TOKENS} tokens for search queries" + ) + + if any(len(token) > MAX_TOKEN_LENGTH for token in tokens): + return Result.Err( + "TOKEN_TOO_LONG", + f"Each search token must be under {MAX_TOKEN_LENGTH} characters" + ) + + # Reject suspicious patterns (repeated wildcard tokens) + wildcard_hits = sum(1 for token in tokens if token == "*") + if wildcard_hits > 0 and wildcard_hits >= len(tokens) - 1: + return Result.Err( + "QUERY_TOO_GENERAL", + "Search query must contain at least one non-wildcard term" + ) + + return None + + def _build_tags_text_clause(self) -> str: + if self._has_tags_text_column: + return " COALESCE(m.tags_text, '') as tags_text,\n" + return "" diff --git a/backend/features/index/service.py b/backend/features/index/service.py new file mode 100644 index 00000000..a9be62d6 --- /dev/null +++ b/backend/features/index/service.py @@ -0,0 +1,381 @@ +""" +Index Service - file scanning, incremental updates, and FTS search. + +This service coordinates multiple specialized components: +- IndexScanner: Directory scanning and file indexing +- IndexSearcher: Search and retrieval operations +- AssetUpdater: Rating and tag updates +- MetadataEnricher: Background metadata enrichment +- MetadataHelpers: Shared metadata utilities +""" +import threading +import os +from pathlib import Path +from typing import Optional, List, Dict, Any + +from ...shared import get_logger, Result +from ...adapters.db.sqlite import Sqlite +from ...adapters.db.schema import table_has_column +from ..metadata import MetadataService +from ..geninfo.parser import parse_geninfo_from_prompt +from .scanner import IndexScanner +from .searcher import IndexSearcher +from .updater import AssetUpdater +from .enricher import MetadataEnricher +from .metadata_helpers import MetadataHelpers + + +logger = get_logger(__name__) + + +class IndexService: + """ + Handles file indexing and search operations. + + Features: + - Recursive directory scanning + - Incremental updates based on mtime + - FTS5 full-text search with BM25 ranking + - Asset metadata extraction integration + + This service acts as a coordinator, delegating to specialized components + for each area of functionality. + """ + + def __init__(self, db: Sqlite, metadata_service: MetadataService): + self.db = db + self.metadata = metadata_service + self._scan_lock = threading.Lock() + self._has_tags_text_column = table_has_column(self.db, "asset_metadata", "tags_text") + logger.debug("asset_metadata.tags_text column available: %s", self._has_tags_text_column) + + # Initialize specialized components + self._scanner = IndexScanner(db, metadata_service, self._scan_lock) + self._searcher = IndexSearcher(db, self._has_tags_text_column) + self._updater = AssetUpdater(db, self._has_tags_text_column) + self._enricher = MetadataEnricher( + db, + metadata_service, + self._scan_lock, + self._scanner._compute_state_hash, + MetadataHelpers.prepare_metadata_fields, + MetadataHelpers.metadata_error_payload, + ) + + # ==================== Scanning Operations ==================== + + def scan_directory( + self, + directory: str, + recursive: bool = True, + incremental: bool = True, + source: str = "output", + root_id: Optional[str] = None, + fast: bool = False, + background_metadata: bool = False, + ) -> Result[Dict[str, Any]]: + """ + Scan a directory for asset files. + + Args: + directory: Path to scan + recursive: Scan subdirectories + incremental: Only update changed files (based on mtime) + source: Source identifier for the scan + root_id: Root identifier for the scan + fast: Skip metadata extraction during scan + background_metadata: Enable background metadata enrichment + + Returns: + Result with scan statistics + """ + result = self._scanner.scan_directory( + directory, + recursive, + incremental, + source, + root_id, + fast, + background_metadata, + ) + + # Handle background enrichment if requested + if result.ok and fast and background_metadata: + to_enrich = result.data.get("to_enrich", []) + if to_enrich: + try: + self._enricher.start_enrichment(to_enrich) + except Exception as exc: + logger.debug("Background enrichment start skipped: %s", exc) + # Remove from stats as it's internal + result.data.pop("to_enrich", None) + + return result + + def index_paths( + self, + paths: List[Path], + base_dir: str, + incremental: bool = True, + source: str = "output", + root_id: Optional[str] = None, + ) -> Result[Dict[str, Any]]: + """ + Index a list of file paths (no directory scan). + + Args: + paths: List of file paths to index + base_dir: Base directory for relative path calculation + incremental: Skip if already indexed and unchanged + source: Source identifier for the index + root_id: Root identifier for the index + + Returns: + Result with indexing statistics + """ + return self._scanner.index_paths(paths, base_dir, incremental, source, root_id) + + # ==================== Search Operations ==================== + + def search( + self, + query: str, + limit: int = 50, + offset: int = 0, + filters: Optional[Dict[str, Any]] = None, + include_total: bool = True, + ) -> Result[Dict[str, Any]]: + """ + Search assets using FTS5 full-text search, or browse all if query is '*'. + + Args: + query: Search query (FTS5 syntax supported, or '*' to browse all) + limit: Max results to return + offset: Pagination offset + filters: Optional filters (kind, rating, tags, etc.) + + Returns: + Result with search results and metadata + """ + return self._searcher.search(query, limit, offset, filters, include_total=include_total) + + def search_scoped( + self, + query: str, + roots: List[str], + limit: int = 50, + offset: int = 0, + filters: Optional[Dict[str, Any]] = None, + include_total: bool = True, + ) -> Result[Dict[str, Any]]: + """ + Search assets but restrict results to files whose absolute filepath is under one of the provided roots. + + This is used for UI scopes like Outputs / Inputs / All without breaking the existing DB structure. + """ + return self._searcher.search_scoped(query, roots, limit, offset, filters, include_total=include_total) + + def has_assets_under_root(self, root: str) -> Result[bool]: + """ + Return True if the DB has at least one asset under the provided root. + """ + return self._searcher.has_assets_under_root(root) + + def get_asset(self, asset_id: int) -> Result[Optional[Dict[str, Any]]]: + """ + Get a single asset by ID. + + Args: + asset_id: Asset database ID + + Returns: + Result with asset data or None if not found + """ + result = self._searcher.get_asset(asset_id) + if not result.ok or not result.data: + return result + + asset = result.data + filepath = asset.get("filepath") + if not filepath or not isinstance(filepath, str): + return result + + try: + has_workflow = int(asset.get("has_workflow") or 0) + has_generation_data = int(asset.get("has_generation_data") or 0) + except (TypeError, ValueError): + has_workflow = 0 + has_generation_data = 0 + + # Opportunistic self-heal: if geninfo is missing but we already have a prompt graph, + # we can compute it without calling external tools. + geninfo = asset.get("geninfo") + prompt_graph = asset.get("prompt") + workflow = asset.get("workflow") + metadata_raw = asset.get("metadata_raw") + + did_update = False + + def _geninfo_score(value: Any) -> int: + if not isinstance(value, dict): + return 0 + score = 0 + if isinstance(value.get("positive"), dict) and value["positive"].get("value"): + score += 3 + if isinstance(value.get("negative"), dict) and value["negative"].get("value"): + score += 2 + models_obj = value.get("models") if isinstance(value.get("models"), dict) else None + if isinstance(models_obj, dict): + # Count "primary" models higher than accessory models (clip/vae). + if any(k in models_obj for k in ("checkpoint", "unet", "diffusion")): + score += 3 + if isinstance(value.get("clip"), dict) and value["clip"].get("name"): + score += 1 + if isinstance(value.get("vae"), dict) and value["vae"].get("name"): + score += 1 + if isinstance(value.get("checkpoint"), dict) and value["checkpoint"].get("name"): + score += 2 + if isinstance(value.get("loras"), list) and value["loras"]: + score += 1 + if isinstance(value.get("sampler"), dict) and value["sampler"].get("name"): + score += 1 + return score + + def _geninfo_is_incomplete(value: Any) -> bool: + if not isinstance(value, dict): + return True + # If we only have sampler-ish fields but no prompts or models, treat as incomplete. + has_prompt = isinstance(value.get("positive"), dict) and bool(value["positive"].get("value")) + has_negative = isinstance(value.get("negative"), dict) and bool(value["negative"].get("value")) + models_obj = value.get("models") if isinstance(value.get("models"), dict) else None + has_primary_model = isinstance(models_obj, dict) and any(k in models_obj for k in ("checkpoint", "unet", "diffusion")) + has_checkpoint = isinstance(value.get("checkpoint"), dict) and bool(value["checkpoint"].get("name")) + # If we don't have any prompt text AND we don't have a primary model id, + # it's likely a stale/partial geninfo and should be recomputed from the prompt graph. + return not (has_prompt or has_negative or has_primary_model or has_checkpoint) + + if isinstance(prompt_graph, dict) and (not geninfo or _geninfo_is_incomplete(geninfo)): + try: + gi_res = parse_geninfo_from_prompt(prompt_graph, workflow=workflow) + if gi_res.ok and gi_res.data: + if _geninfo_score(gi_res.data) <= _geninfo_score(geninfo): + # Keep existing geninfo if it's already as rich (or richer) than the new parse. + raise RuntimeError("Geninfo recompute did not improve richness") + meta_obj = metadata_raw if isinstance(metadata_raw, dict) else {} + meta_obj = dict(meta_obj) + meta_obj.setdefault("prompt", prompt_graph) + if workflow is not None: + meta_obj.setdefault("workflow", workflow) + meta_obj["geninfo"] = gi_res.data + + metadata_result = Result.Ok(meta_obj, quality=str(meta_obj.get("quality") or "partial")) + with self._scan_lock: + MetadataHelpers.write_asset_metadata_row(self.db, asset_id, metadata_result) + did_update = True + except Exception as exc: + logger.debug("Geninfo self-heal skipped for asset_id=%s: %s", asset_id, exc) + + # If we still don't have generation flags, try a targeted extraction (video sidecars, ffprobe tags, etc.). + if not did_update and (has_workflow == 0 and has_generation_data == 0): + try: + if os.path.exists(filepath) and os.path.isfile(filepath): + stat = os.stat(filepath) + mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) + size = int(stat.st_size) + state_hash = self._scanner._compute_state_hash(filepath, int(mtime_ns), int(size)) + + cached = None + try: + cached = MetadataHelpers.retrieve_cached_metadata(self.db, filepath, state_hash) + except Exception: + cached = None + + meta_res = cached or self.metadata.get_metadata(filepath, scan_id=None) + if meta_res and meta_res.ok and meta_res.data: + meta = meta_res.data + width = meta.get("width") + height = meta.get("height") + duration = meta.get("duration") + + try: + MetadataHelpers.store_metadata_cache(self.db, filepath, state_hash, meta_res) + except Exception as exc: + logger.debug("Metadata cache store skipped for asset_id=%s: %s", asset_id, exc) + + with self._scan_lock: + with self.db.transaction(mode="immediate"): + self.db.execute( + """ + UPDATE assets + SET width = COALESCE(?, width), + height = COALESCE(?, height), + duration = COALESCE(?, duration), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (width, height, duration, asset_id), + ) + MetadataHelpers.write_asset_metadata_row(self.db, asset_id, meta_res) + did_update = True + except Exception as exc: + logger.debug("Targeted metadata extraction skipped for asset_id=%s: %s", asset_id, exc) + + if did_update: + return self._searcher.get_asset(asset_id) + + return result + + def get_assets_batch(self, asset_ids: List[int]) -> Result[List[Dict[str, Any]]]: + """ + Batch fetch assets by ID (single query). + + This is used by the UI to reduce network chatter and avoid triggering per-asset self-heal + paths that may invoke external tools. + """ + return self._searcher.get_assets(asset_ids) + + def lookup_assets_by_filepaths(self, filepaths: List[str]) -> Result[Dict[str, Dict[str, Any]]]: + """ + Lookup DB-enriched asset fields for a set of absolute filepaths. + + This is used to enrich filesystem listings (input/custom) without requiring + a full directory scan on every request. + """ + return self._searcher.lookup_assets_by_filepaths(filepaths) + + # ==================== Update Operations ==================== + + def update_asset_rating(self, asset_id: int, rating: int) -> Result[Dict[str, Any]]: + """ + Update the rating for an asset. + + Args: + asset_id: Asset ID + rating: Rating value (0-5) + + Returns: + Result with updated asset info + """ + return self._updater.update_asset_rating(asset_id, rating) + + def update_asset_tags(self, asset_id: int, tags: List[str]) -> Result[Dict[str, Any]]: + """ + Update the tags for an asset. + + Args: + asset_id: Asset ID + tags: List of tag strings + + Returns: + Result with updated asset info + """ + return self._updater.update_asset_tags(asset_id, tags) + + def get_all_tags(self) -> Result[List[str]]: + """ + Get all unique tags from the database for autocomplete. + + Returns: + Result with list of unique tags sorted alphabetically + """ + return self._updater.get_all_tags() diff --git a/backend/features/index/updater.py b/backend/features/index/updater.py new file mode 100644 index 00000000..1a0412c6 --- /dev/null +++ b/backend/features/index/updater.py @@ -0,0 +1,164 @@ +""" +Asset updater - handles rating and tag updates. +""" +import json +from typing import List, Dict, Any + +from ...shared import Result +from ...adapters.db.sqlite import Sqlite + + +class AssetUpdater: + """ + Handles asset metadata updates (ratings and tags). + + Provides methods to update asset ratings and tags while maintaining + data integrity and handling the tags_text column for FTS5 indexing. + """ + + def __init__(self, db: Sqlite, has_tags_text_column: bool): + """ + Initialize asset updater. + + Args: + db: Database adapter instance + has_tags_text_column: Whether the tags_text column exists in asset_metadata + """ + self.db = db + self._has_tags_text_column = has_tags_text_column + + def update_asset_rating(self, asset_id: int, rating: int) -> Result[Dict[str, Any]]: + """ + Update the rating for an asset. + + Args: + asset_id: Asset ID + rating: Rating value (0-5) + + Returns: + Result with updated asset info + """ + rating = max(0, min(5, int(rating or 0))) + + # Ensure asset exists + check_result = self.db.query( + "SELECT id FROM assets WHERE id = ?", + (asset_id,) + ) + if not check_result.ok or not check_result.data or len(check_result.data) == 0: + return Result.Err("NOT_FOUND", f"Asset not found: {asset_id}") + + # Update or insert asset_metadata + result = self.db.execute( + """ + INSERT INTO asset_metadata (asset_id, rating, tags) + VALUES (?, ?, '[]') + ON CONFLICT(asset_id) DO UPDATE SET + rating = excluded.rating + """, + (asset_id, rating) + ) + + if not result.ok: + return Result.Err("UPDATE_FAILED", result.error) + + return Result.Ok({"asset_id": asset_id, "rating": rating}) + + def update_asset_tags(self, asset_id: int, tags: List[str]) -> Result[Dict[str, Any]]: + """ + Update the tags for an asset. + + Args: + asset_id: Asset ID + tags: List of tag strings + + Returns: + Result with updated asset info + """ + # Ensure asset exists + check_result = self.db.query( + "SELECT id FROM assets WHERE id = ?", + (asset_id,) + ) + if not check_result.ok or not check_result.data or len(check_result.data) == 0: + return Result.Err("NOT_FOUND", f"Asset not found: {asset_id}") + + # Sanitize tags + sanitized = [] + for tag in tags: + if not isinstance(tag, str): + continue + tag = str(tag).strip() + if not tag or len(tag) > 100: + continue + if tag not in sanitized: # Deduplicate + sanitized.append(tag) + + tags_json = json.dumps(sanitized, ensure_ascii=False) + + # Build tags_text for FTS5 (if column exists) + tags_text = " ".join(sanitized) if self._has_tags_text_column else None + + # Update or insert asset_metadata + if self._has_tags_text_column: + result = self.db.execute( + """ + INSERT INTO asset_metadata (asset_id, rating, tags, tags_text) + VALUES (?, 0, ?, ?) + ON CONFLICT(asset_id) DO UPDATE SET + tags = excluded.tags, + tags_text = excluded.tags_text + """, + (asset_id, tags_json, tags_text) + ) + else: + result = self.db.execute( + """ + INSERT INTO asset_metadata (asset_id, rating, tags) + VALUES (?, 0, ?) + ON CONFLICT(asset_id) DO UPDATE SET + tags = excluded.tags + """, + (asset_id, tags_json) + ) + + if not result.ok: + return Result.Err("UPDATE_FAILED", result.error) + + return Result.Ok({"asset_id": asset_id, "tags": sanitized}) + + def get_all_tags(self) -> Result[List[str]]: + """ + Get all unique tags from the database for autocomplete. + + Returns: + Result with list of unique tags sorted alphabetically + """ + result = self.db.query( + """ + SELECT DISTINCT tags + FROM asset_metadata + WHERE tags IS NOT NULL AND tags != '[]' + """ + ) + + if not result.ok: + return Result.Err("DB_ERROR", result.error) + + # Collect all unique tags + all_tags = set() + for row in result.data or []: + tags_json = row.get("tags") + if not tags_json: + continue + try: + tags = json.loads(tags_json) if isinstance(tags_json, str) else tags_json + if isinstance(tags, list): + for tag in tags: + if isinstance(tag, str) and tag.strip(): + all_tags.add(tag.strip()) + except Exception: + continue + + sorted_tags = sorted(all_tags) + return Result.Ok(sorted_tags) diff --git a/backend/features/index/watcher.py b/backend/features/index/watcher.py new file mode 100644 index 00000000..37e37350 --- /dev/null +++ b/backend/features/index/watcher.py @@ -0,0 +1,103 @@ +""" +Lightweight directory watcher that triggers incremental scans. +""" +import logging +import threading +from pathlib import Path +from typing import Iterable, Optional + +from .service import IndexService +from ...shared import get_logger +from ...config import WATCHER_INTERVAL_SECONDS, WATCHER_JOIN_TIMEOUT + +logger = get_logger(__name__) + + +class DirectoryWatcher: + """Polls directories and enqueues scans when changes may exist.""" + + def __init__( + self, + index_service: IndexService, + directories: Iterable[str], + interval_seconds: Optional[float] = None, + join_timeout: Optional[float] = None + ): + self.index_service = index_service + self.directories = [Path(dir_path).resolve() for dir_path in directories if dir_path] + self.interval = interval_seconds or WATCHER_INTERVAL_SECONDS + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + self._join_timeout = join_timeout if join_timeout is not None else WATCHER_JOIN_TIMEOUT + + def _create_thread(self) -> threading.Thread: + return threading.Thread(target=self._run, daemon=True) + + def start(self): + with self._lock: + if self._thread is not None and self._thread.is_alive(): + return + + # Allow restart on the same instance + self._stop_event.clear() + self._thread = self._create_thread() + self._thread.start() + + logger.info("File watcher started", extra={"directories": [str(d) for d in self.directories]}) + + def stop(self): + self._stop_event.set() + + with self._lock: + thread = self._thread + + if not thread: + return + + # Avoid deadlocking if stop is (unexpectedly) called from the watcher thread itself. + if thread is threading.current_thread(): + return + + try: + thread.join(timeout=self._join_timeout) + except Exception as exc: + logger.warning("Exception during watcher thread join: %s", exc) + return + + if thread.is_alive(): + logger.warning( + "File watcher thread did not stop within timeout (%.2fs). Thread will be abandoned as daemon.", + self._join_timeout, + ) + return + + with self._lock: + if self._thread is thread: + self._thread = None + logger.info("File watcher stopped successfully") + + def is_alive(self) -> bool: + with self._lock: + return bool(self._thread and self._thread.is_alive()) + + def _run(self): + while not self._stop_event.is_set(): + for directory in self.directories: + if self._stop_event.is_set(): + break + if not directory.exists(): + # Avoid busy-looping when watched path disappears + self._stop_event.wait(self.interval) + continue + + try: + scan_result = self.index_service.scan_directory(str(directory), recursive=True, incremental=True) + if not scan_result.ok: + logger.warning( + "Watcher scan reported issues", + extra={"directory": str(directory), "error": scan_result.error} + ) + except Exception as exc: + logger.warning("Watcher scan failed", extra={"directory": str(directory), "error": str(exc)}) + self._stop_event.wait(self.interval) diff --git a/backend/features/metadata/__init__.py b/backend/features/metadata/__init__.py new file mode 100644 index 00000000..aad59ec3 --- /dev/null +++ b/backend/features/metadata/__init__.py @@ -0,0 +1,10 @@ +"""Metadata extraction feature.""" +from .service import MetadataService +from .extractors import extract_png_metadata, extract_webp_metadata, extract_video_metadata + +__all__ = [ + "MetadataService", + "extract_png_metadata", + "extract_webp_metadata", + "extract_video_metadata" +] diff --git a/backend/features/metadata/extractors.py b/backend/features/metadata/extractors.py new file mode 100644 index 00000000..010dac4e --- /dev/null +++ b/backend/features/metadata/extractors.py @@ -0,0 +1,897 @@ +""" +Metadata extractors for different file types. +Extracts ComfyUI workflow and generation parameters from PNG, WEBP, MP4. +""" +import json +import os +import base64 +import re +import zlib +from pathlib import Path +from typing import Optional, Dict, Any, Tuple + +from ...shared import Result, ErrorCode, get_logger + +logger = get_logger(__name__) + +_BASE64_RE = re.compile(r"^[A-Za-z0-9+/=\s]+$") +_AUTO1111_KV_RE = re.compile(r"(?:^|,\s*)([^:,]+):\s*") + + +def _try_parse_json_text(text: str) -> Optional[Dict[str, Any]]: + """ + Parse JSON that may be embedded as: + - a dict JSON string + - a JSON-encoded string containing JSON (double-encoded) + - base64 (optionally zlib-compressed) JSON + + Returns dict if successful, else None. + """ + if not isinstance(text, str): + return None + raw = text.strip() + if not raw: + return None + + if raw.startswith("workflow:"): + raw = raw.split(":", 1)[1].strip() + if raw.startswith("prompt:"): + raw = raw.split(":", 1)[1].strip() + + if len(raw) > MAX_METADATA_JSON_SIZE: + logger.debug("Skipping oversized metadata JSON payload (%s bytes)", len(raw)) + return None + + def _loads_maybe(s: str) -> Optional[Dict[str, Any]]: + try: + parsed = json.loads(s) + except Exception: + return None + if isinstance(parsed, dict): + return parsed + # Some tools store JSON as a JSON string literal (double-encoded). + if isinstance(parsed, str): + try: + nested = json.loads(parsed) + except Exception: + return None + return nested if isinstance(nested, dict) else None + return None + + direct = _loads_maybe(raw) + if direct is not None: + return direct + + # Base64 (possibly zlib-compressed) JSON payload. + # Only attempt when the string looks like base64 and is reasonably sized. + if len(raw) < 80 or len(raw) > (MAX_METADATA_JSON_SIZE * 2): + return None + if not _BASE64_RE.match(raw): + return None + + try: + decoded = base64.b64decode(raw, validate=False) + except Exception: + return None + + # Detect zlib wrapper (common for compressed prompt/workflow payloads). + def _safe_zlib_decompress(data: bytes, limit: int) -> Optional[bytes]: + try: + decomp = zlib.decompressobj() + out = decomp.decompress(data, limit) + if len(out) >= limit: + return None + tail = decomp.flush() + if tail: + if len(out) + len(tail) > limit: + return None + out += tail + return out + except Exception: + return None + + if decoded.startswith(b"x\x9c") or decoded.startswith(b"x\xda"): + maybe = _safe_zlib_decompress(decoded, 5 * 1024 * 1024) + if maybe is not None: + decoded = maybe + + # Decode to text and try JSON parsing again. + try: + decoded_text = decoded.decode("utf-8", errors="strict").strip() + except Exception: + try: + decoded_text = decoded.decode("utf-8", errors="replace").strip() + except Exception: + return None + + if not decoded_text or len(decoded_text) > MAX_METADATA_JSON_SIZE: + return None + + return _loads_maybe(decoded_text) + +def _coerce_rating_to_stars(value: Any) -> Optional[int]: + """ + Normalize rating values to 0..5 stars. + + Accepts: + - 0..5 directly + - 0..100-ish "percent" (Windows SharedUserRating / RatingPercent) + - string numbers + """ + if value is None: + return None + try: + if isinstance(value, list) and value: + value = value[0] + if isinstance(value, str): + value = value.strip() + if not value: + return None + value = float(value.replace(",", ".")) + if isinstance(value, (int, float)): + v = float(value) + else: + return None + except Exception: + return None + + if v <= 5.0: + stars = int(round(v)) + return max(0, min(5, stars)) + + # Treat as percent-like + if v <= 0: + return 0 + if v >= 88: + return 5 + if v >= 63: + return 4 + if v >= 38: + return 3 + if v >= 13: + return 2 + return 1 + + +def _split_tags(text: str) -> list[str]: + raw = str(text or "").strip() + if not raw: + return [] + # Common separators: semicolon (Windows), comma (many tools), pipe/newlines. + parts = [] + for chunk in raw.replace("\r", "\n").replace("|", ";").split("\n"): + parts.extend(chunk.split(";")) + out: list[str] = [] + seen = set() + for p in parts: + for t in p.split(","): + tag = str(t).strip() + if not tag: + continue + if tag in seen: + continue + if len(tag) > 100: + continue + seen.add(tag) + out.append(tag) + return out + + +def _extract_rating_tags(exif_data: Optional[Dict]) -> tuple[Optional[int], list[str]]: + if not exif_data or not isinstance(exif_data, dict): + return (None, []) + + # ExifTool key normalization: + # - We run ExifTool with `-G1 -s`, which typically yields keys like `XMP-xmp:Rating` + # instead of the simpler `XMP:Rating`. + # - To avoid missing OS metadata (Windows Explorer stars/tags), normalize keys into + # a few predictable aliases and match against those. + # + # Examples: + # - `XMP-xmp:Rating` -> `xmp-xmp:rating`, `xmp:rating`, `rating` + # - `XMP-microsoft:RatingPercent` -> `xmp-microsoft:ratingpercent`, `microsoft:ratingpercent`, `ratingpercent` + def _build_norm_map(d: Dict[str, Any]) -> Dict[str, Any]: + out: Dict[str, Any] = {} + for k, v in d.items(): + try: + key = str(k) + except Exception: + continue + if not key: + continue + kl = key.strip().lower() + if not kl: + continue + out.setdefault(kl, v) + + if ":" in kl: + group, tag = kl.split(":", 1) + if tag: + out.setdefault(tag, v) + # Reduce group like `xmp-xmp` -> `xmp` + group_last = group.split("-")[-1] if group else "" + if group_last and group_last != group: + out.setdefault(f"{group_last}:{tag}", v) + return out + + norm = _build_norm_map(exif_data) + + rating_candidates = ( + # Common XMP rating keys (as seen with ExifTool -G1) + "xmp:rating", + "xmp-xmp:rating", + # Windows Explorer often maps stars into XMP-microsoft rating percent + "microsoft:ratingpercent", + "xmp-microsoft:ratingpercent", + # Windows Property System / legacy keys + "microsoft:shareduserrating", + "xmp-microsoft:shareduserrating", + # Generic tag-only fallbacks + "rating", + "ratingpercent", + "shareduserrating", + ) + rating: Optional[int] = None + for key in rating_candidates: + if key in norm: + rating = _coerce_rating_to_stars(norm.get(key)) + if rating is not None: + break + + tag_candidates = ( + # Common XMP subjects/keywords (ExifTool -G1) + "dc:subject", + "xmp-dc:subject", + "xmp:subject", + "iptc:keywords", + "photoshop:keywords", + "lr:hierarchicalsubject", + # Windows Explorer category/keywords + "microsoft:category", + "xmp-microsoft:category", + "xpkeywords", + # Generic tag-only fallbacks + "keywords", + "subject", + "category", + ) + tags: list[str] = [] + for key in tag_candidates: + if key not in norm: + continue + val = norm.get(key) + if val is None: + continue + if isinstance(val, list): + for item in val: + if item is None: + continue + tags.extend(_split_tags(str(item))) + else: + tags.extend(_split_tags(str(val))) + # De-dupe while keeping order + seen = set() + deduped: list[str] = [] + for t in tags: + if t in seen: + continue + seen.add(t) + deduped.append(t) + return (rating, deduped) + + +def extract_rating_tags_from_exif(exif_data: Optional[Dict]) -> tuple[Optional[int], list[str]]: + """ + Public wrapper for rating/tags extraction from ExifTool metadata dict. + + This is used by lightweight backends that only want rating/tags without parsing workflows. + """ + return _extract_rating_tags(exif_data) + +def _bump_quality(metadata: Dict[str, Any], quality: str) -> None: + """ + Upgrade metadata["quality"] without downgrading. + + Expected values: none < partial < full + """ + order = {"none": 0, "partial": 1, "full": 2} + try: + current = str(metadata.get("quality") or "none") + except Exception: + current = "none" + if order.get(quality, 0) > order.get(current, 0): + metadata["quality"] = quality + + +def _apply_common_exif_fields( + metadata: Dict[str, Any], + exif_data: Dict[str, Any], + *, + workflow: Optional[Dict[str, Any]] = None, + prompt: Optional[Dict[str, Any]] = None, +) -> None: + """ + Apply workflow/prompt + rating/tags into an existing metadata dict. + """ + wf = workflow + pr = prompt + if wf is None or pr is None: + scanned_workflow, scanned_prompt = _extract_json_fields(exif_data) + if wf is None: + wf = scanned_workflow + if pr is None: + pr = scanned_prompt + + if _looks_like_comfyui_workflow(wf): + metadata["workflow"] = wf + _bump_quality(metadata, "full") + + if _looks_like_comfyui_prompt_graph(pr): + metadata["prompt"] = pr + if str(metadata.get("quality") or "none") != "full": + _bump_quality(metadata, "partial") + + rating, tags = _extract_rating_tags(exif_data) + if rating is not None: + metadata["rating"] = rating + if tags: + metadata["tags"] = tags + + +def extract_png_metadata(file_path: str, exif_data: Optional[Dict] = None) -> Result[Dict[str, Any]]: + """ + Extract ComfyUI metadata from PNG file. + + PNG files store workflow in 'PNG:Parameters' field (Auto1111/Forge style) + or in custom tEXt/iTXt chunks. + + Args: + file_path: Path to PNG file + exif_data: Optional pre-fetched EXIF data from ExifTool + + Returns: + Result with extracted metadata (all raw data + interpreted fields) + """ + if not os.path.exists(file_path): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {file_path}") + + # Start with all raw EXIF data + metadata = { + "raw": exif_data or {}, + "workflow": None, + "prompt": None, + "parameters": None, + "quality": "none" + } + + if not exif_data: + return Result.Ok(metadata, quality="none") + + try: + # Check PNG:Parameters field (Auto1111/Forge) + png_params = exif_data.get("PNG:Parameters") + if png_params: + metadata["parameters"] = png_params + _bump_quality(metadata, "partial") + + # Try to parse generation parameters + parsed = _parse_auto1111_params(png_params) + if parsed: + metadata.update(parsed) # Add all parsed fields + _apply_common_exif_fields(metadata, exif_data) + + return Result.Ok(metadata, quality=metadata["quality"]) + + except Exception as e: + logger.warning(f"PNG metadata extraction error: {e}") + return Result.Err(ErrorCode.PARSE_ERROR, str(e), quality="degraded") + +def extract_webp_metadata(file_path: str, exif_data: Optional[Dict] = None) -> Result[Dict[str, Any]]: + """ + Extract ComfyUI metadata from WEBP file. + + WEBP files store workflow in EXIF:Make field as JSON string. + + Args: + file_path: Path to WEBP file + exif_data: Optional pre-fetched EXIF data from ExifTool + + Returns: + Result with extracted metadata (all raw data + interpreted fields) + """ + if not os.path.exists(file_path): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {file_path}") + + # Start with all raw EXIF data + metadata = { + "raw": exif_data or {}, + "workflow": None, + "prompt": None, + "quality": "none" + } + + if not exif_data: + return Result.Ok(metadata, quality="none") + + try: + # 1) ComfyUI JSON in WEBP is commonly stored in EXIF:Make / EXIF:Model + def _inspect_json_field(key_names, container): + for key in key_names: + value = container.get(key) if isinstance(container, dict) else None + parsed = _parse_json_value(value) + if parsed is not None: + return parsed + return None + + workflow = _inspect_json_field(["EXIF:Make", "IFD0:Make", "Keys:Workflow", "comfyui:workflow"], exif_data) + prompt = _inspect_json_field(["EXIF:Model", "IFD0:Model", "Keys:Prompt", "comfyui:prompt"], exif_data) + + # 2) Fallback: scan all tags for workflow/prompt JSON (same approach as video) + if not workflow or not prompt: + scanned_workflow, scanned_prompt = _extract_json_fields(exif_data) + if not workflow: + workflow = scanned_workflow + if not prompt: + prompt = scanned_prompt + + _apply_common_exif_fields(metadata, exif_data, workflow=workflow, prompt=prompt) + + # Look for Auto1111-style prompt/parameters text in additional EXIF fields + text_keys = [ + "EXIF:ImageDescription", + "IFD0:ImageDescription", + "EXIF:UserComment", + "IFD0:UserComment", + "EXIF:Comment", + "IFD0:Comment", + "EXIF:Subject", + "IFD0:Subject", + ] + + for key in text_keys: + candidate = exif_data.get(key) + if not candidate or not isinstance(candidate, str): + continue + + parsed = _parse_auto1111_params(candidate) + if not parsed: + continue + + metadata["parameters"] = candidate + metadata.update(parsed) + if metadata["quality"] != "full": + _bump_quality(metadata, "partial") + if not metadata.get("prompt") and parsed.get("prompt"): + metadata["prompt"] = parsed["prompt"] + break + + return Result.Ok(metadata, quality=metadata["quality"]) + + except Exception as e: + logger.warning(f"WEBP metadata extraction error: {e}") + return Result.Err(ErrorCode.PARSE_ERROR, str(e), quality="degraded") + +def extract_video_metadata(file_path: str, exif_data: Optional[Dict] = None, ffprobe_data: Optional[Dict] = None) -> Result[Dict[str, Any]]: + """ + Extract ComfyUI metadata from video file (MP4, MOV, WEBM, MKV). + + Video files store metadata in QuickTime:Workflow and QuickTime:Prompt fields. + + Args: + file_path: Path to video file + exif_data: Optional pre-fetched EXIF data from ExifTool + ffprobe_data: Optional pre-fetched ffprobe data + + Returns: + Result with extracted metadata (all raw data + interpreted fields) + """ + if not os.path.exists(file_path): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {file_path}") + + exif_data = exif_data or {} + # Start with all raw data + metadata = { + "raw": exif_data or {}, + "raw_ffprobe": ffprobe_data or {}, + "workflow": None, + "prompt": None, + "parameters": None, + "duration": None, + "resolution": None, + "fps": None, + "quality": "none" + } + + try: + # Extract video properties from ffprobe + if ffprobe_data: + video_stream = ffprobe_data.get("video_stream", {}) + format_info = ffprobe_data.get("format", {}) + + metadata["resolution"] = ( + video_stream.get("width"), + video_stream.get("height") + ) + metadata["fps"] = video_stream.get("r_frame_rate") + metadata["duration"] = format_info.get("duration") + + def _inspect_json_field(key_names, container): + for key in key_names: + value = container.get(key) if isinstance(container, dict) else None + parsed = _parse_json_value(value) + if parsed is not None: + return parsed + return None + + def _collect_text_candidates(container): + if not isinstance(container, dict): + return [] + out = [] + for k, v in container.items(): + if isinstance(v, str): + out.append((k, v)) + elif isinstance(v, (list, tuple)): + for item in v: + if isinstance(item, str): + out.append((k, item)) + return out + + # NOTE: Some encoders store JSON in ItemList:Comment that is NOT a ComfyUI workflow. + # We intentionally do not treat ItemList:Comment as a workflow source unless it + # clearly contains a workflow payload (see fallback scan below). + workflow = _inspect_json_field( + ["QuickTime:Workflow", "Keys:Workflow", "comfyui:workflow"], + exif_data + ) + prompt = _inspect_json_field( + ["QuickTime:Prompt", "Keys:Prompt", "comfyui:prompt"], + exif_data + ) + + # Fallback scan across all ExifTool tags, using shape-based heuristics. + if workflow is None or prompt is None: + scanned_workflow, scanned_prompt = _extract_json_fields(exif_data) + if workflow is None and _looks_like_comfyui_workflow(scanned_workflow): + workflow = scanned_workflow + if prompt is None and _looks_like_comfyui_prompt_graph(scanned_prompt): + prompt = scanned_prompt + + # Fallback: check ffprobe container tags (some encoders store metadata here). + format_tags = None + if isinstance(ffprobe_data, dict): + fmt = ffprobe_data.get("format") or {} + if isinstance(fmt, dict): + tags = fmt.get("tags") + if isinstance(tags, dict): + format_tags = tags + + if format_tags and (workflow is None or prompt is None): + scanned_workflow, scanned_prompt = _extract_json_fields(format_tags) + if workflow is None and _looks_like_comfyui_workflow(scanned_workflow): + workflow = scanned_workflow + if prompt is None and _looks_like_comfyui_prompt_graph(scanned_prompt): + prompt = scanned_prompt + + # VHS/other pipelines may store tags on the stream rather than container. + stream_tag_dicts = [] + try: + if isinstance(ffprobe_data, dict) and isinstance(ffprobe_data.get("streams"), list): + for s in ffprobe_data.get("streams") or []: + if not isinstance(s, dict): + continue + tags = s.get("tags") + if isinstance(tags, dict): + stream_tag_dicts.append(tags) + except Exception: + stream_tag_dicts = [] + + if stream_tag_dicts and (workflow is None or prompt is None): + for tags in stream_tag_dicts: + scanned_workflow, scanned_prompt = _extract_json_fields(tags) + if workflow is None and _looks_like_comfyui_workflow(scanned_workflow): + workflow = scanned_workflow + if prompt is None and _looks_like_comfyui_prompt_graph(scanned_prompt): + prompt = scanned_prompt + if workflow is not None and prompt is not None: + break + + # Auto1111-style "parameters" text can also appear in video tags (comment/description). + if metadata.get("parameters") is None: + candidates = _collect_text_candidates(exif_data) + if format_tags: + candidates.extend(_collect_text_candidates(format_tags)) + for tags in stream_tag_dicts or []: + candidates.extend(_collect_text_candidates(tags)) + for _, text in candidates: + parsed = _parse_auto1111_params(text) + if not parsed: + continue + metadata["parameters"] = text + # Keep prompt as a string-based fallback only if we don't have a prompt graph. + if prompt is None and parsed.get("prompt"): + metadata["prompt"] = parsed.get("prompt") + if parsed.get("negative_prompt"): + metadata["negative_prompt"] = parsed.get("negative_prompt") + if metadata["quality"] != "full": + _bump_quality(metadata, "partial") + break + + # NOTE: No sidecar fallback here (e.g. .json/.txt). We only trust embedded tags + # (ExifTool/FFprobe) for video generation metadata to avoid hidden filesystem coupling. + + _apply_common_exif_fields(metadata, exif_data, workflow=workflow, prompt=prompt) + + return Result.Ok(metadata, quality=metadata["quality"]) + + except Exception as e: + logger.warning(f"Video metadata extraction error: {e}") + return Result.Err(ErrorCode.PARSE_ERROR, str(e), quality="degraded") + +def _parse_auto1111_params(params_text: str) -> Optional[Dict[str, Any]]: + """ + Parse Auto1111/Forge parameters text. + + Format: + positive prompt + Negative prompt: negative text + Steps: 20, Sampler: DPM++ SDE, CFG scale: 7, Seed: 123, Size: 512x768, Model: model_name + + Args: + params_text: Parameters text string + + Returns: + Parsed parameters dict or None + """ + if not params_text: + return None + + try: + text = params_text.strip() + if not text: + return None + + result: Dict[str, Any] = {} + + remaining = "" + neg_marker = "Negative prompt:" + neg_idx = text.find(neg_marker) + if neg_idx != -1: + result["prompt"] = text[:neg_idx].strip() + after = text[neg_idx + len(neg_marker):].lstrip() + nl = after.find("\n") + if nl != -1: + result["negative_prompt"] = after[:nl].strip() + remaining = after[nl + 1:].strip() + else: + result["negative_prompt"] = after.strip() + remaining = "" + else: + # No negative prompt, find where parameter kvs start (usually at "Steps:"). + steps_idx = text.find("\nSteps:") + if steps_idx == -1: + steps_idx = text.find("Steps:") + if steps_idx != -1: + # Handle either "\nSteps:" or "Steps:" at start. + if text.startswith("Steps:"): + result["prompt"] = "" + remaining = text + else: + result["prompt"] = text[:steps_idx].strip() + remaining = text[steps_idx:].lstrip() + else: + result["prompt"] = text + remaining = "" + + if remaining: + matches = list(_AUTO1111_KV_RE.finditer(remaining)) + for i, match in enumerate(matches): + key = match.group(1).strip().lower().replace(" ", "_") + value_start = match.end() + value_end = matches[i + 1].start() if (i + 1) < len(matches) else len(remaining) + value = remaining[value_start:value_end].strip().strip(",").strip() + + if not key: + continue + + if key == "steps": + try: + result["steps"] = int(value) + except (ValueError, TypeError): + logger.debug("Invalid steps value: %s", value) + elif key == "sampler": + result["sampler"] = value + elif key in ("cfg_scale", "cfg"): + try: + result["cfg"] = float(value) + except (ValueError, TypeError): + logger.debug("Invalid cfg value: %s", value) + elif key == "seed": + try: + result["seed"] = int(value) + except (ValueError, TypeError): + logger.debug("Invalid seed value: %s", value) + elif key in ("size", "hires_resize"): + if "x" in value: + w, h = value.split("x", 1) + try: + result["width"] = int(w.strip()) + result["height"] = int(h.strip()) + except (ValueError, TypeError): + logger.debug("Invalid size value: %s", value) + elif key == "model": + result["model"] = value + elif key == "model_hash": + result["model_hash"] = value + + return result or None + + except Exception as e: + logger.warning(f"Failed to parse Auto1111 params: {e}") + return None + + +def _parse_json_value(value: Optional[str]) -> Optional[Dict[str, Any]]: + """Try to parse a JSON payload from a tag value, trimming known prefixes. + + ExifTool can return duplicate tags as arrays, so accept strings or lists of strings. + """ + candidates = [] + if isinstance(value, str): + candidates = [value] + elif isinstance(value, (list, tuple)): + candidates = [v for v in value if isinstance(v, str)] + else: + return None + + for raw in candidates: + parsed = _try_parse_json_text(raw) + if isinstance(parsed, dict): + return parsed + + return None + + +def _looks_like_comfyui_workflow(value: Optional[Dict[str, Any]]) -> bool: + """ + Heuristic check: only treat a JSON dict as a ComfyUI workflow if it matches + the expected graph structure (prevents false positives from generic JSON + comments/parameters). + """ + if not isinstance(value, dict): + return False + + nodes = value.get("nodes") + if not isinstance(nodes, list) or not nodes: + return False + + # Most ComfyUI workflows also contain 'links' as an array, but allow missing. + links = value.get("links") + if links is not None and not isinstance(links, list): + return False + + # Validate a few node fields on first N nodes. + sample = nodes[:5] + valid_nodes = 0 + for node in sample: + if not isinstance(node, dict): + continue + if "type" in node and "id" in node: + valid_nodes += 1 + continue + # Some exports use 'title' + 'id' + if "id" in node and ("title" in node or "outputs" in node or "inputs" in node): + valid_nodes += 1 + continue + + min_required = max(2, len(sample) // 2) # at least 50% or 2 nodes + return valid_nodes >= min_required + + +def _extract_json_fields(exif_data: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + """Scan EXIF/ffprobe metadata for workflow/prompt JSON fields.""" + workflow = None + prompt = None + + def _unwrap_workflow_prompt_container(container: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + """ + Some pipelines embed a wrapper object like: + {"workflow": {...}, "prompt": "{...json...}"} + inside a single tag (often `ItemList:Comment`). + + Only return values if they match ComfyUI shapes (prevents false positives). + """ + if not isinstance(container, dict): + return (None, None) + + wf = container.get("workflow") or container.get("Workflow") or None + pr = container.get("prompt") or container.get("Prompt") or None + + wf_out: Optional[Dict[str, Any]] = None + pr_out: Optional[Dict[str, Any]] = None + + if isinstance(wf, dict) and _looks_like_comfyui_workflow(wf): + wf_out = wf + + if isinstance(pr, dict) and _looks_like_comfyui_prompt_graph(pr): + pr_out = pr + elif isinstance(pr, str): + # Prompt can be a JSON string literal or base64/zlib. + parsed = _try_parse_json_text(pr) + if isinstance(parsed, dict) and _looks_like_comfyui_prompt_graph(parsed): + pr_out = parsed + + return (wf_out, pr_out) + + for key, value in exif_data.items(): + normalized = key.lower() if isinstance(key, str) else "" + parsed = _parse_json_value(value) + if not parsed: + continue + + # Wrapper object case (common for some VHS / VideoCombine exporters). + if workflow is None or prompt is None: + wf_candidate, pr_candidate = _unwrap_workflow_prompt_container(parsed) + if workflow is None and wf_candidate is not None: + workflow = wf_candidate + if prompt is None and pr_candidate is not None: + prompt = pr_candidate + if workflow and prompt: + break + + text = value.strip() if isinstance(value, str) else "" + lower_text = text.lower() + has_workflow_prefix = lower_text.startswith("workflow:") + has_prompt_prefix = lower_text.startswith("prompt:") + + # Be strict: only accept a "workflow" if the payload actually looks like a ComfyUI workflow export. + if workflow is None: + if has_workflow_prefix or "workflow" in normalized: + if _looks_like_comfyui_workflow(parsed): + workflow = parsed + elif _looks_like_comfyui_workflow(parsed): + workflow = parsed + + # Similarly, only accept a "prompt" if it looks like a ComfyUI prompt graph. + if prompt is None: + if has_prompt_prefix or "prompt" in normalized: + if _looks_like_comfyui_prompt_graph(parsed): + prompt = parsed + elif _looks_like_comfyui_prompt_graph(parsed): + prompt = parsed + + if workflow and prompt: + break + + return workflow, prompt + + +def _looks_like_comfyui_prompt_graph(value: Optional[Dict[str, Any]]) -> bool: + """ + Heuristic check for a ComfyUI prompt graph (the runtime `prompt` dict keyed by node id). + + Expected shape: + { "3": {"class_type": "...", "inputs": {...}}, ... } + """ + if not isinstance(value, dict) or not value: + return False + + # Avoid confusing workflow exports (they have `nodes: []`). + if "nodes" in value and isinstance(value.get("nodes"), list): + return False + + keys = list(value.keys())[:8] + digit_keys = 0 + valid_nodes = 0 + for k in keys: + if isinstance(k, str) and k.isdigit(): + digit_keys += 1 + node = value.get(k) + if not isinstance(node, dict): + continue + ct = node.get("class_type") or node.get("type") + ins = node.get("inputs") + if isinstance(ct, str) and isinstance(ins, dict): + valid_nodes += 1 + + return digit_keys >= max(2, len(keys) // 2) and valid_nodes >= max(2, len(keys) // 2) +MAX_METADATA_JSON_SIZE = 10 * 1024 * 1024 # 10 MB diff --git a/backend/features/metadata/service.py b/backend/features/metadata/service.py new file mode 100644 index 00000000..9fb2af0d --- /dev/null +++ b/backend/features/metadata/service.py @@ -0,0 +1,662 @@ +""" +Metadata service - coordinates metadata extraction from multiple sources. +""" +import logging +import os +import time +from pathlib import Path +from typing import Dict, Any, Optional + +from ...shared import Result, ErrorCode, get_logger, classify_file, log_structured +from ...adapters.tools import ExifTool, FFProbe +from .extractors import ( + extract_png_metadata, + extract_webp_metadata, + extract_video_metadata, + extract_rating_tags_from_exif, +) +from ..geninfo.parser import parse_geninfo_from_prompt + +logger = get_logger(__name__) + + +def _clean_model_name(value: Any) -> Optional[str]: + if value is None: + return None + s = str(value).strip() + if not s: + return None + s = s.replace("\\", "/").split("/")[-1] + lower = s.lower() + for ext in (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".json"): + if lower.endswith(ext): + return s[: -len(ext)] + return s + + +def _build_geninfo_from_parameters(meta: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Build a backend `geninfo` object from explicit A1111/Forge-style parameters data. + + Accuracy-first (no guessing): only uses fields already parsed by the extractor. + """ + if not isinstance(meta, dict): + return None + + pos = meta.get("prompt") + neg = meta.get("negative_prompt") + steps = meta.get("steps") + sampler = meta.get("sampler") + scheduler = meta.get("scheduler") + cfg = meta.get("cfg") + seed = meta.get("seed") + w = meta.get("width") + h = meta.get("height") + model = meta.get("model") + + has_any = any(v is not None and v != "" for v in (pos, neg, steps, sampler, cfg, seed, w, h, model)) + if not has_any: + return None + + out: Dict[str, Any] = {"engine": {"parser_version": "geninfo-params-v1", "source": "parameters"}} + + if isinstance(pos, str) and pos.strip(): + out["positive"] = {"value": pos.strip(), "confidence": "high", "source": "parameters"} + if isinstance(neg, str) and neg.strip(): + out["negative"] = {"value": neg.strip(), "confidence": "high", "source": "parameters"} + + if sampler is not None: + out["sampler"] = {"name": str(sampler), "confidence": "high", "source": "parameters"} + if scheduler is not None: + out["scheduler"] = {"name": str(scheduler), "confidence": "high", "source": "parameters"} + + if steps is not None: + try: + out["steps"] = {"value": int(steps), "confidence": "high", "source": "parameters"} + except Exception: + pass + if cfg is not None: + try: + out["cfg"] = {"value": float(cfg), "confidence": "high", "source": "parameters"} + except Exception: + pass + if seed is not None: + try: + out["seed"] = {"value": int(seed), "confidence": "high", "source": "parameters"} + except Exception: + pass + + if w is not None and h is not None: + try: + out["size"] = {"width": int(w), "height": int(h), "confidence": "high", "source": "parameters"} + except Exception: + pass + + ckpt = _clean_model_name(model) + if ckpt: + out["checkpoint"] = {"name": ckpt, "confidence": "high", "source": "parameters"} + out["models"] = {"checkpoint": out["checkpoint"]} + + return out if len(out.keys()) > 1 else None + + +def _looks_like_media_pipeline(prompt_graph: Any) -> bool: + """ + Detect "media-only" graphs (load video -> combine/save) that do not represent generation. + + Used to avoid marking such assets as having generation data. + """ + if not isinstance(prompt_graph, dict) or not prompt_graph: + return False + try: + types = [] + for node in prompt_graph.values(): + if not isinstance(node, dict): + continue + ct = str(node.get("class_type") or node.get("type") or "").lower() + if ct: + types.append(ct) + if not types: + return False + + # If any sampler-like node exists, it's not media-only. + if any(("ksampler" in t and "select" not in t) or ("samplercustom" in t) for t in types): + return False + if any("sampler" in t and "select" not in t for t in types): + return False + + has_load = any("loadvideo" in t or "vhs_loadvideo" in t for t in types) + has_combine = any("videocombine" in t or "video_combine" in t or "vhs_videocombine" in t for t in types) + has_save = any(t.startswith("save") or "savevideo" in t or "savegif" in t or "saveanimatedwebp" in t for t in types) + return has_load and (has_combine or has_save) + except Exception: + return False + +class MetadataService: + """ + Metadata extraction service. + + Orchestrates extraction from multiple tools (ExifTool, FFProbe) + and file-specific extractors. + """ + + def __init__(self, exiftool: ExifTool, ffprobe: FFProbe): + """ + Initialize metadata service. + + Args: + exiftool: ExifTool adapter instance + ffprobe: FFProbe adapter instance + """ + self.exiftool = exiftool + self.ffprobe = ffprobe + + def get_metadata(self, file_path: str, scan_id: Optional[str] = None) -> Result[Dict[str, Any]]: + """ + Extract metadata from file. + + Args: + file_path: Path to file + + Returns: + Result with metadata dict including: + - file_info: Basic file information + - exif: Raw EXIF data + - workflow: ComfyUI workflow (if present) + - prompt: Generation parameters + - quality: Metadata quality (full, partial, degraded, none) + """ + if not os.path.exists(file_path): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {file_path}") + + # Get file kind + kind = classify_file(file_path) + if kind == "unknown": + return Result.Err( + ErrorCode.UNSUPPORTED, + f"Unsupported file type: {file_path}", + quality="none" + ) + + logger.debug(f"Extracting metadata from {kind} file: {file_path} (scan_id={scan_id})") + + try: + # Dispatch to appropriate handler + if kind == "image": + return self._extract_image_metadata(file_path, scan_id=scan_id) + elif kind == "video": + return self._extract_video_metadata(file_path, scan_id=scan_id) + elif kind == "audio": + return self._extract_audio_metadata(file_path, scan_id=scan_id) + else: + return Result.Ok({ + "file_info": self._get_file_info(file_path), + "quality": "none" + }, quality="none") + + except Exception as e: + logger.error(f"Metadata extraction failed: {e}") + return Result.Err( + ErrorCode.PARSE_ERROR, + f"Metadata extraction failed: {e}", + quality="degraded" + ) + + def get_workflow_only(self, file_path: str, scan_id: Optional[str] = None) -> Result[Dict[str, Any]]: + """ + Fast path for extracting only embedded ComfyUI workflow/prompt (no ffprobe, no geninfo). + + Used for "drop on canvas" UX where we only need the workflow to load it into the graph. + """ + if not os.path.exists(file_path): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {file_path}", quality="none") + + kind = classify_file(file_path) + if kind not in ("image", "video"): + return Result.Ok({"workflow": None, "prompt": None, "quality": "none"}, quality="none") + + ext = os.path.splitext(file_path)[1].lower() + + exif_start = time.perf_counter() + exif_result = self.exiftool.read(file_path) + exif_duration = time.perf_counter() - exif_start + exif_data = exif_result.data if exif_result.ok else None + if not exif_result.ok: + self._log_metadata_issue( + logging.DEBUG, + "ExifTool failed while reading workflow-only metadata", + file_path, + scan_id=scan_id, + tool="exiftool", + error=exif_result.error, + duration_seconds=exif_duration, + ) + return Result.Ok({"workflow": None, "prompt": None, "quality": "none"}, quality="none") + + if kind == "image": + if ext == ".png": + res = extract_png_metadata(file_path, exif_data) + elif ext == ".webp": + res = extract_webp_metadata(file_path, exif_data) + else: + res = Result.Ok({"workflow": None, "prompt": None, "quality": "none"}, quality="none") + else: + # No ffprobe here (can be slow); extractor supports workflow/prompt from ExifTool tags. + res = extract_video_metadata(file_path, exif_data, ffprobe_data=None) + + if not res.ok: + return res + + data = res.data or {} + payload = { + "workflow": data.get("workflow"), + "prompt": data.get("prompt"), + "quality": data.get("quality", res.meta.get("quality", "none")), + } + return Result.Ok(payload, quality=payload.get("quality", "none")) + + def _extract_image_metadata(self, file_path: str, scan_id: Optional[str] = None) -> Result[Dict[str, Any]]: + """Extract metadata from image file.""" + ext = os.path.splitext(file_path)[1].lower() + + exif_start = time.perf_counter() + exif_result = self.exiftool.read(file_path) + exif_duration = time.perf_counter() - exif_start + exif_data = exif_result.data if exif_result.ok else None + if not exif_result.ok: + self._log_metadata_issue( + logging.WARNING, + "ExifTool failed while reading image metadata", + file_path, + scan_id=scan_id, + tool="exiftool", + error=exif_result.error, + duration_seconds=exif_duration + ) + return Result.Ok({ + "file_info": self._get_file_info(file_path), + "exif": None, + "quality": "none" + }, quality="none") + + # Extract based on format + if ext == ".png": + metadata_result = extract_png_metadata(file_path, exif_data) + elif ext == ".webp": + metadata_result = extract_webp_metadata(file_path, exif_data) + else: + # Generic image + metadata_result = Result.Ok({ + "workflow": None, + "prompt": None, + "quality": "partial" if exif_data else "none" + }) + + if not metadata_result.ok: + self._log_metadata_issue( + logging.WARNING, + "Image extractor failed", + file_path, + scan_id=scan_id, + tool="extractor", + error=metadata_result.error, + duration_seconds=exif_duration + ) + return metadata_result + + # Combine results + combined = { + "file_info": self._get_file_info(file_path), + "exif": exif_data, + **metadata_result.data + } + + # Optional: parse deterministic generation info from ComfyUI prompt graph (backend-first). + try: + prompt_graph = combined.get("prompt") + workflow = combined.get("workflow") + geninfo_res = parse_geninfo_from_prompt(prompt_graph, workflow=workflow) + if geninfo_res.ok and geninfo_res.data: + combined["geninfo"] = geninfo_res.data + elif "geninfo" not in combined: + gi = _build_geninfo_from_parameters(combined) + if gi: + combined["geninfo"] = gi + elif _looks_like_media_pipeline(prompt_graph): + combined["geninfo_status"] = {"kind": "media_pipeline", "reason": "no_sampler"} + except Exception as exc: + logger.debug(f"GenInfo parse skipped: {exc}") + + quality = metadata_result.meta.get("quality", "none") + return Result.Ok(combined, quality=quality) + + def _extract_video_metadata(self, file_path: str, scan_id: Optional[str] = None) -> Result[Dict[str, Any]]: + """Extract metadata from video file.""" + exif_start = time.perf_counter() + exif_result = self.exiftool.read(file_path) + exif_duration = time.perf_counter() - exif_start + exif_data = exif_result.data if exif_result.ok else None + if not exif_result.ok: + self._log_metadata_issue( + logging.WARNING, + "ExifTool failed while reading video metadata", + file_path, + scan_id=scan_id, + tool="exiftool", + error=exif_result.error, + duration_seconds=exif_duration + ) + exif_data = None + + ffprobe_start = time.perf_counter() + ffprobe_result = self.ffprobe.read(file_path) + ffprobe_duration = time.perf_counter() - ffprobe_start + if not ffprobe_result.ok: + self._log_metadata_issue( + logging.WARNING, + "FFprobe failed while reading video metadata", + file_path, + scan_id=scan_id, + tool="ffprobe", + error=ffprobe_result.error, + duration_seconds=ffprobe_duration + ) + ffprobe_data = None + else: + ffprobe_data = ffprobe_result.data + + # Extract video-specific metadata + metadata_result = extract_video_metadata(file_path, exif_data, ffprobe_data) + + if not metadata_result.ok: + self._log_metadata_issue( + logging.WARNING, + "Video extractor failed", + file_path, + scan_id=scan_id, + tool="extractor", + error=metadata_result.error, + duration_seconds=(exif_duration + ffprobe_duration) + ) + return metadata_result + + # Combine results + combined = { + "file_info": self._get_file_info(file_path), + "exif": exif_data, + "ffprobe": ffprobe_data, + **metadata_result.data + } + + try: + prompt_graph = combined.get("prompt") + workflow = combined.get("workflow") + geninfo_res = parse_geninfo_from_prompt(prompt_graph, workflow=workflow) + if geninfo_res.ok and geninfo_res.data: + combined["geninfo"] = geninfo_res.data + elif "geninfo" not in combined: + gi = _build_geninfo_from_parameters(combined) + if gi: + combined["geninfo"] = gi + elif _looks_like_media_pipeline(prompt_graph): + combined["geninfo_status"] = {"kind": "media_pipeline", "reason": "no_sampler"} + except Exception as exc: + logger.debug(f"GenInfo parse skipped: {exc}") + + quality = metadata_result.meta.get("quality", "none") + return Result.Ok(combined, quality=quality) + + def _extract_audio_metadata(self, file_path: str, scan_id: Optional[str] = None) -> Result[Dict[str, Any]]: + """Extract metadata from audio file.""" + exif_start = time.perf_counter() + exif_result = self.exiftool.read(file_path) + exif_duration = time.perf_counter() - exif_start + exif_data = exif_result.data if exif_result.ok else None + if not exif_result.ok: + self._log_metadata_issue( + logging.WARNING, + "ExifTool failed while reading audio metadata", + file_path, + scan_id=scan_id, + tool="exiftool", + error=exif_result.error, + duration_seconds=exif_duration + ) + return Result.Ok({ + "file_info": self._get_file_info(file_path), + "exif": None, + "quality": "none" + }, quality="none") + + return Result.Ok({ + "file_info": self._get_file_info(file_path), + "exif": exif_data, + "quality": "partial" if exif_data else "none" + }, quality="partial" if exif_data else "none") + + def get_metadata_batch(self, file_paths: list[str], scan_id: Optional[str] = None) -> Dict[str, Result[Dict[str, Any]]]: + """ + Extract metadata from multiple files in batch (much faster than individual calls). + + This uses batch ExifTool/FFProbe execution to dramatically reduce subprocess overhead. + + Args: + file_paths: List of file paths to process + scan_id: Optional scan ID for logging + + Returns: + Dict mapping file path to Result with metadata + """ + if not file_paths: + return {} + + from ...shared import classify_file + + # Group files by kind to optimize tool invocations + images = [] + videos = [] + audios = [] + others = [] + + for path in file_paths: + if not os.path.exists(path): + continue + kind = classify_file(path) + if kind == "image": + images.append(path) + elif kind == "video": + videos.append(path) + elif kind == "audio": + audios.append(path) + else: + others.append(path) + + results = {} + + # Batch ExifTool for all files that need it (images + videos + audio) + exif_paths = images + videos + audios + exif_results = {} + if exif_paths: + exif_results = self.exiftool.read_batch(exif_paths) + + # Batch FFProbe for videos only + ffprobe_results = {} + if videos: + ffprobe_results = self.ffprobe.read_batch(videos) + + # Process images + for path in images: + ext = os.path.splitext(path)[1].lower() + exif_data = exif_results.get(path, Result.Err(ErrorCode.EXIFTOOL_ERROR, "No result")).data if exif_results.get(path, Result.Err(ErrorCode.EXIFTOOL_ERROR, "No result")).ok else None + + if ext == ".png": + metadata_result = extract_png_metadata(path, exif_data) + elif ext == ".webp": + metadata_result = extract_webp_metadata(path, exif_data) + else: + metadata_result = Result.Ok({ + "workflow": None, + "prompt": None, + "quality": "partial" if exif_data else "none" + }) + + if metadata_result.ok: + combined = { + "file_info": self._get_file_info(path), + "exif": exif_data, + **metadata_result.data + } + # Optional geninfo parsing + try: + prompt_graph = combined.get("prompt") + workflow = combined.get("workflow") + geninfo_res = parse_geninfo_from_prompt(prompt_graph, workflow=workflow) + if geninfo_res.ok and geninfo_res.data: + combined["geninfo"] = geninfo_res.data + elif "geninfo" not in combined: + gi = _build_geninfo_from_parameters(combined) + if gi: + combined["geninfo"] = gi + elif _looks_like_media_pipeline(prompt_graph): + combined["geninfo_status"] = {"kind": "media_pipeline", "reason": "no_sampler"} + except Exception: + pass + + quality = metadata_result.meta.get("quality", "none") + results[path] = Result.Ok(combined, quality=quality) + else: + results[path] = metadata_result + + # Process videos + for path in videos: + exif_data = exif_results.get(path, Result.Err(ErrorCode.EXIFTOOL_ERROR, "No result")).data if exif_results.get(path, Result.Err(ErrorCode.EXIFTOOL_ERROR, "No result")).ok else None + ffprobe_data = ffprobe_results.get(path, Result.Err(ErrorCode.FFPROBE_ERROR, "No result")).data if ffprobe_results.get(path, Result.Err(ErrorCode.FFPROBE_ERROR, "No result")).ok else None + + metadata_result = extract_video_metadata(path, exif_data, ffprobe_data) + + if metadata_result.ok: + combined = { + "file_info": self._get_file_info(path), + "exif": exif_data, + "ffprobe": ffprobe_data, + **metadata_result.data + } + try: + prompt_graph = combined.get("prompt") + workflow = combined.get("workflow") + geninfo_res = parse_geninfo_from_prompt(prompt_graph, workflow=workflow) + if geninfo_res.ok and geninfo_res.data: + combined["geninfo"] = geninfo_res.data + elif "geninfo" not in combined: + gi = _build_geninfo_from_parameters(combined) + if gi: + combined["geninfo"] = gi + elif _looks_like_media_pipeline(prompt_graph): + combined["geninfo_status"] = {"kind": "media_pipeline", "reason": "no_sampler"} + except Exception: + pass + + quality = metadata_result.meta.get("quality", "none") + results[path] = Result.Ok(combined, quality=quality) + else: + results[path] = metadata_result + + # Process audio (ExifTool only) + for path in audios: + exif_data = exif_results.get(path, Result.Err(ErrorCode.EXIFTOOL_ERROR, "No result")).data if exif_results.get(path, Result.Err(ErrorCode.EXIFTOOL_ERROR, "No result")).ok else None + results[path] = Result.Ok({ + "file_info": self._get_file_info(path), + "exif": exif_data, + "quality": "partial" if exif_data else "none" + }, quality="partial" if exif_data else "none") + + # Process other files + for path in others: + results[path] = Result.Ok({ + "file_info": self._get_file_info(path), + "quality": "none" + }, quality="none") + + return results + + def extract_rating_tags_only(self, file_path: str, scan_id: Optional[str] = None) -> Result[Dict[str, Any]]: + """ + Lightweight extraction for rating/tags only (no workflow/geninfo parsing). + + Used to "hydrate" missing rating/tags from existing OS/file metadata without running a full scan. + """ + if not os.path.exists(file_path): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {file_path}", quality="none") + + exif_start = time.perf_counter() + # Read a narrow tag set for performance; extractor normalizes grouped keys. + tags = [ + # Ratings (XMP + Windows) + "XMP-xmp:Rating", + "XMP-microsoft:RatingPercent", + "Microsoft:SharedUserRating", + "Rating", + "RatingPercent", + # Tags/keywords + "XMP-dc:Subject", + "XMP:Subject", + "IPTC:Keywords", + "Keywords", + "XPKeywords", + "Microsoft:Category", + "Subject", + ] + exif_result = self.exiftool.read(file_path, tags=tags) + exif_duration = time.perf_counter() - exif_start + exif_data = exif_result.data if exif_result.ok else None + + if not exif_result.ok: + self._log_metadata_issue( + logging.DEBUG, + "ExifTool failed while reading rating/tags", + file_path, + scan_id=scan_id, + tool="exiftool", + error=exif_result.error, + duration_seconds=exif_duration, + ) + return Result.Ok({"rating": None, "tags": []}, quality="none") + + rating, tags_list = extract_rating_tags_from_exif(exif_data) + return Result.Ok({"rating": rating, "tags": tags_list}, quality="partial") + + def _log_metadata_issue( + self, + level: int, + message: str, + file_path: str, + scan_id: Optional[str] = None, + tool: Optional[str] = None, + error: Optional[str] = None, + duration_seconds: Optional[float] = None + ): + context = {"file_path": file_path} + if scan_id: + context["scan_id"] = scan_id + if tool: + context["tool"] = tool + if error: + context["error"] = error + if duration_seconds is not None: + try: + context["duration_seconds"] = round(float(duration_seconds), 3) + except Exception: + context["duration_seconds"] = duration_seconds + log_structured(logger, level, message, **context) + + def _get_file_info(self, file_path: str) -> Dict[str, Any]: + """Get basic file information.""" + stat = os.stat(file_path) + return { + "filename": os.path.basename(file_path), + "filepath": file_path, + "size": stat.st_size, + "mtime": stat.st_mtime, + "kind": classify_file(file_path), + "ext": os.path.splitext(file_path)[1].lower() + } diff --git a/backend/features/tags/__init__.py b/backend/features/tags/__init__.py new file mode 100644 index 00000000..d81c5a16 --- /dev/null +++ b/backend/features/tags/__init__.py @@ -0,0 +1,9 @@ +""" +Rating/Tags feature module. + +This module contains best-effort syncing of rating/tags to the filesystem +(sidecar JSON / ExifTool) without breaking the Result pattern. +""" + +from .sync import RatingTagsSyncWorker # noqa: F401 + diff --git a/backend/features/tags/sync.py b/backend/features/tags/sync.py new file mode 100644 index 00000000..42ee79f3 --- /dev/null +++ b/backend/features/tags/sync.py @@ -0,0 +1,351 @@ +""" +Best-effort rating/tags sync to the filesystem/OS (ExifTool + Windows Shell). + +Design goals: +- Never raise to the UI/request handler. +- Keep routes/payloads stable (sync controlled by request headers). +- Avoid blocking request path: run writes in a background worker. +""" + +from __future__ import annotations + +import os +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ...adapters.tools.exiftool import ExifTool +from ...shared import Result, ErrorCode, get_logger + +logger = get_logger(__name__) + + +def _windows_rating_percent(stars: int) -> int: + stars = max(0, min(5, int(stars or 0))) + return {0: 0, 1: 1, 2: 25, 3: 50, 4: 75, 5: 99}[stars] + + +def _normalize_tags(tags: List[str]) -> List[str]: + out: List[str] = [] + seen = set() + for t in tags or []: + if not isinstance(t, str): + continue + tag = t.strip() + if not tag or len(tag) > 100: + continue + if tag in seen: + continue + seen.add(tag) + out.append(tag) + return out + + +def write_exif_rating_tags(exiftool: ExifTool, file_path: str, rating: int, tags: List[str]) -> Result[bool]: + """ + Try writing rating/tags into the file metadata via ExifTool. + """ + try: + if not exiftool or not exiftool.is_available(): + return Result.Err(ErrorCode.TOOL_MISSING, "ExifTool not available") + except (AttributeError, TypeError, ValueError) as exc: + logger.debug("ExifTool availability check failed: %s", exc) + return Result.Err(ErrorCode.TOOL_MISSING, "ExifTool not available") + + try: + p = Path(str(file_path)) + except (OSError, TypeError, ValueError) as exc: + logger.debug("Invalid file path for rating/tags sync: %s", exc) + return Result.Err(ErrorCode.INVALID_INPUT, "Invalid file path") + if not p.exists() or not p.is_file(): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {file_path}") + + stars = max(0, min(5, int(rating or 0))) + tags_norm = _normalize_tags(tags) + joined = "; ".join(tags_norm) + + try: + original_mtime = None + try: + original_mtime = os.path.getmtime(str(p)) + except OSError: + original_mtime = None + + # Multi-namespace writes to maximize Windows Explorer compatibility. + # Arrays are written as repeated values by the ExifTool adapter. + payload: Dict[str, Any] = { + "XMP:Rating": stars, + "xmp:rating": stars, + "rating": stars, + "ratingpercent": _windows_rating_percent(stars), + # Windows Property System (best-effort; writable depends on container/handler) + "Microsoft:SharedUserRating": _windows_rating_percent(stars), + "Microsoft:Category": joined, + "XMP:Subject": tags_norm, + "IPTC:Keywords": tags_norm, + "XPKeywords": joined, + "Keywords": joined, + "Subject": joined, + } + + # Clear tags when explicitly empty. + if tags_norm == []: + payload["XMP:Subject"] = [] + payload["IPTC:Keywords"] = [] + payload["Microsoft:Category"] = "" + payload["XPKeywords"] = "" + payload["Keywords"] = "" + payload["Subject"] = "" + + res = exiftool.write(str(p), payload, preserve_workflow=True) + if not res.ok: + return Result.Err(res.code or ErrorCode.EXIFTOOL_ERROR, res.error or "ExifTool write failed") + + if original_mtime is not None: + try: + os.utime(str(p), (original_mtime, original_mtime)) + except OSError as exc: + logger.debug("Failed to restore mtime after ExifTool write: %s", exc) + + return Result.Ok(True) + except Exception as exc: + return Result.Err(ErrorCode.EXIFTOOL_ERROR, f"ExifTool write failed: {exc}") + + +@dataclass(frozen=True) +class RatingTagsSyncTask: + file_path: str + rating: int + tags: List[str] + mode: str # "off" | "on" | "exiftool" + + +_WIN32COM_CHECKED = False +_WIN32COM = None +_WIN_RATING_IDX: Optional[int] = None +_WIN_TAGS_IDX: Optional[int] = None +_RATING_KEYS = ("rating", "note", "notation", "evaluation", "star", "stars", "etoile", "etoiles") +_TAGS_KEYS = ("tag", "tags", "keywords", "keyword", "category", "categories", "categorie", "catÊgorie", "catÊgories") + + +def _safe_import_win32com(): + global _WIN32COM_CHECKED, _WIN32COM + if _WIN32COM_CHECKED: + return _WIN32COM + _WIN32COM_CHECKED = True + try: + import win32com.client # type: ignore + + _WIN32COM = win32com.client + return _WIN32COM + except ImportError: + _WIN32COM = None + return None + + +def _normalize_label(text: Any) -> str: + try: + return str(text or "").strip().lower() + except Exception: + return "" + + +def _resolve_shell_indices(folder) -> tuple[int, int]: + global _WIN_RATING_IDX, _WIN_TAGS_IDX + if _WIN_RATING_IDX is not None and _WIN_TAGS_IDX is not None: + return _WIN_RATING_IDX, _WIN_TAGS_IDX + + # Common Windows Explorer column indices; will be replaced by discovery below. + rating_idx = 14 + tags_idx = 21 + try: + for i in range(0, 256): + name = _normalize_label(folder.GetDetailsOf(None, i)) + if not name: + continue + if _WIN_RATING_IDX is None and any(k in name for k in _RATING_KEYS): + _WIN_RATING_IDX = i + if _WIN_TAGS_IDX is None and any(k in name for k in _TAGS_KEYS): + _WIN_TAGS_IDX = i + if _WIN_RATING_IDX is not None and _WIN_TAGS_IDX is not None: + break + except Exception as exc: + logger.debug("Windows shell column discovery failed (fallback indices will be used): %s", exc) + + if _WIN_RATING_IDX is None: + _WIN_RATING_IDX = rating_idx + if _WIN_TAGS_IDX is None: + _WIN_TAGS_IDX = tags_idx + return _WIN_RATING_IDX, _WIN_TAGS_IDX + + +def write_windows_rating_tags(file_path: str, rating: int, tags: List[str]) -> Result[bool]: + """ + Windows-only fallback using Shell.Application SetDetailsOf. + + This is best-effort and requires pywin32. It is used only when ExifTool is + unavailable or fails. + """ + if os.name != "nt": + return Result.Err(ErrorCode.UNSUPPORTED, "Windows metadata sync not supported on this OS") + + win32com = _safe_import_win32com() + if not win32com: + return Result.Err(ErrorCode.TOOL_MISSING, "pywin32 not installed (win32com unavailable)") + + try: + p = Path(str(file_path)) + except (OSError, TypeError, ValueError) as exc: + logger.debug("Invalid file path for Windows shell rating/tags sync: %s", exc) + return Result.Err(ErrorCode.INVALID_INPUT, "Invalid file path") + if not p.exists() or not p.is_file(): + return Result.Err(ErrorCode.NOT_FOUND, f"File not found: {file_path}") + + stars = max(0, min(5, int(rating or 0))) + rating_val = _windows_rating_percent(stars) + tags_norm = _normalize_tags(tags) + + original_mtime = None + try: + original_mtime = os.path.getmtime(str(p)) + except OSError: + original_mtime = None + + try: + pythoncom = None + try: + import pythoncom # type: ignore + + pythoncom.CoInitialize() + except Exception: + pythoncom = None + + try: + shell = win32com.Dispatch("Shell.Application") + folder = shell.Namespace(str(p.parent)) + item = folder.ParseName(p.name) + rating_idx, tags_idx = _resolve_shell_indices(folder) + + try: + folder.GetDetailsOf(item, rating_idx) + folder.SetDetailsOf(item, rating_idx, str(rating_val)) + except Exception as exc: + logger.debug("Windows shell rating write skipped: %s", exc) + + try: + folder.GetDetailsOf(item, tags_idx) + folder.SetDetailsOf(item, tags_idx, "; ".join(tags_norm) if tags_norm else "") + except Exception as exc: + logger.debug("Windows shell tags write skipped: %s", exc) + finally: + if pythoncom is not None: + try: + pythoncom.CoUninitialize() + except Exception as exc: + logger.debug("pythoncom.CoUninitialize failed: %s", exc) + + if original_mtime is not None: + try: + os.utime(str(p), (original_mtime, original_mtime)) + except OSError as exc: + logger.debug("Failed to restore mtime after Windows shell write: %s", exc) + + return Result.Ok(True) + except Exception as exc: + return Result.Err(ErrorCode.IO_ERROR, f"Windows metadata sync failed: {exc}") + + +class RatingTagsSyncWorker: + """ + A background worker that coalesces rating/tag writes per file path. + """ + + def __init__(self, exiftool: ExifTool): + self._exiftool = exiftool + self._lock = threading.Lock() + self._event = threading.Event() + self._pending: Dict[str, RatingTagsSyncTask] = {} + self._stop = False + self._thread = threading.Thread(target=self._run, name="mjr-rating-tags-sync", daemon=True) + self._thread.start() + + def stop(self, timeout: float = 2.0) -> None: + try: + self._stop = True + self._event.set() + self._thread.join(timeout=timeout) + except Exception as exc: + logger.debug("RatingTagsSyncWorker stop failed: %s", exc) + + def enqueue(self, file_path: str, rating: int, tags: List[str], mode: str) -> None: + mode_norm = str(mode or "off").strip().lower() + # Backward compatible: "sidecar"/"both" now map to "on" (no sidecar writes). + if mode_norm in ("sidecar", "both", "true", "1", "yes", "enabled", "enable", "on"): + mode_norm = "on" + if mode_norm not in ("off", "on", "exiftool"): + mode_norm = "off" + try: + task = RatingTagsSyncTask(str(file_path), int(rating or 0), list(tags or []), mode_norm) + except (TypeError, ValueError): + return + with self._lock: + self._pending[task.file_path] = task + self._event.set() + + def _drain_one(self) -> Optional[RatingTagsSyncTask]: + with self._lock: + if not self._pending: + self._event.clear() + return None + # pop an arbitrary item (dict is insertion-ordered; popitem() pops last) + _, task = self._pending.popitem() + if not self._pending: + self._event.clear() + return task + + def _run(self) -> None: + while not self._stop: + self._event.wait(timeout=0.5) + if self._stop: + break + + task = self._drain_one() + if not task: + continue + + try: + self._process(task) + except Exception as exc: + # Never let background errors crash ComfyUI. + logger.debug("RatingTagsSyncWorker task failed: %s", exc) + + def _process(self, task: RatingTagsSyncTask) -> None: + mode = task.mode + if mode == "off": + return + + tags_norm = _normalize_tags(task.tags) + rating = max(0, min(5, int(task.rating or 0))) + + # Prefer ExifTool (cross-platform), then Windows Shell fallback (Windows-only). + if mode in ("on", "exiftool"): + try: + ex = write_exif_rating_tags(self._exiftool, task.file_path, rating, tags_norm) + if ex.ok: + time.sleep(0.01) + return + logger.debug("ExifTool rating/tags write skipped: %s", ex.error) + except Exception as exc: + logger.debug("ExifTool rating/tags write failed: %s", exc) + + try: + win = write_windows_rating_tags(task.file_path, rating, tags_norm) + if not win.ok: + logger.debug("Windows rating/tags write skipped: %s", win.error) + except Exception as exc: + logger.debug("Windows rating/tags write failed: %s", exc) + + # Small delay to prevent hammering exiftool when a batch update happens. + time.sleep(0.01) diff --git a/backend/observability.py b/backend/observability.py new file mode 100644 index 00000000..3b21c711 --- /dev/null +++ b/backend/observability.py @@ -0,0 +1,243 @@ +""" +Observability helpers (request id + timing) for aiohttp routes. + +This is designed to be safe in ComfyUI's PromptServer environment and also work +in unit tests where we create our own aiohttp app. +""" + +from __future__ import annotations + +import time +import os +import threading +from typing import Any, Dict, Optional +from uuid import uuid4 + +from aiohttp import web + +from .shared import get_logger + +logger = get_logger(__name__) + +_APPKEY_OBS_INSTALLED = web.AppKey("mjr_observability_installed", bool) + +# Prevent log spam when a client polls rapidly or an endpoint repeatedly errors (e.g. 404 loop). +# This rate limiter is intentionally simple and process-local. +_LOG_RATELIMIT_LOCK = threading.Lock() +_LOG_RATELIMIT_STATE: Dict[str, float] = {} +_LOG_RATELIMIT_CLEAN_AT = 0.0 + + +def _new_request_id() -> str: + return uuid4().hex + + +def _get_request_id(request: web.Request) -> str: + rid = (request.headers.get("X-Request-ID") or "").strip() + return rid or _new_request_id() + + +def _env_flag(name: str, default: bool = False) -> bool: + try: + raw = os.environ.get(name) + except Exception: + raw = None + if raw is None: + return default + value = str(raw).strip().lower() + return value in ("1", "true", "yes", "on") + + +def _env_float(name: str, default: float) -> float: + try: + raw = os.environ.get(name) + except Exception: + raw = None + if raw is None or str(raw).strip() == "": + return float(default) + try: + return float(raw) + except Exception: + return float(default) + + +def _should_emit_log(key: str, *, window_ms: float) -> bool: + """ + Return True if this log key should be emitted now (best-effort rate limit). + """ + global _LOG_RATELIMIT_CLEAN_AT + now = time.monotonic() * 1000.0 + try: + with _LOG_RATELIMIT_LOCK: + last = _LOG_RATELIMIT_STATE.get(key, 0.0) + if last and now - last < window_ms: + return False + _LOG_RATELIMIT_STATE[key] = now + + # Periodic cleanup to avoid unbounded growth. + if not _LOG_RATELIMIT_CLEAN_AT or now - _LOG_RATELIMIT_CLEAN_AT > 60_000: + cutoff = now - max(window_ms * 4.0, 120_000.0) + for k, ts in list(_LOG_RATELIMIT_STATE.items()): + if ts < cutoff: + _LOG_RATELIMIT_STATE.pop(k, None) + _LOG_RATELIMIT_CLEAN_AT = now + except Exception: + # If rate limiting fails for any reason, fall back to logging. + return True + return True + + +def _should_log(request: web.Request, *, status: Optional[int], duration_ms: float) -> bool: + # Only log Majoor endpoints by default to avoid spamming ComfyUI logs. + try: + path = request.path or "" + except Exception: + path = "" + + if not path.startswith("/mjr/am/"): + return False + + # Client-side control (e.g. ComfyUI settings toggle). + try: + client_flag = (request.headers.get("X-MJR-OBS") or "").strip().lower() + except Exception: + client_flag = "" + if client_flag in ("0", "false", "off", "disable", "disabled", "no"): + return False + + client_explicit_on = client_flag in ("1", "true", "on", "enable", "enabled", "yes") + + # Allow opt-in verbose logging + if _env_flag("MJR_OBS_LOG_ALL", default=False): + return True + + # Health polling can be extremely chatty; never log successful polls by default. + if path in ("/mjr/am/health", "/mjr/am/health/counters"): + if status is not None and status >= 400: + return True + return False + + # Always log errors (unless client explicitly opted out above). + if status is not None and status >= 400: + return True + + # By default, do NOT log successes (otherwise polling endpoints spam). + if not _env_flag("MJR_OBS_LOG_SUCCESS", default=False): + return False + + # If the client did not explicitly enable observability, keep successful requests quiet. + if not client_explicit_on: + return False + + # Optional: log slow successful requests (threshold configurable). + if not _env_flag("MJR_OBS_LOG_SLOW", default=False): + return False + + slow_ms = _env_float("MJR_OBS_SLOW_MS", 750.0) + return duration_ms >= slow_ms + + +@web.middleware +async def request_context_middleware(request: web.Request, handler): + if _env_flag("MJR_OBS_DISABLE", default=False): + return await handler(request) + + rid = _get_request_id(request) + request["mjr_request_id"] = rid + start = time.perf_counter() + status: Optional[int] = None + error: Optional[str] = None + error_type: Optional[str] = None + try: + response = await handler(request) + try: + status = int(getattr(response, "status", 200) or 200) + except Exception: + status = 200 + response.headers["X-Request-ID"] = rid + return response + except web.HTTPException as exc: + try: + status = int(getattr(exc, "status", 500) or 500) + except Exception: + status = 500 + error_type = exc.__class__.__name__ + try: + error = str(exc) + except Exception: + error = "HTTPException" + try: + exc.headers["X-Request-ID"] = rid + except (AttributeError, TypeError, KeyError) as exc2: + logger.debug("Unable to attach X-Request-ID to HTTPException: %s", exc2) + raise + except Exception as exc: + status = 500 + error_type = exc.__class__.__name__ + try: + error = str(exc) + except Exception: + error = "Unhandled error" + raise + finally: + duration_ms = (time.perf_counter() - start) * 1000.0 + request["mjr_duration_ms"] = duration_ms + try: + fields = build_request_log_fields(request, response_status=status) + if error: + fields["error"] = error + fields["error_type"] = error_type + + if _should_log(request, status=status, duration_ms=duration_ms): + # Rate-limit repetitive logs (especially error loops / polling). + window_ms = _env_float("MJR_OBS_RATELIMIT_MS", 2000.0) + try: + key = f"{request.method}:{request.path}:{status}" + except Exception: + key = f"unknown:{status}" + + should_emit = _should_emit_log(key, window_ms=window_ms) + if should_emit: + if status is not None and status >= 500: + logger.error("Request handled", extra=fields) + elif status is not None and status >= 400: + logger.warning("Request handled", extra=fields) + else: + logger.info("Request handled", extra=fields) + except Exception as exc: + # Never let logging failures interfere with the request. + try: + logger.debug("Observability log emit failed: %s", exc) + except Exception: + _ = None + + +def ensure_observability(app: web.Application) -> None: + """ + Install middleware once. + """ + try: + if app.get(_APPKEY_OBS_INSTALLED): + return + app[_APPKEY_OBS_INSTALLED] = True + except Exception: + return + + try: + app.middlewares.append(request_context_middleware) + except Exception as exc: + logger.debug("Failed to install observability middleware: %s", exc) + + +def build_request_log_fields(request: web.Request, response_status: Optional[int] = None) -> Dict[str, Any]: + stats = request.get("mjr_stats") + return { + "request_id": request.get("mjr_request_id"), + "method": request.method, + "path": request.path, + "query": dict(request.query), + "status": response_status, + "duration_ms": request.get("mjr_duration_ms"), + "remote": getattr(request, "remote", None), + "stats": stats if isinstance(stats, dict) else None, + } diff --git a/backend/probe_router.py b/backend/probe_router.py new file mode 100644 index 00000000..71b85c3a --- /dev/null +++ b/backend/probe_router.py @@ -0,0 +1,103 @@ +""" +ProbeRouter: Decides which metadata extraction tools to use based on settings. +""" +from typing import List +from pathlib import Path + +from backend.config import MEDIA_PROBE_BACKEND +from backend.tool_detect import has_exiftool, has_ffprobe +from backend.shared import get_logger + +logger = get_logger(__name__) + +# Video extensions +VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv', '.wmv', '.mpg', '.mpeg'} + + +def pick_probe_backend( + filepath: str | Path, + want_generation_tags: bool = True, + settings_override: str | None = None +) -> List[str]: + """ + Pick which probe backend(s) to use for metadata extraction. + + Args: + filepath: Path to the file being probed + want_generation_tags: Whether generation metadata (prompt/workflow) is needed + settings_override: Override the global MEDIA_PROBE_BACKEND setting + + Returns: + List of backends to use, in order: ["exiftool"], ["ffprobe"], or ["exiftool", "ffprobe"] + """ + mode = settings_override or MEDIA_PROBE_BACKEND + + # Validate mode + if mode not in ("auto", "exiftool", "ffprobe", "both"): + logger.warning(f"Invalid media_probe_backend '{mode}', falling back to 'auto'") + mode = "auto" + + # Determine file type + ext = Path(filepath).suffix.lower() + is_video = ext in VIDEO_EXTS + + # Simple modes + if mode == "exiftool": + if has_exiftool(): + return ["exiftool"] + logger.warning("ExifTool requested but not available, trying ffprobe") + if has_ffprobe(): + return ["ffprobe"] + return [] + + if mode == "ffprobe": + if has_ffprobe(): + return ["ffprobe"] + logger.warning("FFprobe requested but not available, trying exiftool") + if has_exiftool(): + return ["exiftool"] + return [] + + if mode == "both": + tools = [] + if has_exiftool(): + tools.append("exiftool") + if has_ffprobe(): + tools.append("ffprobe") + return tools + + # Auto mode (recommended) + if mode == "auto": + if is_video: + # For videos: use both if available + # ExifTool for generation tags, FFprobe for tech metadata + tools = [] + if has_exiftool(): + tools.append("exiftool") + if has_ffprobe(): + tools.append("ffprobe") + return tools if tools else [] + else: + # For images: ExifTool is sufficient + if has_exiftool(): + return ["exiftool"] + # Fallback to ffprobe if available (won't get generation tags though) + if has_ffprobe(): + logger.debug("Using ffprobe for image (ExifTool not available)") + return ["ffprobe"] + return [] + + return [] + + +def get_probe_strategy_description(filepath: str | Path) -> str: + """ + Get a human-readable description of the probe strategy for a file. + Useful for debugging/logging. + """ + backends = pick_probe_backend(filepath) + if not backends: + return "No probe backends available" + if len(backends) == 1: + return f"Using {backends[0]} only" + return f"Using {' + '.join(backends)}" diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 00000000..8d738907 --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1,43 @@ +""" +Modular route system for ComfyUI Majoor Assets Manager. + +This package provides a clean separation of concerns: +- core/: Shared utilities (response, paths, security, services) +- handlers/: Feature-based route handlers +- registry.py: Route registration coordination + +Auto-registers all routes on import via PromptServer decorators. +""" +from pathlib import Path +from backend.config import OUTPUT_ROOT +from backend.shared import get_logger + +logger = get_logger(__name__) + +# Expose COMFY_OUTPUT_DIR for backward compatibility +COMFY_OUTPUT_DIR = OUTPUT_ROOT + +# Initialize and register all routes +logger.info("=" * 60) +logger.info("Initializing Majoor Assets Manager...") +logger.info("=" * 60) +logger.info("Services will be lazily initialized") + +from .registry import ( + register_all_routes, + register_routes, + _install_observability_on_prompt_server, +) + +# Auto-register routes on import +register_all_routes() +_install_observability_on_prompt_server() + +logger.info(f"ComfyUI output directory: {COMFY_OUTPUT_DIR}") + +# Export for backward compatibility and external use +__all__ = [ + "register_routes", + "register_all_routes", + "COMFY_OUTPUT_DIR", +] diff --git a/backend/routes/core/__init__.py b/backend/routes/core/__init__.py new file mode 100644 index 00000000..233ad08c --- /dev/null +++ b/backend/routes/core/__init__.py @@ -0,0 +1,29 @@ +""" +Core utilities for route handlers. +""" +from .response import _json_response +from .paths import ( + _normalize_path, + _is_path_allowed, + _is_path_allowed_custom, + _safe_rel_path, + _is_within_root, + _get_allowed_directories, +) +from .security import _check_rate_limit, _csrf_error +from .services import _require_services, _build_services, get_services_error + +__all__ = [ + "_json_response", + "_normalize_path", + "_is_path_allowed", + "_is_path_allowed_custom", + "_safe_rel_path", + "_is_within_root", + "_get_allowed_directories", + "_check_rate_limit", + "_csrf_error", + "_require_services", + "_build_services", + "get_services_error", +] diff --git a/backend/routes/core/paths.py b/backend/routes/core/paths.py new file mode 100644 index 00000000..73efaf86 --- /dev/null +++ b/backend/routes/core/paths.py @@ -0,0 +1,160 @@ +""" +Path validation and security utilities. +""" +import os +import threading +from pathlib import Path +from typing import Optional + +try: + import folder_paths # type: ignore +except Exception: + class _FolderPathsStub: + @staticmethod + def get_input_directory() -> str: + return str((Path(__file__).resolve().parents[3] / "input").resolve()) + + @staticmethod + def get_output_directory() -> str: + return str((Path(__file__).resolve().parents[3] / "output").resolve()) + + folder_paths = _FolderPathsStub() # type: ignore + +from backend.config import OUTPUT_ROOT +from backend.custom_roots import list_custom_roots +from backend.shared import get_logger + +logger = get_logger(__name__) + +_ALLOWED_DIRECTORIES = None +_ALLOWED_DIRECTORIES_LOCK = threading.Lock() +_ALLOWED_DIRECTORIES_STATE = {"output": None, "input": None} + + +def _get_allowed_directories(): + """ + Resolve allowed base directories lazily. + + NOTE: ComfyUI can change input/output directories depending on configuration; + this keeps the allowed list in sync across requests. + """ + output_root = str(OUTPUT_ROOT) + input_root = str(folder_paths.get_input_directory()) + global _ALLOWED_DIRECTORIES + with _ALLOWED_DIRECTORIES_LOCK: + if ( + _ALLOWED_DIRECTORIES is None + or _ALLOWED_DIRECTORIES_STATE.get("output") != output_root + or _ALLOWED_DIRECTORIES_STATE.get("input") != input_root + ): + _ALLOWED_DIRECTORIES_STATE["output"] = output_root + _ALLOWED_DIRECTORIES_STATE["input"] = input_root + roots = [] + try: + roots.append(Path(output_root).resolve(strict=False)) + except Exception as exc: + logger.warning("Failed to resolve output root for allowlist: %s", exc) + try: + roots.append(Path(input_root).resolve(strict=False)) + except Exception as exc: + logger.warning("Failed to resolve input root for allowlist: %s", exc) + _ALLOWED_DIRECTORIES = roots + return list(_ALLOWED_DIRECTORIES or []) + + +def _normalize_path(value: str) -> Optional[Path]: + if not value: + return None + if "\x00" in value: + return None + try: + candidate = Path(value).expanduser() + # Resolve without requiring file to exist (strict=False) to support directories pending creation + return candidate.resolve(strict=False) + except (OSError, ValueError): + return None + + +def _is_path_allowed(candidate: Optional[Path]) -> bool: + if not candidate: + return False + + for root in _get_allowed_directories(): + try: + root_resolved = root.resolve(strict=False) + except OSError: + continue + + try: + if candidate == root_resolved or candidate.is_relative_to(root_resolved): + return True + except AttributeError: + # Fallback when is_relative_to is unavailable + try: + if os.path.commonpath([str(candidate), str(root_resolved)]) == str(root_resolved): + return True + except ValueError: + continue + + return False + + +def _is_path_allowed_custom(candidate: Optional[Path]) -> bool: + """ + Allow paths within any registered custom root. + """ + if not candidate: + return False + custom = list_custom_roots() + if not custom.ok: + return False + for item in custom.data or []: + root_path = item.get("path") if isinstance(item, dict) else None + if not root_path: + continue + try: + root = Path(str(root_path)).resolve(strict=False) + except OSError: + continue + if _is_within_root(candidate, root): + return True + return False + + +def _safe_rel_path(value: str) -> Optional[Path]: + if value is None: + return Path("") + raw = str(value).strip() + if raw == "": + return Path("") + if "\x00" in raw: + return None + try: + rel = Path(raw) + except (OSError, ValueError): + return None + if getattr(rel, "drive", ""): + return None + if rel.is_absolute(): + return None + # Disallow traversal + if any(part == ".." for part in rel.parts): + return None + return rel + + +def _is_within_root(candidate: Path, root: Path) -> bool: + try: + # SECURITY: require strict resolution so symlinks are fully resolved. + # If we cannot resolve a path to a real filesystem location, treat it as unsafe. + root_resolved = root.resolve(strict=True) + cand_resolved = candidate.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + return False + try: + return cand_resolved == root_resolved or cand_resolved.is_relative_to(root_resolved) + except AttributeError: + try: + return os.path.commonpath([str(cand_resolved), str(root_resolved)]) == str(root_resolved) + except ValueError: + return False diff --git a/backend/routes/core/response.py b/backend/routes/core/response.py new file mode 100644 index 00000000..71fca22f --- /dev/null +++ b/backend/routes/core/response.py @@ -0,0 +1,44 @@ +""" +Response utilities for route handlers. +""" +from typing import Optional +from aiohttp import web +from backend.shared import Result + + +def _json_response(result: Result, status: Optional[int] = None): + """ + Convert Result to JSON response. + + Args: + result: Result object + status: HTTP status code (optional, auto-determined if None) + + Returns: + aiohttp web.Response + """ + if status is None: + if result.ok: + status = 200 + else: + error_code = result.code or "" + if error_code in ("INVALID_INPUT", "INVALID_JSON", "EMPTY_QUERY", "QUERY_TOO_LONG", "RATE_LIMIT"): + status = 400 if error_code != "RATE_LIMIT" else 429 + elif error_code in ("NOT_FOUND", "DIR_NOT_FOUND"): + status = 404 + elif error_code in ("SERVICE_UNAVAILABLE",): + status = 503 + elif error_code in ("CSRF",): + status = 403 + elif error_code in ("DB_ERROR", "SCAN_FAILED", "SEARCH_FAILED"): + status = 500 + else: + status = 400 + + return web.json_response({ + "ok": result.ok, + "data": result.data, + "error": result.error, + "code": result.code, + "meta": result.meta, + }, status=status) diff --git a/backend/routes/core/security.py b/backend/routes/core/security.py new file mode 100644 index 00000000..910cf1c5 --- /dev/null +++ b/backend/routes/core/security.py @@ -0,0 +1,88 @@ +""" +Security utilities: CSRF protection and rate limiting. +""" +import time +import threading +from collections import defaultdict +from typing import Optional +from urllib.parse import urlparse +from aiohttp import web + +_rate_limit_state = defaultdict(list) +_rate_limit_lock = threading.Lock() + + +def _check_rate_limit(endpoint: str, max_requests: int = 3, window_seconds: int = 60) -> bool: + now = time.time() + with _rate_limit_lock: + recent = [ + ts for ts in _rate_limit_state[endpoint] + if now - ts < window_seconds + ] + if not recent: + _rate_limit_state.pop(endpoint, None) + else: + _rate_limit_state[endpoint] = recent + + if len(recent) >= max_requests: + return False + recent.append(now) + _rate_limit_state[endpoint] = recent + return True + + +def _csrf_error(request: web.Request) -> Optional[str]: + """ + Basic CSRF protection for state-changing endpoints. + + Strategy: if the browser sends an Origin header, require it to match Host. + Requests without Origin are allowed (e.g. scripts/CLI). + """ + origin = request.headers.get("Origin") + if not origin: + return None + if origin == "null": + return "Cross-site request blocked (Origin=null)" + + host = request.headers.get("Host") + if not host: + return None + + try: + parsed = urlparse(origin) + except Exception: + return "Cross-site request blocked (invalid Origin)" + + if not parsed.scheme or not parsed.netloc: + return "Cross-site request blocked (invalid Origin)" + + if parsed.netloc != host: + # Allow loopback aliases (localhost, 127.0.0.1, ::1) to interoperate when the + # user opens ComfyUI via a different hostname than the server reports. + try: + origin_host = parsed.hostname or "" + origin_port = parsed.port + except Exception: + origin_host = "" + origin_port = None + + try: + # `Host` may include port. + if ":" in host and not host.endswith("]"): + host_name, host_port_raw = host.rsplit(":", 1) + host_port = int(host_port_raw) + else: + host_name = host + host_port = None + except Exception: + host_name = host + host_port = None + + loopback = {"localhost", "127.0.0.1", "::1"} + if origin_host in loopback and host_name in loopback: + if origin_port is None or host_port is None or origin_port == host_port: + return None + + return f"Cross-site request blocked ({parsed.netloc} != {host})" + + return None diff --git a/backend/routes/core/services.py b/backend/routes/core/services.py new file mode 100644 index 00000000..ef620352 --- /dev/null +++ b/backend/routes/core/services.py @@ -0,0 +1,89 @@ +""" +Service management and initialization. +""" +import threading +from backend.deps import build_services +from backend.shared import Result, get_logger + +logger = get_logger(__name__) + +_services = None +_services_error = None +_services_lock = threading.Lock() + + +def _dispose_services(): + """ + Dispose of all services, ensuring each resource is cleaned up independently. + Errors in one service disposal do not prevent others from being disposed. + """ + global _services + if not _services: + return + + disposal_errors = [] + + # Dispose database connection + db = _services.get("db") + if db: + try: + db.close() + logger.debug("Database connection closed successfully") + except Exception as exc: + error_msg = f"Error closing database: {exc}" + logger.warning(error_msg, exc_info=True) + disposal_errors.append(error_msg) + + # Dispose watcher + watcher = _services.get("watcher") + if watcher: + try: + watcher.stop() + logger.debug("File watcher stopped successfully") + except Exception as exc: + error_msg = f"Error stopping watcher: {exc}" + logger.warning(error_msg, exc_info=True) + disposal_errors.append(error_msg) + + # Clear services reference + _services = None + + # Log summary if there were any errors + if disposal_errors: + logger.warning("Service disposal completed with %d error(s)", len(disposal_errors)) + + +def _build_services(force: bool = False): + global _services, _services_error + with _services_lock: + if _services and not force: + return _services + + if force: + _dispose_services() + + try: + _services = build_services() + _services_error = None + return _services + except Exception as exc: + _services_error = str(exc) + logger.error(f"Failed to initialize services: {exc}", exc_info=True) + _services = None + return None + + +def _require_services(): + services = _build_services() + if services: + return services, None + return None, Result.Err( + "SERVICE_UNAVAILABLE", + "Services are unavailable", + detail=_services_error or "Initialization failed" + ) + + +def get_services_error(): + """Get the current services error if any.""" + return _services_error diff --git a/backend/routes/handlers/__init__.py b/backend/routes/handlers/__init__.py new file mode 100644 index 00000000..7c890725 --- /dev/null +++ b/backend/routes/handlers/__init__.py @@ -0,0 +1,18 @@ +""" +Route handlers package. +""" +from .health import register_health_routes +from .metadata import register_metadata_routes +from .custom_roots import register_custom_roots_routes +from .search import register_search_routes +from .scan import register_scan_routes +from .assets import register_asset_routes + +__all__ = [ + "register_health_routes", + "register_metadata_routes", + "register_custom_roots_routes", + "register_search_routes", + "register_scan_routes", + "register_asset_routes", +] diff --git a/backend/routes/handlers/assets.py b/backend/routes/handlers/assets.py new file mode 100644 index 00000000..3b056a91 --- /dev/null +++ b/backend/routes/handlers/assets.py @@ -0,0 +1,652 @@ +""" +Asset management endpoints: ratings, tags, service retry. +""" +from aiohttp import web +import asyncio +import json +import os +import subprocess +from pathlib import Path + +from backend.shared import Result, get_logger +from backend.config import OUTPUT_ROOT +from backend.custom_roots import list_custom_roots, resolve_custom_root + +try: + import folder_paths # type: ignore +except Exception: # pragma: no cover + class _FolderPathsStub: + @staticmethod + def get_input_directory() -> str: + return str((Path(__file__).resolve().parents[3] / "input").resolve()) + + folder_paths = _FolderPathsStub() # type: ignore + +from ..core import ( + _json_response, + _require_services, + _csrf_error, + _build_services, + get_services_error, + _normalize_path, + _is_path_allowed, + _is_path_allowed_custom, + _is_within_root, +) + +logger = get_logger(__name__) + + +def register_asset_routes(routes: web.RouteTableDef) -> None: + async def _resolve_or_create_asset_id( + *, + services: dict, + filepath: str, + file_type: str = "", + root_id: str = "", + ) -> Result[int]: + """ + Resolve an asset_id for a file path, indexing it if needed. + + This allows rating/tags persistence even when a file is shown from the filesystem + (input/custom) before it exists in the DB. + """ + if not filepath or not isinstance(filepath, str): + return Result.Err("INVALID_INPUT", "Missing filepath") + + candidate = _normalize_path(filepath) + if not candidate: + return Result.Err("INVALID_INPUT", "Invalid filepath") + + if not (_is_path_allowed(candidate) or _is_path_allowed_custom(candidate)): + return Result.Err("FORBIDDEN", "Path is not within allowed roots") + + try: + resolved = candidate.resolve(strict=True) + except Exception: + return Result.Err("NOT_FOUND", "File does not exist") + + source = str(file_type or "").strip().lower() + base_dir: Path | None = None + resolved_root_id: str | None = None + + out_root = Path(OUTPUT_ROOT).resolve(strict=False) + in_root = Path(folder_paths.get_input_directory()).resolve(strict=False) + + if source in ("output", "outputs", "") and _is_within_root(resolved, out_root): + source = "output" + base_dir = out_root + elif source in ("input", "inputs", "") and _is_within_root(resolved, in_root): + source = "input" + base_dir = in_root + else: + # Custom roots must be registered; pick a matching root. + roots_res = list_custom_roots() + if roots_res.ok: + for item in roots_res.data or []: + if not isinstance(item, dict): + continue + rid = str(item.get("id") or "") + rpath = item.get("path") + if not rpath: + continue + try: + rp = Path(str(rpath)).resolve(strict=False) + except Exception: + continue + if _is_within_root(resolved, rp): + base_dir = rp + resolved_root_id = rid or None + source = "custom" + break + + # If caller provided a root_id, validate and prefer it when it matches. + if root_id: + root_result = resolve_custom_root(str(root_id)) + if root_result.ok: + try: + rp = Path(str(root_result.data)).resolve(strict=False) + except Exception: + rp = None + if rp and _is_within_root(resolved, rp): + base_dir = rp + resolved_root_id = str(root_id) + source = "custom" + + if not base_dir: + return Result.Err("INVALID_INPUT", "Unable to infer asset root for filepath") + + # Index the file (best-effort) so it gets a stable asset_id. + try: + await asyncio.to_thread( + services["index"].index_paths, + [Path(resolved)], + str(base_dir), + True, # incremental + source, + (resolved_root_id or None), + ) + except Exception as exc: + logger.debug("Index-on-demand skipped for %s: %s", filepath, exc) + + try: + q = await asyncio.to_thread( + services["db"].query, + "SELECT id FROM assets WHERE filepath = ?", + (str(resolved),), + ) + if not q.ok or not q.data: + return Result.Err("NOT_FOUND", "Asset not indexed") + asset_id = (q.data[0] or {}).get("id") + if asset_id is None: + return Result.Err("NOT_FOUND", "Asset id not available") + return Result.Ok(int(asset_id)) + except Exception as exc: + return Result.Err("DB_ERROR", f"Failed to resolve asset id: {exc}") + + def _get_rating_tags_sync_mode(request: web.Request) -> str: + try: + raw = (request.headers.get("X-MJR-RTSYNC") or "").strip().lower() + except Exception: + raw = "" + if raw in ("", "0", "false", "off", "disable", "disabled", "no"): + return "off" + if raw in ("1", "true", "on", "enable", "enabled"): + return "on" + # Backward compatible values from previous iterations. + if raw in ("sidecar", "both", "exiftool", "on", "off"): + return raw + return "off" + + def _enqueue_rating_tags_sync( + request: web.Request, + services: dict, + asset_id: int, + ) -> None: + mode = _get_rating_tags_sync_mode(request) + if mode == "off": + return + + worker = services.get("rating_tags_sync") + db = services.get("db") + if not worker or not db: + return + + try: + fp_res = db.query("SELECT filepath FROM assets WHERE id = ?", (asset_id,)) + if not fp_res.ok or not fp_res.data: + return + filepath = fp_res.data[0].get("filepath") + if not filepath or not isinstance(filepath, str): + return + except Exception: + return + + rating = 0 + tags = [] + try: + meta_res = db.query("SELECT rating, tags FROM asset_metadata WHERE asset_id = ?", (asset_id,)) + if meta_res.ok and meta_res.data: + row = meta_res.data[0] or {} + rating = int(row.get("rating") or 0) + raw_tags = row.get("tags") + if isinstance(raw_tags, str): + try: + parsed = json.loads(raw_tags) + except Exception: + parsed = [] + tags = parsed if isinstance(parsed, list) else [] + elif isinstance(raw_tags, list): + tags = raw_tags + except Exception: + rating = 0 + tags = [] + + try: + worker.enqueue(filepath, rating, tags, mode) + except Exception: + return + + @routes.post("/mjr/am/retry-services") + async def retry_services(request): + """ + Retry initializing the services if they previously failed. + """ + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + services = _build_services(force=True) + if services: + return _json_response(Result.Ok({"reinitialized": True})) + + error_message = get_services_error() or "Unknown error" + return _json_response(Result.Err("SERVICE_UNAVAILABLE", f"Failed to initialize services: {error_message}")) + + @routes.post("/mjr/am/asset/rating") + async def update_asset_rating(request): + """ + Update asset rating (0-5 stars). + + Body: + - {"asset_id": int, "rating": int} + - OR {"filepath": str, "type": "output|input|custom", "root_id"?: str, "rating": int} + """ + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + try: + body = await request.json() + except Exception as exc: + return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {exc}")) + + asset_id = body.get("asset_id") + rating = body.get("rating") + + if asset_id is None: + fp = body.get("filepath") or body.get("path") or "" + typ = body.get("type") or "" + rid = body.get("root_id") or body.get("custom_root_id") or "" + resolved = await _resolve_or_create_asset_id(services=svc, filepath=str(fp), file_type=str(typ), root_id=str(rid)) + if not resolved.ok: + return _json_response(resolved) + asset_id = resolved.data + else: + try: + asset_id = int(asset_id) + except (ValueError, TypeError): + return _json_response(Result.Err("INVALID_INPUT", "Invalid asset_id")) + + try: + rating = max(0, min(5, int(rating or 0))) + except (ValueError, TypeError): + return _json_response(Result.Err("INVALID_INPUT", "Invalid rating")) + + try: + result = await asyncio.to_thread(svc["index"].update_asset_rating, asset_id, rating) + except Exception as exc: + result = Result.Err("UPDATE_FAILED", f"Failed to update rating: {exc}") + if result.ok: + _enqueue_rating_tags_sync(request, svc, asset_id) + return _json_response(result) + + @routes.post("/mjr/am/asset/tags") + async def update_asset_tags(request): + """ + Update asset tags. + + Body: + - {"asset_id": int, "tags": list[str]} + - OR {"filepath": str, "type": "output|input|custom", "root_id"?: str, "tags": list[str]} + """ + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + try: + body = await request.json() + except Exception as exc: + return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {exc}")) + + asset_id = body.get("asset_id") + tags = body.get("tags") + + if asset_id is None: + fp = body.get("filepath") or body.get("path") or "" + typ = body.get("type") or "" + rid = body.get("root_id") or body.get("custom_root_id") or "" + resolved = await _resolve_or_create_asset_id(services=svc, filepath=str(fp), file_type=str(typ), root_id=str(rid)) + if not resolved.ok: + return _json_response(resolved) + asset_id = resolved.data + else: + try: + asset_id = int(asset_id) + except (ValueError, TypeError): + return _json_response(Result.Err("INVALID_INPUT", "Invalid asset_id")) + + if not isinstance(tags, list): + return _json_response(Result.Err("INVALID_INPUT", "Tags must be a list")) + + # Validate and sanitize tags + sanitized_tags = [] + for tag in tags: + if not isinstance(tag, str): + continue + tag = str(tag).strip() + if not tag or len(tag) > 100: + continue + sanitized_tags.append(tag) + + try: + result = await asyncio.to_thread(svc["index"].update_asset_tags, asset_id, sanitized_tags) + except Exception as exc: + result = Result.Err("UPDATE_FAILED", f"Failed to update tags: {exc}") + if result.ok: + _enqueue_rating_tags_sync(request, svc, asset_id) + return _json_response(result) + + @routes.post("/mjr/am/open-in-folder") + async def open_in_folder(request): + """ + Open the asset's folder in the OS file manager and (when supported) select the file. + + Body: {"asset_id": int} + """ + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + try: + body = await request.json() + except Exception as exc: + return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {exc}")) + + asset_id = body.get("asset_id") + if not asset_id: + return _json_response(Result.Err("INVALID_INPUT", "Missing asset_id")) + + try: + asset_id = int(asset_id) + except (ValueError, TypeError): + return _json_response(Result.Err("INVALID_INPUT", "Invalid asset_id")) + + db = svc.get("db") if isinstance(svc, dict) else None + if not db: + return _json_response(Result.Err("SERVICE_UNAVAILABLE", "Database service unavailable")) + + try: + res = db.query("SELECT filepath FROM assets WHERE id = ?", (asset_id,)) + if not res.ok or not res.data: + return _json_response(Result.Err("NOT_FOUND", "Asset not found")) + raw_path = (res.data[0] or {}).get("filepath") + except Exception as exc: + return _json_response(Result.Err("DB_ERROR", f"Failed to load asset: {exc}")) + + if not raw_path or not isinstance(raw_path, str): + return _json_response(Result.Err("NOT_FOUND", "Asset path not available")) + + candidate = _normalize_path(raw_path) + if not candidate: + return _json_response(Result.Err("INVALID_INPUT", "Invalid asset path")) + + allowed = _is_path_allowed(candidate) or _is_path_allowed_custom(candidate) + if not allowed: + return _json_response(Result.Err("FORBIDDEN", "Path is not within allowed roots")) + + try: + # Require strict resolution to avoid following symlinks outside allowed roots. + resolved = candidate.resolve(strict=True) + except Exception: + return _json_response(Result.Err("NOT_FOUND", "File does not exist")) + + if os.name != "nt": + # Non-Windows: best-effort open directory only. + try: + subprocess.Popen(["xdg-open", str(Path(resolved).parent)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return _json_response(Result.Ok({"opened": True, "selected": False})) + except Exception as exc: + return _json_response(Result.Err("DEGRADED", f"Open-in-folder not supported: {exc}")) + + # Windows: open Explorer and select the file. + try: + subprocess.Popen(["explorer.exe", f"/select,{str(resolved)}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return _json_response(Result.Ok({"opened": True, "selected": True})) + except Exception as exc: + try: + os.startfile(str(Path(resolved).parent)) + return _json_response(Result.Ok({"opened": True, "selected": False, "fallback": "startfile"})) + except Exception: + return _json_response(Result.Err("DEGRADED", f"Failed to open folder: {exc}")) + + @routes.get("/mjr/am/tags") + async def get_all_tags(request): + """ + Get all unique tags from the database for autocomplete. + + Returns: {"ok": true, "data": ["tag1", "tag2", ...]} + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + result = svc["index"].get_all_tags() + return _json_response(result) + + @routes.get("/mjr/am/routes") + async def list_routes(request): + """List all available API routes.""" + routes_info = [ + {"method": "GET", "path": "/mjr/am/health", "description": "Get health status"}, + {"method": "GET", "path": "/mjr/am/health/counters", "description": "Get database counters"}, + {"method": "GET", "path": "/mjr/am/config", "description": "Get configuration"}, + {"method": "GET", "path": "/mjr/am/roots", "description": "Get core and custom roots"}, + {"method": "GET", "path": "/mjr/am/custom-roots", "description": "List custom roots"}, + {"method": "POST", "path": "/mjr/am/custom-roots", "description": "Add custom root"}, + {"method": "POST", "path": "/mjr/am/custom-roots/remove", "description": "Remove custom root"}, + {"method": "GET", "path": "/mjr/am/custom-view", "description": "Serve file from custom root"}, + {"method": "GET", "path": "/mjr/am/list", "description": "List assets (scoped)"}, + {"method": "POST", "path": "/mjr/am/scan", "description": "Scan a directory for assets"}, + {"method": "POST", "path": "/mjr/am/index-files", "description": "Index specific files"}, + {"method": "GET", "path": "/mjr/am/search", "description": "Search assets using FTS5"}, + {"method": "POST", "path": "/mjr/am/assets/batch", "description": "Batch fetch assets by ID"}, + {"method": "GET", "path": "/mjr/am/metadata", "description": "Get metadata for a file"}, + {"method": "POST", "path": "/mjr/am/stage-to-input", "description": "Copy files to input directory"}, + {"method": "GET", "path": "/mjr/am/asset/{asset_id}", "description": "Get single asset by ID"}, + {"method": "POST", "path": "/mjr/am/asset/rating", "description": "Update asset rating (0-5 stars)"}, + {"method": "POST", "path": "/mjr/am/asset/tags", "description": "Update asset tags"}, + {"method": "POST", "path": "/mjr/am/open-in-folder", "description": "Open asset in OS file manager"}, + {"method": "GET", "path": "/mjr/am/tags", "description": "Get all unique tags for autocomplete"}, + {"method": "POST", "path": "/mjr/am/retry-services", "description": "Retry service initialization"}, + {"method": "GET", "path": "/mjr/am/routes", "description": "List all available routes (this endpoint)"}, + ] + return _json_response(Result.Ok({"routes": routes_info})) + + @routes.post("/mjr/am/asset/delete") + async def delete_asset(request): + """ + Delete a single asset file and its database record. + + Body: {"asset_id": int} + """ + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + try: + body = await request.json() + except Exception as exc: + return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {exc}")) + + asset_id = body.get("asset_id") + if not asset_id: + return _json_response(Result.Err("INVALID_INPUT", "Missing asset_id")) + + try: + asset_id = int(asset_id) + except (ValueError, TypeError): + return _json_response(Result.Err("INVALID_INPUT", "Invalid asset_id")) + + # Get file path from database + try: + res = svc["db"].query("SELECT filepath FROM assets WHERE id = ?", (asset_id,)) + if not res.ok or not res.data: + return _json_response(Result.Err("NOT_FOUND", "Asset not found")) + raw_path = (res.data[0] or {}).get("filepath") + except Exception as exc: + return _json_response(Result.Err("DB_ERROR", f"Failed to load asset: {exc}")) + + if not raw_path or not isinstance(raw_path, str): + return _json_response(Result.Err("NOT_FOUND", "Asset path not available")) + + candidate = _normalize_path(raw_path) + if not candidate: + return _json_response(Result.Err("INVALID_INPUT", "Invalid asset path")) + + allowed = _is_path_allowed(candidate) or _is_path_allowed_custom(candidate) + if not allowed: + return _json_response(Result.Err("FORBIDDEN", "Path is not within allowed roots")) + + try: + # Require strict resolution to avoid following symlinks outside allowed roots. + resolved = candidate.resolve(strict=True) + except Exception: + return _json_response(Result.Err("NOT_FOUND", "File does not exist")) + + # Check if file exists before attempting deletion + if not resolved.exists() or not resolved.is_file(): + # Even if file doesn't exist, we can still remove the DB record + pass + + # Delete the file if it exists + if resolved.exists() and resolved.is_file(): + try: + resolved.unlink(missing_ok=True) + except Exception as exc: + return _json_response(Result.Err("DELETE_FAILED", f"Failed to delete file: {exc}")) + + # Delete from database + try: + # Delete from main assets table (this should cascade to asset_metadata) + del_res = svc["db"].execute("DELETE FROM assets WHERE id = ?", (asset_id,)) + if not del_res.ok: + return _json_response(del_res) + + # Clean up related tables + svc["db"].execute("DELETE FROM scan_journal WHERE filepath = ?", (str(resolved),)) + svc["db"].execute("DELETE FROM metadata_cache WHERE filepath = ?", (str(resolved),)) + except Exception as exc: + return _json_response(Result.Err("DB_ERROR", f"Failed to delete asset record: {exc}")) + + return _json_response(Result.Ok({"deleted": 1})) + + @routes.post("/mjr/am/asset/rename") + async def rename_asset(request): + """ + Rename an asset file and update its database record. + + Body: {"asset_id": int, "new_name": str} + """ + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + try: + body = await request.json() + except Exception as exc: + return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {exc}")) + + asset_id = body.get("asset_id") + new_name = body.get("new_name") + + if not asset_id: + return _json_response(Result.Err("INVALID_INPUT", "Missing asset_id")) + if not new_name: + return _json_response(Result.Err("INVALID_INPUT", "Missing new_name")) + + try: + asset_id = int(asset_id) + except (ValueError, TypeError): + return _json_response(Result.Err("INVALID_INPUT", "Invalid asset_id")) + + # Sanitize new_name + new_name = str(new_name).strip() + if not new_name: + return _json_response(Result.Err("INVALID_INPUT", "New name cannot be empty")) + + # Validate new_name doesn't contain dangerous characters + if "/" in new_name or "\\" in new_name: + return _json_response(Result.Err("INVALID_INPUT", "New name cannot contain path separators")) + + if len(new_name) > 255: + return _json_response(Result.Err("INVALID_INPUT", "New name is too long (max 255 chars)")) + + # Get current asset info from database + try: + res = svc["db"].query("SELECT filepath, filename FROM assets WHERE id = ?", (asset_id,)) + if not res.ok or not res.data: + return _json_response(Result.Err("NOT_FOUND", "Asset not found")) + row = res.data[0] or {} + current_filepath = row.get("filepath") + current_filename = row.get("filename") + except Exception as exc: + return _json_response(Result.Err("DB_ERROR", f"Failed to load asset: {exc}")) + + if not current_filepath or not isinstance(current_filepath, str): + return _json_response(Result.Err("NOT_FOUND", "Asset path not available")) + + current_path = _normalize_path(current_filepath) + if not current_path: + return _json_response(Result.Err("INVALID_INPUT", "Invalid current asset path")) + + allowed = _is_path_allowed(current_path) or _is_path_allowed_custom(current_path) + if not allowed: + return _json_response(Result.Err("FORBIDDEN", "Current path is not within allowed roots")) + + try: + # Require strict resolution to avoid following symlinks outside allowed roots. + current_resolved = current_path.resolve(strict=True) + except Exception: + return _json_response(Result.Err("NOT_FOUND", "Current file does not exist")) + + if not current_resolved.exists() or not current_resolved.is_file(): + return _json_response(Result.Err("NOT_FOUND", "Current file does not exist")) + + # Determine the new file path + new_path = current_resolved.parent / new_name + + # Check if new name already exists + if new_path.exists(): + return _json_response(Result.Err("CONFLICT", f"File '{new_name}' already exists")) + + # Perform the rename + try: + current_resolved.rename(new_path) + except Exception as exc: + return _json_response(Result.Err("RENAME_FAILED", f"Failed to rename file: {exc}")) + + # Update database record + try: + # Update assets table + mtime = int(new_path.stat().st_mtime) + update_res = svc["db"].execute( + "UPDATE assets SET filename = ?, filepath = ?, mtime = ? WHERE id = ?", + (new_name, str(new_path), mtime, asset_id) + ) + if not update_res.ok: + return _json_response(update_res) + + # Update related tables - clean old cache entries + svc["db"].execute("DELETE FROM scan_journal WHERE filepath = ?", (str(current_path),)) + svc["db"].execute("DELETE FROM metadata_cache WHERE filepath = ?", (str(current_path),)) + except Exception as exc: + return _json_response(Result.Err("DB_ERROR", f"Failed to update asset record: {exc}")) + + return _json_response(Result.Ok({ + "renamed": 1, + "old_name": current_filename, + "new_name": new_name, + "old_path": str(current_path), + "new_path": str(new_path) + })) diff --git a/backend/routes/handlers/custom_roots.py b/backend/routes/handlers/custom_roots.py new file mode 100644 index 00000000..5b6cdc09 --- /dev/null +++ b/backend/routes/handlers/custom_roots.py @@ -0,0 +1,111 @@ +""" +Custom roots management endpoints. +""" +from pathlib import Path +from aiohttp import web +from backend.custom_roots import ( + add_custom_root, + list_custom_roots, + remove_custom_root, + resolve_custom_root, +) +from backend.shared import Result, get_logger +from ..core import _json_response, _csrf_error, _safe_rel_path, _is_within_root +from .filesystem import _kickoff_background_scan + +logger = get_logger(__name__) + + +def register_custom_roots_routes(routes: web.RouteTableDef) -> None: + @routes.get("/mjr/am/custom-roots") + async def get_custom_roots(request): + result = list_custom_roots() + return _json_response(result) + + @routes.post("/mjr/am/custom-roots") + async def post_custom_roots(request): + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + try: + body = await request.json() + except Exception as exc: + return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {exc}")) + + path = body.get("path") or body.get("directory") or body.get("root") + label = body.get("label") + result = add_custom_root(str(path or ""), label=str(label) if label is not None else None) + if result.ok and isinstance(result.data, dict): + try: + root_path = str(result.data.get("path") or "") + root_id = str(result.data.get("id") or "") + if root_path and root_id: + _kickoff_background_scan(root_path, source="custom", root_id=root_id, recursive=True, incremental=True) + except Exception as exc: + # Best-effort: never fail the request because background scan scheduling failed. + logger.debug("Background scan kickoff skipped: %s", exc) + return _json_response(result) + + @routes.post("/mjr/am/custom-roots/remove") + async def post_custom_roots_remove(request): + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + try: + body = await request.json() + except Exception as exc: + return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {exc}")) + + rid = body.get("id") or body.get("root_id") + result = remove_custom_root(str(rid or "")) + return _json_response(result) + + @routes.get("/mjr/am/custom-view") + async def custom_view(request): + """ + Serve a file from a registered custom root. + + Query params: + root_id: custom root id + filename: file name + subfolder: optional subfolder under the root + """ + root_id = request.query.get("root_id", "").strip() + filename = request.query.get("filename", "").strip() + subfolder = request.query.get("subfolder", "").strip() + + if not root_id or not filename: + return _json_response(Result.Err("INVALID_INPUT", "Missing root_id or filename")) + + root_result = resolve_custom_root(root_id) + if not root_result.ok: + return _json_response(root_result) + + root_dir = root_result.data + rel = _safe_rel_path(subfolder) + if rel is None: + return _json_response(Result.Err("INVALID_INPUT", "Invalid subfolder")) + + # Ensure filename is a basename (no traversal) + if Path(filename).name != filename: + return _json_response(Result.Err("INVALID_INPUT", "Invalid filename")) + + candidate = (root_dir / rel / filename) + + # SECURITY: Validate path is within root before any file operations + if not _is_within_root(candidate, root_dir): + return _json_response(Result.Err("INVALID_INPUT", "Path outside root")) + + # Fix TOCTOU: Use FileResponse directly and let it handle file existence + # This avoids race condition between exists() check and file serving + try: + resolved_path = candidate.resolve(strict=True) + if not resolved_path.is_file(): + return _json_response(Result.Err("NOT_FOUND", "File not found or not a regular file")) + return web.FileResponse(path=str(resolved_path)) + except FileNotFoundError: + return _json_response(Result.Err("NOT_FOUND", "File not found")) + except (OSError, RuntimeError, ValueError) as exc: + return _json_response(Result.Err("VIEW_FAILED", f"Failed to serve file: {exc}")) diff --git a/backend/routes/handlers/filesystem.py b/backend/routes/handlers/filesystem.py new file mode 100644 index 00000000..9100781e --- /dev/null +++ b/backend/routes/handlers/filesystem.py @@ -0,0 +1,248 @@ +""" +Filesystem operations: background scanning and file listing. +""" +import time +import threading +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, Optional + +from backend.shared import Result, classify_file, get_logger +from ..core import _safe_rel_path, _is_within_root + +logger = get_logger(__name__) + +_BACKGROUND_SCAN_LOCK = threading.Lock() +_BACKGROUND_SCAN_LAST: Dict[str, float] = {} + +_FS_LIST_CACHE_LOCK = threading.Lock() +_FS_LIST_CACHE: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() +_FS_LIST_CACHE_MAX = 32 + + +def _kickoff_background_scan( + directory: str, + *, + source: str = "output", + root_id: Optional[str] = None, + recursive: bool = True, + incremental: bool = True, + min_interval_seconds: float = 10.0, +) -> None: + """ + Fire-and-forget indexing into the DB. + + Used to ensure input/custom folders get indexed without blocking list requests. + """ + try: + key = f"{source}|{str(root_id or '')}|{str(directory)}" + now = time.time() + with _BACKGROUND_SCAN_LOCK: + last = _BACKGROUND_SCAN_LAST.get(key, 0.0) + if now - last < float(min_interval_seconds): + return + _BACKGROUND_SCAN_LAST[key] = now + except Exception: + return + + def _run(): + try: + from ..core import _require_services + svc, error_result = _require_services() + if error_result: + return + try: + svc["index"].scan_directory(str(directory), recursive, incremental, source=source, root_id=root_id) + except Exception as exc: + logger.warning("Background scan failed: %s", exc) + except Exception: + return + + try: + threading.Thread(target=_run, daemon=True).start() + except Exception: + return + + +def _fs_cache_get_or_build( + base: Path, + target_dir_resolved: Path, + asset_type: str, + root_id: Optional[str], +) -> Result[Dict[str, Any]]: + try: + dir_mtime_ns = target_dir_resolved.stat().st_mtime_ns + except OSError as exc: + return Result.Err("DIR_NOT_FOUND", f"Directory not found: {exc}") + + cache_key = f"{str(base)}|{str(target_dir_resolved)}|{asset_type}|{str(root_id or '')}" + with _FS_LIST_CACHE_LOCK: + cached = _FS_LIST_CACHE.get(cache_key) + if cached and cached.get("dir_mtime_ns") == dir_mtime_ns and isinstance(cached.get("entries"), list): + _FS_LIST_CACHE.move_to_end(cache_key) + return Result.Ok({"entries": cached["entries"], "dir_mtime_ns": dir_mtime_ns}) + + entries = [] + try: + 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 + + rel_to_root = entry.parent.relative_to(base) + sub = "" if str(rel_to_root) == "." else str(rel_to_root).replace("\\", "/") + 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": 0, + # Keep both keys for backward compatibility; the DB column is `has_generation_data`. + "has_generation_data": 0, + "has_generation_metadata": 0, + "type": asset_type, + "root_id": root_id, + } + ) + except OSError as exc: + return Result.Err("LIST_FAILED", f"Failed to list directory: {exc}") + + with _FS_LIST_CACHE_LOCK: + _FS_LIST_CACHE[cache_key] = {"dir_mtime_ns": dir_mtime_ns, "entries": entries} + _FS_LIST_CACHE.move_to_end(cache_key) + while len(_FS_LIST_CACHE) > _FS_LIST_CACHE_MAX: + _FS_LIST_CACHE.popitem(last=False) + + return Result.Ok({"entries": entries, "dir_mtime_ns": dir_mtime_ns}) + + +def _list_filesystem_assets( + root_dir: Path, + subfolder: str, + query: str, + limit: int, + offset: int, + asset_type: str, + root_id: Optional[str] = None, + filters: Optional[dict] = None, + index_service: Optional[Any] = None, +) -> Result[dict]: + try: + base = root_dir.resolve() + except OSError as exc: + return Result.Err("INVALID_INPUT", f"Invalid root directory: {exc}") + + rel = _safe_rel_path(subfolder or "") + if rel is None: + return Result.Err("INVALID_INPUT", "Invalid subfolder") + + target_dir = (base / rel) + try: + target_dir_resolved = target_dir.resolve(strict=True) + except OSError: + return Result.Err("DIR_NOT_FOUND", f"Directory not found: {target_dir}") + + if not _is_within_root(target_dir_resolved, base): + return Result.Err("INVALID_INPUT", "Subfolder outside root") + + q = (query or "*").strip() + q_lower = q.lower() + browse_all = q == "*" or q == "" + + filter_kind = str(filters.get("kind") or "").strip().lower() if isinstance(filters, dict) else "" + filter_min_rating = int(filters.get("min_rating") or 0) if isinstance(filters, dict) else 0 + filter_workflow_only = bool(filters.get("has_workflow")) if isinstance(filters, dict) else False + + # Removed the early return that was causing filters to return empty results + # The filtering will now be handled properly below after metadata enrichment + + cache_result = _fs_cache_get_or_build(base, target_dir_resolved, asset_type, root_id) + if not cache_result.ok: + return cache_result + all_entries = cache_result.data.get("entries") if isinstance(cache_result.data, dict) else None + if not isinstance(all_entries, list): + return Result.Err("LIST_FAILED", "Failed to list directory") + + entries = [] + for item in all_entries: + if not isinstance(item, dict): + continue + filename = str(item.get("filename") or "") + kind = str(item.get("kind") or "") + if not filename or kind == "unknown": + continue + if filter_kind and kind != filter_kind: + continue + if not browse_all and q_lower not in filename.lower(): + continue + entries.append(item) + + entries.sort(key=lambda x: x.get("mtime") or 0, reverse=True) + + # Enrich with DB fields (workflow/generation/tags/rating) when available. + try: + lookup = getattr(index_service, "lookup_assets_by_filepaths", None) + if callable(lookup) and entries: + filepaths = [str(a.get("filepath") or "") for a in entries if isinstance(a, dict)] + filepaths = [p for p in filepaths if p] + if filepaths: + enrich_result = lookup(filepaths) + if enrich_result and getattr(enrich_result, "ok", False) and isinstance(enrich_result.data, dict): + mapping = enrich_result.data + for asset in entries: + if not isinstance(asset, dict): + continue + fp = str(asset.get("filepath") or "") + db_row = mapping.get(fp) + if not isinstance(db_row, dict): + continue + asset["id"] = db_row.get("id") + asset["rating"] = int(db_row.get("rating") or 0) + asset["tags"] = db_row.get("tags") or [] + asset["has_workflow"] = int(db_row.get("has_workflow") or 0) + has_gen = db_row.get("has_generation_data") + if has_gen is None: + has_gen = db_row.get("has_generation_metadata") + has_gen = int(has_gen or 0) + asset["has_generation_data"] = has_gen + asset["has_generation_metadata"] = has_gen + # Prefer stored root_id if present (custom) + if db_row.get("root_id"): + asset["root_id"] = db_row.get("root_id") + except Exception as exc: + logger.debug("Filesystem DB enrichment skipped: %s", exc) + + # Apply filters after enrichment + filtered_entries = [] + for item in entries: + if filter_kind and item.get("kind") != filter_kind: + continue + if filter_min_rating > 0 and int(item.get("rating", 0)) < filter_min_rating: + continue + if filter_workflow_only and not int(item.get("has_workflow", 0)): + continue + if not browse_all and q_lower not in str(item.get("filename", "")).lower(): + continue + filtered_entries.append(item) + + total = len(filtered_entries) + paged = filtered_entries[offset: offset + limit] if limit > 0 else filtered_entries[offset:] + + return Result.Ok({"assets": paged, "total": total, "limit": limit, "offset": offset, "query": q}) diff --git a/backend/routes/handlers/health.py b/backend/routes/handlers/health.py new file mode 100644 index 00000000..1266bd0e --- /dev/null +++ b/backend/routes/handlers/health.py @@ -0,0 +1,110 @@ +""" +Health check endpoints. +""" +from pathlib import Path +from aiohttp import web + +try: + import folder_paths # type: ignore +except Exception: + class _FolderPathsStub: + @staticmethod + def get_input_directory() -> str: + return str((Path(__file__).resolve().parents[3] / "input").resolve()) + + folder_paths = _FolderPathsStub() # type: ignore + +from backend.config import OUTPUT_ROOT, get_tool_paths, MEDIA_PROBE_BACKEND +from backend.custom_roots import resolve_custom_root +from backend.shared import Result +from backend.tool_detect import get_tool_status +from ..core import _json_response, _require_services + + +def register_health_routes(routes: web.RouteTableDef) -> None: + @routes.get("/mjr/am/health") + async def health(request): + """Get health status.""" + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + result = svc["health"].status() + return _json_response(result) + + @routes.get("/mjr/am/health/counters") + async def health_counters(request): + """Get database counters.""" + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + scope = (request.query.get("scope") or "output").strip().lower() + custom_root_id = request.query.get("custom_root_id") or request.query.get("root_id") or None + + roots = None + if scope == "output": + roots = [str(Path(OUTPUT_ROOT).resolve(strict=False))] + elif scope == "input": + roots = [str(Path(folder_paths.get_input_directory()).resolve(strict=False))] + elif scope == "all": + roots = [ + str(Path(OUTPUT_ROOT).resolve(strict=False)), + str(Path(folder_paths.get_input_directory()).resolve(strict=False)), + ] + elif scope == "custom": + root_result = resolve_custom_root(str(custom_root_id or "")) + if not root_result.ok: + return _json_response(Result.Err("INVALID_INPUT", root_result.error)) + roots = [str(Path(str(root_result.data)).resolve(strict=False))] + else: + return _json_response(Result.Err("INVALID_INPUT", f"Unknown scope: {scope}")) + + result = svc["health"].get_counters(roots=roots) + if result.ok: + if isinstance(result.data, dict): + result.data["scope"] = scope + if scope == "custom": + result.data["custom_root_id"] = custom_root_id + return _json_response(result) + + @routes.get("/mjr/am/config") + async def get_config(request): + """ + Get configuration (output directory, etc.). + """ + return _json_response(Result.Ok({ + "output_directory": OUTPUT_ROOT, + "tool_paths": get_tool_paths(), + "media_probe_backend": MEDIA_PROBE_BACKEND + })) + + @routes.get("/mjr/am/tools/status") + async def tools_status(request): + """ + Get status of external tools (ExifTool, FFprobe). + Returns availability and version info. + """ + status = get_tool_status() + return _json_response(Result.Ok(status)) + + @routes.get("/mjr/am/roots") + async def get_roots(request): + """ + Get core roots and custom roots. + """ + from backend.custom_roots import list_custom_roots + + roots = { + "output_directory": str(Path(OUTPUT_ROOT).resolve()), + "input_directory": str(Path(folder_paths.get_input_directory()).resolve()), + } + + custom = list_custom_roots() + if custom.ok: + roots["custom_roots"] = custom.data + else: + roots["custom_roots"] = [] + roots["custom_roots_error"] = custom.error + + return _json_response(Result.Ok(roots)) diff --git a/backend/routes/handlers/metadata.py b/backend/routes/handlers/metadata.py new file mode 100644 index 00000000..b0096a31 --- /dev/null +++ b/backend/routes/handlers/metadata.py @@ -0,0 +1,92 @@ +""" +Metadata extraction endpoint. +""" +import asyncio +from aiohttp import web +from backend.shared import Result +from backend.config import OUTPUT_ROOT +from backend.custom_roots import resolve_custom_root +from ..core import _json_response, _require_services, _normalize_path, _is_path_allowed, _is_path_allowed_custom, _is_within_root + +try: + import folder_paths # type: ignore +except Exception: + from pathlib import Path + + class _FolderPathsStub: + @staticmethod + def get_input_directory() -> str: + return str((Path(__file__).resolve().parents[3] / "input").resolve()) + + folder_paths = _FolderPathsStub() # type: ignore + + +def register_metadata_routes(routes: web.RouteTableDef) -> None: + @routes.get("/mjr/am/metadata") + async def get_metadata(request): + """ + Get metadata for a file. + + Query params: + path: File path + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + file_path = (request.query.get("path") or "").strip() + normalized = None + + if file_path: + normalized = _normalize_path(file_path) + if not normalized or not (_is_path_allowed(normalized) or _is_path_allowed_custom(normalized)): + result = Result.Err("INVALID_INPUT", "Path not allowed") + return _json_response(result) + else: + # Backward compatible extension: allow resolving a file from (type, filename, subfolder, root_id) + # so callers don't need to send absolute paths. + file_type = (request.query.get("type") or request.query.get("scope") or "output").strip().lower() + filename = (request.query.get("filename") or "").strip() + subfolder = (request.query.get("subfolder") or "").strip() + root_id = (request.query.get("root_id") or request.query.get("custom_root_id") or "").strip() + + if not filename: + return _json_response(Result.Err("INVALID_INPUT", "Missing 'path' or 'filename' parameter")) + + # Resolve base root + if file_type == "input": + base_root = folder_paths.get_input_directory() + elif file_type == "custom": + root_res = resolve_custom_root(root_id) + if not root_res.ok: + return _json_response(Result.Err("INVALID_INPUT", root_res.error or "Invalid custom root")) + base_root = str(root_res.data) + else: + base_root = OUTPUT_ROOT + + # Build candidate and validate root containment + from pathlib import Path + + base_dir = Path(str(base_root)).resolve(strict=False) + candidate = (base_dir / subfolder / filename) if subfolder else (base_dir / filename) + normalized = _normalize_path(str(candidate)) + if not normalized or not normalized.exists(): + return _json_response(Result.Err("NOT_FOUND", "File not found")) + + if file_type == "custom": + if not _is_within_root(normalized, base_dir): + return _json_response(Result.Err("FORBIDDEN", "Source file outside custom root")) + else: + if not _is_path_allowed(normalized): + return _json_response(Result.Err("INVALID_INPUT", "Path not allowed")) + + try: + mode = (request.query.get("mode") or "").strip().lower() + workflow_only = (request.query.get("workflow_only") or "").strip().lower() in ("1", "true", "yes") + if mode in ("workflow", "workflow_only", "workflow-only") or workflow_only: + result = await asyncio.to_thread(svc["metadata"].get_workflow_only, str(normalized)) + else: + result = await asyncio.to_thread(svc["metadata"].get_metadata, str(normalized)) + except Exception as exc: + result = Result.Err("METADATA_FAILED", f"Failed to extract metadata: {exc}") + return _json_response(result) diff --git a/backend/routes/handlers/scan.py b/backend/routes/handlers/scan.py new file mode 100644 index 00000000..89801afe --- /dev/null +++ b/backend/routes/handlers/scan.py @@ -0,0 +1,885 @@ +""" +Directory scanning and file indexing endpoints. +""" +import asyncio +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from aiohttp import web + +try: + import folder_paths # type: ignore +except Exception: + class _FolderPathsStub: + @staticmethod + def get_input_directory() -> str: + return str((Path(__file__).resolve().parents[3] / "input").resolve()) + + folder_paths = _FolderPathsStub() # type: ignore + +from backend.config import OUTPUT_ROOT +from backend.custom_roots import resolve_custom_root +from backend.shared import Result, get_logger +from ..core import ( + _json_response, + _require_services, + _csrf_error, + _check_rate_limit, + _normalize_path, + _is_path_allowed, + _is_path_allowed_custom, + _safe_rel_path, + _is_within_root, +) + +logger = get_logger(__name__) + +def _files_equal_content(src: Path, dst: Path, *, chunk_size: int = 4 * 1024 * 1024) -> bool: + """ + Return True if two files have identical bytes. + + Used to avoid creating *_1 duplicates when re-staging the exact same file. + """ + try: + if not src.exists() or not dst.exists(): + return False + if not src.is_file() or not dst.is_file(): + return False + except Exception: + return False + + try: + import os + if os.path.samefile(str(src), str(dst)): + return True + except Exception: + pass + + try: + if src.stat().st_size != dst.stat().st_size: + return False + except Exception: + return False + + try: + with open(src, "rb") as fs, open(dst, "rb") as fd: + while True: + bs = fs.read(chunk_size) + bd = fd.read(chunk_size) + if bs != bd: + return False + if not bs: + return True + except Exception: + return False + + +@dataclass(frozen=True) +class StageDestination: + path: Path + reused_existing: bool + + +def _resolve_stage_destination(dest_dir: Path, filename: str, src_path: Path) -> StageDestination: + """ + Choose a destination path for staging. + + - If `/` already exists and is byte-identical to `src_path`, reuse it. + - Otherwise, use ``, `_1`, ... to avoid clobbering. + """ + dest_path = dest_dir / filename + if dest_path.exists(): + if _files_equal_content(src_path, dest_path): + return StageDestination(path=dest_path, reused_existing=True) + + stem = dest_path.stem + suffix = dest_path.suffix + counter = 1 + while dest_path.exists(): + dest_path = dest_dir / f"{stem}_{counter}{suffix}" + counter += 1 + + return StageDestination(path=dest_path, reused_existing=False) + + +def _resolve_scan_root(directory: str) -> Result[Path]: + """ + Resolve a scan root strictly (follow symlinks/junctions) and ensure it exists. + + This prevents scanning arbitrary paths via symlink escapes inside allowed roots. + """ + if not directory or "\x00" in str(directory): + return Result.Err("INVALID_INPUT", "Invalid directory") + try: + resolved = Path(str(directory)).expanduser().resolve(strict=True) + except (OSError, RuntimeError, ValueError) as exc: + return Result.Err("DIR_NOT_FOUND", f"Directory not found: {exc}") + if not resolved.exists() or not resolved.is_dir(): + return Result.Err("DIR_NOT_FOUND", "Directory not found") + return Result.Ok(resolved) + + +def register_scan_routes(routes: web.RouteTableDef) -> None: + @routes.post("/mjr/am/scan") + async def scan_directory(request): + """ + Scan a directory for assets. + + JSON body: + directory: Path to scan (optional, defaults to ComfyUI output directory) + recursive: Scan subdirectories (default: true) + incremental: Only update changed files (default: true) + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + if not _check_rate_limit("scan", max_requests=3, window_seconds=60): + return _json_response( + Result.Err("RATE_LIMIT", "Too many scan requests. Please wait before retrying."), + status=429 + ) + + try: + body = await request.json() + except Exception as e: + result = Result.Err("INVALID_JSON", f"Invalid JSON body: {e}") + return _json_response(result) + + scope = (body.get("scope") or "").lower().strip() + custom_root_id = body.get("custom_root_id") or body.get("root_id") or body.get("customRootId") + + # Use ComfyUI output directory if not specified (backwards-compatible) + directory = body.get("directory", OUTPUT_ROOT) + if not directory: + result = Result.Err("INVALID_INPUT", "No directory specified and no output directory configured") + return _json_response(result) + + scan_source = "output" + scan_root_id = None + + # Optional scoped scans (output/input/custom/all) + if scope: + if scope in ("output", "outputs"): + directory = OUTPUT_ROOT + scan_source = "output" + custom_root_id = None + elif scope in ("input", "inputs"): + directory = folder_paths.get_input_directory() + scan_source = "input" + custom_root_id = None + elif scope == "custom": + root_result = resolve_custom_root(str(custom_root_id or "")) + if not root_result.ok: + return _json_response(Result.Err("INVALID_INPUT", root_result.error)) + directory = str(root_result.data) + scan_source = "custom" + scan_root_id = str(custom_root_id) if custom_root_id else None + elif scope == "all": + # Run both output and input scans sequentially. + recursive = body.get("recursive", True) + incremental = body.get("incremental", True) + fast = bool(body.get("fast") or body.get("mode") == "fast" or body.get("manifest_only") is True) + background_metadata = bool(body.get("background_metadata") or body.get("enrich_metadata") or body.get("enqueue_metadata")) + out_res = await asyncio.to_thread( + svc["index"].scan_directory, + str(Path(OUTPUT_ROOT).resolve()), + recursive, + incremental, + "output", + None, + fast, + background_metadata, + ) + in_res = await asyncio.to_thread( + svc["index"].scan_directory, + str(Path(folder_paths.get_input_directory()).resolve()), + recursive, + incremental, + "input", + None, + fast, + background_metadata, + ) + if not out_res.ok: + return _json_response(out_res) + if not in_res.ok: + return _json_response(in_res) + out_stats = out_res.data or {} + in_stats = in_res.data or {} + merged = { + "scanned": out_stats.get("scanned", 0) + in_stats.get("scanned", 0), + "added": out_stats.get("added", 0) + in_stats.get("added", 0), + "updated": out_stats.get("updated", 0) + in_stats.get("updated", 0), + "skipped": out_stats.get("skipped", 0) + in_stats.get("skipped", 0), + "errors": out_stats.get("errors", 0) + in_stats.get("errors", 0), + "start_time": out_stats.get("start_time") or in_stats.get("start_time"), + "end_time": in_stats.get("end_time") or out_stats.get("end_time"), + "scope": "all", + } + try: + if isinstance(merged, dict): + request["mjr_stats"] = { + "scanned": merged.get("scanned", 0), + "added": merged.get("added", 0), + "updated": merged.get("updated", 0), + "skipped": merged.get("skipped", 0), + "errors": merged.get("errors", 0), + "scope": "all", + } + except Exception as exc: + logger.debug("Failed to attach scan stats: %s", exc) + return _json_response(Result.Ok(merged)) + else: + return _json_response(Result.Err("INVALID_INPUT", f"Unknown scope: {scope}")) + + # SECURITY: resolve directory strictly (follows symlinks) and validate it stays within allowed roots. + resolved_dir_res = _resolve_scan_root(str(directory)) + if not resolved_dir_res.ok: + return _json_response(resolved_dir_res) + normalized_dir = resolved_dir_res.data + + # Validate with strict root containment to prevent symlink/junction traversal outside roots. + allowed = False + try: + if scan_source == "output": + base_root_res = _resolve_scan_root(str(OUTPUT_ROOT)) + allowed = base_root_res.ok and _is_within_root(normalized_dir, base_root_res.data) + elif scan_source == "input": + base_root_res = _resolve_scan_root(str(folder_paths.get_input_directory())) + allowed = base_root_res.ok and _is_within_root(normalized_dir, base_root_res.data) + elif scan_source == "custom": + root_result = resolve_custom_root(str(custom_root_id or scan_root_id or "")) + if root_result.ok: + base_root_res = _resolve_scan_root(str(root_result.data)) + allowed = base_root_res.ok and _is_within_root(normalized_dir, base_root_res.data) + else: + allowed = False + else: + # No explicit scope: allow only within configured output/input or registered custom roots. + out_root = _resolve_scan_root(str(OUTPUT_ROOT)) + in_root = _resolve_scan_root(str(folder_paths.get_input_directory())) + allowed = ( + (out_root.ok and _is_within_root(normalized_dir, out_root.data)) + or (in_root.ok and _is_within_root(normalized_dir, in_root.data)) + or _is_path_allowed_custom(normalized_dir) + ) + except Exception as exc: + logger.debug("Scan root containment check failed: %s", exc) + allowed = False + + if not allowed or not (_is_path_allowed(normalized_dir) or _is_path_allowed_custom(normalized_dir)): + return _json_response(Result.Err("INVALID_INPUT", "Directory not allowed")) + + recursive = body.get("recursive", True) + incremental = body.get("incremental", True) + fast = bool(body.get("fast") or body.get("mode") == "fast" or body.get("manifest_only") is True) + background_metadata = bool(body.get("background_metadata") or body.get("enrich_metadata") or body.get("enqueue_metadata")) + + try: + result = await asyncio.to_thread( + svc["index"].scan_directory, + str(normalized_dir), + recursive, + incremental, + scan_source, + scan_root_id, + fast, + background_metadata, + ) + try: + if result.ok and isinstance(result.data, dict): + request["mjr_stats"] = { + "scanned": result.data.get("scanned", 0), + "added": result.data.get("added", 0), + "updated": result.data.get("updated", 0), + "skipped": result.data.get("skipped", 0), + "errors": result.data.get("errors", 0), + "scope": scan_source, + "fast": bool(fast), + } + except Exception as exc: + logger.debug("Failed to attach scan stats: %s", exc) + except Exception as exc: + logger.exception("Unhandled error while scanning directory") + error_result = Result.Err( + "SCAN_FAILED", + "Internal error while scanning assets", + detail=str(exc) + ) + return _json_response(error_result) + + return _json_response(result) + + @routes.post("/mjr/am/index-files") + async def index_files(request): + """ + Index a list of files (no directory scan). + + JSON body: + files: [{ filename, subfolder?, type? }] + incremental: Only update changed files (default: true) + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + try: + body = await request.json() + except Exception as e: + result = Result.Err("INVALID_JSON", f"Invalid JSON body: {e}") + return _json_response(result) + + files = body.get("files") + if not isinstance(files, list) or not files: + result = Result.Err("INVALID_INPUT", "Missing or invalid 'files' list") + return _json_response(result) + + incremental = body.get("incremental", True) + grouped_paths: Dict[Tuple[str, str, str], list] = {} + + for item in files: + if not isinstance(item, dict): + continue + filename = item.get("filename") or item.get("name") + if not filename: + continue + subfolder = item.get("subfolder") or "" + file_type = (item.get("type") or "output").lower() + root_id = item.get("root_id") or item.get("custom_root_id") + + if file_type == "input": + base_root = folder_paths.get_input_directory() + elif file_type == "custom": + root_result = resolve_custom_root(str(root_id or "")) + if not root_result.ok: + continue + base_root = str(root_result.data) + else: + base_root = OUTPUT_ROOT + + # SECURITY: subfolder must be a safe relative path (no traversal, no absolute/drive). + rel = _safe_rel_path(subfolder or "") + if rel is None: + continue + + base_dir = str(Path(base_root).resolve(strict=False)) + candidate = (Path(base_dir) / rel / filename) if str(rel) else (Path(base_dir) / filename) + try: + normalized = candidate.resolve(strict=True) + except Exception: + continue + if not normalized.exists() or not normalized.is_file(): + continue + if not _is_within_root(normalized, Path(base_dir)): + continue + if file_type == "custom": + if not _is_path_allowed_custom(normalized): + continue + else: + if not _is_path_allowed(normalized): + continue + + grouped_paths.setdefault((base_dir, file_type, str(root_id or "")), []).append(normalized) + + if not grouped_paths: + result = Result.Err("INVALID_INPUT", "No valid files to index") + return _json_response(result) + + total_stats = { + "scanned": 0, + "added": 0, + "updated": 0, + "skipped": 0, + "errors": 0, + "start_time": "", + "end_time": "" + } + + for (base_dir, file_type, root_id), paths in grouped_paths.items(): + source = file_type if file_type in ("output", "input", "custom") else "output" + try: + result = await asyncio.to_thread( + svc["index"].index_paths, + paths, + base_dir, + incremental, + source, + (root_id or None), + ) + except Exception as exc: + result = Result.Err("INDEX_FAILED", f"Indexing failed: {exc}") + if not result.ok: + return _json_response(result) + + stats = result.data or {} + if not total_stats["start_time"]: + total_stats["start_time"] = str(stats.get("start_time") or "") + total_stats["end_time"] = str(stats.get("end_time") or total_stats["end_time"] or "") + total_stats["scanned"] += stats.get("scanned", 0) + total_stats["added"] += stats.get("added", 0) + total_stats["updated"] += stats.get("updated", 0) + total_stats["skipped"] += stats.get("skipped", 0) + total_stats["errors"] += stats.get("errors", 0) + + try: + request["mjr_stats"] = { + "scanned": total_stats.get("scanned", 0), + "added": total_stats.get("added", 0), + "updated": total_stats.get("updated", 0), + "skipped": total_stats.get("skipped", 0), + "errors": total_stats.get("errors", 0), + "scope": "index-files", + } + except Exception as exc: + logger.debug("Failed to attach scan stats: %s", exc) + + return _json_response(Result.Ok(total_stats)) + + @routes.post("/mjr/am/stage-to-input") + async def stage_to_input(request): + """ + Copy files into the ComfyUI input directory. + + JSON body: + files: [{ filename, subfolder?, type? }] + index: bool (optional, default true) - whether to index staged files into the DB + purpose: string (optional) - purpose of staging (e.g. "node_drop" for fast path) + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + + try: + body = await request.json() + except Exception as e: + result = Result.Err("INVALID_JSON", f"Invalid JSON body: {e}") + return _json_response(result) + + # Check if this is a fast path request (e.g., for node drop) + purpose = body.get("purpose", "").lower().strip() + skip_index = (purpose == "node_drop") + + # Allow explicit index override to take precedence + index_staged = body.get("index", not skip_index) # Default to not indexing if purpose is node_drop + if isinstance(index_staged, str): + index_staged = index_staged.strip().lower() in ("1", "true", "yes", "on") + else: + index_staged = bool(index_staged) + + files = body.get("files") + if not isinstance(files, list) or not files: + result = Result.Err("INVALID_INPUT", "Missing or invalid 'files' list") + return _json_response(result) + + input_root = Path(folder_paths.get_input_directory()).resolve() + staged = [] + staged_paths = [] + errors = [] + + def _safe_filename(name: str) -> Optional[str]: + if not name or "\x00" in name: + return None + cleaned = Path(name).name + if cleaned != name: + return None + return cleaned + + def _safe_rel_subfolder(value: Any) -> Optional[Path]: + if value is None: + return None + if not isinstance(value, str): + return None + text = value.strip() + if text == "": + return Path(".") + if "\x00" in text: + return None + p = Path(text) + # Windows drive paths like "C:..." or absolute paths are not allowed. + if getattr(p, "drive", ""): + return None + if p.is_absolute(): + return None + # Disallow traversal + if any(part == ".." for part in p.parts): + return None + return p + + for item in files: + if not isinstance(item, dict): + continue + raw_filename = item.get("filename") or item.get("name") + filename = _safe_filename(raw_filename) + if not filename: + errors.append({"file": raw_filename, "error": "Invalid filename"}) + continue + + subfolder = item.get("subfolder") or "" + file_type = (item.get("type") or "output").lower() + root_id = item.get("root_id") or item.get("custom_root_id") + dest_subfolder = _safe_rel_subfolder(item.get("dest_subfolder", None)) + if "dest_subfolder" in item and dest_subfolder is None: + errors.append({"file": raw_filename, "error": "Invalid dest_subfolder"}) + continue + + if file_type == "input": + base_root = folder_paths.get_input_directory() + elif file_type == "custom": + root_result = resolve_custom_root(str(root_id or "")) + if not root_result.ok: + errors.append({"file": raw_filename, "error": root_result.error}) + continue + base_root = str(root_result.data) + else: + base_root = OUTPUT_ROOT + base_dir = str(Path(base_root).resolve()) + + candidate = Path(base_dir) / subfolder / filename if subfolder else Path(base_dir) / filename + normalized = _normalize_path(str(candidate)) + if not normalized or not normalized.exists(): + errors.append({"file": raw_filename, "error": "Source file not found"}) + continue + + if file_type == "custom": + if not _is_within_root(normalized, Path(base_dir)): + errors.append({"file": raw_filename, "error": "Source file outside custom root"}) + continue + else: + if not _is_path_allowed(normalized): + errors.append({"file": raw_filename, "error": "Source file not allowed"}) + continue + + # Destination: + # - default: keep current behavior under input/mjr_staged/ + # - if dest_subfolder is provided: stage under input/ (no extra prefix) + if dest_subfolder is None: + dest_dir = input_root / "mjr_staged" + if subfolder: + dest_dir = dest_dir / subfolder + else: + dest_dir = (input_root / dest_subfolder).resolve() + if not _is_within_root(dest_dir, input_root): + errors.append({"file": raw_filename, "error": "Invalid dest_subfolder"}) + continue + dest_dir.mkdir(parents=True, exist_ok=True) + if not dest_dir.is_dir(): + errors.append({"file": raw_filename, "error": "Destination path exists but is not a directory"}) + continue + + resolved = _resolve_stage_destination(dest_dir, filename, Path(str(normalized))) + dest_path = resolved.path + if resolved.reused_existing: + try: + staged_paths.append(dest_path) + except Exception as exc: + logger.debug("Failed to track staged path: %s", exc) + + staged.append({ + "name": dest_path.name, + "subfolder": "" if dest_dir == input_root else str(dest_dir.relative_to(input_root)), + "path": str(dest_path) + }) + continue + + try: + # Performance: for large media, copying can be slow. Run in thread to avoid blocking event loop. + # Prefer a hardlink when possible (same filesystem/volume). Fall back to copy2 if linking is not supported. + def link_or_copy(): + did_link = False + try: + src = Path(str(normalized)) + dst = Path(str(dest_path)) + # Only attempt hardlink when both paths are on the same drive (Windows) + # or when the OS supports hardlinks generally. + same_drive = True + try: + if getattr(src, "drive", "") and getattr(dst, "drive", ""): + same_drive = src.drive.lower() == dst.drive.lower() + except Exception: + same_drive = True + + if same_drive: + import os + os.link(str(src), str(dst)) + did_link = True + except Exception: + did_link = False + + if not did_link: + shutil.copy2(str(normalized), str(dest_path)) + return did_link + + # Run the potentially slow file operation in a thread + did_link = await asyncio.to_thread(link_or_copy) + except Exception as exc: + errors.append({"file": raw_filename, "error": str(exc)}) + continue + try: + staged_paths.append(dest_path) + except Exception as exc: + logger.debug("Failed to track staged path: %s", exc) + + staged.append({ + "name": dest_path.name, + "subfolder": "" if dest_dir == input_root else str(dest_dir.relative_to(input_root)), + "path": str(dest_path) + }) + + if not staged: + result = Result.Err("STAGE_FAILED", "No files staged", errors=errors) + return _json_response(result) + + # Ensure staged files are indexed in DB as input assets (best-effort). + try: + if index_staged and staged_paths: + # Index in the background so staging returns quickly (DnD UX), + # and avoid blocking the aiohttp event loop on ExifTool/FFprobe. + asyncio.create_task( + asyncio.to_thread( + svc["index"].index_paths, + staged_paths, + str(input_root), + True, # incremental + "input", + ) + ) + except Exception as exc: + logger.debug("Indexing staged files skipped: %s", exc) + + return _json_response(Result.Ok({"staged": staged, "errors": errors})) + + @routes.post("/mjr/am/upload_input") + async def upload_input_file(request): + """ + Upload a file directly to the ComfyUI input directory. + + Multipart form data with 'file' field. + Query params: + - purpose: string (optional) - purpose of upload (e.g. "node_drop" for fast path) + """ + # CSRF protection + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err("CSRF", csrf)) + from aiohttp import web + import shutil + from pathlib import Path + + try: + reader = await request.multipart() + + # Get the file from multipart data + field = await reader.next() + if field.name != 'file': + return _json_response(Result.Err("INVALID_INPUT", "Expected 'file' field")) + + filename = field.filename + if not filename: + return _json_response(Result.Err("INVALID_INPUT", "No filename provided")) + + # Sanitize filename with comprehensive security checks + filename = Path(filename).name + if not filename or "\x00" in filename: + return _json_response(Result.Err("INVALID_INPUT", "Invalid filename")) + + # Block dangerous patterns + if ".." in filename or filename.startswith('.') or '/' in filename or '\\' in filename: + return _json_response(Result.Err("INVALID_INPUT", "Invalid filename")) + + # Additional security checks + forbidden_extensions = ['.exe', '.bat', '.cmd', '.scr', '.com', '.pif', '.lnk', '.hta', '.msi', '.msp', '.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh', '.ps1', '.ps1xml', '.ps2', '.ps2xml', '.psc1', '.psc2', '.msh', '.msh1', '.msh2', '.mshxml', '.msh1xml', '.msh2xml', '.scf', '.lnk', '.inf', '.reg', '.dll', '.sys', '.bin', '.tmp', '.dat', '.ini'] + if any(filename.lower().endswith(ext) for ext in forbidden_extensions): + return _json_response(Result.Err("INVALID_INPUT", "File extension not allowed")) + + # Get purpose from query params + purpose = request.query.get("purpose", "").lower().strip() + skip_index = (purpose == "node_drop") + + # Get input directory + try: + import folder_paths + input_dir = Path(folder_paths.get_input_directory()).resolve() + except Exception: + input_dir = Path(__file__).parent.parent.parent.parent / "input" + input_dir = input_dir.resolve() + + # Create destination path + dest_path = input_dir / filename + + # Handle filename collision atomically to prevent race conditions + if dest_path.exists(): + stem = dest_path.stem + suffix = dest_path.suffix + counter = 1 + while True: + candidate_path = input_dir / f"{stem}_{counter}{suffix}" + try: + # Use 'x' mode to create file only if it doesn't exist (atomic) + with open(candidate_path, 'xb'): + pass # Create empty file to claim the name + dest_path = candidate_path + break # Successfully found unique name + except FileExistsError: + counter += 1 + continue + + # Create the destination directory if it doesn't exist + dest_path.parent.mkdir(parents=True, exist_ok=True) + + # Stream the file to disk in chunks to avoid loading large files in memory + # Run in thread to avoid blocking the event loop during large file operations + async def write_file_async(): + with open(dest_path, 'wb') as f: + while True: + chunk = await field.read_chunk(size=262144) # 256KB chunks + if not chunk: + break + f.write(chunk) + + await write_file_async() + + # Optionally index the file (skip for fast path) + if not skip_index: + try: + svc, error_result = _require_services() + if svc and svc.get("index"): + # Index in the background so upload returns quickly + asyncio.create_task( + asyncio.to_thread( + svc["index"].index_paths, + [dest_path], + str(input_dir), + True, # incremental + "input", + ) + ) + except Exception as exc: + logger.debug("Indexing uploaded file skipped: %s", exc) + + return _json_response(Result.Ok({ + "name": dest_path.name, + "subfolder": "", + "path": str(dest_path) + })) + + except Exception as e: + logger.error(f"Upload failed: {e}") + return _json_response(Result.Err("UPLOAD_FAILED", f"Upload failed: {str(e)}")) + + @routes.get("/mjr/am/download") + async def download_asset(request): + """ + Download an asset file by asset ID or path. + + Query params: + - asset_id: integer asset ID (optional) + - path: relative path (optional) + - type: asset type (output, input, custom) - default: output + - filename: filename for Content-Disposition header + """ + from aiohttp import web, StreamResponse + import mimetypes + from pathlib import Path + + # Get parameters + asset_id = request.query.get("asset_id") + path = request.query.get("path") + asset_type = request.query.get("type", "output").lower() + filename = request.query.get("filename") + + # Validate inputs + if not asset_id and not path: + return _json_response(Result.Err("INVALID_INPUT", "Either asset_id or path must be provided")) + + # Get file path based on asset_id or path + file_path = None + + if asset_id: + try: + asset_id = int(asset_id) + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid asset_id")) + + # Get asset from database + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + try: + asset_result = svc["index"].get_asset(asset_id) + if not asset_result.ok or not asset_result.data: + return _json_response(Result.Err("NOT_FOUND", "Asset not found")) + + asset_data = asset_result.data + file_path = Path(asset_data.get("filepath", "")) + + if not filename: + filename = asset_data.get("filename", "download") + except Exception as e: + return _json_response(Result.Err("QUERY_ERROR", f"Error fetching asset: {str(e)}")) + else: + # Use path directly + if asset_type == "input": + try: + import folder_paths + base_dir = Path(folder_paths.get_input_directory()) + except Exception: + base_dir = Path(__file__).parent.parent.parent.parent / "input" + elif asset_type == "custom": + # For custom paths, we need to validate carefully + return _json_response(Result.Err("INVALID_INPUT", "Path-based download not supported for custom assets")) + else: # output + from backend.config import OUTPUT_ROOT + base_dir = Path(OUTPUT_ROOT) + + # Validate path is within allowed directory + try: + file_path = (base_dir / path).resolve() + if not _is_within_root(file_path, base_dir): + return _json_response(Result.Err("INVALID_INPUT", "Path outside allowed directory")) + except Exception: + return _json_response(Result.Err("INVALID_INPUT", "Invalid path")) + + # Validate file exists + if not file_path or not file_path.exists() or not file_path.is_file(): + return _json_response(Result.Err("NOT_FOUND", "File not found")) + + # Validate file is allowed to be accessed + if not _is_path_allowed(file_path): + return _json_response(Result.Err("FORBIDDEN", "File access not allowed")) + + # Sanitize filename to prevent header injection + safe_filename = (filename or file_path.name).replace('"', '').replace('\r', '').replace('\n', '') + safe_filename = safe_filename[:255] # Limit length + + # Determine content type + content_type, _ = mimetypes.guess_type(str(file_path)) + if not content_type: + content_type = "application/octet-stream" + + # Use FileResponse for better performance with large files + from aiohttp import FileResponse + return FileResponse( + file_path, + headers={ + "Content-Type": content_type, + "Content-Disposition": f'attachment; filename="{safe_filename}"', + "Cache-Control": "no-cache", + "Content-Security-Policy": "default-src 'none'", + "X-Content-Type-Options": "nosniff", + } + ) diff --git a/backend/routes/handlers/search.py b/backend/routes/handlers/search.py new file mode 100644 index 00000000..0645c302 --- /dev/null +++ b/backend/routes/handlers/search.py @@ -0,0 +1,622 @@ +""" +Search and list endpoints. +""" +from pathlib import Path +from aiohttp import web + +try: + import folder_paths # type: ignore +except Exception: + class _FolderPathsStub: + @staticmethod + def get_input_directory() -> str: + return str((Path(__file__).resolve().parents[3] / "input").resolve()) + + folder_paths = _FolderPathsStub() # type: ignore + +from backend.config import OUTPUT_ROOT +from backend.custom_roots import resolve_custom_root +from backend.shared import Result +from backend.features.index.metadata_helpers import MetadataHelpers +from ..core import _json_response, _require_services +from .filesystem import _list_filesystem_assets, _kickoff_background_scan + + +def register_search_routes(routes: web.RouteTableDef) -> None: + @routes.get("/mjr/am/list") + async def list_assets(request): + """ + List assets for a given UI scope. + + Query params: + scope: output|input|all|custom (default: output) + q: search query (default: '*') + limit: number of items (default: 50) + offset: pagination offset (default: 0) + subfolder: for input/custom browsing (default: '') + custom_root_id: required when scope=custom + """ + scope = (request.query.get("scope") or "output").strip().lower() + raw_query = (request.query.get("q") or "").strip() + query = raw_query or "*" + + MAX_OFFSET = 1_000_000 + try: + limit = int(request.query.get("limit", "50")) + offset = int(request.query.get("offset", "0")) + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid limit or offset")) + + limit = max(0, min(5000, limit)) + offset = max(0, offset) + if offset > MAX_OFFSET: + return _json_response(Result.Err("INVALID_INPUT", f"Offset must be less than {MAX_OFFSET}")) + + filters = {} + if "kind" in request.query: + valid_kinds = {"image", "video", "audio", "model3d"} + kind = request.query["kind"].strip().lower() + if kind not in valid_kinds: + return _json_response(Result.Err("INVALID_INPUT", f"Invalid kind. Must be one of: {', '.join(sorted(valid_kinds))}")) + filters["kind"] = kind + if "min_rating" in request.query: + try: + filters["min_rating"] = max(0, min(5, int(request.query["min_rating"]))) + except ValueError: + return _json_response(Result.Err("INVALID_INPUT", "Invalid min_rating")) + if "has_workflow" in request.query: + filters["has_workflow"] = request.query["has_workflow"].lower() in ("true", "1", "yes") + + include_total = request.query.get("include_total", "1").strip().lower() not in ("0", "false", "no", "off") + + if scope == "input": + subfolder = request.query.get("subfolder", "") + root_dir = Path(folder_paths.get_input_directory()) + svc, _ = _require_services() + + # If index service is available, try DB-first approach + if svc and svc.get("index"): + # Normalize root path for DB lookup + root_path = str(root_dir.resolve(strict=False)) + + # Call index searcher with filters, limit, and offset + db_result = svc["index"].search_scoped( + query, + roots=[root_path], + limit=limit, + offset=offset, + filters=filters or None, + include_total=include_total, + ) + + if db_result.ok: + # Add type to results for consistency + for a in db_result.data.get("assets") or []: + a["type"] = "input" + + # Add scope info to response + db_result.data["scope"] = "input" + + return _json_response(db_result) + + # Fallback to filesystem if DB approach fails or is unavailable + if query == "*" and offset == 0 and not filters: + # Avoid scanning entire input trees just by opening the tab; only scan the current folder. + _kickoff_background_scan(str(root_dir), source="input", recursive=False, incremental=True) + result = _list_filesystem_assets( + root_dir, + subfolder, + query, + limit, + offset, + asset_type="input", + filters=filters or None, + index_service=(svc or {}).get("index") if isinstance(svc, dict) else None, + ) + return _json_response(result) + + if scope == "custom": + subfolder = request.query.get("subfolder", "") + root_id = request.query.get("custom_root_id", "") or request.query.get("root_id", "") + root_result = resolve_custom_root(str(root_id or "")) + if not root_result.ok: + return _json_response(root_result) + root_dir = root_result.data + svc, _ = _require_services() + + # If index service is available, try DB-first approach + if svc and svc.get("index"): + # Normalize root path for DB lookup + root_path = str(root_dir.resolve(strict=False)) + + # Call index searcher with filters, limit, and offset + db_result = svc["index"].search_scoped( + query, + roots=[root_path], + limit=limit, + offset=offset, + filters=filters or None, + include_total=include_total, + ) + + if db_result.ok: + # Add type and root_id to results for consistency + for a in db_result.data.get("assets") or []: + a["type"] = "custom" + if root_id: + a["root_id"] = root_id + + # Add scope info to response + db_result.data["scope"] = "custom" + + return _json_response(db_result) + + # Fallback to filesystem if DB approach fails or is unavailable + if query == "*" and offset == 0 and not filters: + # Avoid scanning entire custom trees just by opening the tab; only scan the current folder. + _kickoff_background_scan(str(root_dir), source="custom", root_id=str(root_id), recursive=False, incremental=True) + result = _list_filesystem_assets( + root_dir, + subfolder, + query, + limit, + offset, + asset_type="custom", + root_id=str(root_id), + filters=filters or None, + index_service=(svc or {}).get("index") if isinstance(svc, dict) else None, + ) + return _json_response(result) + + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + output_root = str(Path(OUTPUT_ROOT).resolve(strict=False)) + input_root = str(Path(folder_paths.get_input_directory()).resolve(strict=False)) + + if scope == "all": + # Prefer a single DB query when Inputs have been indexed (fast LIMIT/OFFSET, avoids O(offset) merges). + # Fallback to the legacy Output(DB)+Input(filesystem) behavior until the Input root is present in DB. + is_browse_all = query == "*" + + input_indexed = False + try: + has_input = svc["index"].has_assets_under_root(input_root) + if has_input.ok: + input_indexed = bool(has_input.data) + except Exception: + input_indexed = False + + if not input_indexed and query == "*" and offset == 0 and not filters: + # Opportunistically index the input root so `scope=all` can become DB-only on later requests. + _kickoff_background_scan(str(Path(input_root)), source="input", recursive=False, incremental=True) + + if input_indexed: + scoped = svc["index"].search_scoped(query, roots=[output_root, input_root], limit=limit, offset=offset, filters=filters or None) + if not scoped.ok: + return _json_response(scoped) + data = scoped.data or {} + assets = data.get("assets") or [] + + def _under(root: str, fp: str) -> bool: + try: + root_n = root.replace("/", "\\").casefold().rstrip("\\") + "\\" + fp_n = fp.replace("/", "\\").casefold() + return fp_n == root_n.rstrip("\\") or fp_n.startswith(root_n) + except Exception: + return False + + for a in assets: + src = str((a or {}).get("source") or "").strip().lower() + if src in ("input", "output", "custom"): + a["type"] = src + else: + fp = str((a or {}).get("filepath") or "") + if fp and _under(input_root, fp): + a["type"] = "input" + else: + a["type"] = "output" + + return _json_response( + Result.Ok( + { + "assets": assets, + "total": int(data.get("total") or 0), + "limit": limit, + "offset": offset, + "query": query, + "scope": scope, + } + ) + ) + + if not limit: + # Keep behavior predictable when limit==0: return empty page but valid total. + out_count = svc["index"].search_scoped(query, roots=[output_root], limit=0, offset=0, filters=filters or None) + if not out_count.ok: + return _json_response(out_count) + in_count = _list_filesystem_assets(Path(input_root), "", query, 0, 0, asset_type="input", filters=filters or None) + if not in_count.ok: + return _json_response(in_count) + total = int((out_count.data or {}).get("total") or 0) + int((in_count.data or {}).get("total") or 0) + return _json_response(Result.Ok({"assets": [], "total": total, "limit": 0, "offset": offset, "query": query, "scope": scope})) + + # Non-browse queries keep deterministic concatenation: outputs first, then inputs. + # This enables cheap pagination by splitting offsets instead of fetching everything. + if not is_browse_all: + out_res = svc["index"].search_scoped(query, roots=[output_root], limit=limit, offset=offset, filters=filters or None) + if not out_res.ok: + return _json_response(out_res) + out_data = out_res.data or {} + out_assets = out_data.get("assets") or [] + for a in out_assets: + a["type"] = "output" + out_total = int(out_data.get("total") or 0) + + # If the requested page fits within outputs, we're done. + if offset + limit <= out_total: + in_total_res = _list_filesystem_assets(Path(input_root), "", query, 0, 0, asset_type="input", filters=filters or None) + if not in_total_res.ok: + return _json_response(in_total_res) + total = out_total + int((in_total_res.data or {}).get("total") or 0) + return _json_response(Result.Ok({"assets": out_assets, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope})) + + # Need to spill over into inputs (or we're already past outputs). + need = max(0, limit - len(out_assets)) + in_offset = max(0, offset - out_total) + in_res = _list_filesystem_assets(Path(input_root), "", query, need, in_offset, asset_type="input", filters=filters or None) + if not in_res.ok: + return _json_response(in_res) + in_data = in_res.data or {} + in_assets = in_data.get("assets") or [] + total = out_total + int(in_data.get("total") or 0) + assets = [*out_assets, *in_assets] + return _json_response(Result.Ok({"assets": assets, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope})) + + # Browse-all (`*`): merge by mtime for better UX, with chunked streaming merge. + chunk = max(50, min(500, int(limit) * 2)) + out_offset = 0 + in_offset = 0 + out_buf = [] + in_buf = [] + out_i = 0 + in_i = 0 + out_total = None + in_total = None + + def _mtime(item) -> int: + try: + return int((item or {}).get("mtime") or 0) + except Exception: + return 0 + + def _fill_out(): + nonlocal out_buf, out_total, out_offset + if out_total is not None and out_offset >= out_total: + return + res = svc["index"].search_scoped("*", roots=[output_root], limit=chunk, offset=out_offset, filters=filters or None) + if not res.ok: + return res + data = res.data or {} + out_total = int(data.get("total") or 0) + items = data.get("assets") or [] + for a in items: + a["type"] = "output" + out_offset += len(items) + out_buf.extend(items) + return None + + def _fill_in(): + nonlocal in_buf, in_total, in_offset + if in_total is not None and in_offset >= in_total: + return + res = _list_filesystem_assets(Path(input_root), "", "*", chunk, in_offset, asset_type="input", filters=filters or None) + if not res.ok: + return res + data = res.data or {} + in_total = int(data.get("total") or 0) + items = data.get("assets") or [] + in_offset += len(items) + in_buf.extend(items) + return None + + # Prime buffers (and totals) + err = _fill_out() + if err: + return _json_response(err) + err = _fill_in() + if err: + return _json_response(err) + + total = int(out_total or 0) + int(in_total or 0) + target = offset + limit + produced = 0 + page = [] + + while produced < target: + if out_i >= len(out_buf) and (out_total is None or out_offset < out_total): + err = _fill_out() + if err: + return _json_response(err) + if in_i >= len(in_buf) and (in_total is None or in_offset < in_total): + err = _fill_in() + if err: + return _json_response(err) + + if out_i >= len(out_buf) and in_i >= len(in_buf): + break + + pick_out = False + out_has = out_i < len(out_buf) + in_has = in_i < len(in_buf) + if out_has and in_has: + pick_out = _mtime(out_buf[out_i]) >= _mtime(in_buf[in_i]) + elif out_has: + pick_out = True + + if pick_out: + item = out_buf[out_i] + out_i += 1 + else: + item = in_buf[in_i] + in_i += 1 + + # Periodically truncate consumed prefixes to keep buffers small. + if out_i > 256 and out_i >= len(out_buf) // 2: + out_buf = out_buf[out_i:] + out_i = 0 + if in_i > 256 and in_i >= len(in_buf) // 2: + in_buf = in_buf[in_i:] + in_i = 0 + + if produced >= offset: + page.append(item) + if len(page) >= limit: + break + produced += 1 + + return _json_response(Result.Ok({"assets": page, "total": total, "limit": limit, "offset": offset, "query": query, "scope": scope})) + + if scope not in ("output", "outputs"): + return _json_response(Result.Err("INVALID_INPUT", f"Unknown scope: {scope}")) + + # Use DB search for output scope + out_res = svc["index"].search_scoped( + query, + roots=[output_root], + limit=limit, + offset=offset, + filters=filters or None, + include_total=include_total, + ) + if not out_res.ok: + return _json_response(out_res) + for a in out_res.data.get("assets") or []: + a["type"] = "output" + return _json_response(Result.Ok({**out_res.data, "scope": "output"})) + + @routes.get("/mjr/am/search") + async def search_assets(request): + """ + Search assets using FTS5. + + Query params: + q: Search query + limit: Max results (default: 50) + offset: Pagination offset (default: 0) + kind: Filter by file kind (image, video, audio, model3d) + min_rating: Filter by minimum rating (0-5) + has_workflow: Filter by workflow presence (true/false) + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + raw_query = request.query.get("q", "").strip() + query = raw_query or "*" + + # Parse pagination + MAX_OFFSET = 1_000_000 + try: + limit = int(request.query.get("limit", "50")) + offset = int(request.query.get("offset", "0")) + except ValueError: + result = Result.Err("INVALID_INPUT", "Invalid limit or offset") + return _json_response(result) + + limit = max(0, min(5000, limit)) + offset = max(0, offset) + if offset > MAX_OFFSET: + return _json_response(Result.Err("INVALID_INPUT", f"Offset must be less than {MAX_OFFSET}")) + + # Parse filters + filters = {} + if "kind" in request.query: + valid_kinds = {"image", "video", "audio", "model3d"} + kind = request.query["kind"].strip().lower() + if kind not in valid_kinds: + return _json_response(Result.Err("INVALID_INPUT", f"Invalid kind. Must be one of: {', '.join(sorted(valid_kinds))}")) + filters["kind"] = kind + if "min_rating" in request.query: + try: + filters["min_rating"] = max(0, min(5, int(request.query["min_rating"]))) + except ValueError: + result = Result.Err("INVALID_INPUT", "Invalid min_rating") + return _json_response(result) + if "has_workflow" in request.query: + filters["has_workflow"] = request.query["has_workflow"].lower() in ("true", "1", "yes") + + include_total = request.query.get("include_total", "1").strip().lower() not in ("0", "false", "no", "off") + result = svc["index"].search(query, limit, offset, filters if filters else None, include_total=include_total) + return _json_response(result) + + @routes.post("/mjr/am/assets/batch") + async def get_assets_batch(request): + """ + Batch fetch assets by ID. + + JSON body: + asset_ids: [1, 2, 3, ...] + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + try: + body = await request.json() + except Exception as e: + return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {e}")) + + raw_ids = body.get("asset_ids") or body.get("ids") or [] + if not isinstance(raw_ids, list): + return _json_response(Result.Err("INVALID_INPUT", "asset_ids must be a list")) + + ids = [] + for raw in raw_ids: + try: + n = int(raw) + except Exception: + continue + if n <= 0: + continue + ids.append(n) + if len(ids) >= 200: + break + + result = svc["index"].get_assets_batch(ids) + return _json_response(result) + + @routes.get("/mjr/am/workflow-quick") + async def get_workflow_quick(request): + """ + Ultra-fast workflow-only lookup (no self-heal, no metadata extraction). + + Query params: + filename: Asset filename + subfolder: Optional subfolder + type: Asset type (output, input, custom) + root_id: Optional custom root ID + + Returns just the workflow JSON if available, or null. + Designed for drag & drop operations where speed is critical. + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + filename = request.query.get("filename", "").strip() + if not filename: + return _json_response(Result.Err("INVALID_INPUT", "Missing filename")) + + subfolder = request.query.get("subfolder", "").strip() + asset_type = request.query.get("type", "output").strip().lower() + root_id = request.query.get("root_id", "").strip() + + try: + # Direct SQL query without self-heal for maximum speed + where_parts = ["a.filename = ?", "a.subfolder = ?", "a.source = ?"] + params = [filename, subfolder, asset_type] + + if root_id: + where_parts.append("a.root_id = ?") + params.append(root_id) + + where_clause = " AND ".join(where_parts) + + result = svc["index"].db.query( + f""" + SELECT m.metadata_raw, m.has_workflow + FROM assets a + LEFT JOIN asset_metadata m ON a.id = m.asset_id + WHERE {where_clause} + LIMIT 1 + """, + tuple(params) + ) + + if result.ok and result.data and len(result.data) > 0: + metadata_raw = result.data[0].get("metadata_raw") + has_workflow = result.data[0].get("has_workflow") + + # Check if we have workflow data in metadata_raw + if metadata_raw and (has_workflow or has_workflow is None): + try: + import json + metadata = json.loads(metadata_raw) if isinstance(metadata_raw, str) else metadata_raw + workflow = metadata.get("workflow") if isinstance(metadata, dict) else None + if workflow: + return web.json_response({"ok": True, "workflow": workflow}) + except Exception: + pass + + return web.json_response({"ok": True, "workflow": None}) + + except Exception as e: + logger.error(f"workflow-quick lookup failed: {e}") + return _json_response(Result.Err("QUERY_ERROR", str(e))) + + @routes.get("/mjr/am/asset/{asset_id}") + async def get_asset(request): + """ + Get a single asset by ID. + + Path params: + asset_id: Asset database ID + """ + svc, error_result = _require_services() + if error_result: + return _json_response(error_result) + + try: + asset_id = int(request.match_info["asset_id"]) + except ValueError: + result = Result.Err("INVALID_INPUT", "Invalid asset_id") + return _json_response(result) + + hydrate = (request.query.get("hydrate") or "").strip().lower() + + result = svc["index"].get_asset(asset_id) + if not result.ok or not result.data: + return _json_response(result) + + if hydrate in ("rating_tags", "ratingtags", "rt"): + try: + current = result.data or {} + rating = int(current.get("rating") or 0) + tags = current.get("tags") or [] + tags_empty = True + if isinstance(tags, list): + tags_empty = len(tags) == 0 + elif isinstance(tags, str): + tags_empty = tags.strip() in ("", "[]") + + # Only hydrate when DB doesn't have values yet. + if rating <= 0 or tags_empty: + fp = current.get("filepath") + if isinstance(fp, str) and fp: + meta_svc = svc.get("metadata") + db = svc.get("db") + if meta_svc and db: + meta_res = meta_svc.extract_rating_tags_only(fp) + if meta_res and meta_res.ok and meta_res.data: + try: + MetadataHelpers.write_asset_metadata_row( + db, + asset_id, + Result.Ok( + { + "rating": meta_res.data.get("rating"), + "tags": meta_res.data.get("tags") or [], + "quality": "partial", + } + ), + ) + except Exception: + pass + # Re-fetch after best-effort update. + result = svc["index"].get_asset(asset_id) + except Exception: + pass + + return _json_response(result) diff --git a/backend/routes/registry.py b/backend/routes/registry.py new file mode 100644 index 00000000..9521202d --- /dev/null +++ b/backend/routes/registry.py @@ -0,0 +1,90 @@ +""" +Route registration system. +Coordinates all route handlers and registers them with PromptServer or aiohttp app. +""" +from aiohttp import web +from backend.shared import get_logger +from backend.observability import ensure_observability + +try: + from server import PromptServer # type: ignore +except Exception: + class _PromptServerStub: + instance = type("PromptServerInstance", (), {"routes": web.RouteTableDef()})() + + PromptServer = _PromptServerStub # type: ignore + +from .handlers import ( + register_health_routes, + register_metadata_routes, + register_custom_roots_routes, + register_search_routes, + register_scan_routes, + register_asset_routes, +) + +logger = get_logger(__name__) + + +def register_all_routes() -> web.RouteTableDef: + """ + Register all route handlers and return the RouteTableDef. + This is the central registration point for all routes. + """ + routes = PromptServer.instance.routes + + # Register all handler modules + register_health_routes(routes) + register_metadata_routes(routes) + register_custom_roots_routes(routes) + register_search_routes(routes) + register_scan_routes(routes) + register_asset_routes(routes) + + logger.info("=" * 60) + logger.info("Routes registered:") + logger.info(" GET /mjr/am/health") + logger.info(" GET /mjr/am/health/counters") + logger.info(" GET /mjr/am/config") + logger.info(" GET /mjr/am/tools/status") + logger.info(" GET /mjr/am/metadata?path=") + logger.info(" POST /mjr/am/scan") + logger.info(" POST /mjr/am/index-files") + logger.info(" POST /mjr/am/stage-to-input") + logger.info(" POST /mjr/am/open-in-folder") + logger.info(" GET /mjr/am/search?q=") + logger.info(" GET /mjr/am/asset/{asset_id}") + logger.info("=" * 60) + + return routes + + +def register_routes(app: web.Application) -> None: + """ + Register routes onto an aiohttp application. + + This is primarily used for unit tests; in ComfyUI, routes are registered + via PromptServer decorators at import time. + """ + try: + ensure_observability(app) + except Exception as exc: + logger.debug("Observability not installed on aiohttp app: %s", exc) + try: + app.add_routes(PromptServer.instance.routes) + except Exception as exc: + logger.warning("Failed to register routes on aiohttp app: %s", exc) + + +def _install_observability_on_prompt_server() -> None: + """ + Best-effort installation of the middleware into ComfyUI's PromptServer app. + If PromptServer doesn't expose `app`, this safely no-ops. + """ + try: + app = getattr(PromptServer.instance, "app", None) + if app is not None: + ensure_observability(app) + except Exception as exc: + logger.debug("Observability not installed on PromptServer app: %s", exc) + return diff --git a/backend/routes_compat.py b/backend/routes_compat.py new file mode 100644 index 00000000..fc50af71 --- /dev/null +++ b/backend/routes_compat.py @@ -0,0 +1,110 @@ +""" +HTTP routes for ComfyUI integration. + +BACKWARD COMPATIBILITY SHIM: + +The HTTP layer was refactored into a package under `backend/routes/`. +This module keeps the previous re-exports for any external code that imported +symbols from the old `backend.routes` module. + +Important: +Python can get ambiguous when a package directory and a module file share the +same name. This shim is deliberately named `backend.routes_compat` so the +canonical entry-point remains the `backend.routes` *package*. +""" + +# Import the modular routes system which auto-registers everything. +from .routes import ( # noqa: F401 + register_routes, + register_all_routes, + COMFY_OUTPUT_DIR, +) + +# Re-export core utilities for backward compatibility. +from .routes.core import ( # noqa: F401 + _json_response, + _normalize_path, + _is_path_allowed, + _is_path_allowed_custom, + _safe_rel_path, + _is_within_root, + _get_allowed_directories, + _check_rate_limit, + _csrf_error, + _require_services, + _build_services, +) + +# Re-export filesystem utilities. +from .routes.handlers.filesystem import ( # noqa: F401 + _kickoff_background_scan, + _list_filesystem_assets, +) + +# Re-export for any code that might reference these. +from .config import OUTPUT_ROOT # noqa: F401 +from .shared import Result, get_logger # noqa: F401 + +logger = get_logger(__name__) + +# Expose folder_paths stub for compatibility. +try: + import folder_paths # type: ignore +except Exception: # pragma: no cover + from pathlib import Path + + class _FolderPathsStub: + @staticmethod + def get_input_directory() -> str: + return str((Path(__file__).resolve().parents[2] / "input").resolve()) + + @staticmethod + def get_output_directory() -> str: + return str((Path(__file__).resolve().parents[2] / "output").resolve()) + + folder_paths = _FolderPathsStub() # type: ignore + +# Expose PromptServer stub for compatibility. +try: + from server import PromptServer # type: ignore +except Exception: # pragma: no cover + from aiohttp import web + + class _PromptServerStub: + instance = type("PromptServerInstance", (), {"routes": web.RouteTableDef()})() + + PromptServer = _PromptServerStub # type: ignore + +__all__ = [ + # Main functions + "register_routes", + "register_all_routes", + + # Constants + "COMFY_OUTPUT_DIR", + "OUTPUT_ROOT", + + # Core utilities + "_json_response", + "_normalize_path", + "_is_path_allowed", + "_is_path_allowed_custom", + "_safe_rel_path", + "_is_within_root", + "_get_allowed_directories", + "_check_rate_limit", + "_csrf_error", + "_require_services", + "_build_services", + + # Filesystem utilities + "_kickoff_background_scan", + "_list_filesystem_assets", + + # Dependencies + "folder_paths", + "PromptServer", + "Result", + "logger", +] + diff --git a/backend/shared.py b/backend/shared.py new file mode 100644 index 00000000..e37ea6bf --- /dev/null +++ b/backend/shared.py @@ -0,0 +1,40 @@ +"""Backend-facing alias for shared utilities.""" + +try: + # When loaded by ComfyUI as a package (e.g. .backend.*) + from .. import shared as _root_shared # type: ignore +except Exception: + # When running tests or scripts that import `backend.*` as a top-level module + import shared as _root_shared # type: ignore + +Result = _root_shared.Result +ErrorCode = _root_shared.ErrorCode +get_logger = _root_shared.get_logger +log_success = _root_shared.log_success +log_structured = _root_shared.log_structured +classify_file = _root_shared.classify_file +FileKind = _root_shared.FileKind +MetadataQuality = _root_shared.MetadataQuality +IndexMode = _root_shared.IndexMode +MetadataMode = _root_shared.MetadataMode +try: + from ..shared.types import EXTENSIONS as EXTENSIONS # type: ignore +except Exception: + try: + from shared.types import EXTENSIONS as EXTENSIONS # type: ignore + except Exception: + EXTENSIONS = {} # type: ignore + +__all__ = _root_shared.__all__ + [ + "Result", + "ErrorCode", + "get_logger", + "log_success", + "log_structured", + "classify_file", + "FileKind", + "MetadataQuality", + "IndexMode", + "MetadataMode", + "EXTENSIONS", +] diff --git a/backend/tool_detect.py b/backend/tool_detect.py new file mode 100644 index 00000000..629c4555 --- /dev/null +++ b/backend/tool_detect.py @@ -0,0 +1,155 @@ +""" +Tool detection helpers for ExifTool and FFprobe. +Cached detection to avoid repeated subprocess calls. +""" +import shutil +import subprocess +from typing import Dict, Optional + +from backend.config import EXIFTOOL_BIN, FFPROBE_BIN +from backend.shared import get_logger + +logger = get_logger(__name__) + +# Cache tool availability (None = not checked, True/False = result) +_TOOL_CACHE: Dict[str, Optional[bool]] = { + "exiftool": None, + "ffprobe": None, +} + +# Cache tool versions +_TOOL_VERSIONS: Dict[str, Optional[str]] = { + "exiftool": None, + "ffprobe": None, +} + + +def has_exiftool() -> bool: + """Check if ExifTool is available.""" + if _TOOL_CACHE["exiftool"] is not None: + return _TOOL_CACHE["exiftool"] + + try: + # Try using shutil.which first + if shutil.which(EXIFTOOL_BIN): + _TOOL_CACHE["exiftool"] = True + try: + # Try to get version + result = subprocess.run( + [EXIFTOOL_BIN, "-ver"], + capture_output=True, + text=True, + timeout=2, + check=False + ) + if result.returncode == 0: + _TOOL_VERSIONS["exiftool"] = result.stdout.strip() + except Exception: + pass + return True + + # Fallback: try to execute it + result = subprocess.run( + [EXIFTOOL_BIN, "-ver"], + capture_output=True, + text=True, + timeout=2, + check=False + ) + + available = result.returncode == 0 + _TOOL_CACHE["exiftool"] = available + + if available: + _TOOL_VERSIONS["exiftool"] = result.stdout.strip() + logger.info(f"ExifTool detected: version {_TOOL_VERSIONS['exiftool']}") + else: + logger.warning("ExifTool not found in PATH") + + return available + except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e: + logger.warning(f"ExifTool detection failed: {e}") + _TOOL_CACHE["exiftool"] = False + return False + + +def has_ffprobe() -> bool: + """Check if FFprobe is available.""" + if _TOOL_CACHE["ffprobe"] is not None: + return _TOOL_CACHE["ffprobe"] + + try: + # Try using shutil.which first + if shutil.which(FFPROBE_BIN): + _TOOL_CACHE["ffprobe"] = True + try: + # Try to get version + result = subprocess.run( + [FFPROBE_BIN, "-version"], + capture_output=True, + text=True, + timeout=2, + check=False + ) + if result.returncode == 0: + # Parse first line (version info) + version_line = result.stdout.split('\n')[0] if result.stdout else "" + _TOOL_VERSIONS["ffprobe"] = version_line.strip() + except Exception: + pass + return True + + # Fallback: try to execute it + result = subprocess.run( + [FFPROBE_BIN, "-version"], + capture_output=True, + text=True, + timeout=2, + check=False + ) + + available = result.returncode == 0 + _TOOL_CACHE["ffprobe"] = available + + if available: + # Parse first line (version info) + version_line = result.stdout.split('\n')[0] if result.stdout else "" + _TOOL_VERSIONS["ffprobe"] = version_line.strip() + logger.info(f"FFprobe detected: {_TOOL_VERSIONS['ffprobe']}") + else: + logger.warning("FFprobe not found in PATH") + + return available + except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e: + logger.warning(f"FFprobe detection failed: {e}") + _TOOL_CACHE["ffprobe"] = False + return False + + +def get_tool_status() -> Dict[str, any]: + """ + Get the status of all tools. + Returns: { + "exiftool": bool, + "ffprobe": bool, + "versions": { + "exiftool": str | null, + "ffprobe": str | null + } + } + """ + return { + "exiftool": has_exiftool(), + "ffprobe": has_ffprobe(), + "versions": { + "exiftool": _TOOL_VERSIONS.get("exiftool"), + "ffprobe": _TOOL_VERSIONS.get("ffprobe"), + } + } + + +def reset_tool_cache(): + """Reset tool detection cache (for testing or manual refresh).""" + global _TOOL_CACHE, _TOOL_VERSIONS + _TOOL_CACHE = {"exiftool": None, "ffprobe": None} + _TOOL_VERSIONS = {"exiftool": None, "ffprobe": None} diff --git a/comfyui_extension.json b/comfyui_extension.json index 51f60bbb..4798fd12 100644 --- a/comfyui_extension.json +++ b/comfyui_extension.json @@ -1,7 +1,11 @@ { - "name": "Majoor-AssetsManager", - "version": "1.0.2", - "author": "Ewald ALOEBOETOE (Majoor)", - "frontend": "js/", - "backend": "server/" + "name": "ComfyUI-Majoor-AssetsManager", + "version": "0.1.0", + "author": "Majoor", + "description": "Advanced asset browser for ComfyUI with ratings, tags, and metadata management", + "license": "MIT", + "repository": "", + "install_type": "git-clone", + "js_path": "js", + "nodename_pattern": "^Majoor" } diff --git a/js/api/client.js b/js/api/client.js new file mode 100644 index 00000000..548caec9 --- /dev/null +++ b/js/api/client.js @@ -0,0 +1,312 @@ +/** + * API Client - Safe fetch wrapper (never throws) + */ + +const SETTINGS_KEY = "mjrSettings"; +let _obsEnabledCache = null; +let _obsCacheAt = 0; +let _rtSyncEnabledCache = null; +let _rtSyncCacheAt = 0; +let _tagsCache = null; +let _tagsCacheAt = 0; +const TAGS_CACHE_TTL_MS = 30_000; +const CLIENT_GLOBAL_KEY = "__MJR_API_CLIENT__"; + +export function invalidateObsCache() { + _obsEnabledCache = null; + _obsCacheAt = 0; +} + +export function invalidateRatingTagsSyncCache() { + _rtSyncEnabledCache = null; + _rtSyncCacheAt = 0; +} + +export function invalidateTagsCache() { + _tagsCache = null; + _tagsCacheAt = 0; +} + +// Best-effort cache invalidation when settings change (ComfyUI settings, dev tools, etc.). +try { + const w = typeof window !== "undefined" ? window : null; + if (w && !w[CLIENT_GLOBAL_KEY]) { + w[CLIENT_GLOBAL_KEY] = { initialized: true }; + + w.addEventListener?.("storage", (event) => { + try { + if (event?.key === SETTINGS_KEY) { + invalidateObsCache(); + invalidateRatingTagsSyncCache(); + invalidateTagsCache(); + } + } catch {} + }); + + w.addEventListener?.("mjr-settings-changed", () => { + invalidateObsCache(); + invalidateRatingTagsSyncCache(); + invalidateTagsCache(); + }); + } +} catch {} + +const _readObsEnabled = () => { + const now = Date.now(); + if (_obsEnabledCache !== null && now - _obsCacheAt < 2000) { + return _obsEnabledCache; + } + _obsCacheAt = now; + try { + const raw = localStorage?.getItem?.(SETTINGS_KEY); + if (!raw) { + _obsEnabledCache = false; + return _obsEnabledCache; + } + const parsed = JSON.parse(raw); + _obsEnabledCache = !!parsed?.observability?.enabled; + return _obsEnabledCache; + } catch { + _obsEnabledCache = false; + return _obsEnabledCache; + } +}; + +const _readRatingTagsSyncEnabled = () => { + const now = Date.now(); + if (_rtSyncEnabledCache !== null && now - _rtSyncCacheAt < 2000) { + return _rtSyncEnabledCache; + } + _rtSyncCacheAt = now; + try { + const raw = localStorage?.getItem?.(SETTINGS_KEY); + if (!raw) { + _rtSyncEnabledCache = false; + return _rtSyncEnabledCache; + } + const parsed = JSON.parse(raw); + _rtSyncEnabledCache = !!parsed?.ratingTagsSync?.enabled; + return _rtSyncEnabledCache; + } catch { + _rtSyncEnabledCache = false; + return _rtSyncEnabledCache; + } +}; + +/** + * Fetch wrapper that always returns {ok, data, error} + * Never throws - returns error object instead + */ +export async function fetchAPI(url, options = {}) { + try { + const headers = typeof Headers !== "undefined" ? new Headers(options.headers || {}) : { ...(options.headers || {}) }; + + // Per-client switch: control backend observability logs. + // Explicitly send on/off so backend doesn't have to guess. + const obsEnabled = _readObsEnabled(); + try { + if (headers instanceof Headers) { + if (!headers.has("X-MJR-OBS")) headers.set("X-MJR-OBS", obsEnabled ? "on" : "off"); + } else if (!("X-MJR-OBS" in headers)) { + headers["X-MJR-OBS"] = obsEnabled ? "on" : "off"; + } + } catch {} + + const response = await fetch(url, { ...options, headers }); + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + return { + ok: false, + error: `Server returned non-JSON response (${response.status})`, + code: "INVALID_RESPONSE", + status: response.status, + content_type: contentType, + data: null + }; + } + + const result = await response.json().catch(() => null); + if (typeof result !== "object" || result === null) { + return { + ok: false, + error: "Invalid response structure", + code: "INVALID_RESPONSE", + status: response.status, + data: null + }; + } + + // Preserve HTTP status for callers that want to treat 401/403/404 specially. + if (!("status" in result)) { + try { + result.status = response.status; + } catch {} + } + return result; // Backend returns {ok, data, error, code, meta} + } catch (error) { + return { + ok: false, + error: error.message, + code: "NETWORK_ERROR", + data: null + }; + } +} + +/** + * GET request helper + */ +export async function get(url) { + return fetchAPI(url, { method: "GET" }); +} + +/** + * POST request helper + */ +export async function post(url, body) { + return fetchAPI(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); +} + +/** + * Update asset rating (0-5 stars) + */ +export async function updateAssetRating(assetId, rating) { + const enabled = _readRatingTagsSyncEnabled(); + const asset = assetId && typeof assetId === "object" ? assetId : null; + const resolvedId = asset ? asset.id : assetId; + const payload = { + rating: Math.max(0, Math.min(5, Number(rating) || 0)) + }; + if (resolvedId != null) { + payload.asset_id = resolvedId; + } else if (asset) { + payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || ""; + payload.type = asset.type || "output"; + payload.root_id = asset.root_id || asset.rootId || asset.custom_root_id || ""; + } else { + payload.asset_id = resolvedId; + } + return fetchAPI("/mjr/am/asset/rating", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(enabled ? { "X-MJR-RTSYNC": "on" } : {}) + }, + body: JSON.stringify(payload) + }); +} + +/** + * Update asset tags + */ +export async function updateAssetTags(assetId, tags) { + const enabled = _readRatingTagsSyncEnabled(); + const asset = assetId && typeof assetId === "object" ? assetId : null; + const resolvedId = asset ? asset.id : assetId; + const payload = { + tags: Array.isArray(tags) ? tags : [] + }; + if (resolvedId != null) { + payload.asset_id = resolvedId; + } else if (asset) { + payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || ""; + payload.type = asset.type || "output"; + payload.root_id = asset.root_id || asset.rootId || asset.custom_root_id || ""; + } else { + payload.asset_id = resolvedId; + } + const result = await fetchAPI("/mjr/am/asset/tags", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(enabled ? { "X-MJR-RTSYNC": "on" } : {}) + }, + body: JSON.stringify(payload) + }); + if (result?.ok) { + try { + if (Array.isArray(_tagsCache)) { + const next = new Set(_tagsCache); + for (const t of Array.isArray(tags) ? tags : []) next.add(t); + _tagsCache = Array.from(next); + } else { + invalidateTagsCache(); + } + } catch { + invalidateTagsCache(); + } + } + return result; +} + +/** + * Get all available tags from the database + */ +export async function getAvailableTags() { + const now = Date.now(); + if (Array.isArray(_tagsCache) && now - _tagsCacheAt < TAGS_CACHE_TTL_MS) { + return { ok: true, data: _tagsCache, error: null, code: "OK", meta: { cached: true } }; + } + + const result = await get("/mjr/am/tags"); + if (result?.ok && Array.isArray(result.data)) { + _tagsCache = result.data; + _tagsCacheAt = now; + } + return result; +} + +/** + * Get full asset metadata by ID + */ +export async function getAssetMetadata(assetId) { + return get(`/mjr/am/asset/${assetId}`); +} + +/** + * Batch fetch assets by ID (no per-asset tool invocations / self-heal). + */ +export async function getAssetsBatch(assetIds) { + const ids = Array.isArray(assetIds) ? assetIds : []; + const cleaned = []; + for (const id of ids) { + const n = Number(id); + if (!Number.isFinite(n)) continue; + cleaned.push(Math.trunc(n)); + if (cleaned.length >= 200) break; + } + if (!cleaned.length) return { ok: true, data: [], error: null, code: "OK" }; + return post("/mjr/am/assets/batch", { asset_ids: cleaned }); +} + +export async function hydrateAssetRatingTags(assetId) { + const id = String(assetId ?? "").trim(); + if (!id) return { ok: false, error: "Missing assetId" }; + return get(`/mjr/am/asset/${encodeURIComponent(id)}?hydrate=rating_tags`); +} + +/** + * Get metadata for a file path (works even when file is not indexed in DB). + */ +export async function getFileMetadata(filePath) { + if (!filePath || typeof filePath !== "string") { + return { ok: false, data: null, error: "Missing file path", code: "INVALID_INPUT" }; + } + return get(`/mjr/am/metadata?path=${encodeURIComponent(filePath)}`); +} + +export async function openInFolder(assetId) { + return post("/mjr/am/open-in-folder", { asset_id: assetId }); +} + +export async function deleteAsset(assetId) { + return post("/mjr/am/asset/delete", { asset_id: assetId }); +} + +export async function renameAsset(assetId, newName) { + return post("/mjr/am/asset/rename", { asset_id: assetId, new_name: newName }); +} diff --git a/js/api/endpoints.js b/js/api/endpoints.js new file mode 100644 index 00000000..8c0c27f4 --- /dev/null +++ b/js/api/endpoints.js @@ -0,0 +1,121 @@ +/** + * API Endpoints - Centralized URL constants + */ + +export const ENDPOINTS = { + // Health & Config + HEALTH: "/mjr/am/health", + HEALTH_COUNTERS: "/mjr/am/health/counters", + CONFIG: "/mjr/am/config", + + // Index & Search + SCAN: "/mjr/am/scan", + INDEX_FILES: "/mjr/am/index-files", + SEARCH: "/mjr/am/search", + LIST: "/mjr/am/list", + ROOTS: "/mjr/am/roots", + CUSTOM_ROOTS: "/mjr/am/custom-roots", + CUSTOM_ROOTS_REMOVE: "/mjr/am/custom-roots/remove", + + // Metadata + METADATA: "/mjr/am/metadata", + WORKFLOW_QUICK: "/mjr/am/workflow-quick", + RETRY_SERVICES: "/mjr/am/retry-services", + STAGE_TO_INPUT: "/mjr/am/stage-to-input", + OPEN_IN_FOLDER: "/mjr/am/open-in-folder", + + // View (ComfyUI native) + VIEW: "/view", + + // Custom roots view (Majoor) + CUSTOM_VIEW: "/mjr/am/custom-view", + + // File upload + UPLOAD_INPUT: "/mjr/am/upload_input", + + // File download + DOWNLOAD: "/mjr/am/download", + + // Asset operations + ASSET_DELETE: "/mjr/am/asset/delete", + ASSET_RENAME: "/mjr/am/asset/rename" +}; + +/** + * Build view URL for asset + */ +export function buildViewURL(filename, subfolder = null, type = "output") { + let url = `${ENDPOINTS.VIEW}?filename=${encodeURIComponent(filename)}`; + if (subfolder) { + url += `&subfolder=${encodeURIComponent(subfolder)}`; + } + url += `&type=${encodeURIComponent(type)}`; + return url; +} + +/** + * Build list URL with scope support. + */ +export function buildListURL(params = {}) { + const { + q = "*", + limit = 100, + offset = 0, + scope = "output", + subfolder = "", + customRootId = null, + kind = null, + hasWorkflow = null, + minRating = null, + includeTotal = true + } = params; + + let url = `${ENDPOINTS.LIST}?q=${encodeURIComponent(q)}&limit=${limit}&offset=${offset}&scope=${encodeURIComponent(scope)}`; + if (subfolder) { + url += `&subfolder=${encodeURIComponent(subfolder)}`; + } + if (customRootId) { + url += `&custom_root_id=${encodeURIComponent(customRootId)}`; + } + if (kind) { + url += `&kind=${encodeURIComponent(kind)}`; + } + if (hasWorkflow !== null && hasWorkflow !== undefined) { + url += `&has_workflow=${encodeURIComponent(hasWorkflow ? "true" : "false")}`; + } + if (minRating !== null && minRating !== undefined && Number(minRating) > 0) { + url += `&min_rating=${encodeURIComponent(String(minRating))}`; + } + if (includeTotal === false) { + url += `&include_total=0`; + } + return url; +} + +export function buildCustomViewURL(filename, subfolder = "", rootId = "") { + let url = `${ENDPOINTS.CUSTOM_VIEW}?root_id=${encodeURIComponent(rootId)}&filename=${encodeURIComponent(filename)}`; + if (subfolder) { + url += `&subfolder=${encodeURIComponent(subfolder)}`; + } + return url; +} + +export function buildAssetViewURL(asset) { + const type = (asset?.type || "output").toLowerCase(); + if (type === "custom") { + const rootId = asset?.root_id || asset?.rootId || asset?.custom_root_id || ""; + return buildCustomViewURL(asset?.filename || "", asset?.subfolder || "", rootId); + } + if (type === "input") { + return buildViewURL(asset?.filename || "", asset?.subfolder || "", "input"); + } + return buildViewURL(asset?.filename || "", asset?.subfolder || "", "output"); +} + +/** + * Build search URL with query params + */ +export function buildSearchURL(params = {}) { + const { q = "*", limit = 100, offset = 0 } = params; + return `${ENDPOINTS.SEARCH}?q=${encodeURIComponent(q)}&limit=${limit}&offset=${offset}`; +} diff --git a/js/app/bootstrap.js b/js/app/bootstrap.js new file mode 100644 index 00000000..f07281f6 --- /dev/null +++ b/js/app/bootstrap.js @@ -0,0 +1,69 @@ +/** + * Bootstrap - Auto-scan and initialization + */ + +import { get, post } from "../api/client.js"; +import { ENDPOINTS } from "../api/endpoints.js"; +import { APP_CONFIG } from "./config.js"; + +// Auto-scan state (once per session) +let autoScanDone = false; + +/** + * Trigger auto-scan on first render + */ +export async function triggerAutoScan(options = {}) { + if (!APP_CONFIG || typeof APP_CONFIG !== "object") return; + const force = !!options.force; + if (autoScanDone || (!APP_CONFIG.AUTO_SCAN_ENABLED && !force)) return; + autoScanDone = true; + + try { + console.log("📂 Majoor [â„šī¸]: Starting auto-scan of output directory..."); + + const result = await post(ENDPOINTS.SCAN, { + recursive: true, + incremental: true, + fast: true, + background_metadata: true + }); + + if (result.ok) { + const stats = result.data; + console.log(`📂 Majoor [✅]: Auto-scan complete! Added: ${stats.added}, Updated: ${stats.updated}, Skipped: ${stats.skipped}`); + } else { + console.warn("📂 Majoor [âš ī¸]: Auto-scan failed:", result.error); + } + } catch (error) { + console.error("📂 Majoor [❌]: Auto-scan error:", error); + } +} + +/** + * Trigger background scan as soon as the ComfyUI frontend loads (optional). + * This helps warming the DB before the Assets Manager panel is opened. + */ +export async function triggerStartupScan() { + if (!APP_CONFIG || typeof APP_CONFIG !== "object") return; + if (!APP_CONFIG.AUTO_SCAN_ON_STARTUP) return; + return triggerAutoScan({ force: true, reason: "startup" }); +} + +/** + * Test API connection + */ +export async function testAPI() { + try { + console.log("📂 Majoor: Testing API connection..."); + const data = await get(ENDPOINTS.HEALTH); + + if (data?.ok) { + console.log("📂 Majoor [✅]: API connection successful!"); + console.log("📂 Majoor [â„šī¸]: Health status:", data.data.overall); + } else { + console.error("📂 Majoor [❌]: API health check failed:", data?.error); + } + } catch (error) { + console.error("📂 Majoor [❌]: Failed to connect to API:", error); + } +} diff --git a/js/app/config.js b/js/app/config.js new file mode 100644 index 00000000..979b1505 --- /dev/null +++ b/js/app/config.js @@ -0,0 +1,60 @@ +/** + * App Configuration + */ + +import { get } from "../api/client.js"; +import { ENDPOINTS } from "../api/endpoints.js"; + +// Cache for output directory +let outputDirectory = null; + +/** + * Get ComfyUI output directory + * Cached after first fetch + */ +export async function getOutputDirectory() { + if (outputDirectory) return outputDirectory; + + const result = await get(ENDPOINTS.CONFIG); + if (result.ok) { + outputDirectory = result.data.output_directory; + console.log("📂 Majoor [â„šī¸]: Output directory:", outputDirectory); + return outputDirectory; + } + + console.error("📂 Majoor [❌]: Failed to get output directory"); + return "output"; // fallback +} + +// App constants +export const APP_CONFIG = { + // Grid + GRID_MIN_SIZE: 120, + GRID_GAP: 10, + + // Pagination + DEFAULT_PAGE_SIZE: 100, + // Upper bound for a single list request. The backend allows more, but very large + // pages can create huge DOM batches; keep this as a safety cap. + MAX_PAGE_SIZE: 2000, + INFINITE_SCROLL_ENABLED: true, + INFINITE_SCROLL_ROOT_MARGIN: "800px", + INFINITE_SCROLL_THRESHOLD: 0.01, + + // Polling + STATUS_POLL_INTERVAL: 5000, // 5 seconds + + // Auto-scan + AUTO_SCAN_ENABLED: true, + AUTO_SCAN_ON_STARTUP: false, + + // Rating/tags hydration (grid) + RT_HYDRATE_CONCURRENCY: 2, + RT_HYDRATE_SEEN_MAX: 20000, + RT_HYDRATE_PRUNE_BUDGET: 250, + RT_HYDRATE_SEEN_TTL_MS: 10 * 60 * 1000, + + // Viewer metadata cache + VIEWER_META_TTL_MS: 30_000, + VIEWER_META_MAX_ENTRIES: 500 +}; diff --git a/js/app/dialogs.js b/js/app/dialogs.js new file mode 100644 index 00000000..a88a92a3 --- /dev/null +++ b/js/app/dialogs.js @@ -0,0 +1,348 @@ +/** + * ComfyUI-native-ish dialogs for extensions. + * + * Uses ComfyDialog if available (window.comfyAPI.ui.ComfyDialog) to avoid browser + * alert/confirm/prompt popups. Falls back to window.* when unavailable. + */ + +const getComfyUi = () => { + try { + return window?.comfyAPI?.ui || null; + } catch { + return null; + } +}; + +const fallbackEl = (tag, props = {}, children = []) => { + const el = document.createElement(tag); + Object.entries(props || {}).forEach(([key, value]) => { + if (key === "style" && value && typeof value === "object") { + Object.assign(el.style, value); + return; + } + if (key === "className") { + el.className = String(value); + return; + } + if (key.startsWith("on") && typeof value === "function") { + el.addEventListener(key.slice(2).toLowerCase(), value); + return; + } + try { + el[key] = value; + } catch { + el.setAttribute(key, String(value)); + } + }); + const childList = Array.isArray(children) ? children : [children]; + childList.filter(Boolean).forEach((c) => el.appendChild(c)); + return el; +}; + +const $el = (tag, props, children) => { + const ui = getComfyUi(); + if (ui?.$el) { + try { + return ui.$el(tag, props, children); + } catch { + // fall through + } + } + return fallbackEl(tag, props, children); +}; + +const getComfyDialogCtor = () => { + const ui = getComfyUi(); + return ui?.ComfyDialog || null; +}; + +const styleDialog = (dialog) => { + try { + // Ensure dialogs are above extension panels/popovers. + dialog.element.style.zIndex = 999999; + dialog.element.style.width = "420px"; + dialog.element.style.padding = "0"; + dialog.element.style.backgroundColor = "var(--comfy-menu-bg, #2a2a2a)"; + dialog.element.style.border = "1px solid var(--border-color, #3a3a3a)"; + dialog.element.style.borderRadius = "10px"; + dialog.element.style.boxSizing = "border-box"; + dialog.element.style.overflow = "hidden"; + } catch {} +}; + +export const comfyAlert = async (message, title = "Majoor") => { + const Dialog = getComfyDialogCtor(); + if (!Dialog) { + try { + window.alert(message); + } catch {} + return; + } + + return new Promise((resolve) => { + const dialog = new Dialog(); + styleDialog(dialog); + + const close = () => { + try { + dialog.close(); + } catch {} + resolve(); + }; + + const content = $el("div", { + style: { + display: "flex", + flexDirection: "column", + gap: "12px", + padding: "16px", + }, + }, [ + $el("div", { + textContent: title, + style: { + fontWeight: "600", + fontSize: "14px", + color: "rgba(255,255,255,0.95)", + }, + }), + $el("div", { + textContent: String(message || ""), + style: { + fontSize: "13px", + color: "rgba(255,255,255,0.80)", + whiteSpace: "pre-wrap", + lineHeight: "1.4", + }, + }), + $el("div", { + style: { display: "flex", justifyContent: "flex-end", gap: "10px" }, + }, [ + $el("button", { + textContent: "OK", + onclick: close, + style: { + padding: "8px 12px", + borderRadius: "8px", + border: "1px solid rgba(255,255,255,0.12)", + background: "rgba(0,0,0,0.25)", + color: "rgba(255,255,255,0.9)", + cursor: "pointer", + }, + }), + ]), + ]); + + try { + dialog.show(content); + } catch { + try { + window.alert(message); + } catch {} + resolve(); + } + }); +}; + +export const comfyConfirm = async (message, title = "Majoor") => { + const Dialog = getComfyDialogCtor(); + if (!Dialog) { + try { + return window.confirm(message); + } catch { + return false; + } + } + + return new Promise((resolve) => { + const dialog = new Dialog(); + styleDialog(dialog); + + const finish = (value) => { + try { + dialog.close(); + } catch {} + resolve(!!value); + }; + + const content = $el("div", { + style: { + display: "flex", + flexDirection: "column", + gap: "12px", + padding: "16px", + }, + }, [ + $el("div", { + textContent: title, + style: { + fontWeight: "600", + fontSize: "14px", + color: "rgba(255,255,255,0.95)", + }, + }), + $el("div", { + textContent: String(message || ""), + style: { + fontSize: "13px", + color: "rgba(255,255,255,0.80)", + whiteSpace: "pre-wrap", + lineHeight: "1.4", + }, + }), + $el("div", { + style: { display: "flex", justifyContent: "flex-end", gap: "10px" }, + }, [ + $el("button", { + textContent: "Cancel", + onclick: () => finish(false), + style: { + padding: "8px 12px", + borderRadius: "8px", + border: "1px solid rgba(255,255,255,0.12)", + background: "rgba(0,0,0,0.25)", + color: "rgba(255,255,255,0.85)", + cursor: "pointer", + }, + }), + $el("button", { + textContent: "OK", + onclick: () => finish(true), + style: { + padding: "8px 12px", + borderRadius: "8px", + border: "1px solid rgba(95,179,255,0.45)", + background: "rgba(95,179,255,0.18)", + color: "rgba(255,255,255,0.95)", + cursor: "pointer", + }, + }), + ]), + ]); + + try { + dialog.show(content); + } catch { + try { + resolve(!!window.confirm(message)); + } catch { + resolve(false); + } + } + }); +}; + +export const comfyPrompt = async (message, defaultValue = "", title = "Majoor") => { + const Dialog = getComfyDialogCtor(); + if (!Dialog) { + try { + return window.prompt(message, defaultValue); + } catch { + return null; + } + } + + return new Promise((resolve) => { + const dialog = new Dialog(); + styleDialog(dialog); + + const finish = (value) => { + try { + dialog.close(); + } catch {} + resolve(value ?? null); + }; + + const input = $el("input", { + type: "text", + value: String(defaultValue ?? ""), + style: { + width: "100%", + padding: "10px 10px", + borderRadius: "10px", + border: "1px solid rgba(255,255,255,0.12)", + background: "rgba(0,0,0,0.25)", + color: "rgba(255,255,255,0.9)", + outline: "none", + boxSizing: "border-box", + }, + onkeydown: (e) => { + if (e.key === "Enter") finish(input.value); + if (e.key === "Escape") finish(null); + e.stopPropagation(); + }, + }); + + const content = $el("div", { + style: { + display: "flex", + flexDirection: "column", + gap: "12px", + padding: "16px", + }, + }, [ + $el("div", { + textContent: title, + style: { + fontWeight: "600", + fontSize: "14px", + color: "rgba(255,255,255,0.95)", + }, + }), + $el("div", { + textContent: String(message || ""), + style: { + fontSize: "13px", + color: "rgba(255,255,255,0.80)", + whiteSpace: "pre-wrap", + lineHeight: "1.4", + }, + }), + input, + $el("div", { + style: { display: "flex", justifyContent: "flex-end", gap: "10px" }, + }, [ + $el("button", { + textContent: "Cancel", + onclick: () => finish(null), + style: { + padding: "8px 12px", + borderRadius: "8px", + border: "1px solid rgba(255,255,255,0.12)", + background: "rgba(0,0,0,0.25)", + color: "rgba(255,255,255,0.85)", + cursor: "pointer", + }, + }), + $el("button", { + textContent: "OK", + onclick: () => finish(input.value), + style: { + padding: "8px 12px", + borderRadius: "8px", + border: "1px solid rgba(95,179,255,0.45)", + background: "rgba(95,179,255,0.18)", + color: "rgba(255,255,255,0.95)", + cursor: "pointer", + }, + }), + ]), + ]); + + try { + dialog.show(content); + setTimeout(() => { + try { + input.focus(); + input.select(); + } catch {} + }, 0); + } catch { + try { + resolve(window.prompt(message, defaultValue)); + } catch { + resolve(null); + } + } + }); +}; diff --git a/js/app/events.js b/js/app/events.js new file mode 100644 index 00000000..7e92d017 --- /dev/null +++ b/js/app/events.js @@ -0,0 +1,3 @@ +export const ASSET_RATING_CHANGED_EVENT = "mjr:asset-rating-changed"; +export const ASSET_TAGS_CHANGED_EVENT = "mjr:asset-tags-changed"; + diff --git a/js/app/settings.js b/js/app/settings.js new file mode 100644 index 00000000..8a04b51f --- /dev/null +++ b/js/app/settings.js @@ -0,0 +1,199 @@ +/** + * Majoor settings integration with ComfyUI settings panel. + */ + +import { APP_CONFIG } from "./config.js"; +import { invalidateObsCache } from "../api/client.js"; +import { safeDispatchCustomEvent } from "../utils/events.js"; + +const SETTINGS_KEY = "mjrSettings"; +const SETTINGS_PREFIX = "Majoor"; + +const DEFAULT_SETTINGS = { + grid: { + pageSize: APP_CONFIG.DEFAULT_PAGE_SIZE + }, + autoScan: { + enabled: APP_CONFIG.AUTO_SCAN_ENABLED, + onStartup: APP_CONFIG.AUTO_SCAN_ON_STARTUP + }, + status: { + pollInterval: APP_CONFIG.STATUS_POLL_INTERVAL + }, + observability: { + enabled: false + }, + sidebar: { + position: "right" // "left" or "right" + } +}; + +const deepMerge = (base, next) => { + const output = { ...base }; + if (!next || typeof next !== "object") { + return output; + } + Object.keys(next).forEach((key) => { + const value = next[key]; + if (value && typeof value === "object" && !Array.isArray(value)) { + output[key] = deepMerge(base[key] || {}, value); + } else if (value !== undefined) { + output[key] = value; + } + }); + return output; +}; + +export const loadMajoorSettings = () => { + if (typeof localStorage === "undefined") { + return { ...DEFAULT_SETTINGS }; + } + try { + const raw = localStorage.getItem(SETTINGS_KEY); + if (!raw) return { ...DEFAULT_SETTINGS }; + const parsed = JSON.parse(raw); + return deepMerge(DEFAULT_SETTINGS, parsed); + } catch (error) { + console.warn("[Majoor] settings load failed, using defaults", error); + return { ...DEFAULT_SETTINGS }; + } +}; + +export const saveMajoorSettings = (settings) => { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + } catch (error) { + console.warn("[Majoor] settings save failed", error); + } +}; + +const applySettingsToConfig = (settings) => { + const maxPage = Number(APP_CONFIG.MAX_PAGE_SIZE) || 2000; + const pageSize = Math.max(50, Math.min(maxPage, Number(settings.grid?.pageSize) || APP_CONFIG.DEFAULT_PAGE_SIZE)); + APP_CONFIG.DEFAULT_PAGE_SIZE = pageSize; + APP_CONFIG.AUTO_SCAN_ENABLED = !!settings.autoScan?.enabled; + APP_CONFIG.AUTO_SCAN_ON_STARTUP = !!settings.autoScan?.onStartup; + APP_CONFIG.STATUS_POLL_INTERVAL = Math.max(1000, Number(settings.status?.pollInterval) || APP_CONFIG.STATUS_POLL_INTERVAL); +}; + +export const registerMajoorSettings = (app, onApplied) => { + const settings = loadMajoorSettings(); + applySettingsToConfig(settings); + + if (!app?.ui?.settings?.addSetting) { + return settings; + } + + const notifyApplied = (key) => { + if (typeof onApplied === "function") { + onApplied(settings, key); + } + safeDispatchCustomEvent("mjr-settings-changed", { key }, { warnPrefix: "[Majoor]" }); + }; + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Grid.PageSize`, + name: "Majoor: Page Size (files per request)", + tooltip: "Bigger loads more at once; smaller reduces memory.", + type: "number", + defaultValue: settings.grid.pageSize, + attrs: { min: 50, max: Number(APP_CONFIG.MAX_PAGE_SIZE) || 2000, step: 50 }, + onChange: (val) => { + const maxPage = Number(APP_CONFIG.MAX_PAGE_SIZE) || 2000; + settings.grid.pageSize = Math.max(50, Math.min(maxPage, Number(val) || DEFAULT_SETTINGS.grid.pageSize)); + saveMajoorSettings(settings); + applySettingsToConfig(settings); + notifyApplied("grid.pageSize"); + } + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.AutoScan.Enabled`, + name: "Majoor: Auto-scan on open", + type: "boolean", + defaultValue: settings.autoScan.enabled, + onChange: (val) => { + settings.autoScan.enabled = !!val; + saveMajoorSettings(settings); + applySettingsToConfig(settings); + notifyApplied("autoScan.enabled"); + } + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.AutoScan.OnStartup`, + name: "Majoor: Auto-scan at ComfyUI startup", + tooltip: "Starts a background scan as soon as ComfyUI loads in your browser (before opening the panel).", + type: "boolean", + defaultValue: !!settings.autoScan?.onStartup, + onChange: (val) => { + settings.autoScan = settings.autoScan || {}; + settings.autoScan.onStartup = !!val; + saveMajoorSettings(settings); + applySettingsToConfig(settings); + notifyApplied("autoScan.onStartup"); + } + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Status.PollInterval`, + name: "Majoor: Status poll interval (ms)", + tooltip: "Reload the page to apply.", + type: "number", + defaultValue: settings.status.pollInterval, + attrs: { min: 1000, max: 60000, step: 500 }, + onChange: (val) => { + settings.status.pollInterval = Math.max(1000, Number(val) || DEFAULT_SETTINGS.status.pollInterval); + saveMajoorSettings(settings); + applySettingsToConfig(settings); + notifyApplied("status.pollInterval"); + } + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Observability.Enabled`, + name: "Majoor: Observability (request logging)", + tooltip: "Enables backend request logs for debugging. When disabled, Majoor will send X-MJR-OBS: off to suppress request logs.", + type: "boolean", + defaultValue: !!settings.observability?.enabled, + onChange: (val) => { + settings.observability = settings.observability || {}; + settings.observability.enabled = !!val; + saveMajoorSettings(settings); + invalidateObsCache(); + notifyApplied("observability.enabled"); + } + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Sidebar.Position`, + name: "Majoor: Sidebar position", + tooltip: "Choose whether the asset details sidebar appears on the left or right side of the panel. Reload the page to apply.", + type: "combo", + defaultValue: settings.sidebar?.position || "right", + options: ["left", "right"], + onChange: (val) => { + settings.sidebar = settings.sidebar || {}; + settings.sidebar.position = val === "left" ? "left" : "right"; + saveMajoorSettings(settings); + notifyApplied("sidebar.position"); + } + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.RatingTagsSync.Enabled`, + name: "Majoor: Sync rating/tags to files", + tooltip: "When enabled, Majoor will request the backend to write your rating/tags into the file metadata (ExifTool). On Windows, it may also try a Shell fallback if ExifTool fails.", + type: "boolean", + defaultValue: !!settings.ratingTagsSync?.enabled, + onChange: (val) => { + settings.ratingTagsSync = settings.ratingTagsSync || {}; + settings.ratingTagsSync.enabled = !!val; + saveMajoorSettings(settings); + notifyApplied("ratingTagsSync.enabled"); + } + }); + + return settings; +}; diff --git a/js/app/style.js b/js/app/style.js new file mode 100644 index 00000000..9783b0e7 --- /dev/null +++ b/js/app/style.js @@ -0,0 +1,28 @@ +export function ensureStyleLoaded({ enabled = true } = {}) { + if (!enabled) return; + const id = "mjr-theme-comfy-css"; + if (document.getElementById(id)) return; + + try { + const href = new URL("../theme/theme-comfy.css", import.meta.url).toString(); + const link = document.createElement("link"); + link.id = id; + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = href; + link.onerror = () => { + try { + link.remove(); + } catch {} + }; + document.head.appendChild(link); + link.onload = () => { + try { + document.documentElement.dataset.mjrThemeComfy = "1"; + } catch {} + }; + } catch (err) { + console.warn("Majoor: failed to load Comfy theme CSS", err); + } +} + diff --git a/js/assets_data.js b/js/assets_data.js deleted file mode 100644 index b245d6e6..00000000 --- a/js/assets_data.js +++ /dev/null @@ -1,270 +0,0 @@ -import { api } from "../../../scripts/api.js"; -import { - mjrPrefetchedFiles, - mjrShowToast, - mjrSettings, -} from "./ui_settings.js"; -import { normalizeMtimeOnList, normalizeMtimeValue } from "./assets_filters.js"; -import { mjrThemeVars } from "./ui_theme.js"; - -export const mjrRefreshDefaults = { silent: true, forceFiles: false, metaOnly: false }; - -export const mergeLoadOpts = (current, next) => { - if (!current) return next; - return { - silent: current.silent && next.silent, - force: current.force || next.force, - skipMetadataFetch: current.skipMetadataFetch && next.skipMetadataFetch, - }; -}; - -export const normalizeRefreshOptions = (optionsOrSilent = true, forceFiles = false) => { - if (typeof optionsOrSilent === "object" && optionsOrSilent !== null) { - return { ...mjrRefreshDefaults, ...optionsOrSilent }; - } - return { ...mjrRefreshDefaults, silent: !!optionsOrSilent, forceFiles: !!forceFiles }; -}; - -export const mergeRefreshOptions = (current, next) => { - if (!current) return next; - return { - silent: current.silent && next.silent, - forceFiles: current.forceFiles || next.forceFiles, - metaOnly: current.metaOnly && next.metaOnly, - }; -}; - -export function computeSignature(files) { - try { - return files - .map((f) => { - const path = `${f.subfolder || ""}/${f.filename || f.name || ""}`; - const mtime = normalizeMtimeValue(f.mtime); - f.mtime = mtime; - const rating = - f.rating ?? - (f.meta && f.meta.rating) ?? - (f.metadata && f.metadata.rating) ?? - 0; - const tags = - (Array.isArray(f.tags) && f.tags) || - (Array.isArray(f.meta && f.meta.tags) && f.meta.tags) || - (Array.isArray(f.metadata && f.metadata.tags) && f.metadata.tags) || - []; - return `${path}|${mtime || ""}|${f.size || ""}|${rating}|${tags.length}|${tags.join(",")}`; - }) - .join(";"); - } catch (err) { - console.warn("[Majoor.AssetsManager] signature error", err); - return null; - } -} - -export function sortFilesDeterministically(files) { - files.sort((a, b) => { - const am = normalizeMtimeValue(a.mtime); - const bm = normalizeMtimeValue(b.mtime); - a.mtime = am; - b.mtime = bm; - const diff = bm - am; - if (diff !== 0) return diff; - const ak = `${a.subfolder || ""}/${a.filename || a.name || ""}`; - const bk = `${b.subfolder || ""}/${b.filename || b.name || ""}`; - return ak.localeCompare(bk); - }); -} - -function mjrPageSize() { - const cfg = Number(mjrSettings?.grid?.pageSize); - if (!Number.isFinite(cfg) || cfg <= 0) return 500; - return Math.max(50, Math.min(2000, Math.floor(cfg))); -} - -async function fetchFilesPage({ force = false, offset = 0, limit = 0 } = {}) { - const params = new URLSearchParams(); - params.set("t", Date.now().toString()); - if (force) params.set("force", "1"); - if (offset) params.set("offset", String(offset)); - if (limit) params.set("limit", String(limit)); - const source = String(mjrSettings?.index?.source || "auto").toLowerCase(); - if (source) { - params.set("source", source === "filesystem" ? "scan" : source); - } - - const res = await api.fetchApi(`/mjr/filemanager/files?${params.toString()}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); -} - -export async function loadFiles(state, gridEl, applyFilterAndRender, opts = {}, refs) { - const { silent = false, force = false, skipMetadataFetch = false } = opts; - const { lastSignatureRef, mergeLoadOptsFn = mergeLoadOpts, setLoadingPromise } = refs; - - if (state.loadingPromise) { - state.nextLoadOpts = mergeLoadOptsFn(state.nextLoadOpts, { silent, force, skipMetadataFetch }); - return state.loadingPromise; - } - - state.loadingPromise = (async () => { - state.loading = true; - if (!silent && gridEl.children.length === 0) { - gridEl.innerHTML = '
Loading outputs...
'; - } - try { - if (!force && Array.isArray(mjrPrefetchedFiles) && mjrPrefetchedFiles.length) { - normalizeMtimeOnList(mjrPrefetchedFiles); - const sig = computeSignature(mjrPrefetchedFiles); - state.files = mjrPrefetchedFiles; - state.filesTotal = mjrPrefetchedFiles.length; - state.filesHasMore = true; - state.filesNextOffset = mjrPrefetchedFiles.length; - lastSignatureRef.value = sig; - applyFilterAndRender({ skipMetadataFetch }); - } - - const data = await fetchFilesPage({ force, offset: 0, limit: mjrPageSize() }); - const incoming = data.files || data.items || []; - - normalizeMtimeOnList(incoming); - sortFilesDeterministically(incoming); - - const prevMap = new Map(); - (state.files || []).forEach((f) => { - const raw = f.name || f.filename || ""; - const k = `${f.subfolder || ""}/${raw}`; - prevMap.set(k, f); - }); - incoming.forEach((f) => { - const raw = f.name || f.filename || ""; - const k = `${f.subfolder || ""}/${raw}`; - const prev = prevMap.get(k); - if (prev) { - if (prev.rating !== undefined) f.rating = prev.rating; - if (Array.isArray(prev.tags)) f.tags = prev.tags; - if (prev.__metaLoaded) f.__metaLoaded = true; - } - }); - - const sig = `${data.total ?? incoming.length}|${computeSignature(incoming)}`; - if (!force && sig && sig === lastSignatureRef.value) { - lastSignatureRef.value = sig; - return; - } - state.files = incoming; - lastSignatureRef.value = sig; - state.filesTotal = data.total ?? incoming.length; - state.filesHasMore = data.has_more ?? (incoming.length < (data.total ?? incoming.length)); - state.filesNextOffset = data.next_offset ?? incoming.length; - applyFilterAndRender({ skipMetadataFetch }); - } catch (err) { - console.error("[Majoor.AssetsManager] load error", err); - gridEl.innerHTML = `
Error loading files (see console).
`; - mjrShowToast("error", "Error loading files", "Error"); - } finally { - state.loading = false; - const pending = state.nextLoadOpts; - state.nextLoadOpts = null; - state.loadingPromise = null; - if (pending) { - await loadFiles(state, gridEl, applyFilterAndRender, pending, refs); - } - } - })(); - - if (typeof setLoadingPromise === "function") setLoadingPromise(state.loadingPromise); - return state.loadingPromise; -} - -export async function loadMoreFiles(state, gridEl, applyFilterAndRender, opts = {}, refs = {}) { - const { silent = true, skipMetadataFetch = false } = opts; - const { setLoadingPromise } = refs; - - if (state.loadingPromise) return state.loadingPromise; - if (!state.filesHasMore) return Promise.resolve(); - - if (state.loadingMorePromise) { - state.moreQueued = true; - return state.loadingMorePromise; - } - - state.loadingMorePromise = (async () => { - const offset = Math.max(0, Number(state.filesNextOffset ?? state.files?.length ?? 0) || 0); - const data = await fetchFilesPage({ force: false, offset, limit: mjrPageSize() }); - const incoming = data.files || data.items || []; - - normalizeMtimeOnList(incoming); - sortFilesDeterministically(incoming); - - const existing = new Set( - (state.files || []).map((f) => `${f.subfolder || ""}/${f.filename || f.name || ""}`) - ); - const appended = []; - for (const f of incoming) { - const k = `${f.subfolder || ""}/${f.filename || f.name || ""}`; - if (!existing.has(k)) { - appended.push(f); - existing.add(k); - } - } - - if (appended.length) { - state.files = (state.files || []).concat(appended); - applyFilterAndRender({ skipMetadataFetch }); - } else if (!silent) { - mjrShowToast("info", "No more files to load", "Files"); - } - - state.filesTotal = data.total ?? state.filesTotal ?? state.files.length; - state.filesHasMore = data.has_more ?? (state.files.length < (state.filesTotal ?? state.files.length)); - state.filesNextOffset = data.next_offset ?? (offset + incoming.length); - })() - .catch((err) => { - console.warn("[Majoor.AssetsManager] load more failed", err); - if (!silent) mjrShowToast("warn", "Failed to load more files", "Warning"); - }) - .finally(async () => { - const again = !!state.moreQueued; - state.moreQueued = false; - state.loadingMorePromise = null; - if (again && state.filesHasMore) { - await loadMoreFiles(state, gridEl, applyFilterAndRender, opts, refs); - } - }); - - if (typeof setLoadingPromise === "function") setLoadingPromise(state.loadingMorePromise); - return state.loadingMorePromise; -} - -export function createRefreshInstance(state, fetchMetadataForVisible, loadFilesFn, refs) { - const { mjrRefreshDefaults: refreshDefaults = mjrRefreshDefaults, mergeRefreshOptionsFn = mergeRefreshOptions } = refs || {}; - let instanceRefreshInFlight = null; - let instanceRefreshPending = null; - - const refreshInstance = async (options = {}) => { - const opts = { ...refreshDefaults, ...options }; - if (instanceRefreshInFlight) { - instanceRefreshPending = mergeRefreshOptionsFn(instanceRefreshPending, opts); - return instanceRefreshInFlight; - } - - const run = async () => { - if (!opts.metaOnly) { - await loadFilesFn(opts); - } - await fetchMetadataForVisible(); - }; - - instanceRefreshInFlight = run().finally(() => { - const pending = instanceRefreshPending; - instanceRefreshPending = null; - instanceRefreshInFlight = null; - if (pending) { - refreshInstance(pending); - } - }); - - return instanceRefreshInFlight; - }; - - return refreshInstance; -} diff --git a/js/assets_filters.js b/js/assets_filters.js deleted file mode 100644 index 6b0394be..00000000 --- a/js/assets_filters.js +++ /dev/null @@ -1,70 +0,0 @@ -import { detectKindFromExt, getExt } from "./ui_settings.js"; - -// FIX: Backend now sends mtime in milliseconds, so no conversion needed -// Keep function for compatibility but make it simpler -export const normalizeMtimeValue = (mtime) => { - // Backend sends milliseconds since epoch, just ensure it's a number - return Number(mtime || 0); -}; - -export const normalizeMtimeOnList = (files) => { - // No longer needed since backend sends correct format, but keep for compatibility - if (!Array.isArray(files)) return; - files.forEach((f) => { - if (f.mtime) { - f.mtime = normalizeMtimeValue(f.mtime); - } - }); -}; - -export const SMART_FILTERS = { - "smart:today": { - label: "📅 Today", - filter: (f) => { - const ms = normalizeMtimeValue(f.mtime); - const d = new Date(ms); - const now = new Date(); - return d.toDateString() === now.toDateString(); - }, - }, - "smart:yesterday": { - label: "📅 Yesterday", - filter: (f) => { - const ms = normalizeMtimeValue(f.mtime); - const d = new Date(ms); - const now = new Date(); - now.setDate(now.getDate() - 1); - return d.toDateString() === now.toDateString(); - }, - }, - "smart:last7": { - label: "📅 Last 7 Days", - filter: (f) => { - const ms = normalizeMtimeValue(f.mtime); - const diff = Date.now() - ms; - return diff < 7 * 24 * 60 * 60 * 1000; - }, - }, - "smart:5stars": { - label: "⭐ 5 Stars", - filter: (f) => { - const r = f.rating ?? (f.meta && f.meta.rating) ?? 0; - return Number(r) === 5; - }, - }, - "smart:4plus": { - label: "⭐ 4+ Stars", - filter: (f) => { - const r = f.rating ?? (f.meta && f.meta.rating) ?? 0; - return Number(r) >= 4; - }, - }, - "smart:videos": { - label: "đŸŽĨ Videos", - filter: (f) => (f.kind || detectKindFromExt(f.ext || getExt(f.filename || f.name))) === "video", - }, - "smart:images": { - label: "đŸ–ŧī¸ Images", - filter: (f) => (f.kind || detectKindFromExt(f.ext || getExt(f.filename || f.name))) === "image", - }, -}; diff --git a/js/assets_state.js b/js/assets_state.js deleted file mode 100644 index 4084dac3..00000000 --- a/js/assets_state.js +++ /dev/null @@ -1,22 +0,0 @@ -export const createInitialState = () => ({ - files: [], - filtered: [], - search: "", - minRating: 0, - kindFilter: "", - tagFilter: "", - activeCollection: null, - collectionSet: null, - smartFilter: null, - selected: new Set(), - currentFile: null, - currentMeta: null, - loading: false, - loadingPromise: null, - metaCache: new Map(), - metaFetchAt: new Map(), - nextLoadOpts: null, - renderVersion: 0, -}); - -export const fileKey = (file) => `${file.subfolder || ""}/${file.filename || file.name || ""}`; diff --git a/js/components/Badges.js b/js/components/Badges.js new file mode 100644 index 00000000..ec03e93d --- /dev/null +++ b/js/components/Badges.js @@ -0,0 +1,214 @@ +/** + * Badges Component - File type, rating, tags badges + */ + +/** + * Create file type badge (overlaid on thumbnail) + */ +export function createFileBadge(filename, kind) { + const badge = document.createElement("div"); + badge.className = "mjr-file-badge"; + + const ext = String(filename || "").split(".").pop()?.toUpperCase?.() || ""; + + let bgColor = "rgba(0,0,0,0.7)"; + switch (ext) { + case "PNG": + bgColor = "rgba(33, 150, 243, 0.85)"; + break; + case "JPG": + case "JPEG": + bgColor = "rgba(0, 188, 212, 0.85)"; + break; + case "WEBP": + bgColor = "rgba(3, 169, 244, 0.85)"; + break; + case "GIF": + bgColor = "rgba(0, 150, 136, 0.85)"; + break; + case "BMP": + case "TIF": + case "TIFF": + bgColor = "rgba(63, 81, 181, 0.85)"; + break; + + case "MP4": + bgColor = "rgba(156, 39, 176, 0.85)"; + break; + case "WEBM": + bgColor = "rgba(103, 58, 183, 0.85)"; + break; + case "MOV": + bgColor = "rgba(142, 36, 170, 0.85)"; + break; + case "AVI": + bgColor = "rgba(123, 31, 162, 0.85)"; + break; + case "MKV": + bgColor = "rgba(171, 71, 188, 0.85)"; + break; + + case "MP3": + bgColor = "rgba(255, 152, 0, 0.85)"; + break; + case "WAV": + bgColor = "rgba(255, 111, 0, 0.85)"; + break; + case "OGG": + case "FLAC": + bgColor = "rgba(255, 193, 7, 0.85)"; + break; + + case "OBJ": + case "FBX": + case "GLB": + case "GLTF": + bgColor = "rgba(76, 175, 80, 0.85)"; + break; + + default: + switch (kind) { + case "image": + bgColor = "rgba(33, 150, 243, 0.8)"; + break; + case "video": + bgColor = "rgba(156, 39, 176, 0.8)"; + break; + case "audio": + bgColor = "rgba(255, 152, 0, 0.8)"; + break; + case "model3d": + bgColor = "rgba(76, 175, 80, 0.8)"; + break; + default: + bgColor = "rgba(96, 125, 139, 0.8)"; + } + } + + badge.textContent = ext; + badge.style.cssText = ` + position: absolute; + top: 6px; + left: 6px; + padding: 3px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + background: ${bgColor}; + color: white; + text-transform: uppercase; + pointer-events: none; + z-index: 10; + letter-spacing: 0.5px; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + `; + + return badge; +} + +/** + * Create workflow status dot (inline with filename) + * States: complete (green), partial (orange), none (red), unparsed (grey) + */ +export function createWorkflowDot(asset) { + const dot = document.createElement("span"); + dot.className = "mjr-workflow-dot"; + + let color = "#666"; + let title = "Not parsed yet"; + + if (asset.has_workflow && asset.has_generation_metadata) { + color = "#4CAF50"; + title = "Complete: Workflow + Generation metadata"; + } else if (asset.has_workflow || asset.has_generation_metadata) { + color = "#FF9800"; + title = asset.has_workflow ? "Partial: Workflow only" : "Partial: Generation metadata only"; + } else if (asset.has_workflow === false || asset.has_generation_metadata === false) { + color = "#f44336"; + title = "No workflow or generation data"; + } + + dot.textContent = "\u25CF"; + dot.title = `${title}\nClick to rescan this file`; + dot.style.cssText = ` + color: ${color}; + margin-left: 4px; + font-size: 12px; + cursor: pointer; + `; + + return dot; +} + +/** + * Create rating badge (stars) - Top right position + */ +export function createRatingBadge(rating) { + const ratingValue = Math.max(0, Math.min(5, Number(rating) || 0)); + if (ratingValue <= 0) return null; + + const badge = document.createElement("div"); + badge.className = "mjr-rating-badge"; + badge.style.cssText = ` + position: absolute; + top: 6px; + right: 6px; + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 2px 6px; + border-radius: 6px; + font-size: 13px; + letter-spacing: 1px; + display: inline-flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 10; + text-shadow: 0 2px 6px rgba(0,0,0,0.6); + box-shadow: 0 6px 18px rgba(0,0,0,0.25); + `; + + for (let i = 1; i <= ratingValue; i++) { + const star = document.createElement("span"); + star.textContent = "\u2605"; + star.style.color = "#FFD45A"; + star.style.marginRight = i < ratingValue ? "2px" : "0"; + badge.appendChild(star); + } + + return badge; +} + +/** + * Create tags badge - Bottom left position + */ +export function createTagsBadge(tags) { + const badge = document.createElement("div"); + badge.className = "mjr-tags-badge"; + + if (!tags || tags.length === 0) { + badge.style.display = "none"; + return badge; + } + + badge.textContent = tags.join(", "); + badge.style.cssText = ` + position: absolute; + bottom: 6px; + left: 6px; + padding: 3px 6px; + border-radius: 4px; + background: rgba(0,0,0,0.8); + color: #90CAF9; + font-size: 9px; + max-width: 80%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + z-index: 10; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + `; + + return badge; +} diff --git a/js/components/Card.js b/js/components/Card.js new file mode 100644 index 00000000..22fe14f2 --- /dev/null +++ b/js/components/Card.js @@ -0,0 +1,391 @@ +/** + * Asset Card Component + */ + +import { buildAssetViewURL } from "../api/endpoints.js"; +import { createFileBadge, createRatingBadge, createTagsBadge, createWorkflowDot } from "./Badges.js"; + +const VIDEO_THUMBS_KEY = "__MJR_VIDEO_THUMBS__"; + +const getVideoThumbManager = () => { + try { + if (window?.[VIDEO_THUMBS_KEY]) return window[VIDEO_THUMBS_KEY]; + } catch {} + + const MAX_AUTOPLAY = 6; + const playing = new Set(); + const prepared = new Set(); + + const waitForLoadedData = (video, timeoutMs = 5000) => + new Promise((resolve) => { + if (!video) return resolve(false); + try { + if (video.readyState >= 2) return resolve(true); + } catch {} + + let done = false; + const finish = (ok) => { + if (done) return; + done = true; + try { + video.removeEventListener("loadeddata", onLoaded); + video.removeEventListener("loadedmetadata", onMetadata); + video.removeEventListener("error", onError); + } catch {} + resolve(ok); + }; + const onLoaded = () => finish(true); + const onMetadata = () => finish(true); + const onError = () => finish(false); + + try { + video.addEventListener("loadeddata", onLoaded, { once: true }); + video.addEventListener("loadedmetadata", onMetadata, { once: true }); + video.addEventListener("error", onError, { once: true }); + } catch { + return resolve(false); + } + + try { + setTimeout(() => finish(false), timeoutMs); + } catch {} + }); + + const ensureFirstFrame = async (video) => { + if (!video) return; + try { + if (video.readyState < 2) return; + } catch {} + try { + // Nudge off 0 to force frame decode on some mp4s. + video.currentTime = 0.05; + } catch {} + try { + video.pause(); + } catch {} + }; + + const stopVideo = (video) => { + if (!video) return; + try { + video.pause(); + } catch {} + try { + video.currentTime = 0; + } catch {} + try { + if (video.getAttribute("src")) { + video.removeAttribute("src"); + video.load(); + } + } catch {} + prepared.delete(video); + }; + + const ensurePrepared = async (video) => { + if (!video) return false; + const src = video.dataset.src || ""; + if (!src) return false; + + if (!prepared.has(video)) { + prepared.add(video); + try { + if (!video.getAttribute("src")) { + video.src = src; + video.load(); + } + video.preload = "metadata"; + } catch {} + } + + const ok = await waitForLoadedData(video); + if (ok) await ensureFirstFrame(video); + return ok; + }; + + const playVideo = async (video) => { + if (!video) return; + const src = video.dataset.src || ""; + if (!src) return; + + if (!playing.has(video)) { + playing.add(video); + while (playing.size > MAX_AUTOPLAY) { + const oldest = playing.values().next().value; + playing.delete(oldest); + stopVideo(oldest); + } + } else { + playing.delete(video); + playing.add(video); + } + + await ensurePrepared(video); + + try { + const p = video.play(); + if (p && typeof p.then === "function") { + p.catch(async () => { + await ensurePrepared(video); + }); + } + } catch { + await ensurePrepared(video); + } + }; + + const pauseVideo = async (video) => { + if (!video) return; + playing.delete(video); + try { + video.pause(); + } catch {} + // Keep a decoded frame visible (prevents "grey tiles" after hover ends). + await ensurePrepared(video); + }; + + const onVisibility = () => { + if (!document.hidden) return; + for (const v of Array.from(playing)) { + playing.delete(v); + stopVideo(v); + try { + const overlay = v._mjrPlayOverlay || null; + if (overlay) overlay.style.opacity = "1"; + } catch {} + } + }; + try { + document.addEventListener("visibilitychange", onVisibility); + } catch {} + + const observer = + typeof IntersectionObserver !== "undefined" + ? new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const video = entry.target; + if (!(video instanceof HTMLVideoElement)) continue; + const overlay = video._mjrPlayOverlay || null; + + if (!video.isConnected || document.hidden) { + playing.delete(video); + stopVideo(video); + try { + observer?.unobserve?.(video); + } catch {} + if (overlay) overlay.style.opacity = "1"; + continue; + } + + if (entry.isIntersecting && entry.intersectionRatio >= 0.2) { + // Prepare first frame when visible; play only on hover/explicit request. + ensurePrepared(video); + if (overlay) overlay.style.opacity = "1"; + } else { + playing.delete(video); + stopVideo(video); + if (overlay) overlay.style.opacity = "1"; + } + } + }, + { threshold: [0, 0.2, 0.6] } + ) + : null; + + const api = { + _bindVideo(video) { + if (!video) return; + try { + if (video._mjrThumbBound) return; + video._mjrThumbBound = true; + } catch {} + + try { + video.addEventListener("error", () => { + playing.delete(video); + stopVideo(video); + try { + const overlay = video._mjrPlayOverlay || null; + if (overlay) overlay.style.opacity = "1"; + } catch {} + }); + } catch {} + }, + observe(video) { + if (!observer || !video) return; + try { + api._bindVideo(video); + observer.observe(video); + } catch {} + }, + unobserve(video) { + if (!observer || !video) return; + try { + observer.unobserve(video); + } catch {} + playing.delete(video); + stopVideo(video); + }, + bindHover(thumb, video) { + if (!thumb || !video) return; + try { + if (thumb._mjrHoverBound) return; + thumb._mjrHoverBound = true; + } catch {} + + const showOverlay = (opacity) => { + try { + const overlay = video._mjrPlayOverlay || null; + if (overlay) overlay.style.opacity = opacity; + } catch {} + }; + + const onEnter = () => { + showOverlay("0"); + playVideo(video); + }; + const onLeave = () => { + showOverlay("1"); + pauseVideo(video); + }; + + try { + thumb.addEventListener("mouseenter", onEnter); + thumb.addEventListener("mouseleave", onLeave); + thumb.addEventListener("focusin", onEnter); + thumb.addEventListener("focusout", onLeave); + } catch {} + } + }; + + try { + window[VIDEO_THUMBS_KEY] = api; + } catch {} + return api; +}; + +/** + * Create asset card element + */ +export function createAssetCard(asset) { + const card = document.createElement("div"); + card.className = "mjr-asset-card"; + card.classList.add("mjr-card"); + // Enable dragging (handled via delegated dragstart handler on the grid container). + card.draggable = true; + card.tabIndex = 0; + card.setAttribute("role", "button"); + card.setAttribute("aria-label", `Asset ${asset?.filename || ""}`); + card.setAttribute("aria-selected", "false"); + if (asset?.id != null) { + try { + card.dataset.mjrAssetId = String(asset.id); + } catch {} + } + + const viewUrl = buildAssetViewURL(asset); + + // Thumbnail + const thumb = createThumbnail(asset, viewUrl); + + // Add file type badge (top left) + const fileBadge = createFileBadge(asset.filename, asset.kind); + thumb.appendChild(fileBadge); + + // Add rating badge (top right) + const ratingBadge = createRatingBadge(asset.rating || 0); + if (ratingBadge) thumb.appendChild(ratingBadge); + + // Add tags badge (bottom left) + const tagsBadge = createTagsBadge(asset.tags || []); + thumb.appendChild(tagsBadge); + + // Store asset reference for sidebar + card._mjrAsset = asset; + + // Info section + const info = document.createElement("div"); + info.classList.add("mjr-card-meta"); + + const filenameDiv = document.createElement("div"); + filenameDiv.classList.add("mjr-card-filename"); + filenameDiv.title = asset.filename; + + const filenameText = document.createElement("span"); + filenameText.textContent = asset.filename; + filenameText.classList.add("mjr-card-filename-text"); + + filenameDiv.appendChild(filenameText); + + // Add workflow status dot + const workflowDot = createWorkflowDot(asset); + filenameDiv.appendChild(workflowDot); + + info.appendChild(filenameDiv); + + card.appendChild(thumb); + card.appendChild(info); + + // Store view URL (click handled by panel event delegation) + card.dataset.mjrViewUrl = viewUrl; + + return card; +} + +/** + * Create thumbnail for asset + */ +function createThumbnail(asset, viewUrl) { + const thumb = document.createElement("div"); + thumb.classList.add("mjr-thumb"); + + if (asset.kind === "image") { + const img = document.createElement("img"); + img.src = viewUrl; + img.classList.add("mjr-thumb-media"); + try { + img.loading = "lazy"; + img.decoding = "async"; + } catch {} + thumb.appendChild(img); + } else if (asset.kind === "video") { + // Create video element lazily to avoid eager network/decoder cost on large grids + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "metadata"; + video.classList.add("mjr-thumb-media"); + video.dataset.src = viewUrl; + video.controls = false; + // Avoid capturing clicks (selection is handled at card level); hover is bound on the thumb wrapper. + video.tabIndex = -1; + video.style.pointerEvents = "none"; + try { + video.disablePictureInPicture = true; + } catch {} + + // Play icon overlay + const playIcon = document.createElement("div"); + playIcon.textContent = "â–ļ"; + playIcon.classList.add("mjr-thumb-play"); + + // Replace the placeholder glyph and autoplay when visible. + playIcon.textContent = ""; + const playIconEl = document.createElement("i"); + playIconEl.className = "pi pi-play"; + playIcon.appendChild(playIconEl); + try { + video._mjrPlayOverlay = playIcon; + } catch {} + thumb.appendChild(video); + thumb.appendChild(playIcon); + // Observe after insertion for reliable IntersectionObserver behavior. + const mgr = getVideoThumbManager(); + mgr.observe(video); + mgr.bindHover(thumb, video); + } + + return thumb; +} diff --git a/js/components/ContextMenu.js b/js/components/ContextMenu.js new file mode 100644 index 00000000..109c32f3 --- /dev/null +++ b/js/components/ContextMenu.js @@ -0,0 +1,295 @@ +/** + * Context Menu Component + * Right-click menu for asset cards. + * + * Notes: + * - Idempotent: binds once per grid container. + * - Safe defaults: only uses existing backend routes. + * - Uses PrimeIcons (`pi pi-*`) for icons. + */ + +import { getViewerInstance } from "./Viewer.js"; +import { buildAssetViewURL } from "../api/endpoints.js"; +import { comfyAlert } from "../app/dialogs.js"; +import { openInFolder } from "../api/client.js"; + +const MENU_SELECTOR = ".mjr-context-menu"; + +export function createContextMenu() { + const existing = document.querySelector(MENU_SELECTOR); + if (existing) return existing; + + const menu = document.createElement("div"); + menu.className = "mjr-context-menu"; + menu.style.cssText = ` + position: fixed; + background: var(--comfy-menu-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + min-width: 200px; + z-index: 9999; + display: none; + padding: 4px 0; + `; + + const hide = () => { + menu.style.display = "none"; + }; + + const ac = new AbortController(); + menu._mjrAbortController = ac; + + document.addEventListener( + "click", + (e) => { + if (!menu.contains(e.target)) hide(); + }, + { signal: ac.signal } + ); + document.addEventListener( + "keydown", + (e) => { + if (e.key === "Escape") hide(); + }, + { signal: ac.signal } + ); + document.addEventListener("scroll", hide, { capture: true, signal: ac.signal }); + + document.body.appendChild(menu); + return menu; +} + +function createMenuItem(label, iconClass, shortcut, onClick) { + const item = document.createElement("div"); + item.className = "mjr-context-menu-item"; + item.style.cssText = ` + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + color: var(--fg-color); + font-size: 13px; + transition: background 0.15s; + user-select: none; + `; + + const leftSide = document.createElement("div"); + leftSide.style.cssText = "display: flex; align-items: center; gap: 10px;"; + + if (iconClass) { + const iconEl = document.createElement("i"); + iconEl.className = iconClass; + iconEl.style.cssText = "width: 16px; text-align: center; opacity: 0.8;"; + leftSide.appendChild(iconEl); + } + + const labelEl = document.createElement("span"); + labelEl.textContent = label; + leftSide.appendChild(labelEl); + item.appendChild(leftSide); + + if (shortcut) { + const shortcutEl = document.createElement("span"); + shortcutEl.textContent = shortcut; + shortcutEl.style.cssText = "font-size: 11px; opacity: 0.5; margin-left: 20px;"; + item.appendChild(shortcutEl); + } + + item.addEventListener("mouseenter", () => { + item.style.background = "var(--comfy-input-bg)"; + }); + item.addEventListener("mouseleave", () => { + item.style.background = "transparent"; + }); + + item.addEventListener("click", (e) => { + e.stopPropagation(); + try { + onClick?.(); + } catch {} + }); + + return item; +} + +function createMenuSeparator() { + const separator = document.createElement("div"); + separator.className = "mjr-context-menu-separator"; + separator.style.cssText = ` + height: 1px; + background: var(--border-color); + margin: 4px 0; + `; + return separator; +} + +export function showContextMenu(x, y, asset, allAssets, currentIndex, selectedAssets = [], { onDetails } = {}) { + const menu = createContextMenu(); + menu.innerHTML = ""; + + const selectedCount = selectedAssets.length; + const hide = () => { + try { + menu.style.display = "none"; + } catch {} + }; + + const openViewer = createMenuItem("Open in Viewer", "pi pi-eye", "Double-click", () => { + const viewer = getViewerInstance(); + if (selectedCount >= 2 && selectedCount <= 4) { + viewer.open(selectedAssets, 0); + } else { + viewer.open(allAssets, currentIndex); + } + }); + menu.appendChild(openViewer); + + const details = createMenuItem("Details", "pi pi-info-circle", "D", () => { + hide(); + try { + onDetails?.(asset); + } catch {} + }); + menu.appendChild(details); + + const openNewTab = createMenuItem("Open in New Tab", "pi pi-external-link", null, () => { + hide(); + const url = buildAssetViewURL(asset); + window.open(url, "_blank"); + }); + menu.appendChild(openNewTab); + + menu.appendChild(createMenuSeparator()); + + if (selectedCount === 2) { + const ab = createMenuItem("Compare A/B (2 selected)", "pi pi-clone", null, () => { + const viewer = getViewerInstance(); + viewer.open(selectedAssets, 0); + viewer.setMode("ab"); + }); + menu.appendChild(ab); + } + + if (selectedCount >= 2 && selectedCount <= 4) { + const side = createMenuItem(`Side-by-side (2×2) (${selectedCount} selected)`, "pi pi-table", null, () => { + const viewer = getViewerInstance(); + viewer.open(selectedAssets, 0); + viewer.setMode("sidebyside"); + }); + menu.appendChild(side); + } + + const copyPath = createMenuItem("Copy file path", "pi pi-copy", null, async () => { + try { + const path = asset?.filepath ? String(asset.filepath) : ""; + if (!path) { + await comfyAlert("No file path available for this asset."); + return; + } + await navigator.clipboard.writeText(path); + } catch (error) { + console.error("Copy path failed:", error); + } + hide(); + }); + menu.appendChild(copyPath); + + const openFolder = createMenuItem("Open in Folder", "pi pi-folder-open", null, async () => { + try { + const assetId = asset?.id; + if (!assetId) { + await comfyAlert("No asset id available for this item."); + return; + } + const res = await openInFolder(assetId); + if (!res?.ok) { + await comfyAlert(res?.error || "Failed to open folder."); + } + } catch (error) { + console.error("Open in folder failed:", error); + } + hide(); + }); + menu.appendChild(openFolder); + + menu.style.display = "block"; + + const rect = menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let finalX = x; + let finalY = y; + + if (x + rect.width > viewportWidth) { + finalX = viewportWidth - rect.width - 10; + } + if (y + rect.height > viewportHeight) { + finalY = viewportHeight - rect.height - 10; + } + + menu.style.left = `${finalX}px`; + menu.style.top = `${finalY}px`; +} + +export function bindContextMenu(gridContainer, assets) { + try { + gridContainer._mjrContextMenuAssets = Array.isArray(assets) ? assets : []; + } catch {} + + if (gridContainer._mjrContextMenuBound) return; + gridContainer._mjrContextMenuBound = true; + + gridContainer.addEventListener("contextmenu", (e) => { + const card = e.target.closest(".mjr-asset-card"); + if (!card) return; + + e.preventDefault(); + e.stopPropagation(); + + // If right-clicking on a non-selected card, treat it as a single selection. + try { + if (!card.classList.contains("is-selected")) { + for (const el of gridContainer.querySelectorAll(".mjr-asset-card.is-selected")) { + el.classList.remove("is-selected"); + el.setAttribute?.("aria-selected", "false"); + } + card.classList.add("is-selected"); + card.setAttribute?.("aria-selected", "true"); + try { + const id = card?.dataset?.mjrAssetId; + if (id) { + gridContainer.dataset.mjrSelectedAssetId = String(id); + gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify([String(id)]); + } + } catch {} + } + } catch {} + + const currentAsset = card._mjrAsset; + if (!currentAsset) return; + + const list = gridContainer._mjrContextMenuAssets || []; + const idx = Array.isArray(list) ? list.findIndex((a) => a.id === currentAsset.id) : -1; + + const selected = Array.from(gridContainer.querySelectorAll(".mjr-asset-card.is-selected")) + .map((c) => c?._mjrAsset) + .filter(Boolean); + + // Keep dataset selection in sync so selection survives grid re-renders. + try { + const ids = Array.from(gridContainer.querySelectorAll(".mjr-asset-card.is-selected")) + .map((c) => c?.dataset?.mjrAssetId) + .filter(Boolean) + .map(String); + gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(ids); + if (card?.dataset?.mjrAssetId) gridContainer.dataset.mjrSelectedAssetId = String(card.dataset.mjrAssetId); + } catch {} + + showContextMenu(e.clientX, e.clientY, currentAsset, list, idx, selected, { + onDetails: typeof gridContainer._mjrOpenDetails === "function" ? gridContainer._mjrOpenDetails : null, + }); + }); +} diff --git a/js/components/RatingEditor.js b/js/components/RatingEditor.js new file mode 100644 index 00000000..61449126 --- /dev/null +++ b/js/components/RatingEditor.js @@ -0,0 +1,175 @@ +/** + * Rating Editor Component - Interactive star rating + */ + +import { updateAssetRating } from "../api/client.js"; +import { ASSET_RATING_CHANGED_EVENT } from "../app/events.js"; +import { getPopoverManagerForElement } from "../features/panel/views/popoverManager.js"; +import { safeDispatchCustomEvent } from "../utils/events.js"; + +/** + * Create interactive star rating editor + * @param {Object} asset - Asset object with id and current rating + * @param {Function} onUpdate - Callback when rating changes + * @returns {HTMLElement} + */ +export function createRatingEditor(asset, onUpdate) { + const container = document.createElement("div"); + container.className = "mjr-rating-editor"; + + let currentRating = Math.max(0, Math.min(5, Number(asset.rating) || 0)); + let hoveredStar = 0; + + // Create 5 stars + for (let i = 1; i <= 5; i++) { + const star = document.createElement("button"); + star.type = "button"; + star.className = "mjr-rating-star"; + star.textContent = "★"; + star.dataset.rating = String(i); + star.style.color = i <= currentRating ? "#FFD45A" : "#555"; + + // Hover effects + star.addEventListener("mouseenter", () => { + hoveredStar = i; + updateStarColors(container, hoveredStar); + }); + + star.addEventListener("mouseleave", () => { + hoveredStar = 0; + updateStarColors(container, currentRating); + }); + + // Click to set rating + star.addEventListener("click", async (e) => { + e.stopPropagation(); + e.preventDefault(); + + const newRating = i; + const prevRating = currentRating; + + // Optimistic UI update + asset.rating = newRating; + currentRating = newRating; + updateStarColors(container, newRating); + + // Save to backend + let result = null; + try { + // Supports both `asset_id` and `{filepath,type,root_id}` payloads. + result = await updateAssetRating(asset, newRating); + } catch (err) { + console.error("Failed to update rating:", err); + } + + if (!result?.ok) { + // Revert if backend rejected the change. + asset.rating = prevRating; + currentRating = prevRating; + updateStarColors(container, prevRating); + return; + } + + // If we rated an unindexed file, the backend may have created an asset row. + try { + const newId = result?.data?.asset_id ?? null; + if (asset.id == null && newId != null) asset.id = newId; + } catch {} + + if (onUpdate) onUpdate(newRating); + safeDispatchCustomEvent( + ASSET_RATING_CHANGED_EVENT, + { assetId: String(asset.id), rating: newRating }, + { warnPrefix: "[RatingEditor]" } + ); + }); + + container.appendChild(star); + } + + // Allow external updates (e.g. hotkeys) to refresh the editor UI while keeping behavior consistent. + try { + container._mjrSetRating = (rating) => { + const next = Math.max(0, Math.min(5, Number(rating) || 0)); + asset.rating = next; + currentRating = next; + updateStarColors(container, next); + }; + } catch {} + + return container; +} + +/** + * Update star colors based on rating + */ +function updateStarColors(container, rating) { + const stars = container.querySelectorAll(".mjr-rating-star"); + stars.forEach((star, index) => { + const starValue = index + 1; + star.style.color = starValue <= rating ? "#FFD45A" : "#555"; + star.style.transform = starValue <= rating ? "scale(1.1)" : "scale(1)"; + }); +} + +/** + * Show rating editor in a popover + * @param {HTMLElement} anchor - Element to anchor popover to + * @param {Object} asset - Asset object + * @param {Function} onUpdate - Callback when rating changes + */ +export function showRatingPopover(anchor, asset, onUpdate) { + const existing = document.querySelector(".mjr-rating-popover"); + if (existing) { + try { + existing.remove(); + } catch {} + } + + const popover = document.createElement("div"); + popover.className = "mjr-popover mjr-rating-popover"; + + const popovers = getPopoverManagerForElement(anchor); + const host = anchor?.closest?.(".mjr-am-container") || null; + + const editor = createRatingEditor(asset, (newRating) => { + try { + onUpdate?.(newRating); + } catch {} + setTimeout(() => { + try { + popovers?.close?.(popover); + } catch {} + try { + popover.remove(); + } catch {} + }, 150); + }); + + popover.appendChild(editor); + (host || document.body).appendChild(popover); + popover.style.display = "block"; + + if (popovers) { + popovers.open(popover, anchor); + return popover; + } + + // Fallback positioning when used outside the Assets Manager panel. + const rect = anchor.getBoundingClientRect(); + popover.style.position = "fixed"; + popover.style.top = `${rect.bottom + 8}px`; + popover.style.left = `${rect.left}px`; + popover.style.zIndex = "2147483647"; + + const closeOnClickOutside = (e) => { + if (!popover.contains(e.target) && !anchor.contains(e.target)) { + try { + popover.remove(); + } catch {} + document.removeEventListener("click", closeOnClickOutside); + } + }; + setTimeout(() => document.addEventListener("click", closeOnClickOutside), 100); + return popover; +} diff --git a/js/components/SidebarView.js b/js/components/SidebarView.js new file mode 100644 index 00000000..6fa6668d --- /dev/null +++ b/js/components/SidebarView.js @@ -0,0 +1,3 @@ +// Compatibility shim: keep existing import path stable. +export { createSidebar, showAssetInSidebar, closeSidebar } from "./sidebar/SidebarView.js"; + diff --git a/js/components/TagsEditor.js b/js/components/TagsEditor.js new file mode 100644 index 00000000..f004728d --- /dev/null +++ b/js/components/TagsEditor.js @@ -0,0 +1,401 @@ +/** + * Tags Editor Component - Add/remove tags with autocomplete + */ + +import { updateAssetTags, getAvailableTags } from "../api/client.js"; +import { ASSET_TAGS_CHANGED_EVENT } from "../app/events.js"; +import { getPopoverManagerForElement } from "../features/panel/views/popoverManager.js"; +import { safeDispatchCustomEvent } from "../utils/events.js"; + +/** + * Create interactive tags editor + * @param {Object} asset - Asset object with id and current tags + * @param {Function} onUpdate - Callback when tags change + * @returns {HTMLElement} + */ +export function createTagsEditor(asset, onUpdate) { + const container = document.createElement("div"); + container.className = "mjr-tags-editor"; + container.style.cssText = ` + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + background: rgba(0,0,0,0.95); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); + min-width: 250px; + max-width: 350px; + `; + + const currentTags = Array.isArray(asset.tags) ? [...asset.tags] : []; + + // Current tags display + const tagsDisplay = document.createElement("div"); + tagsDisplay.className = "mjr-tags-display"; + tagsDisplay.style.cssText = ` + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 32px; + `; + + const renderTags = () => { + tagsDisplay.innerHTML = ""; + if (currentTags.length === 0) { + const placeholder = document.createElement("span"); + placeholder.textContent = "No tags yet..."; + placeholder.style.cssText = "color: #666; font-size: 12px; font-style: italic;"; + tagsDisplay.appendChild(placeholder); + return; + } + + currentTags.forEach(tag => { + const tagChip = createTagChip(tag, () => { + const index = currentTags.indexOf(tag); + if (index > -1) { + currentTags.splice(index, 1); + renderTags(); + saveTags(); + } + }); + tagsDisplay.appendChild(tagChip); + }); + }; + + renderTags(); + + // Input for new tags + const inputWrapper = document.createElement("div"); + inputWrapper.style.cssText = "position: relative;"; + + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = "Add tag..."; + input.className = "mjr-tag-input"; + input.style.cssText = ` + width: 100%; + padding: 6px 8px; + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.2); + border-radius: 4px; + color: white; + font-size: 13px; + outline: none; + `; + + // Autocomplete dropdown + const dropdown = document.createElement("div"); + dropdown.className = "mjr-tags-dropdown"; + dropdown.style.cssText = ` + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: rgba(0,0,0,0.95); + border: 1px solid rgba(255,255,255,0.2); + border-radius: 4px; + max-height: 150px; + overflow-y: auto; + display: none; + z-index: 1000; + `; + + let availableTags = []; + let selectedIndex = -1; + + // Load available tags from server + const loadAvailableTags = async () => { + try { + const result = await getAvailableTags(); + if (result.ok && Array.isArray(result.data)) { + availableTags = result.data; + } + } catch (err) { + console.warn("Failed to load available tags:", err); + } + }; + loadAvailableTags(); + + // Filter and show suggestions + const showSuggestions = (query) => { + const normalizedQuery = query.toLowerCase().trim(); + if (!normalizedQuery) { + dropdown.style.display = "none"; + return; + } + + const suggestions = availableTags + .filter(tag => { + const normalized = tag.toLowerCase(); + return normalized.includes(normalizedQuery) && !currentTags.includes(tag); + }) + .slice(0, 10); + + if (suggestions.length === 0) { + dropdown.style.display = "none"; + return; + } + + dropdown.innerHTML = ""; + suggestions.forEach((tag, index) => { + const item = document.createElement("div"); + item.className = "mjr-tag-suggestion"; + item.textContent = tag; + item.style.cssText = ` + padding: 6px 8px; + cursor: pointer; + font-size: 13px; + color: white; + background: ${index === selectedIndex ? "rgba(144, 202, 249, 0.2)" : "transparent"}; + `; + + item.addEventListener("mouseenter", () => { + selectedIndex = index; + showSuggestions(query); + }); + + item.addEventListener("click", () => { + addTag(tag); + input.value = ""; + dropdown.style.display = "none"; + }); + + dropdown.appendChild(item); + }); + + dropdown.style.display = "block"; + }; + + // Add tag function + const addTag = (tag) => { + const normalized = tag.trim(); + if (!normalized || currentTags.includes(normalized)) { + return; + } + currentTags.push(normalized); + renderTags(); + saveTags(); + }; + + // Save tags to backend + const saveTags = async () => { + const prev = Array.isArray(asset.tags) ? [...asset.tags] : []; + let result = null; + try { + // Supports both `asset_id` and `{filepath,type,root_id}` payloads. + result = await updateAssetTags(asset, currentTags); + } catch (err) { + console.error("Failed to update tags:", err); + } + + if (!result?.ok) { + // Revert if backend rejected the change. + currentTags.splice(0, currentTags.length, ...prev); + asset.tags = [...prev]; + renderTags(); + return; + } + + // If we tagged an unindexed file, the backend may have created an asset row. + try { + const newId = result?.data?.asset_id ?? null; + if (asset.id == null && newId != null) asset.id = newId; + } catch {} + + asset.tags = [...currentTags]; + if (onUpdate) onUpdate(currentTags); + safeDispatchCustomEvent( + ASSET_TAGS_CHANGED_EVENT, + { assetId: String(asset.id), tags: [...currentTags] }, + { warnPrefix: "[TagsEditor]" } + ); + }; + + // Input event handlers + input.addEventListener("input", (e) => { + showSuggestions(e.target.value); + selectedIndex = -1; + }); + + input.addEventListener("keydown", (e) => { + const suggestions = dropdown.querySelectorAll(".mjr-tag-suggestion"); + + if (e.key === "ArrowDown") { + e.preventDefault(); + if (suggestions.length > 0) { + selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1); + showSuggestions(input.value); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (suggestions.length > 0) { + selectedIndex = Math.max(selectedIndex - 1, -1); + showSuggestions(input.value); + } + } else if (e.key === "Enter") { + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + suggestions[selectedIndex].click(); + } else { + addTag(input.value); + input.value = ""; + dropdown.style.display = "none"; + } + } else if (e.key === "Escape") { + dropdown.style.display = "none"; + selectedIndex = -1; + } else if (e.key === "," || e.key === ";") { + e.preventDefault(); + addTag(input.value); + input.value = ""; + dropdown.style.display = "none"; + } + }); + + input.addEventListener("blur", () => { + // Delay to allow click on dropdown + setTimeout(() => { + dropdown.style.display = "none"; + }, 200); + }); + + inputWrapper.appendChild(input); + inputWrapper.appendChild(dropdown); + + // Allow external updates (e.g. hotkeys) to refresh the editor UI. + try { + container._mjrSetTags = (tags) => { + const next = Array.isArray(tags) ? tags.filter(Boolean).map(String) : []; + currentTags.splice(0, currentTags.length, ...next); + renderTags(); + }; + } catch {} + + container.appendChild(tagsDisplay); + container.appendChild(inputWrapper); + + return container; +} + +/** + * Create a tag chip with remove button + */ +function createTagChip(tag, onRemove) { + const chip = document.createElement("span"); + chip.className = "mjr-tag-chip"; + chip.style.cssText = ` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: rgba(144, 202, 249, 0.2); + border: 1px solid rgba(144, 202, 249, 0.4); + border-radius: 12px; + font-size: 11px; + color: #90CAF9; + `; + + const text = document.createElement("span"); + text.textContent = tag; + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.textContent = "×"; + removeBtn.style.cssText = ` + background: none; + border: none; + color: #90CAF9; + cursor: pointer; + font-size: 14px; + padding: 0; + margin: 0; + line-height: 1; + opacity: 0.7; + transition: opacity 0.2s; + `; + + removeBtn.addEventListener("mouseenter", () => { + removeBtn.style.opacity = "1"; + }); + + removeBtn.addEventListener("mouseleave", () => { + removeBtn.style.opacity = "0.7"; + }); + + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (onRemove) { + onRemove(); + } + }); + + chip.appendChild(text); + chip.appendChild(removeBtn); + + return chip; +} + +/** + * Show tags editor in a popover + * @param {HTMLElement} anchor - Element to anchor popover to + * @param {Object} asset - Asset object + * @param {Function} onUpdate - Callback when tags change + */ +export function showTagsPopover(anchor, asset, onUpdate) { + const existing = document.querySelector(".mjr-tags-popover"); + if (existing) { + try { + existing.remove(); + } catch {} + } + + const popover = document.createElement("div"); + popover.className = "mjr-popover mjr-tags-popover"; + + const popovers = getPopoverManagerForElement(anchor); + const host = anchor?.closest?.(".mjr-am-container") || null; + + const editor = createTagsEditor(asset, (tags) => { + try { + onUpdate?.(tags); + } catch {} + // Keep the editor open; caller can close explicitly if desired. + }); + popover.appendChild(editor); + (host || document.body).appendChild(popover); + popover.style.display = "block"; + + if (popovers) { + popovers.open(popover, anchor); + return popover; + } + + // Fallback positioning when used outside the Assets Manager panel. + const rect = anchor.getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + let top = rect.bottom + 8; + let left = rect.left; + if (top + popoverRect.height > window.innerHeight) { + top = rect.top - popoverRect.height - 8; + } + if (left + popoverRect.width > window.innerWidth) { + left = window.innerWidth - popoverRect.width - 8; + } + popover.style.position = "fixed"; + popover.style.top = `${top}px`; + popover.style.left = `${left}px`; + popover.style.zIndex = "2147483647"; + + const closeOnClickOutside = (e) => { + if (!popover.contains(e.target) && !anchor.contains(e.target)) { + try { + popover.remove(); + } catch {} + document.removeEventListener("click", closeOnClickOutside); + } + }; + setTimeout(() => document.addEventListener("click", closeOnClickOutside), 100); + return popover; +} diff --git a/js/components/Viewer.js b/js/components/Viewer.js new file mode 100644 index 00000000..240d4a5c --- /dev/null +++ b/js/components/Viewer.js @@ -0,0 +1,1683 @@ +/** + * Asset Viewer Component + * Supports Single, A/B Compare, and Side-by-Side modes + */ + +import { buildAssetViewURL } from "../api/endpoints.js"; +import { updateAssetRating, getAssetMetadata, getAssetsBatch } from "../api/client.js"; +import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../app/events.js"; +import { bindViewerContextMenu } from "../features/viewer/ViewerContextMenu.js"; +import { createFileBadge, createRatingBadge, createTagsBadge } from "./Badges.js"; +import { APP_CONFIG } from "../app/config.js"; +import { safeDispatchCustomEvent } from "../utils/events.js"; + +/** + * Viewer modes + */ +export const VIEWER_MODES = { + SINGLE: 'single', + AB_COMPARE: 'ab', + SIDE_BY_SIDE: 'sidebyside' +}; + +/** + * Create the main viewer overlay + */ +export function createViewer() { + const overlay = document.createElement("div"); + overlay.className = "mjr-viewer-overlay"; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.95); + z-index: 10000; + display: none; + flex-direction: column; + `; + // Ensure the overlay can receive focus so keyboard shortcuts work reliably. + // (We still install a capture listener on `window` to beat ComfyUI global handlers.) + overlay.tabIndex = -1; + overlay.setAttribute("role", "dialog"); + + // Header with controls + const header = document.createElement("div"); + header.className = "mjr-viewer-header"; + header.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + background: rgba(0, 0, 0, 0.8); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: white; + `; + + // Left controls + const leftControls = document.createElement("div"); + leftControls.style.cssText = "display: flex; gap: 12px; align-items: center;"; + + // Filename + badges + const leftMeta = document.createElement("div"); + leftMeta.style.cssText = "display:flex; align-items:center; gap:10px; min-width:0;"; + + const filename = document.createElement("span"); + filename.className = "mjr-viewer-filename"; + filename.style.cssText = + "font-size: 14px; font-weight: 500; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"; + + const badgesBar = document.createElement("div"); + badgesBar.className = "mjr-viewer-badges"; + badgesBar.style.cssText = "display:flex; gap:8px; align-items:center; flex-wrap:wrap;"; + + leftMeta.appendChild(filename); + leftMeta.appendChild(badgesBar); + + // Mode selector + const modeButtons = document.createElement("div"); + modeButtons.className = "mjr-viewer-mode-buttons"; + modeButtons.style.cssText = "display: flex; gap: 6px;"; + + const singleBtn = createModeButton("Single", VIEWER_MODES.SINGLE); + const abBtn = createModeButton("A/B", VIEWER_MODES.AB_COMPARE); + const sideBtn = createModeButton("Side", VIEWER_MODES.SIDE_BY_SIDE); + + modeButtons.appendChild(singleBtn); + modeButtons.appendChild(abBtn); + modeButtons.appendChild(sideBtn); + + leftControls.appendChild(leftMeta); + leftControls.appendChild(modeButtons); + + // Right controls + const rightControls = document.createElement("div"); + rightControls.style.cssText = "display: flex; gap: 12px; align-items: center;"; + + // Zoom controls + const zoomOut = createIconButton("−", "Zoom Out"); + const zoomReset = createIconButton("100%", "Reset Zoom"); + const zoomIn = createIconButton("+", "Zoom In"); + + // Zoom HUD (shows current zoom percentage) + const zoomHUD = document.createElement("div"); + zoomHUD.className = "mjr-viewer-zoom-hud"; + zoomHUD.style.cssText = ` + position: absolute; + top: 20px; + right: 20px; + padding: 6px 12px; + background: rgba(0, 0, 0, 0.7); + color: white; + border-radius: 4px; + font-size: 14px; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1000; + `; + + // Close button + const closeBtn = createIconButton("✕", "Close (Esc)"); + closeBtn.style.fontSize = "20px"; + + rightControls.appendChild(zoomOut); + rightControls.appendChild(zoomReset); + rightControls.appendChild(zoomIn); + rightControls.appendChild(closeBtn); + + // Add zoom HUD to header + header.appendChild(zoomHUD); + + header.appendChild(leftControls); + header.appendChild(rightControls); + + // Content area + const content = document.createElement("div"); + content.className = "mjr-viewer-content"; + content.style.cssText = ` + flex: 1; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + `; + + // Single view container + const singleView = document.createElement("div"); + singleView.className = "mjr-viewer-single"; + singleView.style.cssText = ` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + `; + + // A/B compare container + const abView = document.createElement("div"); + abView.className = "mjr-viewer-ab"; + abView.style.cssText = ` + width: 100%; + height: 100%; + display: none; + position: relative; + `; + + // Side-by-side container + const sideView = document.createElement("div"); + sideView.className = "mjr-viewer-sidebyside"; + sideView.style.cssText = ` + width: 100%; + height: 100%; + display: none; + flex-direction: row; + gap: 2px; + `; + + content.appendChild(singleView); + content.appendChild(abView); + content.appendChild(sideView); + + // Footer with navigation + const footer = document.createElement("div"); + footer.className = "mjr-viewer-footer"; + footer.style.cssText = ` + display: flex; + justify-content: center; + align-items: center; + padding: 12px 20px; + background: rgba(0, 0, 0, 0.8); + border-top: 1px solid rgba(255, 255, 255, 0.1); + color: white; + gap: 20px; + `; + + const prevBtn = createIconButton("‹", "Previous (←)"); + prevBtn.style.fontSize = "24px"; + const indexInfo = document.createElement("span"); + indexInfo.className = "mjr-viewer-index"; + indexInfo.style.cssText = "font-size: 14px;"; + const nextBtn = createIconButton("â€ē", "Next (→)"); + nextBtn.style.fontSize = "24px"; + + footer.appendChild(prevBtn); + footer.appendChild(indexInfo); + footer.appendChild(nextBtn); + + overlay.appendChild(header); + overlay.appendChild(content); + overlay.appendChild(footer); + + // Viewer state + const state = { + mode: VIEWER_MODES.SINGLE, + assets: [], + currentIndex: 0, + zoom: 1, + panX: 0, + panY: 0, + // Smooth animation target values + targetZoom: 1, + targetPanX: 0, + targetPanY: 0, + _lastPointerX: null, + _lastPointerY: null, + _mediaW: 0, + _mediaH: 0, + compareAsset: null + }; + + // Metadata hydration cache for viewer-visible assets (so rating/tags show even if the grid + // passed a lightweight asset object). + const _metaCache = new Map(); // id -> { at:number, data:any } + const META_TTL_MS = APP_CONFIG.VIEWER_META_TTL_MS ?? 30_000; + const META_MAX_ENTRIES = APP_CONFIG.VIEWER_META_MAX_ENTRIES ?? 500; + + const cleanupMetaCache = () => { + if (_metaCache.size <= META_MAX_ENTRIES) return; + const now = Date.now(); + + // Remove expired entries first. + try { + for (const [key, value] of _metaCache.entries()) { + if (!value) continue; + if (now - (value.at || 0) > META_TTL_MS) { + _metaCache.delete(key); + } + } + } catch {} + + if (_metaCache.size <= META_MAX_ENTRIES) return; + // Still too large: remove oldest by timestamp. + try { + const sorted = Array.from(_metaCache.entries()).sort((a, b) => (a?.[1]?.at || 0) - (b?.[1]?.at || 0)); + const excess = _metaCache.size - META_MAX_ENTRIES; + for (let i = 0; i < excess; i++) { + const key = sorted[i]?.[0]; + if (key != null) _metaCache.delete(key); + } + } catch {} + }; + + const applyMetadata = (asset, meta) => { + if (!asset || !meta || typeof meta !== "object") return; + try { + if (meta.rating !== undefined) asset.rating = meta.rating; + } catch {} + try { + if (meta.tags !== undefined) asset.tags = meta.tags; + } catch {} + }; + + const hydrateAssetMetadata = async (asset) => { + const id = asset?.id; + if (id == null) return; + const key = String(id); + const now = Date.now(); + const cached = _metaCache.get(key); + if (cached && now - (cached.at || 0) < META_TTL_MS) { + applyMetadata(asset, cached.data); + return; + } + try { + const res = await getAssetMetadata(id); + if (res?.ok && res.data) { + _metaCache.set(key, { at: now, data: res.data }); + cleanupMetaCache(); + applyMetadata(asset, res.data); + } + } catch {} + }; + + const hydrateAssetsMetadataBatch = async (assets) => { + const list = Array.isArray(assets) ? assets : []; + const now = Date.now(); + const toFetch = []; + for (const asset of list) { + const id = asset?.id; + if (id == null) continue; + const key = String(id); + const cached = _metaCache.get(key); + if (cached && now - (cached.at || 0) < META_TTL_MS) { + applyMetadata(asset, cached.data); + continue; + } + toFetch.push(id); + } + if (!toFetch.length) return; + + try { + const res = await getAssetsBatch(toFetch); + const items = Array.isArray(res?.data) ? res.data : []; + for (const meta of items) { + const id = meta?.id; + if (id == null) continue; + const key = String(id); + _metaCache.set(key, { at: now, data: meta }); + } + cleanupMetaCache(); + // Apply to local asset objects + for (const asset of list) { + const id = asset?.id; + if (id == null) continue; + const cached = _metaCache.get(String(id)); + if (cached && cached.data) applyMetadata(asset, cached.data); + } + } catch {} + }; + + const hydrateVisibleMetadata = async () => { + try { + if (state.mode === VIEWER_MODES.SINGLE) { + const current = state.assets[state.currentIndex]; + if (current) await hydrateAssetsMetadataBatch([current]); + return; + } + const visible = Array.isArray(state.assets) ? state.assets.slice(0, 4) : []; + const batch = visible.slice(); + if (state.compareAsset) batch.push(state.compareAsset); + await hydrateAssetsMetadataBatch(batch); + } catch {} + }; + + const updateMediaNaturalSize = () => { + try { + let root = singleView; + if (state.mode === VIEWER_MODES.AB_COMPARE) root = abView; + else if (state.mode === VIEWER_MODES.SIDE_BY_SIDE) root = sideView; + const el = root?.querySelector?.(".mjr-viewer-media"); + if (!el) return; + if (el.tagName === "IMG") { + const w = Number(el.naturalWidth) || 0; + const h = Number(el.naturalHeight) || 0; + if (w > 0 && h > 0) { + state._mediaW = w; + state._mediaH = h; + } + return; + } + if (el.tagName === "VIDEO") { + const w = Number(el.videoWidth) || 0; + const h = Number(el.videoHeight) || 0; + if (w > 0 && h > 0) { + state._mediaW = w; + state._mediaH = h; + } + } + } catch {} + }; + + const attachMediaLoadHandlers = (mediaEl) => { + if (!mediaEl || mediaEl._mjrMediaSizeBound) return; + mediaEl._mjrMediaSizeBound = true; + const sync = () => { + updateMediaNaturalSize(); + clampPanToBounds(); + applyTransform(); + }; + try { + if (mediaEl.tagName === "IMG") { + mediaEl.addEventListener("load", () => requestAnimationFrame(sync), { once: true }); + } else if (mediaEl.tagName === "VIDEO") { + mediaEl.addEventListener("loadedmetadata", () => requestAnimationFrame(sync), { once: true }); + } + } catch {} + }; + + const clampPanToBounds = () => { + try { + const MIN_ZOOM = 0.1; + const MAX_ZOOM = 16; + const zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Number(state.zoom) || 1)); + if (!(zoom > 1.001)) { + state.panX = 0; + state.panY = 0; + state.targetPanX = 0; + state.targetPanY = 0; + return; + } + + const current = state.assets[state.currentIndex]; + let aw = Number(current?.width) || 0; + let ah = Number(current?.height) || 0; + if (!(aw > 0 && ah > 0)) { + updateMediaNaturalSize(); + aw = Number(state._mediaW) || 0; + ah = Number(state._mediaH) || 0; + } + if (!(aw > 0 && ah > 0)) return; + const aspect = aw / ah; + if (!Number.isFinite(aspect) || aspect <= 0) return; + + const getViewportSize = () => { + if (state.mode === VIEWER_MODES.SINGLE) { + return { w: Number(singleView.clientWidth) || 0, h: Number(singleView.clientHeight) || 0 }; + } + if (state.mode === VIEWER_MODES.AB_COMPARE) { + return { w: Number(abView.clientWidth) || 0, h: Number(abView.clientHeight) || 0 }; + } + // Side-by-side: clamp against the smallest visible cell/panel to avoid blank borders. + const children = Array.from(sideView.children || []).filter((el) => el && el.nodeType === 1); + if (children.length) { + let minW = Infinity; + let minH = Infinity; + for (const child of children) { + const w = Number(child.clientWidth) || 0; + const h = Number(child.clientHeight) || 0; + if (w > 0) minW = Math.min(minW, w); + if (h > 0) minH = Math.min(minH, h); + } + if (Number.isFinite(minW) && Number.isFinite(minH)) return { w: minW, h: minH }; + } + return { w: Number(sideView.clientWidth) || 0, h: Number(sideView.clientHeight) || 0 }; + }; + + const { w: vw, h: vh } = getViewportSize(); + if (!(vw > 0 && vh > 0)) return; + + // Compute the "contain" base size inside the viewport. + const viewportAspect = vw / vh; + let baseW = vw; + let baseH = vh; + if (aspect >= viewportAspect) { + baseW = vw; + baseH = vw / aspect; + } else { + baseH = vh; + baseW = vh * aspect; + } + + const scaledW = baseW * zoom; + const scaledH = baseH * zoom; + + const maxPanX = Math.max(0, (scaledW - vw) / 2); + const maxPanY = Math.max(0, (scaledH - vh) / 2); + + // Clamp the target values instead of the current values + state.targetPanX = Math.max(-maxPanX, Math.min(maxPanX, state.targetPanX)); + state.targetPanY = Math.max(-maxPanY, Math.min(maxPanY, state.targetPanY)); + } catch {} + }; + + const mediaTransform = () => { + const zoom = Math.max(0.1, Math.min(16, Number(state.zoom) || 1)); + const x = Number(state.panX) || 0; + const y = Number(state.panY) || 0; + // Use translate3d for GPU acceleration and scale together for better performance + // Use translate-before-scale but normalize by zoom so panning is in screen pixels + // (otherwise translate gets scaled and clamping becomes incorrect at high zoom). + const nx = x / zoom; + const ny = y / zoom; + return `translate3d(${nx}px, ${ny}px, 0) scale(${zoom})`; + }; + + const setZoom = (next, { clientX = null, clientY = null } = {}) => { + // Clamp zoom to reasonable limits (0.1x to 16x) + const MIN_ZOOM = 0.1; + const MAX_ZOOM = 16; + const prevZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Number(state.targetZoom) || 1)); + const clamped = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Number(next) || 1)); + const nextZoom = Math.round(clamped * 100) / 100; + + // Calculate target pan values for smooth zoom + let targetPanX = state.targetPanX; + let targetPanY = state.targetPanY; + + // Zoom towards pointer when available (keep the point under cursor stable). + if (clientX != null && clientY != null && Number.isFinite(prevZoom) && prevZoom > 0 && nextZoom !== prevZoom) { + try { + const rect = content.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const uX = (Number(clientX) || 0) - cx; + const uY = (Number(clientY) || 0) - cy; + const r = nextZoom / prevZoom; + targetPanX = Math.round(((Number(state.targetPanX) || 0) * r + (1 - r) * uX) * 10) / 10; + targetPanY = Math.round(((Number(state.targetPanY) || 0) * r + (1 - r) * uY) * 10) / 10; + } catch {} + } else if (nextZoom !== prevZoom) { + // When zooming without a pointer, preserve relative pan. + const r = nextZoom / prevZoom; + targetPanX = Math.round(((Number(state.targetPanX) || 0) * r) * 10) / 10; + targetPanY = Math.round(((Number(state.targetPanY) || 0) * r) * 10) / 10; + } + + state.targetZoom = nextZoom; + state.targetPanX = targetPanX; + state.targetPanY = targetPanY; + + if (Math.abs(state.targetZoom - 1) < 0.001) { + state.targetZoom = 1; + state.targetPanX = 0; + state.targetPanY = 0; + } + + // Restart animation loop to apply the new zoom/pan targets + startAnimation(); + }; + + const applyTransform = () => { + try { + clampPanToBounds(); + const t = mediaTransform(); + for (const el of overlay.querySelectorAll(".mjr-viewer-media")) { + try { + el.style.transform = t; + } catch {} + } + } catch {} + }; + + // Zoom HUD functionality + let zoomHUDElement = null; + let zoomHUDTimeout = null; + + // Find the zoom HUD element + zoomHUDElement = overlay.querySelector('.mjr-viewer-zoom-hud'); + + const showZoomHUD = () => { + if (!zoomHUDElement) return; + + // Clear any pending hide timeout + if (zoomHUDTimeout) { + clearTimeout(zoomHUDTimeout); + zoomHUDTimeout = null; + } + + // Calculate zoom percentage + const fitScale = 1; // Assuming 1 is the "fit to screen" scale + const zoomPercentage = Math.round((state.zoom / fitScale) * 100); + + // Update HUD text + zoomHUDElement.textContent = `${zoomPercentage}%`; + + // Show HUD with fade-in effect + zoomHUDElement.style.opacity = '1'; + + // Set timeout to hide HUD after delay + zoomHUDTimeout = setTimeout(() => { + if (zoomHUDElement) { + zoomHUDElement.style.opacity = '0'; + } + zoomHUDTimeout = null; + }, 800); // Hide after 800ms + }; + + // Smooth animation loop using requestAnimationFrame (only when needed) + let animationFrameId = null; + const SMOOTH = 0.18; // Smoothing factor for interpolation + const EPSILON = 0.001; // Threshold to stop animation when close enough + + const tick = () => { + // Check if we need animation (values are significantly different) + const needsAnimation = + Math.abs(state.targetZoom - state.zoom) > EPSILON || + Math.abs(state.targetPanX - state.panX) > EPSILON || + Math.abs(state.targetPanY - state.panY) > EPSILON; + + if (needsAnimation) { + // Interpolate current values toward target values + state.zoom += (state.targetZoom - state.zoom) * SMOOTH; + state.panX += (state.targetPanX - state.panX) * SMOOTH; + state.panY += (state.targetPanY - state.panY) * SMOOTH; + + // Apply the smoothed transform + applyTransform(); + updatePanCursor(); + + // Update zoom HUD if zoom has changed significantly + if (Math.abs(state.zoom - state.targetZoom) > 0.01) { + showZoomHUD(); + } + + // Continue the animation loop + animationFrameId = requestAnimationFrame(tick); + } + // If no animation needed, the loop stops automatically + }; + + // Function to start animation when needed + const startAnimation = () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + animationFrameId = requestAnimationFrame(tick); + }; + + // Start the animation loop initially + startAnimation(); + + const updatePanCursor = () => { + try { + if (overlay.style.display === "none") { + content.style.cursor = ""; + return; + } + const zoomed = (Number(state.zoom) || 1) > 1.01; + if (!zoomed) { + content.style.cursor = ""; + return; + } + content.style.cursor = "grab"; + } catch {} + }; + + const renderBadges = () => { + try { + badgesBar.innerHTML = ""; + } catch {} + + const isSingle = state.mode === VIEWER_MODES.SINGLE; + const items = isSingle + ? [state.assets[state.currentIndex]].filter(Boolean) + : Array.isArray(state.assets) + ? state.assets.slice(0, 4) + : []; + + for (const a of items) { + if (!a) continue; + const pill = document.createElement("div"); + pill.className = "mjr-viewer-asset-pill"; + pill.style.cssText = ` + display: inline-flex; + align-items: center; + gap: 8px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.08); + font-size: 12px; + max-width: 360px; + overflow: hidden; + `; + + // In Single mode the filename is already displayed as the main title. + // Only show per-asset names in compare modes (A/B, side-by-side) where multiple assets are visible. + const name = document.createElement("span"); + name.textContent = String(a.filename || ""); + name.style.cssText = + "max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; opacity:0.95;"; + + const extBadge = createFileBadge(a.filename, a.kind); + try { + extBadge.style.position = "static"; + extBadge.style.top = ""; + extBadge.style.left = ""; + extBadge.style.padding = "2px 6px"; + extBadge.style.fontSize = "10px"; + extBadge.style.borderRadius = "6px"; + extBadge.style.pointerEvents = "none"; + } catch {} + + const stars = createRatingBadge(a.rating || 0); + if (stars) { + try { + stars.style.position = "static"; + stars.style.top = ""; + stars.style.right = ""; + stars.style.padding = "2px 6px"; + stars.style.fontSize = "12px"; + } catch {} + } + + const tags = createTagsBadge(Array.isArray(a.tags) ? a.tags : []); + if (tags) { + try { + tags.style.position = "static"; + tags.style.bottom = ""; + tags.style.left = ""; + tags.style.maxWidth = "220px"; + tags.style.pointerEvents = "none"; + } catch {} + } + + pill.appendChild(extBadge); + if (!isSingle) pill.appendChild(name); + if (stars) pill.appendChild(stars); + if (tags && tags.style.display !== "none") pill.appendChild(tags); + + try { + if (a.filepath) pill.title = String(a.filepath); + } catch {} + + badgesBar.appendChild(pill); + } + }; + + function canAB() { + return state.assets.length === 2; + } + + function canSide() { + const n = state.assets.length; + return n >= 2 && n <= 4; + } + + // Update UI based on state + function updateUI() { + // Update filename + const current = state.assets[state.currentIndex]; + if (state.mode === VIEWER_MODES.AB_COMPARE && canAB()) { + filename.textContent = "A/B Compare"; + } else if (state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide()) { + filename.textContent = state.assets.length > 2 ? "Side-by-side (2×2)" : "Side-by-side"; + } else if (current) { + filename.textContent = current.filename || ''; + } + + // Update index + if (state.mode === VIEWER_MODES.AB_COMPARE && canAB()) { + indexInfo.textContent = "2 selected"; + } else if (state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide()) { + indexInfo.textContent = `${state.assets.length} selected`; + } else { + indexInfo.textContent = `${state.currentIndex + 1} / ${state.assets.length}`; + } + + // Update mode buttons + try { + abBtn.disabled = !canAB(); + sideBtn.disabled = !canSide(); + abBtn.style.opacity = abBtn.disabled ? "0.35" : (abBtn.dataset.mode === state.mode ? "1" : "0.6"); + sideBtn.style.opacity = sideBtn.disabled ? "0.35" : (sideBtn.dataset.mode === state.mode ? "1" : "0.6"); + } catch {} + [singleBtn, abBtn, sideBtn].forEach(btn => { + if (btn === abBtn || btn === sideBtn) return; + btn.style.opacity = btn.dataset.mode === state.mode ? "1" : "0.6"; + btn.style.fontWeight = btn.dataset.mode === state.mode ? "600" : "400"; + }); + + if (state.mode === VIEWER_MODES.AB_COMPARE && !canAB()) { + state.mode = VIEWER_MODES.SINGLE; + } + if (state.mode === VIEWER_MODES.SIDE_BY_SIDE && !canSide()) { + state.mode = VIEWER_MODES.SINGLE; + } + + // Show/hide views + singleView.style.display = state.mode === VIEWER_MODES.SINGLE ? 'flex' : 'none'; + abView.style.display = state.mode === VIEWER_MODES.AB_COMPARE ? 'block' : 'none'; + sideView.style.display = state.mode === VIEWER_MODES.SIDE_BY_SIDE ? 'flex' : 'none'; + + renderBadges(); + + // Hide navigation for compare modes (selected-set compare). + const hideNav = + (state.mode === VIEWER_MODES.AB_COMPARE && canAB()) || + (state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide()); + try { + prevBtn.style.display = hideNav ? "none" : ""; + nextBtn.style.display = hideNav ? "none" : ""; + } catch {} + + // Render current asset + renderAsset(); + + // Hydrate rating/tags for visible assets and refresh the badge bar. + try { + void hydrateVisibleMetadata().then(() => { + try { + renderBadges(); + } catch {} + }); + } catch {} + } + + // Render the current asset based on mode + function renderAsset() { + const current = state.assets[state.currentIndex]; + if (!current) return; + + const viewUrl = buildAssetViewURL(current); + + if (state.mode === VIEWER_MODES.SINGLE) { + singleView.innerHTML = ''; + state._mediaW = 0; + state._mediaH = 0; + const media = createMediaElement(current, viewUrl); + singleView.appendChild(media); + } else if (state.mode === VIEWER_MODES.AB_COMPARE) { + if (canAB()) renderABCompare(current, viewUrl); + } else if (state.mode === VIEWER_MODES.SIDE_BY_SIDE) { + if (canSide()) renderSideBySide(current, viewUrl); + } + applyTransform(); + updatePanCursor(); + } + + // Create image or video element + function createMediaElement(asset, url) { + const container = document.createElement("div"); + container.style.cssText = ` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + `; + + if (asset.kind === 'video') { + const video = document.createElement("video"); + video.className = "mjr-viewer-media"; + video.src = url; + video.controls = true; + video.autoplay = true; + video.loop = true; + video.playsInline = true; + video.style.cssText = ` + max-width: 100%; + max-height: 100%; + object-fit: contain; + transform: ${mediaTransform()}; + transform-origin: center center; + `; + attachMediaLoadHandlers(video); + try { + video.addEventListener( + "canplay", + () => { + try { + const p = video.play?.(); + if (p && typeof p.catch === "function") p.catch(() => {}); + } catch {} + }, + { once: true } + ); + } catch {} + container.appendChild(video); + } else { + const img = document.createElement("img"); + img.className = "mjr-viewer-media"; + img.src = url; + img.style.cssText = ` + max-width: 100%; + max-height: 100%; + object-fit: contain; + transform: ${mediaTransform()}; + transform-origin: center center; + `; + attachMediaLoadHandlers(img); + container.appendChild(img); + } + + return container; + } + + // Create media element for compare modes (A/B and side-by-side). + // Important: the returned element fills the container so both layers share identical sizing/centering, + // which avoids "half-width re-centering" and other overlay drift. + function createCompareMediaElement(asset, url) { + if (asset.kind === "video") { + const video = document.createElement("video"); + video.className = "mjr-viewer-media"; + video.src = url; + video.controls = false; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.autoplay = true; + video.style.cssText = ` + width: 100%; + height: 100%; + object-fit: contain; + transform: ${mediaTransform()}; + transform-origin: center center; + `; + attachMediaLoadHandlers(video); + return video; + } + + const img = document.createElement("img"); + img.className = "mjr-viewer-media"; + img.src = url; + img.style.cssText = ` + width: 100%; + height: 100%; + object-fit: contain; + transform: ${mediaTransform()}; + transform-origin: center center; + `; + attachMediaLoadHandlers(img); + return img; + } + + // Render A/B compare view + function renderABCompare(current, viewUrl) { + abView.innerHTML = ''; + + const other = + state.compareAsset || + (state.assets.length === 2 ? state.assets[1 - state.currentIndex] : null) || + current; + const compareUrl = buildAssetViewURL(other); + + // Base layer (B) + const baseLayer = document.createElement("div"); + baseLayer.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + `; + const baseMedia = createCompareMediaElement(other, compareUrl); + baseLayer.appendChild(baseMedia); + + // Top layer (A) with slider + const topLayer = document.createElement("div"); + topLayer.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + `; + // IMPORTANT: keep A aligned to B by keeping the top layer full-size and clipping it, + // instead of shrinking the layer width (which would re-center A differently). + const supportsClipPath = (() => { + try { + return !!window.CSS?.supports?.("clip-path: inset(0 50% 0 0)"); + } catch { + return false; + } + })(); + const setClipPercent = (percent) => { + const clamped = Math.max(0, Math.min(100, percent)); + const right = 100 - clamped; + if (supportsClipPath) { + const value = `inset(0 ${right}% 0 0)`; + topLayer.style.clipPath = value; + topLayer.style.webkitClipPath = value; + return; + } + // Fallback for older browsers: use deprecated `clip` (works only with absolute positioning) + // Clip rectangle: show left `percent%` of the layer. + const rect = abView.getBoundingClientRect(); + const w = rect.width || 1; + const h = rect.height || 1; + const px = Math.round((w * clamped) / 100); + topLayer.style.clip = `rect(0px, ${px}px, ${h}px, 0px)`; + }; + setClipPercent(50); + const topMedia = createCompareMediaElement(current, viewUrl); + topLayer.appendChild(topMedia); + + // Slider handle + const slider = document.createElement("div"); + slider.style.cssText = ` + position: absolute; + top: 0; + left: 50%; + width: 3px; + height: 100%; + background: white; + cursor: ew-resize; + z-index: 10; + `; + + const handle = document.createElement("div"); + handle.style.cssText = ` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + background: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: black; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + `; + handle.textContent = "\u2194"; + slider.appendChild(handle); + + // Drag functionality (pointer-capture; avoids leaking document-level listeners on re-render) + let isDragging = false; + const onPointerMove = (e) => { + if (!isDragging) return; + const rect = abView.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percent = (x / rect.width) * 100; + const clamped = Math.max(0, Math.min(100, percent)); + setClipPercent(clamped); + slider.style.left = `${clamped}%`; + }; + const stopDrag = () => { + isDragging = false; + }; + slider.addEventListener("pointerdown", (e) => { + isDragging = true; + try { + slider.setPointerCapture(e.pointerId); + } catch {} + onPointerMove(e); + }); + slider.addEventListener("pointermove", onPointerMove); + slider.addEventListener("pointerup", stopDrag); + slider.addEventListener("pointercancel", stopDrag); + + abView.appendChild(baseLayer); + abView.appendChild(topLayer); + abView.appendChild(slider); + } + + // Render side-by-side view + function renderSideBySide(current, viewUrl) { + sideView.innerHTML = ''; + + const items = state.assets.slice(0, 4); + const count = items.length; + if (count > 2) { + // 2x2 grid (3 or 4 items). Do not wrap in another container: theme CSS targets direct children. + sideView.style.display = "grid"; + sideView.style.gridTemplateColumns = "1fr 1fr"; + sideView.style.gridTemplateRows = "1fr 1fr"; + sideView.style.gap = "2px"; + sideView.style.padding = "2px"; + + for (let i = 0; i < 4; i++) { + const cell = document.createElement("div"); + cell.style.cssText = ` + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); + overflow: hidden; + `; + const a = items[i]; + if (a) { + const u = buildAssetViewURL(a); + cell.appendChild(createMediaElement(a, u)); + } + sideView.appendChild(cell); + } + return; + } + + const other = + state.compareAsset || + (state.assets.length === 2 ? state.assets[1 - state.currentIndex] : null) || + current; + const compareUrl = buildAssetViewURL(other); + + const leftPanel = document.createElement("div"); + leftPanel.style.cssText = ` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); + overflow: hidden; + `; + const leftMedia = createMediaElement(current, viewUrl); + leftPanel.appendChild(leftMedia); + + const rightPanel = document.createElement("div"); + rightPanel.style.cssText = ` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); + overflow: hidden; + `; + const rightMedia = createMediaElement(other, compareUrl); + rightPanel.appendChild(rightMedia); + + sideView.style.display = "flex"; + sideView.style.flexDirection = "row"; + sideView.style.gap = "2px"; + sideView.style.padding = "0"; + sideView.appendChild(leftPanel); + sideView.appendChild(rightPanel); + } + + // Mode switchers + singleBtn.addEventListener('click', () => { + state.mode = VIEWER_MODES.SINGLE; + updateUI(); + }); + abBtn.addEventListener('click', () => { + if (!canAB()) return; + state.mode = VIEWER_MODES.AB_COMPARE; + updateUI(); + }); + sideBtn.addEventListener('click', () => { + if (!canSide()) return; + state.mode = VIEWER_MODES.SIDE_BY_SIDE; + updateUI(); + }); + + // Navigation + prevBtn.addEventListener('click', () => { + if (state.currentIndex > 0) { + state.currentIndex--; + updateUI(); + } + }); + + nextBtn.addEventListener('click', () => { + if (state.currentIndex < state.assets.length - 1) { + state.currentIndex++; + updateUI(); + } + }); + + // Zoom controls + zoomIn.addEventListener('click', () => { + setZoom((Number(state.zoom) || 1) + 0.25, { clientX: state._lastPointerX, clientY: state._lastPointerY }); + showZoomHUD(); // Show zoom HUD when zooming with buttons + startAnimation(); // Ensure animation loop is active + }); + + zoomOut.addEventListener('click', () => { + setZoom((Number(state.zoom) || 1) - 0.25, { clientX: state._lastPointerX, clientY: state._lastPointerY }); + showZoomHUD(); // Show zoom HUD when zooming with buttons + startAnimation(); // Ensure animation loop is active + }); + + zoomReset.addEventListener('click', () => { + setZoom(1); + showZoomHUD(); // Show zoom HUD when zooming with buttons + startAnimation(); // Ensure animation loop is active + }); + + // Close + closeBtn.addEventListener('click', () => { + closeViewer(); + }); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + closeViewer(); + } + }); + + // Mouse wheel zoom (trackpad-friendly). Capture + preventDefault so ComfyUI canvas doesn't zoom underneath. + const onWheelZoom = (e) => { + if (overlay.style.display === "none") return; + try { + const t = e.target; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) { + return; + } + } catch {} + + // Only zoom when hovering the viewer content area. + try { + if (!content.contains(e.target)) return; + } catch {} + + try { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + } catch {} + + const dy = Number(e.deltaY) || 0; + if (!dy) return; + + // Smooth scaling: wheel down -> zoom out, wheel up -> zoom in. + const factor = Math.exp(-dy * 0.0015); + const next = (Number(state.zoom) || 1) * factor; + setZoom(next, { clientX: e.clientX, clientY: e.clientY }); + showZoomHUD(); // Show zoom HUD when wheel zooming + startAnimation(); // Ensure animation loop is active + }; + + try { + if (!content._mjrWheelZoomBound) { + content.addEventListener("wheel", onWheelZoom, { passive: false, capture: true }); + content._mjrWheelZoomBound = true; + } + } catch {} + + // Track pointer position to zoom towards cursor for button/keyboard zoom. + try { + if (!content._mjrPointerTrackBound) { + content.addEventListener( + "mousemove", + (e) => { + state._lastPointerX = e.clientX; + state._lastPointerY = e.clientY; + }, + { passive: true, capture: true } + ); + content._mjrPointerTrackBound = true; + } + } catch {} + + // Pan (middle-mouse-like click+drag) when zoomed in. + // When zoomed, left-dragging over the viewer content immediately pans (no threshold). + const pan = { active: false, pointerId: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0 }; + + const onPanPointerDown = (e) => { + if (overlay.style.display === "none") return; + if (!((Number(state.zoom) || 1) > 1.01)) return; + const isLeft = e.button === 0; + const isMiddle = e.button === 1; + if (!isLeft && !isMiddle) return; + try { + const t = e.target; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return; + } catch {} + + // Only start panning inside the viewer content area (avoid header/footer buttons). + try { + if (!content.contains(e.target)) return; + } catch { + return; + } + + pan.active = true; + pan.pointerId = e.pointerId; + try { + state._lastPointerX = e.clientX; + state._lastPointerY = e.clientY; + } catch {} + pan.startX = e.clientX; + pan.startY = e.clientY; + pan.startPanX = Number(state.panX) || 0; + pan.startPanY = Number(state.panY) || 0; + try { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + } catch {} + try { + content.setPointerCapture?.(e.pointerId); + } catch {} + try { + content.style.cursor = "grabbing"; + } catch {} + }; + + const onPanPointerMove = (e) => { + if (!pan.active) return; + try { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + } catch {} + const dx = (Number(e.clientX) || 0) - pan.startX; + const dy = (Number(e.clientY) || 0) - pan.startY; + state.targetPanX = Math.round((pan.startPanX + dx) * 10) / 10; + state.targetPanY = Math.round((pan.startPanY + dy) * 10) / 10; + try { + state._lastPointerX = e.clientX; + state._lastPointerY = e.clientY; + } catch {} + // Don't call clampPanToBounds or applyTransform here - let the animation loop handle it + startAnimation(); // Ensure animation loop is active during pan + }; + + const onPanPointerUp = (e) => { + if (!pan.active) return; + pan.active = false; + pan.pointerId = null; + try { + content.releasePointerCapture?.(e.pointerId); + } catch {} + updatePanCursor(); + }; + + try { + if (!content._mjrPanBound) { + content.addEventListener("pointerdown", onPanPointerDown, { passive: false, capture: true }); + content.addEventListener("pointermove", onPanPointerMove, { passive: false, capture: true }); + content.addEventListener("pointerup", onPanPointerUp, { passive: true, capture: true }); + content.addEventListener("pointercancel", onPanPointerUp, { passive: true, capture: true }); + content._mjrPanBound = true; + } + } catch {} + + // Double-click anywhere in the viewer content resets zoom + pan. + try { + if (!content._mjrDblClickResetBound) { + content.addEventListener( + "dblclick", + (e) => { + if (overlay.style.display === "none") return; + try { + if (!content.contains(e.target)) return; + } catch {} + try { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + } catch {} + // Toggle between fit and max zoom on double-click + const isNearFit = Math.abs(state.targetZoom - 1) < 0.01; + if (isNearFit) { + // If near fit, go to max zoom (8x) + setZoom(Math.min(8, state.targetZoom * 4)); // Scale up by 4x from current zoom + } else { + // If zoomed in, return to fit + setZoom(1); + } + showZoomHUD(); // Show zoom HUD when toggling + }, + { passive: false, capture: true } + ); + content._mjrDblClickResetBound = true; + } + } catch {} + + // Keyboard shortcuts (installed on capture phase to avoid ComfyUI/global handlers eating events). + function handleKeyboard(e) { + // Check if hotkeys are suspended (e.g. when dialogs/popovers are open) + if (window._mjrHotkeysState?.suspended) return; + + if (overlay.style.display === 'none') return; + + // If an input-like element is focused inside the viewer, avoid hijacking typing. + try { + const t = e.target; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + closeViewer(); + } + return; + } + } catch {} + + const isSingle = state.mode === VIEWER_MODES.SINGLE; + const current = state.assets[state.currentIndex]; + + const setRatingFromKey = async (key) => { + if (!isSingle) return false; + if (!current?.id) return false; + if (key !== "0" && key !== "1" && key !== "2" && key !== "3" && key !== "4" && key !== "5") return false; + const rating = key === "0" ? 0 : Number(key); + if (!Number.isFinite(rating)) return false; + + try { + const result = await updateAssetRating(current.id, rating); + if (!result?.ok) { + console.warn("[Viewer] Rating update failed:", result?.error || result); + return true; + } + current.rating = rating; + safeDispatchCustomEvent( + ASSET_RATING_CHANGED_EVENT, + { assetId: String(current.id), rating }, + { warnPrefix: "[Viewer]" } + ); + return true; + } catch (e) { + console.error("[Viewer] Rating update exception:", e); + return true; + } + }; + + const stepFrame = async (direction) => { + if (!isSingle) return false; + if (current?.kind !== "video") return false; + const video = singleView.querySelector("video"); + if (!video) return false; + + try { + // Pause so arrows behave deterministically. + video.pause?.(); + } catch {} + + const fpsGuess = 30; + const step = 1 / fpsGuess; + const delta = direction * step; + + try { + const duration = Number(video.duration); + const next = Math.max(0, Math.min(Number.isFinite(duration) ? duration : Infinity, (video.currentTime || 0) + delta)); + video.currentTime = next; + return true; + } catch { + return true; + } + }; + + // Rating shortcuts (viewer single only) + if (isSingle && (e.key === "0" || e.key === "1" || e.key === "2" || e.key === "3" || e.key === "4" || e.key === "5")) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + void setRatingFromKey(e.key); + return; + } + + // Frame-by-frame (viewer single + video only) + // Shift+Arrow keys for frame stepping when viewing a single video + if (isSingle && e.shiftKey && (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "ArrowLeft" || e.key === "ArrowRight")) { + const videoEl = overlay.querySelector('video'); + if (videoEl) { + // Video exists - use Shift+arrows for frame stepping + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + const direction = (e.key === "ArrowUp" || e.key === "ArrowLeft") ? -1 : 1; + void stepFrame(direction); + return; + } + } + + switch (e.key) { + case 'Escape': + closeViewer(); + break; + case 'ArrowLeft': + // Plain arrows navigate between assets + if (state.currentIndex > 0) { + state.currentIndex--; + updateUI(); + } + break; + case 'ArrowRight': + // Plain arrows navigate between assets + if (state.currentIndex < state.assets.length - 1) { + state.currentIndex++; + updateUI(); + } + break; + case '+': + case '=': + setZoom((Number(state.zoom) || 1) + 0.25, { clientX: state._lastPointerX, clientY: state._lastPointerY }); + showZoomHUD(); // Show zoom HUD when zooming with keyboard + break; + case '-': + case '_': + setZoom((Number(state.zoom) || 1) - 0.25, { clientX: state._lastPointerX, clientY: state._lastPointerY }); + showZoomHUD(); // Show zoom HUD when zooming with keyboard + break; + } + } + + // Bind once per overlay instance. + try { + if (!overlay._mjrKeyboardBound) { + window.addEventListener("keydown", handleKeyboard, true); + overlay._mjrKeyboardBound = true; + } + } catch { + try { + document.addEventListener("keydown", handleKeyboard, true); + } catch {} + } + + // Keep viewer badges in sync when tags/ratings change elsewhere (sidebar, panel hotkeys, etc.). + try { + if (!overlay._mjrBadgeSyncBound) { + window.addEventListener( + ASSET_RATING_CHANGED_EVENT, + (e) => { + const id = e?.detail?.assetId; + const rating = e?.detail?.rating; + if (id == null) return; + for (const a of state.assets || []) { + if (a?.id != null && String(a.id) === String(id)) { + a.rating = rating; + } + } + try { + _metaCache.delete(String(id)); + } catch {} + renderBadges(); + }, + { passive: true } + ); + window.addEventListener( + ASSET_TAGS_CHANGED_EVENT, + (e) => { + const id = e?.detail?.assetId; + const tags = e?.detail?.tags; + if (id == null) return; + for (const a of state.assets || []) { + if (a?.id != null && String(a.id) === String(id)) { + a.tags = tags; + } + } + try { + _metaCache.delete(String(id)); + } catch {} + renderBadges(); + }, + { passive: true } + ); + overlay._mjrBadgeSyncBound = true; + } + } catch {} + + function closeViewer() { + overlay.style.display = 'none'; + try { + document.body.style.overflow = state._prevBodyOverflow ?? ''; + } catch { + document.body.style.overflow = ''; + } + + // Return hotkeys scope to panel + if (window._mjrHotkeysState) { + window._mjrHotkeysState.scope = "panel"; + } + } + + // Public API + const api = { + open(assets, startIndex = 0, compareAsset = null) { + state.assets = Array.isArray(assets) ? assets : [assets]; + state.currentIndex = Math.max(0, Math.min(startIndex, state.assets.length - 1)); + state.zoom = 1; + state.panX = 0; + state.panY = 0; + state._lastPointerX = null; + state._lastPointerY = null; + state._mediaW = 0; + state._mediaH = 0; + state.compareAsset = compareAsset; + + overlay.style.display = 'flex'; + // Removed overlay.focus() to prevent focus ring appearance + // Keyboard events are handled via window-level listeners anyway + try { + state._prevBodyOverflow = document.body.style.overflow; + } catch { + state._prevBodyOverflow = ''; + } + document.body.style.overflow = 'hidden'; + + // Set hotkeys scope to viewer (takes priority over panel) + if (!window._mjrHotkeysState) window._mjrHotkeysState = {}; + window._mjrHotkeysState.scope = "viewer"; + + updateUI(); + + // Restart animation if needed + startAnimation(); + }, + + close() { + closeViewer(); + // Stop animation when viewer is closed to save resources + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + }, + + setMode(mode) { + if (Object.values(VIEWER_MODES).includes(mode)) { + state.mode = mode; + updateUI(); + } + }, + + setCompareAsset(asset) { + state.compareAsset = asset; + updateUI(); + }, + + dispose() { + try { + window.removeEventListener("keydown", handleKeyboard, true); + overlay._mjrKeyboardBound = false; + } catch {} + try { + document.removeEventListener("keydown", handleKeyboard, true); + } catch {} + } + }; + + overlay._mjrViewerAPI = api; + + // Right-click context menu (viewer): tags/rating/open-in-folder/copy path. + try { + bindViewerContextMenu({ + overlayEl: overlay, + getCurrentAsset: () => state.assets[state.currentIndex], + getCurrentViewUrl: (asset) => buildAssetViewURL(asset), + onAssetChanged: () => { + try { + renderBadges(); + } catch {} + }, + }); + } catch {} + + return overlay; +} + +/** + * Helper to create mode button + */ +function createModeButton(label, mode) { + const btn = document.createElement("button"); + btn.textContent = label; + btn.dataset.mode = mode; + btn.style.cssText = ` + padding: 4px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; + `; + btn.addEventListener('mouseenter', () => { + btn.style.background = 'rgba(255, 255, 255, 0.2)'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.background = 'rgba(255, 255, 255, 0.1)'; + }); + return btn; +} + +/** + * Helper to create icon button + */ +function createIconButton(label, title) { + const btn = document.createElement("button"); + btn.textContent = label; + btn.title = title; + btn.style.cssText = ` + padding: 6px 12px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + `; + btn.addEventListener('mouseenter', () => { + btn.style.background = 'rgba(255, 255, 255, 0.1)'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.background = 'transparent'; + }); + return btn; +} + +/** + * Get or create global viewer instance + */ +export function getViewerInstance() { + const existing = document.querySelector('.mjr-viewer-overlay'); + if (existing && existing._mjrViewerAPI) { + return existing._mjrViewerAPI; + } + + const viewer = createViewer(); + document.body.appendChild(viewer); + return viewer._mjrViewerAPI; +} diff --git a/js/components/sidebar/SidebarView.js b/js/components/sidebar/SidebarView.js new file mode 100644 index 00000000..29347946 --- /dev/null +++ b/js/components/sidebar/SidebarView.js @@ -0,0 +1,221 @@ +/** + * Inline Asset Sidebar - Integrates into the Assets Manager panel + * + * This module keeps the public API stable (`createSidebar`, `showAssetInSidebar`, `closeSidebar`) + * while delegating UI rendering/parsing to `js/components/sidebar/sections/*` and `parsers/*`. + */ + +import { getAssetMetadata, getFileMetadata } from "../../api/client.js"; +import { createFieldRow, createSection } from "./utils/dom.js"; +import { createSidebarHeader } from "./sections/HeaderSection.js"; +import { createPreviewSection } from "./sections/PreviewSection.js"; +import { createRatingTagsSection } from "./sections/RatingTagsSection.js"; +import { createGenerationSection } from "./sections/GenerationSection.js"; +import { createWorkflowMinimapSection } from "./sections/WorkflowMinimapSection.js"; +import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js"; + +/** + * Create inline sidebar (for panel integration) + * @param {string} position - "left" or "right" + */ +export function createSidebar(position = "right") { + const sidebar = document.createElement("div"); + sidebar.className = "mjr-inline-sidebar"; + + sidebar.dataset.position = position; + sidebar._requestSeq = 0; + sidebar._closeTimer = null; + + const placeholder = document.createElement("div"); + placeholder.className = "mjr-sidebar-placeholder"; + placeholder.textContent = "Select an asset to view details"; + + sidebar.appendChild(placeholder); + sidebar._placeholder = placeholder; + sidebar._currentAsset = null; + sidebar._currentFullAsset = null; + sidebar._ratingTagsSection = null; + + const ac = typeof AbortController !== "undefined" ? new AbortController() : null; + sidebar._mjrAbortController = ac; + sidebar._dispose = () => { + try { + ac?.abort?.(); + } catch {} + }; + + const matchesCurrent = (assetId) => { + const id = sidebar._currentAsset?.id ?? sidebar._currentFullAsset?.id ?? null; + if (id == null || assetId == null) return false; + return String(id) === String(assetId); + }; + + const onRatingChanged = (ev) => { + const detail = ev?.detail || {}; + const assetId = detail.assetId ?? detail.id ?? null; + const rating = Number(detail.rating); + if (!matchesCurrent(assetId)) return; + if (!Number.isFinite(rating)) return; + try { + if (sidebar._currentAsset) sidebar._currentAsset.rating = rating; + if (sidebar._currentFullAsset) sidebar._currentFullAsset.rating = rating; + sidebar._ratingTagsSection?._mjrSetRating?.(rating); + } catch {} + }; + + const onTagsChanged = (ev) => { + const detail = ev?.detail || {}; + const assetId = detail.assetId ?? detail.id ?? null; + const tags = Array.isArray(detail.tags) ? detail.tags : null; + if (!matchesCurrent(assetId)) return; + if (!tags) return; + try { + if (sidebar._currentAsset) sidebar._currentAsset.tags = tags; + if (sidebar._currentFullAsset) sidebar._currentFullAsset.tags = tags; + sidebar._ratingTagsSection?._mjrSetTags?.(tags); + } catch {} + }; + + try { + window.addEventListener(ASSET_RATING_CHANGED_EVENT, onRatingChanged, ac ? { signal: ac.signal } : undefined); + window.addEventListener(ASSET_TAGS_CHANGED_EVENT, onTagsChanged, ac ? { signal: ac.signal } : undefined); + } catch { + try { + window.addEventListener(ASSET_RATING_CHANGED_EVENT, onRatingChanged); + window.addEventListener(ASSET_TAGS_CHANGED_EVENT, onTagsChanged); + } catch {} + } + + return sidebar; +} + +/** + * Show asset in inline sidebar + */ +export async function showAssetInSidebar(sidebar, asset, onUpdate) { + if (!sidebar || !asset) return; + + try { + if (sidebar._closeTimer) { + clearTimeout(sidebar._closeTimer); + sidebar._closeTimer = null; + } + } catch {} + + const requestSeq = (sidebar._requestSeq = (sidebar._requestSeq || 0) + 1); + + sidebar.classList.add("is-open"); + + if (sidebar._placeholder && sidebar._placeholder.parentNode) { + sidebar._placeholder.remove(); + } + + sidebar.innerHTML = ""; + sidebar._currentAsset = asset; + sidebar._currentFullAsset = null; + sidebar._ratingTagsSection = null; + + let fullAsset = asset; + if (asset.id && (!asset.metadata_raw && !asset.exif && !asset.geninfo && !asset.prompt)) { + try { + const result = await getAssetMetadata(asset.id); + if (result.ok && result.data) { + fullAsset = { ...asset, ...result.data }; + try { + if (typeof onUpdate === "function") onUpdate(fullAsset); + } catch {} + } else { + console.warn("API did not return ok or data:", result); + } + } catch (err) { + console.warn("Failed to load full asset metadata:", err); + } + } + + // If the asset isn't indexed (no id) or still lacks generation metadata, fetch on-demand by filepath. + if (!fullAsset?.geninfo && !fullAsset?.prompt && !fullAsset?.metadata_raw) { + const filePath = fullAsset?.filepath || fullAsset?.path || fullAsset?.file_info?.filepath || null; + if (typeof filePath === "string" && filePath.trim()) { + try { + const result = await getFileMetadata(filePath); + if (result?.ok && result.data) { + const md = result.data; + fullAsset = { + ...fullAsset, + // Prefer existing fields; only fill missing bits. + prompt: fullAsset.prompt ?? md.prompt, + workflow: fullAsset.workflow ?? md.workflow, + geninfo: fullAsset.geninfo ?? md.geninfo, + exif: fullAsset.exif ?? md.exif, + ffprobe: fullAsset.ffprobe ?? md.ffprobe, + metadata_raw: fullAsset.metadata_raw ?? md, + }; + try { + if (typeof onUpdate === "function") onUpdate(fullAsset); + } catch {} + } + } catch (err) { + console.warn("Failed to load metadata by filepath:", err); + } + } + } + + if (requestSeq !== sidebar._requestSeq || sidebar._currentAsset !== asset) { + return; + } + + const header = createSidebarHeader(fullAsset, () => closeSidebar(sidebar)); + sidebar._currentFullAsset = fullAsset; + + const content = document.createElement("div"); + content.className = "mjr-sidebar-content"; + content.style.cssText = ` + flex: 1; + overflow-y: auto; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 20px; + `; + + content.appendChild(createPreviewSection(fullAsset)); + const ratingTagsSection = createRatingTagsSection(fullAsset, onUpdate); + sidebar._ratingTagsSection = ratingTagsSection; + content.appendChild(ratingTagsSection); + + const genMetadata = createGenerationSection(fullAsset); + if (genMetadata) content.appendChild(genMetadata); + + const workflow = createWorkflowMinimapSection(fullAsset); + if (workflow) content.appendChild(workflow); + + sidebar.appendChild(header); + sidebar.appendChild(content); +} + +/** + * Close inline sidebar + */ +export function closeSidebar(sidebar) { + if (!sidebar) return; + + sidebar._requestSeq = (sidebar._requestSeq || 0) + 1; + sidebar.classList.remove("is-open"); + sidebar._currentAsset = null; + + try { + if (sidebar._closeTimer) clearTimeout(sidebar._closeTimer); + } catch {} + + const closeSeq = sidebar._requestSeq; + sidebar._closeTimer = setTimeout(() => { + if (sidebar._requestSeq !== closeSeq) return; + if (sidebar.classList.contains("is-open")) return; + sidebar.innerHTML = ""; + if (sidebar._placeholder) { + sidebar.appendChild(sidebar._placeholder); + } + }, 300); +} + +// Raw metadata is now available as a toggle inside WorkflowMinimapSection. diff --git a/js/components/sidebar/parsers/a1111ParamsParser.js b/js/components/sidebar/parsers/a1111ParamsParser.js new file mode 100644 index 00000000..40b8dd8c --- /dev/null +++ b/js/components/sidebar/parsers/a1111ParamsParser.js @@ -0,0 +1,38 @@ +export function parseA1111Parameters(text) { + if (!text || typeof text !== "string") return null; + + const metadata = {}; + + const parts = text.split(/\nNegative prompt:\s*/i); + if (parts[0]) { + metadata.prompt = parts[0].trim(); + } + + if (parts[1]) { + const paramMatch = parts[1].match(/^(.*?)\n?(Steps:.*)$/s); + if (paramMatch) { + metadata.negative_prompt = paramMatch[1].trim(); + const params = paramMatch[2]; + + const extractParam = (pattern) => { + const match = params.match(pattern); + return match ? match[1].trim() : null; + }; + + metadata.steps = extractParam(/Steps:\s*(\d+)/i); + metadata.sampler = extractParam(/Sampler:\s*([^,\n]+)/i); + metadata.cfg = extractParam(/CFG scale:\s*([\d.]+)/i); + metadata.seed = extractParam(/Seed:\s*(\d+)/i); + metadata.width = extractParam(/Size:\s*(\d+)x\d+/i); + metadata.height = extractParam(/Size:\s*\d+x(\d+)/i); + metadata.model = extractParam(/Model:\s*([^,\n]+)/i); + metadata.denoising = extractParam(/Denoising strength:\s*([\d.]+)/i); + metadata.clip_skip = extractParam(/Clip skip:\s*(\d+)/i); + } else { + metadata.negative_prompt = parts[1].trim(); + } + } + + return Object.keys(metadata).length > 0 ? metadata : null; +} + diff --git a/js/components/sidebar/parsers/comfyWorkflowParser.js b/js/components/sidebar/parsers/comfyWorkflowParser.js new file mode 100644 index 00000000..51d2a2e7 --- /dev/null +++ b/js/components/sidebar/parsers/comfyWorkflowParser.js @@ -0,0 +1,128 @@ +export function looksLikeComfyPromptGraph(obj) { + try { + const entries = Object.entries(obj || {}); + if (!entries.length) return false; + let hits = 0; + for (const [, v] of entries.slice(0, 50)) { + if (!v || typeof v !== "object") continue; + if (v.inputs && typeof v.inputs === "object") hits += 1; + if (hits >= 2) return true; + } + return false; + } catch { + return false; + } +} + +export function parseComfyUIWorkflow(workflow) { + if (!workflow || typeof workflow !== "object") return null; + + const metadata = {}; + + const promptGraph = workflow.prompt && typeof workflow.prompt === "object" ? workflow.prompt : null; + const graph = promptGraph || (looksLikeComfyPromptGraph(workflow) ? workflow : null); + + if (graph) { + const loras = []; + for (const [, node] of Object.entries(graph)) { + if (!node || typeof node !== "object") continue; + const inputs = node.inputs; + if (!inputs || typeof inputs !== "object") continue; + + const classType = String(node?.class_type || node?.type || "").toLowerCase(); + const title = String(node?.title || node?._meta?.title || "").toLowerCase(); + + const setIfString = (key, value) => { + if (metadata[key]) return; + if (typeof value !== "string") return; + const text = value.trim(); + if (!text) return; + // Heuristic: ignore numeric-only payloads (can happen on non-prompt nodes that also use `text`). + if (/^[\d\s.,+-]+$/.test(text)) return; + metadata[key] = text; + }; + + // Prompt extraction: + // - Prefer explicit CLIP text encode nodes (ComfyUI conventions) + // - Avoid taking arbitrary `inputs.text` from unrelated nodes (causes numbers / wrong fields) + const isClipText = + classType.includes("cliptextencode") || + classType.includes("clip_text_encode") || + title.includes("clip text encode"); + if (isClipText && typeof inputs.text === "string") { + const isNegative = title.includes("negative"); + const isPositive = title.includes("positive") || title.includes("(prompt)") || title.includes("prompt"); + if (isNegative) setIfString("negative_prompt", inputs.text); + else if (isPositive) setIfString("prompt", inputs.text); + else setIfString("prompt", inputs.text); + } + + // Backward-compatible prompt fields used by some nodes. + setIfString("negative_prompt", inputs.negative_prompt); + setIfString("negative_prompt", inputs.negative); + + // Last-resort: accept `inputs.text` only if class_type/title suggests it's a prompt node. + // This avoids false positives from note nodes, title fields, or other text-containing nodes. + if (!metadata.prompt && typeof inputs.text === "string") { + const maybe = inputs.text.trim(); + const looksLikePromptNode = + classType.includes("prompt") || + classType.includes("encode") || + classType.includes("positive") || + classType.includes("negative") || + title.includes("prompt") || + title.includes("positive") || + title.includes("negative"); + if (looksLikePromptNode && maybe.length >= 12 && /[a-zA-Z]/.test(maybe)) { + setIfString("prompt", maybe); + } + } + + if (inputs.seed !== undefined && metadata.seed === undefined) metadata.seed = inputs.seed; + if (inputs.steps !== undefined && metadata.steps === undefined) metadata.steps = inputs.steps; + if (inputs.cfg !== undefined && metadata.cfg === undefined) metadata.cfg = inputs.cfg; + if (inputs.sampler_name && !metadata.sampler) metadata.sampler = inputs.sampler_name; + if (inputs.scheduler && !metadata.scheduler) metadata.scheduler = inputs.scheduler; + if (inputs.denoise !== undefined && metadata.denoise === undefined) metadata.denoise = inputs.denoise; + + if (inputs.width !== undefined && metadata.width === undefined) metadata.width = inputs.width; + if (inputs.height !== undefined && metadata.height === undefined) metadata.height = inputs.height; + + // Model chain extraction (keep names only; UI will normalize/strip extensions). + // Different node packs use different keys, so we try a small set of common ones. + const setModel = (key, value) => { + if (metadata[key]) return; + if (typeof value !== "string") return; + const text = value.trim(); + if (!text) return; + metadata[key] = text; + }; + const modelKeyCandidates = [ + inputs.ckpt_name, + inputs.checkpoint, + inputs.checkpoint_name, + inputs.model_name, + inputs.model, + ]; + for (const v of modelKeyCandidates) { + if (!metadata.model) { + setModel("model", v); + } + } + setModel("vae", inputs.vae_name || inputs.vae); + setModel("clip", inputs.clip_name || inputs.clip); + setModel("unet", inputs.unet_name || inputs.unet); + setModel("diffusion", inputs.diffusion_name || inputs.diffusion_model || inputs.diffusion); + + const looksLikeLora = classType.includes("lora") || classType.includes("loraloader"); + if (looksLikeLora) { + const name = inputs.lora_name || inputs.lora || inputs.name || null; + const w = inputs.strength_model ?? inputs.strength ?? inputs.weight ?? inputs.lora_strength ?? null; + if (name) loras.push({ name, weight: w }); + } + } + if (loras.length) metadata.loras = loras; + } + + return Object.keys(metadata).length > 0 ? metadata : null; +} diff --git a/js/components/sidebar/parsers/geninfoParser.js b/js/components/sidebar/parsers/geninfoParser.js new file mode 100644 index 00000000..8aa04e4e --- /dev/null +++ b/js/components/sidebar/parsers/geninfoParser.js @@ -0,0 +1,165 @@ +import { parseA1111Parameters } from "./a1111ParamsParser.js"; +import { looksLikeComfyPromptGraph, parseComfyUIWorkflow } from "./comfyWorkflowParser.js"; + +export function normalizeGenerationMetadata(raw) { + if (!raw) return null; + + if (typeof raw === "object") { + const geninfo = raw.geninfo || raw.GenInfo || raw.generation || null; + if (geninfo && typeof geninfo === "object") { + const mapped = {}; + const pos = geninfo.positive?.value ?? geninfo.positive?.text ?? null; + const neg = geninfo.negative?.value ?? geninfo.negative?.text ?? null; + if (typeof pos === "string" && pos.trim()) mapped.prompt = pos; + if (typeof neg === "string" && neg.trim()) mapped.negative_prompt = neg; + + const ckpt = geninfo.checkpoint?.name ?? geninfo.checkpoint ?? null; + if (typeof ckpt === "string" && ckpt.trim()) mapped.model = ckpt; + + const clip = geninfo.clip?.name ?? geninfo.clip ?? null; + if (typeof clip === "string" && clip.trim()) mapped.clip = clip; + const vae = geninfo.vae?.name ?? geninfo.vae ?? null; + if (typeof vae === "string" && vae.trim()) mapped.vae = vae; + + const models = geninfo.models; + if (models && typeof models === "object") { + mapped.models = models; + } + + const loras = Array.isArray(geninfo.loras) ? geninfo.loras : null; + if (loras) mapped.loras = loras; + + const sampler = geninfo.sampler?.name ?? geninfo.sampler ?? null; + if (typeof sampler === "string" && sampler.trim()) mapped.sampler = sampler; + const scheduler = geninfo.scheduler?.name ?? geninfo.scheduler ?? null; + if (typeof scheduler === "string" && scheduler.trim()) mapped.scheduler = scheduler; + + const steps = geninfo.steps?.value ?? geninfo.steps ?? null; + if (steps !== null && steps !== undefined) mapped.steps = steps; + const cfg = geninfo.cfg?.value ?? geninfo.cfg ?? null; + if (cfg !== null && cfg !== undefined) mapped.cfg = cfg; + const seed = geninfo.seed?.value ?? geninfo.seed ?? null; + if (seed !== null && seed !== undefined) mapped.seed = seed; + const denoise = geninfo.denoise?.value ?? geninfo.denoise ?? null; + if (denoise !== null && denoise !== undefined) mapped.denoise = denoise; + const clipSkip = geninfo.clip_skip?.value ?? geninfo.clip_skip ?? geninfo.clipSkip ?? null; + if (clipSkip !== null && clipSkip !== undefined) mapped.clip_skip = clipSkip; + + const size = geninfo.size || null; + if (size && typeof size === "object") { + if (size.width !== undefined) mapped.width = size.width; + if (size.height !== undefined) mapped.height = size.height; + } + + if (Object.keys(mapped).length) return mapped; + } + + const flatKeys = ["prompt", "negative_prompt", "negativePrompt", "steps", "sampler", "sampler_name", "cfg", "cfg_scale", "seed", "width", "height"]; + const hasFlatKey = flatKeys.some((k) => Object.prototype.hasOwnProperty.call(raw, k)); + if (hasFlatKey) return raw; + + const parsedFromWorkflow = parseComfyUIWorkflow(raw); + if (parsedFromWorkflow) return parsedFromWorkflow; + + if (looksLikeComfyPromptGraph(raw)) { + const parsed = parseComfyUIWorkflow({ prompt: raw }); + if (parsed) return parsed; + } + + const maybeParams = + raw["parameters"] || + raw["PNG:Parameters"] || + raw["EXIF:UserComment"] || + raw["UserComment"] || + raw["ImageDescription"] || + null; + if (typeof maybeParams === "string") { + const parsed = parseA1111Parameters(maybeParams); + if (parsed) return parsed; + } + + const innerWorkflow = raw.workflow || raw.Workflow || raw.comfy || raw.comfyui || raw.ComfyUI || null; + if (innerWorkflow && typeof innerWorkflow === "object") { + const parsed = parseComfyUIWorkflow(innerWorkflow); + if (parsed) return parsed; + } + + return raw; + } + + if (typeof raw === "string") { + const text = raw.trim(); + if (!text) return null; + const parsedParams = parseA1111Parameters(text); + if (parsedParams) return parsedParams; + + if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) { + try { + const obj = JSON.parse(text); + return normalizeGenerationMetadata(obj); + } catch { + return null; + } + } + return { prompt: text }; + } + + return null; +} + +export function normalizePromptsForDisplay(positive, negative) { + let pos = typeof positive === "string" ? positive : ""; + let neg = typeof negative === "string" ? negative : ""; + + // Always check if positive contains a negative block, even if negative is already provided. + // This prevents the "pos/neg concatenation" bug where both sources appear. + if (pos) { + const marker = /(?:^|\n)\s*Negative prompt:\s*/i; + if (marker.test(pos)) { + const parts = pos.split(marker); + const extractedPos = (parts[0] || "").trim(); + const tail = parts.slice(1).join("Negative prompt:").trim(); + const maybeParamsIdx = tail.search(/\n\s*Steps:\s*\d+/i); + const extractedNeg = (maybeParamsIdx >= 0 ? tail.slice(0, maybeParamsIdx) : tail).trim(); + + if (extractedPos) pos = extractedPos; + // Only use extractedNeg if we don't already have an explicit negative + if (!neg && extractedNeg) neg = extractedNeg; + } + } + + if (pos && neg && pos.trim() === neg.trim()) { + neg = ""; + } + + return { positive: pos, negative: neg }; +} + +export function formatModelLabel(value) { + if (!value) return ""; + const s = String(value).trim().replace(/\\/g, "/"); + const base = s.split("/").pop() || s; + return base.replace(/\.(safetensors|ckpt|pt|pth|bin|gguf|json)$/i, ""); +} + +export function formatLoRAItem(lora) { + if (!lora) return ""; + if (typeof lora === "string") return formatModelLabel(lora); + + const name = formatModelLabel(lora.name || lora.lora_name || ""); + if (!name) return ""; + + const w = lora.weight ?? lora.strength ?? null; + const sm = lora.strength_model ?? null; + const sc = lora.strength_clip ?? null; + + if (sm !== null || sc !== null) { + const parts = []; + if (sm !== null && sm !== undefined) parts.push(`m=${sm}`); + if (sc !== null && sc !== undefined) parts.push(`c=${sc}`); + return parts.length ? `${name} (${parts.join(", ")})` : name; + } + + if (w !== null && w !== undefined) return `${name} (${w})`; + return name; +} diff --git a/js/components/sidebar/sections/GenerationSection.js b/js/components/sidebar/sections/GenerationSection.js new file mode 100644 index 00000000..897256b3 --- /dev/null +++ b/js/components/sidebar/sections/GenerationSection.js @@ -0,0 +1,134 @@ +import { createInfoBox, createParametersBox } from "../utils/dom.js"; +import { + formatLoRAItem, + formatModelLabel, + normalizeGenerationMetadata, + normalizePromptsForDisplay, +} from "../parsers/geninfoParser.js"; + +export function createGenerationSection(asset) { + let metadata = null; + + // Prefer backend-parsed geninfo when available (most reliable for pos/neg + model chain). + if (asset?.geninfo && typeof asset.geninfo === "object") { + metadata = { geninfo: asset.geninfo }; + } else if (asset?.metadata && (typeof asset.metadata === "object" || typeof asset.metadata === "string")) { + metadata = asset.metadata; + } else if (asset?.prompt && (typeof asset.prompt === "object" || typeof asset.prompt === "string")) { + metadata = asset.prompt; + } else if (asset?.metadata_raw) { + metadata = asset.metadata_raw; + } else if (asset?.exif) { + metadata = asset.exif; + } + + const normalized = normalizeGenerationMetadata(metadata); + if (!normalized || (typeof normalized === "object" && Object.keys(normalized).length === 0)) { + const status = asset?.metadata_raw?.geninfo_status; + if (status && typeof status === "object" && status.kind === "media_pipeline") { + const container = document.createElement("div"); + container.appendChild( + createInfoBox( + "Generation Data", + "This file looks like a media-only pipeline (e.g. LoadVideo/VideoCombine) and does not contain generation parameters.", + "#9E9E9E" + ) + ); + return container; + } + return null; + } + metadata = normalized; + + const container = document.createElement("div"); + container.style.cssText = ` + display: flex; + flex-direction: column; + gap: 12px; + `; + + const cleaned = normalizePromptsForDisplay( + typeof metadata.prompt === "string" ? metadata.prompt : null, + typeof (metadata.negative_prompt || metadata.negativePrompt) === "string" + ? metadata.negative_prompt || metadata.negativePrompt + : null + ); + + if (typeof cleaned.positive === "string" && cleaned.positive.trim()) { + container.appendChild(createInfoBox("Positive Prompt", cleaned.positive, "#4CAF50")); + } + + if (typeof cleaned.negative === "string" && cleaned.negative.trim()) { + container.appendChild(createInfoBox("Negative Prompt", cleaned.negative, "#F44336")); + } + + const modelData = []; + const models = metadata.models && typeof metadata.models === "object" ? metadata.models : null; + const pickModelName = (m) => { + if (!m) return ""; + if (typeof m === "string") return formatModelLabel(m); + if (typeof m === "object") return formatModelLabel(m.name || m.value || ""); + return ""; + }; + + if (models) { + const ckpt = pickModelName(models.checkpoint); + const unet = pickModelName(models.unet); + const diffusion = pickModelName(models.diffusion); + const clip = pickModelName(models.clip); + const vae = pickModelName(models.vae); + if (ckpt) modelData.push({ label: "Checkpoint", value: ckpt }); + if (unet) modelData.push({ label: "UNet", value: unet }); + if (diffusion) modelData.push({ label: "Diffusion", value: diffusion }); + if (clip) modelData.push({ label: "CLIP", value: clip }); + if (vae) modelData.push({ label: "VAE", value: vae }); + } else if (metadata.model || metadata.checkpoint) { + modelData.push({ label: "Model", value: formatModelLabel(metadata.model || metadata.checkpoint) }); + } + + if (metadata.loras && Array.isArray(metadata.loras) && metadata.loras.length > 0) { + const loraText = metadata.loras + .map((lora) => formatLoRAItem(lora)) + .filter(Boolean) + .join("\n"); + if (loraText) modelData.push({ label: "LoRA", value: loraText }); + } + + if (!models && metadata.clip) { + modelData.push({ label: "CLIP", value: formatModelLabel(metadata.clip) }); + } + if (!models && metadata.vae) { + modelData.push({ label: "VAE", value: formatModelLabel(metadata.vae) }); + } + if (!models && metadata.unet) { + modelData.push({ label: "UNet", value: formatModelLabel(metadata.unet) }); + } + if (!models && metadata.diffusion) { + modelData.push({ label: "Diffusion", value: formatModelLabel(metadata.diffusion) }); + } + if (modelData.length > 0) { + container.appendChild(createParametersBox("Model & LoRA", modelData, "#9C27B0")); + } + + const samplingData = []; + if (metadata.sampler || metadata.sampler_name) { + samplingData.push({ label: "Sampler", value: metadata.sampler || metadata.sampler_name }); + } + if (metadata.steps) samplingData.push({ label: "Steps", value: metadata.steps }); + if (metadata.cfg || metadata.cfg_scale) samplingData.push({ label: "CFG Scale", value: metadata.cfg || metadata.cfg_scale }); + if (metadata.scheduler) samplingData.push({ label: "Scheduler", value: metadata.scheduler }); + if (samplingData.length > 0) { + container.appendChild(createParametersBox("Sampling", samplingData, "#FF9800")); + } + + const imageData = []; + if (metadata.seed) imageData.push({ label: "Seed", value: metadata.seed }); + if (metadata.width && metadata.height) imageData.push({ label: "Size", value: `${metadata.width}\u00D7${metadata.height}` }); + if (metadata.denoise || metadata.denoising) imageData.push({ label: "Denoise", value: metadata.denoise || metadata.denoising }); + if (metadata.clip_skip) imageData.push({ label: "Clip Skip", value: metadata.clip_skip }); + if (imageData.length > 0) { + container.appendChild(createParametersBox("Image", imageData, "#2196F3")); + } + + return container.children.length > 0 ? container : null; +} diff --git a/js/components/sidebar/sections/HeaderSection.js b/js/components/sidebar/sections/HeaderSection.js new file mode 100644 index 00000000..d9e5583b --- /dev/null +++ b/js/components/sidebar/sections/HeaderSection.js @@ -0,0 +1,69 @@ +export function createSidebarHeader(asset, onClose) { + const header = document.createElement("div"); + header.className = "mjr-sidebar-header"; + header.style.cssText = ` + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--mjr-border, rgba(255,255,255,0.12)); + background: var(--mjr-surface-2, #333); + min-width: 0; + `; + + const titleDiv = document.createElement("div"); + titleDiv.style.cssText = "flex: 1; min-width: 0;"; + + const filename = document.createElement("div"); + filename.className = "mjr-sidebar-filename"; + filename.textContent = asset.filename || "Unknown"; + filename.title = asset.filename; + filename.style.cssText = ` + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--content-fg, #fff); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `; + + titleDiv.appendChild(filename); + + const closeBtn = document.createElement("button"); + closeBtn.className = "mjr-sidebar-close"; + closeBtn.type = "button"; + closeBtn.textContent = "×"; + closeBtn.style.cssText = ` + background: none; + border: none; + color: white; + font-size: 26px; + cursor: pointer; + padding: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background 0.2s; + line-height: 1; + `; + + closeBtn.addEventListener("mouseenter", () => { + closeBtn.style.background = "rgba(255,255,255,0.1)"; + }); + + closeBtn.addEventListener("mouseleave", () => { + closeBtn.style.background = "none"; + }); + + closeBtn.addEventListener("click", onClose); + + header.appendChild(titleDiv); + header.appendChild(closeBtn); + + return header; +} + diff --git a/js/components/sidebar/sections/PreviewSection.js b/js/components/sidebar/sections/PreviewSection.js new file mode 100644 index 00000000..c859c409 --- /dev/null +++ b/js/components/sidebar/sections/PreviewSection.js @@ -0,0 +1,88 @@ +import { buildAssetViewURL } from "../../../api/endpoints.js"; +import { formatFileSize, formatShortDate } from "../utils/format.js"; + +export function createPreviewSection(asset) { + const section = document.createElement("div"); + section.className = "mjr-sidebar-preview"; + + const previewContainer = document.createElement("div"); + previewContainer.style.cssText = ` + width: 100%; + aspect-ratio: 1; + background: black; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + `; + + const viewUrl = buildAssetViewURL(asset); + + if (asset.kind === "image") { + const img = document.createElement("img"); + img.src = viewUrl; + img.style.cssText = ` + max-width: 100%; + max-height: 100%; + object-fit: contain; + `; + previewContainer.appendChild(img); + } else if (asset.kind === "video") { + const video = document.createElement("video"); + video.src = viewUrl; + video.controls = true; + video.loop = true; + video.muted = true; + video.style.cssText = ` + max-width: 100%; + max-height: 100%; + object-fit: contain; + `; + previewContainer.appendChild(video); + } else { + const placeholder = document.createElement("div"); + placeholder.textContent = asset.kind?.toUpperCase() || "PREVIEW"; + placeholder.style.cssText = ` + color: rgba(255,255,255,0.4); + font-size: 14px; + `; + previewContainer.appendChild(placeholder); + } + + const metaLine = document.createElement("div"); + metaLine.className = "mjr-sidebar-preview-meta"; + metaLine.style.cssText = ` + margin-top: 10px; + font-size: 12px; + color: var(--mjr-muted, rgba(255,255,255,0.65)); + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + line-height: 1.3; + padding-bottom: 12px; + border-bottom: 1px solid var(--mjr-border, rgba(255,255,255,0.12)); + `; + + const ext = (() => { + const name = String(asset?.filename || ""); + const idx = name.lastIndexOf("."); + if (idx === -1) return ""; + return name.slice(idx + 1).toUpperCase(); + })(); + + const parts = []; + if (ext) parts.push(ext); + if (asset?.kind) parts.push(String(asset.kind).toLowerCase()); + const size = formatFileSize(asset?.size); + if (size) parts.push(size); + const date = formatShortDate(asset?.mtime); + if (date) parts.push(date); + metaLine.textContent = parts.join(" â€ĸ "); + + section.appendChild(previewContainer); + section.appendChild(metaLine); + return section; +} + diff --git a/js/components/sidebar/sections/RatingTagsSection.js b/js/components/sidebar/sections/RatingTagsSection.js new file mode 100644 index 00000000..9d350456 --- /dev/null +++ b/js/components/sidebar/sections/RatingTagsSection.js @@ -0,0 +1,69 @@ +import { createRatingEditor } from "../../RatingEditor.js"; +import { createTagsEditor } from "../../TagsEditor.js"; +import { createSection } from "../utils/dom.js"; + +export function createRatingTagsSection(asset, onUpdate) { + const section = createSection("Rating & Tags"); + + const ratingContainer = document.createElement("div"); + ratingContainer.style.marginBottom = "16px"; + + const ratingLabel = document.createElement("div"); + ratingLabel.textContent = "Rating"; + ratingLabel.style.cssText = ` + font-size: 12px; + color: var(--mjr-muted, rgba(255,255,255,0.65)); + margin-bottom: 8px; + `; + + const ratingEditor = createRatingEditor(asset, (newRating) => { + asset.rating = newRating; + if (onUpdate) onUpdate(asset); + }); + ratingEditor.style.cssText = ` + background: rgba(0,0,0,0.3); + border-radius: 6px; + padding: 8px; + display: inline-flex; + `; + + ratingContainer.appendChild(ratingLabel); + ratingContainer.appendChild(ratingEditor); + + const tagsContainer = document.createElement("div"); + + const tagsLabel = document.createElement("div"); + tagsLabel.textContent = "Tags"; + tagsLabel.style.cssText = ` + font-size: 12px; + color: var(--mjr-muted, rgba(255,255,255,0.65)); + margin-bottom: 8px; + `; + + const tagsEditor = createTagsEditor(asset, (newTags) => { + asset.tags = newTags; + if (onUpdate) onUpdate(asset); + }); + tagsEditor.style.cssText = ` + background: rgba(0,0,0,0.3); + border-radius: 6px; + padding: 12px; + `; + + tagsContainer.appendChild(tagsLabel); + tagsContainer.appendChild(tagsEditor); + + section.appendChild(ratingContainer); + section.appendChild(tagsContainer); + + // Expose minimal imperative API so the sidebar can update when rating/tags are changed externally + // (hotkeys, viewer shortcuts, etc.) without re-mounting the whole sidebar. + try { + section._mjrSetRating = (rating) => ratingEditor?._mjrSetRating?.(rating); + } catch {} + try { + section._mjrSetTags = (tags) => tagsEditor?._mjrSetTags?.(tags); + } catch {} + + return section; +} diff --git a/js/components/sidebar/sections/WorkflowMinimapSection.js b/js/components/sidebar/sections/WorkflowMinimapSection.js new file mode 100644 index 00000000..2aa4e675 --- /dev/null +++ b/js/components/sidebar/sections/WorkflowMinimapSection.js @@ -0,0 +1,344 @@ +import { createFieldRow, createSection } from "../utils/dom.js"; +import { drawWorkflowMinimap, synthesizeWorkflowFromPromptGraph } from "../utils/minimap.js"; + +const MINIMAP_SETTINGS_KEY = "mjrMinimapSettings"; + +const coerceMetadataRawObject = (asset) => { + const raw = asset?.metadata_raw ?? null; + if (!raw) return null; + if (typeof raw === "object") return raw; + if (typeof raw === "string") { + const t = raw.trim(); + if (!t) return null; + try { + const parsed = JSON.parse(t); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } + } + return null; +}; + +const loadSettings = () => { + try { + const raw = localStorage?.getItem?.(MINIMAP_SETTINGS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +}; + +const saveSettings = (settings) => { + try { + localStorage?.setItem?.(MINIMAP_SETTINGS_KEY, JSON.stringify(settings)); + } catch {} +}; + +const coerceWorkflow = (asset) => { + const metadataRaw = coerceMetadataRawObject(asset); + const wf = + asset?.workflow || + asset?.Workflow || + asset?.comfy_workflow || + metadataRaw?.workflow || + metadataRaw?.Workflow || + metadataRaw?.comfy_workflow || + null; + if (!wf) return null; + if (typeof wf === "object") return wf; + if (typeof wf === "string") { + const t = wf.trim(); + if (!t) return null; + try { + return JSON.parse(t); + } catch { + return null; + } + } + return null; +}; + +const looksLikePromptGraph = (obj) => { + try { + const entries = Object.entries(obj || {}); + if (!entries.length) return false; + let hits = 0; + for (const [, v] of entries.slice(0, 50)) { + if (!v || typeof v !== "object") continue; + if (v.inputs && typeof v.inputs === "object") hits += 1; + if (hits >= 2) return true; + } + return false; + } catch { + return false; + } +}; + +const coercePromptGraph = (asset) => { + const metadataRaw = coerceMetadataRawObject(asset); + const raw = + asset?.prompt || + asset?.Prompt || + metadataRaw?.prompt || + metadataRaw?.Prompt || + null; + if (!raw) return null; + if (typeof raw === "object") return looksLikePromptGraph(raw) ? raw : null; + if (typeof raw === "string") { + const t = raw.trim(); + if (!t) return null; + try { + const parsed = JSON.parse(t); + return looksLikePromptGraph(parsed) ? parsed : null; + } catch { + return null; + } + } + return null; +}; + +export function createWorkflowMinimapSection(asset) { + const rawWorkflow = coerceWorkflow(asset); + const promptGraph = coercePromptGraph(asset); + + // Show minimap if we have a positioned workflow OR we can synthesize one from a prompt graph. + if (!rawWorkflow && !promptGraph) return null; + + const workflow = rawWorkflow || synthesizeWorkflowFromPromptGraph(promptGraph); + if (!workflow) return null; + + const section = createSection("ComfyUI Workflow"); + section.appendChild(createFieldRow("Status", asset.has_generation_data ? "Complete" : "Partial")); + + const defaultSettings = { + nodeColors: true, + showLinks: true, + showGroups: true, + renderBypassState: true, + renderErrorState: true, + showViewport: true, + }; + const settings = { ...defaultSettings, ...(loadSettings() || {}) }; + + const headerRow = document.createElement("div"); + headerRow.style.cssText = ` + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 8px; + `; + + const toolsBtn = document.createElement("button"); + toolsBtn.type = "button"; + toolsBtn.className = "mjr-btn mjr-icon-btn"; + toolsBtn.title = "Minimap settings"; + toolsBtn.style.cssText = ` + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--mjr-border, rgba(255,255,255,0.12)); + background: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.9); + cursor: pointer; + `; + const toolsIcon = document.createElement("i"); + toolsIcon.className = "pi pi-sliders-h"; + toolsBtn.appendChild(toolsIcon); + + const headerSpacer = document.createElement("div"); + headerSpacer.style.flex = "1"; + + headerRow.appendChild(headerSpacer); + headerRow.appendChild(toolsBtn); + section.appendChild(headerRow); + + const layout = document.createElement("div"); + layout.style.cssText = ` + display: flex; + gap: 10px; + align-items: stretch; + margin-top: 10px; + `; + + const panel = document.createElement("div"); + panel.style.cssText = ` + width: 190px; + flex: 0 0 auto; + border-radius: 10px; + padding: 10px; + background: rgba(0,0,0,0.20); + border: 1px solid var(--mjr-border, rgba(255,255,255,0.12)); + display: none; + `; + + const createToggle = ({ key, label, iconClass }) => { + const row = document.createElement("button"); + row.type = "button"; + row.style.cssText = ` + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 8px; + border-radius: 10px; + border: 1px solid transparent; + background: transparent; + cursor: pointer; + color: rgba(255,255,255,0.92); + text-align: left; + `; + + row.onmouseenter = () => { + row.style.background = "rgba(255,255,255,0.06)"; + row.style.borderColor = "rgba(255,255,255,0.08)"; + }; + row.onmouseleave = () => { + row.style.background = "transparent"; + row.style.borderColor = "transparent"; + }; + + const box = document.createElement("span"); + box.style.cssText = ` + width: 22px; + height: 22px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + background: ${settings[key] ? "rgba(76,175,80,0.95)" : "rgba(255,255,255,0.08)"}; + border: 1px solid ${settings[key] ? "rgba(76,175,80,0.35)" : "rgba(255,255,255,0.12)"}; + flex: 0 0 auto; + `; + const check = document.createElement("i"); + check.className = "pi pi-check"; + check.style.fontSize = "12px"; + check.style.opacity = settings[key] ? "1" : "0"; + box.appendChild(check); + + const icon = document.createElement("i"); + icon.className = iconClass; + icon.style.cssText = "font-size: 18px; opacity: 0.9; width: 18px;"; + + const text = document.createElement("div"); + text.textContent = label; + text.style.cssText = "font-size: 13px; font-weight: 600;"; + + row.appendChild(box); + row.appendChild(icon); + row.appendChild(text); + + row.onclick = () => { + settings[key] = !settings[key]; + check.style.opacity = settings[key] ? "1" : "0"; + box.style.background = settings[key] ? "rgba(76,175,80,0.95)" : "rgba(255,255,255,0.08)"; + box.style.borderColor = settings[key] ? "rgba(76,175,80,0.35)" : "rgba(255,255,255,0.12)"; + saveSettings(settings); + render(); + }; + + return row; + }; + + panel.appendChild( + createToggle({ key: "nodeColors", label: "Node Colors", iconClass: "pi pi-palette" }) + ); + panel.appendChild( + createToggle({ key: "showLinks", label: "Show Links", iconClass: "pi pi-share-alt" }) + ); + panel.appendChild( + createToggle({ key: "showGroups", label: "Show Frames/Groups", iconClass: "pi pi-th-large" }) + ); + panel.appendChild( + createToggle({ key: "renderBypassState", label: "Render Bypass State", iconClass: "pi pi-ban" }) + ); + panel.appendChild( + createToggle({ key: "renderErrorState", label: "Render Error State", iconClass: "pi pi-exclamation-triangle" }) + ); + panel.appendChild( + createToggle({ key: "showViewport", label: "Show Viewport", iconClass: "pi pi-window-maximize" }) + ); + + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 120; + canvas.style.cssText = ` + width: 100%; + height: 120px; + border-radius: 8px; + margin-top: 0; + background: rgba(0,0,0,0.25); + border: 1px solid var(--mjr-border, rgba(255,255,255,0.12)); + `; + + const render = () => { + const w = Math.max(1, canvas.clientWidth || 320); + const h = Math.max(1, canvas.clientHeight || 120); + const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + const ctx = canvas.getContext("2d"); + if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + drawWorkflowMinimap(canvas, workflow, settings); + }; + + try { + const ro = new ResizeObserver(() => render()); + ro.observe(canvas); + section._mjrMinimapCleanup = () => { + try { + ro.disconnect(); + } catch {} + }; + } catch {} + + layout.appendChild(panel); + layout.appendChild(canvas); + section.appendChild(layout); + + toolsBtn.onclick = () => { + const visible = panel.style.display !== "none"; + panel.style.display = visible ? "none" : "block"; + render(); + }; + + render(); + + const details = document.createElement("details"); + details.style.marginTop = "10px"; + + const summary = document.createElement("summary"); + summary.textContent = "Show raw JSON"; + summary.style.cssText = ` + cursor: pointer; + color: var(--mjr-muted, rgba(255,255,255,0.65)); + font-size: 12px; + user-select: none; + `; + details.appendChild(summary); + + const pre = document.createElement("pre"); + pre.style.cssText = ` + background: rgba(0,0,0,0.5); + padding: 10px; + border-radius: 6px; + font-size: 11px; + overflow: auto; + max-height: 180px; + margin: 10px 0 0 0; + color: #90CAF9; + font-family: 'Consolas', 'Monaco', monospace; + `; + pre.textContent = JSON.stringify(workflow, null, 2); + details.appendChild(pre); + + section.appendChild(details); + + return section; +} diff --git a/js/components/sidebar/utils/dom.js b/js/components/sidebar/utils/dom.js new file mode 100644 index 00000000..a97ee9c4 --- /dev/null +++ b/js/components/sidebar/utils/dom.js @@ -0,0 +1,146 @@ +export function createSection(title) { + const section = document.createElement("div"); + section.className = "mjr-sidebar-section"; + section.style.cssText = ` + background: rgba(0,0,0,0.2); + border-radius: 8px; + padding: 12px; + min-width: 300px; + `; + + const header = document.createElement("div"); + header.style.cssText = ` + font-size: 13px; + font-weight: 600; + color: white; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + `; + header.textContent = title; + + section.appendChild(header); + return section; +} + +export function createInfoBox(title, content, accentColor) { + const box = document.createElement("div"); + box.style.cssText = ` + background: rgba(0,0,0,0.3); + border-left: 3px solid ${accentColor}; + border-radius: 6px; + padding: 12px; + `; + + const header = document.createElement("div"); + header.textContent = title; + header.style.cssText = ` + font-size: 11px; + font-weight: 600; + color: ${accentColor}; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + `; + + const text = document.createElement("div"); + text.textContent = content; + text.style.cssText = ` + font-size: 12px; + color: rgba(255,255,255,0.9); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + `; + + box.appendChild(header); + box.appendChild(text); + return box; +} + +export function createParametersBox(title, fields, accentColor) { + const box = document.createElement("div"); + box.style.cssText = ` + background: rgba(0,0,0,0.3); + border-left: 3px solid ${accentColor}; + border-radius: 6px; + padding: 12px; + `; + + const header = document.createElement("div"); + header.textContent = title; + header.style.cssText = ` + font-size: 11px; + font-weight: 600; + color: ${accentColor}; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; + `; + box.appendChild(header); + + const grid = document.createElement("div"); + grid.style.cssText = ` + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 12px; + align-items: start; + `; + + fields.forEach((field) => { + const label = document.createElement("div"); + label.textContent = field.label + ":"; + label.style.cssText = ` + font-size: 11px; + color: rgba(255,255,255,0.6); + font-weight: 500; + `; + + const value = document.createElement("div"); + value.textContent = String(field.value); + value.style.cssText = ` + font-size: 12px; + color: rgba(255,255,255,0.95); + word-break: break-word; + white-space: pre-wrap; + `; + + grid.appendChild(label); + grid.appendChild(value); + }); + + box.appendChild(grid); + return box; +} + +export function createFieldRow(label, value, multiline = false) { + const row = document.createElement("div"); + row.style.cssText = ` + display: flex; + flex-direction: ${multiline ? "column" : "row"}; + gap: ${multiline ? "4px" : "8px"}; + margin-bottom: 8px; + font-size: 12px; + `; + + const labelEl = document.createElement("div"); + labelEl.textContent = label; + labelEl.style.cssText = ` + color: var(--mjr-muted, rgba(255,255,255,0.65)); + ${multiline ? "" : "min-width: 100px;"} + font-weight: 500; + `; + + const valueEl = document.createElement("div"); + valueEl.textContent = String(value); + valueEl.style.cssText = ` + color: white; + ${multiline ? "white-space: pre-wrap; word-break: break-word;" : ""} + flex: 1; + `; + + row.appendChild(labelEl); + row.appendChild(valueEl); + + return row; +} diff --git a/js/components/sidebar/utils/format.js b/js/components/sidebar/utils/format.js new file mode 100644 index 00000000..ad34a2d7 --- /dev/null +++ b/js/components/sidebar/utils/format.js @@ -0,0 +1,18 @@ +export function formatFileSize(bytes) { + if (!bytes) return "Unknown"; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + const mb = kb / 1024; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + const gb = mb / 1024; + return `${gb.toFixed(2)} GB`; +} + +export function formatShortDate(timestamp) { + if (!timestamp) return ""; + const d = new Date(Number(timestamp) * 1000); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + diff --git a/js/components/sidebar/utils/minimap.js b/js/components/sidebar/utils/minimap.js new file mode 100644 index 00000000..1d256e2f --- /dev/null +++ b/js/components/sidebar/utils/minimap.js @@ -0,0 +1,459 @@ +export function drawWorkflowMinimap(canvas, workflow, options = null) { + if (!canvas) return; + const ctx = canvas.getContext?.("2d"); + if (!ctx) return; + + const settings = { + nodeColors: true, + showLinks: true, + showGroups: true, + renderBypassState: true, + renderErrorState: true, + showViewport: true, + ...(options && typeof options === "object" ? options : {}), + }; + + const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : []; + const groups = + (Array.isArray(workflow?.groups) && workflow.groups) || + (Array.isArray(workflow?.extra?.groups) && workflow.extra.groups) || + (Array.isArray(workflow?.extra?.groupNodes) && workflow.extra.groupNodes) || + (Array.isArray(workflow?.extra?.group_nodes) && workflow.extra.group_nodes) || + []; + const links = + (Array.isArray(workflow?.links) && workflow.links) || + (Array.isArray(workflow?.extra?.links) && workflow.extra.links) || + []; + + const cw = Math.max(1, canvas.clientWidth || canvas.width || 1); + const ch = Math.max(1, canvas.clientHeight || canvas.height || 1); + + if ((!nodes || nodes.length === 0) && (!groups || groups.length === 0)) { + ctx.clearRect(0, 0, cw, ch); + return; + } + + const toRgba = (color, alpha) => { + if (!color) return `rgba(255,255,255,${alpha})`; + if (typeof color !== "string") return `rgba(255,255,255,${alpha})`; + const s = color.trim(); + if (!s) return `rgba(255,255,255,${alpha})`; + + // rgba(...) -> override alpha + const mRgba = s.match(/^rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?\)\s*$/i); + if (mRgba) { + const r = Number(mRgba[1]); + const g = Number(mRgba[2]); + const b = Number(mRgba[3]); + if ([r, g, b].every((v) => Number.isFinite(v))) return `rgba(${r},${g},${b},${alpha})`; + } + + // #rgb / #rrggbb + const hex = s.startsWith("#") ? s.slice(1) : ""; + if (hex.length === 3) { + const r = parseInt(hex[0] + hex[0], 16); + const g = parseInt(hex[1] + hex[1], 16); + const b = parseInt(hex[2] + hex[2], 16); + if ([r, g, b].every((v) => Number.isFinite(v))) return `rgba(${r},${g},${b},${alpha})`; + } + if (hex.length === 6) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + if ([r, g, b].every((v) => Number.isFinite(v))) return `rgba(${r},${g},${b},${alpha})`; + } + + // Otherwise keep as-is (named colors) with globalAlpha. + return s; + }; + + const rects = []; + const nodesById = new Map(); + + const normalizeVec2 = (value) => { + if (Array.isArray(value) && value.length >= 2) return [Number(value[0]), Number(value[1])]; + if (value && typeof value === "object") { + const a = value[0] ?? value["0"] ?? value.x ?? value.left ?? null; + const b = value[1] ?? value["1"] ?? value.y ?? value.top ?? null; + if (a !== null && b !== null) return [Number(a), Number(b)]; + } + return null; + }; + + const normalizeSize2 = (value) => { + if (Array.isArray(value) && value.length >= 2) return [Number(value[0]), Number(value[1])]; + if (value && typeof value === "object") { + const w = value[0] ?? value["0"] ?? value.w ?? value.width ?? null; + const h = value[1] ?? value["1"] ?? value.h ?? value.height ?? null; + if (w !== null && h !== null) return [Number(w), Number(h)]; + } + return null; + }; + + for (const n of nodes || []) { + const nodeIdRaw = n?.id ?? n?.ID ?? n?.node_id ?? null; + const nodeId = nodeIdRaw !== null && nodeIdRaw !== undefined ? String(nodeIdRaw) : null; + const pos = normalizeVec2(n?.pos); + const size = normalizeSize2(n?.size); + if (!pos) continue; + if (!size) continue; + const x = Number(pos[0]); + const y = Number(pos[1]); + const w = Math.max(1, Number(size[0])); + const h = Math.max(1, Number(size[1])); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) continue; + + const mode = Number(n?.mode); + const bypassed = mode === 2 || mode === 4; + + const errExtra = workflow?.extra?.errors || workflow?.extra?.node_errors || null; + const errByNodeId = + errExtra && typeof errExtra === "object" && nodeId ? errExtra[nodeId] : null; + const errored = Boolean( + errByNodeId || + n?.error || + n?.errors || + n?.flags?.error || + n?.properties?.error + ); + + rects.push({ + kind: "node", + id: nodeId, + x, + y, + w, + h, + fill: settings.nodeColors ? n?.bgcolor || n?.color || null : null, + stroke: settings.nodeColors ? n?.color || n?.bgcolor || null : null, + bypassed, + errored, + }); + if (nodeId) nodesById.set(nodeId, rects[rects.length - 1]); + } + if (settings.showGroups) { + for (const g of groups || []) { + // Some exports use `bounding: [x,y,w,h]` instead of pos/size. + const bounding = Array.isArray(g?.bounding) && g.bounding.length >= 4 ? g.bounding : null; + const pos = bounding ? [Number(bounding[0]), Number(bounding[1])] : normalizeVec2(g?.pos); + const size = bounding ? [Number(bounding[2]), Number(bounding[3])] : normalizeSize2(g?.size); + if (!pos || !size) continue; + const x = Number(pos[0]); + const y = Number(pos[1]); + const w = Math.max(1, Number(size[0])); + const h = Math.max(1, Number(size[1])); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) continue; + rects.push({ + kind: "group", + x, + y, + w, + h, + fill: g?.color || g?.bgcolor || g?.borderColor || null, + stroke: g?.borderColor || g?.color || g?.bgcolor || null, + }); + } + } + + if (!rects.length) { + ctx.clearRect(0, 0, cw, ch); + return; + } + + let minX = rects[0].x; + let minY = rects[0].y; + let maxX = rects[0].x + rects[0].w; + let maxY = rects[0].y + rects[0].h; + for (const r of rects) { + minX = Math.min(minX, r.x); + minY = Math.min(minY, r.y); + maxX = Math.max(maxX, r.x + r.w); + maxY = Math.max(maxY, r.y + r.h); + } + + const pad = 6; + const viewW = Math.max(1, maxX - minX); + const viewH = Math.max(1, maxY - minY); + const scale = Math.min((cw - pad * 2) / viewW, (ch - pad * 2) / viewH); + + ctx.clearRect(0, 0, cw, ch); + + // Background + ctx.fillStyle = "rgba(0,0,0,0.22)"; + ctx.fillRect(0, 0, cw, ch); + + const toCanvas = (x, y) => ({ + x: pad + (x - minX) * scale, + y: pad + (y - minY) * scale, + }); + + const drawLinks = () => { + if (!settings.showLinks) return; + if (!links || links.length === 0) return; + + ctx.save(); + ctx.globalAlpha = 1; + ctx.strokeStyle = "rgba(255,255,255,0.18)"; + ctx.lineWidth = 1; + + for (const l of links) { + let originId = null; + let targetId = null; + + if (Array.isArray(l) && l.length >= 4) { + // LiteGraph format: [id, origin_id, origin_slot, target_id, target_slot, type] + originId = l[1]; + targetId = l[3]; + } else if (l && typeof l === "object") { + originId = l.origin_id ?? l.originId ?? l.from ?? null; + targetId = l.target_id ?? l.targetId ?? l.to ?? null; + } + + if (originId === null || targetId === null) continue; + const a = nodesById.get(String(originId)); + const b = nodesById.get(String(targetId)); + if (!a || !b) continue; + + // Use right/left ports for a more ComfyUI-like look. + const aP = toCanvas(a.x + a.w, a.y + a.h / 2); + const bP = toCanvas(b.x, b.y + b.h / 2); + const dx = Math.max(12, Math.min(80, Math.abs(bP.x - aP.x) * 0.35)); + + ctx.beginPath(); + ctx.moveTo(aP.x, aP.y); + ctx.bezierCurveTo(aP.x + dx, aP.y, bP.x - dx, bP.y, bP.x, bP.y); + ctx.stroke(); + } + ctx.restore(); + }; + + const drawRect = (r) => { + const x = pad + (r.x - minX) * scale; + const y = pad + (r.y - minY) * scale; + const w = Math.max(1, r.w * scale); + const h = Math.max(1, r.h * scale); + + const isNode = r.kind === "node"; + const isGroup = r.kind === "group"; + + const bypassed = Boolean(r.bypassed); + const errored = Boolean(r.errored); + + const fillAlpha = isGroup ? 0.18 : bypassed && settings.renderBypassState ? 0.14 : 0.62; + const strokeAlpha = isGroup ? 0.55 : bypassed && settings.renderBypassState ? 0.32 : 0.8; + + const fill = toRgba(r.fill, fillAlpha); + const stroke = toRgba(r.stroke, strokeAlpha); + + // Fill + ctx.save(); + ctx.globalAlpha = 1; + if (typeof fill === "string" && (fill.startsWith("#") || fill.startsWith("rgb") || fill.startsWith("hsl"))) { + ctx.fillStyle = fill; + ctx.globalAlpha = fillAlpha; + } else { + ctx.fillStyle = typeof fill === "string" ? fill : "rgba(255,255,255,0.20)"; + ctx.globalAlpha = fillAlpha; + } + ctx.fillRect(x, y, w, h); + ctx.restore(); + + ctx.globalAlpha = 1; + ctx.strokeStyle = "rgba(255,255,255,0.22)"; + if (typeof stroke === "string" && (stroke.startsWith("#") || stroke.startsWith("rgb") || stroke.startsWith("hsl"))) { + ctx.save(); + ctx.globalAlpha = strokeAlpha; + ctx.strokeStyle = stroke; + ctx.restore(); + } + if (isNode && bypassed && settings.renderBypassState) { + try { + ctx.setLineDash([3, 2]); + } catch {} + } else { + try { + ctx.setLineDash([]); + } catch {} + } + + ctx.lineWidth = 1; + ctx.strokeRect(x, y, w, h); + + if (isNode && errored && settings.renderErrorState) { + try { + ctx.setLineDash([]); + } catch {} + ctx.strokeStyle = "rgba(244,67,54,0.95)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(x - 0.5, y - 0.5, w + 1, h + 1); + } + }; + + // Groups first (backdrops), then nodes on top. + for (const r of rects.filter((r) => r.kind === "group")) drawRect(r); + drawLinks(); + for (const r of rects.filter((r) => r.kind === "node")) drawRect(r); + + // Viewport rectangle (ComfyUI-like): only meaningful when we can read the current canvas DS. + if (settings.showViewport) { + try { + const app = window?.app || null; + const canvasEl = app?.canvas?.canvas || app?.canvas?.el || null; + const ds = app?.canvas?.ds || null; + const scaleDs = Number(ds?.scale); + const off = ds?.offset; + const offX = Array.isArray(off) ? Number(off[0]) : Number(off?.x); + const offY = Array.isArray(off) ? Number(off[1]) : Number(off?.y); + const viewWpx = Number(canvasEl?.clientWidth || canvasEl?.width); + const viewHpx = Number(canvasEl?.clientHeight || canvasEl?.height); + + if ( + Number.isFinite(scaleDs) && + scaleDs > 0 && + Number.isFinite(offX) && + Number.isFinite(offY) && + Number.isFinite(viewWpx) && + Number.isFinite(viewHpx) && + viewWpx > 0 && + viewHpx > 0 + ) { + const vx0 = (-offX) / scaleDs; + const vy0 = (-offY) / scaleDs; + const vx1 = (viewWpx - offX) / scaleDs; + const vy1 = (viewHpx - offY) / scaleDs; + + const p0 = toCanvas(vx0, vy0); + const p1 = toCanvas(vx1, vy1); + const rx = Math.min(p0.x, p1.x); + const ry = Math.min(p0.y, p1.y); + const rw = Math.abs(p1.x - p0.x); + const rh = Math.abs(p1.y - p0.y); + + ctx.save(); + ctx.globalAlpha = 1; + ctx.strokeStyle = "rgba(255,255,255,0.9)"; + ctx.lineWidth = 1; + ctx.strokeRect(rx, ry, rw, rh); + ctx.restore(); + } + } catch {} + } + + ctx.globalAlpha = 1; +} + +export function synthesizeWorkflowFromPromptGraph(promptGraph, options = null) { + if (!promptGraph || typeof promptGraph !== "object") return null; + const settings = { + maxNodes: 220, + ...(options && typeof options === "object" ? options : {}), + }; + + const entries = Object.entries(promptGraph); + if (!entries.length) return null; + + const nodes = []; + const edges = []; + const depsById = new Map(); + + const toStrId = (v) => { + if (v === null || v === undefined) return null; + const s = String(v); + return s ? s : null; + }; + + const isLink = (v) => Array.isArray(v) && v.length === 2 && Number.isFinite(Number(v[0])); + + for (const [idRaw, node] of entries.slice(0, settings.maxNodes)) { + if (!node || typeof node !== "object") continue; + const id = toStrId(idRaw); + if (!id) continue; + const classType = String(node.class_type || node.type || node.classType || "").trim(); + const inputs = node.inputs && typeof node.inputs === "object" ? node.inputs : {}; + + const deps = []; + for (const v of Object.values(inputs)) { + if (!isLink(v)) continue; + const src = toStrId(v[0]); + if (!src) continue; + deps.push(src); + edges.push([src, id]); + } + depsById.set(id, deps); + + // Basic color heuristic (only for minimap readability). + const ctLower = classType.toLowerCase(); + let bgcolor = "#3a3a3a"; + let color = "#6b6b6b"; + if (ctLower.includes("ksampler") || ctLower.includes("sampler")) { + bgcolor = "#6a4b1f"; + color = "#b07a2c"; + } else if (ctLower.includes("cliptext") || ctLower.includes("textencode") || ctLower.includes("conditioning")) { + bgcolor = "#1f5f3a"; + color = "#2cb06c"; + } else if (ctLower.includes("checkpoint") || ctLower.includes("loader") || ctLower.includes("model")) { + bgcolor = "#243a6a"; + color = "#3f6fd6"; + } else if (ctLower.includes("save") || ctLower.includes("preview") || ctLower.includes("video")) { + bgcolor = "#4a2a5f"; + color = "#8c4cd1"; + } + + nodes.push({ + id: Number.isFinite(Number(id)) ? Number(id) : id, + type: classType || "Node", + pos: [0, 0], + size: [180, 80], + bgcolor, + color, + inputs: [], + outputs: [], + }); + } + + if (!nodes.length) return null; + + // Topological-ish layering (best-effort). + const levelById = new Map(); + const visiting = new Set(); + + const computeLevel = (id) => { + if (levelById.has(id)) return levelById.get(id); + if (visiting.has(id)) return 0; + visiting.add(id); + let best = 0; + const deps = depsById.get(id) || []; + for (const dep of deps) best = Math.max(best, computeLevel(dep) + 1); + visiting.delete(id); + levelById.set(id, best); + return best; + }; + + for (const n of nodes) computeLevel(String(n.id)); + + // Place nodes by level then index within level. + const nodesByLevel = new Map(); + for (const n of nodes) { + const lvl = levelById.get(String(n.id)) ?? 0; + if (!nodesByLevel.has(lvl)) nodesByLevel.set(lvl, []); + nodesByLevel.get(lvl).push(n); + } + const levels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b); + for (const lvl of levels) { + const bucket = nodesByLevel.get(lvl) || []; + bucket.sort((a, b) => Number(a.id) - Number(b.id)); + for (let i = 0; i < bucket.length; i++) { + bucket[i].pos = [lvl * 220, i * 110]; + } + } + + // Convert edges to litegraph-style links array. + // [id, origin_id, origin_slot, target_id, target_slot, type] + let linkId = 1; + const links = edges + .filter(([a, b]) => a !== b) + .slice(0, 4000) + .map(([a, b]) => [linkId++, Number.isFinite(Number(a)) ? Number(a) : a, 0, Number.isFinite(Number(b)) ? Number(b) : b, 0, "LINK"]); + + return { id: "synthetic", nodes, links, extra: { synthetic: true } }; +} diff --git a/js/entry.js b/js/entry.js new file mode 100644 index 00000000..f06752b8 --- /dev/null +++ b/js/entry.js @@ -0,0 +1,151 @@ +/** + * Entry Point - Majoor Assets Manager + * Registers extension with ComfyUI and mounts UI. + */ + +import { app } from "../../scripts/app.js"; +import { testAPI, triggerStartupScan } from "./app/bootstrap.js"; +import { ensureStyleLoaded } from "./app/style.js"; +import { registerMajoorSettings } from "./app/settings.js"; +import { initDragDrop } from "./features/dnd/DragDrop.js"; +import { loadAssets, upsertAsset, captureAnchor, restoreAnchor, isAtBottom, scrollToBottom } from "./features/grid/GridView.js"; +import { renderAssetsManager, getActiveGridContainer } from "./features/panel/AssetsManagerPanel.js"; +import { extractOutputFiles } from "./utils/extractOutputFiles.js"; +import { post, get } from "./api/client.js"; +import { ENDPOINTS } from "./api/endpoints.js"; +import { buildListURL } from "./api/endpoints.js"; + +const UI_FLAGS = { + useComfyThemeUI: true, +}; + +function isObservabilityEnabled() { + try { + const raw = localStorage?.getItem?.("mjrSettings"); + if (!raw) return false; + const parsed = JSON.parse(raw); + return !!parsed?.observability?.enabled; + } catch { + return false; + } +} + +app.registerExtension({ + name: "Majoor.AssetsManager", + + async setup() { + testAPI(); + ensureStyleLoaded({ enabled: UI_FLAGS.useComfyThemeUI }); + initDragDrop(); + + registerMajoorSettings(app, () => { + const grid = getActiveGridContainer(); + if (grid) loadAssets(grid); + }); + + triggerStartupScan(); + + const api = app.api || (app.ui ? app.ui.api : null); + if (api) { + // Avoid double-binding when ComfyUI reloads extensions. + try { + if (api._mjrExecutedHandler) { + api.removeEventListener("executed", api._mjrExecutedHandler); + } + } catch {} + + api._mjrExecutedHandler = async (event) => { + const output = event.detail?.output; + const grid = getActiveGridContainer(); + if (!output || !grid) return; + + const hasMedia = output.images || output.gifs || output.videos; + if (!hasMedia) return; + + console.log("📂 Majoor [â„šī¸] New media generated, refreshing grid..."); + + setTimeout(async () => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const files = extractOutputFiles(output); + if (!files.length) return; + + await post(ENDPOINTS.INDEX_FILES, { files, incremental: true }); + + const latestGrid = getActiveGridContainer(); + if (latestGrid) { + // Use incremental update instead of full reload + const anchor = captureAnchor(latestGrid); + + // Get assets for the specific files that were just generated + const generatedFiles = files.map(f => f.filename); // Use the files we know were just generated + const scope = latestGrid.dataset?.mjrScope || "output"; + const customRootId = latestGrid.dataset?.mjrCustomRootId || ""; + const subfolder = latestGrid.dataset?.mjrSubfolder || ""; + const kind = latestGrid.dataset?.mjrFilterKind || ""; + const workflowOnly = latestGrid.dataset?.mjrFilterWorkflowOnly === "1"; + const minRating = Number(latestGrid.dataset?.mjrFilterMinRating || 0) || 0; + + // For each generated file, try to find the corresponding asset + for (const file of generatedFiles) { + // Build a query specifically for this file + const url = buildListURL({ + q: file, // Search specifically for this filename + limit: 1, + offset: 0, + scope, + subfolder, + customRootId: customRootId || null, + kind: kind || null, + hasWorkflow: workflowOnly ? true : null, + minRating: minRating > 0 ? minRating : null, + includeTotal: false + }); + + const result = await get(url); + if (result.ok && result.data?.assets?.length > 0) { + const asset = result.data.assets[0]; + upsertAsset(latestGrid, asset); + } + } + + // Check if user is at bottom to decide whether to follow newest + if (isAtBottom(latestGrid)) { + // If user is at bottom, scroll to bottom after upserting all assets + + // Wait for layout to stabilize before scrolling + await new Promise(r => requestAnimationFrame(() => r())); + await new Promise(r => requestAnimationFrame(() => r())); + scrollToBottom(latestGrid); + } else { + // If user is not at bottom, preserve scroll position + await restoreAnchor(latestGrid, anchor); + } + } + }, 500); + }; + api.addEventListener("executed", api._mjrExecutedHandler); + + console.log("📂 Majoor [✅] Real-time listener registered"); + } else { + console.warn("📂 Majoor [âš ī¸] API not available, real-time updates disabled"); + } + + if (app.extensionManager?.registerSidebarTab) { + app.extensionManager.registerSidebarTab({ + id: "majoor-assets", + icon: "pi pi-folder", + title: "Assets Manager", + tooltip: "Majoor Assets Manager - Browse and search your outputs", + type: "custom", + render: (el) => { + void renderAssetsManager(el, { useComfyThemeUI: UI_FLAGS.useComfyThemeUI }); + }, + }); + + console.log("📂 Majoor Assets Manager: Sidebar tab registered"); + } else { + console.warn("📂 Majoor Assets Manager: extensionManager.registerSidebarTab is unavailable"); + } + }, +}); diff --git a/js/extension.js.old b/js/extension.js.old new file mode 100644 index 00000000..05498189 --- /dev/null +++ b/js/extension.js.old @@ -0,0 +1,420 @@ +/** + * Majoor Assets Manager - ComfyUI Extension + */ + +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +// Test API connection +async function testAPI() { + try { + console.log("📂 Majoor: Testing API connection..."); + const response = await fetch("/mjr/am/health"); + const data = await response.json(); + + if (data.ok) { + console.log("📂 Majoor [✅]: API connection successful!"); + console.log("📂 Majoor [â„šī¸]: Health status:", data.data.overall); + } else { + console.error("📂 Majoor [❌]: API health check failed:", data.error); + } + } catch (error) { + console.error("📂 Majoor [❌]: Failed to connect to API:", error); + } +} + +// Get ComfyUI output directory +let outputDirectory = null; +async function getOutputDirectory() { + if (outputDirectory) return outputDirectory; + + try { + const response = await fetch("/mjr/am/config"); + const result = await response.json(); + if (result.ok) { + outputDirectory = result.data.output_directory; + console.log("📂 Majoor [â„šī¸]: Output directory:", outputDirectory); + return outputDirectory; + } + } catch (error) { + console.error("📂 Majoor [❌]: Failed to get output directory:", error); + } + return "output"; // fallback +} + +// Auto-scan on first render (once per session) +let autoScanDone = false; +async function triggerAutoScan() { + if (autoScanDone) return; + autoScanDone = true; + + try { + console.log("📂 Majoor [â„šī¸]: Starting auto-scan of output directory..."); + const response = await fetch("/mjr/am/scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + recursive: true, + incremental: true + }) + }); + + const result = await response.json(); + if (result.ok) { + const stats = result.data; + console.log(`📂 Majoor [✅]: Auto-scan complete! Added: ${stats.added}, Updated: ${stats.updated}, Skipped: ${stats.skipped}`); + } else { + console.warn("📂 Majoor [âš ī¸]: Auto-scan failed:", result.error); + } + } catch (error) { + console.error("📂 Majoor [❌]: Auto-scan error:", error); + } +} + +// Render the assets manager UI +async function renderAssetsManager(container) { + // Trigger auto-scan on first render + triggerAutoScan(); + + container.innerHTML = ""; + container.style.cssText = ` + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + `; + + // Header + const header = document.createElement("div"); + header.style.cssText = ` + padding: 15px; + border-bottom: 1px solid var(--border-color, #444); + background: var(--comfy-menu-bg, #333); + `; + header.innerHTML = ` +

📂 Majoor Assets Manager

+ `; + + // Content + const content = document.createElement("div"); + content.style.cssText = ` + flex: 1; + padding: 20px; + overflow-y: auto; + `; + + // Status indicator section + const statusSection = document.createElement("div"); + statusSection.style.cssText = "margin-bottom: 20px; padding: 12px; background: var(--bg-color, #1a1a1a); border-radius: 6px; border: 1px solid var(--border-color, #333);"; + statusSection.innerHTML = ` +
+ + Index Status +
+
+ Checking... +
+ `; + + // Search section + const searchSection = document.createElement("div"); + searchSection.style.cssText = "margin-bottom: 20px;"; + searchSection.innerHTML = ` +

Search Assets

+
+ + +
+ `; + + // Browse section with grid + const browseSection = document.createElement("div"); + browseSection.style.cssText = "margin-top: 20px;"; + browseSection.innerHTML = ` +
+

Browse All Assets

+ +
+
+ `; + + content.appendChild(statusSection); + content.appendChild(searchSection); + content.appendChild(browseSection); + container.appendChild(header); + container.appendChild(content); + + // Setup status indicator + const statusDot = container.querySelector("#mjr-status-dot"); + const statusText = container.querySelector("#mjr-status-text"); + + async function updateStatus() { + try { + const response = await fetch("/mjr/am/health/counters"); + const result = await response.json(); + + if (result.ok) { + const counters = result.data; + const totalAssets = counters.total_assets || 0; + + if (totalAssets === 0) { + // No assets indexed - yellow + statusDot.style.background = "#FFA726"; + statusText.textContent = "No assets indexed yet"; + } else { + // Assets indexed - green + statusDot.style.background = "#4CAF50"; + statusText.innerHTML = ` + ${totalAssets.toLocaleString()} assets indexed
+ Images: ${counters.images || 0} â€ĸ Videos: ${counters.videos || 0}
+ With workflows: ${counters.with_workflows || 0} + `; + } + } else { + // Error - red + statusDot.style.background = "#f44336"; + statusText.textContent = "Error checking status"; + } + } catch (error) { + // Error - red + statusDot.style.background = "#f44336"; + statusText.textContent = "Failed to connect"; + } + } + + // Update status on load and every 5 seconds + updateStatus(); + setInterval(updateStatus, 5000); + + // Setup search + const searchInput = container.querySelector("#mjr-search-input"); + const searchBtn = container.querySelector("#mjr-search-btn"); + const assetsGrid = container.querySelector("#mjr-assets-grid"); + const loadAllBtn = container.querySelector("#mjr-load-all-btn"); + + // Function to render asset card + const renderAssetCard = (asset) => { + const card = document.createElement("div"); + card.style.cssText = ` + background: var(--bg-color, #222); + border: 1px solid var(--border-color, #444); + border-radius: 4px; + overflow: hidden; + cursor: pointer; + transition: transform 0.2s, border-color 0.2s; + `; + card.onmouseenter = () => { + card.style.transform = "scale(1.05)"; + card.style.borderColor = "#2196F3"; + }; + card.onmouseleave = () => { + card.style.transform = "scale(1)"; + card.style.borderColor = "var(--border-color, #444)"; + }; + + // Thumbnail (placeholder or actual image) + const thumb = document.createElement("div"); + thumb.style.cssText = ` + width: 100%; + padding-top: 100%; + background: #1a1a1a; + position: relative; + overflow: hidden; + `; + + if (asset.kind === "image") { + const img = document.createElement("img"); + const viewUrl = `/view?filename=${encodeURIComponent(asset.filename)}${asset.subfolder ? '&subfolder=' + encodeURIComponent(asset.subfolder) : ''}&type=output`; + img.src = viewUrl; + img.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + `; + thumb.appendChild(img); + } else if (asset.kind === "video") { + const videoIcon = document.createElement("div"); + videoIcon.textContent = "đŸŽŦ"; + videoIcon.style.cssText = ` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 32px; + `; + thumb.appendChild(videoIcon); + } + + // Info section + const info = document.createElement("div"); + info.style.cssText = ` + padding: 8px; + font-size: 11px; + `; + info.innerHTML = ` +
+ ${asset.filename} +
+
+ ${asset.kind}${asset.has_workflow ? ' â€ĸ ⚡' : ''} +
+ `; + + card.appendChild(thumb); + card.appendChild(info); + + // Click to open viewer + card.onclick = () => { + const viewUrl = `/view?filename=${encodeURIComponent(asset.filename)}${asset.subfolder ? '&subfolder=' + encodeURIComponent(asset.subfolder) : ''}&type=output`; + window.open(viewUrl, '_blank'); + }; + + return card; + }; + + // Load all assets + const loadAllAssets = async () => { + try { + loadAllBtn.disabled = true; + loadAllBtn.textContent = "Loading..."; + assetsGrid.innerHTML = '

Loading assets...

'; + + // Use search with wildcard to get all assets + const response = await fetch('/mjr/am/search?q=*&limit=100'); + const result = await response.json(); + + if (result.ok) { + const assets = result.data.assets; + if (assets.length === 0) { + assetsGrid.innerHTML = '

No assets found

'; + } else { + assetsGrid.innerHTML = ''; + assets.forEach(asset => { + assetsGrid.appendChild(renderAssetCard(asset)); + }); + } + } else { + assetsGrid.innerHTML = `

Error: ${result.error}

`; + } + } catch (error) { + assetsGrid.innerHTML = `

Failed to load assets: ${error.message}

`; + } finally { + loadAllBtn.disabled = false; + loadAllBtn.textContent = "Refresh"; + } + }; + + // Search functionality + const performSearch = async () => { + const query = searchInput.value.trim(); + if (!query) { + assetsGrid.innerHTML = '

Enter a search term or click "Load Assets"

'; + return; + } + + try { + assetsGrid.innerHTML = '

Searching...

'; + + const response = await fetch(`/mjr/am/search?q=${encodeURIComponent(query)}&limit=50`); + const result = await response.json(); + + if (result.ok) { + const assets = result.data.assets; + if (assets.length === 0) { + assetsGrid.innerHTML = '

No results found

'; + } else { + assetsGrid.innerHTML = ''; + assets.forEach(asset => { + assetsGrid.appendChild(renderAssetCard(asset)); + }); + } + } else { + assetsGrid.innerHTML = `

Error: ${result.error}

`; + } + } catch (error) { + assetsGrid.innerHTML = `

Search failed: ${error.message}

`; + } + }; + + loadAllBtn.onclick = loadAllAssets; + searchBtn.onclick = performSearch; + searchInput.onkeypress = (e) => { + if (e.key === "Enter") performSearch(); + }; + + // Auto-load assets on startup if there are any indexed + setTimeout(async () => { + const response = await fetch("/mjr/am/health/counters"); + const result = await response.json(); + if (result.ok && result.data.total_assets > 0) { + loadAllAssets(); + } + }, 1000); +} + +// Register extension +app.registerExtension({ + name: "Majoor.AssetsManager", + + async setup() { + console.log("📂 Majoor: Extension loaded"); + await testAPI(); + + // Register sidebar tab using ComfyUI's modern API + if (app.extensionManager?.registerSidebarTab) { + try { + app.extensionManager.registerSidebarTab({ + id: "majoorAssetsManager", + icon: "pi pi-folder-open", + title: "Assets Manager", + tooltip: "Majoor Assets Manager", + type: "custom", + render: (el) => renderAssetsManager(el), + }); + console.log("📂 Majoor [✅]: Sidebar tab registered"); + } catch (err) { + console.error("📂 Majoor [❌]: Sidebar registration failed", err); + } + } else { + console.warn("📂 Majoor [âš ī¸]: extensionManager.registerSidebarTab not available"); + } + } +}); diff --git a/js/features/contextmenu/GridContextMenu.js b/js/features/contextmenu/GridContextMenu.js new file mode 100644 index 00000000..3af7ac73 --- /dev/null +++ b/js/features/contextmenu/GridContextMenu.js @@ -0,0 +1,417 @@ +/** + * Grid Context Menu - Right-click menu for asset cards + */ + +import { buildAssetViewURL } from "../../api/endpoints.js"; +import { comfyAlert, comfyConfirm, comfyPrompt } from "../../app/dialogs.js"; +import { openInFolder, updateAssetRating, deleteAsset, renameAsset } from "../../api/client.js"; +import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js"; +import { createTagsEditor } from "../../components/TagsEditor.js"; +import { safeDispatchCustomEvent } from "../../utils/events.js"; +// Bulk operations removed - using local helpers +const getSelectedAssetIds = (gridContainer) => { + const selected = new Set(); + if (!gridContainer) return selected; + try { + const cards = gridContainer.querySelectorAll('.mjr-asset-card.is-selected'); + for (const card of cards) { + const id = card.dataset?.mjrAssetId; + if (id) selected.add(String(id)); + } + } catch {} + return selected; +}; + +const clearSelection = (gridContainer) => { + if (!gridContainer) return; + try { + const cards = gridContainer.querySelectorAll('.mjr-asset-card.is-selected'); + for (const card of cards) { + card.classList.remove('is-selected'); + card.setAttribute('aria-selected', 'false'); + } + } catch {} +}; + +const MENU_SELECTOR = ".mjr-grid-context-menu"; +const POPOVER_SELECTOR = ".mjr-grid-popover"; + +function createMenu() { + const existing = document.querySelector(MENU_SELECTOR); + if (existing) return existing; + + const menu = document.createElement("div"); + menu.className = "mjr-grid-context-menu mjr-context-menu"; + menu.style.cssText = ` + position: fixed; + background: var(--comfy-menu-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + min-width: 220px; + z-index: 10001; + display: none; + padding: 4px 0; + `; + + const ac = new AbortController(); + menu._mjrAbortController = ac; + + const hide = () => { + menu.style.display = "none"; + try { + const pop = document.querySelector(POPOVER_SELECTOR); + pop?.remove?.(); + } catch {} + }; + + document.addEventListener( + "click", + (e) => { + if (!menu.contains(e.target)) hide(); + }, + { signal: ac.signal, capture: true } + ); + document.addEventListener( + "keydown", + (e) => { + if (e.key === "Escape") hide(); + }, + { signal: ac.signal, capture: true } + ); + document.addEventListener("scroll", hide, { capture: true, signal: ac.signal }); + + document.body.appendChild(menu); + return menu; +} + +function createItem(label, iconClass, rightHint, onClick, { disabled = false } = {}) { + const item = document.createElement("div"); + item.className = "mjr-context-menu-item"; + item.style.cssText = ` + padding: 8px 14px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: ${disabled ? "default" : "pointer"}; + color: var(--fg-color); + font-size: 13px; + opacity: ${disabled ? "0.45" : "1"}; + transition: background 0.15s; + user-select: none; + `; + + const left = document.createElement("div"); + left.style.cssText = "display:flex; align-items:center; gap:10px;"; + if (iconClass) { + const iconEl = document.createElement("i"); + iconEl.className = iconClass; + iconEl.style.cssText = "width:16px; text-align:center; opacity:0.8;"; + left.appendChild(iconEl); + } + const labelEl = document.createElement("span"); + labelEl.textContent = label; + left.appendChild(labelEl); + item.appendChild(left); + + if (rightHint) { + const hint = document.createElement("span"); + hint.textContent = rightHint; + hint.style.cssText = "font-size: 11px; opacity: 0.55; margin-left: 16px;"; + item.appendChild(hint); + } + + item.addEventListener("mouseenter", () => { + if (!disabled) item.style.background = "var(--comfy-input-bg)"; + }); + item.addEventListener("mouseleave", () => { + item.style.background = "transparent"; + }); + + item.addEventListener("click", async (e) => { + e.stopPropagation(); + e.preventDefault(); + if (disabled) return; + try { + await onClick?.(); + } catch {} + }); + + return item; +} + +function separator() { + const s = document.createElement("div"); + s.style.cssText = "height:1px;background:var(--border-color);margin:4px 0;"; + return s; +} + +function showAt(menu, x, y) { + menu.style.display = "block"; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + + const rect = menu.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let fx = x; + let fy = y; + if (x + rect.width > vw) fx = vw - rect.width - 10; + if (y + rect.height > vh) fy = vh - rect.height - 10; + + menu.style.left = `${Math.max(8, fx)}px`; + menu.style.top = `${Math.max(8, fy)}px`; +} + +function showTagsPopover(x, y, asset, onChanged) { + try { + const existing = document.querySelector(POPOVER_SELECTOR); + existing?.remove?.(); + } catch {} + + const pop = document.createElement("div"); + pop.className = "mjr-grid-popover"; + pop.style.cssText = ` + position: fixed; + z-index: 10002; + left: ${x}px; + top: ${y}px; + `; + + const editor = createTagsEditor(asset, (tags) => { + asset.tags = tags; + safeDispatchCustomEvent( + ASSET_TAGS_CHANGED_EVENT, + { assetId: String(asset.id), tags }, + { warnPrefix: "[GridContextMenu]" } + ); + try { + onChanged?.(); + } catch {} + }); + pop.appendChild(editor); + document.body.appendChild(pop); + + // Clamp into viewport + const rect = pop.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + let fx = x; + let fy = y; + if (x + rect.width > vw) fx = vw - rect.width - 10; + if (y + rect.height > vh) fy = vh - rect.height - 10; + pop.style.left = `${Math.max(8, fx)}px`; + pop.style.top = `${Math.max(8, fy)}px`; +} + +async function setRating(asset, rating, onChanged) { + const assetId = asset?.id; + if (!assetId) return; + try { + const result = await updateAssetRating(assetId, rating); + if (!result?.ok) return; + asset.rating = rating; + safeDispatchCustomEvent( + ASSET_RATING_CHANGED_EVENT, + { assetId: String(assetId), rating }, + { warnPrefix: "[GridContextMenu]" } + ); + onChanged?.(); + } catch {} +} + +export function bindGridContextMenu({ + gridContainer, + getState = () => ({}), + onRequestOpenViewer = () => {}, +} = {}) { + if (!gridContainer) return; + if (gridContainer._mjrGridContextMenuBound) return; + gridContainer._mjrGridContextMenuBound = true; + + const menu = createMenu(); + + gridContainer.addEventListener("contextmenu", async (e) => { + // Check if the contextmenu event is on an asset card + const card = e.target.closest(".mjr-asset-card"); + if (!card) return; + + e.preventDefault(); + e.stopPropagation(); + + const asset = card._mjrAsset; + if (!asset) return; + + menu.innerHTML = ""; + + // Get current selection state + const selectedIds = getSelectedAssetIds(gridContainer); + const isSingleSelected = selectedIds.size === 1 && selectedIds.has(String(asset.id)); + const isMultiSelected = selectedIds.size > 1; + const hasSelection = selectedIds.size > 0; + + // Open Viewer + menu.appendChild( + createItem("Open Viewer", "pi pi-image", null, () => { + onRequestOpenViewer(asset); + }) + ); + + // Open in Folder + menu.appendChild( + createItem("Open in Folder", "pi pi-folder-open", null, async () => { + if (!asset?.id) return; + const res = await openInFolder(asset.id); + if (!res?.ok) { + await comfyAlert(res?.error || "Failed to open folder."); + } + }, { disabled: !asset?.id }) + ); + + // Copy file path + menu.appendChild( + createItem("Copy file path", "pi pi-copy", null, async () => { + const p = asset?.filepath ? String(asset.filepath) : ""; + if (!p) { + await comfyAlert("No file path available for this asset."); + return; + } + try { + await navigator.clipboard.writeText(p); + } catch {} + }) + ); + + menu.appendChild(separator()); + + // Edit Tags + menu.appendChild(createItem("Edit Tagsâ€Ļ", "pi pi-tags", null, () => { + showTagsPopover(e.clientX + 6, e.clientY + 6, asset, () => { + // Update card UI after tags change + const card = document.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(asset.id)) : String(asset.id)}"]`); + if (card && card._mjrAsset) { + card._mjrAsset.tags = [...asset.tags]; + } + }); + })); + + // Rating submenu + menu.appendChild(separator()); + const ratingLabel = (n) => (n === 0 ? "Reset rating" : `Set rating: ${"★".repeat(n)}`); + for (const n of [5, 4, 3, 2, 1, 0]) { + menu.appendChild( + createItem(ratingLabel(n), "pi pi-star", n ? String(n) : "0", () => setRating(asset, n, () => { + // Update card UI after rating change + const card = document.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(asset.id)) : String(asset.id)}"]`); + if (card && card._mjrAsset) { + card._mjrAsset.rating = n; + } + }), { + disabled: !asset?.id + }) + ); + } + + menu.appendChild(separator()); + + // Rename (single only) + menu.appendChild( + createItem("Renameâ€Ļ", "pi pi-pencil", null, async () => { + if (!asset?.id) return; + + const currentName = asset.filename || ""; + const newName = await comfyPrompt("Rename file", currentName); + if (!newName || newName === currentName) return; + + try { + const result = await renameAsset(asset.id, newName); + if (result?.ok) { + // Update the asset object and card UI + asset.filename = newName; + asset.filepath = asset.filepath.replace(/[^\\/]+$/, newName); + + // Update card UI + const card = document.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(asset.id)) : String(asset.id)}"]`); + if (card) { + // Update card display + const filenameEl = card.querySelector('.mjr-filename'); + if (filenameEl) { + filenameEl.textContent = newName; + } + } + + await comfyAlert("File renamed successfully!"); + } else { + await comfyAlert(result?.error || "Failed to rename file."); + } + } catch (error) { + await comfyAlert(`Error renaming file: ${error.message}`); + } + }, { disabled: !asset?.id }) + ); + + // Delete (single or multi) + if (isMultiSelected) { + menu.appendChild( + createItem(`Delete ${selectedIds.size} filesâ€Ļ`, "pi pi-trash", null, async () => { + const confirmed = await comfyConfirm(`Delete ${selectedIds.size} files? This cannot be undone.`); + if (!confirmed) return; + + try { + // Delete each asset individually + let successCount = 0; + let errorCount = 0; + + for (const assetId of selectedIds) { + const result = await deleteAsset(assetId); + if (result?.ok) { + successCount++; + // Remove card from DOM + const card = document.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(assetId)) : String(assetId)}"]`); + if (card) card.remove(); + } else { + errorCount++; + } + } + + // Clear selection + clearSelection(gridContainer); + + if (errorCount === 0) { + await comfyAlert(`${successCount} files deleted successfully!`); + } else { + await comfyAlert(`${successCount} files deleted, ${errorCount} failed.`); + } + } catch (error) { + await comfyAlert(`Error deleting files: ${error.message}`); + } + }) + ); + } else { + menu.appendChild( + createItem("Deleteâ€Ļ", "pi pi-trash", null, async () => { + const confirmed = await comfyConfirm("Delete this file? This cannot be undone."); + if (!confirmed) return; + + try { + const result = await deleteAsset(asset.id); + if (result?.ok) { + // Remove the card from DOM + card.remove(); + await comfyAlert("File deleted successfully!"); + } else { + await comfyAlert(result?.error || "Failed to delete file."); + } + } catch (error) { + await comfyAlert(`Error deleting file: ${error.message}`); + } + }, { disabled: !asset?.id }) + ); + } + + // Bulk operations menu removed + + showAt(menu, e.clientX, e.clientY); + }); +} \ No newline at end of file diff --git a/js/features/dnd/DragDrop.js b/js/features/dnd/DragDrop.js new file mode 100644 index 00000000..47243e2f --- /dev/null +++ b/js/features/dnd/DragDrop.js @@ -0,0 +1,506 @@ +/** + * Drag & Drop support for staging assets to input (video-focused). + */ + +import { app } from "../../../../scripts/app.js"; +import { get, post } from "../../api/client.js"; +import { ENDPOINTS, buildCustomViewURL, buildViewURL } from "../../api/endpoints.js"; +import { comfyAlert } from "../../app/dialogs.js"; +import { smartUploadToInput } from "../../utils/fastUpload.js"; + +import { DND_GLOBAL_KEY, DND_INSTANCE_VERSION, DND_MIME } from "./utils/constants.js"; +import { dndLog } from "./utils/log.js"; +import { buildPayloadViewURL, getDraggedAsset } from "./utils/payload.js"; +import { getDownloadMimeForFilename, isVideoPayload } from "./utils/video.js"; +import { isCanvasDropTarget, markCanvasDirty } from "./targets/canvas.js"; +import { + applyHighlight, + clearHighlight, + ensureComboHasValue, + getNodeUnderClientXY, + pickBestVideoPathWidget, + pickBestImagePathWidget +} from "./targets/node.js"; +import { stageToInput, stageToInputDetailed } from "./staging/stageToInput.js"; + +const buildURL = (payload) => + buildPayloadViewURL(payload, { buildCustomViewURL, buildViewURL }); + +// Helper to check if payload is an image +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif']); +const isImagePayload = (payload) => { + if (!payload) return false; + if (payload.kind === "image") return true; + const filename = String(payload.filename || "").toLowerCase(); + const ext = filename.substring(filename.lastIndexOf('.')); + return IMAGE_EXTS.has(ext); +}; + +// Workflow cache: filename -> { workflow, at } +const _workflowCache = new Map(); +const WORKFLOW_CACHE_TTL_MS = 60_000; // 1 minute +const WORKFLOW_CACHE_MAX = 100; + +const cleanupWorkflowCache = () => { + if (_workflowCache.size <= WORKFLOW_CACHE_MAX) return; + const now = Date.now(); + + // Remove expired first + for (const [key, value] of _workflowCache.entries()) { + if (now - (value?.at || 0) > WORKFLOW_CACHE_TTL_MS) { + _workflowCache.delete(key); + } + } + + // If still too large, remove oldest + if (_workflowCache.size > WORKFLOW_CACHE_MAX) { + const entries = Array.from(_workflowCache.entries()) + .sort((a, b) => (a[1]?.at || 0) - (b[1]?.at || 0)); + const toRemove = entries.slice(0, _workflowCache.size - WORKFLOW_CACHE_MAX); + toRemove.forEach(([key]) => _workflowCache.delete(key)); + } +}; + +const tryLoadWorkflowToCanvas = async (payload, fallbackAbsPath = null) => { + const pl = payload && typeof payload === "object" ? payload : null; + + // Check cache first + const cacheKey = pl?.filename + ? `${pl.type || "output"}:${pl.filename}:${pl.subfolder || ""}:${pl.root_id || pl.rootId || pl.custom_root_id || ""}` + : fallbackAbsPath ? `path:${fallbackAbsPath}` : null; + + if (cacheKey) { + const cached = _workflowCache.get(cacheKey); + const now = Date.now(); + + if (cached && now - (cached.at || 0) < WORKFLOW_CACHE_TTL_MS) { + if (cached.workflow) { + try { + if (typeof app?.loadGraphData === "function") { + app.loadGraphData(cached.workflow); + return true; + } + if (typeof app?.canvas?.graph?.configure === "function") { + app.canvas.graph.configure(cached.workflow); + try { + app.canvas.setDirty?.(true, true); + } catch {} + return true; + } + if (typeof app?.graph?.configure === "function") { + app.graph.configure(cached.workflow); + return true; + } + } catch {} + } + // Cached but no workflow (already tried and failed) + return false; + } + } + + try { + // Try ultra-fast workflow-quick endpoint first (direct SQL, no self-heal) + // Falls back to slower metadata endpoint with workflow_only=1 if needed + let url = null; + let workflow = null; + + if (pl?.filename) { + // First: try the fast endpoint (no self-heal, direct SQL) + const quickUrl = `${ENDPOINTS.WORKFLOW_QUICK}?type=${encodeURIComponent(pl.type || "output")}` + + `&filename=${encodeURIComponent(pl.filename)}` + + `&subfolder=${encodeURIComponent(pl.subfolder || "")}` + + (pl.root_id || pl.rootId || pl.custom_root_id ? `&root_id=${encodeURIComponent(pl.root_id || pl.rootId || pl.custom_root_id || "")}` : ""); + + const quickRes = await get(quickUrl); + if (quickRes?.ok && quickRes.workflow) { + workflow = quickRes.workflow; + } + + // Fallback to slower metadata endpoint if quick lookup didn't find it + if (!workflow) { + url = `${ENDPOINTS.METADATA}?workflow_only=1&type=${encodeURIComponent(pl.type || "output")}` + + `&filename=${encodeURIComponent(pl.filename)}` + + `&subfolder=${encodeURIComponent(pl.subfolder || "")}` + + `&root_id=${encodeURIComponent(pl.root_id || pl.rootId || pl.custom_root_id || "")}`; + } + } else if (fallbackAbsPath) { + // For absolute paths, use metadata endpoint (can't use quick lookup) + url = `${ENDPOINTS.METADATA}?workflow_only=1&path=${encodeURIComponent(String(fallbackAbsPath))}`; + } + + // If we need the fallback metadata endpoint + if (!workflow && url) { + const res = await get(url); + if (!res?.ok || !res.data) { + // Cache negative result to avoid retrying + if (cacheKey) { + _workflowCache.set(cacheKey, { workflow: null, at: Date.now() }); + cleanupWorkflowCache(); + } + return false; + } + workflow = res.data?.workflow; + } + + if (!workflow || typeof workflow !== "object") { + // Cache negative result + if (cacheKey) { + _workflowCache.set(cacheKey, { workflow: null, at: Date.now() }); + cleanupWorkflowCache(); + } + return false; + } + + // Cache the workflow + if (cacheKey) { + _workflowCache.set(cacheKey, { workflow, at: Date.now() }); + cleanupWorkflowCache(); + } + + // Prefer ComfyUI helper if available; otherwise use LiteGraph configure. + if (typeof app?.loadGraphData === "function") { + app.loadGraphData(workflow); + return true; + } + if (typeof app?.canvas?.graph?.configure === "function") { + app.canvas.graph.configure(workflow); + try { + app.canvas.setDirty?.(true, true); + } catch {} + return true; + } + if (typeof app?.graph?.configure === "function") { + app.graph.configure(workflow); + return true; + } + } catch {} + return false; +}; + +export const enableAssetDrag = (cardEl, asset) => { + if (!cardEl) return; + // Deprecated for grid rendering: prefer `bindAssetDragStart(container)` so we don't + // attach a dragstart handler per card (large perf win for big grids). + cardEl.draggable = true; + cardEl.addEventListener("dragstart", (event) => { + if (!event.dataTransfer) return; + const type = String(asset?.type || "output").toLowerCase(); + const payload = { + filename: asset.filename, + subfolder: asset.subfolder || "", + type, + root_id: asset?.root_id || asset?.rootId || asset?.custom_root_id || undefined, + kind: asset.kind + }; + event.dataTransfer.setData(DND_MIME, JSON.stringify(payload)); + event.dataTransfer.setData("text/plain", asset.filename); + + const viewUrl = buildURL(payload); + event.dataTransfer.setData("text/uri-list", viewUrl); + + // Add download URL for drag-out to Windows Explorer + const downloadUrl = `${ENDPOINTS.DOWNLOAD}?asset_id=${asset.id || ''}&filename=${encodeURIComponent(asset.filename || '')}`; + const mime = getDownloadMimeForFilename(asset.filename); + event.dataTransfer.setData("DownloadURL", `${mime}:${asset.filename}:${downloadUrl}`); + event.dataTransfer.effectAllowed = "copy"; + + const preview = cardEl.querySelector("img") || cardEl.querySelector("video"); + if (preview && preview instanceof HTMLElement) { + event.dataTransfer.setDragImage(preview, 10, 10); + } + }); +}; + +export const bindAssetDragStart = (containerEl) => { + if (!containerEl) return; + if (containerEl._mjrAssetDragStartBound) return; + containerEl._mjrAssetDragStartBound = true; + + containerEl.addEventListener( + "dragstart", + (event) => { + const dt = event?.dataTransfer; + if (!dt) return; + const card = event?.target?.closest?.(".mjr-asset-card"); + if (!card) return; + + const asset = card._mjrAsset; + if (!asset || typeof asset !== "object") return; + + const type = String(asset?.type || "output").toLowerCase(); + const payload = { + filename: asset.filename, + subfolder: asset.subfolder || "", + type, + root_id: asset?.root_id || asset?.rootId || asset?.custom_root_id || undefined, + kind: asset.kind + }; + + try { + dt.setData(DND_MIME, JSON.stringify(payload)); + dt.setData("text/plain", String(asset.filename || "")); + const viewUrl = buildURL(payload); + dt.setData("text/uri-list", viewUrl); + + // Add download URL for drag-out to Windows Explorer + const downloadUrl = `${ENDPOINTS.DOWNLOAD}?asset_id=${asset.id || ''}&filename=${encodeURIComponent(asset.filename || '')}`; + const mime = getDownloadMimeForFilename(asset.filename); + dt.setData("DownloadURL", `${mime}:${asset.filename}:${downloadUrl}`); + dt.effectAllowed = "copy"; + } catch {} + + const preview = card.querySelector("img") || card.querySelector("video"); + if (preview && preview instanceof HTMLElement) { + try { + dt.setDragImage(preview, 10, 10); + } catch {} + } + }, + true + ); +}; + +export const initDragDrop = () => { + const existing = (() => { + try { + return window?.[DND_GLOBAL_KEY] || null; + } catch { + return null; + } + })(); + + // If a previous version is already installed, remove it and replace with the current one. + if (existing?.initialized) { + if (existing.version === DND_INSTANCE_VERSION) { + return existing.dispose || (() => {}); + } + try { + existing.dispose?.({ force: true }); + } catch {} + } + + const onDragOver = (event) => { + const types = Array.from(event?.dataTransfer?.types || []); + if (!types.includes(DND_MIME)) return; + const payload = getDraggedAsset(event, DND_MIME); + if (!payload) return; + + // Route to appropriate widget picker based on payload type + const node = getNodeUnderClientXY(app, event.clientX, event.clientY); + const droppedExt = String(payload?.filename || "").split(".").pop() || ""; + let widget = null; + + if (isVideoPayload(payload)) { + widget = node ? pickBestVideoPathWidget(node, droppedExt) : null; + } else if (isImagePayload(payload)) { + widget = node ? pickBestImagePathWidget(node, droppedExt) : null; + } + + if (node && widget) { + event.preventDefault(); + event.stopImmediatePropagation?.(); + event.stopPropagation(); + applyHighlight(app, node, markCanvasDirty); + event.dataTransfer.dropEffect = "copy"; + dndLog("dragover", { node: node?.title, widget: widget?.name }); + return; + } + + clearHighlight(app, markCanvasDirty); + if (!isCanvasDropTarget(app, event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }; + + const onDrop = async (event) => { + const types = Array.from(event?.dataTransfer?.types || []); + + // Check if this is a file drop from OS (not from our asset manager) + if (types.includes("Files")) { + // Handle direct file drop from OS + event.preventDefault(); + event.stopImmediatePropagation?.(); + event.stopPropagation(); + + const files = Array.from(event.dataTransfer.files || []); + if (files.length === 0) return; + + // Process the first file (we typically handle one at a time) + const file = files[0]; + + try { + // Show immediate feedback + const node = getNodeUnderClientXY(app, event.clientX, event.clientY); + if (node) { + // Set widget to "Uploadingâ€Ļ" placeholder + const droppedExt = String(file.name || "").split(".").pop() || ""; + // Route to appropriate widget picker based on file type + const filename = String(file.name || "").toLowerCase(); + const ext = filename.substring(filename.lastIndexOf('.')); + let widget = null; + if (IMAGE_EXTS.has(ext)) { + widget = pickBestImagePathWidget(node, droppedExt); + } else { + widget = pickBestVideoPathWidget(node, droppedExt); + } + + if (widget) { + const oldValue = widget.value; + widget.value = "Uploadingâ€Ļ"; + try { + widget.callback?.(widget.value); + } catch {} + + // Upload the file using the fast path + const result = await smartUploadToInput(file, "node_drop"); + const fileName = result.name || file.name; + + // Update widget with the actual filename + if (widget.type === "combo") ensureComboHasValue(widget, fileName); + widget.value = fileName; + try { + widget.callback?.(widget.value); + } catch {} + + markCanvasDirty(app); + dndLog("file drop inject", { node: node?.title, widget: widget?.name, value: fileName }); + } + } + } catch (error) { + console.error("Majoor: File upload failed", error); + dndLog("file drop failed", { error: error.message }); + } + return; + } + + // Original logic for assets dragged from our manager + if (!types.includes(DND_MIME)) return; + const payload = getDraggedAsset(event, DND_MIME); + if (!payload) return; + if (!isCanvasDropTarget(app, event)) return; + + const node = getNodeUnderClientXY(app, event.clientX, event.clientY); + const droppedExt = String(payload?.filename || "").split(".").pop() || ""; + + // Route to appropriate widget picker based on payload type + let widget = null; + if (isVideoPayload(payload)) { + widget = node ? pickBestVideoPathWidget(node, droppedExt) : null; + } else if (isImagePayload(payload)) { + widget = node ? pickBestImagePathWidget(node, droppedExt) : null; + } + + if (!node || !widget) { + clearHighlight(app, markCanvasDirty); + event.preventDefault(); + event.stopImmediatePropagation?.(); + event.stopPropagation(); + + // Run staging + workflow extraction in TRUE parallel for best UX. + const stagePromise = stageToInputDetailed({ + post, + endpoint: ENDPOINTS.STAGE_TO_INPUT, + payload, + index: false, + purpose: "node_drop" // Fast path - skip indexing for node drops + }); + const workflowPromise = tryLoadWorkflowToCanvas(payload); + + // Execute both in parallel (max performance) + const [loaded, staged] = await Promise.all([workflowPromise, stagePromise]); + + if (loaded) { + dndLog("drop canvas loaded workflow", { file: payload?.filename }); + // Staging has already completed in parallel; no further UI needed here. + return; + } + + const relativePath = staged?.relativePath; + if (!relativePath) { + dndLog("drop canvas stage failed"); + return; + } + dndLog("drop canvas staged", { value: relativePath }); + await comfyAlert( + `Staged to input: ${relativePath}\n\nTip: drop onto a video node to inject the path automatically.`, + "Majoor" + ); + return; + } + + event.preventDefault(); + event.stopImmediatePropagation?.(); + event.stopPropagation(); + clearHighlight(app, markCanvasDirty); + + const relativePath = await stageToInput({ + post, + endpoint: ENDPOINTS.STAGE_TO_INPUT, + payload, + index: false, + purpose: "node_drop" // Fast path - skip indexing for node drops + }); + if (!relativePath) return; + + if (widget.type === "combo") ensureComboHasValue(widget, relativePath); + widget.value = relativePath; + try { + widget.callback?.(widget.value); + } catch {} + + markCanvasDirty(app); + dndLog("drop inject", { node: node?.title, widget: widget?.name, value: relativePath }); + }; + + const onDragLeave = () => { + clearHighlight(app, markCanvasDirty); + dndLog("dragleave"); + }; + + window.addEventListener("dragover", onDragOver, true); + window.addEventListener("drop", onDrop, true); + window.addEventListener("dragleave", onDragLeave, true); + + const dispose = ({ force = false } = {}) => { + const state = (() => { + try { + return window?.[DND_GLOBAL_KEY] || null; + } catch { + return null; + } + })(); + + // Only uninstall if this instance is still the active one. + if (!force && state?.dispose !== dispose) return; + + try { + window.removeEventListener("dragover", onDragOver, true); + window.removeEventListener("drop", onDrop, true); + window.removeEventListener("dragleave", onDragLeave, true); + } catch {} + + try { + clearHighlight(app, markCanvasDirty); + } catch {} + + try { + if (window?.[DND_GLOBAL_KEY]?.dispose === dispose) { + delete window[DND_GLOBAL_KEY]; + } + } catch {} + }; + + try { + window[DND_GLOBAL_KEY] = { + initialized: true, + version: DND_INSTANCE_VERSION, + dispose + }; + } catch {} + + return dispose; +}; + +export const DND_CONSTANTS = { + DND_MIME +}; diff --git a/js/features/dnd/staging/stageToInput.js b/js/features/dnd/staging/stageToInput.js new file mode 100644 index 00000000..9eb5d016 --- /dev/null +++ b/js/features/dnd/staging/stageToInput.js @@ -0,0 +1,45 @@ +const _postStageToInput = async ({ post, endpoint, payload, index = true, purpose = null }) => { + const request = { + index: Boolean(index), + files: [ + { + filename: payload.filename, + subfolder: payload.subfolder || "", + dest_subfolder: "", + type: payload.type || "output", + root_id: payload.root_id || payload.custom_root_id || payload.rootId + } + ] + }; + + // Add purpose if specified (for fast path detection) + if (purpose) { + request.purpose = purpose; + } + + return post(endpoint, request); +}; + +export const stageToInputDetailed = async ({ post, endpoint, payload, index = true, purpose = null }) => { + const result = await _postStageToInput({ post, endpoint, payload, index, purpose }); + + if (!result?.ok) { + console.warn("Majoor: stage-to-input failed", result?.error || result); + return null; + } + + const staged = Array.isArray(result.data?.staged) ? result.data.staged[0] : null; + if (!staged) return null; + const relativePath = staged?.subfolder ? `${staged.subfolder}/${staged.name}` : staged?.name; + return { + relativePath: relativePath || null, + absPath: staged?.path || null, + name: staged?.name || null, + subfolder: staged?.subfolder || "" + }; +}; + +export const stageToInput = async ({ post, endpoint, payload, index = true, purpose = null }) => { + const detailed = await stageToInputDetailed({ post, endpoint, payload, index, purpose }); + return detailed?.relativePath || null; +}; diff --git a/js/features/dnd/targets/canvas.js b/js/features/dnd/targets/canvas.js new file mode 100644 index 00000000..441b1783 --- /dev/null +++ b/js/features/dnd/targets/canvas.js @@ -0,0 +1,23 @@ +export const getCanvasRect = (app) => { + const canvas = app?.canvas?.canvas || document.querySelector("canvas"); + if (!canvas) return null; + return canvas.getBoundingClientRect(); +}; + +export const isCanvasDropTarget = (app, event) => { + const rect = getCanvasRect(app); + if (!rect) return false; + const x = event.clientX; + const y = event.clientY; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +}; + +export const markCanvasDirty = (app) => { + try { + app?.graph?.setDirtyCanvas?.(true, true); + } catch {} + try { + app?.canvas?.setDirty?.(true, true); + } catch {} +}; + diff --git a/js/features/dnd/targets/node.js b/js/features/dnd/targets/node.js new file mode 100644 index 00000000..a3c72246 --- /dev/null +++ b/js/features/dnd/targets/node.js @@ -0,0 +1,247 @@ +import { comboHasAnyVideoValue, looksLikeVideoPath } from "../utils/video.js"; + +let highlightedNode = null; +let highlightedNodePrev = null; + +export const getNodeUnderClientXY = (app, clientX, clientY) => { + const canvasEl = app?.canvas?.canvas || document.querySelector("canvas"); + const graph = app?.canvas?.graph; + const ds = app?.canvas?.ds; + if (!canvasEl || !graph || !ds) return null; + + const rect = canvasEl.getBoundingClientRect(); + if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) { + return null; + } + + const scale = Number(ds.scale) || 1; + const off = ds.offset || [0, 0]; + const offX = Array.isArray(off) ? Number(off[0]) || 0 : Number(off?.x) || 0; + const offY = Array.isArray(off) ? Number(off[1]) || 0 : Number(off?.y) || 0; + const x = (clientX - rect.left) / scale - offX; + const y = (clientY - rect.top) / scale - offY; + + if (typeof graph.getNodeOnPos === "function") { + return graph.getNodeOnPos(x, y); + } + + const nodes = graph._nodes || []; + for (let i = nodes.length - 1; i >= 0; i--) { + const n = nodes[i]; + if (!n?.pos || !n?.size) continue; + if (x >= n.pos[0] && y >= n.pos[1] && x <= n.pos[0] + n.size[0] && y <= n.pos[1] + n.size[1]) { + return n; + } + } + return null; +}; + +export const applyHighlight = (app, node, markCanvasDirty) => { + if (!node || highlightedNode === node) return; + clearHighlight(app, markCanvasDirty); + highlightedNode = node; + highlightedNodePrev = { color: node.color, bgcolor: node.bgcolor }; + node.bgcolor = "#3355ff"; + node.color = "#a9c4ff"; + markCanvasDirty(app); +}; + +export const clearHighlight = (app, markCanvasDirty) => { + if (!highlightedNode) return; + try { + if (highlightedNodePrev) { + highlightedNode.color = highlightedNodePrev.color; + highlightedNode.bgcolor = highlightedNodePrev.bgcolor; + } + } catch {} + highlightedNode = null; + highlightedNodePrev = null; + markCanvasDirty(app); +}; + +export const ensureComboHasValue = (widget, value) => { + if (!widget || widget.type !== "combo" || !widget.options) return; + let vals = null; + let target = null; + if (Array.isArray(widget.options.values)) { + vals = widget.options.values; + target = widget.options; + } else if (widget.options.values && Array.isArray(widget.options.values.values)) { + vals = widget.options.values.values; + target = widget.options.values; + } + if (!Array.isArray(vals) || !target) return; + + const has = vals.some((v) => { + if (typeof v === "string") return v === value; + return (v?.content ?? v?.value ?? v?.text) === value; + }); + if (has) return; + + if (vals.length === 0 || typeof vals[0] === "string") { + vals.unshift(value); + } else { + vals.unshift({ content: value, text: value, value }); + } + target.values = vals; +}; + +export const pickBestImagePathWidget = (node, droppedExt) => { + const widgets = node?.widgets; + if (!Array.isArray(widgets) || !widgets.length) return null; + + const ext = String(droppedExt || "").toLowerCase().replace(/^\./, ""); + const exactNames = new Set(["image", "image_path", "input_image", "source_image", "file", "filename", "path"]); + const nodeType = String(node?.type || "").toLowerCase(); + const isKnownImageNode = + nodeType.includes("loadimage") || + nodeType.includes("load image") || + nodeType.includes("imageloader") || + nodeType === "loadimage"; + + const candidates = []; + for (const w of widgets) { + if (!w) continue; + const type = String(w?.type || "").toLowerCase(); + const value = w?.value; + + const rejectTypes = new Set(["number", "int", "float", "boolean", "toggle", "checkbox"]); + if (rejectTypes.has(type)) continue; + if (typeof value === "number" || typeof value === "boolean") continue; + + const stringLikeByType = type === "text" || type === "string" || type === "combo"; + const stringLikeByCallback = typeof w?.callback === "function" && typeof value === "string"; + if (!stringLikeByType && !stringLikeByCallback) continue; + + const rawName = String(w?.name || w?.label || ""); + const name = rawName.toLowerCase().trim(); + + let score = 0; + if (exactNames.has(name)) score += 100; + + const hasImage = name.includes("image"); + const hasAnyImageHint = + name.includes("path") || name.includes("file") || name.includes("input") || name.includes("src") || name.includes("source"); + if (hasImage && hasAnyImageHint) score += 80; + if (name.includes("file") || name.includes("path")) score += 35; + if (name.includes("media") || name.includes("picture") || name.includes("photo")) score += 45; + + const isOutputy = + name.includes("output") || + name.includes("save") || + name.includes("export") || + name.includes("folder") || + name.includes("dir"); + if (isOutputy) score -= 90; + + if (name === "image") { + const empty = typeof value === "string" && value.trim() === ""; + if (empty) score += 25; + else score -= 50; + } + + if (isKnownImageNode) score += 15; + const emptyValue = typeof value === "string" && value.trim() === ""; + if (emptyValue) score += 3; + + candidates.push({ w, score, emptyValue, combo: type === "combo" }); + } + + if (!candidates.length) return null; + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.emptyValue !== a.emptyValue) return b.emptyValue ? 1 : -1; + if (b.combo !== a.combo) return b.combo ? 1 : -1; + return 0; + }); + + const best = candidates[0]; + if (!best || best.score < 20) return null; + try { + best.w.__mjrImagePickScore = best.score; + } catch {} + return best.w; +}; + +export const pickBestVideoPathWidget = (node, droppedExt) => { + const widgets = node?.widgets; + if (!Array.isArray(widgets) || !widgets.length) return null; + + const ext = String(droppedExt || "").toLowerCase().replace(/^\./, ""); + const exactNames = new Set(["video_path", "input_video", "source_video", "video"]); + const nodeType = String(node?.type || "").toLowerCase(); + const isKnownVideoNode = + nodeType.includes("loadvideo") || + nodeType.includes("vhs_loadvideo") || + nodeType.includes("videoloader") || + nodeType === "loadvideo"; + + const candidates = []; + for (const w of widgets) { + if (!w) continue; + const type = String(w?.type || "").toLowerCase(); + const value = w?.value; + + const rejectTypes = new Set(["number", "int", "float", "boolean", "toggle", "checkbox"]); + if (rejectTypes.has(type)) continue; + if (typeof value === "number" || typeof value === "boolean") continue; + + const stringLikeByType = type === "text" || type === "string" || type === "combo"; + const stringLikeByCallback = typeof w?.callback === "function" && typeof value === "string"; + if (!stringLikeByType && !stringLikeByCallback) continue; + + const rawName = String(w?.name || w?.label || ""); + const name = rawName.toLowerCase().trim(); + + let score = 0; + if (exactNames.has(name)) score += 100; + + if (name === "file" && isKnownVideoNode && type === "combo" && comboHasAnyVideoValue(w, ext)) { + score += 100; + } + + const hasVideo = name.includes("video"); + const hasAnyVideoHint = + name.includes("path") || name.includes("file") || name.includes("input") || name.includes("src") || name.includes("source"); + if (hasVideo && hasAnyVideoHint) score += 80; + if (name.includes("file") || name.includes("path")) score += 35; + if (name.includes("media") || name.includes("clip") || name.includes("footage")) score += 45; + + const isOutputy = + name.includes("output") || + name.includes("save") || + name.includes("export") || + name.includes("folder") || + name.includes("dir"); + if (isOutputy) score -= 90; + + if (name === "video") { + const empty = typeof value === "string" && value.trim() === ""; + if (empty || looksLikeVideoPath(value, ext)) score += 25; + else score -= 50; + } + + if (isKnownVideoNode) score += 15; + const emptyValue = typeof value === "string" && value.trim() === ""; + if (emptyValue) score += 3; + if (type === "combo" && comboHasAnyVideoValue(w, ext)) score += 12; + + candidates.push({ w, score, emptyValue, combo: type === "combo" }); + } + + if (!candidates.length) return null; + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.emptyValue !== a.emptyValue) return b.emptyValue ? 1 : -1; + if (b.combo !== a.combo) return b.combo ? 1 : -1; + return 0; + }); + + const best = candidates[0]; + if (!best || best.score < 20) return null; + try { + best.w.__mjrVideoPickScore = best.score; + } catch {} + return best.w; +}; + diff --git a/js/features/dnd/utils/constants.js b/js/features/dnd/utils/constants.js new file mode 100644 index 00000000..269efd07 --- /dev/null +++ b/js/features/dnd/utils/constants.js @@ -0,0 +1,5 @@ +export const DND_MIME = "application/x-mjr-asset"; +export const VIDEO_EXTS = new Set([".mp4", ".mov", ".mkv", ".webm", ".avi"]); +export const DND_GLOBAL_KEY = "__MJR_DND__"; +export const DND_INSTANCE_VERSION = 1; + diff --git a/js/features/dnd/utils/log.js b/js/features/dnd/utils/log.js new file mode 100644 index 00000000..1b25a91c --- /dev/null +++ b/js/features/dnd/utils/log.js @@ -0,0 +1,13 @@ +const dndDebugEnabled = () => { + try { + return Boolean(window?.MJR_DND_DEBUG); + } catch { + return false; + } +}; + +export const dndLog = (...args) => { + if (!dndDebugEnabled()) return; + console.debug("[Majoor.DnD]", ...args); +}; + diff --git a/js/features/dnd/utils/payload.js b/js/features/dnd/utils/payload.js new file mode 100644 index 00000000..b457ad3d --- /dev/null +++ b/js/features/dnd/utils/payload.js @@ -0,0 +1,22 @@ +export const buildPayloadViewURL = (payload, { buildCustomViewURL, buildViewURL }) => { + const type = String(payload?.type || "output").toLowerCase(); + if (type === "custom") { + const rootId = payload?.root_id || payload?.rootId || payload?.custom_root_id || ""; + return buildCustomViewURL(payload?.filename || "", payload?.subfolder || "", rootId); + } + if (type === "input") { + return buildViewURL(payload?.filename || "", payload?.subfolder || "", "input"); + } + return buildViewURL(payload?.filename || "", payload?.subfolder || "", "output"); +}; + +export const getDraggedAsset = (event, mimeType) => { + const data = event?.dataTransfer?.getData?.(mimeType); + if (!data) return null; + try { + return JSON.parse(data); + } catch { + return null; + } +}; + diff --git a/js/features/dnd/utils/syntheticFile.js b/js/features/dnd/utils/syntheticFile.js new file mode 100644 index 00000000..4b4677a3 --- /dev/null +++ b/js/features/dnd/utils/syntheticFile.js @@ -0,0 +1,31 @@ +export const fetchFileAsDataTransferFile = async (payload, buildURL) => { + if (!payload?.filename) return null; + const url = buildURL(payload); + try { + const res = await fetch(url); + if (!res.ok) return null; + const blob = await res.blob(); + const name = payload.filename || "file"; + return new File([blob], name, { type: blob.type || "application/octet-stream" }); + } catch { + return null; + } +}; + +export const dispatchSyntheticDrop = (target, file) => { + if (!target || !file) return false; + try { + const dt = new DataTransfer(); + dt.items.add(file); + const evt = new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer: dt + }); + target.dispatchEvent(evt); + return true; + } catch { + return false; + } +}; + diff --git a/js/features/dnd/utils/video.js b/js/features/dnd/utils/video.js new file mode 100644 index 00000000..427641e1 --- /dev/null +++ b/js/features/dnd/utils/video.js @@ -0,0 +1,53 @@ +import { VIDEO_EXTS } from "./constants.js"; + +export const isVideoFilename = (filename) => { + if (!filename) return false; + const dot = filename.lastIndexOf("."); + if (dot === -1) return false; + return VIDEO_EXTS.has(filename.slice(dot).toLowerCase()); +}; + +export const isVideoPayload = (payload) => { + if (!payload) return false; + if (payload.kind === "video") return true; + return isVideoFilename(payload.filename); +}; + +export const getDownloadMimeForFilename = (filename) => { + const ext = String(filename || "").split(".").pop()?.toLowerCase(); + return ext === "mp4" + ? "video/mp4" + : ext === "mov" + ? "video/quicktime" + : ext === "webm" + ? "video/webm" + : ext === "mkv" + ? "video/x-matroska" + : "application/octet-stream"; +}; + +export const looksLikeVideoPath = (value, droppedExt) => { + if (typeof value !== "string") return false; + const v = value.trim().toLowerCase(); + if (!v) return false; + const ext = (v.split(/[?#]/)[0].split(".").pop() || "").toLowerCase(); + if (!ext) return false; + if (droppedExt && ext === String(droppedExt).toLowerCase()) return true; + return VIDEO_EXTS.has(`.${ext}`); +}; + +export const comboHasAnyVideoValue = (widget, droppedExt) => { + if (!widget || widget.type !== "combo" || !widget.options) return false; + const vals = + (Array.isArray(widget.options.values) && widget.options.values) || + (widget.options.values && + Array.isArray(widget.options.values.values) && + widget.options.values.values) || + null; + if (!Array.isArray(vals)) return false; + return vals.some((v) => { + const s = typeof v === "string" ? v : v?.content ?? v?.value ?? v?.text; + return looksLikeVideoPath(s, droppedExt); + }); +}; + diff --git a/js/features/grid/GridView.js b/js/features/grid/GridView.js new file mode 100644 index 00000000..67e26da1 --- /dev/null +++ b/js/features/grid/GridView.js @@ -0,0 +1,874 @@ +/** + * Grid View Feature - Asset grid display + */ + +import { get, hydrateAssetRatingTags } from "../../api/client.js"; +import { buildListURL } from "../../api/endpoints.js"; +import { createAssetCard } from "../../components/Card.js"; +import { createRatingBadge, createTagsBadge } from "../../components/Badges.js"; +import { APP_CONFIG } from "../../app/config.js"; +import { getViewerInstance } from "../../components/Viewer.js"; +import { bindContextMenu } from "../../components/ContextMenu.js"; +import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js"; +import { safeDispatchCustomEvent } from "../../utils/events.js"; +import { bindAssetDragStart } from "../dnd/DragDrop.js"; + +const GRID_STATE = new WeakMap(); + +const RT_HYDRATE_CONCURRENCY = APP_CONFIG.RT_HYDRATE_CONCURRENCY ?? 2; +const RT_HYDRATE_SEEN = new Set(); +const RT_HYDRATE_SEEN_MAX = APP_CONFIG.RT_HYDRATE_SEEN_MAX ?? 20000; +const RT_HYDRATE_SEEN_TTL_MS = APP_CONFIG.RT_HYDRATE_SEEN_TTL_MS ?? (10 * 60 * 1000); +let rtHydrateSeenPruneBudget = 0; +let rtHydrateSeenLastPruneAt = 0; +const RT_HYDRATE_QUEUE = []; +let rtHydrateInflight = 0; + +function pruneRatingTagsHydrateSeen() { + // Avoid unbounded growth on large libraries; prune occasionally to keep perf predictable. + const now = Date.now(); + const needsSizePrune = RT_HYDRATE_SEEN.size > RT_HYDRATE_SEEN_MAX; + const needsTimePrune = + rtHydrateSeenLastPruneAt > 0 + && (now - rtHydrateSeenLastPruneAt) > RT_HYDRATE_SEEN_TTL_MS + && RT_HYDRATE_SEEN.size > Math.max(1000, Math.floor(RT_HYDRATE_SEEN_MAX / 4)); + + if (!needsSizePrune && !needsTimePrune) return; + if (rtHydrateSeenPruneBudget > 0) { + rtHydrateSeenPruneBudget -= 1; + return; + } + rtHydrateSeenPruneBudget = APP_CONFIG.RT_HYDRATE_PRUNE_BUDGET ?? 250; + rtHydrateSeenLastPruneAt = now; + + try { + const keep = new Set(); + // Keep items that are still queued/in-flight first. + for (const job of RT_HYDRATE_QUEUE) { + if (job?.id) keep.add(String(job.id)); + } + // Keep up to half of the max from the existing set (in insertion order). + const target = Math.max(1000, Math.floor(RT_HYDRATE_SEEN_MAX / 2)); + if (keep.size < target) { + for (const id of RT_HYDRATE_SEEN) { + keep.add(id); + if (keep.size >= target) break; + } + } + RT_HYDRATE_SEEN.clear(); + for (const id of keep) RT_HYDRATE_SEEN.add(id); + } catch {} +} + +function _updateCardRatingTagsBadges(card, rating, tags) { + if (!card) return; + const thumb = card.querySelector?.(".mjr-thumb"); + if (!thumb) return; + + const nextRatingBadge = createRatingBadge(rating || 0); + const oldRatingBadge = thumb.querySelector?.(".mjr-rating-badge"); + if (oldRatingBadge && nextRatingBadge) { + oldRatingBadge.replaceWith(nextRatingBadge); + } else if (oldRatingBadge && !nextRatingBadge) { + oldRatingBadge.remove(); + } else if (!oldRatingBadge && nextRatingBadge) { + thumb.appendChild(nextRatingBadge); + } + + const oldTagsBadge = thumb.querySelector?.(".mjr-tags-badge"); + if (oldTagsBadge) { + if (Array.isArray(tags) && tags.length) { + oldTagsBadge.textContent = tags.join(", "); + oldTagsBadge.style.display = ""; + } else { + oldTagsBadge.textContent = ""; + oldTagsBadge.style.display = "none"; + } + } else { + const nextTagsBadge = createTagsBadge(Array.isArray(tags) ? tags : []); + thumb.appendChild(nextTagsBadge); + } +} + +function _pumpRatingTagsHydration() { + while (rtHydrateInflight < RT_HYDRATE_CONCURRENCY && RT_HYDRATE_QUEUE.length) { + const job = RT_HYDRATE_QUEUE.shift(); + if (!job) break; + rtHydrateInflight += 1; + (async () => { + const res = await hydrateAssetRatingTags(job.id); + if (!res || !res.ok || !res.data) return; + const updated = res.data; + const rating = Number(updated.rating || 0) || 0; + const tags = Array.isArray(updated.tags) ? updated.tags : []; + + try { + if (job.asset) { + job.asset.rating = rating; + job.asset.tags = tags; + } + } catch {} + + _updateCardRatingTagsBadges(job.card, rating, tags); + + safeDispatchCustomEvent( + ASSET_RATING_CHANGED_EVENT, + { assetId: String(job.id), rating }, + { warnPrefix: "[GridView]" } + ); + safeDispatchCustomEvent( + ASSET_TAGS_CHANGED_EVENT, + { assetId: String(job.id), tags }, + { warnPrefix: "[GridView]" } + ); + })() + .catch(() => null) + .finally(() => { + rtHydrateInflight -= 1; + _pumpRatingTagsHydration(); + }); + } +} + +function enqueueRatingTagsHydration(card, asset) { + const id = asset?.id != null ? String(asset.id) : ""; + if (!id) return; + if (RT_HYDRATE_SEEN.has(id)) return; + + const rating = Number(asset?.rating || 0) || 0; + const tags = asset?.tags; + const tagsEmpty = !(Array.isArray(tags) && tags.length); + if (rating > 0 && !tagsEmpty) { + RT_HYDRATE_SEEN.add(id); + return; + } + + // Hydrate only when both are missing in DB (avoid extra IO). + if (rating > 0 || !tagsEmpty) { + RT_HYDRATE_SEEN.add(id); + return; + } + + RT_HYDRATE_SEEN.add(id); + pruneRatingTagsHydrateSeen(); + RT_HYDRATE_QUEUE.push({ id, card, asset }); + _pumpRatingTagsHydration(); +} + +/** + * Create grid container + */ +export function createGridContainer() { + const container = document.createElement("div"); + container.id = "mjr-assets-grid"; + container.classList.add("mjr-grid"); + container.style.setProperty("--mjr-grid-min-size", `${APP_CONFIG.GRID_MIN_SIZE}px`); + container.style.setProperty("--mjr-grid-gap", `${APP_CONFIG.GRID_GAP}px`); + + // Bind delegated dragstart once (avoid per-card listeners; improves load perf on large grids). + try { + bindAssetDragStart(container); + } catch {} + + // Bind double-click to open viewer + container.addEventListener('dblclick', (e) => { + const card = e.target.closest('.mjr-asset-card'); + if (!card) return; + + const asset = card._mjrAsset; + if (!asset) return; + + // Get all assets in current grid for navigation + const allCards = Array.from(container.querySelectorAll('.mjr-asset-card')); + const allAssets = allCards.map(c => c._mjrAsset).filter(Boolean); + const currentIndex = allAssets.findIndex(a => a.id === asset.id); + + // Open viewer + const viewer = getViewerInstance(); + viewer.open(allAssets, currentIndex); + }); + + return container; +} + +const OVERLAY_CLASS = "mjr-grid-loading-overlay"; +const SENTINEL_CLASS = "mjr-grid-sentinel"; + +function showLoadingOverlay(gridContainer, message = "Loading assets...") { + let overlay = gridContainer.querySelector(`.${OVERLAY_CLASS}`); + if (!overlay) { + overlay = document.createElement("div"); + overlay.className = OVERLAY_CLASS; + } + overlay.textContent = message; + if (!gridContainer.contains(overlay)) { + gridContainer.appendChild(overlay); + } + overlay.style.display = "flex"; + // Ensure the overlay is visible even when the grid is empty/collapsed. + if (!gridContainer.style.minHeight) { + gridContainer.style.minHeight = "160px"; + } +} + +function hideLoadingOverlay(gridContainer) { + const overlay = gridContainer.querySelector(`.${OVERLAY_CLASS}`); + if (overlay) { + overlay.remove(); + } + try { + if (gridContainer.style.minHeight === "160px") { + gridContainer.style.minHeight = ""; + } + } catch {} +} + +function sanitizeQuery(query) { + if (!query || query.trim() === "") { + return query; + } + + if (query === "*") { + return "*"; + } + + // Avoid expensive Unicode property regexes on large inputs; fallback for older engines. + let cleaned = query; + if (query.length > 256) { + cleaned = query.replace(/[^a-z0-9\s-]/gi, " "); + } else { + try { + cleaned = query.replace(/[^\p{L}\p{N}\s-]/gu, " "); + } catch { + cleaned = query.replace(/[^a-z0-9\s-]/gi, " "); + } + } + return cleaned.trim() || query; +} + +function getOrCreateState(gridContainer) { + let state = GRID_STATE.get(gridContainer); + if (!state) { + state = { + query: "*", + offset: 0, + total: null, + loading: false, + done: false, + seenKeys: new Set(), + assets: [], + sentinel: null, + observer: null, + scrollRoot: null, + scrollHandler: null, + ignoreNextScroll: false, + userScrolled: false, + allowUntilFilled: true + }; + GRID_STATE.set(gridContainer, state); + } + return state; +} + +function assetKey(asset) { + if (!asset || typeof asset !== "object") return ""; + if (asset.id != null) return `id:${asset.id}`; + const fp = asset.filepath || ""; + const t = asset.type || ""; + const rid = asset.root_id || asset.rootId || asset.custom_root_id || ""; + return `${t}|${rid}|${fp}|${asset.subfolder || ""}|${asset.filename || ""}`; +} + +function getSelectedIdSet(gridContainer) { + const selected = new Set(); + if (!gridContainer) return selected; + try { + const raw = gridContainer.dataset?.mjrSelectedAssetIds; + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + for (const id of parsed) { + if (id == null) continue; + selected.add(String(id)); + } + } + return selected; + } + } catch {} + try { + const id = gridContainer.dataset?.mjrSelectedAssetId; + if (id) selected.add(String(id)); + } catch {} + return selected; +} + +function appendAssets(gridContainer, assets, state) { + const selectedIds = getSelectedIdSet(gridContainer); + const frag = document.createDocumentFragment(); + const newAssets = []; + let added = 0; + for (const asset of assets || []) { + const key = assetKey(asset); + if (!key) continue; + if (state.seenKeys.has(key)) continue; + state.seenKeys.add(key); + newAssets.push(asset); + const card = createAssetCard(asset); + try { + const id = asset?.id != null ? String(asset.id) : ""; + if (id && selectedIds.has(id)) { + card.classList.add("is-selected"); + card.setAttribute?.("aria-selected", "true"); + } + } catch {} + frag.appendChild(card); + // Best-effort: hydrate existing OS rating/tags (if DB empty) without requiring a full rescan. + enqueueRatingTagsHydration(card, asset); + added += 1; + } + if (added > 0) { + try { + state.assets.push(...newAssets); + } catch {} + gridContainer.appendChild(frag); + // Update context menu asset list (bind is idempotent). + bindContextMenu(gridContainer, state.assets); + } + return added; +} + +function ensureSentinel(gridContainer, state) { + let sentinel = state.sentinel; + if (sentinel && sentinel.isConnected) return sentinel; + sentinel = document.createElement("div"); + sentinel.className = SENTINEL_CLASS; + // Ensure it's always placed at the end of the grid and spans all columns. + // This stabilizes IntersectionObserver triggering as the panel resizes. + sentinel.style.cssText = "height: 1px; width: 100%; grid-column: 1 / -1;"; + state.sentinel = sentinel; + gridContainer.appendChild(sentinel); + return sentinel; +} + +function stopObserver(state) { + try { + if (state.observer) state.observer.disconnect(); + } catch {} + state.observer = null; + try { + if (state.sentinel && state.sentinel.isConnected) state.sentinel.remove(); + } catch {} + state.sentinel = null; + + try { + if (state.scrollRoot && state.scrollHandler) { + state.scrollRoot.removeEventListener("scroll", state.scrollHandler, { passive: true }); + } + } catch {} + state.scrollRoot = null; + state.scrollHandler = null; + state.ignoreNextScroll = false; + state.userScrolled = false; + state.allowUntilFilled = true; +} + +function captureScrollMetrics(state) { + const root = state?.scrollRoot; + if (!root) return null; + const clientHeight = Number(root.clientHeight) || 0; + const scrollHeight = Number(root.scrollHeight) || 0; + const scrollTop = Number(root.scrollTop) || 0; + const bottomGap = scrollHeight - (scrollTop + clientHeight); + return { clientHeight, scrollHeight, scrollTop, bottomGap }; +} + +function maybeKeepPinnedToBottom(state, before) { + const root = state?.scrollRoot; + if (!root || !before) return; + + // If the user was effectively at the bottom before we appended, keep them pinned + // to avoid the scrollbar "jumping up" when new content increases scrollHeight. + if (before.bottomGap <= 2) { + try { + state.ignoreNextScroll = true; + root.scrollTop = root.scrollHeight - root.clientHeight; + } catch {} + } +} + +async function fetchPage(gridContainer, query, limit, offset) { + const scope = gridContainer?.dataset?.mjrScope || "output"; + const customRootId = gridContainer?.dataset?.mjrCustomRootId || ""; + const subfolder = gridContainer?.dataset?.mjrSubfolder || ""; + const kind = gridContainer?.dataset?.mjrFilterKind || ""; + const workflowOnly = gridContainer?.dataset?.mjrFilterWorkflowOnly === "1"; + const minRating = Number(gridContainer?.dataset?.mjrFilterMinRating || 0) || 0; + const sortKey = gridContainer?.dataset?.mjrSort || "mtime_desc"; + + const requestedQuery = query && query.trim() ? query : "*"; + const safeQuery = sanitizeQuery(requestedQuery) || requestedQuery; + + try { + const includeTotal = !(String(scope || "").toLowerCase() === "output" && Number(offset || 0) > 0); + const url = buildListURL({ + q: safeQuery, + limit, + offset, + scope, + subfolder, + customRootId: customRootId || null, + kind: kind || null, + hasWorkflow: workflowOnly ? true : null, + minRating: minRating > 0 ? minRating : null, + includeTotal + }); + const result = await get(url); + + if (result.ok) { + let assets = (result.data?.assets) || []; + const rawTotal = result.data?.total; + const total = rawTotal == null ? null : (Number(rawTotal || 0) || 0); + + // Client-side sorting (applies to current page). Default is already mtime desc from the backend. + if (sortKey !== "mtime_desc") { + const safeName = (a) => String(a?.filename || ""); + const safeMtime = (a) => Number(a?.mtime || 0) || 0; + if (sortKey === "name_asc") { + assets.sort((a, b) => safeName(a).localeCompare(safeName(b))); + } else if (sortKey === "name_desc") { + assets.sort((a, b) => safeName(b).localeCompare(safeName(a))); + } else if (sortKey === "mtime_asc") { + assets.sort((a, b) => safeMtime(a) - safeMtime(b)); + } + } + return { ok: true, assets, total, count: assets.length, sortKey, safeQuery }; + } else { + return { ok: false, error: result.error }; + } + } catch (error) { + return { ok: false, error: error.message }; + } +} + +async function loadNextPage(gridContainer, state) { + if (state.loading || state.done) return; + const limit = Math.max(1, Math.min(APP_CONFIG.MAX_PAGE_SIZE, APP_CONFIG.DEFAULT_PAGE_SIZE)); + state.loading = true; + const before = captureScrollMetrics(state); + try { + const page = await fetchPage(gridContainer, state.query, limit, state.offset); + if (!page.ok) { + state.done = true; + stopObserver(state); + const p = document.createElement("p"); + p.className = "mjr-muted"; + p.style.color = "var(--error-text, #f44336)"; + p.textContent = `Error: ${page.error}`; + gridContainer.appendChild(p); + return; + } + + if (page.total != null) { + state.total = page.total; + } + const added = appendAssets(gridContainer, page.assets || [], state); + state.offset += (page.count || 0); + + maybeKeepPinnedToBottom(state, before); + + const hasTotal = state.total != null && Number(state.total) > 0; + if ((page.count || 0) === 0 || added === 0 || (hasTotal && state.offset >= Number(state.total))) { + state.done = true; + stopObserver(state); + } + } finally { + state.loading = false; + } +} + +function startInfiniteScroll(gridContainer, state) { + if (!APP_CONFIG.INFINITE_SCROLL_ENABLED) return; + stopObserver(state); + const sentinel = ensureSentinel(gridContainer, state); + const rootEl = gridContainer.closest(".mjr-am-browse") || gridContainer.parentElement || null; + + state.scrollRoot = rootEl; + state.userScrolled = false; + state.allowUntilFilled = true; + state.ignoreNextScroll = false; + + if (rootEl && !state.scrollHandler) { + state.scrollHandler = () => { + if (state.ignoreNextScroll) { + state.ignoreNextScroll = false; + return; + } + state.userScrolled = true; + }; + try { + rootEl.addEventListener("scroll", state.scrollHandler, { passive: true }); + } catch {} + } + + state.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries || []) { + if (!entry.isIntersecting) continue; + if (state.loading || state.done) return; + + // Prevent repeated auto-loads when new content is appended while the user + // stays pinned at the bottom. Require a user scroll between loads unless + // the grid doesn't fill the viewport yet (initial fill). + const metrics = captureScrollMetrics(state); + const fillsViewport = metrics ? metrics.scrollHeight > metrics.clientHeight + 40 : true; + if (!state.userScrolled && fillsViewport && !state.allowUntilFilled) { + return; + } + try { + state.observer?.unobserve?.(sentinel); + } catch {} + + state.userScrolled = false; + if (fillsViewport) state.allowUntilFilled = false; + + Promise.resolve(loadNextPage(gridContainer, state)) + .catch(() => null) + .finally(() => { + if (state.done) return; + if (!sentinel.isConnected) return; + try { + state.observer?.observe?.(sentinel); + } catch {} + }); + } + }, + { + root: rootEl, + rootMargin: APP_CONFIG.INFINITE_SCROLL_ROOT_MARGIN || "800px", + threshold: APP_CONFIG.INFINITE_SCROLL_THRESHOLD ?? 0.01 + } + ); + state.observer.observe(sentinel); +} + +export async function loadAssets(gridContainer, query = "*", options = {}) { + const { reset = true } = options || {}; + const state = getOrCreateState(gridContainer); + + const requestedQuery = query && query.trim() ? query : "*"; + state.query = sanitizeQuery(requestedQuery) || requestedQuery; + + if (reset) { + stopObserver(state); + state.offset = 0; + state.total = null; + state.done = false; + state.loading = false; + state.seenKeys = new Set(); + state.assets = []; + gridContainer.innerHTML = ""; + showLoadingOverlay(gridContainer, state.query === "*" ? "Loading assets..." : `Searching for "${state.query}"...`); + } + + try { + await loadNextPage(gridContainer, state); + + if (reset) { + hideLoadingOverlay(gridContainer); + const hasCards = gridContainer.querySelector(".mjr-card") != null; + if (!hasCards && state.offset === 0) { + gridContainer.innerHTML = ""; + const p = document.createElement("p"); + p.className = "mjr-muted"; + p.textContent = "No assets found"; + gridContainer.appendChild(p); + } else { + startInfiniteScroll(gridContainer, state); + } + } + return { ok: true, count: state.offset, total: state.total || 0 }; + } catch (err) { + stopObserver(state); + if (reset) hideLoadingOverlay(gridContainer); + gridContainer.innerHTML = ""; + const p = document.createElement("p"); + p.className = "mjr-muted"; + p.style.color = "var(--error-text, #f44336)"; + p.textContent = `Failed to load assets: ${err?.message || err}`; + gridContainer.appendChild(p); + return { ok: false, error: err?.message || String(err) }; + } finally { + if (reset) hideLoadingOverlay(gridContainer); + } +} + +export function disposeGrid(gridContainer) { + const state = GRID_STATE.get(gridContainer); + if (!state) return; + stopObserver(state); + try { + state.seenKeys?.clear?.(); + } catch {} + GRID_STATE.delete(gridContainer); +} + +/** + * Get sort value for an asset based on sort key + */ +function getSortValue(asset, sortKey) { + switch (sortKey) { + case 'mtime_desc': + return -(Number(asset?.mtime) || 0); // Negative for descending order + case 'mtime_asc': + return Number(asset?.mtime) || 0; + case 'name_asc': + return String(asset?.filename || '').toLowerCase(); + case 'name_desc': + return String(asset?.filename || '').toLowerCase(); // Will be handled specially in comparison + default: + return -(Number(asset?.mtime) || 0); // Default to mtime_desc + } +} + +/** + * Determine if an asset should be inserted before another based on sort order + */ +function shouldInsertBefore(assetValue, cardValue, sortKey) { + if (sortKey === 'name_asc') { + // For ascending string comparisons + return String(assetValue) < String(cardValue); + } else if (sortKey === 'name_desc') { + // For descending string comparisons + return String(assetValue) > String(cardValue); + } else { + // For numeric comparisons + return Number(assetValue) < Number(cardValue); + } +} + +/** + * Find the correct insertion position in an array based on sort order + */ +function findInsertPosition(array, asset, sortKey) { + const assetValue = getSortValue(asset, sortKey); + for (let i = 0; i < array.length; i++) { + const arrValue = getSortValue(array[i], sortKey); + if (shouldInsertBefore(assetValue, arrValue, sortKey)) { + return i; + } + } + return array.length; // Append to end +} + +/** + * Find asset element by ID + */ +function findAssetElement(gridContainer, assetId) { + return gridContainer.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(assetId)) : String(assetId)}"]`); +} + +/** + * Update or insert a single asset in the grid + */ +export function upsertAsset(gridContainer, asset) { + if (!asset || !asset.id) return false; + + const state = getOrCreateState(gridContainer); + const assetId = String(asset.id); + const key = assetKey(asset); + + // Check if asset already exists in the grid + const existingElement = findAssetElement(gridContainer, assetId); + + if (existingElement) { + // Update existing asset + const existingAsset = existingElement._mjrAsset; + if (existingAsset) { + // Check if the existing element was selected before replacement + const wasSelected = existingElement.classList.contains("is-selected"); + const wasAriaSelected = existingElement.getAttribute("aria-selected"); + + // Update the asset data + Object.assign(existingAsset, asset); + // Update the card display + const card = createAssetCard(asset); + + // Preserve selection state after replacement + if (wasSelected) { + card.classList.add("is-selected"); + } + if (wasAriaSelected) { + card.setAttribute("aria-selected", wasAriaSelected); + } + + existingElement.replaceWith(card); + return true; + } + } else { + // Check if asset key is already in seen keys + if (!state.seenKeys.has(key)) { + // Insert new asset + const selectedIds = getSelectedIdSet(gridContainer); + const card = createAssetCard(asset); + try { + if (selectedIds.has(assetId)) { + card.classList.add("is-selected"); + card.setAttribute?.("aria-selected", "true"); + } + } catch {} + + // Determine sort order from grid container + const sortKey = gridContainer.dataset.mjrSort || "mtime_desc"; + + // Insert at the appropriate position based on sort order + const allCards = Array.from(gridContainer.querySelectorAll('.mjr-asset-card')); + let insertIndex = -1; + + // Find the correct position based on sort order + const assetValue = getSortValue(asset, sortKey); + for (let i = 0; i < allCards.length; i++) { + const cardAsset = allCards[i]._mjrAsset; + const cardValue = getSortValue(cardAsset, sortKey); + + if (shouldInsertBefore(assetValue, cardValue, sortKey)) { + insertIndex = i; + break; + } + } + + if (insertIndex === -1) { + // Append to end if no earlier asset found + gridContainer.appendChild(card); + } else { + // Insert at the calculated position + gridContainer.insertBefore(card, allCards[insertIndex]); + } + + // Add to seen keys and assets array in correct sort position + state.seenKeys.add(key); + const insertPos = findInsertPosition(state.assets, asset, sortKey); + if (insertPos === -1) { + state.assets.push(asset); + } else { + state.assets.splice(insertPos, 0, asset); + } + + // Bind context menu and drag start + bindContextMenu(gridContainer, state.assets); + bindAssetDragStart(gridContainer); + + // Hydrate rating/tags if needed + enqueueRatingTagsHydration(card, asset); + + return true; + } + } + + return false; +} + +/** + * Capture scroll anchor before making changes + */ +export function captureAnchor(gridContainer) { + const state = getOrCreateState(gridContainer); + const selectedElement = gridContainer.querySelector('.mjr-asset-card.is-selected'); + const firstVisibleElement = getFirstVisibleAssetElement(gridContainer); + + const el = selectedElement || firstVisibleElement; + if (!el) return null; + + const rect = el.getBoundingClientRect(); + const containerRect = gridContainer.getBoundingClientRect(); + + return { + id: el.dataset.mjrAssetId, + top: rect.top - containerRect.top, + scrollTop: gridContainer.parentElement?.scrollTop || 0 + }; +} + +/** + * Get first visible asset element + */ +function getFirstVisibleAssetElement(gridContainer) { + const cards = gridContainer.querySelectorAll('.mjr-asset-card'); + const containerRect = gridContainer.getBoundingClientRect(); + const containerTop = containerRect.top; + const containerBottom = containerRect.bottom; + + for (const card of cards) { + const cardRect = card.getBoundingClientRect(); + if (cardRect.bottom >= containerTop && cardRect.top <= containerBottom) { + return card; + } + } + + // If no card is visible, return the first one + return cards[0] || null; +} + +/** + * Restore scroll position based on anchor + */ +export async function restoreAnchor(gridContainer, anchor) { + if (!anchor) return; + + // Wait for layout to complete + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + + const el = findAssetElement(gridContainer, anchor.id); + if (!el) return; + + const rect = el.getBoundingClientRect(); + const containerRect = gridContainer.getBoundingClientRect(); + const delta = (rect.top - containerRect.top) - anchor.top; + + const parent = gridContainer.parentElement; + if (parent) { + parent.scrollTop = (parent.scrollTop || 0) + delta; + } +} + +/** + * Check if user is at bottom of grid (within threshold) + */ +export function isAtBottom(gridContainer, threshold = 80) { + const parent = gridContainer.parentElement; + if (!parent) return false; + + const atBottom = (parent.scrollHeight - (parent.scrollTop + parent.clientHeight)) < threshold; + return atBottom; +} + +/** + * Scroll to bottom of grid + */ +export function scrollToBottom(gridContainer) { + const parent = gridContainer.parentElement; + if (parent) { + parent.scrollTop = parent.scrollHeight; + } +} + +/** + * Clear grid placeholder + */ +export function clearGrid(gridContainer) { + try { + const state = GRID_STATE.get(gridContainer); + if (state) { + stopObserver(state); + state.offset = 0; + state.total = null; + state.done = false; + state.loading = false; + state.seenKeys = new Set(); + } + } catch {} + gridContainer.innerHTML = ""; + const p = document.createElement("p"); + p.className = "mjr-muted"; + p.textContent = "Type to search or wait for the scan to finish"; + gridContainer.appendChild(p); +} diff --git a/js/features/panel/AssetsManagerPanel.js b/js/features/panel/AssetsManagerPanel.js new file mode 100644 index 00000000..57dde381 --- /dev/null +++ b/js/features/panel/AssetsManagerPanel.js @@ -0,0 +1,376 @@ +import { triggerAutoScan } from "../../app/bootstrap.js"; +import { comfyAlert, comfyConfirm, comfyPrompt } from "../../app/dialogs.js"; +import { createStatusIndicator, setupStatusPolling, triggerScan } from "../status/StatusDot.js"; +import { createGridContainer, loadAssets, disposeGrid } from "../grid/GridView.js"; +import { get, post } from "../../api/client.js"; +import { ENDPOINTS } from "../../api/endpoints.js"; +import { createSidebar, showAssetInSidebar, closeSidebar } from "../../components/SidebarView.js"; +import { createRatingBadge, createTagsBadge } from "../../components/Badges.js"; +import { loadMajoorSettings } from "../../app/settings.js"; +import { createPopoverManager } from "./views/popoverManager.js"; + +import { createPanelState } from "./state/panelState.js"; +import { createHeaderView } from "./views/headerView.js"; +import { createCustomPopoverView } from "./views/customPopoverView.js"; +import { createFilterPopoverView } from "./views/filterPopoverView.js"; +import { createSortPopoverView } from "./views/sortPopoverView.js"; +import { createSearchView } from "./views/searchView.js"; + +import { normalizeQuery } from "./controllers/query.js"; +import { createGridController } from "./controllers/gridController.js"; +import { createScopeController } from "./controllers/scopeController.js"; +import { createCustomRootsController } from "./controllers/customRootsController.js"; +import { bindFilters } from "./controllers/filtersController.js"; +import { createSortController } from "./controllers/sortController.js"; +import { bindSidebarOpen } from "./controllers/sidebarController.js"; +import { createPanelHotkeysController } from "./controllers/panelHotkeysController.js"; +import { createRatingHotkeysController } from "./controllers/ratingHotkeysController.js"; +import { bindGridContextMenu } from "../contextmenu/GridContextMenu.js"; + +let _activeGridContainer = null; + +export function getActiveGridContainer() { + return _activeGridContainer; +} + +export async function renderAssetsManager(container, { useComfyThemeUI = true } = {}) { + if (useComfyThemeUI) { + container.classList.add("mjr-assets-manager"); + } else { + container.classList.remove("mjr-assets-manager"); + } + + triggerAutoScan(); + + container.innerHTML = ""; + container.classList.add("mjr-am-container"); + + const popovers = createPopoverManager(container); + try { + container._mjrPopoverManager = popovers; + } catch {} + const state = createPanelState(); + + const headerView = createHeaderView(); + const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn } = headerView; + + const { customPopover, customSelect, customAddBtn, customRemoveBtn } = createCustomPopoverView(); + const { filterPopover, kindSelect, wfCheckbox, ratingSelect } = createFilterPopoverView(); + const { sortPopover, sortMenu } = createSortPopoverView(); + + headerActions.appendChild(customPopover); + + const { searchSection, searchInputEl } = createSearchView({ + filterBtn, + sortBtn, + filterPopover, + sortPopover + }); + + const content = document.createElement("div"); + content.classList.add("mjr-am-content"); + let gridContainer = null; + + container.tabIndex = -1; + + const statusSection = createStatusIndicator(); + const statusDot = statusSection.querySelector("#mjr-status-dot"); + const statusText = statusSection.querySelector("#mjr-status-text"); + const capabilitiesSection = statusSection.querySelector("#mjr-status-capabilities"); + + const browseSection = document.createElement("div"); + browseSection.classList.add("mjr-am-browse"); + browseSection.style.cssText = ` + flex: 1; + display: flex; + flex-direction: row; + overflow: hidden; + `; + + const gridWrapper = document.createElement("div"); + gridWrapper.style.cssText = ` + flex: 1; + overflow: auto; + position: relative; + `; + + gridContainer = createGridContainer(); + gridWrapper.appendChild(gridContainer); + _activeGridContainer = gridContainer; + + // Bind grid context menu + bindGridContextMenu({ + gridContainer, + getState: () => state, + onRequestOpenViewer: (asset) => { + gridContainer?.dispatchEvent?.(new CustomEvent("mjr:open-viewer", { detail: { asset } })); + }, + }); + + const settings = loadMajoorSettings(); + const sidebarPosition = settings.sidebar?.position || "right"; + const sidebar = createSidebar(sidebarPosition); + + if (sidebarPosition === "left") { + browseSection.appendChild(sidebar); + browseSection.appendChild(gridWrapper); + } else { + browseSection.appendChild(gridWrapper); + browseSection.appendChild(sidebar); + } + + content.appendChild(statusSection); + content.appendChild(searchSection); + content.appendChild(browseSection); + container.appendChild(header); + container.appendChild(content); + + const getQuery = () => normalizeQuery(searchInputEl); + const gridController = createGridController({ + gridContainer, + loadAssets, + disposeGrid, + getQuery, + state + }); + + const customRootsController = createCustomRootsController({ + state, + customSelect, + customRemoveBtn, + comfyAlert, + comfyConfirm, + comfyPrompt, + get, + post, + ENDPOINTS, + reloadGrid: gridController.reloadGrid + }); + customRootsController.bind({ customAddBtn, customRemoveBtn }); + + const scopeController = createScopeController({ + state, + tabButtons, + customMenuBtn, + customPopover, + popovers, + refreshCustomRoots: customRootsController.refreshCustomRoots, + reloadGrid: gridController.reloadGrid + }); + + Object.values(tabButtons).forEach((btn) => { + btn.addEventListener("click", () => scopeController.setScope(btn.dataset.scope)); + }); + + popovers.setDismissWhitelist([ + customPopover, + filterPopover, + sortPopover, + customMenuBtn, + filterBtn, + sortBtn + ]); + + customMenuBtn.addEventListener("click", (e) => { + e.stopPropagation(); + popovers.close(filterPopover); + popovers.close(sortPopover); + popovers.toggle(customPopover, customMenuBtn); + }); + + filterBtn.addEventListener("click", (e) => { + e.stopPropagation(); + popovers.close(customPopover); + popovers.close(sortPopover); + popovers.toggle(filterPopover, filterBtn); + }); + + const sortController = createSortController({ + state, + sortBtn, + sortMenu, + sortPopover, + popovers, + reloadGrid: gridController.reloadGrid + }); + sortController.bind({ + onBeforeToggle: () => { + popovers.close(customPopover); + popovers.close(filterPopover); + } + }); + + bindFilters({ + state, + kindSelect, + wfCheckbox, + ratingSelect, + reloadGrid: gridController.reloadGrid + }); + + const sidebarController = bindSidebarOpen({ + gridContainer, + sidebar, + createRatingBadge, + createTagsBadge, + showAssetInSidebar, + closeSidebar, + state + }); + + const hotkeys = createPanelHotkeysController({ + onTriggerScan: (ctx) => { + triggerScan(statusDot, statusText, capabilitiesSection, ctx || {}); + }, + onToggleDetails: () => { + sidebarController?.toggleDetails?.(); + }, + getScanContext: () => ({ + scope: state.scope, + customRootId: state.customRootId + }) + }); + + const ratingHotkeys = createRatingHotkeysController({ + gridContainer, + createRatingBadge + }); + + let searchTimeout = null; + let lastSearchValue = ""; + + if (container._eventCleanup) { + try { + container._eventCleanup(); + } catch {} + } + + hotkeys.bind(content); + ratingHotkeys.bind(); + + container._eventCleanup = () => { + try { + hotkeys.dispose(); + } catch {} + try { + ratingHotkeys.dispose(); + } catch {} + try { + if (searchTimeout) clearTimeout(searchTimeout); + searchTimeout = null; + } catch {} + try { + sidebar?._dispose?.(); + } catch {} + try { + sidebarController?.dispose?.(); + } catch {} + try { + if (gridContainer) disposeGrid(gridContainer); + } catch {} + if (_activeGridContainer === gridContainer) { + _activeGridContainer = null; + } + try { + popovers.dispose(); + } catch {} + try { + if (container._mjrPopoverManager === popovers) { + container._mjrPopoverManager = null; + } + } catch {} + }; + + let lastKnownScan = null; + let hasSeenFirstCounters = false; + let pendingReloadCount = 0; + let isReloading = false; + + const queuedReload = async () => { + if (!gridContainer) return; + pendingReloadCount++; + if (isReloading) return; + + isReloading = true; + try { + while (pendingReloadCount > 0) { + pendingReloadCount = 0; + await gridController.reloadGrid(); + } + } finally { + isReloading = false; + } + }; + + const handleCountersUpdate = async (counters) => { + if (!hasSeenFirstCounters) { + hasSeenFirstCounters = true; + lastKnownScan = counters.last_scan_end; + return; + } + + const hasNewScan = counters.last_scan_end && counters.last_scan_end !== lastKnownScan; + if (!hasNewScan) return; + + lastKnownScan = counters.last_scan_end; + await queuedReload(); + }; + + setupStatusPolling( + statusDot, + statusText, + statusSection, + capabilitiesSection, + handleCountersUpdate, + () => ({ scope: state.scope, customRootId: state.customRootId }) + ); + + scopeController.setActiveTabStyles(); + + searchInputEl.addEventListener("input", (e) => { + const value = e?.target?.value ?? ""; + if (value === lastSearchValue) return; + lastSearchValue = value; + + if (searchTimeout) clearTimeout(searchTimeout); + + // Immediate search for empty / browse-all queries. + if (value.length === 0 || value === "*") { + gridController.reloadGrid(); + return; + } + + // Debounce for normal searches. + searchTimeout = setTimeout(() => { + searchTimeout = null; + gridController.reloadGrid(); + }, 200); + }); + searchInputEl.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + gridController.reloadGrid(); + } + }); + + const initialLoadPromise = gridController.reloadGrid(); + const checkAndAutoLoad = async () => { + try { + await initialLoadPromise; + } catch {} + + const hasCards = gridContainer.querySelector(".mjr-card") != null; + if (hasCards) return; + if (state.scope !== "output") return; + if (getQuery() !== "*") return; + + try { + const statusData = await get(`${ENDPOINTS.HEALTH_COUNTERS}?scope=output`); + if (statusData?.ok && (statusData.data?.total_assets || 0) > 0) { + await loadAssets(gridContainer); + } + } catch {} + }; + setTimeout(checkAndAutoLoad, 2000); + + return { gridContainer }; +} diff --git a/js/features/panel/controllers/customRootsController.js b/js/features/panel/controllers/customRootsController.js new file mode 100644 index 00000000..2effaf48 --- /dev/null +++ b/js/features/panel/controllers/customRootsController.js @@ -0,0 +1,94 @@ +export function createCustomRootsController({ + state, + customSelect, + customRemoveBtn, + comfyAlert, + comfyConfirm, + comfyPrompt, + get, + post, + ENDPOINTS, + reloadGrid +}) { + const refreshCustomRoots = async (preferId = null) => { + try { + const json = await get(ENDPOINTS.CUSTOM_ROOTS); + const roots = json && json.ok && Array.isArray(json.data) ? json.data : []; + customSelect.innerHTML = ""; + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select folderâ€Ļ"; + customSelect.appendChild(placeholder); + + roots.forEach((r) => { + const opt = document.createElement("option"); + opt.value = r.id; + opt.textContent = r.name || r.path || r.id; + customSelect.appendChild(opt); + }); + + const desired = preferId || state.customRootId; + if (desired && roots.some((r) => r.id === desired)) { + customSelect.value = desired; + state.customRootId = desired; + } else { + customSelect.value = ""; + state.customRootId = ""; + } + customRemoveBtn.disabled = !state.customRootId; + } catch (err) { + console.warn("Majoor: failed to load custom roots", err); + } + }; + + const bind = ({ customAddBtn, customRemoveBtn }) => { + customSelect.addEventListener("change", async () => { + state.customRootId = customSelect.value || ""; + customRemoveBtn.disabled = !state.customRootId; + await reloadGrid(); + }); + + customAddBtn.addEventListener("click", async () => { + const path = await comfyPrompt( + "Enter a folder path to add as a Custom root:", + "", + "Majoor: Custom Folders" + ); + if (!path) return; + try { + const json = await post(ENDPOINTS.CUSTOM_ROOTS, { path }); + if (!json?.ok) { + await comfyAlert(json?.error || "Failed to add custom folder", "Majoor: Custom Folders"); + return; + } + const newId = json.data?.id; + await refreshCustomRoots(newId); + await reloadGrid(); + } catch (err) { + console.warn("Majoor: add custom root failed", err); + } + }); + + customRemoveBtn.addEventListener("click", async () => { + if (!state.customRootId) return; + const ok = await comfyConfirm("Remove this custom folder?", "Majoor: Custom Folders"); + if (!ok) return; + try { + const json = await post(ENDPOINTS.CUSTOM_ROOTS_REMOVE, { id: state.customRootId }); + if (!json?.ok) { + await comfyAlert(json?.error || "Failed to remove custom folder", "Majoor: Custom Folders"); + return; + } + state.customRootId = ""; + await refreshCustomRoots(); + await reloadGrid(); + } catch (err) { + console.warn("Majoor: remove custom root failed", err); + } + }); + }; + + return { refreshCustomRoots, bind }; +} + diff --git a/js/features/panel/controllers/filtersController.js b/js/features/panel/controllers/filtersController.js new file mode 100644 index 00000000..b107f94f --- /dev/null +++ b/js/features/panel/controllers/filtersController.js @@ -0,0 +1,15 @@ +export function bindFilters({ state, kindSelect, wfCheckbox, ratingSelect, reloadGrid }) { + kindSelect.addEventListener("change", async () => { + state.kindFilter = kindSelect.value || ""; + await reloadGrid(); + }); + wfCheckbox.addEventListener("change", async () => { + state.workflowOnly = Boolean(wfCheckbox.checked); + await reloadGrid(); + }); + ratingSelect.addEventListener("change", async () => { + state.minRating = Number(ratingSelect.value || 0) || 0; + await reloadGrid(); + }); +} + diff --git a/js/features/panel/controllers/gridController.js b/js/features/panel/controllers/gridController.js new file mode 100644 index 00000000..b37924e0 --- /dev/null +++ b/js/features/panel/controllers/gridController.js @@ -0,0 +1,39 @@ +export function createGridController({ gridContainer, loadAssets, disposeGrid, getQuery, state }) { + const reloadGrid = async () => { + gridContainer.dataset.mjrScope = state.scope; + gridContainer.dataset.mjrCustomRootId = state.customRootId || ""; + gridContainer.dataset.mjrFilterKind = state.kindFilter || ""; + gridContainer.dataset.mjrFilterWorkflowOnly = state.workflowOnly ? "1" : "0"; + gridContainer.dataset.mjrFilterMinRating = String(state.minRating || 0); + gridContainer.dataset.mjrSort = state.sort || "mtime_desc"; + + // Keep selection durable across re-renders by persisting it in the dataset + // (GridView re-applies selection as cards are created). + try { + const selected = Array.isArray(state.selectedAssetIds) ? state.selectedAssetIds.filter(Boolean).map(String) : []; + if (selected.length) { + gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(selected); + gridContainer.dataset.mjrSelectedAssetId = String(state.activeAssetId || selected[0] || ""); + } else { + delete gridContainer.dataset.mjrSelectedAssetIds; + delete gridContainer.dataset.mjrSelectedAssetId; + } + } catch {} + + if (state.scope === "custom" && !state.customRootId) { + try { + disposeGrid(gridContainer); + } catch {} + gridContainer.innerHTML = ""; + const p = document.createElement("p"); + p.className = "mjr-muted"; + p.textContent = "Add a custom folder to browse."; + gridContainer.appendChild(p); + return; + } + + await loadAssets(gridContainer, getQuery()); + }; + + return { reloadGrid }; +} diff --git a/js/features/panel/controllers/panelHotkeysController.js b/js/features/panel/controllers/panelHotkeysController.js new file mode 100644 index 00000000..4bbd665d --- /dev/null +++ b/js/features/panel/controllers/panelHotkeysController.js @@ -0,0 +1,143 @@ +// Global state for suspended hotkeys (when dialogs/popovers are open) +// Using window global to make it accessible across modules +if (!window._mjrHotkeysState) { + window._mjrHotkeysState = { suspended: false }; +} + +export function setHotkeysSuspended(suspended) { + window._mjrHotkeysState.suspended = Boolean(suspended); +} + +// Also attach to window for easy access from other modules +window.setHotkeysSuspended = setHotkeysSuspended; + +export function createPanelHotkeysController({ + onTriggerScan, + getScanContext, + onToggleDetails, + allowListKeys, +} = {}) { + let panelFocused = false; + let panelHovered = false; + let boundEl = null; + + const allowed = + allowListKeys instanceof Set + ? allowListKeys + : new Set([ + " ", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "PageUp", + "PageDown", + "Home", + "End", + "Enter", + "Escape", + "Tab", + ]); + + const isPanelActive = () => panelFocused || panelHovered; + + const handlers = { + focusin: () => { + panelFocused = true; + }, + focusout: () => { + panelFocused = false; + }, + mouseenter: () => { + panelHovered = true; + }, + mouseleave: () => { + panelHovered = false; + }, + keydown: (event) => { + // Check if hotkeys are suspended (e.g. when dialogs/popovers are open) + if (window._mjrHotkeysState?.suspended) return; + + // Check if viewer has hotkey priority + if (window._mjrHotkeysState?.scope === "viewer") return; + + // Check if panel is active before processing hotkeys + // This ensures hotkeys only work when the panel is hovered or focused + if (!isPanelActive()) return; + + const lower = event.key?.toLowerCase?.() || ""; + const isTypingTarget = + event.target?.isContentEditable || + event.target?.closest?.("input, textarea, select, [contenteditable='true']"); + + // Ctrl/Cmd+S triggers an index scan for the current scope, without letting the + // surrounding ComfyUI UI capture the shortcut (e.g. workflow save). + if (lower === "s" && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + try { + const ctx = typeof getScanContext === "function" ? getScanContext() : null; + onTriggerScan?.(ctx); + } catch {} + return; + } + + // D toggles the Details sidebar for the current selection. + if (lower === "d" && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (isTypingTarget) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + try { + onToggleDetails?.(); + } catch {} + return; + } + + // For navigation keys, prevent ComfyUI global handlers from stealing focus/scroll. + // Only intercept if panel is actually active + if (allowed.has(event.key) && isPanelActive()) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + } + }, + }; + + const bind = (el) => { + if (!el || boundEl === el) return; + if (boundEl) dispose(); + boundEl = el; + + try { + boundEl.addEventListener("focusin", handlers.focusin); + boundEl.addEventListener("focusout", handlers.focusout); + boundEl.addEventListener("mouseenter", handlers.mouseenter); + boundEl.addEventListener("mouseleave", handlers.mouseleave); + } catch {} + + // Use global listener with capture phase for keydown to ensure it works regardless of focus + try { + window.addEventListener("keydown", handlers.keydown, { capture: true }); + } catch {} + }; + + const dispose = () => { + if (!boundEl) return; + try { + boundEl.removeEventListener("focusin", handlers.focusin); + boundEl.removeEventListener("focusout", handlers.focusout); + boundEl.removeEventListener("mouseenter", handlers.mouseenter); + boundEl.removeEventListener("mouseleave", handlers.mouseleave); + } catch {} + + // Remove global listener for keydown + try { + window.removeEventListener("keydown", handlers.keydown, { capture: true }); + } catch {} + boundEl = null; + }; + + return { bind, dispose }; +} diff --git a/js/features/panel/controllers/query.js b/js/features/panel/controllers/query.js new file mode 100644 index 00000000..154b1a8b --- /dev/null +++ b/js/features/panel/controllers/query.js @@ -0,0 +1,5 @@ +export function normalizeQuery(searchInputEl) { + const raw = String(searchInputEl?.value || "").trim(); + return raw || "*"; +} + diff --git a/js/features/panel/controllers/ratingHotkeysController.js b/js/features/panel/controllers/ratingHotkeysController.js new file mode 100644 index 00000000..3b9b7122 --- /dev/null +++ b/js/features/panel/controllers/ratingHotkeysController.js @@ -0,0 +1,145 @@ +import { updateAssetRating } from "../../../api/client.js"; +import { ASSET_RATING_CHANGED_EVENT } from "../../../app/events.js"; + +const EVENT_NAME = ASSET_RATING_CHANGED_EVENT; + +function getSelectedCards(gridContainer, fallbackEventTarget = null) { + if (!gridContainer) return []; + try { + const selected = Array.from(gridContainer.querySelectorAll(".mjr-asset-card.is-selected")); + if (selected.length) return selected; + } catch {} + + try { + const card = fallbackEventTarget?.closest?.(".mjr-asset-card"); + if (card && gridContainer.contains(card)) return [card]; + } catch {} + + return []; +} + +function updateCardRatingBadge(card, createRatingBadge, rating) { + if (!card) return; + const asset = card._mjrAsset; + if (asset) asset.rating = rating; + + try { + const thumb = card.querySelector(".mjr-thumb"); + if (!thumb) return; + const oldRatingBadge = card.querySelector(".mjr-rating-badge"); + const newBadge = createRatingBadge ? createRatingBadge(rating || 0) : null; + + if (oldRatingBadge && newBadge) { + oldRatingBadge.replaceWith(newBadge); + } else if (oldRatingBadge && !newBadge) { + oldRatingBadge.remove(); + } else if (!oldRatingBadge && newBadge) { + thumb.appendChild(newBadge); + } + } catch {} +} + +export function createRatingHotkeysController({ gridContainer, createRatingBadge } = {}) { + let bound = false; + let onKeyDown = null; + let onRatingEvent = null; + + const applyRatingToCard = async (card, rating) => { + const asset = card?._mjrAsset; + const assetId = asset?.id; + if (assetId == null) return; + + try { + const result = await updateAssetRating(assetId, rating); + if (!result?.ok) return; + } catch { + return; + } + + updateCardRatingBadge(card, createRatingBadge, rating); + }; + + const bind = () => { + if (bound || !gridContainer) return; + bound = true; + + onKeyDown = async (e) => { + // Check if hotkeys are suspended (e.g. when dialogs/popovers are open) + if (window._mjrHotkeysState?.suspended) return; + + // Check if viewer has hotkey priority + if (window._mjrHotkeysState?.scope === "viewer") return; + + if (e.defaultPrevented) return; + const k = String(e.key || ""); + + if (k !== "0" && k !== "1" && k !== "2" && k !== "3" && k !== "4" && k !== "5") return; + + // Check if viewer is open - let it handle rating keys + const viewerOverlay = document.querySelector('.mjr-viewer-overlay'); + if (viewerOverlay && viewerOverlay.style.display !== 'none') return; + + // Check if the event target is within our grid container or if the grid container itself has focus/hover + // This ensures rating hotkeys only work when interacting with the assets manager + const isTargetInGrid = gridContainer.contains(e.target) || gridContainer === e.target; + if (!isTargetInGrid) return; // Only handle rating keys when interacting with our grid + + const rating = k === "0" ? 0 : Number(k); + if (!Number.isFinite(rating)) return; + + const cards = getSelectedCards(gridContainer, e.target); + if (!cards.length) return; + + // Only prevent default and stop propagation if we're actually handling the key + if (cards.length > 0) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation?.(); + } + + // Apply sequentially to avoid hammering the backend on large multi-select. + for (const card of cards) { + await applyRatingToCard(card, rating); + } + }; + + onRatingEvent = (ev) => { + const detail = ev?.detail || {}; + const assetId = detail.assetId ?? detail.id ?? null; + const rating = Number(detail.rating); + if (!assetId || !Number.isFinite(rating)) return; + try { + const card = gridContainer.querySelector( + `.mjr-asset-card[data-mjr-asset-id="${CSS?.escape ? CSS.escape(String(assetId)) : String(assetId)}"]` + ); + if (card) updateCardRatingBadge(card, createRatingBadge, rating); + } catch {} + }; + + try { + // Use window-level listener with capture phase to ensure it works regardless of focus + window.addEventListener("keydown", onKeyDown, { capture: true }); + } catch {} + try { + window.addEventListener(EVENT_NAME, onRatingEvent); + } catch {} + }; + + const dispose = () => { + if (!bound) return; + bound = false; + try { + // Remove window-level listener + window.removeEventListener("keydown", onKeyDown, { capture: true }); + } catch {} + try { + window.removeEventListener(EVENT_NAME, onRatingEvent); + } catch {} + onKeyDown = null; + onRatingEvent = null; + }; + + return { bind, dispose }; +} + +export const RATING_EVENT_NAME = EVENT_NAME; diff --git a/js/features/panel/controllers/scopeController.js b/js/features/panel/controllers/scopeController.js new file mode 100644 index 00000000..fbee0d14 --- /dev/null +++ b/js/features/panel/controllers/scopeController.js @@ -0,0 +1,35 @@ +export function createScopeController({ state, tabButtons, customMenuBtn, customPopover, popovers, refreshCustomRoots, reloadGrid }) { + const setActiveTabStyles = () => { + const buttons = Object.values(tabButtons || {}); + buttons.forEach((b) => { + const scope = b?.dataset?.scope; + const active = scope === state.scope; + b?.classList?.toggle?.("is-active", active); + }); + + customMenuBtn.style.display = state.scope === "custom" ? "inline-flex" : "none"; + if (state.scope !== "custom") { + customPopover.style.display = "none"; + popovers.close(customPopover); + } + }; + + const setScope = async (scope) => { + const allowed = new Set(["output", "outputs", "input", "inputs", "all", "custom"]); + const normalized = String(scope || "").toLowerCase(); + if (!allowed.has(normalized)) return; + + state.scope = normalized === "outputs" ? "output" : normalized === "inputs" ? "input" : normalized; + if (state.scope !== "custom") { + state.customRootId = ""; + } else { + await refreshCustomRoots(); + } + + setActiveTabStyles(); + await reloadGrid(); + }; + + return { setScope, setActiveTabStyles }; +} + diff --git a/js/features/panel/controllers/sidebarController.js b/js/features/panel/controllers/sidebarController.js new file mode 100644 index 00000000..24ef0254 --- /dev/null +++ b/js/features/panel/controllers/sidebarController.js @@ -0,0 +1,457 @@ +import { createWorkflowDot } from "../../../components/Badges.js"; +import { post, getAssetMetadata } from "../../../api/client.js"; +import { ENDPOINTS } from "../../../api/endpoints.js"; + +const RESCAN_FLAG = "_mjrRescanning"; +const RESCAN_TTL_MS = 1500; + +async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) { + if (!card || !asset) return; + + try { + const now = Date.now(); + const last = Number(card[RESCAN_FLAG] || 0) || 0; + if (now - last < RESCAN_TTL_MS) return; + card[RESCAN_FLAG] = now; + } catch {} + + const dot = card.querySelector(".mjr-workflow-dot"); + try { + if (dot) { + dot.style.opacity = "0.6"; + dot.style.cursor = "progress"; + dot.title = "Rescanningâ€Ļ"; + } + } catch {} + + const fileEntry = { + filename: asset.filename, + subfolder: asset.subfolder || "", + type: String(asset.type || "output").toLowerCase(), + root_id: asset.root_id || asset.rootId || asset.custom_root_id || undefined, + }; + + let updated = null; + try { + await post(ENDPOINTS.INDEX_FILES, { files: [fileEntry], incremental: false }); + + if (asset.id != null) { + const fresh = await getAssetMetadata(asset.id); + if (fresh?.ok && fresh.data) { + updated = { ...asset, ...fresh.data }; + } + } + } catch {} + + try { + if (updated) { + card._mjrAsset = updated; + if (typeof onAssetUpdated === "function") onAssetUpdated(updated); + + // If sidebar is open on this asset, refresh it without changing selection. + try { + if (sidebar?.classList?.contains?.("is-open")) { + const curId = sidebar?._currentAsset?.id ?? sidebar?._currentFullAsset?.id ?? null; + if (curId != null && String(curId) === String(updated.id)) { + // The caller owns showAssetInSidebar; handled externally. + sidebar._mjrNeedsRefresh = true; + } + } + } catch {} + } + } finally { + try { + if (dot) { + dot.style.opacity = ""; + dot.style.cursor = ""; + } + } catch {} + } + + return updated; +} + +function setSelectedCard(gridContainer, selectedCard) { + if (!gridContainer) return; + try { + const prev = gridContainer.querySelector(".mjr-asset-card.is-selected"); + if (prev && prev !== selectedCard) { + prev.classList.remove("is-selected"); + prev.setAttribute?.("aria-selected", "false"); + } + } catch {} + if (selectedCard) { + selectedCard.classList.add("is-selected"); + selectedCard.setAttribute?.("aria-selected", "true"); + try { + const id = selectedCard.dataset?.mjrAssetId; + if (id) gridContainer.dataset.mjrSelectedAssetId = String(id); + } catch {} + try { + selectedCard.focus?.({ preventScroll: true }); + } catch { + try { + selectedCard.focus?.(); + } catch {} + } + try { + selectedCard.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + } catch {} + } +} + +function ensureSelectionVisible(gridContainer) { + if (!gridContainer) return; + + const cssEscape = (value) => { + try { + return CSS?.escape ? CSS.escape(String(value)) : String(value).replace(/["\\]/g, "\\$&"); + } catch { + return String(value).replace(/["\\]/g, "\\$&"); + } + }; + + try { + const selected = + gridContainer.querySelector(".mjr-asset-card.is-selected") || + (gridContainer.dataset?.mjrSelectedAssetId + ? gridContainer.querySelector( + `.mjr-asset-card[data-mjr-asset-id="${cssEscape(gridContainer.dataset.mjrSelectedAssetId)}"]` + ) + : null); + if (!selected) return; + selected.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + } catch {} +} + +function setCardSelected(card, selected) { + if (!card) return; + if (selected) { + card.classList.add("is-selected"); + card.setAttribute?.("aria-selected", "true"); + } else { + card.classList.remove("is-selected"); + card.setAttribute?.("aria-selected", "false"); + } +} + +function clearAllSelected(gridContainer) { + if (!gridContainer) return; + try { + for (const el of gridContainer.querySelectorAll(".mjr-asset-card.is-selected")) { + setCardSelected(el, false); + } + } catch {} +} + +function getAllCards(gridContainer) { + try { + return Array.from(gridContainer.querySelectorAll(".mjr-asset-card")); + } catch { + return []; + } +} + +function updateSelectedIdsDataset(gridContainer) { + if (!gridContainer) return; + try { + const ids = Array.from(gridContainer.querySelectorAll(".mjr-asset-card.is-selected")) + .map((c) => c?.dataset?.mjrAssetId) + .filter(Boolean); + gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(ids); + } catch {} +} + +function syncSelectionState({ gridContainer, state, activeId } = {}) { + if (!state || !gridContainer) return; + try { + let selected = []; + if (gridContainer.dataset?.mjrSelectedAssetIds) { + try { + const parsed = JSON.parse(gridContainer.dataset.mjrSelectedAssetIds); + if (Array.isArray(parsed)) selected = parsed.map(String).filter(Boolean); + } catch {} + } else if (gridContainer.dataset?.mjrSelectedAssetId) { + selected = [String(gridContainer.dataset.mjrSelectedAssetId)].filter(Boolean); + } else { + selected = Array.from(gridContainer.querySelectorAll(".mjr-asset-card.is-selected")) + .map((c) => c?.dataset?.mjrAssetId) + .filter(Boolean) + .map(String); + } + + state.selectedAssetIds = selected; + if (activeId != null) state.activeAssetId = String(activeId); + else if (state.activeAssetId && selected.includes(String(state.activeAssetId))) { + // keep existing active + } else { + state.activeAssetId = selected[0] || ""; + } + } catch {} +} + +export function bindSidebarOpen({ + gridContainer, + sidebar, + createRatingBadge, + createTagsBadge, + showAssetInSidebar, + closeSidebar, + state +}) { + if (!gridContainer) return { dispose: () => {} }; + if (gridContainer._mjrSidebarOpenBound) { + return { dispose: gridContainer._mjrSidebarOpenDispose || (() => {}) }; + } + gridContainer._mjrSidebarOpenBound = true; + + // Keep selection visible when the panel/grid resizes (column wrap changes can move the selected card). + try { + if (!gridContainer._mjrSelectionResizeObserver) { + const scrollRoot = gridContainer.parentElement; + let raf = null; + const schedule = () => { + if (raf) cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + raf = null; + ensureSelectionVisible(gridContainer); + }); + }; + + const ro = new ResizeObserver(() => schedule()); + if (scrollRoot) ro.observe(scrollRoot); + ro.observe(gridContainer); + gridContainer._mjrSelectionResizeObserver = ro; + } + } catch {} + + const applyAssetUpdateToCard = (card, asset, updatedAsset) => { + // Keep workflow/gen flags (and dot) in sync when backend self-heals metadata. + try { + const nextHasWorkflow = updatedAsset?.has_workflow ?? updatedAsset?.hasWorkflow ?? asset.has_workflow; + const nextHasGen = + updatedAsset?.has_generation_metadata ?? + updatedAsset?.has_generation_data ?? + updatedAsset?.hasGenerationData ?? + asset.has_generation_metadata; + + if (nextHasWorkflow !== undefined) asset.has_workflow = nextHasWorkflow; + if (nextHasGen !== undefined) asset.has_generation_metadata = nextHasGen; + + const oldDot = card.querySelector(".mjr-workflow-dot"); + const newDot = createWorkflowDot(asset); + if (oldDot && newDot) oldDot.replaceWith(newDot); + } catch {} + + if (updatedAsset?.rating !== undefined && updatedAsset.rating !== asset.rating) { + asset.rating = updatedAsset.rating; + const thumb = card.querySelector(".mjr-thumb"); + const oldRatingBadge = card.querySelector(".mjr-rating-badge"); + const newBadge = createRatingBadge(updatedAsset.rating || 0); + + if (oldRatingBadge && newBadge) { + oldRatingBadge.replaceWith(newBadge); + } else if (oldRatingBadge && !newBadge) { + oldRatingBadge.remove(); + } else if (!oldRatingBadge && newBadge && thumb) { + thumb.appendChild(newBadge); + } + } + if (updatedAsset?.tags && JSON.stringify(updatedAsset.tags) !== JSON.stringify(asset.tags)) { + asset.tags = updatedAsset.tags; + const oldTagsBadge = card.querySelector(".mjr-tags-badge"); + if (oldTagsBadge) { + const newBadge = createTagsBadge(updatedAsset.tags || []); + oldTagsBadge.replaceWith(newBadge); + } + } + }; + + const onClick = async (e) => { + if (e.defaultPrevented) return; + + const card = e.target.closest(".mjr-asset-card"); + if (!card) return; + + // Click on workflow dot triggers a forced re-index of this one file (no directory scan). + const dot = e.target.closest(".mjr-workflow-dot"); + if (dot && card.contains(dot)) { + const asset = card._mjrAsset; + if (!asset) return; + + e.preventDefault(); + e.stopPropagation(); + + // Ensure this card is selected, but do not open the sidebar from dot clicks. + clearAllSelected(gridContainer); + setSelectedCard(gridContainer, card); + updateSelectedIdsDataset(gridContainer); + syncSelectionState({ gridContainer, state, activeId: card?.dataset?.mjrAssetId }); + + const updated = await rescanSingleAsset({ + card, + asset, + sidebar, + onAssetUpdated: (u) => applyAssetUpdateToCard(card, asset, u), + }); + + // Refresh sidebar only if it is already open on this asset. + try { + const curId = sidebar?._currentAsset?.id ?? sidebar?._currentFullAsset?.id ?? null; + if (updated && sidebar?.classList?.contains?.("is-open") && curId != null && String(curId) === String(updated.id)) { + await showAssetInSidebar(sidebar, updated, (u) => applyAssetUpdateToCard(card, asset, u)); + } + } catch {} + + return; + } + + const interactive = e.target.closest("a, button, input, textarea, select, label"); + if (interactive && card.contains(interactive)) return; + + const asset = card._mjrAsset; + if (!asset) return; + + e.preventDefault(); + e.stopPropagation(); + + const isMulti = !!(e.ctrlKey || e.metaKey || e.shiftKey); + if (!isMulti) { + clearAllSelected(gridContainer); + setSelectedCard(gridContainer, card); + updateSelectedIdsDataset(gridContainer); + syncSelectionState({ gridContainer, state, activeId: card?.dataset?.mjrAssetId }); + + // When the sidebar is already open, clicking a different card navigates details. + try { + if (sidebar?.classList?.contains?.("is-open")) { + showAssetInSidebar(sidebar, asset, (updatedAsset) => applyAssetUpdateToCard(card, asset, updatedAsset)); + } + } catch {} + } else { + const cards = getAllCards(gridContainer); + const idx = cards.indexOf(card); + + if (e.shiftKey && idx >= 0) { + const last = Number(gridContainer._mjrLastSelectedIndex); + const anchor = Number.isFinite(last) ? last : idx; + clearAllSelected(gridContainer); + const start = Math.min(anchor, idx); + const end = Math.max(anchor, idx); + for (let i = start; i <= end; i++) { + setCardSelected(cards[i], true); + } + gridContainer._mjrLastSelectedIndex = idx; + updateSelectedIdsDataset(gridContainer); + syncSelectionState({ gridContainer, state, activeId: card?.dataset?.mjrAssetId }); + return; + } + + if (idx >= 0) { + const nextSelected = !card.classList.contains("is-selected"); + setCardSelected(card, nextSelected); + gridContainer._mjrLastSelectedIndex = idx; + } + updateSelectedIdsDataset(gridContainer); + syncSelectionState({ gridContainer, state, activeId: card?.dataset?.mjrAssetId }); + return; + } + // Click only selects the asset (sidebar opens via hotkey/context menu). + try { + ensureSelectionVisible(gridContainer); + card?.focus?.({ preventScroll: true }); + } catch {} + }; + + gridContainer.addEventListener("click", onClick); + + const openDetailsForSelection = async () => { + const activeId = state?.activeAssetId ? String(state.activeAssetId) : ""; + let card = null; + if (activeId) { + try { + card = gridContainer.querySelector(`.mjr-asset-card[data-mjr-asset-id="${CSS?.escape ? CSS.escape(activeId) : activeId}"]`); + } catch { + try { + card = gridContainer.querySelector(`.mjr-asset-card[data-mjr-asset-id="${activeId.replace(/["\\]/g, "\\$&")}"]`); + } catch {} + } + } + if (!card) { + card = gridContainer.querySelector(".mjr-asset-card.is-selected"); + } + const asset = card?._mjrAsset; + if (!asset) return; + + try { + const isOpen = !!sidebar?.classList?.contains?.("is-open"); + const curId = sidebar?._currentAsset?.id ?? sidebar?._currentFullAsset?.id ?? null; + if (isOpen && curId != null && String(curId) === String(asset.id) && typeof closeSidebar === "function") { + closeSidebar(sidebar); + return; + } + } catch {} + + try { + await showAssetInSidebar(sidebar, asset, (updatedAsset) => applyAssetUpdateToCard(card, asset, updatedAsset)); + ensureSelectionVisible(gridContainer); + card?.focus?.({ preventScroll: true }); + } catch {} + }; + + // Keyboard accessibility: toggle details from focused card. + const onKeyDown = (e) => { + if (e.defaultPrevented) return; + const lower = e.key?.toLowerCase?.() || ""; + if (lower !== "d") return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const isTypingTarget = + e.target?.isContentEditable || + e.target?.closest?.("input, textarea, select, [contenteditable='true']"); + if (isTypingTarget) return; + const card = e.target?.closest?.(".mjr-asset-card"); + if (!card) return; + const asset = card._mjrAsset; + if (!asset) return; + e.preventDefault(); + e.stopPropagation(); + setSelectedCard(gridContainer, card); + updateSelectedIdsDataset(gridContainer); + syncSelectionState({ gridContainer, state, activeId: card?.dataset?.mjrAssetId }); + openDetailsForSelection(); + }; + gridContainer.addEventListener("keydown", onKeyDown); + + try { + gridContainer._mjrOpenDetails = openDetailsForSelection; + } catch {} + + const dispose = () => { + try { + gridContainer.removeEventListener("click", onClick); + } catch {} + try { + gridContainer.removeEventListener("keydown", onKeyDown); + } catch {} + try { + if (gridContainer._mjrOpenDetails === openDetailsForSelection) { + delete gridContainer._mjrOpenDetails; + } + } catch {} + try { + gridContainer._mjrSelectionResizeObserver?.disconnect?.(); + } catch {} + try { + delete gridContainer._mjrSelectionResizeObserver; + } catch {} + try { + delete gridContainer._mjrSidebarOpenBound; + } catch {} + try { + delete gridContainer._mjrSidebarOpenDispose; + } catch {} + }; + + gridContainer._mjrSidebarOpenDispose = dispose; + return { dispose, toggleDetails: openDetailsForSelection }; +} diff --git a/js/features/panel/controllers/sortController.js b/js/features/panel/controllers/sortController.js new file mode 100644 index 00000000..e324a0a7 --- /dev/null +++ b/js/features/panel/controllers/sortController.js @@ -0,0 +1,63 @@ +export function createSortController({ state, sortBtn, sortMenu, sortPopover, popovers, reloadGrid }) { + const sortOptions = [ + { key: "mtime_desc", label: "Newest first" }, + { key: "mtime_asc", label: "Oldest first" }, + { key: "name_asc", label: "Name A → Z" }, + { key: "name_desc", label: "Name Z → A" }, + ]; + + const setSortIcon = (key) => { + const icon = sortBtn.querySelector("i"); + if (!icon) return; + if (key === "name_asc") icon.className = "pi pi-sort-alpha-down"; + else if (key === "name_desc") icon.className = "pi pi-sort-alpha-up"; + else if (key === "mtime_asc") icon.className = "pi pi-sort-amount-up-alt"; + else icon.className = "pi pi-sort-amount-down"; + }; + + const renderSortMenu = () => { + sortMenu.replaceChildren(); + for (const opt of sortOptions) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "mjr-menu-item"; + + const label = document.createElement("span"); + label.className = "mjr-menu-item-label"; + label.textContent = opt.label; + + const check = document.createElement("i"); + check.className = "pi pi-check mjr-menu-item-check"; + check.style.opacity = opt.key === state.sort ? "1" : "0"; + + btn.appendChild(label); + btn.appendChild(check); + + btn.addEventListener("click", async () => { + state.sort = opt.key; + setSortIcon(state.sort); + renderSortMenu(); + sortPopover.style.display = "none"; + popovers.close(sortPopover); + await reloadGrid(); + }); + + sortMenu.appendChild(btn); + } + }; + + const bind = ({ onBeforeToggle } = {}) => { + setSortIcon(state.sort); + renderSortMenu(); + sortBtn.addEventListener("click", (e) => { + e.stopPropagation(); + try { + onBeforeToggle?.(); + } catch {} + renderSortMenu(); + popovers.toggle(sortPopover, sortBtn); + }); + }; + + return { bind, renderSortMenu, setSortIcon }; +} diff --git a/js/features/panel/state/panelState.js b/js/features/panel/state/panelState.js new file mode 100644 index 00000000..bbc6e0bc --- /dev/null +++ b/js/features/panel/state/panelState.js @@ -0,0 +1,14 @@ +export function createPanelState() { + return { + scope: "output", + customRootId: "", + kindFilter: "", + workflowOnly: false, + minRating: 0, + sort: "mtime_desc", + // Selection state (source of truth) for durable selection across grid re-renders. + // Stored as strings because DOM dataset values are strings. + activeAssetId: "", + selectedAssetIds: [] + }; +} diff --git a/js/features/panel/views/customPopoverView.js b/js/features/panel/views/customPopoverView.js new file mode 100644 index 00000000..65b1c47c --- /dev/null +++ b/js/features/panel/views/customPopoverView.js @@ -0,0 +1,46 @@ +export function createCustomPopoverView() { + const customPopover = document.createElement("div"); + customPopover.className = "mjr-popover mjr-custom-popover"; + customPopover.style.display = "none"; + + const customRow = document.createElement("div"); + customRow.className = "mjr-popover-row"; + + const customLabel = document.createElement("div"); + customLabel.className = "mjr-popover-label"; + customLabel.textContent = "Folder"; + + const customSelect = document.createElement("select"); + customSelect.className = "mjr-select"; + + customRow.appendChild(customLabel); + customRow.appendChild(customSelect); + + const customActionsRow = document.createElement("div"); + customActionsRow.className = "mjr-popover-row"; + + const customAddBtn = document.createElement("button"); + customAddBtn.type = "button"; + customAddBtn.textContent = "Addâ€Ļ"; + customAddBtn.classList.add("mjr-btn"); + + const customRemoveBtn = document.createElement("button"); + customRemoveBtn.type = "button"; + customRemoveBtn.textContent = "Remove"; + customRemoveBtn.classList.add("mjr-btn"); + customRemoveBtn.disabled = true; + + customActionsRow.appendChild(customAddBtn); + customActionsRow.appendChild(customRemoveBtn); + + customPopover.appendChild(customRow); + customPopover.appendChild(customActionsRow); + + return { + customPopover, + customSelect, + customAddBtn, + customRemoveBtn + }; +} + diff --git a/js/features/panel/views/filterPopoverView.js b/js/features/panel/views/filterPopoverView.js new file mode 100644 index 00000000..70dc5b68 --- /dev/null +++ b/js/features/panel/views/filterPopoverView.js @@ -0,0 +1,75 @@ +export function createFilterPopoverView() { + const filterPopover = document.createElement("div"); + filterPopover.className = "mjr-popover mjr-filter-popover"; + filterPopover.style.display = "none"; + + const kindRow = document.createElement("div"); + kindRow.className = "mjr-popover-row"; + const kindLabel = document.createElement("div"); + kindLabel.className = "mjr-popover-label"; + kindLabel.textContent = "Type"; + const kindSelect = document.createElement("select"); + kindSelect.className = "mjr-select"; + [ + ["", "All"], + ["image", "Image"], + ["video", "Video"], + ["gif", "GIF"], + ["webp", "WebP"], + ["audio", "Audio"], + ["model3d", "3D"], + ].forEach(([val, text]) => { + const opt = document.createElement("option"); + opt.value = val; + opt.textContent = text; + kindSelect.appendChild(opt); + }); + kindRow.appendChild(kindLabel); + kindRow.appendChild(kindSelect); + + const wfRow = document.createElement("div"); + wfRow.className = "mjr-popover-row"; + const wfLabel = document.createElement("div"); + wfLabel.className = "mjr-popover-label"; + wfLabel.textContent = "Workflow"; + const wfToggle = document.createElement("label"); + wfToggle.className = "mjr-popover-toggle"; + const wfCheckbox = document.createElement("input"); + wfCheckbox.type = "checkbox"; + const wfText = document.createElement("span"); + wfText.textContent = "Only with workflow"; + wfToggle.appendChild(wfCheckbox); + wfToggle.appendChild(wfText); + wfRow.appendChild(wfLabel); + wfRow.appendChild(wfToggle); + + const ratingRow = document.createElement("div"); + ratingRow.className = "mjr-popover-row"; + const ratingLabel = document.createElement("div"); + ratingLabel.className = "mjr-popover-label"; + ratingLabel.textContent = "Rating"; + const ratingSelect = document.createElement("select"); + ratingSelect.className = "mjr-select"; + [ + [0, "Any"], + [1, "★ 1"], + [2, "★ 2"], + [3, "★ 3"], + [4, "★ 4"], + [5, "★ 5"], + ].forEach(([val, text]) => { + const opt = document.createElement("option"); + opt.value = String(val); + opt.textContent = text; + ratingSelect.appendChild(opt); + }); + ratingRow.appendChild(ratingLabel); + ratingRow.appendChild(ratingSelect); + + filterPopover.appendChild(kindRow); + filterPopover.appendChild(wfRow); + filterPopover.appendChild(ratingRow); + + return { filterPopover, kindSelect, wfCheckbox, ratingSelect }; +} + diff --git a/js/features/panel/views/headerView.js b/js/features/panel/views/headerView.js new file mode 100644 index 00000000..1e9270ce --- /dev/null +++ b/js/features/panel/views/headerView.js @@ -0,0 +1,56 @@ +import { createIconButton } from "./iconButton.js"; +import { createTabsView } from "./tabsView.js"; + +export function createHeaderView() { + const header = document.createElement("div"); + header.classList.add("mjr-am-header"); + + const headerRow = document.createElement("div"); + headerRow.classList.add("mjr-am-header-row"); + + const headerLeft = document.createElement("div"); + headerLeft.classList.add("mjr-am-header-left"); + + const headerIcon = document.createElement("i"); + headerIcon.className = "pi pi-images mjr-am-header-icon"; + + const headerTitle = document.createElement("div"); + headerTitle.classList.add("mjr-am-header-title"); + headerTitle.textContent = "Assets Manager"; + + headerLeft.appendChild(headerIcon); + headerLeft.appendChild(headerTitle); + + const headerActions = document.createElement("div"); + headerActions.classList.add("mjr-am-header-actions"); + + const headerTools = document.createElement("div"); + headerTools.classList.add("mjr-am-header-tools"); + + const { tabs, tabButtons } = createTabsView(); + headerActions.appendChild(tabs); + headerActions.appendChild(headerTools); + + const customMenuBtn = createIconButton("pi-folder-open", "Custom folders"); + const filterBtn = createIconButton("pi-filter", "Filters"); + const sortBtn = createIconButton("pi-sort", "Sort"); + + customMenuBtn.style.display = "none"; + headerTools.appendChild(customMenuBtn); + + headerRow.appendChild(headerLeft); + headerRow.appendChild(headerActions); + header.appendChild(headerRow); + + return { + header, + headerActions, + headerTools, + tabs, + tabButtons, + customMenuBtn, + filterBtn, + sortBtn + }; +} + diff --git a/js/features/panel/views/iconButton.js b/js/features/panel/views/iconButton.js new file mode 100644 index 00000000..41e462d1 --- /dev/null +++ b/js/features/panel/views/iconButton.js @@ -0,0 +1,11 @@ +export function createIconButton(iconClass, title) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "mjr-icon-btn"; + btn.title = title; + const i = document.createElement("i"); + i.className = `pi ${iconClass}`; + btn.appendChild(i); + return btn; +} + diff --git a/js/features/panel/views/popoverManager.js b/js/features/panel/views/popoverManager.js new file mode 100644 index 00000000..7b6c37f9 --- /dev/null +++ b/js/features/panel/views/popoverManager.js @@ -0,0 +1,215 @@ +export function createPopoverManager(container) { + const openPopovers = new Map(); + let resizeObserver = null; + let repositionRaf = 0; + let dismissWhitelist = []; + let containerScrollHandlerBound = false; + + const attachPopoverToPortal = (popover) => { + if (!popover || !(popover instanceof HTMLElement)) return; + if (!popover._mjrPortal) { + popover._mjrPortal = { + parent: popover.parentNode, + nextSibling: popover.nextSibling + }; + } + popover.classList.add("mjr-popover-portal"); + try { + document.body.appendChild(popover); + } catch {} + }; + + const restorePopoverFromPortal = (popover) => { + const info = popover?._mjrPortal; + if (!info) return; + popover.classList.remove("mjr-popover-portal"); + try { + const parent = info.parent; + if (parent && parent instanceof HTMLElement) { + if (info.nextSibling && info.nextSibling.parentNode === parent) { + parent.insertBefore(popover, info.nextSibling); + } else { + parent.appendChild(popover); + } + } + } catch {} + }; + + const positionPopover = (popover, anchor) => { + if (!popover || !anchor) return; + try { + const anchorRect = anchor.getBoundingClientRect(); + const panelRect = container.getBoundingClientRect(); + const gap = 8; + const pad = 8; + + attachPopoverToPortal(popover); + + popover.style.position = "fixed"; + popover.style.zIndex = "2147483647"; + popover.style.margin = "0"; + + const maxWidth = Math.max( + 200, + Math.min(360, panelRect.width - pad * 2, window.innerWidth - pad * 2) + ); + popover.style.maxWidth = `${maxWidth}px`; + + const popW = popover.offsetWidth || Math.min(280, maxWidth); + const popH = popover.offsetHeight || 1; + + // Align to the anchor's left edge (ComfyUI-like), then clamp inside the panel. + let left = anchorRect.left; + left = Math.max(panelRect.left + pad, Math.min(left, panelRect.right - popW - pad)); + + let top = anchorRect.bottom + gap; + const bottomLimit = Math.min(panelRect.bottom, window.innerHeight) - pad; + const topLimit = Math.max(panelRect.top, 0) + pad; + + if (top + popH > bottomLimit && anchorRect.top - gap - popH >= topLimit) { + top = anchorRect.top - gap - popH; + } + + top = Math.max(topLimit, Math.min(top, bottomLimit - 80)); + const availableBelow = bottomLimit - top; + popover.style.maxHeight = `${Math.max(140, availableBelow)}px`; + popover.style.overflow = "auto"; + + popover.style.left = `${Math.round(left)}px`; + popover.style.top = `${Math.round(top)}px`; + } catch {} + }; + + const scheduleReposition = () => { + if (!openPopovers.size) return; + if (repositionRaf) cancelAnimationFrame(repositionRaf); + repositionRaf = requestAnimationFrame(() => { + repositionRaf = 0; + for (const item of openPopovers.values()) positionPopover(item.popover, item.anchor); + }); + }; + + const close = (popover) => { + if (!popover) return; + try { + popover.style.display = "none"; + } catch {} + try { + restorePopoverFromPortal(popover); + } catch {} + openPopovers.delete(popover); + }; + + const open = (popover, anchor) => { + if (!popover || !anchor) return; + openPopovers.set(popover, { popover, anchor }); + positionPopover(popover, anchor); + scheduleReposition(); + }; + + const toggle = (popover, anchor) => { + if (!popover || !anchor) return; + const isOpen = openPopovers.has(popover) || popover.style.display === "block"; + const next = isOpen ? "none" : "block"; + popover.style.display = next; + if (next !== "none") open(popover, anchor); + else close(popover); + }; + + const closeAll = () => { + for (const item of openPopovers.values()) { + try { + restorePopoverFromPortal(item.popover); + } catch {} + } + for (const popover of Array.from(openPopovers.keys())) close(popover); + openPopovers.clear(); + }; + + const setDismissWhitelist = (elements) => { + dismissWhitelist = Array.isArray(elements) ? elements.filter(Boolean) : []; + }; + + const onDocMouseDown = (event) => { + const target = event.target; + if (!target) return; + // Clicking inside an open popover (or its anchor) should not dismiss it. + for (const item of openPopovers.values()) { + try { + if (item?.popover && item.popover.contains(target)) return; + } catch {} + try { + if (item?.anchor && item.anchor.contains(target)) return; + } catch {} + } + for (const el of dismissWhitelist) { + try { + if (el && typeof el.contains === "function" && el.contains(target)) return; + } catch {} + } + closeAll(); + }; + + const init = () => { + document.addEventListener("mousedown", onDocMouseDown); + try { + window.addEventListener("resize", scheduleReposition); + window.addEventListener("scroll", scheduleReposition, true); + resizeObserver = new ResizeObserver(scheduleReposition); + resizeObserver.observe(container); + } catch {} + try { + // Some scroll containers (nested panels) don't reliably trigger the window capture listener + // across all browsers; bind to the panel root as a fallback. + if (!containerScrollHandlerBound) { + container.addEventListener("scroll", scheduleReposition, true); + containerScrollHandlerBound = true; + } + } catch {} + }; + + const dispose = () => { + try { + document.removeEventListener("mousedown", onDocMouseDown); + } catch {} + try { + if (resizeObserver) resizeObserver.disconnect(); + } catch {} + resizeObserver = null; + try { + window.removeEventListener("resize", scheduleReposition); + } catch {} + try { + window.removeEventListener("scroll", scheduleReposition, true); + } catch {} + try { + if (containerScrollHandlerBound) { + container.removeEventListener("scroll", scheduleReposition, true); + } + } catch {} + containerScrollHandlerBound = false; + try { + closeAll(); + } catch {} + }; + + init(); + + return { + open, + close, + toggle, + closeAll, + scheduleReposition, + setDismissWhitelist, + dispose + }; +} + +export function getPopoverManagerForElement(el) { + try { + const host = el?.closest?.(".mjr-am-container"); + return host?._mjrPopoverManager || null; + } catch {} + return null; +} diff --git a/js/features/panel/views/searchView.js b/js/features/panel/views/searchView.js new file mode 100644 index 00000000..1bd5b175 --- /dev/null +++ b/js/features/panel/views/searchView.js @@ -0,0 +1,43 @@ +export function createSearchView({ filterBtn, sortBtn, filterPopover, sortPopover }) { + const searchSection = document.createElement("div"); + searchSection.classList.add("mjr-am-search"); + + const searchInner = document.createElement("div"); + searchInner.classList.add("mjr-am-search-pill"); + + const searchIcon = document.createElement("span"); + searchIcon.classList.add("mjr-am-search-icon"); + const searchIconEl = document.createElement("i"); + searchIconEl.className = "pi pi-search"; + searchIcon.appendChild(searchIconEl); + + const searchInputEl = document.createElement("input"); + searchInputEl.type = "text"; + searchInputEl.id = "mjr-search-input"; + searchInputEl.classList.add("mjr-input"); + searchInputEl.placeholder = "Search assets..."; + + searchInner.appendChild(searchIcon); + searchInner.appendChild(searchInputEl); + searchSection.appendChild(searchInner); + + const searchTools = document.createElement("div"); + searchTools.className = "mjr-am-search-tools"; + + const filterAnchor = document.createElement("div"); + filterAnchor.className = "mjr-popover-anchor"; + filterAnchor.appendChild(filterBtn); + filterAnchor.appendChild(filterPopover); + + const sortAnchor = document.createElement("div"); + sortAnchor.className = "mjr-popover-anchor"; + sortAnchor.appendChild(sortBtn); + sortAnchor.appendChild(sortPopover); + + searchTools.appendChild(filterAnchor); + searchTools.appendChild(sortAnchor); + searchSection.appendChild(searchTools); + + return { searchSection, searchInputEl }; +} + diff --git a/js/features/panel/views/sortPopoverView.js b/js/features/panel/views/sortPopoverView.js new file mode 100644 index 00000000..436dc56a --- /dev/null +++ b/js/features/panel/views/sortPopoverView.js @@ -0,0 +1,12 @@ +export function createSortPopoverView() { + const sortPopover = document.createElement("div"); + sortPopover.className = "mjr-popover mjr-sort-popover"; + sortPopover.style.display = "none"; + + const sortMenu = document.createElement("div"); + sortMenu.className = "mjr-menu"; + sortPopover.appendChild(sortMenu); + + return { sortPopover, sortMenu }; +} + diff --git a/js/features/panel/views/tabsView.js b/js/features/panel/views/tabsView.js new file mode 100644 index 00000000..6e5c69e1 --- /dev/null +++ b/js/features/panel/views/tabsView.js @@ -0,0 +1,28 @@ +export function createTabsView() { + const tabs = document.createElement("div"); + tabs.classList.add("mjr-tabs"); + + const makeTab = (label, scope) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + btn.dataset.scope = scope; + btn.classList.add("mjr-tab"); + return btn; + }; + + const tabAll = makeTab("All", "all"); + const tabInputs = makeTab("Inputs", "input"); + const tabOutputs = makeTab("Outputs", "output"); + const tabCustom = makeTab("Custom", "custom"); + tabs.appendChild(tabAll); + tabs.appendChild(tabInputs); + tabs.appendChild(tabOutputs); + tabs.appendChild(tabCustom); + + return { + tabs, + tabButtons: { tabAll, tabInputs, tabOutputs, tabCustom } + }; +} + diff --git a/js/features/status/StatusDot.js b/js/features/status/StatusDot.js new file mode 100644 index 00000000..3bb8250d --- /dev/null +++ b/js/features/status/StatusDot.js @@ -0,0 +1,509 @@ +/** + * Status Dot Feature - Health indicator + */ + +import { get, post } from "../../api/client.js"; +import { ENDPOINTS } from "../../api/endpoints.js"; +import { APP_CONFIG } from "../../app/config.js"; + +const TOOL_CAPABILITIES = [ + { + key: "exiftool", + label: "ExifTool metadata", + hint: "PNG/WEBP workflow data (uses ExifTool)" + }, + { + key: "ffprobe", + label: "FFprobe video stats", + hint: "Video duration, FPS, and resolution (uses FFprobe)" + } +]; + +function setStatusLines(statusText, lines, footerText = null) { + if (!statusText) return; + statusText.replaceChildren(); + + const normalizedLines = Array.isArray(lines) ? lines.filter((l) => l != null && String(l).trim() !== "") : []; + normalizedLines.forEach((line, index) => { + if (index > 0) statusText.appendChild(document.createElement("br")); + statusText.appendChild(document.createTextNode(String(line))); + }); + + if (footerText != null && String(footerText).trim() !== "") { + statusText.appendChild(document.createElement("br")); + const span = document.createElement("span"); + span.style.fontSize = "11px"; + span.style.opacity = "0.6"; + span.textContent = String(footerText); + statusText.appendChild(span); + } +} + +function setStatusWithHint(statusText, mainText, hintText) { + if (!statusText) return; + statusText.replaceChildren(); + statusText.appendChild(document.createTextNode(String(mainText || ""))); + statusText.appendChild(document.createElement("br")); + const span = document.createElement("span"); + span.style.fontSize = "11px"; + span.style.opacity = "0.7"; + span.textContent = String(hintText || ""); + statusText.appendChild(span); +} + +/** + * Create status indicator section + */ +export function createStatusIndicator() { + const section = document.createElement("div"); + section.style.cssText = "margin-bottom: 20px; padding: 12px; background: var(--bg-color, #1a1a1a); border-radius: 6px; border: 1px solid var(--border-color, #333); cursor: pointer; transition: all 0.2s;"; + + // Header container + const header = document.createElement("div"); + header.style.cssText = "display: flex; align-items: center; gap: 10px; margin-bottom: 8px;"; + + // Status dot + const statusDot = document.createElement("span"); + statusDot.id = "mjr-status-dot"; + statusDot.style.cssText = "width: 12px; height: 12px; border-radius: 50%; background: #FFA726; display: inline-block; cursor: pointer;"; + + // Title container + const titleSpan = document.createElement("span"); + titleSpan.id = "mjr-status-title"; + titleSpan.style.cssText = "font-weight: 500; font-size: 13px; cursor: pointer;"; + + const indicator = document.createElement("span"); + indicator.id = "mjr-status-title-indicator"; + indicator.style.marginRight = "6px"; + indicator.textContent = "▾"; + + titleSpan.appendChild(indicator); + titleSpan.appendChild(document.createTextNode("Index Status")); + + header.appendChild(statusDot); + header.appendChild(titleSpan); + + // Body container + const body = document.createElement("div"); + body.id = "mjr-status-body"; + body.style.marginLeft = "22px"; + + // Status text + const statusText = document.createElement("div"); + statusText.id = "mjr-status-text"; + statusText.style.cssText = "font-size: 12px; opacity: 0.8;"; + statusText.textContent = "Checking..."; + + // Capabilities section + const capabilities = document.createElement("div"); + capabilities.id = "mjr-status-capabilities"; + capabilities.style.cssText = "font-size: 11px; opacity: 0.75; margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap;"; + capabilities.textContent = "Capabilities: checking..."; + + body.appendChild(statusText); + body.appendChild(capabilities); + + section.appendChild(header); + section.appendChild(body); + + + + const title = section.querySelector("#mjr-status-title"); + if (title) { + title.addEventListener("click", (event) => { + event.stopPropagation(); + const body = section.querySelector("#mjr-status-body"); + if (!body) return; + const collapsed = body.style.display === "none"; + setBodyCollapsed(section, !collapsed); + }); + } + + setBodyCollapsed(section, false); + // Add hover effect + section.onmouseenter = () => { + section.style.borderColor = "var(--mjr-accent-border, var(--border-color, #333))"; + section.style.background = "var(--mjr-accent-bg, var(--bg-color, #1a1a1a))"; + }; + section.onmouseleave = () => { + section.style.borderColor = "var(--border-color, #333)"; + section.style.background = "var(--bg-color, #1a1a1a)"; + }; + + return section; +} + +/** + * Trigger a scan + */ +export async function triggerScan(statusDot, statusText, capabilitiesSection = null, scanTarget = null) { + const desiredScope = String(scanTarget?.scope || "output").toLowerCase(); + const desiredCustomRootId = scanTarget?.customRootId || scanTarget?.custom_root_id || scanTarget?.root_id || null; + + // Fetch roots so we can show meaningful paths + let roots = null; + try { + const rootsResult = await get(ENDPOINTS.ROOTS); + if (rootsResult.ok) roots = rootsResult.data; + } catch {} + + // Fallback to config for output directory if roots endpoint is unavailable + if (!roots) { + const configResult = await get(ENDPOINTS.CONFIG); + if (!configResult.ok) { + statusText.textContent = "Error: Failed to get config"; + return; + } + roots = { output_directory: configResult.data.output_directory }; + } + + const scopeLabel = + desiredScope === "all" + ? "Inputs + Outputs" + : desiredScope === "input" + ? "Inputs" + : desiredScope === "custom" + ? "Custom" + : "Outputs"; + + let detail = ""; + if (desiredScope === "input") detail = roots?.input_directory ? ` (${roots.input_directory})` : ""; + else if (desiredScope === "custom") { + const list = Array.isArray(roots?.custom_roots) ? roots.custom_roots : []; + const picked = desiredCustomRootId ? list.find((r) => r?.id === desiredCustomRootId) : null; + detail = picked?.path ? ` (${picked.path})` : ""; + } else if (desiredScope === "output") detail = roots?.output_directory ? ` (${roots.output_directory})` : ""; + + // Show scanning status + statusDot.style.background = "#FFA726"; // Orange + setStatusWithHint(statusText, `Scanning ${scopeLabel}${detail}...`, "This may take a while"); + + const payload = { + scope: desiredScope, + recursive: true, + incremental: true, + fast: true, + background_metadata: true + }; + if (desiredScope === "custom") { + if (!desiredCustomRootId) { + statusDot.style.background = "#f44336"; + statusText.textContent = "Select a custom folder first"; + return; + } + payload.custom_root_id = desiredCustomRootId; + } + + // Trigger scan + const scanResult = await post(ENDPOINTS.SCAN, payload); + + if (scanResult.ok) { + const stats = scanResult.data; + statusDot.style.background = "#4CAF50"; // Green + setStatusWithHint( + statusText, + "Scan complete!", + `Added: ${stats.added || 0} - Updated: ${stats.updated || 0} - Skipped: ${stats.skipped || 0}` + ); + + // Refresh status after 2 seconds + setTimeout(() => { + updateStatus(statusDot, statusText, capabilitiesSection); + }, 2000); + } else { + statusDot.style.background = "#f44336"; // Red + setStatusLines(statusText, [`Scan failed: ${scanResult.error || "Unknown error"}`]); + + // Restore status after 3 seconds + setTimeout(() => { + updateStatus(statusDot, statusText, capabilitiesSection); + }, 3000); + } +} + +function createCapabilityBadge(label, available, hint) { + const badge = document.createElement("span"); + badge.style.cssText = ` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + `; + badge.textContent = `${available ? "✅" : "❌"} ${label}`; + badge.title = available ? `${label} available` : hint || `${label} unavailable`; + badge.style.opacity = available ? "1" : "0.75"; + return badge; +} + +function renderCapabilities(section, toolAvailability = {}, toolPaths = {}) { + if (!section) return; + section.replaceChildren(); + if (!toolAvailability || Object.keys(toolAvailability).length === 0) { + section.textContent = "Capabilities: discovering tools..."; + return; + } + + const wrapper = document.createElement("div"); + wrapper.style.display = "flex"; + wrapper.style.flexWrap = "wrap"; + wrapper.style.gap = "10px"; + wrapper.style.alignItems = "flex-start"; + + TOOL_CAPABILITIES.forEach(({ key, label, hint }) => { + const available = Boolean(toolAvailability[key]); + const path = toolPaths[key]; + wrapper.appendChild(createCapabilityNode({ label, hint }, available, path)); + }); + + section.appendChild(wrapper); +} + +function createCapabilityNode({ label, hint }, available, path) { + const container = document.createElement("div"); + container.style.cssText = ` + display: flex; + flex-direction: column; + gap: 3px; + padding: 6px 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + min-width: 170px; + `; + + const badge = createCapabilityBadge(label, available, hint); + badge.style.fontWeight = "500"; + container.appendChild(badge); + + const pathLine = document.createElement("span"); + pathLine.style.fontSize = "10px"; + pathLine.style.opacity = "0.7"; + pathLine.style.wordBreak = "break-all"; + pathLine.textContent = `Path: ${path ? path : "auto / not configured"}`; + container.appendChild(pathLine); + + return container; +} + +function setBodyCollapsed(section, collapsed) { + const body = section.querySelector("#mjr-status-body"); + const indicator = section.querySelector("#mjr-status-title-indicator"); + if (!body || !indicator) return; + if (collapsed) { + body.style.display = "none"; + indicator.textContent = "▸"; + } else { + body.style.display = ""; + indicator.textContent = "▾"; + } +} +export async function updateStatus(statusDot, statusText, capabilitiesSection = null, scanTarget = null, meta = null) { + // Optional: caller-provided object to read the last HTTP error details from. + // (Used by the polling loop to avoid relying on string matching.) + if (meta && typeof meta === "object") { + meta.lastCode = null; + meta.lastStatus = null; + } + + const desiredScope = String(scanTarget?.scope || "output").toLowerCase(); + const desiredCustomRootId = scanTarget?.customRootId || scanTarget?.custom_root_id || scanTarget?.root_id || null; + const url = + desiredScope === "custom" + ? `${ENDPOINTS.HEALTH_COUNTERS}?scope=custom&custom_root_id=${encodeURIComponent(String(desiredCustomRootId || ""))}` + : `${ENDPOINTS.HEALTH_COUNTERS}?scope=${encodeURIComponent(desiredScope || "output")}`; + const result = await get(url); + + if (meta && typeof meta === "object") { + meta.lastCode = result?.code || null; + meta.lastStatus = typeof result?.status === "number" ? result.status : null; + } + + if (result.ok) { + const counters = result.data; + const totalAssets = counters.total_assets || 0; + const withWorkflows = counters.with_workflows ?? 0; + const withGenerationData = counters.with_generation_data ?? 0; + const lastScanRaw = counters.last_scan_end; + const lastScanText = lastScanRaw ? new Date(lastScanRaw).toLocaleString() : "N/A"; + const toolAvailability = counters.tool_availability || {}; + const toolPaths = counters.tool_paths || {}; + + renderCapabilities(capabilitiesSection, toolAvailability, toolPaths); + + const scopeLabel = + desiredScope === "all" + ? "All (Inputs + Outputs)" + : desiredScope === "input" + ? "Inputs" + : desiredScope === "custom" + ? "Custom" + : "Outputs"; + + if (totalAssets === 0) { + // No assets indexed - yellow + statusDot.style.background = "#FFA726"; + statusText.textContent = `No assets indexed yet (${scopeLabel})`; + } else { + // Assets indexed - green + statusDot.style.background = "#4CAF50"; + setStatusLines( + statusText, + [ + `${totalAssets.toLocaleString()} assets indexed (${scopeLabel})`, + `Images: ${counters.images || 0} - Videos: ${counters.videos || 0}`, + `With workflows: ${withWorkflows} - Generation data: ${withGenerationData}`, + ], + `Last scan: ${lastScanText}` + ); + } + return counters; + } else { + // Error - red + renderCapabilities(capabilitiesSection, {}, {}); + statusDot.style.background = "#f44336"; + if (result?.code === "INVALID_RESPONSE" && result?.status === 404) { + setStatusWithHint( + statusText, + "Majoor API endpoints not found (404)", + "Backend routes are not loaded. Restart ComfyUI and check the terminal for Majoor import errors." + ); + } else { + setStatusLines(statusText, [result.error || "Error checking status"]); + } + if (result.code === "SERVICE_UNAVAILABLE") { + const retryBtn = createRetryButton(statusDot, statusText, capabilitiesSection); + statusText.appendChild(document.createElement("br")); + statusText.appendChild(retryBtn); + } + } + return null; +} + +function createRetryButton(statusDot, statusText, capabilitiesSection = null) { + const button = document.createElement("button"); + button.textContent = "Retry services"; + button.style.cssText = ` + padding: 4px 10px; + margin-top: 6px; + font-size: 11px; + border-radius: 4px; + border: 1px solid rgba(255,255,255,0.3); + background: transparent; + color: white; + cursor: pointer; + `; + + button.onclick = async () => { + button.disabled = true; + button.textContent = "Retrying..."; + const retryResult = await post(ENDPOINTS.RETRY_SERVICES, {}); + button.disabled = false; + button.textContent = "Retry services"; + if (retryResult.ok) { + const target = typeof getScanTarget === "function" ? getScanTarget() : null; + updateStatus(statusDot, statusText, capabilitiesSection, target); + } else { + setStatusLines(statusText, [retryResult.error || "Retry failed"]); + statusText.appendChild(document.createElement("br")); + statusText.appendChild(button); + } + }; + + return button; +} + +/** + * Setup status polling + */ +export function setupStatusPolling( + statusDot, + statusText, + section, + capabilitiesSection = null, + onCountersUpdate = null, + getScanTarget = null +) { + const GLOBAL_POLL_KEY = "__MJR_STATUS_POLL_DISPOSE__"; + const pollMeta = { lastCode: null, lastStatus: null }; + + const handleUpdate = async () => { + const target = typeof getScanTarget === "function" ? getScanTarget() : null; + const counters = await updateStatus(statusDot, statusText, capabilitiesSection, target, pollMeta); + if (counters && typeof onCountersUpdate === "function") { + onCountersUpdate(counters); + } + return counters; + }; + + // Initial update + handleUpdate(); + + try { + section._mjrStatusPollDispose?.(); + } catch {} + + // Ensure only one polling loop exists globally (panel rerenders can otherwise duplicate timers). + try { + const oldGlobalDispose = window?.[GLOBAL_POLL_KEY]; + if (typeof oldGlobalDispose === "function") oldGlobalDispose(); + } catch {} + + // Poll using a dynamic interval so settings changes apply without reload. + let pollTimerId = null; + let consecutiveFailures = 0; + let lastWasMissingEndpoint = false; + + const scheduleNext = () => { + const baseMs = Math.max(250, Number(APP_CONFIG.STATUS_POLL_INTERVAL) || 2000); + const waitMs = lastWasMissingEndpoint + ? Math.max(30_000, baseMs) + : consecutiveFailures > 0 + ? Math.min(60_000, Math.round(baseMs * Math.min(8, 1 + consecutiveFailures))) + : baseMs; + + pollTimerId = setTimeout(async () => { + const counters = await handleUpdate(); + + // If the backend routes aren't loaded, ComfyUI returns an HTML 404 page (non-JSON). + // Back off to avoid spamming the console/network. + lastWasMissingEndpoint = pollMeta.lastCode === "INVALID_RESPONSE" && pollMeta.lastStatus === 404; + if (counters) { + consecutiveFailures = 0; + } else if (lastWasMissingEndpoint) { + consecutiveFailures = Math.min(999, consecutiveFailures + 1); + } else { + consecutiveFailures = Math.min(999, consecutiveFailures + 1); + } + + // After a few consecutive 404s, stop polling entirely until reload. + if (lastWasMissingEndpoint && consecutiveFailures >= 3) { + try { + section._mjrStatusPollDispose?.(); + } catch {} + return; + } + scheduleNext(); + }, waitMs); + }; + section._mjrStatusPollDispose = () => { + if (pollTimerId) clearTimeout(pollTimerId); + pollTimerId = null; + }; + try { + window[GLOBAL_POLL_KEY] = section._mjrStatusPollDispose; + } catch {} + scheduleNext(); + + const statusDotEl = section.querySelector("#mjr-status-dot"); + if (statusDotEl) { + statusDotEl.onclick = (event) => { + event.stopPropagation(); + const target = typeof getScanTarget === "function" ? getScanTarget() : null; + triggerScan(statusDot, statusText, capabilitiesSection, target); + }; + statusDotEl.title = "Click to scan"; + } +} diff --git a/js/features/viewer/ViewerContextMenu.js b/js/features/viewer/ViewerContextMenu.js new file mode 100644 index 00000000..effbd693 --- /dev/null +++ b/js/features/viewer/ViewerContextMenu.js @@ -0,0 +1,319 @@ +import { buildAssetViewURL } from "../../api/endpoints.js"; +import { comfyAlert, comfyConfirm } from "../../app/dialogs.js"; +import { openInFolder, updateAssetRating, deleteAsset, renameAsset } from "../../api/client.js"; +import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js"; +import { createTagsEditor } from "../../components/TagsEditor.js"; +import { safeDispatchCustomEvent } from "../../utils/events.js"; + +const MENU_SELECTOR = ".mjr-viewer-context-menu"; +const POPOVER_SELECTOR = ".mjr-viewer-popover"; + +function createMenu() { + const existing = document.querySelector(MENU_SELECTOR); + if (existing) return existing; + + const menu = document.createElement("div"); + menu.className = "mjr-viewer-context-menu mjr-context-menu"; + menu.style.cssText = ` + position: fixed; + background: var(--comfy-menu-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + min-width: 220px; + z-index: 10001; + display: none; + padding: 4px 0; + `; + + const ac = new AbortController(); + menu._mjrAbortController = ac; + + const hide = () => { + menu.style.display = "none"; + try { + const pop = document.querySelector(POPOVER_SELECTOR); + pop?.remove?.(); + } catch {} + }; + + document.addEventListener( + "click", + (e) => { + if (!menu.contains(e.target)) hide(); + }, + { signal: ac.signal } + ); + document.addEventListener( + "keydown", + (e) => { + if (e.key === "Escape") hide(); + }, + { signal: ac.signal } + ); + document.addEventListener("scroll", hide, { capture: true, signal: ac.signal }); + + document.body.appendChild(menu); + return menu; +} + +function createItem(label, iconClass, rightHint, onClick, { disabled = false } = {}) { + const item = document.createElement("div"); + item.className = "mjr-context-menu-item"; + item.style.cssText = ` + padding: 8px 14px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: ${disabled ? "default" : "pointer"}; + color: var(--fg-color); + font-size: 13px; + opacity: ${disabled ? "0.45" : "1"}; + transition: background 0.15s; + user-select: none; + `; + + const left = document.createElement("div"); + left.style.cssText = "display:flex; align-items:center; gap:10px;"; + if (iconClass) { + const iconEl = document.createElement("i"); + iconEl.className = iconClass; + iconEl.style.cssText = "width:16px; text-align:center; opacity:0.8;"; + left.appendChild(iconEl); + } + const labelEl = document.createElement("span"); + labelEl.textContent = label; + left.appendChild(labelEl); + item.appendChild(left); + + if (rightHint) { + const hint = document.createElement("span"); + hint.textContent = rightHint; + hint.style.cssText = "font-size: 11px; opacity: 0.55; margin-left: 16px;"; + item.appendChild(hint); + } + + item.addEventListener("mouseenter", () => { + if (!disabled) item.style.background = "var(--comfy-input-bg)"; + }); + item.addEventListener("mouseleave", () => { + item.style.background = "transparent"; + }); + + item.addEventListener("click", async (e) => { + e.stopPropagation(); + if (disabled) return; + try { + await onClick?.(); + } catch {} + }); + + return item; +} + +function separator() { + const s = document.createElement("div"); + s.style.cssText = "height:1px;background:var(--border-color);margin:4px 0;"; + return s; +} + +function showAt(menu, x, y) { + menu.style.display = "block"; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + + const rect = menu.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let fx = x; + let fy = y; + if (x + rect.width > vw) fx = vw - rect.width - 10; + if (y + rect.height > vh) fy = vh - rect.height - 10; + + menu.style.left = `${Math.max(8, fx)}px`; + menu.style.top = `${Math.max(8, fy)}px`; +} + +function showTagsPopover(x, y, asset, onChanged) { + try { + const existing = document.querySelector(POPOVER_SELECTOR); + existing?.remove?.(); + } catch {} + + const pop = document.createElement("div"); + pop.className = "mjr-viewer-popover"; + pop.style.cssText = ` + position: fixed; + z-index: 10002; + left: ${x}px; + top: ${y}px; + `; + + const editor = createTagsEditor(asset, (tags) => { + asset.tags = tags; + safeDispatchCustomEvent( + ASSET_TAGS_CHANGED_EVENT, + { assetId: String(asset.id), tags }, + { warnPrefix: "[ViewerContextMenu]" } + ); + try { + onChanged?.(); + } catch {} + }); + pop.appendChild(editor); + document.body.appendChild(pop); + + // Clamp into viewport + const rect = pop.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + let fx = x; + let fy = y; + if (x + rect.width > vw) fx = vw - rect.width - 10; + if (y + rect.height > vh) fy = vh - rect.height - 10; + pop.style.left = `${Math.max(8, fx)}px`; + pop.style.top = `${Math.max(8, fy)}px`; +} + +async function setRating(asset, rating, onChanged) { + const assetId = asset?.id; + if (!assetId) return; + try { + const result = await updateAssetRating(assetId, rating); + if (!result?.ok) return; + asset.rating = rating; + safeDispatchCustomEvent( + ASSET_RATING_CHANGED_EVENT, + { assetId: String(assetId), rating }, + { warnPrefix: "[ViewerContextMenu]" } + ); + onChanged?.(); + } catch {} +} + +export function bindViewerContextMenu({ + overlayEl, + getCurrentAsset, + getCurrentViewUrl, + onAssetChanged, +} = {}) { + if (!overlayEl || typeof getCurrentAsset !== "function") return; + if (overlayEl._mjrViewerContextMenuBound) return; + overlayEl._mjrViewerContextMenuBound = true; + + const menu = createMenu(); + + overlayEl.addEventListener("contextmenu", async (e) => { + if (!overlayEl.contains(e.target)) return; + e.preventDefault(); + e.stopPropagation(); + + const asset = getCurrentAsset(); + if (!asset) return; + + menu.innerHTML = ""; + + const viewUrl = typeof getCurrentViewUrl === "function" ? getCurrentViewUrl(asset) : buildAssetViewURL(asset); + + menu.appendChild( + createItem("Open in New Tab", "pi pi-external-link", null, () => { + window.open(viewUrl, "_blank"); + }) + ); + + menu.appendChild( + createItem("Copy file path", "pi pi-copy", null, async () => { + const p = asset?.filepath ? String(asset.filepath) : ""; + if (!p) { + await comfyAlert("No file path available for this asset."); + return; + } + try { + await navigator.clipboard.writeText(p); + } catch {} + }) + ); + + menu.appendChild( + createItem("Open in Folder", "pi pi-folder-open", null, async () => { + if (!asset?.id) return; + const res = await openInFolder(asset.id); + if (!res?.ok) { + await comfyAlert(res?.error || "Failed to open folder."); + } + }, { disabled: !asset?.id }) + ); + + menu.appendChild(separator()); + + menu.appendChild(createItem("Edit Tagsâ€Ļ", "pi pi-tags", null, () => { + showTagsPopover(e.clientX + 6, e.clientY + 6, asset, onAssetChanged); + })); + + menu.appendChild(separator()); + + const ratingLabel = (n) => (n === 0 ? "Reset rating" : `Set rating: ${"★".repeat(n)}`); + for (const n of [5, 4, 3, 2, 1, 0]) { + menu.appendChild( + createItem(ratingLabel(n), "pi pi-star", n ? String(n) : "0", () => setRating(asset, n, onAssetChanged), { + disabled: !asset?.id + }) + ); + } + + menu.appendChild(separator()); + + // Rename option + menu.appendChild( + createItem("Renameâ€Ļ", "pi pi-pencil", null, async () => { + if (!asset?.id) return; + + const currentName = asset.filename || ""; + const newName = await comfyPrompt("Rename file", currentName); + if (!newName || newName === currentName) return; + + try { + const renameResult = await renameAsset(asset.id, newName); + if (renameResult?.ok) { + // Update the asset object + asset.filename = newName; + asset.filepath = asset.filepath.replace(/[^\\/]+$/, newName); + + await comfyAlert("File renamed successfully!"); + onAssetChanged?.(); + } else { + await comfyAlert(renameResult?.error || "Failed to rename file."); + } + } catch (error) { + await comfyAlert(`Error renaming file: ${error.message}`); + } + }, { disabled: !asset?.id }) + ); + + // Delete option + menu.appendChild( + createItem("Deleteâ€Ļ", "pi pi-trash", null, async () => { + if (!asset?.id) return; + + const confirmed = await comfyConfirm("Delete this file? This cannot be undone."); + if (!confirmed) return; + + try { + const deleteResult = await deleteAsset(asset.id); + if (deleteResult?.ok) { + await comfyAlert("File deleted successfully!"); + // Close viewer or navigate to next asset + onAssetChanged?.(); + } else { + await comfyAlert(deleteResult?.error || "Failed to delete file."); + } + } catch (error) { + await comfyAlert(`Error deleting file: ${error.message}`); + } + }, { disabled: !asset?.id }) + ); + + showAt(menu, e.clientX, e.clientY); + }); +} diff --git a/js/global_state.js b/js/global_state.js deleted file mode 100644 index a8c7cd31..00000000 --- a/js/global_state.js +++ /dev/null @@ -1,4 +0,0 @@ -// Dedicated shared global state container (no dependencies to avoid cycles) -export const mjrGlobalState = { - instances: new Set(), -}; diff --git a/js/grid/events.js b/js/grid/events.js deleted file mode 100644 index 919a5994..00000000 --- a/js/grid/events.js +++ /dev/null @@ -1,746 +0,0 @@ -import { app } from "../../../../scripts/app.js"; -import { api } from "../../../../scripts/api.js"; -import { buildViewUrl, detectKindFromExt, getExt, mjrSettings, mjrShowToast } from "../ui_settings.js"; -import { mjrGlobalState } from "../global_state.js"; -import { sortFilesDeterministically } from "../assets_data.js"; - -let mjrGlobalRefreshTimer = null; -let mjrFocusListenerAttached = false; -let mjrVideoNodeDropBridgeBound = false; -let mjrQueueListenerBound = false; -let mjrNewFilesListenerBound = false; -let mjrCapabilitiesCache = null; -try { - if (typeof window !== "undefined" && window.MJR_DND_DEBUG === undefined) { - window.MJR_DND_DEBUG = false; - } -} catch (_) {} -const mjrDbg = (...args) => { - try { - if ((mjrSettings && mjrSettings.debugDnD) || (typeof window !== "undefined" && window.MJR_DND_DEBUG)) { - console.debug("[Majoor.DnD]", ...args); - } - } catch (_) {} -}; -let mjrGraphCanvas = null; -let mjrCanvasObserver = null; - -const fileManagerStore = { - addNewGeneratedFiles(newFiles = []) { - if (!Array.isArray(newFiles) || !newFiles.length) return; - const now = Date.now(); - - mjrGlobalState.instances.forEach((inst) => { - const state = inst.state; - if (!state || !Array.isArray(state.files)) return; - - const existingKeys = new Set( - state.files.map((f) => `${f.subfolder || ""}/${f.filename || f.name || ""}`) - ); - - let added = false; - let addedCount = 0; - newFiles.forEach((nf) => { - const filename = nf?.filename; - if (!filename || nf?.type !== "output") return; - const subfolder = nf.subfolder || ""; - const key = `${subfolder}/${filename}`; - if (existingKeys.has(key)) return; - - const ext = getExt(filename) || ""; - const kind = detectKindFromExt(ext); - const fileObj = { - filename, - name: filename, - subfolder, - ext, - kind, - mtime: now, - size: 0, - url: buildViewUrl({ filename, subfolder }), - promptId: nf.promptId, - nodeId: nf.nodeId, - }; - - state.files.push(fileObj); - existingKeys.add(key); - added = true; - addedCount += 1; - }); - - if (added) { - sortFilesDeterministically(state.files); - if (Number.isFinite(state.filesTotal)) { - state.filesTotal += addedCount; - } else { - state.filesTotal = state.files.length; - } - if (Number.isFinite(state.filesNextOffset)) { - state.filesNextOffset += addedCount; - } - if (typeof state.filesHasMore !== "boolean") { - state.filesHasMore = state.files.length < state.filesTotal; - } - if (typeof inst.applyFilterAndRender === "function") { - inst.applyFilterAndRender(); - } - } - }); - }, -}; - -export async function mjrFetchFileAsDataTransferFile(info) { - const url = buildViewUrl(info); - try { - const res = await api.fetchApi(url); - if (!res.ok) return null; - const blob = await res.blob(); - const name = info.filename || info.name || "file"; - return new File([blob], name, { type: blob.type || "application/octet-stream" }); - } catch { - return null; - } -} - -export function mjrDispatchSyntheticDrop(target, file) { - if (!target || !file) return; - try { - const dt = new DataTransfer(); - dt.items.add(file); - const evt = new DragEvent("drop", { - bubbles: true, - cancelable: true, - dataTransfer: dt, - }); - target.dispatchEvent(evt); - } catch (err) { - console.warn("[Majoor.AssetsManager] synthetic drop failed", err); - } -} - -let mjrHighlightedNode = null; -let mjrHighlightedNodePrev = null; - -function getNodeUnderClientXY(clientX, clientY) { - const canvasEl = app?.canvas?.canvas || document.querySelector("canvas"); - const graph = app?.canvas?.graph; - const ds = app?.canvas?.ds; - if (!canvasEl || !graph || !ds) return null; - - const rect = canvasEl.getBoundingClientRect(); - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - return null; - } - - const scale = Number(ds.scale) || 1; - const off = ds.offset || [0, 0]; - const offX = Array.isArray(off) ? Number(off[0]) || 0 : Number(off?.x) || 0; - const offY = Array.isArray(off) ? Number(off[1]) || 0 : Number(off?.y) || 0; - const x = (clientX - rect.left) / scale - offX; - const y = (clientY - rect.top) / scale - offY; - - if (typeof graph.getNodeOnPos === "function") return graph.getNodeOnPos(x, y); - - const nodes = graph._nodes || []; - for (let i = nodes.length - 1; i >= 0; i--) { - const n = nodes[i]; - if (!n?.pos || !n?.size) continue; - if ( - x >= n.pos[0] && - y >= n.pos[1] && - x <= n.pos[0] + n.size[0] && - y <= n.pos[1] + n.size[1] - ) { - return n; - } - } - return null; -} - -function isVhsVideoNode(node) { - if (!node) return false; - const type = String(node.type || ""); - if (type.includes("VHS_LoadVideo")) return true; - return pickBestVideoPathWidget(node, "mp4") != null; -} - -function markCanvasDirty() { - try { - app?.graph?.setDirtyCanvas?.(true, true); - } catch (_) {} - try { - app?.canvas?.setDirty?.(true, true); - } catch (_) {} -} - -function applyHighlight(node) { - if (!node) return; - if (mjrHighlightedNode === node) return; - - clearHighlight(); - mjrHighlightedNode = node; - mjrHighlightedNodePrev = { color: node.color, bgcolor: node.bgcolor }; - node.bgcolor = "#3355ff"; - node.color = "#a9c4ff"; - markCanvasDirty(); -} - -function clearHighlight() { - if (!mjrHighlightedNode) return; - try { - if (mjrHighlightedNodePrev) { - mjrHighlightedNode.color = mjrHighlightedNodePrev.color; - mjrHighlightedNode.bgcolor = mjrHighlightedNodePrev.bgcolor; - } - } catch (_) {} - mjrHighlightedNode = null; - mjrHighlightedNodePrev = null; - markCanvasDirty(); -} - -function ensureComboHasValue(widget, value) { - if (!widget || widget.type !== "combo" || !widget.options) return; - - let vals = null; - let target = null; - if (Array.isArray(widget.options.values)) { - vals = widget.options.values; - target = widget.options; - } else if (widget.options.values && Array.isArray(widget.options.values.values)) { - vals = widget.options.values.values; - target = widget.options.values; - } - if (!Array.isArray(vals) || !target) return; - - const has = vals.some((v) => { - if (typeof v === "string") return v === value; - return (v?.content ?? v?.value ?? v?.text) === value; - }); - if (has) return; - - if (vals.length === 0 || typeof vals[0] === "string") vals.unshift(value); - else vals.unshift({ content: value, text: value, value }); - target.values = vals; -} - -// Video extensions supported for drag-and-drop -const MJR_DND_VIDEO_EXTS = ["mp4", "mov", "mkv", "webm"]; - -// Widget picker scoring thresholds: -// - MIN_SCORE: Minimum confidence to accept a widget as video input (prevents false positives) -// - WARN_SCORE: Below this score, warn user that widget detection may be ambiguous -const MJR_WIDGET_PICK_MIN_SCORE = 20; -const MJR_WIDGET_PICK_WARN_SCORE = 50; // Reduced from 70 to avoid warnings on standard nodes - -// Widget scoring weights (used in pickBestVideoPathWidget) -const SCORE_EXACT_NAME = 100; // Exact match for known video input names -const SCORE_VIDEO_WITH_HINT = 80; // Has "video" + "path"/"file"/"input" in name (increased from 70) -const SCORE_PATH_OR_FILE = 35; // Has "path" or "file" in name (increased from 30) -const SCORE_OUTPUT_PENALTY = -90; // Penalize output/save/export widgets -const SCORE_VIDEO_MATCH = 25; // Widget named "video" with empty or video path value (increased from 20) -const SCORE_VIDEO_MISMATCH = -50; // Widget named "video" with non-video value (increased from -40) -const SCORE_EMPTY_BONUS = 3; // Slight preference for empty widgets (decreased from 5) -const SCORE_COMBO_VIDEO_BONUS = 12; // Combo has video paths in options (increased from 3) -const SCORE_NODE_TYPE_BOOST = 15; // Boost for known video node types -const SCORE_MEDIA_KEYWORD = 45; // For "media", "clip", "footage" keywords - -function _looksLikeVideoPath(value, droppedExt) { - if (typeof value !== "string") return false; - const v = value.trim().toLowerCase(); - if (!v) return false; - const ext = (v.split(/[?#]/)[0].split(".").pop() || "").toLowerCase(); - if (!ext) return false; - if (droppedExt && ext === String(droppedExt).toLowerCase()) return true; - return MJR_DND_VIDEO_EXTS.includes(ext); -} - -function _comboHasAnyVideoValue(widget, droppedExt) { - if (!widget || widget.type !== "combo" || !widget.options) return false; - const vals = - (Array.isArray(widget.options.values) && widget.options.values) || - (widget.options.values && Array.isArray(widget.options.values.values) && widget.options.values.values) || - null; - if (!Array.isArray(vals)) return false; - return vals.some((v) => { - const s = typeof v === "string" ? v : (v?.content ?? v?.value ?? v?.text); - return _looksLikeVideoPath(s, droppedExt); - }); -} - -function pickBestVideoPathWidget(node, droppedExt) { - const widgets = node?.widgets; - if (!Array.isArray(widgets) || !widgets.length) return null; - - const ext = String(droppedExt || "").toLowerCase().replace(/^\./, ""); - - // Exact match widget names (VHS and native ComfyUI nodes) - const exactNames = new Set([ - "video_path", - "input_video", - "source_video", - "video", // VHS nodes (VHS_LoadVideo, VHS_LoadVideoPath) - ]); - - // Check if this is a known video node type - const nodeType = String(node?.type || "").toLowerCase(); - const isKnownVideoNode = - nodeType.includes("loadvideo") || - nodeType.includes("vhs_loadvideo") || - nodeType.includes("videoloader") || - nodeType === "loadvideo"; - - const candidates = []; - for (const w of widgets) { - if (!w) continue; - const type = String(w?.type || "").toLowerCase(); - const value = w?.value; - - const rejectTypes = new Set(["number", "int", "float", "boolean", "toggle", "checkbox"]); - if (rejectTypes.has(type)) continue; - if (typeof value === "number" || typeof value === "boolean") continue; - - const stringLikeByType = type === "text" || type === "string" || type === "combo"; - const stringLikeByCallback = typeof w?.callback === "function" && typeof value === "string"; - if (!stringLikeByType && !stringLikeByCallback) continue; - - const rawName = String(w?.name || w?.label || ""); - const name = rawName.toLowerCase().trim(); - - let score = 0; - - // Exact name match (highest priority) - if (exactNames.has(name)) { - score += SCORE_EXACT_NAME; - } - - // Special handling for "file" widget on known video nodes (native LoadVideo) - if (name === "file" && isKnownVideoNode && type === "combo" && _comboHasAnyVideoValue(w, ext)) { - score += SCORE_EXACT_NAME; // Treat as exact match for LoadVideo - } - - // Video with hint keywords - const hasVideo = name.includes("video"); - const hasAnyVideoHint = - name.includes("path") || - name.includes("file") || - name.includes("input") || - name.includes("src") || - name.includes("source"); - if (hasVideo && hasAnyVideoHint) score += SCORE_VIDEO_WITH_HINT; - - // Path or file keywords - if (name.includes("file") || name.includes("path")) score += SCORE_PATH_OR_FILE; - - // Media/clip/footage keywords (for custom nodes) - if (name.includes("media") || name.includes("clip") || name.includes("footage")) { - score += SCORE_MEDIA_KEYWORD; - } - - // Output widget penalty - const isOutputy = - name.includes("output") || - name.includes("save") || - name.includes("export") || - name.includes("folder") || - name.includes("dir"); - if (isOutputy) score += SCORE_OUTPUT_PENALTY; - - // Special case for widget named exactly "video" - if (name === "video") { - const empty = typeof value === "string" && value.trim() === ""; - if (empty || _looksLikeVideoPath(value, ext)) score += SCORE_VIDEO_MATCH; - else score += SCORE_VIDEO_MISMATCH; - } - - // Node type boost for known video nodes - if (isKnownVideoNode) { - score += SCORE_NODE_TYPE_BOOST; - } - - // Empty value bonus (reduced to prevent overriding better matches) - const emptyValue = typeof value === "string" && value.trim() === ""; - if (emptyValue) score += SCORE_EMPTY_BONUS; - - // Combo with video values bonus (increased importance) - if (type === "combo" && _comboHasAnyVideoValue(w, ext)) score += SCORE_COMBO_VIDEO_BONUS; - - candidates.push({ w, score, emptyValue, combo: type === "combo" }); - } - - if (!candidates.length) return null; - - candidates.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (b.emptyValue !== a.emptyValue) return b.emptyValue ? 1 : -1; - if (b.combo !== a.combo) return b.combo ? 1 : -1; - return 0; - }); - - const best = candidates[0]; - if (!best || best.score < MJR_WIDGET_PICK_MIN_SCORE) return null; - - try { - best.w.__mjrVideoPickScore = best.score; - } catch (_) {} - return best.w; -} - -function comboHasValue(widget, value) { - if (!widget || widget.type !== "combo" || !widget.options) return false; - const vals = - (Array.isArray(widget.options.values) && widget.options.values) || - (widget.options.values && Array.isArray(widget.options.values.values) && widget.options.values.values) || - null; - if (!Array.isArray(vals)) return false; - return vals.some((v) => { - if (typeof v === "string") return v === value; - return (v?.content ?? v?.value ?? v?.text) === value; - }); -} - -export function ensureVideoNodeDropBridge() { - if (mjrVideoNodeDropBridgeBound) return () => {}; - - const onDragOver = (e) => { - const types = Array.from(e?.dataTransfer?.types || []); - if (!types.includes("application/x-mjr-asset")) return; - - let payload = null; - try { - payload = JSON.parse(e.dataTransfer.getData("application/x-mjr-asset")); - } catch (_) { - payload = null; - } - if (!payload || payload.kind !== "video") return; - if (!payload.filename) return; - - const node = getNodeUnderClientXY(e.clientX, e.clientY); - const droppedExt = String(getExt(payload.filename || "") || "").toLowerCase(); - const widget = node ? pickBestVideoPathWidget(node, droppedExt) : null; - if (node && widget) { - e.preventDefault(); - e.stopImmediatePropagation?.(); - e.stopPropagation(); - applyHighlight(node); - e.dataTransfer.dropEffect = "copy"; - mjrDbg("DnD video dragover", { - payload, - nodeTitle: node?.title, - nodeType: node?.type, - widget: { name: widget?.name, type: widget?.type, score: widget?.__mjrVideoPickScore }, - }); - return; - } - - clearHighlight(); - // Pass-through: let ComfyUI handle canvas-friendly video drop (workflow embed). - }; - - const onDrop = async (e) => { - const types = Array.from(e?.dataTransfer?.types || []); - if (!types.includes("application/x-mjr-asset")) return; - - let payload = null; - try { - payload = JSON.parse(e.dataTransfer.getData("application/x-mjr-asset")); - } catch (_) { - payload = null; - } - if (!payload || payload.kind !== "video") return; - - const filename = payload.filename; - if (!filename) return; - - const node = getNodeUnderClientXY(e.clientX, e.clientY); - const subfolder = payload.subfolder || ""; - const droppedExt = String(getExt(filename || "") || "").toLowerCase(); - const widget = node ? pickBestVideoPathWidget(node, droppedExt) : null; - - if (!node || !widget) { - clearHighlight(); - mjrDbg("DnD video drop pass-through", { - payload, - nodeTitle: node?.title, - nodeType: node?.type, - }); - return; // leave ComfyUI to load workflow from video metadata - } - - e.preventDefault(); - e.stopImmediatePropagation?.(); - e.stopPropagation(); - clearHighlight(); - - let stageResp = null; - try { - const res = await api.fetchApi("/mjr/filemanager/stage_to_input", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename, - subfolder, - from_type: "output", - dest_subfolder: "", - collision_policy: "rename", - }), - }); - stageResp = await res.json().catch(() => null); - if (!res.ok && stageResp && stageResp.ok !== true) { - stageResp = { ok: false, error: stageResp?.error || `HTTP ${res.status}` }; - } - } catch (_) { - stageResp = null; - } - - if (!stageResp?.ok) { - mjrShowToast("error", stageResp?.error || "Failed to stage video to input", "Drag & Drop"); - return; - } - - const relativePath = String(stageResp?.relative_path || ""); - const stagedName = stageResp?.filename || stageResp?.name || filename; - const respSub = stageResp?.dest_subfolder ?? ""; - const injectedPath = relativePath || (respSub ? `${respSub}/${stagedName}` : stagedName); - mjrDbg("DnD stage_to_input response", stageResp); - - if (!injectedPath) { - mjrShowToast("error", "Failed to determine staged input path", "Drag & Drop"); - return; - } - - if (stageResp?.collision === "renamed") { - mjrShowToast("info", `Staged as ${stagedName} (renamed to avoid overwrite)`, "Drag & Drop"); - } - - const pickScore = Number(widget?.__mjrVideoPickScore || 0); - if (pickScore > 0 && pickScore < MJR_WIDGET_PICK_WARN_SCORE) { - mjrShowToast( - "warn", - "Widget selection may be ambiguous. Rename the target widget to 'video_path' for best reliability.", - "Drag & Drop" - ); - } - - if (widget.type === "combo") ensureComboHasValue(widget, injectedPath); - widget.value = injectedPath; - try { - widget.callback?.(widget.value); - } catch (_) {} - - markCanvasDirty(); - - mjrDbg("DnD video injected", { - payload, - nodeTitle: node?.title, - nodeType: node?.type, - widget: { name: widget?.name, type: widget?.type, score: widget?.__mjrVideoPickScore }, - value: injectedPath, - }); - }; - - const onDragLeave = (_e) => { - clearHighlight(); - }; - - window.addEventListener("dragover", onDragOver, true); - window.addEventListener("drop", onDrop, true); - window.addEventListener("dragleave", onDragLeave, true); - mjrVideoNodeDropBridgeBound = true; - - return () => { - window.removeEventListener("dragover", onDragOver, true); - window.removeEventListener("drop", onDrop, true); - window.removeEventListener("dragleave", onDragLeave, true); - mjrVideoNodeDropBridgeBound = false; - clearHighlight(); - }; -} - -export function mjrStartAutoRefreshTimer(refreshAllInstances) { - if (mjrGlobalRefreshTimer) { - clearInterval(mjrGlobalRefreshTimer); - mjrGlobalRefreshTimer = null; - } - if (!mjrSettings.autoRefresh.enabled) return; - - const intervalMs = Math.max(3000, Number(mjrSettings.autoRefresh.interval) || 5000); - - mjrGlobalRefreshTimer = setInterval(() => { - if (mjrSettings.autoRefresh.focusOnly && !document.hasFocus()) return; - refreshAllInstances({ silent: true, metaOnly: true }); - }, intervalMs); -} - -export function ensureGlobalAutoRefresh(refreshAllInstances) { - mjrStartAutoRefreshTimer(refreshAllInstances); - if (!mjrFocusListenerAttached) { - const onFocus = () => refreshAllInstances({ silent: true, metaOnly: true }); - window.addEventListener("focus", onFocus); - mjrFocusListenerAttached = true; - return () => window.removeEventListener("focus", onFocus); - } - return () => {}; -} - -async function fetchHistory(promptId) { - try { - const url = `/history/${promptId}`; - const res = await api.fetchApi(url); - if (!res.ok) return null; - return await res.json(); - } catch (err) { - console.warn("[Majoor.AssetsManager] history fetch failed", err); - return null; - } -} - -async function persistGenerationMetadata(videoFiles, history) { - if (!Array.isArray(videoFiles) || !videoFiles.length) return; - if (!history) return; - const promptGraph = - history.prompt || - history.prompt_json || - history.promptGraph || - null; - const workflow = - history.workflow || - (history.extra_data && history.extra_data.workflow) || - (history.extra && history.extra.workflow) || - null; - if (!promptGraph && !workflow) return; - - const payload = { - files: videoFiles.map((f) => ({ - filename: f.filename || f.name, - subfolder: f.subfolder || "", - })), - prompt: promptGraph, - workflow, - }; - - try { - if (mjrCapabilitiesCache === null) { - const resCap = await api.fetchApi("/mjr/filemanager/capabilities"); - if (resCap.ok) { - mjrCapabilitiesCache = await resCap.json().catch(() => null); - } else { - mjrCapabilitiesCache = null; - } - } - const sidecarEnabled = !!(mjrCapabilitiesCache && mjrCapabilitiesCache.sidecar_enabled); - if (!sidecarEnabled) return; - await api.fetchApi("/mjr/filemanager/generation/update", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - } catch (err) { - console.warn("[Majoor.AssetsManager] generation sidecar update failed", err); - } -} - -export async function refreshAssetsForPrompt(promptId) { - const history = await fetchHistory(promptId); - if (!history) return 0; - - const outputs = history.outputs || {}; - const newFiles = []; - const videoFiles = []; - Object.entries(outputs).forEach(([nodeId, data]) => { - if (!data) return; - if (Array.isArray(data.images)) { - data.images.forEach((img) => { - if (!img || img.type !== "output") return; - const filename = img.filename; - const subfolder = img.subfolder; - const ext = getExt(filename); - const kind = detectKindFromExt(ext); - newFiles.push({ - filename, - subfolder, - type: img.type, - promptId, - nodeId, - }); - if (kind === "video") { - videoFiles.push({ filename, subfolder }); - } - }); - } - }); - - if (videoFiles.length) { - persistGenerationMetadata(videoFiles, history); - } - - if (newFiles.length > 0) { - fileManagerStore.addNewGeneratedFiles(newFiles); - mjrShowToast("success", `${newFiles.length} new file(s)`, "Generated"); - return newFiles.length; - } - return 0; -} - -export function ensureQueueListener(refreshAllInstances) { - if (mjrQueueListenerBound) return () => {}; - - const onExecutionSuccess = async (ev) => { - const promptId = ev?.detail?.prompt_id; - if (promptId) { - const added = await refreshAssetsForPrompt(promptId); - if (!added) { - refreshAllInstances({ silent: true, forceFiles: true }); - } - } - }; - - api.addEventListener("execution_success", onExecutionSuccess); - mjrQueueListenerBound = true; - - return () => { - api.removeEventListener("execution_success", onExecutionSuccess); - mjrQueueListenerBound = false; - }; -} - -export function ensureNewFilesListener() { - if (mjrNewFilesListenerBound) return () => {}; - const onNewFiles = (ev) => { - const files = ev?.detail?.files || ev?.data?.files || []; - if (!Array.isArray(files) || !files.length) return; - const mapped = files - .map((f) => { - const filename = f.filename || f.name; - if (!filename) return null; - const subfolder = f.subfolder || ""; - const ext = getExt(filename) || ""; - const kind = detectKindFromExt(ext); - return { - filename, - name: filename, - subfolder, - ext, - kind, - mtime: f.mtime || Date.now(), - size: 0, - url: buildViewUrl({ filename, subfolder }), - type: "output", - }; - }) - .filter(Boolean); - if (mapped.length) { - fileManagerStore.addNewGeneratedFiles(mapped); - } - }; - api.addEventListener("mjr_new_files", onNewFiles); - mjrNewFilesListenerBound = true; - return () => { - api.removeEventListener("mjr_new_files", onNewFiles); - mjrNewFilesListenerBound = false; - }; -} diff --git a/js/grid/fetch.js b/js/grid/fetch.js deleted file mode 100644 index afc02c3f..00000000 --- a/js/grid/fetch.js +++ /dev/null @@ -1,369 +0,0 @@ -import { api } from "../../../../scripts/api.js"; - -export function createMetadataFetcher(state, gridView, opts = {}) { - let metadataAbortCtrl = null; - let metadataFetchInFlight = null; - let metadataDebounceTimer = null; - let currentGridView = gridView || null; - let onMetadataUpdated = typeof opts.onMetadataUpdated === "function" ? opts.onMetadataUpdated : null; - - // Retry limit tracking to prevent infinite loops - const MAX_RETRY_ATTEMPTS = 3; - const RETRY_BACKOFF_MS = 1000; // Start with 1 second - let retryCountVisible = 0; - let lastRetryTimeVisible = 0; - let retryCountFilter = 0; - let lastRetryTimeFilter = 0; - - // Concurrency guard: prevent visible + filter from ping-ponging - let lastFetchType = null; // "visible" | "filter" - let lastFetchTime = 0; - const MIN_FETCH_INTERVAL_MS = 100; // Minimum 100ms between different fetch types - - const setOnMetadataUpdated = (fn) => { - onMetadataUpdated = typeof fn === "function" ? fn : null; - }; - - const shouldHydrateForFilter = () => { - const tagFilter = (state.tagFilter || "").trim(); - const minRating = Number(state.minRating || 0); - return !!tagFilter || minRating > 0; - }; - - const applyBatchResults = (data) => { - const metaByKey = new Map(); - (data.metadatas || []).forEach((m) => metaByKey.set(`${m.subfolder || ""}/${m.filename || ""}`, m)); - - const updatedFiles = []; - state.files.forEach((f) => { - const key = `${f.subfolder || ""}/${f.filename || f.name || ""}`; - const m = metaByKey.get(key); - if (m) { - f.rating = m.rating ?? 0; - f.tags = m.tags || []; - if (m.has_workflow !== undefined) f.hasWorkflow = !!m.has_workflow; - f.__metaLoaded = true; - updatedFiles.push(f); - } - }); - return updatedFiles; - }; - - // Safety check: should we proceed with this fetch? - const canProceedWithFetch = (fetchType, reqVersion) => { - // Check if render version changed - if (reqVersion !== state.renderVersion) { - return false; - } - - // Check if abort controller is aborted - if (metadataAbortCtrl?.signal?.aborted) { - return false; - } - - // Check if loading is in flight - if (state.loadingPromise) { - return false; - } - - // Check if already fetching - if (metadataFetchInFlight) { - return false; - } - - // Concurrency guard: prevent ping-pong between visible and filter - const now = Date.now(); - if (lastFetchType && lastFetchType !== fetchType) { - const timeSinceLastFetch = now - lastFetchTime; - if (timeSinceLastFetch < MIN_FETCH_INTERVAL_MS) { - return false; - } - } - - return true; - }; - - async function fetchMetadataForVisible() { - const reqVersion = state.renderVersion; - - if (!canProceedWithFetch("visible", reqVersion)) { - return; - } - - const fullList = state.filtered || []; - const { visibleStart: start, visibleEnd: end } = state; - - const buffer = 20; - const effectiveStart = Math.max(0, start - buffer); - const effectiveEnd = Math.min(fullList.length, end + buffer); - - const visibleSlice = fullList.slice(effectiveStart, effectiveEnd); - const targetFiles = visibleSlice.filter((f) => !f.__metaLoaded); - - if (!targetFiles.length) { - // Reset retry count if no files need loading - retryCountVisible = 0; - return; - } - - const batch = targetFiles.slice(0, 30).map((f) => ({ filename: f.filename || f.name, subfolder: f.subfolder || "" })); - - if (metadataAbortCtrl) { - try { - metadataAbortCtrl.abort(); - } catch (_) {} - } - metadataAbortCtrl = new AbortController(); - const thisCtrl = metadataAbortCtrl; - - // Update concurrency guard - lastFetchType = "visible"; - lastFetchTime = Date.now(); - - metadataFetchInFlight = api.fetchApi("/mjr/filemanager/metadata/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: batch }), - signal: thisCtrl.signal, - }); - - let updatedAny = false; - try { - const res = await metadataFetchInFlight; - - // Safety check after await - if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion || state.loadingPromise) { - return; - } - - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - let data; - try { - data = await res.json(); - } catch (parseErr) { - console.error("[Majoor.AssetsManager] Failed to parse metadata JSON response", parseErr); - throw new Error(`JSON parse error: ${parseErr.message}`); - } - - // Safety check after JSON parse - if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion || state.loadingPromise) { - return; - } - - const updatedFiles = applyBatchResults(data); - updatedAny = updatedFiles.length > 0; - if (currentGridView?.refreshCardsForFiles) { - currentGridView.refreshCardsForFiles(updatedFiles); - } - } catch (err) { - if (err?.name === "AbortError") { - // ignore aborted fetch - } else { - console.warn("[Majoor.AssetsManager] batch metadata load failed", err); - } - } finally { - metadataFetchInFlight = null; - if (metadataAbortCtrl === thisCtrl) metadataAbortCtrl = null; - - if (updatedAny && onMetadataUpdated) { - setTimeout(() => { - try { - onMetadataUpdated(); - } catch (_) {} - }, 0); - } - - // Retry logic with exponential backoff and max attempts - // Only retry if conditions are still valid - const shouldRetry = - reqVersion === state.renderVersion && - !state.loadingPromise && - !thisCtrl.signal.aborted && - fullList.slice(effectiveStart, effectiveEnd).some((f) => !f.__metaLoaded); - - if (shouldRetry) { - const now = Date.now(); - - // Reset retry count if enough time has passed (30 seconds) - if (now - lastRetryTimeVisible > 30000) { - retryCountVisible = 0; - } - - if (retryCountVisible < MAX_RETRY_ATTEMPTS) { - const backoffDelay = updatedAny ? 0 : RETRY_BACKOFF_MS * Math.pow(2, retryCountVisible); - retryCountVisible++; - lastRetryTimeVisible = now; - - setTimeout(() => { - // Final safety check before retry - if (reqVersion === state.renderVersion && !state.loadingPromise) { - fetchMetadataForVisible(); - } - }, backoffDelay); - } else { - console.warn("[Majoor.AssetsManager] Max metadata fetch retries (visible) reached, stopping to prevent infinite loop"); - // Don't reset here - let the 30s timeout reset it - } - } else { - // Reset retry count on successful completion or abort - retryCountVisible = 0; - } - } - } - - async function fetchMetadataForFilter() { - const reqVersion = state.renderVersion; - - if (!canProceedWithFetch("filter", reqVersion)) { - return; - } - - if (!shouldHydrateForFilter()) { - // Reset retry count if filter is no longer active - retryCountFilter = 0; - return; - } - - const allFiles = state.files || []; - const targetFiles = allFiles.filter((f) => !f.__metaLoaded).slice(0, 30); - - if (!targetFiles.length) { - // Reset retry count if no files need loading - retryCountFilter = 0; - return; - } - - const batch = targetFiles.map((f) => ({ filename: f.filename || f.name, subfolder: f.subfolder || "" })); - - if (metadataAbortCtrl) { - try { - metadataAbortCtrl.abort(); - } catch (_) {} - } - metadataAbortCtrl = new AbortController(); - const thisCtrl = metadataAbortCtrl; - - // Update concurrency guard - lastFetchType = "filter"; - lastFetchTime = Date.now(); - - metadataFetchInFlight = api.fetchApi("/mjr/filemanager/metadata/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: batch }), - signal: thisCtrl.signal, - }); - - let updatedAny = false; - try { - const res = await metadataFetchInFlight; - - // Safety check after await - if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion || state.loadingPromise) { - return; - } - - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - let data; - try { - data = await res.json(); - } catch (parseErr) { - console.error("[Majoor.AssetsManager] Failed to parse metadata JSON response (filter)", parseErr); - throw new Error(`JSON parse error: ${parseErr.message}`); - } - - // Safety check after JSON parse - if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion || state.loadingPromise) { - return; - } - - const updatedFiles = applyBatchResults(data); - updatedAny = updatedFiles.length > 0; - if (currentGridView?.refreshCardsForFiles) { - currentGridView.refreshCardsForFiles(updatedFiles); - } - } catch (err) { - if (err?.name === "AbortError") { - // ignore aborted fetch - } else { - console.warn("[Majoor.AssetsManager] filter metadata load failed", err); - } - } finally { - metadataFetchInFlight = null; - if (metadataAbortCtrl === thisCtrl) metadataAbortCtrl = null; - - if (updatedAny && onMetadataUpdated) { - setTimeout(() => { - try { - onMetadataUpdated(); - } catch (_) {} - }, 0); - } - - // Retry logic with exponential backoff and max attempts - // Only retry if conditions are still valid - const shouldRetry = - reqVersion === state.renderVersion && - !state.loadingPromise && - !thisCtrl.signal.aborted && - shouldHydrateForFilter() && - allFiles.some((f) => !f.__metaLoaded); - - if (shouldRetry) { - const now = Date.now(); - - // Reset retry count if enough time has passed (30 seconds) - if (now - lastRetryTimeFilter > 30000) { - retryCountFilter = 0; - } - - if (retryCountFilter < MAX_RETRY_ATTEMPTS) { - const backoffDelay = updatedAny ? 0 : RETRY_BACKOFF_MS * Math.pow(2, retryCountFilter); - retryCountFilter++; - lastRetryTimeFilter = now; - - setTimeout(() => { - // Final safety check before retry - if (reqVersion === state.renderVersion && !state.loadingPromise && shouldHydrateForFilter()) { - fetchMetadataForFilter(); - } - }, backoffDelay); - } else { - console.warn("[Majoor.AssetsManager] Max metadata fetch retries (filter) reached, stopping to prevent infinite loop"); - // Don't reset here - let the 30s timeout reset it - } - } else { - // Reset retry count on successful completion or abort - retryCountFilter = 0; - } - } - } - - const onRequestMetadata = (start, end) => { - state.visibleStart = start; - state.visibleEnd = end; - if (metadataDebounceTimer) clearTimeout(metadataDebounceTimer); - metadataDebounceTimer = setTimeout(() => fetchMetadataForVisible(), 150); - }; - - const cleanup = () => { - if (metadataDebounceTimer) clearTimeout(metadataDebounceTimer); - if (metadataAbortCtrl) { - try { - metadataAbortCtrl.abort(); - } catch (_) {} - } - // Reset retry counters on cleanup - retryCountVisible = 0; - retryCountFilter = 0; - lastFetchType = null; - }; - - const setGridView = (gv) => { - currentGridView = gv; - }; - - return { fetchMetadataForVisible, fetchMetadataForFilter, onRequestMetadata, cleanup, setGridView, setOnMetadataUpdated }; -} diff --git a/js/grid/load.js b/js/grid/load.js deleted file mode 100644 index 80c34e08..00000000 --- a/js/grid/load.js +++ /dev/null @@ -1,63 +0,0 @@ -import { normalizeRefreshOptions, mergeRefreshOptions, mergeLoadOpts, createRefreshInstance, mjrRefreshDefaults } from "../assets_data.js"; -import { mjrShowToast } from "../ui_settings.js"; -import { mjrGlobalState } from "../global_state.js"; - -export function createRefreshController(state, grid, applyFilterAndRender, fetchMetadataForVisible) { - let mjrGlobalRefreshPromise = null; - let mjrGlobalRefreshPending = null; - const lastSignatureRef = { value: null }; - - const loadFiles = (silent = false, force = false, { skipMetadataFetch = false } = {}) => - import("../assets_data.js").then(({ loadFiles: fmLoadFiles }) => - fmLoadFiles(state, grid, applyFilterAndRender, { silent, force, skipMetadataFetch }, { lastSignatureRef, mergeLoadOptsFn: mergeLoadOpts }) - ); - - const loadFilesWrapper = (opts = {}) => loadFiles(opts.silent ?? true, opts.forceFiles ?? false, { skipMetadataFetch: opts.skipMetadataFetch ?? true }); - - const loadMoreFiles = (opts = {}) => - import("../assets_data.js").then(({ loadMoreFiles: fmLoadMoreFiles }) => - fmLoadMoreFiles(state, grid, applyFilterAndRender, { silent: opts.silent ?? true, skipMetadataFetch: opts.skipMetadataFetch ?? false }, {}) - ); - - const refreshInstance = createRefreshInstance(state, fetchMetadataForVisible || (() => Promise.resolve()), loadFilesWrapper, { - mjrRefreshDefaults, - mergeRefreshOptionsFn: mergeRefreshOptions, - }); - - function refreshAllInstances(optionsOrSilent = true, force = false) { - const opts = normalizeRefreshOptions(optionsOrSilent, force); - if (mjrGlobalRefreshPromise) { - mjrGlobalRefreshPending = mergeRefreshOptions(mjrGlobalRefreshPending, opts); - return mjrGlobalRefreshPromise; - } - - const run = async () => { - const instances = Array.from(mjrGlobalState.instances); - for (const inst of instances) { - try { - if (typeof inst.refresh === "function") { - await inst.refresh(opts); - } else if (typeof inst.loadFiles === "function") { - await inst.loadFiles(opts.silent, opts.forceFiles, { skipMetadataFetch: false }); - } - } catch (err) { - console.warn("[Majoor.AssetsManager] refresh failed for an instance", err); - if (!opts.silent) mjrShowToast("warn", "Auto refresh failed", "Warning"); - } - } - }; - - mjrGlobalRefreshPromise = run().finally(() => { - const pending = mjrGlobalRefreshPending; - mjrGlobalRefreshPending = null; - mjrGlobalRefreshPromise = null; - if (pending) { - refreshAllInstances(pending); - } - }); - - return mjrGlobalRefreshPromise; - } - - return { refreshInstance, refreshAllInstances, loadFilesWrapper, loadFiles, loadMoreFiles, lastSignatureRef }; -} diff --git a/js/grid/render.js b/js/grid/render.js deleted file mode 100644 index ef1cee03..00000000 --- a/js/grid/render.js +++ /dev/null @@ -1,181 +0,0 @@ -import { detectKindFromExt, getBaseName, getExt, mjrSettings } from "../ui_settings.js"; -import { normalizeMtimeValue } from "../assets_filters.js"; - -export function createApplyFilterAndRender(state, fetchMetadataForVisible, fetchMetadataForFilter) { - let gridView = null; - - // Track previous filtered list for two-phase filtering - let previousFiltered = []; - let metaHydrationStartTime = 0; - const META_HYDRATION_TIMEOUT_MS = 5000; // 5 seconds max wait for metadata - - const applyFilterAndRender = (options = {}) => { - const { skipMetadataFetch = false } = options; - const q = (state.search || "").trim().toLowerCase(); - const tagFilter = (state.tagFilter || "").trim().toLowerCase(); - const minRating = Number(state.minRating || 0); - const needsMetaFilter = minRating > 0 || !!tagFilter; - const hidePng = !!mjrSettings.siblings.hidePngSiblings; - let nonImageStems = null; - if (hidePng) { - nonImageStems = new Set(); - for (const f of state.files) { - const raw = f.name || f.filename || ""; - if ((f.kind || detectKindFromExt(f.ext || getExt(raw) || "")) !== "image") { - nonImageStems.add(getBaseName(raw)); - } - } - } - - // TWO-PHASE FILTERING APPROACH - // Phase 1: Basic filtering (always applied) - const basicFiltered = state.files.filter((file) => { - const rawName = file.name || file.filename || ""; - const extUpper = (file.ext || getExt(rawName) || "").toUpperCase(); - const kind = file.kind || detectKindFromExt(extUpper); - if (hidePng && nonImageStems?.has(getBaseName(rawName)) && extUpper === "PNG") return false; - - const name = rawName.toLowerCase(); - const folder = (file.subfolder || "").toLowerCase(); - if (q && !name.includes(q) && !folder.includes(q)) return false; - - file.mtime = normalizeMtimeValue(file.mtime); - if (state.collectionSet) { - const fileRelPath = `${file.subfolder ? file.subfolder + "/" : ""}${rawName}`; - if (!state.collectionSet.has(fileRelPath)) return false; - } else if (state.smartFilter && !state.smartFilter(file)) { - return false; - } - - return true; - }); - - // Phase 2: Metadata filtering (conditional) - let finalFiltered; - let shouldHydrate = false; - - if (needsMetaFilter) { - // Count how many files in basic filtered have metadata loaded - const filesWithMeta = basicFiltered.filter((file) => { - const hasMeta = !!file.__metaLoaded || file.rating !== undefined || Array.isArray(file.tags); - return hasMeta; - }); - - const filesWithoutMeta = basicFiltered.filter((file) => { - const hasMeta = !!file.__metaLoaded || file.rating !== undefined || Array.isArray(file.tags); - return !hasMeta; - }); - - // Calculate hydration progress - const totalNeedingMeta = basicFiltered.length; - const metaLoadedCount = filesWithMeta.length; - const metaHydrationPercent = totalNeedingMeta > 0 ? (metaLoadedCount / totalNeedingMeta) * 100 : 100; - - // Check if we're in hydration phase - const now = Date.now(); - const isHydrating = filesWithoutMeta.length > 0 && (now - metaHydrationStartTime) < META_HYDRATION_TIMEOUT_MS; - - if (filesWithoutMeta.length > 0 && metaHydrationPercent < 95) { - // We have files without metadata and haven't reached 95% hydration - if (metaHydrationStartTime === 0) { - metaHydrationStartTime = now; - } - shouldHydrate = true; - - // TWO-PHASE STRATEGY: - // Keep previous filtered list if we have one and we're still hydrating - // This prevents blinking/popping as metadata loads - if (previousFiltered.length > 0 && isHydrating) { - // Use previous filtered list + newly loaded items that pass the filter - const passesMetaFilter = (file) => { - const fileRating = Number(file.rating ?? 0); - if (fileRating < minRating) return false; - - const tagsArr = Array.isArray(file.tags) ? file.tags : []; - if (tagFilter && !tagsArr.some((tag) => String(tag).toLowerCase().includes(tagFilter))) return false; - - return true; - }; - - const newlyLoadedPassing = filesWithMeta.filter((file) => { - const key = `${file.subfolder || ""}/${file.name || file.filename || ""}`; - const wasInPrevious = previousFiltered.some((pf) => { - const pkey = `${pf.subfolder || ""}/${pf.name || pf.filename || ""}`; - return pkey === key; - }); - return !wasInPrevious && passesMetaFilter(file); - }); - - // Keep previous + add newly loaded passing files - const previousKeys = new Set(previousFiltered.map((f) => `${f.subfolder || ""}/${f.name || f.filename || ""}`)); - const combinedFiltered = [...previousFiltered]; - - newlyLoadedPassing.forEach((file) => { - const key = `${file.subfolder || ""}/${file.name || file.filename || ""}`; - if (!previousKeys.has(key)) { - combinedFiltered.push(file); - } - }); - - finalFiltered = combinedFiltered; - state.metaHydrating = true; - state.metaHydrationPercent = Math.round(metaHydrationPercent); - } else { - // First time or timeout exceeded - apply immediate filter - finalFiltered = filesWithMeta.filter((file) => { - const fileRating = Number(file.rating ?? 0); - if (fileRating < minRating) return false; - - const tagsArr = Array.isArray(file.tags) ? file.tags : []; - if (tagFilter && !tagsArr.some((tag) => String(tag).toLowerCase().includes(tagFilter))) return false; - - return true; - }); - state.metaHydrating = shouldHydrate; - state.metaHydrationPercent = Math.round(metaHydrationPercent); - } - } else { - // All (or 95%+) metadata is loaded, apply final filter - metaHydrationStartTime = 0; // Reset timer - finalFiltered = filesWithMeta.filter((file) => { - const fileRating = Number(file.rating ?? 0); - if (fileRating < minRating) return false; - - const tagsArr = Array.isArray(file.tags) ? file.tags : []; - if (tagFilter && !tagsArr.some((tag) => String(tag).toLowerCase().includes(tagFilter))) return false; - - return true; - }); - state.metaHydrating = false; - state.metaHydrationPercent = 100; - previousFiltered = finalFiltered; // Update previous for next time - } - } else { - // No metadata filter needed, just use basic filtered - finalFiltered = basicFiltered; - state.metaHydrating = false; - state.metaHydrationPercent = 100; - metaHydrationStartTime = 0; - previousFiltered = []; - } - - state.filtered = finalFiltered; - state.filtered.sort((a, b) => b.mtime - a.mtime || (a.name || "").localeCompare(b.name || "")); - state.renderVersion = (state.renderVersion || 0) + 1; - - if (gridView) gridView.renderGrid(); - - if (!skipMetadataFetch) { - fetchMetadataForVisible(); - if (needsMetaFilter && typeof fetchMetadataForFilter === "function") { - fetchMetadataForFilter(); - } - } - }; - - const setGridView = (gv) => { - gridView = gv; - }; - - return { applyFilterAndRender, setGridView }; -} diff --git a/js/grid/state.js b/js/grid/state.js deleted file mode 100644 index 087e8de0..00000000 --- a/js/grid/state.js +++ /dev/null @@ -1,39 +0,0 @@ -import { api } from "../../../../scripts/api.js"; -import { SMART_FILTERS } from "../assets_filters.js"; -import { createInitialState } from "../assets_state.js"; - -export function initManagerState() { - const state = createInitialState(); - state.visibleStart = 0; - state.visibleEnd = 50; - return state; -} - -export async function populateCollectionsOptions(state, selectEl) { - if (!selectEl) return; - selectEl.innerHTML = ''; - - const smartGroup = document.createElement("optgroup"); - smartGroup.label = "Smart Views"; - Object.entries(SMART_FILTERS).forEach(([key, def]) => { - const opt = new Option(def.label, key); - if (state.activeCollection === key) opt.selected = true; - smartGroup.appendChild(opt); - }); - selectEl.appendChild(smartGroup); - - const manualGroup = document.createElement("optgroup"); - manualGroup.label = "Collections"; - try { - const res = await api.fetchApi("/mjr/collections/list"); - const data = await res.json(); - (data.collections || []).forEach((name) => { - const opt = new Option(name, name); - if (state.activeCollection === name) opt.selected = true; - manualGroup.appendChild(opt); - }); - } catch (e) { - console.error(e); - } - selectEl.appendChild(manualGroup); -} diff --git a/js/majoor_assets_manager.js b/js/majoor_assets_manager.js deleted file mode 100644 index 59b86288..00000000 --- a/js/majoor_assets_manager.js +++ /dev/null @@ -1,2 +0,0 @@ -// Entrypoint: load split modules (grid registers the extension + hotkeys). -import "./ui_grid.js"; diff --git a/js/theme/theme-comfy.css b/js/theme/theme-comfy.css new file mode 100644 index 00000000..e62233fb --- /dev/null +++ b/js/theme/theme-comfy.css @@ -0,0 +1,1066 @@ +/** + * Majoor Assets Manager — Comfy-like theme layer + * + * RULES: + * - Do NOT hardcode a custom palette; use existing ComfyUI CSS variables via var(...). + * - Keep everything scoped under `.mjr-assets-manager` to avoid leaking styles to ComfyUI (except `.mjr-popover*` which can be portaled to `document.body`). + */ + +.mjr-assets-manager { + /* Typography */ + font-family: var(--comfy-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif); + font-size: 13px; + line-height: 1.35; + color: var(--content-fg, var(--input-text, #ddd)); + + /* Surfaces */ + --mjr-surface-0: var(--content-bg, #1e1e1e); + --mjr-surface-1: var(--comfy-menu-bg, #2a2a2a); + --mjr-surface-2: var(--comfy-menu-secondary-bg, #333); + --mjr-border: var(--border-color, rgba(255, 255, 255, 0.12)); + --mjr-muted: var(--descrip-text, rgba(255, 255, 255, 0.65)); + --mjr-accent: var(--comfy-accent, #5fb3ff); + + /* Radii/spacing */ + --mjr-r-sm: 8px; + --mjr-r-md: 10px; + --mjr-r-lg: 12px; + --mjr-pad-sm: 8px; + --mjr-pad-md: 12px; + --mjr-pad-lg: 16px; +} + +/* Make the theme visibly apply even with inline styles */ +.mjr-assets-manager * { + box-sizing: border-box; +} + +.mjr-assets-manager.mjr-am-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.mjr-assets-manager .mjr-am-header { + padding: 14px 16px; + border-bottom: 1px solid var(--mjr-border); + background: var(--mjr-surface-1); +} + +.mjr-assets-manager .mjr-am-header-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 28px; + justify-content: space-between; + flex-wrap: wrap; + row-gap: 10px; +} + +.mjr-assets-manager .mjr-am-header-left { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.mjr-assets-manager .mjr-am-header-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + position: relative; + flex-wrap: wrap; +} + +.mjr-assets-manager .mjr-am-header-tools { + display: inline-flex; + gap: 8px; + align-items: center; +} + +.mjr-assets-manager .mjr-tabs { + /* Queue-like tabs (active pill, others as text) */ + background: transparent; + border: none; + padding: 0; + gap: 10px; + display: inline-flex; + align-items: center; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.mjr-assets-manager .mjr-tabs::-webkit-scrollbar { + width: 0; + height: 0; +} + +.mjr-assets-manager .mjr-tab { + padding: 6px 12px; + border-radius: 999px !important; + border: 1px solid transparent !important; + background: transparent !important; + color: color-mix(in srgb, var(--content-fg, #ddd) 65%, transparent) !important; + transition: transform 120ms ease, background-color 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.mjr-assets-manager .mjr-tab:hover { + transform: translateY(-1px); + color: var(--content-fg, #ddd) !important; + background: color-mix(in srgb, var(--mjr-surface-2) 55%, transparent) !important; + border-color: color-mix(in srgb, var(--mjr-border) 70%, transparent) !important; +} + +.mjr-assets-manager .mjr-tab.is-active { + color: var(--content-fg, #fff) !important; + background: color-mix(in srgb, var(--mjr-surface-2) 80%, transparent) !important; + border-color: color-mix(in srgb, var(--mjr-border) 85%, transparent) !important; + box-shadow: 0 6px 18px rgba(0,0,0,0.18); +} + +.mjr-assets-manager .mjr-icon-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-lg, 0.5rem); + border: 1px solid var(--mjr-border); + background: var(--mjr-surface-2); + color: var(--content-fg, #ddd); + cursor: pointer; + padding: 0; + transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease; +} + +.mjr-assets-manager .mjr-icon-btn:hover { + border-color: color-mix(in srgb, var(--mjr-accent) 45%, var(--mjr-border)); + background: color-mix(in srgb, var(--mjr-surface-2) 80%, var(--mjr-accent) 8%); + transform: translateY(-1px); +} + +.mjr-assets-manager .mjr-icon-btn .pi { + font-size: 13px; + line-height: 1; + opacity: 0.9; +} + +/* Popovers can be moved to `document.body` (portal) to avoid being clipped. */ +.mjr-popover { + font-family: var(--comfy-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif); + font-size: 13px; + line-height: 1.35; + color: var(--content-fg, var(--input-text, #ddd)); + + /* Provide fallbacks when outside `.mjr-assets-manager` scope */ + --mjr-surface-1: var(--comfy-menu-bg, #2a2a2a); + --mjr-border: var(--border-color, rgba(255, 255, 255, 0.12)); + --mjr-muted: var(--descrip-text, rgba(255, 255, 255, 0.65)); + --mjr-accent: var(--comfy-accent, #5fb3ff); + + position: fixed; + top: 0; + left: 0; + min-width: 0; + width: min(280px, calc(100vw - 24px)); + max-width: min(360px, calc(100vw - 24px)); + padding: 10px; + border-radius: var(--radius-lg, 0.5rem); + background: var(--mjr-surface-1, var(--comfy-menu-bg, #2a2a2a)); + border: 1px solid var(--mjr-border, var(--border-color, rgba(255, 255, 255, 0.12))); + box-shadow: 0 12px 28px rgba(0,0,0,0.35); + z-index: 2147483647; +} + +.mjr-popover-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.mjr-popover-row:last-child { + margin-bottom: 0; +} + +.mjr-popover-label { + width: 56px; + color: var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, 0.65))); + font-size: 12px; +} + +.mjr-popover-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--content-fg, #ddd); +} + +.mjr-popover-toggle input[type="checkbox"] { + accent-color: var(--mjr-accent, var(--comfy-accent, #5fb3ff)); +} + +.mjr-assets-manager .mjr-am-header-icon { + color: color-mix(in srgb, var(--mjr-accent) 70%, var(--content-fg, #ddd)); + font-size: 14px; + line-height: 1; +} + +.mjr-assets-manager .mjr-am-header-title { + margin: 0; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.mjr-assets-manager .mjr-am-content { + flex: 1; + padding: 16px; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} + +.mjr-assets-manager .mjr-am-browse { + flex: 1; + min-height: 0; + overflow: auto; +} + +/* Scrollbars (match ComfyUI feel) */ +.mjr-assets-manager .mjr-am-browse { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--mjr-border) 85%, var(--content-fg, #ddd)) transparent; +} + +.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar-track { + background: transparent; +} + +.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--mjr-surface-2) 55%, var(--content-fg, #ddd)); + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 999px; +} + +.mjr-assets-manager .mjr-am-browse:hover::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--mjr-surface-2) 70%, var(--content-fg, #ddd)); +} + +.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar-corner { + background: transparent; +} + +.mjr-assets-manager .mjr-am-custom-controls { + display: inline-flex; + flex: 1; + min-width: 0; + gap: 8px; + align-items: center; + justify-content: flex-end; +} + +.mjr-assets-manager .mjr-am-search { + margin-bottom: 18px; + display: flex; + align-items: center; + gap: 10px; + justify-content: space-between; +} + +.mjr-assets-manager .mjr-am-search { + padding-bottom: 12px; + border-bottom: 1px solid var(--mjr-border); + margin-bottom: 12px; +} + +.mjr-assets-manager .mjr-am-search-pill { + display: flex; + align-items: center; + gap: 10px; + height: 1.75rem; + padding: 0 10px; + width: 100%; + max-width: none; + border-radius: var(--radius-lg, 0.5rem); + background: var(--comfy-input-bg, rgba(0, 0, 0, 0.25)); + border: 1px solid var(--mjr-border); + flex: 1 1 auto; + min-width: 0; +} + +.mjr-assets-manager .mjr-am-search-tools { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.mjr-assets-manager .mjr-popover-anchor { + position: relative; + display: inline-flex; + align-items: center; +} + +.mjr-menu, +.mjr-assets-manager .mjr-menu { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mjr-menu-item, +.mjr-assets-manager .mjr-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid transparent; + background: transparent; + color: var(--content-fg, #ddd); + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, transform 80ms ease; +} + +.mjr-menu-item:hover, +.mjr-assets-manager .mjr-menu-item:hover { + border-color: color-mix(in srgb, var(--mjr-border) 80%, transparent); + background: color-mix(in srgb, var(--mjr-surface-2) 55%, transparent); +} + +.mjr-menu-item:active, +.mjr-assets-manager .mjr-menu-item:active { + transform: translateY(1px); +} + +.mjr-menu-item-label, +.mjr-assets-manager .mjr-menu-item-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.mjr-menu-item-check, +.mjr-assets-manager .mjr-menu-item-check { + font-size: 12px; + opacity: 0; +} + +.mjr-menu-item .pi, +.mjr-assets-manager .mjr-menu-item .pi { + font-size: 13px; + opacity: 0.9; +} + +.mjr-assets-manager .mjr-am-search-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--mjr-muted); +} + +.mjr-assets-manager .mjr-am-search-icon .pi { + font-size: 12px; + line-height: 1; +} + +.mjr-assets-manager .mjr-am-search-pill .mjr-input { + padding: 0 !important; + border: none !important; + background: transparent !important; + box-shadow: none !important; + font-size: 12px !important; + line-height: 1.2 !important; +} + +.mjr-assets-manager .mjr-am-search-pill:focus-within { + border-color: color-mix(in srgb, var(--mjr-accent) 65%, var(--mjr-border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mjr-accent) 18%, transparent); +} + +.mjr-assets-manager .mjr-am-browse { + margin-top: 0; + /* Keep cards away from the vertical scrollbar */ + padding-right: 10px; + scrollbar-gutter: stable; +} + + +/* Grid layout */ +.mjr-assets-manager .mjr-grid { + display: grid; + position: relative; + /* Avoid overflow when the panel becomes narrower than the min card size */ + grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--mjr-grid-min-size, 180px)), 1fr)); + gap: var(--mjr-grid-gap, 10px); + margin-top: 10px; + min-height: 0; +} + +/* Card selection + focus (keeps a visible indicator when sidebar is open) */ +.mjr-assets-manager .mjr-asset-card { + outline: none; +} + +.mjr-assets-manager .mjr-asset-card.is-selected { + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--mjr-accent) 55%, transparent), + 0 10px 28px rgba(0,0,0,0.28); + border-radius: var(--mjr-r-lg); +} + +.mjr-assets-manager .mjr-asset-card:focus-visible { + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--mjr-accent) 75%, transparent), + 0 10px 28px rgba(0,0,0,0.28); + border-radius: var(--mjr-r-lg); +} + +/* Grid loading overlay */ +.mjr-assets-manager .mjr-grid-loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, #000 35%, transparent); + color: var(--content-fg, #fff); + font-size: 13px; + letter-spacing: 0.3px; + border-radius: var(--mjr-r-lg); + pointer-events: none; + backdrop-filter: blur(2px); +} + +/* Generic headings inside panel */ +.mjr-assets-manager h3, +.mjr-assets-manager h4 { + font-family: inherit; + letter-spacing: 0.01em; +} + +/* Panels */ +.mjr-assets-manager .mjr-panel { + background: var(--mjr-surface-1); + border: 1px solid var(--mjr-border); + border-radius: var(--mjr-r-lg); + box-shadow: var(--interface-panel-box-shadow, 0 8px 24px rgba(0, 0, 0, 0.25)); +} + +/* Toolbar */ +.mjr-assets-manager .mjr-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: var(--mjr-pad-md); +} + +/* Buttons */ +.mjr-assets-manager .mjr-btn { + appearance: none; + border: 1px solid var(--mjr-border); + background: var(--mjr-surface-2); + color: var(--content-fg, #ddd); + border-radius: var(--mjr-r-md); + padding: 7px 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + user-select: none; + transition: background 120ms ease, border-color 120ms ease, transform 80ms ease; +} + +.mjr-assets-manager .mjr-btn:hover { + border-color: color-mix(in srgb, var(--mjr-accent) 55%, var(--mjr-border)); + background: color-mix(in srgb, var(--mjr-surface-2) 70%, var(--mjr-accent) 10%); +} + +.mjr-assets-manager .mjr-btn:active { + transform: translateY(1px); +} + +.mjr-assets-manager .mjr-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mjr-assets-manager .mjr-btn.mjr-primary { + border-color: color-mix(in srgb, var(--mjr-accent) 60%, var(--mjr-border)); + background: color-mix(in srgb, var(--mjr-accent) 14%, var(--mjr-surface-2)); +} + +.mjr-assets-manager .mjr-btn { + /* Override inline styles if present */ + border: 1px solid var(--mjr-border) !important; + background: var(--mjr-surface-2) !important; + color: var(--content-fg, #ddd) !important; + border-radius: var(--mjr-r-md) !important; +} + +.mjr-assets-manager .mjr-btn:hover { + border-color: color-mix(in srgb, var(--mjr-accent) 55%, var(--mjr-border)) !important; + background: color-mix(in srgb, var(--mjr-surface-2) 70%, var(--mjr-accent) 10%) !important; +} + +/* Inputs / selects (used inside portaled popovers too) */ +.mjr-input, +.mjr-select, +.mjr-assets-manager .mjr-input, +.mjr-assets-manager .mjr-select { + appearance: none; + border: 1px solid var(--mjr-border); + background: var(--comfy-input-bg, rgba(0, 0, 0, 0.25)); + color: var(--input-text, #ddd); + border-radius: var(--mjr-r-md); + padding: 8px 10px; + font-size: 13px; + outline: none; + width: 100%; + min-width: 0; +} + +.mjr-assets-manager .mjr-input::placeholder { + color: color-mix(in srgb, var(--mjr-muted) 80%, transparent); +} + +.mjr-input:focus, +.mjr-select:focus, +.mjr-assets-manager .mjr-input:focus, +.mjr-assets-manager .mjr-select:focus { + border-color: color-mix(in srgb, var(--mjr-accent) 65%, var(--mjr-border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mjr-accent) 20%, transparent); +} + +.mjr-input, +.mjr-select, +.mjr-assets-manager .mjr-input, +.mjr-assets-manager .mjr-select { + /* Override inline styles if present */ + border: 1px solid var(--mjr-border) !important; + background: var(--comfy-input-bg, rgba(0, 0, 0, 0.25)) !important; + color: var(--input-text, #ddd) !important; + border-radius: var(--mjr-r-md) !important; + padding: 8px 10px !important; +} + +/* Tabs: overridden above with queue-like style */ + +.mjr-assets-manager .mjr-tab { + /* Override inline styles if present */ + border-radius: var(--mjr-r-md) !important; + border: 1px solid transparent !important; + background: transparent !important; + color: var(--mjr-muted) !important; +} + +.mjr-assets-manager .mjr-tab.is-active { + color: var(--content-fg, #fff) !important; + border-color: color-mix(in srgb, var(--mjr-accent) 60%, var(--mjr-border)) !important; + background: color-mix(in srgb, var(--mjr-accent) 12%, transparent) !important; +} + +/* Muted text helper */ +.mjr-assets-manager .mjr-muted { + color: var(--mjr-muted); +} + +/* Grid + cards (override inline styles from Card.js) */ +.mjr-assets-manager #mjr-assets-grid { + gap: 10px !important; +} + +.mjr-assets-manager .mjr-asset-card, +.mjr-assets-manager .mjr-card { + background: var(--mjr-surface-1) !important; + border: 1px solid var(--mjr-border) !important; + border-radius: var(--mjr-r-lg) !important; + overflow: hidden !important; + min-width: 0; + transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease !important; +} + +.mjr-assets-manager .mjr-asset-card:hover, +.mjr-assets-manager .mjr-card:hover { + transform: translateY(-1px) !important; + border-color: color-mix(in srgb, var(--mjr-accent) 55%, var(--mjr-border)) !important; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.25) !important; +} + +.mjr-assets-manager .mjr-asset-card img, +.mjr-assets-manager .mjr-asset-card video, +.mjr-assets-manager .mjr-thumb-media { + display: block; + width: 100% !important; + height: 100% !important; + object-fit: cover !important; +} + +.mjr-assets-manager .mjr-thumb { + width: 100%; + aspect-ratio: 1 / 1; + background: color-mix(in srgb, var(--mjr-surface-0) 80%, transparent); + position: relative; + overflow: hidden; +} + +.mjr-assets-manager .mjr-thumb-play { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 28px; + color: rgba(255,255,255,0.9); + text-shadow: 0 0 10px rgba(0,0,0,0.7); + pointer-events: none; + transition: opacity 120ms ease; +} + +.mjr-assets-manager .mjr-card-meta { + padding: 8px 10px; + font-size: 11px; +} + +.mjr-assets-manager .mjr-card-filename { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.mjr-assets-manager .mjr-card-filename-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* Rating & Tags Editors */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +/** + * Archived CSS Styles - Rating and Tags + * These styles were removed from theme-comfy.css + */ + +/* Animations */ +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Popovers */ +.mjr-rating-popover, +.mjr-tags-popover { + animation: fadeIn 0.2s ease; +} + +.mjr-rating-popover, +.mjr-tags-popover { + background: var(--comfy-menu-bg, #2a2a2a); + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.12)); + border-radius: var(--radius-lg, 0.5rem); + box-shadow: 0 12px 28px rgba(0,0,0,0.35); + padding: 8px; +} + +/* Rating Editor */ +.mjr-rating-editor { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px; +} + +.mjr-rating-star { + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + font-size: 20px; + cursor: pointer; + padding: 4px 6px; + line-height: 1; + color: var(--input-text, #ddd); + transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.mjr-rating-star:hover { + border-color: color-mix(in srgb, var(--comfy-accent, #5fb3ff) 35%, transparent); + background: color-mix(in srgb, var(--comfy-menu-secondary-bg, #333) 65%, transparent); +} + +.mjr-rating-editor .mjr-rating-star:hover { + transform: scale(1.2) !important; +} + +.mjr-rating-editor .mjr-rating-star:active { + transform: scale(0.95) !important; +} + +/* Tags Editor */ +.mjr-tags-editor { + font-family: inherit; +} + +.mjr-tag-input:focus { + background: rgba(255, 255, 255, 0.15) !important; + border-color: var(--mjr-accent, #5fb3ff) !important; +} + +.mjr-tag-chip { + transition: all 0.2s ease; +} + +.mjr-tag-chip:hover { + background: rgba(144, 202, 249, 0.3) !important; + border-color: rgba(144, 202, 249, 0.6) !important; +} + +.mjr-tag-suggestion:hover { + background: rgba(144, 202, 249, 0.3) !important; +} + +/* Badges Interactive */ +.mjr-rating-badge, +.mjr-tags-badge { + transition: all 0.2s ease; +} + +.mjr-rating-badge:hover, +.mjr-tags-badge:hover { + transform: scale(1.05); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); +} + +/* Scrollbar for tags dropdown */ +.mjr-tags-dropdown::-webkit-scrollbar { + width: 6px; +} + +.mjr-tags-dropdown::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.3); + border-radius: 3px; +} + +.mjr-tags-dropdown::-webkit-scrollbar-thumb { + background: rgba(144, 202, 249, 0.4); + border-radius: 3px; +} + +.mjr-tags-dropdown::-webkit-scrollbar-thumb:hover { + background: rgba(144, 202, 249, 0.6); +} + +/* Sidebar (can be left or right) */ +.mjr-sidebar { + transition: all 0.3s ease; +} + +.mjr-sidebar.is-open { + /* Border is set dynamically based on position */ +} + +.mjr-sidebar .mjr-sidebar-header { + background: var(--mjr-surface-2, #333); + border-bottom: 1px solid var(--mjr-border, rgba(255,255,255,0.12)); +} + +.mjr-sidebar .mjr-sidebar-content { + background: var(--mjr-surface-1, #2a2a2a); +} + +.mjr-sidebar .mjr-sidebar-content::-webkit-scrollbar { + width: 8px; +} + +.mjr-sidebar .mjr-sidebar-content::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.3); +} + +.mjr-sidebar .mjr-sidebar-content::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; +} + +.mjr-sidebar .mjr-sidebar-content::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +.mjr-sidebar .mjr-sidebar-section { + transition: all 0.2s ease; +} + +.mjr-sidebar .mjr-sidebar-section:hover { + background: rgba(0, 0, 0, 0.3) !important; +} + +/* Inline Sidebar (width-based transitions like archived version) */ +.mjr-inline-sidebar { + width: 0; + min-width: 0; + max-width: 480px; + overflow: hidden; + background: var(--mjr-surface-1, #2a2a2a); + display: flex; + flex-direction: column; + transition: width 0.3s ease, min-width 0.3s ease; +} + +.mjr-inline-sidebar.is-open { + width: 400px; + min-width: 260px; +} + +/* Space between sidebar and grid */ +.mjr-assets-manager .mjr-am-browse { + gap: 10px; +} + +/* Border based on position */ +.mjr-inline-sidebar[data-position="left"] { + border-right: 1px solid var(--mjr-border, rgba(255,255,255,0.12)); +} + +.mjr-inline-sidebar[data-position="right"] { + border-left: 1px solid var(--mjr-border, rgba(255,255,255,0.12)); +} + +/* Sidebar placeholder */ +.mjr-sidebar-placeholder { + padding: 20px; + text-align: center; + color: var(--mjr-muted, rgba(255,255,255,0.65)); + font-size: 14px; + white-space: nowrap; +} + +/* ========== Viewer Component ========== */ +.mjr-viewer-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.95); + z-index: 10000; + display: none; + flex-direction: column; + animation: mjr-fade-in 0.2s ease-out; +} + +@keyframes mjr-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.mjr-viewer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + background: rgba(0, 0, 0, 0.8); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: white; +} + +.mjr-viewer-filename { + font-size: 14px; + font-weight: 500; + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mjr-viewer-mode-buttons { + display: flex; + gap: 6px; +} + +.mjr-viewer-mode-buttons button { + padding: 4px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; +} + +.mjr-viewer-mode-buttons button:hover { + background: rgba(255, 255, 255, 0.2); +} + +.mjr-viewer-content { + flex: 1; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.mjr-viewer-single, +.mjr-viewer-ab, +.mjr-viewer-sidebyside { + width: 100%; + height: 100%; +} + +.mjr-viewer-single { + display: flex; + align-items: center; + justify-content: center; +} + +.mjr-viewer-single img, +.mjr-viewer-single video { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transition: transform 0.2s; +} + +.mjr-viewer-ab { + position: relative; + display: none; +} + +.mjr-viewer-sidebyside { + display: none; + flex-direction: row; + gap: 2px; +} + +.mjr-viewer-sidebyside > div { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); +} + +.mjr-viewer-sidebyside img, +.mjr-viewer-sidebyside video { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.mjr-viewer-footer { + display: flex; + justify-content: center; + align-items: center; + padding: 12px 20px; + background: rgba(0, 0, 0, 0.8); + border-top: 1px solid rgba(255, 255, 255, 0.1); + color: white; + gap: 20px; +} + +.mjr-viewer-footer button { + padding: 6px 12px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.mjr-viewer-footer button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.mjr-viewer-index { + font-size: 14px; + min-width: 60px; + text-align: center; +} + +/* ========== Context Menu Component ========== */ +.mjr-context-menu { + position: fixed; + background: var(--comfy-menu-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + min-width: 200px; + z-index: 9999; + display: none; + padding: 4px 0; + animation: mjr-context-menu-in 0.15s ease-out; +} + +@keyframes mjr-context-menu-in { + from { + opacity: 0; + transform: translateY(-5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.mjr-context-menu-item { + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + color: var(--fg-color); + font-size: 13px; + transition: background 0.15s; +} + +.mjr-context-menu-item:hover { + background: var(--comfy-input-bg); +} + +.mjr-context-menu-separator { + height: 1px; + background: var(--border-color); + margin: 4px 0; +} diff --git a/js/ui_bulk_bar.js b/js/ui_bulk_bar.js deleted file mode 100644 index cfc78f29..00000000 --- a/js/ui_bulk_bar.js +++ /dev/null @@ -1,673 +0,0 @@ -/** - * Bulk Actions Bar - * - * Professional bulk operations UI with progress tracking. - * Shows when multiple assets are selected. - */ - -import { api } from "../../../scripts/api.js"; -import { showToast } from './ui_settings.js'; - -/** - * Create bulk actions bar component - */ -export function createBulkBar(state, api, callbacks) { - const container = document.createElement('div'); - container.className = 'mjr-bulk-bar-container'; - container.style.display = 'none'; - - const bar = document.createElement('div'); - bar.className = 'mjr-bulk-bar'; - - // Selection count label - const countLabel = document.createElement('span'); - countLabel.className = 'mjr-bulk-count'; - countLabel.textContent = '0 selected'; - - // Action buttons - const rateBtn = createButton('⭐ Rate', () => showBulkRateDialog(state, api, callbacks)); - const tagBtn = createButton('đŸˇī¸ Tag', () => showBulkTagDialog(state, api, callbacks)); - const deleteBtn = createButton('đŸ—‘ī¸ Delete', () => showBulkDeleteDialog(state, api, callbacks)); - const exportBtn = createButton('📤 Export', () => exportSelectionList(state)); - const clearBtn = createButton('✖ Clear', () => { - state.selected.clear(); - if (callbacks.onSelectionChange) { - callbacks.onSelectionChange(); - } - updateBulkBar(); - }); - - bar.appendChild(countLabel); - bar.appendChild(rateBtn); - bar.appendChild(tagBtn); - bar.appendChild(deleteBtn); - bar.appendChild(exportBtn); - bar.appendChild(clearBtn); - container.appendChild(bar); - - function updateBulkBar() { - const count = state.selected.size; - if (count > 0) { - countLabel.textContent = `${count} selected`; - container.style.display = 'flex'; - } else { - container.style.display = 'none'; - } - } - - return { - element: container, - update: updateBulkBar - }; -} - -function createButton(text, onClick) { - const btn = document.createElement('button'); - btn.className = 'mjr-bulk-btn'; - btn.textContent = text; - btn.onclick = onClick; - return btn; -} - -// ===== Bulk Dialogs ===== - -async function showBulkRateDialog(state, api, callbacks) { - const selected = Array.from(state.selected); - if (selected.length === 0) return; - - const rating = await promptRating(); - if (rating === null) return; - - await performBulkUpdate( - state, - selected, - { rating }, - `Updating rating for ${selected.length} assets...`, - callbacks - ); -} - -async function showBulkTagDialog(state, api, callbacks) { - const selected = Array.from(state.selected); - if (selected.length === 0) return; - - const result = await promptTags(); - if (!result) return; - - const { action, tags } = result; - - await performBulkUpdate( - state, - selected, - { tags, tagsAction: action }, - `Updating tags for ${selected.length} assets...`, - callbacks - ); -} - -async function showBulkDeleteDialog(state, api, callbacks) { - const selected = Array.from(state.selected); - if (selected.length === 0) return; - - const confirm = window.confirm( - `Delete ${selected.length} assets?\n\n` + - `This will move them to the recycle bin.` - ); - - if (!confirm) return; - - await performBulkDelete(state, selected, callbacks); -} - -function exportSelectionList(state) { - const selected = Array.from(state.selected); - if (selected.length === 0) return; - - // Convert selection to exportable format - const data = selected.map(id => { - const file = state.filesMap?.get(id); - if (!file) return { id }; - - return { - filename: file.filename, - subfolder: file.subfolder || '', - rating: file.rating || 0, - tags: file.tags || [], - has_workflow: file.hasWorkflow || file.has_workflow || false, - }; - }); - - // Create downloadable JSON - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = url; - a.download = `assets-selection-${Date.now()}.json`; - a.click(); - - URL.revokeObjectURL(url); - showToast(`Exported ${selected.length} assets`); -} - -// ===== Progress Modal ===== - -function createProgressModal(title, total) { - const overlay = document.createElement('div'); - overlay.className = 'mjr-progress-overlay'; - - const modal = document.createElement('div'); - modal.className = 'mjr-progress-modal'; - - const titleEl = document.createElement('h3'); - titleEl.textContent = title; - - const progressBar = document.createElement('div'); - progressBar.className = 'mjr-progress-bar'; - const progressFill = document.createElement('div'); - progressFill.className = 'mjr-progress-fill'; - progressBar.appendChild(progressFill); - - const statusLabel = document.createElement('div'); - statusLabel.className = 'mjr-progress-status'; - statusLabel.textContent = '0 / ' + total; - - const cancelBtn = document.createElement('button'); - cancelBtn.className = 'mjr-progress-cancel'; - cancelBtn.textContent = 'Cancel'; - - modal.appendChild(titleEl); - modal.appendChild(progressBar); - modal.appendChild(statusLabel); - modal.appendChild(cancelBtn); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - let cancelled = false; - - cancelBtn.onclick = () => { - cancelled = true; - cancelBtn.disabled = true; - cancelBtn.textContent = 'Cancelling...'; - }; - - return { - update: (current) => { - const percent = (current / total) * 100; - progressFill.style.width = percent + '%'; - statusLabel.textContent = `${current} / ${total}`; - }, - close: () => { - document.body.removeChild(overlay); - }, - isCancelled: () => cancelled - }; -} - -// ===== Bulk Operations ===== - -async function performBulkUpdate(state, selectedIds, updates, progressTitle, callbacks) { - const items = selectedIds.map(id => { - const file = state.filesMap?.get(id); - return file ? { - filename: file.filename, - subfolder: file.subfolder || '' - } : null; - }).filter(Boolean); - - if (items.length === 0) return; - - // Show progress modal for large selections - const showProgress = items.length > 100; - const progress = showProgress ? createProgressModal(progressTitle, items.length) : null; - - try { - // Prepare updates payload - const payload = { - items: items, - updates: {} - }; - - if (updates.rating !== undefined) { - payload.updates.rating = updates.rating; - } - - if (updates.tags !== undefined) { - if (updates.tagsAction === 'add') { - payload.updates.tags_add = updates.tags; - } else if (updates.tagsAction === 'remove') { - payload.updates.tags_remove = updates.tags; - } else { - payload.updates.tags = updates.tags; - } - } - - // Call batch update endpoint - const resp = await api.fetchApi('/mjr/filemanager/metadata/batch_update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}`); - } - - const result = await resp.json(); - - // Sync to index - await api.fetchApi('/mjr/filemanager/index/sync_from_metadata', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ assets: items.map(i => ({ ...i, ...updates })) }) - }); - - // Update progress - if (progress) { - progress.update(items.length); - } - - showToast(`Updated ${items.length} assets successfully`); - - // Refresh grid - if (callbacks.onRefresh) { - callbacks.onRefresh(); - } - - } catch (error) { - console.error('[Majoor] Bulk update failed:', error); - showToast(`Bulk update failed: ${error.message}`, 'error'); - } finally { - if (progress) { - setTimeout(() => progress.close(), 500); - } - } -} - -async function performBulkDelete(state, selectedIds, callbacks) { - const items = selectedIds.map(id => { - const file = state.filesMap?.get(id); - return file ? { - filename: file.filename, - subfolder: file.subfolder || '' - } : null; - }).filter(Boolean); - - if (items.length === 0) return; - - const progress = createProgressModal(`Deleting ${items.length} assets...`, items.length); - - try { - const resp = await api.fetchApi('/mjr/filemanager/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items }) - }); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}`); - } - - progress.update(items.length); - showToast(`Deleted ${items.length} assets`); - - // Clear selection - state.selected.clear(); - - // Refresh grid - if (callbacks.onRefresh) { - callbacks.onRefresh(); - } - - } catch (error) { - console.error('[Majoor] Bulk delete failed:', error); - showToast(`Bulk delete failed: ${error.message}`, 'error'); - } finally { - setTimeout(() => progress.close(), 500); - } -} - -// ===== Input Dialogs ===== - -function promptRating() { - return new Promise((resolve) => { - const overlay = document.createElement('div'); - overlay.className = 'mjr-dialog-overlay'; - - const dialog = document.createElement('div'); - dialog.className = 'mjr-dialog'; - - const title = document.createElement('h3'); - title.textContent = 'Set Rating'; - - const starsContainer = document.createElement('div'); - starsContainer.className = 'mjr-rating-stars'; - - let selectedRating = 0; - - for (let i = 0; i <= 5; i++) { - const star = document.createElement('span'); - star.className = 'mjr-rating-star'; - star.textContent = i === 0 ? 'đŸšĢ' : '★'; - star.dataset.rating = i; - - star.onclick = () => { - selectedRating = i; - updateStars(); - }; - - starsContainer.appendChild(star); - } - - function updateStars() { - Array.from(starsContainer.children).forEach((s, idx) => { - s.classList.toggle('active', idx <= selectedRating && selectedRating > 0); - }); - } - - const btnContainer = document.createElement('div'); - btnContainer.className = 'mjr-dialog-buttons'; - - const okBtn = document.createElement('button'); - okBtn.textContent = 'OK'; - okBtn.onclick = () => { - document.body.removeChild(overlay); - resolve(selectedRating); - }; - - const cancelBtn = document.createElement('button'); - cancelBtn.textContent = 'Cancel'; - cancelBtn.onclick = () => { - document.body.removeChild(overlay); - resolve(null); - }; - - btnContainer.appendChild(okBtn); - btnContainer.appendChild(cancelBtn); - - dialog.appendChild(title); - dialog.appendChild(starsContainer); - dialog.appendChild(btnContainer); - overlay.appendChild(dialog); - document.body.appendChild(overlay); - }); -} - -function promptTags() { - return new Promise((resolve) => { - const overlay = document.createElement('div'); - overlay.className = 'mjr-dialog-overlay'; - - const dialog = document.createElement('div'); - dialog.className = 'mjr-dialog'; - - const title = document.createElement('h3'); - title.textContent = 'Manage Tags'; - - const actionLabel = document.createElement('label'); - actionLabel.textContent = 'Action:'; - - const actionSelect = document.createElement('select'); - actionSelect.innerHTML = ` - - - - `; - - const tagsLabel = document.createElement('label'); - tagsLabel.textContent = 'Tags (comma-separated):'; - - const tagsInput = document.createElement('input'); - tagsInput.type = 'text'; - tagsInput.placeholder = 'favorite, portrait, hd'; - - const btnContainer = document.createElement('div'); - btnContainer.className = 'mjr-dialog-buttons'; - - const okBtn = document.createElement('button'); - okBtn.textContent = 'OK'; - okBtn.onclick = () => { - const tagsStr = tagsInput.value.trim(); - if (!tagsStr) { - resolve(null); - document.body.removeChild(overlay); - return; - } - - const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean); - const action = actionSelect.value; - - document.body.removeChild(overlay); - resolve({ action, tags }); - }; - - const cancelBtn = document.createElement('button'); - cancelBtn.textContent = 'Cancel'; - cancelBtn.onclick = () => { - document.body.removeChild(overlay); - resolve(null); - }; - - btnContainer.appendChild(okBtn); - btnContainer.appendChild(cancelBtn); - - dialog.appendChild(title); - dialog.appendChild(actionLabel); - dialog.appendChild(actionSelect); - dialog.appendChild(tagsLabel); - dialog.appendChild(tagsInput); - dialog.appendChild(btnContainer); - overlay.appendChild(dialog); - document.body.appendChild(overlay); - - tagsInput.focus(); - }); -} - -// ===== CSS Styles ===== - -const STYLES = ` -.mjr-bulk-bar-container { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - z-index: 9999; - animation: mjr-slide-up 0.3s ease-out; -} - -.mjr-bulk-bar { - display: flex; - align-items: center; - gap: 12px; - background: var(--comfy-menu-bg); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 12px 20px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); -} - -.mjr-bulk-count { - font-weight: 600; - margin-right: 8px; - color: var(--fg-color); -} - -.mjr-bulk-btn { - padding: 6px 12px; - border: 1px solid var(--border-color); - background: var(--comfy-input-bg); - color: var(--fg-color); - border-radius: 4px; - cursor: pointer; - font-size: 13px; - transition: all 0.2s; -} - -.mjr-bulk-btn:hover { - background: var(--comfy-input-bg); - filter: brightness(1.2); -} - -.mjr-progress-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; -} - -.mjr-progress-modal { - background: var(--comfy-menu-bg); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 24px; - min-width: 400px; - max-width: 500px; -} - -.mjr-progress-modal h3 { - margin: 0 0 16px 0; - color: var(--fg-color); -} - -.mjr-progress-bar { - width: 100%; - height: 24px; - background: var(--comfy-input-bg); - border-radius: 12px; - overflow: hidden; - margin-bottom: 12px; -} - -.mjr-progress-fill { - height: 100%; - background: var(--mjr-success-gradient, linear-gradient(90deg, #22c55e, #16a34a)); - transition: width 0.3s ease; - width: 0%; -} - -.mjr-progress-status { - text-align: center; - margin-bottom: 16px; - color: var(--fg-color); -} - -.mjr-progress-cancel { - width: 100%; - padding: 8px; - background: var(--comfy-input-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - cursor: pointer; - color: var(--fg-color); -} - -.mjr-dialog-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; -} - -.mjr-dialog { - background: var(--comfy-menu-bg); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 24px; - min-width: 300px; - max-width: 500px; -} - -.mjr-dialog h3 { - margin: 0 0 16px 0; - color: var(--fg-color); -} - -.mjr-dialog label { - display: block; - margin-bottom: 8px; - color: var(--fg-color); -} - -.mjr-dialog input, -.mjr-dialog select { - width: 100%; - padding: 8px; - margin-bottom: 16px; - background: var(--comfy-input-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--input-text); -} - -.mjr-dialog-buttons { - display: flex; - gap: 8px; - justify-content: flex-end; -} - -.mjr-dialog-buttons button { - padding: 8px 16px; - background: var(--comfy-input-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - cursor: pointer; - color: var(--fg-color); -} - -.mjr-rating-stars { - display: flex; - gap: 8px; - justify-content: center; - margin: 20px 0; -} - -.mjr-rating-star { - font-size: 32px; - cursor: pointer; - opacity: 0.3; - transition: opacity 0.2s; -} - -.mjr-rating-star:hover { - opacity: 0.7; -} - -.mjr-rating-star.active { - opacity: 1; -} - -@keyframes mjr-slide-up { - from { - opacity: 0; - transform: translateX(-50%) translateY(20px); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(0); - } -} -`; - -let stylesInjected = false; -export function injectBulkBarStyles() { - if (stylesInjected) return; - stylesInjected = true; - - const styleEl = document.createElement('style'); - styleEl.textContent = STYLES; - document.head.appendChild(styleEl); -} diff --git a/js/ui_cards.js b/js/ui_cards.js deleted file mode 100644 index 6ca656bb..00000000 --- a/js/ui_cards.js +++ /dev/null @@ -1,459 +0,0 @@ -import { - applyStyles, - BADGE_STYLES, - buildViewUrl, - createEl, - detectKindFromExt, - getBaseName, - getExt, - mjrSettings, -} from "./ui_settings.js"; -import { api } from "../../../scripts/api.js"; -import { mjrThemeVars } from "./ui_theme.js"; - -function formatCardHoverTitle(file) { - const rawName = file?.name || file?.filename || "(unnamed)"; - const ext = String(file?.ext || getExt(rawName) || "").toUpperCase(); - const kind = file?.kind || detectKindFromExt(ext); - const subfolder = file?.subfolder ? String(file.subfolder) : "(root)"; - const sizeReadable = file?.size_readable ? String(file.size_readable) : null; - const sizeBytes = Number.isFinite(file?.size) ? `${file.size} B` : null; - const size = sizeReadable || sizeBytes || null; - const date = file?.date ? String(file.date) : null; - - const rating = Math.max(0, Math.min(5, Number(file?.rating ?? (file?.meta && file.meta.rating) ?? 0) || 0)); - const tags = - (Array.isArray(file?.tags) && file.tags) || - (Array.isArray(file?.meta && file.meta.tags) && file.meta.tags) || - []; - - const wfState = resolveWorkflowState(file); - - const lines = []; - lines.push(rawName); - lines.push(`Folder: ${subfolder}`); - lines.push(`Type: ${kind}${ext ? ` (${ext})` : ""}`); - if (date) lines.push(`Date: ${date}`); - if (size) lines.push(`Size: ${size}`); - lines.push(`Rating: ${rating}/5`); - if (tags.length) { - const maxTags = 20; - const shown = tags.slice(0, maxTags).map((t) => String(t)).join(", "); - const more = tags.length > maxTags ? ` (+${tags.length - maxTags} more)` : ""; - lines.push(`Tags: ${shown}${more}`); - } else { - lines.push("Tags: (none)"); - } - lines.push(`Workflow: ${wfState}`); - return lines.join("\n"); -} - -export function resolveWorkflowState(file) { - const toState = (val) => { - if (val === true || val === 1 || val === "true") return "yes"; - if (val === false || val === 0 || val === "false") return "no"; - return null; - }; - - // Check if metadata has been loaded - const metadataLoaded = file?.__metaLoaded === true; - - const candidates = [ - file?.hasWorkflow, - file?.has_workflow, - file?.workflow ? true : null, - file?.meta?.has_workflow, - file?.meta?.workflow ? true : null, - ]; - - for (const c of candidates) { - const s = toState(c); - if (s) return s; - } - - // Distinguish between "not loaded yet" and "definitely no workflow" - // If metadata was loaded but no workflow field was found, it means "no workflow" - if (metadataLoaded) { - return "no"; - } - - return "unknown"; -} - -export function handleDragStart(file, ev, selectedFiles = null) { - if (!ev?.dataTransfer) return; - const filename = file.filename || file.name; - if (!filename) return; - - if (Array.isArray(selectedFiles) && selectedFiles.length > 1) { - const items = selectedFiles - .map((f) => ({ filename: f.filename || f.name, subfolder: f.subfolder || "" })) - .filter((f) => f.filename); - if (items.length > 1) { - const token = `mjr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; - try { - api.fetchApi("/mjr/filemanager/batch_zip", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, items }), - keepalive: true, - }).catch(() => {}); - } catch (_) {} - const url = new URL(`/mjr/filemanager/batch_zip/${encodeURIComponent(token)}`, window.location.href).href; - const zipName = `Majoor_Batch_${items.length}.zip`; - ev.dataTransfer.effectAllowed = "copy"; - ev.dataTransfer.setData("text/uri-list", url); - ev.dataTransfer.setData("DownloadURL", `application/zip:${zipName}:${url}`); - return; - } - } - - const extGuess = file.ext || getExt(filename) || ""; - const kind = file.kind || detectKindFromExt(extGuess); - const rawUrl = file.url || buildViewUrl(file); - const url = new URL(rawUrl, window.location.href).href; - - const extLower = String(extGuess).toLowerCase().replace(/^\./, ""); - const mime = - extLower === "mp4" - ? "video/mp4" - : extLower === "mp3" - ? "audio/mpeg" - : "application/octet-stream"; - - ev.dataTransfer.effectAllowed = "copy"; - ev.dataTransfer.setData("text/uri-list", url); - ev.dataTransfer.setData("DownloadURL", `${mime}:${filename}:${url}`); - - if (kind === "video") { - ev.dataTransfer.setData( - "application/x-mjr-asset", - JSON.stringify({ filename, subfolder: file.subfolder || "", kind: "video" }) - ); - } -} - -export function updateWorkflowDot(card, workflowState) { - const host = card.querySelector(".mjr-fm-thumb") || card; - const existingDots = Array.from(card.querySelectorAll(".mjr-fm-workflow-dot")); - let dot = existingDots.shift() || null; - // Remove extras - existingDots.forEach((d) => d.remove()); - // If the kept dot is not under the host, discard it and recreate - if (dot && dot.parentElement !== host) { - dot.remove(); - dot = null; - } - - if (!dot) { - dot = document.createElement("div"); - dot.className = "mjr-fm-workflow-dot"; - dot.style.position = "absolute"; - dot.style.bottom = "6px"; - dot.style.right = "6px"; - dot.style.width = "10px"; - dot.style.height = "10px"; - dot.style.borderRadius = "50%"; - dot.style.border = "1px solid rgba(0,0,0,0.6)"; - dot.style.pointerEvents = "none"; - // Ensure the card anchors the dot at bottom-right of the card - if (!card.style.position) card.style.position = "relative"; - card.appendChild(dot); - } - - const state = - workflowState === "yes" || workflowState === true - ? "yes" - : workflowState === "no" || workflowState === false - ? "no" - : "unknown"; - - if (state === "yes") { - dot.style.background = mjrThemeVars.statusSuccess; - dot.style.boxShadow = `0 0 6px ${mjrThemeVars.statusSuccessGlow}`; - dot.style.opacity = "1"; - } else if (state === "no") { - dot.style.background = mjrThemeVars.statusError; - dot.style.boxShadow = `0 0 6px ${mjrThemeVars.statusErrorGlow}`; - dot.style.opacity = "0.85"; - } else { - dot.style.background = mjrThemeVars.statusNeutral; - dot.style.boxShadow = `0 0 6px ${mjrThemeVars.statusNeutralGlow}`; - dot.style.opacity = "0.6"; - } -} - -export function updateCardVisuals(card, file) { - const rating = Number(file.rating ?? (file.meta && file.meta.rating) ?? 0); - const tags = - (Array.isArray(file.tags) && file.tags) || - (Array.isArray(file.meta && file.meta.tags) && file.meta.tags) || - []; - - if (mjrSettings.grid.hoverInfo) { - try { - card.title = formatCardHoverTitle(file); - } catch (_) { - card.title = ""; - } - } else { - card.title = ""; - } - - const wfState = resolveWorkflowState(file); - updateWorkflowDot(card, wfState); - - const applyStars = (badge, r) => { - badge.textContent = ""; - const val = Math.max(0, Math.min(5, Number(r) || 0)); - for (let i = 1; i <= 5; i++) { - const span = document.createElement("span"); - const filled = i <= val; - span.textContent = "★"; - span.style.color = filled ? mjrThemeVars.starsFilled : mjrThemeVars.starsEmpty; - span.style.marginRight = i < 5 ? "2px" : "0"; - badge.appendChild(span); - } - badge.dataset.mjrRating = String(val); - }; - - let ratingBadge = card.querySelector(".mjr-fm-rating-badge"); - - if (mjrSettings.grid.showRating && rating > 0) { - if (!ratingBadge) { - ratingBadge = createEl("div", "mjr-fm-rating-badge"); - applyStyles(ratingBadge, BADGE_STYLES.rating); - ratingBadge.style.filter = "drop-shadow(0 0 2px rgba(255, 212, 90, 0.7))"; - ratingBadge.style.fontSize = "10px"; - ratingBadge.style.padding = "4px 6px"; - ratingBadge.style.letterSpacing = "1px"; - ratingBadge.style.display = "inline-flex"; - ratingBadge.style.alignItems = "center"; - ratingBadge.style.justifyContent = "center"; - ratingBadge.style.textAlign = "center"; - applyStars(ratingBadge, rating); - card.appendChild(ratingBadge); - } else if (ratingBadge.dataset.mjrRating !== String(rating)) { - applyStars(ratingBadge, rating); - } - } else if (ratingBadge) { - ratingBadge.remove(); - } - - const newTagsText = mjrSettings.grid.showTags && tags.length > 0 ? tags.join(", ") : ""; - let tagsBadge = card.querySelector(".mjr-fm-tags-badge"); - - if (newTagsText) { - if (!tagsBadge) { - tagsBadge = createEl("div", "mjr-fm-tags-badge"); - applyStyles(tagsBadge, BADGE_STYLES.tags); - tagsBadge.style.fontSize = "0.65rem"; - tagsBadge.textContent = newTagsText; - card.appendChild(tagsBadge); - } else if (tagsBadge.textContent !== newTagsText) { - tagsBadge.textContent = newTagsText; - } - } else if (tagsBadge) { - tagsBadge.remove(); - } -} - -export function renderBadges(card, rating, tags) { - if (mjrSettings.grid.showRating && rating > 0) { - const ratingBadge = createEl("div", "mjr-fm-rating-badge"); - applyStyles(ratingBadge, BADGE_STYLES.rating); - ratingBadge.style.filter = "drop-shadow(0 0 2px rgba(255, 212, 90, 0.7))"; - ratingBadge.style.fontSize = "10px"; - ratingBadge.style.padding = "4px 6px"; - ratingBadge.style.letterSpacing = "1px"; - ratingBadge.style.display = "inline-flex"; - ratingBadge.style.alignItems = "center"; - ratingBadge.style.justifyContent = "center"; - ratingBadge.style.textAlign = "center"; - ratingBadge.dataset.mjrRating = String(rating); - for (let i = 1; i <= 5; i++) { - const span = document.createElement("span"); - const filled = i <= rating; - span.textContent = "★"; - span.style.color = filled ? mjrThemeVars.starsFilled : mjrThemeVars.starsEmpty; - span.style.marginRight = i < 5 ? "2px" : "0"; - ratingBadge.appendChild(span); - } - card.appendChild(ratingBadge); - } - - if (mjrSettings.grid.showTags && tags && tags.length > 0) { - const tagsBadge = createEl("div", "mjr-fm-tags-badge", tags.join(", ")); - applyStyles(tagsBadge, BADGE_STYLES.tags); - tagsBadge.style.fontSize = "0.65rem"; - card.appendChild(tagsBadge); - } -} - -export function createFileThumb(kind, ext, file, card) { - const thumb = createEl("div", "mjr-fm-thumb"); - thumb.style.position = "relative"; - thumb.style.aspectRatio = "1 / 1"; - thumb.style.overflow = "hidden"; - thumb.style.background = mjrThemeVars.thumbBg; - thumb.__mjrHoverTimer = null; - - const badge = createEl("div", "mjr-fm-badge", ext || "FILE"); - badge.style.position = "absolute"; - badge.style.top = "6px"; - badge.style.left = "6px"; - badge.style.padding = "2px 6px"; - badge.style.borderRadius = "4px"; - badge.style.fontSize = "0.65rem"; - badge.style.fontWeight = "600"; - badge.style.background = "rgba(0,0,0,0.7)"; - badge.style.color = mjrThemeVars.badgeFg; - badge.style.textTransform = "uppercase"; - badge.style.pointerEvents = "none"; - thumb.appendChild(badge); - - // Clear any previous cleanup function - if (card.__cleanupVideoListeners) { - card.__cleanupVideoListeners(); - card.__cleanupVideoListeners = null; - } - - const attachVideoPreview = () => { - if (thumb.__mjrVideoPreviewAttached) return; - thumb.__mjrVideoPreviewAttached = true; - const video = document.createElement("video"); - video.src = file.url || buildViewUrl(file); - video.muted = !!mjrSettings.viewer.muteVideos; - video.loop = !!mjrSettings.viewer.loopVideos; - video.playsInline = true; - video.preload = "metadata"; - video.style.width = "100%"; - video.style.height = "100%"; - video.style.objectFit = "cover"; - thumb.appendChild(video); - - const onMouseEnter = () => { - if (thumb.__mjrHoverTimer) clearTimeout(thumb.__mjrHoverTimer); - thumb.__mjrHoverTimer = setTimeout(() => { - // Race condition fix: Check if video still exists and is connected - if (!video || !video.isConnected) return; - if (mjrSettings.viewer.autoplayVideos) { - video.play().catch(() => {}); - } - }, 200); - }; - const onMouseLeave = () => { - if (thumb.__mjrHoverTimer) { - clearTimeout(thumb.__mjrHoverTimer); - thumb.__mjrHoverTimer = null; - } - // Race condition fix: Check if video still exists before pausing - if (!video || !video.isConnected) return; - try { - video.pause(); - video.currentTime = 0; - } catch (_) {} - }; - - card.addEventListener("mouseenter", onMouseEnter); - card.addEventListener("mouseleave", onMouseLeave); - - card.__cleanupVideoListeners = () => { - card.removeEventListener("mouseenter", onMouseEnter); - card.removeEventListener("mouseleave", onMouseLeave); - if (thumb.__mjrHoverTimer) { - clearTimeout(thumb.__mjrHoverTimer); - thumb.__mjrHoverTimer = null; - } - // Clean up video element properly - try { - if (video && video.isConnected) { - video.pause(); - video.src = ""; - video.load(); // Release resources - } - } catch (_) {} - }; - }; - - if (kind === "image") { - const img = document.createElement("img"); - img.loading = "eager"; - img.src = file.url || buildViewUrl(file); - img.style.width = "100%"; - img.style.height = "100%"; - img.style.objectFit = "cover"; - thumb.appendChild(img); - } else if (kind === "video") { - // No server-side thumbs: use inline video preview with hover debounce - attachVideoPreview(); - - const playIcon = document.createElement("i"); - playIcon.className = "pi pi-play"; - playIcon.style.position = "absolute"; - playIcon.style.right = "8px"; - playIcon.style.bottom = "8px"; - playIcon.style.fontSize = "1rem"; - playIcon.style.padding = "3px 5px"; - playIcon.style.borderRadius = "999px"; - playIcon.style.background = "rgba(0,0,0,0.6)"; - playIcon.style.color = mjrThemeVars.icon; - thumb.appendChild(playIcon); - } else if (kind === "audio") { - const audioBg = createEl("div"); - audioBg.style.width = "100%"; - audioBg.style.height = "100%"; - audioBg.style.display = "flex"; - audioBg.style.alignItems = "center"; - audioBg.style.justifyContent = "center"; - audioBg.style.background = mjrThemeVars.audioGradient; - - const icon = document.createElement("i"); - icon.className = "pi pi-volume-up"; - icon.style.fontSize = "1.4rem"; - icon.style.color = mjrThemeVars.icon; - audioBg.appendChild(icon); - - thumb.appendChild(audioBg); - } else if (kind === "model3d") { - const modelBg = createEl("div"); - modelBg.style.width = "100%"; - modelBg.style.height = "100%"; - modelBg.style.display = "flex"; - modelBg.style.alignItems = "center"; - modelBg.style.justifyContent = "center"; - modelBg.style.background = mjrThemeVars.modelGradient; - - const icon = document.createElement("i"); - icon.className = "pi pi-cube"; - icon.style.fontSize = "1.4rem"; - icon.style.color = mjrThemeVars.icon; - modelBg.appendChild(icon); - - thumb.appendChild(modelBg); - } else { - const box = createEl("div"); - box.style.width = "100%"; - box.style.height = "100%"; - box.style.display = "flex"; - box.style.alignItems = "center"; - box.style.justifyContent = "center"; - box.style.background = mjrThemeVars.cardBg; - box.textContent = ext || "FILE"; - thumb.appendChild(box); - } - - return thumb; -} - -export function updateCardSelectionStyle(card, selected) { - if (selected) { - card.style.borderColor = "var(--comfy-accent, #5fb3ff)"; - card.style.boxShadow = "0 0 0 1px var(--comfy-accent, #5fb3ff)"; - card.style.background = "rgba(95,179,255,0.10)"; - } else { - card.style.borderColor = "var(--border-color, #444)"; - card.style.boxShadow = "none"; - card.style.background = "rgba(255,255,255,0.02)"; - } -} diff --git a/js/ui_context_menu.js b/js/ui_context_menu.js deleted file mode 100644 index d68e3f84..00000000 --- a/js/ui_context_menu.js +++ /dev/null @@ -1,225 +0,0 @@ -import { app } from "../../../scripts/app.js"; -import { api } from "../../../scripts/api.js"; -import { applyStyles, CONTEXT_MENU_STYLES, createEl, mjrShowToast } from "./ui_settings.js"; -import { mjrOpenABViewer } from "./ui_viewer.js"; - -export function mjrCreateContextMenu(file, deps) { - const { state, fileKey, removeFileFromState, refreshCollections, closeContextMenu, updateStatus } = deps; - const contextMenu = createEl("div", "context-menu"); - applyStyles(contextMenu, CONTEXT_MENU_STYLES); - const relPath = `${file.subfolder ? file.subfolder + "/" : ""}${file.filename || file.name}`; - const currentKey = fileKey(file); - const normalizePath = (p = "") => (p.startsWith("/") ? p.slice(1) : p); - - // --- COLLECTION SUBMENU --- - const addToCollItem = createEl("div", "", "Add to Collection â–ļ"); - addToCollItem.style.padding = "4px 0"; - addToCollItem.style.cursor = "pointer"; - - const closeSubmenu = (relatedTarget) => { - const submenu = contextMenu.querySelector(".mjr-submenu"); - if (!submenu) return; - const withinSub = relatedTarget && submenu.contains(relatedTarget); - const withinItem = relatedTarget && addToCollItem.contains(relatedTarget); - if (!withinSub && !withinItem) submenu.remove(); - }; - - addToCollItem.addEventListener("mouseenter", async () => { - const old = contextMenu.querySelector(".mjr-submenu"); - if (old) old.remove(); - - const submenu = createEl("div", "mjr-submenu"); - applyStyles(submenu, CONTEXT_MENU_STYLES); - submenu.style.position = "absolute"; - submenu.style.left = "140px"; - submenu.style.top = "0"; - - try { - const res = await api.fetchApi("/mjr/collections/list"); - const data = await res.json().catch(() => ({})); - - // 1. Create New - const newItem = createEl("div", "", "+ New Collection"); - newItem.style.padding = "4px 0"; - newItem.style.cursor = "pointer"; - newItem.style.borderBottom = "1px solid var(--border-color, #444)"; - newItem.style.marginBottom = "4px"; - - const handleAdd = async (name) => { - const targets = (state.selected && state.selected.has(currentKey)) - ? Array.from(state.selected).map(normalizePath) - : [relPath]; - - for (const targetPath of targets) { - await api.fetchApi("/mjr/collections/add", { - method: "POST", - body: JSON.stringify({ name, path: targetPath }), - }); - } - mjrShowToast("success", `Added ${targets.length} file(s) to ${name}`, "Collection"); - refreshCollections(); - closeContextMenu(); - }; - - newItem.addEventListener("click", (e) => { - e.stopPropagation(); - const name = window.prompt("Collection Name:"); - if (name) handleAdd(name); - }); - submenu.appendChild(newItem); - - // 2. Existing Collections - (data.collections || []).forEach((name) => { - const item = createEl("div", "", name); - item.style.padding = "4px 0"; - item.style.cursor = "pointer"; - item.addEventListener("click", (e) => { - e.stopPropagation(); - handleAdd(name); - }); - submenu.appendChild(item); - }); - } catch (err) { - console.error("[Majoor.AssetsManager] collections submenu failed", err); - } - // append to parent to avoid expanding main item height - contextMenu.appendChild(submenu); - - submenu.addEventListener("mouseleave", (ev) => closeSubmenu(ev.relatedTarget)); - }); - - addToCollItem.addEventListener("mouseleave", (ev) => closeSubmenu(ev.relatedTarget)); - contextMenu.appendChild(addToCollItem); - - // --- ACTIONS --- - const actions = [ - { - label: "Open Folder", - action: async () => { - closeContextMenu(); - try { - const res = await api.fetchApi("/mjr/filemanager/open_folder", { - method: "POST", - body: JSON.stringify({ - filename: file.filename || file.name, - subfolder: file.subfolder || "", - }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || data.ok === false) throw new Error(data.error || "Request failed"); - - if (data.warning) mjrShowToast("warn", data.warning, "Folder"); - else mjrShowToast("success", "Opened folder", "Folder"); - } catch (err) { - mjrShowToast("error", err?.message || "Failed to open folder", "Folder"); - } - }, - }, - { - label: "Compare", - action: () => { - closeContextMenu(); - if (state.selected && state.selected.size === 2) { - const keys = Array.from(state.selected); - const keyToFile = new Map(); - for (const f of state.filtered) { - const rawName = f.name || f.filename || "(unnamed)"; - const k = `${f.subfolder || ""}/${rawName}`; - keyToFile.set(k, f); - } - const filesToShow = keys.map((k) => keyToFile.get(k)).filter(Boolean); - if (filesToShow.length === 2) { - mjrOpenABViewer(filesToShow[0], filesToShow[1]); - } - } - }, - }, - { - label: "Delete", - action: async () => { - closeContextMenu(); - - // 1. Gather Items - const currentKeyInner = fileKey(file); - let itemsToDelete = []; - if (state.selected && state.selected.has(currentKeyInner)) { - const keyToFile = new Map(); - for (const f of state.files) keyToFile.set(fileKey(f), f); - state.selected.forEach((k) => { - const f = keyToFile.get(k); - if (f) itemsToDelete.push({ filename: f.filename || f.name, subfolder: f.subfolder || "" }); - }); - } else { - itemsToDelete.push({ filename: file.filename || file.name, subfolder: file.subfolder || "" }); - } - if (!itemsToDelete.length) return; - - // 2. Show Dialog - const count = itemsToDelete.length; - const confirmHtml = ` -
-

Delete ${count} file${count > 1 ? "s" : ""}?

-

This cannot be undone.

-
- - -
-
- `; - - const dialog = new app.ui.dialog.constructor(); - dialog.show(confirmHtml); // Correct usage: pass HTML string to show() - - // 3. Bind Events - const btnDelete = dialog.element.querySelector("#mjr-delete-btn"); - const btnCancel = dialog.element.querySelector("#mjr-cancel-btn"); - - if (btnDelete) btnDelete.onclick = async () => { - dialog.close(); - try { - const res = await api.fetchApi("/mjr/filemanager/delete", { - method: "POST", - body: JSON.stringify({ items: itemsToDelete }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || data.ok === false) throw new Error(data.error || "Request failed"); - - // Update State - itemsToDelete.forEach((item) => { - const f = state.files.find( - (x) => (x.filename || x.name) === item.filename && (x.subfolder || "") === item.subfolder - ); - if (f) removeFileFromState(f); - }); - - if (state.selected && state.selected.clear) { - state.selected.clear(); - updateStatus(); - } - mjrShowToast("success", `${count} file(s) deleted`, "Trash"); - } catch (e) { - mjrShowToast("error", e?.message || "Delete failed", "Error"); - } - }; - - if (btnCancel) btnCancel.onclick = () => dialog.close(); - }, - }, - ]; - - actions.forEach(({ label, action }) => { - const item = createEl("div", "", label); - item.style.padding = "4px 0"; - item.style.cursor = "pointer"; - item.addEventListener("click", () => action()); - contextMenu.appendChild(item); - }); - - return contextMenu; -} diff --git a/js/ui_grid.js b/js/ui_grid.js deleted file mode 100644 index 81bb06f5..00000000 --- a/js/ui_grid.js +++ /dev/null @@ -1,1095 +0,0 @@ -import { app } from "../../../scripts/app.js"; -import { api } from "../../../scripts/api.js"; -import { - createEl, - mjrAttachHoverFeedback, - mjrCardBasePx, - mjrDeepMerge, - mjrPrefetchOutputs, - mjrResetPrefetch, - mjrSaveSettings, - mjrSettings, - mjrSettingsDefaults, - mjrShowToast, - setMjrSettings, - detectKindFromExt, -} from "./ui_settings.js"; -import { mjrGlobalState } from "./global_state.js"; -import { fileKey } from "./assets_state.js"; -import { SMART_FILTERS } from "./assets_filters.js"; -import { mjrCreateContextMenu } from "./ui_context_menu.js"; -import { - mjrOpenABViewer, - mjrOpenViewerForFiles, - mjrStepVideos, - mjrApplyOpenViewerSettings, - mjrUpdateNavVisibility, - mjrCleanupViewerGlobalListeners, - mjrViewerState, -} from "./ui_viewer.js"; -import { createMetadataSidebar } from "./ui_sidebar.js"; -import { createGridView } from "./ui_gridview.js"; -import { createStatusDot, injectStatusDotStyles } from "./ui_status_dot.js"; -import { - ensureGlobalAutoRefresh, - ensureVideoNodeDropBridge, - ensureNewFilesListener, - ensureQueueListener, - mjrStartAutoRefreshTimer, -} from "./grid/events.js"; -import { createMetadataFetcher } from "./grid/fetch.js"; -import { initManagerState, populateCollectionsOptions } from "./grid/state.js"; -import { createApplyFilterAndRender } from "./grid/render.js"; -import { createRefreshController } from "./grid/load.js"; - -// --------------------------------------------------------------------------- -// Majoor Assets Manager globals + hotkeys -// --------------------------------------------------------------------------- - -let mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault; -let refreshAllInstances = () => Promise.resolve(); - -const mjrPanelMode = String(mjrSettings?.integration?.panel || mjrSettingsDefaults?.integration?.panel || "both").toLowerCase(); -const mjrUseBottomPanel = mjrPanelMode === "bottom" || mjrPanelMode === "both"; -const mjrUseSidebarPanel = mjrPanelMode === "sidebar" || mjrPanelMode === "both"; - -function mjrApplySettings(newSettings) { - setMjrSettings(mjrDeepMerge(mjrSettingsDefaults, newSettings || {})); - mjrSaveSettings(mjrSettings); - mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault; - mjrStartAutoRefreshTimer(refreshAllInstances); - mjrUpdateNavVisibility(); - - // Re-render grids with new sizing/badges/filters - mjrGlobalState.instances.forEach((inst) => { - try { - if (typeof inst.setMetadataPanelVisibility === "function") { - inst.setMetadataPanelVisibility(mjrMetaVisible); - } - // Force grid re-render to pick up card size/badge changes - if (typeof inst.applyFilterAndRender === "function") { - inst.applyFilterAndRender(); - } - } catch (err) { - console.warn("[Majoor.AssetsManager] apply settings refresh failed", err); - } - }); - refreshAllInstances({ silent: true, forceFiles: false }); -} - -function renderAssetsManager(root) { - const cleanups = []; - const cleanupsExternal = []; - - root.innerHTML = ""; - Object.assign(root.style, { - display: "flex", - flexDirection: "column", - height: "100%", - userSelect: "text", // allow copying any text in the manager UI - }); - - // --- TOOLBAR --- - const toolbar = createEl("div", "mjr-fm-toolbar"); - Object.assign(toolbar.style, { display: "flex", flexDirection: "column", gap: "6px", padding: "8px 12px 6px 12px", borderBottom: "1px solid var(--border-color, #444)", background: "var(--comfy-menu-bg, #111)" }); - - const headerRow = createEl("div", "mjr-fm-header-row"); - Object.assign(headerRow.style, { display: "flex", alignItems: "center", justifyContent: "flex-start" }); - - const title = createEl("div", "mjr-fm-title", "Majoor Assets Manager"); - Object.assign(title.style, { fontWeight: "600", fontSize: "0.85rem", opacity: "0.9" }); - headerRow.append(title); - - // Status dot (index health + click to reindex) - injectStatusDotStyles(); - const statusDot = createStatusDot(api); - headerRow.appendChild(statusDot.element); - cleanups.push(() => statusDot.cleanup()); - - const searchRow = createEl("div", "mjr-fm-search-row"); - Object.assign(searchRow.style, { display: "flex", alignItems: "center", gap: "6px" }); - - const search = createEl("input", "mjr-fm-search"); - search.type = "search"; - search.placeholder = "Search outputs..."; - Object.assign(search.style, { flex: "1", minWidth: "0", borderRadius: "6px", border: "1px solid var(--border-color, #333)", background: "var(--comfy-input-bg, #1b1b1b)", padding: "6px 8px", fontSize: "0.8rem", color: "var(--input-fg, #eee)" }); - - const refreshBtn = createEl("button", "comfy-btn mjr-fm-refresh"); - Object.assign(refreshBtn.style, { padding: "4px 6px", borderRadius: "6px", minWidth: "32px", display: "flex", alignItems: "center", justifyContent: "center" }); - const refreshIcon = createEl("i", "pi pi-refresh", null, { fontSize: "0.9rem" }); - refreshBtn.appendChild(refreshIcon); - - const bulkBtn = createEl("button", "comfy-btn mjr-fm-bulk"); - bulkBtn.textContent = "Bulk"; - Object.assign(bulkBtn.style, { padding: "4px 10px", borderRadius: "6px", fontSize: "0.8rem", display: "none" }); - - // Show/hide bulk button based on selection - const updateBulkButtonVisibility = () => { - bulkBtn.style.display = state.selected.size > 0 ? "inline-block" : "none"; - }; - - searchRow.append(search, bulkBtn, refreshBtn); - mjrAttachHoverFeedback(search, "Type to filter files by name or text.", 3000); - mjrAttachHoverFeedback(refreshBtn, "Force an immediate refresh of outputs.", 3000); - - const filterRow = createEl("div", "mjr-fm-filter-row"); - Object.assign(filterRow.style, { display: "flex", alignItems: "center", gap: "6px" }); - - const ratingFilter = createEl("select", "mjr-fm-filter-rating"); - [["0", "All"], ["1", "*+"], ["2", "**+"], ["3", "***+"], ["4", "****+"], ["5", "*****"]].forEach(([value, label]) => { - ratingFilter.add(new Option(label, value)); - }); - - const tagFilterInput = createEl("input", "mjr-fm-filter-tag"); - tagFilterInput.type = "text"; - tagFilterInput.placeholder = "Filter by tag..."; - Object.assign(tagFilterInput.style, { flex: "1", minWidth: "0", borderRadius: "6px", border: "1px solid var(--border-color, #333)", background: "var(--comfy-input-bg, #1b1b1b)", padding: "4px 6px", fontSize: "0.8rem", color: "var(--input-fg, #eee)" }); - - filterRow.append( - createEl("div", "", "Min *", { fontSize: "0.75rem", opacity: "0.75" }), - ratingFilter, - tagFilterInput - ); - mjrAttachHoverFeedback(ratingFilter, "Filter by minimum rating (*).", 3000); - mjrAttachHoverFeedback(tagFilterInput, "Filter by tag (separate with commas).", 3000); - - const collectionFilterSelect = createEl("select", "mjr-fm-filter-collection"); - collectionFilterSelect.style.maxWidth = "140px"; - - filterRow.appendChild(collectionFilterSelect); - - const status = createEl("div", "mjr-fm-status"); - Object.assign(status.style, { marginTop: "2px", fontSize: "0.7rem", opacity: "0.65" }); - - toolbar.append(headerRow, searchRow, filterRow, status); - - // --- BODY (Grid + Meta) --- - const body = createEl("div", "mjr-fm-body"); - Object.assign(body.style, { flex: "1", display: "flex", flexDirection: "row", overflow: "hidden", background: "var(--comfy-bg-color, #080808)" }); - - const gridWrapper = createEl("div", "mjr-fm-grid-wrapper"); - Object.assign(gridWrapper.style, { flex: "1.8", overflow: "auto", position: "relative" }); - gridWrapper.setAttribute("tabindex", "0"); // Keep focusable for selection hotkeys - - const grid = createEl("div", "mjr-fm-grid"); - const basePx = mjrCardBasePx(); - Object.assign(grid.style, { display: "grid", gridTemplateColumns: `repeat(auto-fill, minmax(${basePx}px, 1fr))`, gap: "8px", padding: "10px 12px", alignItems: "start", gridAutoRows: "auto" }); - gridWrapper.appendChild(grid); - - const metaPanel = createEl("div", "mjr-fm-meta-panel"); - Object.assign(metaPanel.style, { flex: "1.4", borderLeft: "1px solid var(--border-color, #333)", maxWidth: "480px", minWidth: "260px", display: mjrMetaVisible ? "flex" : "none", flexDirection: "column", padding: "10px 12px", gap: "8px", overflow: "auto" }); - - const metaTitle = createEl("div", "mjr-fm-meta-title", "Metadata"); - Object.assign(metaTitle.style, { fontWeight: "600", fontSize: "0.8rem", opacity: "0.9" }); - - const metaContent = createEl("div", "mjr-fm-meta-content"); - Object.assign(metaContent.style, { flex: "1", display: "flex", flexDirection: "column", gap: "8px" }); - metaContent.innerHTML = '
Click a file to inspect prompts, workflow, rating, and tags.
'; - metaPanel.append(metaTitle, metaContent); - - body.append(gridWrapper, metaPanel); - root.append(toolbar, body); - - // --------------------------------------------------------------------------- - // STATE & LOGIC - // --------------------------------------------------------------------------- - - const state = initManagerState(); - populateCollectionsOptions(state, collectionFilterSelect); - - let openContextMenuEl = null; - const { - fetchMetadataForVisible, - fetchMetadataForFilter, - onRequestMetadata, - cleanup: cleanupMetadataFetcher, - setGridView: setGridViewForMetadata, - setOnMetadataUpdated, - } = createMetadataFetcher(state, null); - const { - applyFilterAndRender, - setGridView: setGridViewForRender, - } = createApplyFilterAndRender(state, fetchMetadataForVisible, fetchMetadataForFilter); - - setOnMetadataUpdated(() => { - const tagFilter = (state.tagFilter || "").trim(); - const minRating = Number(state.minRating || 0); - if (tagFilter || minRating > 0) { - applyFilterAndRender(); - } - }); - const { - refreshInstance, - refreshAllInstances: refreshAllInstancesFn, - loadFilesWrapper, - loadFiles, - loadMoreFiles, - lastSignatureRef, - } = createRefreshController(state, grid, applyFilterAndRender, fetchMetadataForVisible); - refreshAllInstances = refreshAllInstancesFn; - const refreshCollections = () => populateCollectionsOptions(state, collectionFilterSelect); - - // Media drops on the ComfyUI canvas are handled natively by ComfyUI (this plugin does not intercept drop events). - cleanups.push(ensureVideoNodeDropBridge()); - cleanups.push(ensureQueueListener(refreshAllInstances)); - cleanups.push(ensureNewFilesListener()); - cleanups.push(ensureGlobalAutoRefresh(refreshAllInstances)); - - // --- ACTIONS --- - - const closeContextMenu = () => { - if (openContextMenuEl?.parentNode) { - openContextMenuEl.parentNode.removeChild(openContextMenuEl); - } - openContextMenuEl = null; - }; - - const updateStatus = () => { - if (gridView) gridView.updateStatus(); - updateBulkButtonVisibility(); - }; - - const removeFileFromState = (file) => { - const key = fileKey(file); - const removeFrom = (arr) => { - const idx = arr.findIndex((f) => fileKey(f) === key); - if (idx > -1) arr.splice(idx, 1); - }; - removeFrom(state.files); - removeFrom(state.filtered); - state.selected.delete(key); - if (state.currentFile && fileKey(state.currentFile) === key) { - state.currentFile = null; - state.currentMeta = null; - } - applyFilterAndRender(); - updateStatus(); - }; - - const openContextMenu = (ev, file) => { - closeContextMenu(); - const contextMenu = mjrCreateContextMenu(file, { - state, - fileKey, - removeFileFromState, - refreshCollections, - closeContextMenu, - updateStatus - }); - Object.assign(contextMenu.style, { left: `${ev.clientX}px`, top: `${ev.clientY}px` }); - document.body.appendChild(contextMenu); - openContextMenuEl = contextMenu; - - setTimeout(() => { - const handler = (evt) => { - if (openContextMenuEl && !openContextMenuEl.contains(evt.target)) { - closeContextMenu(); - } - }; - document.addEventListener("click", handler, { once: true }); - }, 0); - }; - - const sidebar = createMetadataSidebar({ - state, - metaPanel, - metaContent, - applyFilterAndRender, - refreshAllInstances, - fileKey, - onVisibilityChange: (visible) => { - mjrMetaVisible = visible; - gridDeps.mjrMetaVisible = visible; // Pass dynamic visibility to GridView - }, - }); - - const { loadMetadataForFile, setMetadataPanelVisibility, updateFileMetadata, setRating } = sidebar; - - // --- BULK OPERATIONS --- - - const openBulkOperationsModal = () => { - const selectedFiles = Array.from(state.selected).map(key => { - const parts = key.split(":::"); - return state.files.find(f => fileKey(f) === key); - }).filter(Boolean); - - if (selectedFiles.length === 0) { - mjrShowToast("info", "No files selected", "Bulk Operations"); - return; - } - - const overlay = createEl("div", "mjr-bulk-overlay"); - Object.assign(overlay.style, { - position: "fixed", - top: "0", - left: "0", - right: "0", - bottom: "0", - background: "rgba(0, 0, 0, 0.7)", - display: "flex", - alignItems: "center", - justifyContent: "center", - zIndex: "10000" - }); - - const modal = createEl("div", "mjr-bulk-modal"); - Object.assign(modal.style, { - background: "var(--comfy-menu-bg, #1e1e1e)", - border: "1px solid var(--border-color, #333)", - borderRadius: "8px", - padding: "20px", - minWidth: "400px", - maxWidth: "500px", - boxShadow: "0 4px 12px rgba(0, 0, 0, 0.5)" - }); - - const title = createEl("h3", "", `Bulk Operations (${selectedFiles.length} files)`); - Object.assign(title.style, { marginTop: "0", marginBottom: "16px", fontSize: "1rem" }); - - const ratingRow = createEl("div", ""); - Object.assign(ratingRow.style, { marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }); - const ratingLabel = createEl("label", "", "Set Rating:"); - Object.assign(ratingLabel.style, { flex: "0 0 100px", fontSize: "0.85rem" }); - const ratingSelect = createEl("select", ""); - Object.assign(ratingSelect.style, { - flex: "1", - padding: "6px", - borderRadius: "4px", - background: "var(--comfy-input-bg, #2a2a2a)", - color: "var(--input-fg, #eee)", - border: "1px solid var(--border-color, #444)" - }); - [["", "(no change)"], ["0", "0 - Clear"], ["1", "1 ★"], ["2", "2 ★★"], ["3", "3 ★★★"], ["4", "4 ★★★★"], ["5", "5 ★★★★★"]].forEach(([value, label]) => { - ratingSelect.add(new Option(label, value)); - }); - ratingRow.append(ratingLabel, ratingSelect); - - const tagsRow = createEl("div", ""); - Object.assign(tagsRow.style, { marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }); - const tagsLabel = createEl("label", "", "Add Tags:"); - Object.assign(tagsLabel.style, { flex: "0 0 100px", fontSize: "0.85rem" }); - const tagsInput = createEl("input", ""); - tagsInput.type = "text"; - tagsInput.placeholder = "tag1, tag2, tag3"; - Object.assign(tagsInput.style, { - flex: "1", - padding: "6px", - borderRadius: "4px", - background: "var(--comfy-input-bg, #2a2a2a)", - color: "var(--input-fg, #eee)", - border: "1px solid var(--border-color, #444)" - }); - tagsRow.append(tagsLabel, tagsInput); - - const removeTagsRow = createEl("div", ""); - Object.assign(removeTagsRow.style, { marginBottom: "16px", display: "flex", alignItems: "center", gap: "8px" }); - const removeTagsLabel = createEl("label", "", "Remove Tags:"); - Object.assign(removeTagsLabel.style, { flex: "0 0 100px", fontSize: "0.85rem" }); - const removeTagsInput = createEl("input", ""); - removeTagsInput.type = "text"; - removeTagsInput.placeholder = "tag1, tag2"; - Object.assign(removeTagsInput.style, { - flex: "1", - padding: "6px", - borderRadius: "4px", - background: "var(--comfy-input-bg, #2a2a2a)", - color: "var(--input-fg, #eee)", - border: "1px solid var(--border-color, #444)" - }); - removeTagsRow.append(removeTagsLabel, removeTagsInput); - - const buttonRow = createEl("div", ""); - Object.assign(buttonRow.style, { display: "flex", justifyContent: "flex-end", gap: "8px" }); - - const cancelBtn = createEl("button", "comfy-btn", "Cancel"); - Object.assign(cancelBtn.style, { - padding: "6px 16px", - borderRadius: "4px", - fontSize: "0.85rem" - }); - - const applyBtn = createEl("button", "comfy-btn", "Apply"); - Object.assign(applyBtn.style, { - padding: "6px 16px", - borderRadius: "4px", - fontSize: "0.85rem", - background: "var(--primary-color, #007bff)", - color: "#fff" - }); - - buttonRow.append(cancelBtn, applyBtn); - modal.append(title, ratingRow, tagsRow, removeTagsRow, buttonRow); - overlay.appendChild(modal); - - const closeModal = () => { - document.body.removeChild(overlay); - }; - - cancelBtn.addEventListener("click", closeModal); - overlay.addEventListener("click", (e) => { - if (e.target === overlay) closeModal(); - }); - - applyBtn.addEventListener("click", async () => { - const newRating = ratingSelect.value; - const addTagsStr = tagsInput.value.trim(); - const removeTagsStr = removeTagsInput.value.trim(); - - if (!newRating && !addTagsStr && !removeTagsStr) { - mjrShowToast("info", "No changes specified", "Bulk Operations"); - closeModal(); - return; - } - - const addTags = addTagsStr ? addTagsStr.split(",").map(t => t.trim()).filter(Boolean) : []; - const removeTags = removeTagsStr ? removeTagsStr.split(",").map(t => t.trim()).filter(Boolean) : []; - - let processed = 0; - let errors = 0; - - for (const file of selectedFiles) { - try { - const updates = {}; - - if (newRating !== "") { - file.rating = parseInt(newRating); - updates.rating = parseInt(newRating); - } - - if (addTags.length > 0 || removeTags.length > 0) { - const currentTags = Array.isArray(file.tags) ? [...file.tags] : []; - let newTags = [...currentTags]; - - for (const tag of addTags) { - if (!newTags.includes(tag)) { - newTags.push(tag); - } - } - - newTags = newTags.filter(tag => !removeTags.includes(tag)); - - file.tags = newTags; - updates.tags = newTags; - } - - if (Object.keys(updates).length > 0) { - await updateFileMetadata(file, updates, null); - processed++; - } - } catch (err) { - console.error(`Failed to update ${file.filename || file.name}:`, err); - errors++; - } - } - - closeModal(); - - if (errors > 0) { - mjrShowToast("warning", `Updated ${processed} files, ${errors} failed`, "Bulk Operations"); - } else { - mjrShowToast("success", `Updated ${processed} files`, "Bulk Operations"); - } - - applyFilterAndRender(); - }); - - document.body.appendChild(overlay); - }; - - bulkBtn.addEventListener("click", openBulkOperationsModal); - mjrAttachHoverFeedback(bulkBtn, "Perform bulk operations on selected files.", 3000); - - // --- GRID VIEW INTEGRATION --- - - const gridDeps = { - state, - grid, - gridWrapper, - statusEl: status, - loadMetadataForFile: sidebar.loadMetadataForFile, - openContextMenu, - onRequestMetadata, - onNeedMoreFiles: () => loadMoreFiles({ silent: true }), - isMetaVisible: () => mjrMetaVisible, - }; - const gridView = createGridView(gridDeps); - setGridViewForMetadata(gridView); - setGridViewForRender(gridView); - - // Throttle scroll/resize handlers for better performance - let scrollRaf = null; - const handleScroll = () => { - if (scrollRaf) return; - scrollRaf = requestAnimationFrame(() => { - gridView.renderGrid(); - scrollRaf = null; - }); - }; - - let resizeTimeout = null; - const handleResize = () => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => gridView.renderGrid(), 150); - }; - - gridWrapper.addEventListener("scroll", handleScroll, { passive: true }); - window.addEventListener("resize", handleResize); - cleanups.push(() => { - gridWrapper.removeEventListener("scroll", handleScroll); - if (scrollRaf) cancelAnimationFrame(scrollRaf); - }); - cleanups.push(() => { - window.removeEventListener("resize", handleResize); - if (resizeTimeout) clearTimeout(resizeTimeout); - }); - cleanups.push(() => cleanupMetadataFetcher()); - - // --- GRID HOTKEYS --- - - const onGridSelectAll = (e) => { - const key = String(e.key || "").toLowerCase(); - if (key !== "a") return; - if (!(e.ctrlKey || e.metaKey)) return; - if (mjrViewerState?.overlay && mjrViewerState.overlay.style.display !== "none") return; - - const target = e.target; - const tagName = target && target.tagName ? String(target.tagName).toUpperCase() : ""; - const isInput = - tagName === "INPUT" || - tagName === "TEXTAREA" || - tagName === "SELECT" || - !!(target && target.isContentEditable); - if (isInput) return; - - const active = document.activeElement; - const inGrid = (active && gridWrapper.contains(active)) || (target && root.contains(target)); - if (!inGrid) return; - - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation?.(); - - state.selected.clear(); - (state.filtered || []).forEach((f) => state.selected.add(fileKey(f))); - if (state.filtered && state.filtered.length) { - state.currentFile = state.filtered[0]; - state.__mjrSelectionAnchorKey = fileKey(state.filtered[0]); - } - state.renderVersion = (state.renderVersion || 0) + 1; - gridView.renderGrid(); - updateStatus(); - }; - - document.addEventListener("keydown", onGridSelectAll, true); - cleanups.push(() => document.removeEventListener("keydown", onGridSelectAll, true)); - - // --- EVENTS --- - const onSearchInput = () => { state.search = search.value; applyFilterAndRender(); }; - const onRatingFilterChange = () => { state.minRating = parseInt(ratingFilter.value, 10) || 0; applyFilterAndRender(); }; - const onTagFilterInput = () => { state.tagFilter = tagFilterInput.value; applyFilterAndRender(); }; - const onRefreshClick = () => { refreshAllInstances({ silent: false, forceFiles: true }); }; - const onCollectionChange = async () => { - const val = collectionFilterSelect.value; - state.activeCollection = val; - state.collectionSet = null; - state.smartFilter = null; - if (val) { - if (val.startsWith("smart:")) { - state.smartFilter = SMART_FILTERS[val]?.filter; - } else { - const res = await api.fetchApi(`/mjr/collections/${val}`); - const data = await res.json(); - state.collectionSet = new Set(data.files || []); - } - } - applyFilterAndRender(); - }; - - search.addEventListener("input", onSearchInput); - ratingFilter.addEventListener("change", onRatingFilterChange); - tagFilterInput.addEventListener("input", onTagFilterInput); - refreshBtn.addEventListener("click", onRefreshClick); - collectionFilterSelect.addEventListener("change", onCollectionChange); - cleanups.push(() => search.removeEventListener("input", onSearchInput)); - cleanups.push(() => ratingFilter.removeEventListener("change", onRatingFilterChange)); - cleanups.push(() => tagFilterInput.removeEventListener("input", onTagFilterInput)); - cleanups.push(() => refreshBtn.removeEventListener("click", onRefreshClick)); - cleanups.push(() => collectionFilterSelect.removeEventListener("change", onCollectionChange)); - - // Initial Load - refreshInstance({ silent: false, forceFiles: true }); - - // Register Instance - const instance = { root, state, loadMetadataForFile, setMetadataPanelVisibility, setRating, updateFileMetadata, refresh: refreshInstance, loadFiles, applyFilterAndRender }; - mjrGlobalState.instances.add(instance); - - root.__mjrCleanup = () => { - root.__mjrCleanup = null; - mjrGlobalState.instances.delete(instance); - if (mjrGlobalState.instances.size === 0) { - try { - mjrCleanupViewerGlobalListeners?.(); - } catch (err) { - console.warn("[Majoor.AssetsManager] viewer cleanup failed", err); - } - } - cleanups.forEach(c => c()); - cleanupsExternal.forEach((c) => c()); - }; -} - -app.registerExtension({ - name: "Majoor.AssetsManager", - bottomPanelTabs: mjrUseBottomPanel - ? [ - { - id: "majoorAssetsManagerBottom", - title: "Assets Manager", - type: "custom", - render: (el) => renderAssetsManager(el), - destroy: (el) => el?.__mjrCleanup?.(), - }, - ] - : [], - async setup() { - const SETTINGS_PREFIX = "MajoorAM"; - - const warnIfNoExiftool = async () => { - const warnKey = "mjrExiftoolWarned"; - try { - if (typeof localStorage !== "undefined" && localStorage.getItem(warnKey)) return; - } catch (_) {} - try { - const res = await api.fetchApi("/mjr/filemanager/capabilities"); - if (!res.ok) return; - const data = await res.json(); - if (!data || !data.ok) return; - const exiftoolAvailable = !!data.exiftool_available; - if (!exiftoolAvailable) { - mjrShowToast( - "warn", - "ExifTool missing: Video metadata parsing will be limited. Please install ExifTool on your system.", - "Metadata" - ); - try { localStorage.setItem(warnKey, "1"); } catch (_) {} - } - } catch (_) { - /* ignore */ - } - }; - - // --- INTEGRATION --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Integration.Panel`, - name: "Majoor: Panel Integration (reload to apply)", - tooltip: "Choose where the Assets Manager appears (reload ComfyUI page after changing).", - type: "combo", - options: ["both", "sidebar", "bottom"], - defaultValue: mjrSettings.integration?.panel || mjrSettingsDefaults.integration.panel, - onChange: (val) => { - const next = String(val || "both"); - const current = String(mjrSettings.integration?.panel || "both"); - if (next === current) return; - mjrSettings.integration = mjrSettings.integration || {}; - mjrSettings.integration.panel = next; - mjrSaveSettings(mjrSettings); - mjrShowToast("info", "Reload the ComfyUI page to apply the new panel placement.", "Assets Manager"); - }, - }); - - // --- GRID SETTINGS --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Grid.CardSize`, - name: "Majoor: Card Size", - type: "combo", - options: ["compact", "medium", "large"], - defaultValue: mjrSettings.grid.cardSize, - onChange: (val) => { - mjrSettings.grid.cardSize = val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Grid.PageSize`, - name: "Majoor: Page Size (files per request)", - tooltip: "Bigger loads more at once; smaller reduces memory. Applies on next refresh.", - type: "number", - defaultValue: Number(mjrSettings.grid.pageSize) || mjrSettingsDefaults.grid.pageSize, - attrs: { min: 50, max: 2000, step: 50 }, - onChange: (val) => { - const n = Math.max(50, Math.min(2000, Math.floor(Number(val) || mjrSettingsDefaults.grid.pageSize))); - mjrSettings.grid.pageSize = n; - mjrSaveSettings(mjrSettings); - mjrResetPrefetch(); - mjrPrefetchOutputs(); - refreshAllInstances({ silent: true, forceFiles: true }); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Index.Source`, - name: "Majoor: File Source", - tooltip: "auto uses the SQLite index when fresh; filesystem always scans.", - type: "combo", - options: ["auto", "index", "filesystem"], - defaultValue: mjrSettings.index?.source || mjrSettingsDefaults.index.source, - onChange: (val) => { - mjrSettings.index = mjrSettings.index || {}; - mjrSettings.index.source = String(val || "auto"); - mjrSaveSettings(mjrSettings); - refreshAllInstances({ silent: true, forceFiles: true }); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Grid.ShowTags`, - name: "Majoor: Show Tags on Cards", - type: "boolean", - defaultValue: mjrSettings.grid.showTags, - onChange: (val) => { - mjrSettings.grid.showTags = val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Grid.ShowRating`, - name: "Majoor: Show Ratings on Cards", - type: "boolean", - defaultValue: mjrSettings.grid.showRating, - onChange: (val) => { - mjrSettings.grid.showRating = val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Grid.HoverInfo`, - name: "Majoor: Hover Info Tooltips", - tooltip: "Show file info on hover (native tooltip).", - type: "boolean", - defaultValue: mjrSettings.grid.hoverInfo, - onChange: (val) => { - mjrSettings.grid.hoverInfo = !!val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); - - // --- META PANEL --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.MetaPanel.ShowByDefault`, - name: "Majoor: Show Metadata Panel", - type: "boolean", - defaultValue: mjrSettings.metaPanel.showByDefault, - onChange: (val) => { - mjrSettings.metaPanel.showByDefault = !!val; - mjrSaveSettings(mjrSettings); - mjrMetaVisible = !!val; - mjrGlobalState.instances.forEach((i) => i.setMetadataPanelVisibility?.(mjrMetaVisible)); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.MetaPanel.RefreshInterval`, - name: "Majoor: Metadata Refresh Interval (ms)", - tooltip: "While a file is selected, re-fetch generation metadata at this interval (0 disables).", - type: "number", - defaultValue: Number(mjrSettings.metaPanel.refreshInterval) || mjrSettingsDefaults.metaPanel.refreshInterval, - attrs: { min: 0, step: 250 }, - onChange: (val) => { - const n = Math.max(0, Math.min(60000, Math.floor(Number(val) || 0))); - mjrSettings.metaPanel.refreshInterval = n; - mjrSaveSettings(mjrSettings); - }, - }); - - // --- AUTO REFRESH --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Refresh.Enabled`, - name: "Majoor: Auto-Refresh Enabled", - type: "boolean", - defaultValue: mjrSettings.autoRefresh.enabled, - onChange: (val) => { - mjrSettings.autoRefresh.enabled = val; - mjrSaveSettings(mjrSettings); - mjrStartAutoRefreshTimer(refreshAllInstances); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Refresh.Interval`, - name: "Majoor: Refresh Interval (ms)", - type: "number", - defaultValue: Number(mjrSettings.autoRefresh.interval) || mjrSettingsDefaults.autoRefresh.interval, - attrs: { min: 1000, step: 500 }, - onChange: (val) => { - mjrSettings.autoRefresh.interval = Number(val) || mjrSettingsDefaults.autoRefresh.interval; - mjrSaveSettings(mjrSettings); - mjrStartAutoRefreshTimer(refreshAllInstances); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Refresh.FocusOnly`, - name: "Majoor: Auto-Refresh Only When Focused", - type: "boolean", - defaultValue: mjrSettings.autoRefresh.focusOnly, - onChange: (val) => { - mjrSettings.autoRefresh.focusOnly = !!val; - mjrSaveSettings(mjrSettings); - mjrStartAutoRefreshTimer(refreshAllInstances); - }, - }); - - // --- TOASTS --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Toasts.Enabled`, - name: "Majoor: Toasts Enabled", - type: "boolean", - defaultValue: mjrSettings.toasts.enabled, - onChange: (val) => { - mjrSettings.toasts.enabled = !!val; - mjrSaveSettings(mjrSettings); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Toasts.Verbosity`, - name: "Majoor: Toast Verbosity", - type: "combo", - options: ["all", "warnings", "errors", "none"], - defaultValue: mjrSettings.toasts.verbosity || mjrSettingsDefaults.toasts.verbosity, - onChange: (val) => { - mjrSettings.toasts.verbosity = String(val || "all"); - mjrSaveSettings(mjrSettings); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Toasts.Duration`, - name: "Majoor: Toast Duration (ms)", - type: "number", - defaultValue: Number(mjrSettings.toasts.duration) || mjrSettingsDefaults.toasts.duration, - attrs: { min: 500, max: 20000, step: 250 }, - onChange: (val) => { - const n = Math.max(500, Math.min(20000, Math.floor(Number(val) || mjrSettingsDefaults.toasts.duration))); - mjrSettings.toasts.duration = n; - mjrSaveSettings(mjrSettings); - }, - }); - - // --- VIEWER SETTINGS --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.Autoplay`, - name: "Majoor Viewer: Autoplay Videos", - type: "boolean", - defaultValue: mjrSettings.viewer.autoplayVideos, - onChange: (val) => { - mjrSettings.viewer.autoplayVideos = val; - mjrSaveSettings(mjrSettings); - mjrApplyOpenViewerSettings(); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.Loop`, - name: "Majoor Viewer: Loop Videos", - type: "boolean", - defaultValue: mjrSettings.viewer.loopVideos, - onChange: (val) => { - mjrSettings.viewer.loopVideos = val; - mjrSaveSettings(mjrSettings); - mjrApplyOpenViewerSettings(); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.Mute`, - name: "Majoor Viewer: Mute Videos", - type: "boolean", - defaultValue: mjrSettings.viewer.muteVideos, - onChange: (val) => { - mjrSettings.viewer.muteVideos = !!val; - mjrSaveSettings(mjrSettings); - mjrApplyOpenViewerSettings(); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.Navigation`, - name: "Majoor Viewer: Navigation Enabled", - type: "boolean", - defaultValue: mjrSettings.viewer.navEnabled, - onChange: (val) => { - mjrSettings.viewer.navEnabled = !!val; - mjrSaveSettings(mjrSettings); - mjrApplyOpenViewerSettings(); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.RatingHUD`, - name: "Majoor Viewer: Show Rating HUD", - type: "boolean", - defaultValue: mjrSettings.viewer.showRatingHUD, - onChange: (val) => { - mjrSettings.viewer.showRatingHUD = !!val; - mjrSaveSettings(mjrSettings); - mjrApplyOpenViewerSettings(); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.RatingHotkeys`, - name: "Majoor Viewer: Rating Hotkeys (0-5)", - type: "boolean", - defaultValue: mjrSettings.viewer.ratingHotkeys, - onChange: (val) => { - mjrSettings.viewer.ratingHotkeys = !!val; - mjrSaveSettings(mjrSettings); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.Checkerboard`, - name: "Majoor Viewer: Checkerboard Background", - type: "boolean", - defaultValue: mjrSettings.viewer.useCheckerboard, - onChange: (val) => { - mjrSettings.viewer.useCheckerboard = val; - mjrSaveSettings(mjrSettings); - }, - }); - - // --- HOTKEYS --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Hotkeys.Enabled`, - name: "Majoor: Hotkeys Enabled", - type: "boolean", - defaultValue: mjrSettings.hotkeys.enabled, - onChange: (val) => { - mjrSettings.hotkeys.enabled = !!val; - mjrSaveSettings(mjrSettings); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Hotkeys.Rating`, - name: "Majoor: Rating Hotkeys in Grid (0-5)", - type: "boolean", - defaultValue: mjrSettings.hotkeys.rating, - onChange: (val) => { - mjrSettings.hotkeys.rating = !!val; - mjrSaveSettings(mjrSettings); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Hotkeys.SpaceOpen`, - name: "Majoor: Open Viewer Hotkey (Space)", - type: "boolean", - defaultValue: mjrSettings.hotkeys.spaceOpen, - onChange: (val) => { - mjrSettings.hotkeys.spaceOpen = !!val; - mjrSaveSettings(mjrSettings); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Hotkeys.FrameStep`, - name: "Majoor: Viewer Frame-Step Hotkeys", - type: "boolean", - defaultValue: mjrSettings.hotkeys.frameStep, - onChange: (val) => { - mjrSettings.hotkeys.frameStep = !!val; - mjrSaveSettings(mjrSettings); - }, - }); - - // --- GENERAL --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.General.HideSiblings`, - name: "Majoor: Hide PNG Siblings (Video previews)", - tooltip: "If a video has a matching .png, hide the .png from the grid", - type: "boolean", - defaultValue: mjrSettings.siblings.hidePngSiblings, - onChange: (val) => { - mjrSettings.siblings.hidePngSiblings = val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); - - warnIfNoExiftool(); - - if (mjrUseSidebarPanel && app.extensionManager?.registerSidebarTab) { - try { - app.extensionManager.registerSidebarTab({ - id: "majoorAssetsManagerSidebar", - icon: "pi pi-folder-open", - title: "Assets Manager", - tooltip: "Majoor Assets Manager", - type: "custom", - render: (el) => renderAssetsManager(el), - destroy: (el) => el?.__mjrCleanup?.(), - }); - } catch (err) { - console.error("[Majoor.AssetsManager] sidebar registration failed", err); - } - } - }, -}); - -if (!window.__MajoorAssetsManagerHotkeysInitialized) { - window.__MajoorAssetsManagerHotkeysInitialized = true; - - const onKeyDown = (ev) => { - if (!mjrSettings.hotkeys.enabled) return; - if (mjrViewerState?.overlay && mjrViewerState.overlay.style.display !== "none") return; - if (ev.isComposing) return; - const target = ev.target; - const tagName = (target && target.tagName) ? String(target.tagName).toUpperCase() : ""; - const isTyping = - tagName === "INPUT" || - tagName === "TEXTAREA" || - tagName === "SELECT" || - !!(target && target.isContentEditable); - if (isTyping) return; - - let handled = false; - if (mjrSettings.hotkeys.rating && /^[0-5]$/.test(ev.key) && !ev.ctrlKey && !ev.altKey && !ev.metaKey) { - mjrGlobalState.instances.forEach(inst => { - if (!inst?.state) return; - const rate = parseInt(ev.key, 10); - const keys = Array.from(inst.state.selected || []); - const selectedFiles = keys.map(k => inst.state.filtered.find(f => fileKey(f) === k)).filter(Boolean); - const targets = selectedFiles.length ? selectedFiles : (inst.state.currentFile ? [inst.state.currentFile] : []); - targets.forEach(f => inst.setRating?.(f, rate)); - if (targets.length) handled = true; - }); - } else if ( - mjrSettings.hotkeys.spaceOpen && - (ev.key === " " || ev.key === "Spacebar" || ev.code === "Space") && - !ev.ctrlKey && - !ev.altKey && - !ev.metaKey - ) { - mjrGlobalState.instances.forEach(inst => { - if (inst.state.selected.size > 0) { - const files = Array.from(inst.state.selected).map(k => inst.state.filtered.find(f => fileKey(f) === k)).filter(Boolean); - if (files.length > 0) { - mjrOpenViewerForFiles(files.length >= 2 ? [files[0], files[1]] : [files[0]], inst.state.filtered); - handled = true; - } - } - }); - } - if (handled) ev.preventDefault(); - }; - window.addEventListener("keydown", onKeyDown); -} diff --git a/js/ui_gridview.js b/js/ui_gridview.js deleted file mode 100644 index 0a5991ca..00000000 --- a/js/ui_gridview.js +++ /dev/null @@ -1,464 +0,0 @@ -import { - createEl, - mjrCardBasePx, - getBaseName, - getExt, - detectKindFromExt, - applyStyles, - CARD_STYLES, - mjrSettings, -} from "./ui_settings.js"; -import { createFileThumb, updateCardSelectionStyle, updateCardVisuals, resolveWorkflowState, handleDragStart } from "./ui_cards.js"; -import { mjrOpenABViewer, mjrOpenViewerForFiles } from "./ui_viewer.js"; - -/** - * Creates grid rendering helpers. - * deps: { state, grid, gridWrapper, statusEl, loadMetadataForFile, openContextMenu, onRequestMetadata, isMetaVisible } - */ -export function createGridView(deps) { - const { - state, - grid, - gridWrapper, - statusEl, - loadMetadataForFile, - openContextMenu, - onRequestMetadata, - onNeedMoreFiles, - isMetaVisible, - } = deps; - - let lastRenderWindow = { startIndex: -1, endIndex: -1, cols: -1, version: -1 }; - const selectionAnchorKeyProp = "__mjrSelectionAnchorKey"; - const keyForFile = (f) => `${f.subfolder || ""}/${f.filename || f.name || "(unnamed)"}`; - const buildKeyToIndexMap = () => { - const map = new Map(); - (state.filtered || []).forEach((f, idx) => map.set(keyForFile(f), idx)); - return map; - }; - - const cardVersion = (file) => { - const rating = - Number( - file.rating ?? - (file.meta && file.meta.rating) ?? - (file.metadata && file.metadata.rating) - ) || 0; - const tags = - (Array.isArray(file.tags) && file.tags) || - (Array.isArray(file.meta && file.meta.tags) && file.meta.tags) || - (Array.isArray(file.metadata && file.metadata.tags) && file.metadata.tags) || - []; - - const showRating = mjrSettings.grid.showRating ? 1 : 0; - const showTags = mjrSettings.grid.showTags ? 1 : 0; - const wfState = resolveWorkflowState(file); - - return `${rating}|${tags.length ? tags.join(",") : ""}|${file.mtime || ""}|${showRating}|${showTags}|wf:${wfState}`; - }; - - const refreshCardsForFiles = (files = []) => { - if (!Array.isArray(files) || !files.length) return; - const seen = new Set(); - for (const file of files) { - if (!file) continue; - const key = keyForFile(file); - if (seen.has(key)) continue; - seen.add(key); - - const card = grid.querySelector(`[data-mjr-key="${CSS.escape(key)}"]`); - if (!card) continue; - - card.__mjrFile = file; - const nextVersion = cardVersion(file); - if (card.__mjrVersion !== nextVersion) { - updateCardVisuals(card, file); - card.__mjrVersion = nextVersion; - } - updateCardSelectionStyle(card, state.selected.has(key)); - } - }; - - const updateStatus = () => { - const loaded = (state.files || []).length; - const total = Number.isFinite(state.filesTotal) ? state.filesTotal : loaded; - const totalText = total && total !== loaded ? `/${total}` : ""; - const moreText = state.filesHasMore ? " (more...)" : ""; - - // Add metadata hydration indicator - const hydratingText = state.metaHydrating - ? ` - Hydrating metadata ${state.metaHydrationPercent || 0}%` - : ""; - - statusEl.textContent = `${state.filtered.length}/${loaded}${totalText} items${moreText} - ${state.selected.size} selected${hydratingText}`; - }; - - const clearAllSelection = () => { - state.selected.forEach((k) => { - const el = grid.querySelector(`[data-mjr-key="${CSS.escape(k)}"]`); - if (el) updateCardSelectionStyle(el, false); - }); - state.selected.clear(); - }; - - const createFileCard = (file, handlers) => { - const rawName = file.name || file.filename || "(unnamed)"; - const baseName = getBaseName(rawName); - const ext = file.ext || getExt(rawName) || ""; - const kind = file.kind || detectKindFromExt(ext); - const key = `${file.subfolder || ""}/${rawName}`; - - const rating = - Number( - file.rating ?? - (file.meta && file.meta.rating) ?? - (file.metadata && file.metadata.rating) - ) || 0; - const tags = - (Array.isArray(file.tags) && file.tags) || - (Array.isArray(file.meta && file.meta.tags) && file.meta.tags) || - (Array.isArray(file.metadata && file.metadata.tags) && file.metadata.tags) || - []; - - const card = createEl("div", "mjr-fm-card"); - card.dataset.mjrKey = key; - card.__mjrFile = file; - applyStyles(card, CARD_STYLES); - card.style.transition = - "border-color 0.12s ease, box-shadow 0.12s ease, background 0.12s ease"; - card.style.width = "100%"; - - const thumb = createFileThumb(kind, ext, file, card); - thumb.style.width = "100%"; - thumb.style.aspectRatio = "1 / 1"; - thumb.style.height = "auto"; - updateCardVisuals(card, file); - - const meta = createEl("div", "mjr-fm-meta"); - meta.style.padding = "6px"; - meta.style.display = "flex"; - meta.style.flexDirection = "column"; - meta.style.gap = "2px"; - meta.style.fontSize = "0.75rem"; - - const name = createEl("div", "mjr-fm-name", baseName || "(unnamed)"); - name.style.fontWeight = "500"; - name.style.whiteSpace = "nowrap"; - name.style.textOverflow = "ellipsis"; - name.style.overflow = "hidden"; - - const folder = createEl( - "div", - "mjr-fm-folder", - file.subfolder || "(root)" - ); - folder.style.opacity = "0.7"; - - meta.appendChild(name); - meta.appendChild(folder); - - card.appendChild(thumb); - card.appendChild(meta); - - const currentFile = () => card.__mjrFile || file; - - card.draggable = true; - card.addEventListener("dragstart", (ev) => { - const f = currentFile(); - let selectedFiles = null; - if (state.selected && state.selected.size > 1) { - const key = card.dataset.mjrKey || keyForFile(f); - if (state.selected.has(key)) { - const keyToFile = new Map(); - (state.filtered || []).forEach((item) => keyToFile.set(keyForFile(item), item)); - selectedFiles = Array.from(state.selected).map((k) => keyToFile.get(k)).filter(Boolean); - } - } - handleDragStart(f, ev, selectedFiles); - }); - - card.addEventListener("mouseenter", () => { - if (!state.selected.has(key)) { - card.style.borderColor = "var(--comfy-accent-soft, #888)"; - card.style.background = "rgba(255,255,255,0.04)"; - } - }); - card.addEventListener("mouseleave", () => { - updateCardSelectionStyle(card, state.selected.has(key)); - }); - - card.addEventListener("click", (ev) => { - ev.preventDefault(); - handlers?.onSelect?.(currentFile(), card.dataset.mjrKey || key, card, ev); - }); - - card.addEventListener("contextmenu", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - handlers?.onContextMenu?.(ev, currentFile()); - }); - - card.addEventListener("dblclick", (ev) => { - ev.preventDefault(); - handlers?.onOpen?.(currentFile(), card.dataset.mjrKey || key, ev); - }); - - updateCardSelectionStyle(card, state.selected.has(key)); - return card; - }; - - const renderGrid = () => { - const basePx = mjrCardBasePx(); - grid.style.gridTemplateColumns = `repeat(auto-fill, minmax(${basePx}px, 1fr))`; - - Array.from(grid.children).forEach((el) => { - const isSpacer = - el.classList && - (el.classList.contains("mjr-fm-top-spacer") || el.classList.contains("mjr-fm-bottom-spacer")); - if (!isSpacer && (!el.dataset || !el.dataset.mjrKey)) { - el.remove(); - } - }); - - const getCols = () => - Math.max(1, Math.floor((gridWrapper.clientWidth || grid.clientWidth || 1) / (basePx + 8))); - const cols = getCols(); - const rowHeight = basePx + 90; - const viewportHeight = gridWrapper.clientHeight || 0; - const scrollTop = gridWrapper.scrollTop || 0; - const overscanRows = 2; - const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - overscanRows); - const endRow = Math.floor((scrollTop + viewportHeight) / rowHeight) + overscanRows; - const startIndex = startRow * cols; - const endIndex = Math.min(state.filtered.length, (endRow + 1) * cols); - - if (onRequestMetadata) { - onRequestMetadata(startIndex, endIndex); - } - if (typeof onNeedMoreFiles === "function") { - const threshold = cols * 6; // ~6 rows from the end - const canScroll = - (gridWrapper.scrollHeight || 0) > (gridWrapper.clientHeight || 0) + 10 || - (gridWrapper.scrollTop || 0) > 0; - const filtersActive = !!( - (state.search && String(state.search).trim()) || - (state.tagFilter && String(state.tagFilter).trim()) || - Number(state.minRating || 0) > 0 || - state.collectionSet || - state.smartFilter || - state.activeCollection - ); - - const nearEnd = state.filtered.length && endIndex >= state.filtered.length - threshold; - const underfilled = state.filtered.length && state.filtered.length < endIndex + threshold; - const shouldLoad = state.filesHasMore && (nearEnd || (!filtersActive && underfilled)); - - if ((canScroll || (!filtersActive && underfilled)) && shouldLoad) onNeedMoreFiles(); - } - const totalRows = Math.ceil(state.filtered.length / cols) || 0; - const rowsBefore = startRow; - const rowsAfter = Math.max(0, totalRows - Math.ceil(endIndex / cols)); - - if ( - lastRenderWindow.version === state.renderVersion && - lastRenderWindow.startIndex === startIndex && - lastRenderWindow.endIndex === endIndex && - lastRenderWindow.cols === cols - ) { - return; - } - - let topSpacer = grid.querySelector(".mjr-fm-top-spacer"); - let bottomSpacer = grid.querySelector(".mjr-fm-bottom-spacer"); - if (!topSpacer) { - topSpacer = createEl("div", "mjr-fm-top-spacer"); - topSpacer.style.gridColumn = "1 / -1"; - topSpacer.style.height = "0px"; - grid.appendChild(topSpacer); - } - if (!bottomSpacer) { - bottomSpacer = createEl("div", "mjr-fm-bottom-spacer"); - bottomSpacer.style.gridColumn = "1 / -1"; - bottomSpacer.style.height = "0px"; - grid.appendChild(bottomSpacer); - } - if (grid.firstElementChild !== topSpacer) { - grid.insertBefore(topSpacer, grid.firstElementChild); - } - - const handlers = { - onSelect: (file, key, card, ev) => { - const isCtrl = ev && (ev.ctrlKey || ev.metaKey); - const isShift = ev && ev.shiftKey; - const anchorKey = state[selectionAnchorKeyProp] || null; - - if (isShift) { - const keyToIndex = buildKeyToIndexMap(); - const anchorIndex = anchorKey ? keyToIndex.get(anchorKey) : undefined; - const targetIndex = keyToIndex.get(key); - if (anchorIndex !== undefined && targetIndex !== undefined) { - const start = Math.min(anchorIndex, targetIndex); - const end = Math.max(anchorIndex, targetIndex); - const keysInRange = (state.filtered || []) - .slice(start, end + 1) - .map((f) => keyForFile(f)); - if (isCtrl) { - keysInRange.forEach((k) => state.selected.add(k)); - } else { - state.selected = new Set(keysInRange); - } - state.currentFile = file; - if (!anchorKey) state[selectionAnchorKeyProp] = key; - state.renderVersion = (state.renderVersion || 0) + 1; - renderGrid(); - updateStatus(); - if (typeof isMetaVisible === "function" ? isMetaVisible() : isMetaVisible) { - loadMetadataForFile(file); - } - return; - } - } - - if (!isCtrl) { - clearAllSelection(); - if (state.selected && state.selected.clear) { - state.selected.clear(); - } - } - - if (isCtrl && state.selected.has(key)) { - state.selected.delete(key); - updateCardSelectionStyle(card, false); - } else { - state.selected.add(key); - state.currentFile = file; - updateCardSelectionStyle(card, true); - state[selectionAnchorKeyProp] = key; - } - - updateStatus(); - if (typeof isMetaVisible === "function" ? isMetaVisible() : isMetaVisible) { - loadMetadataForFile(file); - } - }, - onContextMenu: (ev, file) => { - openContextMenu(ev, file); - }, - onOpen: (file, ev) => { - let filesToShow = []; - if (state.selected && state.selected.size === 2) { - const keys = Array.from(state.selected); - const keyToFile = new Map(); - for (const f of state.filtered) { - const rawName = f.name || f.filename || "(unnamed)"; - const k = `${f.subfolder || ""}/${rawName}`; - keyToFile.set(k, f); - } - filesToShow = keys.map((k) => keyToFile.get(k)).filter(Boolean); - } - if (!filesToShow.length) { - filesToShow = [file]; - } - mjrOpenViewerForFiles(filesToShow, state.filtered); - }, - isSelected: (key) => state.selected.has(key), - }; - - if (!state.filtered.length) { - // Cleanup all video listeners before clearing grid - const allCards = grid.querySelectorAll(".mjr-fm-card"); - allCards.forEach((card) => { - if (typeof card.__cleanupVideoListeners === "function") { - try { - card.__cleanupVideoListeners(); - } catch (e) {} - } - }); - - grid.innerHTML = ""; - const empty = createEl("div", "", "No outputs found."); - empty.style.opacity = "0.7"; - empty.style.padding = "10px"; - grid.appendChild(empty); - updateStatus(); - return; - } - - const existingCards = new Map(); - Array.from(grid.children).forEach((el) => { - if (el === topSpacer || el === bottomSpacer) return; - if (el.dataset && el.dataset.mjrKey) existingCards.set(el.dataset.mjrKey, el); - }); - - const keptKeys = new Set(); - const visibleFiles = state.filtered.slice(startIndex, endIndex); - - // NEW: Version string now includes global settings - const orderedElements = visibleFiles.map((file) => { - const rawName = file.name || file.filename || ""; - const key = `${file.subfolder || ""}/${rawName}`; - keptKeys.add(key); - - let card = existingCards.get(key); - if (card) { - card.__mjrFile = file; - const nextVersion = cardVersion(file); - if (card.__mjrVersion !== nextVersion) { - // This will now trigger if mjrSettings.grid.* changed! - updateCardVisuals(card, file); // from ui_cards.js, which reads mjrSettings - card.__mjrVersion = nextVersion; - } - updateCardSelectionStyle(card, state.selected.has(key)); - } else { - card = createFileCard(file, handlers); - card.dataset.mjrKey = key; - card.__mjrVersion = cardVersion(file); - } - return card; - }); - - existingCards.forEach((el, key) => { - if (!keptKeys.has(key)) { - // Cleanup video event listeners before removing card - if (typeof el.__cleanupVideoListeners === "function") { - try { - el.__cleanupVideoListeners(); - } catch (e) { - console.warn("[Majoor.AssetsManager] Error cleaning up video listeners", e); - } - } - el.remove(); - } - }); - - const baseIndex = topSpacer ? 1 : 0; - orderedElements.forEach((card, index) => { - const desiredIndex = baseIndex + index; - const currentAtPos = grid.children[desiredIndex]; - if (currentAtPos !== card) { - if (currentAtPos) { - grid.insertBefore(card, currentAtPos); - } else { - grid.appendChild(card); - } - } - }); - - topSpacer.style.height = `${rowsBefore * rowHeight}px`; - bottomSpacer.style.height = `${rowsAfter * rowHeight}px`; - if (bottomSpacer.parentNode !== grid || grid.lastElementChild !== bottomSpacer) { - grid.appendChild(bottomSpacer); - } - - lastRenderWindow = { - startIndex, - endIndex, - cols, - version: state.renderVersion, - }; - - updateStatus(); - }; - - return { renderGrid, clearAllSelection, updateStatus, refreshCardsForFiles }; -} diff --git a/js/ui_settings.js b/js/ui_settings.js deleted file mode 100644 index 56be8d89..00000000 --- a/js/ui_settings.js +++ /dev/null @@ -1,268 +0,0 @@ -import { api } from "../../../scripts/api.js"; -import { app } from "../../../scripts/app.js"; - -export function createEl(tag, className, text) { - const el = document.createElement(tag); - if (className) el.className = className; - if (text) el.textContent = text; - return el; -} - -export function applyStyles(el, styles = {}) { - Object.entries(styles).forEach(([k, v]) => { - if (v !== undefined && v !== null) el.style[k] = v; - }); -} - -export function getBaseName(name) { - const idx = name.lastIndexOf("."); - return idx > 0 ? name.slice(0, idx) : name; -} - -export function getExt(name) { - const idx = name.lastIndexOf("."); - return idx > -1 ? name.slice(idx + 1).toUpperCase() : ""; -} - -export function detectKindFromExt(extUpper) { - const ext = extUpper.toLowerCase(); - if (["png", "jpg", "jpeg", "webp", "gif"].includes(ext)) return "image"; - if (["mp4", "mov", "webm", "mkv"].includes(ext)) return "video"; - if (["wav", "mp3", "flac", "ogg", "m4a", "aac"].includes(ext)) return "audio"; - if (["obj", "fbx", "glb", "gltf", "stl"].includes(ext)) return "model3d"; - return "other"; -} - -export function buildViewUrl(file) { - const url = new URL("/view", window.location.origin); - url.searchParams.set("filename", file.filename || file.name); - if (file.subfolder) url.searchParams.set("subfolder", file.subfolder); - url.searchParams.set("type", "output"); - return url.toString(); -} - -export const BADGE_STYLES = { - rating: { - position: "absolute", - top: "8px", - right: "8px", - backgroundColor: "rgba(0, 0, 0, 0.6)", - color: "#ffd45a", - padding: "6px 10px", - borderRadius: "8px", - zIndex: "5", - pointerEvents: "none", - fontSize: "20px", - fontWeight: "700", - letterSpacing: "2px", - }, - tags: { - position: "absolute", - top: "auto", - bottom: "50px", - right: "auto", - left: "4px", - backgroundColor: "rgba(0, 0, 0, 0.7)", - color: "#fff", - padding: "1px 5px", - zIndex: "5", - pointerEvents: "none", - fontSize: "0.65rem", - maxWidth: "60%", - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - }, -}; - -export const CARD_STYLES = { - position: "relative", - border: "1px solid var(--border-color, #444)", - borderRadius: "8px", - overflow: "hidden", - display: "flex", - flexDirection: "column", - background: "rgba(255,255,255,0.02)", - cursor: "pointer", - transition: "border-color 0.12s ease, box-shadow 0.12s ease, background 0.12s ease", -}; - -export const CONTEXT_MENU_STYLES = { - position: "absolute", - zIndex: "9999", - padding: "10px", - background: "#1b1b1b", - border: "1px solid rgba(255,255,255,0.08)", - borderRadius: "5px", - boxShadow: "0px 0px 10px rgba(0,0,0,0.3)", - minWidth: "140px", -}; - -export const mjrSettingsDefaults = { - autoRefresh: { enabled: true, interval: 5000, focusOnly: true }, - metaPanel: { showByDefault: true, refreshInterval: 2000 }, - grid: { cardSize: "medium", showTags: true, showRating: true, pageSize: 500, hoverInfo: true }, - index: { source: "filesystem" }, // auto | index | filesystem - integration: { panel: "both" }, // "both" | "sidebar" | "bottom" - viewer: { - navEnabled: true, - autoplayVideos: true, - loopVideos: true, - muteVideos: true, - showRatingHUD: true, - ratingHotkeys: true, - useCheckerboard: false, - }, - siblings: { hidePngSiblings: true }, - toasts: { enabled: true, verbosity: "all", duration: 3200 }, - hotkeys: { - enabled: true, - rating: true, - spaceOpen: true, - frameStep: true, - }, -}; - -export function mjrDeepMerge(base, override) { - const out = Array.isArray(base) ? [...base] : { ...base }; - if (!override || typeof override !== "object") return out; - Object.keys(override).forEach((k) => { - const bv = out[k]; - const ov = override[k]; - if (bv && typeof bv === "object" && !Array.isArray(bv) && ov && typeof ov === "object" && !Array.isArray(ov)) { - out[k] = mjrDeepMerge(bv, ov); - } else { - out[k] = ov; - } - }); - return out; -} - -export function mjrLoadSettings() { - try { - const raw = localStorage.getItem("mjrFMSettings"); - if (!raw) return { ...mjrSettingsDefaults }; - const parsed = JSON.parse(raw); - if (parsed?.hotkeys && parsed.hotkeys.enterOpen !== undefined && parsed.hotkeys.spaceOpen === undefined) { - parsed.hotkeys.spaceOpen = parsed.hotkeys.enterOpen; - } - return mjrDeepMerge(mjrSettingsDefaults, parsed); - } catch (err) { - console.warn("[Majoor.AssetsManager] settings load failed, using defaults", err); - return { ...mjrSettingsDefaults }; - } -} - -export function mjrSaveSettings(settings) { - try { - localStorage.setItem("mjrFMSettings", JSON.stringify(settings || mjrSettingsDefaults)); - } catch (err) { - console.warn("[Majoor.AssetsManager] settings save failed", err); - } -} - -export let mjrSettings = mjrLoadSettings(); -export function setMjrSettings(next) { - mjrSettings = next; - mjrSaveSettings(mjrSettings); - return mjrSettings; -} - -export function mjrCardBasePx() { - const sizeMap = { compact: 160, medium: 200, large: 240 }; - return sizeMap[mjrSettings.grid.cardSize] || sizeMap.medium; -} - -export let mjrPrefetchedFiles = null; -let mjrPrefetchPromise = null; -export async function mjrPrefetchOutputs() { - if (mjrPrefetchPromise) return mjrPrefetchPromise; - const pageSize = Math.max(50, Math.min(2000, Number(mjrSettings.grid?.pageSize) || 500)); - const params = new URLSearchParams(); - params.set("limit", String(pageSize)); - params.set("offset", "0"); - const source = String(mjrSettings?.index?.source || "auto").toLowerCase(); - if (source) params.set("source", source === "filesystem" ? "scan" : source); - mjrPrefetchPromise = api - .fetchApi(`/mjr/filemanager/files?${params.toString()}`) - .then(async (res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - mjrPrefetchedFiles = data.files || data.items || []; - return mjrPrefetchedFiles; - }) - .catch((err) => { - console.warn("[Majoor.AssetsManager] prefetch outputs failed", err); - mjrPrefetchedFiles = null; - }); - return mjrPrefetchPromise; -} - -export function mjrResetPrefetch() { - mjrPrefetchedFiles = null; - mjrPrefetchPromise = null; -} - -export function mjrShouldShowToast(kind) { - if (!mjrSettings.toasts.enabled) return false; - const v = mjrSettings.toasts.verbosity || "all"; - if (v === "all") return true; - if (v === "errors") return kind === "error"; - if (v === "warnings") return kind === "warn" || kind === "error"; - return false; -} - -export function mjrShowToast(kind = "info", message = "", title = null, duration = null) { - if (!mjrShouldShowToast(kind)) return; - - const severityMap = { - info: "info", - success: "success", - warn: "warn", - error: "error", - }; - - if (!app.toast || !app.toast.add) { - console.log(`[${kind.toUpperCase()}] ${title ? title + ": " : ""}${message}`); - return; - } - - app.toast.add({ - severity: severityMap[kind] || "info", - summary: title || (kind.charAt(0).toUpperCase() + kind.slice(1)), - detail: message, - life: duration || Number(mjrSettings.toasts.duration) || 3000, - closable: true, - }); -} - -export function showToast(message, kind = "info", title = null, duration = null) { - mjrShowToast(kind, message, title, duration); -} - -export function mjrAttachHoverFeedback(el, message, delay = 3000) { - if (!el) return; - let t = null; - let lastShownAt = 0; - const cooldownMs = 2000; - el.addEventListener("mouseenter", () => { - const now = Date.now(); - if (now - lastShownAt < cooldownMs) return; // throttle repeated hover toasts - if (t) clearTimeout(t); - t = setTimeout(() => { - mjrShowToast("info", message, "Info", 2500); - lastShownAt = Date.now(); - }, delay); - }); - const clear = () => { - if (t) { - clearTimeout(t); - t = null; - } - }; - el.addEventListener("mouseleave", clear); - el.addEventListener("click", clear); -} - -// Trigger prefetch at startup -mjrPrefetchOutputs(); diff --git a/js/ui_sidebar.js b/js/ui_sidebar.js deleted file mode 100644 index 92f3f963..00000000 --- a/js/ui_sidebar.js +++ /dev/null @@ -1,660 +0,0 @@ -import { api } from "../../../scripts/api.js"; -import { - buildViewUrl, - createEl, - detectKindFromExt, - getBaseName, - getExt, - mjrAttachHoverFeedback, - mjrSettings, - mjrShowToast, -} from "./ui_settings.js"; -import { mjrThemeVars } from "./ui_theme.js"; - -export function createMetadataSidebar(options) { - const { - state, - metaPanel, - metaContent, - applyFilterAndRender, - refreshAllInstances, - fileKey, - onVisibilityChange, - } = options; - - let metaRequestSeq = 0; - const metadataKey = (file) => - fileKey - ? fileKey(file) - : `${file?.subfolder || ""}/${file?.filename || file?.name || ""}`; - - const cacheAllows = (metaCacheEntry, file, metaSourcePath, metaSourceMtime) => { - if (!metaCacheEntry) return false; - const cachedMtime = metaCacheEntry.mtime; - const fileMtime = file?.mtime; - if (cachedMtime === fileMtime) return true; - if (metaCacheEntry.sourceMtime !== undefined) { - if (metaSourceMtime !== undefined && metaCacheEntry.sourceMtime === metaSourceMtime) { - return true; - } - if (metaSourceMtime === undefined) return true; - } - if (!cachedMtime && !metaCacheEntry.sourceMtime) return true; - return false; - }; - - function createCopyField(label, value, multiline = false) { - const wrapper = createEl("div", "mjr-fm-meta-field"); - wrapper.style.display = "flex"; - wrapper.style.flexDirection = "column"; - wrapper.style.gap = "4px"; - - const lbl = createEl("div", "mjr-fm-meta-label", label); - lbl.style.fontSize = "0.7rem"; - lbl.style.opacity = "0.8"; - - const row = createEl("div", "mjr-fm-meta-row"); - row.style.display = "flex"; - row.style.gap = "4px"; - - const field = createEl("div", "mjr-fm-meta-value"); - field.textContent = value || ""; - field.style.padding = "6px 8px"; - field.style.background = "rgba(255,255,255,0.03)"; - field.style.border = "1px solid var(--border-color, #333)"; - field.style.borderRadius = "6px"; - field.style.flex = "1"; - field.style.fontSize = "0.78rem"; - field.style.whiteSpace = multiline ? "pre-wrap" : "nowrap"; - field.style.overflow = "hidden"; - field.style.textOverflow = "ellipsis"; - - const copyBtn = createEl("button", "comfy-btn", "Copy"); - copyBtn.style.fontSize = "0.7rem"; - copyBtn.style.padding = "2px 6px"; - copyBtn.style.borderRadius = "6px"; - copyBtn.addEventListener("click", () => { - const toCopy = value || ""; - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard - .writeText(toCopy) - .then(() => mjrShowToast("success", "Copied to clipboard", "Copy")) - .catch(() => mjrShowToast("error", "Unable to copy", "Copy")); - } else { - const tmp = document.createElement("textarea"); - tmp.value = toCopy; - document.body.appendChild(tmp); - tmp.select(); - try { - document.execCommand("copy"); - mjrShowToast("success", "Copied to clipboard", "Copy"); - } catch (e) { - mjrShowToast("error", "Unable to copy", "Copy"); - } - document.body.removeChild(tmp); - } - }); - - row.appendChild(field); - row.appendChild(copyBtn); - - wrapper.appendChild(lbl); - wrapper.appendChild(row); - - return wrapper; - } - - function showMetadataPanel(file, meta, errorText = null) { - const rawName = file.name || file.filename || "(unnamed)"; - const baseName = getBaseName(rawName); - const ext = file.ext || getExt(rawName) || ""; - const kind = file.kind || detectKindFromExt(ext); - - const url = file.url || buildViewUrl(file); - - // Cleanup video/audio elements before clearing content - try { - const videos = metaContent.querySelectorAll("video"); - videos.forEach((video) => { - video.pause(); - video.src = ""; - video.load(); // Release resources - }); - const audios = metaContent.querySelectorAll("audio"); - audios.forEach((audio) => { - audio.pause(); - audio.src = ""; - audio.load(); // Release resources - }); - } catch (e) { - console.warn("[Majoor.Sidebar] Error cleaning up media elements", e); - } - - metaContent.innerHTML = ""; - metaContent.style.userSelect = "text"; // allow copying any text in the sidebar - - const previewShell = createEl("div", "mjr-fm-meta-preview"); - previewShell.style.borderRadius = "8px"; - previewShell.style.overflow = "hidden"; - previewShell.style.border = "1px solid var(--border-color, #333)"; - previewShell.style.background = mjrThemeVars.sidebarBg; - previewShell.style.display = "flex"; - previewShell.style.alignItems = "center"; - previewShell.style.justifyContent = "center"; - previewShell.style.minHeight = "180px"; - previewShell.style.maxHeight = "320px"; - - if (kind === "image") { - const img = document.createElement("img"); - img.src = url; - img.style.maxWidth = "100%"; - img.style.maxHeight = "100%"; - img.style.objectFit = "contain"; - previewShell.appendChild(img); - } else if (kind === "video") { - const video = document.createElement("video"); - video.src = url; - video.controls = true; - video.loop = true; - video.playsInline = true; - video.muted = false; - video.style.maxWidth = "100%"; - video.style.maxHeight = "100%"; - video.style.objectFit = "contain"; - previewShell.appendChild(video); - } else if (kind === "audio") { - const audioBox = createEl("div"); - audioBox.style.display = "flex"; - audioBox.style.flexDirection = "column"; - audioBox.style.alignItems = "center"; - audioBox.style.justifyContent = "center"; - audioBox.style.gap = "8px"; - audioBox.style.padding = "12px"; - - const icon = document.createElement("i"); - icon.className = "pi pi-volume-up"; - icon.style.display = "block"; - icon.style.fontSize = "1.6rem"; - icon.style.color = mjrThemeVars.icon; - - const audio = document.createElement("audio"); - audio.src = url; - audio.controls = true; - audio.style.width = "100%"; - - audioBox.appendChild(icon); - audioBox.appendChild(audio); - previewShell.appendChild(audioBox); - } else if (kind === "model3d") { - const wrap = createEl("div"); - wrap.style.display = "flex"; - wrap.style.flexDirection = "column"; - wrap.style.alignItems = "center"; - wrap.style.justifyContent = "center"; - wrap.style.padding = "10px"; - - const icon = document.createElement("i"); - icon.className = "pi pi-cube"; - icon.style.display = "block"; - icon.style.fontSize = "1.6rem"; - icon.style.marginBottom = "6px"; - icon.style.color = mjrThemeVars.icon; - - const text = createEl( - "div", - "", - "3D file (preview placeholder). Use an external viewer for full inspection." - ); - text.style.color = mjrThemeVars.textColor; - text.style.opacity = "0.8"; - text.style.fontSize = "0.8rem"; - text.style.textAlign = "center"; - - wrap.appendChild(icon); - wrap.appendChild(text); - previewShell.appendChild(wrap); - } else { - const text = createEl("div", "", "Unsupported preview type. Open in new tab or OS."); - text.style.color = mjrThemeVars.textColor; - text.style.opacity = "0.8"; - text.style.fontSize = "0.8rem"; - previewShell.appendChild(text); - } - - const metaHeader = createEl("div", "mjr-fm-meta-header"); - metaHeader.style.display = "flex"; - metaHeader.style.flexDirection = "column"; - metaHeader.style.gap = "2px"; - metaHeader.style.fontSize = "0.8rem"; - - const infoLine = createEl( - "div", - "mjr-fm-meta-info", - `${ext || ""}${ext && file.kind ? " â€ĸ " : ""}${file.kind || ""}${ - file.size_readable ? " â€ĸ " + file.size_readable : "" - }${file.date ? " â€ĸ " + file.date : ""}` - ); - infoLine.style.opacity = "0.75"; - - metaHeader.appendChild(infoLine); - - const nameLineTop = createEl("div", "mjr-fm-meta-name", baseName || "(unnamed)"); - nameLineTop.style.fontWeight = "600"; - nameLineTop.style.marginBottom = "6px"; - metaContent.appendChild(nameLineTop); - metaContent.appendChild(previewShell); - metaContent.appendChild(metaHeader); - - if (errorText) { - const errBox = createEl("div", "mjr-fm-meta-error", errorText); - errBox.style.marginTop = "8px"; - errBox.style.padding = "6px 8px"; - errBox.style.borderRadius = "6px"; - errBox.style.border = `1px solid ${mjrThemeVars.errorBorder}`; - errBox.style.background = mjrThemeVars.errorBg; - errBox.style.fontSize = "0.75rem"; - metaContent.appendChild(errBox); - return; - } - - const m = meta || {}; - // Normalize workflow presence for UI state - const workflowPresent = !!(m.has_workflow || m.workflow || (file && file.hasWorkflow)); - if (m.has_workflow === undefined) m.has_workflow = workflowPresent; - if (file && workflowPresent) file.hasWorkflow = true; - const normStr = (v) => (typeof v === "string" ? v.trim() : ""); - const cleanName = (val) => { - if (typeof val !== "string") return ""; - let n = val.replace(/\\/g, "/").split("/").pop(); - n = n.replace(/\.(safetensors|ckpt|pth|bin)$/i, ""); - return n.trim(); - }; - const cleanList = (str) => - str - .split(",") - .map((s) => cleanName(s)) - .filter(Boolean) - .join(", "); - const positive = normStr(m.positive_prompt); - const negative = normStr(m.negative_prompt); - const seedList = - Array.isArray(m.seeds) ? m.seeds : m.seed !== undefined && m.seed !== null ? [m.seed] : []; - const seeds = seedList.join(", ").trim(); - const samplerList = Array.isArray(m.samplers) - ? m.samplers - : m.sampler_name - ? [m.sampler_name] - : []; - const samplers = samplerList.join(", ").trim(); - const cfgList = Array.isArray(m.cfg_scales) - ? m.cfg_scales - : m.cfg !== undefined && m.cfg !== null - ? [m.cfg] - : []; - const cfg = cfgList.join(", ").trim(); - const modelsRaw = Array.isArray(m.models) - ? m.models.join(", ").trim() - : m.model - ? String(m.model).trim() - : ""; - const models = modelsRaw ? cleanList(modelsRaw) : ""; - const loras = Array.isArray(m.loras) - ? m.loras - .map((l) => { - if (typeof l === "string") return l.trim(); - if (l && typeof l === "object" && l.name) return String(l.name).trim(); - return null; - }) - .filter(Boolean) - .join(", ") - : ""; - const lorasClean = loras ? cleanList(loras) : ""; - - const hasPositive = positive.length > 0; - const hasNegative = negative.length > 0; - const hasSeeds = seeds.length > 0; - const hasCfg = cfg.length > 0; - const hasSamplers = samplers.length > 0; - const hasModels = models.length > 0; - const hasLoras = loras.length > 0; - const hasWorkflow = workflowPresent; - // Consider file as having generation data if it has workflow OR traditional generation params - const hasAnyGen = - hasPositive || hasNegative || hasSeeds || hasCfg || hasSamplers || hasModels || hasLoras || hasWorkflow; - - const topDivider = createEl("div"); - topDivider.style.borderTop = "1px solid var(--border-color, #333)"; - topDivider.style.margin = "10px 0 8px 0"; - metaContent.appendChild(topDivider); - - const ratingCount = Math.max(0, Math.min(5, parseInt(m.rating, 10) || 0)); - const ratingBlock = createEl("div", "mjr-fm-meta-rating-block"); - ratingBlock.style.display = "flex"; - ratingBlock.style.flexDirection = "column"; - ratingBlock.style.gap = "4px"; - ratingBlock.style.paddingBottom = "8px"; - ratingBlock.style.borderBottom = "1px solid var(--border-color, #333)"; - - const ratingLabel = createEl("div", "mjr-fm-meta-label", "RATING"); - ratingLabel.style.fontSize = "0.7rem"; - ratingLabel.style.opacity = "0.8"; - - const ratingStars = createEl("div", "mjr-fm-meta-rating"); - ratingStars.style.fontSize = "2rem"; - ratingStars.style.lineHeight = "1.2"; - ratingStars.style.color = mjrThemeVars.starsFilled; - ratingStars.style.letterSpacing = "3px"; - ratingStars.style.display = "flex"; - ratingStars.style.gap = "6px"; - mjrAttachHoverFeedback(ratingStars, "Click to rate 1-5 (0 clears).", 3000); - - for (let i = 1; i <= 5; i++) { - const star = createEl("span", "mjr-fm-meta-star", i <= ratingCount ? "★" : "☆"); - star.style.cursor = "pointer"; - star.style.userSelect = "none"; - star.style.color = i <= ratingCount ? mjrThemeVars.starsFilled : mjrThemeVars.starsEmpty; - star.style.transition = "color 0.15s ease"; - star.addEventListener("click", () => { - file.rating = i; - setRating(file, i); - }); - ratingStars.appendChild(star); - } - - ratingBlock.appendChild(ratingLabel); - ratingBlock.appendChild(ratingStars); - metaContent.appendChild(ratingBlock); - - const tagsDivider = createEl("div"); - tagsDivider.style.borderTop = "1px solid var(--border-color, #333)"; - tagsDivider.style.margin = "10px 0 8px 0"; - metaContent.appendChild(tagsDivider); - - const tagsBlock = createEl("div", "mjr-fm-meta-tags-block"); - tagsBlock.style.display = "flex"; - tagsBlock.style.flexDirection = "column"; - tagsBlock.style.gap = "6px"; - tagsBlock.style.paddingTop = "8px"; - - const tagsLabel = createEl("div", "mjr-fm-meta-label", "TAGS"); - tagsLabel.style.fontSize = "0.7rem"; - tagsLabel.style.opacity = "0.8"; - - const tagsRow = createEl("div", "mjr-fm-meta-row"); - tagsRow.style.display = "flex"; - tagsRow.style.gap = "4px"; - tagsRow.style.alignItems = "stretch"; - - const tagsInput = document.createElement("input"); - tagsInput.type = "text"; - tagsInput.value = (m.tags || []).join(", "); - tagsInput.style.flex = "1"; - tagsInput.style.minWidth = "0"; - tagsInput.style.borderRadius = "6px"; - tagsInput.style.border = "1px solid var(--border-color, #333)"; - tagsInput.style.background = "var(--comfy-input-bg, #1b1b1b)"; - tagsInput.style.color = "var(--input-fg, #eee)"; - tagsInput.style.fontSize = "0.75rem"; - tagsInput.style.padding = "4px 6px"; - mjrAttachHoverFeedback(tagsInput, "Enter tags (comma-separated) then Save.", 3000); - - const saveTagsBtn = createEl("button", "comfy-btn", "Save"); - saveTagsBtn.style.fontSize = "0.7rem"; - saveTagsBtn.style.padding = "2px 8px"; - saveTagsBtn.style.borderRadius = "6px"; - - const persistTags = async () => { - const newTags = tagsInput.value - .split(",") - .map((t) => t.trim()) - .filter((t) => t.length > 0); - file.tags = newTags; - await updateFileMetadata(file, { tags: newTags }, "Tags updated"); - }; - - saveTagsBtn.addEventListener("click", () => persistTags()); - tagsInput.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - persistTags(); - } - }); - - tagsRow.appendChild(tagsInput); - tagsRow.appendChild(saveTagsBtn); - tagsBlock.appendChild(tagsLabel); - tagsBlock.appendChild(tagsRow); - metaContent.appendChild(tagsBlock); - - const tagsBottomDivider = createEl("div"); - tagsBottomDivider.style.borderTop = "1px solid var(--border-color, #333)"; - tagsBottomDivider.style.margin = "10px 0 10px 0"; - metaContent.appendChild(tagsBottomDivider); - - const fieldsContainer = createEl("div", "mjr-fm-meta-fields"); - fieldsContainer.style.display = "flex"; - fieldsContainer.style.flexDirection = "column"; - fieldsContainer.style.gap = "6px"; - fieldsContainer.style.marginTop = "8px"; - - const addColoredField = (label, value, multiline = false, color = mjrThemeVars.badgeAccent) => { - const field = createCopyField(label, value, multiline); - const lbl = field.querySelector(".mjr-fm-meta-label"); - if (lbl) { - lbl.style.color = color; - lbl.style.fontWeight = "700"; - } - return field; - }; - - if (hasPositive) fieldsContainer.appendChild(addColoredField("POSITIVE PROMPT", positive, true, mjrThemeVars.positiveField)); - if (hasNegative) fieldsContainer.appendChild(addColoredField("NEGATIVE PROMPT", negative, true, mjrThemeVars.negativeField)); - if (hasSeeds) fieldsContainer.appendChild(addColoredField("SEED(S)", seeds, false, mjrThemeVars.seedField)); - - const genLines = []; - if (models) genLines.push(`Model: ${models}`); - if (lorasClean) genLines.push(`LoRAs: ${lorasClean}`); - if (samplers) genLines.push(`Sampler: ${samplers}`); - if (meta && meta.scheduler) genLines.push(`Scheduler: ${meta.scheduler}`); - if (cfg) genLines.push(`CFG: ${cfg}`); - if (meta && meta.steps !== undefined && meta.steps !== null) genLines.push(`Steps: ${meta.steps}`); - if (meta && meta.denoise !== undefined && meta.denoise !== null) genLines.push(`Denoise: ${meta.denoise}`); - - // Advanced sampler parameters - if (meta && meta.start_at_step !== undefined && meta.start_at_step !== null) genLines.push(`Start Step: ${meta.start_at_step}`); - if (meta && meta.end_at_step !== undefined && meta.end_at_step !== null) genLines.push(`End Step: ${meta.end_at_step}`); - if (meta && meta.add_noise !== undefined && meta.add_noise !== null) genLines.push(`Add Noise: ${meta.add_noise}`); - if (meta && meta.return_with_leftover_noise !== undefined && meta.return_with_leftover_noise !== null) genLines.push(`Return Leftover Noise: ${meta.return_with_leftover_noise}`); - - if (genLines.length) { - fieldsContainer.appendChild(addColoredField("MODEL / LORA / SAMPLER", genLines.join("\n"), true, mjrThemeVars.badgeAccent)); - } - - if (hasAnyGen) { - metaContent.appendChild(fieldsContainer); - } - - const warnMessages = []; - if (!hasAnyGen) warnMessages.push("No generation data found."); - if (!hasWorkflow) warnMessages.push("No workflow present in this file."); - if (warnMessages.length) { - const warn = createEl("div", "", warnMessages.join(" ")); - warn.style.marginTop = "8px"; - warn.style.padding = "6px 8px"; - warn.style.borderRadius = "6px"; - warn.style.border = "1px solid var(--border-color, #333)"; - warn.style.background = "rgba(255,255,255,0.03)"; - warn.style.opacity = "0.85"; - metaContent.appendChild(warn); - } - } - - async function updateFileMetadata(file, updates, toastLabel = "Metadata updated") { - const filename = file.filename || file.name; - if (!filename) { - console.error("Error updating metadata: missing filename"); - return; - } - const subfolder = file.subfolder || ""; - const safeUpdates = updates && typeof updates === "object" ? updates : {}; - - try { - const body = { filename, subfolder }; - if (Object.prototype.hasOwnProperty.call(safeUpdates, "rating")) { - body.rating = Number(safeUpdates.rating) || 0; - } - if (Object.prototype.hasOwnProperty.call(safeUpdates, "tags")) { - body.tags = Array.isArray(safeUpdates.tags) ? safeUpdates.tags : []; - } - - const res = await api.fetchApi("/mjr/filemanager/metadata/update", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...body, - }), - }); - const data = await res.json(); - if (data.ok) { - if (data.rating !== undefined) file.rating = data.rating; - if (data.tags !== undefined) file.tags = data.tags; - if (!(state.metaCache instanceof Map)) state.metaCache = new Map(); - const key = metadataKey(file); - state.metaCache.delete(key); - if (state.metaFetchAt && typeof state.metaFetchAt.delete === "function") { - state.metaFetchAt.delete(key); - } - applyFilterAndRender(); - if (state.currentFile === file) { - try { - loadMetadataForFile(file); - } catch (err) { - console.warn("[Majoor.AssetsManager] metadata panel refresh failed", err); - } - } - refreshAllInstances({ silent: true, metaOnly: true }); - mjrShowToast("success", toastLabel, "Saved"); - } else { - console.error("Error updating metadata:", data.error); - mjrShowToast("error", data.error || "Metadata update failed", "Error"); - } - } catch (err) { - console.error("[Majoor.AssetsManager] metadata update failed", err); - mjrShowToast("error", "Network error while saving metadata", "Error"); - } - } - - function setRating(file, rating) { - file.rating = rating; - updateFileMetadata(file, { rating }, "Rating updated"); - } - - function setMetadataPanelVisibility(visible) { - metaPanel.style.display = visible ? "flex" : "none"; - if (onVisibilityChange) onVisibilityChange(visible); - } - - const showPanel = (file, meta, errorText = null) => { - state.currentFile = file; - state.currentMeta = meta || null; - showMetadataPanel(file, meta, errorText); - }; - - async function loadMetadataForFile(file) { - if (!file) return; - const filename = file.filename || file.name; - if (!filename) { - showPanel(file, null, "Missing filename for metadata lookup."); - return; - } - - if (!(state.metaCache instanceof Map)) state.metaCache = new Map(); - if (!(state.metaFetchAt instanceof Map)) state.metaFetchAt = new Map(); - - const now = Date.now(); - const key = metadataKey(file); - const cachedEntry = key ? state.metaCache.get(key) : null; - const cachedMeta = cachedEntry && cachedEntry.meta ? cachedEntry.meta : cachedEntry; - const lastFetch = key ? state.metaFetchAt.get(key) : 0; - const interval = Math.max(0, Number(mjrSettings.metaPanel.refreshInterval) || 0); - const fallbackMeta = - (cachedEntry && - cacheAllows(cachedEntry, file, cachedEntry.sourcePath, cachedEntry.sourceMtime) && - cachedMeta) || - file.meta || - file.metadata || - (state.currentMeta && state.currentFile === file && state.currentMeta) || - null; - - if (cachedMeta && interval > 0 && lastFetch && now - lastFetch < interval) { - showPanel(file, cachedMeta, null); - return; - } - - const seq = ++metaRequestSeq; - - const params = new URLSearchParams(); - params.set("filename", filename); - if (file.subfolder) params.set("subfolder", file.subfolder); - - showPanel(file, fallbackMeta, cachedEntry ? null : "Loading metadata..."); - - try { - const res = await api.fetchApi(`/mjr/filemanager/metadata?${params.toString()}`); - if (seq !== metaRequestSeq) return; - if (!res.ok) { - let extra = ""; - try { - const txt = await res.text(); - if (txt) extra = `: ${String(txt).slice(0, 160)}`; - } catch (_) {} - showPanel(file, null, `Error while loading metadata (HTTP ${res.status}${extra})`); - return; - } - - let data = null; - try { - data = await res.json(); - } catch (e) { - showPanel(file, null, "Error while loading metadata (invalid server response)."); - return; - } - if (seq !== metaRequestSeq) return; - if (data.ok && data.generation) { - const meta = data.generation || {}; - if (meta.has_workflow === undefined) meta.has_workflow = !!meta.workflow; - if (meta.has_workflow && file) file.hasWorkflow = true; - if (meta.rating === undefined && file.rating !== undefined) meta.rating = file.rating; - if (!meta.tags && file.tags) meta.tags = file.tags; - const sourceMtime = meta.mtime ?? file.mtime; - const payload = { - meta, - mtime: sourceMtime ?? file.mtime, - sourceMtime, - sourcePath: null, - }; - if (key) { - state.metaCache.set(key, payload); - state.metaFetchAt.set(key, Date.now()); - } - file.hasWorkflow = !!meta.has_workflow; - showPanel(file, meta); - } else { - showPanel(file, null, data.error || "No metadata found for this file."); - } - } catch (err) { - if (seq !== metaRequestSeq) return; // ignore stale request failures - if (err?.name === "AbortError") return; - console.error("[Majoor.AssetsManager] metadata error", err); - showPanel(file, null, "Error while loading metadata (check console)."); - } - } - - return { - loadMetadataForFile, - setMetadataPanelVisibility, - showMetadataPanel: showPanel, - updateFileMetadata, - setRating, - }; -} diff --git a/js/ui_status_dot.js b/js/ui_status_dot.js deleted file mode 100644 index 82e1e545..00000000 --- a/js/ui_status_dot.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Status Dot Indicator - * - * Shows real-time index status: - * - Green: Index up-to-date - * - Yellow: Indexing in progress - * - Red: Error - * - Gray: Stale (needs reindex) - */ - -export function createStatusDot(api) { - const dot = document.createElement('span'); - dot.className = 'mjr-status-dot mjr-status-unknown'; - dot.title = 'Index status: loading...'; - - let pollInterval = null; - - async function updateStatus() { - try { - const resp = await api.fetchApi('/mjr/filemanager/index/status'); - const status = await resp.json(); - - // Determine dot class based on status - let dotClass = 'mjr-status-unknown'; - if (status.status === 'error') { - dotClass = 'mjr-status-error'; - } else if (status.status === 'indexing') { - dotClass = 'mjr-status-indexing'; - } else if (status.freshness === 'stale') { - dotClass = 'mjr-status-stale'; - } else if (status.freshness === 'up_to_date') { - dotClass = 'mjr-status-up_to_date'; - } else { - dotClass = 'mjr-status-idle'; - } - - dot.className = `mjr-status-dot ${dotClass}`; - - // Build tooltip - const tooltip = []; - tooltip.push(`Status: ${status.status}`); - - if (status.total_assets !== undefined) { - tooltip.push(`Assets: ${status.indexed_assets.toLocaleString()} / ${status.total_assets.toLocaleString()}`); - } - - if (status.last_scan) { - try { - const lastScan = new Date(status.last_scan); - tooltip.push(`Last scan: ${lastScan.toLocaleString()}`); - } catch (e) { - tooltip.push(`Last scan: ${status.last_scan}`); - } - } - - if (status.backlog > 0) { - tooltip.push(`Backlog: ${status.backlog} files`); - } - - if (status.errors && status.errors.length > 0) { - tooltip.push(`Errors: ${status.errors.slice(0, 3).join(', ')}`); - if (status.errors.length > 3) { - tooltip.push(`...and ${status.errors.length - 3} more`); - } - } - - if (status.fts_available !== undefined) { - if (status.fts_available) { - tooltip.push('✓ Full-text search enabled'); - } else { - tooltip.push('âš ī¸ FTS5 not available, search limited'); - } - } - - dot.title = tooltip.join('\n'); - - } catch (error) { - console.error('[Majoor] Failed to fetch index status:', error); - dot.className = 'mjr-status-dot mjr-status-error'; - dot.title = `Index status: Error\n${error.message}`; - } - } - - // Initial update - updateStatus(); - - // Poll every 5 seconds - pollInterval = setInterval(updateStatus, 5000); - - // Cleanup function - const cleanup = () => { - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - }; - - // Click to manually trigger reindex - dot.addEventListener('click', async (e) => { - e.stopPropagation(); - - const confirm = window.confirm('Reindex all assets? This may take a few minutes for large libraries.'); - if (!confirm) return; - - try { - const resp = await api.fetchApi('/mjr/filemanager/index/reindex', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({scope: 'all'}) - }); - - const result = await resp.json(); - - if (result.status === 'started') { - alert('Reindexing started in background. Check status dot for progress.'); - updateStatus(); // Immediate update - } else { - alert('Reindexing failed: ' + (result.error || 'Unknown error')); - } - } catch (error) { - console.error('[Majoor] Failed to trigger reindex:', error); - alert('Failed to trigger reindex: ' + error.message); - } - }); - - return { - element: dot, - update: updateStatus, - cleanup: cleanup - }; -} - -// CSS styles (inject once) -const STYLES = ` -.mjr-status-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - margin-left: 8px; - transition: background-color 0.3s; - cursor: pointer; -} - - .mjr-status-idle, -.mjr-status-up_to_date { - background-color: var(--mjr-status-success, #22c55e); -} - -.mjr-status-indexing { - background-color: var(--mjr-status-warning, #eab308); - animation: mjr-pulse 2s infinite; -} - -.mjr-status-error { - background-color: var(--mjr-status-error, #ef4444); -} - -.mjr-status-stale { - background-color: var(--mjr-status-neutral, #9ca3af); -} - -.mjr-status-unknown { - background-color: var(--mjr-status-muted, #6b7280); -} - -@keyframes mjr-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} -`; - -// Inject styles once -let stylesInjected = false; -export function injectStatusDotStyles() { - if (stylesInjected) return; - stylesInjected = true; - - const styleEl = document.createElement('style'); - styleEl.textContent = STYLES; - document.head.appendChild(styleEl); -} diff --git a/js/ui_theme.js b/js/ui_theme.js deleted file mode 100644 index 55b72bdf..00000000 --- a/js/ui_theme.js +++ /dev/null @@ -1,46 +0,0 @@ -export const mjrThemeVars = { - errorText: "var(--mjr-error, #e66)", - successGradient: "var(--mjr-success-gradient, linear-gradient(90deg, #22c55e, #16a34a))", - statusSuccess: "var(--mjr-status-success, #65d174)", - statusError: "var(--mjr-status-error, #d45a5a)", - statusNeutral: "var(--mjr-status-neutral, #aaaaaa)", - statusWarning: "var(--mjr-status-warning, #eab308)", - statusSuccessGlow: "var(--mjr-status-success-glow, rgba(101, 209, 116, 0.9))", - statusErrorGlow: "var(--mjr-status-error-glow, rgba(212, 90, 90, 0.8))", - statusNeutralGlow: "var(--mjr-status-neutral-glow, rgba(170, 170, 170, 0.6))", - starsFilled: "var(--mjr-stars-filled, #ffd45a)", - starsEmpty: "var(--mjr-stars-empty, #777)", - cardBg: "var(--mjr-card-bg, #111)", - cardOverlay: "var(--mjr-card-overlay, rgba(0, 0, 0, 0.7))", - highlight: "var(--comfy-accent, #5fb3ff)", - badgeAccent: "var(--mjr-badge-accent, var(--comfy-accent, #5fb3ff))", - badgeFg: "var(--mjr-badge-fg, #fff)", - icon: "var(--mjr-icon-fg, #fff)", - textColor: "var(--mjr-text-color, #eee)", - errorBorder: "var(--mjr-error-border, #b33)", - positiveField: "var(--mjr-positive-color, #8bd5ff)", - negativeField: "var(--mjr-negative-color, #ff9f7f)", - seedField: "var(--mjr-seed-color, #d1b3ff)", - thumbBg: "var(--mjr-thumb-bg, #111)", - filmstripBg: "var(--mjr-filmstrip-bg, rgba(255, 255, 255, 0.08))", - errorBg: "var(--mjr-error-bg, rgba(180, 40, 40, 0.15))", - audioGradient: "var(--mjr-audio-bg, linear-gradient(135deg, #1b2735 0%, #090a0f 100%))", - modelGradient: "var(--mjr-model-bg, radial-gradient(circle at 20% 20%, #3b82f6, #0f172a))", - sidebarBg: "var(--mjr-sidebar-bg, #050505)", - viewerBg: "var(--mjr-viewer-bg, #000000cc)", - viewerBorder: "var(--mjr-viewer-border, #555)", - viewerText: "var(--mjr-viewer-text, #fff)", - viewerPanel: "var(--mjr-viewer-panel, #111)", - viewerPanelBorder: "var(--mjr-viewer-panel-border, #444)", - viewerMuted: "var(--mjr-viewer-muted, #ddd)", - viewerSmallText: "var(--mjr-viewer-small, #888)", - viewerChecker: "var(--mjr-viewer-checker, repeating-conic-gradient(#eee 0 25%, #ccc 0 50%))", - viewerCheckerBg: "var(--mjr-viewer-checker-bg, #000)", - videoIconBg: "var(--mjr-video-icon-bg, linear-gradient(135deg, #1b2735 0%, #090a0f 100%))", - overlay: "var(--mjr-overlay, #000000aa)", -}; - -export function mjrCssVar(name, fallback) { - if (!name) return fallback || ""; - return fallback ? `var(${name}, ${fallback})` : `var(${name})`; -} diff --git a/js/ui_viewer.js b/js/ui_viewer.js deleted file mode 100644 index 1733849b..00000000 --- a/js/ui_viewer.js +++ /dev/null @@ -1,1526 +0,0 @@ -import { - applyStyles, - BADGE_STYLES, - buildViewUrl, - CARD_STYLES, - createEl, - getBaseName, - getExt, - detectKindFromExt, - mjrAttachHoverFeedback, - mjrCardBasePx, - mjrSettings, - mjrShowToast, - mjrSaveSettings, -} from "./ui_settings.js"; -import { mjrGlobalState } from "./global_state.js"; -import { setupViewerInteractions } from "./viewer/interaction.js"; -import { mjrThemeVars } from "./ui_theme.js"; - -export const mjrViewerState = { overlay: null, frame: null, abCleanup: null, resetView: null }; -export const mjrViewerNav = { prev: null, next: null }; -let mjrViewerEscBound = false; -let mjrViewerKeydownHandler = null; // Track handler reference for cleanup -export let mjrViewerIsAB = false; -let mjrViewerRatingEl = null; -let mjrCurrentList = []; -let mjrCurrentIndex = 0; - -// LRU cache for video thumbnails (prevents memory bloat) -const MJR_THUMB_CACHE_MAX_SIZE = 200; -const MJR_THUMB_PROMISE_TIMEOUT = 10000; // 10 second timeout for abandoned promises -const mjrThumbCache = new Map(); -const mjrThumbPromises = new Map(); - -function _addToThumbCache(key, value) { - // Implement LRU eviction for thumbnail cache - if (mjrThumbCache.has(key)) { - mjrThumbCache.delete(key); // Move to end - } - mjrThumbCache.set(key, value); - - // Evict oldest if over limit - if (mjrThumbCache.size > MJR_THUMB_CACHE_MAX_SIZE) { - const firstKey = mjrThumbCache.keys().next().value; - mjrThumbCache.delete(firstKey); - } -} - -function mjrFileThumbUrl(file) { - const name = file?.filename || file?.name || ""; - const kind = file?.kind || detectKindFromExt(file?.ext || getExt(name)); - if (kind === "video" || kind === "audio" || kind === "model3d") { - const pngName = name.includes(".") ? name.replace(/\.[^.]+$/, ".png") : `${name}.png`; - return buildViewUrl({ ...file, filename: pngName, name: pngName }); - } - return buildViewUrl(file); -} - -function mjrThumbPlaceholder(kind) { - const labels = { - video: "VIDEO", - audio: "AUDIO", - model3d: "3D", - image: "IMAGE", - }; - const label = labels[kind] || "FILE"; - const svg = `${label}`; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; -} - -function mjrCaptureVideoThumb(file, imgEl) { - const key = `${file?.subfolder || ""}/${file?.filename || file?.name || ""}`; - if (mjrThumbCache.has(key)) { - const cached = mjrThumbCache.get(key); - if (cached && imgEl) imgEl.src = cached; - return; - } - if (mjrThumbPromises.has(key)) { - mjrThumbPromises.get(key).then((url) => { - if (url && imgEl) imgEl.src = url; - }); - return; - } - - const promise = new Promise((resolve) => { - const proceedVideoCapture = () => { - const video = document.createElement("video"); - video.muted = true; - video.playsInline = true; - video.preload = "auto"; - video.crossOrigin = "anonymous"; - video.src = buildViewUrl(file); - - let timeout = null; - const cleanup = (res) => { - if (timeout) clearTimeout(timeout); - video.onloadeddata = null; - video.onerror = null; - video.src = ""; - resolve(res || null); - }; - - video.onloadeddata = () => { - try { - const w = video.videoWidth || 0; - const h = video.videoHeight || 0; - if (!w || !h) return cleanup(null); - const canvas = document.createElement("canvas"); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext("2d"); - if (!ctx) return cleanup(null); - ctx.drawImage(video, 0, 0, w, h); - const dataUrl = canvas.toDataURL("image/png"); - _addToThumbCache(key, dataUrl); - if (imgEl) imgEl.src = dataUrl; - cleanup(dataUrl); - } catch (_) { - cleanup(null); - } - }; - - video.onerror = () => cleanup(null); - timeout = setTimeout(() => cleanup(null), 3000); - }; - - const serverThumb = mjrFileThumbUrl(file); - if (serverThumb) { - const probeImg = new Image(); - probeImg.onload = () => { - _addToThumbCache(key, serverThumb); - if (imgEl) imgEl.src = serverThumb; - resolve(serverThumb); - }; - probeImg.onerror = () => { - proceedVideoCapture(); - }; - probeImg.src = serverThumb; - return; - } - - proceedVideoCapture(); - }); - - // Store promise with timeout cleanup to prevent memory leaks - mjrThumbPromises.set(key, promise); - - // Ensure promise is cleaned up even if abandoned or fails - const cleanupTimeout = setTimeout(() => { - mjrThumbPromises.delete(key); - }, MJR_THUMB_PROMISE_TIMEOUT); - - promise.finally(() => { - clearTimeout(cleanupTimeout); - mjrThumbPromises.delete(key); - }); -} - -export function mjrUpdateNavVisibility() { - const display = mjrViewerIsAB || !mjrSettings.viewer.navEnabled ? "none" : "inline-flex"; - if (mjrViewerNav.prev) mjrViewerNav.prev.style.display = display; - if (mjrViewerNav.next) mjrViewerNav.next.style.display = display; -} - -export function mjrStepVideos(delta = 1 / 30) { - if (!mjrViewerState.overlay || mjrViewerState.overlay.style.display === "none") return false; - const vids = mjrViewerState.overlay.querySelectorAll("video"); - if (!vids.length) return false; - const step = delta; - vids.forEach((v) => { - try { - v.pause(); - const dur = isFinite(v.duration) ? v.duration : Infinity; - const next = Math.max(0, Math.min(dur, (v.currentTime || 0) + step)); - v.currentTime = next; - } catch (_) {} - }); - return true; -} - -export function mjrEnsureViewerOverlay() { - if (mjrViewerState.overlay) return mjrViewerState.overlay; - - const overlay = document.createElement("div"); - overlay.className = "mjr-fm-viewer-overlay"; - Object.assign(overlay.style, { - position: "fixed", - inset: "0", - background: "rgba(0,0,0,0.9)", - display: "flex", - alignItems: "center", - justifyContent: "center", - zIndex: "9999", - padding: "12px", - }); - - const frame = document.createElement("div"); - frame.className = "mjr-fm-viewer-frame"; - Object.assign(frame.style, { - position: "relative", - display: "flex", - gap: "16px", - width: "100%", - height: "100%", - maxWidth: "100%", - maxHeight: "100%", - aspectRatio: "1 / 1", - alignItems: "center", - justifyContent: "center", - margin: "auto", - }); - - const closeBtn = document.createElement("button"); - closeBtn.textContent = "X"; - Object.assign(closeBtn.style, { - position: "absolute", - top: "12px", - right: "12px", - fontSize: "18px", - border: `1px solid ${mjrThemeVars.viewerBorder}`, - background: mjrThemeVars.viewerBg, - color: mjrThemeVars.viewerText, - cursor: "pointer", - width: "36px", - height: "36px", - borderRadius: "50%", - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - lineHeight: "1", - zIndex: "2", - }); - closeBtn.addEventListener("click", () => mjrCloseViewer()); - - const container = document.createElement("div"); - Object.assign(container.style, { - position: "relative", - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - width: "min(90vw, 90vh)", - height: "min(90vw, 90vh)", - maxWidth: "92vw", - maxHeight: "92vh", - aspectRatio: "1 / 1", - margin: "0 auto", - overflow: "hidden", - boxSizing: "border-box", - }); - - // --- FILMSTRIP UI --- - const filmstrip = document.createElement("div"); - filmstrip.className = "mjr-filmstrip"; - Object.assign(filmstrip.style, { - position: "absolute", - bottom: "20px", - left: "50%", - transform: "translateX(-50%)", - display: "flex", - gap: "6px", - padding: "6px", - background: "rgba(0,0,0,0.6)", - borderRadius: "8px", - backdropFilter: "blur(4px)", - maxWidth: "380px", - overflowX: "hidden", - zIndex: "10001", - scrollbarWidth: "none", - alignItems: "center", - }); - const filmstripCount = document.createElement("div"); - Object.assign(filmstripCount.style, { - color: mjrThemeVars.viewerMuted, - fontSize: "12px", - fontWeight: "600", - padding: "0 6px", - borderRadius: "4px", - background: mjrThemeVars.filmstripBg, - flex: "0 0 auto", - }); - - mjrViewerState.updateFilmstrip = () => { - filmstrip.innerHTML = ""; - if (mjrViewerIsAB || !mjrCurrentList || mjrCurrentList.length < 2) { - filmstrip.style.display = "none"; - return; - } - filmstrip.style.display = "flex"; - filmstrip.appendChild(filmstripCount); - filmstripCount.textContent = `${mjrCurrentIndex + 1} / ${mjrCurrentList.length}`; - - const windowSize = 7; - let start = Math.max(0, mjrCurrentIndex - Math.floor(windowSize / 2)); - let end = start + windowSize; - if (end > mjrCurrentList.length) { - end = mjrCurrentList.length; - start = Math.max(0, end - windowSize); - } - for (let i = start; i < end; i++) { - const file = mjrCurrentList[i]; - if (!file) continue; - const kind = file?.kind || detectKindFromExt(file?.ext || getExt(file?.filename || file?.name || "")); - const thumbUrl = mjrFileThumbUrl(file); - const t = document.createElement("img"); - t.src = thumbUrl; - Object.assign(t.style, { - width: "48px", - height: "48px", - objectFit: "cover", - borderRadius: "4px", - cursor: "pointer", - border: i === mjrCurrentIndex ? `2px solid ${mjrThemeVars.highlight}` : "2px solid transparent", - opacity: i === mjrCurrentIndex ? "1" : "0.6", - }); - t.onerror = () => { - t.onerror = null; - t.src = mjrThumbPlaceholder(kind); - }; - if (kind === "video") { - mjrCaptureVideoThumb(file, t); - } - t.onclick = (e) => { - e.stopPropagation(); - mjrCurrentIndex = i; - mjrRenderSingleCurrent(); - }; - filmstrip.appendChild(t); - } - }; - - overlay.appendChild(filmstrip); - container.appendChild(frame); - container.appendChild(closeBtn); - - overlay.appendChild(container); - - overlay.addEventListener("click", (ev) => { - if (ev.target === overlay) { - mjrCloseViewer(); - } - }); - - mjrViewerState.overlay = overlay; - mjrViewerState.frame = frame; - document.body.appendChild(overlay); - // Navigation buttons (overlay-level) - if (!mjrViewerNav.prev) { - const prevBtn = document.createElement("button"); - prevBtn.textContent = "◀"; - Object.assign(prevBtn.style, { - position: "absolute", - left: "12px", - top: "50%", - transform: "translateY(-50%)", - background: mjrThemeVars.overlay, - border: `1px solid ${mjrThemeVars.viewerBorder}`, - color: mjrThemeVars.viewerText, - borderRadius: "50%", - width: "36px", - height: "36px", - cursor: "pointer", - zIndex: "10000", - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - }); - prevBtn.addEventListener("click", () => mjrViewerPrev()); - overlay.appendChild(prevBtn); - mjrViewerNav.prev = prevBtn; - } - if (!mjrViewerNav.next) { - const nextBtn = document.createElement("button"); - nextBtn.textContent = "â–ļ"; - Object.assign(nextBtn.style, { - position: "absolute", - right: "12px", - top: "50%", - transform: "translateY(-50%)", - background: mjrThemeVars.overlay, - border: `1px solid ${mjrThemeVars.viewerBorder}`, - color: mjrThemeVars.viewerText, - borderRadius: "50%", - width: "36px", - height: "36px", - cursor: "pointer", - zIndex: "10000", - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - }); - nextBtn.addEventListener("click", () => mjrViewerNext()); - overlay.appendChild(nextBtn); - mjrViewerNav.next = nextBtn; - } - - // Setup keyboard handler with proper cleanup - // NOTE: Using normal bubbling (not capture) to avoid conflicts with grid navigation - if (!mjrViewerEscBound && !mjrViewerKeydownHandler) { - mjrViewerKeydownHandler = (ev) => { - const isViewerOpen = - mjrViewerState.overlay && mjrViewerState.overlay.style.display !== "none"; - - // Don't handle any keys if viewer is closed - let grid handle them - if (!isViewerOpen) return; - - const navEnabled = - isViewerOpen && !mjrViewerIsAB && mjrSettings.viewer.navEnabled; - const inInput = - ev.target && - (ev.target.tagName === "INPUT" || - ev.target.tagName === "TEXTAREA" || - ev.target.isContentEditable); - - // Early return if in input field - if (inInput) return; - - let handled = false; - if (ev.key === "Escape" || ev.key === " " || ev.key === "Spacebar") { - mjrCloseViewer(); - handled = true; - } else if (ev.key === "ArrowUp" && navEnabled) { - mjrViewerPrev(); - handled = true; - } else if (ev.key === "ArrowDown" && navEnabled) { - mjrViewerNext(); - handled = true; - } else if (ev.key === "ArrowLeft" && mjrSettings.hotkeys.frameStep) { - if (mjrStepVideos(-1 / 30)) handled = true; - } else if (ev.key === "ArrowRight" && mjrSettings.hotkeys.frameStep) { - if (mjrStepVideos(1 / 30)) handled = true; - } else if ( - navEnabled && - mjrSettings.viewer.ratingHotkeys && - (/^[0-5]$/.test(ev.key) || (ev.code && ev.code.startsWith("Numpad") && /^[0-9]$/.test(ev.key))) - ) { - const num = Number(ev.key); - if (!Number.isNaN(num) && num <= 5) { - mjrSetViewerRating(num); - handled = true; - } - } else if (ev.key === "f" || ev.key === "F") { - if (typeof mjrViewerState.resetView !== "function") { - const pane = mjrViewerState.frame?.firstChild; - if (pane && typeof pane.__mjrReset === "function") { - mjrViewerState.resetView = pane.__mjrReset; - } - } - const runReset = - typeof mjrViewerState.resetView === "function" - ? mjrViewerState.resetView - : mjrViewerState.frame?.firstChild?.__mjrReset; - if (typeof runReset === "function") { - runReset(); - handled = true; - } - } - - // Only prevent default if we actually handled the key - if (handled) { - ev.preventDefault(); - ev.stopPropagation(); - } - }; - // Use normal bubbling instead of capture to avoid conflicts with grid - window.addEventListener("keydown", mjrViewerKeydownHandler); - mjrViewerEscBound = true; - } - mjrUpdateNavVisibility(); - return overlay; -} - -export function mjrCloseViewer() { - // Cleanup A/B viewer listeners - mjrViewerState.abCleanup?.(); - mjrViewerState.abCleanup = null; - mjrViewerState.resetView = null; - - if (!mjrViewerState.overlay) return; - mjrViewerState.overlay.style.display = "none"; - - // Cleanup context menu listeners for all panes - if (mjrViewerState.frame) { - const panes = mjrViewerState.frame.querySelectorAll("[data-mjr-viewer-pane]"); - panes.forEach((pane) => { - if (typeof pane.__mjrCleanupContext === "function") { - try { - pane.__mjrCleanupContext(); - } catch (e) { - console.warn("[Majoor.Viewer] Error cleaning up context listeners", e); - } - } - }); - mjrViewerState.frame.innerHTML = ""; - } - - mjrViewerIsAB = false; - mjrViewerRatingEl = null; - mjrUpdateNavVisibility(); -} - -/** - * Cleanup global event listeners for viewer. - * Call this when unloading the module or disposing the viewer permanently. - */ -export function mjrCleanupViewerGlobalListeners() { - if (mjrViewerKeydownHandler) { - window.removeEventListener("keydown", mjrViewerKeydownHandler); - mjrViewerKeydownHandler = null; - mjrViewerEscBound = false; - } -} - -export function mjrCreateMediaElement(file) { - const rawName = file.name || file.filename || ""; - const ext = (file.ext || getExt(rawName) || "").toLowerCase(); - const src = file.url || buildViewUrl(file); - - let el; - let kind = "other"; - - const cleanExt = ext.startsWith(".") ? ext.slice(1) : ext; - const imgExts = ["png", "jpg", "jpeg", "webp", "gif"]; - const vidExts = ["mp4", "webm", "mov", "mkv"]; - const audExts = ["wav", "mp3", "flac", "ogg", "m4a", "aac"]; - - if (imgExts.includes(cleanExt)) { - el = document.createElement("img"); - el.src = src; - Object.assign(el.style, { - maxWidth: "100%", - maxHeight: "100%", - objectFit: "contain", - }); - kind = "image"; - } else if (vidExts.includes(cleanExt)) { - el = document.createElement("video"); - el.src = src; - el.autoplay = !!mjrSettings.viewer.autoplayVideos; - el.loop = !!mjrSettings.viewer.loopVideos; - el.muted = !!mjrSettings.viewer.muteVideos; - el.controls = true; - Object.assign(el.style, { - maxWidth: "100%", - maxHeight: "100%", - objectFit: "contain", - }); - kind = "video"; - } else if (audExts.includes(cleanExt)) { - el = document.createElement("audio"); - el.src = src; - el.controls = true; - el.style.width = "100%"; - kind = "audio"; - } else { - el = document.createElement("div"); - el.textContent = "No inline preview for this file type."; - el.style.color = mjrThemeVars.viewerMuted; - } - - return { el, kind }; -} - -export function mjrGetRating(file) { - return ( - Number( - file.rating ?? - (file.meta && file.meta.rating) ?? - (file.metadata && file.metadata.rating) - ) || 0 - ); -} - -export function mjrCreateViewerPane(file) { - const pane = document.createElement("div"); - pane.setAttribute("data-mjr-viewer-pane", "true"); // For cleanup queries - Object.assign(pane.style, { - flex: "1 1 0", - display: "flex", - flexDirection: "column", - background: mjrThemeVars.viewerPanel, - borderRadius: "10px", - overflow: "hidden", - border: `1px solid ${mjrThemeVars.viewerPanelBorder}`, - minWidth: "0", - }); - - const mediaWrap = document.createElement("div"); - Object.assign(mediaWrap.style, { - flex: "1 1 auto", - display: "flex", - alignItems: "center", - justifyContent: "center", - background: mjrThemeVars.viewerCheckerBg, - minHeight: "0", - height: "100%", - width: "100%", - position: "relative", - overflow: "hidden", - userSelect: "none", - touchAction: "none", - }); - - const zoomHudOffsetPx = 10; - const zoomHud = document.createElement("div"); - Object.assign(zoomHud.style, { - position: "absolute", - right: "10px", - bottom: `${zoomHudOffsetPx}px`, - padding: "4px 8px", - borderRadius: "6px", - background: mjrThemeVars.viewerPanel, - color: mjrThemeVars.viewerText, - fontSize: "12px", - fontWeight: "600", - letterSpacing: "0.5px", - pointerEvents: "none", - zIndex: "10", - border: "1px solid rgba(255,255,255,0.1)", - boxShadow: "0 2px 8px rgba(0,0,0,0.4)", - }); - zoomHud.textContent = "100%"; - mediaWrap.appendChild(zoomHud); - const checkerPattern = mjrThemeVars.viewerChecker; - const applyCheckerboardBg = (enabled) => { - mediaWrap.style.background = enabled ? checkerPattern : mjrThemeVars.viewerCheckerBg; - }; - applyCheckerboardBg(!!mjrSettings.viewer.useCheckerboard); - - const infoBar = document.createElement("div"); - Object.assign(infoBar.style, { - padding: "6px 10px", - fontSize: "12px", - color: mjrThemeVars.viewerSmallText, - borderTop: `1px solid ${mjrThemeVars.viewerPanelBorder}`, - whiteSpace: "nowrap", - textOverflow: "ellipsis", - overflow: "hidden", - }); - infoBar.textContent = file.name || file.filename || "(unnamed)"; - - const { el, kind } = mjrCreateMediaElement(file); - const { resetZoomPan } = setupViewerInteractions({ - el, - kind, - mediaWrap, - zoomHud, - mjrViewerState, - applyCheckerboardBg, - mjrSettings, - }); - - mediaWrap.appendChild(el); - pane.appendChild(mediaWrap); - pane.appendChild(infoBar); - pane.__mjrReset = resetZoomPan; - requestAnimationFrame(() => resetZoomPan()); - - // Context menu: reset + toggle checkerboard - let contextMenuEl = null; - const closeContextMenu = () => { - if (contextMenuEl && contextMenuEl.parentNode) contextMenuEl.parentNode.removeChild(contextMenuEl); - contextMenuEl = null; - }; - const buildContextMenu = (x, y) => { - closeContextMenu(); - const menu = document.createElement("div"); - Object.assign(menu.style, { - position: "fixed", - left: `${x}px`, - top: `${y}px`, - background: mjrThemeVars.viewerPanel, - color: mjrThemeVars.viewerText, - border: `1px solid ${mjrThemeVars.viewerPanelBorder}`, - borderRadius: "6px", - boxShadow: "0 6px 20px rgba(0,0,0,0.5)", - padding: "6px 0", - minWidth: "150px", - zIndex: "99999", - fontSize: "12px", - userSelect: "none", - }); - menu.addEventListener("pointerdown", (ev) => ev.stopPropagation()); - const makeItem = (label, onClick) => { - const el = document.createElement("div"); - el.textContent = label; - Object.assign(el.style, { - padding: "6px 12px", - cursor: "pointer", - }); - el.addEventListener("mouseenter", () => { - el.style.background = mjrThemeVars.filmstripBg; - }); - el.addEventListener("mouseleave", () => { - el.style.background = "transparent"; - }); - el.addEventListener("click", () => { - closeContextMenu(); - onClick(); - }); - return el; - }; - - menu.appendChild(makeItem("Reset zoom / pan", () => resetZoomPan())); - - const toggleCheckerboard = () => { - const next = !mjrSettings.viewer.useCheckerboard; - mjrSettings.viewer.useCheckerboard = next; - mjrSaveSettings(mjrSettings); - applyCheckerboardBg(next); - }; - const checkerLabel = () => - mjrSettings.viewer.useCheckerboard ? "Hide checkerboard" : "Show checkerboard"; - menu.appendChild(makeItem(checkerLabel(), toggleCheckerboard)); - - document.body.appendChild(menu); - contextMenuEl = menu; - }; - const onWindowPointerDown = () => closeContextMenu(); - const onWindowBlur = () => closeContextMenu(); - mediaWrap.addEventListener("contextmenu", (ev) => { - ev.preventDefault(); - buildContextMenu(ev.clientX, ev.clientY); - }); - window.addEventListener("pointerdown", onWindowPointerDown); - window.addEventListener("blur", onWindowBlur); - mediaWrap.__mjrCleanupContext = () => { - closeContextMenu(); - window.removeEventListener("pointerdown", onWindowPointerDown); - window.removeEventListener("blur", onWindowBlur); - }; - return pane; -} - -export function mjrOpenABViewer(fileA, fileB) { - // Clean up any previous A/B-specific listeners - mjrViewerState.abCleanup?.(); - mjrViewerState.abCleanup = null; - mjrViewerState.resetView = null; - - mjrEnsureViewerOverlay(); - mjrViewerState.overlay.style.display = "flex"; - mjrViewerIsAB = true; - mjrViewerState.updateFilmstrip?.(); - mjrViewerRatingEl = null; - mjrUpdateNavVisibility(); - const frame = mjrViewerState.frame; - frame.innerHTML = ""; - - // Layout unique pour A/B : on force un seul panneau - Object.assign(frame.style, { - display: "flex", - maxWidth: "95vw", - maxHeight: "95vh", - }); - - const container = document.createElement("div"); - Object.assign(container.style, { - position: "relative", - width: "min(90vw, 1600px)", - height: "min(80vh, 900px)", - background: mjrThemeVars.viewerPanel, - borderRadius: "10px", - overflow: "hidden", - border: `1px solid ${mjrThemeVars.viewerPanelBorder}`, - userSelect: "none", - touchAction: "none", - cursor: "default", - }); - - const baseWrap = document.createElement("div"); - Object.assign(baseWrap.style, { - position: "absolute", - inset: "0", - display: "flex", - alignItems: "center", - justifyContent: "center", - overflow: "hidden", - width: "100%", - height: "100%", - touchAction: "none", - userSelect: "none", - }); - - const topWrap = document.createElement("div"); - Object.assign(topWrap.style, { - position: "absolute", - inset: "0", - display: "flex", - alignItems: "center", - justifyContent: "center", - overflow: "hidden", - width: "100%", - height: "100%", - touchAction: "none", - userSelect: "none", - }); - - const { el: baseMedia, kind: kindA } = mjrCreateMediaElement(fileA); - const { el: topMedia, kind: kindB } = mjrCreateMediaElement(fileB); - - Object.assign(baseMedia.style, { - maxWidth: "100%", - maxHeight: "100%", - objectFit: "contain", - }); - Object.assign(topMedia.style, { - maxWidth: "100%", - maxHeight: "100%", - objectFit: "contain", - }); - - // Reuse zoom/pan controls for both panes - function enableZoomPan(mediaEl, wrap, shared) { - const state = shared || { - scale: 1, - minScale: 1, - offsetX: 0, - offsetY: 0, - entries: [], - }; - const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); - const clampOffsets = () => { - let minMaxX = Infinity; - let minMaxY = Infinity; - const entries = state.entries && state.entries.length ? state.entries : [{ media: mediaEl, wrap }]; - entries.forEach((entry) => { - const entryWrap = entry.wrap || wrap; - const rect = entryWrap.getBoundingClientRect(); - const wrapW = rect.width || 1; - const wrapH = rect.height || 1; - const media = entry.media; - const w = media.videoWidth || media.naturalWidth || media.clientWidth || wrapW; - const h = media.videoHeight || media.naturalHeight || media.clientHeight || wrapH; - const maxX = Math.max(0, (w * state.scale - wrapW) / 2); - const maxY = Math.max(0, (h * state.scale - wrapH) / 2); - minMaxX = Math.min(minMaxX, maxX); - minMaxY = Math.min(minMaxY, maxY); - }); - if (!Number.isFinite(minMaxX)) minMaxX = 0; - if (!Number.isFinite(minMaxY)) minMaxY = 0; - if (minMaxX <= 0) state.offsetX = 0; - else state.offsetX = clamp(state.offsetX, -minMaxX, minMaxX); - if (minMaxY <= 0) state.offsetY = 0; - else state.offsetY = clamp(state.offsetY, -minMaxY, minMaxY); - }; - const applyAll = () => { - clampOffsets(); - if (state.scale <= state.minScale) { - state.offsetX = 0; - state.offsetY = 0; - } - for (const entry of state.entries) { - entry.media.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`; - entry.media.style.transformOrigin = "50% 50%"; - entry.media.style.cursor = state.scale > state.minScale ? "grab" : "default"; - if (entry.hud) { - entry.hud.textContent = `${Math.round(state.scale * 100)}%`; - } - } - }; - - if (!state.reset) { - state.reset = () => { - const fit = calcFitScale(); - state.minScale = fit; - state.scale = fit; - state.offsetX = 0; - state.offsetY = 0; - applyAll(); - }; - } - - const zoomHudOffsetPx = 36; - const zoomHud = document.createElement("div"); - Object.assign(zoomHud.style, { - position: "absolute", - right: "10px", - bottom: `${zoomHudOffsetPx}px`, - padding: "4px 8px", - borderRadius: "6px", - background: mjrThemeVars.viewerPanel, - color: mjrThemeVars.viewerText, - fontSize: "12px", - fontWeight: "600", - letterSpacing: "0.5px", - pointerEvents: "none", - zIndex: "5", - border: `1px solid ${mjrThemeVars.viewerPanelBorder}`, - boxShadow: "0 2px 8px rgba(0,0,0,0.4)", - }); - zoomHud.textContent = "100%"; - wrap.appendChild(zoomHud); - - state.entries.push({ media: mediaEl, hud: zoomHud, wrap }); - - wrap.addEventListener( - "wheel", - (ev) => { - ev.preventDefault(); - const delta = ev.deltaY; - const factor = delta > 0 ? 0.9 : 1.1; - const rect = wrap.getBoundingClientRect(); - const px = ev.clientX - rect.left; - const py = ev.clientY - rect.top; - const prevScale = state.scale; - const newScale = clamp(state.scale * factor, state.minScale, 10); - const dx = (px - rect.width / 2 - state.offsetX) / prevScale; - const dy = (py - rect.height / 2 - state.offsetY) / prevScale; - state.offsetX = px - rect.width / 2 - newScale * dx; - state.offsetY = py - rect.height / 2 - newScale * dy; - state.scale = newScale; - clampOffsets(); - applyAll(); - }, - { passive: false } - ); - - let dragging = false; - let dragStartX = 0; - let dragStartY = 0; - let dragOrigX = 0; - let dragOrigY = 0; - const onMove = (ev) => { - if (!dragging) return; - state.offsetX = dragOrigX + (ev.clientX - dragStartX); - state.offsetY = dragOrigY + (ev.clientY - dragStartY); - clampOffsets(); - applyAll(); - }; - const stopDrag = () => { - if (!dragging) return; - dragging = false; - mediaEl.style.cursor = state.scale > state.minScale ? "grab" : "default"; - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", stopDrag); - window.removeEventListener("pointercancel", stopDrag); - }; - - wrap.addEventListener("pointerdown", (ev) => { - if (ev.button !== 0) return; - if (state.scale <= state.minScale) return; - ev.preventDefault(); - try { - wrap.setPointerCapture(ev.pointerId); - } catch (_) {} - dragging = true; - dragStartX = ev.clientX; - dragStartY = ev.clientY; - dragOrigX = state.offsetX; - dragOrigY = state.offsetY; - mediaEl.style.cursor = "grabbing"; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", stopDrag); - window.addEventListener("pointercancel", stopDrag); - }); - - const fitOnReady = () => { - sharedZoom.scale = calcFitScale(); - sharedZoom.minScale = sharedZoom.scale; - sharedZoom.offsetX = 0; - sharedZoom.offsetY = 0; - applySharedZoom(); - }; - mediaEl.addEventListener?.("load", fitOnReady); - mediaEl.addEventListener?.("loadedmetadata", fitOnReady); - - wrap.addEventListener("dblclick", () => { - sharedZoom.scale = calcFitScale(); - sharedZoom.minScale = sharedZoom.scale; - sharedZoom.offsetX = 0; - sharedZoom.offsetY = 0; - applySharedZoom(); - }); - - applyAll(); - return state; - } - - baseWrap.appendChild(baseMedia); - topWrap.appendChild(topMedia); - - // Enable zoom/pan on both - Define shared state and helper functions first - const sharedZoom = { scale: 1, minScale: 1, offsetX: 0, offsetY: 0, entries: [] }; - - // Define calcFitScale before enableZoomPan so it's available in closures - const calcFitScale = () => { - const wrapRect = baseWrap.getBoundingClientRect(); - const wrapW = wrapRect.width || baseWrap.clientWidth || 1; - const wrapH = wrapRect.height || baseWrap.clientHeight || 1; - const mediaW1 = baseMedia.videoWidth || baseMedia.naturalWidth || baseMedia.clientWidth || wrapW; - const mediaH1 = baseMedia.videoHeight || baseMedia.naturalHeight || baseMedia.clientHeight || wrapH; - const mediaW2 = topMedia.videoWidth || topMedia.naturalWidth || topMedia.clientWidth || wrapW; - const mediaH2 = topMedia.videoHeight || topMedia.naturalHeight || topMedia.clientHeight || wrapH; - const ratioA = mediaW1 && mediaH1 ? Math.min(wrapW / mediaW1, wrapH / mediaH1) : 1; - const ratioB = mediaW2 && mediaH2 ? Math.min(wrapW / mediaW2, wrapH / mediaH2) : 1; - const ratio = Math.min(ratioA || 1, ratioB || 1); - return Math.max(0.01, Math.min(10, ratio || 1)); - }; - - // Define applySharedZoom before enableZoomPan so it's available in closures - const applySharedZoom = () => { - if (sharedZoom.scale <= sharedZoom.minScale) { - sharedZoom.offsetX = 0; - sharedZoom.offsetY = 0; - } - for (const entry of sharedZoom.entries) { - entry.media.style.transform = `translate(${sharedZoom.offsetX}px, ${sharedZoom.offsetY}px) scale(${sharedZoom.scale})`; - entry.media.style.transformOrigin = "50% 50%"; - entry.media.style.cursor = sharedZoom.scale > sharedZoom.minScale ? "grab" : "default"; - if (entry.hud) { - entry.hud.textContent = `${Math.round(sharedZoom.scale * 100)}%`; - } - } - }; - sharedZoom.applySharedZoom = applySharedZoom; - - // Now enable zoom/pan with access to calcFitScale and applySharedZoom - enableZoomPan(baseMedia, baseWrap, sharedZoom); - enableZoomPan(topMedia, topWrap, sharedZoom); - - const checkerPattern = mjrThemeVars.viewerChecker; - const applyCheckerboardBg = (enabled) => { - const bg = enabled ? checkerPattern : mjrThemeVars.viewerCheckerBg; - baseWrap.style.background = bg; - topWrap.style.background = bg; - }; - applyCheckerboardBg(!!mjrSettings.viewer.useCheckerboard); - - // Simple context menu to reset zoom/pan - let contextMenuEl = null; - const closeContextMenu = () => { - if (contextMenuEl && contextMenuEl.parentNode) contextMenuEl.parentNode.removeChild(contextMenuEl); - contextMenuEl = null; - }; - const buildContextMenu = (x, y) => { - closeContextMenu(); - const menu = document.createElement("div"); - Object.assign(menu.style, { - position: "fixed", - left: `${x}px`, - top: `${y}px`, - background: mjrThemeVars.viewerPanel, - color: mjrThemeVars.viewerText, - border: `1px solid ${mjrThemeVars.viewerPanelBorder}`, - borderRadius: "6px", - boxShadow: "0 6px 20px rgba(0,0,0,0.5)", - padding: "6px 0", - minWidth: "150px", - zIndex: "99999", - fontSize: "12px", - userSelect: "none", - }); - // Keep clicks inside from closing before they fire - menu.addEventListener("pointerdown", (ev) => ev.stopPropagation()); - const makeItem = (label, onClick) => { - const el = document.createElement("div"); - el.textContent = label; - Object.assign(el.style, { - padding: "6px 12px", - cursor: "pointer", - }); - el.addEventListener("mouseenter", () => { - el.style.background = mjrThemeVars.filmstripBg; - }); - el.addEventListener("mouseleave", () => { - el.style.background = "transparent"; - }); - el.addEventListener("click", () => { - closeContextMenu(); - onClick(); - }); - return el; - }; - - menu.appendChild(makeItem("Reset zoom / pan", () => sharedZoom.reset?.())); - - const toggleCheckerboard = () => { - const next = !mjrSettings.viewer.useCheckerboard; - mjrSettings.viewer.useCheckerboard = next; - mjrSaveSettings(mjrSettings); - applyCheckerboardBg(next); - }; - const checkerLabel = () => - mjrSettings.viewer.useCheckerboard ? "Hide checkerboard" : "Show checkerboard"; - menu.appendChild(makeItem(checkerLabel(), toggleCheckerboard)); - document.body.appendChild(menu); - contextMenuEl = menu; - }; - - const onWindowPointerDown = () => closeContextMenu(); - const onWindowBlur = () => closeContextMenu(); - - container.addEventListener("contextmenu", (ev) => { - ev.preventDefault(); - buildContextMenu(ev.clientX, ev.clientY); - }); - window.addEventListener("pointerdown", onWindowPointerDown); - window.addEventListener("blur", onWindowBlur); - - mjrViewerState.abCleanup = () => { - closeContextMenu(); - window.removeEventListener("pointerdown", onWindowPointerDown); - window.removeEventListener("blur", onWindowBlur); - }; - - // Hint overlay for controls - const hint = document.createElement("div"); - Object.assign(hint.style, { - position: "absolute", - top: "12px", - left: "50%", - transform: "translateX(-50%)", - padding: "6px 10px", - borderRadius: "8px", - background: mjrThemeVars.viewerPanel, - color: mjrThemeVars.viewerText, - fontSize: "12px", - fontWeight: "600", - letterSpacing: "0.3px", - pointerEvents: "none", - zIndex: "10", - border: `1px solid ${mjrThemeVars.viewerPanelBorder}`, - boxShadow: "0 2px 8px rgba(0,0,0,0.35)", - textAlign: "center", - whiteSpace: "nowrap", - }); - hint.textContent = "Scroll: Zoom â€ĸ Drag image (zoomed): Pan â€ĸ Drag bar: Split â€ĸ F: Fit/Reset"; - container.appendChild(hint); - hint.style.transition = "opacity 0.3s ease"; - let hintTimer = setTimeout(() => { - hint.style.opacity = "0"; - }, 5000); - const topBandPx = 80; - const scheduleHintHide = (delay = 1500) => { - if (hintTimer) clearTimeout(hintTimer); - hintTimer = setTimeout(() => { - hint.style.opacity = "0"; - }, delay); - }; - const onHoverMove = (ev) => { - const rect = container.getBoundingClientRect(); - const nearTop = ev.clientY >= rect.top && ev.clientY <= rect.top + topBandPx; - if (nearTop) { - if (hintTimer) { - clearTimeout(hintTimer); - hintTimer = null; - } - hint.style.opacity = "1"; - } else if (!hintTimer) { - scheduleHintHide(700); - } - }; - container.addEventListener("mousemove", onHoverMove); - container.addEventListener("mouseenter", onHoverMove); - container.addEventListener("mouseleave", () => scheduleHintHide(700)); - - const handle = document.createElement("div"); - Object.assign(handle.style, { - position: "absolute", - top: "0", - bottom: "0", - left: "50%", - width: "2px", - marginLeft: "-1px", - background: mjrThemeVars.overlay, - backdropFilter: "blur(1px)", - zIndex: "5", - }); - handle.style.pointerEvents = "none"; - handle.dataset.mjrSplitHandle = "1"; - handle.textContent = "↔"; - handle.style.color = mjrThemeVars.viewerText; - handle.style.fontSize = "13px"; - handle.style.display = "flex"; - handle.style.alignItems = "center"; - handle.style.justifyContent = "center"; - handle.style.transform = "translateX(-50%)"; - - const handleHit = document.createElement("div"); - Object.assign(handleHit.style, { - position: "absolute", - top: "0", - bottom: "0", - left: "50%", - width: "12px", - marginLeft: "-6px", - cursor: "col-resize", - zIndex: "6", - background: "transparent", - }); - handleHit.dataset.mjrSplitHandle = "1"; - handleHit.appendChild(handle); - - // Labels A / B - const labelA = document.createElement("div"); - labelA.textContent = fileA.name || fileA.filename || "A"; - Object.assign(labelA.style, { - position: "absolute", - left: "10px", - bottom: "10px", - padding: "2px 6px", - fontSize: "11px", - borderRadius: "4px", - background: mjrThemeVars.overlay, - color: mjrThemeVars.viewerText, - maxWidth: "45%", - whiteSpace: "nowrap", - textOverflow: "ellipsis", - overflow: "hidden", - }); - - const labelB = document.createElement("div"); - labelB.textContent = fileB.name || fileB.filename || "B"; - Object.assign(labelB.style, { - position: "absolute", - right: "10px", - bottom: "10px", - padding: "2px 6px", - fontSize: "11px", - borderRadius: "4px", - background: mjrThemeVars.overlay, - color: mjrThemeVars.viewerText, - maxWidth: "45%", - whiteSpace: "nowrap", - textOverflow: "ellipsis", - overflow: "hidden", - textAlign: "right", - }); - - const updateSplit = (val) => { - const pct = Math.max(0, Math.min(100, Number(val) || 0)); - topWrap.style.clipPath = `inset(0 ${100 - pct}% 0 0)`; - handle.style.left = pct + "%"; - handleHit.style.left = pct + "%"; - }; - - const resetView = () => { - sharedZoom.scale = calcFitScale(); - sharedZoom.minScale = sharedZoom.scale; - sharedZoom.offsetX = 0; - sharedZoom.offsetY = 0; - applySharedZoom(); - updateSplit(50); - }; - sharedZoom.reset = resetView; - mjrViewerState.resetView = resetView; - - // Drag direct sur la ligne (handle). When zoomed-in, only the handle drags to avoid conflicting with panning. - const startSplitDrag = (ev) => { - const isPrimary = ev.button === 0 || ev.buttons === 1; - if (!isPrimary) return; - const targetIsHandle = - ev.target === handle || (ev.target && ev.target.closest?.("[data-mjr-split-handle]")); - if (sharedZoom.scale > sharedZoom.minScale && !targetIsHandle) return; - ev.preventDefault(); - const rect = container.getBoundingClientRect(); - const apply = (clientX) => { - const x = clientX - rect.left; - const pct = (x / rect.width) * 100; - updateSplit(pct); - }; - apply(ev.clientX); - try { - container.setPointerCapture(ev.pointerId); - } catch (_) {} - - const move = (e) => { - apply(e.clientX); - }; - const up = () => { - container.releasePointerCapture?.(ev.pointerId); - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - window.removeEventListener("pointercancel", up); - }; - window.addEventListener("pointermove", move, { passive: false }); - window.addEventListener("pointerup", up); - window.addEventListener("pointercancel", up); - }; - handleHit.addEventListener("pointerdown", startSplitDrag); - container.addEventListener("pointerdown", startSplitDrag); - - // position initiale - updateSplit(50); - - container.appendChild(baseWrap); - container.appendChild(topWrap); - container.appendChild(handleHit); - container.appendChild(labelA); - container.appendChild(labelB); - - // Warn if not image/video - if (!["image", "video"].includes(kindA) || !["image", "video"].includes(kindB)) { - const warn = document.createElement("div"); - warn.textContent = "A/B is optimized for images / videos. Other types may not display as expected."; - Object.assign(warn.style, { - position: "absolute", - top: "10px", - left: "50%", - transform: "translateX(-50%)", - padding: "4px 8px", - fontSize: "11px", - borderRadius: "4px", - background: mjrThemeVars.overlay, - color: mjrThemeVars.starsFilled, - }); - container.appendChild(warn); - } - - frame.appendChild(container); - - // Fit after layout - requestAnimationFrame(() => resetView()); - // Also refit once media reports intrinsic sizes - const fitOnReady = () => resetView(); - baseMedia.addEventListener?.("load", fitOnReady); - baseMedia.addEventListener?.("loadedmetadata", fitOnReady); - topMedia.addEventListener?.("load", fitOnReady); - topMedia.addEventListener?.("loadedmetadata", fitOnReady); -} - -export function mjrOpenViewerForFiles(files, listContext = null) { - if (!files || !files.length) return; - - const baseList = listContext && listContext.length ? listContext : files; - mjrCurrentList = baseList.slice(); - - const target = files[0]; - const idx = mjrCurrentList.indexOf(target); - mjrCurrentIndex = idx >= 0 ? idx : 0; - - const clamped = files.length >= 2 ? [files[0], files[1]] : [files[0]]; - - if (clamped.length === 2) { - mjrOpenABViewer(clamped[0], clamped[1]); - return; - } - - mjrRenderSingleCurrent(); -} - -export function mjrRenderSingleCurrent() { - if (!mjrCurrentList.length) return; - const file = mjrCurrentList[mjrCurrentIndex]; - if (!file) return; - mjrViewerState.resetView = null; - mjrViewerIsAB = false; - mjrViewerState.updateFilmstrip?.(); - mjrUpdateNavVisibility(); - - mjrEnsureViewerOverlay(); - mjrViewerState.overlay.style.display = "flex"; - if (mjrViewerState.frame) { - Object.assign(mjrViewerState.frame.style, { - width: "100%", - height: "100%", - alignItems: "stretch", - justifyContent: "center", - }); - } - mjrViewerState.frame.innerHTML = ""; - - const pane = mjrCreateViewerPane(file); - mjrViewerState.resetView = pane && pane.__mjrReset ? pane.__mjrReset : null; - mjrViewerState.frame.appendChild(pane); - if (mjrViewerState.frame.firstChild) { - Object.assign(mjrViewerState.frame.firstChild.style, { - flex: "1 1 auto", - width: "100%", - height: "100%", - }); - } - - // Rating overlay in viewer - if (mjrSettings.viewer.showRatingHUD) { - const currentRating = Math.max(0, Math.min(5, mjrGetRating(file))); - const mediaWrap = pane.firstChild; - if (mediaWrap && mediaWrap.appendChild) { - const badge = document.createElement("div"); - Object.assign(badge.style, { - position: "absolute", - top: "8px", - left: "8px", - padding: "6px 10px", - borderRadius: "8px", - background: mjrThemeVars.viewerPanel, - color: mjrThemeVars.starsFilled, - fontWeight: "700", - letterSpacing: "2px", - fontSize: "20px", - pointerEvents: "none", - }); - badge.dataset.mjrViewerRating = "1"; - mediaWrap.appendChild(badge); - mjrViewerRatingEl = badge; - mjrUpdateViewerRatingDisplay(currentRating); - } - } else { - mjrViewerRatingEl = null; - } - - if (mjrViewerState.updateFilmstrip) { - mjrViewerState.updateFilmstrip(); - } -} - -export function mjrViewerPrev() { - if (!mjrCurrentList.length || mjrViewerIsAB) return; - mjrCurrentIndex = (mjrCurrentIndex - 1 + mjrCurrentList.length) % mjrCurrentList.length; - mjrRenderSingleCurrent(); -} - -export function mjrViewerNext() { - if (!mjrCurrentList.length || mjrViewerIsAB) return; - mjrCurrentIndex = (mjrCurrentIndex + 1) % mjrCurrentList.length; - mjrRenderSingleCurrent(); -} - -export function mjrUpdateViewerRatingDisplay(rating) { - if (!mjrViewerRatingEl) return; - const r = Math.max(0, Math.min(5, Number(rating) || 0)); - mjrViewerRatingEl.textContent = ""; - for (let i = 1; i <= 5; i++) { - const span = document.createElement("span"); - const filled = i <= r; - span.textContent = filled ? "★" : "☆"; - span.style.color = filled ? mjrThemeVars.starsFilled : mjrThemeVars.starsEmpty; - span.style.marginRight = i < 5 ? "2px" : "0"; - mjrViewerRatingEl.appendChild(span); - } -} - -export function mjrApplyOpenViewerSettings() { - try { - mjrUpdateNavVisibility(); - } catch (_) {} - - const frame = mjrViewerState.frame; - if (!frame) return; - - // Update video element behavior without rerendering (keeps zoom/pan state) - try { - frame.querySelectorAll("video").forEach((el) => { - el.autoplay = !!mjrSettings.viewer.autoplayVideos; - el.loop = !!mjrSettings.viewer.loopVideos; - el.muted = !!mjrSettings.viewer.muteVideos; - }); - } catch (_) {} - - // Toggle rating HUD (single viewer only) - try { - if (mjrViewerIsAB) return; - const pane = frame.firstChild; - const mediaWrap = pane && pane.firstChild; - if (!mediaWrap || !mediaWrap.appendChild) return; - - if (!mjrSettings.viewer.showRatingHUD) { - if (mjrViewerRatingEl && mjrViewerRatingEl.parentNode) { - mjrViewerRatingEl.parentNode.removeChild(mjrViewerRatingEl); - } - mjrViewerRatingEl = null; - return; - } - - if (!mjrViewerRatingEl) { - const existing = mediaWrap.querySelector?.("[data-mjr-viewer-rating='1']") || null; - if (existing) { - mjrViewerRatingEl = existing; - } else { - const badge = document.createElement("div"); - Object.assign(badge.style, { - position: "absolute", - top: "8px", - left: "8px", - padding: "6px 10px", - borderRadius: "8px", - background: mjrThemeVars.viewerPanel, - color: mjrThemeVars.starsFilled, - fontWeight: "700", - letterSpacing: "2px", - fontSize: "20px", - pointerEvents: "none", - }); - badge.dataset.mjrViewerRating = "1"; - mediaWrap.appendChild(badge); - mjrViewerRatingEl = badge; - } - } - - const file = mjrCurrentList[mjrCurrentIndex]; - const currentRating = Math.max(0, Math.min(5, mjrGetRating(file))); - mjrUpdateViewerRatingDisplay(currentRating); - } catch (_) {} -} - - async function mjrSetViewerRating(rating) { - if (mjrViewerIsAB) return; - if (!mjrCurrentList.length) return; - const file = mjrCurrentList[mjrCurrentIndex]; - if (!file) return; - const r = Math.max(0, Math.min(5, Number(rating) || 0)); - file.rating = r; - mjrUpdateViewerRatingDisplay(r); - - // Update grid displays to reflect new rating - mjrGlobalState.instances.forEach((inst) => { - if (typeof inst.applyFilterAndRender === "function") { - try { - inst.applyFilterAndRender(); - } catch (e) { - console.warn("[Majoor.Viewer] Failed to update grid after rating change", e); - } - } - }); - - // Persist using the first available instance from global state - const firstInst = mjrGlobalState.instances.values().next().value; - if (firstInst && typeof firstInst.updateFileMetadata === "function") { - try { - await firstInst.updateFileMetadata(file, { rating: r }, "Rating updated"); - } catch (err) { - console.warn("[Majoor.AssetsManager] viewer rating persist failed", err); - } - } -} diff --git a/js/utils/debounce.js b/js/utils/debounce.js new file mode 100644 index 00000000..a8764ac3 --- /dev/null +++ b/js/utils/debounce.js @@ -0,0 +1,8 @@ +export function debounce(fn, delay = 300) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + diff --git a/js/utils/events.js b/js/utils/events.js new file mode 100644 index 00000000..9b9a9bd0 --- /dev/null +++ b/js/utils/events.js @@ -0,0 +1,19 @@ +/** + * Event helpers. + * + * Keep event dispatch best-effort (never throw) but avoid fully silent failures. + */ + +export function safeDispatchCustomEvent(eventName, detail, { target = null, warnPrefix = "[Majoor]" } = {}) { + const t = target || (typeof window !== "undefined" ? window : null); + if (!t || typeof t.dispatchEvent !== "function") return false; + try { + return t.dispatchEvent(new CustomEvent(eventName, { detail })); + } catch (e) { + try { + console.warn(`${warnPrefix} Failed to dispatch event: ${eventName}`, e); + } catch {} + return false; + } +} + diff --git a/js/utils/extractOutputFiles.js b/js/utils/extractOutputFiles.js new file mode 100644 index 00000000..3559e0d9 --- /dev/null +++ b/js/utils/extractOutputFiles.js @@ -0,0 +1,20 @@ +export function extractOutputFiles(output) { + const files = []; + const addItems = (items) => { + if (!Array.isArray(items)) return; + items.forEach((item) => { + if (!item || !item.filename) return; + files.push({ + filename: item.filename, + subfolder: item.subfolder || "", + type: item.type || "output", + }); + }); + }; + + addItems(output?.images); + addItems(output?.gifs); + addItems(output?.videos); + return files; +} + diff --git a/js/utils/fastUpload.js b/js/utils/fastUpload.js new file mode 100644 index 00000000..ea5ab1db --- /dev/null +++ b/js/utils/fastUpload.js @@ -0,0 +1,57 @@ +/** + * Fast upload utilities for images and videos + */ + +/** + * Fast upload image to ComfyUI input directory using native endpoint + * @param {File} file - The image file to upload + * @returns {Promise} - Response from the upload endpoint + */ +export async function fastUploadImageToInput(file) { + const fd = new FormData(); + fd.append("image", file, file.name); + + const res = await fetch("/upload/image", { method: "POST", body: fd }); + if (!res.ok) throw new Error(`Upload failed: ${res.status}`); + return await res.json(); // { name: "file.png", ... } +} + +/** + * Fast upload file to ComfyUI input directory using custom endpoint + * @param {File} file - The file to upload (for video, etc.) + * @param {string} purpose - Purpose of upload (e.g. "node_drop") + * @returns {Promise} - Response from the upload endpoint + */ +export async function fastUploadFileToInput(file, purpose = null) { + const fd = new FormData(); + fd.append("file", file, file.name); + + let url = "/mjr/am/upload_input"; + if (purpose) { + url += `?purpose=${encodeURIComponent(purpose)}`; + } + + const res = await fetch(url, { method: "POST", body: fd }); + if (!res.ok) throw new Error(`Upload failed: ${res.status}`); + return await res.json(); // { name: "file.mp4", ... } +} + +/** + * Upload a file with appropriate method based on type + * @param {File} file - The file to upload + * @param {string} purpose - Purpose of upload (e.g. "node_drop") + * @returns {Promise} - Response from the upload endpoint + */ +export async function smartUploadToInput(file, purpose = null) { + // Check if it's an image file + const imageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml']; + const isImage = imageTypes.includes(file.type.toLowerCase()); + + if (isImage) { + // Use native ComfyUI endpoint for images + return await fastUploadImageToInput(file); + } else { + // Use custom endpoint for other files (videos, etc.) + return await fastUploadFileToInput(file, purpose); + } +} \ No newline at end of file diff --git a/js/viewer/interaction.js b/js/viewer/interaction.js deleted file mode 100644 index 42d07824..00000000 --- a/js/viewer/interaction.js +++ /dev/null @@ -1,241 +0,0 @@ -export function setupViewerInteractions({ - el, - kind, - mediaWrap, - zoomHud, - mjrViewerState, - applyCheckerboardBg, - mjrSettings, -}) { - const allowZoom = kind === "image" || kind === "video"; - let scale = 1; - const originX = 50; - const originY = 50; - let offsetX = 0; - let offsetY = 0; - let resetZoomPan = () => {}; - let minScale = 1; - - const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); - const clampOffsets = () => { - const rect = mediaWrap.getBoundingClientRect(); - const wrapW = rect.width || 1; - const wrapH = rect.height || 1; - const mediaW = el.videoWidth || el.naturalWidth || el.clientWidth || wrapW; - const mediaH = el.videoHeight || el.naturalHeight || el.clientHeight || wrapH; - const maxX = Math.max(0, (mediaW * scale - wrapW) / 2); - const maxY = Math.max(0, (mediaH * scale - wrapH) / 2); - if (maxX <= 0) offsetX = 0; - else offsetX = clamp(offsetX, -maxX, maxX); - if (maxY <= 0) offsetY = 0; - else offsetY = clamp(offsetY, -maxY, maxY); - }; - const calcFitScale = () => { - const wrapRect = mediaWrap.getBoundingClientRect(); - const wrapW = wrapRect.width || mediaWrap.clientWidth || 1; - const wrapH = wrapRect.height || mediaWrap.clientHeight || 1; - const mediaW = el.videoWidth || el.naturalWidth || el.clientWidth || wrapW; - const mediaH = el.videoHeight || el.naturalHeight || el.clientHeight || wrapH; - if (!mediaW || !mediaH) return 1; - const ratio = Math.min(wrapW / mediaW, wrapH / mediaH); - return Math.max(0.01, Math.min(10, ratio || 1)); - }; - - if (allowZoom) { - el.style.transformOrigin = `${originX}% ${originY}%`; - el.style.willChange = "transform"; - } else { - zoomHud.style.display = "none"; - el.style.transform = "none"; - el.style.cursor = "default"; - } - - const applyTransform = () => { - if (!allowZoom) return; - if (scale < minScale) scale = minScale; - clampOffsets(); - if (scale <= minScale) { - offsetX = 0; - offsetY = 0; - } - el.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; - el.style.cursor = scale > minScale ? "grab" : "default"; - zoomHud.textContent = `${Math.round(scale * 100)}%`; - }; - - let resizeObserver = null; - let onWindowResize = null; - - if (allowZoom) { - resetZoomPan = () => { - minScale = calcFitScale(); - scale = minScale; - offsetX = 0; - offsetY = 0; - applyTransform(); - }; - mjrViewerState.resetView = resetZoomPan; - const recalcFit = (preserveZoom = true) => { - const oldMin = minScale; - const oldScale = scale; - const zoomRatio = oldMin > 0 ? (oldScale / oldMin) : 1; - minScale = calcFitScale(); - if (!preserveZoom || zoomRatio <= 1.0001) { - scale = minScale; - offsetX = 0; - offsetY = 0; - } else { - scale = clamp(minScale * zoomRatio, minScale, 10); - const k = oldScale > 0 ? (scale / oldScale) : 1; - offsetX *= k; - offsetY *= k; - } - applyTransform(); - }; - const fitOnReady = () => resetZoomPan(); - el.addEventListener?.("load", fitOnReady); - el.addEventListener?.("loadedmetadata", fitOnReady); - if (typeof ResizeObserver !== "undefined") { - resizeObserver = new ResizeObserver(() => recalcFit(true)); - resizeObserver.observe(mediaWrap); - } else { - onWindowResize = () => recalcFit(true); - window.addEventListener("resize", onWindowResize); - } - - mediaWrap.addEventListener( - "wheel", - (ev) => { - ev.preventDefault(); - const delta = ev.deltaY; - const factor = delta > 0 ? 0.9 : 1.1; - const rect = mediaWrap.getBoundingClientRect(); - const px = ev.clientX - rect.left; - const py = ev.clientY - rect.top; - const prevScale = scale; - const newScale = clamp(scale * factor, minScale, 10); - - const dx = (px - rect.width / 2 - offsetX) / prevScale; - const dy = (py - rect.height / 2 - offsetY) / prevScale; - offsetX = px - rect.width / 2 - newScale * dx; - offsetY = py - rect.height / 2 - newScale * dy; - - scale = newScale; - clampOffsets(); - applyTransform(); - }, - { passive: false } - ); - - let dragging = false; - let dragStartX = 0; - let dragStartY = 0; - let dragOrigX = 0; - let dragOrigY = 0; - - const onPointerMove = (ev) => { - if (!dragging) return; - const dx = ev.clientX - dragStartX; - const dy = ev.clientY - dragStartY; - offsetX = dragOrigX + dx; - offsetY = dragOrigY + dy; - clampOffsets(); - applyTransform(); - }; - const stopDrag = () => { - if (!dragging) return; - dragging = false; - el.style.cursor = scale > minScale ? "grab" : "default"; - window.removeEventListener("pointermove", onPointerMove); - window.removeEventListener("pointerup", stopDrag); - window.removeEventListener("pointercancel", stopDrag); - }; - - mediaWrap.addEventListener("pointerdown", (ev) => { - if (ev.button !== 0) return; - if (scale <= minScale) return; - ev.preventDefault(); - try { - mediaWrap.setPointerCapture(ev.pointerId); - } catch (_) {} - dragging = true; - dragStartX = ev.clientX; - dragStartY = ev.clientY; - dragOrigX = offsetX; - dragOrigY = offsetY; - el.style.cursor = "grabbing"; - window.addEventListener("pointermove", onPointerMove); - window.addEventListener("pointerup", stopDrag); - window.addEventListener("pointercancel", stopDrag); - }); - - mediaWrap.addEventListener("dblclick", resetZoomPan); - } - - // Hint overlay for controls - const hint = document.createElement("div"); - Object.assign(hint.style, { - position: "absolute", - top: "12px", - left: "50%", - transform: "translateX(-50%)", - padding: "6px 10px", - borderRadius: "8px", - background: "rgba(0,0,0,0.55)", - color: "#f0f0f0", - fontSize: "12px", - fontWeight: "600", - letterSpacing: "0.3px", - pointerEvents: "none", - zIndex: "10", - border: "1px solid rgba(255,255,255,0.08)", - boxShadow: "0 2px 8px rgba(0,0,0,0.35)", - textAlign: "center", - whiteSpace: "nowrap", - }); - hint.textContent = "Scroll: Zoom â€ĸ Drag (zoomed): Pan â€ĸ F: Fit/Reset"; - mediaWrap.appendChild(hint); - hint.style.transition = "opacity 0.3s ease"; - let hideTimer = setTimeout(() => { - hint.style.opacity = "0"; - }, 5000); - const topBandPx = 80; - const scheduleHide = (delay = 1500) => { - if (hideTimer) clearTimeout(hideTimer); - hideTimer = setTimeout(() => { - hint.style.opacity = "0"; - }, delay); - }; - const onMove = (ev) => { - const rect = mediaWrap.getBoundingClientRect(); - const nearTop = ev.clientY >= rect.top && ev.clientY <= rect.top + topBandPx; - if (nearTop) { - if (hideTimer) { - clearTimeout(hideTimer); - hideTimer = null; - } - hint.style.opacity = "1"; - } else if (!hideTimer) { - scheduleHide(700); - } - }; - - const onLeave = () => scheduleHide(700); - mediaWrap.addEventListener("mousemove", onMove); - mediaWrap.addEventListener("mouseenter", onMove); - mediaWrap.addEventListener("mouseleave", onLeave); - requestAnimationFrame(() => resetZoomPan()); - - const cleanup = () => { - try { mediaWrap.removeEventListener("mousemove", onMove); } catch (_) {} - try { mediaWrap.removeEventListener("mouseenter", onMove); } catch (_) {} - try { mediaWrap.removeEventListener("mouseleave", onLeave); } catch (_) {} - try { resizeObserver?.disconnect?.(); } catch (_) {} - if (onWindowResize) { - try { window.removeEventListener("resize", onWindowResize); } catch (_) {} - onWindowResize = null; - } - }; - - return { resetZoomPan, cleanup }; -} diff --git a/js/workflow_hash.js b/js/workflow_hash.js deleted file mode 100644 index 58fcb83f..00000000 --- a/js/workflow_hash.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Workflow Hash Utilities - * - * Client-side workflow fingerprinting that matches server-side logic. - * Used for "Find assets from current graph" feature. - */ - -import { api } from "../../../scripts/api.js"; - -// Keys to exclude from canonicalization (must match server config) -const EXCLUDE_KEYS = new Set([ - 'id', - 'pos', - 'size', - 'order', - 'mode', - 'properties', - 'color', - 'bgcolor', - 'flags', - 'widgets_values', -]); - -/** - * Deep copy object/array, excluding specified keys. - */ -function deepCopyWithout(obj, excludeKeys) { - if (obj === null || obj === undefined) { - return obj; - } - - if (typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(item => deepCopyWithout(item, excludeKeys)); - } - - const copy = {}; - for (const [key, value] of Object.entries(obj)) { - if (!excludeKeys.has(key)) { - copy[key] = deepCopyWithout(value, excludeKeys); - } - } - return copy; -} - -/** - * Recursively sort object keys to match server-side canonicalization. - */ -function sortKeysDeep(obj) { - if (obj === null || obj === undefined) { - return obj; - } - if (typeof obj !== 'object') { - return obj; - } - if (Array.isArray(obj)) { - return obj.map(item => sortKeysDeep(item)); - } - const sorted = {}; - Object.keys(obj).sort().forEach((key) => { - sorted[key] = sortKeysDeep(obj[key]); - }); - return sorted; -} - -/** - * Canonicalize workflow for hashing. - * - Removes volatile fields (id, pos, size, etc.) - * - Sorts all object keys recursively - * - Returns compact JSON string - */ -export function canonicalizeWorkflow(workflow) { - if (!workflow || typeof workflow !== 'object') { - return ''; - } - - // Remove volatile fields - const clean = deepCopyWithout(workflow, EXCLUDE_KEYS); - - // Sort keys recursively and stringify - const canonical = JSON.stringify(sortKeysDeep(clean)); - return canonical; -} - -/** - * Calculate SHA1 hash of workflow. - * Returns 40-character hex string. - */ -export async function hashWorkflow(workflow) { - if (!workflow) { - return ''; - } - - try { - const canonical = canonicalizeWorkflow(workflow); - if (!canonical) { - return ''; - } - - // Use Web Crypto API for SHA-1 - const encoder = new TextEncoder(); - const data = encoder.encode(canonical); - const hashBuffer = await crypto.subtle.digest('SHA-1', data); - - // Convert to hex string - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - - return hashHex; - } catch (error) { - console.error('[Majoor] Failed to hash workflow:', error); - return ''; - } -} - -/** - * Get current workflow from ComfyUI app. - * Returns the workflow object or null if not available. - */ -export function getCurrentWorkflow(app) { - if (!app || !app.graph) { - return null; - } - - try { - // Serialize current graph to workflow JSON - const workflow = app.graph.serialize(); - return workflow; - } catch (error) { - console.error('[Majoor] Failed to get current workflow:', error); - return null; - } -} - -/** - * Calculate hash of current workflow in ComfyUI. - * Convenience function combining getCurrentWorkflow + hashWorkflow. - */ -export async function hashCurrentWorkflow(app) { - const workflow = getCurrentWorkflow(app); - if (!workflow) { - return ''; - } - return await hashWorkflow(workflow); -} - -/** - * Validate that client-side hashing matches server-side. - * Useful for debugging/testing. - */ -export async function validateHashAlgorithm() { - try { - const resp = await api.fetchApi('/mjr/filemanager/workflow/hash_info'); - const info = await resp.json(); - - console.log('[Majoor] Server workflow hash algorithm:', info); - - // Check if exclude keys match - const serverKeys = new Set(info.exclude_keys || []); - const clientKeys = EXCLUDE_KEYS; - - const missingOnClient = Array.from(serverKeys).filter(k => !clientKeys.has(k)); - const missingOnServer = Array.from(clientKeys).filter(k => !serverKeys.has(k)); - - if (missingOnClient.length > 0) { - console.warn('[Majoor] Client missing exclude keys:', missingOnClient); - } - - if (missingOnServer.length > 0) { - console.warn('[Majoor] Server missing exclude keys:', missingOnServer); - } - - if (missingOnClient.length === 0 && missingOnServer.length === 0) { - console.log('[Majoor] ✓ Workflow hash algorithm validated'); - } - - return info; - } catch (error) { - console.error('[Majoor] Failed to validate hash algorithm:', error); - return null; - } -} diff --git a/node_mapping.json b/node_mapping.json deleted file mode 100644 index f5cb7b74..00000000 --- a/node_mapping.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "_comment": "Custom node type mappings for metadata extraction configuration", - "_description": "Add your custom ComfyUI node class names here to extend metadata extraction support", - "_usage": "Restart ComfyUI after editing this file. Custom classes are merged with built-in defaults.", - "_documentation": "See README.md for details", - - "_sampler_classes_comment": "Add custom sampler node class names (lowercase). Examples: customsampler, mysampler, advancedsampler", - "sampler_classes": [], - - "_checkpoint_loader_classes_comment": "Add custom checkpoint loader class names. Examples: CustomCheckpointLoader, MyModelLoader", - "checkpoint_loader_classes": [], - - "_unet_loader_classes_comment": "Add custom UNET/diffusion model loader class names. Examples: CustomUNETLoader, MyDiffusionLoader", - "unet_loader_classes": [], - - "_diffusers_loader_classes_comment": "Add custom diffusers loader class names. Examples: CustomDiffusersLoader, MyDiffusersWrapper", - "diffusers_loader_classes": [], - - "_lora_classes_comment": "Add custom LoRA loader class names. Examples: CustomLoraLoader, AdvancedLoraLoader", - "lora_classes": [] -} diff --git a/requirements.txt b/requirements.txt index c3a48ac4..31686ee7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,17 @@ -pillow>=10.0.0 -aiohttp>=3.9.0 -send2trash>=1.8.0 -watchdog>=3.0.0 -pywin32; sys_platform == 'win32' \ No newline at end of file +# ComfyUI-Majoor-AssetsManager Python Dependencies + +# Core web framework +aiohttp>=3.8.0 + +# Image processing and thumbnails +pillow>=9.0.0 + +# Safe file deletion (recycle bin support) +send2trash>=1.8.0 + +# File system monitoring for auto-reindexing +watchdog>=2.1.0 + +# Windows-specific: Windows Property System metadata support +# Only required on Windows; gracefully skipped on other platforms +pywin32>=300; sys_platform == 'win32' diff --git a/server/__init__.py b/server/__init__.py deleted file mode 100644 index 3593a1ca..00000000 --- a/server/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Backend entry for Majoor Assets Manager. -Just importing this module will register our HTTP routes on PromptServer. -""" -import os - -if str(os.environ.get("MJR_SKIP_ROUTES", "")).strip().lower() not in ("1", "true", "yes", "on"): - from . import routes # noqa: F401 diff --git a/server/collections_store.py b/server/collections_store.py deleted file mode 100644 index 498c1e4f..00000000 --- a/server/collections_store.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -import json -import re -from .config import COLLECTIONS_DIR -from .utils import ensure_dir - - -def _validate_collection_name(name: str) -> None: - """ - Validate collection name to prevent path traversal and filesystem issues. - - Must be 1-100 characters - - Only alphanumerics, underscores, hyphens, and spaces - - Cannot be . or .. - - Cannot contain path separators - """ - if not name or not isinstance(name, str): - raise ValueError("Collection name must be a non-empty string") - - if len(name) > 100: - raise ValueError("Collection name too long (max 100 characters)") - - # Reject path traversal attempts - if name in (".", "..") or "/" in name or "\\" in name: - raise ValueError("Invalid collection name: path traversal not allowed") - - # Only allow safe characters: alphanumerics, underscore, hyphen, space - if not re.match(r"^[a-zA-Z0-9_\- ]+$", name): - raise ValueError("Collection name contains invalid characters (only alphanumerics, _, -, and spaces allowed)") - - -def _path(name: str) -> str: - _validate_collection_name(name) - return os.path.join(COLLECTIONS_DIR, name + ".json") - - -def get_collections(): - ensure_dir(COLLECTIONS_DIR) - out = [] - for f in os.listdir(COLLECTIONS_DIR): - if f.endswith(".json"): - out.append(f[:-5]) - return out - - -def load_collection(name): - fp = _path(name) - if not os.path.exists(fp): - return [] - with open(fp, "r", encoding="utf-8") as f: - return json.load(f) - - -def save_collection(name, files): - ensure_dir(COLLECTIONS_DIR) - with open(_path(name), "w", encoding="utf-8") as f: - json.dump(files, f, indent=2) - - -def add_to_collection(name, path): - data = load_collection(name) - if path not in data: - data.append(path) - save_collection(name, data) - - -def remove_from_collection(name, path): - data = load_collection(name) - if path in data: - data.remove(path) - save_collection(name, data) diff --git a/server/config.py b/server/config.py deleted file mode 100644 index 5eac68ac..00000000 --- a/server/config.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -import sys -from folder_paths import get_output_directory - -OUTPUT_ROOT = get_output_directory() - -# FIX: Standardize platform detection using sys.platform consistently -# sys.platform returns "win32" for all Windows versions (including 64-bit) -IS_WINDOWS = sys.platform == "win32" - -# Sidecar JSON files are optional; default to Windows metadata on Windows -_DEFAULT_SIDECAR = not IS_WINDOWS -_SIDECAR_ENV = os.environ.get("MJR_ENABLE_SIDECAR") -if _SIDECAR_ENV is None: - ENABLE_JSON_SIDECAR = _DEFAULT_SIDECAR -else: - ENABLE_JSON_SIDECAR = str(_SIDECAR_ENV).strip().lower() not in ( - "0", - "false", - "no", - "off", - ) - -METADATA_EXT = ".mjr.json" -COLLECTIONS_DIR = os.path.join(OUTPUT_ROOT, "_mjr_collections") -os.makedirs(COLLECTIONS_DIR, exist_ok=True) - -# SQLite index configuration -INDEX_DIR = os.path.join(OUTPUT_ROOT, "_mjr_index") -INDEX_DB = os.path.join(INDEX_DIR, "assets.sqlite") - -# ===== Metadata Extraction Configuration (New) ===== - -# Metadata extraction mode -# - "legacy": Use existing Windows Property Store / ExifTool / sidecar only -# - "hybrid": Try new parsers first, fallback to legacy if they fail -# - "native": Use only new parsers (EXIF UserComment, ComfyUI tracer, PNG inject) -_METADATA_MODE_ENV = os.environ.get("MJR_METADATA_MODE", "hybrid").lower() -if _METADATA_MODE_ENV not in ("legacy", "hybrid", "native"): - _METADATA_MODE_ENV = "hybrid" -METADATA_MODE = _METADATA_MODE_ENV - -# Feature flags for new metadata parsers -# Enable native EXIF UserComment decoder (multi-encoding support) -METADATA_EXIF_NATIVE = os.environ.get("MJR_METADATA_EXIF_NATIVE", "1").strip() not in ("0", "false", "no", "off") - -# Enable recursive ComfyUI node tracer v2 (vs simple prompt extraction) -METADATA_COMFY_TRACE_V2 = os.environ.get("MJR_COMFY_TRACE_V2", "1").strip() not in ("0", "false", "no", "off") - -# Enable lossless PNG metadata injection (vs ExifTool/sidecar only) -METADATA_PNG_INJECT = os.environ.get("MJR_METADATA_PNG_INJECT", "1").strip() not in ("0", "false", "no", "off") - -# Enable workflow fingerprinting (stable hash) -METADATA_WORKFLOW_HASH = os.environ.get("MJR_METADATA_WORKFLOW_HASH", "1").strip() not in ("0", "false", "no", "off") - -# Debug logging for metadata extraction -METADATA_DEBUG = os.environ.get("MJR_DEBUG_METADATA", "0").strip() not in ("0", "false", "no", "off") - -# Fallback behavior on extraction errors -# If True, silently fallback to legacy parsers on error -# If False, log errors but continue (don't crash) -METADATA_SAFE_FALLBACK = os.environ.get("MJR_METADATA_SAFE_FALLBACK", "1").strip() not in ("0", "false", "no", "off") - -# Indexing mode: 'auto' runs the SQLite index/watchdog, -# 'index' forces reindex even if filesystem-first UI is configured, -# 'filesystem' disables the index entirely and relies on filesystem scans (default). -_INDEX_MODE_ENV = os.environ.get("MJR_INDEX_MODE", "filesystem").strip().lower() -if _INDEX_MODE_ENV not in ("auto", "index", "filesystem"): - _INDEX_MODE_ENV = "filesystem" -INDEX_MODE = _INDEX_MODE_ENV diff --git a/server/emoji.py b/server/emoji.py deleted file mode 100644 index a988b137..00000000 --- a/server/emoji.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Emoji helpers for Majoor logs. -Prefixes log messages with folder + state-specific emoji. -""" -from __future__ import annotations - -from typing import Dict - -FOLDER_ICON = "📁" -EMOJI_MAP: Dict[str, str] = { - "error": "❌", - "warning": "âš ī¸", - "success": "✅", - "info": "â„šī¸", -} - - -def emoji_prefix(level: str) -> str: - icon = EMOJI_MAP.get(level.lower(), EMOJI_MAP["info"]) - return f"{FOLDER_ICON}{icon} " diff --git a/server/hooks.py b/server/hooks.py deleted file mode 100644 index 51fa2e3e..00000000 --- a/server/hooks.py +++ /dev/null @@ -1,346 +0,0 @@ -import os -from typing import Any, Dict, List, Optional - -import folder_paths - -from server import PromptServer -from .logger import get_logger -from .utils import ( - classify_ext, - get_system_metadata, - load_metadata, - save_metadata, - set_windows_metadata, - set_exif_metadata, - _get_exiftool_path, -) - -log = get_logger(__name__) - - -def _iter_generated_files(output_data: Dict[str, Any]) -> List[str]: - """ - Extract absolute paths for generated files from the ComfyUI execution payload. - """ - files: List[str] = [] - ui = output_data.get("ui") if isinstance(output_data, dict) else None - if not isinstance(ui, dict): - return files - - for category, items in ui.items(): - if not isinstance(items, list): - continue - for item in items: - if not isinstance(item, dict): - continue - filename = item.get("filename") - subfolder = item.get("subfolder", "") - type_ = item.get("type", "output") - if not filename: - continue - try: - dir_path = folder_paths.get_directory_by_type(type_) # type: ignore - except Exception: - continue - if subfolder: - dir_path = os.path.join(dir_path, subfolder) - files.append(os.path.join(dir_path, filename)) - return files - - -def _is_missing_meta(meta: Dict[str, Any]) -> bool: - """ - Determine if metadata is missing (None) or effectively absent. - Treat explicit rating=0/tags=[] as present unless both are empty and no source hints. - """ - if not isinstance(meta, dict): - return True - rating = meta.get("rating") - tags = meta.get("tags") - if rating is None or tags is None: - return True - if rating == 0 and (tags == [] or tags is None): - # consider missing only if nothing else in meta hints at a source - return True - return False - - -def _apply_default_metadata(file_path: str): - """ - Apply default rating/tags to a generated file (video/image) if missing. - Idempotent: never overwrites user-set rating/tags. - """ - try: - if not str(os.environ.get("MJR_PRIME_METADATA", "0")).lower() in ("1", "true", "yes", "on"): - return - except Exception: - return - - filename_lower = os.path.basename(file_path).lower() - kind = classify_ext(filename_lower) - if kind not in ("video", "image"): - return - - if not os.path.exists(file_path): - return - - try: - existing = get_system_metadata(file_path) or load_metadata(file_path) or {} - if not _is_missing_meta(existing): - return - - current_rating = existing.get("rating") - current_tags = existing.get("tags") - default_rating = 0 if current_rating is None else current_rating - default_tags = ["ComfyUI"] if current_tags is None else current_tags - - try: - set_windows_metadata(file_path, default_rating, default_tags) - except Exception: - pass - - if _get_exiftool_path(): - try: - set_exif_metadata(file_path, default_rating, default_tags) - except Exception: - pass - except Exception: - pass - - -def _get_prompt_entry(prompt_id: str) -> Optional[Dict[str, Any]]: - """ - Retrieve prompt entry from ComfyUI history/queue. - - CRITICAL FIX: History is only populated AFTER task_done, but on_executed runs DURING execution. - Solution: Try history first, then fall back to current running queue. - - Returns dict with 'prompt' and potentially 'workflow' if available. - Returns None if not found or on error. - """ - try: - if not hasattr(PromptServer, 'instance'): - return None - - prompt_server = PromptServer.instance - if not hasattr(prompt_server, 'prompt_queue'): - return None - - prompt_queue = prompt_server.prompt_queue - - # STEP 1: Try history (this works for completed prompts) - try: - history = prompt_queue.get_history(prompt_id=prompt_id) - if history and prompt_id in history: - entry = history[prompt_id] - if isinstance(entry, dict): - result = {} - - # Extract prompt graph - if "prompt" in entry and isinstance(entry["prompt"], dict): - result["prompt"] = entry["prompt"] - - # Extract workflow from extra_pnginfo or direct workflow field - try: - extra_pnginfo = entry.get("extra_data", {}).get("extra_pnginfo", {}) - if isinstance(extra_pnginfo, dict) and "workflow" in extra_pnginfo: - result["workflow"] = extra_pnginfo["workflow"] - elif "workflow" in entry: - result["workflow"] = entry["workflow"] - except: - pass - - if result: - result["source"] = "history" - return result - except: - pass - - # STEP 2: Try current queue (THIS IS THE FIX - works during execution) - # During on_executed(), the prompt is still in the running queue, not yet in history - try: - running, pending = prompt_queue.get_current_queue() - - # Search in running queue first (most likely location during on_executed) - for queue_item in running: - # Queue item format: (number, prompt_id, prompt_dict, extra_data, outputs_to_execute) - # We need index 1 (prompt_id), 2 (prompt_dict), 3 (extra_data) - if len(queue_item) >= 4 and queue_item[1] == prompt_id: - result = {} - - # Extract prompt graph (index 2) - if len(queue_item) > 2 and isinstance(queue_item[2], dict): - result["prompt"] = queue_item[2] - - # Extract workflow from extra_data (index 3) - if len(queue_item) > 3 and isinstance(queue_item[3], dict): - extra_data = queue_item[3] - extra_pnginfo = extra_data.get("extra_pnginfo", {}) - if isinstance(extra_pnginfo, dict) and "workflow" in extra_pnginfo: - result["workflow"] = extra_pnginfo["workflow"] - elif "workflow" in extra_data: - result["workflow"] = extra_data["workflow"] - - if result: - result["source"] = "queue_running" - return result - - # Also check pending queue (less likely but possible) - for queue_item in pending: - if len(queue_item) >= 4 and queue_item[1] == prompt_id: - result = {} - - if len(queue_item) > 2 and isinstance(queue_item[2], dict): - result["prompt"] = queue_item[2] - - if len(queue_item) > 3 and isinstance(queue_item[3], dict): - extra_data = queue_item[3] - extra_pnginfo = extra_data.get("extra_pnginfo", {}) - if isinstance(extra_pnginfo, dict) and "workflow" in extra_pnginfo: - result["workflow"] = extra_pnginfo["workflow"] - elif "workflow" in extra_data: - result["workflow"] = extra_data["workflow"] - - if result: - result["source"] = "queue_pending" - return result - - except Exception as e: - log.debug(f"📁🔍 [Majoor] Queue lookup failed: {e}") - - return None - - except Exception as e: - log.debug(f"📁🔍 [Majoor] Failed to get prompt entry for {prompt_id}: {e}") - return None - - -def _attach_generation_sidecar(prompt_id: str, file_path: str): - """ - Attach generation metadata to file via sidecar. - For MP4/WEBP/etc that don't embed metadata, this creates a .mjr.json sidecar - with prompt_id, prompt graph, and workflow if available. - - This enables metadata recovery even after ComfyUI restart. - """ - try: - from pathlib import Path - - # Check if we should create sidecars - # MJR_FORCE_SIDECAR=1 forces sidecar creation even on Windows - force_sidecar = os.environ.get("MJR_FORCE_SIDECAR", "").lower() in ("1", "true", "yes", "on") - debug_meta = os.environ.get("MJR_DEBUG_METADATA", "").lower() in ("1", "true", "yes", "on") - - ext = Path(file_path).suffix.lower() - - # Priority for sidecar creation: - # 1. Always for MP4/video files (they rarely embed metadata) - # 2. Always for WEBP (often lacks embedded workflow, needs persistent metadata) - # 3. For JPG/JPEG if MJR_FORCE_SIDECAR=1 - # 4. Skip PNG (has embedded metadata) - create_sidecar = False - if ext in {".mp4", ".mov", ".webm", ".mkv", ".m4v", ".avi"}: - create_sidecar = True # Always for video - elif ext == ".webp": - create_sidecar = True # ALWAYS for WEBP (CRITICAL FIX) - elif ext in {".jpg", ".jpeg"} and force_sidecar: - create_sidecar = True # Optional for JPG - - if not create_sidecar: - return - - # Load existing sidecar - existing_meta = load_metadata(file_path) or {} - - # Don't overwrite if sidecar already has valid prompt/workflow - if existing_meta.get("prompt") and existing_meta.get("workflow"): - if debug_meta: - log.debug(f"📁🔍 [Majoor] Sidecar already complete for {Path(file_path).name}, skipping") - return - - # Get prompt entry from queue/history - entry = _get_prompt_entry(prompt_id) - if not entry: - if debug_meta: - log.debug(f"đŸ“âš ī¸ [Majoor] No queue/history entry found for {prompt_id}, creating minimal sidecar") - # Still create sidecar with prompt_id for future history lookup - meta = existing_meta.copy() - meta["prompt_id"] = prompt_id - meta["meta_version"] = 1 - meta["source"] = "unknown" - save_metadata(file_path, meta, force=True) - return - - # Merge with existing metadata - meta = existing_meta.copy() - meta["prompt_id"] = prompt_id - meta["meta_version"] = 1 - meta["source"] = entry.get("source", "unknown") # "queue_running", "history", "queue_pending" - - if "prompt" in entry: - meta["prompt"] = entry["prompt"] - if "workflow" in entry: - meta["workflow"] = entry["workflow"] - - # Save sidecar (force=True to ensure it's written even on Windows) - save_metadata(file_path, meta, force=True) - - if debug_meta: - log.debug(f"📁✅ [Majoor] Created sidecar for {Path(file_path).name}") - log.debug(f"📁🔍 [Majoor] - Source: {meta.get('source')}") - log.debug(f"📁🔍 [Majoor] - Has prompt: {('prompt' in meta)}") - log.debug(f"📁🔍 [Majoor] - Has workflow: {('workflow' in meta)}") - - except Exception as e: - log.debug(f"📁❌ [Majoor] Failed to attach sidecar for {file_path}: {e}") - - -def on_executed(prompt_id, node_id, output_data): - """ - Hook invoked by PromptServer after a node execution; primes metadata on generated files. - Creates sidecars for MP4/WEBP with prompt/workflow data to enable metadata recovery. - """ - if not output_data: - return - try: - files = _iter_generated_files(output_data) - pushed: List[Dict[str, Any]] = [] - for fp in files: - _apply_default_metadata(fp) - - # Attach generation sidecar for MP4/WEBP (replaces _store_prompt_id) - # This stores the full prompt graph + workflow, not just prompt_id - if prompt_id: - _attach_generation_sidecar(prompt_id, fp) - - try: - stat = os.stat(fp) - pushed.append( - { - "filename": os.path.basename(fp), - "subfolder": os.path.dirname(fp).replace(folder_paths.get_output_directory(), "").lstrip("\\/"), - "mtime": stat.st_mtime, - } - ) - except Exception: - continue - - if pushed: - try: - PromptServer.instance.send_sync("mjr_new_files", {"files": pushed}) - except Exception: - pass - except Exception as e: - log.exception("📁❌ [Majoor] hook error: %s", e) - - -# Register hook once (only if supported) -try: - if hasattr(PromptServer.instance, "add_on_executed"): - PromptServer.instance.add_on_executed(on_executed) - else: - log.warning( - "[Majoor] add_on_executed not available on PromptServer (legacy frontend); hook disabled." - ) -except Exception as e: - log.exception("[Majoor] failed to register on_executed hook: %s", e) diff --git a/server/index_db.py b/server/index_db.py deleted file mode 100644 index 67f3ea2c..00000000 --- a/server/index_db.py +++ /dev/null @@ -1,1431 +0,0 @@ -""" -SQLite-based asset index for fast search and metadata queries. - -Provides: -- Thread-safe database access with WAL mode -- FTS5 full-text search (with fallback for systems without FTS5) -- Incremental indexing (only parse changed files) -- Background reindexing with progress tracking -""" - -import os -import sqlite3 -import json -import threading -import time -import hashlib -import atexit -from typing import Optional, Dict, List, Callable, Any -from pathlib import Path -from .config import OUTPUT_ROOT, INDEX_DIR, INDEX_DB, INDEX_MODE -from .utils import ( - load_metadata, - get_system_metadata, - classify_ext, - _get_mtime_safe, - IMAGE_EXTS, - VIDEO_EXTS, -) -from .logger import get_logger -from .workflow_hash import hash_workflow_robust -from .metadata_generation import has_generation_workflow - -log = get_logger(__name__) - -# ===== Constants ===== -DB_SCHEMA_VERSION = 3 - -# ===== Threading and Globals ===== - -# Thread-local storage for DB connections (one per thread) -_thread_local = threading.local() - -# Lock for thread-safe initialization -_init_lock = threading.Lock() - -# Global index status -_index_status = { - "status": "idle", # idle|indexing|error - "last_scan": None, - "total_assets": 0, - "indexed_assets": 0, - "backlog": 0, - "errors": [], - "fts_available": False, - "json1_available": False, - "freshness": "unknown", # up_to_date|stale|unknown -} -_status_lock = threading.Lock() - -# Indexing worker state -_indexing_thread: Optional[threading.Thread] = None -_stop_indexing = threading.Event() -_JSON1_AVAILABLE: Optional[bool] = None - - -# ===== Database Initialization ===== - -def _configure_db(conn: sqlite3.Connection) -> None: - """ - Configure database connection with optimal settings. - Enables WAL mode for better concurrency. - """ - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") - conn.execute("PRAGMA temp_store=MEMORY") - conn.execute("PRAGMA cache_size=-64000") # 64MB cache - -def init_db(db_path: Optional[str] = None) -> None: - """ - Initialize the SQLite database with schema and indexes. - Creates the database file and tables if they don't exist. - """ - if db_path is None: - db_path = INDEX_DB - - # Ensure directory exists - os.makedirs(os.path.dirname(db_path), exist_ok=True) - - with _init_lock: - conn = sqlite3.connect(db_path, check_same_thread=False) - conn.row_factory = sqlite3.Row - - try: - _configure_db(conn) - _run_migrations(conn) - conn.commit() - log.info(f"📁✅ [Majoor] Index database initialized at {db_path}") - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to initialize database: {e}") - raise - finally: - conn.close() - -def _run_migrations(conn: sqlite3.Connection) -> None: - """ - Apply database migrations sequentially. - """ - current_version = conn.execute("PRAGMA user_version").fetchone()[0] - log.info(f"📁 [Majoor] DB schema version: {current_version}") - - if current_version >= DB_SCHEMA_VERSION: - # Also run feature detection on every startup, not just on migration - _detect_fts5(conn) - _detect_json1(conn) - return - - log.info(f"📁🔄 [Majoor] Migrating database from v{current_version} to v{DB_SCHEMA_VERSION}...") - - for v in range(current_version, DB_SCHEMA_VERSION): - version_to_apply = v + 1 - log.info(f"📁 [Majoor] Applying migration v{version_to_apply}...") - try: - _MIGRATIONS[version_to_apply](conn) - conn.execute(f"PRAGMA user_version = {version_to_apply}") - conn.commit() - log.info(f"📁✅ [Majoor] Migration v{version_to_apply} applied successfully.") - except Exception as e: - log.error(f"📁❌ [Majoor] FAILED to apply migration v{version_to_apply}: {e}") - conn.rollback() - raise - -def _migrate_v1(conn: sqlite3.Connection) -> None: - - """Schema version 1: Initial creation of assets table and FTS.""" - - # Main assets table - - conn.execute(""" - - CREATE TABLE IF NOT EXISTS assets ( - - -- Identity (stable key) - - id TEXT PRIMARY KEY, - - type TEXT NOT NULL, - - subfolder TEXT, - - filename TEXT NOT NULL, - - ext TEXT, - - abs_path TEXT, - - - - -- File metadata - - mtime INTEGER, - - size INTEGER, - - kind TEXT, - - - - -- Media properties - - width INTEGER, - - height INTEGER, - - duration_ms INTEGER, - - - - -- User metadata - - rating REAL, - - tags_json TEXT, - - notes TEXT, - - - - -- Generation params - - prompt TEXT, - - negative TEXT, - - model TEXT, - - sampler TEXT, - - steps INTEGER, - - cfg REAL, - - seed TEXT, - - - - -- Workflow tracking - - has_workflow INTEGER DEFAULT 0, - - workflow_hash TEXT, - - - - -- Full metadata dump (fallback/archive) - - meta_json TEXT, - - - - -- Timestamps - - created_at INTEGER DEFAULT (strftime('%s', 'now')), - - updated_at INTEGER DEFAULT (strftime('%s', 'now')) - - ) - - """) - - - - # Indexes for common queries - - conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_mtime ON assets(mtime)") - - conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_kind ON assets(kind)") - - conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_rating ON assets(rating)") - - conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_has_workflow ON assets(has_workflow)") - - conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_workflow_hash ON assets(workflow_hash)") - - conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_type_subfolder ON assets(type, subfolder)") - - conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_filename ON assets(filename)") - - - - # Create FTS table only if FTS5 is available - - if not _is_fts5_available(conn): - - return - - - - try: - - conn.execute(""" - - CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5( - - id UNINDEXED, - - filename, - - subfolder, - - prompt, - - negative, - - notes, - - model, - - sampler, - - content='assets', - - content_rowid='rowid' - - ) - - """) - - # Triggers will be added in v2 - - except Exception as e: - - log.warning(f"đŸ“âš ī¸ [Majoor] Failed to create FTS5 table in v1: {e}") - - - - # Feature detection should run as part of the migration - - _detect_json1(conn) - - - -def _migrate_v2(conn: sqlite3.Connection) -> None: - - """Schema version 2: Add tags_text to FTS table and add triggers.""" - - if not _is_fts5_available(conn): - - log.warning("đŸ“âš ī¸ [Majoor] FTS5 not available, skipping migration v2.") - - return - - - - # Check if FTS table needs update - - try: - - rows = conn.execute("PRAGMA table_info(assets_fts)").fetchall() - - cols = [row[1] for row in rows] - - except sqlite3.OperationalError: - - cols = [] # FTS table might not exist if v1 failed - - - - if "tags_text" in cols: - - log.info("📁 [Majoor] FTS schema already up to date, skipping v2 rebuild.") - - else: - - log.warning("đŸ“âš ī¸ [Majoor] FTS schema outdated, rebuilding index for v2.") - - conn.execute("DROP TABLE IF EXISTS assets_fts") - - conn.execute(""" - - CREATE VIRTUAL TABLE assets_fts USING fts5( - - id UNINDEXED, - - filename, - - subfolder, - - prompt, - - negative, - - tags_text, - - notes, - - model, - - sampler, - - content='assets', - - content_rowid='rowid' - - ) - - """) - - - - # Create/update triggers - - conn.execute("DROP TRIGGER IF EXISTS assets_fts_insert") - - conn.execute(""" - - CREATE TRIGGER assets_fts_insert AFTER INSERT ON assets BEGIN - - INSERT INTO assets_fts(rowid, id, filename, subfolder, prompt, negative, tags_text, notes, model, sampler) - - VALUES (new.rowid, new.id, new.filename, new.subfolder, new.prompt, new.negative, - - replace(replace(new.tags_json, '["', ''), '"]', ''), new.notes, new.model, new.sampler); - - END - - """) - - - - conn.execute("DROP TRIGGER IF EXISTS assets_fts_update") - - conn.execute(""" - - CREATE TRIGGER assets_fts_update AFTER UPDATE ON assets BEGIN - - UPDATE assets_fts SET - - filename = new.filename, - - subfolder = new.subfolder, - - prompt = new.prompt, - - negative = new.negative, - - tags_text = replace(replace(new.tags_json, '["', ''), '"]', ''), - - notes = new.notes, - - model = new.model, - - sampler = new.sampler - - WHERE rowid = new.rowid; - - END - - """) - - - - conn.execute("DROP TRIGGER IF EXISTS assets_fts_delete") - - conn.execute(""" - - CREATE TRIGGER assets_fts_delete AFTER DELETE ON assets BEGIN - - DELETE FROM assets_fts WHERE rowid = old.rowid; - - END - - """) - - - - # Re-populate FTS table - - try: - - conn.execute("DELETE FROM assets_fts") - - conn.execute(""" - - INSERT INTO assets_fts(rowid, id, filename, subfolder, prompt, negative, tags_text, notes, model, sampler) - - SELECT rowid, id, filename, subfolder, prompt, negative, - - replace(replace(tags_json, '["', ''), '"]', ''), notes, model, sampler - - FROM assets - - """) - - log.info("📁 [Majoor] FTS index successfully repopulated.") - - except Exception as e: - - log.warning(f"đŸ“âš ī¸ [Majoor] Failed to repopulate FTS index: {e}") - - - - - -def _migrate_v3(conn: sqlite3.Connection) -> None: - - - - - - """Schema version 3: Add indexing_errors table.""" - - - - - - conn.execute(""" - - - - - - CREATE TABLE IF NOT EXISTS indexing_errors ( - - - - - - path TEXT PRIMARY KEY, - - - - - - reason TEXT, - - - - - - details TEXT, - - - - - - last_attempt_at INTEGER NOT NULL - - - - - - ) - - - - - - """) - - - - - - conn.execute("CREATE INDEX IF NOT EXISTS idx_indexing_errors_last_attempt ON indexing_errors(last_attempt_at)") - - - - - - - - - - - - - - - - - -_MIGRATIONS = { - - - - - - 1: _migrate_v1, - - - - - - 2: _migrate_v2, - - - - - - 3: _migrate_v3, - - - - - -} - - - - - - - - - -def _is_fts5_available(conn: sqlite3.Connection) -> bool: - - """Check if FTS5 is compiled in.""" - - cursor = conn.execute("PRAGMA compile_options") - - compile_options = [row[0] for row in cursor.fetchall()] - - return any("FTS5" in opt for opt in compile_options) - - - -def _detect_fts5(conn: sqlite3.Connection) -> None: - - """Detect FTS5 availability and update global status.""" - - global _index_status - - fts5_available = _is_fts5_available(conn) - - with _status_lock: - - if _index_status["fts_available"] != fts5_available: - - log.info(f"📁 [Majoor] FTS5 availability: {fts5_available}") - - _index_status["fts_available"] = fts5_available - - - - -def _detect_json1(conn: sqlite3.Connection) -> None: - """Detect JSON1 availability for efficient tag filtering.""" - global _JSON1_AVAILABLE - try: - conn.execute("SELECT json('[]')") - _JSON1_AVAILABLE = True - except Exception: - _JSON1_AVAILABLE = False - with _status_lock: - _index_status["json1_available"] = bool(_JSON1_AVAILABLE) - - -def get_db() -> sqlite3.Connection: - """ - Get a thread-safe database connection. - Each thread gets its own connection from thread-local storage. - """ - if not hasattr(_thread_local, "connection"): - _thread_local.connection = sqlite3.connect( - INDEX_DB, - check_same_thread=False, - timeout=30.0 # 30 second timeout to prevent deadlocks - ) - _thread_local.connection.row_factory = sqlite3.Row - _configure_db(_thread_local.connection) - - return _thread_local.connection - - -def _cleanup_connections(): - """Close all thread-local connections and stop watcher on exit.""" - from . import watcher - watcher.stop_watcher() - if hasattr(_thread_local, "connection"): - try: - _thread_local.connection.close() - log.debug("📁🔍 [Majoor] Database connection closed") - except Exception as e: - log.debug(f"📁🔍 [Majoor] Error closing connection: {e}") - - -# Register cleanup handler -atexit.register(_cleanup_connections) - - -# ===== Asset ID Generation ===== - -def _make_asset_id(type_name: str, subfolder: str, filename: str) -> str: - """ - Generate stable asset ID from type|subfolder|filename. - Normalized to handle path separators consistently. - """ - subfolder_norm = (subfolder or "").replace("\\", "/").strip("/") - return f"{type_name}|{subfolder_norm}|{filename}" - - -def _parse_asset_id(asset_id: str) -> Dict[str, str]: - """Parse asset ID back into components.""" - parts = asset_id.split("|", 2) - if len(parts) != 3: - return {"type": "output", "subfolder": "", "filename": asset_id} - return {"type": parts[0], "subfolder": parts[1], "filename": parts[2]} - - -# ===== CRUD Operations ===== - -def upsert_asset(record: Dict[str, Any]) -> None: - """ - Insert or update an asset record. - Record should contain at minimum: id, filename, type, mtime, size - """ - conn = get_db() - - # Ensure tags_json is properly serialized - if "tags" in record and "tags_json" not in record: - record["tags_json"] = json.dumps(record["tags"], ensure_ascii=False) - # Remove non-schema keys to avoid insert errors - if "tags" in record: - record.pop("tags", None) - - # Build SQL dynamically based on provided fields - fields = list(record.keys()) - placeholders = ", ".join(["?"] * len(fields)) - update_clause = ", ".join([f"{f} = excluded.{f}" for f in fields if f != "id"]) - - sql = f""" - INSERT INTO assets ({", ".join(fields)}) - VALUES ({placeholders}) - ON CONFLICT(id) DO UPDATE SET - {update_clause}, - updated_at = strftime('%s', 'now') - """ - - try: - conn.execute(sql, [record[f] for f in fields]) - conn.commit() - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to upsert asset {record.get('id')}: {e}") - conn.rollback() - raise - - -def delete_asset(asset_id: str) -> None: - """Delete an asset and any associated indexing errors from the index.""" - conn = get_db() - try: - # Also clear any potential indexing errors for this path - parsed_id = _parse_asset_id(asset_id) - if parsed_id and parsed_id.get("filename"): - # Reconstruct path to be safe - from .config import OUTPUT_ROOT - from pathlib import Path - path = Path(OUTPUT_ROOT) / parsed_id["subfolder"] / parsed_id["filename"] - clear_indexing_error(str(path.resolve())) - - conn.execute("DELETE FROM assets WHERE id = ?", (asset_id,)) - conn.commit() - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to delete asset {asset_id}: {e}") - conn.rollback() - raise - - -def get_asset(asset_id: str) -> Optional[Dict[str, Any]]: - """Get a single asset by ID.""" - conn = get_db() - cursor = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)) - row = cursor.fetchone() - if row is None: - return None - return dict(row) - - -# ===== Query Operations ===== - -def _normalize_tags_filter(value: Optional[Any]) -> list[str]: - if value is None: - return [] - if isinstance(value, (list, tuple, set)): - raw_tags = list(value) - else: - raw_tags = str(value).split(",") - - normalized = [] - for tag in raw_tags: - if tag is None: - continue - tag_str = str(tag).strip() - if not tag_str: - continue - if "\x00" in tag_str: - continue - if any(seq in tag_str for seq in (";", "--", "/*", "*/")): - continue - normalized.append(tag_str) - return normalized[:50] # limit to prevent overly large queries - - -def query_assets( - filters: Optional[Dict[str, Any]] = None, - q: Optional[str] = None, - sort: str = "mtime_desc", - limit: int = 100, - offset: int = 0 -) -> Dict[str, Any]: - """ - Query assets with filters and full-text search. - - Args: - filters: Dict with keys: kind, tags, rating_min, has_workflow, workflow_hash, type, subfolder - q: Full-text search query (requires FTS5) - sort: Sort order (mtime_desc, mtime_asc, rating_desc, rating_asc, filename_asc) - limit: Max results to return - offset: Pagination offset - - Returns: - Dict with keys: assets (list), total (int), limit, offset - """ - conn = get_db() - filters = filters or {} - - # Build WHERE clause - where_clauses = [] - params = [] - - # FTS search (if available and query provided) - use_fts = q and _index_status.get("fts_available", False) - - if use_fts: - # Join with FTS table for full-text search - base_query = """ - SELECT assets.*, rank - FROM assets - JOIN assets_fts ON assets.rowid = assets_fts.rowid - WHERE assets_fts MATCH ? - """ - count_query = """ - SELECT COUNT(*) - FROM assets - JOIN assets_fts ON assets.rowid = assets_fts.rowid - WHERE assets_fts MATCH ? - """ - params.append(q) - else: - base_query = "SELECT * FROM assets WHERE 1=1" - count_query = "SELECT COUNT(*) FROM assets WHERE 1=1" - - # Fallback simple search on filename - if q: - where_clauses.append("(filename LIKE ? OR prompt LIKE ?)") - params.extend([f"%{q}%", f"%{q}%"]) - - # Apply filters - if filters.get("kind"): - kind_filter = filters["kind"] - if isinstance(kind_filter, (list, tuple, set)): - kinds = [k for k in kind_filter if k] - if kinds: - placeholders = ", ".join(["?"] * len(kinds)) - where_clauses.append(f"kind IN ({placeholders})") - params.extend(kinds) - else: - where_clauses.append("kind = ?") - params.append(kind_filter) - - if filters.get("type"): - where_clauses.append("type = ?") - params.append(filters["type"]) - - if filters.get("subfolder") is not None: - where_clauses.append("subfolder = ?") - params.append(filters["subfolder"]) - - if filters.get("rating_min") is not None: - where_clauses.append("rating >= ?") - params.append(float(filters["rating_min"])) - - if filters.get("has_workflow") == 1: - where_clauses.append("has_workflow = 1") - elif filters.get("has_workflow") == 0: - where_clauses.append("has_workflow = 0") - - if filters.get("workflow_hash"): - where_clauses.append("workflow_hash = ?") - params.append(filters["workflow_hash"]) - - # Tags filter (AND logic for multiple tags) - tag_list = _normalize_tags_filter(filters.get("tags")) - if tag_list: - for tag in tag_list: - if _JSON1_AVAILABLE: - where_clauses.append( - "EXISTS (SELECT 1 FROM json_each(assets.tags_json) WHERE value = ?)" - ) - params.append(tag) - else: - where_clauses.append("tags_json LIKE ?") - params.append(f'%"{tag}"%') - - # Add WHERE clauses - if where_clauses: - clause = " AND " + " AND ".join(where_clauses) - base_query += clause - count_query += clause - - # Sort order - sort_map = { - "mtime_desc": "mtime DESC", - "mtime_asc": "mtime ASC", - "rating_desc": "rating DESC, mtime DESC", - "rating_asc": "rating ASC, mtime DESC", - "filename_asc": "filename ASC", - "filename_desc": "filename DESC", - } - order_by = sort_map.get(sort, "mtime DESC") - - if use_fts: - # For FTS, use BM25 ranking - order_by = "rank, " + order_by - - base_query += f" ORDER BY {order_by}" - - # Count total (without limit) - try: - cursor = conn.execute(count_query, params) - total = cursor.fetchone()[0] - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to count assets: {e}") - total = 0 - - # Paginate - base_query += " LIMIT ? OFFSET ?" - params.extend([limit, offset]) - - try: - cursor = conn.execute(base_query, params) - rows = cursor.fetchall() - assets = [_row_to_asset(row) for row in rows] - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to query assets: {e}") - assets = [] - - return { - "assets": assets, - "total": total, - "limit": limit, - "offset": offset, - } - - -def _row_to_asset(row: sqlite3.Row) -> Dict[str, Any]: - """Convert database row to asset dict.""" - asset = dict(row) - - # Parse tags_json back to list - if asset.get("tags_json"): - try: - asset["tags"] = json.loads(asset["tags_json"]) - except: - asset["tags"] = [] - else: - asset["tags"] = [] - - # Remove internal fields from API response - asset.pop("tags_json", None) - asset.pop("meta_json", None) - - return asset - - -def log_indexing_error(path: str, reason: str, details: str) -> None: - """Log an error into the indexing_errors table.""" - conn = get_db() - try: - conn.execute( - """ - INSERT INTO indexing_errors (path, reason, details, last_attempt_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(path) DO UPDATE SET - reason = excluded.reason, - details = excluded.details, - last_attempt_at = excluded.last_attempt_at - """, - (path, reason, details, int(time.time())) - ) - conn.commit() - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to log indexing error for {path}: {e}") - conn.rollback() - -def clear_indexing_error(path: str) -> None: - """Remove a resolved error from the indexing_errors table.""" - conn = get_db() - try: - conn.execute("DELETE FROM indexing_errors WHERE path = ?", (path,)) - conn.commit() - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to clear indexing error for {path}: {e}") - conn.rollback() - - -# ===== Indexing Operations ===== - -def _should_reindex(file_path: str, db_record: Optional[Dict[str, Any]]) -> bool: - """ - Check if file changed since last index. - Returns True if file should be reindexed. - """ - if db_record is None: - return True # New file - - stat = os.stat(file_path) - mtime_ms = int(stat.st_mtime * 1000) - size = stat.st_size - - # Compare mtime and size - if mtime_ms != db_record.get("mtime"): - return True - if size != db_record.get("size"): - return True - - return False # Skip, unchanged - - -def _extract_asset_metadata(file_path: str, type_name: str, subfolder: str, filename: str) -> Optional[Dict[str, Any]]: - """ - Extract metadata from a file for indexing. - Returns a dict of metadata on success, or None on failure. - Errors are logged to the indexing_errors table. - """ - try: - stat = os.stat(file_path) - ext = os.path.splitext(filename)[1].lower() - kind = classify_ext(filename.lower()) - except FileNotFoundError: - # File might have been deleted between scan and processing - log.debug(f"📁🔍 [Majoor] File not found during metadata extraction: {file_path}") - return None - except Exception as e: - log_indexing_error(file_path, "file_stat", f"{type(e).__name__}: {e}") - return None - - # Base record - record = { - "id": _make_asset_id(type_name, subfolder, filename), - "type": type_name, - "subfolder": subfolder or "", - "filename": filename, - "ext": ext, - "abs_path": file_path, - "mtime": int(stat.st_mtime * 1000), - "size": stat.st_size, - "kind": kind, - } - - # Load metadata (system metadata + sidecar) - try: - # Use robust workflow detection first - record["has_workflow"] = 1 if has_generation_workflow(file_path) else 0 - - sys_meta = get_system_metadata(file_path) or {} - sidecar_meta = load_metadata(file_path) or {} - - # Merge metadata (prioritize system metadata for rating/tags) - record["rating"] = sys_meta.get("rating", sidecar_meta.get("rating", 0)) - record["tags"] = sys_meta.get("tags", sidecar_meta.get("tags", [])) - - # Sidecar-only fields - record["notes"] = sidecar_meta.get("notes", "") - # Simplified prompt extraction - prompt_text = "" - if isinstance(sidecar_meta.get("prompt"), dict): - prompt_text = sidecar_meta.get("prompt", {}).get("text", "") - record["prompt"] = prompt_text - record["negative"] = sidecar_meta.get("prompt", {}).get("negative", "") - - # Generation params (from ComfyUI workflow/prompt) - workflow = sidecar_meta.get("workflow") - if workflow: - # If has_workflow wasn't detected but we have a sidecar, mark it. - if record["has_workflow"] == 0: - record["has_workflow"] = 1 - # Calculate workflow hash - record["workflow_hash"] = hash_workflow_robust(workflow) - else: - # No sidecar workflow, hash is None - record["workflow_hash"] = None - - # Extract common generation params from prompt - prompt_data = sidecar_meta.get("prompt", {}) - if isinstance(prompt_data, dict): - # Try to extract from common node types - for node_data in prompt_data.values(): - if isinstance(node_data, dict): - inputs = node_data.get("inputs", {}) - if "ckpt_name" in inputs: - record["model"] = inputs["ckpt_name"] - if "sampler_name" in inputs: - record["sampler"] = inputs["sampler_name"] - if "steps" in inputs: - record["steps"] = inputs["steps"] - if "cfg" in inputs: - record["cfg"] = inputs["cfg"] - if "seed" in inputs: - record["seed"] = str(inputs["seed"]) - - # Media properties (basic extraction, can be extended with PIL/ffmpeg) - if kind == "image" and ext in IMAGE_EXTS: - try: - # Lazy import PIL - from PIL import Image, UnidentifiedImageError - with Image.open(file_path) as img: - record["width"], record["height"] = img.size - except UnidentifiedImageError: - log.debug(f"Could not identify image file {file_path}") - except Exception: - # This can fail for many reasons (corrupt file, etc.), don't log as major error - pass - - # Store full metadata as JSON (optional, for debugging) - # record["meta_json"] = json.dumps({**sys_meta, **sidecar_meta}, ensure_ascii=False) - - # If we reached here, any previous errors for this path are resolved - clear_indexing_error(file_path) - return record - - except Exception as e: - log.warning(f"đŸ“âš ī¸ [Majoor] Failed to extract metadata for {file_path}: {e}") - log_indexing_error(file_path, "metadata_extraction", f"{type(e).__name__}: {e}") - return None - - -def reindex_paths(paths: List[str], progress_cb: Optional[Callable[[int, int], None]] = None) -> Dict[str, Any]: - """ - Reindex specific file paths. - - Args: - paths: List of absolute file paths to reindex - progress_cb: Optional callback(current, total) for progress updates - - Returns: - Dict with keys: indexed (int), skipped (int), errors (list) - """ - indexed = 0 - skipped = 0 - errors = [] - - total = len(paths) - - for i, file_path in enumerate(paths): - try: - # Determine type/subfolder from path - if not os.path.exists(file_path): - errors.append(f"File not found: {file_path}") - continue - - # Guess type from path (assumes files are in OUTPUT_ROOT) - rel_path = os.path.relpath(file_path, OUTPUT_ROOT) - parts = Path(rel_path).parts - - if len(parts) == 1: - type_name = "output" - subfolder = "" - filename = parts[0] - else: - type_name = parts[0] if parts[0] in ("output", "input", "temp") else "output" - subfolder = os.path.join(*parts[1:-1]) if len(parts) > 2 else parts[1] if len(parts) == 2 else "" - filename = parts[-1] - - asset_id = _make_asset_id(type_name, subfolder, filename) - - # Check if reindex needed - db_record = get_asset(asset_id) - if not _should_reindex(file_path, db_record): - skipped += 1 - if progress_cb: - progress_cb(i + 1, total) - continue - - # Extract metadata and upsert - record = _extract_asset_metadata(file_path, type_name, subfolder, filename) - if record: - upsert_asset(record) - indexed += 1 - else: - # Error was already logged by _extract_asset_metadata - errors.append(f"Failed to extract metadata for {file_path}") - continue - - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to index {file_path}: {e}") - errors.append(f"{file_path}: {e}") - log_indexing_error(file_path, "indexing_general", f"{type(e).__name__}: {e}") - - if progress_cb: - progress_cb(i + 1, total) - - return { - "indexed": indexed, - "skipped": skipped, - "errors": errors, - } - - -def reindex_all(progress_cb: Optional[Callable[[int, int], None]] = None) -> Dict[str, Any]: - """ - Reindex all assets in OUTPUT_ROOT. - Scans filesystem and updates index incrementally. - - Args: - progress_cb: Optional callback(current, total) for progress updates - - Returns: - Dict with keys: indexed (int), skipped (int), deleted (int), errors (list) - """ - global _index_status - - with _status_lock: - _index_status["status"] = "indexing" - _index_status["backlog"] = 0 - _index_status["errors"] = [] - - indexed = 0 - skipped = 0 - deleted = 0 - errors = [] - - try: - # Scan filesystem - file_paths = [] - for root, dirs, files in os.walk(OUTPUT_ROOT): - # Skip index directory - if "_mjr_index" in root: - continue - - for filename in files: - # Skip metadata sidecar files - if filename.endswith(".mjr.json"): - continue - - # Skip hidden files - if filename.startswith("."): - continue - - file_path = os.path.join(root, filename) - file_paths.append(file_path) - - total = len(file_paths) - - with _status_lock: - _index_status["backlog"] = total - - # Build set of existing asset IDs in DB - conn = get_db() - cursor = conn.execute("SELECT id, abs_path FROM assets") - db_assets = {row["id"]: row["abs_path"] for row in cursor.fetchall()} - - # Track seen IDs to detect deleted files - seen_ids = set() - - # Index each file - for i, file_path in enumerate(file_paths): - if _stop_indexing.is_set(): - log.info("📁 [Majoor] Indexing stopped by user") - break - - try: - # Determine type/subfolder from path - rel_path = os.path.relpath(file_path, OUTPUT_ROOT) - parts = Path(rel_path).parts - - if len(parts) == 1: - type_name = "output" - subfolder = "" - filename = parts[0] - else: - type_name = parts[0] if parts[0] in ("output", "input", "temp") else "output" - subfolder = os.path.join(*parts[1:-1]) if len(parts) > 2 else "" - filename = parts[-1] - - asset_id = _make_asset_id(type_name, subfolder, filename) - seen_ids.add(asset_id) - - # Check if reindex needed - db_record = get_asset(asset_id) - if not _should_reindex(file_path, db_record): - skipped += 1 - else: - # Extract metadata and upsert - record = _extract_asset_metadata(file_path, type_name, subfolder, filename) - if record: - upsert_asset(record) - indexed += 1 - else: - # Error already logged - pass - - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to index {file_path}: {e}") - errors.append(f"{file_path}: {e}") - log_indexing_error(file_path, "indexing_general", f"{type(e).__name__}: {e}") - - if progress_cb: - progress_cb(i + 1, total) - - with _status_lock: - _index_status["backlog"] = total - (i + 1) - - # Delete assets that no longer exist on filesystem - for asset_id, abs_path in db_assets.items(): - if asset_id not in seen_ids: - delete_asset(asset_id) - deleted += 1 - - with _status_lock: - _index_status["status"] = "idle" - _index_status["last_scan"] = time.time() - _index_status["backlog"] = 0 - _index_status["errors"] = errors - _index_status["freshness"] = "up_to_date" - - # Update counts - cursor = conn.execute("SELECT COUNT(*) FROM assets") - _index_status["total_assets"] = cursor.fetchone()[0] - _index_status["indexed_assets"] = _index_status["total_assets"] - - except Exception as e: - log.error(f"📁❌ [Majoor] Reindex failed: {e}") - with _status_lock: - _index_status["status"] = "error" - _index_status["errors"].append(str(e)) - - return { - "indexed": indexed, - "skipped": skipped, - "deleted": deleted, - "errors": errors, - } - - -def start_background_reindex() -> None: - """Start reindexing in a background thread.""" - global _indexing_thread - - if _indexing_thread and _indexing_thread.is_alive(): - log.warning("đŸ“âš ī¸ [Majoor] Indexing already in progress") - return - - _stop_indexing.clear() - - def worker(): - try: - reindex_all() - except Exception as e: - log.error(f"📁❌ [Majoor] Background reindex failed: {e}") - - _indexing_thread = threading.Thread(target=worker, daemon=True) - _indexing_thread.start() - - -def stop_background_reindex() -> None: - """Stop the background reindexing.""" - _stop_indexing.set() - - -# ===== Status Operations ===== - -def get_index_status() -> Dict[str, Any]: - """Get current index status.""" - with _status_lock: - status = dict(_index_status) - - # Format last_scan as ISO timestamp - if status["last_scan"]: - from datetime import datetime - status["last_scan"] = datetime.fromtimestamp(status["last_scan"]).isoformat() - - return status - - -def get_indexing_errors() -> List[Dict[str, Any]]: - """Get all records from the indexing_errors table.""" - conn = get_db() - try: - cursor = conn.execute("SELECT * FROM indexing_errors ORDER BY last_attempt_at DESC") - rows = cursor.fetchall() - return [dict(row) for row in rows] - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to get indexing errors: {e}") - return [] - - -def check_freshness() -> str: - """ - Check if index is fresh by comparing file count on disk vs DB. - Returns: "up_to_date", "stale", or "unknown" - """ - try: - # Quick filesystem count - file_count = 0 - for root, dirs, files in os.walk(OUTPUT_ROOT): - if "_mjr_index" in root: - continue - file_count += sum(1 for f in files if not f.endswith(".mjr.json") and not f.startswith(".")) - - # DB count - conn = get_db() - cursor = conn.execute("SELECT COUNT(*) FROM assets") - db_count = cursor.fetchone()[0] - - # Allow 5% difference for tolerance - diff = abs(file_count - db_count) - if diff == 0: - return "up_to_date" - elif diff / max(file_count, 1) < 0.05: - return "up_to_date" - else: - return "stale" - - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to check freshness: {e}") - return "unknown" - - -# ===== Initialization ===== - -def auto_init_index() -> None: - """ - Auto-initialize index after ComfyUI is ready. - Creates DB if it doesn't exist, checks freshness, and triggers reindex if needed. - """ - if INDEX_MODE == "filesystem": - log.info("[Majoor] Indexing disabled (filesystem mode)") - with _status_lock: - _index_status["status"] = "idle" - return - - try: - # Ensure index directory exists - os.makedirs(INDEX_DIR, exist_ok=True) - - # Initialize database and run migrations - init_db() - - # Check freshness to catch up on offline changes - freshness = check_freshness() - with _status_lock: - _index_status["freshness"] = freshness - - if freshness == "stale": - log.info("[Majoor] Index is stale, triggering one-time reindex.") - start_background_reindex() - - # Start the real-time file watcher - from . import watcher - watcher.start_watcher(OUTPUT_ROOT) - - except Exception as e: - log.error(f"📁❌ [Majoor] Failed to auto-initialize index: {e}") - - - -# Note: auto_init_index() should be called from __init__.py or routes.py after server starts -# Commenting out auto-init on import to avoid race condition -# auto_init_index() diff --git a/server/logger.py b/server/logger.py deleted file mode 100644 index 96703a2f..00000000 --- a/server/logger.py +++ /dev/null @@ -1,62 +0,0 @@ -import logging -import os -import threading -from typing import Optional - -_CONFIG_LOCK = threading.Lock() -_CONFIGURED = False - - -def _parse_level(val: Optional[str], default: int) -> int: - if not val: - return default - name = str(val).strip().upper() - return int(getattr(logging, name, default)) - - -def _configure_once() -> None: - """ - Configure the Majoor logger without interfering with ComfyUI's global logging. - - By default we rely on ComfyUI/root handlers. If `MJR_LOG_FILE` is set, we add a - dedicated FileHandler for persistent logs. - """ - global _CONFIGURED - if _CONFIGURED: - return - with _CONFIG_LOCK: - if _CONFIGURED: - return - - base = logging.getLogger("Majoor.AssetsManager") - base.setLevel(_parse_level(os.environ.get("MJR_LOG_LEVEL"), logging.INFO)) - - propagate_raw = (os.environ.get("MJR_LOG_PROPAGATE") or "").strip().lower() - if propagate_raw in ("0", "false", "no", "off"): - base.propagate = False - - log_file = (os.environ.get("MJR_LOG_FILE") or "").strip() - if log_file: - fmt = os.environ.get( - "MJR_LOG_FORMAT", - "%(asctime)s %(levelname)s %(name)s: %(message)s", - ) - datefmt = os.environ.get("MJR_LOG_DATEFMT", "%Y-%m-%d %H:%M:%S") - handler = logging.FileHandler(log_file, encoding="utf-8") - handler.setLevel(base.level) - handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt)) - base.addHandler(handler) - - _CONFIGURED = True - - -def get_logger(name: str) -> logging.Logger: - _configure_once() - suffix = (name or "").strip() - if not suffix: - return logging.getLogger("Majoor.AssetsManager") - return logging.getLogger(f"Majoor.AssetsManager.{suffix}") - - -__all__ = ["get_logger"] - diff --git a/server/metadata/__init__.py b/server/metadata/__init__.py deleted file mode 100644 index 7cb23fd7..00000000 --- a/server/metadata/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from .core import update_metadata, update_metadata_with_windows, get_metadata, deep_merge_metadata # noqa: F401 - -# Enhanced metadata extraction (new) -try: - from .enhanced_extraction import ( # noqa: F401 - extract_enhanced_metadata, - write_enhanced_metadata, - extract_workflow_hash, - get_metadata_source_info - ) - _ENHANCED_AVAILABLE = True -except ImportError: - _ENHANCED_AVAILABLE = False - -if _ENHANCED_AVAILABLE: - __all__ = [ - "update_metadata", - "update_metadata_with_windows", - "deep_merge_metadata", - "get_metadata", - "extract_enhanced_metadata", - "write_enhanced_metadata", - "extract_workflow_hash", - "get_metadata_source_info" - ] -else: - __all__ = ["update_metadata", "update_metadata_with_windows", "deep_merge_metadata", "get_metadata"] diff --git a/server/metadata/comfy_tracer.py b/server/metadata/comfy_tracer.py deleted file mode 100644 index 904455c6..00000000 --- a/server/metadata/comfy_tracer.py +++ /dev/null @@ -1,886 +0,0 @@ -""" -Recursive ComfyUI Workflow Node Tracer - -Traverses ComfyUI workflow graphs to extract metadata from complex/modular workflows. - -Handles: -- Output nodes (SaveImage, PreviewImage, VHS_VideoCombine, etc.) -- Upstream traversal via BFS/DFS -- Reroute nodes -- Custom nodes and wrappers -- Parameter extraction (positive/negative prompts, model, sampler, etc.) - -Usage: - from server.metadata.comfy_tracer import extract_comfy_params - - workflow_json = {...} # ComfyUI workflow - metadata = extract_comfy_params(workflow_json) - - print(metadata["positive_prompts"]) # ["portrait of a woman", ...] - print(metadata["negative_prompts"]) # ["low quality", ...] - print(metadata["model"]) # "sd_xl_base_1.0.safetensors" -""" - -import logging -import json -from pathlib import Path -from typing import Dict, List, Any, Set, Optional, Tuple -from collections import deque - -log = logging.getLogger(__name__) - - -def _load_custom_node_mappings() -> Dict[str, List[str]]: - """ - Load custom node type mappings from node_mapping.json. - - Returns: - Dict with keys like 'clip_text_encode_classes', 'conditioning_classes', etc. - Empty dict if config file doesn't exist or is invalid. - - Example node_mapping.json: - { - "_comment": "Custom node type mappings for ComfyUI metadata extraction", - "clip_text_encode_classes": ["MyCustomTextEncode", "SpecialPromptNode"], - "conditioning_classes": ["MyConditioningMix"], - "controlnet_classes": ["CustomControlNet"], - "lora_classes": ["PowerfulLoraLoader"], - "sampler_classes": ["MyAdvancedSampler"] - } - """ - config_path = Path(__file__).parent.parent / "node_mapping.json" - if not config_path.exists(): - return {} - - try: - with open(config_path, "r", encoding="utf-8") as f: - data = json.load(f) - # Remove comment fields (keys starting with _) - return {k: v for k, v in data.items() if not k.startswith("_")} - except (json.JSONDecodeError, OSError, ValueError) as e: - log.debug(f"[Majoor] Failed to load node_mapping.json: {e}") - return {} - - -# Load custom mappings once at module import -_custom_mappings = _load_custom_node_mappings() - - -# Default node type sets (can be extended via node_mapping.json) - -_DEFAULT_OUTPUT_NODE_TYPES = { - "SaveImage", - "PreviewImage", - "VHS_VideoCombine", - "SaveImageWebsocket", - "SaveAnimatedWEBP", - "SaveAnimatedPNG", - "ImageUploadS3", -} - -_DEFAULT_REROUTE_NODE_TYPES = { - "Reroute", - "RerouteNode", -} - -_DEFAULT_CLIP_TEXT_ENCODE_TYPES = { - # Core ComfyUI - "CLIPTextEncode", - "CLIPTextEncodeSDXL", - "CLIPTextEncodeSDXLRefiner", - - # SDXL/Flux/SD3 encoders - "DualCLIPTextEncode", - "FluxGuidance", - "SD3TextEncoder", - "T5TextEncode", - - # Advanced/Custom encoders - "BNK_CLIPTextEncodeAdvanced", - "AdvancedCLIPTextEncode", - "CLIPTextEncodeAdvanced", - "smZ CLIPTextEncode", - "CLIPSetLastLayer", - - # Text processing nodes - "TextConcatenate", - "StringConcatenate", - "Text_Multiline", - "StringLiteral", - "TextMultiline", - "text_multiline", - - # Prompt builders - "PromptBuilder", - "PromptComposer", - "DynamicPrompt", - "WildcardProcessor", - "CustomText", - "Text_Node", -} - -_DEFAULT_CONDITIONING_NODE_TYPES = { - "ConditioningAverage", - "ConditioningCombine", - "ConditioningConcat", - "ConditioningSetArea", - "ConditioningSetMask", - "ConditioningSetAreaPercentage", - "ConditioningSetAreaStrength", - "ConditioningSetTimestepRange", -} - -_DEFAULT_CONTROLNET_NODE_TYPES = { - "ControlNetApply", - "ControlNetApplyAdvanced", - "ControlNetApplySD3", - "ACN_AdvancedControlNetApply", -} - -_DEFAULT_LORA_LOADER_TYPES = { - "LoraLoader", - "LoraLoaderModelOnly", - "PowerLoraLoader", - "LoraLoaderAdvanced", -} - -_DEFAULT_CHECKPOINT_LOADER_TYPES = { - "CheckpointLoaderSimple", - "CheckpointLoader", - "CheckpointLoaderSDXL", - "unCLIPCheckpointLoader", -} - -_DEFAULT_SAMPLER_NODE_TYPES = { - "KSampler", - "KSamplerAdvanced", - "SamplerCustom", - "SamplerCustomAdvanced", -} - -# Merge custom mappings with defaults -OUTPUT_NODE_TYPES = _DEFAULT_OUTPUT_NODE_TYPES | set( - _custom_mappings.get("output_node_classes", []) -) - -REROUTE_NODE_TYPES = _DEFAULT_REROUTE_NODE_TYPES | set( - _custom_mappings.get("reroute_classes", []) -) - -CLIP_TEXT_ENCODE_TYPES = _DEFAULT_CLIP_TEXT_ENCODE_TYPES | set( - _custom_mappings.get("clip_text_encode_classes", []) -) - -CONDITIONING_NODE_TYPES = _DEFAULT_CONDITIONING_NODE_TYPES | set( - _custom_mappings.get("conditioning_classes", []) -) - -CONTROLNET_NODE_TYPES = _DEFAULT_CONTROLNET_NODE_TYPES | set( - _custom_mappings.get("controlnet_classes", []) -) - -LORA_LOADER_TYPES = _DEFAULT_LORA_LOADER_TYPES | set( - _custom_mappings.get("lora_classes", []) -) - -CHECKPOINT_LOADER_TYPES = _DEFAULT_CHECKPOINT_LOADER_TYPES | set( - _custom_mappings.get("checkpoint_loader_classes", []) -) - -SAMPLER_NODE_TYPES = _DEFAULT_SAMPLER_NODE_TYPES | set( - _custom_mappings.get("sampler_classes", []) -) - - -class WorkflowGraph: - """ - Indexed graph structure for ComfyUI workflows. - - Provides efficient traversal and node lookup. - """ - - def __init__(self, workflow_json: Dict[str, Any]): - """ - Build graph from workflow JSON. - - Args: - workflow_json: ComfyUI workflow (dict of node_id -> node_data) - """ - self.nodes: Dict[str, Dict[str, Any]] = {} - self.edges: Dict[str, List[str]] = {} # node_id -> [connected_node_ids] - self.reverse_edges: Dict[str, List[str]] = {} # node_id -> [upstream_node_ids] - - self._build_graph(workflow_json) - - def _build_graph(self, workflow_json: Dict[str, Any]) -> None: - """Parse workflow and build graph structure.""" - if not isinstance(workflow_json, dict): - return - - # Add all nodes - for node_id, node_data in workflow_json.items(): - if not isinstance(node_data, dict): - continue - - self.nodes[node_id] = node_data - self.edges[node_id] = [] - self.reverse_edges[node_id] = [] - - # Build edges from inputs - for node_id, node_data in self.nodes.items(): - inputs = node_data.get("inputs", {}) - if not isinstance(inputs, dict): - continue - - for input_name, input_value in inputs.items(): - # ComfyUI inputs can be: - # - [source_node_id, output_slot] for connections - # - literal values (str, int, float, etc.) - if isinstance(input_value, list) and len(input_value) >= 1: - source_node_id = str(input_value[0]) - - if source_node_id in self.nodes: - # Add edge: source -> current - self.edges[source_node_id].append(node_id) - # Add reverse edge: current -> source (upstream) - self.reverse_edges[node_id].append(source_node_id) - - def get_node(self, node_id: str) -> Optional[Dict[str, Any]]: - """Get node data by ID.""" - return self.nodes.get(node_id) - - def get_upstream_nodes(self, node_id: str) -> List[str]: - """Get all directly connected upstream nodes.""" - return self.reverse_edges.get(node_id, []) - - def get_downstream_nodes(self, node_id: str) -> List[str]: - """Get all directly connected downstream nodes.""" - return self.edges.get(node_id, []) - - def get_node_type(self, node_id: str) -> Optional[str]: - """Get node class_type.""" - node = self.get_node(node_id) - if node: - return node.get("class_type") - return None - - def trace_upstream( - self, - start_node_ids: List[str], - max_depth: int = 50 - ) -> List[str]: - """ - Trace upstream from given nodes using BFS. - - Args: - start_node_ids: Starting nodes - max_depth: Maximum traversal depth (prevents infinite loops) - - Returns: - List of node IDs in traversal order (BFS) - """ - visited: Set[str] = set() - queue = deque([(node_id, 0) for node_id in start_node_ids]) - result = [] - - while queue: - node_id, depth = queue.popleft() - - if node_id in visited or depth > max_depth: - continue - - visited.add(node_id) - result.append(node_id) - - # Add upstream nodes to queue - upstream = self.get_upstream_nodes(node_id) - for upstream_id in upstream: - if upstream_id not in visited: - queue.append((upstream_id, depth + 1)) - - return result - - def find_nodes_by_type(self, class_types: Set[str]) -> List[str]: - """ - Find all nodes matching given class types. - - Args: - class_types: Set of class_type strings - - Returns: - List of node IDs - """ - result = [] - for node_id, node_data in self.nodes.items(): - node_type = node_data.get("class_type") - if node_type in class_types: - result.append(node_id) - return result - - -def extract_text_from_node( - graph: WorkflowGraph, - node_id: str, - input_key: str = "text" -) -> Optional[str]: - """ - Extract text from a node's input. - - Follows reroute nodes, conditioning nodes, and ControlNet if necessary. - - Args: - graph: Workflow graph - node_id: Node to extract from - input_key: Input key to extract (default "text") - - Returns: - Extracted text or None - """ - node = graph.get_node(node_id) - if not node: - return None - - inputs = node.get("inputs", {}) - text_input = inputs.get(input_key) - - # Direct text value - if isinstance(text_input, str): - return text_input.strip() - - # Connected input [source_node_id, output_slot] - if isinstance(text_input, list) and len(text_input) >= 1: - source_node_id = str(text_input[0]) - source_node = graph.get_node(source_node_id) - - if not source_node: - return None - - source_type = source_node.get("class_type") - - # Follow reroute nodes - if source_type in REROUTE_NODE_TYPES: - return extract_text_from_node(graph, source_node_id, input_key) - - # Extract from source if it's also a text node - if source_type in CLIP_TEXT_ENCODE_TYPES: - return extract_text_from_node(graph, source_node_id, "text") - - # Follow conditioning nodes (try multiple input names) - if source_type in CONDITIONING_NODE_TYPES: - for cond_input in ["conditioning_1", "conditioning_2", "conditioning_from", "conditioning"]: - result = extract_text_from_node(graph, source_node_id, cond_input) - if result: - return result - - # Follow ControlNet nodes - if source_type in CONTROLNET_NODE_TYPES: - return extract_text_from_node(graph, source_node_id, "conditioning") - - return None - - -def trace_conditioning_to_texts( - graph: WorkflowGraph, - sampler_id: str, - input_name: str # 'positive' or 'negative' -) -> List[str]: - """ - Trace from sampler's positive/negative input back to ALL text nodes. - - Handles conditioning combiners, ControlNet, reroutes, etc. using BFS. - - Args: - graph: Workflow graph - sampler_id: Sampler node ID - input_name: Input to trace ('positive' or 'negative') - - Returns: - List of text strings found by tracing backward - """ - texts = [] - visited = set() - - sampler = graph.get_node(sampler_id) - if not sampler: - return texts - - input_value = sampler.get("inputs", {}).get(input_name) - if not isinstance(input_value, list) or len(input_value) < 1: - return texts - - # BFS from conditioning input - queue = deque([str(input_value[0])]) - - while queue: - node_id = queue.popleft() - if node_id in visited: - continue - visited.add(node_id) - - node = graph.get_node(node_id) - if not node: - continue - - node_type = node.get("class_type") - - # Text encoder - extract text - if node_type in CLIP_TEXT_ENCODE_TYPES: - text = extract_text_from_node(graph, node_id, "text") - if text: - texts.append(text) - - # Conditioning combiner - traverse all conditioning inputs - elif node_type in CONDITIONING_NODE_TYPES: - inputs = node.get("inputs", {}) - for key, value in inputs.items(): - if key.startswith("conditioning") and isinstance(value, list) and len(value) >= 1: - queue.append(str(value[0])) - - # ControlNet - follow conditioning input - elif node_type in CONTROLNET_NODE_TYPES: - inputs = node.get("inputs", {}) - cond = inputs.get("conditioning") - if isinstance(cond, list) and len(cond) >= 1: - queue.append(str(cond[0])) - - # Reroute - follow upstream - elif node_type in REROUTE_NODE_TYPES: - for upstream_id in graph.get_upstream_nodes(node_id): - queue.append(upstream_id) - - return texts - - -def extract_prompts(graph: WorkflowGraph) -> Tuple[List[str], List[str]]: - """ - Extract positive and negative prompts from workflow. - - Uses improved multi-hop resolution from samplers to text nodes, - properly handling conditioning combiners and ControlNet. - - Returns: - (positive_prompts, negative_prompts) - """ - positive_prompts = [] - negative_prompts = [] - - # Find all samplers - sampler_nodes = graph.find_nodes_by_type(SAMPLER_NODE_TYPES) - - if sampler_nodes: - # Trace from each sampler to text nodes - for sampler_id in sampler_nodes: - try: - pos = trace_conditioning_to_texts(graph, sampler_id, "positive") - neg = trace_conditioning_to_texts(graph, sampler_id, "negative") - - positive_prompts.extend(pos) - negative_prompts.extend(neg) - except Exception as e: - log.debug(f"[Majoor] Failed to trace conditioning from sampler {sampler_id}: {e}") - continue - - # Deduplicate while preserving order - positive_prompts = list(dict.fromkeys(positive_prompts)) - negative_prompts = list(dict.fromkeys(negative_prompts)) - - else: - # Fallback: no samplers found, use heuristic classification - positive_prompts, negative_prompts = _collect_text_nodes_with_heuristics(graph) - - return positive_prompts, negative_prompts - - -def _collect_text_nodes_with_heuristics(graph: WorkflowGraph) -> Tuple[List[str], List[str]]: - """ - Fallback method: collect all text nodes when sampler-based tracing fails. - - Uses multiple heuristics for positive/negative classification. - - Returns: - (positive_prompts, negative_prompts) - """ - positive = [] - negative = [] - - text_nodes = graph.find_nodes_by_type(CLIP_TEXT_ENCODE_TYPES) - - for node_id in text_nodes: - try: - node = graph.get_node(node_id) - if not node: - continue - - text = extract_text_from_node(graph, node_id, "text") - if not text: - continue - - # Heuristic 1: Check node title - meta = node.get("_meta", {}) - title = meta.get("title", "").lower() - - # Heuristic 2: Check input field names - inputs = node.get("inputs", {}) - input_keys = " ".join(inputs.keys()).lower() - - # Heuristic 3: Check text content patterns - text_lower = text.lower() - negative_patterns = { - "worst quality", "low quality", "blurry", "bad", "ugly", - "nsfw", "poorly", "amateur", "distorted", "deformed" - } - - is_negative = ( - "negative" in title or - "neg" in title or - "bad" in title or - "unwanted" in title or - "negative" in input_keys or - any(pattern in text_lower for pattern in negative_patterns) - ) - - if is_negative: - negative.append(text) - else: - positive.append(text) - - except Exception as e: - log.debug(f"[Majoor] Failed to extract text from node {node_id}: {e}") - continue - - return positive, negative - - -def extract_model(graph: WorkflowGraph) -> Optional[str]: - """ - Extract checkpoint/model name from workflow. - - Returns: - Model filename (e.g., "sd_xl_base_1.0.safetensors") or None - """ - # Find checkpoint loader nodes - loader_nodes = graph.find_nodes_by_type(CHECKPOINT_LOADER_TYPES) - - for node_id in loader_nodes: - node = graph.get_node(node_id) - if not node: - continue - - inputs = node.get("inputs", {}) - - # Try common input names - for key in ["ckpt_name", "checkpoint_name", "model_name"]: - model_name = inputs.get(key) - if isinstance(model_name, str) and model_name: - return model_name.strip() - - return None - - -def extract_sampler_params(graph: WorkflowGraph) -> Dict[str, Any]: - """ - Extract sampler parameters (steps, cfg, sampler_name, scheduler). - - Returns: - { - "steps": int, - "cfg": float, - "sampler_name": str, - "scheduler": str, - "seed": int - } - """ - params = {} - - # Find sampler nodes - sampler_nodes = graph.find_nodes_by_type(SAMPLER_NODE_TYPES) - - if not sampler_nodes: - return params - - # Use first sampler found - node = graph.get_node(sampler_nodes[0]) - if not node: - return params - - inputs = node.get("inputs", {}) - - # Extract common sampler params - if "steps" in inputs: - params["steps"] = inputs["steps"] - - if "cfg" in inputs: - params["cfg"] = inputs["cfg"] - - if "sampler_name" in inputs: - params["sampler_name"] = inputs["sampler_name"] - - if "scheduler" in inputs: - params["scheduler"] = inputs["scheduler"] - - if "seed" in inputs: - params["seed"] = inputs["seed"] - - return params - - -def extract_loras(graph: WorkflowGraph) -> List[Dict[str, Any]]: - """ - Extract LoRA information from workflow. - - Returns: - List of LoRA dictionaries with name and strength values - """ - loras = [] - - # Find LoRA loader nodes - lora_nodes = graph.find_nodes_by_type(LORA_LOADER_TYPES) - - for node_id in lora_nodes: - node = graph.get_node(node_id) - if not node: - continue - - inputs = node.get("inputs", {}) - - # Try common input names - lora_name = inputs.get("lora_name") or inputs.get("lora") or inputs.get("name") - - if lora_name and isinstance(lora_name, str): - lora_info = { - "name": lora_name.strip(), - "strength_model": inputs.get("strength_model"), - "strength_clip": inputs.get("strength_clip"), - } - - # Remove None values - lora_info = {k: v for k, v in lora_info.items() if v is not None} - - loras.append(lora_info) - - return loras - - -def extract_comfy_params(workflow_json: Dict[str, Any]) -> Dict[str, Any]: - """ - Extract all metadata from ComfyUI workflow. - - This is the main entry point for workflow metadata extraction. - - Args: - workflow_json: ComfyUI workflow (dict of node_id -> node_data) - - Returns: - { - "positive_prompts": [str, ...], - "negative_prompts": [str, ...], - "model": str or None, - "steps": int or None, - "cfg": float or None, - "sampler_name": str or None, - "scheduler": str or None, - "seed": int or None, - "loras": [{"name": str, "strength_model": float, ...}, ...], - "output_nodes": [str, ...], # Node IDs - "node_count": int - } - - Example: - >>> workflow = {...} - >>> params = extract_comfy_params(workflow) - >>> print(params["positive_prompts"][0]) - "portrait of a woman, photorealistic" - """ - if not workflow_json or not isinstance(workflow_json, dict): - return { - "positive_prompts": [], - "negative_prompts": [], - "model": None, - "steps": None, - "cfg": None, - "sampler_name": None, - "scheduler": None, - "seed": None, - "loras": [], - "output_nodes": [], - "node_count": 0 - } - - try: - # Build graph - graph = WorkflowGraph(workflow_json) - - # Extract prompts - positive_prompts, negative_prompts = extract_prompts(graph) - - # Extract model - model = extract_model(graph) - - # Extract sampler params - sampler_params = extract_sampler_params(graph) - - # Extract LoRAs - loras = extract_loras(graph) - - # Find output nodes - output_nodes = graph.find_nodes_by_type(OUTPUT_NODE_TYPES) - - return { - "positive_prompts": positive_prompts, - "negative_prompts": negative_prompts, - "model": model, - "steps": sampler_params.get("steps"), - "cfg": sampler_params.get("cfg"), - "sampler_name": sampler_params.get("sampler_name"), - "scheduler": sampler_params.get("scheduler"), - "seed": sampler_params.get("seed"), - "loras": loras, - "output_nodes": output_nodes, - "node_count": len(graph.nodes) - } - - except Exception as e: - # Never crash - return empty metadata - log.debug(f"[Majoor] Error extracting ComfyUI params: {e}") - return { - "positive_prompts": [], - "negative_prompts": [], - "model": None, - "steps": None, - "cfg": None, - "sampler_name": None, - "scheduler": None, - "seed": None, - "loras": [], - "output_nodes": [], - "node_count": 0 - } - - -# Self-test -if __name__ == "__main__": - # Test with a minimal ComfyUI workflow - test_workflow = { - "1": { - "class_type": "CheckpointLoaderSimple", - "inputs": { - "ckpt_name": "sd_xl_base_1.0.safetensors" - } - }, - "2": { - "class_type": "CLIPTextEncode", - "inputs": { - "text": "portrait of a woman, photorealistic, 8k", - "clip": ["1", 1] - }, - "_meta": { - "title": "Positive Prompt" - } - }, - "3": { - "class_type": "CLIPTextEncode", - "inputs": { - "text": "low quality, blurry", - "clip": ["1", 1] - }, - "_meta": { - "title": "Negative Prompt" - } - }, - "4": { - "class_type": "KSampler", - "inputs": { - "seed": 123456, - "steps": 20, - "cfg": 7.5, - "sampler_name": "euler_ancestral", - "scheduler": "normal", - "positive": ["2", 0], - "negative": ["3", 0], - "model": ["1", 0] - } - }, - "5": { - "class_type": "SaveImage", - "inputs": { - "images": ["4", 0], - "filename_prefix": "ComfyUI" - } - } - } - - print("Running ComfyUI tracer self-tests...\n") - - # Test 1: Extract all params - print("TEST 1: Extract metadata from workflow") - params = extract_comfy_params(test_workflow) - - print(f" Positive prompts: {params['positive_prompts']}") - print(f" Negative prompts: {params['negative_prompts']}") - print(f" Model: {params['model']}") - print(f" Steps: {params['steps']}") - print(f" CFG: {params['cfg']}") - print(f" Sampler: {params['sampler_name']}") - print(f" Scheduler: {params['scheduler']}") - print(f" Seed: {params['seed']}") - print(f" Output nodes: {params['output_nodes']}") - print(f" Node count: {params['node_count']}") - - # Validate - assert len(params['positive_prompts']) == 1 - assert "portrait of a woman" in params['positive_prompts'][0] - assert len(params['negative_prompts']) == 1 - assert "low quality" in params['negative_prompts'][0] - assert params['model'] == "sd_xl_base_1.0.safetensors" - assert params['steps'] == 20 - assert params['cfg'] == 7.5 - assert params['sampler_name'] == "euler_ancestral" - assert params['seed'] == 123456 - assert len(params['output_nodes']) == 1 - assert params['node_count'] == 5 - - print("\n[PASS] All assertions passed") - - # Test 2: Empty workflow - print("\nTEST 2: Handle empty workflow") - empty_params = extract_comfy_params({}) - assert empty_params['positive_prompts'] == [] - assert empty_params['model'] is None - print("[PASS] Empty workflow handled correctly") - - # Test 3: Malformed workflow - print("\nTEST 3: Handle malformed workflow") - malformed_params = extract_comfy_params({"invalid": "data"}) - assert malformed_params['positive_prompts'] == [] - print("[PASS] Malformed workflow handled correctly") - - # Test 4: Reroute nodes - print("\nTEST 4: Handle reroute nodes") - reroute_workflow = { - "1": { - "class_type": "CLIPTextEncode", - "inputs": { - "text": "test prompt" - } - }, - "2": { - "class_type": "Reroute", - "inputs": { - "value": ["1", 0] - } - }, - "3": { - "class_type": "KSampler", - "inputs": { - "positive": ["2", 0] - } - } - } - - reroute_params = extract_comfy_params(reroute_workflow) - assert len(reroute_params['positive_prompts']) == 1 - assert "test prompt" in reroute_params['positive_prompts'][0] - print("[PASS] Reroute nodes handled correctly") - - print("\n" + "="*50) - print("All tests passed successfully!") diff --git a/server/metadata/core.py b/server/metadata/core.py deleted file mode 100644 index 88cae073..00000000 --- a/server/metadata/core.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations -from typing import Dict - -from ..utils import load_metadata, save_metadata, update_metadata_with_windows as _update_metadata_with_windows - - -def get_metadata(path: str) -> Dict: - """Load sidecar JSON (or fallback) for the given path.""" - return load_metadata(path) - - -def update_metadata(path: str, updates: dict) -> Dict: - """Update and persist the sidecar JSON for a file.""" - meta = load_metadata(path) - meta.update(updates or {}) - save_metadata(path, meta) - return meta - - -def deep_merge_metadata(path: str, updates: dict) -> Dict: - """ - Deep-merge updates into sidecar JSON for a file. - Dict values are merged recursively; scalars/arrays overwrite. - """ - base = load_metadata(path) or {} - - def _merge(dst, src): - for k, v in (src or {}).items(): - if isinstance(v, dict) and isinstance(dst.get(k), dict): - _merge(dst[k], v) - else: - dst[k] = v - - _merge(base, updates or {}) - save_metadata(path, base) - return base - - -def update_metadata_with_windows(path: str, updates: dict) -> Dict: - """Delegate to the canonical implementation in utils to avoid divergence.""" - return _update_metadata_with_windows(path, updates) - - -__all__ = ["get_metadata", "update_metadata", "deep_merge_metadata", "update_metadata_with_windows"] diff --git a/server/metadata/enhanced_extraction.py b/server/metadata/enhanced_extraction.py deleted file mode 100644 index abf14181..00000000 --- a/server/metadata/enhanced_extraction.py +++ /dev/null @@ -1,1247 +0,0 @@ -""" -Enhanced Metadata Extraction Integration - -Integrates new metadata parsers (EXIF UserComment, ComfyUI tracer v2, PNG inject, workflow fingerprint) -with the existing metadata pipeline. - -This module provides a unified API that: -- Uses new parsers when available/enabled -- Falls back to legacy parsers on error -- Respects configuration flags -- Never crashes on corrupted files - -Usage: - from server.metadata.enhanced_extraction import extract_enhanced_metadata, write_enhanced_metadata - - # Extract metadata (hybrid mode: try new, fallback to legacy) - metadata = extract_enhanced_metadata("/path/to/image.png") - - # Write metadata (lossless PNG injection if supported) - write_enhanced_metadata("/path/to/image.png", {"rating": 5, "tags": ["test"]}) -""" - -import json -import logging -from typing import Dict, Any, Optional, Tuple -from pathlib import Path - -# Import config flags -from ..config import ( - METADATA_MODE, - METADATA_EXIF_NATIVE, - METADATA_COMFY_TRACE_V2, - METADATA_PNG_INJECT, - METADATA_WORKFLOW_HASH, - METADATA_DEBUG, - METADATA_SAFE_FALLBACK -) - -# Import new parsers -from .exif_usercomment import decode_user_comment -from .comfy_tracer import extract_comfy_params -from .png_inject import read_metadata as png_read_metadata, inject_metadata as png_inject_metadata -from .workflow_fingerprint import create_workflow_fingerprint -from .workflow_normalize import _normalize_workflow_to_prompt_graph -from .workflow_reconstruct import prompt_graph_to_workflow - -# Import existing metadata utilities -from .core import get_metadata as legacy_get_metadata, update_metadata as legacy_update_metadata -from ..utils import get_windows_metadata, metadata_path, safe_metadata_json_load - -log = logging.getLogger(__name__) - - -def extract_enhanced_metadata(file_path: str) -> Dict[str, Any]: - """ - Extract metadata using enhanced parsers (with fallback to legacy). - - This is the main entry point for metadata extraction. - - Args: - file_path: Path to asset file - - Returns: - Metadata dictionary with all extracted fields - - Mode behavior: - - legacy: Use only existing Windows props / ExifTool / sidecar - - hybrid: Try new parsers first, fallback to legacy on error (default) - - native: Use only new parsers (no fallback) - """ - if METADATA_DEBUG: - log.debug(f"[Majoor] Extracting enhanced metadata from {file_path} (mode: {METADATA_MODE})") - - # Start with empty metadata - metadata = {} - - # Legacy mode: skip new parsers entirely - if METADATA_MODE == "legacy": - if METADATA_DEBUG: - log.debug("[Majoor] Using legacy mode - skipping new parsers") - return legacy_get_metadata(file_path) - - # Try new parsers - try: - # 1. Extract from PNG iTXt chunks (if PNG file) - if file_path.lower().endswith('.png') and METADATA_PNG_INJECT: - try: - png_metadata = png_read_metadata(file_path) - if png_metadata: - if METADATA_DEBUG: - log.debug(f"[Majoor] Found {len(png_metadata)} PNG iTXt metadata keys") - - # Convert mjr:* keys to standard keys - for key, value in png_metadata.items(): - if key.startswith("mjr:"): - clean_key = key[4:] # Remove "mjr:" prefix - metadata[clean_key] = value - - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] PNG metadata extraction failed: {e}") - if not METADATA_SAFE_FALLBACK: - raise - - # 2. Extract EXIF UserComment (if enabled) - if METADATA_EXIF_NATIVE and file_path.lower().endswith(('.jpg', '.jpeg', '.png', '.tiff', '.tif')): - try: - try: - from PIL import Image - from PIL.ExifTags import TAGS - except ImportError: - if METADATA_DEBUG: - log.debug("[Majoor] PIL/Pillow not available, skipping EXIF extraction") - else: - # Read EXIF data - with Image.open(file_path) as img: - exif_data = img.getexif() - if exif_data: - # Look for UserComment tag (0x9286) - for tag_id, value in exif_data.items(): - tag_name = TAGS.get(tag_id, tag_id) - - if tag_name == "UserComment" or tag_id == 0x9286: - if isinstance(value, bytes): - decoded = decode_user_comment(value) - if decoded: - metadata['user_comment'] = decoded - if METADATA_DEBUG: - log.debug(f"[Majoor] Decoded EXIF UserComment: {decoded[:50]}...") - elif isinstance(value, str): - metadata['user_comment'] = value - if METADATA_DEBUG: - log.debug(f"[Majoor] Found EXIF UserComment (already string): {value[:50]}...") - - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] EXIF extraction failed: {e}") - if not METADATA_SAFE_FALLBACK: - raise - - # 3. Extract workflow metadata (if workflow_json present) - if METADATA_COMFY_TRACE_V2 or METADATA_WORKFLOW_HASH: - # First, get existing metadata to check for workflow - existing = legacy_get_metadata(file_path) - workflow_json = existing.get("workflow") - - if workflow_json: - try: - if METADATA_WORKFLOW_HASH: - # Create fingerprint - fp = create_workflow_fingerprint(workflow_json) - - if METADATA_DEBUG: - log.debug(f"[Majoor] Workflow hash: {fp['hash'][:12]}...") - - metadata["workflow_hash"] = fp["hash"] - - if METADATA_COMFY_TRACE_V2: - # Extract workflow params - params = extract_comfy_params(workflow_json) - - if METADATA_DEBUG: - log.debug(f"[Majoor] Extracted {len(params['positive_prompts'])} prompts from workflow") - - # Merge extracted params - if params["positive_prompts"]: - metadata["prompt"] = " | ".join(params["positive_prompts"]) - - if params["negative_prompts"]: - metadata["negative"] = " | ".join(params["negative_prompts"]) - - if params["model"]: - metadata["model"] = params["model"] - - if params["steps"]: - metadata["steps"] = params["steps"] - - if params["cfg"]: - metadata["cfg"] = params["cfg"] - - if params["sampler_name"]: - metadata["sampler"] = params["sampler_name"] - - if params["seed"]: - metadata["seed"] = params["seed"] - - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] Workflow extraction failed: {e}") - if not METADATA_SAFE_FALLBACK: - raise - - # 4. Merge with legacy metadata (hybrid mode) - if METADATA_MODE == "hybrid" or not metadata: - if METADATA_DEBUG: - log.debug("[Majoor] Merging with legacy metadata") - - legacy_metadata = legacy_get_metadata(file_path) - - # Merge: new parsers take precedence - for key, value in legacy_metadata.items(): - if key not in metadata: - metadata[key] = value - - return metadata - - except Exception as e: - # Fallback to legacy on error (if safe fallback enabled) - if METADATA_SAFE_FALLBACK: - log.warning(f"[Majoor] Enhanced metadata extraction failed for {file_path}, falling back to legacy: {e}") - return legacy_get_metadata(file_path) - else: - log.error(f"[Majoor] Enhanced metadata extraction failed for {file_path}: {e}") - raise - - -def write_enhanced_metadata(file_path: str, updates: Dict[str, Any]) -> Dict[str, Any]: - """ - Write metadata using enhanced writers (with fallback to legacy). - - Args: - file_path: Path to asset file - updates: Metadata updates to apply - - Returns: - Full metadata after update - - Mode behavior: - - legacy: Use only sidecar / Windows props - - hybrid: Try PNG inject first, fallback to sidecar on error - - native: Use only PNG inject (no fallback) - """ - if METADATA_DEBUG: - log.debug(f"[Majoor] Writing enhanced metadata to {file_path} (mode: {METADATA_MODE})") - - # Validate inputs - if not updates: - log.debug("[Majoor] Empty metadata update, skipping") - return extract_enhanced_metadata(file_path) - - if not isinstance(updates, dict): - raise ValueError(f"Updates must be a dict, got {type(updates)}") - - # Validate for namespace collisions and reserved keys - RESERVED_KEYS = {'workflow', 'prompt', 'metadata', '__metadata__'} - filtered_updates = {} - - for key, value in updates.items(): - # Skip reserved keys - if key in RESERVED_KEYS: - log.warning(f"[Majoor] Ignoring reserved key in metadata update: '{key}'") - continue - - # Skip None values - if value is None: - log.debug(f"[Majoor] Skipping None value for key: '{key}'") - continue - - # Validate key is a valid identifier (no special chars that could cause issues) - if not isinstance(key, str) or not key: - log.warning(f"[Majoor] Ignoring invalid key: {key!r}") - continue - - filtered_updates[key] = value - - if not filtered_updates: - log.debug("[Majoor] All metadata keys were filtered out, skipping update") - return extract_enhanced_metadata(file_path) - - # Use filtered updates - updates = filtered_updates - - # Legacy mode: skip new writers entirely - if METADATA_MODE == "legacy": - if METADATA_DEBUG: - log.debug("[Majoor] Using legacy mode - skipping PNG inject") - return legacy_update_metadata(file_path, updates) - - # Try PNG inject for PNG files - if file_path.lower().endswith('.png') and METADATA_PNG_INJECT: - try: - # Convert updates to mjr:* namespaced keys - png_updates = {} - for key, value in updates.items(): - # Convert value to string for PNG iTXt - if isinstance(value, (list, dict)): - value_str = json.dumps(value) - else: - value_str = str(value) - - png_updates[f"mjr:{key}"] = value_str - - # Inject into PNG - success = png_inject_metadata(file_path, png_updates, backup=True) - - if success: - if METADATA_DEBUG: - log.debug(f"[Majoor] Wrote {len(png_updates)} keys to PNG iTXt chunks") - - # In hybrid mode, also update sidecar for compatibility - if METADATA_MODE == "hybrid": - legacy_update_metadata(file_path, updates) - - # Return full metadata - return extract_enhanced_metadata(file_path) - - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] PNG metadata write failed: {e}") - - if not METADATA_SAFE_FALLBACK: - raise - - # Fallback to legacy (or if not a PNG) - if METADATA_MODE == "hybrid" or not file_path.lower().endswith('.png'): - if METADATA_DEBUG: - log.debug("[Majoor] Falling back to legacy metadata write") - return legacy_update_metadata(file_path, updates) - - # Native mode for non-PNG files: error - if METADATA_MODE == "native": - raise NotImplementedError(f"Native mode PNG inject not supported for {Path(file_path).suffix} files") - - return {} - - -def extract_workflow_hash(workflow_json: Dict[str, Any]) -> str: - """ - Extract workflow hash (convenience wrapper). - - Args: - workflow_json: ComfyUI workflow dict - - Returns: - 40-character SHA1 hash or empty string - """ - if not METADATA_WORKFLOW_HASH: - return "" - - try: - fp = create_workflow_fingerprint(workflow_json) - return fp["hash"] - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] Workflow hash extraction failed: {e}") - return "" - - -def get_metadata_source_info(file_path: str) -> Dict[str, Any]: - """ - Get information about which metadata sources are available for a file. - - Useful for debugging and UI display. - - Args: - file_path: Path to asset file - - Returns: - { - "has_png_metadata": bool, - "has_sidecar": bool, - "has_windows_props": bool, - "has_workflow": bool, - "png_keys": [str, ...], - "mode": str - } - """ - info = { - "has_png_metadata": False, - "has_sidecar": False, - "has_windows_props": False, - "has_workflow": False, - "png_keys": [], - "mode": METADATA_MODE - } - - try: - # Check PNG metadata - if file_path.lower().endswith('.png'): - png_metadata = png_read_metadata(file_path) - if png_metadata: - info["has_png_metadata"] = True - info["png_keys"] = list(png_metadata.keys()) - - # Check legacy metadata - legacy_metadata = legacy_get_metadata(file_path) - if legacy_metadata: - if "workflow" in legacy_metadata: - info["has_workflow"] = True - - # Check if sidecar file exists - import os - sidecar_path = metadata_path(file_path) - if sidecar_path and os.path.exists(sidecar_path): - info["has_sidecar"] = True - - # Check for Windows properties - if os.name == "nt": - windows_meta = get_windows_metadata(file_path) - if windows_meta.get("rating") or windows_meta.get("tags"): - info["has_windows_props"] = True - - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] Error getting metadata source info: {e}") - - return info - - -def _extract_json_from_text(text: str) -> Optional[Dict[str, Any]]: - """ - Extract JSON from text by looking for JSON-like patterns and structures. - This function looks for JSON in any text field, not just standard metadata fields. - """ - if not text or not isinstance(text, str): - return None - - from .video_extraction import _balanced_json_from_text # Import here to avoid circular import - - # First, try to find JSON directly - if text.strip().startswith('{'): - parsed = safe_metadata_json_load(text.strip()) - if parsed: - return parsed if isinstance(parsed, dict) else {"nodes": parsed} - - # Extract potential JSON structures - start = text.find('{') - while start != -1: - json_candidate = _balanced_json_from_text(text, start) - if json_candidate: - parsed = safe_metadata_json_load(json_candidate) - if parsed: - return parsed if isinstance(parsed, dict) else {"nodes": parsed} - start = text.find('{', start + 1) - - # Try to find JSON-like patterns with common ComfyUI keys - keywords = ['"prompt"', '"workflow"', '"nodes"', '"extra_pnginfo"', '"class_type"', '"inputs"'] - for keyword in keywords: - if keyword in text.lower(): - start = text.find('{') - if start != -1: - json_candidate = _balanced_json_from_text(text, start) - if json_candidate: - parsed = safe_metadata_json_load(json_candidate) - if parsed: - return parsed if isinstance(parsed, dict) else {"nodes": parsed} - - return None - - -def _is_comfyui_prompt_graph(obj: Any) -> bool: - """ - Check if an object looks like a ComfyUI prompt graph. - - A prompt graph is a dict where: - - Keys are node IDs (usually numeric strings like "1", "2", "42") - - Values are dicts with "inputs" and "class_type" fields - - This works for ANY ComfyUI node type, built-in or custom. - """ - if not isinstance(obj, dict) or len(obj) == 0: - return False - - # Check if at least one value looks like a ComfyUI node - node_count = 0 - for key, value in obj.items(): - if isinstance(value, dict): - # Must have "class_type" field (identifies the node type) - if "class_type" in value: - node_count += 1 - # Most nodes have "inputs", but some might not (e.g., constant nodes) - # So we don't require "inputs", just "class_type" - - # If at least 30% of entries look like nodes, it's likely a prompt graph - # This handles edge cases where metadata might have mixed content - if node_count > 0 and node_count >= len(obj) * 0.3: - return True - - return False - - -def _is_comfyui_workflow(obj: Any) -> bool: - """ - Check if an object looks like a ComfyUI workflow. - - A workflow is a dict with: - - "nodes" array (visual node positions, connections, etc.) - - Usually also has "links", "groups", "config", "extra", "version" - """ - if not isinstance(obj, dict): - return False - - # Primary indicator: "nodes" field that's a list - if "nodes" in obj and isinstance(obj["nodes"], list): - # Validate that nodes have workflow-specific fields - nodes = obj["nodes"] - if len(nodes) > 0: - # Check first node for workflow-specific fields like "id", "type", "pos" - first_node = nodes[0] - if isinstance(first_node, dict): - # Workflow nodes have "type" (node class), not "class_type" - if "type" in first_node or "id" in first_node: - return True - # Empty nodes array is still valid - return True - - return False - - -def _scan_all_metadata_fields(metadata_dict: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - """ - Recursively scan all metadata fields to find JSON, workflow, or prompt information. - This enhanced function looks through all metadata fields, not just standard ones. - - Enhanced to work with: - - ANY ComfyUI node type (built-in or custom) - - Multiple metadata storage patterns - - Nested and prefixed metadata formats - """ - from .workflow_normalize import _ensure_dict_from_json # Import here to avoid circular import - - prompt_found = None - workflow_found = None - - def scan_recursive(obj: Any, path: str = ""): - nonlocal prompt_found, workflow_found - - if isinstance(obj, dict): - # Check for direct prompt/workflow fields - if "prompt" in obj and isinstance(obj["prompt"], (str, dict)): - prompt_data = _ensure_dict_from_json(obj["prompt"]) - if _is_comfyui_prompt_graph(prompt_data): - prompt_found = prompt_data - - if "workflow" in obj and isinstance(obj["workflow"], (str, dict)): - workflow_data = _ensure_dict_from_json(obj["workflow"]) - if _is_comfyui_workflow(workflow_data): - workflow_found = workflow_data - - # Check if this dict itself looks like a prompt graph or workflow - if prompt_found is None and _is_comfyui_prompt_graph(obj): - prompt_found = obj - - if workflow_found is None and _is_comfyui_workflow(obj): - workflow_found = obj - - # Scan all values recursively - for key, value in obj.items(): - current_path = f"{path}.{key}" if path else key - scan_recursive(value, current_path) - - elif isinstance(obj, list): - for i, item in enumerate(obj): - current_path = f"{path}[{i}]" if path else f"[{i}]" - scan_recursive(item, current_path) - - elif isinstance(obj, str): - # Try to extract JSON from string values in any field - # CRITICAL FIX: Check for { anywhere in string, not just at start - # ComfyUI stores data as "prompt:{JSON}" and "workflow:{JSON}" - if '{' in obj: - json_data = _extract_json_from_text(obj) - if json_data: - if prompt_found is None and _is_comfyui_prompt_graph(json_data): - prompt_found = json_data - if workflow_found is None and _is_comfyui_workflow(json_data): - workflow_found = json_data - - scan_recursive(metadata_dict) - return prompt_found, workflow_found - - -def extract_comprehensive_metadata_png(file_path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - """ - Extract comprehensive metadata from PNG files using PIL. - This enhanced version scans ALL PNG info fields AND all EXIF tags for JSON/workflow/prompt data. - """ - from PIL import Image - from PIL.ExifTags import TAGS - from ..logger import get_logger - - log = get_logger(__name__) - - try: - with Image.open(file_path) as img: - info = getattr(img, "info", {}) or {} - - # Also extract ALL EXIF data comprehensively - exif_data = {} - try: - exif = img.getexif() - if exif: - for tag_id, value in exif.items(): - tag_name = TAGS.get(tag_id, f"Tag_{tag_id}") - exif_data[tag_name] = value - # Also store by numeric ID for comprehensive coverage - exif_data[f"0x{tag_id:04x}"] = value - - # CRITICAL FIX: ComfyUI stores workflow/prompt in Make/Model tags! - # Decode bytes values immediately to check for JSON - if isinstance(value, bytes): - try: - decoded = value.decode('utf-8', errors='ignore') - # Store decoded version for scanning - exif_data[f"{tag_name}_decoded"] = decoded - exif_data[f"0x{tag_id:04x}_decoded"] = decoded - except Exception: - pass - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] Could not extract EXIF from {file_path}: {e}") - - # Combine PNG info and EXIF data for comprehensive scanning - all_metadata = {**info, **exif_data} - - # Scan all PNG info fields for JSON/workflow/prompt data using enhanced scanner - prompt, workflow = _scan_all_metadata_fields(all_metadata) - - # If not found, try more aggressive extraction from all string values - # This catches data stored in non-standard fields like Make/Model EXIF tags - if not prompt and not workflow: - for key, value in all_metadata.items(): - if isinstance(value, str) and '{' in value: - json_data = _extract_json_from_text(value) - if json_data: - if not prompt and _is_comfyui_prompt_graph(json_data): - prompt = json_data - if METADATA_DEBUG: - log.debug(f"[Majoor] Found prompt in EXIF field: {key}") - if not workflow and _is_comfyui_workflow(json_data): - workflow = json_data - if METADATA_DEBUG: - log.debug(f"[Majoor] Found workflow in EXIF field: {key}") - # Check bytes/bytearray fields too - elif isinstance(value, (bytes, bytearray)): - try: - text_value = value.decode('utf-8', errors='ignore') - if '{' in text_value: - json_data = _extract_json_from_text(text_value) - if json_data: - if not prompt and _is_comfyui_prompt_graph(json_data): - prompt = json_data - if METADATA_DEBUG: - log.debug(f"[Majoor] Found prompt in bytes field: {key}") - if not workflow and _is_comfyui_workflow(json_data): - workflow = json_data - if METADATA_DEBUG: - log.debug(f"[Majoor] Found workflow in bytes field: {key}") - except Exception: - pass - - return prompt, workflow - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] Failed to extract PNG metadata from {file_path}: {e}") - return None, None - - -def extract_comprehensive_metadata_video_exiftool(file_path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - """ - Extract comprehensive metadata from video/image files using ExifTool. - This version uses enhanced scanning of ALL metadata fields with aggressive extraction. - - ExifTool flags used: - - -j: JSON output - - -g: Group names for organization - - -ee: Extract embedded data - - -a: Allow duplicate tags - - -U: Extract unknown tags - - -G1: Include group1 names - """ - from ..utils import _get_exiftool_path - from ..logger import get_logger - import subprocess - - log = get_logger(__name__) - exe = _get_exiftool_path() - if not exe: - return None, None - - try: - # Run ExifTool with maximum extraction flags to get ALL possible metadata - cmd = [ - exe, - "-j", # JSON output - "-g", # Group names - "-ee", # Extract embedded data - "-a", # Allow duplicate tags - "-U", # Extract unknown tags - "-G1", # Include group1 names - "-b", # Binary mode for non-printable data - str(file_path) - ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - - if result.returncode != 0 or not result.stdout: - return None, None - - # Parse the JSON output from ExifTool - try: - exif_data = json.loads(result.stdout) - if not exif_data: - return None, None - except json.JSONDecodeError: - return None, None - - # Get the first entry (ExifTool returns array with one object per file) - metadata = exif_data[0] if exif_data else {} - - # Scan all extracted metadata for JSON/workflow/prompt data using enhanced scanner - prompt, workflow = _scan_all_metadata_fields(metadata) - - # If not found, do aggressive text extraction from ALL fields - if not prompt and not workflow: - for key, value in metadata.items(): - if isinstance(value, str) and ('{' in value or '"prompt"' in value.lower() or '"workflow"' in value.lower()): - json_data = _extract_json_from_text(value) - if json_data: - if not prompt and _is_comfyui_prompt_graph(json_data): - prompt = json_data - if not workflow and _is_comfyui_workflow(json_data): - workflow = json_data - - return prompt, workflow - except subprocess.TimeoutExpired: - if METADATA_DEBUG: - log.debug(f"[Majoor] ExifTool timeout on {file_path}") - return None, None - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] ExifTool error on {file_path}: {e}") - return None, None - - -def extract_comprehensive_metadata_video_ffprobe(file_path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - """ - Extract comprehensive metadata from video files using FFprobe. - This version uses enhanced scanning of ALL metadata fields including all stream tags. - """ - import shutil - import subprocess - from ..logger import get_logger - - log = get_logger(__name__) - ffprobe = shutil.which("ffprobe") - if not ffprobe: - return None, None - - try: - # Extract ALL possible metadata: format, streams, chapters, and data - cmd = [ - ffprobe, - "-v", "quiet", # Suppress warnings - "-print_format", "json", - "-show_format", # Show container format - "-show_streams", # Show all streams - "-show_chapters", # Show chapters (may contain metadata) - "-show_entries", # Show specific entries - "format_tags:stream_tags", # All format and stream tags - str(file_path) - ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) - - if result.returncode != 0 or not result.stdout: - return None, None - - try: - ffprobe_data = json.loads(result.stdout) - except json.JSONDecodeError: - return None, None - - # Scan the FFprobe output for JSON/workflow/prompt data using enhanced scanner - prompt, workflow = _scan_all_metadata_fields(ffprobe_data) - - # If not found, aggressively search all tags in format and streams - if not prompt and not workflow: - # Check format tags - format_tags = ffprobe_data.get("format", {}).get("tags", {}) - if format_tags: - for key, value in format_tags.items(): - if isinstance(value, str) and ('{' in value or '"prompt"' in value.lower() or '"workflow"' in value.lower()): - json_data = _extract_json_from_text(value) - if json_data: - if not prompt and _is_comfyui_prompt_graph(json_data): - prompt = json_data - if not workflow and _is_comfyui_workflow(json_data): - workflow = json_data - - # Check stream tags - for stream in ffprobe_data.get("streams", []): - if prompt and workflow: - break - stream_tags = stream.get("tags", {}) - if stream_tags: - for key, value in stream_tags.items(): - if isinstance(value, str) and ('{' in value or '"prompt"' in value.lower() or '"workflow"' in value.lower()): - json_data = _extract_json_from_text(value) - if json_data: - if not prompt and _is_comfyui_prompt_graph(json_data): - prompt = json_data - if not workflow and _is_comfyui_workflow(json_data): - workflow = json_data - - return prompt, workflow - except subprocess.TimeoutExpired: - if METADATA_DEBUG: - log.debug(f"[Majoor] FFprobe timeout on {file_path}") - return None, None - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] FFprobe error on {file_path}: {e}") - return None, None - - -def _detect_generation_metadata(file_path: str) -> tuple[bool, str | None]: - """ - Detect if a file contains generation metadata from various AI image generation tools. - - Supports detection of metadata from: - - Automatic1111 / Forge / SD.Next (parameters field) - - NovelAI (Comment field) - - Fooocus (fooocus_meta field) - - InvokeAI (invokeai_metadata field) - - StableSwarmUI (sd_metadata field) - - Generic tools (generation_data, Description, Software, UserComment) - - Args: - file_path: Path to the file to check - - Returns: - Tuple of (has_generation_metadata: bool, source_name: str | None) - """ - from pathlib import Path - - try: - file_path = Path(file_path) - ext = file_path.suffix.lower() - - # Only check image formats where these tools typically store metadata - if ext not in [".png", ".jpg", ".jpeg", ".webp", ".tiff", ".tif"]: - return False, None - - # For PNG files, check PNG info chunks - if ext == ".png": - from PIL import Image - - with Image.open(file_path) as img: - info = getattr(img, "info", {}) or {} - - # Check for various metadata field formats used by different tools - # Case-insensitive matching for parameters field - generation_fields = [ - ('parameters', 'A1111/Forge/SD.Next'), - ('Parameters', 'A1111/Forge/SD.Next'), - ('Comment', 'NovelAI'), - ('Description', 'metadata'), - ('Software', 'metadata'), - ('generation_data', 'generic'), - ('fooocus_meta', 'Fooocus'), - ('invokeai_metadata', 'InvokeAI'), - ('sd_metadata', 'StableSwarmUI'), - ('UserComment', 'EXIF'), - ] - - for field_name, source_name in generation_fields: - if field_name in info: - field_value = info[field_name] - # Check if it's a valid non-empty string with meaningful content - if isinstance(field_value, str) and len(field_value) > 20: - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] Found {source_name} metadata in '{field_name}' field ({len(field_value)} chars)") - return True, source_name - - # For WEBP/JPEG/TIFF, check both PNG-style info AND EXIF data - elif ext in [".webp", ".jpg", ".jpeg", ".tiff", ".tif"]: - from PIL import Image - from PIL.ExifTags import TAGS - - with Image.open(file_path) as img: - # Check PNG-style info first (WEBP can have this) - info = getattr(img, "info", {}) or {} - - # Case-insensitive check for 'parameters' in info dict - for key in info.keys(): - key_lower = str(key).lower() - # Match 'parameters', 'xmp:parameters', etc. - if 'parameters' in key_lower: - field_value = info[key] - if isinstance(field_value, str) and len(field_value) > 20: - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] Found A1111 parameters in '{key}' field ({len(field_value)} chars)") - return True, 'A1111/Forge/SD.Next' - elif isinstance(field_value, bytes) and len(field_value) > 20: - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] Found A1111 parameters (bytes) in '{key}' field ({len(field_value)} bytes)") - return True, 'A1111/Forge/SD.Next' - - # Check EXIF data - try: - exif = img.getexif() - if exif: - # Check all EXIF tags for parameters-like content - for tag_id, value in exif.items(): - tag_name = TAGS.get(tag_id, f"Tag_{tag_id}") - tag_name_lower = tag_name.lower() - - # Decode bytes values - if isinstance(value, bytes): - try: - value = value.decode('utf-8', errors='ignore') - except: - continue - - # FIX #2: Detect ComfyUI prefixed strings in EXIF (Make/Model tags) - # Pattern: Make="workflow:{...json...}", Model="prompt:{...json...}" - if isinstance(value, str) and len(value) > 20: - v = value.lstrip() - vl = v.lower() - - # Exact prefix match for ComfyUI format - if vl.startswith("workflow:{") or vl.startswith("prompt:{"): - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] Found ComfyUI prefixed JSON in EXIF tag '{tag_name}' ({len(v)} chars)") - return True, "ComfyUI/EXIF_prefixed" - - # Generic heuristic: JSON-like content with workflow/prompt hints - if ('"workflow"' in vl or '"prompt"' in vl) and '{' in vl and '}' in vl: - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] Found ComfyUI JSON hint in EXIF tag '{tag_name}' ({len(v)} chars)") - return True, "ComfyUI/EXIF_json_hint" - - # Check for 'parameters' in tag name - if 'parameters' in tag_name_lower and isinstance(value, str) and len(value) > 20: - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] Found parameters in EXIF tag '{tag_name}' ({len(value)} chars)") - return True, 'A1111/Forge/SD.Next' - - # Check UserComment tag (37510) - if tag_id == 37510 and isinstance(value, str) and len(value) > 20: - return True, 'EXIF/UserComment' - - # Check ImageDescription tag (270) - if tag_id == 270 and isinstance(value, str) and len(value) > 20: - # Check if it looks like generation params - if any(keyword in value.lower() for keyword in ['steps:', 'sampler:', 'cfg scale:', 'seed:', 'model:', 'negative prompt:']): - return True, 'EXIF/ImageDescription' - - except Exception: - pass - - except Exception: - pass - - return False, None - - -def extract_comprehensive_metadata(file_path: str) -> Dict[str, Any]: - """ - Extract comprehensive metadata from any file type using multiple methods. - - CRITICAL: Checks sidecar .mjr.json FIRST for WEBP/MP4/etc before scanning EXIF/ffprobe. - This is the primary metadata source for files that don't embed workflow data. - - This enhanced function thoroughly analyzes all possible metadata fields to find - JSON, workflow, or prompt-like information using ExifTool, FFprobe, and Pillow. - It searches every possible metadata field instead of just standard ones. - - Args: - file_path: Path to the file to analyze - - Returns: - Dictionary with extracted metadata including: - - prompt_graph: The extracted prompt graph if found - - workflow: The extracted workflow if found - - has_workflow: Boolean indicating if workflow/prompt was found - - metadata_sources: List of sources where metadata was found - - has_generation_metadata: Boolean indicating if generation metadata from any AI tool was found - - generation_metadata_source: Name of the tool that created the metadata (if detected) - """ - from pathlib import Path - from ..config import METADATA_EXT - from ..metadata.workflow_normalize import _ensure_dict_from_json - - file_path = Path(file_path) - if not file_path.exists(): - return {"has_workflow": False} - - # Initialize result - result = { - "prompt_graph": None, - "workflow": None, - "has_workflow": False, - "metadata_sources": [] - } - - ext = file_path.suffix.lower() - - # Track what we find - found_prompt = None - found_workflow = None - sources = [] - - # PATCH #2 - CRITICAL FIX: Check sidecar .mjr.json FIRST - # This is the primary metadata source for WEBP/MP4 that don't embed workflow - sidecar_path = Path(str(file_path) + METADATA_EXT) - if sidecar_path.exists(): - try: - with open(sidecar_path, 'r', encoding='utf-8') as f: - sidecar_data = json.load(f) - - if isinstance(sidecar_data, dict): - # Try to extract prompt (can be under "prompt" or "prompt_graph") - prompt_from_sidecar = sidecar_data.get("prompt") or sidecar_data.get("prompt_graph") - if prompt_from_sidecar: - # Handle case where it might be JSON string - prompt_from_sidecar = _ensure_dict_from_json(prompt_from_sidecar) - if isinstance(prompt_from_sidecar, dict): - found_prompt = prompt_from_sidecar - sources.append("sidecar") - - # Try to extract workflow - workflow_from_sidecar = sidecar_data.get("workflow") - if workflow_from_sidecar: - workflow_from_sidecar = _ensure_dict_from_json(workflow_from_sidecar) - if isinstance(workflow_from_sidecar, dict): - found_workflow = workflow_from_sidecar - if "sidecar" not in sources: - sources.append("sidecar") - - # If we found complete metadata in sidecar, return early (performance optimization) - if found_prompt or found_workflow: - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"📁✅ [Majoor] Found metadata in sidecar for {file_path.name}") - log.debug(f"📁🔍 [Majoor] - Has prompt: {found_prompt is not None}") - log.debug(f"📁🔍 [Majoor] - Has workflow: {found_workflow is not None}") - - # Early return with sidecar data - result["prompt_graph"] = found_prompt - result["workflow"] = found_workflow - result["has_workflow"] = True - result["metadata_sources"] = sources - return result - - except Exception as e: - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"đŸ“âš ī¸ [Majoor] Failed to read sidecar for {file_path.name}: {e}") - # Continue to other extraction methods - - # For PNG files, use enhanced PIL extraction - if ext == ".png": - prompt, workflow = extract_comprehensive_metadata_png(str(file_path)) - if prompt: - found_prompt = prompt - sources.append("png_info") - if workflow: - found_workflow = workflow - sources.append("png_info") - - # For video files, use multiple methods with enhanced scanning - elif ext in [".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v", ".wmv", ".flv"]: - # Try ExifTool first with enhanced scanning - prompt, workflow = extract_comprehensive_metadata_video_exiftool(str(file_path)) - if prompt: - found_prompt = prompt - sources.append("exiftool") - if workflow: - found_workflow = workflow - sources.append("exiftool") - - # Try FFprobe if ExifTool didn't find anything - if not found_prompt and not found_workflow: - prompt, workflow = extract_comprehensive_metadata_video_ffprobe(str(file_path)) - if prompt: - found_prompt = prompt - sources.append("ffprobe") - if workflow: - found_workflow = workflow - sources.append("ffprobe") - - # For other image formats, try both Pillow and ExifTool with enhanced scanning - elif ext in [".jpg", ".jpeg", ".tiff", ".tif", ".webp", ".gif", ".bmp", ".ico"]: - # CRITICAL FIX: Use dedicated EXIF extractor for ComfyUI data FIRST - # This handles prefixed formats like Make="workflow:{json}", Model="prompt:{json}" - try: - from .exif_extraction import extract_comfyui_prompt_workflow_from_exif_fields - - prompt, workflow, exif_sources = extract_comfyui_prompt_workflow_from_exif_fields(str(file_path)) - if prompt: - found_prompt = prompt - sources.extend(exif_sources) - if workflow: - found_workflow = workflow - if exif_sources and exif_sources not in sources: - sources.extend(exif_sources) - - if METADATA_DEBUG and (prompt or workflow): - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"📁✅ [Majoor] EXIF extractor found data in: {exif_sources}") - - except Exception as e: - if METADATA_DEBUG: - from ..logger import get_logger - log = get_logger(__name__) - log.debug(f"đŸ“âš ī¸ [Majoor] EXIF extractor failed: {e}") - - # Fallback: Try generic Pillow scan if still missing - if not found_prompt or not found_workflow: - try: - from PIL import Image - from PIL.ExifTags import TAGS - from ..logger import get_logger - - log = get_logger(__name__) - - with Image.open(file_path) as img: - # Extract PNG-style info if available - info = getattr(img, "info", {}) or {} - - # Extract EXIF data - exif_data = {} - try: - exif = img.getexif() - if exif: - for tag_id, value in exif.items(): - tag_name = TAGS.get(tag_id, f"Tag_{tag_id}") - exif_data[tag_name] = value - exif_data[f"0x{tag_id:04x}"] = value - - # Decode bytes values - if isinstance(value, bytes): - try: - decoded = value.decode('utf-8', errors='replace') - exif_data[f"{tag_name}_decoded"] = decoded - exif_data[f"0x{tag_id:04x}_decoded"] = decoded - except Exception: - pass - except Exception: - pass - - # Combine and scan - all_metadata = {**info, **exif_data} - if all_metadata: - prompt, workflow = _scan_all_metadata_fields(all_metadata) - if prompt and not found_prompt: - found_prompt = prompt - sources.append("pillow_scan") - if workflow and not found_workflow: - found_workflow = workflow - sources.append("pillow_scan") - except Exception as e: - if METADATA_DEBUG: - log.debug(f"[Majoor] Pillow fallback scan failed for {file_path}: {e}") - - # Then try ExifTool for even more comprehensive extraction - if not found_prompt or not found_workflow: - prompt, workflow = extract_comprehensive_metadata_video_exiftool(str(file_path)) - if prompt and not found_prompt: - found_prompt = prompt - sources.append("exiftool") - if workflow and not found_workflow: - found_workflow = workflow - sources.append("exiftool") - - # Check for generation metadata from other AI tools (A1111, Forge, NovelAI, etc.) - has_gen_metadata, gen_metadata_source = _detect_generation_metadata(str(file_path)) - - # Update result - result["prompt_graph"] = found_prompt - result["workflow"] = found_workflow - result["has_generation_metadata"] = has_gen_metadata - result["generation_metadata_source"] = gen_metadata_source - # Mark has_workflow as true if we found ComfyUI workflow OR generation metadata from other tools - result["has_workflow"] = bool(found_prompt or found_workflow or has_gen_metadata) - result["metadata_sources"] = sources - - # If we found a workflow but no prompt, try to normalize it - if found_workflow and not found_prompt: - normalized_prompt = _normalize_workflow_to_prompt_graph(found_workflow) - if normalized_prompt: - result["prompt_graph"] = normalized_prompt - - # If we found a prompt but no workflow, create a minimal workflow - if found_prompt and not found_workflow: - reconstructed_workflow = prompt_graph_to_workflow(found_prompt) - if reconstructed_workflow: - result["workflow"] = reconstructed_workflow - - return result - - -def has_generation_workflow_enhanced(file_path: str) -> bool: - """ - Enhanced version of has_generation_workflow that uses comprehensive extraction. - This function uses the enhanced extraction method to find workflow/prompt data - in any possible metadata field. - - PATCH #2: Ultra-fast sidecar check first for performance. - If sidecar exists and contains prompt/workflow, return True immediately. - """ - from pathlib import Path - from ..config import METADATA_EXT - - # Ultra-fast sidecar check first (PATCH #2) - sidecar_path = Path(str(file_path) + METADATA_EXT) - if sidecar_path.exists(): - try: - with open(sidecar_path, 'r', encoding='utf-8') as f: - sidecar_data = json.load(f) - if isinstance(sidecar_data, dict): - # Check for prompt/workflow/prompt_graph - if sidecar_data.get("prompt") or sidecar_data.get("workflow") or sidecar_data.get("prompt_graph"): - return True - except Exception: - pass - - # Fall back to comprehensive extraction - result = extract_comprehensive_metadata(file_path) - return result.get("has_workflow", False) - - -__all__ = [ - "extract_enhanced_metadata", - "write_enhanced_metadata", - "extract_workflow_hash", - "get_metadata_source_info", - "extract_comprehensive_metadata", - "has_generation_workflow_enhanced", - "_extract_json_from_text", - "_scan_all_metadata_fields" -] diff --git a/server/metadata/exif_extraction.py b/server/metadata/exif_extraction.py deleted file mode 100644 index 7623f511..00000000 --- a/server/metadata/exif_extraction.py +++ /dev/null @@ -1,238 +0,0 @@ -""" -Robust EXIF extraction for ComfyUI prompt/workflow data. -Handles various storage patterns used by different Save nodes. -""" -from typing import Optional, Dict, Any, Tuple, List - -from ..utils import safe_metadata_json_load - - -def extract_comfyui_prompt_workflow_from_exif_fields(file_path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], List[str]]: - """ - Extract ComfyUI prompt_graph and workflow from EXIF/metadata fields. - - Handles various patterns: - - "prompt:{json}" / "workflow:{json}" (prefixed format) - - "Prompt:{json}" / "Workflow:{json}" (capitalized) - - Direct JSON in tags (Make/Model/ImageDescription/UserComment) - - Args: - file_path: Path to image file - - Returns: - (prompt_graph, workflow, sources) tuple - - prompt_graph: dict with node IDs as keys, or None - - workflow: dict with nodes array, or None - - sources: list of tag names where data was found - """ - from PIL import Image - from PIL.ExifTags import TAGS - - prompt_graph = None - workflow = None - sources = [] - - try: - with Image.open(file_path) as img: - # 1. Check PNG-style info dict first (WEBP can have this) - info = getattr(img, "info", {}) or {} - - for key, value in info.items(): - if not isinstance(value, (str, bytes)): - continue - - # Decode bytes - if isinstance(value, bytes): - try: - value = value.decode('utf-8', errors='replace') - except: - continue - - # Try to extract JSON - extracted = _extract_from_field(str(key), value) - if extracted: - p, w = extracted - if p and not prompt_graph: - prompt_graph = p - sources.append(f"info.{key}") - if w and not workflow: - workflow = w - sources.append(f"info.{key}") - - # 2. Check EXIF tags - try: - exif = img.getexif() - if exif: - # Priority tags that commonly contain ComfyUI data - priority_tags = [ - (0x010F, 'Make'), # Often contains "workflow:{...}" - (0x0110, 'Model'), # Often contains "prompt:{...}" - (0x010E, 'ImageDescription'), - (0x9286, 'UserComment'), - ] - - # Check priority tags first - for tag_id, tag_name in priority_tags: - if tag_id in exif: - value = exif[tag_id] - - # Decode bytes - if isinstance(value, bytes): - try: - value = value.decode('utf-8', errors='replace') - except: - continue - - if isinstance(value, str): - extracted = _extract_from_field(tag_name, value) - if extracted: - p, w = extracted - if p and not prompt_graph: - prompt_graph = p - sources.append(f"exif.{tag_name}") - if w and not workflow: - workflow = w - sources.append(f"exif.{tag_name}") - - # If still missing, scan all other tags - if not prompt_graph or not workflow: - for tag_id, value in exif.items(): - if tag_id in [t[0] for t in priority_tags]: - continue # Already checked - - tag_name = TAGS.get(tag_id, f"Tag_{tag_id}") - - # Decode bytes - if isinstance(value, bytes): - try: - value = value.decode('utf-8', errors='replace') - except: - continue - - if isinstance(value, str): - extracted = _extract_from_field(tag_name, value) - if extracted: - p, w = extracted - if p and not prompt_graph: - prompt_graph = p - sources.append(f"exif.{tag_name}") - if w and not workflow: - workflow = w - sources.append(f"exif.{tag_name}") - - # Stop if we found both - if prompt_graph and workflow: - break - - except Exception: - pass - - except Exception: - pass - - return prompt_graph, workflow, sources - - -def _extract_from_field(field_name: str, value: str) -> Optional[Tuple[Optional[Dict], Optional[Dict]]]: - """ - Extract prompt_graph and/or workflow from a single metadata field. - - Returns: - (prompt_graph, workflow) tuple, or None if nothing found - """ - if not value or len(value) < 20: - return None - - value = value.strip() - value_lower = value.lower() - - prompt_graph = None - workflow = None - - # Pattern 1: Prefixed format "prompt:{json}" or "workflow:{json}" - if value_lower.startswith("prompt:"): - json_str = value[7:].lstrip() # Remove "prompt:" prefix - prompt_graph = _parse_json_safe(json_str) - if prompt_graph and _is_comfyui_prompt_graph(prompt_graph): - return (prompt_graph, None) - - if value_lower.startswith("workflow:"): - json_str = value[9:].lstrip() # Remove "workflow:" prefix - workflow = _parse_json_safe(json_str) - if workflow and _is_comfyui_workflow(workflow): - return (None, workflow) - - # Pattern 2: Direct JSON (starts with '{') - if value.startswith('{'): - obj = _parse_json_safe(value) - if obj: - if _is_comfyui_prompt_graph(obj): - return (obj, None) - elif _is_comfyui_workflow(obj): - return (None, obj) - - # Pattern 3: JSON embedded somewhere in text - if '{' in value: - # Find first '{' and try to extract balanced JSON - start = value.find('{') - if start != -1: - json_str = value[start:] - obj = _parse_json_safe(json_str) - if obj: - if _is_comfyui_prompt_graph(obj): - return (obj, None) - elif _is_comfyui_workflow(obj): - return (None, obj) - - return None - - -def _parse_json_safe(text: str) -> Optional[Dict]: - """Parse JSON safely, return None on error.""" - parsed = safe_metadata_json_load(text) - if isinstance(parsed, dict): - return parsed - return None - - -def _is_comfyui_prompt_graph(obj: Any) -> bool: - """ - Check if object looks like a ComfyUI prompt graph. - Prompt graph: dict with node IDs as keys, values have "class_type" field. - """ - if not isinstance(obj, dict) or len(obj) == 0: - return False - - # Check if at least one value looks like a ComfyUI node - node_count = 0 - for key, value in obj.items(): - if isinstance(value, dict) and "class_type" in value: - node_count += 1 - - # If at least 30% of entries look like nodes, it's likely a prompt graph - if node_count > 0 and node_count >= len(obj) * 0.3: - return True - - return False - - -def _is_comfyui_workflow(obj: Any) -> bool: - """ - Check if object looks like a ComfyUI workflow. - Workflow: dict with "nodes" array. - """ - if not isinstance(obj, dict): - return False - - # Primary indicator: "nodes" field that's a list - if "nodes" in obj and isinstance(obj["nodes"], list): - nodes = obj["nodes"] - if len(nodes) > 0: - # Check first node has workflow-specific fields - first_node = nodes[0] - if isinstance(first_node, dict): - # Workflow nodes have "type" (node class), not "class_type" - if "type" in first_node or "id" in first_node: - return True - - return False diff --git a/server/metadata/exif_usercomment.py b/server/metadata/exif_usercomment.py deleted file mode 100644 index d92e15b0..00000000 --- a/server/metadata/exif_usercomment.py +++ /dev/null @@ -1,313 +0,0 @@ -""" -EXIF UserComment Decoder with Multi-Encoding Support - -Handles EXIF UserComment field which can contain text in multiple encodings: -- UTF-16 LE/BE (with or without BOM) -- UTF-8 -- ASCII/Latin-1 -- EXIF-specific headers ("UNICODE\0", "ASCII\0\0\0", "JIS\0\0\0\0\0") - -Never raises exceptions - returns None on decode failure. - -Usage: - from server.metadata.exif_usercomment import decode_user_comment - - raw_bytes = exif_data.get(0x9286) # UserComment tag - text = decode_user_comment(raw_bytes) - if text: - print(f"Decoded: {text}") -""" - -import logging -from typing import Optional - -log = logging.getLogger(__name__) - - -# EXIF UserComment character code headers -EXIF_HEADERS = { - b"ASCII\x00\x00\x00": "ascii", - b"JIS\x00\x00\x00\x00\x00": "shift_jis", - b"UNICODE\x00": "utf-16", # Ambiguous, need BOM detection - b"\x00\x00\x00\x00\x00\x00\x00\x00": None, # Undefined (try auto-detect) -} - -# BOM (Byte Order Mark) signatures -BOM_UTF16_LE = b"\xff\xfe" -BOM_UTF16_BE = b"\xfe\xff" -BOM_UTF8 = b"\xef\xbb\xbf" - - -def detect_encoding(raw_bytes: bytes) -> Optional[str]: - """ - Detect encoding from EXIF UserComment bytes. - - Returns: - Encoding name ("utf-16le", "utf-16be", "utf-8", "ascii", "shift_jis") or None - """ - if not raw_bytes or len(raw_bytes) == 0: - return None - - # Check for EXIF character code headers (first 8 bytes) - for header, encoding in EXIF_HEADERS.items(): - if raw_bytes.startswith(header): - if encoding == "utf-16": - # UNICODE header found, check BOM in remaining bytes - remaining = raw_bytes[len(header):] - if remaining.startswith(BOM_UTF16_LE): - return "utf-16le" - elif remaining.startswith(BOM_UTF16_BE): - return "utf-16be" - else: - # No BOM, assume LE (Windows default) - return "utf-16le" - return encoding - - # No EXIF header, check for BOM at start - if raw_bytes.startswith(BOM_UTF16_LE): - return "utf-16le" - elif raw_bytes.startswith(BOM_UTF16_BE): - return "utf-16be" - elif raw_bytes.startswith(BOM_UTF8): - return "utf-8" - - # No BOM, try heuristics - # Check for null bytes pattern (UTF-16 signature) - if len(raw_bytes) >= 4: - # UTF-16 LE typically has pattern: char, \x00, char, \x00 - if raw_bytes[1] == 0 and raw_bytes[3] == 0: - return "utf-16le" - # UTF-16 BE: \x00, char, \x00, char - if raw_bytes[0] == 0 and raw_bytes[2] == 0: - return "utf-16be" - - # Default fallback: try UTF-8 first, then ASCII - # Return None to trigger fallback chain in decode_user_comment - return None - - -def sanitize_text(text: str) -> str: - """ - Sanitize decoded text. - - - Strip null bytes (\x00) - - Normalize line endings (\r\n → \n) - - Strip leading/trailing whitespace - - Returns: - Cleaned text - """ - if not text: - return "" - - # Remove null bytes - text = text.replace("\x00", "") - - # Normalize line endings - text = text.replace("\r\n", "\n").replace("\r", "\n") - - # Strip whitespace - text = text.strip() - - return text - - -def decode_user_comment(raw_bytes: bytes) -> Optional[str]: - """ - Decode EXIF UserComment field with multi-encoding support. - - Encoding detection order: - 1. EXIF character code header (ASCII, UNICODE, JIS, Undefined) - 2. BOM detection (UTF-16 LE/BE, UTF-8) - 3. Heuristic detection (null byte patterns for UTF-16) - 4. Fallback chain: UTF-8 → Latin-1/ASCII - - Args: - raw_bytes: Raw bytes from EXIF UserComment field - - Returns: - Decoded text string or None if decode fails - - Example: - >>> decode_user_comment(b"ASCII\\x00\\x00\\x00Hello World") - 'Hello World' - >>> decode_user_comment(b"\\xff\\xfeH\\x00e\\x00l\\x00l\\x00o\\x00") - 'Hello' - >>> decode_user_comment(b"Bonjour") - 'Bonjour' - """ - if not raw_bytes: - return None - - try: - # Detect encoding - encoding = detect_encoding(raw_bytes) - - # Strip EXIF header if present - data = raw_bytes - for header in EXIF_HEADERS.keys(): - if raw_bytes.startswith(header): - data = raw_bytes[len(header):] - break - - # Strip BOM if present - if data.startswith(BOM_UTF16_LE): - data = data[len(BOM_UTF16_LE):] - elif data.startswith(BOM_UTF16_BE): - data = data[len(BOM_UTF16_BE):] - elif data.startswith(BOM_UTF8): - data = data[len(BOM_UTF8):] - - # If no data left after stripping, return None - if not data: - return None - - # Try detected encoding first - if encoding: - try: - text = data.decode(encoding, errors="ignore") - text = sanitize_text(text) - if text: # Only return if non-empty after sanitization - return text - except (UnicodeDecodeError, LookupError) as e: - log.debug(f"[Majoor] Failed to decode with detected encoding '{encoding}': {e}") - - # Fallback chain: UTF-8 → Latin-1 → ASCII - for fallback_encoding in ["utf-8", "latin-1", "ascii"]: - try: - text = data.decode(fallback_encoding, errors="ignore") - text = sanitize_text(text) - if text: # Only return if non-empty - log.debug(f"[Majoor] Decoded UserComment with fallback encoding '{fallback_encoding}'") - return text - except (UnicodeDecodeError, LookupError): - continue - - # All decoding attempts failed - log.debug("[Majoor] All encoding attempts failed for UserComment") - return None - - except Exception as e: - # Never crash - return None on any unexpected error - log.debug(f"[Majoor] Unexpected error decoding UserComment: {e}") - return None - - -def decode_user_comment_with_metadata(raw_bytes: bytes) -> Optional[dict]: - """ - Decode UserComment and return metadata about the decoding process. - - Useful for debugging and logging. - - Returns: - { - "text": str, - "encoding": str, - "had_exif_header": bool, - "had_bom": bool, - "original_length": int, - "decoded_length": int - } - or None if decode fails - """ - if not raw_bytes: - return None - - try: - encoding = detect_encoding(raw_bytes) - had_exif_header = False - had_bom = False - - # Check for EXIF header - for header in EXIF_HEADERS.keys(): - if raw_bytes.startswith(header): - had_exif_header = True - break - - # Check for BOM - if raw_bytes.startswith((BOM_UTF16_LE, BOM_UTF16_BE, BOM_UTF8)): - had_bom = True - - # Decode - text = decode_user_comment(raw_bytes) - - if text is None: - return None - - return { - "text": text, - "encoding": encoding or "fallback", - "had_exif_header": had_exif_header, - "had_bom": had_bom, - "original_length": len(raw_bytes), - "decoded_length": len(text) - } - - except Exception as e: - log.debug(f"[Majoor] Error in decode_user_comment_with_metadata: {e}") - return None - - -# Self-test -if __name__ == "__main__": - # Test cases - test_cases = [ - # ASCII with EXIF header - (b"ASCII\x00\x00\x00Hello World", "Hello World", "ASCII header"), - - # UTF-16 LE with BOM - (b"\xff\xfeH\x00e\x00l\x00l\x00o\x00", "Hello", "UTF-16 LE with BOM"), - - # UTF-16 BE with BOM - (b"\xfe\xff\x00H\x00e\x00l\x00l\x00o", "Hello", "UTF-16 BE with BOM"), - - # UTF-8 with BOM - (b"\xef\xbb\xbfBonjour", "Bonjour", "UTF-8 with BOM"), - - # UNICODE header + UTF-16 LE BOM - (b"UNICODE\x00\xff\xfeH\x00i\x00", "Hi", "UNICODE header + BOM"), - - # Plain ASCII (no header, no BOM) - (b"Plain text", "Plain text", "Plain ASCII"), - - # UTF-8 without BOM - (b"\xc3\xa9cole", "Êcole", "UTF-8 without BOM"), - - # Empty - (b"", None, "Empty bytes"), - - # Null bytes only - (b"\x00\x00\x00", None, "Null bytes"), - - # Undefined header (8 null bytes) + text - (b"\x00\x00\x00\x00\x00\x00\x00\x00Test", "Test", "Undefined header"), - ] - - print("Running EXIF UserComment decoder self-tests...\n") - passed = 0 - failed = 0 - - for raw_bytes, expected, description in test_cases: - result = decode_user_comment(raw_bytes) - status = "[PASS]" if result == expected else "[FAIL]" - - if result == expected: - passed += 1 - else: - failed += 1 - - print(f"{status} {description}") - print(f" Input: {raw_bytes[:50]!r}{'...' if len(raw_bytes) > 50 else ''}") - print(f" Expected: {expected!r}") - print(f" Got: {result!r}") - - # Show metadata - if raw_bytes: - metadata = decode_user_comment_with_metadata(raw_bytes) - if metadata: - print(f" Encoding: {metadata['encoding']}, " - f"EXIF header: {metadata['had_exif_header']}, " - f"BOM: {metadata['had_bom']}") - print() - - print(f"\nResults: {passed} passed, {failed} failed") diff --git a/server/metadata/png_inject.py b/server/metadata/png_inject.py deleted file mode 100644 index 62688027..00000000 --- a/server/metadata/png_inject.py +++ /dev/null @@ -1,587 +0,0 @@ -""" -Lossless PNG Metadata Injector - -Injects metadata into PNG files using chunk manipulation (no re-encoding). - -Features: -- Read/write PNG chunks without re-encoding image data -- CRC32 validation for chunk integrity -- Namespaced keys (mjr:tags, mjr:rating, mjr:notes, mjr:workflow_hash) -- Uses iTXt (international text) for UTF-8 support -- Never corrupts existing PNG data - -PNG Structure: - [Signature: 8 bytes] - [IHDR chunk] - [... other chunks ...] - [IDAT chunks - actual image data] - [... optional chunks ...] - [IEND chunk] - -We inject metadata chunks BEFORE IEND to preserve file integrity. - -Usage: - from server.metadata.png_inject import inject_metadata, read_metadata - - # Read existing metadata - metadata = read_metadata("output/image.png") - print(metadata.get("mjr:rating")) - - # Inject new metadata - inject_metadata("output/image.png", { - "mjr:rating": "5", - "mjr:tags": '["portrait", "photorealistic"]', - "mjr:workflow_hash": "abc123..." - }) -""" - -import struct -import zlib -import logging -from typing import Dict, List, Optional, Tuple -from pathlib import Path - -log = logging.getLogger(__name__) - - -# PNG file signature -PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n' - -# Chunk types -CHUNK_IEND = b'IEND' -CHUNK_ITXT = b'iTXt' # International text (UTF-8) -CHUNK_TEXT = b'tEXt' # Latin-1 text -CHUNK_ZTXT = b'zTXt' # Compressed Latin-1 text - -# Namespace for our metadata -METADATA_NAMESPACE = "mjr:" - - -class PNGChunk: - """Represents a PNG chunk.""" - - def __init__(self, chunk_type: bytes, data: bytes): - """ - Create PNG chunk. - - Args: - chunk_type: 4-byte chunk type (e.g., b'IEND', b'iTXt') - data: Chunk data - """ - self.chunk_type = chunk_type - self.data = data - - def to_bytes(self) -> bytes: - """ - Serialize chunk to bytes with length and CRC32. - - PNG chunk format: - [Length: 4 bytes] [Type: 4 bytes] [Data: N bytes] [CRC: 4 bytes] - """ - length = len(self.data) - crc = zlib.crc32(self.chunk_type + self.data) & 0xffffffff - - return struct.pack('>I', length) + self.chunk_type + self.data + struct.pack('>I', crc) - - @staticmethod - def from_bytes(data: bytes, offset: int) -> Tuple['PNGChunk', int]: - """ - Parse chunk from bytes. - - Args: - data: PNG file bytes - offset: Starting position - - Returns: - (chunk, new_offset) - """ - # Read length (4 bytes, big-endian) - length = struct.unpack('>I', data[offset:offset+4])[0] - - # Read chunk type (4 bytes) - chunk_type = data[offset+4:offset+8] - - # Read chunk data - chunk_data = data[offset+8:offset+8+length] - - # Read CRC (4 bytes) - stored_crc = struct.unpack('>I', data[offset+8+length:offset+12+length])[0] - - # Validate CRC - calculated_crc = zlib.crc32(chunk_type + chunk_data) & 0xffffffff - if calculated_crc != stored_crc: - log.warning(f"[Majoor] CRC mismatch in {chunk_type.decode('latin-1', errors='ignore')} chunk") - - new_offset = offset + 12 + length - return PNGChunk(chunk_type, chunk_data), new_offset - - def __repr__(self): - return f"PNGChunk(type={self.chunk_type}, data_len={len(self.data)})" - - -def read_png_chunks(file_path: str) -> Optional[List[PNGChunk]]: - """ - Read all chunks from PNG file. - - Args: - file_path: Path to PNG file - - Returns: - List of PNGChunk objects or None if not a valid PNG - """ - try: - with open(file_path, 'rb') as f: - data = f.read() - - # Validate PNG signature - if not data.startswith(PNG_SIGNATURE): - log.debug(f"[Majoor] Not a PNG file: {file_path}") - return None - - chunks = [] - offset = len(PNG_SIGNATURE) - - # Parse all chunks - while offset < len(data): - try: - chunk, offset = PNGChunk.from_bytes(data, offset) - chunks.append(chunk) - - # Stop after IEND - if chunk.chunk_type == CHUNK_IEND: - break - - except Exception as e: - log.debug(f"[Majoor] Error parsing chunk at offset {offset}: {e}") - break - - return chunks - - except Exception as e: - log.debug(f"[Majoor] Error reading PNG file {file_path}: {e}") - return None - - -def create_itxt_chunk(key: str, value: str) -> PNGChunk: - """ - Create iTXt chunk with UTF-8 text. - - iTXt chunk format: - Keyword: null-terminated string (Latin-1) - Compression flag: 1 byte (0 = uncompressed) - Compression method: 1 byte (0) - Language tag: null-terminated string (empty for us) - Translated keyword: null-terminated string (empty for us) - Text: UTF-8 string (not null-terminated) - - Args: - key: Metadata key (e.g., "mjr:rating") - value: Metadata value (UTF-8 string) - - Returns: - iTXt chunk - """ - # Build chunk data - keyword = key.encode('latin-1', errors='ignore') + b'\x00' - compression_flag = b'\x00' # Uncompressed - compression_method = b'\x00' - language_tag = b'\x00' # Empty - translated_keyword = b'\x00' # Empty - text = value.encode('utf-8', errors='ignore') - - data = keyword + compression_flag + compression_method + language_tag + translated_keyword + text - - return PNGChunk(CHUNK_ITXT, data) - - -def parse_itxt_chunk(chunk: PNGChunk) -> Optional[Tuple[str, str]]: - """ - Parse iTXt chunk. - - Returns: - (key, value) or None if invalid - """ - if chunk.chunk_type != CHUNK_ITXT: - return None - - try: - data = chunk.data - - # Find null terminator for keyword - null_idx = data.find(b'\x00') - if null_idx == -1: - return None - - keyword = data[:null_idx].decode('latin-1', errors='ignore') - - # Skip: compression flag (1), compression method (1) - offset = null_idx + 1 + 2 - - # Find null terminator for language tag - null_idx = data.find(b'\x00', offset) - if null_idx == -1: - return None - - # Skip language tag - offset = null_idx + 1 - - # Find null terminator for translated keyword - null_idx = data.find(b'\x00', offset) - if null_idx == -1: - return None - - # Skip translated keyword - offset = null_idx + 1 - - # Remaining data is the text (UTF-8) - text = data[offset:].decode('utf-8', errors='ignore') - - return keyword, text - - except Exception as e: - log.debug(f"[Majoor] Error parsing iTXt chunk: {e}") - return None - - -def read_metadata(file_path: str) -> Dict[str, str]: - """ - Read metadata from PNG file. - - Only returns keys in our namespace (mjr:*). - - Args: - file_path: Path to PNG file - - Returns: - Dictionary of metadata (e.g., {"mjr:rating": "5", ...}) - """ - chunks = read_png_chunks(file_path) - if not chunks: - return {} - - metadata = {} - - for chunk in chunks: - if chunk.chunk_type == CHUNK_ITXT: - result = parse_itxt_chunk(chunk) - if result: - key, value = result - # Only return our namespaced keys - if key.startswith(METADATA_NAMESPACE): - metadata[key] = value - - return metadata - - -def inject_metadata( - file_path: str, - metadata: Dict[str, str], - backup: bool = True -) -> bool: - """ - Inject metadata into PNG file (lossless). - - Creates new iTXt chunks and inserts them before IEND. - Removes old chunks with same keys first. - - Args: - file_path: Path to PNG file - metadata: Dictionary of metadata to inject - backup: Create backup file (.png.backup) before modifying - - Returns: - True if successful, False otherwise - - Example: - >>> inject_metadata("output/image.png", { - ... "mjr:rating": "5", - ... "mjr:tags": '["portrait", "photorealistic"]' - ... }) - True - """ - try: - # Read existing chunks - chunks = read_png_chunks(file_path) - if not chunks: - log.warning(f"[Majoor] Cannot inject metadata: not a valid PNG file: {file_path}") - return False - - # Create backup if requested (or force on Windows for safety) - try: - from ..config import IS_WINDOWS - except Exception: - try: - from config import IS_WINDOWS # type: ignore - except Exception: - IS_WINDOWS = False - if IS_WINDOWS and not backup: - log.debug("[Majoor] Forcing backup on Windows for data safety") - backup = True - - backup_path = None - if backup: - backup_path = Path(file_path).with_suffix('.png.backup') - try: - import shutil - shutil.copy2(file_path, backup_path) - - # Verify backup - if not backup_path.exists(): - log.error(f"[Majoor] Backup file not created: {backup_path}") - return False - - # Verify backup size matches original - import os - if os.path.getsize(backup_path) != os.path.getsize(file_path): - log.error(f"[Majoor] Backup size mismatch for {file_path}") - return False - - log.debug(f"[Majoor] Created and verified backup: {backup_path}") - except Exception as e: - log.error(f"[Majoor] Failed to create backup: {e}") - return False - - # Remove existing metadata chunks with same keys - keys_to_inject = set(metadata.keys()) - filtered_chunks = [] - - for chunk in chunks: - # Keep chunk if it's not our metadata - if chunk.chunk_type != CHUNK_ITXT: - filtered_chunks.append(chunk) - else: - result = parse_itxt_chunk(chunk) - if result: - key, _ = result - # Keep if not in keys we're injecting - if key not in keys_to_inject: - filtered_chunks.append(chunk) - - # Create new metadata chunks - new_chunks = [] - for key, value in metadata.items(): - # Ensure key is namespaced - if not key.startswith(METADATA_NAMESPACE): - key = METADATA_NAMESPACE + key - - new_chunks.append(create_itxt_chunk(key, value)) - - # Build new PNG file - # Structure: [signature] [existing chunks except IEND] [new chunks] [IEND] - output = bytearray(PNG_SIGNATURE) - - iend_chunk = None - for chunk in filtered_chunks: - if chunk.chunk_type == CHUNK_IEND: - iend_chunk = chunk - else: - output.extend(chunk.to_bytes()) - - # Add new metadata chunks - for chunk in new_chunks: - output.extend(chunk.to_bytes()) - - # Add IEND at the end - if iend_chunk: - output.extend(iend_chunk.to_bytes()) - else: - # Create new IEND if missing - output.extend(PNGChunk(CHUNK_IEND, b'').to_bytes()) - - # Write to file (with rollback on failure) - try: - with open(file_path, 'wb') as f: - f.write(output) - - # Verify write succeeded by checking file size - import os - if not os.path.exists(file_path) or os.path.getsize(file_path) < len(output): - raise IOError("PNG write verification failed (file size mismatch)") - - log.debug(f"[Majoor] Injected {len(metadata)} metadata keys into {file_path}") - return True - - except Exception as write_error: - # Rollback: restore from backup if available - if backup_path and backup_path.exists(): - log.error(f"[Majoor] PNG write failed, restoring from backup: {write_error}") - try: - import shutil - shutil.copy2(backup_path, file_path) - log.info(f"[Majoor] Successfully restored {file_path} from backup") - except Exception as rollback_error: - log.critical(f"[Majoor] CRITICAL: Failed to restore from backup: {rollback_error}") - raise write_error - - except Exception as e: - log.error(f"[Majoor] Error injecting metadata into {file_path}: {e}") - return False - - -def remove_metadata(file_path: str, keys: Optional[List[str]] = None, backup: bool = True) -> bool: - """ - Remove metadata from PNG file. - - Args: - file_path: Path to PNG file - keys: List of keys to remove (None = remove all mjr:* keys) - backup: Create backup before modifying - - Returns: - True if successful - """ - try: - chunks = read_png_chunks(file_path) - if not chunks: - return False - - # Create backup - if backup: - backup_path = Path(file_path).with_suffix('.png.backup') - import shutil - shutil.copy2(file_path, backup_path) - - # Filter chunks - keys_to_remove = set(keys) if keys else None - filtered_chunks = [] - - for chunk in chunks: - keep = True - - if chunk.chunk_type == CHUNK_ITXT: - result = parse_itxt_chunk(chunk) - if result: - key, _ = result - - # Remove if: - # - No specific keys given and key is namespaced - # - Specific keys given and key is in list - if keys_to_remove is None: - if key.startswith(METADATA_NAMESPACE): - keep = False - else: - if key in keys_to_remove: - keep = False - - if keep: - filtered_chunks.append(chunk) - - # Rebuild PNG - output = bytearray(PNG_SIGNATURE) - for chunk in filtered_chunks: - output.extend(chunk.to_bytes()) - - with open(file_path, 'wb') as f: - f.write(output) - - return True - - except Exception as e: - log.error(f"[Majoor] Error removing metadata from {file_path}: {e}") - return False - - -# Self-test -if __name__ == "__main__": - import tempfile - import os - - print("Running PNG metadata injector self-tests...\n") - - # Create a minimal valid PNG (1x1 white pixel) - minimal_png = ( - PNG_SIGNATURE + - # IHDR chunk - b'\x00\x00\x00\x0d' + # Length: 13 - b'IHDR' + - b'\x00\x00\x00\x01' + # Width: 1 - b'\x00\x00\x00\x01' + # Height: 1 - b'\x08\x06\x00\x00\x00' + # Bit depth: 8, Color type: 6 (RGBA), Compression: 0, Filter: 0, Interlace: 0 - b'\x1f\x15\xc4\x89' + # CRC - # IDAT chunk (compressed image data) - b'\x00\x00\x00\x0a' + # Length: 10 - b'IDAT' + - b'\x78\x9c\x62\x00\x01\x00\x00\x05\x00\x01' + # Compressed data (white pixel) - b'\x0d\x0a\x2d\xb4' + # CRC - # IEND chunk - b'\x00\x00\x00\x00' + # Length: 0 - b'IEND' + - b'\xae\x42\x60\x82' # CRC - ) - - # Create temporary file - with tempfile.NamedTemporaryFile(mode='wb', suffix='.png', delete=False) as f: - temp_file = f.name - f.write(minimal_png) - - try: - # Test 1: Read chunks - print("TEST 1: Read PNG chunks") - chunks = read_png_chunks(temp_file) - assert chunks is not None - assert len(chunks) == 3 # IHDR, IDAT, IEND - print(f"[PASS] Read {len(chunks)} chunks") - - # Test 2: Inject metadata - print("\nTEST 2: Inject metadata") - metadata = { - "mjr:rating": "5", - "mjr:tags": '["test", "automated"]', - "mjr:workflow_hash": "abc123def456" - } - result = inject_metadata(temp_file, metadata, backup=False) - assert result is True - print("[PASS] Metadata injected") - - # Test 3: Read back metadata - print("\nTEST 3: Read back metadata") - read_back = read_metadata(temp_file) - assert read_back["mjr:rating"] == "5" - assert read_back["mjr:tags"] == '["test", "automated"]' - assert read_back["mjr:workflow_hash"] == "abc123def456" - print(f"[PASS] Read back: {read_back}") - - # Test 4: Update metadata (replace existing) - print("\nTEST 4: Update metadata") - updated_metadata = { - "mjr:rating": "4", # Changed - "mjr:notes": "Test note" # New - } - inject_metadata(temp_file, updated_metadata, backup=False) - read_back = read_metadata(temp_file) - assert read_back["mjr:rating"] == "4" - assert read_back["mjr:notes"] == "Test note" - assert read_back["mjr:tags"] == '["test", "automated"]' # Still exists - print(f"[PASS] Updated: {read_back}") - - # Test 5: Remove specific keys - print("\nTEST 5: Remove specific metadata keys") - remove_metadata(temp_file, keys=["mjr:notes"], backup=False) - read_back = read_metadata(temp_file) - assert "mjr:notes" not in read_back - assert "mjr:rating" in read_back - print(f"[PASS] Removed mjr:notes: {read_back}") - - # Test 6: Remove all metadata - print("\nTEST 6: Remove all metadata") - remove_metadata(temp_file, keys=None, backup=False) - read_back = read_metadata(temp_file) - assert len(read_back) == 0 - print("[PASS] All metadata removed") - - # Test 7: Verify PNG still valid - print("\nTEST 7: Verify PNG integrity") - chunks = read_png_chunks(temp_file) - assert chunks is not None - assert chunks[-1].chunk_type == CHUNK_IEND - print("[PASS] PNG structure intact") - - print("\n" + "="*50) - print("All tests passed successfully!") - - finally: - # Cleanup - os.unlink(temp_file) - backup_file = temp_file + '.backup' - if os.path.exists(backup_file): - os.unlink(backup_file) diff --git a/server/metadata/video_extraction.py b/server/metadata/video_extraction.py deleted file mode 100644 index 9cf94b22..00000000 --- a/server/metadata/video_extraction.py +++ /dev/null @@ -1,526 +0,0 @@ -import copy -import json -import os -import shutil -import subprocess -import threading -import time -from collections import OrderedDict -from pathlib import Path -from typing import Any, Dict, Optional, Tuple - -from .workflow_normalize import _normalize_workflow_to_prompt_graph, _ensure_dict_from_json, _json_loads_relaxed -from .workflow_reconstruct import prompt_graph_to_workflow -from ..logger import get_logger -from ..utils import _get_exiftool_path - -log = get_logger(__name__) - -_VIDEO_META_CACHE_LOCK = threading.Lock() -_VIDEO_META_CACHE: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() - - -def _video_meta_cache_cfg() -> tuple[int, float, bool]: - """ - Returns (max_entries, ttl_seconds, debug). - - max_entries: 0 disables caching - - ttl_seconds: 0 disables TTL expiry (cache lives until eviction) - """ - try: - max_entries = int(os.environ.get("MJR_VIDEO_META_CACHE_SIZE", "256")) - except Exception: - max_entries = 256 - max_entries = max(0, min(max_entries, 10_000)) - - try: - ttl = float(os.environ.get("MJR_VIDEO_META_CACHE_TTL", "0")) - except Exception: - ttl = 0.0 - ttl = max(0.0, min(ttl, 24 * 60 * 60)) - - dbg = (os.environ.get("MJR_VIDEO_META_CACHE_DEBUG", "") or "").strip().lower() in ( - "1", - "true", - "yes", - "on", - ) - return max_entries, ttl, dbg - - -def _video_meta_cache_key(path: Path) -> str: - try: - return os.path.normcase(os.path.abspath(str(path))) - except Exception: - return str(path) - - -def _video_meta_signature(path: Path) -> Optional[tuple[int, int]]: - try: - st = path.stat() - mtime_ns = getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000)) - size = int(st.st_size) - return int(mtime_ns), size - except Exception: - return None - - -def _video_meta_cache_get(path: Path) -> Optional[Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]]: - max_entries, ttl, dbg = _video_meta_cache_cfg() - if max_entries <= 0: - return None - - key = _video_meta_cache_key(path) - sig = _video_meta_signature(path) - if sig is None: - return None - - now = time.time() - with _VIDEO_META_CACHE_LOCK: - entry = _VIDEO_META_CACHE.get(key) - if not entry: - return None - if entry.get("sig") != sig: - _VIDEO_META_CACHE.pop(key, None) - return None - if ttl > 0 and (now - float(entry.get("ts", 0.0))) > ttl: - _VIDEO_META_CACHE.pop(key, None) - return None - _VIDEO_META_CACHE.move_to_end(key) - if dbg: - log.debug("[Majoor] video meta cache hit: %s", path.name) - return copy.deepcopy(entry.get("prompt")), copy.deepcopy(entry.get("workflow")) - - -def _video_meta_cache_put( - path: Path, - prompt: Optional[Dict[str, Any]], - workflow: Optional[Dict[str, Any]], -) -> None: - max_entries, ttl, dbg = _video_meta_cache_cfg() - if max_entries <= 0: - return - - key = _video_meta_cache_key(path) - sig = _video_meta_signature(path) - if sig is None: - return - - with _VIDEO_META_CACHE_LOCK: - _VIDEO_META_CACHE[key] = { - "sig": sig, - "ts": time.time(), - "prompt": copy.deepcopy(prompt), - "workflow": copy.deepcopy(workflow), - } - _VIDEO_META_CACHE.move_to_end(key) - - evicted = 0 - while len(_VIDEO_META_CACHE) > max_entries: - _VIDEO_META_CACHE.popitem(last=False) - evicted += 1 - if dbg: - msg = f"[Majoor] video meta cache store: {path.name}" - if evicted: - msg += f" (evicted {evicted})" - log.debug("%s", msg) - - -def _balanced_json_from_text(text: str, start_idx: int, max_chars: int = 2_000_000) -> Optional[str]: - """ - Simple balanced-brace scanner starting at `start_idx` (expected to be '{'). - Stops when depth returns to zero or when exceeding `max_chars`. - Ignores braces inside quoted strings. - """ - if start_idx < 0 or start_idx >= len(text) or text[start_idx] != "{": - return None - - depth = 0 - in_str = False - escape = False - limit = min(len(text), start_idx + max_chars) - - for i in range(start_idx, limit): - ch = text[i] - if in_str: - if escape: - escape = False - elif ch == "\\": - escape = True - elif ch == '"': - in_str = False - continue - - if ch == '"': - in_str = True - continue - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - return text[start_idx : i + 1] - return None - - -def _extract_json_from_video(path: Path) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - """ - Extract prompt/workflow JSON from a video container using exiftool. - Returns (prompt_graph, raw_workflow). - """ - cached = _video_meta_cache_get(path) - if cached is not None: - return cached - - exe = _get_exiftool_path() if callable(_get_exiftool_path) else None - found_prompt = None - found_workflow = None - - try: - exif_timeout = float(os.environ.get("MJR_META_EXIFTOOL_TIMEOUT", "12")) - except Exception: - exif_timeout = 12.0 - exif_timeout = max(1.0, min(exif_timeout, 60.0)) - - try: - ffprobe_timeout = float(os.environ.get("MJR_META_FFPROBE_TIMEOUT", "8")) - except Exception: - ffprobe_timeout = 8.0 - ffprobe_timeout = max(1.0, min(ffprobe_timeout, 30.0)) - - def parse_vhs_json(json_str: str): - nonlocal found_prompt, found_workflow - try: - payload = _json_loads_relaxed(json_str) - if not isinstance(payload, dict): - return - if "workflow" in payload: - wf = payload["workflow"] - if isinstance(wf, str): - wf = _ensure_dict_from_json(wf) - if isinstance(wf, dict) and wf.get("nodes"): - found_workflow = wf - if "prompt" in payload: - pr = payload["prompt"] - if isinstance(pr, str): - pr = _ensure_dict_from_json(pr) - if isinstance(pr, dict): - first = next(iter(pr.values()), None) - if isinstance(first, dict) and "inputs" in first: - found_prompt = pr - if found_workflow is None and isinstance(payload.get("nodes"), list): - found_workflow = payload - if found_prompt is None: - found_prompt = _normalize_workflow_to_prompt_graph(payload) - if found_prompt is None: - if any(isinstance(v, dict) and "inputs" in v for v in payload.values() if isinstance(v, dict)): - found_prompt = payload - except Exception: - pass - - def recursive_scan(obj): - nonlocal found_prompt, found_workflow - if found_prompt and found_workflow: - return - if isinstance(obj, dict): - for k, v in obj.items(): - key_norm = str(k).lower() - - # QuickTime Keys tags contain workflow/prompt directly - if key_norm in ("keys:workflow", "quicktime:workflow") and isinstance(v, str): - s = v.strip() - if s.startswith("{"): - try: - wf = _json_loads_relaxed(s) - if isinstance(wf, dict) and wf.get("nodes"): - found_workflow = wf - except Exception: - pass - continue - - if key_norm in ("keys:prompt", "quicktime:prompt") and isinstance(v, str): - s = v.strip() - if s.startswith("{"): - try: - pr = _json_loads_relaxed(s) - if isinstance(pr, dict): - first = next(iter(pr.values()), None) - if isinstance(first, dict) and "inputs" in first: - found_prompt = pr - except Exception: - pass - continue - - key_interesting = key_norm in ( - "comment", - "usercomment", - "description", - "parameters", - "xpcomment", - "userdata", - "user_data", - "xmp:comment", - "xmp:description", - "xmp:usercomment", - "itemlist:comment", - "itemlist:description", - "itemlist:usercomment", - ) - if isinstance(v, str): - s = v.strip() - if s.startswith("{"): - parse_vhs_json(s) - elif any(tok in s for tok in ('"prompt"', '"workflow"', '"nodes"', '"extra_pnginfo"', '"class_type"', '"inputs"')): - start = s.find("{") - if start != -1: - candidate = _balanced_json_from_text(s, start) - if candidate: - parse_vhs_json(candidate) - elif key_interesting: - # If the value is not obvious JSON but comes from a comment-like field, try to locate balanced JSON anyway - start = s.find("{") - if start != -1: - candidate = _balanced_json_from_text(s, start) - if candidate: - parse_vhs_json(candidate) - elif isinstance(v, (dict, list)): - recursive_scan(v) - return - if isinstance(obj, list): - for v in obj: - recursive_scan(v) - return - if isinstance(obj, str): - s = obj.strip() - if s.startswith("{"): - parse_vhs_json(s) - elif any(tok in s for tok in ('"prompt"', '"workflow"', '"nodes"')): - start = s.find("{") - if start != -1: - candidate = _balanced_json_from_text(s, start) - if candidate: - parse_vhs_json(candidate) - - # Preferred order for video metadata extraction: - # 1) ExifTool (best coverage for container/user metadata) - # 2) ffprobe (format/stream tags) - # 3) Windows Property System (Explorer properties like Comment) - # FIX: Use subprocess.run with proper cleanup instead of check_output - if exe and not (found_prompt and found_workflow): - proc = None - try: - cmd = [ - exe, - "-j", - "-Comment", - "-UserComment", - "-Description", - "-XPComment", - "-Parameters", - "-XMP:Comment", - "-XMP:Description", - "-XMP:UserComment", - "-ItemList:Comment", - "-ItemList:Description", - "-ItemList:UserComment", - "-Keys:Workflow", - "-Keys:Prompt", - str(path), - ] - proc = subprocess.run( - cmd, - capture_output=True, - timeout=exif_timeout, - check=False - ) - if proc.returncode == 0 and proc.stdout: - data_list = json.loads(proc.stdout) - if data_list: - recursive_scan(data_list[0]) - except subprocess.TimeoutExpired: - if proc: - proc.kill() - proc.wait() - log.warning( - "[Majoor] ExifTool (light) timeout on %s after %ss", - path.name, - exif_timeout, - ) - except Exception: - pass - finally: - if proc and proc.returncode is None: - try: - proc.kill() - proc.wait() - except Exception: - pass - - if exe and not (found_prompt and found_workflow): - proc = None - try: - cmd = [exe, "-j", "-g", "-ee", str(path)] - proc = subprocess.run( - cmd, - capture_output=True, - timeout=exif_timeout, - check=False - ) - if proc.returncode == 0 and proc.stdout: - data_list = json.loads(proc.stdout) - if data_list: - recursive_scan(data_list[0]) - except subprocess.TimeoutExpired: - if proc: - proc.kill() - proc.wait() - log.warning( - "[Majoor] ExifTool timeout on %s after %ss", - path.name, - exif_timeout, - ) - except (json.JSONDecodeError, OSError) as e: - log.warning("[Majoor] ExifTool error on %s: %s", path.name, e) - except Exception as e: - log.warning("[Majoor] ExifTool unexpected error on %s: %s", path.name, e) - finally: - if proc and proc.returncode is None: - try: - proc.kill() - proc.wait() - except Exception: - pass - - if not found_prompt and not found_workflow: - ffprobe = shutil.which("ffprobe") - if ffprobe: - proc = None - try: - cmd = [ - ffprobe, - "-v", - "error", - "-print_format", - "json", - "-show_entries", - "format_tags:stream_tags", - str(path), - ] - proc = subprocess.run( - cmd, - capture_output=True, - timeout=ffprobe_timeout, - check=False - ) - if proc.returncode == 0 and proc.stdout: - data = json.loads(proc.stdout) - tags = (data.get("format") or {}).get("tags") or {} - recursive_scan(tags) - for stream in data.get("streams") or []: - recursive_scan(stream.get("tags") or {}) - except subprocess.TimeoutExpired: - if proc: - proc.kill() - proc.wait() - log.warning( - "[Majoor] ffprobe timeout on %s after %ss", - path.name, - ffprobe_timeout, - ) - except (json.JSONDecodeError, OSError) as e: - log.warning("[Majoor] ffprobe error on %s: %s", path.name, e) - except Exception as e: - log.warning("[Majoor] ffprobe unexpected error on %s: %s", path.name, e) - finally: - if proc and proc.returncode is None: - try: - proc.kill() - proc.wait() - except Exception: - pass - - if not found_prompt and not found_workflow and os.name == "nt": - try: - import pythoncom # type: ignore - from win32com.propsys import propsys, pscon # type: ignore - - pythoncom.CoInitialize() - store = propsys.SHGetPropertyStoreFromParsingName(str(path), None, pscon.GPS_BESTEFFORT) - props = {} - for kname in ("PKEY_Comment", "PKEY_Title", "PKEY_Subject", "PKEY_Keywords", "PKEY_Category"): - key = getattr(pscon, kname, None) - if key is None: - continue - try: - val = store.GetValue(key).GetValue() - except Exception: - continue - if val is None: - continue - if isinstance(val, tuple): - val = list(val) - props[kname[5:].lower()] = val - if props: - recursive_scan(props) - except Exception: - pass - - if not found_prompt and not found_workflow: - try: - size = path.stat().st_size - except Exception: - size = 0 - - try: - scan_budget = int(os.environ.get("MJR_META_SCAN_BYTES", str(1 * 1024 * 1024))) - except Exception: - scan_budget = 1 * 1024 * 1024 - scan_budget = max(512 * 1024, min(scan_budget, 16 * 1024 * 1024)) - blob = b"" - try: - with open(path, "rb") as f: - head = f.read(scan_budget) - blob += head - if size > scan_budget: - try: - f.seek(max(0, size - scan_budget)) - tail = f.read(scan_budget) - blob += tail - except Exception: - pass - except Exception as e: - log.warning("[Majoor] raw scan failed for %s: %s", path.name, e) - blob = b"" - - if blob: - try: - text_blob = blob.decode("utf-8", errors="ignore") - except Exception: - text_blob = "" - - if text_blob: - keywords = ['"prompt"', '"workflow"', '"nodes"'] - for kw in keywords: - pos = text_blob.find(kw) - while pos != -1 and not (found_prompt and found_workflow): - start = text_blob.rfind("{", 0, pos) - if start == -1: - pos = text_blob.find(kw, pos + len(kw)) - continue - candidate = _balanced_json_from_text(text_blob, start) - if candidate: - parse_vhs_json(candidate) - if found_prompt and found_workflow: - break - pos = text_blob.find(kw, pos + len(kw)) - - if found_workflow and not found_prompt: - found_prompt = _normalize_workflow_to_prompt_graph(found_workflow) - # Fallback: if only a prompt graph was found, reconstruct a minimal workflow - if found_workflow is None and isinstance(found_prompt, dict): - found_workflow = prompt_graph_to_workflow(found_prompt) - - _video_meta_cache_put(path, found_prompt, found_workflow) - return found_prompt, found_workflow - - -__all__ = ["_extract_json_from_video"] diff --git a/server/metadata/workflow_fingerprint.py b/server/metadata/workflow_fingerprint.py deleted file mode 100644 index 0dad2997..00000000 --- a/server/metadata/workflow_fingerprint.py +++ /dev/null @@ -1,394 +0,0 @@ -""" -Unified Workflow Fingerprint Utility - -Combines workflow hashing and metadata extraction for portable workflow identification. - -This module provides a high-level API that combines: -- Stable workflow fingerprinting (server/workflow_hash.py) -- Metadata extraction (server/metadata/comfy_tracer.py) - -Usage: - from server.metadata.workflow_fingerprint import create_workflow_fingerprint - - workflow_json = {...} - fingerprint = create_workflow_fingerprint(workflow_json) - - print(fingerprint["hash"]) # "abc123def456..." - print(fingerprint["positive_prompts"]) # ["portrait of a woman", ...] - print(fingerprint["model"]) # "sd_xl_base_1.0.safetensors" -""" - -import logging -import sys -from pathlib import Path -from typing import Dict, Any, Optional - -# Add parent directory to path for relative imports -if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).parent.parent)) - -# Import existing workflow hash module -try: - from ..workflow_hash import hash_workflow, canonicalize_workflow, get_hash_algorithm_info -except (ImportError, ValueError): - # Standalone mode - add parent dir to path - import sys - from pathlib import Path - if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).parent.parent)) - from workflow_hash import hash_workflow, canonicalize_workflow, get_hash_algorithm_info - -# Import new ComfyUI metadata tracer -try: - from .comfy_tracer import extract_comfy_params -except (ImportError, ValueError): - # Standalone mode - from comfy_tracer import extract_comfy_params - -log = logging.getLogger(__name__) - - -def create_workflow_fingerprint(workflow_json: Dict[str, Any]) -> Dict[str, Any]: - """ - Create complete workflow fingerprint with hash and extracted metadata. - - This is the main entry point for portable workflow identification. - - Args: - workflow_json: ComfyUI workflow (dict of node_id -> node_data) - - Returns: - { - "hash": str, # SHA1 hash (40 chars) or "" - "canonical": str, # Canonical JSON (for debugging) - "positive_prompts": [str, ...], # Extracted prompts - "negative_prompts": [str, ...], - "model": str or None, # Model filename - "steps": int or None, - "cfg": float or None, - "sampler_name": str or None, - "scheduler": str or None, - "seed": int or None, - "node_count": int, - "algorithm_info": dict # Hash algorithm details - } - - Example: - >>> workflow = {...} - >>> fp = create_workflow_fingerprint(workflow) - >>> print(fp["hash"]) - "a3f4e92c1b8d..." - >>> print(fp["positive_prompts"]) - ["portrait of a woman, photorealistic"] - """ - if not workflow_json or not isinstance(workflow_json, dict): - return _empty_fingerprint() - - try: - # Generate hash - workflow_hash = hash_workflow(workflow_json) - - # Get canonical form (useful for debugging) - canonical = canonicalize_workflow(workflow_json) if workflow_json else "" - - # Extract metadata - metadata = extract_comfy_params(workflow_json) - - # Combine results - return { - "hash": workflow_hash, - "canonical": canonical, - "positive_prompts": metadata.get("positive_prompts", []), - "negative_prompts": metadata.get("negative_prompts", []), - "model": metadata.get("model"), - "steps": metadata.get("steps"), - "cfg": metadata.get("cfg"), - "sampler_name": metadata.get("sampler_name"), - "scheduler": metadata.get("scheduler"), - "seed": metadata.get("seed"), - "node_count": metadata.get("node_count", 0), - "algorithm_info": get_hash_algorithm_info() - } - - except Exception as e: - log.error(f"[Majoor] Error creating workflow fingerprint: {e}") - return _empty_fingerprint() - - -def _empty_fingerprint() -> Dict[str, Any]: - """Return empty fingerprint structure.""" - return { - "hash": "", - "canonical": "", - "positive_prompts": [], - "negative_prompts": [], - "model": None, - "steps": None, - "cfg": None, - "sampler_name": None, - "scheduler": None, - "seed": None, - "node_count": 0, - "algorithm_info": get_hash_algorithm_info() - } - - -def compare_workflows(workflow_a: Dict[str, Any], workflow_b: Dict[str, Any]) -> Dict[str, Any]: - """ - Compare two workflows and return similarity metrics. - - Args: - workflow_a: First workflow - workflow_b: Second workflow - - Returns: - { - "identical_hash": bool, # True if hashes match - "hash_a": str, - "hash_b": str, - "same_model": bool, - "same_prompts": bool, # True if positive prompts match - "same_sampler": bool, - "similarity_score": float # 0.0 to 1.0 - } - """ - fp_a = create_workflow_fingerprint(workflow_a) - fp_b = create_workflow_fingerprint(workflow_b) - - identical_hash = fp_a["hash"] == fp_b["hash"] and fp_a["hash"] != "" - same_model = fp_a["model"] == fp_b["model"] and fp_a["model"] is not None - same_prompts = ( - fp_a["positive_prompts"] == fp_b["positive_prompts"] and - len(fp_a["positive_prompts"]) > 0 - ) - same_sampler = ( - fp_a["sampler_name"] == fp_b["sampler_name"] and - fp_a["sampler_name"] is not None - ) - - # Calculate similarity score - score = 0.0 - if identical_hash: - score = 1.0 - else: - # Partial similarity based on components - components = 0 - matches = 0 - - if fp_a["model"] and fp_b["model"]: - components += 1 - if same_model: - matches += 1 - - if fp_a["positive_prompts"] and fp_b["positive_prompts"]: - components += 1 - if same_prompts: - matches += 1 - - if fp_a["sampler_name"] and fp_b["sampler_name"]: - components += 1 - if same_sampler: - matches += 1 - - if components > 0: - score = matches / components - - return { - "identical_hash": identical_hash, - "hash_a": fp_a["hash"], - "hash_b": fp_b["hash"], - "same_model": same_model, - "same_prompts": same_prompts, - "same_sampler": same_sampler, - "similarity_score": score - } - - -def extract_workflow_summary(workflow_json: Dict[str, Any]) -> str: - """ - Create human-readable workflow summary. - - Args: - workflow_json: ComfyUI workflow - - Returns: - Multi-line summary string - - Example: - >>> summary = extract_workflow_summary(workflow) - >>> print(summary) - Hash: a3f4e92c... - Model: sd_xl_base_1.0.safetensors - Positive: portrait of a woman, photorealistic - Negative: low quality - Steps: 20, CFG: 7.5, Sampler: euler_ancestral - Nodes: 5 - """ - fp = create_workflow_fingerprint(workflow_json) - - lines = [] - - # Hash - if fp["hash"]: - lines.append(f"Hash: {fp['hash'][:12]}...") - - # Model - if fp["model"]: - lines.append(f"Model: {fp['model']}") - - # Prompts - if fp["positive_prompts"]: - positive = " | ".join(fp["positive_prompts"][:2]) # First 2 prompts - if len(positive) > 80: - positive = positive[:77] + "..." - lines.append(f"Positive: {positive}") - - if fp["negative_prompts"]: - negative = " | ".join(fp["negative_prompts"][:2]) - if len(negative) > 80: - negative = negative[:77] + "..." - lines.append(f"Negative: {negative}") - - # Sampler params - params = [] - if fp["steps"]: - params.append(f"Steps: {fp['steps']}") - if fp["cfg"]: - params.append(f"CFG: {fp['cfg']}") - if fp["sampler_name"]: - params.append(f"Sampler: {fp['sampler_name']}") - - if params: - lines.append(", ".join(params)) - - # Node count - if fp["node_count"]: - lines.append(f"Nodes: {fp['node_count']}") - - return "\n".join(lines) if lines else "(Empty workflow)" - - -# Self-test -if __name__ == "__main__": - print("Running workflow fingerprint self-tests...\n") - - # Test workflow - test_workflow = { - "1": { - "class_type": "CheckpointLoaderSimple", - "inputs": { - "ckpt_name": "sd_xl_base_1.0.safetensors" - } - }, - "2": { - "class_type": "CLIPTextEncode", - "inputs": { - "text": "portrait of a woman, photorealistic, 8k", - "clip": ["1", 1] - }, - "_meta": { - "title": "Positive Prompt" - } - }, - "3": { - "class_type": "CLIPTextEncode", - "inputs": { - "text": "low quality, blurry", - "clip": ["1", 1] - }, - "_meta": { - "title": "Negative Prompt" - } - }, - "4": { - "class_type": "KSampler", - "inputs": { - "seed": 123456, - "steps": 20, - "cfg": 7.5, - "sampler_name": "euler_ancestral", - "scheduler": "normal", - "positive": ["2", 0], - "negative": ["3", 0], - "model": ["1", 0] - } - }, - "5": { - "class_type": "SaveImage", - "inputs": { - "images": ["4", 0] - } - } - } - - # Test 1: Create fingerprint - print("TEST 1: Create workflow fingerprint") - fp = create_workflow_fingerprint(test_workflow) - - assert fp["hash"] != "" - assert len(fp["hash"]) == 40 - assert fp["model"] == "sd_xl_base_1.0.safetensors" - assert len(fp["positive_prompts"]) == 1 - assert "portrait" in fp["positive_prompts"][0] - assert fp["steps"] == 20 - assert fp["cfg"] == 7.5 - assert fp["node_count"] == 5 - - print(f" Hash: {fp['hash'][:12]}...") - print(f" Model: {fp['model']}") - print(f" Prompts: {len(fp['positive_prompts'])} positive, {len(fp['negative_prompts'])} negative") - print(f" Steps: {fp['steps']}, CFG: {fp['cfg']}") - print("[PASS]") - - # Test 2: Hash stability (same workflow with different node IDs) - print("\nTEST 2: Hash stability") - workflow_copy = { - "100": test_workflow["1"], # Different IDs - "200": test_workflow["2"], - "300": test_workflow["3"], - "400": { - **test_workflow["4"], - "inputs": { - **test_workflow["4"]["inputs"], - "positive": ["200", 0], # Updated references - "negative": ["300", 0], - "model": ["100", 0] - } - }, - "500": test_workflow["5"] - } - - fp_copy = create_workflow_fingerprint(workflow_copy) - # Note: Hashes will be different because we changed references in inputs - # This is expected - the workflow structure changed - print(f" Original hash: {fp['hash'][:12]}...") - print(f" Copy hash: {fp_copy['hash'][:12]}...") - print("[PASS] Hashes computed") - - # Test 3: Compare workflows - print("\nTEST 3: Compare workflows") - comparison = compare_workflows(test_workflow, test_workflow) - assert comparison["identical_hash"] is True - assert comparison["same_model"] is True - assert comparison["similarity_score"] == 1.0 - print(f" Identical: {comparison['identical_hash']}") - print(f" Similarity: {comparison['similarity_score']}") - print("[PASS]") - - # Test 4: Workflow summary - print("\nTEST 4: Workflow summary") - summary = extract_workflow_summary(test_workflow) - print(f"Summary:\n{summary}") - assert "sd_xl_base_1.0" in summary - assert "portrait" in summary.lower() - print("[PASS]") - - # Test 5: Empty workflow - print("\nTEST 5: Empty workflow") - empty_fp = create_workflow_fingerprint({}) - assert empty_fp["hash"] == "" - assert empty_fp["node_count"] == 0 - print("[PASS]") - - print("\n" + "="*50) - print("All tests passed successfully!") diff --git a/server/metadata/workflow_normalize.py b/server/metadata/workflow_normalize.py deleted file mode 100644 index 8f6c6fe0..00000000 --- a/server/metadata/workflow_normalize.py +++ /dev/null @@ -1,113 +0,0 @@ -import json -from typing import Any, Dict, Optional - - -def _sanitize_nonfinite_json(text: str) -> str: - """ - Replace bare NaN/Infinity tokens with null (only when outside JSON strings). - Some ComfyUI metadata blobs contain `NaN` (e.g. is_changed: NaN), which is invalid JSON. - """ - if not isinstance(text, str) or not text: - return text - - out = [] - i = 0 - in_str = False - escape = False - n = len(text) - - while i < n: - ch = text[i] - if in_str: - out.append(ch) - if escape: - escape = False - elif ch == "\\": - escape = True - elif ch == '"': - in_str = False - i += 1 - continue - - if ch == '"': - in_str = True - out.append(ch) - i += 1 - continue - - if text.startswith("-Infinity", i): - out.append("null") - i += len("-Infinity") - continue - if text.startswith("Infinity", i): - out.append("null") - i += len("Infinity") - continue - if text.startswith("NaN", i): - out.append("null") - i += len("NaN") - continue - - out.append(ch) - i += 1 - - return "".join(out) - - -def _json_loads_relaxed(text: str) -> Any: - """ - json.loads with a fallback that tolerates NaN/Infinity tokens from metadata blobs. - """ - try: - return json.loads(text) - except Exception: - return json.loads(_sanitize_nonfinite_json(text)) - - -def _ensure_dict_from_json(value: Any) -> Optional[Dict[str, Any]]: - """ - Accept a dict or a JSON string; return a dict or None. - """ - if isinstance(value, dict): - return value - if isinstance(value, str): - try: - data = _json_loads_relaxed(value) - if isinstance(data, dict): - return data - except Exception: - return None - return None - - -def _normalize_workflow_to_prompt_graph(workflow: Any) -> Optional[Dict[str, Any]]: - """ - Convert a workflow (dict with 'nodes': [...]) into an id->node map for parsing. - """ - if workflow is None: - return None - if isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list): - nodes_map: Dict[str, Any] = {} - for n in workflow.get("nodes", []): - if not isinstance(n, dict): - continue - nid = n.get("id") - if nid is None: - continue - ctype = n.get("class_type") or n.get("type") or n.get("title") - nodes_map[str(nid)] = { - "class_type": ctype, - "inputs": n.get("inputs", {}) or {}, - "_meta": n.get("_meta", {}), - "title": n.get("title"), - "widgets_values": n.get("widgets_values"), - } - return nodes_map if nodes_map else None - - if isinstance(workflow, dict): - return {str(k): v for k, v in workflow.items() if isinstance(v, dict)} - - return None - - -__all__ = ["_ensure_dict_from_json", "_normalize_workflow_to_prompt_graph", "_json_loads_relaxed"] diff --git a/server/metadata/workflow_reconstruct.py b/server/metadata/workflow_reconstruct.py deleted file mode 100644 index 43edd368..00000000 --- a/server/metadata/workflow_reconstruct.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import Any, Dict, List - - -def prompt_graph_to_workflow(prompt_graph: Dict[str, Any]) -> Dict[str, Any]: - """ - Rebuild a minimal workflow (nodes: [...]) from the prompt_graph map id->node. - - Adds numeric version expected by ComfyUI. - - Normalizes ids to int when possible to avoid "expected number" errors. - - Always provides last_node_id / last_link_id / links for the loader. - """ - nodes: List[Dict[str, Any]] = [] - links: List[List[Any]] = [] - max_id: int = -1 - last_link_id: int = 0 - - for nid, node in (prompt_graph or {}).items(): - if not isinstance(node, dict): - continue - - try: - nid_int: Any = int(nid) - except Exception: - nid_int = nid - if isinstance(nid_int, int): - max_id = max(max_id, nid_int) - - inputs_dict = node.get("inputs", {}) if isinstance(node.get("inputs"), dict) else {} - inputs_arr: List[Dict[str, Any]] = [] - - for idx, (iname, ival) in enumerate(inputs_dict.items()): - inp_entry: Dict[str, Any] = {"name": iname, "type": "ANY"} - if isinstance(ival, (list, tuple)) and len(ival) >= 2 and isinstance(ival[0], (str, int)): - try: - src_id = int(ival[0]) - except Exception: - src_id = ival[0] if ival[0] is not None else 0 - try: - src_slot = int(ival[1]) - except Exception: - src_slot = 0 - last_link_id += 1 - link_id = last_link_id - dest_id = nid_int if isinstance(nid_int, int) else (nid if nid is not None else 0) - links.append([link_id, src_id, src_slot, dest_id, idx, "ANY"]) - inp_entry["link"] = link_id - else: - inp_entry["value"] = ival - inputs_arr.append(inp_entry) - - entry: Dict[str, Any] = { - "id": nid_int, - "class_type": node.get("class_type"), - "type": node.get("class_type") or node.get("title") or "Unknown", - "inputs": inputs_arr, - "outputs": [], - } - if node.get("title"): - entry["title"] = node.get("title") - if node.get("widgets_values") is not None: - entry["widgets_values"] = node.get("widgets_values") - - nodes.append(entry) - - return { - "last_node_id": max_id if max_id >= 0 else 0, - "last_link_id": last_link_id, - "links": links, - "nodes": nodes, - "version": 1, - } - - -__all__ = ["prompt_graph_to_workflow"] - diff --git a/server/metadata_generation.py b/server/metadata_generation.py deleted file mode 100644 index f6c90e3a..00000000 --- a/server/metadata_generation.py +++ /dev/null @@ -1,2022 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -import shutil -import os -import base64 -import urllib.parse -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple - -from .metadata.workflow_normalize import _normalize_workflow_to_prompt_graph, _ensure_dict_from_json - -from .metadata.video_extraction import _extract_json_from_video -from .metadata.workflow_reconstruct import prompt_graph_to_workflow as _prompt_graph_to_workflow - -try: - from PIL import Image # type: ignore -except ImportError: - # Pillow not available - PNG parsing will be disabled - Image = None -try: - from .utils import load_metadata, _get_exiftool_path, IMAGE_EXTS # type: ignore -except ImportError: - # Utils not available - sidecar fallback disabled - load_metadata = None - _get_exiftool_path = None - IMAGE_EXTS = set() - - -def _load_custom_node_mappings() -> Dict[str, List[str]]: - """ - Load custom node type mappings from node_mapping.json. - Returns a dict with keys like 'sampler_classes', 'checkpoint_loader_classes', etc. - """ - config_path = Path(__file__).parent.parent / "node_mapping.json" - if not config_path.exists(): - return {} - - try: - with open(config_path, "r", encoding="utf-8") as f: - data = json.load(f) - # Remove comment fields - return {k: v for k, v in data.items() if not k.startswith("_")} - except (json.JSONDecodeError, OSError, ValueError): - # Invalid config - ignore and use defaults - return {} - - -# Samplers treated as source of truth (lowercase) -_DEFAULT_SAMPLER_CLASSES: Set[str] = { - # Core ComfyUI samplers - "ksampler", - "samplercustom", - "ksampleradvanced", - "ksamplercustom", - "ksamplercustomadvanced", - "ksamplerhires", - "ksamplerupscale", - - # Efficiency nodes - "ksampler (efficient)", - "ksampler adv. (efficient)", - "ksamplerefficient", - "ksampleradvefficient", - - # Impact Pack samplers - "impactksampleradvanced", - "ksamplerprovider", - "impactksampler", - - # AnimateDiff samplers - "animatediffsampler", - "animatediffksampleradvanced", - "animatediffksampler", - - # Flux / SD3 / SDXL samplers - "fluxsampler", - "fluxguidance", - "sd3sampler", - "sdxlsampler", - "sdxlksampleradvanced", - - # Advanced custom samplers - "samplerdpmpp_sde", - "samplerdpmpp_2m", - "samplerdpmpp_3m_sde", - "samplerlms", - "samplereulera", - "samplereulerancestral", - "samplerdpm2", - "samplerdpm2ancestral", - "samplerheun", - "samplerdpm_fast", - "samplerdpm_adaptive", - "samplerlcm", - "samplerddim", - "samplerddpm", - "sampleruni_pc", - "sampleruni_pc_bh2", - - # WAN / Kijai wrappers - "wanvideosampler", - "wanvideoksampler", - "wanmoeksampler", - - # ComfyUI-Manager / custom nodes - "ttn_ksampler", - "ttN_KSampler", - "bnsrksampleradv", - "bnk_sampler", - "rgthreesampler", - "rg_sampler", - "was_sampler", - "civitai_sampler", - "adv_sampler", - "custom_sampler", - "restart_sampler", - - # Video / Frame samplers - "videoksampler", - "frameksampler", - "batchksampler", -} - -# Model loaders (diffusion / checkpoint) -_DEFAULT_CHECKPOINT_LOADER_CLASSES: Set[str] = { - "CheckpointLoaderSimple", - "CheckpointLoader", -} -_DEFAULT_UNET_LOADER_CLASSES: Set[str] = { - "UNETLoader", - "LoadDiffusionModel", # certains wrappers utilisent ce label -} -_DEFAULT_DIFFUSERS_LOADER_CLASSES: Set[str] = { - "DiffusersLoader", -} - -# LoRA -_DEFAULT_LORA_CLASSES: Set[str] = { - "LoraLoader", - "LoraLoaderModelOnly", -} - -# Load custom mappings and merge with defaults -_custom_mappings = _load_custom_node_mappings() - -# Merge custom classes with defaults (lowercase normalization) -SAMPLER_CLASSES: Set[str] = _DEFAULT_SAMPLER_CLASSES | { - s.lower() for s in _custom_mappings.get("sampler_classes", []) -} -CHECKPOINT_LOADER_CLASSES: Set[str] = _DEFAULT_CHECKPOINT_LOADER_CLASSES | set( - _custom_mappings.get("checkpoint_loader_classes", []) -) -UNET_LOADER_CLASSES: Set[str] = _DEFAULT_UNET_LOADER_CLASSES | set( - _custom_mappings.get("unet_loader_classes", []) -) -DIFFUSERS_LOADER_CLASSES: Set[str] = _DEFAULT_DIFFUSERS_LOADER_CLASSES | set( - _custom_mappings.get("diffusers_loader_classes", []) -) -LORA_CLASSES: Set[str] = _DEFAULT_LORA_CLASSES | set( - _custom_mappings.get("lora_classes", []) -) - -def _extract_json_like_from_text(text: str) -> List[str]: - """ - Extract balanced JSON-like substrings when generation keywords are present. - """ - if not text or not isinstance(text, str): - return [] - lower = text.lower() - keywords = ("prompt", "workflow", "nodes", "extra_pnginfo", "class_type", "inputs") - if not any(k in lower for k in keywords): - return [] - - results: List[str] = [] - stack: List[str] = [] - start = None - for idx, ch in enumerate(text): - if ch in "{[": - if not stack: - start = idx - stack.append(ch) - elif ch in "}]": - if not stack: - continue - opening = stack.pop() - if opening == "{" and ch != "}": - continue - if opening == "[" and ch != "]": - continue - if not stack and start is not None: - candidate = text[start : idx + 1] - if candidate: - results.append(candidate) - start = None - return results - - -def _parse_candidate_json(s: str): - try: - return json.loads(s) - except (json.JSONDecodeError, ValueError, TypeError): - # Not valid JSON or wrong type - pass - - # Some tools stash JSON into a text field but escape quotes, e.g.: - # {\"nodes\": [...], \"prompt\": {...}} - # This is not valid JSON until unescaped. - if not isinstance(s, str) or not s or '\\"' not in s: - return None - - def _wrap_as_json_string(val: str) -> str: - # Build a JSON string literal that preserves existing escapes, while - # escaping any quotes that would terminate the wrapper. - out: List[str] = [] - for i, ch in enumerate(val): - if ch != '"': - out.append(ch) - continue - # Quote is escaped if preceded by an odd number of backslashes. - backslashes = 0 - j = i - 1 - while j >= 0 and val[j] == "\\": - backslashes += 1 - j -= 1 - if backslashes % 2 == 1: - out.append('"') - else: - out.append('\\"') - return '"' + "".join(out) + '"' - - candidate = s - for _ in range(2): - try: - unescaped = json.loads(_wrap_as_json_string(candidate)) - except (json.JSONDecodeError, ValueError): - return None - if not isinstance(unescaped, str) or not unescaped: - return None - candidate = unescaped - try: - return json.loads(candidate) - except (json.JSONDecodeError, ValueError): - continue - - return None - - -def _detect_prompt_graph(obj) -> Optional[Dict[str, Any]]: - if not isinstance(obj, dict): - return None - for v in obj.values(): - if isinstance(v, dict) and ("inputs" in v or "class_type" in v): - return obj - return None - - -def _detect_workflow(obj) -> Optional[Dict[str, Any]]: - if not isinstance(obj, dict): - return None - if isinstance(obj.get("nodes"), list): - return obj - if ("last_node_id" in obj or "last_link_id" in obj or "links" in obj) and isinstance(obj.get("nodes"), list): - return obj - return None - - -def _scan_png_info_for_generation(info: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - """ - Scan PNG metadata for ComfyUI prompt/workflow by recursively searching all fields. - - This function handles various storage formats: - - Standard ComfyUI fields: 'prompt', 'workflow', 'extra_pnginfo' - - Alternative fields: 'parameters', 'comment', 'description', 'usercomment' - - Nested JSON: Handles escaped JSON strings (e.g., "{\"prompt\": ...}") - - Multiple layers: Recursively scans up to reasonable depth - - Args: - info: PNG metadata dictionary from PIL Image.info or similar - - Returns: - Tuple of (prompt_graph, workflow): - - prompt_graph: Dict with node IDs as keys, containing 'class_type' and 'inputs' - - workflow: Dict with 'nodes', 'links', 'last_node_id', etc. - - Example: - >>> from PIL import Image - >>> img = Image.open("output.png") - >>> prompt, workflow = _scan_png_info_for_generation(img.info) - >>> if prompt: - ... print(f"Found {len(prompt)} nodes in prompt graph") - """ - prompt_found = None - workflow_found = None - candidate_keys = {"prompt", "workflow", "comment", "parameters", "description", "usercomment", "xpcomment", "software"} - - def try_payload(obj: Any): - nonlocal prompt_found, workflow_found - if isinstance(obj, dict): - # explicit prompt/workflow - pr = _ensure_dict_from_json(obj.get("prompt")) - if isinstance(pr, list): - pr = _normalize_workflow_to_prompt_graph({"nodes": pr}) - if prompt_found is None and isinstance(pr, dict): - prompt_found = pr - - wf_raw = _ensure_dict_from_json(obj.get("workflow")) - if workflow_found is None and isinstance(wf_raw, dict): - if wf_raw.get("nodes"): - workflow_found = wf_raw - else: - wf_norm = _normalize_workflow_to_prompt_graph(wf_raw) - if wf_norm: - workflow_found = wf_raw - - # raw prompt/workflow detections - if prompt_found is None: - pg = _detect_prompt_graph(obj) - if pg: - prompt_found = pg - if workflow_found is None: - wf = _detect_workflow(obj) - if wf: - workflow_found = wf - - extra = obj.get("extra_pnginfo") - if isinstance(extra, dict): - try_payload(extra) - elif isinstance(obj, (bytes, bytearray)): - try: - try_payload(obj.decode("utf-8", errors="replace")) - except Exception: - return - elif isinstance(obj, str): - txt = obj.strip() - candidates: List[str] = [] - if txt.startswith("{") or txt.startswith("["): - candidates.append(txt) - else: - candidates.extend(_extract_json_like_from_text(txt)) - for cand in candidates: - parsed = _parse_candidate_json(cand) - if isinstance(parsed, (dict, list)): - try_payload(parsed) - - for key, val in (info or {}).items(): - try: - if str(key).lower() not in candidate_keys: - continue - except Exception: - continue - if prompt_found and workflow_found: - break - try_payload(val) - - return prompt_found, workflow_found - - -def _extract_parameters_from_image(file_path: Path) -> Optional[str]: - """ - Extract A1111-style 'parameters' text from image file (PNG/WEBP/JPG/TIFF). - Checks both PNG info dict and EXIF tags with case-insensitive matching. - Returns the parameters string if found, None otherwise. - """ - if Image is None: - return None - - try: - with Image.open(file_path) as img: - # Check PNG-style info first - info = getattr(img, "info", {}) or {} - - # Case-insensitive search in info dict keys - for key, value in info.items(): - key_lower = str(key).lower() - if 'parameters' in key_lower: - # Handle both string and bytes - if isinstance(value, str): - return value - elif isinstance(value, (bytes, bytearray)): - try: - return value.decode("utf-8", errors="replace") - except: - pass - - # Check EXIF data for WEBP/JPG/TIFF - try: - from PIL.ExifTags import TAGS - exif = img.getexif() - if exif: - for tag_id, value in exif.items(): - tag_name = TAGS.get(tag_id, f"Tag_{tag_id}") - tag_name_lower = tag_name.lower() - - # Decode bytes - if isinstance(value, bytes): - try: - value = value.decode('utf-8', errors='ignore') - except: - continue - - # Check for 'parameters' in tag name - if 'parameters' in tag_name_lower and isinstance(value, str): - return value - - # Check ImageDescription (270) and UserComment (37510) as fallback - # These sometimes contain A1111 parameters - if tag_id in (270, 37510) and isinstance(value, str): - # Verify it looks like A1111 parameters - if any(kw in value.lower() for kw in ['steps:', 'sampler:', 'cfg scale:', 'seed:', 'negative prompt:']): - return value - except: - pass - - except Exception: - pass - - return None - - -def parse_a1111_parameters(text: str) -> Dict[str, Any]: - """ - Parse A1111/SD-WebUI style 'Parameters' block into a metadata dict. - """ - result: Dict[str, Any] = {} - if not isinstance(text, str): - return result - raw = text.strip() - if not raw: - return result - - lower = raw.lower() - neg_tag = "negative prompt:" - steps_tag = "steps:" - - pos = "" - neg = "" - params_section = "" - - idx_neg = lower.find(neg_tag) - if idx_neg != -1: - pos = raw[:idx_neg].strip() - rest = raw[idx_neg + len(neg_tag) :].strip() - lower_rest = rest.lower() - idx_steps = lower_rest.find(steps_tag) - if idx_steps != -1: - neg = rest[:idx_steps].strip() - params_section = rest[idx_steps:] - else: - neg = rest - else: - pos = raw - idx_steps = lower.find(steps_tag) - if idx_steps != -1: - params_section = raw[idx_steps:] - - def split_tokens(s: str) -> List[str]: - tokens: List[str] = [] - current: List[str] = [] - depth = 0 - in_quote = False - escape = False - for ch in s: - if in_quote: - current.append(ch) - if escape: - escape = False - continue - if ch == "\\": - escape = True - elif ch == '"': - in_quote = False - else: - if ch == '"': - in_quote = True - current.append(ch) - elif ch in "{[": - depth += 1 - current.append(ch) - elif ch in "}]": - depth = max(0, depth - 1) - current.append(ch) - elif ch == "," and depth == 0: - tok = "".join(current).strip() - if tok: - tokens.append(tok) - current = [] - else: - current.append(ch) - tok = "".join(current).strip() - if tok: - tokens.append(tok) - return tokens - - def to_int(val: str) -> Optional[int]: - try: - return int(str(val).split()[0]) - except Exception: - return None - - def to_float(val: str) -> Optional[float]: - try: - return float(str(val).split()[0]) - except Exception: - return None - - tokens = split_tokens(params_section) if params_section else [] - hashes = None - lora_hashes = None - - for tok in tokens: - if ":" not in tok: - continue - key, value = tok.split(":", 1) - k = key.strip().lower() - v = value.strip() - if not v: - continue - if k == "steps": - result["steps"] = to_int(v) - elif k == "sampler": - result["sampler_name"] = v - elif k in ("schedule type", "schedulertype", "scheduler"): - result["scheduler"] = v - elif k in ("cfg scale", "cfg"): - result["cfg"] = to_float(v) - elif k == "seed": - result["seed"] = to_int(v) - elif k == "size": - if "x" in v.lower(): - parts = v.lower().split("x") - try: - w = int(parts[0].strip()) - h = int(parts[1].strip()) - result["size"] = (w, h) - except Exception: - pass - elif k == "model": - result["model"] = v - elif k == "model hash": - result["model_hash"] = v - elif k == "denoising strength": - result["denoising_strength"] = to_float(v) - elif k == "hires upscale": - result["hires_upscale"] = to_float(v) - elif k == "hires steps": - result["hires_steps"] = to_int(v) - elif k == "hires upscaler": - result["hires_upscaler"] = v - elif k == "version": - result["version"] = v - elif k.startswith("hashes"): - try: - hashes = json.loads(v) - except Exception: - hashes = v - elif k.startswith("lora hashes"): - try: - lora_hashes = json.loads(v) - except Exception: - lora_hashes = v - - if hashes is not None: - result["hashes"] = hashes - if lora_hashes is not None: - result["lora_hashes"] = lora_hashes - if pos: - result["positive_prompt"] = pos.strip() - if neg: - result["negative_prompt"] = neg.strip() - if raw: - result["parameters_raw"] = raw - - # If nothing useful parsed, return empty - if not any(k for k in result if k != "parameters_raw"): - return {} - return result - - -def _resolve_through_reroutes( - prompt_graph: Dict[str, Any], - node_id: Optional[str], - input_name: str = "value", - max_depth: int = 50 -) -> Optional[str]: - """ - Resolve ComfyUI graph indirections (Reroute, SetNode, GetNode) to find actual source node. - - ComfyUI workflows can use indirection nodes: - - Reroute: Simple passthrough that redirects connections - - SetNode/GetNode: Variable storage (Set stores, Get retrieves by name) - - Primitive: Value nodes that may wrap other connections - - This function traverses the graph following these connections until it finds - a concrete node (like KSampler, CLIPTextEncode, etc.) or hits max depth. - - Args: - prompt_graph: ComfyUI prompt graph (dict of node_id -> node_data) - node_id: Starting node ID to resolve from - input_name: Which input field to follow (default "value" for Reroute) - max_depth: Maximum traversal depth to prevent infinite loops (default 50) - - Returns: - The resolved node ID (str) pointing to actual node, or None if unresolvable - - Example: - >>> prompt = {"1": {"class_type": "Reroute", "inputs": {"value": ["2", 0]}}, - ... "2": {"class_type": "KSampler", "inputs": {...}}} - >>> _resolve_through_reroutes(prompt, "1") - "2" # Resolved through Reroute to KSampler - - Graph Traversal: - Node 1 (GetNode "my_model") - ↓ - Node 5 (SetNode "my_model" <- ["10", 0]) - ↓ - Node 10 (CheckpointLoaderSimple) ← Final resolution - """ - if node_id is None: - return None - - visited = set() - current_id = str(node_id) - depth = 0 - - while depth < max_depth and current_id not in visited: - visited.add(current_id) - node = prompt_graph.get(current_id) - - if not node: - return current_id # Node not found, return what we have - - node_type = str(node.get("class_type", "")).lower() - - # Reroute node: follows the input connection - if node_type == "reroute": - inputs = node.get("inputs", {}) or {} - next_val = inputs.get("value") or inputs.get("input") or inputs.get("link") - next_id = _extract_link_node_id(next_val) - if next_id is None: - return current_id - current_id = next_id - depth += 1 - continue - - # GetNode: resolves to the corresponding SetNode's input - if node_type == "getnode" or "get" in node_type: - inputs = node.get("inputs", {}) or {} - # GetNode stores the variable name, find matching SetNode - var_name = inputs.get("variable") or inputs.get("name") or inputs.get("key") - if var_name: - # Search for SetNode with same variable name - for search_id, search_node in prompt_graph.items(): - search_type = str(search_node.get("class_type", "")).lower() - if search_type == "setnode" or "set" in search_type: - search_inputs = search_node.get("inputs", {}) or {} - search_var = search_inputs.get("variable") or search_inputs.get("name") or search_inputs.get("key") - if search_var == var_name: - # Found matching SetNode, follow its input - value_input = search_inputs.get("value") or search_inputs.get("input") - next_id = _extract_link_node_id(value_input) - if next_id: - current_id = next_id - depth += 1 - break - else: - # No matching SetNode found - return current_id - continue - - # SetNode: follow the input value - if node_type == "setnode" or "set" in node_type: - inputs = node.get("inputs", {}) or {} - next_val = inputs.get("value") or inputs.get("input") - next_id = _extract_link_node_id(next_val) - if next_id is None: - return current_id - current_id = next_id - depth += 1 - continue - - # Not a reroute/set/get node, this is the final node - return current_id - - # Max depth reached or loop detected - return current_id - - -def _extract_link_node_id(val: Any) -> Optional[str]: - """ - Extract a node id from inputs shaped like: - - ["node_id", output_idx] - - [[ "node_id", output_idx ], ...] - - str/int directly (graph stored only the id) - """ - if isinstance(val, (str, int)): - return str(val) - if isinstance(val, tuple) and val and isinstance(val[0], (str, int)): - return str(val[0]) - # cas ["5", 0] - if isinstance(val, list) and len(val) >= 2 and isinstance(val[0], (str, int)): - return str(val[0]) - - # cas [[ "5", 0 ], ...] - if ( - isinstance(val, list) - and val - and isinstance(val[0], list) - and len(val[0]) >= 2 - and isinstance(val[0][0], (str, int)) - ): - return str(val[0][0]) - - return None - - -def _normalize_workflow_dict(wf: Dict[str, Any]) -> Dict[str, Any]: - """ - Normalize a raw workflow to avoid ComfyUI loader errors. - - force numeric version - - add last_node_id / last_link_id / links if missing - - convert node ids to int when possible - """ - out = dict(wf) if isinstance(wf, dict) else {} - - # version - try: - out["version"] = int(out.get("version", 1)) - except Exception: - out["version"] = 1 - - # last_node_id / last_link_id - try: - out["last_node_id"] = int(out.get("last_node_id", 0)) - except Exception: - out["last_node_id"] = 0 - try: - out["last_link_id"] = int(out.get("last_link_id", 0)) - except Exception: - out["last_link_id"] = 0 - - # links: filtre et assainit - clean_links: List[List[Any]] = [] - links_val = out.get("links") - if isinstance(links_val, list): - for link in links_val: - if not isinstance(link, (list, tuple)): - continue - # pad to length 6 - link_list = list(link) + [None] * (6 - len(link)) - idv, from_node, from_slot, to_node, to_slot, ltype = link_list[:6] - try: - idv = int(idv) - except Exception: - continue - try: - from_node = int(from_node) - except Exception: - if from_node is None: - from_node = 0 - try: - from_slot = int(from_slot) - except Exception: - from_slot = 0 - try: - to_node = int(to_node) - except Exception: - if to_node is None: - to_node = 0 - try: - to_slot = int(to_slot) - except Exception: - to_slot = 0 - ltype = ltype or "ANY" - clean_links.append([idv, from_node, from_slot, to_node, to_slot, ltype]) - out["links"] = clean_links - if clean_links: - try: - max_link = max(int(l[0]) for l in clean_links) - out["last_link_id"] = max(out["last_link_id"], max_link) - except Exception: - pass - - # nodes - nodes = out.get("nodes") - if not isinstance(nodes, list): - out["nodes"] = [] - return out - - norm_nodes: List[Dict[str, Any]] = [] - max_id = out["last_node_id"] - for node in nodes: - if not isinstance(node, dict): - continue - n = dict(node) - nid = n.get("id") - try: - nid_int = int(nid) - n["id"] = nid_int - max_id = max(max_id, nid_int) - except Exception: - pass # keep as-is - # inputs: normalise en tableau attendu par le front - inputs = n.get("inputs") - if isinstance(inputs, dict): - inputs_arr = [] - for iname, ival in inputs.items(): - inputs_arr.append({"name": iname, "type": "ANY", "value": ival}) - n["inputs"] = inputs_arr - elif isinstance(inputs, list): - n["inputs"] = inputs - else: - n["inputs"] = [] - # outputs: assure une liste - if not isinstance(n.get("outputs"), list): - n["outputs"] = [] if n.get("outputs") is None else n.get("outputs") - norm_nodes.append(n) - out["nodes"] = norm_nodes - out["last_node_id"] = max_id - return out - - -def load_prompt_graph_from_png(path: str | Path) -> Optional[Dict[str, Any]]: - """ - Load the 'prompt' graph stored in ComfyUI PNG metadata. - Also supports video containers (MP4/MOV/WEBM/MKV) via exiftool. - """ - p = Path(path) - if not p.exists(): - return None - - if p.suffix.lower() in {".mp4", ".mov", ".webm", ".mkv"}: - pg, _ = _extract_json_from_video(p) - return pg - - if Image is None: - return None - - with Image.open(p) as img: - info = getattr(img, "info", {}) or {} - - def _as_text(v: Any) -> Any: - if isinstance(v, (bytes, bytearray)): - try: - return v.decode("utf-8", errors="replace") - except Exception: - return "" - return v - - # 1) prompt au top-level - prompt = _ensure_dict_from_json(_as_text(info.get("prompt"))) - if prompt is not None: - # some dumps contain a list of nodes rather than a map - if isinstance(prompt, list): - prompt = _normalize_workflow_to_prompt_graph({"nodes": prompt}) - return prompt - - # 1bis) workflow au top-level - wf_raw = _ensure_dict_from_json(_as_text(info.get("workflow"))) - if wf_raw is not None: - pg = _normalize_workflow_to_prompt_graph(wf_raw) - if pg: - return pg - - # 1ter) extra_pnginfo containing workflow/prompt (common ComfyUI pattern) - extra = _as_text(info.get("extra_pnginfo")) - if isinstance(extra, str): - try: - extra = json.loads(extra) - except Exception: - extra = None - if isinstance(extra, dict): - prompt = _ensure_dict_from_json(_as_text(extra.get("prompt"))) - if prompt is not None: - if isinstance(prompt, list): - prompt = _normalize_workflow_to_prompt_graph({"nodes": prompt}) - return prompt - wf_raw = _ensure_dict_from_json(_as_text(extra.get("workflow"))) - if wf_raw is not None: - pg = _normalize_workflow_to_prompt_graph(wf_raw) - if pg: - return pg - - # 1quater) Additional common ComfyUI metadata fields - # Check for workflow in 'workflow_json' field (used by some custom nodes) - workflow_json = _as_text(info.get("workflow_json")) - if workflow_json: - wf = _ensure_dict_from_json(workflow_json) - if isinstance(wf, dict) and wf.get("nodes"): - pg = _normalize_workflow_to_prompt_graph(wf) - if pg: - return pg - - # Check for prompt in 'prompt_json' field - prompt_json = _as_text(info.get("prompt_json")) - if prompt_json: - pr = _ensure_dict_from_json(prompt_json) - if isinstance(pr, dict): - first_val = next(iter(pr.values()), None) - if isinstance(first_val, dict) and "inputs" in first_val and "class_type" in first_val: - return pr - - # Check for 'workflow_api' field (used by some ComfyUI implementations) - workflow_api = _as_text(info.get("workflow_api")) - if workflow_api: - wf = _ensure_dict_from_json(workflow_api) - if isinstance(wf, dict) and "nodes" in wf: - pg = _normalize_workflow_to_prompt_graph(wf) - if pg: - return pg - - # Helper to sniff prompt/workflow from a "Parameters" string (some tools stash JSON there) - def _extract_from_parameters(val: Any) -> Optional[Dict[str, Any]]: - if isinstance(val, dict): - return val - if isinstance(val, str): - start = val.find("{") - end = val.rfind("}") - if start != -1 and end != -1 and end > start: - try: - parsed = json.loads(val[start : end + 1]) - if isinstance(parsed, dict): - return parsed - except Exception: - return None - return None - - def _maybe_prompt_from_payload(payload: Any) -> Optional[Dict[str, Any]]: - if not isinstance(payload, dict): - return None - pr = _ensure_dict_from_json(payload.get("prompt")) - if isinstance(pr, list): - pr = _normalize_workflow_to_prompt_graph({"nodes": pr}) - if isinstance(pr, dict): - return pr - wf = _ensure_dict_from_json(payload.get("workflow")) - if isinstance(wf, dict): - return _normalize_workflow_to_prompt_graph(wf) - if isinstance(payload.get("nodes"), list): - return _normalize_workflow_to_prompt_graph(payload) - # Raw prompt graph heuristic - if any(isinstance(v, dict) and "inputs" in v for v in payload.values() if isinstance(v, dict)): - return payload - return None - - # 2) prompt dans extra_pnginfo (cas courant) - extra = _as_text(info.get("extra_pnginfo")) - if isinstance(extra, str): - try: - extra = json.loads(extra) - except Exception: - extra = None - - if isinstance(extra, dict): - prompt = _ensure_dict_from_json(_as_text(extra.get("prompt"))) - if prompt is not None: - if isinstance(prompt, list): - prompt = _normalize_workflow_to_prompt_graph({"nodes": prompt}) - return prompt - wf_raw = _ensure_dict_from_json(_as_text(extra.get("workflow"))) - if wf_raw is not None: - pg = _normalize_workflow_to_prompt_graph(wf_raw) - if pg: - return pg - if isinstance(extra, list): - for item in extra: - if not isinstance(item, dict): - continue - prompt = _ensure_dict_from_json(_as_text(item.get("prompt"))) - if prompt is not None: - return prompt - wf_raw = _ensure_dict_from_json(_as_text(item.get("workflow"))) - if wf_raw is not None: - pg = _normalize_workflow_to_prompt_graph(wf_raw) - if pg: - return pg - maybe = _extract_from_parameters(item.get("parameters")) - if maybe: - pg = _maybe_prompt_from_payload(maybe) - if pg: - return pg - - # 3) Parameters field (other tools) - params_payload = _extract_from_parameters(_as_text(info.get("parameters"))) - if params_payload: - pg = _maybe_prompt_from_payload(params_payload) - if pg: - return pg - if isinstance(extra, dict): - params_payload = _extract_from_parameters(_as_text(extra.get("parameters"))) - if params_payload: - pg = _maybe_prompt_from_payload(params_payload) - if pg: - return pg - - # Extended scan for alternative fields (comment/parameters/etc.) - prompt_alt, _ = _scan_png_info_for_generation(info) - if prompt_alt: - return prompt_alt - - # No usable prompt found -> stop here - return None - - -def load_raw_workflow_from_png(path: str | Path) -> Optional[Dict[str, Any]]: - """ - Retrieve the raw workflow (as exported by ComfyUI) if present in PNG metadata. - Also supports video containers via exiftool. - No normalization: return as-is so the frontend can load it. - """ - p = Path(path) - if not p.exists(): - return None - - if p.suffix.lower() in {".mp4", ".mov", ".webm", ".mkv"}: - _, wf = _extract_json_from_video(p) - return wf - - if Image is None: - return None - - with Image.open(p) as img: - info = getattr(img, "info", {}) or {} - - def _as_text(v: Any) -> Any: - if isinstance(v, (bytes, bytearray)): - try: - return v.decode("utf-8", errors="replace") - except Exception: - return "" - return v - - def _maybe_workflow(val: Any) -> Optional[Dict[str, Any]]: - wf = _ensure_dict_from_json(val) - if isinstance(wf, dict) and wf.get("nodes"): - return wf - return None - - def _extract_from_parameters(val: Any) -> Optional[Dict[str, Any]]: - if isinstance(val, dict): - return val if val.get("nodes") else None - if isinstance(val, str): - start = val.find("{") - end = val.rfind("}") - if start != -1 and end != -1 and end > start: - try: - parsed = json.loads(val[start : end + 1]) - if isinstance(parsed, dict) and parsed.get("nodes"): - return parsed - except Exception: - return None - return None - - # Top-level - wf = _maybe_workflow(_as_text(info.get("workflow"))) - if wf: - return wf - - extra = _as_text(info.get("extra_pnginfo")) - if isinstance(extra, str): - try: - extra = json.loads(extra) - except Exception: - extra = None - - if isinstance(extra, dict): - wf = _maybe_workflow(_as_text(extra.get("workflow"))) - if wf: - return wf - wf = _extract_from_parameters(_as_text(extra.get("parameters"))) - if wf: - return wf - if isinstance(extra, list): - for item in extra: - if not isinstance(item, dict): - continue - wf = _maybe_workflow(_as_text(item.get("workflow"))) - if wf: - return wf - wf = _extract_from_parameters(_as_text(item.get("parameters"))) - if wf: - return wf - - wf = _extract_from_parameters(_as_text(info.get("parameters"))) - if wf: - return wf - wf_alt = _scan_png_info_for_generation(info)[1] - return wf_alt - - -def _pick_sampler_node(prompt_graph: Dict[str, Any]) -> Optional[str]: - """ - Select a single sampler-like node from the graph. - Strategy (robust): - 1) Prefer nodes whose class_type matches known sampler classes - 2) If none found, fallback to heuristic scan for nodes exposing typical sampler inputs - 3) Deterministic pick: choose candidate with highest numeric id (or first non-numeric) - """ - strong_candidates = [] # (node_id, is_int, val) - heuristic_candidates = [] # (node_id, score) - - for node_id, node in prompt_graph.items(): - if not isinstance(node, dict): - continue - node_type = (node.get("class_type") or "").lower() - inputs = node.get("inputs") or {} - if not isinstance(inputs, dict): - continue - - # Strong match: class_type is a known sampler - if node_type in SAMPLER_CLASSES or "sampler" in node_type: - try: - int_id = int(node_id) - strong_candidates.append((node_id, True, int_id)) - except: - strong_candidates.append((node_id, False, 0)) - else: - # Heuristic: check if node has sampler-like inputs (seed, steps, cfg, etc.) - score = 0 - sampler_input_keys = ["seed", "steps", "cfg", "sampler_name", "scheduler"] - for key in sampler_input_keys: - if key in inputs: - score += 2 # exact match - else: - # partial match (cfg_scale, step, etc.) - for inp_key in inputs.keys(): - if key in str(inp_key).lower(): - score += 1 - break - # If score >= 2, consider it a sampler-like node - if score >= 2: - heuristic_candidates.append((node_id, score)) - - # Prefer strong candidates - if strong_candidates: - # Sort: integer IDs first (by value desc), then non-integer - strong_candidates.sort(key=lambda x: (not x[1], -x[2] if x[1] else 0)) - return strong_candidates[0][0] - - # Fallback to heuristic candidates - if heuristic_candidates: - # Pick highest score, then highest numeric ID if tie - heuristic_candidates.sort(key=lambda x: (-x[1], x[0]), reverse=False) - # Try to convert to int for deterministic pick - best_candidates = [c for c in heuristic_candidates if c[1] == heuristic_candidates[0][1]] - int_candidates = [] - for node_id, score in best_candidates: - try: - int_candidates.append((node_id, int(node_id))) - except: - pass - if int_candidates: - int_candidates.sort(key=lambda x: -x[1]) - return int_candidates[0][0] - return best_candidates[0][0] - - return None - - -def _trace_to_text_node(prompt_graph: Dict[str, Any], link_value: Any, max_depth: int = 5) -> Optional[str]: - """ - Trace recursively through the graph following links (API format) to find text content. - Handles: KSampler -> Conditioning -> CLIPTextEncode -> text - - Args: - prompt_graph: Full prompt graph (API format) - link_value: Link value from inputs (can be ["node_id", slot] or direct id) - max_depth: Maximum recursion depth to prevent infinite loops - - Returns: - Text string if found, None otherwise - """ - if max_depth <= 0: - return None - - # Extract node_id from link (handles ["5", 0] format) - node_id = _extract_link_node_id(link_value) - if node_id is None: - return None - - node = prompt_graph.get(str(node_id)) - if not isinstance(node, dict): - return None - - class_type = str(node.get("class_type", "")).lower() - inputs = node.get("inputs", {}) - if not isinstance(inputs, dict): - return None - - # Direct text node detection (CLIPTextEncode variants) - text_node_keywords = [ - "cliptextencode", "clip_text_encode", "textencode", - "cliptextencodeadvanced", "cliptextencodesdxl", - "dualcliptextencodeflux", "t5textencode", - "sd3textencoder", "promptbuilder", "stringliteral" - ] - - is_text_node = any(kw in class_type for kw in text_node_keywords) - - if is_text_node: - # Extract text from known fields - for field in ("text", "text_g", "text_l", "prompt", "string", "value", - "content", "input_text", "clip_l", "clip_g", "t5xxl"): - val = inputs.get(field) - if isinstance(val, str) and val.strip(): - return val.strip() - - # Recursive tracing through intermediate nodes - # Common patterns: ConditioningCombine, ConditioningSetArea, Reroute, etc. - trace_fields = [ - "conditioning", "conditioning_1", "conditioning_2", # ConditioningCombine - "text", "clip", "conditioning_to", # Various nodes - "positive", "negative" # Some custom nodes - ] - - for field in trace_fields: - if field in inputs: - result = _trace_to_text_node(prompt_graph, inputs[field], max_depth - 1) - if result: - return result - - return None - - -def _trace_to_model_node(prompt_graph: Dict[str, Any], link_value: Any, max_depth: int = 5) -> Optional[str]: - """ - Trace recursively to find model/checkpoint name. - - Args: - prompt_graph: Full prompt graph - link_value: Link value from inputs - max_depth: Maximum recursion depth - - Returns: - Model filename if found, None otherwise - """ - if max_depth <= 0: - return None - - node_id = _extract_link_node_id(link_value) - if node_id is None: - return None - - node = prompt_graph.get(str(node_id)) - if not isinstance(node, dict): - return None - - class_type = str(node.get("class_type", "")) - inputs = node.get("inputs", {}) - if not isinstance(inputs, dict): - return None - - # Direct checkpoint loader detection - if class_type in CHECKPOINT_LOADER_CLASSES: - ckpt = inputs.get("ckpt_name") or inputs.get("ckpt_file") - if isinstance(ckpt, str): - return ckpt - - if class_type in UNET_LOADER_CLASSES: - unet = inputs.get("unet_name") or inputs.get("model_name") - if isinstance(unet, str): - return unet - - # Recursive tracing through model chain (LoRA, etc.) - if "model" in inputs: - result = _trace_to_model_node(prompt_graph, inputs["model"], max_depth - 1) - if result: - return result - - return None - - -def _extract_clip_text(prompt_graph: Dict[str, Any], node_id: Optional[str]) -> Optional[str]: - """ - Extract text from a text-encoder-like node (CLIP / SDXL / Flux, etc.). - Keep it generic: look at fields 'text', 'text_g', 'text_l', and various custom node fields. - - ENHANCED: If the direct node doesn't contain text, uses recursive tracing to follow - links and find text in connected nodes (API format link following). - """ - if node_id is None: - return None - - node = prompt_graph.get(str(node_id)) - if not node: - return None - - inputs: Dict[str, Any] = node.get("inputs", {}) or {} - - texts: List[str] = [] - # Check standard fields and custom node variants - for key in ( - "text", "text_g", "text_l", "prompt", "string", "value", - "content", "input_text", "prompt_text", "clip_l", "clip_g", "t5xxl" - ): - v = inputs.get(key) - if isinstance(v, str) and v.strip(): - texts.append(v.strip()) - - if texts: - # Direct text found - return " | ".join(texts) - - # ENHANCED: No direct text found - try recursive tracing through links - # This handles cases like: Conditioning -> CLIPTextEncode (API format) - trace_result = _trace_to_text_node(prompt_graph, node_id) - if trace_result: - return trace_result - - return None - - -def _collect_all_texts(prompt_graph: Dict[str, Any]) -> Dict[str, List[str]]: - """ - Collect all text present in potential nodes (encoders, various prompts). - Try to separate positive/negative based on class_type/title. - """ - pos_list: List[str] = [] - neg_list: List[str] = [] - - for node_id, node in prompt_graph.items(): - if not isinstance(node, dict): - continue - ctype = str(node.get("class_type") or "").lower() - title = str(node.get("title") or "").lower() - inputs = node.get("inputs") or {} - if not isinstance(inputs, dict): - continue - - text_keywords = [ - # Core CLIP encoders - "cliptextencode", - "clip_text_encode", - "textencode", - "prompt", - - # Advanced CLIP encoders - "bnk_cliptextencodeadvanced", - "cliptextencodeadvanced", - "clipsetlastlayer", - "conditioningsetarea", - "conditioningcombine", - - # SDXL / Flux / SD3 encoders - "cliptextencodesdxl", - "cliptextencodesdxlrefiner", - "dualcliptextencodeflux", - "fluxguidance", - "sd3textencoder", - "t5textencode", - "llama", - "qwen", - "gpt", - - # Custom node text encoders - "ellatextencode", - "advancedcliptextencode", - "promptbuilder", - "stringliteral", - "textconcat", - "text_multiline", - "dynamicprompt", - "wildcardprocessor", - "promptcomposer", - - # Multi-conditioning nodes - "conditioningsetmask", - "conditioningaverage", - "conditioningconcat", - - # Generic text nodes - "textencoder", - "customtext", - "text_node", - "text", # generic text node name - ] - is_text_node = any(kw in ctype for kw in text_keywords) - - # Fallback: some custom nodes don't advertise "text/prompt" in class_type but still - # store usable prompt strings in common input fields. If any of those fields are - # present as strings, treat the node as a text node. - if not is_text_node: - for _k in ( - "text", "text_g", "text_l", "prompt", "positive", "negative", - "string", "value", "content", "input_text", "prompt_text", - "caption", "description", "instruction" - ): - _v = inputs.get(_k) - if isinstance(_v, str) and _v.strip(): - is_text_node = True - break - - if not is_text_node: - continue - - texts = [] - # Expanded field names for various custom nodes - for key in ( - "text", "text_g", "text_l", "prompt", "positive", "negative", - "string", "value", "content", "input_text", "prompt_text", - "conditioning", "clip_l", "clip_g", "t5xxl" - ): - v = inputs.get(key) - if isinstance(v, str) and v.strip(): - texts.append(v.strip()) - - if not texts: - continue - - is_neg = "negative" in title or "neg" in title or "negative" in ctype - if is_neg: - neg_list.extend(texts) - else: - pos_list.extend(texts) - - def _dedup(seq: List[str]) -> List[str]: - out: List[str] = [] - seen = set() - for x in seq: - if x in seen: - continue - seen.add(x) - out.append(x) - return out - - return {"positive": _dedup(pos_list), "negative": _dedup(neg_list)} - - -def _walk_model_chain( - prompt_graph: Dict[str, Any], - start_id: Optional[str], -) -> Tuple[Optional[str], Optional[str], List[Dict[str, Any]]]: - """ - Walk the 'model' chain from the sampler to collect: - - model_name : ckpt / diffusion model / unet - - vae_name : if present (CheckpointLoaderSimple) - - loras : list [{name, strength_model, strength_clip}, ...] - - Only follows the 'model' branch. - """ - model_name: Optional[str] = None - vae_name: Optional[str] = None - loras: List[Dict[str, Any]] = [] - visited: Set[str] = set() - - current_id = str(start_id) if start_id is not None else None - - while current_id and current_id not in visited: - visited.add(current_id) - node = prompt_graph.get(current_id) - if not node: - break - - node_type = node.get("class_type") - inputs: Dict[str, Any] = node.get("inputs", {}) or {} - - # --- Checkpoints --- - if node_type in CHECKPOINT_LOADER_CLASSES: - ckpt = inputs.get("ckpt_name") or inputs.get("ckpt_file") - if isinstance(ckpt, str): - model_name = ckpt - vae = inputs.get("vae_name") - if isinstance(vae, str): - vae_name = vae - break - - # --- UNET / Diffusion model loaders --- - if node_type in UNET_LOADER_CLASSES: - unet = inputs.get("unet_name") or inputs.get("model_name") - if isinstance(unet, str): - model_name = unet - break - - if node_type in DIFFUSERS_LOADER_CLASSES: - m = inputs.get("model_path") or inputs.get("model_name") - if isinstance(m, str): - model_name = m - break - - # --- LoRA chain --- - if node_type in LORA_CLASSES: - lora_name = inputs.get("lora_name") - if isinstance(lora_name, str): - l_entry = { - "name": lora_name, - "strength_model": inputs.get("strength_model"), - "strength_clip": inputs.get("strength_clip"), - } - loras.append(l_entry) - - next_id = _extract_link_node_id(inputs.get("model")) - # Resolve through reroutes/set/get nodes - next_id = _resolve_through_reroutes(prompt_graph, next_id) - if next_id is None: - break - current_id = next_id - continue - - # --- Other links in the 'model' chain --- - next_id = _extract_link_node_id(inputs.get("model")) - # Resolve through reroutes/set/get nodes - next_id = _resolve_through_reroutes(prompt_graph, next_id) - if next_id is None: - break - current_id = next_id - - return model_name, vae_name, loras - - -def extract_generation_params_from_prompt_graph( - prompt_graph: Dict[str, Any], - raw_workflow: Optional[Dict[str, Any]] = None, - *, - reconstruct_allowed: bool = False, -) -> Dict[str, Any]: - """ - Core extraction of generation parameters from a prompt graph (map id -> node). - - `raw_workflow` can be supplied to return as-is. - - if `reconstruct_allowed` is True and no workflow is provided, rebuild a minimal workflow. - """ - if not prompt_graph: - return {} - - sampler_id = _pick_sampler_node(prompt_graph) - - if sampler_id is None: - collected = _collect_all_texts(prompt_graph) - wf_fallback = None - if isinstance(raw_workflow, dict): - wf_fallback = raw_workflow - elif reconstruct_allowed: - wf_fallback = _prompt_graph_to_workflow(prompt_graph) - return { - "positive_prompt": " | ".join(collected.get("positive") or []), - "negative_prompt": " | ".join(collected.get("negative") or []), - "seed": None, - "steps": None, - "cfg": None, - "sampler_name": None, - "scheduler": None, - "model": None, - "vae": None, - "loras": [], - "workflow": wf_fallback, - } - - sampler = prompt_graph.get(sampler_id, {}) - inputs: Dict[str, Any] = sampler.get("inputs", {}) or {} - - # seed / cfg / steps / sampler / scheduler / denoise / advanced params - seed = inputs.get("seed") or inputs.get("noise_seed") - cfg = ( - inputs.get("cfg") - or inputs.get("cfg_scale") - or inputs.get("guidance") - or inputs.get("guidance_scale") - ) - steps = inputs.get("steps") - sampler_name = inputs.get("sampler_name") or inputs.get("sampler") - scheduler = inputs.get("scheduler") - denoise = inputs.get("denoise") - - # Advanced sampler parameters - start_at_step = inputs.get("start_at_step") - end_at_step = inputs.get("end_at_step") - return_with_leftover_noise = inputs.get("return_with_leftover_noise") - add_noise = inputs.get("add_noise") - - # Prompts - resolve through reroutes first - pos_input = inputs.get("positive") - neg_input = inputs.get("negative") - pos_id = _extract_link_node_id(pos_input) - neg_id = _extract_link_node_id(neg_input) - - # Resolve through reroute/set/get nodes - pos_id = _resolve_through_reroutes(prompt_graph, pos_id) - neg_id = _resolve_through_reroutes(prompt_graph, neg_id) - - if pos_id is None and isinstance(pos_input, str): - positive_prompt = pos_input - else: - # Try direct extraction first, then recursive tracing - positive_prompt = _extract_clip_text(prompt_graph, pos_id) - if not positive_prompt and pos_input is not None: - # ENHANCED: Direct trace from sampler's positive input (API format) - positive_prompt = _trace_to_text_node(prompt_graph, pos_input) - - if neg_id is None and isinstance(neg_input, str): - negative_prompt = neg_input - else: - # Try direct extraction first, then recursive tracing - negative_prompt = _extract_clip_text(prompt_graph, neg_id) - if not negative_prompt and neg_input is not None: - # ENHANCED: Direct trace from sampler's negative input (API format) - negative_prompt = _trace_to_text_node(prompt_graph, neg_input) - - # Fallback: collect any text nodes if prompts are missing - if not positive_prompt or not negative_prompt: - collected = _collect_all_texts(prompt_graph) - if not positive_prompt and collected.get("positive"): - positive_prompt = " | ".join(collected["positive"]) - if not negative_prompt and collected.get("negative"): - negative_prompt = " | ".join(collected["negative"]) - - # Model chain - resolve through reroutes first - model_link_id = ( - _extract_link_node_id(inputs.get("model")) - or _extract_link_node_id(inputs.get("guider")) - ) - # Resolve through reroute/set/get nodes - model_link_id = _resolve_through_reroutes(prompt_graph, model_link_id) - - # If the sampler doesn't expose a model link (common in some video pipelines), - # fallback to any known model loader node in the graph. - if model_link_id is None: - loader_ids: List[str] = [] - for _nid, _node in prompt_graph.items(): - if not isinstance(_node, dict): - continue - _ctype = str(_node.get("class_type") or "") - if _ctype in CHECKPOINT_LOADER_CLASSES or _ctype in UNET_LOADER_CLASSES: - loader_ids.append(str(_nid)) - - if loader_ids: - def _id_score(_id: str) -> tuple[int, str]: - try: - return (int(_id), _id) - except Exception: - return (-1, _id) - - loader_ids.sort(key=_id_score) - model_link_id = loader_ids[-1] - - model_name, vae_name, loras = _walk_model_chain(prompt_graph, model_link_id) - - reconstructed_from_prompt = False - if isinstance(raw_workflow, dict): - workflow = raw_workflow - elif reconstruct_allowed: - workflow = _prompt_graph_to_workflow(prompt_graph) - reconstructed_from_prompt = workflow is not None - else: - workflow = None - - result = { - "positive_prompt": positive_prompt, - "negative_prompt": negative_prompt, - "seed": seed, - "steps": steps, - "cfg": cfg, - "sampler_name": sampler_name, - "scheduler": scheduler, - "denoise": denoise, - "start_at_step": start_at_step, - "end_at_step": end_at_step, - "return_with_leftover_noise": return_with_leftover_noise, - "add_noise": add_noise, - "model": model_name, - "vae": vae_name, - "loras": loras, - "workflow": workflow, - "raw_workflow": raw_workflow if isinstance(raw_workflow, dict) else None, - "prompt_graph": prompt_graph, - "workflow_reconstructed": workflow if reconstructed_from_prompt else None, - "has_workflow": True, # prompt graph present => mark as available - } - return result - - -def extract_generation_params_from_workflow(workflow: Dict[str, Any]) -> Dict[str, Any]: - """ - Variant that takes a raw ComfyUI workflow (dict with 'nodes': [...]). - """ - prompt_graph = _normalize_workflow_to_prompt_graph(workflow) - if prompt_graph is None: - return {} - return extract_generation_params_from_prompt_graph( - prompt_graph, raw_workflow=workflow, reconstruct_allowed=False - ) - - -def _try_extract_from_history(prompt_id: str, debug: bool = False) -> Optional[Dict[str, Any]]: - """ - Attempt to extract generation metadata from ComfyUI history using prompt_id. - Returns prompt_graph dict if found, None otherwise. - - This is a best-effort fallback for files (MP4/WEBP) that don't embed metadata. - """ - try: - from server import PromptServer - - if not hasattr(PromptServer, 'instance'): - if debug: - from .logger import get_logger - log = get_logger(__name__) - log.debug("📁🔍 [Majoor] PromptServer.instance not available for history lookup") - return None - - prompt_server = PromptServer.instance - if not hasattr(prompt_server, 'prompt_queue'): - if debug: - from .logger import get_logger - log = get_logger(__name__) - log.debug("📁🔍 [Majoor] PromptServer.prompt_queue not available") - return None - - # Get history for this prompt_id - history = prompt_server.prompt_queue.get_history(prompt_id=prompt_id) - if not history or prompt_id not in history: - if debug: - from .logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] No history found for prompt_id: {prompt_id}") - return None - - prompt_data = history[prompt_id] - if not isinstance(prompt_data, dict): - return None - - # Extract prompt graph from history - # History format: {prompt_id: {"prompt": {node_id: {...}}, ...}} - prompt_graph = prompt_data.get("prompt") - if prompt_graph and isinstance(prompt_graph, dict): - if debug: - from .logger import get_logger - log = get_logger(__name__) - log.debug(f"📁✅ [Majoor] Found prompt graph in history for {prompt_id} ({len(prompt_graph)} nodes)") - return prompt_graph - - return None - - except Exception as e: - if debug: - from .logger import get_logger - log = get_logger(__name__) - log.debug(f"đŸ“âš ī¸ [Majoor] History lookup failed for {prompt_id}: {e}") - return None - - -def extract_generation_params_from_png(path: str | Path, *, debug: bool = False) -> Dict[str, Any]: - """ - Read all generation info from a ComfyUI output. - - - If path is a video, read prompt/workflow directly from the container (no PNG sibling). - - Read the 'prompt' graph from PNG metadata. - - Choose a sampler (KSampler / SamplerCustom / WAN...). - - Walk the 'model' chain and the positive/negative branches. - """ - p = Path(path) - - original_ext = p.suffix.lower() - - if not p.exists(): - return {} - - # First, try the enhanced comprehensive extraction method - try: - from .metadata.enhanced_extraction import extract_comprehensive_metadata - enhanced_result = extract_comprehensive_metadata(str(p)) - prompt_graph = enhanced_result.get("prompt_graph") - raw_workflow = enhanced_result.get("workflow") - - # CRITICAL FIX: If we have workflow but no prompt_graph, try to normalize - if raw_workflow and not prompt_graph: - prompt_graph = _normalize_workflow_to_prompt_graph(raw_workflow) - - # If normalization failed, try EXIF fallback for prompt_graph - if not prompt_graph and original_ext in {".webp", ".jpg", ".jpeg", ".tif", ".tiff"}: - try: - from .metadata.exif_extraction import extract_comfyui_prompt_workflow_from_exif_fields - exif_prompt, exif_workflow, _ = extract_comfyui_prompt_workflow_from_exif_fields(str(p)) - if exif_prompt: - prompt_graph = exif_prompt - except: - pass - - if prompt_graph or raw_workflow: - # If we found data with enhanced extraction, use it - reconstruct_allowed = original_ext != ".png" - - # Use prompt_graph if available, otherwise try to construct from workflow - graph_for_extraction = prompt_graph - if not graph_for_extraction and raw_workflow: - graph_for_extraction = _normalize_workflow_to_prompt_graph(raw_workflow) or {} - - result = extract_generation_params_from_prompt_graph( - graph_for_extraction, - raw_workflow=raw_workflow, - reconstruct_allowed=reconstruct_allowed, - ) - # Ensure has_workflow is properly set - result["has_workflow"] = enhanced_result.get("has_workflow", False) - result["metadata_sources"] = enhanced_result.get("metadata_sources", []) - - # Debug logging if enabled - if debug and os.environ.get("MJR_DEBUG_METADATA", "0").lower() in ("1", "true", "yes", "on"): - from .logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] Extracted params for {p.name}:") - log.debug(f" - has_workflow: {result.get('has_workflow')}") - log.debug(f" - has prompt_graph: {bool(prompt_graph)}") - log.debug(f" - has raw_workflow: {bool(raw_workflow)}") - log.debug(f" - has positive_prompt: {bool(result.get('positive_prompt'))}") - log.debug(f" - has seed: {result.get('seed') is not None}") - log.debug(f" - has model: {bool(result.get('model'))}") - log.debug(f" - sources: {result.get('metadata_sources')}") - - return result - except ImportError: - # Fallback to original method if enhanced extraction is not available - pass - - raw_workflow = None - prompt_graph = None - - if original_ext in {".mp4", ".mov", ".webm", ".mkv"}: - prompt_graph, raw_workflow = _extract_json_from_video(p) - else: - raw_workflow = load_raw_workflow_from_png(p) - prompt_graph = load_prompt_graph_from_png(p) - - # Try sidecar metadata and history lookup - prompt_id_from_sidecar = None - if prompt_graph is None and load_metadata is not None: - try: - side_meta = load_metadata(str(p)) - if isinstance(side_meta, dict): - # Store prompt_id for potential history lookup - prompt_id_from_sidecar = side_meta.get("prompt_id") - - prompt = _ensure_dict_from_json(side_meta.get("prompt")) - if prompt is not None: - prompt_graph = prompt - else: - wf_raw = _ensure_dict_from_json(side_meta.get("workflow")) - if wf_raw is not None: - prompt_graph = _normalize_workflow_to_prompt_graph(wf_raw) - if raw_workflow is None and isinstance(wf_raw, dict): - raw_workflow = wf_raw - except Exception: - prompt_graph = None - - # Sibling PNG fallback for video/WEBP files without embedded metadata - if prompt_graph is None and original_ext in {".mp4", ".mov", ".webm", ".mkv", ".m4v", ".webp"}: - try: - stem = p.stem - parent_dir = p.parent - candidates = [ - f for f in parent_dir.iterdir() - if f.is_file() and f.suffix.lower() == ".png" and ( - f.stem == stem or f.stem.startswith(f"{stem}_") or f.stem.startswith(f"{stem}-") - ) - ] - - if candidates: - # Sort by modification time (most recent first) - candidates.sort(key=lambda f: f.stat().st_mtime, reverse=True) - # Try to extract from sibling PNG - for png_sibling in candidates: - try: - png_prompt = load_prompt_graph_from_png(png_sibling) - if png_prompt: - prompt_graph = png_prompt - if raw_workflow is None: - raw_workflow = load_raw_workflow_from_png(png_sibling) - if debug: - log.debug(f"📁🔍 [Majoor] Using sibling PNG: {png_sibling.name}") - break - except Exception: - continue - except Exception: - pass - - # History lookup fallback for MP4/WEBP and other formats that may lack embedded metadata - if prompt_graph is None and prompt_id_from_sidecar: - # Determine if we should try history lookup - allow_history = original_ext in {".mp4", ".mov", ".m4v", ".webm", ".mkv", ".webp", ".jpg", ".jpeg", ".avif"} - - # Allow override via environment variable - if os.environ.get("MJR_META_ALLOW_HISTORY", "").lower() in ("1", "true", "yes", "on"): - allow_history = True - - if allow_history: - if debug: - from .logger import get_logger - log = get_logger(__name__) - log.debug(f"📁🔍 [Majoor] Attempting history lookup for {p.name} with prompt_id={prompt_id_from_sidecar}") - - history_prompt_graph = _try_extract_from_history(prompt_id_from_sidecar, debug=debug) - if history_prompt_graph: - prompt_graph = history_prompt_graph - if debug: - from .logger import get_logger - log = get_logger(__name__) - log.debug(f"📁✅ [Majoor] Successfully retrieved metadata from history for {p.name}") - - # Fallback: A1111/SD-WebUI Parameters block (no workflow) - # Extended to support WEBP, JPG, TIFF in addition to PNG - if prompt_graph is None and original_ext in {".png", ".webp", ".jpg", ".jpeg", ".tif", ".tiff"} and Image is not None: - try: - # Use the robust helper that checks both PNG info and EXIF - params_val = _extract_parameters_from_image(p) - - if isinstance(params_val, str) and params_val.strip(): - parsed = parse_a1111_parameters(params_val) - if parsed: - parsed.setdefault("has_workflow", False) - parsed.setdefault("source", "a1111_parameters") - # Add informational note - parsed.setdefault("metadata_notes", "Generation parameters detected (A1111-style), ComfyUI workflow not embedded") - - # Log debug info if enabled - if debug or os.environ.get("MJR_DEBUG_METADATA", "").lower() in ("1", "true", "yes", "on"): - from .logger import get_logger - log = get_logger(__name__) - log.debug(f"📁✅ [Majoor] Extracted A1111 parameters from {original_ext} file: {p.name}") - log.debug(f"📁🔍 [Majoor] - Positive prompt length: {len(parsed.get('positive_prompt', ''))}") - log.debug(f"📁🔍 [Majoor] - Has seed: {parsed.get('seed') is not None}") - log.debug(f"📁🔍 [Majoor] - Has steps: {parsed.get('steps') is not None}") - - return parsed - except Exception as e: - if debug or os.environ.get("MJR_DEBUG_METADATA", "").lower() in ("1", "true", "yes", "on"): - from .logger import get_logger - log = get_logger(__name__) - log.debug(f"đŸ“âš ī¸ [Majoor] A1111 parameter extraction failed for {p.name}: {e}") - - if not prompt_graph: - return {} - - reconstruct_allowed = original_ext != ".png" - return extract_generation_params_from_prompt_graph( - prompt_graph, - raw_workflow=raw_workflow, - reconstruct_allowed=reconstruct_allowed, - ) - - -def has_generation_workflow(path: str | Path) -> bool: - """ - Lightweight presence check for generation workflow/prompt. - Avoids reconstruction; just detects whether metadata contains a prompt/workflow. - - PATCH #3: Added sibling PNG fallback for WEBP files (like MP4). - """ - p = Path(path) - if not p.exists(): - return False - - # First, try the enhanced comprehensive extraction method - try: - from .metadata.enhanced_extraction import has_generation_workflow_enhanced - if has_generation_workflow_enhanced(str(p)): - return True - except ImportError: - # Fallback to original method if enhanced extraction is not available - pass - - ext = p.suffix.lower() - prompt_graph = None - raw_workflow = None - - if ext in {".mp4", ".mov", ".webm", ".mkv"}: - prompt_graph, raw_workflow = _extract_json_from_video(p) - else: - if ext not in IMAGE_EXTS: - return False - if Image is None or not p.exists(): - return False - prompt_graph = None - raw_workflow = None - try: - with Image.open(p) as img: - info = getattr(img, "info", {}) or {} - prompt_graph, raw_workflow = _scan_png_info_for_generation(info) - except Exception: - prompt_graph = None - raw_workflow = None - if not prompt_graph: - prompt_graph = load_prompt_graph_from_png(p) - if not raw_workflow: - raw_workflow = load_raw_workflow_from_png(p) - - if prompt_graph or raw_workflow: - return True - - if load_metadata is not None: - try: - side_meta = load_metadata(str(p)) - if isinstance(side_meta, dict): - pr = _ensure_dict_from_json(side_meta.get("prompt")) - wf = _ensure_dict_from_json(side_meta.get("workflow")) - if (isinstance(pr, dict) and pr) or (isinstance(wf, dict) and wf): - return True - except Exception: - pass - - # PATCH #3: Sibling PNG fallback for WEBP/MP4 files - # If this is a WEBP or video file without workflow, check for sibling PNG - if ext in {".webp", ".mp4", ".mov", ".webm", ".mkv", ".m4v"}: - try: - stem = p.stem - parent_dir = p.parent - candidates = [ - f for f in parent_dir.iterdir() - if f.is_file() and f.suffix.lower() == ".png" and ( - f.stem == stem or f.stem.startswith(f"{stem}_") or f.stem.startswith(f"{stem}-") - ) - ] - - if candidates: - # Sort by modification time (most recent first) - candidates.sort(key=lambda f: f.stat().st_mtime, reverse=True) - # Check if any sibling PNG has workflow - for png_sibling in candidates: - if has_generation_workflow(png_sibling): - return True - except Exception: - pass - - return False diff --git a/server/routes.py b/server/routes.py deleted file mode 100644 index 3e42e9e2..00000000 --- a/server/routes.py +++ /dev/null @@ -1,2577 +0,0 @@ -import os -import sys -import subprocess -import asyncio -import time -import threading -import shutil -import platform -import concurrent.futures -import json -import re -import zipfile -import math -from pathlib import Path -from typing import Any, Dict, List, Optional -from functools import wraps -from collections import OrderedDict - -try: - from send2trash import send2trash # type: ignore -except Exception: - send2trash = None - -from aiohttp import web - -import folder_paths -from server import PromptServer -from .logger import get_logger -from .utils import ( - IMAGE_EXTS, - VIDEO_EXTS, - AUDIO_EXTS, - MODEL3D_EXTS, - classify_ext, - get_system_metadata, - load_metadata, - save_metadata, - metadata_path, - _get_exiftool_path, - _coerce_rating_to_stars, - _normalize_tags, -) -from .metadata import update_metadata_with_windows, deep_merge_metadata -from .metadata_generation import extract_generation_params_from_png, has_generation_workflow -from .config import OUTPUT_ROOT, ENABLE_JSON_SIDECAR, METADATA_EXT, INDEX_MODE -from .collections_store import ( - get_collections, - load_collection, - add_to_collection, - remove_from_collection, - save_collection, - _validate_collection_name, -) -from .emoji import emoji_prefix -from .index_db import ( - get_index_status, - query_assets, - start_background_reindex, - reindex_paths, -) -from .workflow_hash import get_hash_algorithm_info - -log = get_logger(__name__) - - -def handle_connection_reset(func): - """ - Decorator to handle ConnectionResetError consistently across endpoints. - Returns HTTP 499 (Client Closed Request) when connection is reset. - """ - @wraps(func) - async def wrapper(*args, **kwargs): - try: - return await func(*args, **kwargs) - except ConnectionResetError: - return web.Response(status=499) - return wrapper - - -def _json_sanitize(obj: Any) -> Any: - """ - Ensure objects are JSON-safe and strict-JSON compliant. - Python's json.dumps allows NaN/Infinity by default, but browsers reject them. - """ - if obj is None: - return None - if isinstance(obj, float): - return obj if math.isfinite(obj) else None - if isinstance(obj, (bytes, bytearray)): - try: - return bytes(obj).decode("utf-8", errors="replace") - except (UnicodeDecodeError, AttributeError) as e: - log.debug("[Majoor] Failed to decode bytes: %s", e) - return repr(obj) - if isinstance(obj, Path): - return str(obj) - if isinstance(obj, dict): - out: Dict[str, Any] = {} - for k, v in obj.items(): - try: - key = str(k) - except (TypeError, ValueError) as e: - log.debug("[Majoor] Failed to convert key to string: %s", e) - key = repr(k) - out[key] = _json_sanitize(v) - return out - if isinstance(obj, (list, tuple, set)): - return [_json_sanitize(v) for v in obj] - return obj - - -def _json_response(data: Any, status: int = 200) -> web.Response: - cleaned = _json_sanitize(data) - return web.json_response( - cleaned, - status=status, - dumps=lambda x: json.dumps(x, ensure_ascii=False, allow_nan=False), - ) - - -def _route_error_response( - description: str, - *, - status: int = 500, - exc: Optional[Exception] = None, - body: Optional[Dict[str, Any]] = None, - level: str = "error", -) -> web.Response: - prefix = emoji_prefix(level) - log.error("%s%s", prefix, description, exc_info=exc) - payload = body or {"ok": False, "error": description} - return _json_response(payload, status=status) - - -_MAX_METADATA_TAGS = 64 -_MAX_METADATA_TAG_LENGTH = 128 - - -async def _validate_payload_size(request: web.Request, max_size_mb: float = 10.0) -> Optional[web.Response]: - """ - Validate request payload size to prevent DoS attacks. - Returns error response if payload exceeds limit, None otherwise. - """ - content_length = request.content_length - if content_length is not None: - max_bytes = int(max_size_mb * 1024 * 1024) - if content_length > max_bytes: - return _json_response( - {"ok": False, "error": f"Payload too large (max {max_size_mb}MB)"}, - status=413 - ) - return None - - -def _clean_metadata_tags(raw: Any) -> List[str]: - tags = _normalize_tags(raw) - sanitized: List[str] = [] - for tag in tags: - if not tag: - continue - trimmed = tag[:_MAX_METADATA_TAG_LENGTH] - if not trimmed: - continue - sanitized.append(trimmed) - if len(sanitized) >= _MAX_METADATA_TAGS: - break - return sanitized - - -# --------------------------------------------------------------------------- -# File Request Validation Helpers (reduces code duplication) -# --------------------------------------------------------------------------- - -async def _parse_json_payload(request: web.Request) -> tuple[Optional[dict], Optional[web.Response]]: - """ - Parse JSON payload with proper error handling. - Returns (payload_dict, error_response). If error_response is not None, return it immediately. - """ - try: - payload = await request.json() - return (payload, None) - except json.JSONDecodeError as e: - return (None, _route_error_response("Invalid JSON payload", status=400, exc=e)) - except Exception as e: - return (None, _route_error_response("Failed to parse JSON payload", status=400, exc=e)) - - -def _validate_file_path(root: Path, filename: str, subfolder: str = "") -> tuple[Optional[Path], Optional[dict]]: - """ - Validate and construct safe file path. - Returns (path, error_dict). If error_dict is not None, return error response. - - Common validation pattern used across multiple endpoints. - """ - if not filename: - return (None, {"ok": False, "error": "Missing filename"}) - - try: - target = _safe_target(root, subfolder, filename) - except ValueError as e: - log.warning("đŸ“âš ī¸ [Majoor] Invalid file path during validation: %s", e) - return (None, {"ok": False, "error": "Invalid path"}) - - if not target.exists(): - return (None, {"ok": False, "error": "File not found"}) - - return (target, None) - - -# --------------------------------------------------------------------------- -# In-memory cache for output listing -# Avoids redoing a full os.scandir on every auto-refresh. -# --------------------------------------------------------------------------- - -_FILE_CACHE: List[Dict[str, Any]] = [] -_LAST_SCAN_TS: float = 0.0 -_LAST_FOLDER_MTIME: float = 0.0 -_LAST_FOLDER_SIGNATURE: Optional[tuple] = None -_LAST_FOLDER_CHECK_TS: float = 0.0 # Cache folder signature checks -_BATCH_ZIP_CACHE: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() -_BATCH_ZIP_LOCK = threading.Lock() -_BATCH_ZIP_MAX_AGE_S = 3600.0 -_BATCH_ZIP_CACHE_MAX = int(os.environ.get("MJR_BATCH_ZIP_CACHE_MAX", "64")) -_BATCH_ZIP_CACHE_MAX = max(1, min(_BATCH_ZIP_CACHE_MAX, 256)) - -try: - _SCAN_MIN_INTERVAL = float(os.environ.get("MJR_SCAN_MIN_INTERVAL", "5.0")) -except (ValueError, TypeError) as e: - log.warning("đŸ“âš ī¸ [Majoor] Invalid MJR_SCAN_MIN_INTERVAL, using default: %s", e) - _SCAN_MIN_INTERVAL = 5.0 - -try: - _FOLDER_CHECK_INTERVAL = float(os.environ.get("MJR_FOLDER_CHECK_INTERVAL", "2.0")) -except (ValueError, TypeError) as e: - log.warning("đŸ“âš ī¸ [Majoor] Invalid MJR_FOLDER_CHECK_INTERVAL, using default: %s", e) - _FOLDER_CHECK_INTERVAL = 2.0 - -try: - _META_PREFETCH_COUNT = int(os.environ.get("MJR_META_PREFETCH_COUNT", "80")) - if _META_PREFETCH_COUNT < 0: - _META_PREFETCH_COUNT = 0 -except (ValueError, TypeError) as e: - log.warning("đŸ“âš ī¸ [Majoor] Invalid MJR_META_PREFETCH_COUNT, using default: %s", e) - _META_PREFETCH_COUNT = 80 - -_CACHE_LOCK = threading.Lock() - -_ASYNCIO_SILENCER_INSTALLED = False -_ASYNCIO_SILENCER_LOCK = threading.Lock() - -# LRU cache for has_workflow checks (prevents repeated PNG/video parsing) -_HAS_WORKFLOW_CACHE: "OrderedDict[str, tuple[float, bool]]" = OrderedDict() -_HAS_WORKFLOW_CACHE_LOCK = threading.Lock() -try: - _HAS_WORKFLOW_CACHE_MAX = int(os.environ.get("MJR_HAS_WORKFLOW_CACHE_SIZE", "2000")) -except Exception: - _HAS_WORKFLOW_CACHE_MAX = 2000 -_HAS_WORKFLOW_CACHE_MAX = max(0, min(_HAS_WORKFLOW_CACHE_MAX, 20000)) - -# Windows reserved filenames (security constant) -_WINDOWS_RESERVED_NAMES = frozenset({ - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" -}) - - -def _install_windows_asyncio_connection_reset_silencer() -> None: - """ - On Windows, aborted HTTP requests can produce noisy asyncio logs like: - "Exception in callback _ProactorBasePipeTransport._call_connection_lost()" - with ConnectionResetError([WinError 10054]). - - This does not affect correctness, but it can confuse users and pollute logs. - - FIX: Use lock to prevent race condition during handler installation. - """ - global _ASYNCIO_SILENCER_INSTALLED - - # Fast path: already installed - if _ASYNCIO_SILENCER_INSTALLED: - return - - # Non-Windows fast path - if sys.platform != "win32": - with _ASYNCIO_SILENCER_LOCK: - _ASYNCIO_SILENCER_INSTALLED = True - return - - # Lock to prevent concurrent installation - with _ASYNCIO_SILENCER_LOCK: - # Double-check after acquiring lock - if _ASYNCIO_SILENCER_INSTALLED: - return - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return - - previous = loop.get_exception_handler() - - def _handler(loop: asyncio.AbstractEventLoop, context: Dict[str, Any]) -> None: - exc = context.get("exception") - msg = context.get("message") or "" - if ( - isinstance(exc, ConnectionResetError) - and getattr(exc, "winerror", None) == 10054 - and "_call_connection_lost" in str(msg) - ): - return - - if previous: - previous(loop, context) - else: - loop.default_exception_handler(context) - - loop.set_exception_handler(_handler) - _ASYNCIO_SILENCER_INSTALLED = True - - -def _sanitize_batch_token(token: Optional[str]) -> Optional[str]: - if token is None: - return None - token = str(token).strip() - if not token: - return None - if not re.match(r"^[A-Za-z0-9_-]{6,80}$", token): - return None - return token - - -def _cleanup_batch_zips(root: Path) -> None: - now = time.time() - with _BATCH_ZIP_LOCK: - for token, info in list(_BATCH_ZIP_CACHE.items()): - created_at = float(info.get("created_at") or 0) - if now - created_at <= _BATCH_ZIP_MAX_AGE_S: - continue - path = info.get("path") - try: - if isinstance(path, Path) and path.exists(): - path.unlink() - except (OSError, PermissionError) as e: - log.debug("[Majoor] Failed to delete batch zip cache file: %s", e) - _BATCH_ZIP_CACHE.pop(token, None) - while len(_BATCH_ZIP_CACHE) > _BATCH_ZIP_CACHE_MAX: - _, info = _BATCH_ZIP_CACHE.popitem(last=False) - old_path = info.get("path") - if isinstance(old_path, Path) and old_path.exists(): - try: - old_path.unlink() - except (OSError, PermissionError): - pass - - -def _store_batch_zip_cache(token: str, entry: Dict[str, Any]) -> None: - with _BATCH_ZIP_LOCK: - if token in _BATCH_ZIP_CACHE: - _BATCH_ZIP_CACHE.pop(token) - _BATCH_ZIP_CACHE[token] = entry - while len(_BATCH_ZIP_CACHE) > _BATCH_ZIP_CACHE_MAX: - old_token, old_info = _BATCH_ZIP_CACHE.popitem(last=False) - old_path = old_info.get("path") - if isinstance(old_path, Path) and old_path.exists(): - try: - old_path.unlink() - except (OSError, PermissionError): - pass - - -def _folder_changed(root_path: Path) -> bool: - """ - Quick check if folder changed (file added/modified). - Uses a two-tier caching strategy: - 1. Check root mtime (fast) - 2. If unchanged, cache signature check for _FOLDER_CHECK_INTERVAL seconds - """ - global _LAST_FOLDER_MTIME, _LAST_FOLDER_SIGNATURE, _LAST_FOLDER_CHECK_TS - try: - current_mtime = root_path.stat().st_mtime - if current_mtime != _LAST_FOLDER_MTIME: - _LAST_FOLDER_MTIME = current_mtime - _LAST_FOLDER_SIGNATURE = None # force recompute - _LAST_FOLDER_CHECK_TS = 0.0 - return True - - # If we recently checked the signature and it was unchanged, skip rechecking - now = time.time() - if _LAST_FOLDER_SIGNATURE is not None and (now - _LAST_FOLDER_CHECK_TS) < _FOLDER_CHECK_INTERVAL: - return False - - # Fallback signature (top-level dirs/files count + max mtime) - def _signature(): - total = 0 - max_m = current_mtime - sub_sig = [] - try: - with os.scandir(root_path) as it: - for entry in it: - try: - st = entry.stat() - max_m = max(max_m, st.st_mtime) - except Exception: - continue - total += 1 - if entry.is_dir(): - try: - with os.scandir(entry.path) as sub: - sub_count = sum(1 for _ in sub) - sub_sig.append((entry.name, st.st_mtime, sub_count)) - except (OSError, PermissionError): - # Subdirectory inaccessible, use None for count - sub_sig.append((entry.name, st.st_mtime, None)) - else: - sub_sig.append((entry.name, st.st_mtime, None)) - except (OSError, PermissionError) as e: - log.debug("[Majoor] Failed to read folder entry during signature: %s", e) - return None - sub_sig.sort() - return (total, max_m, tuple(sub_sig)) - - sig = _signature() - _LAST_FOLDER_CHECK_TS = now - if sig is None: - return True - if sig != _LAST_FOLDER_SIGNATURE: - _LAST_FOLDER_SIGNATURE = sig - return True - return False - except (OSError, PermissionError, AttributeError) as e: - log.debug("[Majoor] Error checking folder changes: %s", e) - return True # Assume changed on error - - -def _get_output_root() -> Path: - """Use the ComfyUI output folder (respects --output-directory).""" - return Path(folder_paths.get_output_directory()).resolve() - - -def _safe_target(root: Path, subfolder: str, filename: str) -> Path: - """ - Build a path under root while rejecting traversal/absolute components. - - filename must be a plain name (no separators) - - subfolder must be relative and free of '..' - - Additional security checks for null bytes, suspicious patterns, ADS, UNC paths - - FIX: Added UNC path detection to prevent bypassing is_absolute() check - """ - if not filename: - raise ValueError("Missing filename") - - # Security: Reject null bytes (path truncation attacks) - if "\x00" in filename or (subfolder and "\x00" in subfolder): - raise ValueError("Null byte in path") - - # Security: Reject UNC paths (\\server\share or //server/share) - if (filename.startswith("\\\\") or filename.startswith("//") or - (subfolder and (subfolder.startswith("\\\\") or subfolder.startswith("//")))): - raise ValueError("UNC paths not allowed") - - # Security: Reject Windows Alternate Data Streams (ADS) - if sys.platform == "win32": - if ":" in filename or (subfolder and ":" in subfolder): - # Allow drive letters in absolute paths but reject in filenames - if not (len(filename) == 2 and filename[1] == ":"): - raise ValueError("Invalid path: Alternate Data Streams not allowed") - - # Security: Reject suspicious path components - if Path(filename).name != filename: - raise ValueError("Invalid filename") - - # Security: Check for Windows reserved names - base_name = filename.split('.')[0].upper() - if base_name in _WINDOWS_RESERVED_NAMES: - raise ValueError("Invalid filename: Windows reserved name") - - if filename in (".", ".."): - raise ValueError("Invalid filename component") - - sub = Path(subfolder) if subfolder else Path() - if sub.is_absolute() or any(part == ".." for part in sub.parts): - raise ValueError("Invalid subfolder") - - # Security: Additional check for current directory references - if any(part == "." for part in sub.parts): - raise ValueError("Invalid subfolder: current directory reference") - - candidate = (root / sub / filename).resolve() - - # Security: Verify resolved path is under root (prevents symlink escapes) - try: - candidate.relative_to(root) - except ValueError: - raise ValueError("Path outside root directory") - - # Security: Double-check no intermediate symlinks escape root (defense in depth) - if platform.system() != "Windows": # Only applicable to Unix-like systems - # Walk up the path and verify no symlinks point outside root - check_path = candidate - while check_path != root: - if check_path.is_symlink(): - real_target = check_path.readlink() - if real_target.is_absolute(): - try: - real_target.resolve().relative_to(root) - except ValueError: - raise ValueError("Symlink points outside root directory") - check_path = check_path.parent - if check_path == check_path.parent: # Reached filesystem root - break - - return candidate - - -_STAGE_VIDEO_EXTS = {"mp4", "mov", "mkv", "webm"} - - -def _is_allowed_video_filename(filename: str) -> bool: - try: - ext = Path(filename).suffix.lower().lstrip(".") - except Exception: - return False - return ext in _STAGE_VIDEO_EXTS - - -def _resolve_stage_collision( - dest_dir: Path, filename: str, collision_policy: str -) -> tuple[Path, str, str]: - """ - Returns (dest_path, final_filename, collision_state). - collision_state is one of: "none", "renamed", "overwritten". - """ - collision_policy = (collision_policy or "rename").lower().strip() - if collision_policy not in ("rename", "overwrite", "error"): - raise ValueError("Invalid collision_policy") - - dest = (dest_dir / filename).resolve() - dest.relative_to(dest_dir) # safety guard - - if not dest.exists(): - return dest, filename, "none" - - if collision_policy == "overwrite": - return dest, filename, "overwritten" - - if collision_policy == "error": - raise FileExistsError("Destination exists") - - base = Path(filename).stem - suffix = Path(filename).suffix - for i in range(1, 10000): - candidate_name = f"{base}_{i:03d}{suffix}" - candidate = (dest_dir / candidate_name).resolve() - candidate.relative_to(dest_dir) - if not candidate.exists(): - return candidate, candidate_name, "renamed" - - raise RuntimeError("Failed to generate a unique filename") - - -def _rating_tags_with_fallback(meta_target: Path, kind: str, _cache: Optional[dict] = None) -> tuple[int, List[str]]: - """ - Read rating/tags for a file, and for videos optionally fall back to a PNG sibling sidecar - when both rating and tags are missing. - - FIX: Added optional request-scoped cache parameter to prevent redundant reads within same request. - Pass a dict as _cache to enable caching for the request lifecycle. - """ - # Check request-scoped cache first - if _cache is not None: - cache_key = str(meta_target) - if cache_key in _cache: - return _cache[cache_key] - sys_meta = get_system_metadata(str(meta_target)) or {} - json_meta = load_metadata(str(meta_target)) or {} - - rating = sys_meta["rating"] if "rating" in sys_meta else json_meta.get("rating") - tags = sys_meta["tags"] if "tags" in sys_meta else json_meta.get("tags") - - sys_empty = not sys_meta - json_empty = not json_meta - emptyish_rating = rating is None or rating == 0 - emptyish_tags = tags is None or tags == [] - - if kind == "video" and sys_empty and json_empty and emptyish_rating and emptyish_tags: - try: - sib_png = meta_target.with_suffix(".png") - sib_json = load_metadata(str(sib_png)) or {} - if rating is None: - r = sib_json.get("rating") - if isinstance(r, int): - rating = r - if tags is None: - t = sib_json.get("tags") - if isinstance(t, list): - tags = t - except Exception: - pass - - if rating is None: - rating = 0 - if tags is None: - tags = [] - - result = (rating, tags if isinstance(tags, list) else []) - - # Store in request-scoped cache - if _cache is not None: - _cache[cache_key] = result - - return result - - -def _has_workflow_cached(target: Path) -> bool: - """ - Cache has_generation_workflow by (path, mtime) to avoid repeated parsing. - """ - if _HAS_WORKFLOW_CACHE_MAX <= 0: - try: - return has_generation_workflow(target) - except Exception: - return False - - try: - mtime = target.stat().st_mtime - except Exception: - return False - - key = str(target) - with _HAS_WORKFLOW_CACHE_LOCK: - cached = _HAS_WORKFLOW_CACHE.get(key) - if cached and cached[0] == mtime: - _HAS_WORKFLOW_CACHE.move_to_end(key) - return cached[1] - - try: - has_wf = has_generation_workflow(target) - except Exception: - has_wf = False - - with _HAS_WORKFLOW_CACHE_LOCK: - _HAS_WORKFLOW_CACHE[key] = (mtime, has_wf) - _HAS_WORKFLOW_CACHE.move_to_end(key) - while len(_HAS_WORKFLOW_CACHE) > _HAS_WORKFLOW_CACHE_MAX: - _HAS_WORKFLOW_CACHE.popitem(last=False) - - return has_wf - - -def _open_in_explorer(path: Path) -> bool: - """Open Windows Explorer selecting the given file.""" - try: - try: - fm_timeout = float(os.environ.get("MJR_FILE_MANAGER_TIMEOUT", "15")) - except Exception: - fm_timeout = 15.0 - fm_timeout = max(1.0, min(fm_timeout, 60.0)) - start_cmd = f'start "" explorer.exe /select,"{str(path)}"' - res_start = subprocess.run( - start_cmd, - shell=True, - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=fm_timeout, - ) - if res_start.returncode in (0, 1): - return True - - cmd = f'explorer.exe /select,"{str(path)}"' - res = subprocess.run( - cmd, - shell=True, - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=fm_timeout, - ) - if res.returncode in (0, 1): - return True - - res2 = subprocess.run( - ["explorer.exe", "/select,", str(path)], - shell=False, - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=fm_timeout, - ) - return res2.returncode in (0, 1) - except Exception: - return False - - -def _open_in_file_manager(path: Path) -> tuple[bool, Optional[str]]: - """ - Open the OS file manager for a given path. - - Windows: Explorer with selection when possible. - - macOS: Finder with selection via `open -R`. - - Linux: open the containing folder via xdg-open/gio/kde-open. - Returns: (ok, warning_message) - """ - try: - system = platform.system() - is_file = path.is_file() - folder = path.parent if is_file else path - try: - fm_timeout = float(os.environ.get("MJR_FILE_MANAGER_TIMEOUT", "15")) - except Exception: - fm_timeout = 15.0 - fm_timeout = max(1.0, min(fm_timeout, 60.0)) - - def _run(cmd: List[str]) -> int: - return subprocess.run( - cmd, - shell=False, - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=fm_timeout, - ).returncode - - if system == "Windows": - if is_file and _open_in_explorer(path): - return True, None - rc = _run(["explorer.exe", str(folder)]) - if rc in (0, 1): - return True, ("Opened folder (selection may not be highlighted)" if is_file else None) - return False, f"explorer.exe failed (code {rc})" - - if system == "Darwin": - if is_file: - rc = _run(["open", "-R", str(path)]) - if rc == 0: - return True, None - rc2 = _run(["open", str(folder)]) - if rc2 == 0: - return True, "Opened folder (selection may not be highlighted)" - return False, f"open failed (code {rc2})" - rc = _run(["open", str(folder)]) - return (rc == 0), (None if rc == 0 else f"open failed (code {rc})") - - # Linux / other - opener = None - for candidate in ("xdg-open", "gio", "kde-open5", "kde-open", "gnome-open", "exo-open"): - if shutil.which(candidate): - opener = candidate - break - if not opener: - return False, "No supported opener found (xdg-open/gio/kde-open/gnome-open)" - - if opener == "gio": - rc = _run([opener, "open", str(folder)]) - else: - rc = _run([opener, str(folder)]) - if rc == 0: - return True, ("Opened folder (selection may not be supported on this OS)" if is_file else None) - return False, f"{opener} failed (code {rc})" - except Exception as exc: - return False, str(exc) - - -def _format_size(num_bytes: int) -> str: - if not isinstance(num_bytes, (int, float)) or num_bytes < 0: - return "" - units = ["B", "KB", "MB", "GB", "TB"] - value = float(num_bytes) - i = 0 - while value >= 1024.0 and i < len(units) - 1: - value /= 1024.0 - i += 1 - if value >= 10: - return f"{value:.1f} {units[i]}" - return f"{value:.2f} {units[i]}" - - -def _format_date(ts: float) -> str: - from datetime import datetime - - try: - return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") - except Exception: - return "" - - -def _build_view_url(filename: str, subfolder: str) -> str: - from urllib.parse import urlencode - - params = {"filename": filename, "type": "output"} - if subfolder: - params["subfolder"] = subfolder - return f"/view?{urlencode(params)}" - - -# --------------------------------------------------------------------------- -# OPTIMIZED SCANNING LOGIC (os.scandir) -# --------------------------------------------------------------------------- - -def _scan_folder_recursive(base_path: str, subfolder: str, results: List[Dict[str, Any]]): - """ - Recursive scanner using os.scandir for performance. - - Avoids Path object creation in the hot loop. - - Uses entry.stat() which is cached on Windows. - """ - try: - with os.scandir(base_path) as it: - for entry in it: - if entry.is_dir(): - if not entry.name.startswith("."): - new_sub = os.path.join(subfolder, entry.name) if subfolder else entry.name - _scan_folder_recursive(entry.path, new_sub, results) - elif entry.is_file(): - try: - name = entry.name - lower = name.lower() - kind = classify_ext(lower) - if kind == "other": - continue - - stat = entry.stat() - mtime = stat.st_mtime - size = stat.st_size - - # FIX: Convert mtime to milliseconds for frontend consistency - # Frontend expects milliseconds (JavaScript Date uses ms) - mtime_ms = int(mtime * 1000) - - ext = (os.path.splitext(name)[1][1:] or "").upper() - - rel_path = f"{subfolder}/{name}" if subfolder else name - rel_path = rel_path.replace("\\", "/") - sub_clean = subfolder.replace("\\", "/") - - results.append({ - "filename": name, - "name": name, - "subfolder": sub_clean, - "relpath": rel_path, - "mtime": mtime_ms, # Send as milliseconds - "date": _format_date(mtime), - "size": size, - "size_readable": _format_size(size), - "kind": kind, - "ext": ext, - "url": _build_view_url(name, sub_clean), - }) - except OSError: - continue - except OSError: - pass - - -def _scan_outputs() -> List[Dict[str, Any]]: - """ - Entry point for the optimized scanner. - """ - root = _get_output_root() - files: List[Dict[str, Any]] = [] - - if not root.exists(): - return files - - _scan_folder_recursive(str(root), "", files) - - files.sort(key=lambda f: f["mtime"], reverse=True) - return files - - -def _list_files_from_index(offset: int, limit: int) -> Dict[str, Any]: - """ - List files from the SQLite index (output only), preserving the /files response shape. - """ - limit = max(0, int(limit or 0)) - offset = max(0, int(offset or 0)) - # Match filesystem scanner behavior: output + known media kinds only. - filters = {"type": "output", "kind": ["image", "video", "audio", "model3d"]} - result = query_assets(filters=filters, sort="mtime_desc", limit=limit or 10**9, offset=offset) - items = [] - for asset in result.get("assets", []): - filename = asset.get("filename") or "" - subfolder = (asset.get("subfolder") or "").replace("\\", "/") - rel_path = f"{subfolder}/{filename}" if subfolder else filename - rel_path = rel_path.replace("\\", "/") - ext = (asset.get("ext") or os.path.splitext(filename)[1].lstrip(".") or "").upper() - mtime_ms = int(asset.get("mtime") or 0) - mtime_s = (mtime_ms / 1000.0) if mtime_ms else 0.0 - size = int(asset.get("size") or 0) - kind = asset.get("kind") or classify_ext(filename.lower()) - items.append( - { - "filename": filename, - "name": filename, - "subfolder": subfolder, - "relpath": rel_path, - "mtime": mtime_ms, - "date": _format_date(mtime_s) if mtime_s else "", - "size": size, - "size_readable": _format_size(size), - "kind": kind, - "ext": ext, - "url": _build_view_url(filename, subfolder), - "rating": asset.get("rating", 0), - "tags": asset.get("tags", []), - "has_workflow": asset.get("has_workflow", 0), - } - ) - - total = int(result.get("total") or 0) - has_more = (offset + len(items)) < total - next_offset = (offset + len(items)) if has_more else None - return {"files": items, "total": total, "has_more": has_more, "next_offset": next_offset} - - -async def _list_files_from_index_async(offset: int, limit: int) -> Dict[str, Any]: - return await asyncio.to_thread(_list_files_from_index, offset, limit) - - -def _scan_outputs_cached(force: bool = False) -> List[Dict[str, Any]]: - """ - Wrapper around _scan_outputs with in-memory cache. - Uses double-checked locking with proper atomic update to prevent race conditions. - """ - global _FILE_CACHE, _LAST_SCAN_TS - - # Fast path: check without lock (read cache snapshot atomically) - if not force: - with _CACHE_LOCK: - now = time.time() - if _FILE_CACHE and (now - _LAST_SCAN_TS) < _SCAN_MIN_INTERVAL: - # Return a copy to prevent external modifications - return _FILE_CACHE - - # Slow path: acquire lock and recheck - with _CACHE_LOCK: - now = time.time() - # Recheck after acquiring lock (another thread may have updated) - if not force and _FILE_CACHE and (now - _LAST_SCAN_TS) < _SCAN_MIN_INTERVAL: - return _FILE_CACHE - - root = _get_output_root() - if not force and _FILE_CACHE and not _folder_changed(root): - # Update timestamp even if folder hasn't changed (prevents repeated checks) - _LAST_SCAN_TS = now - return _FILE_CACHE - - # Perform scan and atomically update cache - files = _scan_outputs() - _FILE_CACHE = files - _LAST_SCAN_TS = now - return files - - -async def _scan_outputs_async(force: bool = False) -> List[Dict[str, Any]]: - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, _scan_outputs_cached, force) - - -@PromptServer.instance.routes.get("/mjr/filemanager/files") -@handle_connection_reset -async def list_files(request: web.Request) -> web.Response: - _install_windows_asyncio_connection_reset_silencer() - force_param = (request.query.get("force") or "").lower() - force = force_param in ("1", "true", "yes", "force") - source = (request.query.get("source") or "scan").strip().lower() - - # Optional pagination - # Backward-compatible: if no limit is provided, return the full list. - try: - offset = int(request.query.get("offset") or "0") - except Exception: - offset = 0 - try: - limit_raw = request.query.get("limit") - paged = limit_raw is not None - limit = int(limit_raw) if limit_raw is not None else 0 - except Exception: - paged = False - limit = 0 - - offset = max(0, offset) - limit = max(0, limit) - - using_index = False - index_payload: Optional[Dict[str, Any]] = None - - if paged and source in ("auto", "index"): - try: - status = await asyncio.to_thread(get_index_status) - freshness = status.get("freshness") - status_state = status.get("status") - ready = freshness == "up_to_date" and status_state != "error" - if source == "index": - using_index = ready - else: - using_index = ready - except Exception: - using_index = False - - if using_index: - index_payload = await _list_files_from_index_async(offset, limit) - items = index_payload.get("files", []) - total = int(index_payload.get("total") or 0) - has_more = bool(index_payload.get("has_more")) - next_offset = index_payload.get("next_offset") - - # Safety fallback: if index is empty but filesystem has outputs, use scan. - # This avoids "No outputs found" when index is stale but marked up_to_date. - if source == "auto" and total == 0 and paged and offset == 0: - try: - scan_files = await _scan_outputs_async(force=False) - except Exception: - scan_files = [] - if scan_files: - using_index = False - items = scan_files[:limit] if limit > 0 else scan_files - total = len(scan_files) - has_more = (offset + len(items)) < total - next_offset = (offset + len(items)) if has_more else None - try: - await asyncio.to_thread(start_background_reindex) - except Exception: - pass - else: - files = await _scan_outputs_async(force=force) - total = len(files) - - items = files - if limit > 0: - items = files[offset : offset + limit] - else: - offset = 0 - limit = total - - has_more = (offset + len(items)) < total - next_offset = (offset + len(items)) if has_more else None - - # Prefetch metadata for the first N items without delaying the response. - # Only do this for the first paged request (offset=0, limit provided). - # Uses asyncio.create_task for true non-blocking async execution. - # FIX: Create deep copy of items to avoid race condition with cache modifications - prefetch_count = min(len(items), _META_PREFETCH_COUNT) if (paged and offset == 0 and not using_index) else 0 - if prefetch_count > 0: - root = _get_output_root() - - async def _prefetch_async(prefetch_items: List[Dict[str, Any]]): - """ - Background task to prefetch metadata and safely update the main cache. - """ - loop = asyncio.get_running_loop() - - def _prefetch_one(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Fetches metadata for a single item and returns it. - Does NOT modify any shared state. - """ - try: - filename = item.get("filename") - subfolder = item.get("subfolder", "") - relpath = item.get("relpath") - if not filename or relpath is None: - return None - - target = _safe_target(root, subfolder, filename) - if not target.exists(): - return None - - kind = item.get("kind") or classify_ext(filename.lower()) - rating, tags = _rating_tags_with_fallback(target, kind) - has_wf = _has_workflow_cached(target) if kind in ("image", "video") else False - - return { - "relpath": relpath, - "rating": rating, - "tags": tags, - "has_workflow": has_wf, - "__metaLoaded": True, - } - except Exception as e: - log.debug(f"📁🔍 [Majoor] Prefetch failed for {item.get('filename')}: {e}") - return None - - max_workers_env = os.environ.get("MJR_META_PREFETCH_WORKERS") - try: - max_workers_cfg = int(max_workers_env) if max_workers_env is not None else None - except (ValueError, TypeError): - max_workers_cfg = None - max_workers = max(1, min(prefetch_count, max_workers_cfg or 4)) - - results: List[Optional[Dict[str, Any]]] = [] - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: - futures = [loop.run_in_executor(pool, _prefetch_one, item) for item in prefetch_items] - gathered_results = await asyncio.gather(*futures, return_exceptions=True) - results = [r for r in gathered_results if isinstance(r, dict)] - - if not results: - return - - # Safely update the main cache with the results - with _CACHE_LOCK: - # Create a map for quick lookups - cache_map = {item.get("relpath"): item for item in _FILE_CACHE if item.get("relpath")} - for res in results: - if not res: - continue - relpath = res.get("relpath") - if relpath in cache_map: - cache_map[relpath].update(res) - - # Schedule background task with a copy of the items to prefetch - import copy - items_to_prefetch = copy.deepcopy(items[:prefetch_count]) - asyncio.create_task(_prefetch_async(items_to_prefetch)) - - return _json_response( - { - "files": items, - "total": total, - "offset": offset, - "limit": limit, - "has_more": has_more, - "next_offset": next_offset, - "source": "index" if using_index else "scan", - } - ) - - -@PromptServer.instance.routes.post("/mjr/filemanager/metadata/batch") -@handle_connection_reset -async def batch_metadata(request: web.Request) -> web.Response: - _install_windows_asyncio_connection_reset_silencer() - - # Validate payload size - size_error = await _validate_payload_size(request, max_size_mb=5.0) - if size_error: - return size_error - - root = _get_output_root() - - # FIX: Use specific exception types instead of bare except - payload, error_response = await _parse_json_payload(request) - if error_response: - return error_response - - items = payload.get("items") or [] - - # Validate request size to prevent DoS - max_batch_size = int(os.environ.get("MJR_MAX_BATCH_SIZE", "1000")) - if len(items) > max_batch_size: - return _json_response( - {"ok": False, "error": f"Batch size exceeds limit ({max_batch_size})"}, - status=400 - ) - - loop = asyncio.get_running_loop() - - # FIX: Create request-scoped cache to prevent redundant metadata reads - request_cache: Dict[str, tuple[int, List[str]]] = {} - - def _fetch_one(item: Dict[str, Any]) -> Dict[str, Any]: - filename = (item or {}).get("filename") - subfolder = (item or {}).get("subfolder", "") - result: Dict[str, Any] = {"filename": filename, "subfolder": subfolder} - - if not filename: - result["error"] = "Missing filename" - return result - - try: - target = _safe_target(root, subfolder, filename) - except ValueError: - result["error"] = "Outside output directory" - return result - - if not target.exists(): - result["error"] = "Not found" - return result - - kind = classify_ext(filename.lower()) - # FIX: Pass request-scoped cache to prevent redundant reads in batch - rating, tags = _rating_tags_with_fallback(target, kind, _cache=request_cache) - - has_wf = False - if kind in ("image", "video"): - try: - has_wf = _has_workflow_cached(target) - except Exception: - has_wf = False - - result.update( - { - "rating": rating, - "tags": tags, - "has_workflow": has_wf, - } - ) - return result - - # Parallelize within the executor to avoid long serial batches - results: List[Dict[str, Any]] = [] - errors: List[Dict[str, Any]] = [] - max_workers_env = os.environ.get("MJR_META_BATCH_WORKERS") - try: - max_workers_cfg = int(max_workers_env) if max_workers_env is not None else None - except (ValueError, TypeError): - max_workers_cfg = None - max_workers = max(1, max_workers_cfg) if max_workers_cfg else 4 - - # FIX: Track exceptions properly and log failures - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: - # Map futures to original items for better error reporting - future_to_item = {pool.submit(_fetch_one, itm): itm for itm in items} - - for fut in concurrent.futures.as_completed(future_to_item): - item = future_to_item[fut] - try: - res = fut.result() - if res.get("error"): - errors.append({"filename": res.get("filename"), "error": res.get("error")}) - elif res.get("filename"): - results.append(res) - except Exception as e: - # Collect errors silently, will log summary below - filename = item.get("filename", "unknown") - error_msg = f"Worker exception: {type(e).__name__}: {str(e)}" - errors.append({"filename": filename, "error": error_msg}) - - # Log summary - only one line to avoid console spam - if errors: - # Group errors by error type for debug logging - error_types = {} - for err in errors: - error_key = err.get("error", "Unknown error") - if error_key not in error_types: - error_types[error_key] = [] - error_types[error_key].append(err.get("filename", "unknown")) - - # Single warning line for console - log.warning(f"đŸ“âš ī¸ [Majoor] Batch metadata: {len(results)} OK, {len(errors)} failed") - - # Detailed error breakdown only in debug mode - for error_type, filenames in error_types.items(): - log.debug(f"📁🔍 [Majoor] â€ĸ {error_type} ({len(filenames)} files)") - if len(filenames) <= 3: - for fn in filenames: - log.debug(f"📁🔍 [Majoor] - {fn}") - else: - for fn in filenames[:2]: - log.debug(f"📁🔍 [Majoor] - {fn}") - log.debug(f"📁🔍 [Majoor] ... and {len(filenames) - 2} more files") - else: - # Success case - also keep it brief - if len(results) > 0: - log.debug(f"📁🔍 [Majoor] Batch metadata: {len(results)} files processed successfully") - - return _json_response({"ok": True, "metadatas": results, "errors": errors}) - - -@PromptServer.instance.routes.post("/mjr/filemanager/delete") -async def delete_files(request: web.Request) -> web.Response: - # Validate payload size - size_error = await _validate_payload_size(request, max_size_mb=2.0) - if size_error: - return size_error - - root = _get_output_root() - - try: - payload = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - items = payload.get("items") or [] - - # Validate request size to prevent DoS - max_batch_size = int(os.environ.get("MJR_MAX_BATCH_SIZE", "1000")) - if len(items) > max_batch_size: - return _json_response( - {"ok": False, "error": f"Batch size exceeds limit ({max_batch_size})"}, - status=400 - ) - - deleted = [] - errors = [] - - for item in items: - filename = (item or {}).get("filename") - subfolder = (item or {}).get("subfolder", "") - - if not filename: - continue - - try: - candidate = _safe_target(root, subfolder, filename) - except ValueError: - errors.append( - { - "filename": filename, - "subfolder": subfolder, - "error": "Outside output directory", - } - ) - continue - - if not candidate.exists(): - errors.append( - {"filename": filename, "subfolder": subfolder, "error": "Not found"} - ) - continue - - try: - if send2trash: - send2trash(str(candidate)) - else: - log.warning( - "[Majoor.AssetsManager] send2trash unavailable; deleting permanently: %s", - candidate, - ) - candidate.unlink() - deleted.append({"filename": filename, "subfolder": subfolder}) - except Exception as exc: - errors.append( - {"filename": filename, "subfolder": subfolder, "error": str(exc)} - ) - - return _json_response({"ok": True, "deleted": deleted, "errors": errors}) - - -@PromptServer.instance.routes.post("/mjr/filemanager/stage_to_input") -@handle_connection_reset -async def stage_to_input(request: web.Request) -> web.Response: - """ - Stage a file from ComfyUI output to ComfyUI input for Drag & Drop workflows. - Expected JSON payload: - { - "filename": "xxx.mp4", - "subfolder": "optional/subfolder", - "from_type": "output", - "dest_subfolder": "_mjr_drop" | "", - "collision_policy": "rename" | "overwrite" | "error" - } - """ - _install_windows_asyncio_connection_reset_silencer() - try: - payload = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - filename = payload.get("filename") - subfolder = payload.get("subfolder", "") or "" - from_type = (payload.get("from_type") or "output").lower().strip() - dest_subfolder = payload.get("dest_subfolder", None) - if dest_subfolder is None: - dest_subfolder = "_mjr_drop" - else: - dest_subfolder = str(dest_subfolder) - collision_policy = payload.get("collision_policy", "rename") or "rename" - - if not filename: - return _json_response({"ok": False, "error": "Missing filename"}, status=400) - if Path(str(filename)).name != str(filename): - return _json_response({"ok": False, "error": "Invalid filename"}, status=400) - - if from_type != "output": - return _json_response({"ok": False, "error": "Unsupported from_type"}, status=400) - - if not _is_allowed_video_filename(str(filename)): - return _json_response( - {"ok": False, "error": "Unsupported video extension (mp4/mov/mkv/webm only)"}, - status=400, - ) - - output_root = _get_output_root() - try: - src = _safe_target(output_root, str(subfolder), str(filename)) - except ValueError: - return _json_response({"ok": False, "error": "File is outside output directory"}, status=400) - - if not src.exists() or not src.is_file(): - return _json_response({"ok": False, "error": "File not found"}, status=404) - - input_root = Path(folder_paths.get_input_directory()).resolve() - - dest_sub = Path(str(dest_subfolder)) if dest_subfolder else Path() - if dest_sub.is_absolute() or any(part == ".." for part in dest_sub.parts): - return _json_response({"ok": False, "error": "Invalid dest_subfolder"}, status=400) - - try: - dest_dir = (input_root / dest_sub).resolve() - dest_dir.relative_to(input_root) - except Exception: - return _json_response({"ok": False, "error": "Destination is outside input directory"}, status=400) - - try: - os.makedirs(dest_dir, exist_ok=True) - except Exception as exc: - return _json_response({"ok": False, "error": str(exc)}, status=500) - - try: - dest_path, staged_name, collision = _resolve_stage_collision( - dest_dir, str(filename), str(collision_policy) - ) - except FileExistsError: - return _json_response({"ok": False, "error": "Destination file exists"}, status=409) - except ValueError as exc: - return _json_response({"ok": False, "error": str(exc)}, status=400) - except Exception as exc: - return _json_response({"ok": False, "error": str(exc)}, status=500) - - try: - # Check disk space before copying - src_size = src.stat().st_size - dest_stat = shutil.disk_usage(dest_path.parent) - available_space = dest_stat.free - - # Require at least 100MB buffer beyond file size - required_space = src_size + (100 * 1024 * 1024) - if available_space < required_space: - return _json_response( - { - "ok": False, - "error": f"Insufficient disk space ({available_space / 1024 / 1024:.1f}MB available, {required_space / 1024 / 1024:.1f}MB required)" - }, - status=507 # HTTP 507 Insufficient Storage - ) - - shutil.copy2(src, dest_path) - except OSError as exc: - # Handle specific OS errors with better messages - if exc.errno == 28: # ENOSPC - No space left on device - return _json_response( - {"ok": False, "error": "Disk full: No space left on device"}, - status=507 - ) - elif exc.errno == 122: # EDQUOT - Disk quota exceeded - return _json_response( - {"ok": False, "error": "Disk quota exceeded"}, - status=507 - ) - return _json_response({"ok": False, "error": f"File operation failed: {exc}"}, status=500) - except Exception as exc: - return _json_response({"ok": False, "error": str(exc)}, status=500) - - rel = (dest_sub / staged_name).as_posix() if dest_subfolder else staged_name - return _json_response( - { - "ok": True, - "filename": staged_name, - "relative_path": rel, - "dest_subfolder": str(dest_subfolder), - "collision": collision, - } - ) - - -@PromptServer.instance.routes.post("/mjr/filemanager/batch_zip") -async def create_batch_zip(request: web.Request) -> web.Response: - size_error = await _validate_payload_size(request, max_size_mb=5.0) - if size_error: - return size_error - - try: - payload = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - token = _sanitize_batch_token(payload.get("token")) - items = payload.get("items") or [] - if not token: - return _json_response({"ok": False, "error": "Invalid token"}, status=400) - if not isinstance(items, list) or not items: - return _json_response({"ok": False, "error": "No items provided"}, status=400) - - max_batch_size = int(os.environ.get("MJR_MAX_BATCH_SIZE", "1000")) - if len(items) > max_batch_size: - return _json_response( - {"ok": False, "error": f"Batch size exceeds limit ({max_batch_size})"}, - status=400, - ) - - root = _get_output_root() - _cleanup_batch_zips(root) - - zip_path = (root / f".mjr_batch_{token}.zip").resolve() - try: - zip_path.relative_to(root) - except Exception: - return _json_response({"ok": False, "error": "Invalid zip path"}, status=400) - - event = asyncio.Event() - filename = f"Majoor_Batch_{len(items)}.zip" - _store_batch_zip_cache( - token, - { - "path": zip_path, - "event": event, - "ready": False, - "created_at": time.time(), - "filename": filename, - }, - ) - - def _build_zip() -> int: - try: - if zip_path.exists(): - zip_path.unlink() - except Exception: - pass - count = 0 - with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for item in items: - filename_item = (item or {}).get("filename") - subfolder = (item or {}).get("subfolder", "") or "" - if not filename_item: - continue - try: - target = _safe_target(root, str(subfolder), str(filename_item)) - except ValueError: - continue - if not target.exists() or not target.is_file(): - continue - arcname = f"{subfolder}/{filename_item}" if subfolder else str(filename_item) - arcname = arcname.replace("\\", "/") - try: - zf.write(str(target), arcname) - count += 1 - except Exception: - continue - return count - - ok = False - error = None - count = 0 - try: - count = await asyncio.to_thread(_build_zip) - if count > 0: - ok = True - else: - error = "No valid files to archive" - except Exception as exc: - error = str(exc) - - with _BATCH_ZIP_LOCK: - entry = _BATCH_ZIP_CACHE.get(token) - if entry: - entry["ready"] = ok - entry["error"] = error - entry["count"] = count - entry["event"].set() - - if not ok: - try: - if zip_path.exists(): - zip_path.unlink() - except Exception: - pass - - return _json_response({"ok": ok, "token": token, "count": count, "filename": filename, "error": error}) - - -@PromptServer.instance.routes.get("/mjr/filemanager/batch_zip/{token}") -async def get_batch_zip(request: web.Request) -> web.Response: - token = _sanitize_batch_token(request.match_info.get("token")) - if not token: - return _json_response({"ok": False, "error": "Invalid token"}, status=400) - - with _BATCH_ZIP_LOCK: - entry = _BATCH_ZIP_CACHE.get(token) - if not entry: - return _json_response({"ok": False, "error": "Not found"}, status=404) - - event = entry.get("event") - if isinstance(event, asyncio.Event) and not entry.get("ready"): - try: - await asyncio.wait_for(event.wait(), timeout=15.0) - except asyncio.TimeoutError: - return _json_response({"ok": False, "error": "Zip not ready"}, status=404) - - with _BATCH_ZIP_LOCK: - entry = _BATCH_ZIP_CACHE.get(token) - if not entry or not entry.get("ready"): - return _json_response({"ok": False, "error": "Not ready"}, status=404) - - path = entry.get("path") - if not isinstance(path, Path) or not path.exists(): - return _json_response({"ok": False, "error": "File missing"}, status=404) - - name = entry.get("filename") or f"{token}.zip" - headers = {"Content-Disposition": f'attachment; filename="{name}"'} - return web.FileResponse(path, headers=headers) - - -@PromptServer.instance.routes.post("/mjr/filemanager/open_explorer") -async def open_explorer(request: web.Request) -> web.Response: - return await open_folder(request) - - -@PromptServer.instance.routes.post("/mjr/filemanager/open_folder") -async def open_folder(request: web.Request) -> web.Response: - root = _get_output_root() - try: - payload = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - filename = payload.get("filename") - subfolder = payload.get("subfolder", "") - if not filename: - return _json_response({"ok": False, "error": "Missing filename"}, status=400) - - try: - target = _safe_target(root, subfolder, filename) - except ValueError: - return _json_response({"ok": False, "error": "File is outside output directory"}, status=400) - - if not target.exists(): - return _json_response({"ok": False, "error": "File not found"}, status=404) - - ok, warning = _open_in_file_manager(target) - if ok: - resp = {"ok": True} - if warning: - resp["warning"] = warning - return _json_response(resp, status=200) - return _json_response({"ok": False, "error": warning or "Failed to open folder"}, status=500) - - -# --------------------------------------------------------------------------- -# METADATA / WORKFLOW EXTRACTION -# --------------------------------------------------------------------------- - -@PromptServer.instance.routes.get("/mjr/filemanager/metadata") -@handle_connection_reset -async def get_metadata(request: web.Request) -> web.Response: - _install_windows_asyncio_connection_reset_silencer() - root = _get_output_root() - filename = request.query.get("filename") - subfolder = request.query.get("subfolder", "") - - # Debug mode for detailed logging (enables history lookup logs, parser failures, etc.) - debug = request.query.get("debug", "").lower() in ("1", "true", "yes", "on") - - # FIX: Use validation helper to reduce code duplication - target, error = await asyncio.to_thread(_validate_file_path, root, filename, subfolder) - if error: - # Map error to appropriate status code - status = 404 if "not found" in error["error"].lower() else 400 - return _json_response(error, status=status) - - loop = asyncio.get_running_loop() - - def _extract_and_process_generation(path: Path) -> Dict[str, Any]: - """Wrapper to extract params and handle video/sibling logic.""" - try: - params = extract_generation_params_from_png(path, debug=debug) or {} - - # Simplified sibling logic check directly after initial extraction - # Support for MP4/WEBP files that may lack embedded metadata - ext_lower = path.suffix.lower() - if ext_lower in {".mp4", ".mov", ".m4v", ".webm", ".mkv", ".webp"} and not params.get("has_workflow"): - stem = path.stem - parent_dir = path.parent - candidates = [ - f for f in parent_dir.iterdir() - if f.is_file() and f.suffix.lower() == ".png" and ( - f.stem == stem or f.stem.startswith(f"{stem}_") or f.stem.startswith(f"{stem}-") - ) - ] - - if candidates: - candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) - for png_sibling in candidates: - sib_params = extract_generation_params_from_png(png_sibling, debug=debug) or {} - if sib_params.get("has_workflow"): - sib_params["generation_source"] = "sibling_png" - return sib_params - return params - except Exception as e: - if debug: - log.exception("📁❌ [Majoor.AssetsManager] metadata parsing error for %s: %s", path.name, e) - else: - log.warning("đŸ“âš ī¸ [Majoor.AssetsManager] metadata parsing error for %s: %s", path.name, e) - return {} - - # Run generation param extraction and robust workflow check in parallel - params_future = loop.run_in_executor(None, _extract_and_process_generation, target) - has_wf_future = loop.run_in_executor(None, has_generation_workflow, target) - - params, has_wf_flag = await asyncio.gather(params_future, has_wf_future) - - # CRITICAL FIX: Use non-destructive OR to preserve workflow detection from extraction - # NEVER overwrite True with False - combine results from both sources - if isinstance(params, dict): - params["has_workflow"] = bool( - params.get("has_workflow") # What extraction already found - or params.get("workflow") # Raw workflow object if present - or has_wf_flag # Fast check result - ) - - kind = classify_ext(filename.lower()) - rating, tags = await loop.run_in_executor(None, _rating_tags_with_fallback, target, kind) - - return _json_response({"ok": True, "generation": params, "rating": rating, "tags": tags}) - - -@PromptServer.instance.routes.get("/mjr/filemanager/capabilities") -async def get_capabilities(request: web.Request) -> web.Response: - os_name = platform.system().lower() - exiftool_available = _get_exiftool_path() is not None - return _json_response( - { - "ok": True, - "os": os_name, - "exiftool_available": exiftool_available, - "sidecar_enabled": ENABLE_JSON_SIDECAR, - } - ) - - -@PromptServer.instance.routes.post("/mjr/filemanager/metadata/update") -async def update_metadata(request: web.Request) -> web.Response: - root = _get_output_root() - - try: - payload = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - filename = payload.get("filename") - subfolder = payload.get("subfolder", "") - rating_provided = "rating" in payload - tags_provided = "tags" in payload - rating = payload.get("rating") if rating_provided else None - tags = payload.get("tags") if tags_provided else None - - if not filename: - return _json_response({"ok": False, "error": "Missing filename"}, status=400) - - try: - target = _safe_target(root, subfolder, filename) - except ValueError: - return _json_response({"ok": False, "error": "File is outside output directory"}, status=400) - - kind = classify_ext(filename.lower()) - if not target.exists(): - return _json_response({"ok": False, "error": "File not found"}, status=404) - - updates = {} - if rating_provided: - updates["rating"] = _coerce_rating_to_stars(rating) - if tags_provided: - updates["tags"] = _clean_metadata_tags(tags) - - if not updates: - return _json_response({"ok": False, "error": "No metadata updates provided"}, status=400) - - try: - meta = update_metadata_with_windows(str(target), updates) - except Exception as exc: - return _route_error_response( - "Failed to update metadata", - exc=exc, - status=500, - body={ - "ok": False, - "file": {"filename": filename, "subfolder": subfolder}, - "error": "Unable to update metadata", - }, - ) - - return _json_response({"ok": True, "rating": meta.get("rating", 0), "tags": meta.get("tags", [])}) - - -@PromptServer.instance.routes.post("/mjr/filemanager/metadata/batch_update") -async def batch_update_metadata(request: web.Request) -> web.Response: - """ - Bulk update metadata for multiple files in parallel. - Payload: - { - "items": [ - {"filename": "...", "subfolder": "...", "rating": 3, "tags": ["tag1", "tag2"]}, - ... - ] - } - Returns: - { - "ok": true, - "updated": [{"filename": "...", "subfolder": "...", "rating": 3, "tags": [...]}], - "errors": [{"filename": "...", "subfolder": "...", "error": "..."}] - } - """ - # Validate payload size - size_error = await _validate_payload_size(request, max_size_mb=5.0) - if size_error: - return size_error - - root = _get_output_root() - - payload, error_response = await _parse_json_payload(request) - if error_response: - return error_response - - items = payload.get("items") or [] - - # Validate request size to prevent DoS - max_batch_size = int(os.environ.get("MJR_MAX_BATCH_SIZE", "1000")) - if len(items) > max_batch_size: - return _json_response( - {"ok": False, "error": f"Batch size exceeds limit ({max_batch_size})"}, - status=400 - ) - - loop = asyncio.get_running_loop() - - def _update_one(item: Dict[str, Any]) -> Dict[str, Any]: - filename = (item or {}).get("filename") - subfolder = (item or {}).get("subfolder", "") - rating_provided = "rating" in item - tags_provided = "tags" in item - rating = item.get("rating") if rating_provided else None - tags = item.get("tags") if tags_provided else None - - result: Dict[str, Any] = {"filename": filename, "subfolder": subfolder} - - if not filename: - result["error"] = "Missing filename" - return result - - try: - target = _safe_target(root, subfolder, filename) - except ValueError: - result["error"] = "Outside output directory" - return result - - if not target.exists(): - result["error"] = "File not found" - return result - - # Build updates dict - updates = {} - if rating_provided: - updates["rating"] = rating - if tags_provided: - updates["tags"] = tags if tags is not None else [] - - if not updates: - result["error"] = "No updates provided (rating or tags required)" - return result - - try: - meta = update_metadata_with_windows(str(target), updates) - result["rating"] = meta.get("rating", 0) - result["tags"] = meta.get("tags", []) - except Exception as exc: - result["error"] = f"{type(exc).__name__}: {str(exc)}" - return result - - return result - - # Parallelize updates - updated: List[Dict[str, Any]] = [] - errors: List[Dict[str, Any]] = [] - max_workers_env = os.environ.get("MJR_META_BATCH_WORKERS") - try: - max_workers_cfg = int(max_workers_env) if max_workers_env is not None else None - except (ValueError, TypeError): - max_workers_cfg = None - max_workers = max(1, max_workers_cfg) if max_workers_cfg else 4 - - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: - future_to_item = {pool.submit(_update_one, itm): itm for itm in items} - - for fut in concurrent.futures.as_completed(future_to_item): - item = future_to_item[fut] - try: - res = fut.result() - if res.get("error"): - errors.append({ - "filename": res.get("filename"), - "subfolder": res.get("subfolder"), - "error": res.get("error") - }) - else: - updated.append(res) - except Exception as e: - filename = item.get("filename", "unknown") - error_msg = f"Worker exception: {type(e).__name__}: {str(e)}" - log.warning("đŸ“âš ī¸ [Majoor] Batch update worker failed for %s: %s", filename, error_msg) - errors.append({ - "filename": filename, - "subfolder": item.get("subfolder", ""), - "error": error_msg - }) - - # Log summary - if errors: - log.info("📁 [Majoor] Batch update completed: %d updated, %d errors", len(updated), len(errors)) - else: - log.debug("[Majoor] Batch update completed: %d files updated successfully", len(updated)) - - return _json_response({"ok": True, "updated": updated, "errors": errors}) - - -@PromptServer.instance.routes.post("/mjr/filemanager/generation/update") -async def update_generation_sidecar(request: web.Request) -> web.Response: - """ - Persist prompt/workflow into sidecar JSON for generated videos. - Payload: - { - "files": [{"filename": "...", "subfolder": "..."}], - "prompt": {...}, # optional prompt graph (id -> node) - "workflow": {...} # optional raw workflow - } - """ - root = _get_output_root() - try: - payload = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - files = payload.get("files") or [] - prompt = payload.get("prompt") - workflow = payload.get("workflow") - - if not isinstance(files, list) or not files: - return _json_response({"ok": False, "error": "No files provided"}, status=400) - - updated: List[Dict[str, Any]] = [] - errors: List[Dict[str, Any]] = [] - - for item in files: - filename = (item or {}).get("filename") or (item or {}).get("name") - subfolder = (item or {}).get("subfolder", "") - if not filename: - errors.append({"file": item, "error": "Missing filename"}) - continue - try: - target = _safe_target(root, subfolder, filename) - except ValueError: - errors.append({"file": {"filename": filename, "subfolder": subfolder}, "error": "Outside output directory"}) - continue - if not target.exists(): - errors.append({"file": {"filename": filename, "subfolder": subfolder}, "error": "File not found"}) - continue - if classify_ext(filename.lower()) != "video": - continue - - meta_path = metadata_path(str(target)) - if not meta_path: - errors.append({"file": {"filename": filename, "subfolder": subfolder}, "error": "Sidecar disabled"}) - continue - - meta = load_metadata(str(target)) or {} - if "rating" not in meta: - meta["rating"] = 0 - if not isinstance(meta.get("tags"), list): - meta["tags"] = [] - if prompt is not None: - meta["prompt"] = prompt - if workflow is not None: - meta["workflow"] = workflow - if workflow is not None or prompt is not None: - meta["has_workflow"] = bool(workflow or prompt) - - try: - save_metadata(str(target), meta) - updated.append({"filename": filename, "subfolder": subfolder}) - except Exception as exc: - errors.append({"file": {"filename": filename, "subfolder": subfolder}, "error": str(exc)}) - - return _json_response({"ok": True, "updated": updated, "errors": errors}) - - -@PromptServer.instance.routes.post("/mjr/filemanager/sidecar/update") -async def update_sidecar(request: web.Request) -> web.Response: - """ - Deep-merge arbitrary meta (rating/tags/prompt/workflow/...) into a file sidecar. - """ - root = _get_output_root() - try: - payload = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - filename = payload.get("filename") - subfolder = payload.get("subfolder", "") - meta_updates = payload.get("meta") - - if not filename or not isinstance(meta_updates, dict): - return _json_response({"ok": False, "error": "Missing filename or invalid meta"}, status=400) - - try: - target = _safe_target(root, subfolder, filename) - except ValueError: - return _json_response({"ok": False, "error": "File is outside output directory"}, status=400) - - if not target.exists(): - return _json_response({"ok": False, "error": "File not found"}, status=404) - - meta_path = metadata_path(str(target)) - if not meta_path: - return _json_response({"ok": False, "error": "Sidecar disabled"}, status=400) - - try: - merged = deep_merge_metadata(str(target), meta_updates) - return _json_response({"ok": True, "meta": merged}) - except Exception as exc: - return _json_response({"ok": False, "error": str(exc)}, status=500) - - -def _normalize_collection_path(root: Path, raw_path: Any) -> str: - if not raw_path or not isinstance(raw_path, str): - raise ValueError("Collection path must be a string") - cleaned = raw_path.replace("\\", "/").strip() - if not cleaned: - raise ValueError("Collection path cannot be empty") - parts = [part.strip() for part in cleaned.split("/") if part.strip()] - if not parts: - raise ValueError("Collection path cannot be empty") - filename = parts[-1] - if not filename: - raise ValueError("Collection path must include a filename") - subfolder = "/".join(parts[:-1]) - target = _safe_target(root, subfolder, filename) - try: - rel = target.relative_to(root) - except ValueError as exc: - raise ValueError("Collection path is outside output directory") from exc - return rel.as_posix() - - -# --------------------------------------------------------------------------- -# Collections endpoints -# --------------------------------------------------------------------------- - -@PromptServer.instance.routes.get("/mjr/collections/list") -async def list_collections_route(request: web.Request) -> web.Response: - names = await asyncio.to_thread(get_collections) - return _json_response({"collections": names}) - - -@PromptServer.instance.routes.get("/mjr/collections/{name}") -async def get_collection_route(request: web.Request) -> web.Response: - name = request.match_info["name"] - try: - _validate_collection_name(name) - except ValueError as exc: - return _json_response( - {"ok": False, "error": str(exc)}, status=400 - ) - files = await asyncio.to_thread(load_collection, name) - return _json_response({"files": files}) - - -@PromptServer.instance.routes.post("/mjr/collections/add") -async def add_to_collection_route(request: web.Request) -> web.Response: - try: - data = await request.json() - except json.JSONDecodeError: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - root = _get_output_root() - name = data.get("name") - raw_path = data.get("path") - - try: - _validate_collection_name(name) - normalized_path = _normalize_collection_path(root, raw_path) - except ValueError as exc: - return _json_response({"ok": False, "error": str(exc)}, status=400) - - try: - await asyncio.to_thread(add_to_collection, name, normalized_path) - return _json_response({"ok": True}) - except Exception as e: - return _route_error_response("Failed to add path to collection", exc=e, status=500) - - -@PromptServer.instance.routes.post("/mjr/collections/remove") -async def remove_from_collection_route(request: web.Request) -> web.Response: - try: - data = await request.json() - except json.JSONDecodeError: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - root = _get_output_root() - name = data.get("name") - raw_path = data.get("path") - - try: - _validate_collection_name(name) - normalized_path = _normalize_collection_path(root, raw_path) - except ValueError as exc: - return _json_response({"ok": False, "error": str(exc)}, status=400) - - try: - await asyncio.to_thread(remove_from_collection, name, normalized_path) - return _json_response({"ok": True}) - except Exception as e: - return _route_error_response("Failed to remove path from collection", exc=e, status=500) - - -@PromptServer.instance.routes.post("/mjr/collections/health_check") -async def health_check_collection_route(request: web.Request) -> web.Response: - """ - Check health of a collection by validating all file paths exist. - Payload: - { - "name": "collection_name", - "auto_repair": false // optional - remove broken paths automatically - } - Returns: - { - "ok": true, - "total": 100, - "valid": 95, - "broken": 5, - "broken_paths": ["path1", "path2", ...], - "repaired": false // true if auto_repair was used - } - """ - try: - data = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - name = data.get("name") - auto_repair = data.get("auto_repair", False) - - if not name: - return _json_response({"ok": False, "error": "Missing collection name"}, status=400) - - try: - _validate_collection_name(name) - except ValueError as exc: - return _json_response({"ok": False, "error": str(exc)}, status=400) - - try: - # Load collection - files = await asyncio.to_thread(load_collection, name) - total = len(files) - - # Check which paths exist - root = _get_output_root() - broken_paths = [] - valid_count = 0 - - def _check_path(file_path: str) -> bool: - """Check if a file path exists. Returns True if valid, False if broken.""" - try: - # Parse the path (format: "subfolder/filename" or just "filename") - parts = file_path.split("/") - if len(parts) == 1: - filename = parts[0] - subfolder = "" - else: - filename = parts[-1] - subfolder = "/".join(parts[:-1]) - - target = _safe_target(root, subfolder, filename) - return target.exists() - except (ValueError, OSError): - return False - - # Check all paths - for file_path in files: - if _check_path(file_path): - valid_count += 1 - else: - broken_paths.append(file_path) - - broken_count = len(broken_paths) - repaired = False - - # Auto-repair if requested - if auto_repair and broken_count > 0: - valid_files = [f for f in files if f not in broken_paths] - await asyncio.to_thread(save_collection, name, valid_files) - repaired = True - log.info( - "[Majoor] Collection '%s' auto-repaired: removed %d broken paths", - name, - broken_count - ) - - return _json_response({ - "ok": True, - "total": total, - "valid": valid_count, - "broken": broken_count, - "broken_paths": broken_paths, - "repaired": repaired - }) - - except Exception as e: - return _route_error_response( - "Collection health check failed", - exc=e, - status=500, - ) - - -# ===== Index Routes (SQLite Asset Index) ===== - -@PromptServer.instance.routes.get("/mjr/filemanager/index/status") -async def index_status_route(request: web.Request) -> web.Response: - """ - Get current index status. - - Returns: - { - "status": "idle|indexing|error", - "fts_available": true|false, - "last_scan": "2025-12-22T10:30:00" or null, - "total_assets": 50234, - "indexed_assets": 50234, - "backlog": 0, - "errors": [], - "freshness": "up_to_date|stale|unknown" - } - """ - try: - status = await asyncio.to_thread(get_index_status) - return _json_response(status) - except Exception as e: - return _route_error_response( - "Failed to get index status", - exc=e, - status=500, - body={"status": "error", "error": "Unable to retrieve index status"}, - ) - - -@PromptServer.instance.routes.post("/mjr/filemanager/index/reindex") -async def index_reindex_route(request: web.Request) -> web.Response: - """ - Trigger reindexing of assets. - - Payload: - { - "scope": "all" | "paths", - "paths": ["path1", "path2", ...] // optional, required if scope="paths" - } - - Returns: - { - "status": "started", - "job_id": "reindex_20251222_103045" - } - """ - try: - data = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - scope = data.get("scope", "all") - - if scope == "all": - # Start background reindex - await asyncio.to_thread(start_background_reindex) - return _json_response({ - "status": "started", - "job_id": f"reindex_all_{int(time.time())}" - }) - elif scope == "paths": - paths = data.get("paths", []) - if not paths: - return _json_response({"ok": False, "error": "No paths provided"}, status=400) - - output_root = Path(OUTPUT_ROOT).resolve() - normalized_paths: List[str] = [] - invalid_paths: List[str] = [] - - for raw in paths: - try: - raw_str = str(raw) - except Exception: - invalid_paths.append(repr(raw)) - continue - if not raw_str: - invalid_paths.append(raw_str) - continue - - candidate = Path(raw_str) - if not candidate.is_absolute(): - candidate = (output_root / candidate) - try: - resolved = candidate.resolve() - resolved.relative_to(output_root) - except Exception: - invalid_paths.append(raw_str) - continue - - normalized_paths.append(str(resolved)) - - if invalid_paths: - return _json_response( - {"ok": False, "error": "Invalid paths (outside output directory)", "paths": invalid_paths}, - status=400, - ) - if not normalized_paths: - return _json_response({"ok": False, "error": "No valid paths provided"}, status=400) - - # Reindex specific paths (synchronous, but fast) - result = await asyncio.to_thread(reindex_paths, normalized_paths) - return _json_response({ - "status": "completed", - "indexed": result["indexed"], - "skipped": result["skipped"], - "errors": result["errors"] - }) - else: - return _json_response({"ok": False, "error": f"Invalid scope: {scope}"}, status=400) - - -@PromptServer.instance.routes.get("/mjr/filemanager/index/query") -async def index_query_route(request: web.Request) -> web.Response: - """ - Query assets with filters and full-text search. - - Query params: - - q: Full-text search query (requires FTS5) - - kind: Filter by type (image, video, audio, model3d, other) - - type: Filter by type (output, input, temp) - - subfolder: Filter by subfolder - - tags: Comma-separated tags (AND filter) - - rating_min: Minimum rating (0-5) - - has_workflow: 1 or 0 - - workflow_hash: Exact workflow hash match - - sort: Sort order (mtime_desc, rating_desc, filename_asc, etc.) - - limit: Max results (default 100) - - offset: Pagination offset (default 0) - - Returns: - { - "assets": [...], - "total": 1234, - "limit": 100, - "offset": 0 - } - """ - try: - params = request.rel_url.query - - # Extract filters - filters = {} - - if params.get("kind"): - filters["kind"] = params["kind"] - - if params.get("type"): - filters["type"] = params["type"] - - if params.get("subfolder") is not None: - filters["subfolder"] = params["subfolder"] - - if params.get("rating_min"): - try: - filters["rating_min"] = float(params["rating_min"]) - except: - pass - - if params.get("has_workflow"): - try: - filters["has_workflow"] = int(params["has_workflow"]) - except: - pass - - if params.get("workflow_hash"): - filters["workflow_hash"] = params["workflow_hash"] - - if params.get("tags"): - filters["tags"] = params["tags"].split(",") - - # Query parameters - q = params.get("q") - sort = params.get("sort", "mtime_desc") - - try: - limit = int(params.get("limit", "100")) - except: - limit = 100 - - try: - offset = int(params.get("offset", "0")) - except: - offset = 0 - - # Execute query - result = await asyncio.to_thread( - query_assets, - filters=filters, - q=q, - sort=sort, - limit=limit, - offset=offset - ) - - return _json_response(result) - - except Exception as e: - return _route_error_response("Failed to query index", exc=e, status=500) - - -@PromptServer.instance.routes.post("/mjr/filemanager/index/sync_from_metadata") -async def index_sync_metadata_route(request: web.Request) -> web.Response: - """ - Push metadata changes to the index. - Called automatically after batch_update to keep index in sync. - - Payload: - { - "assets": [ - {"filename": "img.png", "subfolder": "", "rating": 5, "tags": ["favorite"]}, - ... - ] - } - - Returns: - { - "ok": true, - "synced": 10 - } - """ - try: - data = await request.json() - except Exception: - return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400) - - assets = data.get("assets", []) - if not assets: - return _json_response({"ok": False, "error": "No assets provided"}, status=400) - - try: - # Build file paths from assets - root = _get_output_root() - paths = [] - for asset in assets: - filename = asset.get("filename") - subfolder = asset.get("subfolder", "") - if filename: - file_path = str(_safe_target(root, subfolder, filename)) - paths.append(file_path) - - # Reindex those specific paths - result = await asyncio.to_thread(reindex_paths, paths) - - return _json_response({ - "ok": True, - "synced": result["indexed"] - }) - - except Exception as e: - return _route_error_response("Failed to sync metadata to index", exc=e, status=500) - - -@PromptServer.instance.routes.post("/mjr/filemanager/cache/clear") -async def clear_workflow_cache(request: web.Request) -> web.Response: - """ - Clear the server-side workflow detection cache. - Useful after updating metadata extraction code or when workflow detection seems incorrect. - - Returns: - { - "ok": true, - "cleared": - } - """ - try: - with _HAS_WORKFLOW_CACHE_LOCK: - count = len(_HAS_WORKFLOW_CACHE) - _HAS_WORKFLOW_CACHE.clear() - - log.info("📁 [Majoor] Cleared workflow cache (%d entries)", count) - return _json_response({"ok": True, "cleared": count}) - except Exception as e: - return _route_error_response("Failed to clear workflow cache", exc=e, status=500) - - -@PromptServer.instance.routes.get("/mjr/filemanager/workflow/hash_info") -async def workflow_hash_info_route(request: web.Request) -> web.Response: - """ - Get information about the workflow hashing algorithm. - Used by frontend to implement client-side hashing. - - Returns: - { - "algorithm": "SHA1", - "exclude_keys": ["id", "pos", "size", ...], - "description": "Canonical JSON (sorted keys, no whitespace) -> SHA1 hex" - } - """ - try: - info = get_hash_algorithm_info() - return _json_response(info) - except Exception as e: - return _route_error_response("Failed to get workflow hash info", exc=e, status=500) - - -@PromptServer.instance.routes.get("/mjr/filemanager/index/errors") -async def get_indexing_errors_route(request: web.Request) -> web.Response: - """ - Get all logged indexing errors. - - Returns: - { - "ok": true, - "errors": [ - {"path": "...", "reason": "...", "details": "...", "last_attempt_at": ...}, - ... - ] - } - """ - try: - from .index_db import get_indexing_errors - errors = await asyncio.to_thread(get_indexing_errors) - return _json_response({"ok": True, "errors": errors}) - except Exception as e: - return _route_error_response("Failed to retrieve indexing errors", exc=e, status=500) - - -@PromptServer.instance.routes.post("/mjr/filemanager/index/retry_failed") -async def retry_failed_route(request: web.Request) -> web.Response: - """ - Trigger a re-index attempt on all files that previously failed. - - Returns: - { - "ok": true, - "message": "Retrying X failed items in the background." - } - """ - try: - from .index_db import get_indexing_errors - errors = await asyncio.to_thread(get_indexing_errors) - if not errors: - return _json_response({"ok": True, "message": "No failed items to retry."}) - - paths_to_retry = [e["path"] for e in errors] - - # Run this in the background as it could be slow - def _retry(): - reindex_paths(paths_to_retry) - - # Using a thread is safer for long-running background tasks - # that are not heavily I/O bound in the asyncio sense. - import threading - threading.Thread(target=_retry, daemon=True).start() - - return _json_response({ - "ok": True, - "message": f"Retrying {len(paths_to_retry)} failed items in the background." - }) - except Exception as e: - return _route_error_response("Failed to start retry job", exc=e, status=500) - - -# ===== Initialize Index After Routes Loaded ===== - -def _init_index_after_server_ready(): - """Initialize index after ComfyUI server is ready (avoids race condition).""" - if INDEX_MODE == "filesystem": - log.info("[Majoor] Filesystem index mode active; skipping index initialization") - return - try: - from .index_db import auto_init_index - # Small delay to ensure server is fully up - import threading - def delayed_init(): - import time - time.sleep(2) # Wait 2 seconds - auto_init_index() - threading.Thread(target=delayed_init, daemon=True).start() - log.info("[Majoor] Index initialization scheduled") - except Exception as e: - log.error("[Majoor] Failed to schedule index init: %s", e) - -# Call init after routes are registered -_init_index_after_server_ready() diff --git a/server/tmp_test.py b/server/tmp_test.py deleted file mode 100644 index 1d7ffe9f8f8eb01897b4be97fbdc713a3b5ffbd0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34 mcmezWuYjS5A(J7Gp@cz$L7gFkA(bHq2=jq#O$J^DE(QRlf(G>f diff --git a/server/utils.py b/server/utils.py deleted file mode 100644 index 303ca3ae..00000000 --- a/server/utils.py +++ /dev/null @@ -1,1190 +0,0 @@ -import json -import os -import sys -import re -import shutil -import subprocess -import time -import unicodedata -import threading -from collections import OrderedDict -from typing import Any, Dict, List, Optional -from .config import ENABLE_JSON_SIDECAR, METADATA_EXT, IS_WINDOWS -from .logger import get_logger - -log = get_logger(__name__) - -# Supported extensions -IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} -VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv"} -AUDIO_EXTS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac"} -MODEL3D_EXTS = {".obj", ".fbx", ".glb", ".gltf", ".stl"} -WINDOWS_RATING_INDEX = 14 # fallback -WINDOWS_TAGS_INDEX = 21 # fallback -STAR_CHARS = ("\u2605", "\u2606", "\u22c6", "\u272d", "\u272e", "\u2730") -RATING_KEYS = ( - "rating", - "note", - "notation", - "evaluation", - "evaluations", - "etoile", - "etoiles", - "star", - "stars", -) -TAG_KEYS = ( - "tag", - "mot-cle", - "mot cle", - "mots cle", - "keywords", - "keyword", - "category", - "categories", - "categorie", - "catÊgorie", - "catÊgories", -) - -_WIN32COM: Optional[object] = None -_WIN32COM_CHECKED = False -_CACHED_RATING_INDEX: Optional[int] = None -_CACHED_TAGS_INDEX: Optional[int] = None -_EXIFTOOL_PATH: Optional[str] = None -_EXIFTOOL_CHECKED = False - -_MAX_METADATA_JSON_TEXT = 120_000 -_MAX_METADATA_JSON_DEPTH = 12 -_MAX_METADATA_JSON_DICT_KEYS = 500 -_MAX_METADATA_JSON_LIST_ITEMS = 500 -_MAX_METADATA_JSON_STRING_LEN = 4_096 - -# LRU cache for metadata (prevents unbounded memory growth) -# Max size configurable via MJR_META_CACHE_SIZE env var (default: 1000 entries) -try: - _META_CACHE_MAX_SIZE = int(os.environ.get("MJR_META_CACHE_SIZE", "1000")) - if _META_CACHE_MAX_SIZE <= 0: - _META_CACHE_MAX_SIZE = 1000 -except (ValueError, TypeError) as e: - log.warning("đŸ“âš ī¸ [Majoor] Invalid MJR_META_CACHE_SIZE, using default: %s", e) - _META_CACHE_MAX_SIZE = 1000 - -_META_CACHE: OrderedDict[str, tuple[Optional[float], int, dict]] = OrderedDict() -_CACHE_EPOCH = 0 -_CACHE_LOCK = threading.Lock() - - -def _get_cache_epoch() -> int: - with _CACHE_LOCK: - return _CACHE_EPOCH - - -def _next_cache_epoch() -> int: - global _CACHE_EPOCH - with _CACHE_LOCK: - _CACHE_EPOCH += 1 - return _CACHE_EPOCH - - -def _bump_cache_epoch_if(expected: int) -> int: - global _CACHE_EPOCH - with _CACHE_LOCK: - if _CACHE_EPOCH == expected: - _CACHE_EPOCH += 1 - return _CACHE_EPOCH - - -def _cache_get(file_path: str, mtime: Optional[float], epoch: int) -> Optional[tuple[Optional[dict], int]]: - """ - FIX: Return both cached data and the epoch it was cached at to detect invalidation. - Returns (data_dict, cache_epoch) if valid, or (None, current_epoch) if invalid. - """ - with _CACHE_LOCK: - current_epoch = _CACHE_EPOCH - cached = _META_CACHE.get(file_path) - if cached and cached[0] == mtime and cached[1] == current_epoch: - return (dict(cached[2]), current_epoch) - return (None, current_epoch) - - -def _cache_set(file_path: str, mtime: Optional[float], epoch: int, meta: dict) -> None: - """ - Add or update an entry in the LRU metadata cache with automatic eviction. - """ - with _CACHE_LOCK: - # Move to end if already exists (LRU update) - if file_path in _META_CACHE: - _META_CACHE.move_to_end(file_path) - - _META_CACHE[file_path] = (mtime, epoch, meta) - - # Evict oldest entries if cache exceeds max size - while len(_META_CACHE) > _META_CACHE_MAX_SIZE: - _META_CACHE.popitem(last=False) # Remove oldest (FIFO when not accessed) - - -def _env_timeout(name: str, default: float, min_s: float = 0.5, max_s: float = 60.0) -> float: - """ - Parse a timeout value from env and clamp it to a safe range. - Keeps external metadata tools from hanging the server. - """ - try: - raw = os.environ.get(name, None) - val = float(default) if raw is None else float(raw) - except (ValueError, TypeError): - val = float(default) - try: - return max(float(min_s), min(float(val), float(max_s))) - except (ValueError, TypeError, OverflowError): - return float(default) - - -def _force_sidecar_enabled() -> bool: - val = str(os.environ.get("MJR_FORCE_SIDECAR", "0")).lower() - return val in ("1", "true", "yes", "on") - - -def _sidecar_allowed_for(path: str) -> bool: - return ENABLE_JSON_SIDECAR or _force_sidecar_enabled() - - -def _normalize_label(text) -> str: - try: - normalized = unicodedata.normalize("NFKD", str(text or "")) - normalized = "".join(ch for ch in normalized if not unicodedata.combining(ch)) - return normalized.lower().strip() - except (ValueError, TypeError, AttributeError): - # Fallback for non-normalizable text - return str(text or "").lower().strip() - - -def _is_safe_metadata_json(value: Any, depth: int = 0) -> bool: - if depth > _MAX_METADATA_JSON_DEPTH: - return False - if isinstance(value, dict): - if len(value) > _MAX_METADATA_JSON_DICT_KEYS: - return False - for key, sub in value.items(): - if not isinstance(key, str): - return False - if not _is_safe_metadata_json(sub, depth + 1): - return False - return True - if isinstance(value, list): - if len(value) > _MAX_METADATA_JSON_LIST_ITEMS: - return False - for item in value: - if not _is_safe_metadata_json(item, depth + 1): - return False - return True - if isinstance(value, str): - return len(value) <= _MAX_METADATA_JSON_STRING_LEN - if isinstance(value, (int, float, bool)) or value is None: - return True - return False - - -def safe_metadata_json_load(text: Any) -> Optional[Any]: - if not isinstance(text, str): - return None - candidate = text.strip() - if not candidate or len(candidate) > _MAX_METADATA_JSON_TEXT: - return None - try: - data = json.loads(candidate) - except json.JSONDecodeError: - return None - if not _is_safe_metadata_json(data): - return None - return data - - -def _get_mtime_safe(path: str) -> Optional[float]: - try: - return os.path.getmtime(path) - except (OSError, ValueError): - # File doesn't exist or path is invalid - return None - - -def safe_import_win32com(): - global _WIN32COM, _WIN32COM_CHECKED - if _WIN32COM_CHECKED: - return _WIN32COM - _WIN32COM_CHECKED = True - if IS_WINDOWS: - try: - import win32com.client # type: ignore - - _WIN32COM = win32com.client - except ImportError: - log.warning("Windows logic disabled (pywin32 not found)") - _WIN32COM = None - else: - _WIN32COM = None - return _WIN32COM - - -def _set_windows_property_store(file_path: str, rating: int, tags, *, clear_tags: bool = False) -> bool: - """ - Write rating/tags using the Windows Property System (more reliable than Shell column indices). - Returns True on success. - """ - if not IS_WINDOWS: - return False - try: - import pythoncom # type: ignore - from win32com.propsys import propsys, pscon # type: ignore - - pythoncom.CoInitialize() - try: - store = propsys.SHGetPropertyStoreFromParsingName(file_path, None, pscon.GPS_READWRITE) - # System.Rating expects 0..99 - percent = rating_to_windows_percent(rating) - store.SetValue(pscon.PKEY_Rating, percent) - # Explorer's "Shared User Rating" can map to different property keys depending on handlers. - media_key = getattr(pscon, "PKEY_ShareUserRating", None) or getattr(pscon, "PKEY_Media_UserRating", None) - if media_key is not None: - try: - store.SetValue(media_key, percent) - except (OSError, AttributeError): - # Media key not supported on this file type - pass - - if tags is not None: - tags_list = _normalize_tags(tags) - cat_key = getattr(pscon, "PKEY_Category", None) - if tags_list: - # For videos, Explorer commonly shows tags under "Category/Categories". - if cat_key is not None: - try: - store.SetValue(cat_key, tuple(tags_list)) - except (OSError, AttributeError): - # Category key not supported on this file type - pass - store.SetValue(pscon.PKEY_Keywords, tuple(tags_list)) - elif clear_tags: - if cat_key is not None: - try: - store.SetValue(cat_key, tuple()) - except (OSError, AttributeError): - # Category key not supported on this file type - pass - store.SetValue(pscon.PKEY_Keywords, tuple()) - - store.Commit() - return True - finally: - try: - pythoncom.CoUninitialize() - except (OSError, AttributeError): - # COM cleanup failed, not critical - pass - except (ImportError, OSError, AttributeError) as e: - log.debug("[Majoor] Failed to set Windows metadata: %s", e) - return False - - -def _get_windows_property_store(file_path: str) -> dict: - """ - Read rating/tags using the Windows Property System. - Helps when Shell column indices are missing or differ per file type (notably videos). - """ - if not IS_WINDOWS: - return {} - try: - import pythoncom # type: ignore - from win32com.propsys import propsys, pscon # type: ignore - - pythoncom.CoInitialize() - try: - store = propsys.SHGetPropertyStoreFromParsingName(file_path, None, pscon.GPS_BESTEFFORT) - - rating_raw = None - try: - rating_raw = store.GetValue(pscon.PKEY_Rating).GetValue() - except Exception: - rating_raw = None - if rating_raw is None: - media_key = getattr(pscon, "PKEY_ShareUserRating", None) or getattr(pscon, "PKEY_Media_UserRating", None) - if media_key is not None: - try: - rating_raw = store.GetValue(media_key).GetValue() - except Exception: - rating_raw = None - - rating = windows_percent_to_stars(int(rating_raw)) if rating_raw is not None else 0 - - tags: List[str] = [] - cat_key = getattr(pscon, "PKEY_Category", None) - if cat_key is not None: - try: - cat_val = store.GetValue(cat_key).GetValue() - if isinstance(cat_val, (list, tuple)): - tags = _normalize_tags(list(cat_val)) - elif isinstance(cat_val, str): - tags = _normalize_tags(cat_val) - except (OSError, AttributeError, TypeError): - # Category property not available or invalid - tags = [] - - if not tags: - try: - kw_val = store.GetValue(pscon.PKEY_Keywords).GetValue() - if isinstance(kw_val, (list, tuple)): - tags = _normalize_tags(list(kw_val)) - elif isinstance(kw_val, str): - tags = _normalize_tags(kw_val) - except (OSError, AttributeError, TypeError): - # Keywords property not available or invalid - tags = [] - - return {"rating": rating, "tags": tags} - finally: - try: - pythoncom.CoUninitialize() - except (OSError, AttributeError): - # COM cleanup failed, not critical - pass - except (ImportError, OSError, AttributeError) as e: - log.debug("[Majoor] Failed to read Windows metadata: %s", e) - return {} - - -def _get_exiftool_path() -> Optional[str]: - global _EXIFTOOL_PATH, _EXIFTOOL_CHECKED - if _EXIFTOOL_CHECKED: - return _EXIFTOOL_PATH - _EXIFTOOL_CHECKED = True - _EXIFTOOL_PATH = shutil.which("exiftool") or shutil.which("exiftool.exe") - return _EXIFTOOL_PATH - - -def _parse_rating_value(raw) -> int: - if raw in (None, ""): - return 0 - s_raw = str(raw).strip() - if not s_raw: - return 0 - - star_count = sum(s_raw.count(ch) for ch in STAR_CHARS) - if star_count: - return max(0, min(star_count, 5)) - - try: - numeric = float(s_raw.replace(",", ".").split()[0]) - except Exception: - return 0 - - if numeric <= 0: - return 0 - if numeric > 5: - if numeric <= 25: - return 2 - if numeric <= 50: - return 3 - if numeric <= 75: - return 4 - return 5 - return int(round(min(numeric, 5))) - - -def _coerce_rating_to_stars(raw) -> int: - """ - Normalize arbitrary rating inputs to 0..5 stars. - Accepts: - - 0..5 stars (int/float/str) - - Windows percent scale (0..100) - - star glyphs (e.g. ★★★☆☆) - """ - if raw in (None, ""): - return 0 - try: - num = float(str(raw).strip().replace(",", ".").split()[0]) - except Exception: - return max(0, min(_parse_rating_value(raw), 5)) - - if num <= 0: - return 0 - if 0 <= num <= 5: - return max(0, min(int(round(num)), 5)) - if num <= 100: - return windows_percent_to_stars(int(round(num))) - return max(0, min(_parse_rating_value(raw), 5)) - - -def _normalize_tags(raw) -> List[str]: - """ - Normalize tags to a deduplicated, order-preserving list of non-empty strings. - Accepts list or comma/semicolon-delimited strings. - """ - if raw is None: - return [] - if isinstance(raw, list): - candidates = raw - elif isinstance(raw, str): - candidates = [t.strip() for t in re.split(r"[;,]", raw)] - else: - return [] - - out: List[str] = [] - seen = set() - for t in candidates: - s = str(t).strip() - if not s or s in seen: - continue - seen.add(s) - out.append(s) - return out - - -def rating_to_windows_percent(stars: int) -> int: - """ - Map 0..5 stars to Windows percent scale expected by SharedUserRating. - """ - try: - r = int(stars) - except Exception: - r = 0 - mapping = {0: 0, 1: 1, 2: 25, 3: 50, 4: 75, 5: 99} - return max(0, min(99, mapping.get(r, 0 if r < 0 else r))) - - -def windows_percent_to_stars(percent: int) -> int: - """ - Convert Windows percent (SharedUserRating) to 0..5 stars using thresholds. - """ - try: - p = int(percent) - except Exception: - return 0 - if p <= 0: - return 0 - if p <= 12: - return 1 - if p <= 37: - return 2 - if p <= 62: - return 3 - if p <= 87: - return 4 - return 5 - - -def tags_to_windows_category(tags: list) -> str: - """ - Join tags for Microsoft:Category; use '; ' which Explorer parses. - """ - if not isinstance(tags, list): - return "" - cleaned = [str(t).strip() for t in tags if str(t).strip()] - return "; ".join(cleaned) - - -def windows_category_to_tags(cat: str) -> list: - """ - Split Microsoft:Category back into a list; preserve order, drop empties. - """ - if not cat: - return [] - parts = [p.strip() for p in str(cat).split(";") if p.strip()] - return parts - - -def get_windows_metadata(file_path: str) -> dict: - """Fetch file metadata via the Windows Shell API.""" - try: - prop_meta = _get_windows_property_store(file_path) - if (prop_meta.get("rating") or prop_meta.get("tags")) and not ( - prop_meta.get("rating") == 0 and not prop_meta.get("tags") - ): - return prop_meta - - win32com = safe_import_win32com() - if not win32com: - return {"rating": 0, "tags": []} - - pythoncom = None - try: - import pythoncom # type: ignore - - pythoncom.CoInitialize() - except Exception: - pythoncom = None - - try: - shell = win32com.Dispatch("Shell.Application") - folder = shell.Namespace(os.path.dirname(file_path)) - file = folder.ParseName(os.path.basename(file_path)) - - rating_idx, tags_idx = _resolve_shell_indices(folder) - - rating_raw = folder.GetDetailsOf(file, rating_idx) - tags_raw = folder.GetDetailsOf(file, tags_idx) - - rating = 0 - # Windows may expose either stars or SharedUserRating percent; normalize to stars - try: - numeric = float(str(rating_raw).strip()) - if 0 <= numeric <= 100 and numeric > 5: - rating = windows_percent_to_stars(int(round(numeric))) - else: - rating = _parse_rating_value(numeric) - except Exception: - rating = _parse_rating_value(rating_raw) - - tags = [] - if tags_raw: - tag_text = str(tags_raw) - for sep in [",", ";"]: - if sep in tag_text: - tags = [t.strip() for t in tag_text.split(sep) if t.strip()] - break - else: - t = tag_text.strip() - if t: - tags = [t] - - return { - "rating": rating, - "tags": tags, - } - finally: - if pythoncom is not None: - try: - pythoncom.CoUninitialize() - except Exception: - pass - except Exception as e: - log.warning("Error reading metadata for %s: %s", file_path, e) - return {"rating": 0, "tags": []} - - -# Canonical ExifTool reader for rating/tags (avoid duplicate implementations) -def get_exif_metadata(file_path: str) -> dict: - """ - Fast read via exiftool when available. - Returns {"rating": int, "tags": List[str]} or {} on fallback. - """ - exe = _get_exiftool_path() - if not exe: - return {} - try: - timeout_s = _env_timeout("MJR_EXIFTOOL_READ_TIMEOUT", 2.0, min_s=0.5, max_s=60.0) - is_video = file_path.lower().endswith(tuple(VIDEO_EXTS)) - - # For videos, prefer XMP tags (portable and consistent) then fall back to Microsoft tags. - # For images/others, keep the existing wide read set (Windows + XMP + common keyword fields). - if is_video: - cmd = [ - exe, - "-n", - "-json", - "-XMP:Rating", - "-XMP:Subject", - "-Microsoft:SharedUserRating", - "-Microsoft:Category", - file_path, - ] - else: - cmd = [ - exe, - "-n", - "-json", - "-Rating", - "-RatingPercent", - "-Microsoft:SharedUserRating", - "-Microsoft:Category", - "-Subject", - "-Keywords", - "-XPKeywords", - file_path, - ] - proc = None - try: - proc = subprocess.run(cmd, capture_output=True, timeout=timeout_s, check=False) - if proc.returncode != 0 or not proc.stdout: - return {} - data = json.loads(proc.stdout)[0] - except subprocess.TimeoutExpired: - # Ensure process terminated - if proc and proc.returncode is None: - try: - proc.kill() - proc.wait() - except Exception: - pass - return {} - except Exception as e: - log.debug("[Majoor] ExifTool read failed: %s", e) - return {} - - rating_raw = None - if is_video: - rating_raw = data.get("XMP:Rating") - if rating_raw is None: - rating_raw = data.get("Microsoft:SharedUserRating") or data.get("SharedUserRating") - else: - rating_raw = data.get("Rating") - if rating_raw is None: - rating_raw = data.get("RatingPercent") - if rating_raw is None: - rating_raw = data.get("Microsoft:SharedUserRating") - rating = 0 - if rating_raw is not None: - try: - num = float(rating_raw) - except Exception: - num = None - if num is not None and 0 <= num <= 5: - rating = int(round(num)) - elif num is not None and num > 5: - rating = windows_percent_to_stars(int(round(num))) - else: - rating = _parse_rating_value(rating_raw) - - tags: List[str] = [] - if is_video: - subj = data.get("XMP:Subject") - if subj: - if isinstance(subj, list): - tags = _normalize_tags(subj) - elif isinstance(subj, str): - tags = _normalize_tags(subj) - if not tags: - cat = data.get("Microsoft:Category") or data.get("Category") - if cat: - tags = _normalize_tags(cat) - else: - for key in ("Subject", "Keywords", "XPKeywords"): - val = data.get(key) - if isinstance(val, list): - tags.extend([str(v).strip() for v in val if str(v).strip()]) - elif isinstance(val, str): - tags.extend([t.strip() for t in val.split(",") if t.strip()]) - - if tags and not is_video: - tags = sorted(set(tags)) - - return {"rating": rating, "tags": tags} - except subprocess.TimeoutExpired: - return {} - except Exception: - return {} - - -# Self-check for rating normalization; kept lightweight -def _test_rating_normalization(): - assert windows_percent_to_stars(5) == 1 # percent-like but low - assert windows_percent_to_stars(99) == 5 - assert windows_percent_to_stars(75) == 4 - assert windows_percent_to_stars(1) == 1 - assert _parse_rating_value(5) == 5 - assert _parse_rating_value(0) == 0 - -_test_rating_normalization() - - -# Canonical ExifTool writer for rating/tags (video-aware); do not duplicate -def set_exif_metadata(file_path: str, rating: int, tags: list) -> bool: - """ - Write Rating/Keywords via exiftool when available. Returns True on success. - """ - exe = _get_exiftool_path() - if not exe: - return False - - epoch = _next_cache_epoch() - original_mtime = _get_mtime_safe(file_path) - is_video = file_path.lower().endswith((".mp4", ".mov", ".m4v", ".webm", ".mkv")) - - # Retry loop for "file not ready yet" cases on Windows: right after generation, - # the encoder can still be finalizing/locking the container. We treat common - # "cannot open/write" errors as retryable and back off exponentially. - def _exiftool_is_retryable(stderr: bytes) -> bool: - msg = (stderr or b"").decode("utf-8", errors="ignore").lower() - return any( - s in msg - for s in ( - "cannot open", - "can't open", - "error opening file", - "permission denied", - "access is denied", - "being used by another process", - "used by another process", - "sharing violation", - "file is locked", - "cannot write", - "can't write", - "write error", - ) - ) - - def _exiftool_run_with_retries(cmd: list, timeout_s: float, *, max_tries: int = 5) -> subprocess.CompletedProcess: - last = None - for attempt in range(max_tries): - last = subprocess.run(cmd, check=False, capture_output=True, timeout=timeout_s) - if last.returncode == 0: - return last - if not _exiftool_is_retryable(last.stderr): - return last - time.sleep(0.15 * (2**attempt)) - return last # type: ignore[return-value] - - try: - write_timeout_s = _env_timeout("MJR_EXIFTOOL_WRITE_TIMEOUT", 3.0, min_s=0.5, max_s=120.0) - r = _coerce_rating_to_stars(rating) - win_r = rating_to_windows_percent(r) - - clean_tags = _normalize_tags(tags) - joined = "; ".join(clean_tags) - - if not is_video: - # Keep existing behavior for images/other files: write rating + common keyword fields. - cmd = [exe, "-overwrite_original"] - cmd += [ - f"-XMP:Rating={r}", - f"-xmp:rating={r}", - f"-rating={r}", - f"-ratingpercent={win_r}", - "-Subject=", - "-Keywords=", - "-XPKeywords=", - "-XMP:Subject=", - ] - if clean_tags: - cmd.append(f"-XMP:Subject={joined}") - cmd.append(f"-xpkeywords={joined}") - for t in clean_tags: - cmd.append(f"-iptc:keywords+={t}") - cmd.append(file_path) - res = subprocess.run(cmd, check=False, capture_output=True, timeout=write_timeout_s) - if res.returncode != 0: - return False - - if original_mtime is not None: - try: - os.utime(file_path, (original_mtime, original_mtime)) - except Exception: - pass - - _cache_set( - file_path, - original_mtime if original_mtime is not None else _get_mtime_safe(file_path), - epoch, - {"rating": rating, "tags": tags}, - ) - return True - - # Preserve embedded ComfyUI workflow JSON stored in Comment-like tags without putting large blobs on the - # command line: copy those tags from the original file (`@`) onto the rewritten file, then override rating/tags. - preserve_copy_args = [ - "-tagsFromFile", - "@", - "-Comment", - "-UserComment", - "-Description", - "-XPComment", - "-Parameters", - "-UserData", - "-XMP:Comment", - "-XMP:Description", - "-XMP:UserComment", - "-ItemList:Comment", - "-ItemList:Description", - "-ItemList:UserComment", - ] - - # Multi-namespace strategy for videos: - # - Always write XMP:Rating (0..5) and XMP:Subject (list of tags). - # - Best-effort write Microsoft:SharedUserRating (0..100-ish) and Microsoft:Category (semicolon list). - # If the Microsoft tags are not writable for a given container/handler, we still succeed as long as - # the XMP tags were written. - base_cmd = [exe, "-overwrite_original", "-sep", "; ", *preserve_copy_args] - base_cmd += [ - f"-XMP:Rating={r}", - "-XMP:Subject=", - ] - if joined: - base_cmd.append(f"-XMP:Subject={joined}") - - cmd_with_ms = list(base_cmd) - cat = tags_to_windows_category(clean_tags) - cmd_with_ms += [ - f"-Microsoft:SharedUserRating={win_r}", - f"-Microsoft:Category={cat}", - ] - - # Preflight: wait a bit for the file to become readable after generation (Windows lock/finalization). - for attempt in range(5): - try: - with open(file_path, "rb") as f: - f.read(1) - break - except Exception: - time.sleep(0.15 * (2**attempt)) - - def _is_ms_write_issue(stderr: bytes) -> bool: - msg = (stderr or b"").decode("utf-8", errors="ignore").lower() - return ("microsoft:" in msg) and any(s in msg for s in ("not writable", "unsupported", "warning")) - - res = _exiftool_run_with_retries(cmd_with_ms + [file_path], write_timeout_s) - if res.returncode != 0 and _is_ms_write_issue(res.stderr): - # Retry once without Microsoft namespace tags (XMP remains the source of truth). - res = _exiftool_run_with_retries(base_cmd + [file_path], write_timeout_s) - if res.returncode != 0: - return False - - # Restore mtime to avoid reordering the grid after rating/tag updates - if original_mtime is not None: - try: - os.utime(file_path, (original_mtime, original_mtime)) - except Exception: - pass - - # Update cache with current epoch - _cache_set( - file_path, - original_mtime if original_mtime is not None else _get_mtime_safe(file_path), - epoch, - {"rating": rating, "tags": tags}, - ) - return True - except subprocess.TimeoutExpired: - return False - except Exception: - return False - - -def set_windows_metadata(file_path: str, rating: int, tags: list) -> bool: - """Write metadata via the Windows Shell API.""" - try: - win32com = safe_import_win32com() - if not win32com: - return False - - original_mtime = _get_mtime_safe(file_path) - - # Prefer the Windows Property System when possible (works better for media files like MP4) - tags_list = _normalize_tags(tags) - clear_tags = tags == [] - if _set_windows_property_store(file_path, _coerce_rating_to_stars(rating), tags_list, clear_tags=clear_tags): - if original_mtime is not None: - try: - os.utime(file_path, (original_mtime, original_mtime)) - except Exception: - pass - - epoch = _next_cache_epoch() - _cache_set( - file_path, - original_mtime if original_mtime is not None else _get_mtime_safe(file_path), - epoch, - {"rating": _coerce_rating_to_stars(rating), "tags": tags_list}, - ) - return True - - pythoncom = None - try: - import pythoncom # type: ignore - - pythoncom.CoInitialize() - except Exception: - pythoncom = None - - try: - shell = win32com.Dispatch("Shell.Application") - folder = shell.Namespace(os.path.dirname(file_path)) - file = folder.ParseName(os.path.basename(file_path)) - - rating_idx, tags_idx = _resolve_shell_indices(folder) - - # Rating (SharedUserRating percent 0..99); accept stars or percent and normalize - if rating is not None: - stars = _coerce_rating_to_stars(rating) - rating_val = rating_to_windows_percent(stars) - folder.GetDetailsOf(file, rating_idx) # force index load - folder.SetDetailsOf(file, rating_idx, str(rating_val)) - - if tags_list: - folder.GetDetailsOf(file, tags_idx) - folder.SetDetailsOf(file, tags_idx, "; ".join(tags_list)) - elif tags == []: - folder.GetDetailsOf(file, tags_idx) - folder.SetDetailsOf(file, tags_idx, "") - - # Restore mtime to avoid reordering the grid after rating/tag updates - if original_mtime is not None: - try: - os.utime(file_path, (original_mtime, original_mtime)) - except Exception: - pass - - epoch = _next_cache_epoch() - _cache_set( - file_path, - original_mtime if original_mtime is not None else _get_mtime_safe(file_path), - epoch, - {"rating": _coerce_rating_to_stars(rating), "tags": tags_list}, - ) - return True - finally: - if pythoncom is not None: - try: - pythoncom.CoUninitialize() - except Exception: - pass - except Exception: - return False - - -def _resolve_shell_indices(folder) -> (int, int): - """ - Tente de localiser les colonnes "Rating"/"Tags" en fonction de la locale. - Retourne un tuple (rating_idx, tags_idx) avec fallback sur les constantes. - """ - global _CACHED_RATING_INDEX, _CACHED_TAGS_INDEX - if _CACHED_RATING_INDEX is not None and _CACHED_TAGS_INDEX is not None: - return _CACHED_RATING_INDEX, _CACHED_TAGS_INDEX - - rating_idx = WINDOWS_RATING_INDEX - tags_idx = WINDOWS_TAGS_INDEX - - try: - for i in range(0, 256): - col_name = _normalize_label(folder.GetDetailsOf(None, i)) - if not col_name: - continue - if _CACHED_RATING_INDEX is None and any(key in col_name for key in RATING_KEYS): - _CACHED_RATING_INDEX = i - if _CACHED_TAGS_INDEX is None and any(key in col_name for key in TAG_KEYS): - _CACHED_TAGS_INDEX = i - if _CACHED_RATING_INDEX is not None and _CACHED_TAGS_INDEX is not None: - break - except Exception: - pass - - if _CACHED_RATING_INDEX is None: - _CACHED_RATING_INDEX = rating_idx - if _CACHED_TAGS_INDEX is None: - _CACHED_TAGS_INDEX = tags_idx - - return _CACHED_RATING_INDEX, _CACHED_TAGS_INDEX - - -def apply_windows_metadata(file_path: str, rating, tags: list) -> dict: - """ - Apply rating/tags via Windows API while handling defaults. - Retourne {"rating": int, "tags": List[str]}. - """ - try: - rating_int = int(rating) if rating is not None else 0 - except Exception: - rating_int = 0 - tags_list: List[str] = [] - if isinstance(tags, list): - tags_list = [str(t) for t in tags if str(t).strip()] - set_windows_metadata(file_path, rating_int, tags_list) - return {"rating": rating_int, "tags": tags_list} - - -# Canonical metadata updater (Windows + ExifTool + optional sidecar); keep single definition -def update_metadata_with_windows(file_path: str, updates: dict) -> dict: - """ - Update rating/tags via Windows + sidecar (if enabled). - Returns final metadata. - """ - start_epoch = _get_cache_epoch() - - # Load current values (prefer system metadata, fallback to sidecar) - sys_meta = get_system_metadata(file_path) or {} - sidecar = load_metadata(file_path) or {} - current_rating = sys_meta.get("rating", sidecar.get("rating", 0)) - current_tags = sys_meta.get("tags", sidecar.get("tags", [])) - - has_rating = "rating" in updates - has_tags = "tags" in updates - - if not has_rating and not has_tags: - return {"rating": current_rating, "tags": current_tags} - - rating = _coerce_rating_to_stars(updates.get("rating", current_rating)) - tags = _normalize_tags(updates.get("tags", current_tags)) - - # Only write tags when provided; otherwise preserve existing tags in system metadata - target_tags = tags if has_tags else _normalize_tags(current_tags) - - ok_win = False - ok_exif = False - if has_rating or has_tags: - is_video = str(file_path).lower().endswith(tuple(VIDEO_EXTS)) - # For videos, prefer ExifTool writes (more portable; also writes Microsoft:Category/SharedUserRating). - if is_video and _get_exiftool_path(): - ok_exif = set_exif_metadata( - file_path, - rating if has_rating else current_rating, - target_tags if has_tags else current_tags, - ) - ok_win = set_windows_metadata( - file_path, - rating if has_rating else current_rating, - target_tags if has_tags else current_tags, - ) - else: - ok_win = set_windows_metadata( - file_path, - rating if has_rating else current_rating, - target_tags if has_tags else current_tags, - ) - ok_exif = set_exif_metadata( - file_path, - rating if has_rating else current_rating, - target_tags if has_tags else current_tags, - ) - - sidecar_saved = False - if _sidecar_allowed_for(file_path): - save_metadata(file_path, {"rating": rating if has_rating else current_rating, "tags": target_tags}) - sidecar_saved = True - - # Video robustness: if Windows metadata fields are not supported and ExifTool is unavailable, - # still persist rating/tags for the manager via a sidecar JSON (even on Windows). - if (not ok_win and not ok_exif) and str(file_path).lower().endswith(tuple(VIDEO_EXTS)): - try: - save_metadata(file_path, {"rating": rating if has_rating else current_rating, "tags": target_tags}, force=True) - sidecar_saved = True - except Exception: - pass - - effective_rating = (rating if has_rating else current_rating) if (ok_win or ok_exif or sidecar_saved) else current_rating - effective_tags = target_tags - - # Ensure cache invalidates even when mtime is preserved (Windows/ExifTool writes). - if ok_win or ok_exif or sidecar_saved: - epoch = _bump_cache_epoch_if(start_epoch) - else: - epoch = _get_cache_epoch() - _cache_set( - file_path, - _get_mtime_safe(file_path), - epoch, - {"rating": _coerce_rating_to_stars(effective_rating), "tags": _normalize_tags(effective_tags)}, - ) - return {"rating": effective_rating, "tags": effective_tags} - - -def apply_system_metadata(file_path: str, rating, tags: list) -> dict: - """ - Try exiftool first (if installed), otherwise Windows Shell. - """ - rating_int = _coerce_rating_to_stars(rating) - tags_list = _normalize_tags(tags) - - # exiftool (meilleure compat Windows + autres OS) - if set_exif_metadata(file_path, rating_int, tags_list): - epoch = _next_cache_epoch() - _cache_set( - file_path, - _get_mtime_safe(file_path), - epoch, - {"rating": rating_int, "tags": tags_list}, - ) - return {"rating": rating_int, "tags": tags_list} - - # fallback shell API - meta = apply_windows_metadata(file_path, rating_int, tags_list) - epoch = _next_cache_epoch() - _cache_set(file_path, _get_mtime_safe(file_path), epoch, meta) - return meta - - -def get_system_metadata(file_path: str) -> dict: - """ - Prefer ExifTool metadata (Rating/Keywords), otherwise Windows API. - FIX: Use improved cache_get that returns epoch for validation. - """ - mtime = _get_mtime_safe(file_path) - epoch = _get_cache_epoch() - cached_data, cache_epoch = _cache_get(file_path, mtime, epoch) - # Verify epoch hasn't changed since cache fetch - if cached_data is not None and cache_epoch == _get_cache_epoch(): - return cached_data - - meta = get_exif_metadata(file_path) - # For videos, ExifTool is the source of truth (Explorer can be inconsistent across handlers). - if file_path.lower().endswith(tuple(VIDEO_EXTS)) and meta: - _cache_set(file_path, mtime, _get_cache_epoch(), meta) - return meta - - if meta.get("rating") or meta.get("tags"): - _cache_set(file_path, mtime, _get_cache_epoch(), meta) - return meta - - meta = get_windows_metadata(file_path) - if (meta.get("rating") or meta.get("tags")) and not (meta.get("rating") == 0 and not meta.get("tags")): - _cache_set(file_path, mtime, _get_cache_epoch(), meta) - return meta - - # Sidecar fallback only when enabled/forced - if _sidecar_allowed_for(file_path): - sidecar = load_metadata(file_path) - if sidecar: - r = sidecar.get("rating", 0) - t = sidecar.get("tags", []) - meta = {"rating": r if isinstance(r, int) else 0, "tags": t if isinstance(t, list) else []} - _cache_set(file_path, mtime, _get_cache_epoch(), meta) - return meta - - _cache_set(file_path, mtime, _get_cache_epoch(), meta) - return meta - - -def classify_ext(lower_name: str) -> str: - """Retourne image / video / audio / model3d / other.""" - if lower_name.endswith(tuple(IMAGE_EXTS)): - return "image" - if lower_name.endswith(tuple(VIDEO_EXTS)): - return "video" - if lower_name.endswith(tuple(AUDIO_EXTS)): - return "audio" - if lower_name.endswith(tuple(MODEL3D_EXTS)): - return "model3d" - return "other" - -def ensure_dir(path): - if not os.path.exists(path): - os.makedirs(path) - -def is_image(fname: str): - f = fname.lower() - return f.endswith(tuple(IMAGE_EXTS)) - -def metadata_path(image_path: str): - candidate = image_path + METADATA_EXT - # Always allow reading an existing sidecar (even when disabled by default on Windows) - if os.path.exists(candidate): - return candidate - if not _sidecar_allowed_for(image_path): - return None - return candidate - -def load_metadata(image_path): - meta_file = metadata_path(image_path) - if not meta_file: - return {} - if not os.path.exists(meta_file): - return {} - try: - with open(meta_file, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - -def save_metadata(image_path, meta: dict, force: bool = False): - meta_file = image_path + METADATA_EXT if force else metadata_path(image_path) - if not meta_file: - return - with open(meta_file, "w", encoding="utf-8") as f: - json.dump(meta, f, indent=2, ensure_ascii=False) diff --git a/server/version.py b/server/version.py deleted file mode 100644 index 2be0457e..00000000 --- a/server/version.py +++ /dev/null @@ -1 +0,0 @@ -VERSION = "1.0.2" diff --git a/server/watcher.py b/server/watcher.py deleted file mode 100644 index 5dd93315..00000000 --- a/server/watcher.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -File system watcher using the `watchdog` library to trigger real-time reindexing. -This is an optional feature. If watchdog is not installed, real-time indexing will be disabled. -""" - -import threading -import time -from typing import Optional, Dict, Set -import itertools - -from .config import OUTPUT_ROOT -from .logger import get_logger -from .utils import IMAGE_EXTS, VIDEO_EXTS, AUDIO_EXTS, MODEL3D_EXTS - -log = get_logger(__name__) - -try: - from watchdog.observers import Observer - from watchdog.events import PatternMatchingEventHandler, FileSystemEvent, FileSystemMovedEvent - WATCHDOG_AVAILABLE = True -except ModuleNotFoundError: - # Create dummy classes to prevent import errors elsewhere - class Observer: pass - class PatternMatchingEventHandler: pass - class FileSystemEvent: pass - class FileSystemMovedEvent: pass - WATCHDOG_AVAILABLE = False - -_observer: Optional[Observer] = None -_observer_thread: Optional[threading.Thread] = None - -# Debounce mechanism to handle files being written in chunks -_pending_reindex: Dict[str, float] = {} # path -> timestamp -_pending_lock = threading.Lock() -_debounce_thread: Optional[threading.Thread] = None -_debounce_shutdown = threading.Event() -DEBOUNCE_DELAY = 1.0 # Wait 1 second after last modification before indexing - -# Patterns for files we want to track -all_exts = itertools.chain(IMAGE_EXTS, VIDEO_EXTS, AUDIO_EXTS, MODEL3D_EXTS) -PATTERNS = [f"*{ext}" for ext in all_exts] -IGNORE_PATTERNS = [ - "*/.mjr_batch_*.zip", - "*/_mjr_collections/*", - "*/_mjr_index/*" -] - -def _debounce_worker(): - """Background worker that processes pending reindex requests after debounce delay.""" - from .index_db import reindex_paths - - while not _debounce_shutdown.is_set(): - time.sleep(0.5) # Check every 500ms - - now = time.time() - ready_paths = [] - - with _pending_lock: - # Find paths that have been quiet for DEBOUNCE_DELAY seconds - for path, timestamp in list(_pending_reindex.items()): - if now - timestamp >= DEBOUNCE_DELAY: - ready_paths.append(path) - del _pending_reindex[path] - - if ready_paths: - log.debug(f"📁🔍 [Watcher] Debounce completed for {len(ready_paths)} file(s), indexing...") - try: - reindex_paths(ready_paths) - except Exception as e: - log.error(f"📁❌ [Watcher] Debounced reindex failed: {e}") - -def _schedule_reindex(path: str): - """Schedule a path for reindexing with debounce delay.""" - with _pending_lock: - _pending_reindex[path] = time.time() - log.debug(f"đŸ“âąī¸ [Watcher] Scheduled debounced reindex: {path}") - -class AssetWatcherHandler(PatternMatchingEventHandler): - """Handles file system events and triggers reindexing.""" - - def __init__(self): - if not WATCHDOG_AVAILABLE: - return - super().__init__(patterns=PATTERNS, ignore_patterns=IGNORE_PATTERNS, ignore_directories=True, case_sensitive=False) - - def _get_asset_id_from_path(self, path: str) -> Optional[str]: - """Helper to construct asset ID from a file path.""" - try: - from .index_db import _make_asset_id # Import here to avoid circular dependency - import os - from pathlib import Path - - rel_path = os.path.relpath(path, OUTPUT_ROOT) - parts = Path(rel_path).parts - - if len(parts) == 1: - type_name = "output" - subfolder = "" - filename = parts[0] - else: - # This logic should be kept in sync with index_db.reindex_all - type_name = "output" - subfolder = os.path.join(*parts[:-1]) - filename = parts[-1] - - return _make_asset_id(type_name, subfolder, filename) - except Exception: - return None - - def on_created(self, event: FileSystemEvent): - log.debug(f"📁🔍 [Watcher] Created: {event.src_path}") - # Use debounce to avoid race condition with file still being written - _schedule_reindex(event.src_path) - - def on_deleted(self, event: FileSystemEvent): - from .index_db import delete_asset # Import here to avoid circular dependency - log.debug(f"📁🔍 [Watcher] Deleted: {event.src_path}") - - # Remove from pending reindex if scheduled - with _pending_lock: - _pending_reindex.pop(event.src_path, None) - - # Delete immediately (no debounce needed for deletion) - asset_id = self._get_asset_id_from_path(event.src_path) - if asset_id: - try: - delete_asset(asset_id) - except Exception as e: - log.error(f"📁❌ [Watcher] Failed to delete asset {asset_id}: {e}") - - def on_modified(self, event: FileSystemEvent): - log.debug(f"📁🔍 [Watcher] Modified: {event.src_path}") - # Use debounce - updates timestamp if already scheduled - # This handles files being written in chunks (multiple modify events) - _schedule_reindex(event.src_path) - - def on_moved(self, event: FileSystemMovedEvent): - from .index_db import delete_asset # Import here to avoid circular dependency - log.debug(f"📁🔍 [Watcher] Moved: {event.src_path} to {event.dest_path}") - - # Remove old path from pending if scheduled - with _pending_lock: - _pending_reindex.pop(event.src_path, None) - - # Delete the old record immediately - old_asset_id = self._get_asset_id_from_path(event.src_path) - if old_asset_id: - try: - delete_asset(old_asset_id) - except Exception as e: - log.error(f"📁❌ [Watcher] Failed to delete moved asset {old_asset_id}: {e}") - - # Schedule reindex for new path (debounced in case move is still in progress) - _schedule_reindex(event.dest_path) - -def start_watcher(path: str = OUTPUT_ROOT) -> None: - """Starts the file system watcher in a background thread.""" - global _observer, _observer_thread, _debounce_thread - if not WATCHDOG_AVAILABLE: - log.warning("đŸ“âš ī¸ [Watcher] Watchdog library not found. Real-time indexing is disabled.") - log.warning("đŸ“âš ī¸ [Watcher] To enable, run: pip install watchdog") - return - - if _observer and _observer.is_alive(): - log.warning("đŸ“âš ī¸ [Watcher] Watcher is already running.") - return - - # Start debounce worker thread - _debounce_shutdown.clear() - _debounce_thread = threading.Thread(target=_debounce_worker, daemon=True, name="MajoorDebounceWorker") - _debounce_thread.start() - log.debug("📁✅ [Watcher] Debounce worker started") - - event_handler = AssetWatcherHandler() - _observer = Observer() - _observer.schedule(event_handler, path, recursive=True) - - def run(): - log.info(f"📁✅ [Watcher] Starting file system watcher on: {path}") - _observer.start() - try: - while _observer and _observer.is_alive(): - _observer.join(1) - except Exception as e: - log.error(f"📁❌ [Watcher] Watcher thread encountered an error: {e}") - finally: - if _observer: - _observer.stop() - _observer.join() - log.info("[Watcher] File system watcher stopped.") - - _observer_thread = threading.Thread(target=run, daemon=True) - _observer_thread.start() - -def stop_watcher() -> None: - """Stops the file system watcher.""" - global _observer, _debounce_thread - if not WATCHDOG_AVAILABLE: - return - - # Stop debounce worker - _debounce_shutdown.set() - if _debounce_thread: - _debounce_thread.join(timeout=5.0) - _debounce_thread = None - log.debug("📁✅ [Watcher] Debounce worker stopped") - - if _observer: - _observer.stop() - # The join is handled in the thread itself - _observer = None - diff --git a/server/workflow_hash.py b/server/workflow_hash.py deleted file mode 100644 index 255e1a96..00000000 --- a/server/workflow_hash.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -Workflow fingerprinting for Graph ↔ Asset bridge. - -Generates stable hashes of ComfyUI workflows for matching assets to graphs. -Canonicalizes workflow JSON to ensure consistent hashes regardless of: -- Node IDs (may change on copy/paste) -- Node positions (UI layout irrelevant) -- Execution order (non-deterministic) -- UI properties (colors, minimized state, etc.) -""" - -import json -import hashlib -from typing import Any, Dict, Set - - -# Fields to exclude from canonicalization (volatile/UI-only) -EXCLUDE_KEYS: Set[str] = { - "id", # Node IDs may change - "pos", # Position irrelevant - "size", # Size irrelevant - "order", # Execution order may vary - "mode", # Mode (active/muted) irrelevant - "properties", # UI properties - "color", # Node color - "bgcolor", # Background color - "flags", # UI flags - "widgets_values", # Widget positions (use inputs instead) -} - - -def deep_copy_without(obj: Any, exclude_keys: Set[str]) -> Any: - """ - Deep copy dict/list, excluding specified keys. - - Args: - obj: Object to copy (dict, list, or primitive) - exclude_keys: Set of keys to exclude from dicts - - Returns: - Deep copy with excluded keys removed - """ - if isinstance(obj, dict): - return { - k: deep_copy_without(v, exclude_keys) - for k, v in obj.items() - if k not in exclude_keys - } - elif isinstance(obj, list): - return [deep_copy_without(item, exclude_keys) for item in obj] - else: - return obj - - -def canonicalize_workflow(workflow: Dict[str, Any]) -> str: - """ - Convert workflow to canonical JSON for hashing. - - Removes volatile fields and sorts keys recursively. - - Args: - workflow: ComfyUI workflow dict - - Returns: - Canonical JSON string (sorted, compact) - """ - # Remove volatile fields - clean = deep_copy_without(workflow, EXCLUDE_KEYS) - - # Sort all dict keys recursively - canonical = json.dumps(clean, sort_keys=True, separators=(',', ':'), ensure_ascii=False) - return canonical - - -def hash_workflow(workflow: Dict[str, Any]) -> str: - """ - Generate stable SHA1 hash of workflow. - - Args: - workflow: ComfyUI workflow dict - - Returns: - 40-character hex SHA1 hash or empty string - """ - if not workflow: - return "" - - try: - canonical = canonicalize_workflow(workflow) - # Check if canonical is empty (avoids hash of empty string) - if not canonical or canonical in ("{}", "[]", "null"): - return "" - - return hashlib.sha1(canonical.encode('utf-8')).hexdigest() - except Exception as e: - # Log error but don't crash indexing - from .logger import get_logger - log = get_logger(__name__) - log.warning(f"[Majoor] Failed to hash workflow: {e}") - return "" - - -def hash_workflow_robust(workflow: Any) -> str: - """ - Robust workflow hashing with fallback for invalid data. - - Handles: - - None/empty workflows - - Non-dict workflows - - Malformed JSON - - Args: - workflow: ComfyUI workflow (dict, str, or None) - - Returns: - Workflow hash or empty string on failure - """ - if not workflow: - return "" - - # If workflow is a string, try to parse it - if isinstance(workflow, str): - try: - workflow = json.loads(workflow) - except: - return "" - - # Ensure it's a dict - if not isinstance(workflow, dict): - return "" - - return hash_workflow(workflow) - - -# ===== Client-side compatible hashing (for frontend) ===== - -def get_hash_algorithm_info() -> Dict[str, str]: - """ - Get information about the hashing algorithm for client-side implementation. - - Returns: - Dict with keys: algorithm, exclude_keys, description - """ - return { - "algorithm": "SHA1", - "exclude_keys": list(EXCLUDE_KEYS), - "description": "Canonical JSON (sorted keys, no whitespace) -> SHA1 hex", - } - - -# ===== Testing/Validation ===== - -def _test_workflow_hashing(): - """Self-test for workflow hashing consistency.""" - # Test workflow - workflow1 = { - "nodes": [ - { - "id": 1, - "type": "KSampler", - "pos": [100, 200], - "size": [300, 400], - "inputs": {"seed": 42, "steps": 20}, - "properties": {"note": "test"}, - } - ], - "links": [[1, 0, 2, 0, "LATENT"]], - } - - # Same workflow, different IDs/positions - workflow2 = { - "nodes": [ - { - "id": 999, # Different ID - "type": "KSampler", - "pos": [500, 600], # Different position - "size": [100, 100], # Different size - "inputs": {"seed": 42, "steps": 20}, # Same inputs - "properties": {"note": "different"}, # Different properties - } - ], - "links": [[1, 0, 2, 0, "LATENT"]], - } - - # Different workflow (different inputs) - workflow3 = { - "nodes": [ - { - "id": 1, - "type": "KSampler", - "pos": [100, 200], - "inputs": {"seed": 99, "steps": 30}, # Different - } - ], - "links": [[1, 0, 2, 0, "LATENT"]], - } - - hash1 = hash_workflow(workflow1) - hash2 = hash_workflow(workflow2) - hash3 = hash_workflow(workflow3) - - # workflow1 and workflow2 should have same hash - assert hash1 == hash2, f"Hash mismatch: {hash1} != {hash2}" - - # workflow1 and workflow3 should have different hashes - assert hash1 != hash3, f"Hash collision: {hash1} == {hash3}" - - # Hash should be 40 characters (SHA1) - assert len(hash1) == 40, f"Invalid hash length: {len(hash1)}" - - print("[Majoor] Workflow hashing tests passed") - - -# Run self-test on import -if __name__ != "__main__": - try: - _test_workflow_hashing() - except Exception as e: - try: - from .logger import get_logger - log = get_logger(__name__) - log.error(f"[Majoor] Workflow hashing self-test failed: {e}") - except: - pass # Standalone mode diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 00000000..66399538 --- /dev/null +++ b/shared/__init__.py @@ -0,0 +1,22 @@ +"""Shared utilities for Majoor Assets Manager.""" +from .result import Result +from .log import get_logger, log_success, log_structured +from .time import now, ms, format_timestamp, timer +from .types import FileKind, MetadataQuality, ErrorCode, classify_file, IndexMode, MetadataMode + +__all__ = [ + "Result", + "get_logger", + "log_success", + "now", + "ms", + "format_timestamp", + "timer", + "FileKind", + "MetadataQuality", + "ErrorCode", + "classify_file", + "IndexMode", + "MetadataMode", + "log_structured", +] diff --git a/shared/log.py b/shared/log.py new file mode 100644 index 00000000..d7044da4 --- /dev/null +++ b/shared/log.py @@ -0,0 +1,97 @@ +""" +Logging utilities with consistent formatting and emoji indicators. +""" +import json +import logging +from datetime import datetime, timezone +from typing import Optional + +# Emoji indicators for log levels +EMOJI_MAP = { + "DEBUG": "🔍", # Magnifying glass for debug + "INFO": "â„šī¸", # Info symbol + "WARNING": "âš ī¸", # Warning sign + "ERROR": "❌", # Error cross + "CRITICAL": "đŸ”Ĩ", # Fire for critical + "SUCCESS": "✅", # Success checkmark +} + +# Global logger prefix +PREFIX = "📂 Majoor" + +class EmojiFormatter(logging.Formatter): + """Custom formatter that adds emoji based on log level.""" + + def format(self, record): + # Get emoji for log level + emoji = EMOJI_MAP.get(record.levelname, "📂") + + # Format: 📂 Majoor [📂✅] module: message + log_format = f"{PREFIX} [{emoji}] %(name)s: %(message)s" + formatter = logging.Formatter(log_format) + return formatter.format(record) + +def get_logger(name: str, level: Optional[int] = None) -> logging.Logger: + """ + Get a logger with Majoor prefix and emoji indicators. + + Args: + name: Logger name (usually __name__) + level: Optional logging level (DEBUG, INFO, WARNING, ERROR) + + Returns: + Configured logger instance with emoji formatting + """ + # Clean name (remove module prefix if present) + if name.startswith("__main__"): + name = "main" + elif "." in name: + parts = name.split(".") + if "server" in parts: + idx = parts.index("server") + name = ".".join(parts[idx:]) + elif "backend" in parts: + idx = parts.index("backend") + name = ".".join(parts[idx:]) + + logger = logging.getLogger(f"majoor.{name}") + + if level is not None: + logger.setLevel(level) + elif not logger.handlers: + # Default to INFO if not configured + logger.setLevel(logging.INFO) + + # Add console handler if none exists + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(EmojiFormatter()) + logger.addHandler(handler) + + # Prevent propagation to avoid duplicate logs + logger.propagate = False + + return logger + +# Add SUCCESS level +logging.SUCCESS = 25 # Between INFO (20) and WARNING (30) +logging.addLevelName(logging.SUCCESS, "SUCCESS") + +def log_success(logger, message: str): + """ + Log a success message with ✅ emoji. + + Args: + logger: Logger instance + message: Success message + """ + logger.log(logging.SUCCESS, message) + +def log_structured(logger, level, message: str, **context): + """Emit a structured JSON log entry with contextual fields.""" + payload = { + "message": message, + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "context": context, + } + logger.log(level, json.dumps(payload, ensure_ascii=False)) diff --git a/shared/result.py b/shared/result.py new file mode 100644 index 00000000..b62ee615 --- /dev/null +++ b/shared/result.py @@ -0,0 +1,51 @@ +""" +Result pattern for error handling without exceptions. +All service methods return Result[T] to avoid HTTP 500 errors. +""" +from dataclasses import dataclass, field +from typing import Any, Dict, Generic, Optional, TypeVar + +T = TypeVar("T") + +@dataclass +class Result(Generic[T]): + """ + Result pattern for safe error handling. + + Usage: + def get_metadata(path: str) -> Result[dict]: + if not os.path.exists(path): + return Result.Err("NOT_FOUND", f"File not found: {path}") + return Result.Ok({"filename": path}) + """ + ok: bool + data: Optional[T] = None + error: Optional[str] = None + code: str = "OK" # OK, DEGRADED, TOOL_MISSING, DB_ERROR, UNSUPPORTED, etc. + meta: Dict[str, Any] = field(default_factory=dict) + + @staticmethod + def Ok(data: T, **meta) -> "Result[T]": + """Create a successful result with data and optional metadata.""" + return Result(ok=True, data=data, code="OK", meta=meta) + + @staticmethod + def Err(code: str, error: str, **meta) -> "Result[T]": + """Create an error result with code, message, and optional metadata.""" + return Result(ok=False, error=error, code=code, meta=meta) + + def map(self, fn): + """Map the data if ok, otherwise return self.""" + if self.ok and self.data is not None: + return Result.Ok(fn(self.data), **self.meta) + return self + + def unwrap(self) -> T: + """Get data or raise ValueError if error.""" + if self.ok: + return self.data + raise ValueError(f"[{self.code}] {self.error}") + + def unwrap_or(self, default: T) -> T: + """Get data or return default if error.""" + return self.data if (self.ok and self.data is not None) else default diff --git a/shared/time.py b/shared/time.py new file mode 100644 index 00000000..543d3615 --- /dev/null +++ b/shared/time.py @@ -0,0 +1,48 @@ +""" +Time utilities for timestamps and performance measurement. +""" +import time +from typing import Optional +from contextlib import contextmanager + +def now() -> float: + """Get current timestamp in seconds (float).""" + return time.time() + +def ms() -> int: + """Get current timestamp in milliseconds (int).""" + return int(time.time() * 1000) + +def format_timestamp(ts: Optional[float] = None) -> str: + """ + Format timestamp as ISO 8601 string. + + Args: + ts: Timestamp in seconds (default: now()) + + Returns: + ISO 8601 formatted string (e.g., "2025-12-29T19:30:45") + """ + if ts is None: + ts = now() + return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ts)) + +@contextmanager +def timer(label: str, logger=None): + """ + Context manager for timing operations. + + Usage: + with timer("metadata extraction", logger): + extract_metadata(path) + """ + start = now() + try: + yield + finally: + elapsed = now() - start + msg = f"{label} took {elapsed:.3f}s" + if logger: + logger.debug(msg) + else: + print(msg) diff --git a/shared/types.py b/shared/types.py new file mode 100644 index 00000000..c84ee92b --- /dev/null +++ b/shared/types.py @@ -0,0 +1,67 @@ +""" +Shared types, enums, and constants. +""" +from enum import Enum +from typing import Literal + +# File type classifications +FileKind = Literal["image", "video", "audio", "model3d", "unknown"] + +# Metadata quality levels +MetadataQuality = Literal["full", "partial", "degraded", "none"] + +# Error codes +class ErrorCode: + """Standard error codes for Result pattern.""" + OK = "OK" + NOT_FOUND = "NOT_FOUND" + UNSUPPORTED = "UNSUPPORTED" + DEGRADED = "DEGRADED" + TOOL_MISSING = "TOOL_MISSING" + DB_ERROR = "DB_ERROR" + EXIFTOOL_ERROR = "EXIFTOOL_ERROR" + FFPROBE_ERROR = "FFPROBE_ERROR" + PARSE_ERROR = "PARSE_ERROR" + INVALID_INPUT = "INVALID_INPUT" + TIMEOUT = "TIMEOUT" + +# File extensions by type +EXTENSIONS = { + "image": {".png", ".jpg", ".jpeg", ".webp", ".gif"}, + "video": {".mp4", ".mov", ".webm", ".mkv"}, + "audio": {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac"}, + "model3d": {".obj", ".fbx", ".glb", ".gltf", ".stl"}, +} + +def classify_file(filename: str) -> FileKind: + """ + Classify file by extension. + + Args: + filename: File name or path + + Returns: + File kind (image, video, audio, model3d, unknown) + """ + import os + ext = os.path.splitext(filename)[1].lower() + + for kind, exts in EXTENSIONS.items(): + if ext in exts: + return kind + + return "unknown" + +# Index modes +class IndexMode(str, Enum): + """SQLite index modes.""" + AUTO = "auto" # Try index first, fallback to scan + INDEX = "index" # Use SQLite index only + FILESYSTEM = "filesystem" # Direct filesystem scan + +# Metadata modes +class MetadataMode(str, Enum): + """Metadata extraction strategies.""" + LEGACY = "legacy" # Windows props, ExifTool, sidecars only + HYBRID = "hybrid" # Try new parsers first, fallback to legacy + NATIVE = "native" # New native parsers only