Files
olivv-cs-ComfyUI-FunPack/movie_editor/frontend/settings_window.js
T
digital-garbage bd92dbc555 feat(editor): pin an Engine category, not just the section
Sections can name an inner view via pinTarget/ctx.sub; a pin's identity
includes it, so two Engine categories don't evict each other.
2026-08-22 22:36:13 +03:00

461 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Unified Settings window, macOS System Settings style: a sidebar of grouped,
// searchable sections (colored icon chips) + one content pane. Sections self-register
// via SettingsWindow.register(); the legacy per-modal facades (EditorSettingsModal,
// EngineSettingsModal, ModelsModal, TempBrowserModal) deep-link into their section,
// so every existing entry point (menus, inspector, workflow import) keeps working.
(function () {
const { el, clear } = window.dom;
const GROUPS = ["", "Generation", "Learning", "System"];
const registry = [];
let overlay = null, activeId = null, cleanup = null, query = "";
let navEl, bodyEl, titleEl, subEl, actionsEl;
function register(spec) {
registry.push(spec);
if (overlay) renderNav();
}
// Sidebar order: group order, then explicit spec.order, then registration order.
function orderedSections() {
const out = [];
GROUPS.forEach((g) => {
out.push(...registry
.filter((s) => (s.group || "") === g)
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99)));
});
return out;
}
function matches(spec) {
if (!query) return true;
return (spec.title + " " + (spec.subtitle || "") + " " + (spec.keywords || ""))
.toLowerCase().includes(query);
}
function iconEl(spec) {
const w = el("span", "sw-icon");
if (spec.iconBg) w.style.background = spec.iconBg;
w.innerHTML = spec.icon || "";
return w;
}
function renderNav() {
clear(navEl);
const ordered = orderedSections();
GROUPS.forEach((g) => {
const items = ordered.filter((s) => (s.group || "") === g && matches(s));
if (!items.length) return;
if (g) navEl.append(el("div", "sw-group-title", g));
items.forEach((s) => {
const it = el("div", "sw-item" + (s.id === activeId ? " active" : ""));
it.append(iconEl(s), el("span", "sw-item-label", s.title));
it.onclick = () => show(s.id);
navEl.append(it);
});
});
if (!navEl.children.length) navEl.append(el("div", "sw-nav-empty", "No matches"));
}
function teardownSection() {
if (typeof cleanup === "function") { try { cleanup(); } catch (_) {} }
cleanup = null;
}
// `sub` names a place INSIDE a section (an Engine category, say). Sections that have
// inner views read it from ctx on mount; the rest ignore it harmlessly.
function show(id, subView) {
const spec = registry.find((s) => s.id === id) || orderedSections()[0];
if (!spec || !overlay) return;
teardownSection();
activeId = spec.id;
titleEl.textContent = spec.title;
subEl.textContent = spec.subtitle || "";
subEl.hidden = !spec.subtitle;
clear(actionsEl);
clear(bodyEl);
// flush sections manage their own inner layout/scrolling (e.g. Models' sidebar)
bodyEl.classList.toggle("sw-flush", !!spec.flush);
bodyEl.scrollTop = 0;
const ctx = {
setActions: (nodes) => { clear(actionsEl); (nodes || []).forEach((n) => actionsEl.append(n)); },
openSection: (sid) => show(sid),
sub: subView || null,
};
cleanup = spec.mount(bodyEl, ctx) || null;
renderNav();
}
function close() {
const wasOpen = !!overlay;
teardownSection();
if (overlay) overlay.remove();
overlay = null; activeId = null;
// One save on the way out, with the values on screen. A save landing mid-edit used
// to come back and overwrite the knob under the cursor.
if (wasOpen) window.Store?.resumeSave?.();
}
function open(id, subView) {
// Recover if something removed our overlay from the DOM directly
// (e.g. the tour's closeModalOverlay sweep) without calling close().
if (overlay && !overlay.isConnected) { close(); }
if (overlay) { show(id || activeId, subView); return; }
query = "";
// Held until close(). Paired with the overlay's lifetime, so only on this branch.
window.Store?.suspendSave?.();
overlay = el("div", "modal-overlay sw-overlay");
const win = el("div", "settings-win");
const side = el("div", "sw-side");
const search = el("input", "sw-search");
search.type = "search"; search.placeholder = "Search"; search.autocomplete = "off";
search.oninput = () => { query = search.value.trim().toLowerCase(); renderNav(); };
navEl = el("div", "sw-nav");
side.append(search, navEl);
const main = el("div", "sw-main");
const head = el("div", "sw-head");
const ht = el("div", "sw-head-text");
titleEl = el("div", "sw-head-title");
subEl = el("div", "sw-head-sub");
ht.append(titleEl, subEl);
actionsEl = el("div", "sw-head-actions");
const pin = el("button", "btn ghost tiny sw-pin", "📌 Pin to a button");
pin.type = "button";
pin.title = "Put a shortcut to whatever is open here on the timeline toolbar";
pin.onclick = () => window.PinnedButtons?.pinCurrent();
const x = el("button", "btn ghost tiny sw-close", "✕");
x.onclick = close;
head.append(ht, actionsEl, pin, x);
if (!window.PinnedButtons) pin.hidden = true;
bodyEl = el("div", "sw-body");
main.append(head, bodyEl);
win.append(side, main);
overlay.append(win);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
show(id, subView);
}
window.addEventListener("keydown", (e) => {
if (overlay && e.key === "Escape") { close(); return; }
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key === ",") {
e.preventDefault();
overlay ? close() : open();
}
});
// Shared builder for inner-sidebar nav items (Models node list, Engine categories):
// { icon | dot, label, sub, badge, active, onClick } → .mn-item element.
function navItem(opts) {
const it = el("div", "mn-item" + (opts.active ? " active" : ""));
if (opts.dot) it.append(el("span", "mn-dot " + opts.dot));
else if (opts.icon) it.append(el("span", "mn-ico", opts.icon));
const lab = el("span", "mn-label");
lab.append(el("span", "mn-name", opts.label));
if (opts.sub) lab.append(el("span", "mn-sub", opts.sub));
it.append(lab);
if (opts.badge != null) it.append(el("span", "mn-badge", String(opts.badge)));
it.onclick = opts.onClick;
return it;
}
// Mount ONE registered section into an arbitrary container — no sidebar, no search, no
// window chrome. Used by the setup wizard to show a section as a screen of its own.
// Returns { spec, cleanup }, or null when nothing has registered that id.
function mountSection(id, host, opts = {}) {
const spec = registry.find((s) => s.id === id);
if (!spec) return null;
host.classList.toggle("sw-flush", !!spec.flush);
const cleanup = spec.mount(host, {
setActions: opts.setActions || (() => {}),
openSection: opts.openSection || (() => {}),
});
return { spec, cleanup: () => { if (typeof cleanup === "function") { try { cleanup(); } catch (_) {} } } };
}
function hasSection(id) { return registry.some((s) => s.id === id); }
/** What a pinned button should reopen to get back to what is on screen now.
*
* A section can override this (Models points at the open NODE rather than at itself),
* which is the whole value of the feature: the deep places are the slow ones to reach.
*/
function currentTarget() {
const spec = registry.find((s) => s.id === activeId);
if (!spec) return null;
let custom = null;
try { custom = typeof spec.pinTarget === "function" ? spec.pinTarget() : null; } catch (_) {}
return custom || { kind: "section", id: spec.id, label: spec.title };
}
/** Every section, for the pin dialog's "somewhere else" list. */
function sectionList() {
return orderedSections().map((s) => ({ id: s.id, title: s.title, group: s.group || "" }));
}
window.SettingsWindow = {
open, close, register, navItem, mountSection, hasSection, currentTarget, sectionList,
};
// ── shared row builders for settings panels ────────────────────────────
function actionRow(title, hint, btnLabel, onClick, opts = {}) {
const row = el("div", "sw-row");
const main = el("div", "sw-row-main");
main.append(el("div", "sw-row-title", title));
if (hint) main.append(el("div", "sw-row-hint", hint));
row.append(main);
const btn = el("button", "btn ghost tiny" + (opts.danger ? " danger" : ""), btnLabel);
btn.type = "button";
if (opts.disabled) btn.disabled = true;
btn.onclick = onClick;
row.append(btn);
return row;
}
function infoRow(title, value, ok) {
const row = el("div", "sw-row");
const main = el("div", "sw-row-main");
main.append(el("div", "sw-row-title", title));
row.append(main);
const val = el("span", "sw-row-value", value);
if (ok != null) {
const led = el("span", "sw-led " + (ok ? "ok" : "bad"));
row.append(led);
}
row.append(val);
return row;
}
// ── built-in section: About FunPack ────────────────────────────────────
register({
id: "about", group: "", order: 0, title: "About FunPack",
subtitle: "",
keywords: "about version commit branch copyright info funpack cutting room "
+ "cpu chip memory ram gpu graphics vram disk storage system os python torch cuda specs hardware",
iconBg: "linear-gradient(180deg,#ffc36b,#e0891f)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5"><circle cx="8" cy="8" r="6.2"/><circle cx="8" cy="8" r="2.4" fill="#fff" stroke="none"/></svg>',
mount(body, ctx) {
const G = window.FunPackGit;
const wrap = el("div", "sw-about");
body.append(wrap);
// Host facts describe the machine ComfyUI runs on, which on a rental is NOT the
// machine showing this window. Fetched once per mount; nothing here changes while
// the window is open. Null until it arrives (or forever, if the backend is older).
let sys = null;
const gb = (n) => (n == null ? null : `${n} GB`);
function hardwareFacts(fact) {
if (!sys) return;
const cpu = sys.cpu || {};
const cores = cpu.cores && cpu.threads && cpu.cores !== cpu.threads
? `${cpu.cores}C/${cpu.threads}T`
: (cpu.threads ? `${cpu.threads}C` : null);
fact("Chip", [cpu.name, cores].filter(Boolean).join(" · ") || cpu.arch);
const mem = sys.memory || {};
fact("Memory", mem.available_gb != null && mem.total_gb != null
? `${mem.available_gb} GB free of ${mem.total_gb} GB`
: gb(mem.total_gb));
const gpus = sys.gpus || [];
if (gpus.length) {
gpus.forEach((g, i) => {
const label = gpus.length > 1 ? `Graphics ${i}` : "Graphics";
fact(label, [g.name, gb(g.vram_gb), g.capability].filter(Boolean).join(" · "));
});
} else {
// No CUDA device: say which kind of nothing, since "—" reads like a bug.
fact("Graphics", sys.mps ? "Apple GPU (MPS)" : "CPU only (no CUDA device)");
}
const disk = sys.disk || {};
fact("Storage", disk.free_gb != null && disk.total_gb != null
? `${disk.free_gb} GB available of ${disk.total_gb} GB`
: gb(disk.total_gb));
}
function softwareFacts(fact) {
if (!sys) return;
fact("System", sys.os);
fact("ComfyUI", sys.comfyui);
fact("Python", sys.python);
const t = sys.torch || {};
fact("Torch", [t.version, t.cuda ? `CUDA ${t.cuda}` : null].filter(Boolean).join(" · "));
if (t.attention) fact("Attention", t.attention);
if (sys.host) fact("Host", sys.host);
}
function render() {
clear(wrap);
const git = G ? G.get() : null;
wrap.append(el("div", "sw-about-mark", "◉"));
const major = String(git?.version || "").split(".")[0];
wrap.append(el("div", "sw-about-name", "FunPack" + (major ? " " + major : "")));
// Omitted when absent — empty quotes would look like a bug.
if (git?.codename) {
wrap.append(el("div", "sw-about-codename", "“" + git.codename + "”"));
}
// window.FunPackAppName lets a different frontend sharing this file (e.g. Easy
// Gen) relabel the app name without forking the whole section; Editor leaves it
// unset and keeps "Cutting Room".
wrap.append(el("div", "sw-about-sub", window.FunPackAppName || "Cutting Room"));
const facts = el("div", "sw-about-facts");
const fact = (k, v) => {
const r = el("div", "sw-about-fact");
r.append(el("span", "sw-about-k", k), el("span", "sw-about-v", v || "—"));
facts.append(r);
};
const group = (label) => facts.append(el("div", "sw-about-group", label));
fact("Version", git?.version);
fact("Commit", git?.ok ? git.commit + (git.dirty ? " (local changes)" : "") : null);
fact("Branch", git?.ok ? git.branch : null);
if (sys) {
group("Hardware");
hardwareFacts(fact);
group("Software");
softwareFacts(fact);
}
wrap.append(facts);
if (sys) {
wrap.append(el("div", "sw-about-hint",
"The machine ComfyUI runs on — not this browser."));
}
const upd = el("button", "btn ghost tiny", "Software Update…");
upd.onclick = () => ctx.openSection("system");
wrap.append(upd);
wrap.append(el("div", "sw-about-copy", "© 2025–2026 DigitalGarbage"));
}
render();
if (G?.refresh) G.refresh().then(() => { if (wrap.isConnected) render(); }).catch(() => {});
// Optional-chained: a frontend sharing this file without the endpoint (or an older
// backend) simply keeps the version-only About instead of erroring.
window.MovieEditorAPI?.systemInfo?.()
.then((info) => { sys = info; if (wrap.isConnected) render(); })
.catch(() => {});
},
});
// ── built-in section: Refinement & Taste (Learning) ────────────────────
// Buttons reuse the single implementations exported by menubar.js
// (window.FunPackMaintenance) — no duplicated logic, just a second door.
register({
id: "refinement", group: "Learning", title: "Refinement & Taste",
subtitle: "Learned-taste state: refinement keys and the Absolute global-taste store.",
keywords: "refinement key export import delete clear absolute taste store learning ratings",
iconBg: "linear-gradient(180deg,#ff8bc2,#d84f92)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13"><path d="M8 1.5 9.6 6.4 14.5 8 9.6 9.6 8 14.5 6.4 9.6 1.5 8 6.4 6.4 8 1.5z" fill="#fff"/></svg>',
mount(body) {
const M = window.FunPackMaintenance || {};
const S = window.Store;
const wrap = el("div", "sw-stack");
body.append(wrap);
function render() {
clear(wrap);
wrap.append(el("div", "sw-hint",
"Refinement keys hold what FunPack has learned from your ratings for a named session. "
+ "They are stored on this ComfyUI instance as <key>.json plus sidecar banks."));
const st = S ? S.get() : {};
const studioOn = !!window.PipelineCaps?.usesFunpackStudio?.(st);
const armed = !!st.resetSessionArmed;
wrap.append(el("div", "sw-rows-label", "Studio session"));
const sess = el("div", "sw-rows");
sess.append(actionRow("Reset Studio session",
armed
? "Armed — the session's learned keys are wiped on the FIRST run of the next generation. Click to cancel."
: "Wipe this session's learned keys on the next generation (asks which keys first).",
armed ? "✓ Armed — cancel" : "Reset…",
() => S?.resetStudioSession?.(),
{ disabled: !st.project || !studioOn, danger: !armed }));
wrap.append(sess);
wrap.append(el("div", "sw-rows-label", "Refinement keys"));
const rows = el("div", "sw-rows");
rows.append(actionRow("Export refinement key", "Download a key as <key>.json.", "⬇ Export…", () => M.exportRefinementKey?.()));
rows.append(actionRow("Import refinement key", "Load a previously exported <key>.json onto this instance.", "Import…", () => M.importRefinementKeyFile?.()));
wrap.append(rows);
wrap.append(el("div", "sw-rows-label", "Danger zone"));
const danger = el("div", "sw-rows");
danger.append(actionRow("Delete refinement key",
"Removes a key AND all its sidecars (value function, blessed banks, creativity latent, velocity memory).",
"Delete…", () => M.deleteRefinementKey?.(), { danger: true }));
danger.append(actionRow("Clear global taste store",
"Wipes the Absolute store pooled from every rated run (applied in absolute/both steer mode).",
"Clear…", () => M.clearGlobalTaste?.(), { danger: true }));
wrap.append(danger);
}
const unsub = S ? S.subscribe(render) : null;
render();
return () => { if (unsub) unsub(); };
},
});
// ── built-in section: Updates & ComfyUI (System) ───────────────────────
register({
id: "system", group: "System", order: 1, title: "Updates & ComfyUI",
subtitle: "Server connection, FunPack code updates, pipeline health.",
keywords: "update git branch restart comfyui server connection health template version",
iconBg: "linear-gradient(180deg,#a7adb8,#6b7280)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.6v2M8 12.4v2M1.6 8h2M12.4 8h2M3.5 3.5l1.4 1.4M11.1 11.1l1.4 1.4M12.5 3.5l-1.4 1.4M4.9 11.1l-1.4 1.4"/></svg>',
mount(body) {
const S = window.Store, G = window.FunPackGit, M = window.FunPackMaintenance || {};
const wrap = el("div", "sw-stack");
body.append(wrap);
function render() {
clear(wrap);
const st = S ? S.get() : {};
const git = G ? G.get() : null;
const gitOk = !!(git && git.ok);
wrap.append(el("div", "sw-rows-label", "ComfyUI server"));
const srv = el("div", "sw-rows");
srv.append(infoRow("Connection", st.health?.ok ? "Connected · " + window.location.host : "Not reachable", !!st.health?.ok));
srv.append(actionRow("Open ComfyUI", "The node-graph UI, in a new tab.", "↗ Open", () => window.open("/", "_blank")));
srv.append(actionRow("Restart ComfyUI", "Down for ~10–40s; any running generation is lost. Reloads automatically.", "⟳ Restart…", () => M.restartComfy?.(), { danger: true }));
wrap.append(srv);
wrap.append(el("div", "sw-rows-label", "FunPack code"));
const code = el("div", "sw-rows");
code.append(infoRow("Branch", gitOk
? git.branch + (git.commit ? " @ " + git.commit : "") + (git.dirty ? " (local changes)" : "")
: (git?.detail || "unavailable")));
code.append(actionRow("Switch branch", "Check out another FunPack branch and reload.", "Switch…",
() => G?.switchBranch?.(), { disabled: !gitOk || !!git?.dirty }));
code.append(actionRow("Update FunPack",
gitOk && git.behind > 0 ? git.behind + " new commit(s) available." : "Pull the latest code and reload.",
gitOk && git.behind > 0 ? git.behind + "↓ Update" : "⬇⟳ Update",
() => G?.update?.(), { disabled: !gitOk || !!git?.dirty }));
wrap.append(code);
wrap.append(el("div", "sw-rows-label", "Pipeline health"));
const health = el("div", "sw-rows");
health.append(infoRow("Workflow template", st.health?.reference_loaded ? "loaded" : "missing", !!st.health?.reference_loaded));
health.append(infoRow("Configured nodes", String(st.health?.configured_slots ?? 0)));
wrap.append(health);
}
const unsub = S ? S.subscribe(render) : null;
render();
if (G?.refresh) G.refresh().then(() => { if (wrap.isConnected) render(); }).catch(() => {});
return () => { if (unsub) unsub(); };
},
});
})();