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:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
+14
-13
@@ -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
@@ -1,2 +1,2 @@
|
||||
import "./chunks/entry-DDLQfahJ.js";
|
||||
import "./chunks/entry-BXyIAWFG.js";
|
||||
import "../../scripts/app.js";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user