Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/grid/AssetCardRenderer.ts
T
Ewald ALOEBOETOE 4407289e42 Fix duplicate-stack render loop and wrong duplicate grouping
- Clicking a duplicate-stack badge could freeze ComfyUI: displayAssets,
  the per-card ref callback (syncRenderedDuplicateCards) and the live
  upsert dedupe each rewrote _mjrDupStack/_mjrDupMembers on reactive
  assets read by the grid template, with a new array every pass and
  conflicting representatives. The ref callback ran on every render, so
  each write scheduled another render (no recursion limit in prod Vue).
- Compute duplicate stacks only in buildDisplayAssets with change-guarded
  writes and stable member arrays; keep one ref callback per card; stop
  the other passes from touching duplicate fields.
- A duplicate is now the same name in the same source/root/subfolder:
  files sharing a name in different folders are no longer stacked,
  hidden from the grid or given another file's generation time.
- The duplicates view lists every copy using cycle-free member copies.
- Add regression tests (render-loop idempotence, folder scoping).
2026-11-04 08:11:01 +00:00

512 lines
18 KiB
TypeScript

/** Asset card rendering helpers extracted from GridView_impl.js (P3-B-04). */
export function getFilenameKey(filename: any): string {
try {
return String(filename || "")
.trim()
.toLowerCase();
} catch {
return "";
}
}
export function getExtUpper(filename: any): string {
try {
return (
String(filename || "")
.split(".")
.pop() || ""
).toUpperCase();
} catch {
return "";
}
}
export function getStemLower(filename: any): string {
try {
const s = String(filename || "");
const idx = s.lastIndexOf(".");
const stem = idx > 0 ? s.slice(0, idx) : s;
return String(stem || "")
.trim()
.toLowerCase();
} catch {
return "";
}
}
/**
* Check if asset is a video with audio track (detected by filename pattern).
* Videos with "-audio" or "_audio" suffix contain audio.
*/
function isVideoWithAudio(asset: any) {
try {
const kind = String(asset?.kind || "").toLowerCase();
if (kind !== "video") return false;
const filename = String(asset?.filename || "").toLowerCase();
return filename.includes("-audio") || filename.includes("_audio");
} catch {
return false;
}
}
/**
* Calculate priority for stack representative selection.
* Higher priority = better candidate for stack cover.
* Priority: video_with_audio > video > image > other
*/
function getStackRepPriority(asset: any) {
try {
const kind = String(asset?.kind || "").toLowerCase();
let kindPriority = 0;
if (isVideoWithAudio(asset)) {
kindPriority = 2;
} else if (kind === "video") {
kindPriority = 1;
}
const hasGeneration = Number(asset?.has_generation_data || 0) > 0 ? 1 : 0;
const size = Number(asset?.size || 0);
const mtime = Number(asset?.mtime || 0);
// Return array for comparison: [kindPriority, mtime, hasGeneration, size]
return [kindPriority, mtime, hasGeneration, size];
} catch {
return [0, 0, 0, 0];
}
}
/**
* Compare two priority tuples. Returns > 0 if a > b.
*/
function comparePriority(a: any, b: any) {
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (a[i] || 0) - (b[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
/**
* Select the best representative from a list of assets.
* Prefers video with audio > video > image, then by mtime/generation/size.
*/
export function selectStackRepresentative(assets: unknown[]): any {
if (!Array.isArray(assets) || assets.length === 0) return null;
if (assets.length === 1) return assets[0];
let best = assets[0];
let bestPriority = getStackRepPriority(best);
for (let i = 1; i < assets.length; i++) {
const asset = assets[i];
const priority = getStackRepPriority(asset);
if (comparePriority(priority, bestPriority) > 0) {
best = asset;
bestPriority = priority;
}
}
return best;
}
export function preserveRepresentativeGenerationTime(primary: any, members: any[]): any {
if (!primary || !Array.isArray(members) || members.length === 0) return primary;
const current =
Number(primary?.generation_time_ms ?? primary?.metadata?.generation_time_ms ?? 0) || 0;
if (current > 0) return primary;
const donor = members.find(
(asset) =>
(Number(asset?.generation_time_ms ?? asset?.metadata?.generation_time_ms ?? 0) || 0) >
0,
);
if (!donor) return primary;
const donorMs =
Number(donor?.generation_time_ms ?? donor?.metadata?.generation_time_ms ?? 0) || 0;
if (donorMs <= 0) return primary;
primary.generation_time_ms = donorMs;
if (!primary.has_generation_data && donor?.has_generation_data) {
primary.has_generation_data = donor.has_generation_data;
}
return primary;
}
export function detectKind(asset: any, extUpper: string): string {
const k = String(asset?.kind || "").toLowerCase();
if (k) return k;
const images = new Set(["PNG", "JPG", "JPEG", "WEBP", "GIF", "BMP", "TIF", "TIFF"]);
const videos = new Set(["MP4", "WEBM", "MOV", "AVI", "MKV"]);
const audio = new Set(["MP3", "WAV", "OGG", "FLAC"]);
// 3D model formats added by ComfyUI v1.42 (load3d extension)
const model3d = new Set(["OBJ", "FBX", "GLB", "GLTF", "STL", "PLY", "SPLAT", "KSPLAT", "SPZ"]);
if (images.has(extUpper)) return "image";
if (videos.has(extUpper)) return "video";
if (audio.has(extUpper)) return "audio";
if (model3d.has(extUpper)) return "model3d";
return "unknown";
}
export function isHidePngSiblingsEnabled(loadMajoorSettings: () => Record<string, any>): boolean {
try {
const s = loadMajoorSettings();
return !!s?.siblings?.hidePngSiblings;
} catch {
return false;
}
}
export function setCollisionTooltip(card: HTMLElement | null, filename: string, count: number | null): void {
if (!card || count == null) return;
const n = Number(count) || 0;
if (n < 2) return;
try {
const row = card.querySelector?.(".mjr-card-filename");
if (row) {
(row as HTMLElement).title = `${String(filename || "")}\nName collision: ${n} items in this view`;
return;
}
} catch (e: any) {
console.debug?.(e);
}
try {
card.title = `Name collision: ${n} items in this view`;
} catch (e: any) {
console.debug?.(e);
}
}
export function buildCollisionPaths(bucket: any[] | null): string[] {
const out = [];
const seen = new Set();
for (const asset of bucket || []) {
const raw =
asset?.filepath ||
asset?.path ||
(asset?.subfolder
? `${asset.subfolder}/${asset.filename || ""}`
: `${asset?.filename || ""}`);
const p = String(raw || "").trim();
if (!p) continue;
const key = p.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(p);
}
return out;
}
function getSiblingContextKey(asset: any) {
const source = String(asset?.source || asset?.type || "")
.trim()
.toLowerCase();
const rootId = String(asset?.root_id || asset?.custom_root_id || "")
.trim()
.toLowerCase();
const subfolder = String(asset?.subfolder || "")
.trim()
.toLowerCase();
return `${source}|${rootId}|${subfolder}`;
}
function getFilenameCollisionKey(asset: any) {
const filename = getFilenameKey(asset?.filename);
if (!filename) return "";
return `${getSiblingContextKey(asset)}|${filename}`;
}
function getSiblingMatchKey(asset: any, extUpper = getExtUpper(asset?.filename || "")) {
const kind = detectKind(asset, extUpper);
const filename = String(asset?.filename || "").trim();
if (!filename) return "";
const ctx = getSiblingContextKey(asset);
if (kind === "model3d") {
return `${ctx}|model3d|${filename.toLowerCase()}`;
}
const stem = getStemLower(filename);
if (!stem) return "";
return `${ctx}|media|${stem}`;
}
function ensureSiblingState(state: any) {
const nonImageSiblingKeys = state.nonImageSiblingKeys || new Set();
state.nonImageSiblingKeys = nonImageSiblingKeys;
const stemMap = state.stemMap || new Map();
state.stemMap = stemMap;
const assetIdSet = state.assetIdSet || new Set();
state.assetIdSet = assetIdSet;
const seenKeys = state.seenKeys || new Set();
state.seenKeys = seenKeys;
if (state.hiddenPngSiblings == null) state.hiddenPngSiblings = 0;
return { nonImageSiblingKeys, stemMap, assetIdSet, seenKeys };
}
export function rebuildAssetRendererState(
state: any,
assets: any[] = [],
{ assetKey = null, preserveHiddenCount = false }: any = {},
): void {
const previousHiddenCount = Number(state?.hiddenPngSiblings || 0) || 0;
state.seenKeys = new Set();
state.assetIdSet = new Set();
state.filenameCounts = new Map();
state.nonImageSiblingKeys = new Set();
state.stemMap = new Map();
state.renderedFilenameMap = new Map();
state.hiddenPngSiblings = preserveHiddenCount ? previousHiddenCount : 0;
if (typeof assetKey === "function") state.assetKeyFn = assetKey;
const siblingMaps = ensureSiblingState(state);
for (const asset of Array.isArray(assets) ? assets : []) {
if (!asset || typeof asset !== "object") continue;
try {
const id = asset?.id != null ? String(asset.id) : "";
if (id) siblingMaps.assetIdSet.add(id);
} catch (e: any) {
console.debug?.(e);
}
try {
const key = typeof assetKey === "function"
? assetKey(asset)
: state?.assetKeyFn?.(asset);
if (key) siblingMaps.seenKeys.add(key);
} catch (e: any) {
console.debug?.(e);
}
try {
const filenameKey = getFilenameCollisionKey(asset);
if (filenameKey) {
state.filenameCounts.set(
filenameKey,
(Number(state.filenameCounts.get(filenameKey) || 0) || 0) + 1,
);
}
} catch (e: any) {
console.debug?.(e);
}
try {
const extUpper = getExtUpper(asset?.filename || "");
const matchKey = getSiblingMatchKey(asset, extUpper);
if (!matchKey) continue;
let list = siblingMaps.stemMap.get(matchKey);
if (!list) {
list = [];
siblingMaps.stemMap.set(matchKey, list);
}
list.push(asset);
const kind = detectKind(asset, extUpper);
if (kind === "video" || kind === "audio" || kind === "model3d" || extUpper === "WEBP") {
siblingMaps.nonImageSiblingKeys.add(matchKey);
}
} catch (e: any) {
console.debug?.(e);
}
}
}
export function unregisterHiddenSibling(state: any, removed: any, siblingMaps: any): void {
try {
if (removed?.id != null) siblingMaps.assetIdSet.delete(String(removed.id));
} catch (e: any) {
console.debug?.(e);
}
try {
const removedKey = state?.assetKeyFn?.(removed);
if (removedKey) siblingMaps.seenKeys.delete(removedKey);
} catch (e: any) {
console.debug?.(e);
}
}
function removeExistingHiddenSiblings(state: any, matchKey: any, siblingMaps: any, predicate: any) {
const list = siblingMaps.stemMap.get(matchKey);
if (!list?.length) return [];
const removed = [];
for (let i = list.length - 1; i >= 0; i--) {
if (!predicate(list[i])) continue;
removed.push(list[i]);
list.splice(i, 1);
}
if (!list.length) siblingMaps.stemMap.delete(matchKey);
return removed;
}
export function shouldHideSiblingAsset(asset: any, state: any, loadMajoorSettings: () => Record<string, any>): { hidden: boolean; hideEnabled: boolean; removed: unknown[] } {
const hidePngSiblings = isHidePngSiblingsEnabled(loadMajoorSettings);
if (!hidePngSiblings) return { hidden: false, hideEnabled: false, removed: [] };
const siblingMaps = ensureSiblingState(state);
const filename = String(asset?.filename || "");
const extUpper = getExtUpper(filename);
const kind = detectKind(asset, extUpper);
const matchKey = getSiblingMatchKey(asset, extUpper);
if (!matchKey) return { hidden: false, hideEnabled: true, removed: [] };
if (kind === "video" || kind === "audio" || kind === "model3d" || extUpper === "WEBP") {
siblingMaps.nonImageSiblingKeys.add(matchKey);
const removed = removeExistingHiddenSiblings(
state,
matchKey,
siblingMaps,
(item: any) => getExtUpper(item?.filename || "") === "PNG",
);
return { hidden: false, hideEnabled: true, removed };
}
if (extUpper === "PNG") {
const model3dPngKey = `${getSiblingContextKey(asset)}|model3d|${getStemLower(filename)}`;
if (
siblingMaps.nonImageSiblingKeys.has(matchKey) ||
siblingMaps.nonImageSiblingKeys.has(model3dPngKey)
) {
return { hidden: true, hideEnabled: true, removed: [] };
}
}
return { hidden: false, hideEnabled: true, removed: [] };
}
export function appendAssets(gridContainer: any, assets: any[], state: any, deps: any): any {
const hidePngSiblings = isHidePngSiblingsEnabled(deps.loadMajoorSettings);
const filenameCounts = state.filenameCounts || new Map();
state.filenameCounts = filenameCounts;
deps.clearGridMessage(gridContainer);
const vg = deps.ensureVirtualGrid(gridContainer, state);
if (!vg) return 0;
if (!hidePngSiblings) state.hiddenPngSiblings = 0;
state.assetKeyFn = deps.assetKey;
const siblingMaps = ensureSiblingState(state);
const filenameToAssets = new Map();
for (const existing of state.assets || []) {
const key = getFilenameCollisionKey(existing);
if (!key) continue;
let list = filenameToAssets.get(key);
if (!list) {
list = [];
filenameToAssets.set(key, list);
}
list.push(existing);
}
let addedCount = 0;
let needsUpdate = false;
const validNewAssets = [];
const assetsToRemoveFromState = new Set();
// Only maintain per-key counts here. Collapsing duplicates into one card
// is a display concern owned by `buildDisplayAssets` (Vue grid). Removing
// the copies from `state.assets` and tagging a primary here fought with
// that pass (each reset the other's markers on reactive objects) and made
// the stack membership depend on paging order.
const applyFilenameCollisions = () => {
try {
const visibleIds = new Set(
(Array.isArray(state.assets) ? state.assets : [])
.map((a: any) => String(a?.id || ""))
.filter(Boolean),
);
for (const [key, bucket] of filenameToAssets.entries()) {
const count = (Array.isArray(bucket) ? bucket : []).filter((asset: any) => {
const id = String(asset?.id || "");
return id ? visibleIds.has(id) : false;
}).length;
filenameCounts.set(key, count);
}
} catch (e: any) {
console.debug?.(e);
}
};
for (const asset of assets || []) {
try {
if (asset?.id == null || String(asset.id).trim() === "") {
const kindLower = String(asset?.kind || "").toLowerCase();
const fp = String(asset?.filepath || "").trim();
const sub = String(asset?.subfolder || "").trim();
const name = String(asset?.filename || "").trim();
const type = String(asset?.type || "")
.trim()
.toLowerCase();
const base = `${type}|${kindLower}|${fp}|${sub}|${name}`;
asset.id = `asset:${base || "unknown"}`;
}
} catch (e: any) {
console.debug?.(e);
}
const filename = String(asset?.filename || "");
const extUpper = getExtUpper(filename);
const match = shouldHideSiblingAsset(asset, state, deps.loadMajoorSettings);
for (const removed of match.removed || []) assetsToRemoveFromState.add(removed);
if (match.hidden) {
state.hiddenPngSiblings += 1;
continue;
}
const fnKey = getFilenameCollisionKey(asset);
if (fnKey) {
let bucket = filenameToAssets.get(fnKey);
if (!bucket) {
bucket = [];
filenameToAssets.set(fnKey, bucket);
}
bucket.push(asset);
}
const key = deps.assetKey(asset);
if (!key) continue;
if (siblingMaps.seenKeys.has(key)) continue;
if (asset.id != null && siblingMaps.assetIdSet.has(String(asset.id))) continue;
siblingMaps.seenKeys.add(key);
if (asset.id != null) siblingMaps.assetIdSet.add(String(asset.id));
validNewAssets.push(asset);
const matchKey = getSiblingMatchKey(asset, extUpper);
if (matchKey) {
let list = siblingMaps.stemMap.get(matchKey);
if (!list) {
list = [];
siblingMaps.stemMap.set(matchKey, list);
}
list.push(asset);
}
addedCount++;
}
if (assetsToRemoveFromState.size > 0) {
state.hiddenPngSiblings += assetsToRemoveFromState.size;
state.assets = state.assets.filter((a: any) => !assetsToRemoveFromState.has(a));
for (let i = validNewAssets.length - 1; i >= 0; i--) {
if (!assetsToRemoveFromState.has(validNewAssets[i])) continue;
validNewAssets.splice(i, 1);
addedCount = Math.max(0, addedCount - 1);
}
for (const removed of assetsToRemoveFromState) {
unregisterHiddenSibling(state, removed, siblingMaps);
}
try {
for (const removed of assetsToRemoveFromState) {
const key = getFilenameCollisionKey(removed);
if (!key) continue;
const bucket = filenameToAssets.get(key);
if (!bucket) continue;
const idx = bucket.indexOf(removed);
if (idx > -1) bucket.splice(idx, 1);
if (!bucket.length) filenameToAssets.delete(key);
}
} catch (e: any) {
console.debug?.(e);
}
needsUpdate = true;
}
if (validNewAssets.length > 0) {
state.assets.push(...validNewAssets);
needsUpdate = true;
}
if (needsUpdate) {
applyFilenameCollisions();
vg.setItems(state.assets);
if (state.sentinel) {
gridContainer.appendChild(state.sentinel);
}
}
try {
gridContainer.dataset.mjrHidePngSiblingsEnabled = hidePngSiblings ? "1" : "0";
gridContainer.dataset.mjrHiddenPngSiblings = String(
Number(state.hiddenPngSiblings || 0) || 0,
);
} catch (e: any) {
console.debug?.(e);
}
return addedCount;
}
export function updateGridSettingsClasses(container: any, deps: any): any {
return deps.updateGridSettingsClasses(container);
}