Fix custom Inputs indexing and expose an explicit scan button

Co-authored-by: MajoorWaldi <129623145+MajoorWaldi@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-10-24 16:46:36 +00:00
committed by GitHub
co-authored by MajoorWaldi
parent 228fabf084
commit 14d773f06d
39 changed files with 9492 additions and 8555 deletions
+1
View File
@@ -211,6 +211,7 @@ docs/COMFYUI_FRONTEND_IMPROVEMENTS.md
.github/agents/badge-card-qa.agent.md
.mjr_index_directory_override
.mjr_output_directory_override
.mjr_input_directory_override
coverage/
ComfyUI-Majoor-AssetsManager_release.zip
@@ -1,12 +1,12 @@
import { Zt as e, g as t, y as n } from "./viewerRuntimeHosts-DyiuTSfr.js";
import { B as r, C as i, I as a, M as o, O as s, P as c, R as l, V as u, j as d, m as f, o as p, r as m, rt as h, v as g, w as _, x as v } from "./events-DrYpMD-I.js";
import { a as y, i as b, o as x, s as S } from "./graphTraversal-BIYmTTPW.js";
import { _ as C, g as w, m as T, n as E, p as ee, r as D, w as O } from "./Viewer-DXsFcJRb.js";
import { r as k, v as A } from "./SidebarWorkflowSection-BEJKYrrz.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-CjYabCh7.js";
import { a as W, n as ae, r as oe } from "./model3dRenderer-CWRtZrSS.js";
import { i as se, o as ce, r as le, t as ue } from "./geninfoParser-DS9m_bHm.js";
import { t as de } from "./genInfo-D0BtEEbx.js";
import { $t as e, g as t, y as n } from "./viewerRuntimeHosts-BBd8uK6j.js";
import { B as r, C as i, I as a, M as o, O as s, P as c, R as l, V as u, j as d, m as f, o as p, r as m, rt as h, v as g, w as _, x as v } from "./events-CoI83paj.js";
import { a as y, i as b, o as x, s as S } from "./graphTraversal-C94Q5bJN.js";
import { _ as C, g as w, m as T, n as E, p as ee, r as D, w as O } from "./Viewer-DWgaGos-.js";
import { r as k, v as A } from "./SidebarWorkflowSection-DpoPGJrq.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-CxqtW_yN.js";
import { a as W, n as ae, r as oe } from "./model3dRenderer-e5uWwIMW.js";
import { i as se, o as ce, r as le, t as ue } from "./geninfoParser-Bwtqk4p6.js";
import { t as de } from "./genInfo-y4P7FXnU.js";
//#region ui/features/viewer/floatingViewerConstants.ts
var G = Object.freeze({
SIMPLE: "simple",
@@ -169,7 +169,7 @@ function Ee(e, t = null, { kind: n = "" } = {}) {
} catch (e) {
console.debug?.(e);
}
if (!(!i || e?.paused)) {
if (i && !e?.paused) {
try {
let t = e;
if (typeof t?.requestVideoFrameCallback == "function") {
@@ -186,7 +186,7 @@ function Ee(e, t = null, { kind: n = "" } = {}) {
}
}
}, P = () => {
M(), !(!i || e?.paused) && N();
M(), i && !e?.paused && N();
}, F = (e) => {
try {
e?.stopPropagation?.();
@@ -200,7 +200,7 @@ function Ee(e, t = null, { kind: n = "" } = {}) {
}
D();
}, L = (t, n) => {
if (F(n), !(!a || !(e instanceof HTMLVideoElement))) {
if (F(n), a && e instanceof HTMLVideoElement) {
try {
e.pause?.();
} catch (e) {
@@ -334,7 +334,7 @@ function J(e, t) {
return n.className = `mjr-mfv-label label-${t}`, n.textContent = e, n;
}
function je(e) {
if (!(!e || typeof e.play != "function")) try {
if (e && typeof e.play == "function") try {
let t = e.play();
t && typeof t.catch == "function" && t.catch(() => {});
} catch (e) {
@@ -342,7 +342,7 @@ function je(e) {
}
}
function Me(e) {
if (!(!e || typeof e.play != "function")) try {
if (e && typeof e.play == "function") try {
let t = e.play();
t && typeof t.catch == "function" && t.catch(() => {
try {
@@ -646,7 +646,7 @@ var Xe = class {
return this._body = c, t.appendChild(c), this._el = t, this._applyExpandedState(), t;
}
_applyExpandedState() {
if (!(!this._el || !this._body)) {
if (this._el && this._body) {
if (this._el.classList.toggle("is-collapsible", this._collapsible), this._el.classList.toggle("is-collapsed", this._collapsible && !this._expanded), this._toggleBtn) {
let e = this._expanded ? "pi pi-chevron-down" : "pi pi-chevron-right";
this._toggleBtn.textContent = "";
@@ -939,7 +939,7 @@ function ft(e) {
index: o
});
}
!a || c.has(a.index) || te(a.widget, X(e.value), t) && (c.add(a.index), l += 1);
a && !c.has(a.index) && te(a.widget, X(e.value), t) && (c.add(a.index), l += 1);
}
return u(), {
ok: l > 0,
@@ -1725,7 +1725,7 @@ function Zt(e) {
], r = /* @__PURE__ */ new Set(), i = [];
for (let [e, t] of n) {
let n = le(t?.name || t?.value || t || "");
if (!(!n || r.has(n)) && (r.add(n), i.push(`${e}: ${n}`), i.length >= 3)) break;
if (n && !r.has(n) && (r.add(n), i.push(`${e}: ${n}`), i.length >= 3)) break;
}
return i.join(" | ");
}
@@ -2414,7 +2414,7 @@ function En(e, t) {
].map((t) => Tn(e[`_media${t}`])).filter(Boolean));
for (let r of t) {
let t = Tn(r);
if (!(!t || n.has(t))) {
if (t && !n.has(t)) {
try {
URL.revokeObjectURL(t);
} catch {}
@@ -2515,9 +2515,9 @@ function Mn(e, ...t) {
return Array.from(new Set([String(e || ""), ...t].join(" ").split(/\s+/).filter(Boolean))).join(" ");
}
function Nn(e, t) {
if (!(!e || !t)) for (let n of Array.from(e.attributes || [])) {
if (e && t) for (let n of Array.from(e.attributes || [])) {
let e = String(n?.name || "");
if (!(!e || e === "class" || e === "style")) try {
if (e && e !== "class" && e !== "style") try {
t.setAttribute(e, n.value);
} catch (e) {
console.debug?.(e);
@@ -2745,7 +2745,7 @@ function Wn(e) {
}, 250);
}
function Gn(e) {
!e._isPopped || e._popoutRestoreGuard || (e._popoutRestoreGuard = !0, window.setTimeout(() => {
e._isPopped && !e._popoutRestoreGuard && (e._popoutRestoreGuard = !0, window.setTimeout(() => {
try {
e.popIn({ closePopupWindow: !1 });
} finally {
@@ -3487,7 +3487,7 @@ var dr = 0, fr = class {
let i = Math.max(0, Math.min(.92, Number(r) || 0));
if (!(i > 0)) return;
e.save(), e.fillStyle = `rgba(0,0,0,${i})`, e.fillRect(0, 0, t.width, t.height), e.globalCompositeOperation = "destination-out";
for (let t of n) !t || !(t.w > 1 && t.h > 1) || e.fillRect(t.x, t.y, t.w, t.h);
for (let t of n) t && t.w > 1 && t.h > 1 && e.fillRect(t.x, t.y, t.w, t.h);
e.restore();
} catch (e) {
console.debug?.(e);
@@ -3550,7 +3550,7 @@ var dr = 0, fr = class {
_bindLayoutObserver() {
this._unbindLayoutObserver();
let e = this._contentEl;
if (!(!e || typeof ResizeObserver > "u")) try {
if (e && typeof ResizeObserver < "u") try {
this._layoutObserver = new ResizeObserver(() => {
this._applyTransform();
}), this._layoutObserver.observe(e);
@@ -3881,7 +3881,7 @@ var dr = 0, fr = class {
return i.childNodes.length ? r : null;
}
_appendIdeogramGenInfoCards(e, t) {
if (!(!e || !t)) {
if (e && t) {
t.highLevelDescription && e.appendChild(this._buildGenInfoCard({
title: "High Level Description",
accent: "#4CAF50",
@@ -3967,7 +3967,7 @@ var dr = 0, fr = class {
return a.className = "mjr-mfv-gen-field-value", a.textContent = n, r.appendChild(i), r.appendChild(a), r;
}
_bindGenInfoCopy(e, t) {
!e || typeof t != "function" || (e.title = "Click to copy", e.addEventListener("click", async (n) => {
e && typeof t == "function" && (e.title = "Click to copy", e.addEventListener("click", async (n) => {
n.stopPropagation();
let r = String(t() || "").trim();
if (r) try {
@@ -1,5 +1,5 @@
import { J as e, o as t, r as n } from "./events-DrYpMD-I.js";
import { t as r } from "./floatingViewerManager-Crq2oCKD.js";
import { J as e, o as t, r as n } from "./events-CoI83paj.js";
import { t as r } from "./floatingViewerManager-XH4AFW4G.js";
//#region 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",
@@ -1,4 +1,4 @@
import { a as e, n as t, r as n, s as r } from "./graphTraversal-BIYmTTPW.js";
import { a as e, n as t, r as n, s as r } from "./graphTraversal-C94Q5bJN.js";
import { KnownNodesAdapter as i, t as a } from "./KnownNodesAdapter-D3kDh32d.js";
//#region ui/features/viewer/nodeStream/NodeStreamRegistry.ts
var o = [], s = !1;
@@ -454,7 +454,7 @@ function Ce(e) {
}
if (!(e.depth >= F)) for (let t of ye(e.node)) {
let n = String(t?.id ?? "");
!n || i.has(n) || (i.add(n), r.push({
n && !i.has(n) && (i.add(n), r.push({
node: t,
depth: e.depth + 1
}));
@@ -493,7 +493,7 @@ function Z({ force: e = !1 } = {}) {
return;
}
let i = we(r);
!e && n === M && i === j || (M = n, j = i, D(r));
(e || n !== M || i !== j) && (M = n, j = i, D(r));
}
function Q() {
let e = T;
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { F as e, Zt as t, v as n } from "./viewerRuntimeHosts-DyiuTSfr.js";
import { St as r, m as i, n as a } from "./events-DrYpMD-I.js";
import { Y as o } from "./Viewer-DXsFcJRb.js";
import { A as s, C as c, E as l, G as u, J as d, K as f, L as ee, N as p, R as te, T as m, U as ne, V as h, Y as g, b as _, ft as v, j as y, k as b, lt as x, rt as S, ut as C, z as w } from "./mjr-primevue-BlSRoWcG.js";
import { $t as e, F as t, v as n } from "./viewerRuntimeHosts-BBd8uK6j.js";
import { St as r, m as i, n as a } from "./events-CoI83paj.js";
import { Y as o } from "./Viewer-DWgaGos-.js";
import { A as s, C as c, E as l, G as u, J as d, K as f, L as ee, N as p, R as te, T as m, U as ne, V as h, Y as g, b as _, ft as v, j as y, k as b, lt as x, rt as S, ut as C, z as w } from "./mjr-primevue-BOf2ACk_.js";
//#region ui/vue/components/common/TagsEditor.vue?vue&type=script&setup=true&lang.ts
var T = ["aria-busy"], E = ["aria-label"], re = {
key: 0,
@@ -111,7 +111,7 @@ var T = ["aria-busy"], E = ["aria-label"], re = {
q = o;
let s = null;
try {
s = await e(N.asset, a, o ? { signal: o.signal } : {});
s = await t(N.asset, a, o ? { signal: o.signal } : {});
} catch (e) {
console.error("Failed to update tags:", e);
}
@@ -123,8 +123,8 @@ var T = ["aria-busy"], E = ["aria-label"], re = {
}
break;
}
let e = [...J];
I.value = e, P("update:modelValue", e), t(s?.error || i("toast.tagsUpdateFailed", "Failed to update tags"), "error"), G = !1, q = null, U.value = !1;
let t = [...J];
I.value = t, P("update:modelValue", t), e(s?.error || i("toast.tagsUpdateFailed", "Failed to update tags"), "error"), G = !1, q = null, U.value = !1;
return;
}
try {
@@ -140,18 +140,18 @@ var T = ["aria-busy"], E = ["aria-label"], re = {
} catch (e) {
console.debug?.(e);
}
if (K || (I.value = [...c]), fe(c), t(i("toast.tagsUpdated", "Tags updated"), "success", 1e3), !K) break;
if (K || (I.value = [...c]), fe(c), e(i("toast.tagsUpdated", "Tags updated"), "success", 1e3), !K) break;
K = !1;
}
if (n >= 10) {
let e = [...J];
I.value = e;
let t = [...J];
I.value = t;
try {
N.asset.tags = [...e];
N.asset.tags = [...t];
} catch (e) {
console.debug?.(e);
}
P("update:modelValue", e), t(i("toast.tagsUpdateFailed", "Failed to update tags"), "error");
P("update:modelValue", t), e(i("toast.tagsUpdateFailed", "Failed to update tags"), "error");
}
G = !1, q = null, U.value = !1;
}
@@ -174,14 +174,14 @@ var T = ["aria-busy"], E = ["aria-label"], re = {
console.debug?.(e);
}
});
function $(e) {
function $(t) {
if (N.disabled) return;
let n = se(e);
let n = se(t);
if (!n) return;
let r = A(n);
if (r && !I.value.some((e) => A(e) === r)) {
if (I.value.length >= D) {
t(i("toast.maxTagsReached", "Maximum number of tags reached"), "warning");
e(i("toast.maxTagsReached", "Maximum number of tags reached"), "warning");
return;
}
I.value = [...I.value, n], Q();
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
import { a as e, i as t } from "./viewerRuntimeHosts-DyiuTSfr.js";
import { r as n } from "./events-DrYpMD-I.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-DXsFcJRb.js";
import { i as d, r as f } from "./floatingViewerManager-Crq2oCKD.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-BlSRoWcG.js";
import { t as F } from "./TagsEditor-BiRWztXF.js";
import { a as e, i as t } from "./viewerRuntimeHosts-BBd8uK6j.js";
import { r as n } from "./events-CoI83paj.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-DWgaGos-.js";
import { i as d, r as f } from "./floatingViewerManager-XH4AFW4G.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-BOf2ACk_.js";
import { t as F } from "./TagsEditor-BXjsZ3IO.js";
//#endregion
//#region ui/vue/components/viewer/FloatingViewerHost.vue
var I = /* @__PURE__ */ x({
@@ -112,7 +112,7 @@ var I = /* @__PURE__ */ x({
});
}
async function J(e, t, n = "main") {
if (!(!e || e.type !== "item" || e.disabled)) {
if (e && e.type === "item" && !e.disabled) {
if (Array.isArray(e.submenu) && e.submenu.length) {
q(e, t);
return;
@@ -1,4 +1,4 @@
import { a as e, n as t, s as n } from "./graphTraversal-BIYmTTPW.js";
import { a as e, n as t, s as n } from "./graphTraversal-C94Q5bJN.js";
//#region ui/utils/ids.ts
function r(e) {
try {
@@ -106,6 +106,7 @@ var m = 200, h = 0, g = {
COLLECT_FILES: "/mjr/am/collect-files",
TOOLS_STATUS: "/mjr/am/tools/status",
SETTINGS_OUTPUT_DIRECTORY: "/mjr/am/settings/output-directory",
SETTINGS_INPUT_DIRECTORY: "/mjr/am/settings/input-directory",
SETTINGS_INDEX_DIRECTORY: "/mjr/am/settings/index-directory",
SETTINGS_WORKFLOW_ROOTS: "/mjr/am/settings/workflow-roots",
SETTINGS_METADATA_FALLBACK: "/mjr/am/settings/metadata-fallback",
@@ -1163,7 +1164,7 @@ function U() {
}
function W(e, t, n) {
let r = B.get(String(e || ""));
if (!(!r || !r.size)) for (let i of Array.from(r)) try {
if (r && r.size) for (let i of Array.from(r)) try {
i(t, n, e);
} catch (e) {
console.debug?.(e);
@@ -1,6 +1,6 @@
import { t as e } from "./rolldown-runtime-Dy4uBu1J.js";
import { _ as t, d as n, o as r, s as i, t as a } from "./viewerRuntimeHosts-DyiuTSfr.js";
import { o, r as s, z as c } from "./events-DrYpMD-I.js";
import { _ as t, d as n, o as r, s as i, t as a } from "./viewerRuntimeHosts-BBd8uK6j.js";
import { o, r as s, z as c } from "./events-CoI83paj.js";
//#region ui/features/panel/panelRuntimeRefs.ts
var l = null;
function u(e) {
@@ -98,11 +98,11 @@ var te = /* @__PURE__ */ e({
teardownFloatingViewerManager: () => we
}), v = null, y = null;
async function ne() {
return v || (y ||= import("./FloatingViewer-C_N3LZxW.js").then((e) => (v = e.FloatingViewer, v)), y);
return v || (y ||= import("./FloatingViewer-38MC8yxJ.js").then((e) => (v = e.FloatingViewer, v)), y);
}
var b = null, re = null;
async function ie() {
if (!b) return re ||= import("./NodeStreamController-DpGjUOfx.js").then((e) => {
if (!b) return re ||= import("./NodeStreamController-DaqaPtXc.js").then((e) => {
b = e.setNodeStreamActive;
}), re;
}
@@ -180,7 +180,7 @@ function se() {
}
}
function F(e) {
typeof window > "u" || window.dispatchEvent(new CustomEvent(s.MFV_VISIBILITY_CHANGED, { detail: { visible: !!e } }));
typeof window < "u" && window.dispatchEvent(new CustomEvent(s.MFV_VISIBILITY_CHANGED, { detail: { visible: !!e } }));
}
function I(e) {
e && (e.setLiveActive(T), e.setPreviewActive(E), e.setNodeStreamActive?.(D));
@@ -310,7 +310,7 @@ function B(e) {
}
}
function V() {
k || typeof window > "u" || (window.addEventListener(s.SELECTION_CHANGED, B), k = !0);
k || typeof window < "u" && (window.addEventListener(s.SELECTION_CHANGED, B), k = !0);
}
function H() {
typeof window < "u" && window.removeEventListener(s.SELECTION_CHANGED, B), k = !1, N();
@@ -409,7 +409,7 @@ var Y = {
},
async upsertWithContent(e) {
let t = await M(), n = !!t.isVisible;
!n && o.MFV_LIVE_AUTO_OPEN === !1 || (t.show(), I(t), V(), ce(t, e), n || F(!0));
(n || o.MFV_LIVE_AUTO_OPEN !== !1) && (t.show(), I(t), V(), ce(t, e), n || F(!0));
},
setLiveActive(e) {
T = !!e, S?.setLiveActive(T);
@@ -433,7 +433,7 @@ var Y = {
async feedPreviewBlob(e, t = {}) {
if (!Y.canAcceptPreviewBlob()) return;
let n = await M(), r = !!n.isVisible;
!r && o.MFV_PREVIEW_AUTO_OPEN === !1 || (n.isVisible || n.show(), I(n), n.loadPreviewBlob(e, ...Object.keys(t).length ? [t] : []), r || F(!0));
(r || o.MFV_PREVIEW_AUTO_OPEN !== !1) && (n.isVisible || n.show(), I(n), n.loadPreviewBlob(e, ...Object.keys(t).length ? [t] : []), r || F(!0));
},
toggleNodeStream() {
Y.setNodeStreamActive(!D);
@@ -458,7 +458,7 @@ var Y = {
async feedNodeStream(e) {
if (!D) return;
let t = await M(), n = !!t.isVisible;
!n && o.MFV_NODE_STREAM_AUTO_OPEN === !1 || (t.isVisible || (t.show(), V()), I(t), L(t), ce(t, e), n || F(!0));
(n || o.MFV_NODE_STREAM_AUTO_OPEN !== !1) && (t.isVisible || (t.show(), V()), I(t), L(t), ce(t, e), n || F(!0));
}
}, X = !1, pe = () => Y.open(), me = () => Y.close(), he = () => Y.toggle(), Z = () => Y.toggleLive(), ge = () => Y.togglePreview(), _e = () => Y.toggleNodeStream(), ve = () => Y.popOut(), ye = () => {
try {
@@ -520,7 +520,7 @@ var Q = (e) => {
}
};
function $() {
X || typeof window > "u" || !window?.addEventListener || (window.addEventListener(s.MFV_OPEN, pe), window.addEventListener(s.MFV_CLOSE, me), window.addEventListener(s.MFV_TOGGLE, he), window.addEventListener(s.MFV_LIVE_TOGGLE, Z), window.addEventListener(s.MFV_PREVIEW_TOGGLE, ge), window.addEventListener(s.MFV_NODESTREAM_TOGGLE, _e), window.addEventListener(s.MFV_POPOUT, ve), window.addEventListener(s.SETTINGS_CHANGED, be), window.addEventListener("keydown", Q, !0), window.addEventListener("beforeunload", ye), X = !0);
X || typeof window < "u" && window?.addEventListener && (window.addEventListener(s.MFV_OPEN, pe), window.addEventListener(s.MFV_CLOSE, me), window.addEventListener(s.MFV_TOGGLE, he), window.addEventListener(s.MFV_LIVE_TOGGLE, Z), window.addEventListener(s.MFV_PREVIEW_TOGGLE, ge), window.addEventListener(s.MFV_NODESTREAM_TOGGLE, _e), window.addEventListener(s.MFV_POPOUT, ve), window.addEventListener(s.SETTINGS_CHANGED, be), window.addEventListener("keydown", Q, !0), window.addEventListener("beforeunload", ye), X = !0);
}
function Ce() {
if (typeof window > "u" || !window?.removeEventListener) {
@@ -1,8 +1,8 @@
import { t as e } from "./rolldown-runtime-Dy4uBu1J.js";
import { m as t, o as n } from "./events-DrYpMD-I.js";
import { h as r } from "./Viewer-DXsFcJRb.js";
import { i, n as a, r as o, t as s, u as c } from "./SidebarWorkflowSection-BEJKYrrz.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-BlSRoWcG.js";
import { m as t, o as n } from "./events-CoI83paj.js";
import { h as r } from "./Viewer-DWgaGos-.js";
import { i, n as a, r as o, t as s, u as c } from "./SidebarWorkflowSection-DpoPGJrq.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-BOf2ACk_.js";
//#region ui/vue/components/viewer/ViewerMetadataBlock.vue?vue&type=script&setup=true&lang.ts
var y = { style: {
display: "flex",
@@ -109,7 +109,7 @@ var y = { style: {
let t = Object.entries(e || {});
if (!t.length) return !1;
let n = 0;
for (let [, e] of t.slice(0, 50)) if (!(!e || typeof e != "object") && (e.inputs && typeof e.inputs == "object" && (n += 1), n >= 2)) return !0;
for (let [, e] of t.slice(0, 50)) if (e && typeof e == "object" && (e.inputs && typeof e.inputs == "object" && (n += 1), n >= 2)) return !0;
} catch {
return !1;
}
@@ -22,7 +22,7 @@ function t(e) {
let t = Object.entries(e || {});
if (!t.length) return !1;
let n = 0;
for (let [, e] of t.slice(0, 50)) if (!(!e || typeof e != "object") && (e.inputs && typeof e.inputs == "object" && (n += 1), n >= 2)) return !0;
for (let [, e] of t.slice(0, 50)) if (e && typeof e == "object" && (e.inputs && typeof e.inputs == "object" && (n += 1), n >= 2)) return !0;
return !1;
} catch {
return !1;
@@ -195,7 +195,7 @@ function l(e, t, n) {
}
function u(e, t) {
let n = Number(e), r = Number(t);
if (!(!Number.isFinite(n) || !Number.isFinite(r))) return n + r;
if (Number.isFinite(n) && Number.isFinite(r)) return n + r;
}
function d(e, t) {
if (e == null || e === "") return "";
@@ -227,7 +227,7 @@ function p(e, t, n) {
}
function m(e) {
let t = {};
for (let [n, r] of Object.entries(e || {})) r != null && r !== "" && (Array.isArray(r) && r.length === 0 || r && typeof r == "object" && !Array.isArray(r) && Object.keys(r).length === 0 || (t[n] = r));
for (let [n, r] of Object.entries(e || {})) r != null && r !== "" && (Array.isArray(r) && r.length === 0 || (!r || typeof r != "object" || Array.isArray(r) || Object.keys(r).length !== 0) && (t[n] = r));
return t;
}
function h(e) {
@@ -495,7 +495,7 @@ function R(r) {
let i = r.geninfo || r.GenInfo || r.generation || null;
if (i && typeof i == "object") {
let e = {}, t = /* @__PURE__ */ new Set(), n = (e, n) => {
if (!(!n || typeof n != "object") && (n.confidence === "override" || n.source === "majoor_geninfo")) for (let n of Array.isArray(e) ? e : [e]) t.add(n);
if (n && typeof n == "object" && (n.confidence === "override" || n.source === "majoor_geninfo")) for (let n of Array.isArray(e) ? e : [e]) t.add(n);
}, a = i.positive?.value ?? i.positive?.text ?? null, o = i.negative?.value ?? i.negative?.text ?? null;
typeof a == "string" && a.trim() && (e.prompt = a), typeof o == "string" && o.trim() && (e.negative_prompt = o), n("prompt", i.positive), n("negative_prompt", i.negative);
let s = i.checkpoint?.name ?? i.checkpoint ?? null;
@@ -669,7 +669,7 @@ function H(e, t) {
return null;
}
function U(e, t) {
if (!(!t || typeof t != "object")) {
if (t && typeof t == "object") {
for (let [n, r] of Object.entries(t)) if (r != null && r !== "") {
if (Array.isArray(r) && Array.isArray(e[n])) {
e[n] = G(e[n], r);
@@ -25,7 +25,7 @@ function a(e, t = e?.graph) {
return r ? `${r}:${n}` : n;
}
function o(e, t, n, r) {
!n || typeof n != "object" || t.has(n) || (t.add(n), e.push({
n && typeof n == "object" && !t.has(n) && (t.add(n), e.push({
graph: n,
label: r
}));
@@ -908,7 +908,7 @@ function vn(e, n, i = t) {
};
}
let C = b ? Array(e.length).fill(mn) : mn, w = (e) => {
if (!(!(m.flags & 1) || !m.dirty && !e)) {
if (m.flags & 1 && (m.dirty || e)) {
if (n) {
let t = m.run();
if (e || o || y || (b ? t.some((e, t) => oe(e, C[t])) : oe(t, C))) {
@@ -2442,8 +2442,8 @@ function _a(e, i) {
m & 8 ? (u & 16 && ye(l, i, a), d !== l && p(n, d)) : u & 16 ? m & 16 ? me(l, d, n, r, i, a, o, s, c) : ye(l, i, a, !0) : (u & 8 && p(n, ""), m & 16 && T(d, n, r, i, a, o, s, c));
}, pe = (e, t, r, i, a, o, s, c, l) => {
e ||= n, t ||= n;
let u = e.length, d = t.length, f = Math.min(u, d), p;
for (p = 0; p < f; p++) {
let u = e.length, d = t.length, f = Math.min(u, d), p = 0;
for (; p < f; p++) {
let n = t[p] = l ? Ka(t[p]) : Ga(t[p]);
v(e[p], n, r, null, a, o, s, c, l);
}
@@ -3033,7 +3033,7 @@ var bo = vo ? (e) => vo.createHTML(e) : (e) => e, xo = "http://www.w3.org/2000/s
},
insertStaticContent(e, t, n, r, i, a) {
let o = n ? n.previousSibling : t.lastChild;
if (i && (i === a || i.nextSibling)) for (; t.insertBefore(i.cloneNode(!0), n), !(i === a || !(i = i.nextSibling)););
if (i && (i === a || i.nextSibling)) for (; t.insertBefore(i.cloneNode(!0), n), i !== a && (i = i.nextSibling););
else {
wo.innerHTML = bo(r === "svg" ? `<svg>${e}</svg>` : r === "mathml" ? `<math>${e}</math>` : e);
let i = wo.content;
@@ -3318,7 +3318,7 @@ function ys(e, t) {
}
var bs = (e) => e.charCodeAt(0) === 111 && e.charCodeAt(1) === 110 && e.charCodeAt(2) > 96 && e.charCodeAt(2) < 123, xs = (e, t, n, r, i, s) => {
let c = i === "svg";
t === "class" ? Ko(e, r, c) : t === "style" ? $o(e, n, r) : a(t) ? o(t) || fs(e, t, n, r, s) : (t[0] === "." ? (t = t.slice(1), !0) : t[0] === "^" ? (t = t.slice(1), !1) : Ss(e, t, r, c)) ? (cs(e, t, r), !e.tagName.includes("-") && (t === "value" || t === "checked" || t === "selected") && ss(e, t, r, c, s, t !== "value")) : e._isVueCE && (Cs(e, t) || e._def.__asyncLoader && (/[A-Z]/.test(t) || !g(r))) ? cs(e, T(t), r, s, t) : (t === "true-value" ? e._trueValue = r : t === "false-value" && (e._falseValue = r), ss(e, t, r, c));
t === "class" ? Ko(e, r, c) : t === "style" ? $o(e, n, r) : a(t) ? o(t) || fs(e, t, n, r, s) : (t[0] === "." ? (t = t.slice(1), 1) : t[0] === "^" ? (t = t.slice(1), 0) : Ss(e, t, r, c)) ? (cs(e, t, r), !e.tagName.includes("-") && (t === "value" || t === "checked" || t === "selected") && ss(e, t, r, c, s, t !== "value")) : e._isVueCE && (Cs(e, t) || e._def.__asyncLoader && (/[A-Z]/.test(t) || !g(r))) ? cs(e, T(t), r, s, t) : (t === "true-value" ? e._trueValue = r : t === "false-value" && (e._falseValue = r), ss(e, t, r, c));
};
function Ss(e, t, n, r) {
if (r) return !!(t === "innerHTML" || t === "textContent" || t in e && bs(t) && h(n));
@@ -3407,7 +3407,7 @@ function Ms(e, t, n) {
}
function Ns(e, t) {
let n = e.multiple, r = d(t);
if (!(n && !r && !p(t))) {
if (!n || r || p(t)) {
for (let i = 0, a = e.options.length; i < a; i++) {
let a = e.options[i], o = Ps(a);
if (n) {
@@ -4818,7 +4818,7 @@ function ru(e) {
el: i,
css: r,
unload: function() {
!s || !n.value || (E(), Vc(i.value) && s.head.removeChild(i.value), n.value = !1, i.value = null);
s && n.value && (E(), Vc(i.value) && s.head.removeChild(i.value), n.value = !1, i.value = null);
},
load: ie,
isLoaded: /* @__PURE__ */ Gt(n)
@@ -6420,7 +6420,7 @@ var Qd = Vd.extend("ripple", {
},
onMouseDown: function(e) {
var t = this, n = e.currentTarget, r = this.getInk(n);
if (!(!r || getComputedStyle(r, null).display === "none")) {
if (r && getComputedStyle(r, null).display !== "none") {
if (!this.isUnstyled() && Dc(r, "p-ink-active"), r.setAttribute("data-p-ink-active", "false"), !Xc(r) && !nl(r)) {
var i = Math.max(Rc(n), $c(n));
r.style.height = i + "px", r.style.width = i + "px";
@@ -11262,7 +11262,7 @@ var ag = {
bindOutsideClickListener: function() {
var e = this;
this.outsideClickListener || (this.outsideClickListener = function(t) {
var n = e.container && !e.container.contains(t.target), r = !(e.target && (e.target === t.target || e.target.contains(t.target)));
var n = e.container && !e.container.contains(t.target), r = !e.target || e.target !== t.target && !e.target.contains(t.target);
e.overlayVisible && n && r ? e.hide() : !e.popup && n && r && (e.focusedOptionIndex = -1);
}, document.addEventListener("click", this.outsideClickListener, !0));
},
@@ -1,4 +1,4 @@
import { $ as e, F as t, I as n, K as r, L as i, Q as a, T as o, X as s, Z as c, et as l, nt as u, ot as d, rt as f, st as p, tt as m } from "./mjr-primevue-BlSRoWcG.js";
import { $ as e, F as t, I as n, K as r, L as i, Q as a, T as o, X as s, Z as c, et as l, nt as u, ot as d, rt as f, st as p, tt as m } from "./mjr-primevue-BOf2ACk_.js";
//#region node_modules/pinia/dist/pinia.js
var h = typeof window < "u", g, _ = (e) => g = e, v = () => t() && n(y) || g, y = Symbol();
function b(e) {
@@ -1,9 +1,9 @@
import { t as e } from "./rolldown-runtime-Dy4uBu1J.js";
import { m as t, mt as n, o as r, r as i } from "./events-DrYpMD-I.js";
import { m as t, mt as n, o as r, r as i } from "./events-CoI83paj.js";
import { n as a } from "./state-DPiaUMw1.js";
//#region ui/features/viewer/model3dCore.ts
function o(e, t) {
if (!(!e || typeof t != "function")) try {
if (e && typeof t == "function") try {
e.traverse?.((e) => {
(e?.isMesh || e?.isPoints || e?.isLine) && t(e);
});
@@ -1093,7 +1093,7 @@ function F(e, a, o = {}) {
let n = Math.max(1e-4, (Number(e) || 1) / Math.max(1, Number(t) || 1)), r = Ce * n;
V.left = -r / 2, V.right = r / 2, V.top = Ce / 2, V.bottom = -Ce / 2, V.updateProjectionMatrix();
}, Be = () => {
if (!(!G || !H || !K)) {
if (G && H && K) {
if (q === A.ORTHOGRAPHIC) {
let e = _.getBoundingClientRect();
Ce = m(V, H, K, Math.max(1e-4, (e.width || 1) / Math.max(1, e.height || 1))) || Ce, z = V;
@@ -1147,7 +1147,7 @@ function F(e, a, o = {}) {
}
});
}, Ue = (e) => {
!Se || !I || (u(Te), l(I, Se, e, we, Te), Fe());
Se && I && (u(Te), l(I, Se, e, we, Te), Fe());
}, We = (e) => {
if (!G) return;
let t = ce[e] || [
@@ -1201,9 +1201,9 @@ function F(e, a, o = {}) {
P.progressSlider.max = String(e);
} catch {}
}, Je = () => {
!X || !Z.length || (je ? (X.timeScale = 0, je = !1, P.playBtn.textContent = t("model3d.play", "Play")) : (X.timeScale = Ae, Q[$]?.isRunning?.() ? (je = !0, P.playBtn.textContent = t("model3d.pause", "Pause")) : qe($)));
X && Z.length && (je ? (X.timeScale = 0, je = !1, P.playBtn.textContent = t("model3d.play", "Play")) : (X.timeScale = Ae, Q[$]?.isRunning?.() ? (je = !0, P.playBtn.textContent = t("model3d.pause", "Pause")) : qe($)));
}, Ye = (e, t) => {
!e || e.length === 0 || !I || (Z = e, X = new I.AnimationMixer(t), ke = new I.Clock(), Q = Array(e.length).fill(null), P.animSel.innerHTML = "", e.forEach((e, t) => {
e && e.length !== 0 && I && (Z = e, X = new I.AnimationMixer(t), ke = new I.Clock(), Q = Array(e.length).fill(null), P.animSel.innerHTML = "", e.forEach((e, t) => {
let n = document.createElement("option");
n.value = String(t), n.textContent = e.name || `Animation ${t + 1}`, P.animSel.appendChild(n);
}), P.animSel.style.display = e.length > 1 ? "" : "none", P.bar.style.display = "flex", M.style.bottom = "54px", qe(0), P.playBtn.addEventListener("click", (e) => {
@@ -1597,7 +1597,7 @@ function F(e, a, o = {}) {
}
Fe();
let de = () => {
if (!(F || ye || !L || !R || !z)) {
if (!F && !ye && L && R && z) {
try {
if (H?.update?.(), X && ke) {
let e = ke.getDelta();
@@ -1,6 +1,6 @@
import { A as e, E as t, J as n, R as r, S as i, Y as a, b as o, j as s, m as c } from "./events-DrYpMD-I.js";
import { a as l, n as u } from "./graphTraversal-BIYmTTPW.js";
import { S as d, Y as f, b as p, x as m, y as h } from "./SidebarWorkflowSection-BEJKYrrz.js";
import { A as e, E as t, J as n, R as r, S as i, Y as a, b as o, j as s, m as c } from "./events-CoI83paj.js";
import { a as l, n as u } from "./graphTraversal-C94Q5bJN.js";
import { S as d, Y as f, b as p, x as m, y as h } from "./SidebarWorkflowSection-DpoPGJrq.js";
//#region ui/features/viewer/floatingViewerProgress.ts
var g = "progress-update", _ = "__MJR_MFV_PROGRESS_SERVICE__";
function ee() {
@@ -100,7 +100,7 @@ var te = class {
return r ? (this._attachApi(r), r) : null;
}
_attachApi(e) {
!e || typeof e.addEventListener != "function" || this._api !== e && (this.dispose({
e && typeof e.addEventListener == "function" && this._api !== e && (this.dispose({
resetPatchedQueuePrompt: !1,
keepState: !0
}), this._api = e, this._patchQueuePrompt(e), this._attachApiListeners(e));
@@ -340,7 +340,7 @@ function k(e) {
let t = fe(e), n = e?.properties && typeof e.properties == "object" ? e.properties : null;
if (Array.isArray(e?._mjrSubgraphProxyParams)) for (let n of e._mjrSubgraphProxyParams) {
let e = String(n?.label || n?.key || "").trim();
!e || t.some(([t]) => String(t) === e) || t.push([e, n?.value]);
e && !t.some(([t]) => String(t) === e) && t.push([e, n?.value]);
}
if (n) for (let [e, r] of Object.entries(n)) ge(e) || r != null && typeof r != "object" && t.push([e, r]);
return t.slice(0, 160);
@@ -370,7 +370,7 @@ function A(e) {
});
}
function pe(e) {
return !!(e && typeof e == "object" && !Array.isArray(e) && !j(e));
return !(!e || typeof e != "object" || Array.isArray(e) || j(e));
}
function me(e) {
if (Array.isArray(e)) return e;
@@ -415,7 +415,7 @@ function P(e) {
let n = [];
for (let e of ["required", "optional"]) {
let r = t[e];
if (!(!r || typeof r != "object")) for (let [e, t] of Object.entries(r)) I(t) && n.push(e);
if (r && typeof r == "object") for (let [e, t] of Object.entries(r)) I(t) && n.push(e);
}
return n.length ? n : N(e);
}
@@ -642,7 +642,7 @@ function Oe(e, t) {
let d = u.find(([e]) => V(e) === V(c)) || u.find(([e]) => V(e) === "value") || (u.length === 1 ? u[0] : null);
if (!d) continue;
let f = `${String(s)}:${je(t, c, d[0])}`, p = a.get(f) || a.get(String(s)) || Me(l, d[0], c);
i.size && !i.has(V(p)) || o.push({
(!i.size || i.has(V(p))) && o.push({
label: p,
value: d[1],
innerNodeId: s,
@@ -666,7 +666,7 @@ function Ae(e) {
let n = Array.isArray(e) ? e[1] : e?.origin_id ?? e?.originId ?? e?.from;
if (String(n) !== "-10") continue;
let i = Number(Array.isArray(e) ? e[2] : e?.origin_slot ?? e?.originSlot ?? e?.fromSlot), a = Array.isArray(e) ? e[3] : e?.target_id ?? e?.targetId ?? e?.to, o = Number(Array.isArray(e) ? e[4] : e?.target_slot ?? e?.targetSlot ?? e?.toSlot), s = Number.isFinite(i) ? t[i] : null, c = String(s?.label || s?.localized_name || s?.name || "").trim();
!c || a == null || V(c) !== "value" && (r.set(String(a), c), Number.isFinite(o) && r.set(`${String(a)}:${o}`, c));
c && a != null && V(c) !== "value" && (r.set(String(a), c), Number.isFinite(o) && r.set(`${String(a)}:${o}`, c));
}
return r;
}
@@ -1,5 +1,5 @@
import { i as e } from "./model3dRenderer-CWRtZrSS.js";
import { i as t, o as n } from "./geninfoParser-DS9m_bHm.js";
import { i as e } from "./model3dRenderer-e5uWwIMW.js";
import { i as t, o as n } from "./geninfoParser-Bwtqk4p6.js";
//#region ui/features/viewer/sideBySide.ts
function r(e) {
if (!e) return null;
@@ -143,7 +143,7 @@ function o(e) {
for (let t = 0; t < r.length; t++) {
if (t === e) continue;
let a = r[t], o = a._mjr3D.camera, s = a._mjr3D.controls;
!o || !s || (o.position.copy(n.position), o.quaternion.copy(n.quaternion), n.zoom !== void 0 && (o.zoom = n.zoom), o.updateProjectionMatrix(), s.target.copy(i.target), s.update());
o && s && (o.position.copy(n.position), o.quaternion.copy(n.quaternion), n.zoom !== void 0 && (o.zoom = n.zoom), o.updateProjectionMatrix(), s.target.copy(i.target), s.update());
}
} catch (e) {
console.debug?.(e);
@@ -1,6 +1,6 @@
import { t as e } from "./rolldown-runtime-Dy4uBu1J.js";
import { r as t } from "./events-DrYpMD-I.js";
import { t as n } from "./Viewer-DXsFcJRb.js";
import { r as t } from "./events-CoI83paj.js";
import { t as n } from "./Viewer-DWgaGos-.js";
//#region ui/features/viewer/viewerOpenRequest.ts
var r = /* @__PURE__ */ e({ requestViewerOpen: () => o });
function i(e) {
File diff suppressed because it is too large Load Diff
+6093 -6049
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -184,6 +184,14 @@ Use that line as the quickest confirmation that the current browser session can
## File System Settings
### Input Directory
- **UI location**: Settings -> Majoor Assets Manager -> Advanced -> Paths / Input -> Input Directory
- **Purpose**: Overrides the folder indexed and browsed by the Inputs tab. Choose an existing directory.
- **Default behavior**: Leave empty to use ComfyUI's input directory, including `--input-directory`.
- **After changing it**: Select **Inputs**, then click **Scan** in **Index Status**. The colored status dot also starts a scan.
- **Security**: The configured input root is allowlisted; files outside configured roots remain blocked.
### Workflow Roots
- **UI location**: Settings -> Majoor Assets Manager -> Advanced -> Paths / Workflows
+6 -12
View File
@@ -121,6 +121,8 @@ class ComfyCoreAdapter:
safe_type = str(item_type or "").strip().lower()
if not safe_type:
return None
if safe_type == "input":
return self.get_input_directory()
try:
import folder_paths # type: ignore
@@ -133,19 +135,11 @@ class ComfyCoreAdapter:
logger.debug("Failed to resolve ComfyUI directory type=%s", safe_type, exc_info=True)
return None
def get_input_directory(self) -> str | None:
"""Return ComfyUI input directory when folder_paths is available."""
try:
import folder_paths # type: ignore
def get_input_directory(self) -> str:
"""Return the managed input root, falling back to ComfyUI's live root."""
from ..config import get_runtime_input_root
getter = getattr(folder_paths, "get_input_directory", None)
if callable(getter):
value = getter()
if value:
return str(value)
except Exception:
logger.debug("Failed to resolve ComfyUI input directory", exc_info=True)
return None
return get_runtime_input_root()
def get_output_directory(self) -> str | None:
"""Return ComfyUI output directory when folder_paths is available."""
+45 -1
View File
@@ -21,7 +21,7 @@ The **first** strategy that returns a valid directory wins:
A log line at startup (level INFO) reports which strategy was selected.
Input/index directory resolution follows a similar pattern; see
``_resolve_index_dir()``.
``get_runtime_input_root()`` and ``_resolve_index_dir()``.
"""
import contextlib
@@ -39,6 +39,8 @@ logger = logging.getLogger(__name__)
_INIT_LOCK = threading.Lock()
_DIRS_INITIALIZED = False
_OUTPUT_DIR_OVERRIDE_FILE_PATH = Path(__file__).resolve().parents[1] / ".mjr_output_directory_override"
_INPUT_DIR_OVERRIDE_FILE_PATH = Path(__file__).resolve().parents[1] / ".mjr_input_directory_override"
_INPUT_DIR_OVERRIDE_ENV_NAMES = ("MJR_AM_INPUT_DIRECTORY", "MAJOOR_INPUT_DIRECTORY")
_INDEX_DIR_OVERRIDE_ENV_NAMES = ("MJR_AM_INDEX_DIRECTORY", "MAJOOR_INDEX_DIRECTORY")
_INDEX_DIR_OVERRIDE_FILE_PATH = Path(__file__).resolve().parents[1] / ".mjr_index_directory_override"
_INDEX_DIR_LOGGED_SELECTIONS: set[tuple[str, str]] = set()
@@ -127,6 +129,48 @@ def _read_output_dir_override_from_file() -> str | None:
def _read_index_dir_override_from_file() -> str | None:
return _read_override_from_file(_INDEX_DIR_OVERRIDE_FILE_PATH)
def get_runtime_input_root() -> str:
"""Resolve the managed override, then ComfyUI's live input root and CLI hints."""
override = _env_raw(*_INPUT_DIR_OVERRIDE_ENV_NAMES) or _read_override_from_file(_INPUT_DIR_OVERRIDE_FILE_PATH)
if override:
try:
return str(Path(override).expanduser().resolve())
except (OSError, RuntimeError, ValueError):
logger.warning("Invalid input directory override, using ComfyUI defaults")
try:
import folder_paths # type: ignore
getter = getattr(folder_paths, "get_input_directory", None)
value = getter() if callable(getter) else getattr(folder_paths, "input_directory", None)
if value:
return str(Path(value).expanduser().resolve())
except Exception:
logger.debug("Failed to resolve live ComfyUI input directory", exc_info=True)
try:
from comfy.cli_args import args as comfy_args # type: ignore
value = getattr(comfy_args, "input_directory", None)
if value:
return str(Path(value).expanduser().resolve())
except Exception:
logger.debug("ComfyUI input CLI settings unavailable", exc_info=True)
try:
argv = list(sys.argv or [])
for i, token in enumerate(argv):
if token == "--input-directory" and i + 1 < len(argv):
return str(Path(argv[i + 1]).expanduser().resolve())
if isinstance(token, str) and token.startswith("--input-directory="):
value = token.split("=", 1)[1].strip()
if value:
return str(Path(value).expanduser().resolve())
except (OSError, RuntimeError, TypeError, ValueError):
logger.debug("Invalid input directory CLI argument", exc_info=True)
fp_mod = sys.modules.get("folder_paths")
base = getattr(fp_mod, "base_path", None) or _detect_comfy_root(Path(__file__).resolve())
return str((Path(base) / "input" if base else Path.cwd() / "input").resolve())
def _resolve_output_root() -> Path:
resolved = _resolve_output_root_from_env()
if resolved is not None:
@@ -13,6 +13,10 @@ async def apply_startup_settings(
) -> None:
startup_steps = (
("Security bootstrap failed: %s", "ensure_security_bootstrap"),
(
"Input directory override restore failed: %s",
"apply_input_directory_override_on_startup",
),
(
"Output directory override restore failed: %s",
"apply_output_directory_override_on_startup",
+91
View File
@@ -745,6 +745,97 @@ def register_health_routes(routes: web.RouteTableDef) -> None:
),
}))
@routes.get("/mjr/am/settings/input-directory")
async def get_input_directory_setting(request):
user_auth = _require_authenticated_user(request)
if not user_auth.ok:
return _json_response(
Result.Err(user_auth.code or "AUTH_REQUIRED", user_auth.error or "Authentication required"),
status=401,
)
svc, error_result = await _require_services()
if error_result:
return _json_response(error_result)
settings_service = svc.get("settings")
if not settings_service:
return _json_response(Result.Err("SERVICE_UNAVAILABLE", "Settings service unavailable"))
value = await settings_service.get_input_directory()
return _json_response(Result.Ok({"input_directory": value or ""}))
@routes.post("/mjr/am/settings/input-directory")
async def update_input_directory_setting(request):
csrf = _csrf_error(request)
if csrf:
return _json_response(Result.Err("CSRF", csrf))
auth = _require_write_access(request)
if not auth.ok:
return _json_response(auth)
svc, error_result = await _require_services()
if error_result:
return _json_response(error_result)
settings_service = svc.get("settings")
if not settings_service:
return _json_response(Result.Err("SERVICE_UNAVAILABLE", "Settings service unavailable"))
body_res = await _read_json(request)
if not body_res.ok:
return _json_response(body_res)
raw_value = (body_res.data or {}).get("input_directory")
if raw_value is not None and not isinstance(raw_value, str):
return _json_response(Result.Err("INVALID_INPUT", "input_directory must be a string"))
value = (raw_value or "").strip()
if value:
try:
normalized_path = Path(value).expanduser().resolve(strict=True)
if not normalized_path.is_dir():
return _json_response(Result.Err("INVALID_INPUT", "input_directory must be a directory"))
value = str(normalized_path)
except (OSError, RuntimeError, ValueError):
return _json_response(Result.Err("INVALID_INPUT", "input_directory must be an existing directory"))
old_input_dir = get_input_directory()
result = await settings_service.set_input_directory(value)
if not result.ok:
return _json_response(result)
new_input_dir = get_input_directory()
watcher = svc.get("watcher")
if watcher and old_input_dir != new_input_dir:
try:
# Watcher registrations are keyed by path, not by source.
if Path(old_input_dir).resolve() != Path(get_runtime_output_root()).resolve():
watcher.remove_path(old_input_dir)
except Exception:
logger.debug("Input directory watcher removal failed", exc_info=True)
try:
watcher.add_path(new_input_dir, source="input", root_id=None)
except Exception:
logger.debug("Input directory watcher registration failed", exc_info=True)
try:
await _invalidate_fs_list_cache()
except Exception:
logger.debug("Input directory listing cache invalidation failed", exc_info=True)
if new_input_dir and new_input_dir != old_input_dir:
try:
await _kickoff_background_scan(
new_input_dir,
source="input",
root_id=None,
recursive=True,
incremental=True,
respect_bg_scan_on_list=False,
)
except Exception:
logger.debug("Input directory initial scan failed", exc_info=True)
response_result = Result.Ok({"input_directory": result.data})
await _audit_settings_write(
svc,
request,
"settings_input_directory",
"settings:input_directory",
response_result,
previous=old_input_dir,
current=str(result.data or ""),
)
return _json_response(response_result)
@routes.get("/mjr/am/settings/output-directory")
async def get_output_directory_setting(request):
user_auth = _require_authenticated_user(request)
+61
View File
@@ -24,6 +24,8 @@ from typing import Any
from mjr_am_shared.runtime_env import get_env, set_env, unset_env
from .config import (
_INPUT_DIR_OVERRIDE_ENV_NAMES,
_INPUT_DIR_OVERRIDE_FILE_PATH,
_OUTPUT_DIR_OVERRIDE_FILE_PATH,
MEDIA_PROBE_BACKEND,
OUTPUT_ROOT,
@@ -42,6 +44,7 @@ logger = get_logger(__name__)
_PROBE_BACKEND_KEY = "media_probe_backend"
_OUTPUT_DIRECTORY_KEY = "output_directory_override"
_INPUT_DIRECTORY_KEY = "input_directory_override"
_WORKFLOW_ROOTS_KEY = "workflow_roots"
_METADATA_FALLBACK_IMAGE_KEY = "metadata_fallback_image"
_METADATA_FALLBACK_MEDIA_KEY = "metadata_fallback_media"
@@ -1515,6 +1518,64 @@ class AppSettings:
"media": parse_bool(self._cache.get(media_key), self._default_metadata_fallback_media),
}
async def get_input_directory(self) -> str | None:
"""Return the persisted input directory override, or None when unset."""
async with self._lock:
return await self._read_setting(_INPUT_DIRECTORY_KEY) or None
async def set_input_directory(self, path: str) -> Result[str]:
"""Persist a validated input override without changing ComfyUI globals."""
from pathlib import Path
normalized = str(path or "").strip()
if normalized:
try:
target = Path(normalized).expanduser().resolve(strict=True)
if not target.is_dir():
return Result.Err("INVALID_INPUT", "input_directory must be a directory")
normalized = str(target)
except (OSError, RuntimeError, ValueError):
return Result.Err("INVALID_INPUT", "input_directory must be an existing directory")
async with self._lock:
result = (
await self._write_setting(_INPUT_DIRECTORY_KEY, normalized)
if normalized
else await self._delete_setting(_INPUT_DIRECTORY_KEY)
)
if not result.ok:
return Result.Err("DB_ERROR", result.error or "Failed to persist input directory")
self._apply_input_directory_override(normalized)
try:
if normalized:
_INPUT_DIR_OVERRIDE_FILE_PATH.write_text(normalized + "\n", encoding="utf-8")
else:
_INPUT_DIR_OVERRIDE_FILE_PATH.unlink(missing_ok=True)
except OSError:
logger.warning("Failed to update input directory override file", exc_info=True)
await self._warn_if_bump_fails("Failed to bump settings version")
return Result.Ok(normalized)
def _apply_input_directory_override(self, normalized: str) -> None:
for name in _INPUT_DIR_OVERRIDE_ENV_NAMES:
if normalized:
set_env(name, normalized)
else:
unset_env(name)
async def apply_input_directory_override_on_startup(self) -> None:
"""Restore the persisted override before scanning or handling requests."""
from pathlib import Path
try:
async with self._lock:
raw = await self._read_setting(_INPUT_DIRECTORY_KEY)
if raw:
target = Path(raw).expanduser().resolve(strict=True)
if target.is_dir():
self._apply_input_directory_override(str(target))
except (OSError, RuntimeError, ValueError) as exc:
logger.warning("Failed to restore input directory override on startup: %s", exc)
async def get_output_directory(self) -> str | None:
"""Return persisted output directory override, or None when unset."""
async with self._lock:
+67
View File
@@ -1,6 +1,7 @@
import sys
from types import SimpleNamespace
import pytest
from mjr_am_backend.adapters.comfy_core import ComfyCoreAdapter
@@ -140,3 +141,69 @@ def test_comfy_core_adapter_base_path_none_when_unavailable(monkeypatch):
adapter = ComfyCoreAdapter()
assert adapter.get_base_path() is None
@pytest.mark.parametrize(
"source",
["env", "file", "live", "attribute", "cli", "argv", "argv_equals", "broken_live", "base", "fallback"],
)
def test_input_directory_resolution_sources(monkeypatch, tmp_path, source):
from mjr_am_backend import config
target = tmp_path / "custom-input"
override_file = tmp_path / "override"
monkeypatch.setattr(config, "_INPUT_DIR_OVERRIDE_FILE_PATH", override_file)
for name in config._INPUT_DIR_OVERRIDE_ENV_NAMES:
monkeypatch.delenv(name, raising=False)
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace())
monkeypatch.setitem(sys.modules, "comfy.cli_args", SimpleNamespace(args=SimpleNamespace()))
monkeypatch.setattr(sys, "argv", ["main.py"])
monkeypatch.setattr(config, "_detect_comfy_root", lambda _: None)
monkeypatch.chdir(tmp_path)
if source == "env":
monkeypatch.setenv("MAJOOR_INPUT_DIRECTORY", str(target))
override_file.write_text(str(tmp_path / "lower-priority"))
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace(get_input_directory=lambda: "ignored"))
elif source == "file":
override_file.write_text(str(target))
elif source == "live":
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace(get_input_directory=lambda: str(target)))
monkeypatch.setitem(sys.modules, "comfy.cli_args", SimpleNamespace(args=SimpleNamespace(input_directory="ignored")))
elif source == "attribute":
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace(input_directory=str(target)))
elif source == "cli":
monkeypatch.setitem(sys.modules, "comfy.cli_args", SimpleNamespace(args=SimpleNamespace(input_directory=str(target))))
elif source == "argv":
monkeypatch.setitem(sys.modules, "folder_paths", None)
monkeypatch.setattr(sys, "argv", ["main.py", "--input-directory", str(target)])
elif source == "argv_equals":
monkeypatch.setattr(sys, "argv", ["main.py", f"--input-directory={target}"])
elif source == "broken_live":
def broken_getter():
raise RuntimeError("ComfyUI not initialized")
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace(get_input_directory=broken_getter))
monkeypatch.setattr(sys, "argv", ["main.py", "--input-directory", str(target)])
elif source == "base":
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace(base_path=str(tmp_path)))
target = tmp_path / "input"
else:
target = tmp_path / "input"
adapter = ComfyCoreAdapter()
assert adapter.get_input_directory() == str(target)
assert adapter.get_directory_by_type("input") == str(target)
def test_input_directory_tracks_live_changes_without_override(monkeypatch, tmp_path):
from mjr_am_backend import config
monkeypatch.setattr(config, "_INPUT_DIR_OVERRIDE_FILE_PATH", tmp_path / "absent")
for name in config._INPUT_DIR_OVERRIDE_ENV_NAMES:
monkeypatch.delenv(name, raising=False)
module = SimpleNamespace(input_directory=str(tmp_path / "first"))
module.get_input_directory = lambda: module.input_directory
monkeypatch.setitem(sys.modules, "folder_paths", module)
adapter = ComfyCoreAdapter()
assert adapter.get_input_directory() == str(tmp_path / "first")
module.input_directory = str(tmp_path / "second")
assert adapter.get_input_directory() == str(tmp_path / "second")
+110
View File
@@ -378,6 +378,116 @@ async def test_update_output_directory_real_csrf_and_auth_guards(monkeypatch) ->
assert body_missing_auth.get("code") == "AUTH_REQUIRED"
@pytest.mark.asyncio
async def test_input_directory_real_security_guards(monkeypatch):
from types import SimpleNamespace
from mjr_am_backend.routes.core import security_auth_context
monkeypatch.setenv("MAJOOR_REQUIRE_AUTH", "1")
for name in ("MAJOOR_API_TOKEN", "MJR_API_TOKEN", "MAJOOR_API_TOKEN_HASH", "MJR_API_TOKEN_HASH"):
monkeypatch.delenv(name, raising=False)
app = _build_health_app()
monkeypatch.setattr(
security_auth_context,
"_get_comfy_user_manager",
lambda _: SimpleNamespace(enabled=True, get_request_user_id=lambda _: None),
)
for method, headers, code in (
("GET", {}, "AUTH_REQUIRED"),
("POST", {}, "CSRF"),
("POST", {"X-Requested-With": "XMLHttpRequest"}, "AUTH_REQUIRED"),
):
request = make_mocked_request(method, "/mjr/am/settings/input-directory", headers=headers, app=app)
response = await (await app.router.resolve(request)).handler(request)
assert json.loads(response.text)["code"] == code
if method == "GET":
assert response.status == 401
monkeypatch.setattr(health_mod, "_csrf_error", lambda _: None)
monkeypatch.setattr(health_mod, "_require_write_access", lambda _: Result.Err("FORBIDDEN", "Read only"))
request = make_mocked_request("POST", "/mjr/am/settings/input-directory", app=app)
response = await (await app.router.resolve(request)).handler(request)
assert json.loads(response.text)["code"] == "FORBIDDEN"
@pytest.mark.asyncio
@pytest.mark.parametrize("shared_output", [False, True])
async def test_input_directory_routes_validation_and_runtime_refresh(monkeypatch, tmp_path, shared_output):
from mjr_am_backend.adapters.comfy_core import get_input_directory
from mjr_am_backend.settings import AppSettings
from .test_settings_backend import _DB
settings = AppSettings(_DB())
override_file = tmp_path / "override"
monkeypatch.setattr("mjr_am_backend.settings._INPUT_DIR_OVERRIDE_FILE_PATH", override_file)
monkeypatch.setattr("mjr_am_backend.config._INPUT_DIR_OVERRIDE_FILE_PATH", override_file)
for name in ("MAJOOR_INPUT_DIRECTORY", "MJR_AM_INPUT_DIRECTORY"):
monkeypatch.delenv(name, raising=False)
original = get_input_directory()
output = original + "/." if shared_output else str(tmp_path / "output")
monkeypatch.setattr(health_mod, "get_runtime_output_root", lambda: output)
custom = tmp_path / "custom"
custom.mkdir()
watcher = Mock()
calls = {"scan": [], "invalidated": 0, "audit": []}
payload = {}
async def services():
return {"settings": settings, "watcher": watcher}, None
async def read_json(_):
return Result.Ok(payload)
async def scan(*args, **kwargs):
calls["scan"].append((args, kwargs))
async def invalidate():
calls["invalidated"] += 1
async def audit(_svc, **kwargs):
calls["audit"].append(kwargs)
monkeypatch.setattr(health_mod, "_require_services", services)
monkeypatch.setattr(health_mod, "_require_authenticated_user", lambda _: Result.Ok({}))
monkeypatch.setattr(health_mod, "_csrf_error", lambda _: None)
monkeypatch.setattr(health_mod, "_require_write_access", lambda _: Result.Ok({}))
monkeypatch.setattr(health_mod, "_read_json", read_json)
monkeypatch.setattr(health_mod, "_kickoff_background_scan", scan)
monkeypatch.setattr(health_mod, "_invalidate_fs_list_cache", invalidate)
monkeypatch.setattr(health_mod, "audit_log_write", audit)
app = _build_health_app()
async def request(method):
req = make_mocked_request(method, "/mjr/am/settings/input-directory", app=app)
return json.loads((await (await app.router.resolve(req)).handler(req)).text)
assert (await request("GET"))["data"] == {"input_directory": ""}
not_dir = tmp_path / "file"
not_dir.write_text("x")
for invalid in (str(tmp_path / "missing"), str(not_dir), "bad\0path", [], 123):
payload["input_directory"] = invalid
assert (await request("POST"))["code"] == "INVALID_INPUT"
assert await settings.get_input_directory() is None
assert not calls["scan"]
payload["input_directory"] = str(custom)
assert (await request("POST"))["data"] == {"input_directory": str(custom)}
assert (await request("GET"))["data"] == {"input_directory": str(custom)}
if shared_output:
watcher.remove_path.assert_not_called()
else:
watcher.remove_path.assert_called_once_with(original)
watcher.add_path.assert_called_once_with(str(custom), source="input", root_id=None)
assert calls["invalidated"] == 1
assert calls["scan"][0][0] == (str(custom),)
assert calls["scan"][0][1]["source"] == "input"
assert calls["audit"][0]["operation"] == "settings_input_directory"
payload["input_directory"] = ""
assert (await request("POST"))["data"] == {"input_directory": ""}
assert get_input_directory() == original
assert calls["scan"][-1][0] == (original,)
@pytest.mark.asyncio
async def test_update_workflow_roots_setting_success(monkeypatch, tmp_path) -> None:
settings = _Settings()
+47
View File
@@ -1575,6 +1575,53 @@ async def test_watcher_scope_restart_success(monkeypatch) -> None:
assert payload.get("data", {}).get("enabled") is True
@pytest.mark.asyncio
@pytest.mark.parametrize("configuration", ["override", "cli", "argv"])
async def test_scan_scope_input_uses_custom_directory_and_real_allowlist(monkeypatch, tmp_path, configuration):
import sys
from mjr_am_backend import config
custom = tmp_path / "custom-input"
custom.mkdir()
seen = []
class _Index:
async def scan_directory(self, directory, recursive, incremental, source, root_id, fast, background_metadata):
seen.append((Path(directory), source))
return Result.Ok({"scanned": 1, "added": 1, "updated": 0, "skipped": 0, "errors": 0})
async def services():
return {"index": _Index()}, None
async def read_json(_):
return Result.Ok({"scope": "input"})
for name in config._INPUT_DIR_OVERRIDE_ENV_NAMES:
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(config, "_INPUT_DIR_OVERRIDE_FILE_PATH", tmp_path / "absent-override")
if configuration == "override":
monkeypatch.setenv("MJR_AM_INPUT_DIRECTORY", str(custom))
else:
monkeypatch.setitem(sys.modules, "folder_paths", None)
cli_args = SimpleNamespace(input_directory=str(custom) if configuration == "cli" else None)
monkeypatch.setitem(sys.modules, "comfy.cli_args", SimpleNamespace(args=cli_args))
monkeypatch.setattr(sys, "argv", ["main.py", "--input-directory", str(custom)])
monkeypatch.setattr(scan_mod, "_require_services", services)
monkeypatch.setattr(scan_mod, "is_db_maintenance_active", lambda: False)
monkeypatch.setattr(scan_mod, "_csrf_error", lambda _: None)
monkeypatch.setattr(scan_mod, "_require_write_access", lambda _: Result.Ok({}))
monkeypatch.setattr(scan_mod, "_check_rate_limit", lambda *args, **kwargs: (True, None))
monkeypatch.setattr(scan_mod, "_read_json", read_json)
app = _app()
request = make_mocked_request("POST", "/mjr/am/scan", app=app)
response = await (await app.router.resolve(request)).handler(request)
result = json.loads(response.text)
assert result["ok"], result
assert seen[0][0] == custom
assert seen[0][1] == "input"
@pytest.mark.asyncio
async def test_scan_route_scope_output_success(monkeypatch, tmp_path: Path) -> None:
out_root = tmp_path / "out"
+36
View File
@@ -3,6 +3,7 @@
import asyncio
import datetime
import json
from pathlib import Path
import pytest
from aiohttp import web
@@ -917,6 +918,41 @@ async def test_autocomplete_invalid_limit_and_service_error(monkeypatch) -> None
assert json.loads(resp.text).get("code") == "SERVICE_UNAVAILABLE"
@pytest.mark.asyncio
async def test_list_input_uses_custom_directory_without_folder_paths_patch(monkeypatch, tmp_path):
custom = tmp_path / "custom-input"
custom.mkdir()
seen = []
class _Index:
async def search_scoped(self, *args, **kwargs):
return Result.Err("DB", "Use filesystem fallback")
async def services():
return {"index": _Index()}, None
async def filesystem(root, *args, **kwargs):
seen.append(Path(root))
return Result.Ok({"assets": [{"filename": "custom.png", "filepath": str(custom / "custom.png")}], "total": 1})
async def scan(*args, **kwargs):
return None
monkeypatch.setenv("MJR_AM_INPUT_DIRECTORY", str(custom))
monkeypatch.setattr(search_impl, "_require_services", services)
monkeypatch.setattr(search_impl, "_check_rate_limit", lambda *args, **kwargs: (True, None))
monkeypatch.setattr(search_impl, "_touch_enrichment_pause", lambda *args, **kwargs: None)
monkeypatch.setattr(search_impl, "_list_filesystem_assets", filesystem)
monkeypatch.setattr(search_impl, "_kickoff_background_scan", scan)
app = _build_search_app()
request = make_mocked_request("GET", "/mjr/am/list?scope=input&q=*&offset=0", app=app)
response = await (await app.router.resolve(request)).handler(request)
result = json.loads(response.text)
assert result["ok"]
assert seen == [custom]
assert result["data"]["assets"][0]["filename"] == "custom.png"
@pytest.mark.asyncio
async def test_list_input_fallback_filesystem(monkeypatch, tmp_path) -> None:
class _Index:
+90
View File
@@ -6,6 +6,7 @@ import pytest
from mjr_am_backend.routes.core import security as sec
from mjr_am_backend.settings import (
_AI_VERBOSE_LOGS_KEY,
_INPUT_DIRECTORY_KEY,
_METADATA_FALLBACK_IMAGE_KEY,
_METADATA_FALLBACK_MEDIA_KEY,
_OUTPUT_DIRECTORY_KEY,
@@ -421,6 +422,95 @@ async def test_output_directory_db_errors(tmp_path: Path):
assert out2.code == "DB_ERROR"
@pytest.fixture
def input_override_file(tmp_path, monkeypatch):
path = tmp_path / "input-override.txt"
monkeypatch.setattr("mjr_am_backend.settings._INPUT_DIR_OVERRIDE_FILE_PATH", path)
monkeypatch.setattr("mjr_am_backend.config._INPUT_DIR_OVERRIDE_FILE_PATH", path)
for name in ("MJR_AM_INPUT_DIRECTORY", "MAJOOR_INPUT_DIRECTORY"):
monkeypatch.delenv(name, raising=False)
return path
@pytest.mark.asyncio
async def test_input_directory_persistence_restart_clear(tmp_path, monkeypatch, input_override_file):
from types import SimpleNamespace
from mjr_am_backend.adapters.comfy_core import get_input_directory
from mjr_am_backend.features.runtime.bootstrap import apply_startup_settings
from mjr_am_backend.routes.core import paths
default = tmp_path / "cli-input"
custom = tmp_path / "custom"
default.mkdir()
custom.mkdir()
monkeypatch.setitem(sys.modules, "folder_paths", None)
monkeypatch.setitem(sys.modules, "comfy.cli_args", None)
monkeypatch.setattr(sys, "argv", ["main.py", "--input-directory", str(default)])
monkeypatch.setattr(paths, "get_runtime_output_root", lambda: str(tmp_path / "output"))
db = _DB()
settings = AppSettings(db)
assert await settings.get_input_directory() is None
assert get_input_directory() == str(default)
assert paths._is_path_allowed(default / "a.png")
saved = await settings.set_input_directory(str(custom))
assert saved.ok and saved.data == str(custom)
assert db.store[_INPUT_DIRECTORY_KEY] == str(custom)
assert input_override_file.read_text().strip() == str(custom)
assert paths._is_path_allowed(custom / "a.png")
assert not paths._is_path_allowed(default / "a.png")
assert not paths._is_path_allowed(tmp_path / "outside.png")
(custom / "escape").symlink_to(default, target_is_directory=True)
assert not paths._is_path_allowed(custom / "escape" / "a.png")
for name in ("MJR_AM_INPUT_DIRECTORY", "MAJOOR_INPUT_DIRECTORY"):
monkeypatch.delenv(name)
# The early-startup file resolves before the database is initialized.
assert get_input_directory() == str(custom)
input_override_file.unlink()
restarted = AppSettings(db)
warnings = []
await apply_startup_settings(
SimpleNamespace(apply_input_directory_override_on_startup=restarted.apply_input_directory_override_on_startup),
warn=lambda *args: warnings.append(args),
)
assert not warnings
assert await restarted.get_input_directory() == str(custom)
assert get_input_directory() == str(custom)
cleared = await restarted.set_input_directory("")
assert cleared.ok
assert _INPUT_DIRECTORY_KEY not in db.store
assert not input_override_file.exists()
assert await restarted.get_input_directory() is None
assert get_input_directory() == str(default)
assert not paths._is_path_allowed(custom / "a.png")
assert paths._is_path_allowed(default / "a.png")
@pytest.mark.asyncio
async def test_input_directory_validation_and_db_errors(tmp_path, input_override_file):
db = _DB()
settings = AppSettings(db)
not_dir = tmp_path / "file"
not_dir.write_text("x")
for invalid in (str(tmp_path / "missing"), str(not_dir), "bad\0path"):
result = await settings.set_input_directory(invalid)
assert result.code == "INVALID_INPUT"
assert _INPUT_DIRECTORY_KEY not in db.store
assert not input_override_file.exists()
db.fail_write = True
result = await settings.set_input_directory(str(tmp_path))
assert result.code == "DB_ERROR"
assert not input_override_file.exists()
db.fail_write = False
assert (await settings.set_input_directory(str(tmp_path))).ok
db.fail_delete = True
result = await settings.set_input_directory("")
assert result.code == "DB_ERROR"
assert input_override_file.exists()
assert await settings.get_input_directory() == str(tmp_path)
@pytest.mark.asyncio
async def test_workflow_roots_set_get_clear(tmp_path: Path, monkeypatch):
db = _DB()
+9
View File
@@ -124,6 +124,15 @@ export async function getOutputDirectorySetting() {
return get(ENDPOINTS.SETTINGS_OUTPUT_DIRECTORY);
}
export async function getInputDirectorySetting() {
return get(ENDPOINTS.SETTINGS_INPUT_DIRECTORY);
}
export async function setInputDirectorySetting(inputDirectory: any, options: Record<string, any> = {}) {
const value = String(inputDirectory ?? "").trim();
return post(ENDPOINTS.SETTINGS_INPUT_DIRECTORY, { input_directory: value }, options);
}
export async function setOutputDirectorySetting(outputDirectory: any, options: Record<string, any> = {}) {
const value = String(outputDirectory ?? "").trim();
return post(ENDPOINTS.SETTINGS_OUTPUT_DIRECTORY, { output_directory: value }, options);
+1
View File
@@ -77,6 +77,7 @@ export const ENDPOINTS = {
COLLECT_FILES: "/mjr/am/collect-files",
TOOLS_STATUS: "/mjr/am/tools/status",
SETTINGS_OUTPUT_DIRECTORY: "/mjr/am/settings/output-directory",
SETTINGS_INPUT_DIRECTORY: "/mjr/am/settings/input-directory",
SETTINGS_INDEX_DIRECTORY: "/mjr/am/settings/index-directory",
SETTINGS_WORKFLOW_ROOTS: "/mjr/am/settings/workflow-roots",
SETTINGS_METADATA_FALLBACK: "/mjr/am/settings/metadata-fallback",
+74
View File
@@ -9,9 +9,11 @@ import {
setMetadataFallbackSettings,
getIndexDirectorySetting,
getOutputDirectorySetting,
getInputDirectorySetting,
getWorkflowRootsSetting,
setIndexDirectorySetting,
setOutputDirectorySetting,
setInputDirectorySetting,
setWorkflowRootsSetting,
vectorStats,
vectorBackfill,
@@ -35,6 +37,7 @@ import {
} from "../i18n.js";
import { _safeNum, _safeOneOf } from "./settingsUtils.js";
import { DEFAULT_SETTINGS, saveMajoorSettings, applySettingsToConfig } from "./settingsCore.js";
import { setSettingForApp } from "../hostAdapter.js";
const SETTINGS_PREFIX = "Majoor";
const SETTINGS_CATEGORY = "Majoor Assets Manager";
@@ -151,6 +154,77 @@ export function registerAdvancedSettings(safeAddSetting: (def: any) => void, set
console.debug?.(e);
}
// -- InputDirectory ----------------------------------------------------
let inputDirCommittedValue = String(settings.paths?.inputDirectory || "");
let inputDirSaveTimer: ReturnType<typeof setTimeout> | null = null;
let inputDirSaveSeq = 0;
let inputDirSaveAbort: AbortController | null = null;
const applyInputDirectoryValue = (value: string) => {
settings.paths = settings.paths || {};
settings.paths.inputDirectory = value;
saveMajoorSettings(settings);
setSettingForApp(app, `${SETTINGS_PREFIX}.Paths.InputDirectory`, value);
notifyApplied("paths.inputDirectory");
};
safeAddSetting({
id: `${SETTINGS_PREFIX}.Paths.InputDirectory`,
category: cat(t("cat.advanced"), "Paths / Input"),
name: "Majoor: Input Directory",
tooltip:
"Override the folder indexed by the Inputs tab. Leave empty to use ComfyUI's input directory (--input-directory). Run a scan after changing it.",
type: "text",
defaultValue: String(settings.paths?.inputDirectory || ""),
attrs: { placeholder: "D:\\ComfyUI\\input" },
onChange: (value: any) => {
const next = String(value || "").trim();
if (next === String(settings.paths?.inputDirectory || "")) return;
settings.paths = settings.paths || {};
settings.paths.inputDirectory = next;
saveMajoorSettings(settings);
if (inputDirSaveTimer) clearTimeout(inputDirSaveTimer);
const seq = ++inputDirSaveSeq;
inputDirSaveAbort?.abort();
inputDirSaveTimer = setTimeout(async () => {
inputDirSaveTimer = null;
inputDirSaveAbort =
typeof AbortController !== "undefined" ? new AbortController() : null;
try {
const res = await setInputDirectorySetting(
next,
inputDirSaveAbort ? { signal: inputDirSaveAbort.signal } : {},
);
if (seq !== inputDirSaveSeq) return;
if (!res?.ok) {
throw new Error(res?.error || "Failed to set input directory");
}
const serverValue = String(res?.data?.input_directory ?? next).trim();
inputDirCommittedValue = serverValue;
applyInputDirectoryValue(serverValue);
} catch (error: any) {
if (seq !== inputDirSaveSeq) return;
if (error?.name === "AbortError" || error?.code === "ABORTED") return;
applyInputDirectoryValue(inputDirCommittedValue);
comfyToast(error?.message || "Failed to set input directory", "error");
}
}, 700);
},
});
try {
getInputDirectorySetting()
.then((res) => {
if (!res?.ok || inputDirSaveSeq !== 0) return;
const serverValue = String(res?.data?.input_directory || "").trim();
inputDirCommittedValue = serverValue;
applyInputDirectoryValue(serverValue);
})
.catch(() => {});
} catch (e) {
console.debug?.(e);
}
// -- IndexDirectory ----------------------------------------------------
let _indexDirCommittedValue = String(settings.paths?.indexDirectory || "");
+1
View File
@@ -151,6 +151,7 @@ export const DEFAULT_SETTINGS = {
},
paths: {
outputDirectory: "",
inputDirectory: "",
indexDirectory: "",
workflowRoots: "",
},
+10 -4
View File
@@ -425,6 +425,13 @@ export function createStatusIndicator(options: Record<string, any> = {}): any {
header.appendChild(statusDot);
header.appendChild(titleSpan);
const scanButton = document.createElement("button");
scanButton.id = "mjr-status-scan";
scanButton.type = "button";
scanButton.textContent = t("btn.scan", "Scan");
scanButton.style.cssText = "margin-left: auto; padding: 4px 10px; cursor: pointer;";
header.appendChild(scanButton);
// Body container
const body = document.createElement("div");
body.id = "mjr-status-body";
@@ -2595,13 +2602,12 @@ export function setupStatusPolling(
}
scheduleNext();
const statusDotEl = section.querySelector("#mjr-status-dot");
if (statusDotEl) {
statusDotEl.onclick = (event: any) => {
for (const control of section.querySelectorAll("#mjr-status-dot, #mjr-status-scan")) {
control.onclick = (event: any) => {
event.stopPropagation();
const target = typeof getScanTarget === "function" ? getScanTarget() : null;
triggerScan(statusDot, statusText, capabilitiesSection, target);
};
statusDotEl.title = t("status.clickToScan", "Click to scan");
control.title = t("btn.scan", "Scan");
}
}
+136
View File
@@ -0,0 +1,136 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAdvancedSettings } from "../app/settings/settingsAdvanced.js";
import { DEFAULT_SETTINGS, saveMajoorSettings } from "../app/settings/settingsCore.js";
import { getInputDirectorySetting, setInputDirectorySetting } from "../api/client.js";
import { comfyToast } from "../app/toast.js";
vi.mock("../api/client.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/client.js")>();
const unavailable = vi.fn().mockResolvedValue({ ok: false });
return {
...actual,
getInputDirectorySetting: vi.fn(),
setInputDirectorySetting: vi.fn(),
getOutputDirectorySetting: unavailable,
getIndexDirectorySetting: unavailable,
getWorkflowRootsSetting: unavailable,
getMetadataFallbackSettings: unavailable,
getRouteLoggingSettings: unavailable,
getStartupLoggingSettings: unavailable,
getHuggingFaceSettings: unavailable,
getAiLoggingSettings: unavailable,
vectorStats: unavailable,
};
});
vi.mock("../app/settings/settingsCore.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../app/settings/settingsCore.js")>()),
saveMajoorSettings: vi.fn(),
}));
vi.mock("../app/toast.js", () => ({ comfyToast: vi.fn(), recordToastHistory: vi.fn() }));
vi.mock("../app/i18n.js", () => ({
t: (key: string, fallback?: string) => fallback || key,
getSupportedLanguages: () => [],
getCurrentLang: () => "en",
}));
function register() {
const settings = structuredClone(DEFAULT_SETTINGS);
const definitions: any[] = [];
const notify = vi.fn();
let input: any;
const setSettingValue = vi.fn((_id, value) => input?.onChange(value));
registerAdvancedSettings(
(definition) => definitions.push(definition), settings, notify,
{ ui: { settings: { setSettingValue } } },
);
input = definitions.find((definition) => definition.id === "Majoor.Paths.InputDirectory");
return { settings, input, notify, setSettingValue };
}
describe("Input Directory setting", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
vi.mocked(getInputDirectorySetting).mockResolvedValue({
ok: true, data: { input_directory: "/custom/inputs" },
});
vi.mocked(setInputDirectorySetting).mockImplementation(async (value) => ({
ok: true, data: { input_directory: value },
}));
});
afterEach(() => vi.useRealTimers());
it("loads the persisted backend override and registers a Paths / Input field", async () => {
const { settings, input, notify, setSettingValue } = register();
await vi.advanceTimersByTimeAsync(700);
expect(input.category).toContain("Paths / Input");
expect(input.type).toBe("text");
expect(settings.paths.inputDirectory).toBe("/custom/inputs");
expect(notify).toHaveBeenCalledWith("paths.inputDirectory");
expect(setSettingValue).toHaveBeenCalledWith("Majoor.Paths.InputDirectory", "/custom/inputs");
expect(setInputDirectorySetting).not.toHaveBeenCalled();
});
it("debounces edits, trims paths, and supports clearing the override", async () => {
const { settings, input } = register();
await vi.advanceTimersByTimeAsync(0);
input.onChange("/first");
input.onChange(" /second ");
await vi.advanceTimersByTimeAsync(699);
expect(setInputDirectorySetting).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(setInputDirectorySetting).toHaveBeenCalledExactlyOnceWith(
"/second", expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(settings.paths.inputDirectory).toBe("/second");
input.onChange("");
await vi.advanceTimersByTimeAsync(700);
expect(settings.paths.inputDirectory).toBe("");
expect(saveMajoorSettings).toHaveBeenCalledWith(settings);
});
it("restores the committed value when the backend rejects a path", async () => {
const { settings, input, setSettingValue } = register();
await vi.advanceTimersByTimeAsync(0);
vi.mocked(setInputDirectorySetting).mockResolvedValue({
ok: false, data: null, error: "Directory does not exist",
});
input.onChange("/missing");
await vi.advanceTimersByTimeAsync(700);
expect(settings.paths.inputDirectory).toBe("/custom/inputs");
expect(setSettingValue).toHaveBeenLastCalledWith("Majoor.Paths.InputDirectory", "/custom/inputs");
expect(comfyToast).toHaveBeenCalledWith("Directory does not exist", "error");
});
it("does not let initial backend loading overwrite a new edit", async () => {
const { settings, input } = register();
input.onChange("/new");
await vi.advanceTimersByTimeAsync(0);
expect(settings.paths.inputDirectory).toBe("/new");
await vi.advanceTimersByTimeAsync(700);
expect(settings.paths.inputDirectory).toBe("/new");
});
it("ignores stale saves when another path is entered while a request is pending", async () => {
const { settings, input } = register();
await vi.advanceTimersByTimeAsync(0);
let finishFirst!: (value: any) => void;
vi.mocked(setInputDirectorySetting).mockImplementationOnce(
() => new Promise((resolve) => { finishFirst = resolve; }),
);
input.onChange("/first");
await vi.advanceTimersByTimeAsync(700);
const options = vi.mocked(setInputDirectorySetting).mock.calls[0][1];
input.onChange("/second");
expect(options?.signal.aborted).toBe(true);
finishFirst({ ok: true, data: { input_directory: "/first" } });
await vi.advanceTimersByTimeAsync(0);
expect(settings.paths.inputDirectory).toBe("/second");
await vi.advanceTimersByTimeAsync(700);
expect(settings.paths.inputDirectory).toBe("/second");
});
});
+61
View File
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStatusIndicator, setupStatusPolling } from "../features/status/StatusDot.js";
import { post } from "../api/client.js";
import { ENDPOINTS } from "../api/endpoints.js";
vi.mock("../api/client.js", () => ({
get: vi.fn().mockResolvedValue({ ok: true, data: { input_directory: "/custom/inputs" } }),
post: vi.fn().mockResolvedValue({ ok: false, error: "Scan stopped for test" }),
listDbBackups: vi.fn().mockResolvedValue({ ok: true, data: { items: [] } }),
getToolsStatus: vi.fn().mockResolvedValue({ ok: false }),
getWatcherStatus: vi.fn().mockResolvedValue({ ok: false }),
}));
vi.mock("../app/i18n.js", () => ({
t: (key: string, fallback?: string) => fallback || key,
}));
vi.mock("../app/toast.js", () => ({ comfyToast: vi.fn(), recordToastHistory: vi.fn() }));
vi.mock("../app/settings.js", () => ({ loadMajoorSettings: () => ({ scan: { fastMode: true } }) }));
describe("Index Status Scan button", () => {
let section: any;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
section?._mjrStatusPollDispose?.();
section?.remove();
vi.useRealTimers();
});
it("exposes a labeled button even when status details are collapsed and scans the current scope", async () => {
section = createStatusIndicator();
document.body.appendChild(section);
const dot = section.querySelector("#mjr-status-dot");
const text = section.querySelector("#mjr-status-text");
let scope = "input";
setupStatusPolling(dot, text, section, null, null, () => ({ scope }));
const button = section.querySelector("#mjr-status-scan") as HTMLButtonElement;
expect(button.textContent).toBe("Scan");
expect(button.type).toBe("button");
expect(section.querySelector("#mjr-status-body").style.display).toBe("none");
expect(button.closest("#mjr-status-body")).toBeNull();
button.click();
await vi.advanceTimersByTimeAsync(0);
expect(post).toHaveBeenLastCalledWith(
ENDPOINTS.SCAN, expect.objectContaining({ scope: "input", recursive: true }),
);
scope = "output";
button.click();
await vi.advanceTimersByTimeAsync(0);
expect(post).toHaveBeenLastCalledWith(
ENDPOINTS.SCAN, expect.objectContaining({ scope: "output" }),
);
dot.click();
await vi.advanceTimersByTimeAsync(0);
expect(post).toHaveBeenCalledTimes(3);
});
});