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:
co-authored by
Claude Opus 4.8
parent
94ffbb3cda
commit
6d25c8c1c5
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user