Add batch zip download feature and collection service tests and more
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
Majoor_AssetsManager_Deep_Deep_Audit_Fixe04.md
|
||||
DEEP_AUDIT_REPORT.md
|
||||
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
@@ -49,3 +50,5 @@ backend/routes/core/test_security.py
|
||||
backend/routes/handlers/test_assets_delete.py
|
||||
SECURITY_AUDIT_COMPLETE.md
|
||||
node.zip
|
||||
backend/routes/handlers/test_batch_zip.py
|
||||
nul
|
||||
|
||||
@@ -1,311 +1,109 @@
|
||||
# 📂 Majoor Assets Manager for ComfyUI
|
||||
# Majoor Assets Manager for ComfyUI 🧩
|
||||
|
||||
Advanced asset browser for ComfyUI outputs (images/videos) with **ratings & tags**, **full-text search**, **workflow fingerprinting**, and advanced metadata management.
|
||||
|
||||
## 💡 Why This Exists
|
||||
|
||||
I work daily in ComfyUI in real production contexts (VFX / generative pipelines), and asset management quickly became a problem.
|
||||
|
||||
I've tested several asset managers over time. Some are external tools. Some are partially integrated. Most of them are either too rigid, too opinionated, or disconnected from how ComfyUI is actually used in production.
|
||||
|
||||
Sometimes you need custom nodes. Sometimes external logic makes sense. But jumping outside ComfyUI just to understand what you generated breaks the flow.
|
||||
|
||||
**So I built an Assets Manager that lives directly inside ComfyUI.**
|
||||
|
||||
> ⚠️ **Work in Progress**: This extension is still under active development. Many things will move and change. Recommendations and feedback are welcomed!
|
||||
An asset browser that lives directly inside ComfyUI: browse outputs/inputs/custom roots, search (FTS5), inspect generation metadata, rate/tag, collect, and drag assets into ComfyUI (or out to your OS).
|
||||
|
||||

