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).
This commit is contained in:
@@ -30,6 +30,11 @@ All notable changes to this project are documented in this file.
|
||||
### Fixed
|
||||
- **`mjr_am_backend.adapters.tools` missing from Registry installs**: `.comfyignore`'s unanchored `tools/` pattern also matched `mjr_am_backend/adapters/tools/`, stripping the ExifTool/FFProbe adapters out of every Registry-published package and causing `ModuleNotFoundError: No module named 'mjr_am_backend.adapters.tools'` at startup. The pattern is now anchored to the repo-root `/tools/` dev-scripts folder only.
|
||||
|
||||
### Fixed (duplicate stacks)
|
||||
- **Clicking a duplicate-stack badge froze ComfyUI**: three passes (`displayAssets`, the per-card ref callback `syncRenderedDuplicateCards`, the live-upsert dedupe) each rewrote `_mjrDupStack` / `_mjrDupMembers` on reactive assets read by the grid template, with a fresh array on every pass and conflicting representatives. The ref callback ran on every render (a new closure each time), so every write scheduled another render: an endless update loop (production Vue has no recursion limit). Duplicate stacks are now computed in one place with change-guarded writes, card refs are stable, and the other passes no longer touch these fields.
|
||||
- **"Corrupted" duplicate stacks**: stacks were also built from the file name alone across folders, so unrelated files sharing a name (e.g. frame grabs `at-7.958s.png` from different videos) were stacked, hidden from the grid and given another file's generation time. A duplicate is now the same name in the same source/root/subfolder only.
|
||||
- **Duplicates view collapsed back to one card**: the "Duplicates - N copies" view re-grouped its own members; it now lists every copy, using cycle-free copies of the members.
|
||||
|
||||
### Fixed (asset sync)
|
||||
- **Renamed / moved files lost their ratings, tags and collections**: the `assets_fts` triggers ran a plain `UPDATE`/`DELETE` on an external-content FTS5 table, so every filename/subfolder change failed with "database disk image is malformed" (and could start a needless DB recovery). The watcher then fell back to delete + re-index, creating a new asset id. Triggers now use FTS5's `'delete'` command (update trigger limited to `filename, subfolder`); existing databases are repaired and the FTS index rebuilt automatically at startup.
|
||||
- **Outputs missed after a generation**: ComfyUI calls `on_prompt_end` before it stores the prompt history, so post-execution ingestion could read an empty history and index nothing (prompts queued from the API, another tab or another device). Ingestion now waits briefly for the history entry.
|
||||
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
import { $t as e, g as t, y as n } from "./viewerRuntimeHosts-CkUqwo6l.js";
|
||||
import { C as r, H as i, I as a, M as o, O as s, P as c, V as l, at as u, j as d, m as f, o as p, r as m, v as h, w as g, x as _, z as v } from "./events-B9CSrqJ_.js";
|
||||
import { a as y, i as b, o as x, s as S } from "./graphTraversal-DTdQWkn5.js";
|
||||
import { _ as C, g as w, m as T, n as E, p as ee, r as D, w as O } from "./Viewer-BEptWhbV.js";
|
||||
import { r as k, v as A } from "./SidebarWorkflowSection-BjUgGET_.js";
|
||||
import { _ as j, a as M, c as N, d as P, f as F, g as I, h as L, i as te, l as R, m as z, o as ne, p as B, r as re, s as V, t as ie, u as H, v as U } from "./openMajoorSettings-BZtL8v_l.js";
|
||||
import { _ as C, g as w, m as T, n as E, p as ee, r as D, w as O } from "./Viewer-DCWIIJQl.js";
|
||||
import { r as k, v as A } from "./SidebarWorkflowSection-BMRq92Vq.js";
|
||||
import { _ as j, a as M, c as N, d as P, f as F, g as I, h as L, i as te, l as R, m as z, o as ne, p as B, r as re, s as V, t as ie, u as H, v as U } from "./openMajoorSettings-D92RuxBO.js";
|
||||
import { a as W, n as ae, r as oe } from "./model3dRenderer-BsJ9YHaZ.js";
|
||||
import { i as se, o as ce, r as le, t as ue } from "./geninfoParser-CgHp-y-n.js";
|
||||
import { t as de } from "./genInfo-C3ZF_agc.js";
|
||||
import { t as de } from "./genInfo-B60U6rEQ.js";
|
||||
// ui/features/viewer/floatingViewerConstants.ts
|
||||
var G = Object.freeze({
|
||||
SIMPLE: "simple",
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Z as e, o as t, r as n } from "./events-B9CSrqJ_.js";
|
||||
import { t as r } from "./floatingViewerManager-DI520cRz.js";
|
||||
import { t as r } from "./floatingViewerManager-B_1xQj1m.js";
|
||||
// ui/features/viewer/LiveStreamTracker.ts
|
||||
var i = !1, a = null, o = null, s = null, c = null, l = null, u = null, d = null, f = null, p = 0, m = 0, h = !1, g = 400, _ = "kj_preview_override", v = /* @__PURE__ */ new Set([
|
||||
"image/jpeg",
|
||||
Vendored
+12
-12
@@ -1,10 +1,10 @@
|
||||
import { $ as e, $t as t, At as n, Bt as r, Ct as i, Dt as a, Et as o, Ft as s, Gt as c, Ht as l, I as u, It as d, K as f, Kt as p, Mt as m, N as h, Nt as g, O as _, Ot as v, Pt as y, Q as b, Qt as x, R as S, Rt as C, S as ee, St as te, T as ne, Tt as w, Ut as T, Vt as re, X as ie, Y as E, Yt as ae, Z as oe, Zt as se, at as ce, cn as le, ct as ue, dt as de, et as D, gt as fe, it as pe, jt as me, k as he, kt as ge, ln as _e, lt as ve, nt as ye, ot as be, p as xe, rt as Se, tn as Ce, tt as we, ut as Te, w as Ee, wt as De } from "./viewerRuntimeHosts-CkUqwo6l.js";
|
||||
import { G as Oe, J as ke, N as Ae, R as je, T as Me, Tt as Ne, X as Pe, c as Fe, d as Ie, f as Le, h as Re, ht as ze, j as Be, l as Ve, m as O, o as k, p as He, rt as Ue, s as A, u as We, x as Ge, y as Ke } from "./events-B9CSrqJ_.js";
|
||||
import { F as qe, K as Je, P as Ye, Y as Xe, f as Ze, m as Qe, p as $e } from "./Viewer-BEptWhbV.js";
|
||||
import { t as et } from "./floatingViewerManager-DI520cRz.js";
|
||||
import { F as qe, G as Je, J as Ye, P as Xe, f as Ze, m as Qe, p as $e } from "./Viewer-DCWIIJQl.js";
|
||||
import { t as et } from "./floatingViewerManager-B_1xQj1m.js";
|
||||
import { A as tt, C as j, D as nt, E as M, G as rt, J as it, K as at, N as ot, O as N, R as st, S as ct, T as P, U as F, V as I, Y as lt, _ as ut, a as dt, b as ft, c as pt, d as mt, dt as L, f as ht, ft as R, g as gt, h as _t, i as vt, j as yt, k as z, l as bt, lt as B, m as xt, n as St, nt as Ct, o as wt, p as Tt, r as Et, rt as V, s as Dt, t as Ot, u as kt, ut as At, y as jt, z as Mt } from "./mjr-primevue-BRqtdXzb.js";
|
||||
import { t as Nt } from "./mjr-vue-vendor-mHY_eIVE.js";
|
||||
import { t as Pt } from "./viewerOpenRequest-B8IT-KHd.js";
|
||||
import { t as Pt } from "./viewerOpenRequest-DaR0P9Y2.js";
|
||||
import { a as Ft, i as It, n as Lt, o as Rt, r as zt, t as Bt } from "./geninfoParser-CgHp-y-n.js";
|
||||
// ui/app/settings/settingsUtils.ts
|
||||
var H = (e, t) => {
|
||||
@@ -251,7 +251,7 @@ var H = (e, t) => {
|
||||
console.debug?.(e);
|
||||
}
|
||||
try {
|
||||
Xe("mjr-settings-save-failed", { error: String(e?.message || e || "") }, { warnPrefix: "[Majoor]" });
|
||||
Ye("mjr-settings-save-failed", { error: String(e?.message || e || "") }, { warnPrefix: "[Majoor]" });
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
@@ -318,7 +318,7 @@ async function Zt() {
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
G(n), K(n), Xe("mjr-settings-changed", { key: "security" }, { warnPrefix: "[Majoor]" });
|
||||
G(n), K(n), Ye("mjr-settings-changed", { key: "security" }, { warnPrefix: "[Majoor]" });
|
||||
} catch (e) {
|
||||
console.warn("[Majoor] failed to sync backend security settings", e);
|
||||
}
|
||||
@@ -330,7 +330,7 @@ async function Qt() {
|
||||
let t = e.data?.prefs;
|
||||
if (!t || typeof t != "object") return;
|
||||
let n = Xt();
|
||||
n.ai = n.ai || {}, n.ai.vectorSearchEnabled = H(t.enabled, n.ai.vectorSearchEnabled ?? !0), n.ai.vectorCaptionOnIndex = H(t.caption_on_index ?? t.captionOnIndex, n.ai.vectorCaptionOnIndex ?? !1), n.ai.vectorIndexOnScan = H(t.index_on_scan ?? t.indexOnScan, n.ai.vectorIndexOnScan ?? !1), n.ai.vectorUnloadAfterUse = H(t.unload_after_use ?? t.unloadAfterUse, n.ai.vectorUnloadAfterUse ?? !1), n.ai.vectorConcurrency = Math.max(1, Math.min(16, Math.floor(Number(t.concurrency ?? n.ai.vectorConcurrency ?? 1) || 1))), G(n), K(n), Xe("mjr-settings-changed", { key: "ai.vectorSearch" }, { warnPrefix: "[Majoor]" });
|
||||
n.ai = n.ai || {}, n.ai.vectorSearchEnabled = H(t.enabled, n.ai.vectorSearchEnabled ?? !0), n.ai.vectorCaptionOnIndex = H(t.caption_on_index ?? t.captionOnIndex, n.ai.vectorCaptionOnIndex ?? !1), n.ai.vectorIndexOnScan = H(t.index_on_scan ?? t.indexOnScan, n.ai.vectorIndexOnScan ?? !1), n.ai.vectorUnloadAfterUse = H(t.unload_after_use ?? t.unloadAfterUse, n.ai.vectorUnloadAfterUse ?? !1), n.ai.vectorConcurrency = Math.max(1, Math.min(16, Math.floor(Number(t.concurrency ?? n.ai.vectorConcurrency ?? 1) || 1))), G(n), K(n), Ye("mjr-settings-changed", { key: "ai.vectorSearch" }, { warnPrefix: "[Majoor]" });
|
||||
} catch (e) {
|
||||
console.warn("[Majoor] failed to sync backend vector search settings", e);
|
||||
}
|
||||
@@ -342,7 +342,7 @@ async function $t() {
|
||||
let t = e.data?.prefs;
|
||||
if (!t || typeof t != "object") return;
|
||||
let n = Xt();
|
||||
n.executionGrouping = n.executionGrouping || {}, n.executionGrouping.enabled = H(t.enabled, n.executionGrouping.enabled ?? A.EXECUTION_GROUPING_ENABLED), G(n), K(n), Xe("mjr-settings-changed", { key: "executionGrouping.enabled" }, { warnPrefix: "[Majoor]" });
|
||||
n.executionGrouping = n.executionGrouping || {}, n.executionGrouping.enabled = H(t.enabled, n.executionGrouping.enabled ?? A.EXECUTION_GROUPING_ENABLED), G(n), K(n), Ye("mjr-settings-changed", { key: "executionGrouping.enabled" }, { warnPrefix: "[Majoor]" });
|
||||
} catch (e) {
|
||||
console.warn("[Majoor] failed to sync backend execution grouping settings", e);
|
||||
}
|
||||
@@ -2602,12 +2602,12 @@ function Mr(e, t, { initRuntime: n = !1 } = {}) {
|
||||
if (!i.size) return;
|
||||
let e = Array.from(i);
|
||||
i.clear();
|
||||
for (let t of e) Xe("mjr-settings-changed", { key: t }, { warnPrefix: "[Majoor]" });
|
||||
for (let t of e) Ye("mjr-settings-changed", { key: t }, { warnPrefix: "[Majoor]" });
|
||||
}, s = () => {
|
||||
if (!a.size) return;
|
||||
let e = Array.from(a);
|
||||
a.clear();
|
||||
for (let t of e) Xe("mjr-settings-changed", { key: t }, { warnPrefix: "[Majoor]" });
|
||||
for (let t of e) Ye("mjr-settings-changed", { key: t }, { warnPrefix: "[Majoor]" });
|
||||
}, c = fn(o, 120), l = fn(s, 450), u = (e) => {
|
||||
typeof e == "string" && i.add(e), c();
|
||||
}, d = (e) => {
|
||||
@@ -2672,7 +2672,7 @@ try {
|
||||
let e = Xt();
|
||||
e?.watcher && typeof e.watcher.enabled == "boolean" && Te().then((e) => {
|
||||
let t = !!e?.ok && !!e?.data?.enabled, n = Xt();
|
||||
n.watcher = n.watcher || {}, typeof t == "boolean" && t !== !!n.watcher.enabled && (n.watcher.enabled = t, G(n), Xe("mjr-settings-changed", { key: "watcher.enabled" }, { warnPrefix: "[Majoor]" }));
|
||||
n.watcher = n.watcher || {}, typeof t == "boolean" && t !== !!n.watcher.enabled && (n.watcher.enabled = t, G(n), Ye("mjr-settings-changed", { key: "watcher.enabled" }, { warnPrefix: "[Majoor]" }));
|
||||
}).catch(() => {});
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
@@ -4256,7 +4256,7 @@ var Xa = {
|
||||
label: "Generation Time",
|
||||
value: `${(Number(h) / 1e3).toFixed(1)}s`,
|
||||
tooltip: "Time taken to generate this asset (workflow execution time)",
|
||||
valueStyle: `color: ${Ye(h)}; font-weight: 600;`
|
||||
valueStyle: `color: ${Xe(h)}; font-weight: 600;`
|
||||
});
|
||||
let g = e.generation_time || e.file_creation_time || e.mtime || e.created_at;
|
||||
if (g) {
|
||||
@@ -5171,7 +5171,7 @@ var Vo = ["title"], Ho = ["src"], Uo = {
|
||||
setup(e) {
|
||||
let n = e, r = V(0), i = V(!1), a = null;
|
||||
function o() {
|
||||
return a ||= import("./floatingViewerManager-DI520cRz.js").then((e) => e.n), a;
|
||||
return a ||= import("./floatingViewerManager-B_1xQj1m.js").then((e) => e.n), a;
|
||||
}
|
||||
function s() {
|
||||
return (Array.isArray(n.inputFile?.previewCandidates) ? n.inputFile.previewCandidates : [])[r.value] || "";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { $t as e, F as t, v as n } from "./viewerRuntimeHosts-CkUqwo6l.js";
|
||||
import { m as r, n as i, wt as a } from "./events-B9CSrqJ_.js";
|
||||
import { Y as o } from "./Viewer-BEptWhbV.js";
|
||||
import { J as o } from "./Viewer-DCWIIJQl.js";
|
||||
import { A as s, C as c, E as l, G as u, J as d, K as f, L as p, N as m, R as ee, T as h, U as te, V as g, Y as _, b as ne, ft as v, j as y, k as b, lt as x, rt as S, ut as C, z as w } from "./mjr-primevue-BRqtdXzb.js";
|
||||
// ui/vue/components/common/TagsEditor.vue?vue&type=script&setup=true&lang.ts
|
||||
var T = ["aria-busy"], re = ["aria-label"], ie = {
|
||||
+1137
-1214
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { a as e, i as t } from "./viewerRuntimeHosts-CkUqwo6l.js";
|
||||
import { r as n } from "./events-B9CSrqJ_.js";
|
||||
import { a as r, c as i, i as a, l as o, o as s, s as c, t as l, u } from "./Viewer-BEptWhbV.js";
|
||||
import { i as d, r as f } from "./floatingViewerManager-DI520cRz.js";
|
||||
import { a as r, c as i, i as a, l as o, o as s, s as c, t as l, u } from "./Viewer-DCWIIJQl.js";
|
||||
import { i as d, r as f } from "./floatingViewerManager-B_1xQj1m.js";
|
||||
import { B as p, C as m, D as h, E as g, G as _, J as v, K as y, L as b, N as x, O as S, T as C, U as w, V as T, dt as E, ft as D, j as O, k, lt as A, rt as j, ut as M, w as N, z as P } from "./mjr-primevue-BRqtdXzb.js";
|
||||
import { t as F } from "./TagsEditor-gpBsG4si.js";
|
||||
import { t as F } from "./TagsEditor-BydzUbtv.js";
|
||||
// ui/vue/components/viewer/FloatingViewerHost.vue
|
||||
var I = /* @__PURE__ */ x({
|
||||
__name: "FloatingViewerHost",
|
||||
Vendored
+1
-1
@@ -96,7 +96,7 @@ var te = /* @__PURE__ */ e({
|
||||
teardownFloatingViewerManager: () => we
|
||||
}), v = null, y = null;
|
||||
async function ne() {
|
||||
return v || (y ||= import("./FloatingViewer-Bw0UgjBJ.js").then((e) => (v = e.FloatingViewer, v)), y);
|
||||
return v || (y ||= import("./FloatingViewer-DjWZOB3Z.js").then((e) => (v = e.FloatingViewer, v)), y);
|
||||
}
|
||||
var b = null, re = null;
|
||||
async function ie() {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
|
||||
import { m as t, o as n } from "./events-B9CSrqJ_.js";
|
||||
import { h as r } from "./Viewer-BEptWhbV.js";
|
||||
import { i, n as a, r as o, t as s, u as c } from "./SidebarWorkflowSection-BjUgGET_.js";
|
||||
import { h as r } from "./Viewer-DCWIIJQl.js";
|
||||
import { i, n as a, r as o, t as s, u as c } from "./SidebarWorkflowSection-BMRq92Vq.js";
|
||||
import { D as l, E as u, N as d, O as f, T as p, V as m, dt as h, ft as g, k as _, lt as v } from "./mjr-primevue-BRqtdXzb.js";
|
||||
// ui/vue/components/viewer/ViewerMetadataBlock.vue?vue&type=script&setup=true&lang.ts
|
||||
var y = { style: {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { A as e, E as t, S as n, Z as r, b as i, j as a, m as o, z as s } from "./events-B9CSrqJ_.js";
|
||||
import { a as c, n as l } from "./graphTraversal-DTdQWkn5.js";
|
||||
import { S as u, Y as d, b as f, x as p, y as m } from "./SidebarWorkflowSection-BjUgGET_.js";
|
||||
import { S as u, Y as d, b as f, x as p, y as m } from "./SidebarWorkflowSection-BMRq92Vq.js";
|
||||
// ui/features/viewer/floatingViewerProgress.ts
|
||||
var h = "progress-update", g = "__MJR_MFV_PROGRESS_SERVICE__";
|
||||
function ee() {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
|
||||
import { r as t } from "./events-B9CSrqJ_.js";
|
||||
import { t as n } from "./Viewer-BEptWhbV.js";
|
||||
import { t as n } from "./Viewer-DCWIIJQl.js";
|
||||
// ui/features/viewer/viewerOpenRequest.ts
|
||||
var r = /* @__PURE__ */ e({ requestViewerOpen: () => o });
|
||||
function i(e) {
|
||||
Vendored
+2244
-2214
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,34 @@ Pistes restantes :
|
||||
- retirer aussi du flux de génération les éléments supprimés hors de l'interface ;
|
||||
- un seul événement par nouvel asset au lieu de `mjr-asset-added` plus `mjr.asset.indexed` (chacun doublé par l'enveloppe `mjr.event`).
|
||||
|
||||
## 3 quater. Piles de doublons : gel de ComfyUI et piles « corrompues »
|
||||
|
||||
**Symptôme.** Un clic sur le badge de doublons (icône de copie suivie d'un nombre) gèle tout l'onglet ComfyUI ; certaines piles empilent des images qui n'ont rien à voir entre elles.
|
||||
|
||||
**Cause du gel : une boucle de rendu Vue infinie.**
|
||||
1. Le template de `VirtualAssetGridHost.vue` lit `asset._mjrDupStack` et `asset._mjrDupCount`, et passe `:ref="getCardRef(asset)"`, qui crée une nouvelle fonction à chaque rendu. Vue rappelle donc les refs à chaque rendu (`null`, puis l'élément).
|
||||
2. `bindCardRef` appelait `syncRenderedDuplicateCards`, qui remettait `_mjrDupStack` à `false` sur tous les membres puis à `true` sur une carte principale, et assignait un nouveau tableau `_mjrDupMembers`.
|
||||
3. En parallèle, le `computed` `displayAssets` faisait l'inverse, avec une autre carte principale (`bucket[0]` contre `selectStackRepresentative`).
|
||||
4. Chaque écriture relançait le rendu du parent, donc les refs, donc de nouvelles écritures. En production, Vue n'a pas de limite de récursion : l'onglet tourne à l'infini.
|
||||
|
||||
La vue « doublons » déclenchait la boucle à coup sûr : elle affiche N cartes de même nom, aussitôt re-regroupées. Un test le reproduit : **31 rendus en 5 passages** avec l'ancien code, contre zéro rendu supplémentaire après correctif.
|
||||
|
||||
**Cause de la « corruption ».** Quatre définitions du doublon coexistaient :
|
||||
- `AssetCardRenderer` et `displayAssets` : même nom dans le même source / racine / sous-dossier ;
|
||||
- `useVirtualGrid.dedupeAssetsByKey` et `syncRenderedDuplicateCards` : **nom seul, tous dossiers confondus**.
|
||||
|
||||
Ces deux derniers retiraient de `state.assets` des fichiers distincts d'autres dossiers et les rangeaient sous une mauvaise carte. Celle-ci recevait au passage le temps de génération d'une autre image (`preserveRepresentativeGenerationTime`).
|
||||
|
||||
**Correctif**
|
||||
- `buildDisplayAssets` est la seule source de vérité. Ses écritures sont gardées (assignation seulement si la valeur brute change, tableau des membres conservé tant que la composition ne change pas), avec une option `collapseDuplicates`.
|
||||
- `syncRenderedDuplicateCards` ne touche plus l'état réactif.
|
||||
- Une seule fonction de ref par carte (`getCardRef`).
|
||||
- `dedupeAssetsByKey` ne retire plus que les vrais doublons (même id ou même clé).
|
||||
- `AssetCardRenderer.appendAssets` ne retire plus de copies : il ne fait que compter.
|
||||
- La vue « doublons » (scope `similar`) liste toutes les copies, avec des membres copiés sans cycle (`toDuplicateGroupMembers`).
|
||||
|
||||
**À suivre.** Une pile « même dossier, même nom » correspond à plusieurs lignes en base pour un même fichier. C'est possible avec des index créés par d'anciennes versions, par exemple des variantes de casse sous Windows : les insertions actuelles normalisent le chemin. Une fusion de ces lignes, qui conserverait notes, tags et collections, reste à faire comme opération de maintenance explicite.
|
||||
|
||||
## 4. Feuille de route d'évolution (meilleure intégration)
|
||||
|
||||
1. **Progression par nœud** — écouter l'événement websocket `progress_state`
|
||||
|
||||
@@ -384,6 +384,11 @@ export function appendAssets(gridContainer: any, assets: any[], state: any, deps
|
||||
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(
|
||||
@@ -392,101 +397,11 @@ export function appendAssets(gridContainer: any, assets: any[], state: any, deps
|
||||
.filter(Boolean),
|
||||
);
|
||||
for (const [key, bucket] of filenameToAssets.entries()) {
|
||||
const visibleBucket = (Array.isArray(bucket) ? bucket : []).filter((asset: any) => {
|
||||
const count = (Array.isArray(bucket) ? bucket : []).filter((asset: any) => {
|
||||
const id = String(asset?.id || "");
|
||||
return id ? visibleIds.has(id) : false;
|
||||
});
|
||||
const count = visibleBucket.length;
|
||||
}).length;
|
||||
filenameCounts.set(key, count);
|
||||
|
||||
if (count < 2) {
|
||||
// Clear any stale dup-stack markers (count dropped back to 1)
|
||||
for (const asset of visibleBucket) {
|
||||
asset._mjrNameCollision = false;
|
||||
delete asset._mjrNameCollisionCount;
|
||||
delete asset._mjrNameCollisionPaths;
|
||||
if (asset._mjrDupStack) {
|
||||
asset._mjrDupStack = false;
|
||||
asset._mjrDupMembers = null;
|
||||
asset._mjrDupCount = 0;
|
||||
}
|
||||
}
|
||||
const renderedList = state.renderedFilenameMap?.get(key);
|
||||
if (renderedList) {
|
||||
for (const card of renderedList) {
|
||||
const badge = card.querySelector?.(".mjr-file-badge");
|
||||
deps.setFileBadgeCollision(badge, false);
|
||||
try {
|
||||
deps.ensureDupStackCard?.(gridContainer, card, card._mjrAsset);
|
||||
} catch (e: any) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// -- count >= 2: group into a duplicate stack ------------------------------
|
||||
// Select the best representative: prefer video_with_audio > video > image.
|
||||
const primary = preserveRepresentativeGenerationTime(
|
||||
selectStackRepresentative(visibleBucket),
|
||||
visibleBucket,
|
||||
);
|
||||
const secondaries = visibleBucket.filter((a: any) => a !== primary);
|
||||
|
||||
// Ensure stale collision markers are cleared on all visible members.
|
||||
for (const asset of visibleBucket) {
|
||||
asset._mjrNameCollision = false;
|
||||
delete asset._mjrNameCollisionCount;
|
||||
delete asset._mjrNameCollisionPaths;
|
||||
if (asset !== primary) {
|
||||
asset._mjrDupStack = false;
|
||||
asset._mjrDupMembers = null;
|
||||
asset._mjrDupCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge new duplicates with existing _mjrDupMembers to survive across pages.
|
||||
const prevMembers = Array.isArray(primary._mjrDupMembers)
|
||||
? primary._mjrDupMembers
|
||||
: [];
|
||||
const prevIds = new Set(prevMembers.map((a: any) => String(a?.id || "")));
|
||||
const merged = [
|
||||
...prevMembers,
|
||||
...visibleBucket.filter((a: any) => !prevIds.has(String(a?.id || ""))),
|
||||
];
|
||||
primary._mjrDupStack = true;
|
||||
primary._mjrDupMembers = merged;
|
||||
primary._mjrDupCount = merged.length;
|
||||
primary._mjrNameCollision = false; // primary card shows stack button, not red badge
|
||||
|
||||
// Remove secondary copies from state.assets so only the primary card shows.
|
||||
const secondaryIds = new Set(secondaries.map((a: any) => String(a?.id || "")));
|
||||
if (secondaryIds.size > 0) {
|
||||
state.assets = state.assets.filter(
|
||||
(a: any) => !secondaryIds.has(String(a?.id || "")),
|
||||
);
|
||||
}
|
||||
|
||||
// Update rendered primary card: attach dup-stack button, clear red collision badge.
|
||||
const renderedList = state.renderedFilenameMap?.get(key);
|
||||
if (renderedList) {
|
||||
for (const card of renderedList) {
|
||||
const cardAsset = card._mjrAsset;
|
||||
const badge = card.querySelector?.(".mjr-file-badge");
|
||||
if (
|
||||
cardAsset === primary ||
|
||||
String(cardAsset?.id || "") === String(primary?.id || "")
|
||||
) {
|
||||
deps.setFileBadgeCollision(badge, false);
|
||||
try {
|
||||
deps.ensureDupStackCard?.(gridContainer, card, primary);
|
||||
} catch (e: any) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.debug?.(e);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { get, getAssetMetadata, vectorFindSimilar } from "../../api/client.js";
|
||||
import { buildListURL, buildNodeContextMembersURL } from "../../api/endpoints.js";
|
||||
import { comfyToast } from "../../app/toast.js";
|
||||
import { t } from "../../app/i18n.js";
|
||||
import { toDuplicateGroupMembers } from "../../vue/grid/useGridDisplayAssets.js";
|
||||
|
||||
const STACK_GROUP_RENDER_LIMIT = 500;
|
||||
|
||||
@@ -373,9 +374,9 @@ export function bindSimilarSearch({
|
||||
}
|
||||
const activeAsset = await getActiveAsset();
|
||||
const activeId = String(activeAsset?.id || selectedAssetId() || "").trim();
|
||||
const members = Array.isArray(activeAsset?._mjrDupMembers)
|
||||
? activeAsset._mjrDupMembers
|
||||
: [];
|
||||
const members = toDuplicateGroupMembers(
|
||||
Array.isArray(activeAsset?._mjrDupMembers) ? activeAsset._mjrDupMembers : [],
|
||||
);
|
||||
if (members.length >= 2) {
|
||||
writePanelValue("similarResults", members);
|
||||
writePanelValue("similarSourceAssetId", activeId ? `duplicates:${activeId}` : "duplicates");
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computed, effect, reactive } from "vue";
|
||||
|
||||
import { buildDisplayAssets, toDuplicateGroupMembers } from "../vue/grid/useGridDisplayAssets.js";
|
||||
|
||||
function asset(
|
||||
id: number,
|
||||
subfolder: string,
|
||||
filename = "at-7.958s.png",
|
||||
extra: Record<string, any> = {},
|
||||
) {
|
||||
return { id, filename, subfolder, source: "output", type: "output", kind: "image", ...extra };
|
||||
}
|
||||
|
||||
describe("duplicate stacks (display)", () => {
|
||||
it("never stacks files that only share a name in different folders", () => {
|
||||
const out = buildDisplayAssets([
|
||||
asset(1, "video_a"),
|
||||
asset(2, "video_b"),
|
||||
asset(3, "video_c"),
|
||||
]);
|
||||
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out.every((a) => !a._mjrDupStack)).toBe(true);
|
||||
});
|
||||
|
||||
it("stacks real duplicates of the same location and lists every copy", () => {
|
||||
const out = buildDisplayAssets([
|
||||
asset(1, "shots"),
|
||||
asset(2, "Shots"),
|
||||
asset(3, "shots", "AT-7.958S.png"),
|
||||
]);
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]._mjrDupStack).toBe(true);
|
||||
expect(out[0]._mjrDupCount).toBe(3);
|
||||
expect(out[0]._mjrDupMembers.map((a: any) => a.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("is write-idempotent on reactive assets, so a render cannot loop", () => {
|
||||
// The grid template reads these fields; any write on a stable pass
|
||||
// re-triggers the render that ran this computed (the ComfyUI freeze).
|
||||
const state = reactive({
|
||||
assets: [asset(1, "s"), asset(2, "s"), asset(3, "s"), asset(4, "t")],
|
||||
});
|
||||
const display = computed(() => buildDisplayAssets(state.assets));
|
||||
let renders = 0;
|
||||
effect(() => {
|
||||
renders += 1;
|
||||
for (const a of display.value) {
|
||||
void a._mjrDupStack;
|
||||
void a._mjrDupCount;
|
||||
void a._mjrDupMembers;
|
||||
void a._mjrNameCollision;
|
||||
}
|
||||
});
|
||||
const settled = renders;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
buildDisplayAssets(state.assets);
|
||||
}
|
||||
|
||||
expect(renders).toBe(settled);
|
||||
expect(renders).toBeLessThan(5);
|
||||
const rep = display.value[0];
|
||||
const membersRef = rep._mjrDupMembers;
|
||||
buildDisplayAssets(state.assets);
|
||||
expect(rep._mjrDupMembers).toBe(membersRef);
|
||||
});
|
||||
|
||||
it("shows every copy individually in the duplicates view", () => {
|
||||
const assets = [asset(1, "s"), asset(2, "s"), asset(3, "s")];
|
||||
buildDisplayAssets(assets);
|
||||
|
||||
const out = buildDisplayAssets(assets, { collapseDuplicates: false });
|
||||
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out.every((a) => !a._mjrDupStack && a._mjrDupMembers == null)).toBe(true);
|
||||
});
|
||||
|
||||
it("hands the duplicates view cycle-free copies", () => {
|
||||
const [rep] = buildDisplayAssets([asset(1, "s"), asset(2, "s")]);
|
||||
expect(rep._mjrDupMembers).toContain(rep); // the live graph is cyclic
|
||||
|
||||
const members = toDuplicateGroupMembers(rep._mjrDupMembers);
|
||||
|
||||
expect(members.map((m) => m.id)).toEqual([1, 2]);
|
||||
expect(() => JSON.stringify(members)).not.toThrow();
|
||||
expect(members[0]).not.toBe(rep);
|
||||
expect(members.some((m) => "_mjrDupMembers" in m || "_mjrDupStack" in m)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ import { ensureStackGroupCard, ensureDupStackCard } from "../../../features/grid
|
||||
import { setSelectedIdsDataset } from "../../../features/grid/GridSelectionManager.js";
|
||||
import { applyGridSettingsClasses, configureGridContainer } from "../../../features/grid/gridApi.js";
|
||||
import { bindAssetDragStart } from "../../../features/dnd/DragDrop.js";
|
||||
import { preserveRepresentativeGenerationTime, selectStackRepresentative } from "../../../features/grid/AssetCardRenderer.js";
|
||||
import AssetCardInner from "./AssetCardInner.vue";
|
||||
import FolderCard from "./FolderCard.vue";
|
||||
import { useGridState } from "../../composables/useGridState.js";
|
||||
@@ -25,7 +24,11 @@ import { useGridLoader } from "../../composables/useGridLoader.js";
|
||||
import { isGridHostVisible } from "../../composables/gridVisibility.js";
|
||||
import { buildStaticGridRows, useGridVirtualRows } from "../../grid/useGridVirtualRows.js";
|
||||
import { useInfiniteTrigger } from "../../grid/useInfiniteTrigger.js";
|
||||
import { buildDisplayAssets, isRenderableAsset } from "../../grid/useGridDisplayAssets.js";
|
||||
import {
|
||||
buildDisplayAssets,
|
||||
isRenderableAsset,
|
||||
toDuplicateGroupMembers,
|
||||
} from "../../grid/useGridDisplayAssets.js";
|
||||
import { readRenderedAssetCards } from "./gridDomBridge.js";
|
||||
import type { MjrAssetLike } from "../../../types/asset";
|
||||
|
||||
@@ -138,7 +141,9 @@ provide("mjrStackService", {
|
||||
openDupGroup: (asset) => {
|
||||
const gc = gridContainerRef.value;
|
||||
if (!gc || !asset) return;
|
||||
const members = Array.isArray(asset._mjrDupMembers) ? asset._mjrDupMembers : [asset];
|
||||
const members = toDuplicateGroupMembers(
|
||||
Array.isArray(asset._mjrDupMembers) ? asset._mjrDupMembers : [asset],
|
||||
);
|
||||
gc.dispatchEvent(
|
||||
new CustomEvent(EVENTS.OPEN_STACK_GROUP, {
|
||||
bubbles: true,
|
||||
@@ -426,7 +431,12 @@ function emitSelectionChanged() {
|
||||
|
||||
emitSelectionChanged._lastSelectionKey = "";
|
||||
|
||||
const displayAssets = computed(() => buildDisplayAssets(state.assets));
|
||||
// The "Duplicates - N copies" / similar view must list every copy; collapsing
|
||||
// it again would show one card with the same badge and reopen the same view.
|
||||
const gridViewScope = ref("");
|
||||
const displayAssets = computed(() =>
|
||||
buildDisplayAssets(state.assets, { collapseDuplicates: gridViewScope.value !== "similar" }),
|
||||
);
|
||||
|
||||
const renderableAssets = computed(() => {
|
||||
const assets = Array.isArray(displayAssets.value) ? displayAssets.value : [];
|
||||
@@ -971,6 +981,10 @@ function readGridScope() {
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (gridScope.value !== raw) gridScope.value = raw;
|
||||
const view = String(gridContainerRef.value?.dataset?.mjrViewScope || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (gridViewScope.value !== view) gridViewScope.value = view;
|
||||
}
|
||||
|
||||
function scheduleInitialHostMeasurements() {
|
||||
@@ -1013,7 +1027,7 @@ watch(
|
||||
});
|
||||
scopeObserver.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-mjr-scope"],
|
||||
attributeFilter: ["data-mjr-scope", "data-mjr-view-scope"],
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
@@ -1104,65 +1118,20 @@ function unregisterFilenameCard(card) {
|
||||
}
|
||||
|
||||
function syncRenderedDuplicateCards(filenameKey) {
|
||||
// Duplicate stacks are owned by `displayAssets` (useGridDisplayAssets).
|
||||
// This used to re-derive them here from the *file name alone* (across
|
||||
// folders) on every card ref callback and write `_mjrDupStack` /
|
||||
// `_mjrDupMembers` (a new array each time) onto reactive assets that the
|
||||
// template reads. The ref callback runs on every render, so each write
|
||||
// scheduled another render: an infinite update loop that froze ComfyUI,
|
||||
// and stacks that mixed unrelated files sharing a name. Only the DOM-side
|
||||
// cleanup remains; it never touches reactive state.
|
||||
const key = String(filenameKey || "").trim().toLowerCase();
|
||||
if (!key) return;
|
||||
|
||||
const renderedSet = state.renderedFilenameMap.get(key);
|
||||
const renderedCards = Array.from(renderedSet || []);
|
||||
if (!renderedCards.length) return;
|
||||
|
||||
const allMembers = (Array.isArray(state.assets) ? state.assets : []).filter(
|
||||
(asset) => String(asset?.filename || "").trim().toLowerCase() === key,
|
||||
);
|
||||
const count = Math.max(
|
||||
Number(state.filenameCounts?.get?.(key) || 0) || 0,
|
||||
allMembers.length,
|
||||
);
|
||||
|
||||
// Select the best representative: prefer video_with_audio > video > image.
|
||||
const selectedAsset = selectStackRepresentative(allMembers);
|
||||
const selectedCard = renderedCards.find((card) => {
|
||||
const cardAssetId = String(card._mjrAsset?.id || "");
|
||||
const selectedId = String(selectedAsset?.id || "");
|
||||
return cardAssetId && selectedId && cardAssetId === selectedId;
|
||||
}) || renderedCards[0];
|
||||
const primaryCard = selectedCard || renderedCards[0] || null;
|
||||
const primaryAsset = primaryCard?._mjrAsset || selectedAsset || allMembers[0] || null;
|
||||
preserveRepresentativeGenerationTime(primaryAsset, allMembers);
|
||||
|
||||
for (const card of renderedCards) {
|
||||
delete card.dataset.mjrDupHidden;
|
||||
const asset = card._mjrAsset;
|
||||
if (asset) {
|
||||
asset._mjrNameCollision = false;
|
||||
delete asset._mjrNameCollisionCount;
|
||||
delete asset._mjrNameCollisionPaths;
|
||||
asset._mjrDupStack = false;
|
||||
asset._mjrDupMembers = null;
|
||||
asset._mjrDupCount = 0;
|
||||
asset._mjrDupHidden = false;
|
||||
}
|
||||
for (const card of renderedSet || []) {
|
||||
if (card?.dataset?.mjrDupHidden) delete card.dataset.mjrDupHidden;
|
||||
}
|
||||
|
||||
if (!primaryCard || !primaryAsset || count < 2) {
|
||||
for (const card of renderedCards) {
|
||||
try {
|
||||
// ...
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
primaryAsset._mjrDupStack = true;
|
||||
primaryAsset._mjrDupMembers = allMembers.length ? allMembers : [primaryAsset];
|
||||
primaryAsset._mjrDupCount = primaryAsset._mjrDupMembers.length;
|
||||
primaryAsset._mjrNameCollision = false;
|
||||
|
||||
// Vue owns duplicate collapsing through `displayAssets`/`renderableAssets`.
|
||||
// Hiding already-mounted flex children here leaves holes in virtual rows
|
||||
// until the asset list recomputes, and can persist with recycled rows.
|
||||
}
|
||||
|
||||
function syncAllRenderedDuplicateCards() {
|
||||
@@ -1200,8 +1169,25 @@ function bindCardRef(asset, node) {
|
||||
}
|
||||
}
|
||||
|
||||
// One ref callback per asset id. A fresh closure on every render makes Vue
|
||||
// call the old ref with null and the new one with the element each time,
|
||||
// which re-ran bindCardRef (and its side effects) on every render.
|
||||
const cardRefCallbacks = new Map();
|
||||
|
||||
function getCardRef(asset) {
|
||||
return (node) => bindCardRef(asset, node);
|
||||
const id = String(asset?.id ?? "").trim();
|
||||
if (!id) return (node) => bindCardRef(asset, node);
|
||||
let entry = cardRefCallbacks.get(id);
|
||||
if (!entry) {
|
||||
entry = { asset, fn: null };
|
||||
entry.fn = (node) => {
|
||||
bindCardRef(entry.asset, node);
|
||||
if (!node) cardRefCallbacks.delete(id);
|
||||
};
|
||||
cardRefCallbacks.set(id, entry);
|
||||
}
|
||||
entry.asset = asset;
|
||||
return entry.fn;
|
||||
}
|
||||
|
||||
function isInteractiveTarget(target, currentTarget) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* This centralizes virtualization helpers that were previously split
|
||||
* across VirtualScroller.js and InfiniteScroll.js.
|
||||
*/
|
||||
import { getFilenameKey, shouldHideSiblingAsset, unregisterHiddenSibling } from "../../features/grid/AssetCardRenderer.js";
|
||||
import { shouldHideSiblingAsset, unregisterHiddenSibling } from "../../features/grid/AssetCardRenderer.js";
|
||||
import {
|
||||
gridQueryHasActiveFilters,
|
||||
readGridQueryFromDataset,
|
||||
@@ -683,53 +683,28 @@ function _shouldKeepExistingAssetOnFilterMismatch(incomingAsset: any, existingAs
|
||||
}
|
||||
|
||||
function dedupeAssetsByKey(state: any, deps: any) {
|
||||
// Drop only true duplicates (same id or same asset key). Grouping assets
|
||||
// that merely share a file name is a *display* concern owned by
|
||||
// `buildDisplayAssets` (same folder only); doing it here by file name
|
||||
// alone removed unrelated files in other folders from `state.assets` and
|
||||
// stacked them under the wrong card.
|
||||
const seenIds = new Set();
|
||||
const seenKeys = new Set();
|
||||
const filenamePrimary = new Map();
|
||||
const deduped: any[] = [];
|
||||
let changed = false;
|
||||
for (const asset of Array.isArray(state?.assets) ? state.assets : []) {
|
||||
const assetId = asset?.id != null ? String(asset.id) : "";
|
||||
const key = deps.assetKey(asset);
|
||||
const filenameKey = getFilenameKey(asset?.filename);
|
||||
if (assetId && seenIds.has(assetId)) continue;
|
||||
if (key && seenKeys.has(key)) continue;
|
||||
|
||||
if (filenameKey) {
|
||||
const primary = filenamePrimary.get(filenameKey);
|
||||
if (primary) {
|
||||
const prevMembers = Array.isArray(primary._mjrDupMembers) ? primary._mjrDupMembers : [primary];
|
||||
const prevIds = new Set(prevMembers.map((a: any) => String(a?.id || "")));
|
||||
const merged = [
|
||||
...prevMembers,
|
||||
...[asset].filter((a) => !prevIds.has(String(a?.id || ""))),
|
||||
];
|
||||
primary._mjrDupStack = true;
|
||||
primary._mjrDupMembers = merged;
|
||||
primary._mjrDupCount = merged.length;
|
||||
primary._mjrNameCollision = false;
|
||||
delete primary._mjrNameCollisionCount;
|
||||
delete primary._mjrNameCollisionPaths;
|
||||
|
||||
asset._mjrNameCollision = false;
|
||||
delete asset._mjrNameCollisionCount;
|
||||
delete asset._mjrNameCollisionPaths;
|
||||
asset._mjrDupStack = false;
|
||||
asset._mjrDupMembers = null;
|
||||
asset._mjrDupCount = 0;
|
||||
continue;
|
||||
}
|
||||
if ((assetId && seenIds.has(assetId)) || (key && seenKeys.has(key))) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assetId) seenIds.add(assetId);
|
||||
if (key) seenKeys.add(key);
|
||||
if (filenameKey) filenamePrimary.set(filenameKey, asset);
|
||||
|
||||
asset._mjrNameCollision = false;
|
||||
delete asset._mjrNameCollisionCount;
|
||||
delete asset._mjrNameCollisionPaths;
|
||||
deduped.push(asset);
|
||||
}
|
||||
state.assets = deduped;
|
||||
if (changed) state.assets = deduped;
|
||||
// Always resync id/key sets: callers rely on it after placeholder merges.
|
||||
syncAssetCollectionState(state, { assetKey: deps.assetKey });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
import { getFilenameKey } from "../../features/grid/AssetCardRenderer.js";
|
||||
import { toRaw } from "vue";
|
||||
|
||||
import {
|
||||
getFilenameKey,
|
||||
preserveRepresentativeGenerationTime,
|
||||
} from "../../features/grid/AssetCardRenderer.js";
|
||||
|
||||
/**
|
||||
* Single source of truth for the "duplicate stack" badge in the Vue grid.
|
||||
*
|
||||
* A duplicate is the same file name in the same source/root/subfolder (i.e.
|
||||
* the same location on disk, typically duplicate index rows or case-only path
|
||||
* variants). Files that merely share a name in *different* folders are
|
||||
* distinct assets and are never stacked.
|
||||
*
|
||||
* This runs inside a `computed`, on reactive asset objects that the grid
|
||||
* template reads (`_mjrDupStack`, `_mjrDupCount`). Every write is therefore
|
||||
* guarded: a field is only assigned when its raw value actually differs, and
|
||||
* the members array keeps its identity while membership is unchanged.
|
||||
* Unconditional writes (a fresh array each pass, false->true flips) re-trigger
|
||||
* the render that called us; in a production build Vue has no recursion
|
||||
* limit, so that loop froze the whole ComfyUI tab.
|
||||
*/
|
||||
|
||||
const DUP_FIELDS_TO_DELETE = ["_mjrNameCollisionCount", "_mjrNameCollisionPaths"] as const;
|
||||
|
||||
export function getDisplayGroupKey(asset: Record<string, any> | null | undefined): string {
|
||||
const filenameKey = getFilenameKey(asset?.filename);
|
||||
@@ -13,54 +37,122 @@ export function isRenderableAsset(asset: any): boolean {
|
||||
return !!asset;
|
||||
}
|
||||
|
||||
export function buildDisplayAssets(assets: any[]): Record<string, any>[] {
|
||||
function setIfChanged(asset: any, key: string, value: unknown): void {
|
||||
const raw = toRaw(asset);
|
||||
if (raw?.[key] !== value) asset[key] = value;
|
||||
}
|
||||
|
||||
function deleteIfPresent(asset: any, key: string): void {
|
||||
const raw = toRaw(asset);
|
||||
if (raw && Object.prototype.hasOwnProperty.call(raw, key)) delete asset[key];
|
||||
}
|
||||
|
||||
function sameMembers(previous: unknown, next: any[]): boolean {
|
||||
if (!Array.isArray(previous) || previous.length !== next.length) return false;
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
if (toRaw(previous[i]) !== toRaw(next[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearDuplicateMarkers(asset: any): void {
|
||||
setIfChanged(asset, "_mjrNameCollision", false);
|
||||
for (const key of DUP_FIELDS_TO_DELETE) deleteIfPresent(asset, key);
|
||||
setIfChanged(asset, "_mjrDupStack", false);
|
||||
setIfChanged(asset, "_mjrDupMembers", null);
|
||||
setIfChanged(asset, "_mjrDupCount", 0);
|
||||
}
|
||||
|
||||
function markRepresentative(rep: any, bucket: any[]): void {
|
||||
for (const key of DUP_FIELDS_TO_DELETE) deleteIfPresent(rep, key);
|
||||
setIfChanged(rep, "_mjrNameCollision", false);
|
||||
setIfChanged(rep, "_mjrDupStack", true);
|
||||
if (!sameMembers(toRaw(rep)?._mjrDupMembers, bucket)) {
|
||||
rep._mjrDupMembers = bucket.slice();
|
||||
}
|
||||
setIfChanged(rep, "_mjrDupCount", bucket.length);
|
||||
// Only copies a value onto an empty field, so it is idempotent.
|
||||
preserveRepresentativeGenerationTime(rep, bucket);
|
||||
}
|
||||
|
||||
export function buildDisplayAssets(
|
||||
assets: any[],
|
||||
{ collapseDuplicates = true }: { collapseDuplicates?: boolean } = {},
|
||||
): Record<string, any>[] {
|
||||
const list = Array.isArray(assets) ? assets : [];
|
||||
const buckets = new Map();
|
||||
if (!collapseDuplicates) {
|
||||
// e.g. the "Duplicates - N copies" view: show every copy individually.
|
||||
const output: any[] = [];
|
||||
for (const asset of list) {
|
||||
if (!asset) continue;
|
||||
clearDuplicateMarkers(asset);
|
||||
output.push(asset);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
const buckets = new Map<string, any[]>();
|
||||
const output: any[] = [];
|
||||
|
||||
for (const asset of list) {
|
||||
if (!asset) continue;
|
||||
const filenameKey = getDisplayGroupKey(asset);
|
||||
if (!filenameKey) {
|
||||
asset._mjrNameCollision = false;
|
||||
delete asset._mjrNameCollisionCount;
|
||||
delete asset._mjrNameCollisionPaths;
|
||||
asset._mjrDupStack = false;
|
||||
asset._mjrDupMembers = null;
|
||||
asset._mjrDupCount = 0;
|
||||
const groupKey = getDisplayGroupKey(asset);
|
||||
if (!groupKey) {
|
||||
clearDuplicateMarkers(asset);
|
||||
output.push(asset);
|
||||
continue;
|
||||
}
|
||||
|
||||
let bucket = buckets.get(filenameKey);
|
||||
let bucket = buckets.get(groupKey);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
buckets.set(filenameKey, bucket);
|
||||
buckets.set(groupKey, bucket);
|
||||
output.push(asset);
|
||||
}
|
||||
bucket.push(asset);
|
||||
}
|
||||
|
||||
for (const bucket of buckets.values()) {
|
||||
const rep = bucket[0];
|
||||
for (const member of bucket) {
|
||||
member._mjrNameCollision = false;
|
||||
delete member._mjrNameCollisionCount;
|
||||
delete member._mjrNameCollisionPaths;
|
||||
member._mjrDupStack = false;
|
||||
member._mjrDupMembers = null;
|
||||
member._mjrDupCount = 0;
|
||||
}
|
||||
if (bucket.length >= 2) {
|
||||
const rep = bucket[0];
|
||||
rep._mjrDupStack = true;
|
||||
rep._mjrDupMembers = bucket.slice();
|
||||
rep._mjrDupCount = bucket.length;
|
||||
if (member !== rep || bucket.length < 2) clearDuplicateMarkers(member);
|
||||
}
|
||||
if (bucket.length >= 2) markRepresentative(rep, bucket);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function getRenderableAssets(assets: unknown[]): Record<string, any>[] {
|
||||
return buildDisplayAssets(assets).filter(isRenderableAsset);
|
||||
return buildDisplayAssets(assets as any[]).filter(isRenderableAsset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain, cycle-free copies of a duplicate stack's members for the
|
||||
* "Duplicates" view. The representative lists itself in `_mjrDupMembers`, so
|
||||
* handing the live objects to another view (Pinia state, a second grid) would
|
||||
* share and re-mutate the same reactive graph.
|
||||
*/
|
||||
export function toDuplicateGroupMembers(members: unknown): Record<string, any>[] {
|
||||
const list = Array.isArray(members) ? members : [];
|
||||
const out: Record<string, any>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const member of list) {
|
||||
if (!member || typeof member !== "object") continue;
|
||||
const copy: Record<string, any> = { ...(toRaw(member) as Record<string, any>) };
|
||||
delete copy._mjrDupMembers;
|
||||
delete copy._mjrDupStack;
|
||||
delete copy._mjrDupCount;
|
||||
delete copy._mjrDupHidden;
|
||||
delete copy._mjrNameCollision;
|
||||
delete copy._mjrNameCollisionCount;
|
||||
delete copy._mjrNameCollisionPaths;
|
||||
const id = String(copy.id ?? "");
|
||||
if (id) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
}
|
||||
out.push(copy);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user