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
This commit is contained in:
Ewald
2026-04-10 15:18:53 +02:00
parent eba5074f03
commit 13a69c1237
24 changed files with 17006 additions and 16038 deletions
+4
View File
@@ -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;
@@ -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;
+49 -27
View File
@@ -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",
},
-25
View File
@@ -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);
}
+3
View File
@@ -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();
}
@@ -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();
});
});
+254
View File
@@ -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();
});
});
+109
View File
@@ -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");
});
});
+6
View File
@@ -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);
});
});
+53
View File
@@ -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();
});
});
+41
View File
@@ -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);
}
+2 -1
View File
@@ -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);
}
+2 -1
View File
@@ -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);
}
+152 -5
View File
@@ -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"
/>
</div>
<!-- ── STACK GROUP BUTTON (execution grouping) ────────────────────────── -->
<button
v-if="hasStackGroup"
type="button"
class="mjr-stack-group-button"
:aria-label="`Open generation group in grid (${stackCount} assets)`"
:title="`Open generation group in grid (${stackCount} assets)`"
@click="onStackGroupClick"
@pointerdown.stop
@mousedown.stop.prevent
@touchstart.stop.passive
@dblclick.stop
@keydown.stop
@dragstart.stop.prevent
>
<span class="pi pi-clone"></span>
<span class="mjr-stack-group-button-count">{{ stackCount }}</span>
</button>
<!-- ── DUPLICATE STACK BUTTON (same-filename copies) ──────────────────── -->
<button
v-if="hasDupStack"
type="button"
class="mjr-dup-stack-button"
:aria-label="`${dupCount} duplicate${dupCount > 1 ? 's' : ''} — click to compare all copies`"
:title="`${dupCount} duplicate${dupCount > 1 ? 's' : ''} — click to compare all copies`"
@click="onDupStackClick"
@pointerdown.stop
@mousedown.stop.prevent
@touchstart.stop.passive
@dblclick.stop
@keydown.stop
@dragstart.stop.prevent
>
<span class="pi pi-copy"></span>
<span class="mjr-dup-stack-count">{{ dupCount }}</span>
</button>
</template>
+77 -17
View File
@@ -1,7 +1,10 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from "vue";
import { measureElement, useVirtualizer } from "@tanstack/vue-virtual";
import { APP_CONFIG } from "../../../app/config.js";
import { EVENTS } from "../../../app/events.js";
import { get } from "../../../api/client.js";
import { buildStackMembersURL } from "../../../api/endpoints.js";
import { requestViewerOpen } from "../../../features/viewer/viewerOpenRequest.js";
import { ensureStackGroupCard, ensureDupStackCard } from "../../../features/grid/StackGroupCards.js";
import { setSelectedIdsDataset } from "../../../features/grid/GridSelectionManager.js";
@@ -47,6 +50,65 @@ const hasMeasuredHostWidthOnce = ref(false);
const settingsVersion = ref(0);
const cardFilenameKeys = new WeakMap();
// ─── Stack service (provide/inject for AssetCardInner Vue buttons) ────────────
function _sortMembersByMtime(items) {
return (Array.isArray(items) ? items : []).slice().sort((a, b) => {
const am = Number(a?.mtime || a?.created_at || 0) || 0;
const bm = Number(b?.mtime || b?.created_at || 0) || 0;
return bm - am;
});
}
provide("mjrStackService", {
openStackGroup: async (asset) => {
const gc = gridContainerRef.value;
if (!gc || !asset) return;
const stackId = String(asset.stack_id || "").trim();
if (!stackId) return;
const groupKey = `stack:${stackId}`;
if (!gc._mjrStackMembersCache) gc._mjrStackMembersCache = new Map();
let members = gc._mjrStackMembersCache.get(groupKey);
if (!Array.isArray(members)) {
const res = await get(buildStackMembersURL(stackId), { timeoutMs: 30_000 });
if (res?.ok && Array.isArray(res?.data)) {
members = res.data;
gc._mjrStackMembersCache.set(groupKey, members);
} else {
return;
}
}
const ordered = _sortMembersByMtime(members);
gc.dispatchEvent(
new CustomEvent(EVENTS.OPEN_STACK_GROUP, {
bubbles: true,
detail: {
asset,
members: ordered,
title: `Generation group (${ordered.length} assets)`,
stackId,
},
}),
);
},
openDupGroup: (asset) => {
const gc = gridContainerRef.value;
if (!gc || !asset) return;
const members = Array.isArray(asset._mjrDupMembers) ? asset._mjrDupMembers : [asset];
gc.dispatchEvent(
new CustomEvent(EVENTS.OPEN_STACK_GROUP, {
bubbles: true,
detail: {
asset,
members,
title: `Duplicates — ${members.length} copies`,
isDupGroup: true,
},
}),
);
},
});
const {
state,
selectedIdSet,
@@ -151,6 +213,12 @@ function isFolderAsset(asset) {
return String(asset?.kind || "").toLowerCase() === "folder";
}
function isAssetStacked(asset) {
const gc = gridContainerRef.value;
if (!gc || String(gc.dataset?.mjrGroupStacks || "") !== "1") return false;
return !!String(asset?.stack_id || "").trim() && Number(asset?.stack_asset_count || 0) > 1;
}
function isRenderableAsset(asset) {
return !!asset && asset._mjrDupHidden !== true;
}
@@ -783,12 +851,6 @@ function syncRenderedDuplicateCards(filenameKey) {
renderedCards[index]._mjrAsset._mjrDupHidden = true;
}
}
try {
ensureDupStackCard(gridContainerRef.value, primaryCard, primaryAsset);
} catch (e) {
console.debug?.(e);
}
}
function syncAllRenderedDuplicateCards() {
@@ -819,16 +881,6 @@ function bindCardRef(asset, node) {
registerFilenameCard(card, asset);
syncRenderedDuplicateCards(String(asset?.filename || "").trim().toLowerCase());
try {
ensureStackGroupCard(gridContainerRef.value, card, asset);
} catch (e) {
console.debug?.(e);
}
try {
ensureDupStackCard(gridContainerRef.value, card, asset);
} catch (e) {
console.debug?.(e);
}
try {
props.onCardRendered?.(card, asset, gridContainerRef.value);
} catch (e) {
@@ -1299,6 +1351,10 @@ defineExpose({
:data-mjr-ext="getAssetExt(asset)"
:data-mjr-stem="getAssetStem(asset)"
:data-mjr-kind="String(asset.kind || '')"
:data-mjr-stacked="isAssetStacked(asset) ? 'true' : undefined"
:data-mjr-stack-count="isAssetStacked(asset) ? String(asset.stack_asset_count || 0) : undefined"
:data-mjr-dup-stacked="asset._mjrDupStack ? 'true' : undefined"
:data-mjr-dup-count="asset._mjrDupStack ? String(asset._mjrDupCount || 0) : undefined"
:aria-label="`Asset ${asset.filename || ''}`"
:aria-selected="isSelected(asset) ? 'true' : 'false'"
@mousedown.left="handleCardPrimaryMouseDown($event, asset)"
@@ -1347,6 +1403,10 @@ defineExpose({
:data-mjr-ext="getAssetExt(asset)"
:data-mjr-stem="getAssetStem(asset)"
:data-mjr-kind="String(asset.kind || '')"
:data-mjr-stacked="isAssetStacked(asset) ? 'true' : undefined"
:data-mjr-stack-count="isAssetStacked(asset) ? String(asset.stack_asset_count || 0) : undefined"
:data-mjr-dup-stacked="asset._mjrDupStack ? 'true' : undefined"
:data-mjr-dup-count="asset._mjrDupStack ? String(asset._mjrDupCount || 0) : undefined"
:aria-label="`Asset ${asset.filename || ''}`"
:aria-selected="isSelected(asset) ? 'true' : 'false'"
@mousedown.left="handleCardPrimaryMouseDown($event, asset)"
+8 -3
View File
@@ -11,7 +11,7 @@ import { consumeEarlyFetch } from "../../features/runtime/entryUiRegistration.js
import {
appendAssets as cardAppendAssets,
} from "../../features/grid/AssetCardRenderer.js";
import { getStackAwareAssetKey, ensureDupStackCard } from "../../features/grid/StackGroupCards.js";
import { getStackAwareAssetKey, ensureDupStackCard, disposeStackGroupCards } from "../../features/grid/StackGroupCards.js";
import {
compareAssets,
fetchPage as fetchGridPage,
@@ -585,7 +585,7 @@ export function useGridLoader({
const sort = String(gridContainer.dataset?.mjrSort || "mtime_desc").toLowerCase();
const normalizedQuery = String(query || "*").trim() || "*";
const isDefaultContext = scope === "output" && normalizedQuery === "*" && sort === "mtime_desc";
if (isDefaultContext) {
const earlyFetchPromise = consumeEarlyFetch("output:*:mtime_desc");
if (earlyFetchPromise) {
@@ -892,7 +892,7 @@ export function useGridLoader({
});
if (reset) {
try {
state.abortController?.abort?.();
} catch (e) {
@@ -970,6 +970,11 @@ export function useGridLoader({
}
function prepareGridForScopeSwitch() {
try {
disposeStackGroupCards(getGridContainer());
} catch (e) {
console.debug?.(e);
}
try {
state.abortController?.abort?.();
} catch (e) {
@@ -1 +1 @@
.mjr-rating-badge[data-v-821bf1b1]{position:absolute;top:6px;right:6px;background:#0000008c;border:1px solid rgba(255,255,255,.12);padding:2px 6px;border-radius:6px;font-size:13px;letter-spacing:1px;display:inline-flex;align-items:center;justify-content:center;pointer-events:none;z-index:10;text-shadow:0 2px 6px rgba(0,0,0,.6);box-shadow:0 6px 18px #00000040;animation:mjr-fade-in-821bf1b1 .2s ease-out}@keyframes mjr-fade-in-821bf1b1{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}.mjr-tags-badge[data-v-15044260]{position:absolute;bottom:6px;left:6px;padding:3px 6px;border-radius:4px;background:#000c;font-size:9px;max-width:calc(100% - 56px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;animation:mjr-fade-in-15044260 .2s ease-out}@keyframes mjr-fade-in-15044260{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.mjr-gentime-badge[data-v-60fe3677]{position:absolute;bottom:6px;right:6px;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:800;letter-spacing:.015em;line-height:1.1;background:#0000008c;border:1px solid rgba(255,255,255,.12);pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;text-shadow:0 1px 2px rgba(0,0,0,.9),0 0 10px color-mix(in srgb,currentColor 35%,transparent);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}.mjr-popover-row[data-v-d1ebebd4]{display:grid;grid-template-columns:110px 1fr;gap:8px;align-items:center;margin-bottom:8px}.mjr-popover-row--3col[data-v-d1ebebd4]{grid-template-columns:110px 1fr 1fr}.mjr-popover-label[data-v-d1ebebd4]{font-size:12px;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)));white-space:nowrap}.mjr-popover-toggle[data-v-d1ebebd4]{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--fg-color, #e6edf7)}.mjr-filter-group-toggle[data-v-d1ebebd4]{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0;margin:0 0 10px;border:0;background:transparent;color:inherit;cursor:pointer;text-align:left}.mjr-filter-group-title[data-v-d1ebebd4]{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.mjr-filter-group-chevron[data-v-d1ebebd4]{font-size:12px;line-height:1;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)))}.mjr-filter-group-body[data-v-d1ebebd4]{padding-top:2px}.mjr-filter-group[data-v-d1ebebd4]:not(.is-open){padding-bottom:8px}.mjr-filter-group:not(.is-open) .mjr-filter-group-toggle[data-v-d1ebebd4]{margin-bottom:0}.mjr-rating-star[data-v-060f7122]{outline:none}.mjr-rating-star[data-v-060f7122]:hover:not(:disabled){transform:scale(1.2)}.mjr-rating-star[data-v-060f7122]:focus-visible{outline:2px solid var(--focus-outline-color, #4dabf7);outline-offset:2px;border-radius:2px}.mjr-grid-skeleton-layout[data-v-4340edb5]{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:12px}.mjr-grid-skeleton-card[data-v-4340edb5]{border-radius:12px;border:1px solid rgba(255,255,255,.22);background:#c0c6ce33;overflow:hidden;min-height:146px;box-shadow:0 6px 18px #0000003d}.mjr-grid-skeleton-thumb[data-v-4340edb5]{aspect-ratio:1 / 1;width:100%;background:linear-gradient(90deg,#9ca2ab85,#dce1e8b8 45%,#9ca2ab85);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-4340edb5 1.2s linear infinite}.mjr-grid-skeleton-line[data-v-4340edb5]{height:10px;margin:8px 10px;border-radius:8px;background:linear-gradient(90deg,#a4aab37a,#d6dbe2b3 45%,#a4aab37a);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-4340edb5 1.2s linear infinite}.mjr-grid-skeleton-line--title[data-v-4340edb5]{width:72%}.mjr-grid-skeleton-line--meta[data-v-4340edb5]{width:48%;margin-top:2px;margin-bottom:10px}@keyframes mjr-grid-skeleton-shimmer-4340edb5{0%{background-position:100% 0}to{background-position:-100% 0}}
.mjr-rating-badge[data-v-821bf1b1]{position:absolute;top:6px;right:6px;background:#0000008c;border:1px solid rgba(255,255,255,.12);padding:2px 6px;border-radius:6px;font-size:13px;letter-spacing:1px;display:inline-flex;align-items:center;justify-content:center;pointer-events:none;z-index:10;text-shadow:0 2px 6px rgba(0,0,0,.6);box-shadow:0 6px 18px #00000040;animation:mjr-fade-in-821bf1b1 .2s ease-out}@keyframes mjr-fade-in-821bf1b1{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}.mjr-tags-badge[data-v-15044260]{position:absolute;bottom:6px;left:6px;padding:3px 6px;border-radius:4px;background:#000c;font-size:9px;max-width:calc(100% - 56px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;animation:mjr-fade-in-15044260 .2s ease-out}@keyframes mjr-fade-in-15044260{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.mjr-gentime-badge[data-v-60fe3677]{position:absolute;bottom:6px;right:6px;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:800;letter-spacing:.015em;line-height:1.1;background:#0000008c;border:1px solid rgba(255,255,255,.12);pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;text-shadow:0 1px 2px rgba(0,0,0,.9),0 0 10px color-mix(in srgb,currentColor 35%,transparent);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}.mjr-popover-row[data-v-d1ebebd4]{display:grid;grid-template-columns:110px 1fr;gap:8px;align-items:center;margin-bottom:8px}.mjr-popover-row--3col[data-v-d1ebebd4]{grid-template-columns:110px 1fr 1fr}.mjr-popover-label[data-v-d1ebebd4]{font-size:12px;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)));white-space:nowrap}.mjr-popover-toggle[data-v-d1ebebd4]{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--fg-color, #e6edf7)}.mjr-filter-group-toggle[data-v-d1ebebd4]{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0;margin:0 0 10px;border:0;background:transparent;color:inherit;cursor:pointer;text-align:left}.mjr-filter-group-title[data-v-d1ebebd4]{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.mjr-filter-group-chevron[data-v-d1ebebd4]{font-size:12px;line-height:1;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)))}.mjr-filter-group-body[data-v-d1ebebd4]{padding-top:2px}.mjr-filter-group[data-v-d1ebebd4]:not(.is-open){padding-bottom:8px}.mjr-filter-group:not(.is-open) .mjr-filter-group-toggle[data-v-d1ebebd4]{margin-bottom:0}.mjr-rating-star[data-v-6d012325]{outline:none}.mjr-rating-star[data-v-6d012325]:hover:not(:disabled){transform:scale(1.2)}.mjr-rating-star[data-v-6d012325]:focus-visible{outline:2px solid var(--focus-outline-color, #4dabf7);outline-offset:2px;border-radius:2px}.mjr-grid-skeleton-layout[data-v-dba549e5]{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:12px}.mjr-grid-skeleton-card[data-v-dba549e5]{border-radius:12px;border:1px solid rgba(255,255,255,.22);background:#c0c6ce33;overflow:hidden;min-height:146px;box-shadow:0 6px 18px #0000003d}.mjr-grid-skeleton-thumb[data-v-dba549e5]{aspect-ratio:1 / 1;width:100%;background:linear-gradient(90deg,#9ca2ab85,#dce1e8b8 45%,#9ca2ab85);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-dba549e5 1.2s linear infinite}.mjr-grid-skeleton-line[data-v-dba549e5]{height:10px;margin:8px 10px;border-radius:8px;background:linear-gradient(90deg,#a4aab37a,#d6dbe2b3 45%,#a4aab37a);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-dba549e5 1.2s linear infinite}.mjr-grid-skeleton-line--title[data-v-dba549e5]{width:72%}.mjr-grid-skeleton-line--meta[data-v-dba549e5]{width:48%;margin-top:2px;margin-bottom:10px}@keyframes mjr-grid-skeleton-shimmer-dba549e5{0%{background-position:100% 0}to{background-position:-100% 0}}
@@ -1,4 +1,4 @@
import { s as j, t as S, E as T, n as te, a as U, b as V, e as G, g as z, c as $, i as W, d as ie, h as ne, M as se, j as oe, k as re, l as le, A as ae } from "./entry-DDLQfahJ.js";
import { s as j, t as S, E as T, n as te, a as U, b as V, e as G, g as z, c as $, i as W, d as ie, h as ne, M as se, j as oe, k as re, l as le, A as ae } from "./entry-BXyIAWFG.js";
const g = Object.freeze({
SIMPLE: "simple",
AB: "ab",
@@ -1,4 +1,4 @@
import { E as S, w as L, f as s } from "./entry-DDLQfahJ.js";
import { E as S, w as L, f as s } from "./entry-BXyIAWFG.js";
let l = null, c = null, d = null, m = !1, u = null, g = null, h = 0, _ = 0, f = null;
const v = /* @__PURE__ */ new WeakMap();
function k() {
File diff suppressed because one or more lines are too long
@@ -4481,18 +4481,19 @@ function bc(t, e, s) {
return o.$id = n, o;
}
export {
xt as A,
eo as B,
dl as C,
dc as D,
Dr as E,
ic as A,
xt as B,
eo as C,
dl as D,
dc as E,
mt as F,
bc as G,
_c as H,
cc as I,
uc as J,
_o as K,
mo as L,
Dr as G,
bc as H,
_c as I,
cc as J,
uc as K,
_o as L,
mo as M,
oc as T,
gc as a,
fc as b,
@@ -4518,6 +4519,6 @@ export {
hc as v,
qe as w,
sc as x,
Gr as y,
ic as z
fe as y,
Gr as z
};
+1 -1
View File
@@ -1,2 +1,2 @@
import "./chunks/entry-DDLQfahJ.js";
import "./chunks/entry-BXyIAWFG.js";
import "../../scripts/app.js";
+2
View File
@@ -344,6 +344,8 @@ class DebouncedWatchHandler(FileSystemEventHandler):
return
if not self._is_supported(path):
return
if _is_recent_generated(path):
return
if not self._on_files_removed:
return
key = self._normalize_path(path)
+5
View File
@@ -17,6 +17,7 @@ from pathlib import Path
from typing import Any
from ...adapters.tools.exiftool import ExifTool
from ...features.index.watcher import mark_recent_generated
from ...shared import ErrorCode, Result, get_logger
logger = get_logger(__name__)
@@ -454,6 +455,10 @@ class RatingTagsSyncWorker:
tags_norm = _normalize_tags(task.tags)
rating = max(0, min(5, int(task.rating or 0)))
# Mark the file so the watcher ignores events triggered by the
# metadata write (ExifTool may delete+rename, causing ID loss).
mark_recent_generated([task.file_path])
# Prefer ExifTool (cross-platform), then Windows Shell fallback (Windows-only).
if mode in ("on", "exiftool"):
try: