diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 394657d7..aaa760f1 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -418,8 +418,8 @@ const head = el("div", "insp-global-head"); head.append(el("span", "insp-global-title", "Global prompt")); const apply = el("button", "btn primary tiny", "Apply →"); - apply.title = "Reparse this prompt into anchor, scenes and transitions (overwrites the timeline)"; - apply.disabled = !dirty; + apply.title = "Split this prompt into anchor, scenes and transitions on the timeline (overwrites it)"; + apply.disabled = !val.trim(); // clickable whenever there's a prompt to (re)split — not only after edits apply.onclick = async () => { apply.disabled = true; apply.textContent = "Applying…"; await S.applyGlobalPrompt(gpDraft != null ? gpDraft : live); @@ -431,11 +431,11 @@ const ta = el("textarea", "insp-global-ta"); ta.rows = 3; ta.value = val; ta.dataset.k = "global-prompt"; ta.placeholder = "Anchor, then scene texts joined by transition markers — the whole montage as one prompt."; - ta.oninput = () => { gpDraft = ta.value; apply.disabled = (gpDraft === live); }; + ta.oninput = () => { gpDraft = ta.value; apply.disabled = !ta.value.trim(); }; sec.append(ta); sec.append(el("div", "insp-hint", dirty - ? "Edited — press Apply to rebuild the timeline from this prompt." - : "Live view of the assembled prompt. Edit a scene below and it updates here; edit here + Apply to rewrite scenes.")); + ? "Edited — press Apply to (re)split this prompt onto the timeline." + : "Live view of the assembled prompt. Edit a scene below and it updates here; press Apply to split this prompt into scenes.")); body.append(sec); } diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index f5ea5ea4..11130e4f 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -272,7 +272,7 @@ const cur = slot.input_sources[ci.name] || ""; srcs.forEach((s) => { const o = el("option", null, s.label); o.value = s.value; if (s.value === cur) o.selected = true; sel.append(o); }); if (cur && !srcs.some((s) => s.value === cur)) { const o = el("option", null, cur + " (missing)"); o.value = cur; o.selected = true; sel.append(o); } - sel.onchange = async () => { slot.input_sources[ci.name] = sel.value; await persist(); }; + sel.onchange = async () => { _setInputSource(slot, ci.name, sel.value); await persist(); render(); }; row.append(sel); sbox.append(row); }); @@ -314,6 +314,55 @@ return Array.isArray(raw) ? raw : [raw]; } + // ── edge mirroring ──────────────────────────────────────────────────────────── + // An output→input edge can be authored from either side: a slot's wires + // ("node::") OR the destination's input_sources ("out::"). + // They describe the SAME connection, so we keep both in sync — wiring an output shows + // the input as connected on the other node, and vice-versa. + function _parseNodeTarget(t) { + if (typeof t !== "string" || !t.startsWith("node:")) return null; + const parts = t.split(":"); + return { slotId: parts[1], input: parts.slice(2).join(":") }; + } + function _parseOutSource(s) { + if (typeof s !== "string" || !s.startsWith("out:")) return null; + const parts = s.split(":"); + return { slotId: parts[1], out: parts.slice(2).join(":") }; + } + function _addWire(slotId, outName, target) { + const s = slotById(slotId); if (!s) return; + s.wires = s.wires || {}; + const arr = wireTargets(s.wires[outName]); + if (!arr.includes(target)) arr.push(target); + s.wires[outName] = arr; + } + function _removeWire(slotId, outName, target) { + const s = slotById(slotId); if (!s || !s.wires) return; + s.wires[outName] = wireTargets(s.wires[outName]).filter((t) => t !== target); + } + // Set a destination's input source + mirror it as a wire on the source slot. + function _setInputSource(destSlot, inp, value) { + const prev = _parseOutSource(destSlot.input_sources[inp]); + if (prev) _removeWire(prev.slotId, prev.out, `node:${destSlot.id}:${inp}`); + destSlot.input_sources[inp] = value; + const next = _parseOutSource(value); + if (next) _addWire(next.slotId, next.out, `node:${destSlot.id}:${inp}`); + } + // Replace a wire target + mirror it as an input source on the destination slot. + function _setWireTarget(srcSlot, outName, oldTarget, newTarget) { + const old = _parseNodeTarget(oldTarget); + if (old) { + const ds = slotById(old.slotId); + if (ds && ds.input_sources && ds.input_sources[old.input] === `out:${srcSlot.id}:${outName}`) + ds.input_sources[old.input] = ""; + } + const nu = _parseNodeTarget(newTarget); + if (nu) { + const ds = slotById(nu.slotId); + if (ds) { ds.input_sources = ds.input_sources || {}; ds.input_sources[nu.input] = `out:${srcSlot.id}:${outName}`; } + } + } + function destMulti(slot, out) { slot.wires = slot.wires || {}; // normalize to array in-place @@ -331,10 +380,10 @@ const sel = el("select", "wire-select"); dests.forEach((d) => { const o = el("option", null, d.label); o.value = d.value; if (d.value === t) o.selected = true; sel.append(o); }); if (t && !dests.some((d) => d.value === t)) { const o = el("option", null, t + " (missing)"); o.value = t; o.selected = true; sel.append(o); } - sel.onchange = async () => { targets[i] = sel.value; await persist(); }; + sel.onchange = async () => { _setWireTarget(slot, out.name, targets[i], sel.value); targets[i] = sel.value; await persist(); render(); }; const rm = el("button", "btn ghost tiny wire-rm", "×"); rm.title = "Remove this wire"; - rm.onclick = async () => { targets.splice(i, 1); renderRows(); await persist(); }; + rm.onclick = async () => { _setWireTarget(slot, out.name, targets[i], ""); targets.splice(i, 1); await persist(); render(); }; row.append(sel, rm); wrap.append(row); });