diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 63337559..44bc3a0b 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -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); diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 517cb9e3..f5e05875 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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"); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 12aff897..66317315 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€