From 13a69c1237cb5e4baaf4b84852f4042b488bb685 Mon Sep 17 00:00:00 2001 From: Ewald <129623145+MajoorWaldi@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:18:53 +0200 Subject: [PATCH] fix: wire video autoplay mode setting to grid cards - Import APP_CONFIG in AssetCardInner.vue and read GRID_VIDEO_AUTOPLAY_MODE - Add reactive videoMode ref updated via mjr-settings-changed listener - Add bindVideoAutoplay() with IntersectionObserver for 'always' mode - Add cleanupVideoBehaviors()/applyVideoMode() to switch between off/hover/always - watch(videoMode) re-applies behavior on runtime setting change - Fix rating reset bug: mark_recent_generated before ExifTool write (sync.py/watcher.py) - Add scope-switch cache, exit-similar-scope, and rating/tags ID tests --- js/features/grid/StackGroupCards.js | 4 + .../panel/controllers/gridController.js | 36 +- js/features/panel/panelRuntime.js | 76 +- js/features/panel/panelSimilarSearch.js | 25 - js/stores/usePanelStore.js | 3 + ...d_controller_exit_similar_scope.vitest.mjs | 142 + js/tests/grid_scope_switch_cache.vitest.mjs | 254 + js/tests/rating_tags_id_save.vitest.mjs | 109 + js/tests/scope_controller.vitest.mjs | 6 + js/tests/stack_group_cards.vitest.mjs | 53 + js/theme/theme-comfy.css | 41 + js/vue/components/common/RatingEditor.vue | 3 +- js/vue/components/common/TagsEditor.vue | 3 +- js/vue/components/grid/AssetCardInner.vue | 157 +- .../components/grid/VirtualAssetGridHost.vue | 94 +- js/vue/composables/useGridLoader.js | 11 +- .../assets/comfyui-majoor-assetsmanager.css | 2 +- ...Bzy37aKj.js => FloatingViewer-eFdyLyMp.js} | 2 +- ...OZGMZ.js => LiveStreamTracker-DbQnXqIi.js} | 2 +- .../{entry-DDLQfahJ.js => entry-BXyIAWFG.js} | 31985 ++++++++-------- ...BNlx3aIz.js => mjr-vue-vendor-C2uEs-2e.js} | 27 +- js_dist/entry.js | 2 +- mjr_am_backend/features/index/watcher.py | 2 + mjr_am_backend/features/tags/sync.py | 5 + 24 files changed, 17006 insertions(+), 16038 deletions(-) create mode 100644 js/tests/grid_controller_exit_similar_scope.vitest.mjs create mode 100644 js/tests/grid_scope_switch_cache.vitest.mjs create mode 100644 js/tests/rating_tags_id_save.vitest.mjs rename js_dist/chunks/{FloatingViewer-Bzy37aKj.js => FloatingViewer-eFdyLyMp.js} (99%) rename js_dist/chunks/{LiveStreamTracker-Bi0OZGMZ.js => LiveStreamTracker-DbQnXqIi.js} (99%) rename js_dist/chunks/{entry-DDLQfahJ.js => entry-BXyIAWFG.js} (87%) rename js_dist/chunks/{mjr-vue-vendor-BNlx3aIz.js => mjr-vue-vendor-C2uEs-2e.js} (99%) diff --git a/js/features/grid/StackGroupCards.js b/js/features/grid/StackGroupCards.js index 6ae778b7..95fc9dbd 100644 --- a/js/features/grid/StackGroupCards.js +++ b/js/features/grid/StackGroupCards.js @@ -78,6 +78,8 @@ export function ensureStackGroupCard(gridContainer, card, asset) { } return; } + // Vue cards handle their own stack buttons reactively via AssetCardInner.vue + if (card._mjrIsVue) return; const groupKey = _getGroupKey(asset); if (!groupKey) { card.querySelector?.(".mjr-stack-group-button")?.remove?.(); @@ -179,6 +181,8 @@ export function disposeStackGroupCards(gridContainer) { */ export function ensureDupStackCard(gridContainer, card, asset) { if (!card || !asset) return; + // Vue cards handle their own dup-stack buttons reactively via AssetCardInner.vue + if (card._mjrIsVue) return; const isDupStack = !!asset._mjrDupStack; const count = Number(asset._mjrDupCount || 0) || 0; diff --git a/js/features/panel/controllers/gridController.js b/js/features/panel/controllers/gridController.js index 67eae57e..21038ac8 100644 --- a/js/features/panel/controllers/gridController.js +++ b/js/features/panel/controllers/gridController.js @@ -46,6 +46,7 @@ export function createGridController({ ]); let _isReloading = false; let _pendingReload = false; + let _nextReloadOptions = {}; let _lastReloadErrorAt = 0; let _lastAiHintAt = 0; const RELOAD_WATCHDOG_MS = 30000; @@ -209,7 +210,7 @@ export function createGridController({ * * Explicit activation only: semantic toggle or "ai:" prefix. */ - const _loadWithSemanticFallback = async (query) => { + const _loadWithSemanticFallback = async (query, loadOptions = {}) => { let q = String(query || "").trim(); // ── "ai:" prefix forces semantic search ──────────────────── @@ -221,7 +222,7 @@ export function createGridController({ const aiEnabled = _isAiEnabled(); const semanticMode = aiEnabled && _isSemanticMode(); if (!aiEnabled) { - return await loadAssets(gridContainer, q || "*"); + return await loadAssets(gridContainer, q || "*", loadOptions); } const semanticRequested = semanticMode || hasAiPrefix; let aiAttempted = false; @@ -247,6 +248,7 @@ export function createGridController({ return await loadAssetsFromList(gridContainer, hybRes.data, { title: `AI Search: "${q}" (${hybRes.data.length} results)`, reset: true, + ...loadOptions, }); } if (hybRes?.ok === false) { @@ -268,6 +270,7 @@ export function createGridController({ return await loadAssetsFromList(gridContainer, vecRes.data, { title: `AI Search: "${q}" (${vecRes.data.length} results)`, reset: true, + ...loadOptions, }); } if (vecRes?.ok === false) { @@ -279,7 +282,7 @@ export function createGridController({ console.debug?.("[Majoor] Semantic search failed, falling back to FTS", err); } } - const ftsResult = await loadAssets(gridContainer, q || "*"); + const ftsResult = await loadAssets(gridContainer, q || "*", loadOptions); if (aiAttempted && aiError) { const count = Number(ftsResult?.count || 0) || 0; @@ -291,7 +294,7 @@ export function createGridController({ return ftsResult; }; - const runReloadOnce = async () => { + const runReloadOnce = async (reloadOptions = {}) => { // Expose the current query on the container so external listeners (ComfyUI executed events) // can decide whether to do incremental upserts or avoid disrupting an active search. try { @@ -299,7 +302,14 @@ export function createGridController({ } catch (e) { console.debug?.(e); } + const prevViewScope = String(gridContainer.dataset.mjrViewScope || "") + .trim() + .toLowerCase(); + const nextViewScope = String(read("viewScope", state?.viewScope || "") || "") + .trim() + .toLowerCase(); gridContainer.dataset.mjrScope = read("scope", "output"); + gridContainer.dataset.mjrViewScope = nextViewScope; gridContainer.dataset.mjrCustomRootId = read("customRootId", "") || ""; const subfolder = read("currentFolderRelativePath", "") || ""; gridContainer.dataset.mjrSubfolder = subfolder; @@ -317,6 +327,7 @@ export function createGridController({ gridContainer.dataset.mjrFilterDateRange = read("dateRangeFilter", "") || ""; gridContainer.dataset.mjrFilterDateExact = read("dateExactFilter", "") || ""; gridContainer.dataset.mjrSort = read("sort", "mtime_desc") || "mtime_desc"; + gridContainer.dataset.mjrCollectionId = read("collectionId", "") || ""; gridContainer.dataset.mjrGroupStacks = APP_CONFIG.EXECUTION_GROUPING_ENABLED && (read("scope", "output") === "output" || read("scope", "output") === "all") @@ -382,7 +393,7 @@ export function createGridController({ write("collectionName", ""); } - const viewScope = String(read("viewScope", state?.viewScope || "") || "").toLowerCase(); + const viewScope = nextViewScope; if (viewScope === "similar") { const bridgeList = Array.isArray(read("similarResults", [])) ? read("similarResults", []) : []; const stateList = Array.isArray(state?.similarResults) ? state.similarResults : []; @@ -400,6 +411,7 @@ export function createGridController({ const result = await loadAssetsFromList(gridContainer, list, { title: title || "Similar", reset: true, + ...reloadOptions, }); try { write("lastGridCount", Number(result?.count || 0) || 0); @@ -413,7 +425,12 @@ export function createGridController({ return; } - const result = await _loadWithSemanticFallback(getQuery()); + const exitingVirtualSimilarScope = prevViewScope === "similar" && viewScope !== "similar"; + const result = await _loadWithSemanticFallback(getQuery(), { + ...reloadOptions, + preserveVisibleUntilReady: + reloadOptions.preserveVisibleUntilReady ?? !exitingVirtualSimilarScope, + }); // Track search query timing if timer was started try { @@ -447,13 +464,15 @@ export function createGridController({ _isReloading = true; try { while (_pendingReload) { + const reloadOptions = { ...(_nextReloadOptions || {}) }; + _nextReloadOptions = {}; _pendingReload = false; try { const useAiTimeout = _isAiEnabled() && (_isSemanticMode() || String(getQuery?.() || "").length >= 12); await runWithWatchdog( - () => runReloadOnce(), + () => runReloadOnce(reloadOptions), useAiTimeout ? AI_RELOAD_WATCHDOG_MS : RELOAD_WATCHDOG_MS, ); } catch (err) { @@ -477,8 +496,9 @@ export function createGridController({ // Input debounce: batches rapid reloadGrid() calls (e.g., scope switch + // watcher event arriving simultaneously) into a single _doReload execution. // The inner coalescing loop still handles calls that arrive mid-reload. - const reloadGrid = () => { + const reloadGrid = (options = {}) => { return new Promise((resolve, reject) => { + _nextReloadOptions = { ...(_nextReloadOptions || {}), ...(options || {}) }; if (_debounceTimer) clearTimeout(_debounceTimer); _debounceTimer = setTimeout(() => { _debounceTimer = null; diff --git a/js/features/panel/panelRuntime.js b/js/features/panel/panelRuntime.js index aa81b98c..f6df4b0d 100644 --- a/js/features/panel/panelRuntime.js +++ b/js/features/panel/panelRuntime.js @@ -245,7 +245,9 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter gridWrapper.appendChild(gridContainer); } setActiveGridContainer(gridContainer); - try { gridContainer.dataset.mjrScope = state.scope; } catch (e) { console.debug?.(e); } + try { + gridContainer.dataset.mjrScope = String(readPanelValue("scope", state.scope || "output") || "output"); + } catch (e) { console.debug?.(e); } const summaryBarDisposers = []; const registerSummaryDispose = (fn) => { @@ -385,8 +387,8 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter // ── 13. DUPLICATE ALERTS ───────────────────────────────────────────── const refreshDuplicateAlerts = async () => { - const scope = String(state.scope || "output").toLowerCase(); - const customRootId = String(state.customRootId || "").trim(); + const scope = String(readPanelValue("scope", state.scope || "output") || "output").toLowerCase(); + const customRootId = String(readPanelValue("customRootId", state.customRootId || "") || "").trim(); if (scope === "custom" && !customRootId) { _duplicatesAlert = null; notifyContextChanged(); @@ -510,20 +512,40 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter reconcileSelection: reconcileVisibleSelection, onChanged: () => { try { - if (gridContainer) gridContainer.dataset.mjrScope = state.scope; + if (gridContainer) { + gridContainer.dataset.mjrScope = String( + readPanelValue("scope", state.scope || "output") || "output", + ); + } } catch (e) { console.debug?.(e); } notifyContextChanged(); }, onScopeChanged: async () => { - try { await applyWatcherForScope(state.scope); } catch (e) { console.debug?.(e); } + try { + await applyWatcherForScope( + String(readPanelValue("scope", state.scope || "output") || "output"), + ); + } catch (e) { console.debug?.(e); } try { await refreshDuplicateAlerts(); } catch (e) { console.debug?.(e); } }, onBeforeReload: async () => { + // Sync key dataset fields *before* prepareGridForScopeSwitch so that + // snapshot-cache hydration uses the correct (new-scope) key. + try { + gridContainer.dataset.mjrScope = readPanelValue("scope", "output"); + gridContainer.dataset.mjrCustomRootId = readPanelValue("customRootId", "") || ""; + gridContainer.dataset.mjrSubfolder = readPanelValue("currentFolderRelativePath", "") || ""; + gridContainer.dataset.mjrCollectionId = readPanelValue("collectionId", "") || ""; + gridContainer.dataset.mjrViewScope = readPanelValue("viewScope", "") || ""; + } catch (e) { console.debug?.(e); } try { prepareGridForScopeSwitchFn(gridContainer); } catch (e) { console.debug?.(e); } try { await updateStatus( statusDot, statusText, capabilitiesSection, - { scope: state.scope, customRootId: state.customRootId }, + { + scope: readPanelValue("scope", state.scope || "output"), + customRootId: readPanelValue("customRootId", state.customRootId || ""), + }, null, { signal: panelLifecycleAC?.signal || null }, ); @@ -533,7 +555,7 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter const exitSimilarViewIfActive = async ({ reload = false } = {}) => { try { - if (String(state.viewScope || "").trim().toLowerCase() !== "similar") return; + if (String(readPanelValue("viewScope", state.viewScope || "") || "").trim().toLowerCase() !== "similar") return; await scopeController?.clearSimilarScope?.({ reload }); } catch (e) { console.debug?.(e); } }; @@ -990,34 +1012,34 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter didHydrateFromSnapshot = await hydrateGridFromSnapshotFn( gridContainer, { - scope: state.scope || "output", + scope: readPanelValue("scope", state.scope || "output") || "output", query: String( normalizeQuery( panelStateBridge.read("searchQuery", state.searchQuery || "*") || "*", ) || "*", ), - customRootId: state.customRootId || "", - subfolder: state.currentFolderRelativePath || "", - collectionId: state.collectionId || "", - viewScope: state.viewScope || "", - kind: state.kindFilter || "", - workflowOnly: !!state.workflowOnly, - minRating: state.minRating || "", - minSizeMB: state.minSizeMB || "", - maxSizeMB: state.maxSizeMB || "", - resolutionCompare: state.resolutionCompare || "", - minWidth: state.minWidth || "", - minHeight: state.minHeight || "", - maxWidth: state.maxWidth || "", - maxHeight: state.maxHeight || "", - workflowType: state.workflowType || "", - dateRange: state.dateRangeFilter || "", - dateExact: state.dateExactFilter || "", - sort: state.sort || "mtime_desc", + customRootId: readPanelValue("customRootId", state.customRootId || "") || "", + subfolder: readPanelValue("currentFolderRelativePath", state.currentFolderRelativePath || "") || "", + collectionId: readPanelValue("collectionId", state.collectionId || "") || "", + viewScope: readPanelValue("viewScope", state.viewScope || "") || "", + kind: readPanelValue("kindFilter", state.kindFilter || "") || "", + workflowOnly: !!readPanelValue("workflowOnly", state.workflowOnly), + minRating: readPanelValue("minRating", state.minRating || "") || "", + minSizeMB: readPanelValue("minSizeMB", state.minSizeMB || "") || "", + maxSizeMB: readPanelValue("maxSizeMB", state.maxSizeMB || "") || "", + resolutionCompare: readPanelValue("resolutionCompare", state.resolutionCompare || "") || "", + minWidth: readPanelValue("minWidth", state.minWidth || "") || "", + minHeight: readPanelValue("minHeight", state.minHeight || "") || "", + maxWidth: readPanelValue("maxWidth", state.maxWidth || "") || "", + maxHeight: readPanelValue("maxHeight", state.maxHeight || "") || "", + workflowType: readPanelValue("workflowType", state.workflowType || "") || "", + dateRange: readPanelValue("dateRangeFilter", state.dateRangeFilter || "") || "", + dateExact: readPanelValue("dateExactFilter", state.dateExactFilter || "") || "", + sort: readPanelValue("sort", state.sort || "mtime_desc") || "mtime_desc", }, { title: - state.collectionName || + readPanelValue("collectionName", state.collectionName || "") || panelStateBridge.read("searchQuery", state.searchQuery || "") || "Cached", }, diff --git a/js/features/panel/panelSimilarSearch.js b/js/features/panel/panelSimilarSearch.js index 93109b2b..38ae5547 100644 --- a/js/features/panel/panelSimilarSearch.js +++ b/js/features/panel/panelSimilarSearch.js @@ -1,6 +1,5 @@ import { EVENTS } from "../../app/events.js"; import { vectorFindSimilar } from "../../api/client.js"; -import { loadAssetsFromList } from "../grid/gridApi.js"; import { comfyToast } from "../../app/toast.js"; import { t } from "../../app/i18n.js"; @@ -27,18 +26,6 @@ export function bindSimilarSearch({ scopeController, closePopovers, }) { - // ── Helper: load a list into the grid and update stats ───────────────── - - const _loadSimilarList = async (list, title) => { - const loaded = await loadAssetsFromList(gridContainer, list, { title, reset: true }); - const count = Number((loaded?.count ?? list.length) || 0) || 0; - writePanelValue("lastGridCount", count); - writePanelValue("lastGridTotal", count); - gridContainer.dispatchEvent?.( - new CustomEvent("mjr:grid-stats", { detail: { count, total: count } }), - ); - }; - // ── Similar button click ─────────────────────────────────────────────── similarBtn?.addEventListener( @@ -103,12 +90,6 @@ export function bindSimilarSearch({ }), ); await scopeController?.setScope?.("similar"); - try { - const title = String(readPanelValue("similarTitle", "") || "Similar").trim(); - await _loadSimilarList(list, title); - } catch (err) { - console.debug?.(err); - } } catch (err) { console.debug?.(err); comfyToast( @@ -140,12 +121,6 @@ export function bindSimilarSearch({ `Generation group (${list.length} assets)`, ); await scopeController?.setScope?.("similar"); - try { - const title = String(readPanelValue("similarTitle", "") || "Similar").trim(); - await _loadSimilarList(list, title); - } catch (err) { - console.debug?.(err); - } } catch (err) { console.debug?.(err); } diff --git a/js/stores/usePanelStore.js b/js/stores/usePanelStore.js index 29a9445e..35c26f1f 100644 --- a/js/stores/usePanelStore.js +++ b/js/stores/usePanelStore.js @@ -274,6 +274,9 @@ export const usePanelStore = defineStore("mjr-panel", () => { customRootId.value = opts.customRootId || ""; customRootLabel.value = opts.customRootLabel || ""; viewScope.value = ""; + similarResults.value = []; + similarTitle.value = ""; + similarSourceAssetId.value = ""; clearSelection(); } diff --git a/js/tests/grid_controller_exit_similar_scope.vitest.mjs b/js/tests/grid_controller_exit_similar_scope.vitest.mjs new file mode 100644 index 00000000..593782e0 --- /dev/null +++ b/js/tests/grid_controller_exit_similar_scope.vitest.mjs @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const bridgeState = vi.hoisted(() => ({ + values: { + 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", + activeAssetId: "", + selectedAssetIds: [], + collectionId: "", + collectionName: "", + viewScope: "", + similarResults: [], + similarTitle: "", + similarSourceAssetId: "", + lastGridCount: 0, + lastGridTotal: 0, + }, +})); + +vi.mock("../stores/panelStateBridge.js", () => ({ + createPanelStateBridge: () => ({ + read: (key, fallback = "") => + Object.prototype.hasOwnProperty.call(bridgeState.values, key) + ? bridgeState.values[key] + : fallback, + write: vi.fn((key, value) => { + bridgeState.values[key] = value; + }), + }), +})); + +vi.mock("../api/client.js", () => ({ + vectorSearch: vi.fn(), + hybridSearch: vi.fn(), + vectorStats: vi.fn(), +})); + +vi.mock("../app/toast.js", () => ({ + comfyToast: vi.fn(), +})); + +vi.mock("../app/i18n.js", () => ({ + t: (_key, fallback) => fallback, +})); + +vi.mock("../app/settings.js", () => ({ + loadMajoorSettings: () => ({ ai: { vectorSearchEnabled: true } }), +})); + +function createGridContainer() { + return { + dataset: { mjrViewScope: "similar" }, + dispatchEvent: vi.fn(), + }; +} + +describe("gridController exiting similar scope", () => { + beforeEach(() => { + vi.clearAllMocks(); + bridgeState.values = { + 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", + activeAssetId: "", + selectedAssetIds: [], + collectionId: "", + collectionName: "", + viewScope: "", + similarResults: [], + similarTitle: "", + similarSourceAssetId: "", + lastGridCount: 0, + lastGridTotal: 0, + }; + globalThis.window = {}; + globalThis.CustomEvent = class { + constructor(type, init = {}) { + this.type = type; + this.detail = init.detail; + } + }; + }); + + it("does not preserve stale similar cards when switching back to normal scope", async () => { + const { createGridController } = await import("../features/panel/controllers/gridController.js"); + + const loadAssets = vi.fn(async () => ({ ok: true, count: 175, total: 175 })); + const loadAssetsFromList = vi.fn(async () => ({ ok: true, count: 2, total: 2 })); + + const controller = createGridController({ + gridContainer: createGridContainer(), + loadAssets, + loadAssetsFromList, + getCollectionAssets: vi.fn(), + disposeGrid: vi.fn(), + getQuery: () => "*", + searchInputEl: { dataset: { mjrSemanticMode: "0" } }, + state: { + scope: "output", + viewScope: "", + }, + }); + + await controller.reloadGrid(); + + expect(loadAssets).toHaveBeenCalledWith( + expect.any(Object), + "*", + expect.objectContaining({ preserveVisibleUntilReady: false }), + ); + expect(loadAssetsFromList).not.toHaveBeenCalled(); + }); +}); diff --git a/js/tests/grid_scope_switch_cache.vitest.mjs b/js/tests/grid_scope_switch_cache.vitest.mjs new file mode 100644 index 00000000..ec1f9d43 --- /dev/null +++ b/js/tests/grid_scope_switch_cache.vitest.mjs @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const bridgeState = vi.hoisted(() => ({ + values: { + 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", + activeAssetId: "", + selectedAssetIds: [], + collectionId: "", + collectionName: "", + viewScope: "", + similarResults: [], + similarTitle: "", + similarSourceAssetId: "", + lastGridCount: 0, + lastGridTotal: 0, + }, +})); + +vi.mock("../stores/panelStateBridge.js", () => ({ + createPanelStateBridge: () => ({ + read: (key, fallback = "") => + Object.prototype.hasOwnProperty.call(bridgeState.values, key) + ? bridgeState.values[key] + : fallback, + write: vi.fn((key, value) => { + bridgeState.values[key] = value; + }), + }), +})); + +vi.mock("../api/client.js", () => ({ + vectorSearch: vi.fn(), + hybridSearch: vi.fn(), + vectorStats: vi.fn(), +})); + +vi.mock("../app/toast.js", () => ({ + comfyToast: vi.fn(), +})); + +vi.mock("../app/i18n.js", () => ({ + t: (_key, fallback) => fallback, +})); + +vi.mock("../app/settings.js", () => ({ + loadMajoorSettings: () => ({ ai: { vectorSearchEnabled: true } }), +})); + +vi.mock("../app/config.js", () => ({ + APP_CONFIG: { EXECUTION_GROUPING_ENABLED: true }, +})); + +function createGridContainer() { + return { + dataset: { mjrViewScope: "", mjrScope: "output" }, + dispatchEvent: vi.fn(), + }; +} + +describe("gridController scope switch cache behavior", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(bridgeState.values, { + 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", + activeAssetId: "", + selectedAssetIds: [], + collectionId: "", + collectionName: "", + viewScope: "", + similarResults: [], + similarTitle: "", + similarSourceAssetId: "", + lastGridCount: 0, + lastGridTotal: 0, + }); + globalThis.window = {}; + globalThis.CustomEvent = class { + constructor(type, init = {}) { + this.type = type; + this.detail = init.detail; + } + }; + }); + + it("writes mjrCollectionId to the dataset during reload", async () => { + const { createGridController } = await import( + "../features/panel/controllers/gridController.js" + ); + + bridgeState.values.collectionId = "col-123"; + bridgeState.values.collectionName = "Test Collection"; + + const gridContainer = createGridContainer(); + const loadAssets = vi.fn(async () => ({ ok: true, count: 5, total: 5 })); + const getCollectionAssets = vi.fn(async () => ({ + ok: true, + data: { assets: [{ id: 1 }, { id: 2 }] }, + })); + const loadAssetsFromList = vi.fn(async () => ({ ok: true, count: 2, total: 2 })); + + const controller = createGridController({ + gridContainer, + loadAssets, + loadAssetsFromList, + getCollectionAssets, + disposeGrid: vi.fn(), + getQuery: () => "*", + searchInputEl: { dataset: { mjrSemanticMode: "0" } }, + state: {}, + }); + + await controller.reloadGrid(); + + expect(gridContainer.dataset.mjrCollectionId).toBe("col-123"); + }); + + it("loads collection assets when collectionId is set", async () => { + const { createGridController } = await import( + "../features/panel/controllers/gridController.js" + ); + + bridgeState.values.collectionId = "col-456"; + bridgeState.values.collectionName = "My Collection"; + + const gridContainer = createGridContainer(); + const loadAssets = vi.fn(async () => ({ ok: true, count: 5, total: 5 })); + const collectionAssets = [ + { id: 10, filename: "a.png" }, + { id: 11, filename: "b.png" }, + ]; + const getCollectionAssets = vi.fn(async () => ({ + ok: true, + data: { assets: collectionAssets }, + })); + const loadAssetsFromList = vi.fn(async () => ({ ok: true, count: 2, total: 2 })); + + const controller = createGridController({ + gridContainer, + loadAssets, + loadAssetsFromList, + getCollectionAssets, + disposeGrid: vi.fn(), + getQuery: () => "*", + searchInputEl: { dataset: { mjrSemanticMode: "0" } }, + state: {}, + }); + + await controller.reloadGrid(); + + expect(getCollectionAssets).toHaveBeenCalledWith("col-456"); + expect(loadAssetsFromList).toHaveBeenCalledWith( + gridContainer, + collectionAssets, + expect.objectContaining({ title: "Collection: My Collection" }), + ); + // Normal loadAssets should NOT be called when collection is active + expect(loadAssets).not.toHaveBeenCalled(); + }); + + it("falls back to normal load when collection fetch fails", async () => { + const { createGridController } = await import( + "../features/panel/controllers/gridController.js" + ); + + bridgeState.values.collectionId = "col-bad"; + + const gridContainer = createGridContainer(); + const loadAssets = vi.fn(async () => ({ ok: true, count: 50, total: 50 })); + const getCollectionAssets = vi.fn(async () => ({ ok: false, error: "Not found" })); + const loadAssetsFromList = vi.fn(); + + const controller = createGridController({ + gridContainer, + loadAssets, + loadAssetsFromList, + getCollectionAssets, + disposeGrid: vi.fn(), + getQuery: () => "*", + searchInputEl: { dataset: { mjrSemanticMode: "0" } }, + state: {}, + }); + + await controller.reloadGrid(); + + // Should fall back to normal loadAssets + expect(loadAssets).toHaveBeenCalled(); + // collectionId should be cleared + expect(bridgeState.values.collectionId).toBe(""); + }); + + it("switches from output to input scope and reloads normally", async () => { + const { createGridController } = await import( + "../features/panel/controllers/gridController.js" + ); + + const gridContainer = createGridContainer(); + const loadAssets = vi.fn(async () => ({ ok: true, count: 100, total: 100 })); + + const controller = createGridController({ + gridContainer, + loadAssets, + loadAssetsFromList: vi.fn(async () => ({ ok: true })), + getCollectionAssets: vi.fn(), + disposeGrid: vi.fn(), + getQuery: () => "*", + searchInputEl: { dataset: { mjrSemanticMode: "0" } }, + state: {}, + }); + + // First load in output scope + await controller.reloadGrid(); + expect(gridContainer.dataset.mjrScope).toBe("output"); + + // Switch to input scope + bridgeState.values.scope = "input"; + loadAssets.mockClear(); + await controller.reloadGrid(); + + expect(gridContainer.dataset.mjrScope).toBe("input"); + expect(loadAssets).toHaveBeenCalled(); + }); +}); diff --git a/js/tests/rating_tags_id_save.vitest.mjs b/js/tests/rating_tags_id_save.vitest.mjs new file mode 100644 index 00000000..cca76e42 --- /dev/null +++ b/js/tests/rating_tags_id_save.vitest.mjs @@ -0,0 +1,109 @@ +/** + * rating_tags_id_save.vitest.mjs + * + * Tests that after a successful rating/tag save the returned DB asset_id + * is stored on the asset object — even when the existing id is a synthetic + * string ("asset:…") produced by AssetCardRenderer for unindexed assets. + * + * Bug: the original condition `props.asset.id == null` never triggered for + * synthetic ids, so subsequent calls fell back to filepath-based resolution + * that could fail with "File not found". + */ +import { describe, expect, it, vi } from "vitest"; +import { normalizeAssetId } from "../utils/ids.js"; + +// --------------------------------------------------------------------------- +// normalizeAssetId rejects synthetic IDs +// --------------------------------------------------------------------------- + +describe("normalizeAssetId rejects synthetic card IDs", () => { + it("returns empty for asset:… prefixed ids", () => { + expect(normalizeAssetId("asset:output||foo/bar.png||bar.png")).toBe(""); + }); + + it("returns empty for non-integer string ids", () => { + expect(normalizeAssetId("abc-123")).toBe(""); + expect(normalizeAssetId("asset:0")).toBe(""); + expect(normalizeAssetId(null)).toBe(""); + expect(normalizeAssetId(undefined)).toBe(""); + expect(normalizeAssetId(0)).toBe(""); + }); + + it("returns valid string for positive integers", () => { + expect(normalizeAssetId(42)).toBe("42"); + expect(normalizeAssetId("42")).toBe("42"); + }); +}); + +// --------------------------------------------------------------------------- +// ID save-back logic (unit-level, no Vue mount) +// --------------------------------------------------------------------------- + +/** + * Simulates the ID save-back logic used in both RatingEditor.vue and + * TagsEditor.vue after a successful API response. + */ +function applyIdSaveBack(asset, responseAssetId) { + const newId = responseAssetId ?? null; + // NEW condition matching the fix: + if (newId != null && !normalizeAssetId(asset.id)) { + asset.id = newId; + } +} + +describe("ID save-back after successful rating/tag update", () => { + it("saves DB id when asset.id is null", () => { + const asset = { id: null, filepath: "/output/img.png" }; + applyIdSaveBack(asset, 123); + expect(asset.id).toBe(123); + }); + + it("saves DB id when asset.id is undefined", () => { + const asset = { filepath: "/output/img.png" }; + applyIdSaveBack(asset, 456); + expect(asset.id).toBe(456); + }); + + it("saves DB id when asset.id is a synthetic string", () => { + const asset = { id: "asset:output||foo/bar.png||bar.png", filepath: "/output/bar.png" }; + applyIdSaveBack(asset, 789); + expect(asset.id).toBe(789); + }); + + it("saves DB id when asset.id is empty string", () => { + const asset = { id: "", filepath: "/output/img.png" }; + applyIdSaveBack(asset, 100); + expect(asset.id).toBe(100); + }); + + it("saves DB id when asset.id is 0", () => { + const asset = { id: 0, filepath: "/output/img.png" }; + applyIdSaveBack(asset, 55); + expect(asset.id).toBe(55); + }); + + it("does NOT overwrite a valid integer id", () => { + const asset = { id: 42, filepath: "/output/img.png" }; + applyIdSaveBack(asset, 999); + // Already has a valid DB id — do not overwrite + expect(asset.id).toBe(42); + }); + + it("does NOT overwrite a valid integer-string id", () => { + const asset = { id: "42", filepath: "/output/img.png" }; + applyIdSaveBack(asset, 999); + expect(asset.id).toBe("42"); + }); + + it("does not save when response asset_id is null", () => { + const asset = { id: "asset:synth", filepath: "/x.png" }; + applyIdSaveBack(asset, null); + expect(asset.id).toBe("asset:synth"); + }); + + it("does not save when response asset_id is undefined", () => { + const asset = { id: "asset:synth", filepath: "/x.png" }; + applyIdSaveBack(asset, undefined); + expect(asset.id).toBe("asset:synth"); + }); +}); diff --git a/js/tests/scope_controller.vitest.mjs b/js/tests/scope_controller.vitest.mjs index d8a5ff53..b1ae9316 100644 --- a/js/tests/scope_controller.vitest.mjs +++ b/js/tests/scope_controller.vitest.mjs @@ -87,6 +87,9 @@ describe("scopeController similar scope", () => { await controller.clearSimilarScope({ reload: false }); expect(panelStore.viewScope).toBe(""); + expect(panelStore.similarResults).toEqual([]); + expect(panelStore.similarTitle).toBe(""); + expect(panelStore.similarSourceAssetId).toBe(""); expect(tabButtons.tabSimilar.style.display).toBe("none"); await controller.setScope("output"); @@ -94,6 +97,9 @@ describe("scopeController similar scope", () => { expect(panelStore.collectionId).toBe(""); expect(panelStore.collectionName).toBe(""); expect(panelStore.currentFolderRelativePath).toBe(""); + expect(panelStore.similarResults).toEqual([]); + expect(panelStore.similarTitle).toBe(""); + expect(panelStore.similarSourceAssetId).toBe(""); expect(reconcileSelection).toHaveBeenCalledTimes(1); }); }); diff --git a/js/tests/stack_group_cards.vitest.mjs b/js/tests/stack_group_cards.vitest.mjs index af7f70b1..3e092368 100644 --- a/js/tests/stack_group_cards.vitest.mjs +++ b/js/tests/stack_group_cards.vitest.mjs @@ -80,4 +80,57 @@ describe("StackGroupCards overlay buttons", () => { expect(detail.stackId).toBe("stack-1"); expect(detail.members.map((entry) => entry.id)).toEqual(["asset-2", "asset-1"]); }); + + it("skips imperative button creation for Vue cards (_mjrIsVue guard)", async () => { + const { ensureStackGroupCard, ensureDupStackCard } = await import("../features/grid/StackGroupCards.js"); + + const grid = document.createElement("div"); + grid.dataset.mjrGroupStacks = "1"; + const card = document.createElement("div"); + card.className = "mjr-asset-card"; + card._mjrIsVue = true; + grid.appendChild(card); + + const asset = { + id: "asset-1", + filename: "image.png", + stack_id: "stack-1", + stack_asset_count: 3, + _mjrDupStack: true, + _mjrDupCount: 2, + }; + + ensureStackGroupCard(grid, card, asset); + expect(card.querySelector(".mjr-stack-group-button")).toBeNull(); + + ensureDupStackCard(grid, card, asset); + expect(card.querySelector(".mjr-dup-stack-button")).toBeNull(); + }); + + it("still creates buttons for non-Vue cards", async () => { + const { ensureStackGroupCard, ensureDupStackCard } = await import("../features/grid/StackGroupCards.js"); + + const grid = document.createElement("div"); + grid.dataset.mjrGroupStacks = "1"; + const card = document.createElement("div"); + card.className = "mjr-asset-card"; + // _mjrIsVue not set + grid.appendChild(card); + + const asset = { + id: "asset-1", + filename: "image.png", + stack_id: "stack-1", + stack_asset_count: 3, + _mjrDupStack: true, + _mjrDupCount: 2, + _mjrDupMembers: [{ id: "a1" }, { id: "a2" }], + }; + + ensureStackGroupCard(grid, card, asset); + expect(card.querySelector(".mjr-stack-group-button")).toBeTruthy(); + + ensureDupStackCard(grid, card, asset); + expect(card.querySelector(".mjr-dup-stack-button")).toBeTruthy(); + }); }); diff --git a/js/theme/theme-comfy.css b/js/theme/theme-comfy.css index c928869a..c0316ad4 100644 --- a/js/theme/theme-comfy.css +++ b/js/theme/theme-comfy.css @@ -5319,6 +5319,47 @@ transform: none !important; } +/* ── Visual stacking effect — layered cards behind the main card ──────────── */ +/* Creates pseudo-element "cards" offset behind the primary card to visually */ +/* communicate that this card represents a group of multiple images. */ +.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"] { + position: relative; + z-index: 1; +} + +.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]::before, +.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--comfy-menu-bg, #1a1a2e); + border: 1px solid rgba(255, 255, 255, 0.06); + pointer-events: none; + z-index: -1; + transition: transform 180ms ease, opacity 180ms ease; +} + +.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]::before { + transform: translate(3px, 3px) rotate(1deg); + opacity: 0.45; +} + +.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]::after { + transform: translate(6px, 6px) rotate(2.2deg); + opacity: 0.25; +} + +.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]:hover::before { + transform: translate(4px, 4px) rotate(1.5deg); + opacity: 0.55; +} + +.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]:hover::after { + transform: translate(8px, 8px) rotate(3deg); + opacity: 0.35; +} + .mjr-stack-group-popover { width: min(440px, 92vw); } diff --git a/js/vue/components/common/RatingEditor.vue b/js/vue/components/common/RatingEditor.vue index d5eb238b..c36b3edb 100644 --- a/js/vue/components/common/RatingEditor.vue +++ b/js/vue/components/common/RatingEditor.vue @@ -11,6 +11,7 @@ import { ASSET_RATING_CHANGED_EVENT } from "../../../app/events.js"; import { t } from "../../../app/i18n.js"; import { comfyToast } from "../../../app/toast.js"; import { safeDispatchCustomEvent } from "../../../utils/events.js"; +import { normalizeAssetId } from "../../../utils/ids.js"; function clampRating(value) { return Math.max(0, Math.min(5, Number(value) || 0)); @@ -113,7 +114,7 @@ async function flushSaves() { try { const newId = result?.data?.asset_id ?? null; - if (props.asset.id == null && newId != null) props.asset.id = newId; + if (newId != null && !normalizeAssetId(props.asset.id)) props.asset.id = newId; } catch (e) { console.debug?.(e); } diff --git a/js/vue/components/common/TagsEditor.vue b/js/vue/components/common/TagsEditor.vue index 05845568..048cbf20 100644 --- a/js/vue/components/common/TagsEditor.vue +++ b/js/vue/components/common/TagsEditor.vue @@ -11,6 +11,7 @@ import { ASSET_TAGS_CHANGED_EVENT } from "../../../app/events.js"; import { t } from "../../../app/i18n.js"; import { comfyToast } from "../../../app/toast.js"; import { safeDispatchCustomEvent } from "../../../utils/events.js"; +import { normalizeAssetId } from "../../../utils/ids.js"; const MAX_TAG_LEN = 100; const MAX_TAGS = 200; @@ -212,7 +213,7 @@ async function saveTags() { try { const newId = result?.data?.asset_id ?? null; - if (props.asset.id == null && newId != null) props.asset.id = newId; + if (newId != null && !normalizeAssetId(props.asset.id)) props.asset.id = newId; } catch (e) { console.debug?.(e); } diff --git a/js/vue/components/grid/AssetCardInner.vue b/js/vue/components/grid/AssetCardInner.vue index 99134601..24fcd79b 100644 --- a/js/vue/components/grid/AssetCardInner.vue +++ b/js/vue/components/grid/AssetCardInner.vue @@ -9,7 +9,7 @@ * * Phase 4.2 — full inner card replacement. */ -import { computed, ref, watch, watchEffect, onUnmounted } from "vue"; +import { computed, inject, ref, watch, watchEffect, onMounted, onUnmounted } from "vue"; import { buildAssetViewURL } from "../../../api/endpoints.js"; import { genTimeColor, @@ -19,6 +19,7 @@ import { } from "../../../components/Badges.js"; import { formatDuration, formatDate, formatTime } from "../../../utils/format.js"; import { MediaBlobCache } from "../../../features/grid/MediaBlobCache.js"; +import { APP_CONFIG } from "../../../app/config.js"; import RatingBadge from "../common/RatingBadge.vue"; import TagsBadge from "../common/TagsBadge.vue"; import GenTimeBadge from "../common/GenTimeBadge.vue"; @@ -64,13 +65,13 @@ async function observeVideoThumb(video) { try { const src = String(video.dataset?.src || "").trim(); if (!src || video.getAttribute("src")) return; - + // Check if already errored if (MediaBlobCache.hasError(src)) { video.src = src; // Let browser show error return; } - + // Try to get from blob cache (or fetch + cache) const cachedUrl = await MediaBlobCache.acquireUrl(src); if (cachedUrl) { @@ -123,16 +124,66 @@ function bindVideoThumbHover(thumbEl, video) { }; } -function unobserveVideoThumb(video) { +function bindVideoAutoplay(video) { + if (!video) return; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + try { + if (entry.isIntersecting) { + video.play?.().catch?.(() => {}); + } else { + video.pause?.(); + video.currentTime = 0; + } + } catch (e) { + console.debug?.(e); + } + } + }, + { threshold: 0.25 }, + ); + observer.observe(video); + video._mjrAutoplayCleanup = () => { + try { + observer.unobserve(video); + observer.disconnect(); + } catch (e) { + console.debug?.(e); + } + }; +} + +function cleanupVideoBehaviors(video) { if (!video) return; try { video._mjrHoverCleanup?.(); video._mjrHoverCleanup = null; + video._mjrAutoplayCleanup?.(); + video._mjrAutoplayCleanup = null; + video.pause?.(); + video.currentTime = 0; } catch (e) { console.debug?.(e); } } +function applyVideoMode(thumbEl, video, mode) { + if (!video) return; + cleanupVideoBehaviors(video); + if (mode === "hover") { + bindVideoThumbHover(thumbEl, video); + } else if (mode === "always") { + bindVideoAutoplay(video); + } + // "off" — no bindings, video stays paused +} + +function unobserveVideoThumb(video) { + if (!video) return; + cleanupVideoBehaviors(video); +} + // ─── Props ─────────────────────────────────────────────────────────────────── const props = defineProps({ @@ -207,6 +258,43 @@ const fileBadgeBg = computed(() => { return map[kind.value] || "var(--mjr-badge-image, #888)"; }); +// ─── Stack / dup-stack (Vue-reactive buttons, replacing imperative DOM) ────── + +const stackService = inject("mjrStackService", null); + +const hasStackGroup = computed(() => { + if (!stackService) return false; + const stackId = String(props.asset.stack_id || "").trim(); + if (!stackId) return false; + return Number(props.asset.stack_asset_count || props.asset._mjrFeedGroupCount || 0) > 1; +}); +const stackCount = computed(() => + Number(props.asset.stack_asset_count || props.asset._mjrFeedGroupCount || 0) || 0, +); + +const hasDupStack = computed(() => !!props.asset._mjrDupStack && Number(props.asset._mjrDupCount || 0) >= 2); +const dupCount = computed(() => Number(props.asset._mjrDupCount || 0) || 0); + +async function onStackGroupClick(event) { + event.preventDefault(); + event.stopPropagation(); + try { + await stackService?.openStackGroup?.(props.asset); + } catch (err) { + console.debug?.(err); + } +} + +function onDupStackClick(event) { + event.preventDefault(); + event.stopPropagation(); + try { + stackService?.openDupGroup?.(props.asset); + } catch (err) { + console.debug?.(err); + } +} + // ─── Template refs ─────────────────────────────────────────────────────────── const thumbRef = ref(null); @@ -233,6 +321,18 @@ watch( { immediate: true } ); +// ─── Video autoplay mode (reactive to settings changes) ────────────────────── + +const videoMode = ref(APP_CONFIG.GRID_VIDEO_AUTOPLAY_MODE || "hover"); + +function onSettingsChangedForVideo() { + videoMode.value = APP_CONFIG.GRID_VIDEO_AUTOPLAY_MODE || "hover"; +} + +onMounted(() => { + window.addEventListener("mjr-settings-changed", onSettingsChangedForVideo); +}); + // ─── Video thumb lifecycle (observe/unobserve) ──────────────────────────────── watch(videoRef, (newEl, oldEl) => { @@ -242,12 +342,21 @@ watch(videoRef, (newEl, oldEl) => { if (newEl && thumbRef.value) { try { observeVideoThumb(newEl); - bindVideoThumbHover(thumbRef.value, newEl); + applyVideoMode(thumbRef.value, newEl, videoMode.value); } catch {} } }); +watch(videoMode, (mode) => { + const video = videoRef.value; + const thumb = thumbRef.value; + if (video && thumb) { + applyVideoMode(thumb, video, mode); + } +}); + onUnmounted(() => { + window.removeEventListener("mjr-settings-changed", onSettingsChangedForVideo); const v = videoRef.value; if (v) { try { unobserveVideoThumb(v); } catch {} @@ -467,4 +576,42 @@ function onFileBadgeClick(event) { style="position:absolute;right:8px;bottom:6px;z-index:2" /> + + + + + + diff --git a/js/vue/components/grid/VirtualAssetGridHost.vue b/js/vue/components/grid/VirtualAssetGridHost.vue index 8e1321fa..f272b6b5 100644 --- a/js/vue/components/grid/VirtualAssetGridHost.vue +++ b/js/vue/components/grid/VirtualAssetGridHost.vue @@ -1,7 +1,10 @@