Movie Editor: real min/max/step on exposed controls + stop autosave from eating input

Exposed node controls (e.g. a LoRA strength in project settings) now carry the
node's actual min/max/step/default from object_info instead of step=1/no bounds:
- capture numeric widget meta at expose time and on link creation (models.js)
- apply it in the Models node editor and the inspector's Exposed controls
- backfill meta onto pre-existing exposures when Models is opened / refreshed

Typing an exposed value no longer gets disrupted: the field now carries a data-k
so the edit guard protects it, and setModelInput/setModelLink update in place +
debounce the server save (was one saveModels POST per keystroke, notifying twice
and rebuilding the field mid-type). Pending model saves flush on blur and before
generate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-17 12:38:22 +03:00
co-authored by Claude Opus 4.8
parent 94ffbb3cda
commit 6d25c8c1c5
3 changed files with 92 additions and 31 deletions
+15 -6
View File
@@ -710,7 +710,7 @@
body.append(wrap);
}
function exposedControl(desc, value, on) {
function exposedControl(desc, value, on, key) {
let ctrl;
if (desc.kind === "combo") {
ctrl = el("select");
@@ -721,13 +721,19 @@
ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!value; ctrl.style.width = "auto";
ctrl.onchange = () => on(ctrl.checked);
} else if (desc.kind === "int" || desc.kind === "float") {
ctrl = el("input"); ctrl.type = "number"; if (desc.kind === "float") ctrl.step = "any";
ctrl.value = value != null ? value : "";
ctrl = el("input"); ctrl.type = "number";
// Real bounds/step from the node spec (captured at expose time) — not step=1 for all.
if (desc.min != null) ctrl.min = String(desc.min);
if (desc.max != null) ctrl.max = String(desc.max);
if (desc.step != null) ctrl.step = String(desc.step);
else if (desc.kind === "float") ctrl.step = "any";
ctrl.value = value != null ? value : (desc.default != null ? desc.default : "");
ctrl.oninput = () => on(desc.kind === "int" ? parseInt(ctrl.value || "0", 10) : parseFloat(ctrl.value || "0"));
} else {
ctrl = el("input"); ctrl.type = "text"; ctrl.value = value != null ? value : "";
ctrl = el("input"); ctrl.type = "text"; ctrl.value = value != null ? value : (desc.default != null ? desc.default : "");
ctrl.oninput = () => on(ctrl.value);
}
if (key) ctrl.dataset.k = key; // protect from autosave rebuilds while editing
return ctrl;
}
@@ -749,12 +755,15 @@
const note = el("div", "lib-sub"); note.textContent = `← ${SRC_LBL[l.editor_key] || l.editor_key}`;
wrap.append(field(`🔗 ${l.name} (${(l.members || []).length})`, note));
} else {
const ctrl = exposedControl({ kind: l.kind, choices: l.choices }, l.value, (v) => S.setModelLink(l.id, v));
const ctrl = exposedControl(
{ kind: l.kind, choices: l.choices, min: l.min, max: l.max, step: l.step, default: l.default },
l.value, (v) => S.setModelLink(l.id, v), "exp-link-" + l.id);
wrap.append(field(`🔗 ${l.name} (${(l.members || []).length})`, ctrl));
}
});
slotItems.forEach(([slot, d]) => {
const ctrl = exposedControl(d, (slot.inputs || {})[d.name], (v) => S.setModelInput(slot.id, d.name, v));
const ctrl = exposedControl(d, (slot.inputs || {})[d.name], (v) => S.setModelInput(slot.id, d.name, v),
"exp-" + slot.id + "-" + d.name);
wrap.append(field(`${slotLabel(slot)} · ${d.label || d.name}`, ctrl));
});
body.append(wrap);
+49 -16
View File
@@ -162,6 +162,25 @@
function specFor(slot) { return specByClass[slot.node_class] || null; }
function inputSpecFor(slot, name) { return ((specFor(slot) || {}).inputs || []).find((w) => w.name === name) || null; }
// Numeric widget bounds/step/default straight from the node's object_info opts, so an
// exposed control (e.g. a LoRA strength) gets its real min/max/step instead of step=1.
function numMeta(spec) {
const o = (spec && spec.options) || {};
const m = {};
if (o.min != null) m.min = o.min;
if (o.max != null) m.max = o.max;
if (o.step != null) m.step = o.step;
const def = o.default != null ? o.default : (spec && spec.default);
if (def != null) m.default = def;
return m;
}
function applyNumMeta(ctrl, meta, isFloat) {
if (meta.min != null) ctrl.min = String(meta.min);
if (meta.max != null) ctrl.max = String(meta.max);
if (meta.step != null) ctrl.step = String(meta.step);
else if (isFloat) ctrl.step = "any";
}
// Exposed controls and shared links snapshot their combo `choices` at expose time (so the main
// editor can render dropdowns without re-fetching object_info). Those snapshots go stale after
// a model-list refresh — a newly installed LoRA/checkpoint shows up inside the Models menu (live
@@ -169,23 +188,26 @@
// choices from the freshly loaded spec. Returns true if anything changed (caller persists).
function refreshExposedChoices() {
let changed = false;
(config.slots || []).forEach((slot) => {
(slot.exposed || []).forEach((e) => {
if (e.kind !== "combo") return;
const w = inputSpecFor(slot, e.name);
if (w && Array.isArray(w.choices) && JSON.stringify(w.choices) !== JSON.stringify(e.choices || [])) {
e.choices = w.choices.slice(); changed = true;
const syncMeta = (target, w) => {
if (!w) return;
if (target.kind === "combo") {
if (Array.isArray(w.choices) && JSON.stringify(w.choices) !== JSON.stringify(target.choices || [])) {
target.choices = w.choices.slice(); changed = true;
}
});
} else if (target.kind === "int" || target.kind === "float") {
const meta = numMeta(w);
["min", "max", "step", "default"].forEach((k) => {
if (meta[k] != null && target[k] !== meta[k]) { target[k] = meta[k]; changed = true; }
});
}
};
(config.slots || []).forEach((slot) => {
(slot.exposed || []).forEach((e) => syncMeta(e, inputSpecFor(slot, e.name)));
});
(config.links || []).forEach((link) => {
if (link.kind !== "combo") return;
const first = (link.members || [])[0];
const slot = first && slotById(first.slotId);
const w = slot && inputSpecFor(slot, first.input);
if (w && Array.isArray(w.choices) && JSON.stringify(w.choices) !== JSON.stringify(link.choices || [])) {
link.choices = w.choices.slice(); changed = true;
}
syncMeta(link, slot && inputSpecFor(slot, first.input));
});
return changed;
}
@@ -219,9 +241,11 @@
function isExposed(slot, name) { return (slot.exposed || []).some((e) => e.name === name); }
function toggleExpose(slot, spec) {
slot.exposed = slot.exposed || [];
if (isExposed(slot, spec.name)) slot.exposed = slot.exposed.filter((e) => e.name !== spec.name);
else slot.exposed.push({ name: spec.name, kind: spec.kind,
choices: spec.kind === "combo" ? (spec.choices || []) : undefined, label: spec.name });
if (isExposed(slot, spec.name)) { slot.exposed = slot.exposed.filter((e) => e.name !== spec.name); return; }
const e = { name: spec.name, kind: spec.kind, label: spec.name };
if (spec.kind === "combo") e.choices = spec.choices || [];
else if (spec.kind === "int" || spec.kind === "float") Object.assign(e, numMeta(spec));
slot.exposed.push(e);
}
function eyeButton(slot, spec) {
const b = el("button", "eye-btn" + (isExposed(slot, spec.name) ? " on" : ""), "◉");
@@ -249,7 +273,8 @@
ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!value; ctrl.style.width = "auto";
ctrl.onchange = () => onChange(ctrl.checked);
} else if (spec.kind === "int" || spec.kind === "float") {
ctrl = el("input"); ctrl.type = "number"; if (spec.kind === "float") ctrl.step = "any";
ctrl = el("input"); ctrl.type = "number";
applyNumMeta(ctrl, numMeta(spec), spec.kind === "float");
ctrl.value = value != null ? value : (spec.default != null ? spec.default : "");
ctrl.oninput = () => onChange(spec.kind === "int" ? parseInt(ctrl.value || "0", 10) : parseFloat(ctrl.value || "0"));
} else {
@@ -876,6 +901,10 @@
const link = { id: uid(), name: nm.trim() || def, kind: first.kind,
choices: first.kind === "combo" ? (first.choices || []) : undefined, value: val,
members: linkSel.map((s) => ({ slotId: s.slotId, input: s.input })), exposed: false };
if (first.kind === "int" || first.kind === "float") {
const w = s0 && inputSpecFor(s0, first.input);
if (w) Object.assign(link, numMeta(w));
}
ensureLinks().push(link);
applyLinkValue(link, val);
linkMode = false; linkSel = []; await persist(); render();
@@ -1061,6 +1090,10 @@
reconcileOpenPortWiring();
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || []; } catch (_) { coreNodes = []; }
await prewarmSpecs();
// Backfill numeric bounds/step (and combo choices) onto controls exposed before this
// metadata was captured, so opening Models once upgrades existing projects. persist()
// dispatches funpack-models-changed → the store reloads and the inspector re-renders.
if (refreshExposedChoices()) { try { await persist(); } catch (_) {} }
overlay = el("div", "modal-overlay");
const modal = el("div", "modal");
+28 -9
View File
@@ -47,6 +47,8 @@
// stale optimistic DOM (scene prompt text could read blank until reload/regenerate).
let _renderAfterSave = false;
let _selectionAnchorId = null; // shift-click range anchor
let _modelsSaveTimer = null; // debounce exposed-control (node input) saves
let _modelsSaveDirty = false;
function notify() { listeners.forEach((fn) => { try { fn(state); } catch (e) { console.error(e); } }); }
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
@@ -751,6 +753,7 @@
// commit promise when it flushed, else null.
function flushSave() {
clearTimeout(saveTimer); saveTimer = null;
flushModelsSave(); // persist any debounced exposed-control (node input) edits too
if (!_localDirty && !_commitPromise) return null;
return commit();
}
@@ -2386,6 +2389,7 @@
async function _flushSaveForGenerate() {
await flushGlobalPromptApply(); // distribute any just-typed global prompt into scenes first
await flushModelsSave(); // persist exposed-control edits before building the graph
await flushSave();
if (_localDirty) {
throw new Error(
@@ -3358,18 +3362,34 @@
notify();
}
// Edit a configured node input from the main editor (an "exposed" control) and persist.
async function setModelInput(slotId, name, value) {
const slot = (state.models.slots || []).find((s) => s.id === slotId);
if (!slot) return;
slot.inputs = slot.inputs || {}; slot.inputs[name] = value;
notify();
// Persisting a node input hits the server; typing a weight used to fire one save PER
// KEYSTROKE and notify twice (before + after the await), which rebuilt the inspector and
// yanked the field. Now: update in place + notify once (cheap, and the field is data-k
// protected from rebuild), and DEBOUNCE the network save. flushModelsSave() runs on field
// blur and before generate (via flushSave / _flushSaveForGenerate).
function _scheduleModelsSave() {
_modelsSaveDirty = true;
clearTimeout(_modelsSaveTimer);
_modelsSaveTimer = setTimeout(() => { _modelsSaveTimer = null; flushModelsSave(); }, 500);
}
async function flushModelsSave() {
if (!_modelsSaveTimer && !_modelsSaveDirty) return;
clearTimeout(_modelsSaveTimer); _modelsSaveTimer = null; _modelsSaveDirty = false;
try { state.models = await API.saveModels(state.project?.id, state.models); notify(); }
catch (e) { console.error("saveModels failed", e); }
}
// Edit a configured node input from the main editor (an "exposed" control) and persist.
function setModelInput(slotId, name, value) {
const slot = (state.models.slots || []).find((s) => s.id === slotId);
if (!slot) return;
slot.inputs = slot.inputs || {}; slot.inputs[name] = value;
notify();
_scheduleModelsSave();
}
// Set a linked control's shared value (writes through to all member inputs) and persist.
async function setModelLink(linkId, value) {
function setModelLink(linkId, value) {
const link = (state.models.links || []).find((l) => l.id === linkId);
if (!link) return;
link.value = value;
@@ -3378,8 +3398,7 @@
if (s) { s.inputs = s.inputs || {}; s.inputs[m.input] = value; }
});
notify();
try { state.models = await API.saveModels(state.project?.id, state.models); notify(); }
catch (e) { console.error("saveModels failed", e); }
_scheduleModelsSave();
}
// ── media bin + libraries ─────────────────────────────────────────────────────