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:
co-authored by
Claude Opus 4.8
parent
8b278c9515
commit
cce91c5a45
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user