|
||||
|
||||
---
|
||||
## Key Features ✨
|
||||
|
||||
## 🚀 Key Features
|
||||
### Search & Filtering 🔎
|
||||
- Full-text search powered by SQLite **FTS5** (BM25 ranking).
|
||||
- Scope: **Outputs**, **Inputs**, **Custom roots**, **Collections**.
|
||||
- Filters: kind, min rating, workflow-only, date filters, sorting.
|
||||
- Optional: hide PNG siblings of video previews.
|
||||
- Summary line: total assets + selected count + current context.
|
||||
|
||||
### 🔍 **Search & Discovery**
|
||||
- **Full-text search** powered by SQLite FTS5 with BM25 ranking
|
||||
- **Workflow fingerprinting**: find all assets generated with the same workflow (upcoming)
|
||||
- **Smart filters**: Today, Yesterday, Last 7 Days, 5 Stars, 4+ Stars, Videos, Images
|
||||
- **Advanced filtering**: by rating, tags (AND/OR logic), file type, workflow presence
|
||||
- **Collections system**: save and organize filtered asset groups in `_mjr_collections`
|
||||
### Metadata & Viewer 🧾
|
||||
- Extracts generation info from PNG/WEBP/video (ExifTool/FFprobe; degrades gracefully).
|
||||
- Viewer modes: single, A/B compare, side-by-side (2-4).
|
||||
- Video frame stepping in viewer (Shift + Arrow keys), zoom/pan.
|
||||
- Viewer context menu: tags, rating, add to collection, open in folder, rename, delete.
|
||||
|
||||
### ⭐ **Ratings & Tags**
|
||||
- Set ratings `0–5` on individual or bulk-selected assets
|
||||
- Add custom tags with instant metadata updates
|
||||
- Metadata stored in OS-native formats (Windows Property System, XMP) with optional `.mjr.json` sidecars
|
||||
- Visual badges on cards and in viewer
|
||||
- Bulk operations bar for multi-file rating/tagging
|
||||
### Ratings, Tags, Collections 🗂️
|
||||
- Ratings/tags stored in the index DB and (optionally) synced into files when enabled.
|
||||
- Collections: create, add items (duplicates skipped and reported at the end), remove items, open as a view.
|
||||
|
||||
### ⚡ **Performance**
|
||||
- **Virtualized grid** with lazy loading (handles thousands of assets)
|
||||
- Paginated file listing (configurable page size, default 500)
|
||||
- **Incremental indexing**: only re-scans changed files based on mtime
|
||||
- LRU cache for thumbnails (200 items)
|
||||
- Debounced search (300ms) and optimistic UI updates
|
||||
- Optional auto-refresh with focus-aware polling
|
||||
### Drag & Drop 🧲
|
||||
- Drag an asset onto the ComfyUI canvas (video-focused): stage to input, inject paths into compatible nodes.
|
||||
- Drag out to the OS (Explorer/Desktop): uses `DownloadURL`/`text/uri-list` and supports multi-selection ZIP (flat ZIP; folders excluded).
|
||||
|
||||
### 🧠 **Metadata Intelligence**
|
||||
- **Prompt/workflow inspector**: extracts generation parameters from PNG, video, and EXIF UserComment
|
||||
- **Workflow tracer v2**: recursive node graph reconstruction for ComfyUI workflows
|
||||
- **Lossless PNG metadata injection**: preserves workflow JSON when writing ratings/tags
|
||||
- Native EXIF parser with multi-encoding support (UTF-8, UTF-16, Latin-1)
|
||||
- **Hybrid metadata mode**: tries advanced parsers first, falls back to legacy methods
|
||||
- Reads model, sampler, steps, CFG, seed, LoRA, and custom node parameters
|
||||
### UX Details 🧠
|
||||
- Filename collision indicator: if multiple assets share the same filename, the extension badge shows `EXT+` (e.g. `PNG+`).
|
||||
- Context menus: add/remove collection, open viewer, open in folder, copy path, etc.
|
||||
|
||||
### 🖼️ **Viewer & A/B Compare**
|
||||
- **Single viewer**: full-screen zoom/pan, video playback, rating HUD
|
||||
- **A/B compare mode**: side-by-side comparison with synchronized controls
|
||||
- **Frame-by-frame navigation** for videos (±1/30s step)
|
||||
- Keyboard-driven navigation (`ArrowUp`/`Down` for prev/next, `0-5` for rating)
|
||||
- Optional checkerboard background for transparency
|
||||
- Autoplay, loop, and mute controls
|
||||
## Installation ⚙️
|
||||
|
||||
### ⌨️ **Keyboard-First Workflow**
|
||||
- `0`-`5`: set rating for selected files
|
||||
- `Space`: open viewer (1 selected) or A/B compare (2 selected)
|
||||
- `Ctrl/Cmd+A`: select all filtered files
|
||||
- Explorer-like selection: `Ctrl`/`Shift` + Click for toggle/range
|
||||
- Viewer hotkeys: `Esc`, `ArrowUp`/`Down`, `ArrowLeft`/`Right`, `F` (fit/reset)
|
||||
|
||||
### 🎨 **UI & Integration**
|
||||
- **Flexible panel placement**: sidebar, bottom panel, or both
|
||||
- Hover tooltips with file metadata (filename, folder, size, date, rating, tags, workflow state)
|
||||
- Real-time index status indicator (green/yellow/red/gray dot)
|
||||
- Workflow state badge on cards (green: full data, yellow: partial, red: corrupt, gray: none)
|
||||
- Drag-and-drop support for files into ComfyUI canvas
|
||||
- Context menu for quick actions (delete, open in explorer, stage to input)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Method 1: ComfyUI Manager (Recommended)
|
||||
|
||||
Install directly from the ComfyUI Manager interface.
|
||||
|
||||
### Method 2: Manual
|
||||
### ComfyUI Manager (recommended)
|
||||
Install via the ComfyUI Manager UI.
|
||||
|
||||
### Manual
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone <this-repo-url> ComfyUI-Majoor-AssetsManager
|
||||
git clone https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager ComfyUI-Majoor-AssetsManager
|
||||
pip install -r ComfyUI-Majoor-AssetsManager/requirements.txt
|
||||
```
|
||||
|
||||
Restart ComfyUI.
|
||||
|
||||
---
|
||||
## Usage 🧭
|
||||
|
||||
## ✅ Requirements
|
||||
- Open the **Assets Manager** panel/tab.
|
||||
- Pick a scope (**Outputs / Inputs / Custom / Collections**).
|
||||
- Use search + filters; right-click cards for actions; double-click to open the viewer.
|
||||
- Use **Collections** to save sets of files and browse them later.
|
||||
- Drag assets:
|
||||
- to the ComfyUI canvas (staging/inject path),
|
||||
- or to your OS (single file or a ZIP for multi-selection).
|
||||
|
||||
### Python Dependencies
|
||||
## Hotkeys (high level) ⌨️
|
||||
|
||||
All Python dependencies are automatically installed via `requirements.txt`:
|
||||
Hotkeys are ignored while typing inside inputs.
|
||||
|
||||
- `aiohttp` (REST API server)
|
||||
- `pillow` (thumbnail generation)
|
||||
- `send2trash` (safe delete to recycle bin)
|
||||
- `watchdog` (real-time file system monitoring for automatic reindexing)
|
||||
- `pywin32` (Windows only; enables Windows Property System metadata)
|
||||
- `Ctrl/Cmd+S`: trigger an index scan for the current scope.
|
||||
- `D`: toggle details sidebar for the current selection.
|
||||
- `0`-`5`: set rating for the current selection (when interacting with the grid).
|
||||
- Viewer: `Esc` closes, Shift+Arrow steps video frames (single video).
|
||||
|
||||
### External Tools (Strongly Recommended)
|
||||
## Settings 🧰
|
||||
|
||||
For full metadata capabilities, install these external tools:
|
||||
Settings are stored under the `mjrSettings` key in `localStorage`.
|
||||
|
||||
- 🧰 **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`
|
||||
- Page size (assets per request)
|
||||
- Auto-scan (on open / on startup)
|
||||
- Status poll interval
|
||||
- Sidebar position (left/right)
|
||||
- Hide PNG siblings (video previews)
|
||||
- Sync rating/tags to files (best-effort; requires tools)
|
||||
- Media probe backend (auto/exiftool/ffprobe/both)
|
||||
- Observability (request logging)
|
||||
|
||||
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.
|
||||
## Environment Variables (backend) 🧪
|
||||
|
||||
---
|
||||
- `MAJOOR_OUTPUT_DIRECTORY` - override output directory
|
||||
- `MAJOOR_EXIFTOOL_PATH` / `MAJOOR_EXIFTOOL_BIN` - ExifTool path
|
||||
- `MAJOOR_FFPROBE_PATH` / `MAJOOR_FFPROBE_BIN` - FFprobe path
|
||||
- `MAJOOR_MEDIA_PROBE_BACKEND` - `auto|exiftool|ffprobe|both`
|
||||
- `MAJOOR_ENABLE_FILE_WATCHER` - auto reindexing (default `false`)
|
||||
- `MAJOOR_WATCHER_INTERVAL` / `MAJOOR_WATCHER_JOIN_TIMEOUT` / `MAJOOR_WATCHER_PATHS`
|
||||
- `MAJOOR_DB_TIMEOUT` / `MAJOOR_DB_MAX_CONNECTIONS` / `MAJOOR_DB_QUERY_TIMEOUT`
|
||||
|
||||
## 🎬 Metadata Storage & Video Reliability
|
||||
## Files & Storage 🗃️
|
||||
|
||||
### Metadata Storage Strategies
|
||||
- Index DB: `<output>/_mjr_index/assets.sqlite`
|
||||
- Custom roots store: `<output>/_mjr_index/custom_roots.json`
|
||||
- Collections store: `<output>/_mjr_index/collections/*.json`
|
||||
- Temporary drag-out ZIPs: `<output>/_mjr_batch_zips/` (auto-cleaned)
|
||||
|
||||
- **Windows**: OS metadata via Property Store (default) + optional `.mjr.json` sidecars
|
||||
- **macOS/Linux**: `.mjr.json` sidecar files (default) + ExifTool XMP when available
|
||||
- **Configurable**: Set `MJR_ENABLE_SIDECAR=1` or `MJR_FORCE_SIDECAR=1` environment variables
|
||||
## Development 🧑💻
|
||||
|
||||
### Video Metadata Redundancy
|
||||
```bash
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
Windows Explorer and media players can be inconsistent with MP4/MOV/MKV/WEBM metadata. The manager uses a **redundant write strategy**:
|
||||
## Architecture 🏗️
|
||||
|
||||
- ✅ **Always writes**:
|
||||
- `XMP:Rating` (0–5 stars, industry standard)
|
||||
- `XMP:Subject` (tags list with `; ` separator)
|
||||
|
||||
- ✅ **Best-effort writes** (does not fail if unsupported):
|
||||
- `Microsoft:SharedUserRating` (0–99 "percent" scale for Windows Explorer)
|
||||
- `Microsoft:Category` (tags formatted for Explorer: `tag1; tag2`)
|
||||
|
||||
- 🛡️ **Workflow preservation**: Copies original metadata fields (`-tagsFromFile @ ...`) before writing, preserving embedded ComfyUI workflow JSON in comment-like fields
|
||||
|
||||
- ⏳ **Retry mechanism**: Handles "file not ready/locked" errors right after generation with exponential backoff (up to 5 retries)
|
||||
|
||||
- 📥 **Read priority**: ExifTool values (XMP > Microsoft) → Windows Property System → sidecar fallback
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
|
||||
**Note**: Hotkeys are ignored while typing in input fields or textareas.
|
||||
|
||||
### Grid View (Asset Browser)
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `0`–`5` | Set rating for selected files (`0` clears rating) |
|
||||
| `Space` | Open viewer (1 selected) or A/B compare (2 selected) |
|
||||
| `Ctrl`/`Cmd` + `A` | Select all filtered files |
|
||||
| `Click` | Clear selection + select item |
|
||||
| `Ctrl`/`Cmd` + `Click` | Toggle item selection |
|
||||
| `Shift` + `Click` | Select range from last anchor |
|
||||
| `Ctrl`/`Cmd` + `Shift` + `Click` | Add range to selection |
|
||||
|
||||
### Viewer Mode
|
||||
|
||||
| Key | Action | Requires Setting |
|
||||
|-----|--------|------------------|
|
||||
| `Esc` or `Space` | Close viewer | - |
|
||||
| `ArrowUp` / `ArrowDown` | Previous/next asset | Navigation enabled |
|
||||
| `0`–`5` (incl. numpad) | Set rating | Rating hotkeys enabled |
|
||||
| `ArrowLeft` / `ArrowRight` | Step video ±1/30s | Frame-step hotkeys enabled |
|
||||
| `F` | Fit/reset zoom | - |
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Settings (ComfyUI → Settings)
|
||||
|
||||
Access settings via the ComfyUI settings panel (gear icon).
|
||||
|
||||
### Panel & Display
|
||||
|
||||
- 🧩 **Panel Integration** (requires reload): `sidebar`, `bottom`, or `both` (default)
|
||||
- 📄 **Page Size**: files per request (default: `500`)
|
||||
- 🎴 **Card Size**: compact, medium, or large grid cards
|
||||
- 🛈 **Hover Info Tooltips**: show/hide metadata tooltips on card hover
|
||||
- 🏷️ **Show Tags on Cards**: display tag badges
|
||||
- ⭐ **Show Ratings on Cards**: display rating badges
|
||||
|
||||
### Auto-Refresh
|
||||
|
||||
- 🔁 **Enable Auto-refresh**: automatically poll for new assets
|
||||
- ⏱️ **Refresh Interval**: polling frequency in milliseconds (default: `5000`)
|
||||
- 🔍 **Focus-Only Mode**: only refresh when ComfyUI window is active
|
||||
|
||||
### Viewer Settings
|
||||
|
||||
- ▶️ **Autoplay Videos**: start playback automatically
|
||||
- 🔁 **Loop Videos**: repeat video playback
|
||||
- 🔇 **Mute Videos**: disable audio by default
|
||||
- ◀️▶️ **Show Navigation**: display prev/next arrows
|
||||
- ⭐ **Show Rating HUD**: display rating overlay in viewer
|
||||
- ⌨️ **Rating Hotkeys**: enable `0`-`5` keys in viewer
|
||||
- ⌨️ **Frame-Step Hotkeys**: enable `ArrowLeft`/`Right` for video stepping
|
||||
- 🎨 **Checkerboard Background**: show transparency pattern
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 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 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 `<output_directory>/_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
|
||||
- **Supported file types**:
|
||||
- Images: `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`
|
||||
- Videos: `.mp4`, `.mov`, `.webm`, `.mkv`
|
||||
- Audio: `.wav`, `.mp3`, `.flac`, `.ogg`, `.m4a`, `.aac`
|
||||
- 3D Models: `.obj`, `.fbx`, `.glb`, `.gltf`, `.stl`
|
||||
- **Bulk operations**: When selecting 100+ files, a progress modal appears with real-time updates and cancellation support
|
||||
- **Connection handling**: Gracefully handles HTTP 499 (client closed request) and JSON serialization edge cases (NaN/Infinity)
|
||||
- **Progressive enhancement**: Full-text search gracefully falls back to LIKE queries if FTS5 is unavailable
|
||||
- `backend/` - Python backend (routes + features)
|
||||
- `features/index` (scan/search), `features/metadata` (extract), `features/tags` (sync), `features/collections`
|
||||
- `routes/handlers` (HTTP), `routes/core` (security/paths/response)
|
||||
- `js/` - frontend extension
|
||||
- `features/grid`, `features/panel`, `features/viewer`, `features/dnd`
|
||||
- `components` (Card/Viewer/Sidebar/menus), `app` (settings/bootstrap), `theme`
|
||||
|
||||
+10
-3
@@ -57,8 +57,8 @@ def _try_delete_windows_path(path: str) -> None:
|
||||
|
||||
# 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)
|
||||
subprocess.run(["cmd", "/c", f'del /f /q "{nt_path}"'], check=False, capture_output=True)
|
||||
subprocess.run(["cmd", "/c", f'rd /s /q "{nt_path}"'], check=False, capture_output=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -76,8 +76,15 @@ def _cleanup_windows_reserved_custom_nodes() -> Optional[str]:
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
reserved = {"CON", "PRN", "AUX", "NUL"}
|
||||
reserved.update({f"COM{i}" for i in range(1, 10)})
|
||||
reserved.update({f"LPT{i}" for i in range(1, 10)})
|
||||
|
||||
for name in entries:
|
||||
if name.strip().lower() != "nul":
|
||||
# Windows device names are reserved even with extensions and trailing dots/spaces.
|
||||
cleaned = str(name or "").strip().rstrip(". ")
|
||||
base = cleaned.split(".", 1)[0].strip().upper()
|
||||
if base not in reserved:
|
||||
continue
|
||||
try:
|
||||
victim = str((custom_nodes_dir / name).resolve())
|
||||
|
||||
@@ -236,16 +236,26 @@ class CollectionsService:
|
||||
|
||||
existing = data.get("items") if isinstance(data.get("items"), list) else []
|
||||
seen = set()
|
||||
existing_seen = set()
|
||||
for it in existing:
|
||||
if isinstance(it, dict):
|
||||
fp = it.get("filepath")
|
||||
if fp:
|
||||
seen.add(_normalize_fp(str(fp)))
|
||||
k = _normalize_fp(str(fp))
|
||||
seen.add(k)
|
||||
existing_seen.add(k)
|
||||
|
||||
added = 0
|
||||
skipped_existing = 0
|
||||
skipped_duplicate = 0
|
||||
for it in cleaned:
|
||||
key = _normalize_fp(it["filepath"])
|
||||
if key in seen:
|
||||
# Already in collection (or duplicated in the same request); report as skipped.
|
||||
if key in existing_seen:
|
||||
skipped_existing += 1
|
||||
else:
|
||||
skipped_duplicate += 1
|
||||
continue
|
||||
existing.append(it)
|
||||
seen.add(key)
|
||||
@@ -259,7 +269,15 @@ class CollectionsService:
|
||||
except Exception as exc:
|
||||
return Result.Err(ErrorCode.DB_ERROR, f"Failed to update collection: {exc}")
|
||||
|
||||
return Result.Ok({"id": str(data.get("id") or collection_id), "added": int(added), "count": len(existing)})
|
||||
return Result.Ok(
|
||||
{
|
||||
"id": str(data.get("id") or collection_id),
|
||||
"added": int(added),
|
||||
"skipped_existing": int(skipped_existing),
|
||||
"skipped_duplicate": int(skipped_duplicate),
|
||||
"count": len(existing),
|
||||
}
|
||||
)
|
||||
|
||||
def remove_filepaths(self, collection_id: str, filepaths: List[str]) -> Result[Dict[str, Any]]:
|
||||
if not isinstance(filepaths, list) or not filepaths:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
|
||||
from backend.features.collections.service import CollectionsService
|
||||
|
||||
|
||||
def test_add_assets_reports_skipped_existing_and_duplicates(monkeypatch, tmp_path):
|
||||
# Ensure the service writes into a temp collections directory.
|
||||
import backend.features.collections.service as service_mod
|
||||
|
||||
monkeypatch.setattr(service_mod, "COLLECTIONS_DIR_PATH", tmp_path, raising=True)
|
||||
|
||||
svc = CollectionsService()
|
||||
created = svc.create("Test")
|
||||
assert created.ok
|
||||
cid = created.data["id"]
|
||||
|
||||
fp = str((tmp_path / "a.png").resolve())
|
||||
|
||||
# Same filepath twice in the same request -> 1 added, 1 duplicate skipped.
|
||||
res1 = svc.add_assets(
|
||||
cid,
|
||||
[
|
||||
{"filepath": fp, "filename": "a.png", "type": "output"},
|
||||
{"filepath": fp, "filename": "a.png", "type": "output"},
|
||||
],
|
||||
)
|
||||
assert res1.ok
|
||||
assert res1.data["added"] == 1
|
||||
assert res1.data["skipped_duplicate"] == 1
|
||||
assert res1.data["skipped_existing"] == 0
|
||||
|
||||
# Adding again -> skipped_existing increments.
|
||||
res2 = svc.add_assets(cid, [{"filepath": fp, "filename": "a.png", "type": "output"}])
|
||||
assert res2.ok
|
||||
assert res2.data["added"] == 0
|
||||
assert res2.data["skipped_existing"] == 1
|
||||
|
||||
@@ -8,6 +8,7 @@ from .search import register_search_routes
|
||||
from .scan import register_scan_routes
|
||||
from .assets import register_asset_routes
|
||||
from .collections import register_collections_routes
|
||||
from .batch_zip import register_batch_zip_routes
|
||||
|
||||
__all__ = [
|
||||
"register_health_routes",
|
||||
@@ -17,4 +18,5 @@ __all__ = [
|
||||
"register_scan_routes",
|
||||
"register_asset_routes",
|
||||
"register_collections_routes",
|
||||
"register_batch_zip_routes",
|
||||
]
|
||||
|
||||
@@ -41,6 +41,41 @@ MAX_TAGS_PER_ASSET = 50
|
||||
MAX_TAG_LENGTH = 100
|
||||
|
||||
|
||||
def _delete_file_best_effort(path: Path) -> Result[bool]:
|
||||
"""
|
||||
Prefer moving to recycle bin (send2trash) when available, fallback to permanent delete.
|
||||
|
||||
Never raises: returns Result.
|
||||
"""
|
||||
try:
|
||||
if not path.exists() or not path.is_file():
|
||||
return Result.Ok(True, method="noop")
|
||||
except Exception as exc:
|
||||
return Result.Err("DELETE_FAILED", f"Failed to stat file: {exc}")
|
||||
|
||||
# Prefer recycle bin when possible.
|
||||
try:
|
||||
from send2trash import send2trash # type: ignore
|
||||
|
||||
try:
|
||||
send2trash(str(path))
|
||||
return Result.Ok(True, method="send2trash")
|
||||
except Exception as exc:
|
||||
# Fall back to unlink (still acceptable; better than aborting UX).
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
return Result.Ok(True, method="unlink_fallback", warning=str(exc))
|
||||
except Exception as exc2:
|
||||
return Result.Err("DELETE_FAILED", f"{exc2}")
|
||||
except Exception:
|
||||
# send2trash not available or failed to import.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
return Result.Ok(True, method="unlink")
|
||||
except Exception as exc:
|
||||
return Result.Err("DELETE_FAILED", f"{exc}")
|
||||
|
||||
|
||||
def register_asset_routes(routes: web.RouteTableDef) -> None:
|
||||
async def _resolve_or_create_asset_id(
|
||||
*,
|
||||
@@ -553,7 +588,9 @@ def register_asset_routes(routes: web.RouteTableDef) -> None:
|
||||
file_deletion_error = None
|
||||
if resolved.exists() and resolved.is_file():
|
||||
try:
|
||||
resolved.unlink(missing_ok=True)
|
||||
del_res = _delete_file_best_effort(resolved)
|
||||
if not del_res.ok:
|
||||
file_deletion_error = str(del_res.error or "delete failed")
|
||||
except Exception as exc:
|
||||
file_deletion_error = str(exc)
|
||||
|
||||
@@ -784,7 +821,10 @@ def register_asset_routes(routes: web.RouteTableDef) -> None:
|
||||
resolved = asset_info["resolved"]
|
||||
if resolved and resolved.exists() and resolved.is_file():
|
||||
try:
|
||||
resolved.unlink(missing_ok=True)
|
||||
del_res = _delete_file_best_effort(resolved)
|
||||
if not del_res.ok:
|
||||
file_deletion_errors.append({"asset_id": asset_info["id"], "error": str(del_res.error or "delete failed")})
|
||||
break
|
||||
except Exception as exc:
|
||||
file_deletion_errors.append({"asset_id": asset_info["id"], "error": f"{exc}"})
|
||||
break
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Batch ZIP builder for drag-out (AssetsManager -> OS).
|
||||
|
||||
Used by the frontend to support multi-selection drag-out via "DownloadURL".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from backend.config import OUTPUT_ROOT_PATH
|
||||
from backend.custom_roots import resolve_custom_root
|
||||
from backend.shared import Result, get_logger
|
||||
from backend.routes.core.paths import _is_within_root, _safe_rel_path
|
||||
from backend.routes.core.response import _json_response
|
||||
from backend.routes.core.security import _check_rate_limit, _csrf_error
|
||||
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
except Exception:
|
||||
folder_paths = None # type: ignore
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_BATCH_DIR = OUTPUT_ROOT_PATH / "_mjr_batch_zips"
|
||||
_BATCH_LOCK = threading.Lock()
|
||||
_BATCH_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
_BATCH_TTL_SECONDS = int(os.environ.get("MAJOOR_BATCH_ZIP_TTL_SECONDS", "300")) # 5 minutes
|
||||
_BATCH_MAX = int(os.environ.get("MAJOOR_BATCH_ZIP_MAX", "50"))
|
||||
_MAX_ITEMS = int(os.environ.get("MAJOOR_MAX_BATCH_SIZE", "1000"))
|
||||
|
||||
|
||||
def _sanitize_token(token: Any) -> str:
|
||||
raw = str(token or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if len(raw) > 200:
|
||||
return ""
|
||||
allowed = []
|
||||
for ch in raw:
|
||||
if ch.isalnum() or ch in ("_", "-"):
|
||||
allowed.append(ch)
|
||||
out = "".join(allowed)
|
||||
if not out:
|
||||
return ""
|
||||
# Prevent trivially-guessable tokens (very short / enumerable).
|
||||
if len(out) < 16:
|
||||
return ""
|
||||
return out
|
||||
|
||||
|
||||
def _cleanup_batch_zips() -> None:
|
||||
now = time.time()
|
||||
stale: List[str] = []
|
||||
with _BATCH_LOCK:
|
||||
for token, entry in list(_BATCH_CACHE.items()):
|
||||
created_at = float(entry.get("created_at") or 0)
|
||||
if created_at and now - created_at > _BATCH_TTL_SECONDS:
|
||||
stale.append(token)
|
||||
for token in stale:
|
||||
entry = _BATCH_CACHE.pop(token, None)
|
||||
path = entry.get("path") if isinstance(entry, dict) else None
|
||||
if isinstance(path, Path):
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cap cache size in case of unexpected usage.
|
||||
if len(_BATCH_CACHE) > _BATCH_MAX:
|
||||
items = sorted(_BATCH_CACHE.items(), key=lambda kv: float(kv[1].get("created_at") or 0))
|
||||
to_drop = items[: max(0, len(items) - _BATCH_MAX)]
|
||||
for token, entry in to_drop:
|
||||
_BATCH_CACHE.pop(token, None)
|
||||
path = entry.get("path") if isinstance(entry, dict) else None
|
||||
if isinstance(path, Path):
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_item_path(item: Dict[str, Any]) -> Optional[Path]:
|
||||
filename_raw = (item or {}).get("filename")
|
||||
subfolder_raw = (item or {}).get("subfolder", "") or ""
|
||||
typ = str((item or {}).get("type") or "output").lower()
|
||||
|
||||
filename_rel = _safe_rel_path(str(filename_raw or ""))
|
||||
if not filename_rel or len(filename_rel.parts) != 1:
|
||||
return None
|
||||
|
||||
subfolder_rel = _safe_rel_path(str(subfolder_raw))
|
||||
if subfolder_rel is None:
|
||||
return None
|
||||
|
||||
base_dir: Optional[Path] = None
|
||||
if typ == "input":
|
||||
try:
|
||||
if folder_paths is not None:
|
||||
base_dir = Path(str(folder_paths.get_input_directory())).resolve(strict=True)
|
||||
except Exception:
|
||||
base_dir = None
|
||||
if base_dir is None:
|
||||
return None
|
||||
elif typ == "custom":
|
||||
root_id = (item or {}).get("root_id") or (item or {}).get("rootId") or (item or {}).get("custom_root_id") or ""
|
||||
root_res = resolve_custom_root(str(root_id))
|
||||
if not root_res.ok or not isinstance(root_res.data, Path):
|
||||
return None
|
||||
try:
|
||||
base_dir = root_res.data.resolve(strict=True)
|
||||
except Exception:
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
base_dir = OUTPUT_ROOT_PATH.resolve(strict=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
candidate = (base_dir / subfolder_rel / filename_rel).resolve(strict=True)
|
||||
except Exception:
|
||||
return None
|
||||
if not _is_within_root(candidate, base_dir):
|
||||
return None
|
||||
if not candidate.exists() or not candidate.is_file():
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def register_batch_zip_routes(routes: web.RouteTableDef) -> None:
|
||||
@routes.post("/mjr/am/batch-zip")
|
||||
async def create_batch_zip(request: web.Request) -> web.Response:
|
||||
csrf = _csrf_error(request)
|
||||
if csrf:
|
||||
return _json_response(Result.Err("CSRF", csrf))
|
||||
|
||||
allowed, retry_after = _check_rate_limit(request, "batch_zip_create", max_requests=30, window_seconds=60)
|
||||
if not allowed:
|
||||
return _json_response(Result.Err("RATE_LIMITED", "Rate limit exceeded. Please wait before retrying.", retry_after=retry_after))
|
||||
|
||||
# Simple payload guard (dragstart should be tiny).
|
||||
try:
|
||||
if request.content_length and int(request.content_length) > 5 * 1024 * 1024:
|
||||
return _json_response(Result.Err("PAYLOAD_TOO_LARGE", "Payload too large"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as exc:
|
||||
return _json_response(Result.Err("INVALID_JSON", f"Invalid JSON body: {exc}"))
|
||||
|
||||
token = _sanitize_token((payload or {}).get("token"))
|
||||
items = (payload or {}).get("items") or []
|
||||
if not token:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Invalid token"))
|
||||
if not isinstance(items, list) or not items:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "No items provided"))
|
||||
if len(items) > _MAX_ITEMS:
|
||||
return _json_response(Result.Err("INVALID_INPUT", f"Batch size exceeds limit ({_MAX_ITEMS})"))
|
||||
|
||||
try:
|
||||
_BATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc:
|
||||
return _json_response(Result.Err("IO_ERROR", f"Cannot create batch directory: {exc}"))
|
||||
|
||||
_cleanup_batch_zips()
|
||||
|
||||
zip_path = (_BATCH_DIR / f".mjr_batch_{token}.zip").resolve()
|
||||
try:
|
||||
zip_path.relative_to(_BATCH_DIR.resolve(strict=True))
|
||||
except Exception:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Invalid zip path"))
|
||||
|
||||
event = asyncio.Event()
|
||||
filename = f"Majoor_Batch_{len(items)}.zip"
|
||||
with _BATCH_LOCK:
|
||||
_BATCH_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
|
||||
used_names = set()
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for raw in items:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
target = _resolve_item_path(raw)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
# Flatten: never include subfolders in the ZIP. This matches drag-out UX
|
||||
# expectations (a simple bundle of files).
|
||||
arc_name_rel = _safe_rel_path(str(raw.get("filename") or ""))
|
||||
if not arc_name_rel or len(arc_name_rel.parts) != 1:
|
||||
arc_name_rel = Path(target.name)
|
||||
|
||||
arc_base = arc_name_rel.name or target.name
|
||||
arc_base = arc_base.replace("\x00", "").replace("\r", "").replace("\n", "")
|
||||
arc_base = arc_base.replace("/", "_").replace("\\", "_")
|
||||
if not arc_base:
|
||||
arc_base = target.name
|
||||
|
||||
# Avoid collisions when multiple folders contain the same filename.
|
||||
stem = Path(arc_base).stem
|
||||
suffix = Path(arc_base).suffix
|
||||
if not stem and suffix:
|
||||
stem = arc_base[: -len(suffix)] or arc_base
|
||||
candidate = arc_base[:255]
|
||||
if candidate in used_names:
|
||||
n = 2
|
||||
while True:
|
||||
attempt = f"{stem} ({n}){suffix}" if suffix else f"{stem} ({n})"
|
||||
attempt = attempt[:255]
|
||||
if attempt not in used_names:
|
||||
candidate = attempt
|
||||
break
|
||||
n += 1
|
||||
used_names.add(candidate)
|
||||
|
||||
arc = candidate
|
||||
try:
|
||||
zf.write(str(target), arc)
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
return count
|
||||
|
||||
ok = False
|
||||
error = None
|
||||
count = 0
|
||||
try:
|
||||
count = await asyncio.to_thread(_build_zip)
|
||||
ok = count > 0
|
||||
if not ok:
|
||||
error = "No valid files to archive"
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
|
||||
with _BATCH_LOCK:
|
||||
entry = _BATCH_CACHE.get(token)
|
||||
if entry:
|
||||
entry["ready"] = ok
|
||||
entry["error"] = error
|
||||
entry["count"] = count
|
||||
try:
|
||||
entry["event"].set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not ok:
|
||||
try:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if ok:
|
||||
return _json_response(Result.Ok({"token": token, "count": count, "filename": filename}))
|
||||
return _json_response(Result.Err("NO_VALID_FILES", error or "No valid files to archive", token=token, count=count, filename=filename))
|
||||
|
||||
@routes.get("/mjr/am/batch-zip/{token}")
|
||||
async def get_batch_zip(request: web.Request) -> web.StreamResponse:
|
||||
token = _sanitize_token(request.match_info.get("token"))
|
||||
if not token:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Invalid token"), status=404)
|
||||
|
||||
_cleanup_batch_zips()
|
||||
|
||||
with _BATCH_LOCK:
|
||||
entry = _BATCH_CACHE.get(token)
|
||||
|
||||
if not entry:
|
||||
return _json_response(Result.Err("NOT_FOUND", "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(Result.Err("NOT_READY", "Zip not ready"), status=404)
|
||||
except Exception:
|
||||
return _json_response(Result.Err("NOT_READY", "Zip not ready"), status=404)
|
||||
|
||||
with _BATCH_LOCK:
|
||||
entry = _BATCH_CACHE.get(token)
|
||||
if not entry or not entry.get("ready"):
|
||||
err = str((entry or {}).get("error") or "Not ready")
|
||||
return _json_response(Result.Err("NOT_READY", err), status=404)
|
||||
|
||||
path = entry.get("path")
|
||||
if not isinstance(path, Path) or not path.exists():
|
||||
return _json_response(Result.Err("NOT_FOUND", "File missing"), status=404)
|
||||
|
||||
name = entry.get("filename") or f"{token}.zip"
|
||||
safe_name = str(name).replace('"', "").replace("\r", "").replace("\n", "")[:255]
|
||||
headers = {"Content-Disposition": f'attachment; filename="{safe_name}"'}
|
||||
try:
|
||||
return web.FileResponse(path, headers=headers)
|
||||
except Exception as exc:
|
||||
logger.debug("FileResponse failed: %s", exc)
|
||||
return _json_response(Result.Err("IO_ERROR", "Failed to serve zip"), status=500)
|
||||
@@ -6,7 +6,16 @@ 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, _check_rate_limit, _normalize_path, _is_path_allowed, _is_path_allowed_custom, _is_within_root
|
||||
from ..core import (
|
||||
_json_response,
|
||||
_require_services,
|
||||
_check_rate_limit,
|
||||
_normalize_path,
|
||||
_is_path_allowed,
|
||||
_is_path_allowed_custom,
|
||||
_is_within_root,
|
||||
_safe_rel_path,
|
||||
)
|
||||
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
@@ -40,15 +49,17 @@ def register_metadata_routes(routes: web.RouteTableDef) -> None:
|
||||
|
||||
file_path = (request.query.get("path") or "").strip()
|
||||
normalized = None
|
||||
deprecated_params = []
|
||||
|
||||
if file_path:
|
||||
# Deprecated: absolute path access will be phased out in favor of scoped params.
|
||||
deprecated_params.append("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.
|
||||
# Preferred API: resolve a file from (type, filename, subfolder, root_id) without 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()
|
||||
@@ -57,10 +68,23 @@ def register_metadata_routes(routes: web.RouteTableDef) -> None:
|
||||
if not filename:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Missing 'path' or 'filename' parameter"))
|
||||
|
||||
if file_type not in ("output", "input", "custom"):
|
||||
return _json_response(Result.Err("INVALID_INPUT", f"Unknown type: {file_type}"))
|
||||
|
||||
safe_name = _safe_rel_path(filename)
|
||||
if not safe_name or len(safe_name.parts) != 1:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Invalid filename"))
|
||||
|
||||
safe_sub = _safe_rel_path(subfolder)
|
||||
if safe_sub is None:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Invalid subfolder"))
|
||||
|
||||
# Resolve base root
|
||||
if file_type == "input":
|
||||
base_root = folder_paths.get_input_directory()
|
||||
elif file_type == "custom":
|
||||
if not root_id:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Missing custom root_id"))
|
||||
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"))
|
||||
@@ -72,7 +96,7 @@ def register_metadata_routes(routes: web.RouteTableDef) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
base_dir = Path(str(base_root)).resolve(strict=False)
|
||||
candidate = (base_dir / subfolder / filename) if subfolder else (base_dir / filename)
|
||||
candidate = base_dir / (safe_sub or Path("")) / safe_name
|
||||
normalized = _normalize_path(str(candidate))
|
||||
if not normalized or not normalized.exists():
|
||||
return _json_response(Result.Err("NOT_FOUND", "File not found"))
|
||||
@@ -93,4 +117,13 @@ def register_metadata_routes(routes: web.RouteTableDef) -> None:
|
||||
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}")
|
||||
|
||||
# Surface deprecation info without breaking clients.
|
||||
if deprecated_params:
|
||||
try:
|
||||
meta = result.meta if isinstance(result.meta, dict) else {}
|
||||
meta.setdefault("deprecated_params", deprecated_params)
|
||||
result.meta = meta
|
||||
except Exception:
|
||||
pass
|
||||
return _json_response(result)
|
||||
|
||||
@@ -22,6 +22,7 @@ from .handlers import (
|
||||
register_scan_routes,
|
||||
register_asset_routes,
|
||||
register_collections_routes,
|
||||
register_batch_zip_routes,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -106,6 +107,7 @@ def register_all_routes() -> web.RouteTableDef:
|
||||
register_scan_routes(routes)
|
||||
register_asset_routes(routes)
|
||||
register_collections_routes(routes)
|
||||
register_batch_zip_routes(routes)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Routes registered:")
|
||||
@@ -123,6 +125,8 @@ def register_all_routes() -> web.RouteTableDef:
|
||||
logger.info(" POST /mjr/am/assets/delete")
|
||||
logger.info(" POST /mjr/am/assets/rename")
|
||||
logger.info(" GET /mjr/am/collections")
|
||||
logger.info(" POST /mjr/am/batch-zip")
|
||||
logger.info(" GET /mjr/am/batch-zip/{token}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
return routes
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "ComfyUI-Majoor-AssetsManager",
|
||||
"version": "0.1.0",
|
||||
"author": "Majoor",
|
||||
"version": "2.0.0",
|
||||
"author": "MajoorWaldi",
|
||||
"description": "Advanced asset browser for ComfyUI with ratings, tags, and metadata management",
|
||||
"license": "MIT",
|
||||
"repository": "",
|
||||
"repository": "https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager",
|
||||
"icon": "js/assets/icon.svg",
|
||||
"install_type": "git-clone",
|
||||
"js_path": "js",
|
||||
"nodename_pattern": "^Majoor"
|
||||
|
||||
@@ -36,6 +36,9 @@ export const ENDPOINTS = {
|
||||
// File download
|
||||
DOWNLOAD: "/mjr/am/download",
|
||||
|
||||
// Drag-out (OS) helpers
|
||||
BATCH_ZIP_CREATE: "/mjr/am/batch-zip",
|
||||
|
||||
// Asset operations
|
||||
ASSET_DELETE: "/mjr/am/asset/delete",
|
||||
ASSET_RENAME: "/mjr/am/asset/rename",
|
||||
@@ -111,6 +114,10 @@ export function buildCustomViewURL(filename, subfolder = "", rootId = "") {
|
||||
return url;
|
||||
}
|
||||
|
||||
export function buildBatchZipDownloadURL(token) {
|
||||
return `${ENDPOINTS.BATCH_ZIP_CREATE}/${encodeURIComponent(String(token || ""))}`;
|
||||
}
|
||||
|
||||
export function buildAssetViewURL(asset) {
|
||||
const type = (asset?.type || "output").toLowerCase();
|
||||
if (type === "custom") {
|
||||
|
||||
@@ -13,6 +13,9 @@ const DEFAULT_SETTINGS = {
|
||||
grid: {
|
||||
pageSize: APP_CONFIG.DEFAULT_PAGE_SIZE
|
||||
},
|
||||
siblings: {
|
||||
hidePngSiblings: true
|
||||
},
|
||||
autoScan: {
|
||||
enabled: APP_CONFIG.AUTO_SCAN_ENABLED,
|
||||
onStartup: APP_CONFIG.AUTO_SCAN_ON_STARTUP
|
||||
@@ -198,6 +201,20 @@ export const registerMajoorSettings = (app, onApplied) => {
|
||||
}
|
||||
});
|
||||
|
||||
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: !!settings.siblings?.hidePngSiblings,
|
||||
onChange: (val) => {
|
||||
settings.siblings = settings.siblings || {};
|
||||
settings.siblings.hidePngSiblings = !!val;
|
||||
saveMajoorSettings(settings);
|
||||
notifyApplied("siblings.hidePngSiblings");
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: `${SETTINGS_PREFIX}.Probe.Backend`,
|
||||
name: "Majoor: Media probe backend",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#6a5cff"/>
|
||||
<stop offset="1" stop-color="#00c2ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="10" y="10" width="108" height="108" rx="22" fill="url(#g)"/>
|
||||
<path d="M40 88V40h12l12 20 12-20h12v48h-12V62l-12 20-12-20v26H40z" fill="#ffffff" opacity="0.95"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 459 B |
+13
-2
@@ -5,11 +5,14 @@
|
||||
/**
|
||||
* Create file type badge (overlaid on thumbnail)
|
||||
*/
|
||||
export function createFileBadge(filename, kind) {
|
||||
export function createFileBadge(filename, kind, nameCollision = false) {
|
||||
const badge = document.createElement("div");
|
||||
badge.className = "mjr-file-badge";
|
||||
|
||||
const ext = String(filename || "").split(".").pop()?.toUpperCase?.() || "";
|
||||
try {
|
||||
badge.dataset.mjrExt = ext;
|
||||
} catch {}
|
||||
|
||||
let bgColor = "rgba(0,0,0,0.7)";
|
||||
switch (ext) {
|
||||
@@ -85,7 +88,7 @@ export function createFileBadge(filename, kind) {
|
||||
}
|
||||
}
|
||||
|
||||
badge.textContent = ext;
|
||||
badge.textContent = ext + (nameCollision ? "+" : "");
|
||||
badge.style.cssText = `
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -106,6 +109,14 @@ export function createFileBadge(filename, kind) {
|
||||
return badge;
|
||||
}
|
||||
|
||||
export function setFileBadgeCollision(badgeEl, nameCollision) {
|
||||
if (!badgeEl) return;
|
||||
try {
|
||||
const ext = badgeEl.dataset?.mjrExt || "";
|
||||
badgeEl.textContent = String(ext || "") + (nameCollision ? "+" : "");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow status dot (inline with filename)
|
||||
* States: complete (green), partial (orange), none (red), unparsed (grey)
|
||||
|
||||
+11
-1
@@ -284,13 +284,23 @@ export function createAssetCard(asset) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Helpful datasets for cross-card behaviors (selection, siblings hide, name collisions).
|
||||
try {
|
||||
const filename = String(asset?.filename || "");
|
||||
const ext = (filename.split(".").pop() || "").toUpperCase();
|
||||
const stem = filename.includes(".") ? filename.slice(0, filename.lastIndexOf(".")) : filename;
|
||||
card.dataset.mjrFilenameKey = filename.trim().toLowerCase();
|
||||
card.dataset.mjrExt = ext;
|
||||
card.dataset.mjrStem = String(stem || "").trim().toLowerCase();
|
||||
} catch {}
|
||||
|
||||
const viewUrl = buildAssetViewURL(asset);
|
||||
|
||||
// Thumbnail
|
||||
const thumb = createThumbnail(asset, viewUrl);
|
||||
|
||||
// Add file type badge (top left)
|
||||
const fileBadge = createFileBadge(asset.filename, asset.kind);
|
||||
const fileBadge = createFileBadge(asset.filename, asset.kind, !!asset?._mjrNameCollision);
|
||||
thumb.appendChild(fileBadge);
|
||||
|
||||
// Add rating badge (top right)
|
||||
|
||||
@@ -662,7 +662,7 @@ export function createViewer() {
|
||||
name.style.cssText =
|
||||
"max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; opacity:0.95;";
|
||||
|
||||
const extBadge = createFileBadge(a.filename, a.kind);
|
||||
const extBadge = createFileBadge(a.filename, a.kind, !!a?._mjrNameCollision);
|
||||
try {
|
||||
extBadge.style.position = "static";
|
||||
extBadge.style.top = "";
|
||||
|
||||
@@ -42,6 +42,26 @@ function simplifyAsset(a) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatAddResultMessage({ collectionName, selectedCount, addRes }) {
|
||||
const added = Number(addRes?.data?.added ?? 0) || 0;
|
||||
const skippedExisting = Number(addRes?.data?.skipped_existing ?? 0) || 0;
|
||||
const skippedDuplicate = Number(addRes?.data?.skipped_duplicate ?? 0) || 0;
|
||||
|
||||
const name = String(collectionName || "").trim() || "collection";
|
||||
let msg = `Added ${added} item(s) to "${name}".`;
|
||||
|
||||
if (skippedExisting > 0) {
|
||||
msg += `\n\nSkipped ${skippedExisting} item(s): already present in the collection.`;
|
||||
}
|
||||
if (skippedDuplicate > 0) {
|
||||
msg += `\n\nIgnored ${skippedDuplicate} duplicate(s) in selection.`;
|
||||
}
|
||||
if (added === 0 && skippedExisting > 0 && selectedCount > 0) {
|
||||
msg = `Nothing added to "${name}".\n\nAll selected item(s) were already present in the collection.`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export async function showAddToCollectionMenu({ x, y, assets }) {
|
||||
const selected = Array.isArray(assets) ? assets.map(simplifyAsset).filter(Boolean) : [];
|
||||
if (!selected.length) {
|
||||
@@ -67,7 +87,7 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
|
||||
await comfyAlert(addRes?.error || "Failed to add assets to collection.");
|
||||
return;
|
||||
}
|
||||
await comfyAlert(`Added ${selected.length} item(s) to "${created.data?.name || name}".`);
|
||||
await comfyAlert(formatAddResultMessage({ collectionName: created.data?.name || name, selectedCount: selected.length, addRes }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,7 +109,7 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
|
||||
await comfyAlert(addRes?.error || "Failed to add assets to collection.");
|
||||
return;
|
||||
}
|
||||
await comfyAlert(`Added ${selected.length} item(s) to "${created.data?.name || name}".`);
|
||||
await comfyAlert(formatAddResultMessage({ collectionName: created.data?.name || name, selectedCount: selected.length, addRes }));
|
||||
});
|
||||
menu.appendChild(createIt);
|
||||
menu.appendChild(separator());
|
||||
@@ -106,7 +126,7 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
|
||||
await comfyAlert(addRes?.error || "Failed to add assets to collection.");
|
||||
return;
|
||||
}
|
||||
await comfyAlert(`Added ${selected.length} item(s) to "${name}".`);
|
||||
await comfyAlert(formatAddResultMessage({ collectionName: name, selectedCount: selected.length, addRes }));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { buildAssetViewURL } from "../../api/endpoints.js";
|
||||
import { comfyAlert, comfyConfirm, comfyPrompt } from "../../app/dialogs.js";
|
||||
import { openInFolder, updateAssetRating, deleteAsset, renameAsset } from "../../api/client.js";
|
||||
import { openInFolder, updateAssetRating, deleteAsset, renameAsset, removeFilepathsFromCollection } 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";
|
||||
@@ -38,6 +38,15 @@ const getSelectedAssets = (gridContainer) => {
|
||||
return assets;
|
||||
};
|
||||
|
||||
const getAssetFilepath = (asset) => {
|
||||
try {
|
||||
const fp = asset?.filepath || asset?.path || asset?.file_info?.filepath || "";
|
||||
return fp ? String(fp) : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = (gridContainer) => {
|
||||
if (!gridContainer) return;
|
||||
try {
|
||||
@@ -246,6 +255,46 @@ export function bindGridContextMenu({
|
||||
})
|
||||
);
|
||||
|
||||
// Remove from current collection (only when in collection view)
|
||||
const state = (() => {
|
||||
try {
|
||||
return getState?.() || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const collectionId = String(state?.collectionId || "").trim();
|
||||
if (collectionId) {
|
||||
const selectedAssets = getSelectedAssets(gridContainer);
|
||||
const list = selectedAssets.length ? selectedAssets : [asset];
|
||||
const filepaths = list.map(getAssetFilepath).filter(Boolean);
|
||||
const label = filepaths.length > 1 ? `Remove from collection (${filepaths.length})` : "Remove from collection";
|
||||
menu.appendChild(
|
||||
createItem(label, "pi pi-bookmark", null, async () => {
|
||||
try {
|
||||
menu.style.display = "none";
|
||||
} catch {}
|
||||
if (!filepaths.length) {
|
||||
await comfyAlert("No file path available for this asset.");
|
||||
return;
|
||||
}
|
||||
const confirmed = await comfyConfirm(`Remove ${filepaths.length} item(s) from this collection?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
const res = await removeFilepathsFromCollection(collectionId, filepaths);
|
||||
if (!res?.ok) {
|
||||
await comfyAlert(res?.error || "Failed to remove from collection.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the collection view (best effort).
|
||||
try {
|
||||
gridContainer?.dispatchEvent?.(new CustomEvent("mjr:reload-grid"));
|
||||
} catch {}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
menu.appendChild(separator());
|
||||
|
||||
// Edit Tags
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 { applyDragOutToOS } from "./out/DragOut.js";
|
||||
import {
|
||||
applyHighlight,
|
||||
clearHighlight,
|
||||
@@ -194,10 +195,7 @@ export const bindAssetDragStart = (containerEl) => {
|
||||
dt.setData(DND_MIME, JSON.stringify(payload));
|
||||
dt.setData("text/plain", String(asset.filename || ""));
|
||||
const viewUrl = buildURL(payload);
|
||||
dt.setData("text/uri-list", viewUrl);
|
||||
const mime = getDownloadMimeForFilename(asset.filename);
|
||||
dt.setData("DownloadURL", `${mime}:${asset.filename}:${viewUrl}`);
|
||||
dt.effectAllowed = "copy";
|
||||
applyDragOutToOS({ dt, asset, containerEl, card, viewUrl });
|
||||
} catch {}
|
||||
|
||||
const preview = card.querySelector("img") || card.querySelector("video");
|
||||
@@ -211,6 +209,9 @@ export const bindAssetDragStart = (containerEl) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Stable alias (entry-point contract).
|
||||
export const enableAssetDrag = bindAssetDragStart;
|
||||
|
||||
export const initDragDrop = () => {
|
||||
const existing = (() => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Drag-out support (AssetsManager -> OS / Explorer).
|
||||
*
|
||||
* Implements "DownloadURL" + "text/uri-list" so users can drop an asset
|
||||
* outside ComfyUI (desktop, Explorer, other apps).
|
||||
*
|
||||
* Also supports multi-selection: creates a temporary ZIP on the backend and
|
||||
* drags that ZIP URL instead of individual files.
|
||||
*/
|
||||
|
||||
import { post } from "../../../api/client.js";
|
||||
import { ENDPOINTS, buildBatchZipDownloadURL } from "../../../api/endpoints.js";
|
||||
import { getDownloadMimeForFilename } from "../utils/video.js";
|
||||
|
||||
const _abs = (url) => {
|
||||
try {
|
||||
return new URL(String(url || ""), window.location.href).href;
|
||||
} catch {
|
||||
return String(url || "");
|
||||
}
|
||||
};
|
||||
|
||||
const _makeToken = () => {
|
||||
try {
|
||||
const cryptoObj = globalThis?.crypto || window?.crypto;
|
||||
if (cryptoObj?.getRandomValues) {
|
||||
const bytes = new Uint8Array(18); // 144 bits
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bin += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const b64 = btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
return `mjr_${b64}`;
|
||||
}
|
||||
return `mjr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
} catch {
|
||||
return `mjr_${Date.now()}`;
|
||||
}
|
||||
};
|
||||
|
||||
const _assetToZipItem = (asset) => {
|
||||
const a = asset && typeof asset === "object" ? asset : null;
|
||||
if (!a) return null;
|
||||
const filename = a.filename || a.name;
|
||||
if (!filename) return null;
|
||||
return {
|
||||
filename,
|
||||
subfolder: a.subfolder || "",
|
||||
type: String(a.type || "output").toLowerCase(),
|
||||
root_id: a.root_id || a.rootId || a.custom_root_id || undefined
|
||||
};
|
||||
};
|
||||
|
||||
const _getSelectedAssets = (containerEl, draggedCard) => {
|
||||
const out = [];
|
||||
if (!containerEl || !draggedCard) return out;
|
||||
|
||||
// Prefer DOM selection state (fast, always available).
|
||||
let selectedCards = [];
|
||||
try {
|
||||
selectedCards = Array.from(containerEl.querySelectorAll(".mjr-asset-card.is-selected")) || [];
|
||||
} catch {
|
||||
selectedCards = [];
|
||||
}
|
||||
|
||||
// Only treat it as "multi" if the dragged card is part of the selection.
|
||||
try {
|
||||
if (!selectedCards.includes(draggedCard)) {
|
||||
return out;
|
||||
}
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const el of selectedCards) {
|
||||
const asset = el?._mjrAsset;
|
||||
if (!asset || typeof asset !== "object") continue;
|
||||
out.push(asset);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const applyDragOutToOS = ({ dt, asset, containerEl, card, viewUrl }) => {
|
||||
if (!dt || !asset) return;
|
||||
|
||||
const selected = _getSelectedAssets(containerEl, card);
|
||||
if (Array.isArray(selected) && selected.length > 1) {
|
||||
const items = selected.map(_assetToZipItem).filter(Boolean);
|
||||
if (items.length > 1) {
|
||||
const token = _makeToken();
|
||||
|
||||
// Fire-and-forget zip build. Never block dragstart.
|
||||
try {
|
||||
post(ENDPOINTS.BATCH_ZIP_CREATE, { token, items }).catch(() => {});
|
||||
} catch {}
|
||||
|
||||
const url = _abs(buildBatchZipDownloadURL(token));
|
||||
const zipName = `Majoor_Batch_${items.length}.zip`;
|
||||
try {
|
||||
dt.setData("text/uri-list", url);
|
||||
dt.setData("DownloadURL", `application/zip:${zipName}:${url}`);
|
||||
dt.effectAllowed = "copy";
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Single asset drag-out.
|
||||
const filename = asset.filename || asset.name;
|
||||
if (!filename) return;
|
||||
|
||||
const url = _abs(viewUrl);
|
||||
const mime = getDownloadMimeForFilename(filename);
|
||||
try {
|
||||
dt.setData("text/uri-list", url);
|
||||
dt.setData("DownloadURL", `${mime}:${filename}:${url}`);
|
||||
dt.effectAllowed = "copy";
|
||||
} catch {}
|
||||
};
|
||||
@@ -5,13 +5,14 @@
|
||||
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 { createRatingBadge, createTagsBadge, setFileBadgeCollision } from "../../components/Badges.js";
|
||||
import { APP_CONFIG } from "../../app/config.js";
|
||||
import { getViewerInstance } from "../../components/Viewer.js";
|
||||
// Context menus are bound by the panel (GridContextMenu) to avoid duplicate handlers.
|
||||
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";
|
||||
import { loadMajoorSettings } from "../../app/settings.js";
|
||||
|
||||
const GRID_STATE = new WeakMap();
|
||||
|
||||
@@ -257,6 +258,8 @@ function getOrCreateState(gridContainer) {
|
||||
done: false,
|
||||
seenKeys: new Set(),
|
||||
assets: [],
|
||||
filenameCounts: new Map(),
|
||||
nonImageStems: new Set(),
|
||||
sentinel: null,
|
||||
observer: null,
|
||||
scrollRoot: null,
|
||||
@@ -302,18 +305,145 @@ function getSelectedIdSet(gridContainer) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
function _getFilenameKey(filename) {
|
||||
try {
|
||||
return String(filename || "").trim().toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function _getExtUpper(filename) {
|
||||
try {
|
||||
return (String(filename || "").split(".").pop() || "").toUpperCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function _getStemLower(filename) {
|
||||
try {
|
||||
const s = String(filename || "");
|
||||
const idx = s.lastIndexOf(".");
|
||||
const stem = idx > 0 ? s.slice(0, idx) : s;
|
||||
return String(stem || "").trim().toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function _detectKind(asset, extUpper) {
|
||||
const k = String(asset?.kind || "").toLowerCase();
|
||||
if (k) return k;
|
||||
const images = new Set(["PNG", "JPG", "JPEG", "WEBP", "GIF", "BMP", "TIF", "TIFF"]);
|
||||
const videos = new Set(["MP4", "WEBM", "MOV", "AVI", "MKV"]);
|
||||
const audio = new Set(["MP3", "WAV", "OGG", "FLAC"]);
|
||||
if (images.has(extUpper)) return "image";
|
||||
if (videos.has(extUpper)) return "video";
|
||||
if (audio.has(extUpper)) return "audio";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function _isHidePngSiblingsEnabled() {
|
||||
try {
|
||||
const s = loadMajoorSettings();
|
||||
return !!s?.siblings?.hidePngSiblings;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function appendAssets(gridContainer, assets, state) {
|
||||
const hidePngSiblings = _isHidePngSiblingsEnabled();
|
||||
const selectedIds = getSelectedIdSet(gridContainer);
|
||||
const frag = document.createDocumentFragment();
|
||||
const newAssets = [];
|
||||
let added = 0;
|
||||
|
||||
const filenameCounts = state.filenameCounts || new Map();
|
||||
state.filenameCounts = filenameCounts;
|
||||
const nonImageStems = state.nonImageStems || new Set();
|
||||
state.nonImageStems = nonImageStems;
|
||||
|
||||
for (const asset of assets || []) {
|
||||
const filename = String(asset?.filename || "");
|
||||
const extUpper = _getExtUpper(filename);
|
||||
const stemLower = _getStemLower(filename);
|
||||
const kind = _detectKind(asset, extUpper);
|
||||
|
||||
// Hide PNG siblings for videos: if a non-image exists with the same stem, hide the PNG.
|
||||
if (hidePngSiblings && stemLower) {
|
||||
if (kind !== "image") {
|
||||
try {
|
||||
nonImageStems.add(stemLower);
|
||||
} catch {}
|
||||
|
||||
// If the PNG was already rendered, remove it now (best-effort).
|
||||
try {
|
||||
const stemSel = CSS.escape ? CSS.escape(stemLower) : stemLower;
|
||||
const sibCards = Array.from(
|
||||
gridContainer.querySelectorAll(`.mjr-asset-card[data-mjr-stem="${stemSel}"][data-mjr-ext="PNG"]`)
|
||||
);
|
||||
for (const c of sibCards) {
|
||||
try {
|
||||
const sibAsset = c?._mjrAsset;
|
||||
const sibKey = assetKey(sibAsset);
|
||||
if (sibKey) {
|
||||
try {
|
||||
state.assets = (state.assets || []).filter((a) => assetKey(a) !== sibKey);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
c.remove?.();
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
} else if (extUpper === "PNG" && nonImageStems.has(stemLower)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Name collisions: same filename appears multiple times (different folders/roots).
|
||||
const fnKey = _getFilenameKey(filename);
|
||||
if (fnKey) {
|
||||
const prev = Number(filenameCounts.get(fnKey) || 0) || 0;
|
||||
const next = prev + 1;
|
||||
filenameCounts.set(fnKey, next);
|
||||
if (next >= 2) {
|
||||
asset._mjrNameCollision = true;
|
||||
if (prev === 1) {
|
||||
// Update already-rendered cards to show "+" too.
|
||||
try {
|
||||
for (const a of state.assets || []) {
|
||||
if (_getFilenameKey(a?.filename) === fnKey) a._mjrNameCollision = true;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const sel = CSS.escape ? CSS.escape(fnKey) : fnKey;
|
||||
const badges = Array.from(
|
||||
gridContainer.querySelectorAll(`[data-mjr-filename-key="${sel}"] .mjr-file-badge`)
|
||||
);
|
||||
for (const b of badges) {
|
||||
setFileBadgeCollision(b, true);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (asset?._mjrNameCollision) {
|
||||
const badge = card.querySelector?.(".mjr-file-badge");
|
||||
setFileBadgeCollision(badge, true);
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const id = asset?.id != null ? String(asset.id) : "";
|
||||
if (id && selectedIds.has(id)) {
|
||||
@@ -494,7 +624,7 @@ async function loadNextPage(gridContainer, state) {
|
||||
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))) {
|
||||
if ((page.count || 0) === 0 || (hasTotal && state.offset >= Number(state.total))) {
|
||||
state.done = true;
|
||||
stopObserver(state);
|
||||
}
|
||||
@@ -583,6 +713,8 @@ export async function loadAssets(gridContainer, query = "*", options = {}) {
|
||||
state.loading = false;
|
||||
state.seenKeys = new Set();
|
||||
state.assets = [];
|
||||
state.filenameCounts = new Map();
|
||||
state.nonImageStems = new Set();
|
||||
gridContainer.innerHTML = "";
|
||||
showLoadingOverlay(gridContainer, state.query === "*" ? "Loading assets..." : `Searching for "${state.query}"...`);
|
||||
}
|
||||
@@ -638,6 +770,8 @@ export async function loadAssetsFromList(gridContainer, assets, options = {}) {
|
||||
state.query = String(title || "Collection");
|
||||
state.seenKeys = new Set();
|
||||
state.assets = [];
|
||||
state.filenameCounts = new Map();
|
||||
state.nonImageStems = new Set();
|
||||
gridContainer.innerHTML = "";
|
||||
showLoadingOverlay(gridContainer, list.length ? `Loading ${title}...` : `${title} is empty`);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 { createSummaryBarView } from "./views/summaryBarView.js";
|
||||
|
||||
import { normalizeQuery } from "./controllers/query.js";
|
||||
import { createGridController } from "./controllers/gridController.js";
|
||||
@@ -72,6 +73,8 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
collectionsPopover
|
||||
});
|
||||
|
||||
const { bar: summaryBar, update: updateSummaryBar } = createSummaryBarView();
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.classList.add("mjr-am-content");
|
||||
let gridContainer = null;
|
||||
@@ -126,6 +129,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
|
||||
content.appendChild(statusSection);
|
||||
content.appendChild(searchSection);
|
||||
content.appendChild(summaryBar);
|
||||
content.appendChild(browseSection);
|
||||
container.appendChild(header);
|
||||
container.appendChild(content);
|
||||
@@ -141,6 +145,47 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
state
|
||||
});
|
||||
|
||||
// Summary bar updates: selection changes, load changes, scope/collection changes.
|
||||
try {
|
||||
updateSummaryBar({ state, gridContainer });
|
||||
const onStats = () => updateSummaryBar({ state, gridContainer });
|
||||
gridContainer.addEventListener("mjr:grid-stats", onStats);
|
||||
document.addEventListener?.("mjr-settings-changed", onStats);
|
||||
|
||||
// Observe DOM changes for card count / selection class changes.
|
||||
let raf = null;
|
||||
const schedule = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = null;
|
||||
updateSummaryBar({ state, gridContainer });
|
||||
});
|
||||
};
|
||||
const mo = new MutationObserver(() => schedule());
|
||||
mo.observe(gridContainer, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
||||
gridContainer._mjrSummaryBarObserver = mo;
|
||||
gridContainer._mjrSummaryBarDispose = () => {
|
||||
try {
|
||||
gridContainer.removeEventListener("mjr:grid-stats", onStats);
|
||||
} catch {}
|
||||
try {
|
||||
document.removeEventListener?.("mjr-settings-changed", onStats);
|
||||
} catch {}
|
||||
try {
|
||||
mo.disconnect();
|
||||
} catch {}
|
||||
};
|
||||
} catch {}
|
||||
|
||||
// Allow context menus to request a refresh (e.g. after "Remove from collection").
|
||||
try {
|
||||
gridContainer.addEventListener("mjr:reload-grid", () => {
|
||||
try {
|
||||
gridController.reloadGrid();
|
||||
} catch {}
|
||||
});
|
||||
} catch {}
|
||||
|
||||
const customRootsController = createCustomRootsController({
|
||||
state,
|
||||
customSelect,
|
||||
|
||||
@@ -31,6 +31,11 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
|
||||
p.className = "mjr-muted";
|
||||
p.textContent = "Add a custom folder to browse.";
|
||||
gridContainer.appendChild(p);
|
||||
try {
|
||||
state.lastGridCount = 0;
|
||||
state.lastGridTotal = 0;
|
||||
gridContainer.dispatchEvent?.(new CustomEvent("mjr:grid-stats", { detail: { count: 0, total: 0 } }));
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,7 +43,12 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
|
||||
const res = await getCollectionAssets?.(state.collectionId);
|
||||
if (res?.ok && res.data && Array.isArray(res.data.assets)) {
|
||||
const title = state.collectionName ? `Collection: ${state.collectionName}` : "Collection";
|
||||
await loadAssetsFromList(gridContainer, res.data.assets, { title, reset: true });
|
||||
const result = await loadAssetsFromList(gridContainer, res.data.assets, { title, reset: true });
|
||||
try {
|
||||
state.lastGridCount = Number(result?.count || 0) || 0;
|
||||
state.lastGridTotal = Number(result?.total || 0) || 0;
|
||||
gridContainer.dispatchEvent?.(new CustomEvent("mjr:grid-stats", { detail: result || {} }));
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
// If collection fetch fails, fall back to normal loading and clear the broken state.
|
||||
@@ -46,7 +56,12 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
|
||||
state.collectionName = "";
|
||||
}
|
||||
|
||||
await loadAssets(gridContainer, getQuery());
|
||||
const result = await loadAssets(gridContainer, getQuery());
|
||||
try {
|
||||
state.lastGridCount = Number(result?.count || 0) || 0;
|
||||
state.lastGridTotal = Number(result?.total || 0) || 0;
|
||||
gridContainer.dispatchEvent?.(new CustomEvent("mjr:grid-stats", { detail: result || {} }));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { reloadGrid };
|
||||
|
||||
@@ -10,6 +10,9 @@ export function createPanelState() {
|
||||
workflowOnly: false,
|
||||
minRating: 0,
|
||||
sort: "mtime_desc",
|
||||
// Last grid load stats (best-effort; used by UI summary bar).
|
||||
lastGridCount: 0,
|
||||
lastGridTotal: 0,
|
||||
// Selection state (source of truth) for durable selection across grid re-renders.
|
||||
// Stored as strings because DOM dataset values are strings.
|
||||
activeAssetId: "",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const _safeText = (v) => {
|
||||
try {
|
||||
return String(v ?? "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const _titleScope = (scope) => {
|
||||
const s = String(scope || "").toLowerCase();
|
||||
if (s === "input" || s === "inputs") return "Inputs";
|
||||
if (s === "custom") return "Custom";
|
||||
return "Outputs";
|
||||
};
|
||||
|
||||
export function createSummaryBarView() {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "mjr-am-summary";
|
||||
bar.setAttribute("aria-live", "polite");
|
||||
|
||||
const left = document.createElement("div");
|
||||
left.className = "mjr-am-summary-left";
|
||||
|
||||
const right = document.createElement("div");
|
||||
right.className = "mjr-am-summary-right";
|
||||
|
||||
const text = document.createElement("div");
|
||||
text.className = "mjr-am-summary-text";
|
||||
|
||||
left.appendChild(text);
|
||||
bar.appendChild(left);
|
||||
bar.appendChild(right);
|
||||
|
||||
const update = ({ state, gridContainer } = {}) => {
|
||||
const cardsCount = (() => {
|
||||
try {
|
||||
return Number(gridContainer?.querySelectorAll?.(".mjr-asset-card")?.length || 0) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
|
||||
const selectedCount = (() => {
|
||||
try {
|
||||
const fromState = Array.isArray(state?.selectedAssetIds) ? state.selectedAssetIds.length : 0;
|
||||
if (fromState > 0) return fromState;
|
||||
} catch {}
|
||||
try {
|
||||
return Number(gridContainer?.querySelectorAll?.(".mjr-asset-card.is-selected")?.length || 0) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
|
||||
const total = (() => {
|
||||
const v = Number(state?.lastGridTotal ?? 0) || 0;
|
||||
return v > 0 ? v : 0;
|
||||
})();
|
||||
|
||||
const shown = (() => {
|
||||
const v = Number(state?.lastGridCount ?? 0) || 0;
|
||||
if (v > 0) return v;
|
||||
return cardsCount;
|
||||
})();
|
||||
|
||||
const scope = _titleScope(state?.scope || gridContainer?.dataset?.mjrScope || "output");
|
||||
const subfolder = _safeText(gridContainer?.dataset?.mjrSubfolder || "").trim();
|
||||
const folderPart = subfolder ? `folder: ${subfolder}` : "";
|
||||
|
||||
const collectionPart = (() => {
|
||||
const cid = _safeText(state?.collectionId || "").trim();
|
||||
if (!cid) return "";
|
||||
const name = _safeText(state?.collectionName || cid).trim();
|
||||
return `collection: ${name}`;
|
||||
})();
|
||||
|
||||
const customPart = (() => {
|
||||
const scopeRaw = String(state?.scope || gridContainer?.dataset?.mjrScope || "").toLowerCase();
|
||||
if (scopeRaw !== "custom") return "";
|
||||
const rid = _safeText(state?.customRootId || gridContainer?.dataset?.mjrCustomRootId || "").trim();
|
||||
return rid ? `custom: ${rid}` : "custom: (none)";
|
||||
})();
|
||||
|
||||
const countPart = total && total >= shown ? `${shown}/${total}` : `${shown}`;
|
||||
const parts = [`assets: ${countPart}`];
|
||||
if (selectedCount > 0) parts.push(`selected: ${selectedCount}`);
|
||||
parts.push(scope);
|
||||
if (collectionPart) parts.push(collectionPart);
|
||||
else if (customPart) parts.push(customPart);
|
||||
else if (folderPart) parts.push(folderPart);
|
||||
|
||||
try {
|
||||
text.textContent = parts.filter(Boolean).join(" · ");
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { bar, update };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { buildAssetViewURL } from "../../api/endpoints.js";
|
||||
import { comfyAlert, comfyConfirm } from "../../app/dialogs.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";
|
||||
import { getOrCreateMenu, createMenuItem, createMenuSeparator, showMenuAt } from "../../components/contextmenu/MenuCore.js";
|
||||
import { hideMenu, clearMenu } from "../../components/contextmenu/MenuCore.js";
|
||||
import { showAddToCollectionMenu } from "../collections/contextmenu/addToCollectionMenu.js";
|
||||
|
||||
const MENU_SELECTOR = ".mjr-viewer-context-menu";
|
||||
const POPOVER_SELECTOR = ".mjr-viewer-popover";
|
||||
@@ -186,6 +187,21 @@ export function bindViewerContextMenu({
|
||||
}, { disabled: !asset?.id })
|
||||
);
|
||||
|
||||
menu.appendChild(
|
||||
createItem("Add to collection...", "pi pi-bookmark", null, async () => {
|
||||
try {
|
||||
hideMenu(menu);
|
||||
} catch {}
|
||||
try {
|
||||
await showAddToCollectionMenu({ x: e.clientX, y: e.clientY, assets: [asset] });
|
||||
} catch (err) {
|
||||
try {
|
||||
console.error("Add to collection failed:", err);
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
menu.appendChild(separator());
|
||||
|
||||
menu.appendChild(createItem("Edit Tags…", "pi pi-tags", null, () => {
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
}
|
||||
|
||||
.mjr-assets-manager .mjr-am-search {
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
@@ -283,9 +283,27 @@
|
||||
}
|
||||
|
||||
.mjr-assets-manager .mjr-am-search {
|
||||
padding-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--mjr-border);
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.mjr-assets-manager .mjr-am-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 0 0 8px 0;
|
||||
padding: 0;
|
||||
min-height: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--mjr-muted, rgba(255, 255, 255, 0.65));
|
||||
}
|
||||
|
||||
.mjr-assets-manager .mjr-am-summary-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mjr-assets-manager .mjr-am-search-pill {
|
||||
@@ -428,7 +446,7 @@
|
||||
/* 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;
|
||||
margin-top: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "majoor-assetsmanager"
|
||||
description = ""
|
||||
version = "1.0.1"
|
||||
description = "Advanced asset browser for ComfyUI outputs (images/videos) with ratings & tags, full-text search, workflow fingerprinting, and advanced metadata management."
|
||||
version = "2.0.0"
|
||||
license = {file = "LICENSE"}
|
||||
# classifiers = [
|
||||
# # For OS-independent nodes (works on all operating systems)
|
||||
@@ -31,7 +31,7 @@ Documentation = "https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/wik
|
||||
[tool.comfy]
|
||||
PublisherId = "majoor-waldi"
|
||||
DisplayName = "ComfyUI-Majoor-AssetsManager"
|
||||
Icon = ""
|
||||
Icon = "js/assets/icon.svg"
|
||||
includes = []
|
||||
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
|
||||
|
||||
|
||||
Reference in New Issue
Block a user