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:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,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?.())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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`;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">← 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 []
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 []),
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 |
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user