diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 8f0b587d..c342177d 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -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); } diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 34678194..8ae47867 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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); } diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 87b5f3fa..d50554f2 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, }; })(); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 72efc238..1fd586a0 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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; }