Files
olivv-cs-ComfyUI-FunPack/app/legacy/engine_settings.js
T
digital-garbageandClaude Sonnet 5 4a1f60bda6 feat: sampler quick-access -- steps/scheduler/sampler in one click
[[project_v5_ui_requirements]]: "steps, scheduler and sampler are reached
for constantly, and in v4 they are several clicks deep... a thin hovering
window above the timeline, which keeps them visible without spending
timeline height." Nothing in v5 built this yet -- the only existing path
was Settings > Engine > Sampling, a modal several clicks deep.

app/legacy/sampler_quickbar.js: a toggle button in the timeline header
(next to Assets/Preview/Properties/Composer) opens a fixed-position panel
above the timeline showing only category="sampling" module settings.
Hidden until PipelineState confirms at least one installed module has a
sampling setting (the "absent, not greyed" convention this codebase
already uses for a module/role that doesn't apply).

The quickbar needed to read/write the SAME module-values tree
engine_settings.js already edited. That tree gets serialized into one
opaque JSON blob and written into a pipeline slot by the server
(core/graph.py's place()) with no server-side merge -- a second, naive
editor sending a partial tree would silently erase every other module's
settings. To avoid two independent caches of the same server data
diverging (the exact bug class pipeline_state.js's own header comment
already describes for `slots`, from an earlier fix), extracted the
shared pieces instead of duplicating them:

- PipelineState gained currentValues() (always-fresh: defaults +
  valuesAlreadyPlaced()) and setModuleValue(moduleId, name, value)
  (patches one field, sends the whole tree). Single source of truth,
  used by both editors.
- app/legacy/settings_field.js: the field-widget builder (checkbox/
  slider/enum/color/text, the `when`-clause visibility gate) extracted
  out of engine_settings.js verbatim, parameterized on a values tree and
  an onChange callback instead of a private cache -- one place renders a
  setting, one place saves one, for every editor of this tree from now on.
- engine_settings.js refactored to use both instead of its own private
  values/seeded/setValue/ensureSeeded/commit/controlFor.

Two rounds of extensive_testing. Round 1 found a real, live-reproduced
defect: setModuleValue's "always fresh, no cache" design had its own
race -- two calls fired before either's round-trip resolved could
silently lose the earlier edit, because the second call's "read the
whole tree" snapshot didn't yet include the first call's in-flight
write. Fixed with a `pendingValues` overlay in pipeline_state.js,
written synchronously before every read, reconciled (per-key, not
wholesale) once valuesAlreadyPlaced() actually confirms it server-side.
Round 2 re-verified the original race closes -- checked against
valuesAlreadyPlaced() specifically (decoded straight from the server's
own response, not the optimistic overlay) for both a two-different-
modules race and a three-calls-same-field race -- and found one more
thing, currently inert: setModuleValue's returned promise doesn't mean
"this specific edit is confirmed" for every concurrent caller, only for
whichever one owns save()'s queue at that moment. Documented in place
(both real callers only re-render from shared state, which is already
correct regardless of promise timing) rather than reworking save()'s
shared queue for a guarantee nothing currently needs.

Live-verified: button appears only when a sampling setting exists;
opens positioned above the timeline; editing in the quickbar changes
only the target field (confirmed via full before/after tree diff);
same change visible in the Engine Settings modal and vice versa; real
outside-click closes it; 5 open/close cycles leave no DOM leak.

npm test: 691 pass, 1 pre-existing unrelated flake
(shell/tests/projects.test.js:285). No test file exists yet for
pipeline_state.js/engine_settings.js/settings_field.js/
sampler_quickbar.js -- a real, named test gap, not fixed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 06:09:29 +03:00

143 lines
5.6 KiB
JavaScript

// Engine settings: a schema-driven renderer over v5's real module manifest
// (GET /funpack/api/modules) instead of v4's ~84 hardcoded Studio/Chain
// Sampler `kind:` declarations, which describe nodes that no longer exist.
// Same outer shell as before (SettingsWindow section, "engine"/"Generation"/
// order 1) so every existing entry point -- the inspector's "Engine settings
// →" button, the wheel pin, the tour -- keeps working unchanged; only the
// CONTENT is new. A category with nothing to show is simply absent from the
// sidebar: this tab looking emptier than v4's screenshot is it reflecting
// real backend state, not a bug (see the port plan).
//
// The field-widget builder and the module-values tree it reads/writes both
// live outside this file now (settings_field.js, PipelineState.currentValues/
// setModuleValue) -- the sampler quick-access bar (sampler_quickbar.js)
// edits the SAME tree through the SAME functions, so there is exactly one
// place that knows how to render a setting and exactly one place that knows
// how to save one.
(function () {
const { el, clear } = window.dom;
const S = window.Store;
const API = window.MovieEditorAPI;
const SF = window.SettingsField;
const CATEGORY_LABELS = {
continuity: "Continuity", guidance: "Guidance", conditioning: "Conditioning",
sampling: "Sampling", post: "Post", system: "System", "": "Other",
};
const CATEGORY_ORDER = ["continuity", "guidance", "conditioning", "sampling", "post", "system", ""];
const PS = window.PipelineState;
let _mounted = null;
let unsub = null;
let category = null; // selected sidebar category, chosen once modules load
function onChange(moduleId, name, value) {
PS.setModuleValue(moduleId, name, value).then(render);
}
function categoriesWithContent() {
const found = new Set();
Object.values(PS.modulesById()).forEach((m) => {
if (Object.keys(m.settings || {}).length) found.add(m.category || "");
});
return CATEGORY_ORDER.filter((c) => found.has(c));
}
function renderPane(pane) {
if (PS.loading()) { pane.append(SF.hintEl("Loading…")); return; }
if (PS.loadError()) {
pane.append(SF.hintEl(`Could not load Engine settings: ${PS.loadError()}`));
return;
}
const cats = categoriesWithContent();
if (!cats.length) {
pane.append(SF.hintEl(
"No installed module exposes a setting yet. This fills in as model "
+ "and effect modules are built -- see the port plan."));
return;
}
if (!category || !cats.includes(category)) category = cats[0];
const modulesInCategory = Object.values(PS.modulesById())
.filter((m) => (m.category || "") === category && Object.keys(m.settings || {}).length);
const notes = PS.saveNotes();
if (notes.length) pane.append(SF.hintEl(notes.join(" ")));
const values = PS.currentValues();
modulesInCategory.forEach((m) => {
const g = SF.group(pane, m.title || m.id);
Object.entries(m.settings || {}).forEach(([name, spec]) => {
if (!SF.whenSatisfied(values, m.id, spec.when)) return;
g.append(SF.controlFor(m.id, name, spec, values, onChange));
});
});
}
function renderContent(container) {
if (PS.loading() || PS.loadError() || !categoriesWithContent().length) {
const solo = el("div", "models-pane");
renderPane(solo);
container.append(solo);
return;
}
const cats = categoriesWithContent();
const cols = el("div", "models-cols");
const side = el("div", "models-side");
cats.forEach((c) => {
side.append(window.SettingsWindow.navItem({
label: CATEGORY_LABELS[c] || c, active: category === c,
onClick: () => { category = c; render(); },
}));
});
const pane = el("div", "models-pane eng-pane");
renderPane(pane);
cols.append(side, pane);
container.append(cols);
}
function render() {
if (!_mounted) return;
const { content } = _mounted;
const prevPane = content.querySelector(".models-pane");
const scrollTop = prevPane ? prevPane.scrollTop : 0;
clear(content);
renderContent(content);
const pane = content.querySelector(".models-pane");
if (pane) pane.scrollTop = scrollTop;
}
function mount(body) {
const content = el("div", "models-mount eng-mount");
body.append(content);
_mounted = { content };
render();
// PipelineState loads at most once per session and is shared with every
// other panel that edits the pipeline -- closing and reopening this one
// must not re-fetch over whatever is already held (see pipeline_state.js).
PS.ensureLoaded().then(render);
unsub = S.subscribe(() => {}); // kept: other sections rely on the same subscribe/unsub shape
return () => {
if (unsub) { unsub(); unsub = null; }
_mounted = null;
};
}
window.SettingsWindow.register({
id: "engine", group: "Generation", order: 1, title: "Engine",
subtitle: "Settings every installed module has volunteered, grouped by what they're for.",
keywords: "engine module settings continuity guidance conditioning sampling post system",
iconBg: "linear-gradient(180deg,#ffb64d,#e07f1f)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13"><path d="M9.2 1.3 3 9h4.1l-1 5.7L12.9 7H8.5l.7-5.7z" fill="#fff"/></svg>',
mount,
pinTarget: () => (category
? { kind: "section", id: "engine", sub: category, label: `Engine ▸ ${CATEGORY_LABELS[category] || category}` }
: null),
});
window.EngineSettingsModal = {
open: () => window.SettingsWindow.open("engine"),
close: () => window.SettingsWindow.close(),
};
})();