feat(docs): comprehensive AI features guide and new documentation

- Added new AI_FEATURES.md guide covering all AI-powered capabilities
- Updated DOCUMENTATION_INDEX.md to include new AI features guide
- Added AI Features section to README.md with overview and models used
- Improved backfill warning information in README.md
- Updated reset index / delete DB procedures in DB_MAINTENANCE.md
- Added configuration options for AI features in README.md
This commit is contained in:
MajoorWaldi
2026-03-06 12:43:46 +01:00
parent 4382adf934
commit 834ef1fef3
66 changed files with 6924 additions and 359 deletions
+148
View File
@@ -33,6 +33,7 @@ Useful links:
- 📝 Changelog: [`CHANGELOG.md`](CHANGELOG.md)
- 📚 Documentation index: [`docs/DOCUMENTATION_INDEX.md`](docs/DOCUMENTATION_INDEX.md)
- 🔧 API reference: [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md)
- 🤖 **AI Features Guide**: [`docs/AI_FEATURES.md`](docs/AI_FEATURES.md) — NEW!
---
@@ -45,6 +46,9 @@ Useful links:
- [Majoor Floating Viewer (MFV)](#majoor-floating-viewer-mfv)
- [Hotkeys & Shortcuts](#hotkeys--shortcuts)
- [Advanced Features](#advanced-features)
- [AI Features (How to Use)](#ai-features-how-to-use)
- [Debug: Reset Index / Delete DB](#debug-reset-index--delete-db)
- [Backfill Warning (Important)](#backfill-warning-important)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [Development & Testing](#development--testing)
@@ -83,6 +87,17 @@ Useful links:
## What's New in v2.4.0
### 🤖 AI Features — Comprehensive Guide — NEW!
Complete AI-powered asset discovery and organization:
- **Semantic Search**: Natural language queries ("sunset over mountains")
- **Find Similar**: Visual similarity matching
- **AI Auto-Tags**: Automatic tagging (portrait, landscape, cyberpunk, etc.)
- **Enhanced Captions**: Florence-2 generated descriptions
- **Prompt Alignment**: Score image-prompt matching quality
- **Smart Collections**: Auto-group by visual themes
- **Models**: SigLIP2 (image/text), X-CLIP (video), Florence-2 (caption)
- See full guide: [`docs/AI_FEATURES.md`](docs/AI_FEATURES.md)
### 🎉 Majoor Floating Viewer (MFV) — NEW! 🎯
A lightweight floating viewer panel for real-time generation comparison:
- **Live Stream Mode**: Automatically follows new generations from Save/Load nodes
@@ -322,6 +337,139 @@ See [`docs/DB_MAINTENANCE.md`](docs/DB_MAINTENANCE.md) for database recovery pro
---
## AI Features (How to Use)
📚 **Complete Guide**: See [`docs/AI_FEATURES.md`](docs/AI_FEATURES.md) for comprehensive documentation.
### What AI features are available
- **🔮 AI Semantic Search**: Natural-language search across your library (e.g., "sunset over mountains")
- **🔍 Find Similar**: Pick one asset and retrieve visually similar assets
- **🏷️ AI Auto-Tags**: Automatic tag suggestions (portrait, landscape, cyberpunk, anime, etc.)
- **📝 Enhanced Captions**: Florence-2 generated detailed image descriptions
- **📊 Prompt Alignment Score**: Measure how well an image matches its generation prompt
- **📁 Smart Collections**: Auto-create themed collections from AI suggestions
- **🔬 Discover Groups**: Cluster your library by visual similarity
### How to use AI features
1. **Enable AI**: Open **Settings** → Keep **Enable AI semantic search** enabled (default: ON)
2. **Build Vectors**: After indexing, click **Backfill vectors** in **Index Status**
3. **Use Features**:
- **Search**: Click sparkles icon (🔮) → Type natural language queries
- **Find Similar**: Select asset → Right-click → Find Similar
- **Collections**: Open Collections → Smart Suggestions / Discover Groups
- **Caption**: Open asset → Sidebar → Image Description → Generate
- **Alignment**: Open asset → Sidebar → Prompt Alignment score
### Per-Asset Quick Actions
You can trigger scan and backfill vectors for **individual assets** directly from the grid:
| Action | How To |
|--------|--------|
| **Index Asset** | Click status dot on card → "Index Asset" |
| **Generate Vector** | Click status dot → "Generate Vector" |
| **Generate Caption** | Hover card → Click sparkles (🔮) → Generate Caption |
| **Compute Alignment** | Hover card → Click sparkles → Compute Alignment |
| **Suggest Tags** | Hover card → Click sparkles → Suggest Tags |
| **Find Similar** | Hover card → Click sparkles → Find Similar |
#### Visual Status Indicators
| Indicator | Meaning |
|-----------|---------|
| 🟢 Green dot | Asset fully indexed with vectors |
| 🟡 Yellow dot | Asset indexed, vectors pending |
| 🔴 Red dot | Indexing failed or vectors unavailable |
| 🔮 Sparkles visible | AI features available |
| ⏳ Spinning icon | Processing in progress |
### AI Models
| Model | Purpose | Size |
|-------|---------|------|
| **SigLIP2 SO400M** | Image & text embeddings | ~1.2 GB |
| **X-CLIP Base** | Video embeddings | ~600 MB |
| **Florence-2 Base** | Image captioning | ~800 MB |
Models are downloaded automatically on first use and cached locally.
### Quick Tips
- ✅ Non-AI features work independently if AI is disabled
- ✅ AI quality depends on metadata quality and vector coverage
- ✅ First backfill can take time (5-30 min for 1000 assets)
- ✅ GPU acceleration recommended for large libraries
### Configuration
```bash
# Enable/disable AI features (default: ON)
export MJR_AM_ENABLE_VECTOR_SEARCH=1
# Adjust auto-tag sensitivity (0.0-1.0, default: 0.06)
export MJR_AM_VECTOR_AUTOTAG_THRESHOLD=0.06
# Verbose AI logging for debugging
export MJR_AM_AI_VERBOSE_LOGS=1
```
### Performance
| Library Size | Backfill Time (CPU) | Backfill Time (GPU) |
|--------------|---------------------|---------------------|
| 100 assets | 2-5 min | 30-60 sec |
| 500 assets | 10-25 min | 2-5 min |
| 1000 assets | 20-50 min | 5-10 min |
| 5000 assets | 2-4 hours | 25-50 min |
*GPU: NVIDIA RTX 3060 or equivalent*
---
## Debug: Reset Index / Delete DB
Use this when indexing behavior is inconsistent, after major updates, or when the DB is corrupted.
### Reset Index
- Rebuilds index data and rescans files.
- Best first step when DB is still readable.
### Delete DB
- Emergency recovery mode: force-delete DB files and rebuild from scratch.
- Use this when DB is corrupted or reset fails.
### Dialog behavior
When you click **Reset Index** or **Delete DB**, the UI first asks whether existing AI vectors should be kept, then shows a simple confirmation dialog.
- If you keep vectors, the reset/rebuild preserves the existing AI vector data.
- If you do not keep vectors, the reset is full and vectors will be recalculated later if needed.
---
## Backfill Warning (Important)
Backfilling vectors can take a long time on large libraries, especially over 1000 assets.
### How backfill works
- It scans indexed assets and computes missing AI embeddings (vectors).
- It runs as a background process, in batches.
- You can continue using **Assets Manager** and **ComfyUI** while it runs.
### Why it can be slow
- Number of assets and media type mix (images/videos)
- CPU/GPU availability and model load time
- Disk speed and overall system load
- Available system RAM (more RAM helps keep processing smoother)
### What you get after backfill
- Better semantic search coverage
- Better **Find Similar** results
- Better clustering/smart collection quality
- Better prompt-alignment and AI-assisted metadata workflows
---
## Configuration
### Browser Settings (localStorage)
+1081
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -42,9 +42,7 @@ The **Delete DB** button calls `POST /mjr/am/db/force-delete`. It is designed to
### Confirmation dialog
Before proceeding, a native browser `confirm()` dialog warns:
> This will permanently delete the index database and rebuild it from scratch. All ratings, tags, and cached metadata will be lost.
Before proceeding, the UI first asks whether existing AI vectors should be kept, then shows a simple confirmation dialog in the ComfyUI Manager style for the selected action.
### What is lost
+17 -3
View File
@@ -2,8 +2,8 @@
Welcome to the comprehensive documentation for the **Majoor Assets Manager** for ComfyUI. This collection of guides covers all features, from basic usage to advanced configuration and development.
**Current Version**: 2.3.3
**Last Updated**: February 28, 2026
**Current Version**: 2.4.0
**Last Updated**: March 5, 2026
---
@@ -15,7 +15,8 @@ Welcome to the comprehensive documentation for the **Majoor Assets Manager** for
3. **[Hotkeys & Shortcuts](HOTKEYS_SHORTCUTS.md)** — Essential keyboard shortcuts
4. **[Basic Search](SEARCH_FILTERING.md)** — Find your assets quickly
### Returning Users (v2.3.3)
### Returning Users (v2.4.0)
- **[AI Features Guide](AI_FEATURES.md)** — NEW comprehensive AI capabilities
- **[Majoor Floating Viewer](VIEWER_FEATURE_TUTORIAL.md#majoor-floating-viewer-mfv)** — NEW real-time preview panel
- **[Changelog](../CHANGELOG.md)** — What's new in this version
- **[Linux Support](INSTALLATION.md#linux-installation)** — Now fully supported on Linux
@@ -39,6 +40,7 @@ Welcome to the comprehensive documentation for the **Majoor Assets Manager** for
| **[VIEWER_FEATURE_TUTORIAL.md](VIEWER_FEATURE_TUTORIAL.md)** | Advanced viewer, MFV, and analysis tools |
| **[RATINGS_TAGS_COLLECTIONS.md](RATINGS_TAGS_COLLECTIONS.md)** | Organization with ratings, tags, and collections |
| **[DRAG_DROP.md](DRAG_DROP.md)** | Drag & drop to canvas and OS |
| **[AI_FEATURES.md](AI_FEATURES.md)** | 🆕 AI-powered semantic search, auto-tags, and more |
### Configuration & Settings
| Document | Description |
@@ -103,6 +105,18 @@ Welcome to the comprehensive documentation for the **Majoor Assets Manager** for
- Node compatibility and staging
- File transfer mechanisms
### AI Features — NEW! 🎉
**[AI_FEATURES.md](AI_FEATURES.md)** — Complete guide to AI-powered capabilities:
- **Semantic Search**: Natural language search across your library
- **Find Similar**: Discover visually similar assets
- **AI Auto-Tags**: Automatic tag suggestions (portrait, landscape, cyberpunk, etc.)
- **Enhanced Captions**: Florence-2 generated image descriptions
- **Prompt Alignment Score**: Measure image-prompt matching quality
- **Smart Collections**: Auto-group assets by visual similarity
- **Discover Groups**: Cluster library by visual themes
- Models: SigLIP2 (image/text), X-CLIP (video), Florence-2 (caption)
- Configuration, tuning, and troubleshooting
---
## 🔧 Configuration Guides
+11 -2
View File
@@ -882,6 +882,13 @@ export async function resetIndex(options = {}) {
clear_metadata_cache: _bool(options.clearMetadataCache ?? options.clear_metadata_cache, true),
clear_asset_metadata: _bool(options.clearAssetMetadata ?? options.clear_asset_metadata, true),
clear_assets: _bool(options.clearAssets ?? options.clear_assets, true),
preserve_vectors: _bool(
options.preserveVectors ??
options.preserve_vectors ??
options.keepVectors ??
options.keep_vectors,
false
),
rebuild_fts: _bool(options.rebuildFts ?? options.rebuild_fts, true),
incremental: _bool(options.incremental, false),
fast: _bool(options.fast, true),
@@ -1099,7 +1106,8 @@ export async function vectorSearch(query, topKOrOptions = 20) {
let url = `${ENDPOINTS.VECTOR_SEARCH}?q=${encodeURIComponent(q)}&top_k=${topK}`;
if (scope) url += `&scope=${encodeURIComponent(scope)}`;
if (customRootId) url += `&custom_root_id=${encodeURIComponent(customRootId)}`;
return get(url);
// Model cold-start (SigLIP download/load) can take 60-120s on first use
return get(url, { timeoutMs: 120_000 });
}
/**
@@ -1310,7 +1318,8 @@ export async function hybridSearch(query, { topK = 50, scope = "output", customR
if (!q) return { ok: false, error: "Empty query" };
let url = `${ENDPOINTS.HYBRID_SEARCH}?q=${encodeURIComponent(q)}&top_k=${Math.max(1, Math.min(200, topK))}&scope=${encodeURIComponent(scope)}`;
if (customRootId) url += `&custom_root_id=${encodeURIComponent(customRootId)}`;
return get(url);
// Model cold-start (SigLIP download/load) can take 60-120s on first use
return get(url, { timeoutMs: 120_000 });
}
/**
+271 -8
View File
@@ -5,6 +5,9 @@
* alert/confirm/prompt popups. Falls back to window.* when unavailable.
*/
import { getComfyApp } from "./comfyApiBridge.js";
import { t } from "./i18n.js";
const getComfyUi = () => {
try {
const app = getComfyApp();
@@ -232,7 +235,7 @@ export const comfyAlert = async (message, title = "Majoor", options = {}) => {
style: { display: "flex", justifyContent: "flex-end", gap: "10px" },
}, [
$el("button", {
textContent: "Confirm",
textContent: t("dialog.confirm", "Confirm"),
onclick: close,
style: {
padding: "10px 16px",
@@ -264,7 +267,7 @@ export const comfyConfirm = async (message, title = "Majoor") => {
if (extensionDialog) {
try {
const result = await extensionDialog.confirm({
title: String(title || "Confirm"),
title: String(title || t("dialog.confirm", "Confirm")),
message: String(message || ""),
});
return !!result;
@@ -329,7 +332,7 @@ export const comfyConfirm = async (message, title = "Majoor") => {
style: { display: "flex", justifyContent: "flex-end", gap: "10px" },
}, [
$el("button", {
textContent: "Cancel",
textContent: t("dialog.cancel", "Cancel"),
onclick: () => finish(false),
style: {
padding: "10px 16px",
@@ -342,7 +345,7 @@ export const comfyConfirm = async (message, title = "Majoor") => {
},
}),
$el("button", {
textContent: "Confirm",
textContent: t("dialog.confirm", "Confirm"),
onclick: () => finish(true),
style: {
padding: "10px 16px",
@@ -370,12 +373,273 @@ export const comfyConfirm = async (message, title = "Majoor") => {
});
};
export const comfyYesNoCancel = async (message, title = "Majoor", labels = {}) => {
const yesLabel = String(labels?.yes || t("dialog.yes", "Yes"));
const noLabel = String(labels?.no || t("dialog.no", "No"));
const cancelLabel = String(labels?.cancel || t("dialog.cancel", "Cancel"));
const Dialog = getComfyDialogCtor();
if (!Dialog) {
try {
const answer = window.prompt(
`${String(title || "Majoor")}\n\n${String(message || "")}\n\n${yesLabel}=y, ${noLabel}=n, ${cancelLabel}=c`,
"c"
);
const normalized = String(answer || "").trim().toLowerCase();
if (!normalized) return "cancel";
if (["y", "yes", "1"].includes(normalized)) return "yes";
if (["n", "no", "2"].includes(normalized)) return "no";
return "cancel";
} catch {
return "cancel";
}
}
return new Promise((resolve) => {
const dialog = new Dialog();
styleDialog(dialog);
const finish = (value) => {
try {
dialog.close();
} catch (e) { console.debug?.(e); }
resolve(value || "cancel");
};
const content = $el("div", {
style: {
display: "flex",
flexDirection: "column",
gap: "18px",
padding: "18px 20px 18px 20px",
},
onkeydown: (e) => {
if (e.key === "Escape") {
e.preventDefault();
finish("cancel");
}
},
}, [
$el("div", {
style: {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
},
}, [
$el("div", {
textContent: title,
style: {
fontWeight: "700",
fontSize: "30px",
color: "rgba(255,255,255,0.96)",
lineHeight: "1.2",
},
}),
]),
$el("div", {
textContent: String(message || ""),
style: {
fontSize: "22px",
color: "rgba(255,255,255,0.86)",
whiteSpace: "pre-wrap",
lineHeight: "1.45",
},
}),
$el("div", {
style: { display: "flex", justifyContent: "flex-end", gap: "10px", flexWrap: "wrap" },
}, [
$el("button", {
textContent: yesLabel,
onclick: () => finish("yes"),
style: {
padding: "10px 16px",
borderRadius: "10px",
border: "1px solid rgba(17,132,255,0.75)",
background: "#1184ff",
color: "rgba(255,255,255,0.98)",
fontWeight: "600",
cursor: "pointer",
},
}),
$el("button", {
textContent: noLabel,
onclick: () => finish("no"),
style: {
padding: "10px 16px",
borderRadius: "10px",
border: "1px solid rgba(244,67,54,0.70)",
background: "rgba(244,67,54,0.18)",
color: "rgba(255,220,220,0.98)",
fontWeight: "600",
cursor: "pointer",
},
}),
$el("button", {
textContent: cancelLabel,
onclick: () => finish("cancel"),
style: {
padding: "10px 16px",
borderRadius: "10px",
border: "1px solid rgba(255,255,255,0.18)",
background: "rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.85)",
fontWeight: "600",
cursor: "pointer",
},
}),
]),
]);
try {
dialog.show(content);
setTimeout(() => hideDialogNativeClose(dialog), 0);
} catch {
resolve("cancel");
}
});
};
export const comfyChoice = async (message, title = "Majoor", choices = []) => {
const safeChoices = Array.isArray(choices)
? choices
.filter((c) => c && typeof c === "object")
.map((c, idx) => ({
id: String(c.id || `choice_${idx}`),
label: String(c.label || c.id || `Choice ${idx + 1}`),
variant: String(c.variant || "").toLowerCase(),
}))
: [];
if (!safeChoices.length) return null;
const Dialog = getComfyDialogCtor();
if (!Dialog) {
try {
const numbered = safeChoices
.map((c, idx) => `${idx + 1}. ${c.label}`)
.join("\n");
const answer = window.prompt(
`${String(title || "Majoor")}\n\n${String(message || "")}\n\n${numbered}\n\n${t("dialog.choiceTypeNumber", "Type a number:")}`,
String(safeChoices.length)
);
if (answer == null) return null;
const index = Number.parseInt(String(answer).trim(), 10) - 1;
if (Number.isInteger(index) && index >= 0 && index < safeChoices.length) {
return safeChoices[index].id;
}
return null;
} catch {
return null;
}
}
return new Promise((resolve) => {
const dialog = new Dialog();
styleDialog(dialog);
const finish = (value) => {
try {
dialog.close();
} catch (e) { console.debug?.(e); }
resolve(value ?? null);
};
const buttonForChoice = (choice) => {
const isDanger = choice.variant === "danger";
const isPrimary = choice.variant === "primary";
return $el("button", {
textContent: choice.label,
onclick: () => finish(choice.id),
style: {
padding: "10px 14px",
borderRadius: "10px",
border: isDanger
? "1px solid rgba(244,67,54,0.70)"
: isPrimary
? "1px solid rgba(17,132,255,0.75)"
: "1px solid rgba(255,255,255,0.18)",
background: isDanger
? "rgba(244,67,54,0.18)"
: isPrimary
? "#1184ff"
: "rgba(255,255,255,0.06)",
color: isDanger
? "rgba(255,220,220,0.98)"
: isPrimary
? "rgba(255,255,255,0.98)"
: "rgba(255,255,255,0.85)",
fontWeight: "600",
cursor: "pointer",
},
});
};
const content = $el("div", {
style: {
display: "flex",
flexDirection: "column",
gap: "18px",
padding: "18px 20px 18px 20px",
},
onkeydown: (e) => {
if (e.key === "Escape") {
e.preventDefault();
finish(null);
}
},
}, [
$el("div", {
style: {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
},
}, [
$el("div", {
textContent: title,
style: {
fontWeight: "700",
fontSize: "30px",
color: "rgba(255,255,255,0.96)",
lineHeight: "1.2",
},
}),
]),
$el("div", {
textContent: String(message || ""),
style: {
fontSize: "22px",
color: "rgba(255,255,255,0.86)",
whiteSpace: "pre-wrap",
lineHeight: "1.45",
},
}),
$el("div", {
style: {
display: "flex",
justifyContent: "flex-end",
gap: "10px",
flexWrap: "wrap",
},
}, safeChoices.map(buttonForChoice)),
]);
try {
dialog.show(content);
setTimeout(() => hideDialogNativeClose(dialog), 0);
} catch {
resolve(null);
}
});
};
export const comfyPrompt = async (message, defaultValue = "", title = "Majoor") => {
const extensionDialog = getExtensionManagerDialog();
if (extensionDialog) {
try {
const result = await extensionDialog.prompt({
title: String(title || "Prompt"),
title: String(title || t("dialog.prompt", "Prompt")),
message: String(message || ""),
defaultValue: String(defaultValue ?? ""),
});
@@ -454,7 +718,7 @@ export const comfyPrompt = async (message, defaultValue = "", title = "Majoor")
style: { display: "flex", justifyContent: "flex-end", gap: "10px" },
}, [
$el("button", {
textContent: "Cancel",
textContent: t("dialog.cancel", "Cancel"),
onclick: () => finish(null),
style: {
padding: "8px 12px",
@@ -466,7 +730,7 @@ export const comfyPrompt = async (message, defaultValue = "", title = "Majoor")
},
}),
$el("button", {
textContent: "OK",
textContent: t("dialog.ok", "OK"),
onclick: () => finish(input.value),
style: {
padding: "8px 12px",
@@ -498,4 +762,3 @@ export const comfyPrompt = async (message, defaultValue = "", title = "Majoor")
}
});
};
import { getComfyApp } from "./comfyApiBridge.js";
+143 -1
View File
@@ -198,6 +198,7 @@ const DICTIONARY = {
"tab.input": "Input",
"tab.all": "All",
"tab.custom": "Custom",
"tab.similar": "Similar",
"manager.title": "Assets Manager",
// --- Panel: Buttons ---
@@ -226,6 +227,7 @@ const DICTIONARY = {
"btn.up": "Up",
"btn.saving": "Saving...",
"btn.restoring": "Restoring...",
"btn.markAllRead": "Mark all read",
// --- Panel: Labels ---
"label.folder": "Folder",
@@ -252,14 +254,27 @@ const DICTIONARY = {
"label.heightPx": "Height (px)",
"label.day": "Day",
"label.collections": "Collections",
"label.collection": "collection",
"label.messages": "Messages",
"label.readMe": "Read Me",
"label.userGuide": "User Guide",
"label.info": "Info",
"btn.giveStar": "Give a star",
"label.filters": "Filters",
"label.selectFolder": "Select folder?",
"label.thisFolder": "this folder",
"label.thisFile": "this file",
"label.computer": "Computer",
"search.placeholder": "Search assets...",
"search.title": "Search by filename, tags, or attributes (e.g. rating:5, ext:png)",
"search.semanticToggle": "Toggle AI semantic search (CLIP-based)",
"search.aiSearch": "AI Search",
"search.findSimilar": "Find Similar",
"search.findingSimilar": "Finding similar assets...",
"search.selectAssetForSimilar": "Select an asset first to find similar images/videos.",
"search.findSimilarFailed": "Failed to find similar assets",
"search.similarResults": "Similar to asset #{id} ({n} results)",
"search.similarReference": "Reference #{id}",
"action.copyToClipboard": "Copy to clipboard",
"action.clickToCopy": "Click to copy",
"tooltip.copyFieldValue": "Copy value",
@@ -274,6 +289,7 @@ const DICTIONARY = {
"tooltip.tab.input": "Browse input folder assets",
"tooltip.tab.output": "Browse generated outputs",
"tooltip.tab.custom": "Browse browser folders",
"tooltip.tab.similar": "Browse current similar findings",
"tooltip.browserFolders": "Browser folders",
"tooltip.pinnedFolders": "Pinned folders",
"tooltip.clearFilter": "Clear {label}",
@@ -281,9 +297,12 @@ const DICTIONARY = {
"tooltip.closeSidebar": "Close sidebar",
"tooltip.closeSidebarEsc": "Close sidebar (Esc)",
"tooltip.supportKofi": "Buy Me a White Monster Drink",
"tooltip.starGithub": "Open GitHub and give a star",
"tooltip.sidebarTab": "Assets Manager - Browse and search your outputs",
"tooltip.openMFV": "Open Floating Viewer",
"tooltip.closeMFV": "Close Floating Viewer",
"tooltip.openMessages": "Messages and updates",
"tooltip.openMessagesUnread": "Messages ({count} unread)",
"tooltip.deleteDb": "Force-delete database and rebuild from scratch",
"tooltip.workflowMultiOutput": "Multiple outputs with different prompts",
"tooltip.generationInputs": "Input files used in generation",
@@ -424,6 +443,7 @@ const DICTIONARY = {
"scope.output": "Outputs",
"scope.custom": "Custom",
"scope.customBrowser": "Browser",
"scope.similar": "Similar",
// --- Tools ---
"tool.exiftool": "ExifTool metadata",
@@ -440,8 +460,24 @@ const DICTIONARY = {
"msg.errorLoadingFolders": "Error loading folders",
"msg.noGenerationData": "No generation data found for this file.",
"msg.rawMetadata": "Raw metadata",
"msg.noMessages": "No messages for now.",
"msg.noPinnedFolders": "No pinned folders",
"msg.noTagsYet": "No tags yet...",
"msg.category.information": "Information",
"msg.whatsNew.title.pinReference": "What's New",
"msg.whatsNew.body.pinReference": "Floating Viewer: new Pin Reference feature. You can now pin A or B, then compare quickly with selected assets in the grid while keeping the fixed reference.",
"msg.whatsNew.title.vectorResetKeepVectors": "Important",
"msg.whatsNew.body.vectorResetKeepVectors": "Reset index and Delete DB now first ask whether to keep AI vectors. If you already have older indexed assets, keeping the vectors is recommended: a full reset without them can trigger a long Vector Backfill for old assets and temporarily increase RAM usage.",
"msg.whatsNew.title.localUserGuide": "Need help?",
"msg.whatsNew.body.localUserGuide": "Open the local User Guide directly from your Assets Manager custom_nodes folder.",
"msg.collectionAdd.added": "Added {added} item(s) to \"{name}\".",
"msg.collectionAdd.skippedExisting": "Skipped {count} item(s): already present in the collection.",
"msg.collectionAdd.skippedDuplicate": "Ignored {count} duplicate(s) in selection.",
"msg.collectionAdd.noneAddedExisting": "No new items added to \"{name}\" (all exist).",
"msg.aiFeaturesNotice": "Warning:\n\nAssets Manager now includes AI features like auto captions, AI search, smart collections, etc.\nThese features are still WIP.\n\nIf you already had Assets Manager installed, delete the DB first.\nWhen enrich is finished, run Vector Backfill for your existing assets.\nAfter clicking Backfill, you can go sleep: it may take a very, very long time if you have many assets.",
"msg.dbResetNoticeDetail": "Majoor Update Notice:\n\nTo avoid database errors with this new version, please delete your existing index. Click the 'Delete DB' button in the Index Status panel to reset it.",
"msg.nightlyUpdateTitle": "Majoor Assets Manager",
"msg.nightlyUpdateDetail": "A newer nightly build is available: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/releases/tag/nightly",
"msg.newVersionTitle": "Majoor Assets Manager",
"msg.newVersionDetail": "A new version is available: {latest} (Current: {current})",
"msg.dbResetNotice": "Database reset required",
@@ -530,11 +566,17 @@ const DICTIONARY = {
"ctx.resetRating": "Reset rating",
"ctx.showMetadataPanel": "Show metadata panel",
"ctx.unpinFolder": "Unpin folder",
"ctx.openFolder": "Open folder",
"ctx.pinAsBrowserRoot": "Pin as Browser Root",
// --- Dialogs ---
"dialog.confirm": "Confirm",
"dialog.cancel": "Cancel",
"dialog.yes": "Yes",
"dialog.no": "No",
"dialog.ok": "OK",
"dialog.prompt": "Prompt",
"dialog.choiceTypeNumber": "Type a number:",
"dialog.delete.title": "Delete file?",
"dialog.delete.msg": "Are you sure you want to delete this file? This cannot be undone.",
"dialog.rename.title": "Rename file",
@@ -563,6 +605,20 @@ const DICTIONARY = {
"dialog.deleteExactDuplicates": "Delete exact duplicates?",
"dialog.startDuplicateAnalysis": "Start duplicate analysis?",
"dialog.dbDelete.confirm": "This will permanently delete the index database and rebuild it from scratch. All ratings, tags, and cached metadata will be lost.\n\nContinue?",
"dialog.settingsSaveFailed": "Majoor: Failed to save settings (browser storage full or blocked).",
"dialog.confirmDeleteTitle": "Majoor: Confirm delete",
"dialog.deleteSelectedFiles": "Delete {count} selected files?",
"dialog.deleteSingleFile": "Delete \"{label}\"?",
"dialog.vectorsReset.title": "AI vectors",
"dialog.vectorsReset.choice": "Also reset AI vectors?\n\nConfirm = yes, reset everything (vectors will be recalculated)\nCancel = no, keep existing vectors",
"dialog.vectorsReset.keepQuestion": "Keep existing AI vectors?\n\nConfirm = keep vectors\nCancel = continue without vectors",
"dialog.vectorsReset.wipeConfirm": "Reset AI vectors too?\n\nConfirm = yes, reset everything\nCancel = abort",
"dialog.vectorsReset.singleQuestion": "Choose reset mode for {action}:\n\nYes = keep existing AI vectors\nNo = full reset (vectors will be recalculated)\nCancel = abort",
"dialog.vectorsReset.optionKeep": "Yes - keep vectors",
"dialog.vectorsReset.optionFull": "No - full reset",
"dialog.vectorsReset.optionCancel": "Cancel",
"dialog.resetIndex.confirmKeepVectors": "This will reset index data and rescan files while keeping existing AI vectors.\n\nContinue?",
"dialog.dbDelete.keepVectorsConfirm": "This will reset index data and keep existing AI vectors. Database files will not be force-deleted.\n\nContinue?",
// --- Toasts ---
"toast.scanStarted": "Scan started",
@@ -600,6 +656,13 @@ const DICTIONARY = {
"toast.noValidAssetsSelected": "No valid assets selected.",
"toast.failedCreateCollectionDot": "Failed to create collection.",
"toast.failedAddAssetsToCollection": "Failed to add assets to collection.",
"toast.failedCreateSmartCollection": "Failed to create smart collection",
"toast.failedAddAssetsToSmartCollection": "Failed to add assets to smart collection",
"toast.noGroupsFoundIndexFirst": "No groups found. Index more assets first.",
"toast.failedLoadClusterAssets": "Failed to load cluster assets",
"toast.collectionCreatedWithAssets": "Collection \"{name}\" created with {count} assets!",
"toast.collectionCreatedNamed": "Collection \"{name}\" created.",
"toast.clusterAnalysisFailed": "Cluster analysis failed",
"toast.removeFromCollectionFailed": "Failed to remove from collection.",
"toast.removeFromCollectionError": "Error removing from collection: {error}",
"toast.copyClipboardFailed": "Failed to copy to clipboard",
@@ -644,6 +707,14 @@ const DICTIONARY = {
"toast.unableResolveFolderPath": "Unable to resolve folder path",
"toast.pathCopyFailed": "Failed to copy path",
"toast.noFilePath": "No file path available for this asset.",
"toast.vectorBackfillStarting": "Starting vector backfill... This may take a while.",
"toast.vectorBackfillRunning": "Vector backfill still running in background{job}.",
"toast.vectorBackfillComplete": "Vector backfill complete! Processed: {processed}, Indexed: {indexed}, Skipped: {skipped}",
"toast.vectorBackfillFailedGeneric": "Backfill failed",
"toast.vectorBackfillFailedDetail": "Vector backfill failed: {error}",
"toast.rescanUpdatingAiIndex": "Rescanning file + updating AI index...",
"toast.metadataVectorUpdated": "Metadata + AI vector index updated for this asset.",
"toast.metadataUpdatedVectorFailed": "Metadata updated. AI vector index could not be updated.",
"toast.downloadingFile": "Downloading {filename}...",
"toast.playbackRate": "Playback {rate}x",
"toast.metadataRefreshed": "Metadata refreshed{suffix}",
@@ -691,6 +762,7 @@ const DICTIONARY = {
"tab.input": "Entree",
"tab.all": "Tout",
"tab.custom": "Navigateur",
"tab.similar": "Similaire",
"manager.title": "Gestionnaire d'assets",
"scope.all": "Entrees + Sorties",
@@ -699,11 +771,21 @@ const DICTIONARY = {
"scope.output": "Sorties",
"scope.custom": "Navigateur",
"scope.customBrowser": "Navigateur",
"scope.similar": "Similaire",
"search.placeholder": "Rechercher des assets...",
"search.title": "Rechercher par nom de fichier, tags ou attributs (ex. rating:5, ext:png)",
"search.semanticToggle": "Activer/desactiver la recherche semantique IA (CLIP)",
"search.aiSearch": "Recherche IA",
"search.findSimilar": "Trouver similaires",
"search.findingSimilar": "Recherche d'assets similaires...",
"search.selectAssetForSimilar": "Selectionnez d'abord un asset pour trouver des images/videos similaires.",
"search.findSimilarFailed": "Echec de la recherche similaire",
"search.similarResults": "Similaires a l'asset #{id} ({n} resultats)",
"search.similarReference": "Reference #{id}",
"tooltip.openMessages": "Messages et nouveautes",
"tooltip.openMessagesUnread": "Messages ({count} non lus)",
"tooltip.tab.similar": "Parcourir les trouvailles similaires courantes",
"setting.ai.vector.enabled.name": "Activer la recherche semantique IA",
"setting.ai.vector.enabled.desc": "Active la recherche semantique basee sur CLIP. Desactivez pour une recherche par mots-cles uniquement.",
@@ -718,6 +800,7 @@ const DICTIONARY = {
"btn.up": "Monter",
"btn.saving": "Sauvegarde...",
"btn.restoring": "Restauration...",
"btn.markAllRead": "Tout marquer lu",
"ctx.pinAsBrowserRoot": "Epingler comme racine Browser",
"ctx.createFolderHere": "Creer dossier ici...",
@@ -725,12 +808,32 @@ const DICTIONARY = {
"ctx.moveFolder": "Deplacer dossier...",
"ctx.deleteFolder": "Supprimer dossier...",
"ctx.refreshMetadata": "Rafraichir metadonnees",
"ctx.openFolder": "Ouvrir dossier",
"dialog.browserRootLabelOptional": "Label pour nouvelle racine browser (optionnel)",
"dialog.newFolderName": "Nom du nouveau dossier",
"dialog.renameFolder": "Renommer dossier",
"dialog.destinationDirectoryPath": "Chemin dossier destination",
"dialog.deleteFolderRecursive": "Supprimer le dossier \"{name}\" et tout son contenu ?",
"dialog.settingsSaveFailed": "Majoor : echec sauvegarde des parametres (stockage navigateur plein ou bloque).",
"dialog.yes": "Oui",
"dialog.no": "Non",
"dialog.ok": "OK",
"dialog.prompt": "Saisie",
"dialog.choiceTypeNumber": "Entrez un numero :",
"dialog.confirmDeleteTitle": "Majoor : confirmer suppression",
"dialog.deleteSelectedFiles": "Supprimer {count} fichiers selectionnes ?",
"dialog.deleteSingleFile": "Supprimer \"{label}\" ?",
"dialog.vectorsReset.title": "Vecteurs IA",
"dialog.vectorsReset.choice": "Reinitialiser aussi les vecteurs IA ?\n\nConfirmer = oui, tout reinitialiser (les vecteurs seront recalcules)\nAnnuler = non, conserver les vecteurs existants",
"dialog.vectorsReset.keepQuestion": "Conserver les vecteurs IA existants ?\n\nConfirmer = conserver les vecteurs\nAnnuler = continuer sans vecteurs",
"dialog.vectorsReset.wipeConfirm": "Reinitialiser aussi les vecteurs IA ?\n\nConfirmer = oui, tout reinitialiser\nAnnuler = abandonner",
"dialog.vectorsReset.singleQuestion": "Choisissez le mode de reinitialisation pour {action} :\n\nOui = conserver les vecteurs IA existants\nNon = reinitialisation complete (les vecteurs seront recalcules)\nAnnuler = abandonner",
"dialog.vectorsReset.optionKeep": "Oui - conserver vecteurs",
"dialog.vectorsReset.optionFull": "Non - reinit complete",
"dialog.vectorsReset.optionCancel": "Annuler",
"dialog.resetIndex.confirmKeepVectors": "Cette action reinitialise l'index et relance le scan en conservant les vecteurs IA existants.\n\nContinuer ?",
"dialog.dbDelete.keepVectorsConfirm": "Cette action reinitialise l'index et conserve les vecteurs IA existants. Les fichiers DB ne seront pas supprimes de force.\n\nContinuer ?",
"toast.createFolderFailed": "Echec creation dossier",
"toast.renameFolderFailed": "Echec renommage dossier",
@@ -742,13 +845,52 @@ const DICTIONARY = {
"toast.folderDeleted": "Dossier supprime",
"toast.pinFolderFailed": "Echec epinglage dossier",
"toast.folderPinnedAsBrowserRoot": "Dossier epingle comme racine browser",
"toast.failedCreateSmartCollection": "Echec creation collection intelligente",
"toast.failedAddAssetsToSmartCollection": "Echec ajout des assets a la collection intelligente",
"toast.noGroupsFoundIndexFirst": "Aucun groupe trouve. Indexez plus d'assets d'abord.",
"toast.failedLoadClusterAssets": "Echec chargement assets du cluster",
"toast.collectionCreatedWithAssets": "Collection \"{name}\" creee avec {count} assets !",
"toast.collectionCreatedNamed": "Collection \"{name}\" creee.",
"toast.clusterAnalysisFailed": "Echec analyse des clusters",
"toast.vectorBackfillStarting": "Demarrage du vector backfill... Cela peut prendre du temps.",
"toast.vectorBackfillRunning": "Le vector backfill continue en arriere-plan{job}.",
"toast.vectorBackfillComplete": "Vector backfill termine ! Traites : {processed}, Indexes : {indexed}, Ignores : {skipped}",
"toast.vectorBackfillFailedGeneric": "Echec du backfill",
"toast.vectorBackfillFailedDetail": "Echec du vector backfill : {error}",
"toast.rescanUpdatingAiIndex": "Rescan du fichier + mise a jour index IA...",
"toast.metadataVectorUpdated": "Metadonnees + index vectoriel IA mis a jour pour cet asset.",
"toast.metadataUpdatedVectorFailed": "Metadonnees mises a jour. L'index vectoriel IA n'a pas pu etre mis a jour.",
"label.computer": "Ordinateur",
"label.collection": "collection",
"label.thisFile": "ce fichier",
"label.messages": "Messages",
"label.readMe": "Read Me",
"label.userGuide": "Guide utilisateur",
"label.info": "Info",
"btn.giveStar": "Mettre une etoile",
"label.resolutionMinWxH": "Min LxH (px)",
"label.resolutionMaxWxH": "Max LxH (px)",
"msg.noMessages": "Aucun message pour le moment.",
"msg.noPinnedFolders": "Aucun dossier epingle",
"sidebar.placeholderSelectAsset": "Selectionnez un asset pour voir les details",
"msg.noTagsYet": "Aucun tag pour le moment..."
"msg.noTagsYet": "Aucun tag pour le moment...",
"msg.category.information": "Information",
"msg.whatsNew.title.pinReference": "Quoi de neuf",
"msg.whatsNew.body.pinReference": "Floating Viewer : nouvelle fonction Pin Reference. Vous pouvez maintenant epingler A ou B, puis comparer rapidement avec les assets selectionnes dans la grille tout en gardant la reference fixe.",
"msg.whatsNew.title.vectorResetKeepVectors": "Quoi de neuf",
"msg.whatsNew.body.vectorResetKeepVectors": "Reset index et Delete DB demandent d'abord s'il faut conserver les vecteurs IA. Si vous avez deja des anciens assets indexes, garder les vecteurs est recommande : un reset complet sans eux peut declencher un long Vector Backfill sur les anciens assets et augmenter temporairement la consommation RAM.",
"msg.whatsNew.title.localUserGuide": "Quoi de neuf",
"msg.whatsNew.body.localUserGuide": "Ouvrez le Guide utilisateur local directement depuis le dossier custom_nodes d'Assets Manager.",
"msg.collectionAdd.added": "{added} element(s) ajoute(s) a \"{name}\".",
"msg.collectionAdd.skippedExisting": "{count} element(s) ignores : deja presents dans la collection.",
"msg.collectionAdd.skippedDuplicate": "{count} doublon(s) ignores dans la selection.",
"msg.collectionAdd.noneAddedExisting": "Aucun nouvel element ajoute a \"{name}\" (tous deja presents).",
"msg.aiFeaturesNotice": "Avertissement :\n\nAssets Manager inclut maintenant des fonctions IA (auto captions, recherche IA, smart collections, etc.).\nCes fonctions sont encore en cours de developpement.\n\nSi Assets Manager etait deja installe, supprimez d'abord la DB.\nQuand l'enrichissement est termine, lancez Vector Backfill pour vos assets existants.\nApres avoir clique sur Backfill, cela peut prendre tres longtemps si vous avez beaucoup d'assets.",
"msg.dbResetNoticeDetail": "Note de mise a jour Majoor :\n\nPour eviter les erreurs de base de donnees avec cette version, supprimez votre index existant. Cliquez sur le bouton \"Delete DB\" dans le panneau Index Status pour le reinitialiser.",
"msg.nightlyUpdateTitle": "Majoor Assets Manager",
"msg.nightlyUpdateDetail": "Une build nightly plus recente est disponible : https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/releases/tag/nightly",
"tooltip.starGithub": "Ouvrir GitHub et mettre une etoile",
},
};
+14 -6
View File
@@ -543,7 +543,7 @@ export function registerAdvancedSettings(safeAddSetting, settings, notifyApplied
if (String(value || "") !== "Run backfill now") return;
try {
comfyToast("Starting vector backfill... This may take a while.", "info");
comfyToast(t("toast.vectorBackfillStarting", "Starting vector backfill... This may take a while."), "info");
const result = await vectorBackfill(64);
if (result?.ok) {
@@ -557,11 +557,19 @@ export function registerAdvancedSettings(safeAddSetting, settings, notifyApplied
if (pending) {
const jobId = String(data?.job_id || "").trim();
comfyToast(
`Vector backfill still running in background${jobId ? ` (job ${jobId.slice(0, 8)})` : ""}.`,
t(
"toast.vectorBackfillRunning",
"Vector backfill still running in background{job}.",
{ job: jobId ? ` (job ${jobId.slice(0, 8)})` : "" }
),
"info",
);
} else {
const msg = `Vector backfill complete! Processed: ${processed}, Indexed: ${indexed}, Skipped: ${skipped}`;
const msg = t(
"toast.vectorBackfillComplete",
"Vector backfill complete! Processed: {processed}, Indexed: {indexed}, Skipped: {skipped}",
{ processed, indexed, skipped }
);
comfyToast(msg, "success");
}
try {
@@ -573,11 +581,11 @@ export function registerAdvancedSettings(safeAddSetting, settings, notifyApplied
console.warn("[Majoor] Failed to refresh vector stats:", statsErr);
}
} else {
throw new Error(result?.error || "Backfill failed");
throw new Error(result?.error || t("toast.vectorBackfillFailedGeneric", "Backfill failed"));
}
} catch (error) {
const errMsg = error?.message || String(error || "Unknown error");
comfyToast(`Vector backfill failed: ${errMsg}`, "error");
const errMsg = error?.message || String(error || t("status.unknown", "unknown"));
comfyToast(t("toast.vectorBackfillFailedDetail", "Vector backfill failed: {error}", { error: errMsg }), "error");
console.error("[Majoor] Vector backfill error:", error);
}
},
+4 -1
View File
@@ -9,6 +9,7 @@ import { safeDispatchCustomEvent } from "../../utils/events.js";
import { SettingsStore } from "./SettingsStore.js";
import { SETTINGS_KEY, SETTINGS_SCHEMA_VERSION } from "../settingsStore.js";
import { _safeBool, _safeNum, deepMerge, resolveGridMinSize } from "./settingsUtils.js";
import { t } from "../i18n.js";
// ─── Default settings ─────────────────────────────────────────────────────
@@ -236,7 +237,9 @@ export const saveMajoorSettings = (settings) => {
if (now - last > 30_000) {
window._mjrSettingsSaveFailAt = now;
import("./dialogs.js")
.then(({ comfyAlert }) => comfyAlert("Majoor: Failed to save settings (browser storage full or blocked)."))
.then(({ comfyAlert }) => comfyAlert(
t("dialog.settingsSaveFailed", "Majoor: Failed to save settings (browser storage full or blocked).")
))
.catch(() => {});
}
} catch (e) { console.debug?.(e); }
+2 -2
View File
@@ -160,7 +160,7 @@ function showAiFeaturesNoticeOnce(localVersion, branch) {
comfyAlert(
t(
"msg.aiFeaturesNotice",
"Avertissement / Warning:\n\nAssets Manager now includes AI features like auto captions, AI search, smart collections, etc.\nThese features are still WIP.\n\nIf you already had Assets Manager installed, delete the DB first.\nWhen enrich is finished, run Vector Backfill for your existing assets.\nAfter clicking Backfill, you can go sleep: it may take a very, very long time if you have many assets."
"Warning:\n\nAssets Manager now includes AI features like auto captions, AI search, smart collections, etc.\nThese features are still WIP.\n\nIf you already had Assets Manager installed, delete the DB first.\nWhen enrich is finished, run Vector Backfill for your existing assets.\nAfter clicking Backfill, you can go sleep: it may take a very, very long time if you have many assets."
),
"Majoor",
{ native: true }
@@ -313,7 +313,7 @@ export async function checkMajoorVersion({ force = false } = {}) {
setTimeout(() => {
comfyAlert(
t(
"msg.dbResetNotice",
"msg.dbResetNoticeDetail",
"Majoor Update Notice:\n\nTo avoid database errors with this new version, please delete your existing index. Click the 'Delete DB' button in the Index Status panel to reset it."
),
"Majoor",
@@ -1,6 +1,7 @@
import { comfyPrompt } from "../../../app/dialogs.js";
import { comfyToast } from "../../../app/toast.js";
import { listCollections, createCollection, addAssetsToCollection } from "../../../api/client.js";
import { t } from "../../../app/i18n.js";
import {
getOrCreateMenu as getOrCreateMenuCore,
createMenuItem,
@@ -51,17 +52,17 @@ function formatAddResultMessage({ collectionName, selectedCount, addRes }) {
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}".`;
const name = String(collectionName || "").trim() || t("label.collection", "collection");
let msg = t("msg.collectionAdd.added", "Added {added} item(s) to \"{name}\".", { added, name });
if (skippedExisting > 0) {
msg += `\n\nSkipped ${skippedExisting} item(s): already present in the collection.`;
msg += `\n\n${t("msg.collectionAdd.skippedExisting", "Skipped {count} item(s): already present in the collection.", { count: skippedExisting })}`;
}
if (skippedDuplicate > 0) {
msg += `\n\nIgnored ${skippedDuplicate} duplicate(s) in selection.`;
msg += `\n\n${t("msg.collectionAdd.skippedDuplicate", "Ignored {count} duplicate(s) in selection.", { count: skippedDuplicate })}`;
}
if (added === 0 && skippedExisting > 0 && selectedCount > 0) {
msg = `No new items added to "${name}" (all exist).`;
msg = t("msg.collectionAdd.noneAddedExisting", "No new items added to \"{name}\" (all exist).", { name });
}
return msg;
}
@@ -74,7 +75,7 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
const selected = Array.isArray(assets) ? assets.map(simplifyAsset).filter(Boolean) : [];
if (!selected.length) {
comfyToast("No valid assets selected.", "warning");
comfyToast(t("toast.noValidAssetsSelected", "No valid assets selected."), "warning");
return;
}
@@ -83,17 +84,20 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
// If none exist, directly prompt creation.
if (!collections.length) {
const name = await comfyPrompt("Create collection", "My collection");
const name = await comfyPrompt(
t("dialog.createCollection", "Create collection"),
t("dialog.collectionPlaceholder", "My collection")
);
if (!name) return;
const created = await createCollection(name);
if (!created?.ok) {
comfyToast(created?.error || "Failed to create collection.", "error");
comfyToast(created?.error || t("toast.failedCreateCollectionDot", "Failed to create collection."), "error");
return;
}
const cid = created.data?.id;
const addRes = await addAssetsToCollection(cid, selected);
if (!addRes?.ok) {
comfyToast(addRes?.error || "Failed to add assets to collection.", "error");
comfyToast(addRes?.error || t("toast.failedAddAssetsToCollection", "Failed to add assets to collection."), "error");
return;
}
comfyToast(formatAddResultMessage({ collectionName: created.data?.name || name, selectedCount: selected.length, addRes }), "success", 4000);
@@ -103,19 +107,22 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
const menu = getOrCreateCollectionsMenu();
clearMenu(menu);
const createIt = createItem("Create collection...", "pi pi-plus", async () => {
const createIt = createItem(`${t("dialog.createCollection", "Create collection")}...`, "pi pi-plus", async () => {
hideMenu(menu);
const name = await comfyPrompt("Create collection", "My collection");
const name = await comfyPrompt(
t("dialog.createCollection", "Create collection"),
t("dialog.collectionPlaceholder", "My collection")
);
if (!name) return;
const created = await createCollection(name);
if (!created?.ok) {
comfyToast(created?.error || "Failed to create collection.", "error");
comfyToast(created?.error || t("toast.failedCreateCollectionDot", "Failed to create collection."), "error");
return;
}
const cid = created.data?.id;
const addRes = await addAssetsToCollection(cid, selected);
if (!addRes?.ok) {
comfyToast(addRes?.error || "Failed to add assets to collection.", "error");
comfyToast(addRes?.error || t("toast.failedAddAssetsToCollection", "Failed to add assets to collection."), "error");
return;
}
comfyToast(formatAddResultMessage({ collectionName: created.data?.name || name, selectedCount: selected.length, addRes }), "success", 4000);
@@ -132,7 +139,7 @@ export async function showAddToCollectionMenu({ x, y, assets }) {
hideMenu(menu);
const addRes = await addAssetsToCollection(cid, selected);
if (!addRes?.ok) {
comfyToast(addRes?.error || "Failed to add assets to collection.", "error");
comfyToast(addRes?.error || t("toast.failedAddAssetsToCollection", "Failed to add assets to collection."), "error");
return;
}
comfyToast(formatAddResultMessage({ collectionName: name, selectedCount: selected.length, addRes }), "success", 4000);
@@ -306,7 +306,7 @@ async function _appendSmartSuggestions(menu, state, popovers, collectionsPopover
// 1. Create the collection
const res = await createCollection(idea.label);
if (!res?.ok) {
comfyToast(res?.error || "Failed to create smart collection", "error");
comfyToast(res?.error || t("toast.failedCreateSmartCollection", "Failed to create smart collection"), "error");
return;
}
const colId = String(res.data?.id || "");
@@ -334,7 +334,7 @@ async function _appendSmartSuggestions(menu, state, popovers, collectionsPopover
}
const addRes = await addAssetsToCollection(colId, assets);
if (!addRes?.ok) {
comfyToast(addRes?.error || "Failed to add assets to smart collection", "error");
comfyToast(addRes?.error || t("toast.failedAddAssetsToSmartCollection", "Failed to add assets to smart collection"), "error");
return;
}
const addedCount = Number(addRes?.data?.added || assets.length || 0);
@@ -359,7 +359,7 @@ async function _appendSmartSuggestions(menu, state, popovers, collectionsPopover
await reloadGrid();
} catch (err) {
console.error("[Majoor] Smart collection creation failed:", err);
comfyToast("Failed to create smart collection", "error");
comfyToast(t("toast.failedCreateSmartCollection", "Failed to create smart collection"), "error");
}
});
@@ -444,7 +444,7 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
try {
const res = await vectorSuggestCollections(8);
if (!res?.ok || !Array.isArray(res.data) || !res.data.length) {
comfyToast("No groups found. Index more assets first.", "info", 3000);
comfyToast(t("toast.noGroupsFoundIndexFirst", "No groups found. Index more assets first."), "info", 3000);
discoverBtn.textContent = "🔍 Analyze Library";
discoverBtn.disabled = false;
return;
@@ -522,7 +522,7 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
createBtn.textContent = "…";
const colRes = await createCollection(label);
if (!colRes?.ok) {
comfyToast(colRes?.error || "Failed to create collection", "error");
comfyToast(colRes?.error || t("toast.failedCreateCollection", "Failed to create collection"), "error");
createBtn.disabled = false;
createBtn.textContent = "+ Create";
return;
@@ -531,22 +531,29 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
if (colId && allIds.length) {
const hydrated = await _resolveCollectionAssetsByIds(allIds);
if (!hydrated.ok) {
comfyToast(hydrated.error || "Failed to load cluster assets", "error");
comfyToast(hydrated.error || t("toast.failedLoadClusterAssets", "Failed to load cluster assets"), "error");
createBtn.disabled = false;
createBtn.textContent = "+ Create";
return;
}
const addRes = await addAssetsToCollection(colId, hydrated.data || []);
if (!addRes?.ok) {
comfyToast(addRes?.error || "Failed to add assets to collection", "error");
comfyToast(addRes?.error || t("toast.failedAddAssetsToCollection", "Failed to add assets to collection"), "error");
createBtn.disabled = false;
createBtn.textContent = "+ Create";
return;
}
const addedCount = Number(addRes?.data?.added || (hydrated.data || []).length || 0);
comfyToast(`Collection "${label}" created with ${addedCount} assets!`, "success", 3000);
comfyToast(
t("toast.collectionCreatedWithAssets", "Collection \"{name}\" created with {count} assets!", {
name: label,
count: addedCount,
}),
"success",
3000
);
} else {
comfyToast(`Collection "${label}" created.`, "success", 2200);
comfyToast(t("toast.collectionCreatedNamed", "Collection \"{name}\" created.", { name: label }), "success", 2200);
}
state.collectionId = colId;
state.collectionName = label;
@@ -555,7 +562,7 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
await reloadGrid();
} catch (err) {
console.error("[Majoor] Cluster collection creation failed:", err);
comfyToast("Failed to create collection", "error");
comfyToast(t("toast.failedCreateCollection", "Failed to create collection"), "error");
createBtn.disabled = false;
createBtn.textContent = "+ Create";
}
@@ -568,7 +575,7 @@ async function _appendDiscoverGroups(menu, state, popovers, collectionsPopover,
}
} catch (err) {
console.error("[Majoor] Discover groups failed:", err);
comfyToast("Cluster analysis failed", "error");
comfyToast(t("toast.clusterAnalysisFailed", "Cluster analysis failed"), "error");
discoverBtn.textContent = "🔍 Analyze Library";
discoverBtn.disabled = false;
}
+4 -4
View File
@@ -403,9 +403,9 @@ export function bindGridContextMenu({
const currentPath = String(gridContainer?.dataset?.mjrSubfolder || "").trim();
if (!currentPath) return;
menu.appendChild(
createItem("Create folder here...", "pi pi-plus", null, async () => {
createItem(t("ctx.createFolderHere", "Create folder here..."), "pi pi-plus", null, async () => {
try {
const name = await comfyPrompt("New folder name", "");
const name = await comfyPrompt(t("dialog.newFolderName", "New folder name"), "");
const next = String(name || "").trim();
if (!next) return;
const res = await browserFolderOp({ op: "create", path: currentPath, name: next });
@@ -480,7 +480,7 @@ export function bindGridContextMenu({
const isBrowserScope = String(panelState?.scope || "").toLowerCase() === "custom";
const folderPath = String(asset?.filepath || "").trim();
menu.appendChild(
createItem("Open folder", "pi pi-folder-open", null, () => {
createItem(t("ctx.openFolder", "Open folder"), "pi pi-folder-open", null, () => {
try {
gridContainer.dispatchEvent(
new CustomEvent("mjr:open-folder-asset", {
@@ -581,7 +581,7 @@ export function bindGridContextMenu({
menu.appendChild(
createItem(t("ctx.deleteFolder"), "pi pi-trash", null, async () => {
try {
const folderName = String(asset?.filename || "this folder");
const folderName = String(asset?.filename || t("label.thisFolder", "this folder"));
const ok = await comfyConfirm(
t("dialog.deleteFolderRecursive", { name: folderName })
);
+65 -8
View File
@@ -52,8 +52,10 @@ import { createFilterPopoverView } from "./views/filterPopoverView.js";
import { createSortPopoverView } from "./views/sortPopoverView.js";
import { createSearchView } from "./views/searchView.js";
import { createPinnedFoldersPopoverView } from "./views/pinnedFoldersPopoverView.js";
import { createMessagePopoverView } from "./views/messagePopoverView.js";
import { createSummaryBarView } from "./views/summaryBarView.js";
import { createAgendaCalendar } from "../filters/calendar/AgendaCalendar.js";
import { bindMessagePopoverController } from "./messages/messagePopoverController.js";
import { normalizeQuery } from "./controllers/query.js";
import { createGridController } from "./controllers/gridController.js";
@@ -133,9 +135,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
try {
container._mjrPanelState = state;
} catch (e) { console.debug?.(e); }
state.viewScope = String(state.viewScope || "").trim().toLowerCase() === "similar" ? "similar" : "";
state.similarResults = Array.isArray(state.similarResults) ? state.similarResults : [];
state.similarTitle = String(state.similarTitle || "");
state.similarSourceAssetId = String(state.similarSourceAssetId || "");
const headerView = createHeaderView();
const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn, collectionsBtn, pinnedFoldersBtn } = headerView;
const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn, collectionsBtn, pinnedFoldersBtn, messageBtn } = headerView;
const { customPopover, customSelect, customAddBtn, customRemoveBtn } = createCustomPopoverView();
const {
@@ -158,8 +164,10 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
const { sortPopover, sortMenu } = createSortPopoverView();
const { collectionsPopover, collectionsMenu } = createCollectionsPopoverView();
const { pinnedFoldersPopover, pinnedFoldersMenu } = createPinnedFoldersPopoverView();
const { messagePopover, messageList, markReadBtn } = createMessagePopoverView();
headerActions.appendChild(customPopover);
headerActions.appendChild(messagePopover);
const { searchSection, searchInputEl, similarBtn, setSemanticEnabled } = createSearchView({
filterBtn,
@@ -213,6 +221,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
`;
const gridWrapper = document.createElement("div");
gridWrapper.classList.add("mjr-am-grid-scroll");
gridWrapper.style.cssText = `
flex: 1;
overflow: auto;
@@ -479,6 +488,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
getCollectionAssets,
disposeGrid,
getQuery,
searchInputEl,
state
});
@@ -683,6 +693,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
}
});
const exitSimilarViewIfActive = async ({ reload = false } = {}) => {
try {
if (String(state.viewScope || "").trim().toLowerCase() !== "similar") return;
await scopeController?.clearSimilarScope?.({ reload });
} catch (e) { console.debug?.(e); }
};
if (!header._mjrTabListenersBound) {
Object.values(tabButtons).forEach((btn) => {
btn.addEventListener("click", () => scopeController.setScope(btn.dataset.scope), { signal: panelLifecycleAC?.signal });
@@ -690,17 +707,35 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
header._mjrTabListenersBound = true;
}
bindMessagePopoverController({
messageBtn,
messagePopover,
messageList,
markReadBtn,
popovers,
signal: panelLifecycleAC?.signal,
onBeforeToggle: () => {
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(pinnedFoldersPopover);
},
});
popovers.setDismissWhitelist([
customPopover,
filterPopover,
sortPopover,
collectionsPopover,
pinnedFoldersPopover,
messagePopover,
customMenuBtn,
filterBtn,
sortBtn,
collectionsBtn,
pinnedFoldersBtn
pinnedFoldersBtn,
messageBtn
]);
customMenuBtn.addEventListener("click", (e) => {
@@ -708,6 +743,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
popovers.toggle(customPopover, customMenuBtn);
}, { signal: panelLifecycleAC?.signal });
@@ -716,6 +752,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(customPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
popovers.toggle(filterPopover, filterBtn);
}, { signal: panelLifecycleAC?.signal });
@@ -730,6 +767,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
} catch (err) { console.debug?.(err); }
const selectedIdRaw = String(
@@ -757,13 +795,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
return;
}
const list = Array.isArray(res?.data) ? res.data : [];
await loadAssetsFromList(gridContainer, list, {
title: t("search.similarResults", "Similar to asset #{id} ({n} results)", {
id: selectedId,
n: list.length,
}),
reset: true,
state.similarResults = list;
state.similarSourceAssetId = String(selectedId);
state.similarTitle = t("search.similarResults", "Similar to asset #{id} ({n} results)", {
id: selectedId,
n: list.length,
});
await scopeController?.setScope?.("similar");
try {
state.lastGridCount = Number(list.length || 0) || 0;
state.lastGridTotal = Number(list.length || 0) || 0;
@@ -794,6 +832,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
}
});
@@ -814,6 +853,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(messagePopover);
}
});
@@ -961,6 +1001,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
popovers.toggle(pinnedFoldersPopover, pinnedFoldersBtn);
}, { signal: panelLifecycleAC?.signal });
@@ -1013,6 +1054,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
reloadGrid: gridController.reloadGrid,
lifecycleSignal: panelLifecycleAC?.signal || null,
onFiltersChanged: () => {
void exitSimilarViewIfActive({ reload: false });
try {
agendaCalendar?.refresh?.();
} catch (e) { console.debug?.(e); }
@@ -1048,6 +1090,19 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
reloadGrid: () => gridController.reloadGrid(),
getExtraContext: () => ({ duplicatesAlert: _duplicatesAlert }),
extraActions: {
clearSimilarScope: async () => {
try {
await scopeController?.clearSimilarScope?.();
} catch (e) { console.debug?.(e); }
},
clearTransientContext: async () => {
try {
state.viewScope = "";
state.similarResults = [];
state.similarTitle = "";
state.similarSourceAssetId = "";
} catch (e) { console.debug?.(e); }
},
resetBrowserHistory: () => {
try {
browserNav?.resetHistory?.();
@@ -1423,6 +1478,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
const value = e?.target?.value ?? "";
if (value === lastSearchValue) return;
lastSearchValue = value;
void exitSimilarViewIfActive({ reload: false });
notifyContextChanged();
if (searchTimeout) clearTimeout(searchTimeout);
@@ -1443,6 +1499,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
}, { signal: panelLifecycleAC?.signal });
searchInputEl.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
void exitSimilarViewIfActive({ reload: false });
startTimer('searchQuery');
gridController.reloadGrid();
notifyContextChanged();
@@ -84,6 +84,12 @@ export function createContextController({
} catch (e) { console.debug?.(e); }
},
clearScope: async () => {
try {
if (String(state?.viewScope || "").trim().toLowerCase() === "similar" && typeof extraActions?.clearSimilarScope === "function") {
await extraActions.clearSimilarScope();
return;
}
} catch (e) { console.debug?.(e); }
let didSetScope = false;
try {
state.customRootId = "";
@@ -244,6 +250,9 @@ export function createContextController({
state.currentFolderRelativePath = "";
delete gridContainer?.dataset?.mjrSubfolder;
resetBrowserHistory();
try {
await extraActions?.clearTransientContext?.();
} catch (e) { console.debug?.(e); }
if (typeof scopeController?.setScope === "function") {
await scopeController.setScope("output");
didSetScope = true;
@@ -3,12 +3,22 @@ import { comfyToast } from "../../../app/toast.js";
import { t } from "../../../app/i18n.js";
import { loadMajoorSettings } from "../../../app/settings.js";
export function createGridController({ gridContainer, loadAssets, loadAssetsFromList, getCollectionAssets, disposeGrid, getQuery, state }) {
export function createGridController({
gridContainer,
loadAssets,
loadAssetsFromList,
getCollectionAssets,
disposeGrid,
getQuery,
searchInputEl,
state,
}) {
let _isReloading = false;
let _pendingReload = false;
let _lastReloadErrorAt = 0;
let _lastAiHintAt = 0;
const RELOAD_WATCHDOG_MS = 30000;
const AI_RELOAD_WATCHDOG_MS = 150000;
const AI_HINT_COOLDOWN_MS = 15_000;
const runWithWatchdog = async (promiseFactory, timeoutMs = RELOAD_WATCHDOG_MS) => {
@@ -32,8 +42,7 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
*/
const _isSemanticMode = () => {
try {
const input = gridContainer?.closest?.(".mjr-am-panel")?.querySelector?.("#mjr-search-input");
return input?.dataset?.mjrSemanticMode === "1";
return searchInputEl?.dataset?.mjrSemanticMode === "1";
} catch { return false; }
};
@@ -305,6 +314,25 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
state.collectionName = "";
}
if (String(state.viewScope || "").toLowerCase() === "similar") {
const list = Array.isArray(state.similarResults) ? state.similarResults : [];
const sourceId = String(state.similarSourceAssetId || "").trim();
const title = String(
state.similarTitle
|| t("search.similarResults", "Similar to asset #{id} ({n} results)", {
id: sourceId || "?",
n: list.length,
})
).trim();
const result = await loadAssetsFromList(gridContainer, list, { title: title || "Similar", 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 (e) { console.debug?.(e); }
return;
}
const result = await _loadWithSemanticFallback(getQuery());
// Track search query timing if timer was started
@@ -335,7 +363,8 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
while (_pendingReload) {
_pendingReload = false;
try {
await runWithWatchdog(() => runReloadOnce());
const useAiTimeout = _isAiEnabled() && (_isSemanticMode() || String(getQuery?.() || "").length >= 12);
await runWithWatchdog(() => runReloadOnce(), useAiTimeout ? AI_RELOAD_WATCHDOG_MS : RELOAD_WATCHDOG_MS);
} catch (err) {
// Keep UI responsive even if one reload got stuck, but don't fail silently.
try {
@@ -367,4 +396,3 @@ export function createGridController({ gridContainer, loadAssets, loadAssetsFrom
return { reloadGrid };
}
@@ -10,29 +10,79 @@ export function createScopeController({
onBeforeReload = null
}) {
let scopeSwitchSeq = 0;
const hasSimilarResults = () => {
try {
return !!(String(state?.viewScope || "").trim().toLowerCase() === "similar"
|| (Array.isArray(state?.similarResults) && state.similarResults.length > 0)
|| String(state?.similarTitle || "").trim());
} catch {
return false;
}
};
const resetSimilarScope = () => {
state.viewScope = "";
state.similarResults = [];
state.similarTitle = "";
state.similarSourceAssetId = "";
};
const setActiveTabStyles = () => {
const buttons = Object.values(tabButtons || {});
const activeScope = String(state?.viewScope || state?.scope || "output").toLowerCase();
buttons.forEach((b) => {
const scope = b?.dataset?.scope;
const active = scope === state.scope;
const active = scope === activeScope;
b?.classList?.toggle?.("is-active", active);
});
try {
const similarBtn = tabButtons?.tabSimilar;
if (similarBtn) {
similarBtn.style.display = hasSimilarResults() ? "" : "none";
}
} catch (e) { console.debug?.(e); }
customMenuBtn.style.display = "none";
customPopover.style.display = "none";
popovers.close(customPopover);
};
const clearSimilarScope = async ({ reload = true } = {}) => {
const hadSimilar = hasSimilarResults();
resetSimilarScope();
setActiveTabStyles();
try {
onChanged?.();
} catch (e) { console.debug?.(e); }
if (!hadSimilar || !reload) return;
await reloadGrid();
};
const setScope = async (scope) => {
const requestedSeq = ++scopeSwitchSeq;
const allowed = new Set(["output", "outputs", "input", "inputs", "all", "custom"]);
const allowed = new Set(["output", "outputs", "input", "inputs", "all", "custom", "similar"]);
const normalized = String(scope || "").toLowerCase();
if (!allowed.has(normalized)) return;
if (normalized === "similar") {
if (!hasSimilarResults()) return;
state.viewScope = "similar";
setActiveTabStyles();
try {
onChanged?.();
} catch (e) { console.debug?.(e); }
try {
await onBeforeReload?.(state);
} catch (e) { console.debug?.(e); }
if (requestedSeq !== scopeSwitchSeq) return;
await reloadGrid();
return;
}
// Switching scope exits any active collection view.
state.collectionId = "";
state.collectionName = "";
resetSimilarScope();
state.scope = normalized === "outputs" ? "output" : normalized === "inputs" ? "input" : normalized;
// Scope switch should reset folder context to avoid stale filtering behavior.
@@ -56,6 +106,6 @@ export function createScopeController({
await reloadGrid();
};
return { setScope, setActiveTabStyles };
return { setScope, setActiveTabStyles, clearSimilarScope };
}
@@ -31,7 +31,7 @@ async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) {
}
} catch (e) { console.debug?.(e); }
try { comfyToast("Rescanning file + updating AI index...", "info", 2200); } catch (e) { console.debug?.(e); }
try { comfyToast(t("toast.rescanUpdatingAiIndex", "Rescanning file + updating AI index..."), "info", 2200); } catch (e) { console.debug?.(e); }
const fileEntry = {
filename: asset.filename,
@@ -106,9 +106,9 @@ async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) {
try {
if (indexOk && vectorIndexOk) {
comfyToast("Metadata + AI vector index updated for this asset.", "success", 2200);
comfyToast(t("toast.metadataVectorUpdated", "Metadata + AI vector index updated for this asset."), "success", 2200);
} else if (indexOk && vectorAttempted && !vectorIndexOk) {
comfyToast("Metadata updated. AI vector index could not be updated.", "warning", 2600);
comfyToast(t("toast.metadataUpdatedVectorFailed", "Metadata updated. AI vector index could not be updated."), "warning", 2600);
}
} catch (e) { console.debug?.(e); }
+324
View File
@@ -0,0 +1,324 @@
const STORAGE_MESSAGES_KEY = "mjr_panel_messages_v1";
const STORAGE_LAST_READ_KEY = "mjr_panel_messages_last_read_v1";
const STORAGE_DISMISSED_BUILTIN_IDS_KEY = "mjr_panel_messages_dismissed_builtin_ids_v1";
const MAX_STORED_MESSAGES = 120;
const LOCAL_USER_GUIDE_URL = "/mjr/am/user-guide";
const ALLOWED_MESSAGE_LEVELS = new Set(["info", "success", "warning", "error"]);
export const PANEL_MESSAGES_EVENT = "mjr:panel-messages-changed";
let _loaded = false;
let _messages = [];
const BUILTIN_PANEL_MESSAGES = Object.freeze([
{
id: "whats-new-2026-03-05-pin-reference",
title: "What's New",
titleKey: "msg.whatsNew.title.pinReference",
category: "Information",
categoryKey: "msg.category.information",
level: "info",
createdAt: Date.parse("2026-03-05T10:00:00Z"),
body: "Floating Viewer: new Pin Reference feature. You can now pin A or B, then compare quickly with selected assets in the grid while keeping the fixed reference.",
bodyKey: "msg.whatsNew.body.pinReference",
},
{
id: "whats-new-2026-03-05-vector-reset-keep-vectors",
title: "What's New",
titleKey: "msg.whatsNew.title.vectorResetKeepVectors",
category: "Information",
categoryKey: "msg.category.information",
level: "info",
createdAt: Date.parse("2026-03-05T12:00:00Z"),
body: "Reset index and Delete DB now first ask whether to keep AI vectors. If you already have older indexed assets, keeping the vectors is recommended: a full reset without them can trigger a long Vector Backfill for old assets and temporarily increase RAM usage.",
bodyKey: "msg.whatsNew.body.vectorResetKeepVectors",
},
{
id: "whats-new-2026-03-05-local-user-guide",
title: "What's New",
titleKey: "msg.whatsNew.title.localUserGuide",
category: "Information",
categoryKey: "msg.category.information",
level: "info",
createdAt: Date.parse("2026-03-05T13:00:00Z"),
body: "Open the local User Guide directly from your Assets Manager custom_nodes folder.",
bodyKey: "msg.whatsNew.body.localUserGuide",
actionLabel: "User Guide",
actionLabelKey: "label.userGuide",
actionUrl: LOCAL_USER_GUIDE_URL,
},
]);
const BUILTIN_MESSAGE_IDS = new Set(BUILTIN_PANEL_MESSAGES
.map((msg) => String(msg?.id || "").trim())
.filter(Boolean));
function _toNumber(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function _safeNow() {
return Date.now();
}
function _makeId() {
return `msg-${_safeNow()}-${Math.random().toString(36).slice(2, 8)}`;
}
function _normalizeMessageLevel(value) {
const raw = String(value || "").trim().toLowerCase();
if (raw === "warn") return "warning";
if (raw === "danger") return "error";
return ALLOWED_MESSAGE_LEVELS.has(raw) ? raw : "info";
}
function _isBuiltinMessageId(id) {
return BUILTIN_MESSAGE_IDS.has(String(id || "").trim());
}
function _normalizeMessage(input = {}) {
const id = String(input?.id || "").trim() || _makeId();
const title = String(input?.title || "").trim() || "Info";
const titleKey = String(input?.titleKey || "").trim();
const emoji = String(input?.emoji || "").trim();
const body = String(input?.body || "").trim();
const bodyKey = String(input?.bodyKey || "").trim();
const category = String(input?.category || "").trim() || "Info";
const categoryKey = String(input?.categoryKey || "").trim();
const createdAt = _toNumber(input?.createdAt, _safeNow());
const level = _normalizeMessageLevel(input?.level);
const actionLabel = String(input?.actionLabel || "").trim();
const actionLabelKey = String(input?.actionLabelKey || "").trim();
const actionUrl = String(input?.actionUrl || "").trim();
return {
id,
title,
titleKey,
emoji,
body,
bodyKey,
category,
categoryKey,
createdAt,
level,
actionLabel,
actionLabelKey,
actionUrl,
};
}
function _readStorageJson(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw);
} catch {
return fallback;
}
}
function _writeStorageJson(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore storage quota/privacy errors
}
}
function _readLastReadAt() {
try {
return _toNumber(localStorage.getItem(STORAGE_LAST_READ_KEY), 0);
} catch {
return 0;
}
}
function _writeLastReadAt(ts) {
try {
localStorage.setItem(STORAGE_LAST_READ_KEY, String(_toNumber(ts, _safeNow())));
} catch {
// ignore storage quota/privacy errors
}
}
function _readDismissedBuiltinIds() {
const raw = _readStorageJson(STORAGE_DISMISSED_BUILTIN_IDS_KEY, []);
const ids = Array.isArray(raw) ? raw : [];
return new Set(
ids
.map((id) => String(id || "").trim())
.filter((id) => _isBuiltinMessageId(id))
);
}
function _writeDismissedBuiltinIds(ids) {
const safeIds = Array.from(ids || [])
.map((id) => String(id || "").trim())
.filter((id) => _isBuiltinMessageId(id));
_writeStorageJson(STORAGE_DISMISSED_BUILTIN_IDS_KEY, safeIds);
}
function _dismissBuiltinMessage(id) {
const key = String(id || "").trim();
if (!_isBuiltinMessageId(key)) return false;
const dismissedIds = _readDismissedBuiltinIds();
if (dismissedIds.has(key)) return false;
dismissedIds.add(key);
_writeDismissedBuiltinIds(dismissedIds);
return true;
}
function _restoreBuiltinMessage(id) {
const key = String(id || "").trim();
if (!_isBuiltinMessageId(key)) return false;
const dismissedIds = _readDismissedBuiltinIds();
if (!dismissedIds.delete(key)) return false;
_writeDismissedBuiltinIds(dismissedIds);
return true;
}
function _sortMessages() {
_messages.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
if (_messages.length > MAX_STORED_MESSAGES) {
_messages = _messages.slice(0, MAX_STORED_MESSAGES);
}
}
function _persistMessages() {
_writeStorageJson(STORAGE_MESSAGES_KEY, _messages);
}
function _seedBuiltinMessages() {
const dismissedBuiltinIds = _readDismissedBuiltinIds();
let changed = false;
for (const raw of BUILTIN_PANEL_MESSAGES) {
const normalized = _normalizeMessage(raw);
if (dismissedBuiltinIds.has(normalized.id)) {
continue;
}
const idx = _messages.findIndex((m) => m.id === normalized.id);
if (idx < 0) {
_messages.push(normalized);
changed = true;
continue;
}
const prev = _messages[idx];
const merged = {
...prev,
...normalized,
// Keep the original publish timestamp if already present.
createdAt: _toNumber(prev?.createdAt, normalized.createdAt),
};
if (JSON.stringify(prev) !== JSON.stringify(merged)) {
_messages[idx] = merged;
changed = true;
}
}
if (!changed) return false;
_sortMessages();
_persistMessages();
return true;
}
function _emitChanged() {
try {
window.dispatchEvent(new CustomEvent(PANEL_MESSAGES_EVENT, {
detail: {
total: _messages.length,
unread: getPanelUnreadCount(),
},
}));
} catch (e) { console.debug?.(e); }
}
function _ensureLoaded() {
if (_loaded) return;
_loaded = true;
const parsed = _readStorageJson(STORAGE_MESSAGES_KEY, []);
const safeArray = Array.isArray(parsed) ? parsed : [];
_messages = safeArray.map(_normalizeMessage);
_sortMessages();
}
export function ensurePanelMessagesReady() {
_ensureLoaded();
if (_seedBuiltinMessages()) _emitChanged();
}
export function listPanelMessages() {
_ensureLoaded();
return _messages.map((message) => ({ ...message }));
}
export function getPanelUnreadCount() {
_ensureLoaded();
const lastReadAt = _readLastReadAt();
return _messages.reduce((count, msg) => {
return count + (Number(msg.createdAt || 0) > lastReadAt ? 1 : 0);
}, 0);
}
export function getPanelLastReadAt() {
_ensureLoaded();
return _readLastReadAt();
}
export function markPanelMessagesRead({ upTo = _safeNow() } = {}) {
_ensureLoaded();
_writeLastReadAt(_toNumber(upTo, _safeNow()));
_emitChanged();
}
export function upsertPanelMessage(message) {
_ensureLoaded();
const normalized = _normalizeMessage(message);
_restoreBuiltinMessage(normalized.id);
const idx = _messages.findIndex((m) => m.id === normalized.id);
let stored = null;
if (idx >= 0) {
const prev = _messages[idx];
stored = {
...prev,
...normalized,
// Keep original publish timestamp unless explicitly provided.
createdAt: _toNumber(message?.createdAt, _toNumber(prev?.createdAt, normalized.createdAt)),
};
_messages[idx] = stored;
} else {
stored = { ...normalized };
_messages.push(stored);
}
_sortMessages();
_persistMessages();
_emitChanged();
return { ...stored };
}
export function addPanelMessage(message) {
return upsertPanelMessage(message);
}
export function removePanelMessage(id) {
_ensureLoaded();
const key = String(id || "").trim();
if (!key) return false;
const next = _messages.filter((m) => m.id !== key);
if (next.length === _messages.length) return false;
_messages = next;
_dismissBuiltinMessage(key);
_persistMessages();
_emitChanged();
return true;
}
export function clearPanelMessages() {
_ensureLoaded();
const dismissedBuiltinIds = _readDismissedBuiltinIds();
for (const builtinId of BUILTIN_MESSAGE_IDS) {
dismissedBuiltinIds.add(builtinId);
}
_writeDismissedBuiltinIds(dismissedBuiltinIds);
_messages = [];
_persistMessages();
_writeLastReadAt(_safeNow());
_emitChanged();
}
@@ -0,0 +1,250 @@
import { t } from "../../../app/i18n.js";
import {
PANEL_MESSAGES_EVENT,
ensurePanelMessagesReady,
getPanelLastReadAt,
getPanelUnreadCount,
listPanelMessages,
markPanelMessagesRead,
} from "./messageCenter.js";
const ALLOWED_MESSAGE_LEVELS = new Set(["info", "success", "warning", "error"]);
function formatMessageDate(ts) {
const stamp = Number(ts);
if (!Number.isFinite(stamp) || stamp <= 0) return "";
try {
return new Date(stamp).toLocaleString([], {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
}
function resolveMessageText(entry, keyField, fallbackField, fallbackText) {
const key = String(entry?.[keyField] || "").trim();
const fallback = String(entry?.[fallbackField] || fallbackText || "").trim() || String(fallbackText || "");
return key ? t(key, fallback) : fallback;
}
function normalizeMessageLevel(value) {
const raw = String(value || "").trim().toLowerCase();
if (raw === "warn") return "warning";
if (raw === "danger") return "error";
return ALLOWED_MESSAGE_LEVELS.has(raw) ? raw : "info";
}
function resolveSafeActionUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
try {
const baseHref = String(globalThis?.window?.location?.href || "http://127.0.0.1/");
const url = new URL(raw, baseHref);
const protocol = String(url.protocol || "").toLowerCase();
return protocol === "http:" || protocol === "https:" ? url.href : "";
} catch {
return "";
}
}
function resolveMessageEmoji(entry) {
const explicit = String(entry?.emoji || "").trim();
if (explicit) return explicit;
const level = normalizeMessageLevel(entry?.level);
if (level === "success") return "\u2705";
if (level === "warning") return "\u26A0\uFE0F";
if (level === "error") return "\u274C";
return "\uD83D\uDCA1";
}
export function bindMessagePopoverController({
messageBtn = null,
messagePopover = null,
messageList = null,
markReadBtn = null,
popovers = null,
onBeforeToggle = null,
signal = null,
} = {}) {
ensurePanelMessagesReady();
const syncExpandedState = () => {
const isOpen = messagePopover?.style?.display === "block";
try {
messageBtn?.setAttribute("aria-expanded", isOpen ? "true" : "false");
} catch (e) { console.debug?.(e); }
try {
messagePopover?.setAttribute("aria-hidden", isOpen ? "false" : "true");
} catch (e) { console.debug?.(e); }
};
const renderMessagesPopover = () => {
if (!messageList) return;
const entries = listPanelMessages();
const lastReadAt = getPanelLastReadAt();
messageList.replaceChildren();
if (!entries.length) {
const empty = document.createElement("div");
empty.className = "mjr-messages-empty";
empty.textContent = t("msg.noMessages", "No messages for now.");
messageList.appendChild(empty);
return;
}
for (const entry of entries) {
const item = document.createElement("article");
item.className = "mjr-message-item";
const level = normalizeMessageLevel(entry?.level);
item.classList.add(`mjr-message-item--${level}`);
const createdAt = Number(entry?.createdAt || 0);
const unread = createdAt > Number(lastReadAt || 0);
if (unread) item.classList.add("mjr-message-item--unread");
const head = document.createElement("div");
head.className = "mjr-message-item-head";
const titleWrap = document.createElement("div");
titleWrap.className = "mjr-message-item-title-wrap";
const emoji = document.createElement("span");
emoji.className = "mjr-message-item-emoji";
emoji.textContent = resolveMessageEmoji(entry);
emoji.setAttribute("aria-hidden", "true");
const title = document.createElement("div");
title.className = "mjr-message-item-title";
title.textContent = resolveMessageText(entry, "titleKey", "title", t("label.info", "Info"));
const category = document.createElement("span");
category.className = "mjr-message-item-category";
category.textContent = resolveMessageText(entry, "categoryKey", "category", t("label.info", "Info"));
titleWrap.appendChild(emoji);
titleWrap.appendChild(title);
head.appendChild(titleWrap);
head.appendChild(category);
const meta = document.createElement("div");
meta.className = "mjr-message-item-meta";
meta.textContent = formatMessageDate(createdAt);
const body = document.createElement("div");
body.className = "mjr-message-item-body";
body.textContent = resolveMessageText(entry, "bodyKey", "body", "");
item.appendChild(head);
if (meta.textContent) item.appendChild(meta);
if (body.textContent) item.appendChild(body);
const actionUrl = resolveSafeActionUrl(entry?.actionUrl);
const actionLabel = resolveMessageText(entry, "actionLabelKey", "actionLabel", t("label.readMe", "Read Me"));
if (actionUrl && actionLabel) {
const action = document.createElement("a");
action.className = "mjr-message-item-action";
action.href = actionUrl;
action.target = "_blank";
action.rel = "noopener noreferrer";
action.textContent = actionLabel;
item.appendChild(action);
}
messageList.appendChild(item);
}
};
const updateMessageButtonState = () => {
if (!messageBtn) return;
const unreadCount = Number(getPanelUnreadCount() || 0);
const badge = messageBtn.querySelector(".mjr-message-badge");
const title = unreadCount > 0
? t("tooltip.openMessagesUnread", "Messages ({count} unread)", { count: unreadCount })
: t("tooltip.openMessages", "Messages and updates");
messageBtn.classList.toggle("mjr-message-has-unread", unreadCount > 0);
messageBtn.title = title;
messageBtn.setAttribute("aria-label", title);
if (!badge) return;
if (unreadCount <= 0) {
badge.style.display = "none";
badge.textContent = "";
return;
}
badge.style.display = "inline-flex";
badge.textContent = unreadCount > 9 ? "9+" : String(unreadCount);
};
const refreshMessagesUI = () => {
updateMessageButtonState();
if (messagePopover?.style?.display === "block") {
renderMessagesPopover();
}
};
const listenerOptions = signal ? { signal } : undefined;
try {
window.addEventListener(PANEL_MESSAGES_EVENT, refreshMessagesUI, listenerOptions);
} catch (e) { console.debug?.(e); }
try {
if (messageBtn && messagePopover) {
if (!messagePopover.id) messagePopover.id = "mjr-messages-popover";
messageBtn.setAttribute("aria-haspopup", "dialog");
messageBtn.setAttribute("aria-controls", messagePopover.id);
}
} catch (e) { console.debug?.(e); }
let expandedObserver = null;
try {
if (typeof MutationObserver !== "undefined" && messagePopover) {
expandedObserver = new MutationObserver(syncExpandedState);
expandedObserver.observe(messagePopover, {
attributes: true,
attributeFilter: ["style"],
});
}
} catch (e) { console.debug?.(e); }
try {
signal?.addEventListener?.("abort", () => expandedObserver?.disconnect?.(), { once: true });
} catch (e) { console.debug?.(e); }
updateMessageButtonState();
syncExpandedState();
messageBtn?.addEventListener("click", (e) => {
if (!messagePopover || !popovers || !messageBtn) return;
e.stopPropagation();
renderMessagesPopover();
try {
if (typeof onBeforeToggle === "function") onBeforeToggle();
} catch (err) { console.debug?.(err); }
popovers.toggle(messagePopover, messageBtn);
if (messagePopover?.style?.display === "block") {
markPanelMessagesRead();
}
syncExpandedState();
updateMessageButtonState();
}, listenerOptions);
markReadBtn?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
markPanelMessagesRead();
renderMessagesPopover();
updateMessageButtonState();
}, listenerOptions);
return {
refresh: refreshMessagesUI,
render: renderMessagesPopover,
updateButtonState: updateMessageButtonState,
close: () => {
if (!messagePopover || !popovers) return;
popovers.close(messagePopover);
syncExpandedState();
},
};
}
+11 -1
View File
@@ -72,6 +72,10 @@ function savePanelState(state) {
// Don't persist transient stats
delete toSave.lastGridCount;
delete toSave.lastGridTotal;
delete toSave.viewScope;
delete toSave.similarResults;
delete toSave.similarTitle;
delete toSave.similarSourceAssetId;
const serialized = JSON.stringify(toSave);
if (serialized === _lastSavedSerialized) return;
_lastSavedSerialized = serialized;
@@ -115,7 +119,13 @@ export function createPanelState() {
selectedAssetIds: saved.selectedAssetIds || [],
// Sidebar open state for restoring across panel close/reopen.
sidebarOpen: saved.sidebarOpen || false
sidebarOpen: saved.sidebarOpen || false,
// Transient virtual scopes.
viewScope: "",
similarResults: [],
similarTitle: "",
similarSourceAssetId: "",
};
let proxy = null;
+18 -4
View File
@@ -18,6 +18,7 @@ const _labelSort = (key) => {
const _labelScope = (scope) => {
const s = String(scope || "").toLowerCase();
if (s === "similar") return t("scope.similar", "Similar");
if (s === "custom") return t("scope.customBrowser");
if (s === "all") return t("tab.all");
if (s === "input" || s === "inputs") return t("scope.input");
@@ -81,12 +82,14 @@ export function createContextPillsView() {
);
const isSortActive = _safeText(state?.sort || "mtime_desc").trim() !== "mtime_desc";
const isCollectionActive = _safeText(state?.collectionId || "").trim().length > 0;
const scopeValue = String(state?.scope || "output").toLowerCase();
const scopeValue = String(state?.viewScope || state?.scope || "output").toLowerCase();
const isSimilarScope = scopeValue === "similar";
const isBrowserScope = scopeValue === "custom";
const isScopeActive = scopeValue !== "output";
const similarSourceId = _safeText(state?.similarSourceAssetId || "").trim();
const anyContext =
isQueryActive || isFilterActive || isSortActive || isCollectionActive || isScopeActive;
isQueryActive || isFilterActive || isSortActive || isCollectionActive || isScopeActive || isSimilarScope;
if (!anyContext && !root.childElementCount) {
return;
}
@@ -104,12 +107,23 @@ export function createContextPillsView() {
})
);
}
if (isSimilarScope) {
root.appendChild(
_createPill({
label: t("search.findSimilar", "Find Similar"),
value: similarSourceId
? t("search.similarReference", "Reference #{id}", { id: similarSourceId })
: t("scope.similar", "Similar"),
onClear: () => safeActions?.clearSimilarScope?.()
})
);
}
if (isScopeActive) {
root.appendChild(
_createPill({
label: t("label.scope"),
value: _labelScope(state?.scope || "output"),
onClear: () => safeActions?.clearScope?.()
value: _labelScope(scopeValue || "output"),
onClear: () => (isSimilarScope ? safeActions?.clearSimilarScope?.() : safeActions?.clearScope?.())
})
);
}
+10
View File
@@ -236,6 +236,15 @@ export function createHeaderView() {
window.addEventListener(EVENTS.MFV_VISIBILITY_CHANGED, _mfvVisibilityHandler);
headerTools.appendChild(mfvBtn);
const messageBtn = createIconButton("pi-info-circle", t("tooltip.openMessages", "Messages and updates"));
messageBtn.classList.add("mjr-message-btn");
const messageBadge = document.createElement("span");
messageBadge.className = "mjr-message-badge";
messageBadge.style.display = "none";
messageBadge.setAttribute("aria-hidden", "true");
messageBtn.appendChild(messageBadge);
headerTools.appendChild(messageBtn);
headerRow.appendChild(headerLeft);
headerRow.appendChild(headerActions);
header.appendChild(headerRow);
@@ -287,6 +296,7 @@ export function createHeaderView() {
collectionsBtn,
pinnedFoldersBtn,
mfvBtn,
messageBtn,
dispose,
};
}
+2
View File
@@ -3,8 +3,10 @@ export function createIconButton(iconClass, title) {
btn.type = "button";
btn.className = "mjr-icon-btn";
btn.title = title;
if (title) btn.setAttribute("aria-label", title);
const i = document.createElement("i");
i.className = `pi ${iconClass}`;
i.setAttribute("aria-hidden", "true");
btn.appendChild(i);
return btn;
}
@@ -0,0 +1,73 @@
import { t } from "../../../app/i18n.js";
const GITHUB_REPO_URL = "https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager";
export function createMessagePopoverView() {
const messagePopover = document.createElement("div");
messagePopover.className = "mjr-popover mjr-messages-popover";
messagePopover.id = "mjr-messages-popover";
messagePopover.setAttribute("role", "dialog");
messagePopover.setAttribute("aria-label", t("label.messages", "Messages"));
messagePopover.setAttribute("aria-hidden", "true");
messagePopover.tabIndex = -1;
messagePopover.style.display = "none";
messagePopover.style.cssText = "display:none;";
const head = document.createElement("div");
head.className = "mjr-messages-head";
const title = document.createElement("div");
title.className = "mjr-messages-title";
title.textContent = t("label.messages", "Messages");
const actions = document.createElement("div");
actions.className = "mjr-messages-actions";
const starLink = document.createElement("a");
starLink.className = "mjr-btn mjr-messages-star-link";
starLink.href = GITHUB_REPO_URL;
starLink.target = "_blank";
starLink.rel = "noopener noreferrer";
starLink.title = t("tooltip.starGithub", "Open GitHub and give a star");
const starIcon = document.createElement("span");
starIcon.className = "mjr-messages-star-icon";
starIcon.textContent = "\u2605";
starIcon.setAttribute("aria-hidden", "true");
const starLabel = document.createElement("span");
starLabel.className = "mjr-messages-star-label";
starLabel.textContent = t("btn.giveStar", "Give a star");
starLink.appendChild(starIcon);
starLink.appendChild(starLabel);
const markReadBtn = document.createElement("button");
markReadBtn.type = "button";
markReadBtn.className = "mjr-btn mjr-messages-mark-read-btn";
markReadBtn.textContent = t("btn.markAllRead", "Mark all read");
markReadBtn.title = t("tooltip.markMessagesRead", "Mark all messages as read");
head.appendChild(title);
actions.appendChild(starLink);
actions.appendChild(markReadBtn);
head.appendChild(actions);
const messageList = document.createElement("div");
messageList.className = "mjr-messages-list";
const emptyState = document.createElement("div");
emptyState.className = "mjr-messages-empty";
emptyState.textContent = t("msg.noMessages", "No messages for now.");
messageList.appendChild(emptyState);
messagePopover.appendChild(head);
messagePopover.appendChild(messageList);
return {
messagePopover,
messageList,
emptyState,
markReadBtn,
};
}
+17
View File
@@ -17,8 +17,24 @@ export function createPopoverManager(container) {
const POPOVER_BOTTOM_SAFETY_PX = 80;
const POPOVER_MIN_HEIGHT_PX = 140;
const bindPopoverInteractionGuards = (popover) => {
if (!popover || popover._mjrInteractionGuardsBound) return;
const stopPropagation = (event) => {
try {
event.stopPropagation();
} catch (e) { console.debug?.(e); }
};
try {
popover.addEventListener("wheel", stopPropagation, { passive: true });
popover.addEventListener("mousedown", stopPropagation);
popover.addEventListener("touchmove", stopPropagation, { passive: true });
popover._mjrInteractionGuardsBound = true;
} catch (e) { console.debug?.(e); }
};
const attachPopoverToPortal = (popover) => {
if (!popover || !(popover instanceof HTMLElement)) return;
bindPopoverInteractionGuards(popover);
if (!popover._mjrPortal) {
popover._mjrPortal = {
parent: popover.parentNode,
@@ -90,6 +106,7 @@ export function createPopoverManager(container) {
const availableBelow = bottomLimit - top;
popover.style.maxHeight = `${Math.max(POPOVER_MIN_HEIGHT_PX, availableBelow)}px`;
popover.style.overflow = "auto";
popover.style.overscrollBehavior = "contain";
popover.style.left = `${Math.round(left)}px`;
popover.style.top = `${Math.round(top)}px`;
+20 -13
View File
@@ -11,6 +11,7 @@ const _safeText = (v) => {
const _titleScope = (scope) => {
const s = String(scope || "").toLowerCase();
if (s === "similar") return t("scope.similar", "Similar");
if (s === "input" || s === "inputs") return t("scope.input");
if (s === "custom") return t("scope.customBrowser");
if (s === "all") return t("tab.all");
@@ -108,7 +109,8 @@ export function createSummaryBarView() {
Math.max(0, Number(state?.lastGridCount ?? 0) || 0) ||
cardsCount;
const scope = _titleScope(state?.scope || gridContainer?.dataset?.mjrScope || "output");
const activeScope = String(state?.viewScope || state?.scope || gridContainer?.dataset?.mjrScope || "output").toLowerCase();
const scope = _titleScope(activeScope);
const countPart = total && total >= shown ? `${shown}/${total}` : `${shown}`;
const primaryLabel =
@@ -127,21 +129,26 @@ export function createSummaryBarView() {
} catch (e) { console.debug?.(e); }
try {
const alert = context?.duplicatesAlert || null;
const exact = Number(alert?.exactCount || 0) || 0;
const similar = Number(alert?.similarCount || 0) || 0;
const hasAlert = exact > 0 || similar > 0;
if (!hasAlert) {
if (activeScope === "similar") {
dupAlert.style.display = "none";
dupAlert.onclick = null;
} else {
dupAlert.style.display = "";
dupAlert.textContent = `${t("summary.duplicates")}: ${exact} | ${t("summary.similar")}: ${similar}`;
dupAlert.onclick = () => {
try {
actions?.onDuplicateAlertClick?.(alert);
} catch (e) { console.debug?.(e); }
};
const alert = context?.duplicatesAlert || null;
const exact = Number(alert?.exactCount || 0) || 0;
const similar = Number(alert?.similarCount || 0) || 0;
const hasAlert = exact > 0 || similar > 0;
if (!hasAlert) {
dupAlert.style.display = "none";
dupAlert.onclick = null;
} else {
dupAlert.style.display = "";
dupAlert.textContent = `${t("summary.duplicates")}: ${exact} | ${t("summary.similar")}: ${similar}`;
dupAlert.onclick = () => {
try {
actions?.onDuplicateAlertClick?.(alert);
} catch (e) { console.debug?.(e); }
};
}
}
} catch (e) { console.debug?.(e); }
+4 -1
View File
@@ -18,14 +18,17 @@ export function createTabsView() {
const tabInputs = makeTab(t("tab.input"), "input", t("tooltip.tab.input"));
const tabOutputs = makeTab(t("tab.output"), "output", t("tooltip.tab.output"));
const tabCustom = makeTab(t("tab.custom"), "custom", t("tooltip.tab.custom"));
const tabSimilar = makeTab(t("tab.similar", "Similar"), "similar", t("tooltip.tab.similar", "Browse current similar findings"));
tabSimilar.style.display = "none";
tabs.appendChild(tabAll);
tabs.appendChild(tabInputs);
tabs.appendChild(tabOutputs);
tabs.appendChild(tabCustom);
tabs.appendChild(tabSimilar);
return {
tabs,
tabButtons: { tabAll, tabInputs, tabOutputs, tabCustom }
tabButtons: { tabAll, tabInputs, tabOutputs, tabCustom, tabSimilar }
};
}
+73 -11
View File
@@ -5,6 +5,7 @@
import { get, getToolsStatus, post, resetIndex, getWatcherStatus, forceDeleteDb, listDbBackups, saveDbBackup, restoreDbBackup, vectorBackfill } from "../../api/client.js";
import { ENDPOINTS } from "../../api/endpoints.js";
import { APP_CONFIG } from "../../app/config.js";
import { comfyConfirm } from "../../app/dialogs.js";
import { comfyToast } from "../../app/toast.js";
import { t } from "../../app/i18n.js";
import { getEnrichmentState, setEnrichmentState } from "../../app/runtimeState.js";
@@ -180,6 +181,16 @@ function emitGlobalGridReload(reason = "status-action") {
} catch (e) { console.debug?.(e); }
}
async function askKeepVectors(actionLabel = "") {
return await comfyConfirm(
t(
"dialog.vectorsReset.keepQuestion",
"Keep existing AI vectors?\n\nConfirm = keep vectors\nCancel = continue without vectors"
),
actionLabel || t("dialog.vectorsReset.title", "AI vectors")
);
}
/**
* Create status indicator section
*/
@@ -362,7 +373,10 @@ export function createStatusIndicator(options = {}) {
comfyToast(t("toast.dbRestoreSelect", "Select a DB backup first"), "warning", 2000);
return;
}
const confirmed = confirm(t("dialog.dbRestore.confirm", "Restore selected DB backup? Current DB will be replaced."));
const confirmed = await comfyConfirm(
t("dialog.dbRestore.confirm", "Restore selected DB backup? Current DB will be replaced."),
t("btn.dbRestore", "Restore DB")
);
if (!confirmed) return;
comfyToast(t("toast.dbRestoreStarted", "DB restore started"), "info", 1800);
setMaintenanceActive(true);
@@ -453,6 +467,17 @@ export function createStatusIndicator(options = {}) {
};
resetBtn.onclick = async (event) => {
event.stopPropagation();
const preserveVectors = await askKeepVectors(t("btn.resetIndex", "Reset index"));
const confirmed = await comfyConfirm(
preserveVectors
? t(
"dialog.resetIndex.confirmKeepVectors",
"This will reset index data and rescan files while keeping existing AI vectors.\n\nContinue?"
)
: t("dialog.resetIndex.msg", "This will delete the database and rescan all files. Continue?"),
t("dialog.resetIndex.title", "Reset index?")
);
if (!confirmed) return;
setMaintenanceActive(true);
const originalText = resetBtn.textContent;
@@ -476,11 +501,12 @@ export function createStatusIndicator(options = {}) {
customRootId: isCustomBrowserMode ? null : customRootId,
reindex: true,
maintenance_force: true,
hard_reset_db: isAll,
hard_reset_db: isAll && !preserveVectors,
clear_scan_journal: true,
clear_metadata_cache: true,
clear_asset_metadata: true,
clear_assets: true,
clear_assets: !preserveVectors,
preserve_vectors: preserveVectors,
rebuild_fts: true,
});
if (res?.ok) {
@@ -593,7 +619,11 @@ export function createStatusIndicator(options = {}) {
const errors = Number(res?.data?.errors ?? progress?.errors ?? 0);
if (pending) {
const jobId = String(res?.data?.job_id || "").trim();
const msg = `Vector backfill is still running in background${jobId ? ` (job ${jobId.slice(0, 8)})` : ""}.`;
const msg = t(
"toast.vectorBackfillRunning",
"Vector backfill still running in background{job}.",
{ job: jobId ? ` (job ${jobId.slice(0, 8)})` : "" }
);
comfyToast(msg, "info", 4200);
setActionLog(
`Backfill running in background — candidates ${processed}, indexed ${indexed}, skipped ${skipped}, errors ${errors}`,
@@ -602,13 +632,21 @@ export function createStatusIndicator(options = {}) {
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
applyStatusHighlight(section, "info");
} else {
comfyToast(`Vector backfill done — processed ${processed}, indexed ${indexed}, skipped ${skipped}`, "success", 3000);
comfyToast(
t(
"toast.vectorBackfillComplete",
"Vector backfill complete! Processed: {processed}, Indexed: {indexed}, Skipped: {skipped}",
{ processed, indexed, skipped }
),
"success",
3000
);
setActionLog(`Backfill OK — processed ${processed}, indexed ${indexed}, skipped ${skipped}`, "success");
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
applyStatusHighlight(section, "success");
}
} else {
const err = String(res?.error || "Backfill failed");
const err = String(res?.error || t("toast.vectorBackfillFailedGeneric", "Backfill failed"));
const code = String(res?.code || "").trim();
const status = Number(res?.status || 0) || 0;
const detail = [
@@ -671,21 +709,45 @@ export function createStatusIndicator(options = {}) {
};
deleteDbBtn.onclick = async (event) => {
event.stopPropagation();
const confirmed = confirm(t("dialog.dbDelete.confirm"));
const preserveVectors = await askKeepVectors(t("btn.deleteDb", "Delete DB"));
const confirmed = await comfyConfirm(
preserveVectors
? t(
"dialog.dbDelete.keepVectorsConfirm",
"This will reset index data and keep existing AI vectors. Database files will not be force-deleted.\n\nContinue?"
)
: t(
"dialog.dbDelete.confirm",
"This will permanently delete the index database and rebuild it from scratch. All ratings, tags, and cached metadata will be lost.\n\nContinue?"
),
t("btn.deleteDb", "Delete DB")
);
if (!confirmed) return;
setMaintenanceActive(true);
const originalText = deleteDbBtn.textContent;
deleteDbBtn.disabled = true;
deleteDbBtn.textContent = t("btn.deletingDb");
deleteDbBtn.textContent = preserveVectors ? t("btn.resetting", "Resetting...") : t("btn.deletingDb");
resetBtn.disabled = true;
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
applyStatusHighlight(section, "info");
try {
const res = await forceDeleteDb();
const res = preserveVectors
? await resetIndex({
scope: "all",
reindex: true,
maintenance_force: true,
hard_reset_db: false,
clear_scan_journal: true,
clear_metadata_cache: true,
clear_asset_metadata: true,
clear_assets: false,
preserve_vectors: true,
rebuild_fts: true,
})
: await forceDeleteDb();
if (res?.ok) {
globalThis._mjrCorruptToastShown = false;
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
@@ -702,7 +764,7 @@ export function createStatusIndicator(options = {}) {
deleteDbBtn.disabled = false;
deleteDbBtn.textContent = originalText;
resetBtn.disabled = false;
emitGlobalGridReload("db-force-delete");
emitGlobalGridReload(preserveVectors ? "index-reset-preserve-vectors" : "db-force-delete");
try {
const target = getScanContext ? getScanContext() : null;
await updateStatus(statusDot, statusText, capabilities, target, null, { force: true });
+423 -91
View File
@@ -24,6 +24,7 @@ const MFV_ZOOM_MIN = 0.25;
const MFV_ZOOM_MAX = 8;
const MFV_ZOOM_FACTOR = 0.0008; // multiplied by deltaY per wheel tick
const MFV_RESIZE_EDGE_HIT_PX = 8;
let _mfvInstanceSeq = 0;
// Media extensions for explicit kind detection.
const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
@@ -184,10 +185,13 @@ function _canvasLabel(ctx, text, x, y) {
export class FloatingViewer {
constructor() {
this._instanceId = ++_mfvInstanceSeq;
this.element = null;
this.isVisible = false;
this._contentEl = null;
this._closeBtn = null;
this._modeBtn = null;
this._pinSelect = null;
this._liveBtn = null;
this._genBtn = null;
this._genDropdown = null;
@@ -196,6 +200,7 @@ export class FloatingViewer {
this._mode = MFV_MODES.SIMPLE;
this._mediaA = null;
this._mediaB = null;
this._pinnedSlot = null;
this._abDividerX = 0.5; // 0..1
// Pan/zoom state
@@ -207,7 +212,7 @@ export class FloatingViewer {
// AbortController for toolbar/header button click listeners (NM-1).
// Aborted in dispose() so listeners are cleaned up without needing named references.
this._btnAC = new AbortController();
this._btnAC = null;
// Generation counter: incremented on every loadMediaA/loadMediaPair call so
// stale async metadata enrichment results can be discarded (NM-2).
this._refreshGen = 0;
@@ -216,6 +221,9 @@ export class FloatingViewer {
this._popoutWindow = null;
this._popoutBtn = null;
this._isPopped = false;
this._popoutCloseHandler = null;
this._popoutCloseTimer = null;
this._popoutRestoreGuard = false;
// Preview stream state: button ref + last blob URL for cleanup.
this._previewBtn = null;
@@ -225,6 +233,10 @@ export class FloatingViewer {
// Panel-level listeners and edge-resize state.
this._panelAC = new AbortController();
this._resizeState = null;
this._titleId = `mjr-mfv-title-${this._instanceId}`;
this._genDropdownId = `mjr-mfv-gen-dropdown-${this._instanceId}`;
this._docClickHost = null;
this._handleDocClick = null;
}
// ── Build DOM ─────────────────────────────────────────────────────────────
@@ -232,17 +244,22 @@ export class FloatingViewer {
render() {
const el = document.createElement("div");
el.className = "mjr-mfv";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "false");
el.setAttribute("aria-hidden", "true");
this.element = el;
el.appendChild(this._buildHeader());
el.setAttribute("aria-labelledby", this._titleId);
el.appendChild(this._buildToolbar());
this._contentEl = document.createElement("div");
this._contentEl.className = "mjr-mfv-content";
el.appendChild(this._contentEl);
this._initEdgeResize(el);
this._initDrag(el.querySelector(".mjr-mfv-header"));
this._rebindControlHandlers();
this._bindPanelInteractions();
this._bindDocumentUiHandlers();
this._refresh();
return el;
}
@@ -253,19 +270,20 @@ export class FloatingViewer {
const title = document.createElement("span");
title.className = "mjr-mfv-header-title";
title.id = this._titleId;
title.textContent = "Majoor Viewer Lite";
const closeBtn = document.createElement("button");
this._closeBtn = closeBtn;
closeBtn.type = "button";
closeBtn.className = "mjr-icon-btn";
closeBtn.title = t("tooltip.closeViewer", "Close viewer");
const closeLabel = t("tooltip.closeViewer", "Close viewer");
closeBtn.title = closeLabel;
closeBtn.setAttribute("aria-label", closeLabel);
const _closeBtnIcon = document.createElement("i");
_closeBtnIcon.className = "pi pi-times";
_closeBtnIcon.setAttribute("aria-hidden", "true");
closeBtn.appendChild(_closeBtnIcon);
closeBtn.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_CLOSE));
}, { signal: this._btnAC.signal });
header.appendChild(title);
header.appendChild(closeBtn);
@@ -280,10 +298,26 @@ export class FloatingViewer {
this._modeBtn = document.createElement("button");
this._modeBtn.type = "button";
this._modeBtn.className = "mjr-icon-btn";
this._modeBtn.addEventListener("click", () => this._cycleMode(), { signal: this._btnAC.signal });
this._updateModeBtnUI();
bar.appendChild(this._modeBtn);
this._pinSelect = document.createElement("select");
this._pinSelect.className = "mjr-mfv-pin-select";
this._pinSelect.setAttribute("aria-label", "Pin Reference");
this._pinSelect.value = this._pinnedSlot || "";
for (const { value, label } of [
{ value: "", label: "No Pin" },
{ value: "A", label: "Pin A" },
{ value: "B", label: "Pin B" },
]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
this._pinSelect.appendChild(option);
}
this._updatePinSelectUI();
bar.appendChild(this._pinSelect);
// Separator
const sep = document.createElement("div");
sep.className = "mjr-mfv-toolbar-sep";
@@ -294,40 +328,32 @@ export class FloatingViewer {
this._liveBtn = document.createElement("button");
this._liveBtn.type = "button";
this._liveBtn.className = "mjr-icon-btn";
this._liveBtn.title = t("tooltip.liveStreamOff", "Live Stream: OFF — click to follow");
this._liveBtn.innerHTML = '<i class="pi pi-circle" aria-hidden="true"></i>';
this._liveBtn.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_LIVE_TOGGLE));
}, { signal: this._btnAC.signal });
this._liveBtn.setAttribute("aria-pressed", "false");
this._liveBtn.setAttribute("aria-label", t("tooltip.liveStreamOff", "Live Stream: OFF — click to follow"));
this._liveBtn.title = t("tooltip.liveStreamOff", "Live Stream: OFF — click to follow");
bar.appendChild(this._liveBtn);
// KSampler Preview Stream toggle
this._previewBtn = document.createElement("button");
this._previewBtn.type = "button";
this._previewBtn.className = "mjr-icon-btn";
this._previewBtn.title = t("tooltip.previewStreamOff", "KSampler Preview: OFF — click to stream denoising steps");
this._previewBtn.innerHTML = '<i class="pi pi-eye" aria-hidden="true"></i>';
this._previewBtn.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_PREVIEW_TOGGLE));
}, { signal: this._btnAC.signal });
this._previewBtn.setAttribute("aria-pressed", "false");
this._previewBtn.setAttribute("aria-label", t("tooltip.previewStreamOff", "KSampler Preview: OFF — click to stream denoising steps"));
this._previewBtn.title = t("tooltip.previewStreamOff", "KSampler Preview: OFF — click to stream denoising steps");
bar.appendChild(this._previewBtn);
// Gen Info button (shows dropdown with checkboxes)
this._genBtn = document.createElement("button");
this._genBtn.type = "button";
this._genBtn.className = "mjr-icon-btn";
this._genBtn.setAttribute("aria-haspopup", "dialog");
this._genBtn.setAttribute("aria-expanded", "false");
const _genBtnIcon = document.createElement("i");
_genBtnIcon.className = "pi pi-info-circle";
_genBtnIcon.setAttribute("aria-hidden", "true");
this._genBtn.appendChild(_genBtnIcon);
this._genBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this._genDropdown?.classList?.contains("is-visible")) {
this._closeGenDropdown();
} else {
this._openGenDropdown();
}
}, { signal: this._btnAC.signal });
bar.appendChild(this._genBtn);
this._updateGenBtnUI();
@@ -335,46 +361,117 @@ export class FloatingViewer {
this._popoutBtn = document.createElement("button");
this._popoutBtn.type = "button";
this._popoutBtn.className = "mjr-icon-btn";
this._popoutBtn.title = t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
const popoutLabel = t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
this._popoutBtn.title = popoutLabel;
this._popoutBtn.setAttribute("aria-label", popoutLabel);
this._popoutBtn.setAttribute("aria-pressed", "false");
const _popoutIcon = document.createElement("i");
_popoutIcon.className = "pi pi-external-link";
_popoutIcon.setAttribute("aria-hidden", "true");
this._popoutBtn.appendChild(_popoutIcon);
this._popoutBtn.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_POPOUT));
}, { signal: this._btnAC.signal });
bar.appendChild(this._popoutBtn);
// Download / capture button
this._captureBtn = document.createElement("button");
this._captureBtn.type = "button";
this._captureBtn.className = "mjr-icon-btn";
this._captureBtn.title = t("tooltip.captureView", "Save view as image");
const captureLabel = t("tooltip.captureView", "Save view as image");
this._captureBtn.title = captureLabel;
this._captureBtn.setAttribute("aria-label", captureLabel);
const _captureBtnIcon = document.createElement("i");
_captureBtnIcon.className = "pi pi-download";
_captureBtnIcon.setAttribute("aria-hidden", "true");
this._captureBtn.appendChild(_captureBtnIcon);
this._captureBtn.addEventListener("click", () => this._captureView(), { signal: this._btnAC.signal });
bar.appendChild(this._captureBtn);
// Close dropdown when clicking outside
this._handleDocClick = (ev) => {
if (!this._genDropdown) return;
if (ev.target === this._genBtn) return;
if (this._genDropdown.contains(ev.target)) return;
const target = ev?.target;
if (this._genBtn?.contains?.(target)) return;
if (this._genDropdown.contains(target)) return;
this._closeGenDropdown();
};
document.addEventListener("click", this._handleDocClick);
this._bindDocumentUiHandlers();
return bar;
}
_rebindControlHandlers() {
try { this._btnAC?.abort(); } catch (e) { console.debug?.(e); }
this._btnAC = new AbortController();
const signal = this._btnAC.signal;
this._closeBtn?.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_CLOSE));
}, { signal });
this._modeBtn?.addEventListener("click", () => this._cycleMode(), { signal });
this._pinSelect?.addEventListener("change", (e) => {
this._pinnedSlot = e?.target?.value || null;
if (this._pinnedSlot && this._mode === MFV_MODES.SIMPLE) {
this.setMode(MFV_MODES.AB);
}
this._updatePinSelectUI();
}, { signal });
this._liveBtn?.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_LIVE_TOGGLE));
}, { signal });
this._previewBtn?.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_PREVIEW_TOGGLE));
}, { signal });
this._genBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (this._genDropdown?.classList?.contains("is-visible")) {
this._closeGenDropdown();
} else {
this._openGenDropdown();
}
}, { signal });
this._popoutBtn?.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_POPOUT));
}, { signal });
this._captureBtn?.addEventListener("click", () => this._captureView(), { signal });
}
_resetGenDropdownForCurrentDocument() {
this._closeGenDropdown();
try { this._genDropdown?.remove?.(); } catch (e) { console.debug?.(e); }
this._genDropdown = null;
this._updateGenBtnUI();
}
_bindDocumentUiHandlers() {
if (!this._handleDocClick) return;
const doc = this.element?.ownerDocument || document;
if (this._docClickHost === doc) return;
this._unbindDocumentUiHandlers();
doc.addEventListener("click", this._handleDocClick);
this._docClickHost = doc;
}
_unbindDocumentUiHandlers() {
if (!this._docClickHost || !this._handleDocClick) return;
try { this._docClickHost.removeEventListener("click", this._handleDocClick); } catch (e) { console.debug?.(e); }
this._docClickHost = null;
}
_isGenDropdownOpen() {
return !!this._genDropdown?.classList?.contains("is-visible");
}
_openGenDropdown() {
if (!this.element) return;
if (!this._genDropdown) {
this._genDropdown = this._buildGenDropdown();
this.element.appendChild(this._genDropdown);
}
this._bindDocumentUiHandlers();
const rect = this._genBtn.getBoundingClientRect();
const parentRect = this.element.getBoundingClientRect();
const left = rect.left - parentRect.left;
@@ -382,11 +479,13 @@ export class FloatingViewer {
this._genDropdown.style.left = `${left}px`;
this._genDropdown.style.top = `${top}px`;
this._genDropdown.classList.add("is-visible");
this._updateGenBtnUI();
}
_closeGenDropdown() {
if (!this._genDropdown) return;
this._genDropdown.classList.remove("is-visible");
this._updateGenBtnUI();
}
/** Reflect how many fields are enabled on the gen info button. */
@@ -394,15 +493,28 @@ export class FloatingViewer {
if (!this._genBtn) return;
const count = this._genInfoSelections.size;
const isOn = count > 0;
const isOpen = this._isGenDropdownOpen();
this._genBtn.classList.toggle("is-on", isOn);
this._genBtn.title = isOn
? `Gen Info (${count} field${count > 1 ? "s" : ""} shown) — click to configure`
: "Gen Info — click to show overlay";
this._genBtn.classList.toggle("is-open", isOpen);
const label = isOn
? `Gen Info (${count} field${count > 1 ? "s" : ""} shown)${isOpen ? " — open" : " — click to configure"}`
: `Gen Info${isOpen ? " — open" : " — click to show overlay"}`;
this._genBtn.title = label;
this._genBtn.setAttribute("aria-label", label);
this._genBtn.setAttribute("aria-expanded", String(isOpen));
if (this._genDropdown) {
this._genBtn.setAttribute("aria-controls", this._genDropdownId);
} else {
this._genBtn.removeAttribute("aria-controls");
}
}
_buildGenDropdown() {
const d = document.createElement("div");
d.className = "mjr-mfv-gen-dropdown";
d.id = this._genDropdownId;
d.setAttribute("role", "group");
d.setAttribute("aria-label", "Generation info fields");
const opts = [
["prompt", "Prompt"],
["seed", "Seed"],
@@ -552,6 +664,22 @@ export class FloatingViewer {
this._refresh();
}
getPinnedSlot() {
return this._pinnedSlot;
}
_updatePinSelectUI() {
if (!this._pinSelect) return;
const pinned = this._pinnedSlot === "A" || this._pinnedSlot === "B";
this._pinSelect.value = this._pinnedSlot || "";
this._pinSelect.classList.toggle("is-pinned", pinned);
const label = pinned
? `Pin Reference: ${this._pinnedSlot}`
: "Pin Reference: Off";
this._pinSelect.title = label;
this._pinSelect.setAttribute("aria-label", label);
}
_updateModeBtnUI() {
if (!this._modeBtn) return;
const cfg = {
@@ -565,25 +693,33 @@ export class FloatingViewer {
_modeBtnIcon.setAttribute("aria-hidden", "true");
this._modeBtn.replaceChildren(_modeBtnIcon);
this._modeBtn.title = label;
this._modeBtn.setAttribute("aria-label", label);
this._modeBtn.removeAttribute("aria-pressed");
}
// ── Live Stream UI ────────────────────────────────────────────────────────
setLiveActive(active) {
if (!this._liveBtn) return;
this._liveBtn.classList.toggle("mjr-live-active", Boolean(active));
if (active) {
const isActive = Boolean(active);
this._liveBtn.classList.toggle("mjr-live-active", isActive);
const label = isActive
? t("tooltip.liveStreamOn", "Live Stream: ON — click to disable")
: t("tooltip.liveStreamOff", "Live Stream: OFF — click to follow");
this._liveBtn.setAttribute("aria-pressed", String(isActive));
this._liveBtn.setAttribute("aria-label", label);
if (isActive) {
const _liveIconActive = document.createElement("i");
_liveIconActive.className = "pi pi-circle-fill";
_liveIconActive.setAttribute("aria-hidden", "true");
this._liveBtn.replaceChildren(_liveIconActive);
this._liveBtn.title = t("tooltip.liveStreamOn", "Live Stream: ON — click to disable");
this._liveBtn.title = label;
} else {
const _liveIconInactive = document.createElement("i");
_liveIconInactive.className = "pi pi-circle";
_liveIconInactive.setAttribute("aria-hidden", "true");
this._liveBtn.replaceChildren(_liveIconInactive);
this._liveBtn.title = t("tooltip.liveStreamOff", "Live Stream: OFF — click to follow");
this._liveBtn.title = label;
}
}
@@ -592,19 +728,24 @@ export class FloatingViewer {
setPreviewActive(active) {
this._previewActive = Boolean(active);
if (!this._previewBtn) return;
this._previewBtn.classList.toggle("mjr-live-active", this._previewActive);
this._previewBtn.classList.toggle("mjr-preview-active", this._previewActive);
const label = this._previewActive
? t("tooltip.previewStreamOn", "KSampler Preview: ON — streaming denoising steps")
: t("tooltip.previewStreamOff", "KSampler Preview: OFF — click to stream denoising steps");
this._previewBtn.setAttribute("aria-pressed", String(this._previewActive));
this._previewBtn.setAttribute("aria-label", label);
if (this._previewActive) {
const icon = document.createElement("i");
icon.className = "pi pi-eye";
icon.setAttribute("aria-hidden", "true");
this._previewBtn.replaceChildren(icon);
this._previewBtn.title = t("tooltip.previewStreamOn", "KSampler Preview: ON — streaming denoising steps");
this._previewBtn.title = label;
} else {
const icon = document.createElement("i");
icon.className = "pi pi-eye-slash";
icon.setAttribute("aria-hidden", "true");
this._previewBtn.replaceChildren(icon);
this._previewBtn.title = t("tooltip.previewStreamOff", "KSampler Preview: OFF — click to stream denoising steps");
this._previewBtn.title = label;
// Revoke last blob URL when turning off
this._revokePreviewBlob();
}
@@ -1024,7 +1165,9 @@ export class FloatingViewer {
show() {
if (!this.element) return;
this._bindDocumentUiHandlers();
this.element.classList.add("is-visible");
this.element.setAttribute("aria-hidden", "false");
this.isVisible = true;
}
@@ -1034,7 +1177,9 @@ export class FloatingViewer {
// even if hide() is called mid-drag (NM-5).
this._destroyPanZoom();
this._stopEdgeResize();
this._closeGenDropdown();
this.element.classList.remove("is-visible");
this.element.setAttribute("aria-hidden", "true");
this.isVisible = false;
}
@@ -1042,8 +1187,8 @@ export class FloatingViewer {
/**
* Move the viewer into a separate browser window so it can be
* dragged onto a second monitor. Uses adoptNode() to transfer
* the live DOM tree — all JS references and listeners stay valid.
* dragged onto a second monitor. Opens a dedicated same-origin page,
* then adopts the live DOM tree into that page so state/listeners persist.
*/
popOut() {
if (this._isPopped || !this.element) return;
@@ -1058,34 +1203,201 @@ export class FloatingViewer {
const left = (window.screenX || window.screenLeft) + Math.round((window.outerWidth - w) / 2);
const top = (window.screenY || window.screenTop) + Math.round((window.outerHeight - h) / 2);
const features = `width=${w},height=${h},left=${left},top=${top},resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no`;
const popup = window.open("", "_mjr_viewer", features);
const popup = window.open(this._getPopoutUrl(), "_mjr_viewer", features);
if (!popup) {
console.warn("[MFV] Pop-out blocked — allow popups for this site.");
return;
}
this._popoutWindow = popup;
this._isPopped = true;
this._popoutRestoreGuard = false;
let mounted = false;
const handlePopupClosing = () => this._schedulePopInFromPopupClose();
this._popoutCloseHandler = handlePopupClosing;
// ----- Build the popup document -----
const doc = popup.document;
doc.title = "Majoor Viewer";
const mountViewer = () => {
if (mounted) return;
const doc = popup.document;
if (!doc) return;
if (!this._ensurePopoutShell(doc)) return;
doc.title = "Majoor Viewer";
this._installPopoutStyles(doc);
const root = doc.getElementById("mjr-mfv-popout-root") || doc.body;
if (!root) return;
try { root.replaceChildren(); } catch (e) { console.debug?.(e); }
root.appendChild(doc.adoptNode(el));
el.classList.add("is-visible");
this.isVisible = true;
mounted = true;
this._resetGenDropdownForCurrentDocument();
this._rebindControlHandlers();
this._bindDocumentUiHandlers();
this._updatePopoutBtnUI();
};
// Copy ALL stylesheets from the parent page so themes, PrimeIcons,
// and MFV-specific CSS are available in the popup.
for (const ss of document.querySelectorAll('link[rel="stylesheet"], style')) {
doc.head.appendChild(doc.importNode(ss, true));
try {
popup.addEventListener("load", mountViewer, { once: true });
} catch (e) {
console.debug?.("[MFV] pop-out page mount failed", e);
}
if (popup.document?.readyState === "interactive" || popup.document?.readyState === "complete") {
setTimeout(mountViewer, 0);
}
// Override styles for the popped-out viewer (fill window, no fixed position)
const overrideStyle = doc.createElement("style");
overrideStyle.textContent = `
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #1e1e1e;
color: #ddd;
// When the user closes the popup window, automatically pop the viewer back in.
popup.addEventListener("beforeunload", handlePopupClosing);
popup.addEventListener("pagehide", handlePopupClosing);
popup.addEventListener("unload", handlePopupClosing);
this._startPopoutCloseWatch();
// Relay keyboard shortcuts from popup back to main window
popup.addEventListener("keydown", (e) => {
const tag = String(e?.target?.tagName || "").toLowerCase();
if (e?.defaultPrevented || e?.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") {
return;
}
window.dispatchEvent(new KeyboardEvent("keydown", {
key: e.key, code: e.code, keyCode: e.keyCode,
ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
}));
});
}
_clearPopoutCloseWatch() {
if (this._popoutCloseTimer == null) return;
try { window.clearInterval(this._popoutCloseTimer); } catch (e) { console.debug?.(e); }
this._popoutCloseTimer = null;
}
_startPopoutCloseWatch() {
this._clearPopoutCloseWatch();
this._popoutCloseTimer = window.setInterval(() => {
if (!this._isPopped) {
this._clearPopoutCloseWatch();
return;
}
const popup = this._popoutWindow;
if (!popup || popup.closed) {
this._clearPopoutCloseWatch();
this._schedulePopInFromPopupClose();
}
}, 250);
}
_schedulePopInFromPopupClose() {
if (!this._isPopped || this._popoutRestoreGuard) return;
this._popoutRestoreGuard = true;
window.setTimeout(() => {
try {
this.popIn({ closePopupWindow: false });
} finally {
this._popoutRestoreGuard = false;
}
}, 0);
}
_getPopoutUrl() {
try {
return new URL("/mjr/viewer/popout", window.location.href).toString();
} catch {
return "/mjr/viewer/popout";
}
}
_buildPopoutShellHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1e1e1e">
<title>Majoor Viewer</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
color: #ddd;
font-family: system-ui, sans-serif;
}
body {
display: flex;
min-height: 100vh;
}
#mjr-mfv-popout-root {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
align-items: stretch;
justify-content: stretch;
background:
radial-gradient(circle at top, rgba(95, 179, 255, 0.12), transparent 35%),
linear-gradient(180deg, #1a1a1a 0%, #101010 100%);
}
.mjr-mfv-popout-loading {
margin: auto;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(20,20,20,0.82);
font-size: 12px;
letter-spacing: 0.02em;
opacity: 0.84;
}
</style>
</head>
<body>
<div id="mjr-mfv-popout-root">
<div class="mjr-mfv-popout-loading">Preparing viewer...</div>
</div>
</body>
</html>`;
}
_ensurePopoutShell(doc) {
if (!doc) return false;
try {
if (doc.getElementById("mjr-mfv-popout-root")) return true;
doc.open();
doc.write(this._buildPopoutShellHtml());
doc.close();
return !!doc.getElementById("mjr-mfv-popout-root");
} catch (e) {
console.debug?.("[MFV] pop-out shell init failed", e);
return false;
}
}
_installPopoutStyles(doc) {
if (!doc?.head) return;
try {
for (const existing of doc.head.querySelectorAll("[data-mjr-popout-cloned-style='1']")) {
existing.remove();
}
} catch (e) { console.debug?.(e); }
for (const ss of document.querySelectorAll('link[rel="stylesheet"], style')) {
try {
let clone = null;
if (ss.tagName === "LINK") {
clone = doc.createElement("link");
for (const attr of Array.from(ss.attributes || [])) {
if (attr?.name === "href") continue;
clone.setAttribute(attr.name, attr.value);
}
clone.setAttribute("href", ss.href || ss.getAttribute("href") || "");
} else {
clone = doc.importNode(ss, true);
}
clone.setAttribute("data-mjr-popout-cloned-style", "1");
doc.head.appendChild(clone);
} catch (e) { console.debug?.(e); }
}
const overrideStyle = doc.createElement("style");
overrideStyle.setAttribute("data-mjr-popout-cloned-style", "1");
overrideStyle.textContent = `
.mjr-mfv {
position: static !important;
width: 100% !important;
@@ -1100,46 +1412,48 @@ export class FloatingViewer {
}
`;
doc.head.appendChild(overrideStyle);
// Adopt the existing DOM node into the popup document
doc.body.appendChild(doc.adoptNode(el));
// Force visible (it may have been hidden if toggled before pop-out)
el.classList.add("is-visible");
this.isVisible = true;
// Update pop-out button to show "pop-in" icon
this._updatePopoutBtnUI();
// When the user closes the popup window, automatically pop the viewer back in.
popup.addEventListener("beforeunload", () => this.popIn());
// Relay keyboard shortcuts from popup back to main window
popup.addEventListener("keydown", (e) => {
window.dispatchEvent(new KeyboardEvent("keydown", {
key: e.key, code: e.code, keyCode: e.keyCode,
ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
}));
});
}
/**
* Move the viewer back into the main ComfyUI page from the popup window.
*/
popIn() {
popIn({ closePopupWindow = true } = {}) {
if (!this._isPopped || !this.element) return;
const popup = this._popoutWindow;
const closeHandler = this._popoutCloseHandler;
this._clearPopoutCloseWatch();
if (popup && closeHandler) {
try { popup.removeEventListener("beforeunload", closeHandler); } catch (e) { console.debug?.(e); }
try { popup.removeEventListener("pagehide", closeHandler); } catch (e) { console.debug?.(e); }
try { popup.removeEventListener("unload", closeHandler); } catch (e) { console.debug?.(e); }
}
this._popoutCloseHandler = null;
this._isPopped = false;
// Re-adopt the element into the main document and append to body
const adopted = document.adoptNode(this.element);
let adopted = this.element;
try {
adopted = adopted?.ownerDocument === document ? adopted : document.adoptNode(adopted);
} catch (e) {
console.debug?.("[MFV] pop-in adopt failed", e);
}
document.body.appendChild(adopted);
this._resetGenDropdownForCurrentDocument();
this._rebindControlHandlers();
this._bindPanelInteractions();
this._bindDocumentUiHandlers();
adopted.classList.add("is-visible");
adopted.setAttribute("aria-hidden", "false");
this.isVisible = true;
// Restore inline position styles (they were cleared by the popup override)
// The viewer will revert to its normal fixed-position CSS.
this._updatePopoutBtnUI();
// Close the popup window if it's still open
try { this._popoutWindow?.close(); } catch (e) { console.debug?.(e); }
if (closePopupWindow) {
try { popup?.close(); } catch (e) { console.debug?.(e); }
}
this._popoutWindow = null;
}
@@ -1151,13 +1465,17 @@ export class FloatingViewer {
}
this._popoutBtn.classList.toggle("mjr-popin-active", this._isPopped);
const icon = this._popoutBtn.querySelector("i") || document.createElement("i");
const label = this._isPopped
? t("tooltip.popInViewer", "Return viewer to ComfyUI window")
: t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
if (this._isPopped) {
icon.className = "pi pi-sign-in";
this._popoutBtn.title = t("tooltip.popInViewer", "Return viewer to ComfyUI window");
} else {
icon.className = "pi pi-external-link";
this._popoutBtn.title = t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
}
this._popoutBtn.title = label;
this._popoutBtn.setAttribute("aria-label", label);
this._popoutBtn.setAttribute("aria-pressed", String(this._isPopped));
if (!this._popoutBtn.contains(icon)) {
this._popoutBtn.replaceChildren(icon);
}
@@ -1207,6 +1525,15 @@ export class FloatingViewer {
this.element.style.cursor = "";
}
_bindPanelInteractions() {
if (!this.element) return;
this._stopEdgeResize();
try { this._panelAC?.abort(); } catch (e) { console.debug?.(e); }
this._panelAC = new AbortController();
this._initEdgeResize(this.element);
this._initDrag(this.element.querySelector(".mjr-mfv-header"));
}
_initEdgeResize(el) {
if (!el) return;
const resolveDir = (e) => {
@@ -1319,9 +1646,11 @@ export class FloatingViewer {
_initDrag(handle) {
if (!handle) return;
const signal = this._panelAC?.signal;
handle.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
if (e.target.closest("button")) return; // Don't drag when clicking buttons
if (e.target.closest("select")) return;
if (this._isPopped || !this.element) return;
// Let edge-resize take precedence when pointer is near panel borders.
const edgeDir = this._getResizeDirectionFromPoint(
@@ -1352,7 +1681,7 @@ export class FloatingViewer {
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
});
}, signal ? { signal } : undefined);
}
// ── Canvas capture ────────────────────────────────────────────────────────
@@ -1621,6 +1950,7 @@ export class FloatingViewer {
dispose() {
this._destroyPanZoom();
this._stopEdgeResize();
this._clearPopoutCloseWatch();
try { this._panelAC?.abort(); this._panelAC = null; } catch (e) { console.debug?.(e); }
// Abort all button click listeners in one call (NM-1).
try { this._btnAC?.abort(); this._btnAC = null; } catch (e) { console.debug?.(e); }
@@ -1630,11 +1960,13 @@ export class FloatingViewer {
try { this.element?.remove(); } catch (e) { console.debug?.(e); }
this.element = null;
this._contentEl = null;
this._closeBtn = null;
this._modeBtn = null;
this._pinSelect = null;
this._liveBtn = null;
this._popoutBtn = null;
this._captureBtn = null;
try { document.removeEventListener("click", this._handleDocClick); } catch (e) { console.debug?.(e); }
this._unbindDocumentUiHandlers();
try { this._genDropdown?.remove(); } catch (e) { console.debug?.(e); }
this._mediaA = null;
this._mediaB = null;
+33 -7
View File
@@ -40,6 +40,18 @@ function _cancelFetch() {
_fetchAC = null;
}
function _emitVisibilityChanged(visible) {
window.dispatchEvent(new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, {
detail: { visible: Boolean(visible) },
}));
}
function _syncViewerControls(inst) {
if (!inst) return;
inst.setLiveActive(_liveActive);
inst.setPreviewActive(_previewActive);
}
/**
* When only 1 asset is selected and the MFV is in a compare mode,
* look up the adjacent card in the rendered grid DOM to use as slot B.
@@ -78,22 +90,30 @@ async function _loadFromIds(selectedIds) {
_fetchAC = ac;
try {
const pinnedSlot = _instance.getPinnedSlot();
let ids = selectedIds.slice(0, 2);
// Compare-mode fallback: if only 1 asset, auto-pick the adjacent grid item for slot B.
const mode = _instance._mode;
if (ids.length === 1 && (mode === MFV_MODES.AB || mode === MFV_MODES.SIDE)) {
if (pinnedSlot && (mode === MFV_MODES.AB || mode === MFV_MODES.SIDE)) {
// With a pinned side, only one new asset is needed.
ids = [ids[0]];
} else if (ids.length === 1 && (mode === MFV_MODES.AB || mode === MFV_MODES.SIDE)) {
// Compare-mode fallback: if only 1 asset, auto-pick the adjacent grid item for slot B.
const adjId = _findAdjacentGridId(ids[0]);
if (adjId) ids = [ids[0], adjId];
}
const result = await getAssetsBatch(ids);
const result = await getAssetsBatch(ids, ac ? { signal: ac.signal } : {});
if (ac?.signal.aborted) return;
if (!result?.ok || !Array.isArray(result.data) || !result.data.length) return;
if (!_instance) return; // disposed while fetching
const assets = result.data;
if (ids.length >= 2 && assets.length >= 2) {
if (pinnedSlot === "A" && _instance._mediaA) {
_instance.loadMediaPair(_instance._mediaA, assets[0]);
} else if (pinnedSlot === "B" && _instance._mediaB) {
_instance.loadMediaPair(assets[0], _instance._mediaB);
} else if (ids.length >= 2 && assets.length >= 2) {
_instance.loadMediaPair(assets[0], assets[1]);
} else {
_instance.loadMediaA(assets[0], { autoMode: true });
@@ -164,17 +184,17 @@ export const floatingViewerManager = {
open() {
const inst = _getInstance();
inst.show();
inst.setLiveActive(_liveActive); // Sync button visual state
_syncViewerControls(inst);
_bindSelectionListener();
// KEY FIX: immediately show whatever is selected in the grid.
_syncCurrentGridSelection();
window.dispatchEvent(new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, { detail: { visible: true } }));
_emitVisibilityChanged(true);
},
close() {
if (_instance) _instance.hide();
_unbindSelectionListener();
window.dispatchEvent(new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, { detail: { visible: false } }));
_emitVisibilityChanged(false);
},
toggle() {
@@ -192,9 +212,12 @@ export const floatingViewerManager = {
*/
upsertWithContent(fileData) {
const inst = _getInstance();
const wasVisible = Boolean(inst.isVisible);
inst.show();
_syncViewerControls(inst);
_bindSelectionListener();
inst.loadMediaA(fileData, { autoMode: true });
if (!wasVisible) _emitVisibilityChanged(true);
},
setLiveActive(active) {
@@ -243,10 +266,13 @@ export const floatingViewerManager = {
feedPreviewBlob(blob) {
if (!_previewActive) return;
const inst = _getInstance();
const wasVisible = Boolean(inst.isVisible);
if (!inst.isVisible) {
inst.show();
}
_syncViewerControls(inst);
inst.loadPreviewBlob(blob);
if (!wasVisible) _emitVisibilityChanged(true);
},
};
+169
View File
@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../app/i18n.js", () => ({
t: (_key, fallback) => fallback,
}));
vi.mock("../app/events.js", () => ({
EVENTS: {},
}));
vi.mock("../api/endpoints.js", () => ({
buildViewURL: vi.fn(() => ""),
buildAssetViewURL: vi.fn(() => ""),
}));
vi.mock("../features/viewer/genInfo.js", () => ({
ensureViewerMetadataAsset: vi.fn(async (asset) => asset),
}));
vi.mock("../api/client.js", () => ({
getAssetMetadata: vi.fn(async () => ({})),
getFileMetadataScoped: vi.fn(async () => ({})),
}));
vi.mock("../components/sidebar/parsers/geninfoParser.js", () => ({
normalizeGenerationMetadata: vi.fn((meta) => meta),
}));
describe("FloatingViewer", () => {
beforeEach(() => {
globalThis.document = {
adoptNode: vi.fn((node) => node),
body: {
appendChild: vi.fn(),
},
createElement: vi.fn(() => ({
className: "",
setAttribute: vi.fn(),
appendChild: vi.fn(),
replaceChildren: vi.fn(),
})),
};
});
it("rebinds panel interactions when popping back into the main document", async () => {
const { FloatingViewer } = await import("../features/viewer/FloatingViewer.js");
const popup = {
removeEventListener: vi.fn(),
close: vi.fn(),
};
const element = {
ownerDocument: { name: "popup-doc" },
classList: {
add: vi.fn(),
},
setAttribute: vi.fn(),
};
const viewer = {
_isPopped: true,
element,
_popoutWindow: popup,
_popoutCloseHandler: vi.fn(),
_clearPopoutCloseWatch: vi.fn(),
_resetGenDropdownForCurrentDocument: vi.fn(),
_rebindControlHandlers: vi.fn(),
_bindPanelInteractions: vi.fn(),
_bindDocumentUiHandlers: vi.fn(),
_updatePopoutBtnUI: vi.fn(),
isVisible: false,
};
FloatingViewer.prototype.popIn.call(viewer, { closePopupWindow: false });
expect(document.adoptNode).toHaveBeenCalledWith(element);
expect(document.body.appendChild).toHaveBeenCalledWith(element);
expect(viewer._clearPopoutCloseWatch).toHaveBeenCalledTimes(1);
expect(popup.removeEventListener).toHaveBeenCalledTimes(3);
expect(viewer._resetGenDropdownForCurrentDocument).toHaveBeenCalledTimes(1);
expect(viewer._rebindControlHandlers).toHaveBeenCalledTimes(1);
expect(viewer._bindPanelInteractions).toHaveBeenCalledTimes(1);
expect(viewer._bindDocumentUiHandlers).toHaveBeenCalledTimes(1);
expect(viewer._updatePopoutBtnUI).toHaveBeenCalledTimes(1);
expect(element.classList.add).toHaveBeenCalledWith("is-visible");
expect(element.setAttribute).toHaveBeenCalledWith("aria-hidden", "false");
expect(viewer.isVisible).toBe(true);
expect(viewer._isPopped).toBe(false);
expect(viewer._popoutWindow).toBeNull();
expect(popup.close).not.toHaveBeenCalled();
});
it("refreshes drag and resize bindings when interactions are rebound", async () => {
const { FloatingViewer } = await import("../features/viewer/FloatingViewer.js");
const oldController = {
abort: vi.fn(),
};
const header = { nodeName: "HEADER" };
const element = {
querySelector: vi.fn(() => header),
};
const viewer = {
element,
_panelAC: oldController,
_stopEdgeResize: vi.fn(),
_initEdgeResize: vi.fn(),
_initDrag: vi.fn(),
};
FloatingViewer.prototype._bindPanelInteractions.call(viewer);
expect(viewer._stopEdgeResize).toHaveBeenCalledTimes(1);
expect(oldController.abort).toHaveBeenCalledTimes(1);
expect(viewer._panelAC).toBeInstanceOf(AbortController);
expect(viewer._panelAC).not.toBe(oldController);
expect(viewer._initEdgeResize).toHaveBeenCalledWith(element);
expect(element.querySelector).toHaveBeenCalledWith(".mjr-mfv-header");
expect(viewer._initDrag).toHaveBeenCalledWith(header);
});
it("rebinds toolbar and header controls with a fresh abort controller", async () => {
const { FloatingViewer } = await import("../features/viewer/FloatingViewer.js");
const oldController = {
abort: vi.fn(),
};
const closeBtn = { addEventListener: vi.fn() };
const modeBtn = { addEventListener: vi.fn() };
const pinSelect = { addEventListener: vi.fn() };
const liveBtn = { addEventListener: vi.fn() };
const previewBtn = { addEventListener: vi.fn() };
const genBtn = { addEventListener: vi.fn() };
const popoutBtn = { addEventListener: vi.fn() };
const captureBtn = { addEventListener: vi.fn() };
const viewer = {
_btnAC: oldController,
_closeBtn: closeBtn,
_modeBtn: modeBtn,
_pinSelect: pinSelect,
_liveBtn: liveBtn,
_previewBtn: previewBtn,
_genBtn: genBtn,
_popoutBtn: popoutBtn,
_captureBtn: captureBtn,
_genDropdown: null,
_pinnedSlot: null,
_mode: "simple",
_cycleMode: vi.fn(),
_updatePinSelectUI: vi.fn(),
_closeGenDropdown: vi.fn(),
_openGenDropdown: vi.fn(),
_captureView: vi.fn(),
setMode: vi.fn(),
};
FloatingViewer.prototype._rebindControlHandlers.call(viewer);
expect(oldController.abort).toHaveBeenCalledTimes(1);
expect(viewer._btnAC).toBeInstanceOf(AbortController);
expect(closeBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
expect(modeBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
expect(pinSelect.addEventListener).toHaveBeenCalledWith("change", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
expect(liveBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
expect(previewBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
expect(genBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
expect(popoutBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
expect(captureBtn.addEventListener).toHaveBeenCalledWith("click", expect.any(Function), expect.objectContaining({ signal: viewer._btnAC.signal }));
});
});
+324
View File
@@ -0,0 +1,324 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => {
const MFV_MODES = {
SIMPLE: "simple",
AB: "ab",
SIDE: "side",
};
let viewerMode = MFV_MODES.SIMPLE;
let lastViewer = null;
let activeGrid = null;
let pinnedSlot = null;
const getAssetsBatchMock = vi.fn();
const getSelectedIdSetMock = vi.fn();
const reportErrorMock = vi.fn();
class FloatingViewerMock {
constructor() {
this._mode = viewerMode;
this._mediaA = null;
this._mediaB = null;
this._pinnedSlot = pinnedSlot;
this.isVisible = false;
this.loadMediaA = vi.fn((asset, opts) => {
this._mediaA = asset;
this._lastLoadMediaA = [asset, opts];
});
this.loadMediaPair = vi.fn((assetA, assetB) => {
this._mediaA = assetA;
this._mediaB = assetB;
this._lastLoadMediaPair = [assetA, assetB];
});
this.setLiveActive = vi.fn();
this.setPreviewActive = vi.fn();
this.loadPreviewBlob = vi.fn();
lastViewer = this;
}
render() {
return { nodeName: "DIV" };
}
show() {
this.isVisible = true;
}
hide() {
this.isVisible = false;
}
get isPopped() {
return false;
}
getPinnedSlot() {
return this._pinnedSlot;
}
popOut() {}
popIn() {}
}
return {
MFV_MODES,
FloatingViewerMock,
getAssetsBatchMock,
getSelectedIdSetMock,
reportErrorMock,
getActiveGridContainer() {
return activeGrid;
},
reset() {
viewerMode = MFV_MODES.SIMPLE;
lastViewer = null;
activeGrid = null;
pinnedSlot = null;
getAssetsBatchMock.mockReset();
getSelectedIdSetMock.mockReset();
reportErrorMock.mockReset();
},
setActiveGrid(grid) {
activeGrid = grid;
},
setViewerMode(mode) {
viewerMode = mode;
},
setPinnedSlot(slot) {
pinnedSlot = slot || null;
},
getLastViewer() {
return lastViewer;
},
};
});
vi.mock("../features/viewer/FloatingViewer.js", () => ({
FloatingViewer: state.FloatingViewerMock,
MFV_MODES: state.MFV_MODES,
}));
vi.mock("../api/client.js", () => ({
getAssetsBatch: state.getAssetsBatchMock,
}));
vi.mock("../features/panel/AssetsManagerPanel.js", () => ({
getActiveGridContainer: () => state.getActiveGridContainer(),
}));
vi.mock("../features/grid/GridSelectionManager.js", () => ({
getSelectedIdSet: state.getSelectedIdSetMock,
}));
vi.mock("../utils/logging.js", () => ({
reportError: state.reportErrorMock,
}));
function createWindowStub() {
const listeners = new Map();
return {
addEventListener(type, handler) {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(handler);
},
removeEventListener(type, handler) {
listeners.get(type)?.delete(handler);
},
dispatchEvent(event) {
const handlers = Array.from(listeners.get(event?.type) || []);
for (const handler of handlers) {
handler(event);
}
return true;
},
};
}
function createGrid(ids = []) {
return {
querySelectorAll() {
return ids.map((id) => ({ dataset: { mjrAssetId: String(id) } }));
},
};
}
async function flushAsyncWork() {
await Promise.resolve();
await Promise.resolve();
}
beforeEach(() => {
vi.resetModules();
state.reset();
globalThis.window = createWindowStub();
globalThis.document = {
body: {
appendChild: vi.fn(),
},
};
globalThis.CustomEvent = class {
constructor(type, init = {}) {
this.type = type;
this.detail = init.detail;
}
};
});
describe("floatingViewerManager", () => {
it("loads the current selection when no pin is active", async () => {
state.setActiveGrid(createGrid(["101"]));
state.getSelectedIdSetMock.mockReturnValue(new Set(["101"]));
state.getAssetsBatchMock.mockResolvedValue({
ok: true,
data: [{ id: 101, filename: "one.png" }],
});
const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
floatingViewerManager.open();
await flushAsyncWork();
const viewer = state.getLastViewer();
expect(viewer).toBeTruthy();
expect(viewer.loadMediaA).toHaveBeenCalledWith(
{ id: 101, filename: "one.png" },
{ autoMode: true },
);
expect(state.reportErrorMock).not.toHaveBeenCalled();
});
it("keeps compare-mode fallback working when no pin is active", async () => {
state.setViewerMode(state.MFV_MODES.AB);
state.setActiveGrid(createGrid(["10", "11", "12"]));
state.getSelectedIdSetMock.mockReturnValue(new Set(["11"]));
state.getAssetsBatchMock.mockResolvedValue({
ok: true,
data: [
{ id: 11, filename: "left.png" },
{ id: 12, filename: "right.png" },
],
});
const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
floatingViewerManager.open();
await flushAsyncWork();
const viewer = state.getLastViewer();
expect(viewer.loadMediaPair).toHaveBeenCalledWith(
{ id: 11, filename: "left.png" },
{ id: 12, filename: "right.png" },
);
expect(state.getAssetsBatchMock).toHaveBeenCalledWith(
["11", "12"],
expect.objectContaining({ signal: expect.any(Object) }),
);
expect(state.reportErrorMock).not.toHaveBeenCalled();
});
it("preserves pinned slot A and updates slot B from new selections", async () => {
state.setViewerMode(state.MFV_MODES.AB);
state.setPinnedSlot("A");
state.setActiveGrid(createGrid(["201", "202"]));
state.getSelectedIdSetMock.mockReturnValue(new Set());
state.getAssetsBatchMock.mockResolvedValue({
ok: true,
data: [{ id: 202, filename: "candidate.png" }],
});
const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
floatingViewerManager.open();
const viewer = state.getLastViewer();
viewer._mediaA = { id: 9001, filename: "reference-a.png" };
window.dispatchEvent(new CustomEvent("mjr:selection-changed", {
detail: { selectedIds: ["202"] },
}));
await flushAsyncWork();
expect(viewer.loadMediaPair).toHaveBeenCalledWith(
{ id: 9001, filename: "reference-a.png" },
{ id: 202, filename: "candidate.png" },
);
expect(state.getAssetsBatchMock).toHaveBeenCalledWith(
["202"],
expect.objectContaining({ signal: expect.any(Object) }),
);
});
it("preserves pinned slot B and updates slot A from new selections", async () => {
state.setViewerMode(state.MFV_MODES.AB);
state.setPinnedSlot("B");
state.setActiveGrid(createGrid(["301", "302"]));
state.getSelectedIdSetMock.mockReturnValue(new Set());
state.getAssetsBatchMock.mockResolvedValue({
ok: true,
data: [{ id: 301, filename: "candidate-a.png" }],
});
const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
floatingViewerManager.open();
const viewer = state.getLastViewer();
viewer._mediaB = { id: 9002, filename: "reference-b.png" };
window.dispatchEvent(new CustomEvent("mjr:selection-changed", {
detail: { selectedIds: ["301"] },
}));
await flushAsyncWork();
expect(viewer.loadMediaPair).toHaveBeenCalledWith(
{ id: 301, filename: "candidate-a.png" },
{ id: 9002, filename: "reference-b.png" },
);
expect(state.getAssetsBatchMock).toHaveBeenCalledWith(
["301"],
expect.objectContaining({ signal: expect.any(Object) }),
);
});
it("emits visibility and syncs control states when live stream auto-opens the viewer", async () => {
const seen = [];
window.addEventListener("mjr:mfv-visibility-changed", (event) => {
seen.push(Boolean(event?.detail?.visible));
});
const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
floatingViewerManager.setLiveActive(true);
floatingViewerManager.upsertWithContent({ filename: "live-output.png", type: "output" });
const viewer = state.getLastViewer();
expect(viewer).toBeTruthy();
expect(viewer.isVisible).toBe(true);
expect(viewer.setLiveActive).toHaveBeenLastCalledWith(true);
expect(viewer.setPreviewActive).toHaveBeenLastCalledWith(false);
expect(viewer.loadMediaA).toHaveBeenCalledWith(
{ filename: "live-output.png", type: "output" },
{ autoMode: true },
);
expect(seen).toEqual([true]);
});
it("emits visibility and syncs preview state when preview stream auto-opens the viewer", async () => {
const seen = [];
const blob = { fake: "blob" };
window.addEventListener("mjr:mfv-visibility-changed", (event) => {
seen.push(Boolean(event?.detail?.visible));
});
const { floatingViewerManager } = await import("../features/viewer/floatingViewerManager.js");
floatingViewerManager.setPreviewActive(true);
floatingViewerManager.feedPreviewBlob(blob);
const viewer = state.getLastViewer();
expect(viewer).toBeTruthy();
expect(viewer.isVisible).toBe(true);
expect(viewer.setLiveActive).toHaveBeenLastCalledWith(false);
expect(viewer.setPreviewActive).toHaveBeenLastCalledWith(true);
expect(viewer.loadPreviewBlob).toHaveBeenCalledWith(blob);
expect(seen).toEqual([true]);
});
});
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiState = vi.hoisted(() => ({
hybridSearch: vi.fn(),
vectorSearch: vi.fn(),
vectorStats: vi.fn(),
}));
vi.mock("../api/client.js", () => ({
hybridSearch: apiState.hybridSearch,
vectorSearch: apiState.vectorSearch,
vectorStats: apiState.vectorStats,
}));
vi.mock("../app/toast.js", () => ({
comfyToast: vi.fn(),
}));
vi.mock("../app/i18n.js", () => ({
t: (_key, fallback, vars) => {
if (!vars) return fallback;
return String(fallback || "").replace(/\{(\w+)\}/g, (_m, key) => String(vars[key] ?? ""));
},
}));
vi.mock("../app/settings.js", () => ({
loadMajoorSettings: () => ({ ai: { vectorSearchEnabled: true } }),
}));
function createGridContainer() {
return {
dataset: {},
dispatchEvent: vi.fn(),
};
}
describe("gridController semantic mode", () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.window = {};
globalThis.CustomEvent = class {
constructor(type, init = {}) {
this.type = type;
this.detail = init.detail;
}
};
});
it("uses hybrid AI search when the semantic toggle is active", async () => {
const { createGridController } = await import("../features/panel/controllers/gridController.js");
apiState.hybridSearch.mockResolvedValue({
ok: true,
data: [{ id: 3, asset_id: 3, filename: "dino.png" }],
});
apiState.vectorSearch.mockResolvedValue({ ok: true, data: [] });
apiState.vectorStats.mockResolvedValue({ ok: true, data: { total: 33 } });
const loadAssets = vi.fn(async () => ({ ok: true, count: 0, total: 0 }));
const loadAssetsFromList = vi.fn(async (_grid, assets) => ({
ok: true,
count: assets.length,
total: assets.length,
}));
const controller = createGridController({
gridContainer: createGridContainer(),
loadAssets,
loadAssetsFromList,
getCollectionAssets: vi.fn(),
disposeGrid: vi.fn(),
getQuery: () => "dinosaure",
searchInputEl: { dataset: { mjrSemanticMode: "1" } },
state: {
scope: "output",
customRootId: "",
currentFolderRelativePath: "",
kindFilter: "",
workflowOnly: false,
minRating: 0,
minSizeMB: 0,
maxSizeMB: 0,
resolutionCompare: "gte",
minWidth: 0,
minHeight: 0,
maxWidth: 0,
maxHeight: 0,
workflowType: "",
dateRangeFilter: "",
dateExactFilter: "",
sort: "mtime_desc",
selectedAssetIds: [],
activeAssetId: "",
collectionId: "",
collectionName: "",
viewScope: "",
},
});
await controller.reloadGrid();
expect(apiState.hybridSearch).toHaveBeenCalledWith("dinosaure", expect.objectContaining({
topK: 100,
scope: "output",
}));
expect(loadAssetsFromList).toHaveBeenCalledWith(
expect.any(Object),
[{ id: 3, asset_id: 3, filename: "dino.png" }],
expect.objectContaining({ reset: true })
);
expect(loadAssets).not.toHaveBeenCalled();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
class MemoryStorage {
constructor() {
this.store = new Map();
}
clear() {
this.store.clear();
}
getItem(key) {
return this.store.has(key) ? this.store.get(key) : null;
}
removeItem(key) {
this.store.delete(key);
}
setItem(key, value) {
this.store.set(key, String(value));
}
}
function installRuntimeGlobals() {
const eventTarget = new EventTarget();
eventTarget.location = { href: "http://127.0.0.1:8188/" };
globalThis.window = eventTarget;
globalThis.localStorage = new MemoryStorage();
globalThis.CustomEvent = class CustomEvent extends Event {
constructor(type, init = {}) {
super(type);
this.detail = init.detail;
}
};
}
async function loadMessageCenter() {
vi.resetModules();
return import("../features/panel/messages/messageCenter.js");
}
describe("messageCenter", () => {
beforeEach(() => {
installRuntimeGlobals();
});
it("sanitizes unsupported levels to safe values", async () => {
const { addPanelMessage, listPanelMessages } = await loadMessageCenter();
addPanelMessage({ id: "bad-level", title: "Bad", level: "error urgent" });
addPanelMessage({ id: "warn-level", title: "Warn", level: "warn" });
const bad = listPanelMessages().find((message) => message.id === "bad-level");
const warn = listPanelMessages().find((message) => message.id === "warn-level");
expect(bad?.level).toBe("info");
expect(warn?.level).toBe("warning");
});
it("keeps builtin removals dismissed across reloads", async () => {
const builtinId = "whats-new-2026-03-05-pin-reference";
const firstLoad = await loadMessageCenter();
firstLoad.ensurePanelMessagesReady();
expect(firstLoad.listPanelMessages().map((message) => message.id)).toContain(builtinId);
expect(firstLoad.removePanelMessage(builtinId)).toBe(true);
expect(firstLoad.listPanelMessages().map((message) => message.id)).not.toContain(builtinId);
const secondLoad = await loadMessageCenter();
secondLoad.ensurePanelMessagesReady();
expect(secondLoad.listPanelMessages().map((message) => message.id)).not.toContain(builtinId);
});
it("keeps builtin announcements cleared after clear all", async () => {
const firstLoad = await loadMessageCenter();
firstLoad.ensurePanelMessagesReady();
expect(firstLoad.listPanelMessages().length).toBeGreaterThan(0);
firstLoad.clearPanelMessages();
expect(firstLoad.listPanelMessages()).toEqual([]);
const secondLoad = await loadMessageCenter();
secondLoad.ensurePanelMessagesReady();
expect(secondLoad.listPanelMessages()).toEqual([]);
});
it("returns the stored merged message when updating an existing entry", async () => {
const { addPanelMessage, listPanelMessages, upsertPanelMessage } = await loadMessageCenter();
addPanelMessage({
id: "existing-message",
title: "Initial title",
createdAt: 123456,
level: "info",
});
const updated = upsertPanelMessage({
id: "existing-message",
title: "Updated title",
level: "success",
});
const stored = listPanelMessages().find((message) => message.id === "existing-message");
expect(updated.createdAt).toBe(123456);
expect(updated.title).toBe("Updated title");
expect(updated.level).toBe("success");
expect(stored).toMatchObject({
id: "existing-message",
title: "Updated title",
createdAt: 123456,
level: "success",
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { createScopeController } from "../features/panel/controllers/scopeController.js";
function makeButton(scope) {
const classes = new Set();
return {
dataset: { scope },
style: { display: "" },
classList: {
toggle(name, active) {
if (active) classes.add(name);
else classes.delete(name);
},
has(name) {
return classes.has(name);
},
},
};
}
describe("scopeController similar scope", () => {
it("activates a temporary similar scope and clears it cleanly", async () => {
const tabButtons = {
tabAll: makeButton("all"),
tabInputs: makeButton("input"),
tabOutputs: makeButton("output"),
tabCustom: makeButton("custom"),
tabSimilar: makeButton("similar"),
};
const state = {
scope: "output",
viewScope: "",
similarResults: [{ asset_id: 7 }],
similarTitle: "Similar to asset #42 (1 results)",
similarSourceAssetId: "42",
collectionId: "",
collectionName: "",
currentFolderRelativePath: "",
customRootId: "",
};
const reloadGrid = vi.fn(async () => {});
const onChanged = vi.fn();
const onScopeChanged = vi.fn();
const onBeforeReload = vi.fn();
const popovers = { close: vi.fn() };
const customMenuBtn = { style: { display: "" } };
const customPopover = { style: { display: "" } };
const controller = createScopeController({
state,
tabButtons,
customMenuBtn,
customPopover,
popovers,
reloadGrid,
onChanged,
onScopeChanged,
onBeforeReload,
});
controller.setActiveTabStyles();
expect(tabButtons.tabSimilar.style.display).toBe("");
expect(tabButtons.tabOutputs.classList.has("is-active")).toBe(true);
await controller.setScope("similar");
expect(state.viewScope).toBe("similar");
expect(tabButtons.tabSimilar.classList.has("is-active")).toBe(true);
expect(onScopeChanged).not.toHaveBeenCalled();
expect(onBeforeReload).toHaveBeenCalledTimes(1);
expect(reloadGrid).toHaveBeenCalledTimes(1);
await controller.clearSimilarScope({ reload: false });
expect(state.viewScope).toBe("");
expect(state.similarResults).toEqual([]);
expect(state.similarTitle).toBe("");
expect(state.similarSourceAssetId).toBe("");
expect(tabButtons.tabSimilar.style.display).toBe("none");
});
});
+426 -31
View File
@@ -179,6 +179,37 @@
opacity: 1;
}
.mjr-assets-manager .mjr-icon-btn.mjr-message-btn {
position: relative;
overflow: visible;
}
.mjr-assets-manager .mjr-icon-btn.mjr-message-has-unread {
border-color: color-mix(in srgb, var(--mjr-accent) 75%, var(--mjr-border));
background: color-mix(in srgb, var(--mjr-surface-2) 72%, var(--mjr-accent) 11%);
}
.mjr-assets-manager .mjr-message-badge {
position: absolute;
top: -5px;
right: -6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--mjr-surface-1) 55%, rgba(255,255,255,0.3));
background: #ef5350;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.mjr-assets-manager .mjr-icon-btn .pi {
font-size: 13px;
line-height: 1;
@@ -237,6 +268,9 @@
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: var(--mjr-popover-z-index);
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.mjr-filter-popover {
@@ -257,6 +291,175 @@
padding: 10px;
}
.mjr-messages-popover {
min-width: 320px;
width: min(380px, calc(100vw - 24px));
padding: 10px;
}
.mjr-messages-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.mjr-messages-title {
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: color-mix(in srgb, var(--content-fg, #ddd) 82%, var(--mjr-muted));
}
.mjr-messages-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.mjr-messages-mark-read-btn {
padding: 4px 8px;
font-size: 11px;
line-height: 1.2;
}
.mjr-messages-star-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
line-height: 1.2;
text-decoration: none;
color: var(--content-fg, #f1f1f1);
border-color: color-mix(in srgb, #f3c84b 42%, var(--mjr-border));
background: color-mix(in srgb, #f3c84b 12%, var(--mjr-surface-2));
}
.mjr-messages-star-link:hover,
.mjr-messages-star-link:focus-visible {
text-decoration: none;
}
.mjr-messages-star-icon {
color: #f3c84b;
font-size: 12px;
line-height: 1;
}
.mjr-messages-star-label {
white-space: nowrap;
}
.mjr-messages-list {
display: grid;
gap: 8px;
}
.mjr-messages-empty {
padding: 8px 6px;
font-size: 12px;
opacity: 0.72;
}
.mjr-message-item {
display: grid;
gap: 5px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--mjr-border) 86%, transparent);
background: color-mix(in srgb, var(--mjr-surface-2) 58%, transparent);
padding: 8px;
}
.mjr-message-item--unread {
border-color: color-mix(in srgb, var(--mjr-accent) 58%, var(--mjr-border));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 26%, transparent);
}
.mjr-message-item--warning {
border-color: color-mix(in srgb, #f0b85a 62%, var(--mjr-border));
}
.mjr-message-item--error {
border-color: color-mix(in srgb, #f36b6b 62%, var(--mjr-border));
}
.mjr-message-item--success {
border-color: color-mix(in srgb, #58c07d 62%, var(--mjr-border));
}
.mjr-message-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mjr-message-item-title-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.mjr-message-item-emoji {
flex: 0 0 auto;
font-size: 16px;
line-height: 1;
}
.mjr-message-item-title {
min-width: 0;
font-weight: 600;
color: var(--content-fg, #f1f1f1);
}
.mjr-message-item-category {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
background: color-mix(in srgb, var(--mjr-accent) 20%, var(--mjr-surface-2));
color: color-mix(in srgb, var(--content-fg, #fff) 86%, transparent);
white-space: nowrap;
}
.mjr-message-item-meta {
font-size: 11px;
opacity: 0.72;
}
.mjr-message-item-body {
font-size: 12px;
opacity: 0.92;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.mjr-message-item-action {
font-size: 11px;
font-weight: 600;
color: var(--mjr-accent, var(--comfy-accent, #5fb3ff));
text-decoration: none;
width: fit-content;
}
.mjr-message-item-action:hover {
text-decoration: underline;
}
.mjr-filter-group {
border: 1px solid color-mix(in srgb, var(--mjr-border) 85%, transparent);
background: color-mix(in srgb, var(--mjr-surface-2) 70%, transparent);
@@ -340,32 +543,32 @@
}
/* Scrollbars (match ComfyUI feel) */
.mjr-assets-manager .mjr-am-browse {
.mjr-assets-manager .mjr-am-grid-scroll {
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 {
.mjr-assets-manager .mjr-am-grid-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar-track {
.mjr-assets-manager .mjr-am-grid-scroll::-webkit-scrollbar-track {
background: transparent;
}
.mjr-assets-manager .mjr-am-browse::-webkit-scrollbar-thumb {
.mjr-assets-manager .mjr-am-grid-scroll::-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 {
.mjr-assets-manager .mjr-am-grid-scroll: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 {
.mjr-assets-manager .mjr-am-grid-scroll::-webkit-scrollbar-corner {
background: transparent;
}
@@ -815,8 +1018,10 @@
.mjr-assets-manager .mjr-am-browse {
margin-top: 0;
/* Keep cards away from the vertical scrollbar */
padding-right: 10px;
}
.mjr-assets-manager .mjr-am-grid-scroll {
padding-right: 4px;
scrollbar-gutter: stable;
}
@@ -1336,7 +1541,7 @@
/* Space between sidebar and grid */
.mjr-assets-manager .mjr-am-browse {
gap: 10px;
gap: 0;
}
/* Border based on position */
@@ -2347,62 +2552,119 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
padding: 6px 10px;
background: var(--mjr-surface-1);
border-bottom: 1px solid var(--mjr-border);
cursor: move;
user-select: none;
flex-shrink: 0;
gap: 6px;
min-height: 30px;
min-height: 34px;
}
.mjr-mfv-header-title {
font-size: 11px;
opacity: 0.6;
font-size: 12px;
font-weight: 600;
opacity: 0.78;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
letter-spacing: 0.2px;
letter-spacing: 0.28px;
}
/* ── Toolbar ─────────────────────────────────────────────────────────── */
.mjr-mfv-toolbar {
display: flex;
align-items: center;
gap: 3px;
padding: 3px 7px;
gap: 4px;
padding: 4px 8px;
background: color-mix(in srgb, var(--mjr-surface-0) 50%, var(--mjr-surface-1) 50%);
border-bottom: 1px solid var(--mjr-border);
flex-shrink: 0;
min-height: 28px;
min-height: 36px;
}
.mjr-mfv-toolbar-sep {
width: 1px;
height: 14px;
height: 18px;
background: var(--mjr-border);
margin: 0 2px;
margin: 0 3px;
flex-shrink: 0;
}
.mjr-mfv-pin-select {
height: 30px;
min-width: 88px;
border-radius: 7px;
border: 1px solid var(--mjr-border);
background: var(--mjr-surface-2);
color: var(--content-fg, #ddd);
outline: none;
padding: 0 28px 0 10px;
font-size: 12px;
font-weight: 500;
line-height: 28px;
flex-shrink: 0;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
color-scheme: dark;
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 14px) calc(50% - 1px),
calc(100% - 9px) calc(50% - 1px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.mjr-mfv-pin-select:hover {
border-color: color-mix(in srgb, var(--mjr-accent) 45%, var(--mjr-border));
background-color: color-mix(in srgb, var(--mjr-surface-2) 80%, var(--mjr-accent) 8%);
}
.mjr-mfv-pin-select:focus {
border-color: color-mix(in srgb, var(--mjr-accent) 55%, var(--mjr-border));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 28%, transparent);
}
.mjr-mfv-pin-select:focus-visible {
border-color: color-mix(in srgb, var(--mjr-accent) 55%, var(--mjr-border));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mjr-accent) 28%, transparent);
}
.mjr-mfv-pin-select.is-pinned {
border-color: color-mix(in srgb, var(--mjr-accent) 58%, #ffffff 12%);
background-color: color-mix(in srgb, var(--mjr-surface-2) 42%, var(--mjr-accent) 58%);
color: #eef7ff;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 24%, transparent);
}
.mjr-mfv-pin-select option {
background: var(--mjr-surface-1, #1c1c1c);
color: var(--content-fg, #ddd);
}
/* ── Icon buttons inside MFV (outside .mjr-assets-manager scope) ─────── */
.mjr-mfv .mjr-icon-btn {
width: 26px;
height: 26px;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border-radius: 7px;
border: 1px solid var(--mjr-border);
background: var(--mjr-surface-2);
color: var(--content-fg, #ddd);
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease;
transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.mjr-mfv .mjr-icon-btn:hover {
@@ -2411,8 +2673,14 @@
transform: translateY(-1px);
}
.mjr-mfv .mjr-icon-btn:focus-visible {
outline: none;
border-color: color-mix(in srgb, var(--mjr-accent) 58%, var(--mjr-border));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mjr-accent) 24%, transparent);
}
.mjr-mfv .mjr-icon-btn .pi {
font-size: 12px;
font-size: 13px;
line-height: 1;
opacity: 0.9;
}
@@ -2421,18 +2689,122 @@
border-color: color-mix(in srgb, var(--mjr-status-success) 60%, var(--mjr-border));
color: var(--mjr-status-success);
background: color-mix(in srgb, var(--mjr-surface-2) 80%, var(--mjr-status-success) 12%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-status-success) 25%, transparent);
}
.mjr-mfv .mjr-icon-btn.mjr-live-active .pi {
opacity: 1;
}
.mjr-mfv .mjr-icon-btn.mjr-preview-active {
border-color: color-mix(in srgb, var(--mjr-accent) 60%, var(--mjr-border));
color: var(--mjr-accent);
background: color-mix(in srgb, var(--mjr-surface-2) 80%, var(--mjr-accent) 12%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-accent) 25%, transparent);
}
.mjr-mfv .mjr-icon-btn.mjr-preview-active .pi {
opacity: 1;
}
/* Pop-in mode (viewer popped out): raise button contrast/brightness */
.mjr-mfv.mjr-mfv--popped {
background: #262626;
border-color: color-mix(in srgb, var(--mjr-border) 86%, rgba(255,255,255,0.14));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.52);
}
.mjr-mfv.mjr-mfv--popped .mjr-mfv-header {
background: linear-gradient(
180deg,
rgba(74, 74, 74, 0.98),
rgba(52, 52, 52, 0.98)
);
border-bottom-color: color-mix(in srgb, var(--mjr-border) 88%, rgba(255,255,255,0.12));
}
.mjr-mfv.mjr-mfv--popped .mjr-mfv-header-title {
opacity: 0.96;
color: #eef4ff;
}
.mjr-mfv.mjr-mfv--popped .mjr-mfv-toolbar {
background: linear-gradient(
180deg,
rgba(48, 48, 48, 0.98),
rgba(28, 28, 28, 0.98)
);
border-bottom-color: color-mix(in srgb, var(--mjr-border) 88%, rgba(255,255,255,0.12));
}
.mjr-mfv.mjr-mfv--popped .mjr-mfv-toolbar-sep {
background: color-mix(in srgb, var(--mjr-border) 84%, rgba(255,255,255,0.16));
}
.mjr-mfv.mjr-mfv--popped .mjr-icon-btn {
border-color: color-mix(in srgb, var(--mjr-border) 40%, #ffffff 60%);
background: color-mix(in srgb, var(--mjr-surface-2) 65%, #ffffff 18%);
color: color-mix(in srgb, var(--content-fg, #ddd) 75%, #ffffff 25%);
border-color: color-mix(in srgb, var(--mjr-border) 84%, rgba(255,255,255,0.16));
background: linear-gradient(
180deg,
rgba(88, 88, 88, 0.94),
rgba(66, 66, 66, 0.98)
);
color: var(--content-fg, #ddd) !important;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04);
}
.mjr-mfv.mjr-mfv--popped .mjr-icon-btn:hover {
border-color: color-mix(in srgb, var(--mjr-accent) 50%, #ffffff 50%);
background: color-mix(in srgb, var(--mjr-surface-2) 55%, #ffffff 30%);
border-color: color-mix(in srgb, var(--mjr-accent) 52%, var(--mjr-border));
background: linear-gradient(
180deg,
rgba(91, 120, 148, 0.96),
rgba(56, 82, 106, 0.98)
);
color: #f4f8ff !important;
}
.mjr-mfv.mjr-mfv--popped .mjr-icon-btn .pi {
opacity: 1;
color: inherit !important;
}
.mjr-mfv.mjr-mfv--popped .mjr-icon-btn.mjr-live-active {
border-color: color-mix(in srgb, var(--mjr-status-success) 60%, #ffffff 40%);
background: linear-gradient(180deg, rgba(76, 175, 80, 0.62), rgba(76, 175, 80, 0.34));
color: #ffffff !important;
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.12),
0 0 0 1px color-mix(in srgb, var(--mjr-status-success) 24%, transparent),
0 0 18px rgba(76, 175, 80, 0.18);
}
.mjr-mfv.mjr-mfv--popped .mjr-icon-btn.mjr-preview-active {
border-color: color-mix(in srgb, var(--mjr-accent) 58%, #ffffff 42%);
background: linear-gradient(180deg, rgba(95, 179, 255, 0.62), rgba(95, 179, 255, 0.34));
color: #ffffff !important;
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.12),
0 0 0 1px color-mix(in srgb, var(--mjr-accent) 24%, transparent),
0 0 18px rgba(95, 179, 255, 0.18);
}
.mjr-mfv.mjr-mfv--popped .mjr-pin-select,
.mjr-mfv.mjr-mfv--popped .mjr-mfv-pin-select {
border-color: color-mix(in srgb, var(--mjr-border) 84%, rgba(255,255,255,0.16));
background-color: rgba(74, 74, 74, 0.96);
color: var(--content-fg, #ddd);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04);
}
.mjr-mfv.mjr-mfv--popped .mjr-mfv-pin-select:hover,
.mjr-mfv.mjr-mfv--popped .mjr-mfv-pin-select:focus {
border-color: color-mix(in srgb, var(--mjr-accent) 52%, var(--mjr-border));
background-color: rgba(66, 96, 124, 0.98);
}
.mjr-mfv.mjr-mfv--popped .mjr-mfv-pin-select.is-pinned {
border-color: color-mix(in srgb, var(--mjr-accent) 58%, #ffffff 42%);
background: linear-gradient(180deg, rgba(95,179,255,0.60), rgba(95,179,255,0.34));
color: #ffffff;
}
.mjr-mfv .mjr-icon-btn.mjr-popin-active {
@@ -2487,15 +2859,25 @@
position: absolute;
top: 0;
bottom: 0;
width: 3px;
width: 0.5px;
cursor: col-resize;
z-index: 10;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.4);
transition: background 100ms ease;
}
.mjr-mfv-ab-divider::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 12px;
transform: translateX(-50%);
}
.mjr-mfv-ab-divider:hover,
.mjr-mfv-ab-divider:active {
background: var(--mjr-accent, #5fb3ff);
@@ -2754,6 +3136,19 @@
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mjr-accent, #5fb3ff) 25%, transparent);
}
.mjr-mfv .mjr-icon-btn.is-open {
border-color: color-mix(in srgb, var(--mjr-accent, #5fb3ff) 62%, #ffffff 12%);
color: #eef7ff;
background: linear-gradient(180deg, rgba(95, 179, 255, 0.28), rgba(95, 179, 255, 0.14));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.08),
0 0 0 1px color-mix(in srgb, var(--mjr-accent, #5fb3ff) 26%, transparent);
}
.mjr-mfv.mjr-mfv--popped .mjr-icon-btn.is-open {
color: #ffffff !important;
}
/* Gen info dropdown panel */
.mjr-mfv-gen-dropdown {
position: absolute;
+6 -4
View File
@@ -1,11 +1,13 @@
import { APP_CONFIG } from "../app/config.js";
import { comfyConfirm } from "../app/dialogs.js";
import { t } from "../app/i18n.js";
export async function confirmDeletion(count, label) {
if (!APP_CONFIG.DELETE_CONFIRMATION) return true;
const target = count > 1 ? `${count} files` : `"${label || "this file"}"`;
const message = count > 1
? `Delete ${count} selected files?`
: `Delete ${target}?`;
return comfyConfirm(message, "Majoor: Confirm delete");
? t("dialog.deleteSelectedFiles", "Delete {count} selected files?", { count })
: t("dialog.deleteSingleFile", "Delete \"{label}\"?", {
label: String(label || t("label.thisFile", "this file")),
});
return comfyConfirm(message, t("dialog.confirmDeleteTitle", "Majoor: Confirm delete"));
}
+1
View File
File diff suppressed because one or more lines are too long
@@ -444,6 +444,16 @@ class Sqlite:
def __init__(self, db_path: str, max_connections: int | None = None, timeout: float = 30.0, *, attach: dict[str, str] | None = None):
self.db_path = Path(db_path)
self._attach_dbs: dict[str, str] = dict(attach) if attach else {}
if "vec" not in self._attach_dbs:
# Keep vector DB colocated with the primary DB by default so schema
# self-heal works even when callers don't pass attach={"vec": ...}.
default_vec = self.db_path.with_name("vectors.sqlite")
try:
if default_vec.resolve(strict=False) != self.db_path.resolve(strict=False):
self._attach_dbs["vec"] = str(default_vec)
except Exception:
if default_vec != self.db_path:
self._attach_dbs["vec"] = str(default_vec)
user_config = self._load_user_db_config()
max_conn = (
int(max_connections)
+28 -1
View File
@@ -479,16 +479,43 @@ VECTOR_INDEX_DIR = str(VECTOR_INDEX_DIR_PATH)
# Embedding dimensionality (SigLIP SO400M → 1152, base → 768).
VECTOR_EMBEDDING_DIM = _env_int(1152, "MJR_AM_VECTOR_DIM", min_value=64, max_value=4096)
# Pre-warm the text-query model path and Faiss index during startup prewarm.
VECTOR_PREWARM_ON_STARTUP = _env_bool(
True,
"MJR_AM_VECTOR_PREWARM_ON_STARTUP",
"MAJOOR_VECTOR_PREWARM_ON_STARTUP",
)
# Auto-tagging: minimum cosine similarity to assign a tag.
VECTOR_AUTOTAG_THRESHOLD = _env_float(
0.25,
0.06,
"MJR_AM_VECTOR_AUTOTAG_THRESHOLD",
min_value=0.0,
max_value=1.0,
)
# Semantic text search: score floor and relative cutoff versus best hit.
VECTOR_TEXT_SEARCH_MIN_SCORE = _env_float(
0.02,
"MJR_AM_VECTOR_TEXT_SEARCH_MIN_SCORE",
min_value=0.0,
max_value=1.0,
)
VECTOR_TEXT_SEARCH_RELATIVE_RATIO = _env_float(
0.70,
"MJR_AM_VECTOR_TEXT_SEARCH_RELATIVE_RATIO",
min_value=0.0,
max_value=1.0,
)
# Maximum number of similar results returned by "Find Similar".
VECTOR_SIMILAR_TOPK = _env_int(20, "MJR_AM_VECTOR_SIMILAR_TOPK", min_value=1, max_value=200)
VECTOR_SIMILAR_MIN_SCORE = _env_float(
0.60,
"MJR_AM_VECTOR_SIMILAR_MIN_SCORE",
min_value=0.0,
max_value=1.0,
)
# Number of threads used by Faiss for search (0 = automatic).
VECTOR_FAISS_NPROBE = _env_int(0, "MJR_AM_VECTOR_FAISS_NPROBE", min_value=0, max_value=128)
@@ -15,6 +15,18 @@ from .metadata_helpers import MetadataHelpers
from .scan_batch_utils import compute_state_hash, is_incremental_unchanged, normalize_filepath_str
async def _asset_missing_vector(scanner: Any, *, asset_id: int) -> bool:
checker = getattr(scanner, "_asset_has_vector", None)
if not callable(checker):
return False
try:
return not bool(await checker(asset_id=asset_id))
except TypeError:
return not bool(await checker(asset_id))
except Exception:
return False
async def prepare_metadata_for_entry(
scanner: Any,
*,
@@ -142,6 +154,9 @@ async def maybe_skip_prepare_for_incremental(
cached_result = await refresh_entry_from_cached_metadata(scanner, prepare_ctx=prepare_ctx, file_path=file_path, fast=fast)
if cached_result is not None:
return cached_result
existing_id = int(prepare_ctx.get("existing_id") or 0)
if existing_id and await _asset_missing_vector(scanner, asset_id=existing_id):
return None
if await scanner._asset_has_rich_metadata(asset_id=prepare_ctx["existing_id"]):
return Result.Ok({"action": "skipped"})
return None
@@ -160,9 +175,13 @@ async def should_skip_by_journal_state(
return False
if str(prepare_ctx["journal_state_hash"]) != prepare_ctx["state_hash"]:
return False
if fast:
return True
existing_id = int(prepare_ctx.get("existing_id") or 0)
if fast:
if existing_id and await _asset_missing_vector(scanner, asset_id=existing_id):
return False
return True
if existing_id and await _asset_missing_vector(scanner, asset_id=existing_id):
return False
return bool(existing_id and await scanner._asset_has_rich_metadata(asset_id=existing_id))
+74 -13
View File
@@ -9,7 +9,7 @@ from typing import Any
from ...adapters.db.sqlite import Sqlite
from ...config import MAX_TO_ENRICH_ITEMS, is_vector_index_on_scan_enabled
from ...shared import get_logger
from ...shared import FileKind, get_logger
from ..metadata import MetadataService
from .fs_walker import SCAN_IOPS_LIMIT, FileSystemWalker
from .index_batching import append_batch_metadata_entries, existing_map_for_batch, journal_map_for_batch, prefetch_batch_cache_and_rich_meta, prefetch_metadata_cache_rows, prefetch_rich_metadata_rows, prepare_batch_entries, prepare_single_batch_entry, resolve_existing_state_for_batch
@@ -108,37 +108,93 @@ class IndexScanner:
added_ids=added_ids,
is_fatal_db_error=_is_fatal_db_error,
)
self._schedule_added_image_vector_index(prev_added_count=prev_added_count, added_ids=added_ids)
self._schedule_prepared_vector_index(
prepared=prepared,
prev_added_count=prev_added_count,
added_ids=added_ids,
)
def set_vector_services(self, vector_service: Any, vector_searcher: Any | None = None) -> None:
self._vector_service = vector_service
self._vector_searcher = vector_searcher
async def _asset_has_vector(self, *, asset_id: int) -> bool:
try:
asset_id_int = int(asset_id)
except Exception:
return False
if asset_id_int <= 0:
return False
result = await self.db.aquery(
"""
SELECT 1
FROM vec.asset_embeddings
WHERE asset_id = ?
AND vector IS NOT NULL
AND length(vector) > 0
LIMIT 1
""",
(asset_id_int,),
)
return bool(result.ok and result.data)
def _schedule_added_image_vector_index(self, *, prev_added_count: int, added_ids: list[int] | None) -> None:
self._schedule_prepared_vector_index(
prepared=None,
prev_added_count=prev_added_count,
added_ids=added_ids,
)
def _schedule_prepared_vector_index(
self,
*,
prepared: list[dict[str, Any]] | None,
prev_added_count: int,
added_ids: list[int] | None,
) -> None:
if not is_vector_index_on_scan_enabled():
return
if self._vector_service is None or not isinstance(added_ids, list) or len(added_ids) <= prev_added_count:
if self._vector_service is None:
return
new_ids: list[int] = []
for aid in added_ids[prev_added_count:]:
candidate_ids: list[int] = []
seen_ids: set[int] = set()
def _append_asset_id(raw_id: Any) -> None:
try:
parsed_id = int(aid)
parsed_id = int(raw_id)
except Exception:
continue
if parsed_id > 0:
new_ids.append(parsed_id)
if not new_ids:
return
if parsed_id <= 0 or parsed_id in seen_ids:
return
seen_ids.add(parsed_id)
candidate_ids.append(parsed_id)
if isinstance(added_ids, list):
for aid in added_ids[prev_added_count:]:
_append_asset_id(aid)
if isinstance(prepared, list):
for entry in prepared:
action = str(entry.get("action") or "").strip().lower()
if action not in {"refresh", "updated"}:
continue
_append_asset_id(entry.get("asset_id"))
if not candidate_ids:
return
try:
task = asyncio.create_task(self._index_added_image_vectors(asset_ids=new_ids))
task = asyncio.create_task(self._index_missing_asset_vectors(asset_ids=candidate_ids))
self._vector_index_tasks.add(task)
task.add_done_callback(self._vector_index_tasks.discard)
except Exception as exc:
logger.debug("Scanner vector indexing scheduling failed: %s", exc)
async def _index_added_image_vectors(self, *, asset_ids: list[int]) -> None:
await self._index_missing_asset_vectors(asset_ids=asset_ids)
async def _index_missing_asset_vectors(self, *, asset_ids: list[int]) -> None:
if self._vector_service is None or not isinstance(asset_ids, list) or not asset_ids:
return
@@ -148,8 +204,11 @@ class IndexScanner:
"""
SELECT a.id, a.filepath, a.kind, m.metadata_raw
FROM assets a
LEFT JOIN vec.asset_embeddings ae ON ae.asset_id = a.id
LEFT JOIN asset_metadata m ON a.id = m.asset_id
WHERE a.kind IN ('image', 'video') AND {IN_CLAUSE}
WHERE a.kind IN ('image', 'video')
AND (ae.asset_id IS NULL OR ae.vector IS NULL OR length(ae.vector) = 0)
AND {IN_CLAUSE}
""",
"a.id",
asset_ids,
@@ -195,6 +254,8 @@ class IndexScanner:
aid = int(entry.get("asset_id") or 0)
try:
filepath = str(entry.get("filepath") or "")
entry_kind_raw = str(entry.get("kind") or "").strip().lower()
entry_kind: FileKind = "video" if entry_kind_raw == "video" else "image"
if not filepath:
skipped += 1
continue
@@ -204,7 +265,7 @@ class IndexScanner:
self._vector_service,
asset_id=aid,
filepath=filepath,
kind=str(entry.get("kind") or "image"),
kind=entry_kind,
metadata_raw=entry.get("metadata_raw"),
),
timeout=_VECTOR_INDEX_PER_ASSET_TIMEOUT_S,
+271 -22
View File
@@ -52,14 +52,35 @@ _A1111_PARAMS_RE = re.compile(
re.IGNORECASE,
)
# Regex to split prompts into semantic segments (commas, periods, semicolons)
_SEGMENT_SPLIT_RE = re.compile(r"[,;.]+")
_MIN_SEGMENT_CHARS = 4 # ignore segments shorter than this
# ── Score calibration constants ────────────────────────────────────────────
# Raw SigLIP2 cosine similarity for well-aligned image-text pairs typically
# falls in the 0.15–0.45 range. We remap this to a 0–1 scale so that
# the UI percentage is meaningful to users.
_CALIB_LOW = 0.15 # raw score mapped to 0 (floor)
_CALIB_HIGH = 0.45 # raw score mapped to 1 (ceiling)
# SigLIP2 native features (get_image_features / get_text_features) produce
# L2-normalised embeddings whose cosine similarity for well-aligned pairs
# typically falls in the −0.06 to +0.05 range (sigmoid loss → lower raw
# values than CLIP). We remap this band to a 0–1 UI scale.
_CALIB_LOW = 0.00 # raw score mapped to 0 (floor)
_CALIB_HIGH = 0.06 # raw score mapped to 1 (ceiling)
_NEG_PENALTY_WEIGHT = 0.25 # how much negative-prompt match penalises the score
# ── Multi-signal fusion weights ────────────────────────────────────────────
_W_IMAGE_TEXT = 0.60 # weight for image↔text multi-segment score
_W_CAPTION_TEXT = 0.20 # weight for enhanced_caption↔prompt text similarity
_W_SEMANTIC = 0.20 # weight for semantic dimension decomposition
_SEGMENT_MAX_BOOST = 0.10 # bonus factor for best-matching segment
# ── Semantic dimension categories ──────────────────────────────────────────
# Each dimension has a prefix used to construct a probing prompt.
_SEMANTIC_DIMENSIONS: dict[str, str] = {
"subject": "a photo depicting",
"style": "an artwork in the style of",
"medium": "created using the medium of",
"mood": "an image with the mood of",
"color": "an image with the color palette of",
}
# ── Canonical auto-tag vocabulary ──────────────────────────────────────────
# Each entry maps a human-readable tag to a text prompt.
# The prompt is intentionally verbose to improve multimodal classification accuracy.
@@ -165,20 +186,24 @@ async def index_asset_vector(
vector = emb_result.data
# 2. Prompt-image alignment score (optional)
aesthetic = await _compute_prompt_alignment(vs, vector, metadata_raw)
# 2. Enhanced caption (Florence-2) for image assets (best-effort).
# Generated BEFORE alignment so it can contribute to the score.
caption: str | None = None
if kind == "image":
caption = await _generate_and_store_caption(db, vs, asset_id, filepath)
# 3. Persist embedding
# 3. Prompt-image alignment score (optional, uses caption if available)
aesthetic = await _compute_prompt_alignment(
vs, vector, metadata_raw, enhanced_caption=caption,
)
# 4. Persist embedding
blob = vector_to_blob(vector)
store_result = await _store_embedding(db, asset_id, blob, aesthetic, vs._model_name)
if not store_result.ok:
return store_result
# 3b. Enhanced caption (Florence-2) for image assets (best-effort).
if kind == "image":
await _try_store_enhanced_caption(db, vs, asset_id, filepath)
# 4. Auto-tagging
# 5. Auto-tagging
await _apply_autotags(db, vs, asset_id, vector)
return Result.Ok(True)
@@ -233,18 +258,37 @@ async def compute_prompt_alignment(
prompt: str,
*,
negative_prompt: str | None = None,
enhanced_caption: str | None = None,
) -> Result[float]:
"""Compute calibrated alignment between an image embedding and a prompt.
Returns a float in [0, 1]. Higher means the image closely matches the
textual prompt. If *negative_prompt* is provided, its similarity to the
image is subtracted (weighted) to penalise unwanted content leaking in.
"""
text_result = await vs.get_text_embedding(prompt)
if not text_result.ok or not text_result.data:
return Result.Err("METADATA_FAILED", text_result.error or "Text embedding failed")
raw_pos = VectorService.cosine_similarity(asset_embedding, text_result.data)
textual prompt.
Three signals are fused:
1. **Multi-segment image↔text** — the prompt is split into segments;
each segment is scored against the image embedding, then combined
as a length-weighted average with a best-segment bonus.
2. **Caption↔prompt text** — if an enhanced caption is available, its
text embedding is compared to the prompt embedding for a text↔text
coherence signal.
3. **Semantic decomposition** — key concepts (subject, style, medium,
mood, color) are extracted and each dimension is scored separately
against the image embedding.
If *negative_prompt* is provided, its similarity to the image is
subtracted (weighted) to penalise unwanted content leaking in.
"""
# ── 1. Multi-segment image↔text score ──────────────────────────────
raw_image_text = await _multi_segment_score(vs, asset_embedding, prompt)
if raw_image_text is None:
# Fallback: single-embedding score
text_result = await vs.get_text_embedding(prompt)
if not text_result.ok or not text_result.data:
return Result.Err("METADATA_FAILED", text_result.error or "Text embedding failed")
raw_image_text = VectorService.cosine_similarity(asset_embedding, text_result.data)
# ── Negative prompt penalty ────────────────────────────────────────
neg_penalty = 0.0
if negative_prompt:
neg_result = await vs.get_text_embedding(negative_prompt)
@@ -252,10 +296,181 @@ async def compute_prompt_alignment(
raw_neg = VectorService.cosine_similarity(asset_embedding, neg_result.data)
neg_penalty = max(0.0, raw_neg) * _NEG_PENALTY_WEIGHT
calibrated = _calibrate_score(raw_pos - neg_penalty)
adjusted_image_text = raw_image_text - neg_penalty
# ── 2. Caption↔prompt text similarity ──────────────────────────────
caption_score: float | None = None
if enhanced_caption and enhanced_caption.strip():
caption_score = await _caption_prompt_similarity(vs, enhanced_caption, prompt)
# ── 3. Semantic decomposition ──────────────────────────────────────
semantic_score = await _semantic_dimension_score(vs, asset_embedding, prompt)
# ── Weighted fusion ────────────────────────────────────────────────
total_weight = _W_IMAGE_TEXT
weighted_sum = adjusted_image_text * _W_IMAGE_TEXT
if caption_score is not None:
weighted_sum += caption_score * _W_CAPTION_TEXT
total_weight += _W_CAPTION_TEXT
if semantic_score is not None:
weighted_sum += semantic_score * _W_SEMANTIC
total_weight += _W_SEMANTIC
fused_raw = weighted_sum / total_weight if total_weight > 0 else adjusted_image_text
calibrated = _calibrate_score(fused_raw)
return Result.Ok(round(calibrated, 4))
async def _multi_segment_score(
vs: VectorService,
asset_embedding: list[float],
prompt: str,
) -> float | None:
"""Split prompt into segments and compute a weighted similarity score.
Each segment is embedded independently and scored against the image.
The final score is a length-weighted average with a bonus for the
best-matching segment, which captures the "main subject" better.
"""
segments = [s.strip() for s in _SEGMENT_SPLIT_RE.split(prompt) if len(s.strip()) >= _MIN_SEGMENT_CHARS]
if len(segments) <= 1:
# Not worth splitting — return None to use the full prompt
return None
scores: list[tuple[float, int]] = [] # (similarity, char_length)
for seg in segments:
emb = await vs.get_text_embedding(seg)
if emb.ok and emb.data:
sim = VectorService.cosine_similarity(asset_embedding, emb.data)
scores.append((sim, len(seg)))
if not scores:
return None
# Length-weighted average
total_len = sum(length for _, length in scores)
if total_len == 0:
return None
weighted_avg = sum(sim * length for sim, length in scores) / total_len
# Boost from best segment
best_sim = max(sim for sim, _ in scores)
return weighted_avg + (best_sim - weighted_avg) * _SEGMENT_MAX_BOOST
async def _caption_prompt_similarity(
vs: VectorService,
caption: str,
prompt: str,
) -> float | None:
"""Text↔text cosine similarity between enhanced caption and prompt."""
cap_emb = await vs.get_text_embedding(caption)
if not cap_emb.ok or not cap_emb.data:
return None
prompt_emb = await vs.get_text_embedding(prompt)
if not prompt_emb.ok or not prompt_emb.data:
return None
return VectorService.cosine_similarity(cap_emb.data, prompt_emb.data)
async def _semantic_dimension_score(
vs: VectorService,
asset_embedding: list[float],
prompt: str,
) -> float | None:
"""Score the image against semantic concept dimensions extracted from the prompt.
For each semantic dimension (subject, style, medium, mood, color),
we construct a probing prompt "<prefix> <extracted_concept>" and
measure its similarity to the image embedding. The final score is
the average of the individual dimension scores (only counting
dimensions where a concept was actually extracted).
"""
concepts = _extract_semantic_concepts(prompt)
if not concepts:
return None
dim_scores: list[float] = []
for dim, concept in concepts.items():
prefix = _SEMANTIC_DIMENSIONS.get(dim, "")
probe = f"{prefix} {concept}" if prefix else concept
emb = await vs.get_text_embedding(probe)
if emb.ok and emb.data:
sim = VectorService.cosine_similarity(asset_embedding, emb.data)
dim_scores.append(sim)
return sum(dim_scores) / len(dim_scores) if dim_scores else None
def _extract_semantic_concepts(prompt: str) -> dict[str, str]:
"""Heuristic extraction of semantic dimensions from a prompt.
Attempts to identify subject, style, medium, mood, and color palette
concepts by matching known keywords. Returns a dict of dimension→concept
for the dimensions that were detected.
"""
lower = prompt.lower()
concepts: dict[str, str] = {}
# Subject: first segment (usually the main subject)
segments = [s.strip() for s in _SEGMENT_SPLIT_RE.split(prompt) if len(s.strip()) >= _MIN_SEGMENT_CHARS]
if segments:
concepts["subject"] = segments[0]
# Style keywords
style_kws = (
"anime", "manga", "photorealistic", "realistic", "cartoon", "oil painting",
"watercolor", "impressionist", "surrealist", "art nouveau", "art deco",
"pixel art", "comic", "3d render", "concept art", "digital art",
"cinematic", "studio ghibli", "ukiyo-e", "pop art", "minimalist",
"gothic", "baroque", "renaissance", "cubist", "futuristic",
)
for kw in style_kws:
if kw in lower:
concepts["style"] = kw
break
# Medium keywords (longer/compound keywords first to avoid substring
# matches like "painting" taking precedence over "watercolor painting").
medium_kws = (
"oil painting", "watercolor", "photograph", "photo", "illustration",
"painting", "drawing", "sketch", "render", "sculpture", "collage",
"engraving", "print", "pencil", "charcoal", "pastel", "acrylic",
"fresco", "mosaic", "tapestry",
)
for kw in medium_kws:
if kw in lower:
concepts["medium"] = kw
break
# Mood keywords
mood_kws = (
"dark", "bright", "moody", "cheerful", "mysterious", "serene",
"dramatic", "peaceful", "ethereal", "gloomy", "vibrant", "melancholic",
"nostalgic", "whimsical", "epic", "dreamy", "eerie", "cozy",
"romantic", "surreal", "ominous", "tranquil",
)
for kw in mood_kws:
if kw in lower:
concepts["mood"] = kw
break
# Color palette keywords
color_kws = (
"monochrome", "black and white", "pastel colors", "neon", "warm tones",
"cool tones", "golden hour", "blue hour", "sepia", "high contrast",
"muted colors", "saturated", "desaturated", "earth tones",
)
for kw in color_kws:
if kw in lower:
concepts["color"] = kw
break
return concepts
async def generate_enhanced_prompt(
db: Sqlite,
vs: VectorService,
@@ -314,6 +529,8 @@ async def _compute_prompt_alignment(
vs: VectorService,
vector: list[float],
metadata_raw: dict[str, Any] | None,
*,
enhanced_caption: str | None = None,
) -> float | None:
"""Extract the original prompt from metadata and compute alignment."""
if not metadata_raw:
@@ -324,7 +541,11 @@ async def _compute_prompt_alignment(
return None
negative = _extract_negative_prompt(metadata_raw)
result = await compute_prompt_alignment(vs, vector, prompt, negative_prompt=negative)
result = await compute_prompt_alignment(
vs, vector, prompt,
negative_prompt=negative,
enhanced_caption=enhanced_caption,
)
return result.data if result.ok else None
@@ -522,6 +743,34 @@ async def _try_store_enhanced_caption(
logger.debug("Enhanced caption generation skipped for asset %d: %s", asset_id, exc)
async def _generate_and_store_caption(
db: Sqlite,
vs: VectorService,
asset_id: int,
filepath: str,
) -> str | None:
"""Generate, store, and return the enhanced caption (or None on failure)."""
try:
generated = await vs.generate_enhanced_caption(filepath)
if not generated.ok or not generated.data:
return None
fallback_title = (
Path(filepath).stem.replace("_", " ").replace("-", " ").strip()
or "Untitled"
)
caption = _normalise_title_caption(
str(generated.data).strip(),
fallback_title=fallback_title,
)
if not caption:
return None
await _store_enhanced_caption(db, asset_id, caption)
return caption
except Exception as exc:
logger.debug("Enhanced caption generation skipped for asset %d: %s", asset_id, exc)
return None
async def _apply_autotags(
db: Sqlite,
vs: VectorService,
@@ -53,6 +53,25 @@ class VectorSearcher:
"""Mark the in-memory index as stale (will be rebuilt on next query)."""
self._dirty = True
async def prewarm_index(self) -> Result[dict[str, Any]]:
"""Build the in-memory Faiss index ahead of the first semantic query."""
try:
await self._ensure_index()
except Exception as exc:
return Result.Err("SERVICE_UNAVAILABLE", f"Vector index warmup failed: {exc}")
total = 0
try:
total = int(getattr(self._index, "ntotal", 0) or 0)
except Exception:
total = 0
return Result.Ok({
"loaded": bool(self._index is not None),
"total": total,
"dirty": bool(self._dirty),
})
async def _ensure_index(self) -> None:
"""Build (or rebuild) the Faiss index from the database."""
if not self._dirty and self._index is not None:
+155 -70
View File
@@ -236,14 +236,69 @@ def _extract_frame_at(video_path: str, timestamp: float) -> PILImage.Image | Non
return None
def _extract_keyframes_cv2(video_path: str, interval: float) -> list[PILImage.Image]:
"""Fallback: extract key-frames using OpenCV when ffprobe/ffmpeg are unavailable."""
try:
import cv2
from PIL import Image as PILImage # noqa: F811
except ImportError:
return []
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
logger.debug("cv2.VideoCapture failed to open %s", video_path)
return []
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
duration = total_frames / fps if fps > 0 and total_frames > 0 else 0.0
if duration <= 0:
# Try a single frame
ret, frame_bgr = cap.read()
if ret and frame_bgr is not None:
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
return [PILImage.fromarray(frame_rgb)]
return []
timestamps: list[float] = []
t = 0.0
while t < duration:
timestamps.append(t)
t += interval
if duration > interval and (duration / 2) not in timestamps:
timestamps.append(duration / 2)
timestamps.sort()
frames: list[PILImage.Image] = []
for ts in timestamps:
frame_no = int(ts * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no)
ret, frame_bgr = cap.read()
if ret and frame_bgr is not None:
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
frames.append(PILImage.fromarray(frame_rgb))
return frames
finally:
cap.release()
def extract_keyframes(video_path: str, interval: float | None = None) -> list[PILImage.Image]:
"""Return a list of key-frame PIL Images sampled every *interval* seconds."""
"""Return a list of key-frame PIL Images sampled every *interval* seconds.
Tries ffprobe/ffmpeg first, then falls back to OpenCV (cv2) if the
ffmpeg-based extraction yields no frames.
"""
interval = interval or VECTOR_VIDEO_KEYFRAME_INTERVAL
duration = _extract_video_duration(video_path)
if duration is None or duration <= 0:
# Fallback: try a single frame at t=0
frame = _extract_frame_at(video_path, 0.0)
return [frame] if frame else []
if frame:
return [frame]
# ffmpeg unavailable — try OpenCV
return _extract_keyframes_cv2(video_path, interval)
timestamps = []
t = 0.0
@@ -260,6 +315,9 @@ def extract_keyframes(video_path: str, interval: float | None = None) -> list[PI
frame = _extract_frame_at(video_path, ts)
if frame is not None:
frames.append(frame)
# If ffmpeg extraction failed for every frame, fall back to OpenCV
if not frames:
frames = _extract_keyframes_cv2(video_path, interval)
return frames
@@ -299,6 +357,8 @@ class VectorService:
self._prompt_model: Any | None = None
self._prompt_processor: Any | None = None
self._prompt_lock = asyncio.Lock()
self._text_query_warmed = False
self._text_query_warm_lock = asyncio.Lock()
self._dim = VECTOR_EMBEDDING_DIM
self._last_error: str = ""
self._last_error_at: str | None = None
@@ -779,12 +839,40 @@ class VectorService:
self._last_error = ""
self._last_error_at = None
async def prewarm_text_queries(self, probe_text: str = "warmup") -> Result[dict[str, Any]]:
"""Preload the text-query path so the first AI search is not cold."""
if self._text_query_warmed:
return Result.Ok(self.get_runtime_status())
async with self._text_query_warm_lock:
if self._text_query_warmed:
return Result.Ok(self.get_runtime_status())
result = await self.get_text_embedding(probe_text)
if not result.ok or not result.data:
return Result.Err(
result.code or "SERVICE_UNAVAILABLE",
result.error or "Vector text-query warmup failed",
)
self._text_query_warmed = True
return Result.Ok(self.get_runtime_status())
def get_runtime_status(self) -> dict[str, Any]:
siglip_loaded = bool(self._siglip_model is not None and self._siglip_processor is not None)
sentence_transformer_loaded = bool(self._model is not None)
prompt_model_loaded = bool(self._prompt_model is not None and self._prompt_processor is not None)
video_model_loaded = bool(self._video_model is not None and self._video_processor is not None)
return {
"model_name": self._model_name,
"video_model_name": self._video_model_name,
"prompt_model_name": self._prompt_model_name,
"loaded": bool(self._model is not None),
"loaded": bool(sentence_transformer_loaded or siglip_loaded),
"sentence_transformer_loaded": sentence_transformer_loaded,
"siglip_loaded": siglip_loaded,
"video_model_loaded": video_model_loaded,
"prompt_model_loaded": prompt_model_loaded,
"text_query_warmed": bool(self._text_query_warmed),
"dim": int(self._dim),
"last_error": self._last_error or None,
"last_error_at": self._last_error_at,
@@ -1167,6 +1255,36 @@ class VectorService:
if not frames:
return Result.Err("UNSUPPORTED", "No frames could be extracted from video")
# Use the same native SigLIP2 path that works for image embeddings
# to encode each keyframe, then average.
if self._use_native_siglip():
try:
import numpy as np
import torch
processor, native_model = await self._ensure_siglip_components()
def _embed_frames_native() -> list[float]:
vecs = []
with torch.inference_mode():
for frame in frames:
inputs = processor(images=[frame], return_tensors="pt")
feats = native_model.get_image_features(**inputs)
arr = feats.detach().cpu().numpy()[0]
vecs.append(_normalise_vector(arr))
mean_vec = np.mean(vecs, axis=0)
vec = _normalise_vector(mean_vec)
return _coerce_vector_dim(vec, self._dim)
vec = await asyncio.to_thread(_embed_frames_native)
self._clear_error()
return Result.Ok(vec)
except Exception as exc:
self._record_error(f"Video embedding failed for {path.name}: {exc}")
logger.debug("Native SigLIP video embedding failed for %s: %s", path.name, exc)
return Result.Err("METADATA_FAILED", f"Video embedding failed: {exc}")
# Legacy SentenceTransformer fallback
try:
import numpy as np # noqa: F811
@@ -1201,17 +1319,19 @@ class VectorService:
path.name,
batch_exc,
)
def _encode_single_frame(frame_obj: Any) -> Any:
return _encode_quiet(
model,
cast(Any, [frame_obj]),
convert_to_numpy=True,
batch_size=1,
show_progress_bar=False,
)
vecs = []
for frame in frames:
encoded = await asyncio.to_thread(
lambda fr=frame: _encode_quiet(
model,
cast(Any, [fr]),
convert_to_numpy=True,
batch_size=1,
show_progress_bar=False,
)
)
encoded = await asyncio.to_thread(_encode_single_frame, frame)
vecs.append(_coerce_first_vector(encoded))
mean_vec = np.mean(vecs, axis=0)
@@ -1538,73 +1658,38 @@ class VectorService:
if not rgb_frames:
raise RuntimeError("No RGB frames available for X-CLIP embedding")
def _get_field(container: Any, field: str) -> Any:
try:
getter = getattr(container, "get", None)
if callable(getter):
return getter(field)
except Exception:
pass
try:
return container[field]
except Exception:
pass
try:
return getattr(container, field)
except Exception:
return None
def _try_processor(**kwargs: Any) -> Any:
try:
return processor(**kwargs)
except Exception:
return None
# X-CLIP expects exactly 8 frames; pad by repeating if fewer.
target_nframes = 8
while len(rgb_frames) < target_nframes:
rgb_frames.append(rgb_frames[len(rgb_frames) % len(rgb_frames)])
with torch.inference_mode():
inputs: Any | None = None
pixel_values: Any | None = None
# Some processor/model combos return non-mapping values for
# `images=[...]` (seen in the field). Probe fallback signatures
# and extract `pixel_values` defensively.
processor_calls: list[dict[str, Any]] = [
{"images": rgb_frames, "return_tensors": "pt", "padding": True},
{"videos": [rgb_frames], "return_tensors": "pt", "padding": True},
{"videos": rgb_frames, "return_tensors": "pt", "padding": True},
{"images": rgb_frames[0], "return_tensors": "pt"},
]
for kwargs in processor_calls:
out = _try_processor(**kwargs)
if out is None:
continue
inputs = out
pixel_values = _get_field(out, "pixel_values")
if pixel_values is None and hasattr(out, "shape") and hasattr(out, "dtype"):
pixel_values = out
if pixel_values is not None:
break
# XCLIPProcessor uses images= (not videos=) to build
# pixel_values with shape [batch, num_frames, C, H, W].
inputs = processor(images=rgb_frames, return_tensors="pt")
pixel_values = inputs.get("pixel_values")
if pixel_values is None:
raise RuntimeError("X-CLIP processor returned no pixel_values")
if hasattr(model, "get_image_features"):
if pixel_values is None:
raise RuntimeError("X-CLIP processor returned no pixel_values")
feats = model.get_image_features(pixel_values=pixel_values)
if hasattr(model, "get_video_features"):
# get_video_features may return a tuple in some
# transformers versions; handle both cases.
out = model.get_video_features(pixel_values=pixel_values)
if isinstance(out, tuple):
feats = out[0]
else:
feats = out
else:
model_inputs: dict[str, Any] = {}
if isinstance(inputs, Mapping):
try:
model_inputs = dict(inputs)
except Exception:
model_inputs = {}
if not model_inputs and pixel_values is not None:
model_inputs = {"pixel_values": pixel_values}
if not model_inputs:
raise RuntimeError("X-CLIP processor returned no usable model inputs")
out = model(**model_inputs)
feats = getattr(out, "pooler_output", None)
out = model(pixel_values=pixel_values)
feats = getattr(out, "video_embeds", None)
if feats is None:
feats = getattr(out, "pooler_output", None)
if feats is None:
feats = getattr(out, "last_hidden_state", None)
if feats is None:
raise RuntimeError("X-CLIP output does not expose usable features")
feats = feats.mean(dim=1)
arr = feats.detach().cpu().numpy()
mean_vec = np.mean(arr, axis=0)
vec = _normalise_vector(mean_vec)
+37 -1
View File
@@ -5,6 +5,7 @@ import asyncio
import threading
from typing import Any
from mjr_am_backend.config import VECTOR_PREWARM_ON_STARTUP
from mjr_am_backend.deps import build_services
from mjr_am_backend.shared import Result, get_logger
@@ -100,6 +101,41 @@ def get_services_error():
return _services_error
async def _run_vector_prewarm_step(label: str, runner: Any) -> None:
try:
result = await runner()
except Exception as exc:
logger.warning("%s failed during startup prewarm: %s", label, exc)
return
if not getattr(result, "ok", False):
logger.warning("%s failed during startup prewarm: %s", label, getattr(result, "error", None) or "unknown error")
return
logger.info("%s prewarmed", label)
async def _prewarm_vector_components(services: dict[str, Any] | None) -> None:
if not VECTOR_PREWARM_ON_STARTUP or not isinstance(services, dict):
return
vector_service = services.get("vector_service")
vector_searcher = services.get("vector_searcher")
steps: list[asyncio.Future[Any] | asyncio.Task[Any] | Any] = []
prewarm_text = getattr(vector_service, "prewarm_text_queries", None)
if callable(prewarm_text):
steps.append(_run_vector_prewarm_step("Vector text-query model", prewarm_text))
prewarm_index = getattr(vector_searcher, "prewarm_index", None)
if callable(prewarm_index):
steps.append(_run_vector_prewarm_step("Vector search index", prewarm_index))
if steps:
await asyncio.gather(*steps)
async def prewarm_services() -> None:
"""Pre-warm services in the background so the first user request is fast."""
await _build_services()
services = await _build_services()
await _prewarm_vector_components(services)
@@ -3,6 +3,7 @@ Asset management endpoints: ratings, tags, service retry.
"""
import asyncio
import errno
import html as _html_mod
import json
import os
import re
@@ -58,6 +59,7 @@ logger = get_logger(__name__)
MAX_TAGS_PER_ASSET = 50
MAX_TAG_LENGTH = 100
MAX_RENAME_LENGTH = 255
USER_GUIDE_FILE_NAME = "user_guide.html"
# P2-E-05/06 delegation to extracted modules.
from ..assets import filename_validator as _fv
@@ -76,6 +78,63 @@ _pg_build_download_response = _pg.build_download_response
_pg_safe_download_filename = _pg.safe_download_filename
def _resolve_local_user_guide_path() -> Path:
try:
return (Path(__file__).resolve().parents[3] / USER_GUIDE_FILE_NAME).resolve(strict=False)
except Exception:
return Path(USER_GUIDE_FILE_NAME)
def _resolve_docs_dir() -> Path:
"""Return the absolute path to the docs/ folder in the project root."""
try:
return (Path(__file__).resolve().parents[3] / "docs").resolve(strict=False)
except Exception:
return Path("docs")
# Allowed filename pattern for doc files (letters, digits, hyphens, underscores, dots).
_DOC_FILENAME_RE = re.compile(r"^[A-Za-z0-9_\-]+\.md$")
_DOCS_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} - Majoor Assets Manager Docs</title>
<style>
:root {{
--bg: #1e1e1e; --bg2: #2a2a2a; --fg: #ddd;
--fg2: rgba(255,255,255,0.65); --accent: #5fb3ff;
--border: rgba(255,255,255,0.12); --code-bg: #333;
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: var(--font); background: var(--bg); color: var(--fg);
line-height: 1.6; font-size: 14px; padding: 0; }}
.header {{ background: var(--bg2); padding: 16px 24px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 16px; }}
.header a {{ color: var(--accent); text-decoration: none; font-size: 13px; }}
.header a:hover {{ text-decoration: underline; }}
.header h1 {{ font-size: 16px; font-weight: 600; }}
.content {{ max-width: 960px; margin: 0 auto; padding: 24px 32px; }}
.content pre {{ background: var(--code-bg); padding: 16px; border-radius: 8px;
overflow-x: auto; font-family: Consolas, Monaco, monospace;
font-size: 13px; line-height: 1.5; white-space: pre-wrap;
word-wrap: break-word; color: var(--fg2); }}
</style>
</head>
<body>
<div class="header">
<a href="/mjr/am/user-guide">&larr; Back to User Guide</a>
<h1>{title}</h1>
</div>
<div class="content"><pre>{content}</pre></div>
</body>
</html>
"""
def register_asset_routes(routes: web.RouteTableDef) -> None:
"""Register asset CRUD routes (get, delete, rename)."""
def _filepath_db_keys(path_value: str | Path | None) -> tuple[str, ...]:
@@ -732,6 +791,47 @@ def register_asset_routes(routes: web.RouteTableDef) -> None:
)
return _json_response(result)
@routes.get("/mjr/am/user-guide")
async def open_local_user_guide(_request):
"""
Serve the local user_guide.html from this custom node folder.
"""
guide_path = _resolve_local_user_guide_path()
try:
if not guide_path.is_file():
return web.Response(status=404, text="User guide not found")
return web.FileResponse(path=str(guide_path))
except Exception as exc:
return _json_response(
Result.Err("FS_ERROR", _safe_error_message(exc, "Failed to open user guide"))
)
@routes.get("/mjr/am/docs/{filename}")
async def serve_doc_file(request):
"""Serve a markdown documentation file from the docs/ folder."""
filename = request.match_info.get("filename", "")
if not _DOC_FILENAME_RE.match(filename):
return web.Response(status=400, text="Invalid document filename")
docs_dir = _resolve_docs_dir()
doc_path = (docs_dir / filename).resolve()
# Path traversal guard: resolved path must stay inside docs_dir.
if not str(doc_path).startswith(str(docs_dir.resolve())):
return web.Response(status=403, text="Access denied")
if not doc_path.is_file():
return web.Response(status=404, text="Document not found")
try:
content = doc_path.read_text(encoding="utf-8")
title = filename.replace("_", " ").replace(".md", "")
html_page = _DOCS_HTML_TEMPLATE.format(
title=_html_mod.escape(title),
content=_html_mod.escape(content),
)
return web.Response(text=html_page, content_type="text/html")
except Exception as exc:
return _json_response(
Result.Err("FS_ERROR", _safe_error_message(exc, "Failed to read document"))
)
@routes.get("/mjr/am/routes")
async def list_routes(request):
"""List all available API routes."""
@@ -759,6 +859,8 @@ def register_asset_routes(routes: web.RouteTableDef) -> None:
{"method": "POST", "path": "/mjr/am/assets/rename", "description": "Alias: rename a single asset file"},
{"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": "GET", "path": "/mjr/am/user-guide", "description": "Open local Assets Manager user guide"},
{"method": "GET", "path": "/mjr/am/docs/{filename}", "description": "Serve a markdown documentation file"},
{"method": "POST", "path": "/mjr/am/retry-services", "description": "Retry service initialization"},
{"method": "GET", "path": "/mjr/am/routes", "description": "List all available routes (this endpoint)"},
]
@@ -661,7 +661,7 @@ async def _backfill_missing_asset_vectors(
scope: str = "output",
custom_root_id: str = "",
on_progress: Any | None = None,
) -> Result[dict[str, int]]:
) -> Result[dict[str, int | str | None]]:
"""
Generate embeddings for existing assets that currently have no vector.
@@ -19,11 +19,16 @@ import re
from typing import Any
from aiohttp import web
from ...config import VECTOR_TEXT_SEARCH_MIN_SCORE, VECTOR_TEXT_SEARCH_RELATIVE_RATIO
from ...shared import Result, get_logger
from ..core import _json_response, _require_services
from ..core.security import _check_rate_limit
from .vector_search import _hydrate_vector_results, _require_vector_services
from .vector_search import (
_filter_text_search_hits,
_hydrate_vector_results,
_require_vector_services,
)
logger = get_logger(__name__)
@@ -96,10 +101,20 @@ async def _filter_semantic_hits(
filters: dict[str, Any],
scope: str,
custom_root_id: str | None,
min_score: float,
relative_ratio: float,
) -> list[dict[str, Any]]:
if db is None or not isinstance(hits, list) or not hits:
return hits if isinstance(hits, list) else []
hits = _filter_text_search_hits(
list(hits),
min_score=min_score,
relative_ratio=relative_ratio,
)
if not hits:
return []
asset_ids: list[int] = []
for hit in hits:
try:
@@ -380,6 +395,8 @@ def register_hybrid_search_routes(routes: web.RouteTableDef) -> None:
filters=inline_filters,
scope=scope,
custom_root_id=custom_root_id,
min_score=VECTOR_TEXT_SEARCH_MIN_SCORE,
relative_ratio=VECTOR_TEXT_SEARCH_RELATIVE_RATIO,
)
except Exception:
return []
+51 -2
View File
@@ -21,7 +21,7 @@ except Exception:
folder_paths = _FolderPathsStub() # type: ignore
from mjr_am_backend.adapters.db.schema import rebuild_fts
from mjr_am_backend.adapters.db.schema import purge_orphan_vec_embeddings, rebuild_fts
from mjr_am_backend.config import (
COLLECTIONS_DIR_PATH,
INDEX_DIR_PATH,
@@ -876,6 +876,7 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
reindex: bool (default: true)
clear_scan_journal: bool (default: true)
clear_metadata_cache: bool (default: true)
preserve_vectors: bool (default: false)
rebuild_fts: bool (default: true)
incremental: bool (default: false)
fast: bool (default: true)
@@ -941,6 +942,17 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
clear_metadata_cache = _bool_option(("clear_metadata_cache", "clearMetadataCache"), True)
clear_asset_metadata = _bool_option(("clear_asset_metadata", "clearAssetMetadata"), True)
clear_assets_table = _bool_option(("clear_assets", "clearAssets"), True)
preserve_vectors = _bool_option(
(
"preserve_vectors",
"preserveVectors",
"keep_vectors",
"keepVectors",
"keep_embeddings",
"keepEmbeddings",
),
False,
)
rebuild_fts_flag = _bool_option(("rebuild_fts", "rebuildFts"), True)
incremental = _bool_option(("incremental",), False)
fast = _bool_option(("fast",), True)
@@ -965,6 +977,11 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
),
False,
)
if preserve_vectors:
# Preserve embeddings by keeping the assets table (asset_id continuity)
# and never escalating to a hard DB-file reset.
clear_assets_table = False
hard_reset_db = False
output_root = await _runtime_output_root(svc)
target_roots: list[dict[str, str | None]] = []
@@ -1253,6 +1270,19 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
if not res.ok:
# Fallback: if scoped clear hits a malformed DB, escalate to hard adapter reset.
if _is_db_malformed_result(res):
if preserve_vectors:
_emit_maintenance_status(
"failed",
"error",
"Database is malformed; preserving vectors is not possible. Retry with full reset.",
operation="reset_index",
)
return _json_response(
Result.Err(
"DB_MALFORMED",
"Database is malformed; preserving vectors is not possible. Retry with full reset.",
)
)
try:
if scan_lock is not None and hasattr(scan_lock, "__aenter__"):
async with scan_lock:
@@ -1285,6 +1315,17 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
_emit_maintenance_status("failed", "error", res.error or "Index reset failed", operation="reset_index")
return _json_response(res)
vectors_purged: bool | None = None
if clear_assets_table:
try:
purge_res = await purge_orphan_vec_embeddings(db)
vectors_purged = bool(purge_res.ok)
if not purge_res.ok:
logger.warning("Failed to purge orphan vectors during index reset: %s", purge_res.error)
except Exception as exc:
vectors_purged = False
logger.warning("Exception while purging orphan vectors during index reset: %s", exc)
# FULL RESET CLEANUP: VACUUM and Physical Files
if scope == "all" and (clear_scan_journal or clear_metadata_cache):
# 1. Vacuum DB to reclaim space and rebuild file
@@ -1302,8 +1343,14 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
# IMPORTANT: when adding a new persistent state file to the index directory,
# register its name (or prefix) below to prevent it from being wiped on
# a full reindex. Using an explicit constant avoids silent data loss.
_KEEP_PREFIXES: frozenset[str] = frozenset({"assets.sqlite"}) # DB + WAL/SHM
keep_prefixes = {"assets.sqlite"} # DB + WAL/SHM
if preserve_vectors:
# Keep vectors DB (+ WAL/SHM) when user opted to preserve embeddings.
keep_prefixes.add("vectors.sqlite")
_KEEP_PREFIXES: frozenset[str] = frozenset(keep_prefixes)
_KEEP_NAMES: frozenset[str] = frozenset({"custom_roots.json"})
if preserve_vectors:
_KEEP_NAMES = frozenset(set(_KEEP_NAMES) | {"vectors"})
for item in INDEX_DIR_PATH.iterdir():
if any(item.name.startswith(p) for p in _KEEP_PREFIXES):
continue
@@ -1408,6 +1455,8 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
Result.Ok(
{
"cleared": cleared,
"preserve_vectors": bool(preserve_vectors),
"vectors_purged": vectors_purged,
"scan_summary": scan_summary,
"rebuild_fts": rebuild_status,
}
+114 -1
View File
@@ -12,7 +12,13 @@ import math
from typing import Any
from aiohttp import web
from ...config import VECTOR_SIMILAR_TOPK, is_vector_search_enabled
from ...config import (
VECTOR_SIMILAR_MIN_SCORE,
VECTOR_SIMILAR_TOPK,
VECTOR_TEXT_SEARCH_MIN_SCORE,
VECTOR_TEXT_SEARCH_RELATIVE_RATIO,
is_vector_search_enabled,
)
from ...shared import Result, get_logger
from ..core import _json_response, _require_services
@@ -188,6 +194,102 @@ async def _filter_hits_by_scope(
return filtered
async def _filter_similar_hits(
db: Any,
hits: list[dict[str, Any]],
*,
query_asset_id: int,
min_score: float,
) -> list[dict[str, Any]]:
if not isinstance(hits, list) or not hits:
return []
score_floor = max(0.0, min(1.0, float(min_score or 0.0)))
filtered = []
for hit in hits:
try:
score = float(hit.get("score") or 0.0)
except Exception:
score = 0.0
if score < score_floor:
continue
filtered.append(hit)
if not filtered or db is None:
return filtered
source_row = await db.aquery(
"SELECT LOWER(COALESCE(kind, '')) AS kind FROM assets WHERE id = ?",
(int(query_asset_id),),
)
if not source_row.ok or not source_row.data:
return filtered
source_kind = str(source_row.data[0].get("kind") or "").strip().lower()
if not source_kind:
return filtered
asset_ids: list[int] = []
for hit in filtered:
try:
aid = int(hit.get("asset_id") or 0)
except Exception:
aid = 0
if aid > 0:
asset_ids.append(aid)
if not asset_ids:
return []
placeholders = ",".join("?" for _ in asset_ids)
rows = await db.aquery(
f"SELECT id AS asset_id FROM assets WHERE id IN ({placeholders}) AND LOWER(COALESCE(kind, '')) = ?",
tuple(asset_ids + [source_kind]),
)
if not rows.ok or not rows.data:
return []
allowed = set()
for row in rows.data:
try:
allowed.add(int(row.get("asset_id") or 0))
except Exception:
continue
if not allowed:
return []
return [hit for hit in filtered if int(hit.get("asset_id") or 0) in allowed]
def _filter_text_search_hits(
hits: list[dict[str, Any]],
*,
min_score: float,
relative_ratio: float,
) -> list[dict[str, Any]]:
if not isinstance(hits, list) or not hits:
return []
ratio = max(0.0, min(1.0, float(relative_ratio or 0.0)))
floor = max(0.0, min(1.0, float(min_score or 0.0)))
filtered: list[dict[str, Any]] = []
best_score = 0.0
for hit in hits:
try:
score = float(hit.get("score") or 0.0)
except Exception:
score = 0.0
if score > best_score:
best_score = score
threshold = max(floor, best_score * ratio)
for hit in hits:
try:
score = float(hit.get("score") or 0.0)
except Exception:
score = 0.0
if score >= threshold:
filtered.append(hit)
return filtered
def _require_vector_services(services: dict[str, Any]):
"""Return (vector_searcher, error_response | None)."""
if not is_vector_search_enabled():
@@ -365,6 +467,11 @@ def register_vector_search_routes(routes: web.RouteTableDef) -> None:
# Hydrate results with full asset data for grid rendering
if result.ok and result.data:
db = services_dict.get("db")
result.data = _filter_text_search_hits(
list(result.data or []),
min_score=VECTOR_TEXT_SEARCH_MIN_SCORE,
relative_ratio=VECTOR_TEXT_SEARCH_RELATIVE_RATIO,
)
result.data = (await _filter_hits_by_scope(
db,
list(result.data or []),
@@ -427,6 +534,12 @@ def register_vector_search_routes(routes: web.RouteTableDef) -> None:
# Hydrate results with full asset data for grid rendering
if result.ok and result.data:
db = services_dict.get("db")
result.data = await _filter_similar_hits(
db,
list(result.data or []),
query_asset_id=asset_id,
min_score=VECTOR_SIMILAR_MIN_SCORE,
)
result.data = (await _filter_hits_by_scope(
db,
list(result.data or []),
+56
View File
@@ -23,9 +23,65 @@ from ..core import (
logger = get_logger(__name__)
_POPOUT_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1e1e1e">
<title>Majoor Viewer</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
color: #ddd;
font-family: system-ui, sans-serif;
}
body {
display: flex;
min-height: 100vh;
}
#mjr-mfv-popout-root {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
align-items: stretch;
justify-content: stretch;
background:
radial-gradient(circle at top, rgba(95, 179, 255, 0.12), transparent 35%),
linear-gradient(180deg, #1a1a1a 0%, #101010 100%);
}
.mjr-mfv-popout-loading {
margin: auto;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(20,20,20,0.82);
font-size: 12px;
letter-spacing: 0.02em;
opacity: 0.84;
}
</style>
</head>
<body>
<div id="mjr-mfv-popout-root">
<div class="mjr-mfv-popout-loading">Preparing viewer…</div>
</div>
</body>
</html>
"""
def register_viewer_routes(routes: web.RouteTableDef) -> None:
"""Register viewer info and file-serving routes."""
@routes.get("/mjr/viewer/popout")
async def viewer_popout_page(_request: web.Request):
return web.Response(text=_POPOUT_HTML, content_type="text/html")
@routes.get("/mjr/am/viewer/info")
async def viewer_info(request: web.Request):
"""
+9
View File
@@ -41,6 +41,15 @@ protobuf>=3.20.0,<6.0.0
# timm is required by Florence-2 model implementation
timm>=0.9.0,<2.0.0
# einops is required by Florence-2 vision encoder
einops>=0.7.0,<1.0.0
# sentencepiece is required by the SigLIP2 tokenizer (SiglipTokenizer)
sentencepiece>=0.1.99,<1.0.0
# OpenCV headless for video frame extraction fallback when ffprobe/ffmpeg are unavailable
opencv-python-headless>=4.8.0,<5.0.0
# Efficient approximate nearest-neighbour search (CPU-only build)
faiss-cpu>=1.7.0,<2.0.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

+66
View File
@@ -102,3 +102,69 @@ async def test_services_build_failure_sets_error(monkeypatch) -> None:
assert required is None
assert err is not None
assert err.code == "SERVICE_UNAVAILABLE"
@pytest.mark.asyncio
async def test_prewarm_services_warms_vector_components(monkeypatch) -> None:
calls = {"text": 0, "index": 0}
class _Db:
async def aclose(self):
return None
class _VectorService:
async def prewarm_text_queries(self):
calls["text"] += 1
return Result.Ok({"loaded": True})
class _VectorSearcher:
async def prewarm_index(self):
calls["index"] += 1
return Result.Ok({"loaded": True, "total": 0})
async def _build_ok():
return Result.Ok({
"db": _Db(),
"vector_service": _VectorService(),
"vector_searcher": _VectorSearcher(),
})
monkeypatch.setattr(svc, "build_services", _build_ok)
monkeypatch.setattr(svc, "VECTOR_PREWARM_ON_STARTUP", True)
svc._services = None
svc._services_error = None
await svc.prewarm_services()
assert calls == {"text": 1, "index": 1}
@pytest.mark.asyncio
async def test_prewarm_services_swallow_vector_warmup_failures(monkeypatch) -> None:
class _Db:
async def aclose(self):
return None
class _VectorService:
async def prewarm_text_queries(self):
raise RuntimeError("boom")
class _VectorSearcher:
async def prewarm_index(self):
return Result.Err("SERVICE_UNAVAILABLE", "no index")
async def _build_ok():
return Result.Ok({
"db": _Db(),
"vector_service": _VectorService(),
"vector_searcher": _VectorSearcher(),
})
monkeypatch.setattr(svc, "build_services", _build_ok)
monkeypatch.setattr(svc, "VECTOR_PREWARM_ON_STARTUP", True)
svc._services = None
svc._services_error = None
await svc.prewarm_services()
assert isinstance(svc._services, dict)
@@ -312,7 +312,6 @@ async def test_get_tags_success_and_error(monkeypatch):
resp2 = await (await app.router.resolve(req2)).handler(req2)
assert _json(resp2).get("code") == "DB_ERROR"
@pytest.mark.asyncio
async def test_delete_asset_validation_paths(monkeypatch, tmp_path: Path):
app = _app()
@@ -82,6 +82,58 @@ async def test_hybrid_search_filters_semantic_hits_by_scope(monkeypatch) -> None
assert data[0].get("_matchType") == "semantic"
@pytest.mark.asyncio
async def test_hybrid_search_filters_weak_semantic_tail(monkeypatch) -> None:
class _DB:
async def aquery(self, sql: str, params=()):
if "FROM assets_fts" in sql:
return Result.Ok([])
if "SELECT a.id AS asset_id" in sql and "WHERE a.id IN" in sql:
return Result.Ok(
[
{"asset_id": 2},
{"asset_id": 3},
{"asset_id": 4},
]
)
return Result.Ok([])
class _Searcher:
async def search_by_text(self, _query: str, *, top_k: int = 20):
assert top_k >= 20
return Result.Ok(
[
{"asset_id": 2, "score": 0.14},
{"asset_id": 3, "score": 0.10},
{"asset_id": 4, "score": 0.05},
]
)
async def _require_services():
return {"db": _DB(), "vector_searcher": _Searcher()}, None
async def _hydrate_passthrough(_services, result):
return Result.Ok(result.data or [])
monkeypatch.setattr(hybrid_search, "_require_services", _require_services)
monkeypatch.setattr(hybrid_search, "_hydrate_vector_results", _hydrate_passthrough)
monkeypatch.setattr(hybrid_search, "_check_rate_limit", lambda *_args, **_kwargs: (True, None))
monkeypatch.setattr(hybrid_search, "VECTOR_TEXT_SEARCH_MIN_SCORE", 0.02)
monkeypatch.setattr(hybrid_search, "VECTOR_TEXT_SEARCH_RELATIVE_RATIO", 0.7)
app = _build_hybrid_app()
req = make_mocked_request("GET", "/mjr/am/search/hybrid?q=dinosaure&scope=output&top_k=5", app=app)
match = await app.router.resolve(req)
req._match_info = match
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
data = body.get("data") or []
assert [item.get("asset_id") for item in data] == [2, 3]
assert all(item.get("_matchType") == "semantic" for item in data)
@pytest.mark.asyncio
async def test_hybrid_search_scope_all_does_not_force_source_all(monkeypatch) -> None:
seen = {"checked": False}
+21
View File
@@ -33,6 +33,27 @@ async def test_should_skip_by_journal_state_checks_rich_metadata_when_not_fast()
assert await ipo.should_skip_by_journal_state(scanner, prepare_ctx=prepare_ctx, incremental=True, fast=False)
@pytest.mark.asyncio
async def test_should_skip_by_journal_state_keeps_missing_vector_assets_active() -> None:
async def _has_rich_metadata(*, asset_id):
return asset_id == 5
async def _asset_has_vector(*, asset_id):
return False
scanner = SimpleNamespace(
_asset_has_rich_metadata=_has_rich_metadata,
_asset_has_vector=_asset_has_vector,
)
prepare_ctx = {"journal_state_hash": "s", "state_hash": "s", "existing_id": 5}
assert await ipo.should_skip_by_journal_state(
scanner,
prepare_ctx=prepare_ctx,
incremental=True,
fast=False,
) is False
@pytest.mark.asyncio
async def test_refresh_entry_from_cached_metadata_returns_refresh_entry(monkeypatch) -> None:
async def _cached(_db, _fp, _state_hash):
@@ -106,6 +106,16 @@ async def test_should_skip_no_existing_id():
assert await ipo.should_skip_by_journal_state(scanner, prepare_ctx=ctx, incremental=True, fast=False) is False
@pytest.mark.asyncio
async def test_should_skip_fast_false_when_vector_missing():
async def _asset_has_vector(*, asset_id):
return False
scanner = SimpleNamespace(_asset_has_vector=_asset_has_vector)
ctx = {"journal_state_hash": "h", "state_hash": "h", "existing_id": 1}
assert await ipo.should_skip_by_journal_state(scanner, prepare_ctx=ctx, incremental=True, fast=True) is False
# ─── refresh_entry_from_cached_metadata ─────────────────────────────────────
@pytest.mark.asyncio
@@ -176,6 +186,34 @@ async def test_maybe_skip_incremental_unchanged_rich_metadata(monkeypatch):
assert result is not None and result.data == {"action": "skipped"}
@pytest.mark.asyncio
async def test_maybe_skip_incremental_unchanged_rich_metadata_but_missing_vector(monkeypatch):
async def _should_skip(scanner, *, prepare_ctx, incremental, fast):
return False
async def _refresh_cached(scanner, *, prepare_ctx, file_path, fast):
return None
async def _has_rich(*, asset_id):
return True
async def _asset_has_vector(*, asset_id):
return False
monkeypatch.setattr(ipo, "should_skip_by_journal_state", _should_skip)
monkeypatch.setattr(ipo, "refresh_entry_from_cached_metadata", _refresh_cached)
scanner = SimpleNamespace(
_asset_has_rich_metadata=_has_rich,
_asset_has_vector=_asset_has_vector,
)
ctx = {"state_hash": "h", "journal_state_hash": "diff", "existing_id": 1, "mtime": 5, "size": 10, "existing_mtime": 5}
result = await ipo.maybe_skip_prepare_for_incremental(
scanner, prepare_ctx=ctx, incremental=True, fast=False, file_path=Path("x.png")
)
assert result is None
@pytest.mark.asyncio
async def test_maybe_skip_not_incremental_unchanged_returns_none(monkeypatch):
async def _should_skip(scanner, *, prepare_ctx, incremental, fast):
@@ -83,3 +83,30 @@ async def test_schedule_added_vectors_respects_scan_toggle(monkeypatch) -> None:
await asyncio.sleep(0)
assert called["n"] == 0
@pytest.mark.asyncio
async def test_schedule_prepared_vectors_includes_refreshed_and_updated_assets(monkeypatch) -> None:
scanner = IndexScanner(_DbStub([]), metadata_service=object(), scan_lock=asyncio.Lock())
scanner.set_vector_services(vector_service=object(), vector_searcher=None)
captured = {"asset_ids": None}
async def _fake_index(*, asset_ids):
captured["asset_ids"] = list(asset_ids)
monkeypatch.setattr(scanner, "_index_missing_asset_vectors", _fake_index)
scanner._schedule_prepared_vector_index(
prepared=[
{"action": "refresh", "asset_id": 4},
{"action": "updated", "asset_id": 5},
{"action": "skipped", "asset_id": 6},
{"action": "updated", "asset_id": 4},
],
prev_added_count=1,
added_ids=[3, 7, 5],
)
await asyncio.sleep(0)
assert captured["asset_ids"] == [7, 5, 4]
@@ -0,0 +1,205 @@
"""Tests for the multi-signal prompt alignment improvements.
Covers:
1. Multi-segment scoring (prompt split by commas)
2. Enhanced caption ↔ prompt text fusion
3. Semantic dimension decomposition
"""
import pytest
from mjr_am_backend.features.index import vector_indexer as m
from mjr_am_backend.features.index.vector_service import VectorService
from mjr_am_backend.shared import Result
# ── Helpers ────────────────────────────────────────────────────────────────
class _VS:
"""Deterministic VectorService stub.
Returns a fixed vector for text/image. If *text_vectors* is supplied,
the stub returns different vectors per prompt string, allowing tests
to exercise per-segment scoring.
"""
def __init__(self, *, text_vectors: dict[str, list[float]] | None = None):
self._model_name = "test"
self._default_vec = [1.0, 0.0]
self._text_vectors = text_vectors or {}
async def get_text_embedding(self, text: str) -> Result[list[float]]:
vec = self._text_vectors.get(text, self._default_vec)
return Result.Ok(vec)
async def get_image_embedding(self, _path: str) -> Result[list[float]]:
return Result.Ok(self._default_vec)
# ── _extract_semantic_concepts ─────────────────────────────────────────────
def test_extract_semantic_concepts_full():
prompt = "a beautiful woman, anime style, watercolor painting, dreamy, neon colors"
concepts = m._extract_semantic_concepts(prompt)
assert concepts["subject"] == "a beautiful woman"
assert concepts["style"] == "anime"
assert concepts["medium"] == "watercolor" # "watercolor" before generic "painting"
assert concepts["mood"] == "dreamy"
assert concepts["color"] == "neon"
def test_extract_semantic_concepts_partial():
prompt = "a car on a highway, impressionist"
concepts = m._extract_semantic_concepts(prompt)
assert "subject" in concepts
assert concepts["style"] == "impressionist"
assert "medium" not in concepts
assert "mood" not in concepts
def test_extract_semantic_concepts_no_matches():
prompt = "xyzzy123"
concepts = m._extract_semantic_concepts(prompt)
assert concepts == {"subject": "xyzzy123"}
# ── _multi_segment_score ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_multi_segment_returns_none_for_single_segment():
vs = _VS()
result = await m._multi_segment_score(vs, [1.0, 0.0], "a single prompt")
assert result is None
@pytest.mark.asyncio
async def test_multi_segment_splits_and_scores():
vs = _VS()
prompt = "a beautiful woman, sunset landscape, dramatic lighting"
result = await m._multi_segment_score(vs, [1.0, 0.0], prompt)
assert result is not None
assert isinstance(result, float)
# All segments map to the same default vector → cosine 1.0 for each
assert result >= 0.99
@pytest.mark.asyncio
async def test_multi_segment_ignores_short_segments():
vs = _VS()
prompt = "a beautiful woman, x, y, z, dramatic scene"
result = await m._multi_segment_score(vs, [1.0, 0.0], prompt)
assert result is not None
# Only "a beautiful woman" and "dramatic scene" survive (>= 4 chars)
# ── _caption_prompt_similarity ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_caption_similarity_same_vectors():
vs = _VS()
result = await m._caption_prompt_similarity(vs, "a beautiful sunset", "sunset scene")
assert result is not None
assert result >= 0.99 # Same embedding → cosine 1.0
@pytest.mark.asyncio
async def test_caption_similarity_different_vectors():
vs = _VS(text_vectors={
"a cat sitting on a mat": [1.0, 0.0],
"fireworks at night": [0.0, 1.0],
})
score = await m._caption_prompt_similarity(vs, "a cat sitting on a mat", "fireworks at night")
assert score is not None
# Orthogonal vectors → cosine 0.0
assert abs(score) < 0.01
# ── _semantic_dimension_score ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_semantic_score_with_concepts():
vs = _VS()
prompt = "a warrior, anime style, dark mood, oil painting"
score = await m._semantic_dimension_score(vs, [1.0, 0.0], prompt)
assert score is not None
assert isinstance(score, float)
@pytest.mark.asyncio
async def test_semantic_score_no_concepts():
# Single short segment with no known keywords
vs = _VS()
score = await m._semantic_dimension_score(vs, [1.0, 0.0], "xy")
# "xy" is < 4 chars → no segments → no subject → empty concepts
assert score is None
# ── compute_prompt_alignment (full pipeline) ──────────────────────────────
@pytest.mark.asyncio
async def test_alignment_with_caption_boosts_score():
"""When an enhanced caption is provided, it contributes to the score."""
vs = _VS()
prompt = "a beautiful sunset over mountains"
emb = [1.0, 0.0]
without_caption = await m.compute_prompt_alignment(vs, emb, prompt)
with_caption = await m.compute_prompt_alignment(
vs, emb, prompt, enhanced_caption="golden sunset over mountain range",
)
assert without_caption.ok
assert with_caption.ok
# With identical vectors everywhere, both should be ~1.0
assert with_caption.data >= 0.99
@pytest.mark.asyncio
async def test_alignment_with_segments_and_negative():
vs = _VS(text_vectors={
"a warrior in armor": [1.0, 0.0],
"dark forest background": [1.0, 0.0],
"ugly deformed": [0.0, 1.0], # orthogonal = no penalty
})
prompt = "a warrior in armor, dark forest background"
emb = [1.0, 0.0]
result = await m.compute_prompt_alignment(
vs, emb, prompt, negative_prompt="ugly deformed",
)
assert result.ok
# Negative prompt is orthogonal → no penalty, score should be ~1.0
assert result.data >= 0.95
@pytest.mark.asyncio
async def test_alignment_negative_penalty_reduces_score():
"""When the negative prompt vector matches the image, it should lower the score."""
import math
# Use vectors whose raw cosine falls inside the SigLIP2 calibration band
# [0.00, 0.06]. cos(87°) ≈ 0.052 → calibrates to ~0.87.
# neg vector = img vector → cosine 1.0 → penalty = 1.0 * 0.25 = 0.25
angle = math.radians(87)
prompt_vec = [math.cos(angle), math.sin(angle)] # cos ≈ 0.052
img_vec = [1.0, 0.0]
neg_vec = [1.0, 0.0] # Identical to image → max penalty
class _VSAngle:
_model_name = "test"
async def get_text_embedding(self, text: str) -> Result[list[float]]:
if text == "ugly distorted":
return Result.Ok(neg_vec)
return Result.Ok(prompt_vec)
vs = _VSAngle()
prompt = "a beautiful portrait"
no_neg = await m.compute_prompt_alignment(vs, img_vec, prompt)
with_neg = await m.compute_prompt_alignment(
vs, img_vec, prompt, negative_prompt="ugly distorted",
)
assert no_neg.ok and with_neg.ok
assert with_neg.data < no_neg.data
+256
View File
@@ -1692,6 +1692,262 @@ async def test_reset_index_non_malformed_clear_error_returns_error(monkeypatch,
assert payload.get("code") == "DB_ERROR"
@pytest.mark.asyncio
async def test_reset_index_preserve_vectors_skips_assets_clear_and_hard_reset(monkeypatch, tmp_path: Path) -> None:
out_root = tmp_path / "out"
out_root.mkdir()
executed_sql: list[str] = []
purge_calls = {"n": 0}
class _Tx:
ok = True
error = None
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class _DB:
def __init__(self):
self.areset_calls = 0
def atransaction(self, mode="immediate"):
_ = mode
return _Tx()
async def aexecute(self, sql, params=None):
_ = params
executed_sql.append(" ".join(str(sql).split()))
return Result.Ok(1)
async def avacuum(self):
return Result.Ok({})
async def areset(self):
self.areset_calls += 1
return Result.Ok({})
class _Index:
async def stop_enrichment(self, clear_queue=True):
_ = clear_queue
async def scan_directory(self, *_args, **_kwargs):
return Result.Ok({"scanned": 0, "added": 0, "updated": 0, "skipped": 0, "errors": 0})
db = _DB()
async def _runtime_output_root(_svc):
return str(out_root)
async def _require_services():
return {"db": db, "index": _Index()}, None
async def _read_json(_request):
return Result.Ok(
{
"scope": "output",
"reindex": False,
"hard_reset_db": True,
"clear_scan_journal": True,
"clear_metadata_cache": True,
"clear_asset_metadata": True,
"clear_assets": True,
"preserve_vectors": True,
"rebuild_fts": False,
}
)
async def _resolve_security_prefs(_svc):
return {}
monkeypatch.setattr(scan_mod, "_csrf_error", lambda _request: None)
monkeypatch.setattr(scan_mod, "_require_write_access", lambda _request: Result.Ok({}))
monkeypatch.setattr(scan_mod, "_require_services", _require_services)
monkeypatch.setattr(scan_mod, "_resolve_security_prefs", _resolve_security_prefs)
monkeypatch.setattr(scan_mod, "_require_operation_enabled", lambda *_args, **_kwargs: Result.Ok({}))
monkeypatch.setattr(scan_mod, "is_db_maintenance_active", lambda: False)
monkeypatch.setattr(scan_mod, "_read_json", _read_json)
monkeypatch.setattr(scan_mod, "_runtime_output_root", _runtime_output_root)
async def _purge_orphans(_db):
purge_calls["n"] += 1
return Result.Ok(True)
monkeypatch.setattr(scan_mod, "purge_orphan_vec_embeddings", _purge_orphans)
app = _app()
req = make_mocked_request("POST", "/mjr/am/index/reset", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
assert payload.get("ok") is True
assert payload.get("data", {}).get("preserve_vectors") is True
assert db.areset_calls == 0
assert purge_calls["n"] == 0
assert not any(sql.startswith("DELETE FROM assets") for sql in executed_sql)
assert any("DELETE FROM scan_journal" in sql for sql in executed_sql)
assert any("DELETE FROM metadata_cache" in sql for sql in executed_sql)
@pytest.mark.asyncio
async def test_reset_index_preserve_vectors_malformed_returns_error_without_hard_reset(monkeypatch, tmp_path: Path) -> None:
out_root = tmp_path / "out"
out_root.mkdir()
class _Tx:
ok = True
error = None
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class _DB:
def __init__(self):
self.areset_calls = 0
def atransaction(self, mode="immediate"):
_ = mode
return _Tx()
async def aexecute(self, sql, params=None):
_ = (sql, params)
return Result.Err("DB_ERROR", "database disk image is malformed")
async def areset(self):
self.areset_calls += 1
return Result.Ok({})
class _Index:
async def stop_enrichment(self, clear_queue=True):
_ = clear_queue
db = _DB()
async def _runtime_output_root(_svc):
return str(out_root)
async def _require_services():
return {"db": db, "index": _Index()}, None
async def _read_json(_request):
return Result.Ok(
{
"scope": "output",
"reindex": False,
"preserve_vectors": True,
"clear_scan_journal": True,
}
)
async def _resolve_security_prefs(_svc):
return {}
monkeypatch.setattr(scan_mod, "_csrf_error", lambda _request: None)
monkeypatch.setattr(scan_mod, "_require_write_access", lambda _request: Result.Ok({}))
monkeypatch.setattr(scan_mod, "_require_services", _require_services)
monkeypatch.setattr(scan_mod, "_resolve_security_prefs", _resolve_security_prefs)
monkeypatch.setattr(scan_mod, "_require_operation_enabled", lambda *_args, **_kwargs: Result.Ok({}))
monkeypatch.setattr(scan_mod, "is_db_maintenance_active", lambda: False)
monkeypatch.setattr(scan_mod, "_read_json", _read_json)
monkeypatch.setattr(scan_mod, "_runtime_output_root", _runtime_output_root)
app = _app()
req = make_mocked_request("POST", "/mjr/am/index/reset", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
assert payload.get("ok") is False
assert payload.get("code") == "DB_MALFORMED"
assert db.areset_calls == 0
@pytest.mark.asyncio
async def test_reset_index_without_preserve_vectors_purges_orphan_vectors(monkeypatch, tmp_path: Path) -> None:
out_root = tmp_path / "out"
out_root.mkdir()
purge_calls = {"n": 0}
class _Tx:
ok = True
error = None
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class _DB:
def atransaction(self, mode="immediate"):
_ = mode
return _Tx()
async def aexecute(self, sql, params=None):
_ = (sql, params)
return Result.Ok(1)
async def avacuum(self):
return Result.Ok({})
class _Index:
async def stop_enrichment(self, clear_queue=True):
_ = clear_queue
async def _runtime_output_root(_svc):
return str(out_root)
async def _require_services():
return {"db": _DB(), "index": _Index()}, None
async def _read_json(_request):
return Result.Ok(
{
"scope": "output",
"reindex": False,
"clear_scan_journal": True,
"clear_metadata_cache": True,
"clear_asset_metadata": True,
"clear_assets": True,
"preserve_vectors": False,
"rebuild_fts": False,
}
)
async def _resolve_security_prefs(_svc):
return {}
async def _purge_orphans(_db):
purge_calls["n"] += 1
return Result.Ok(True)
monkeypatch.setattr(scan_mod, "_csrf_error", lambda _request: None)
monkeypatch.setattr(scan_mod, "_require_write_access", lambda _request: Result.Ok({}))
monkeypatch.setattr(scan_mod, "_require_services", _require_services)
monkeypatch.setattr(scan_mod, "_resolve_security_prefs", _resolve_security_prefs)
monkeypatch.setattr(scan_mod, "_require_operation_enabled", lambda *_args, **_kwargs: Result.Ok({}))
monkeypatch.setattr(scan_mod, "is_db_maintenance_active", lambda: False)
monkeypatch.setattr(scan_mod, "_read_json", _read_json)
monkeypatch.setattr(scan_mod, "_runtime_output_root", _runtime_output_root)
monkeypatch.setattr(scan_mod, "purge_orphan_vec_embeddings", _purge_orphans)
app = _app()
req = make_mocked_request("POST", "/mjr/am/index/reset", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
assert payload.get("ok") is True
assert payload.get("data", {}).get("preserve_vectors") is False
assert payload.get("data", {}).get("vectors_purged") is True
assert purge_calls["n"] == 1
@pytest.mark.asyncio
async def test_upload_input_csrf_and_auth(monkeypatch) -> None:
app = _app()
@@ -171,6 +171,58 @@ async def test_vector_search_route_filters_hits_by_scope(monkeypatch) -> None:
assert body.get("data") == [{"asset_id": 2, "score": 0.93}]
@pytest.mark.asyncio
async def test_vector_search_route_filters_weak_score_tail(monkeypatch) -> None:
class _DB:
async def aquery(self, sql: str, params=()):
if "SELECT a.id AS asset_id FROM assets a WHERE" in sql:
return Result.Ok(
[
{"asset_id": 1},
{"asset_id": 2},
{"asset_id": 3},
]
)
return Result.Ok([])
class _Searcher:
async def search_by_text(self, _query: str, *, top_k: int = 20):
assert top_k >= 20
return Result.Ok(
[
{"asset_id": 1, "score": 0.14},
{"asset_id": 2, "score": 0.10},
{"asset_id": 3, "score": 0.05},
]
)
async def _require_services():
return {"vector_searcher": _Searcher(), "db": _DB()}, None
async def _hydrate_passthrough(_services, result):
return Result.Ok(result.data or [])
monkeypatch.setattr(vector_search, "_require_services", _require_services)
monkeypatch.setattr(vector_search, "_hydrate_vector_results", _hydrate_passthrough)
monkeypatch.setattr(vector_search, "is_vector_search_enabled", lambda: True)
monkeypatch.setattr(vector_search, "_check_rate_limit", lambda *_args, **_kwargs: (True, None))
monkeypatch.setattr(vector_search, "VECTOR_TEXT_SEARCH_MIN_SCORE", 0.02)
monkeypatch.setattr(vector_search, "VECTOR_TEXT_SEARCH_RELATIVE_RATIO", 0.7)
app = _build_vector_app()
req = make_mocked_request("GET", "/mjr/am/vector/search?q=dinosaure&scope=output&top_k=5", app=app)
match = await app.router.resolve(req)
req._match_info = match
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
assert body.get("data") == [
{"asset_id": 1, "score": 0.14},
{"asset_id": 2, "score": 0.1},
]
@pytest.mark.asyncio
async def test_vector_similar_route_success(monkeypatch) -> None:
class _Searcher:
@@ -239,6 +291,51 @@ async def test_vector_similar_route_filters_hits_by_scope(monkeypatch) -> None:
assert body.get("data") == [{"asset_id": 88, "score": 0.9}]
@pytest.mark.asyncio
async def test_vector_similar_route_filters_by_kind_and_min_score(monkeypatch) -> None:
class _DB:
async def aquery(self, sql: str, params=()):
if "SELECT LOWER(COALESCE(kind, '')) AS kind FROM assets WHERE id = ?" in sql:
return Result.Ok([{"kind": "image"}])
if "SELECT id AS asset_id FROM assets WHERE id IN" in sql and "LOWER(COALESCE(kind, '')) = ?" in sql:
return Result.Ok([{"asset_id": 77}, {"asset_id": 88}])
return Result.Ok([])
class _Searcher:
async def find_similar(self, asset_id: int, *, top_k: int = 20):
assert asset_id == 42
assert top_k > 10
return Result.Ok(
[
{"asset_id": 77, "score": 0.91},
{"asset_id": 88, "score": 0.55},
{"asset_id": 99, "score": 0.89},
]
)
async def _require_services():
return {"vector_searcher": _Searcher(), "db": _DB()}, None
async def _hydrate_passthrough(_services, result):
return Result.Ok(result.data or [])
monkeypatch.setattr(vector_search, "_require_services", _require_services)
monkeypatch.setattr(vector_search, "_hydrate_vector_results", _hydrate_passthrough)
monkeypatch.setattr(vector_search, "is_vector_search_enabled", lambda: True)
monkeypatch.setattr(vector_search, "_check_rate_limit", lambda *_args, **_kwargs: (True, None))
monkeypatch.setattr(vector_search, "VECTOR_SIMILAR_MIN_SCORE", 0.6)
app = _build_vector_app()
req = make_mocked_request("GET", "/mjr/am/vector/similar/42?top_k=10&scope=all", app=app)
match = await app.router.resolve(req)
req._match_info = match
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
assert body.get("data") == [{"asset_id": 77, "score": 0.91}]
@pytest.mark.asyncio
async def test_vector_enhance_prompt_alias_route_success(monkeypatch) -> None:
class _DB:
@@ -99,21 +99,22 @@ class _FakeFeatures:
class _FakeXclipModel:
def get_image_features(self, *, pixel_values=None):
def get_video_features(self, *, pixel_values=None):
assert pixel_values is not None
return _FakeFeatures()
class _FakeProcessorMapping(dict):
"""Fake processor output that acts as a mapping with .get()."""
pass
class _ProcessorWithFallbackSignature:
def __call__(self, *args, **kwargs):
_ = args
# Simulate a non-mapping return for the primary images signature.
# Older code indexed directly with ["pixel_values"] and crashed.
if "images" in kwargs:
return PILImage.new("RGB", (2, 2), color=(0, 0, 0))
if "videos" in kwargs:
return {"pixel_values": [[[[1.0]]]]}
return {"pixel_values": [[[[1.0]]]]}
return _FakeProcessorMapping({"pixel_values": [[[[1.0]]]]})
return _FakeProcessorMapping({"pixel_values": [[[[1.0]]]]})
class _BatchFailsOnMultiFrameModel:
@@ -162,6 +163,9 @@ async def test_video_embedding_falls_back_to_single_frame_when_batch_fails(monke
async def _fake_xclip_fail(_path):
return m.Result.Err("METADATA_FAILED", "xclip unavailable")
# Force non-native path so the legacy SentenceTransformer fallback is tested
monkeypatch.setattr(vs, "_use_native_siglip", lambda: False)
async def _fake_ensure_model():
return _BatchFailsOnMultiFrameModel()
+795
View File
@@ -0,0 +1,795 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Majoor Assets Manager - User Guide</title>
<style>
:root {
/* ComfyUI CSS Variables (matching theme-comfy.css) */
--bg-primary: var(--content-bg, #1e1e1e);
--bg-secondary: var(--comfy-menu-bg, #2a2a2a);
--bg-tertiary: var(--comfy-menu-secondary-bg, #333333);
--text-primary: var(--content-fg, var(--input-text, #ddd));
--text-secondary: var(--descrip-text, rgba(255, 255, 255, 0.65));
--accent: var(--comfy-accent, #5fb3ff);
--accent-hover: color-mix(in srgb, var(--comfy-accent, #5fb3ff) 85%, white);
--border: var(--border-color, rgba(255, 255, 255, 0.12));
--success: var(--comfy-status-success, #4CAF50);
--warning: var(--comfy-status-warning, #FFA726);
--error: var(--comfy-status-error, #f44336);
--info: var(--comfy-status-info, #2196F3);
--star-color: #FFD45A;
--badge-image: #2196F3;
--badge-video: #9C27B0;
--badge-audio: #FF9800;
--font-family: var(--comfy-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif);
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-family);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.35;
font-size: 13px;
}
.container { max-width: 1200px; margin: 0 auto; padding: 16px; }
header {
background: var(--bg-secondary);
padding: 24px 20px;
text-align: center;
border-bottom: 1px solid var(--border);
}
header h1 {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
letter-spacing: 0.01em;
}
header p { color: var(--text-secondary); font-size: 12px; }
nav {
background: var(--bg-secondary);
padding: 10px 16px;
position: sticky;
top: 0;
z-index: 100;
border-bottom: 1px solid var(--border);
}
nav ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
nav a {
color: var(--text-primary);
text-decoration: none;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid transparent;
background: transparent;
font-size: 12px;
transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease;
}
nav a:hover {
transform: translateY(-1px);
background: color-mix(in srgb, var(--bg-tertiary) 55%, transparent);
border-color: color-mix(in srgb, var(--border) 70%, transparent);
}
section {
background: var(--bg-secondary);
margin: 16px 0;
padding: 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
}
h2 {
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
h3 {
color: var(--accent);
font-size: 13px;
font-weight: 600;
margin: 20px 0 12px;
}
h4 {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
margin: 16px 0 8px;
}
p { margin-bottom: 12px; color: var(--text-secondary); line-height: 1.45; }
ul, ol { margin: 12px 0 12px 24px; color: var(--text-secondary); }
li { margin-bottom: 6px; line-height: 1.4; }
code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
color: var(--warning);
}
pre {
background: var(--bg-tertiary);
padding: 12px;
border-radius: var(--radius-sm);
overflow-x: auto;
margin: 12px 0;
border: 1px solid var(--border);
}
pre code {
background: none;
padding: 0;
font-size: 11px;
color: var(--text-primary);
}
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
font-size: 12px;
}
th, td {
padding: 8px 12px;
text-align: left;
border: 1px solid var(--border);
}
th {
background: color-mix(in srgb, var(--bg-tertiary) 80%, var(--accent) 20%);
color: var(--text-primary);
font-weight: 600;
font-size: 11px;
letter-spacing: 0.03em;
}
tr:nth-child(even) { background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent); }
tr:hover { background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); }
.tip {
background: color-mix(in srgb, var(--success) 10%, var(--bg-tertiary));
border-left: 3px solid var(--success);
padding: 10px 12px;
margin: 12px 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.warning {
background: color-mix(in srgb, var(--warning) 10%, var(--bg-tertiary));
border-left: 3px solid var(--warning);
padding: 10px 12px;
margin: 12px 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.feature-card {
background: var(--bg-tertiary);
padding: 12px;
border-radius: var(--radius-sm);
margin: 12px 0;
border: 1px solid var(--border);
}
.feature-card h4 { margin-top: 0; color: var(--accent); }
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
margin-left: 8px;
background: color-mix(in srgb, var(--accent) 20%, var(--bg-tertiary));
color: color-mix(in srgb, var(--text-primary) 86%, transparent);
}
.badge-new {
background: color-mix(in srgb, var(--accent) 25%, var(--bg-tertiary));
color: var(--text-primary);
}
.badge-ai {
background: linear-gradient(135deg, rgba(102,126,234,0.3), rgba(118,75,162,0.3));
color: var(--text-primary);
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
}
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
a:hover {
border-bottom-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.toc {
background: var(--bg-tertiary);
padding: 12px;
border-radius: var(--radius-sm);
margin: 12px 0;
border: 1px solid var(--border);
}
.toc strong { font-size: 11px; color: var(--text-primary); }
.toc ul { margin: 8px 0 0 20px; }
.toc li { margin-bottom: 4px; }
.model-table td { font-family: 'Consolas', 'Monaco', monospace; font-size: 11px; }
footer {
text-align: center;
padding: 20px;
color: var(--text-secondary);
border-top: 1px solid var(--border);
margin-top: 24px;
font-size: 11px;
}
/* Status indicators */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.green { background: var(--success); }
.status-dot.yellow { background: var(--warning); }
.status-dot.red { background: var(--error); }
/* Icon button style */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: transform 120ms ease, background-color 120ms ease, border-color 120ms ease;
}
.icon-btn:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--bg-tertiary) 80%, var(--accent) 8%);
transform: translateY(-1px);
}
</style>
</head>
<body>
<header>
<h1>🎨 Majoor Assets Manager</h1>
<p>Comprehensive User Guide for ComfyUI</p>
<p><strong>Version 2.4.0</strong> | Last Updated: March 5, 2026</p>
</header>
<nav>
<ul>
<li><a href="#quickstart">Quick Start</a></li>
<li><a href="#installation">Installation</a></li>
<li><a href="#basic-usage">Basic Usage</a></li>
<li><a href="#ai-features">🤖 AI Features</a></li>
<li><a href="#viewer">Viewer</a></li>
<li><a href="#organization">Organization</a></li>
<li><a href="#shortcuts">Shortcuts</a></li>
<li><a href="#troubleshooting">Troubleshooting</a></li>
</ul>
</nav>
<div class="container">
<!-- Quick Start -->
<section id="quickstart">
<h2>🚀 Quick Start</h2>
<div class="toc">
<strong>Table of Contents:</strong>
<ul>
<li><a href="#installation">Installation</a></li>
<li><a href="#basic-usage">Basic Usage</a></li>
<li><a href="#ai-features">AI Features (NEW!)</a></li>
<li><a href="#viewer">Advanced Viewer</a></li>
<li><a href="#organization">Ratings, Tags & Collections</a></li>
<li><a href="#shortcuts">Keyboard Shortcuts</a></li>
</ul>
</div>
<div class="tip">
<strong>💡 New to Majoor Assets Manager?</strong> Start with the installation section, then jump to Basic Usage to get up and running quickly!
</div>
</section>
<!-- Installation -->
<section id="installation">
<h2>📦 Installation</h2>
<h3>Method A: ComfyUI Manager (Recommended)</h3>
<ol>
<li>Open <strong>ComfyUI Manager</strong> in your browser</li>
<li>Search for <strong>"Majoor Assets Manager"</strong></li>
<li>Click <strong>Install</strong>, then restart ComfyUI completely</li>
<li>The extension appears in the sidebar as a folder icon 📁</li>
</ol>
<h3>Method B: Manual Installation</h3>
<pre><code># Navigate to ComfyUI custom_nodes directory
cd ComfyUI/custom_nodes
# Clone the repository
git clone https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager ComfyUI-Majoor-AssetsManager
# Install Python dependencies
cd ComfyUI-Majoor-AssetsManager
pip install -r requirements.txt
# Restart ComfyUI</code></pre>
<h3>Optional Dependencies (Recommended)</h3>
<p>For full metadata extraction, install these tools:</p>
<h4>Windows (PowerShell)</h4>
<pre><code># Using Scoop
scoop install ffmpeg exiftool
# OR using Chocolatey
choco install -y ffmpeg exiftool
# OR using WinGet
winget install -e --id Gyan.FFmpeg
winget install -e --id PhilHarvey.ExifTool</code></pre>
<h4>macOS</h4>
<pre><code>brew install ffmpeg exiftool</code></pre>
<h4>Linux</h4>
<pre><code># Ubuntu/Debian
sudo apt update
sudo apt install -y ffmpeg libimage-exiftool-perl
# Fedora/RHEL
sudo dnf install -y ffmpeg perl-Image-ExifTool</code></pre>
</section>
<!-- Basic Usage -->
<section id="basic-usage">
<h2>📖 Basic Usage</h2>
<h3>First Steps</h3>
<ol>
<li><strong>Open Assets Manager:</strong> Click the folder icon 📁 in ComfyUI sidebar</li>
<li><strong>Choose Scope:</strong> Select Outputs, Inputs, Custom, or Collections</li>
<li><strong>Browse Assets:</strong> Scroll through the grid view with virtual scrolling</li>
<li><strong>Search:</strong> Type in the search bar for full-text search</li>
<li><strong>Filter:</strong> Use dropdowns for kind, rating, workflow, date filters</li>
</ol>
<h3>Asset Operations (Right-Click Menu)</h3>
<div class="feature-card">
<ul>
<li><strong>Open in Viewer:</strong> Double-click or right-click → Open in Viewer</li>
<li><strong>Rate:</strong> Assign 0-5 stars</li>
<li><strong>Edit Tags:</strong> Add/remove custom tags</li>
<li><strong>Add to Collection:</strong> Save to existing or new collection</li>
<li><strong>Rename:</strong> Change filename</li>
<li><strong>Delete:</strong> Remove asset (with confirmation)</li>
<li><strong>Open in Folder:</strong> Show in file explorer</li>
<li><strong>Copy Path:</strong> Copy full file path to clipboard</li>
<li><strong>Stage to Input:</strong> Copy to ComfyUI input directory</li>
</ul>
</div>
<h3>Collections</h3>
<ol>
<li>Select assets (Ctrl/Cmd+click for multiple)</li>
<li>Right-click → <strong>Add to Collection</strong></li>
<li>Create new or add to existing collection</li>
<li>Access via <strong>Collections</strong> scope</li>
</ol>
</section>
<!-- AI Features -->
<section id="ai-features">
<h2>🤖 AI Features <span class="badge badge-ai">NEW!</span></h2>
<div class="tip">
<strong>📚 Complete Guide:</strong> See <a href="docs/AI_FEATURES.md" target="_blank">docs/AI_FEATURES.md</a> for comprehensive AI documentation.
</div>
<h3>What AI Features Are Available</h3>
<div class="feature-card">
<h4>🔮 AI Semantic Search</h4>
<p>Search using natural language instead of keywords. Example: "sunset over mountains with orange sky"</p>
</div>
<div class="feature-card">
<h4>🔍 Find Similar</h4>
<p>Select an asset and discover visually similar images based on content, not just tags.</p>
</div>
<div class="feature-card">
<h4>🏷️ AI Auto-Tags</h4>
<p>Automatic tag suggestions based on image content: portrait, landscape, cyberpunk, anime, etc.</p>
</div>
<div class="feature-card">
<h4>📝 Enhanced Captions (Florence-2)</h4>
<p>Generate detailed AI-powered image descriptions for better searchability.</p>
</div>
<div class="feature-card">
<h4>📊 Prompt Alignment Score</h4>
<p>Measure how well your generated image matches the original prompt (0-100%).</p>
</div>
<div class="feature-card">
<h4>📁 Smart Collections</h4>
<p>Auto-create themed collections based on visual similarity clustering.</p>
</div>
<div class="feature-card">
<h4>🔬 Discover Groups</h4>
<p>Cluster your entire library by visual themes to discover patterns.</p>
</div>
<h3>AI Models Used</h3>
<table class="model-table">
<tr>
<th>Model</th>
<th>Purpose</th>
<th>Size</th>
</tr>
<tr>
<td>SigLIP2 SO400M</td>
<td>Image & text embeddings</td>
<td>~1.2 GB</td>
</tr>
<tr>
<td>X-CLIP Base</td>
<td>Video embeddings</td>
<td>~600 MB</td>
</tr>
<tr>
<td>Florence-2 Base</td>
<td>Image captioning</td>
<td>~800 MB</td>
</tr>
</table>
<div class="warning">
<strong>⚠️ First-Time Setup:</strong> Models are downloaded automatically on first use. Initial download may take several minutes depending on your connection.
</div>
<h3>How to Enable AI Features</h3>
<ol>
<li>Open <strong>Settings</strong> in Assets Manager</li>
<li>Ensure <strong>Enable AI semantic search</strong> is toggled ON (default: ON)</li>
<li>Trigger initial index scan (Ctrl+S)</li>
<li>Click <strong>Backfill vectors</strong> in Index Status section</li>
<li>Wait for embeddings to be computed (5-30 min for 1000 assets)</li>
</ol>
<h3>Using AI Features</h3>
<h4>Semantic Search</h4>
<ol>
<li>Click the <strong>sparkles icon</strong> (🔮) in the search bar</li>
<li>Type natural language: "cyberpunk city at night with neon lights"</li>
<li>Press Enter to see results ranked by relevance</li>
</ol>
<h4>Find Similar</h4>
<ol>
<li>Select an asset in the grid</li>
<li>Right-click → <strong>Find Similar</strong></li>
<li>Browse visually similar assets</li>
</ol>
<h4>Generate Caption</h4>
<ol>
<li>Open an asset in the Viewer</li>
<li>In sidebar, find <strong>Image Description</strong> section</li>
<li>Click <strong>Generate</strong></li>
<li>Wait for AI caption (5-30 seconds)</li>
</ol>
<h3>Per-Asset Operations (Quick Actions)</h3>
<p>You can trigger scan and backfill vectors for <strong>individual assets</strong> directly from the grid:</p>
<h4>Via Card Status Dot</h4>
<ol>
<li><strong>Click the status dot</strong> on an asset card (corner indicator)</li>
<li>Select from the menu:
<ul>
<li><strong>"Index Asset"</strong> — Trigger metadata scan for this asset</li>
<li><strong>"Generate Vector"</strong> — Compute AI embedding</li>
<li><strong>"Re-index"</strong> — Force re-scan and re-compute vectors</li>
</ul>
</li>
</ol>
<h4>Via Sparkles Icon (🔮) on Card</h4>
<ol>
<li><strong>Hover over an asset card</strong> to reveal action buttons</li>
<li><strong>Click the sparkles icon</strong> (🔮) if visible</li>
<li>Choose AI actions:
<ul>
<li><strong>Generate Caption</strong> — Run Florence-2 captioning</li>
<li><strong>Compute Alignment</strong> — Calculate prompt alignment score</li>
<li><strong>Suggest Tags</strong> — Generate AI auto-tags</li>
<li><strong>Find Similar</strong> — Show visually similar assets</li>
</ul>
</li>
</ol>
<h4>Visual Status Indicators</h4>
<table>
<tr>
<th>Indicator</th>
<th>Meaning</th>
</tr>
<tr>
<td>🟢 Green dot</td>
<td>Asset fully indexed with vectors</td>
</tr>
<tr>
<td>🟡 Yellow dot</td>
<td>Asset indexed, vectors pending</td>
</tr>
<tr>
<td>🔴 Red dot</td>
<td>Indexing failed or vectors unavailable</td>
</tr>
<tr>
<td>🔮 Sparkles visible</td>
<td>AI features available for this asset</td>
</tr>
<tr>
<td>⏳ Spinning icon</td>
<td>Processing in progress</td>
</tr>
</table>
<div class="tip">
<strong>💡 Tip:</strong> For best results, run a full backfill first, then use per-asset operations to update individual items as needed.
</div>
<h3>Performance Expectations</h3>
<table>
<tr>
<th>Library Size</th>
<th>Backfill Time (CPU)</th>
<th>Backfill Time (GPU)</th>
</tr>
<tr>
<td>100 assets</td>
<td>2-5 min</td>
<td>30-60 sec</td>
</tr>
<tr>
<td>500 assets</td>
<td>10-25 min</td>
<td>2-5 min</td>
</tr>
<tr>
<td>1000 assets</td>
<td>20-50 min</td>
<td>5-10 min</td>
</tr>
<tr>
<td>5000 assets</td>
<td>2-4 hours</td>
<td>25-50 min</td>
</tr>
</table>
<div class="tip">
<strong>💡 GPU Acceleration:</strong> For large libraries, GPU acceleration (NVIDIA RTX 3060 or better) significantly speeds up embedding computation.
</div>
<h3>Configuration</h3>
<pre><code># Enable/disable AI features (default: ON)
export MJR_AM_ENABLE_VECTOR_SEARCH=1
# Adjust auto-tag sensitivity (0.0-1.0, default: 0.25)
export MJR_AM_VECTOR_AUTOTAG_THRESHOLD=0.25
# Verbose AI logging for debugging
export MJR_AM_AI_VERBOSE_LOGS=1</code></pre>
</section>
<!-- Viewer -->
<section id="viewer">
<h2>🖼️ Advanced Viewer</h2>
<h3>Opening the Viewer</h3>
<ul>
<li><strong>Double-click</strong> any asset in the grid</li>
<li>Or right-click → <strong>Open in Viewer</strong></li>
<li>Or press <strong>Enter</strong> on selected asset</li>
</ul>
<h3>Viewer Features</h3>
<div class="feature-card">
<h4>Image Enhancement</h4>
<ul>
<li>Exposure (EV) adjustment</li>
<li>Gamma correction</li>
<li>Channel isolation (R/G/B)</li>
</ul>
</div>
<div class="feature-card">
<h4>Analysis Tools</h4>
<ul>
<li>False color visualization</li>
<li>Zebra patterns for exposure</li>
<li>Histogram display</li>
<li>Waveform monitor</li>
<li>Vectorscope</li>
</ul>
</div>
<div class="feature-card">
<h4>Visual Overlays</h4>
<ul>
<li>Grid overlays (rule of thirds)</li>
<li>Pixel probe for color sampling</li>
<li>Loupe magnification</li>
</ul>
</div>
<h3>Navigation</h3>
<ul>
<li><strong>Mouse Wheel:</strong> Zoom in/out</li>
<li><strong>Click + Drag:</strong> Pan zoomed image</li>
<li><strong>Left/Right Arrow:</strong> Previous/Next asset</li>
<li><strong>Esc:</strong> Close viewer</li>
<li><strong>F:</strong> Toggle fullscreen</li>
</ul>
</section>
<!-- Organization -->
<section id="organization">
<h2>📋 Organization: Ratings, Tags & Collections</h2>
<h3>Ratings System</h3>
<ul>
<li><strong>0-5 Stars:</strong> Rate assets by quality or preference</li>
<li><strong>Windows Sync:</strong> Ratings sync with Windows Explorer (RatingPercent)</li>
<li><strong>Filter by Rating:</strong> Show only 4+ star assets, etc.</li>
<li><strong>Keyboard:</strong> Press 0-5 to rate selected asset</li>
</ul>
<h3>Tags System</h3>
<ul>
<li><strong>Custom Tags:</strong> Add any tags you want</li>
<li><strong>Hierarchical:</strong> Use colons for hierarchy (style:anime:fantasy)</li>
<li><strong>Autocomplete:</strong> Tags suggest as you type</li>
<li><strong>Bulk Operations:</strong> Apply tags to multiple assets</li>
</ul>
<h3>Collections</h3>
<ul>
<li><strong>Save Selections:</strong> Group assets across scopes</li>
<li><strong>Smart Collections:</strong> AI-suggested groupings</li>
<li><strong>Discover Groups:</strong> Auto-cluster by visual similarity</li>
<li><strong>Persistent:</strong> Collections saved to disk</li>
</ul>
</section>
<!-- Shortcuts -->
<section id="shortcuts">
<h2>⌨️ Keyboard Shortcuts</h2>
<h3>Global / Panel</h3>
<table>
<tr><th>Shortcut</th><th>Action</th></tr>
<tr><td>Ctrl+S / Cmd+S</td><td>Trigger index scan</td></tr>
<tr><td>Ctrl+F / Ctrl+K</td><td>Focus search input</td></tr>
<tr><td>Ctrl+H</td><td>Clear search input</td></tr>
<tr><td>D</td><td>Toggle sidebar (details)</td></tr>
</table>
<h3>Grid View</h3>
<table>
<tr><th>Shortcut</th><th>Action</th></tr>
<tr><td>Arrow Keys</td><td>Navigate selection</td></tr>
<tr><td>Enter / Space</td><td>Open Viewer</td></tr>
<tr><td>Ctrl+A</td><td>Select all</td></tr>
<tr><td>Ctrl+D</td><td>Deselect all</td></tr>
<tr><td>0-5</td><td>Set rating (0-5 stars)</td></tr>
<tr><td>T</td><td>Edit tags</td></tr>
<tr><td>B</td><td>Add to collection</td></tr>
<tr><td>F2</td><td>Rename file</td></tr>
<tr><td>Delete</td><td>Delete file</td></tr>
</table>
<h3>Viewer</h3>
<table>
<tr><th>Shortcut</th><th>Action</th></tr>
<tr><td>Esc</td><td>Close viewer</td></tr>
<tr><td>F</td><td>Toggle fullscreen</td></tr>
<tr><td>D</td><td>Toggle info panel</td></tr>
<tr><td>Space</td><td>Play/pause video</td></tr>
<tr><td>Left/Right</td><td>Previous/next asset</td></tr>
<tr><td>I</td><td>Toggle pixel probe</td></tr>
<tr><td>C</td><td>Copy probed color</td></tr>
<tr><td>L</td><td>Toggle loupe</td></tr>
<tr><td>Z</td><td>Toggle zebra patterns</td></tr>
<tr><td>G</td><td>Cycle grid overlays</td></tr>
</table>
</section>
<!-- Troubleshooting -->
<section id="troubleshooting">
<h2>🔧 Troubleshooting</h2>
<h3>Extension Not Appearing</h3>
<ul>
<li>Verify installation in <code>custom_nodes/ComfyUI-Majoor-AssetsManager</code></li>
<li>Check ComfyUI console for import errors</li>
<li>Ensure all dependencies: <code>pip install -r requirements.txt</code></li>
<li>Restart ComfyUI completely</li>
</ul>
<h3>Database Corruption</h3>
<ol>
<li>Open Assets Manager → Index Status section</li>
<li>Click <strong>Delete DB</strong> button</li>
<li>Confirm the warning dialog</li>
<li>Database rebuilds automatically</li>
</ol>
<h3>External Tools Not Found</h3>
<pre><code># Verify installation
exiftool -ver
ffprobe -version
# If not found, install:
# Windows: scoop install ffmpeg exiftool
# macOS: brew install ffmpeg exiftool
# Linux: sudo apt install ffmpeg libimage-exiftool-perl</code></pre>
<h3>AI Features Not Working</h3>
<ul>
<li>Check if AI is enabled in Settings</li>
<li>Verify models downloaded (check console logs)</li>
<li>Run <strong>Backfill vectors</strong> in Index Status</li>
<li>Ensure sufficient RAM (8GB minimum, 16GB recommended)</li>
</ul>
<h3>Slow Performance</h3>
<ul>
<li>Reduce page size in settings (default: 50-100)</li>
<li>Exclude very large directories from custom roots</li>
<li>Ensure SSD storage for database</li>
<li>For AI: enable GPU acceleration if available</li>
</ul>
</section>
<!-- Support -->
<section id="support">
<h2>📚 Additional Resources</h2>
<h3>Documentation</h3>
<ul>
<li><a href="docs/DOCUMENTATION_INDEX.md" target="_blank">📑 Documentation Index</a></li>
<li><a href="docs/AI_FEATURES.md" target="_blank">🤖 AI Features Guide</a></li>
<li><a href="docs/API_REFERENCE.md" target="_blank">🔧 API Reference</a></li>
<li><a href="docs/INSTALLATION.md" target="_blank">📦 Installation Guide</a></li>
<li><a href="docs/SETTINGS_CONFIGURATION.md" target="_blank">⚙️ Settings & Configuration</a></li>
<li><a href="docs/DB_MAINTENANCE.md" target="_blank">🗄️ Database Maintenance</a></li>
<li><a href="docs/VIEWER_FEATURE_TUTORIAL.md" target="_blank">🖼️ Viewer Tutorial</a></li>
<li><a href="docs/HOTKEYS_SHORTCUTS.md" target="_blank">⌨️ Hotkeys & Shortcuts</a></li>
</ul>
<h3>Community</h3>
<ul>
<li><a href="https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager" target="_blank">GitHub Repository</a></li>
<li><a href="https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/issues" target="_blank">Report Issues</a></li>
<li><a href="https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/discussions" target="_blank">Discussions</a></li>
<li><a href="https://ko-fi.com/majoorwaldi" target="_blank">☕ Support the Developer</a></li>
</ul>
</section>
</div>
<footer>
<p><strong>Majoor Assets Manager for ComfyUI</strong></p>
<p>Version 2.4.0 | Copyright © 2026 Ewald ALOEBOETOE (MajoorWaldi)</p>
<p>Licensed under GNU General Public License v3.0</p>
</footer>
</body>
</html>