Movie Editor: expose-to-main-editor eye toggle + collapsible node cards

- each configured node input gets an eye toggle ('Show in main editor
  window'); exposed inputs render as live controls in the inspector's
  'Exposed controls' panel and edit/persist the node config (Store.models is
  now shared between the Models modal and the main editor via a
  funpack-models-changed event)
- node slot cards are collapsed by default and after adding (like Studio
  shortcuts); click the header to expand; header shows exposed count + status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-06 20:36:53 +03:00
co-authored by Claude Opus 4.8
parent 8b278c9515
commit cce91c5a45
4 changed files with 108 additions and 6 deletions
+40
View File
@@ -101,11 +101,51 @@
wrap.append(box); body.append(wrap);
}
function exposedControl(desc, value, on) {
let ctrl;
if (desc.kind === "combo") {
ctrl = el("select");
(desc.choices || []).forEach((c) => { const o = el("option", null, String(c)); o.value = c; if (c === value) o.selected = true; ctrl.append(o); });
if (!(desc.choices || []).length) { ctrl.append(el("option", null, "(none)")); ctrl.disabled = true; }
ctrl.onchange = () => on(ctrl.value);
} else if (desc.kind === "boolean") {
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.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.oninput = () => on(ctrl.value);
}
return ctrl;
}
function slotLabel(slot) {
return slot.role && slot.role !== "custom" ? slot.role : (slot.node_class || "node");
}
function renderExposed(st) {
const items = [];
((st.models && st.models.slots) || []).forEach((slot) =>
(slot.exposed || []).forEach((d) => items.push([slot, d])));
if (!items.length) return;
const wrap = el("div", "insp-block");
const tag = el("div", "insp-tag"); tag.textContent = "Exposed controls"; wrap.append(tag);
items.forEach(([slot, d]) => {
const ctrl = exposedControl(d, (slot.inputs || {})[d.name], (v) => S.setModelInput(slot.id, d.name, v));
wrap.append(field(`${slotLabel(slot)} · ${d.label || d.name}`, ctrl));
});
body.append(wrap);
}
function render(st) {
clear(body);
if (!st.project) { title.textContent = "Inspector"; body.append(el("div", "pj-meta", "No project open.")); return; }
const scene = st.selectedSceneId ? S.scene(st.selectedSceneId) : null;
if (scene) renderScene(st, scene); else renderProject(st);
renderExposed(st);
renderSplit(st);
}
+36 -4
View File
@@ -11,6 +11,7 @@
const specByClass = {}; // class -> full node spec (inputs/outputs/connection_inputs)
let config = { slots: [] }; // {slots:[{id,role,node_class,inputs:{},wires:{outName:value}}]}
let overlay = null;
const expanded = new Set(); // slot ids currently expanded (collapsed by default)
function roleLabel(key) { const r = roles.find((x) => x.key === key); return r ? r.label : (key === "custom" ? "Node" : key); }
@@ -30,7 +31,26 @@
async function loadSpec(cls) { if (!specByClass[cls]) cache(await API.nodeSpec(cls)); return specByClass[cls]; }
function specFor(slot) { return specByClass[slot.node_class] || null; }
async function persist() { try { config = await API.saveModels(config); } catch (e) { console.error(e); } }
async function persist() {
try { config = await API.saveModels(config); window.dispatchEvent(new Event("funpack-models-changed")); }
catch (e) { console.error(e); }
}
// ── "expose to main editor" (eye toggle) ─────────────────────────────────────
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 });
}
function eyeButton(slot, spec) {
const b = el("button", "eye-btn" + (isExposed(slot, spec.name) ? " on" : ""), "◉");
b.type = "button";
b.title = isExposed(slot, spec.name) ? "Hide from main editor window" : "Show in main editor window";
b.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); toggleExpose(slot, spec); await persist(); render(); };
return b;
}
// ── widget field rendering from object_info spec ─────────────────────────────
function widgetField(spec, value, onChange) {
@@ -127,21 +147,30 @@
// ── slot row (configured) ────────────────────────────────────────────────────
function slotRow(slot, issues) {
const role = roles.find((r) => r.key === slot.role);
const card = el("div", "slot-card");
const isExp = expanded.has(slot.id);
const card = el("div", "slot-card" + (isExp ? " open" : ""));
const errs = (issues || []).filter((i) => i.level === "error").length;
const warns = (issues || []).filter((i) => i.level === "warn").length;
if (errs) card.classList.add("slot-bad");
else if (warns) card.classList.add("slot-warn");
const head = el("div", "slot-head");
head.append(el("span", "slot-chev", isExp ? "▾" : "▸"));
head.append(el("span", "slot-role", role ? role.label : slot.role));
head.append(el("span", "slot-node", slot.node_class));
const nExp = (slot.exposed || []).length;
if (nExp) head.append(el("span", "slot-badge exposed", `◉ ${nExp}`));
if (errs) head.append(el("span", "slot-badge bad", `${errs} error${errs > 1 ? "s" : ""}`));
else if (warns) head.append(el("span", "slot-badge warn", `${warns} warning${warns > 1 ? "s" : ""}`));
else head.append(el("span", "slot-badge ok", "ready"));
const rm = el("button", "btn ghost tiny danger", "remove");
rm.onclick = async () => { config.slots = config.slots.filter((s) => s.id !== slot.id); await persist(); render(); };
rm.onclick = async (e) => { e.stopPropagation(); config.slots = config.slots.filter((s) => s.id !== slot.id); expanded.delete(slot.id); await persist(); render(); };
head.append(rm);
head.onclick = () => { isExp ? expanded.delete(slot.id) : expanded.add(slot.id); render(); };
card.append(head);
if (!isExp) return card; // collapsed: header only
const ib = issuesBox(issues);
if (ib) card.append(ib);
@@ -149,7 +178,10 @@
if (cand && cand.inputs.length) {
const grid = el("div", "slot-fields");
cand.inputs.forEach((spec) => {
grid.append(widgetField(spec, slot.inputs[spec.name], async (v) => { slot.inputs[spec.name] = v; await persist(); }));
const f = widgetField(spec, slot.inputs[spec.name], async (v) => { slot.inputs[spec.name] = v; await persist(); });
f.classList.add("with-eye");
f.append(eyeButton(slot, spec));
grid.append(f);
});
card.append(grid);
}
+20 -1
View File
@@ -11,6 +11,7 @@
preview: null, // {combined_prompt, parsed, warning, parse_error}
gen: { state: "idle", promptId: null, media: [], msg: "" },
saving: false,
models: { slots: [] }, // pluggable node config (shared with Models modal)
};
const listeners = new Set();
@@ -153,10 +154,28 @@
}, 2000);
}
// ── pluggable models / exposed controls ──────────────────────────────────────
async function loadModels() {
try { state.models = await API.getModels(); } catch (_) { state.models = { slots: [] }; }
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();
try { state.models = await API.saveModels(state.models); notify(); }
catch (e) { console.error("saveModels failed", e); }
}
// ── boot ─────────────────────────────────────────────────────────────────────
async function init() {
try { state.health = await API.health(); } catch (_) { state.health = { ok: false }; }
try { const t = await API.transitions(); state.transitions = t.transitions || []; } catch (_) { state.transitions = []; }
await loadModels();
window.addEventListener("funpack-models-changed", loadModels);
await refreshProjectList();
if (state.projects[0]) await loadProject(state.projects[0].id);
else await newProject("My first montage");
@@ -167,6 +186,6 @@
get, set, subscribe, init,
refreshProjectList, loadProject, newProject, deleteProject,
patchProject, patchScene, selectScene, addScene, removeScene, moveScene, scene,
refreshPreview, generate,
refreshPreview, generate, loadModels, setModelInput,
};
})();
+12 -1
View File
@@ -269,13 +269,16 @@ input[type=number] { font-family: var(--font-mono); }
.slot-card { border-left: 3px solid var(--amber); }
.slot-card.slot-warn { border-left-color: var(--amber); }
.slot-card.slot-bad { border-left-color: var(--danger); }
.slot-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.slot-head { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
.slot-card.open .slot-head { margin-bottom: 10px; }
.slot-chev { color: var(--faint); width: 12px; flex: 0 0 auto; font-size: 11px; }
.slot-role { font-weight: 600; }
.slot-node { font-family: var(--font-mono); font-size: 11px; color: var(--muted); background: var(--ink-0); padding: 2px 7px; border-radius: 5px; border: 1px solid var(--line); }
.slot-badge { font-family: var(--font-mono); font-size: 10px; letter-spacing: .5px; text-transform: uppercase; padding: 2px 7px; border-radius: 5px; border: 1px solid; }
.slot-badge.ok { color: var(--teal); border-color: color-mix(in srgb, var(--teal) 50%, var(--line)); }
.slot-badge.warn { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 50%, var(--line)); }
.slot-badge.bad { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 50%, var(--line)); }
.slot-badge.exposed { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 45%, var(--line)); }
.slot-head .btn { margin-left: auto; }
.issue-box { margin-bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
@@ -286,6 +289,14 @@ input[type=number] { font-family: var(--font-mono); }
.slot-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px; }
.slot-fields label.field, .composer-preview label.field { margin-bottom: 0; }
/* eye toggle: expose a node input to the main editor window */
label.field.with-eye { position: relative; padding-right: 26px; }
.eye-btn { position: absolute; top: 0; right: 0; width: 22px; height: 18px; padding: 0; line-height: 1;
background: transparent; border: 1px solid var(--line); border-radius: 5px; color: var(--faint);
cursor: pointer; font-size: 11px; }
.eye-btn:hover { color: var(--muted); border-color: var(--muted); }
.eye-btn.on { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 55%, var(--line)); }
.wire-box { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--line); }
.wire-title { font-family: var(--font-mono); font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; }
.wire-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }