From 3fd08dcd135fedc6badc6e8892309ed3986b411b Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:51:13 +0300 Subject: [PATCH] Improve live preview VAE picker and Models wiring. Show node class in the picker, allow manual wire to Chain Sampler live preview from Models, and keep wires synced with the View menu config. Co-authored-by: Cursor --- movie_editor/backend/builder.py | 12 ++- movie_editor/backend/live_preview.py | 33 ++++++++ movie_editor/backend/nodes.py | 4 + movie_editor/backend/pipeline_wiring.py | 5 ++ movie_editor/frontend/menubar.js | 8 +- movie_editor/frontend/models.js | 100 +++++++++++++++++++++++- movie_editor/frontend/player.js | 12 ++- movie_editor/frontend/store.js | 49 +++++++++++- movie_editor/tests/test_builder.py | 25 ++++++ 9 files changed, 239 insertions(+), 9 deletions(-) diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index 6352b71c..7050ce90 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -81,6 +81,7 @@ OPEN_PORTS: list[tuple[str, str, str, bool]] = [ # (core_id, input, type, requ ("studio", "source_image", "IMAGE", False), ("studio", "latent", "LATENT", False), ("sampler", "vae", "VAE", True), + ("sampler", "preview_vae", "VAE", False), ("concat", "audio_latent", "LATENT", True), ("audiodec", "audio_vae", "VAE", True), ] @@ -207,6 +208,9 @@ def _wire_live_preview_vae( return if "sampler" not in graph: return + existing = graph["sampler"].get("inputs", {}).get("preview_vae") + if isinstance(existing, list): + return sid = lp.get("slot_id") if not sid: report["unsatisfied"].append( @@ -591,13 +595,14 @@ def core_graph(object_info: dict, models_config: dict | None = None) -> list[dic def _port_index(object_info: dict) -> dict[str, tuple[str, str]]: """Map pipeline-port id ('Class.input' / 'FunPackStudio.input') -> (core_id, input).""" - cls_to_core = {v: k for k, v in CORE.items()} idx: dict[str, tuple[str, str]] = {} for cid, cls in CORE.items(): nd = object_info.get(cls) for ci in connection_inputs(nd or {}): idx[f"{cls}.{ci['name']}"] = (cid, ci["name"]) - # FunPack ports use the class name directly too (already covered above). + # Authoritative open-core ports (includes optional sockets like preview_vae). + for port_id, (cid, inp) in pipeline_wiring.PORT_TO_OPEN_CORE.items(): + idx[port_id] = (cid, inp) return idx @@ -661,6 +666,9 @@ def _autowire(graph, slots, slot_node_id, slot_def, object_info, producers, repo L = lambda nid: label.get(nid, nid) targets = list(OPEN_PORTS) # (core_id, input, type, required) + # Live preview VAE is explicit-only (Models wire or live_preview picker), never auto-wired. + targets = [(nid, inp, t, req) for (nid, inp, t, req) in targets + if not (nid == "sampler" and inp == "preview_vae")] for s in slots: # slot connection inputs (e.g. image-proc vae/image/length) if active_slots is not None and s["id"] not in active_slots: continue # inert slot (feeds nothing) — don't auto-wire or block on its inputs diff --git a/movie_editor/backend/live_preview.py b/movie_editor/backend/live_preview.py index 1d0aedf9..2811ac5c 100644 --- a/movie_editor/backend/live_preview.py +++ b/movie_editor/backend/live_preview.py @@ -72,8 +72,41 @@ def parse_config(raw: Any) -> dict[str, Any]: return {} +PREVIEW_VAE_PORT = "FunPackLTXAVSceneChainSampler.preview_vae" +PREVIEW_VAE_WIRE = f"port:{PREVIEW_VAE_PORT}" + + +def _wire_targets(raw: Any) -> list[str]: + if not raw: + return [] + if isinstance(raw, list): + return [str(t) for t in raw if t] + return [str(raw)] + + +def preview_vae_wire(models: Any) -> tuple[Optional[str], Optional[str]]: + """Return (slot_id, vae_output) wired to Chain Sampler live preview, if any.""" + if not isinstance(models, dict): + return None, None + for slot in models.get("slots") or []: + sid = slot.get("id") + if not sid: + continue + for out_name, raw in (slot.get("wires") or {}).items(): + if PREVIEW_VAE_WIRE in _wire_targets(raw): + return str(sid), str(out_name or "VAE") + lp = models.get("live_preview") or {} + sid = lp.get("slot_id") + if sid: + return str(sid), str(lp.get("vae_output") or "VAE") + return None, None + + def models_enabled(models: Any) -> bool: if not isinstance(models, dict): return False + sid, _ = preview_vae_wire(models) + if sid: + return True lp = models.get("live_preview") or {} return bool(lp.get("enabled") and lp.get("slot_id")) diff --git a/movie_editor/backend/nodes.py b/movie_editor/backend/nodes.py index be0c1d19..69173468 100644 --- a/movie_editor/backend/nodes.py +++ b/movie_editor/backend/nodes.py @@ -12,6 +12,7 @@ from __future__ import annotations import json from . import config +from . import pipeline_wiring # Types that are graph CONNECTIONS (wired automatically), not user widgets. LINK_TYPES = { @@ -80,6 +81,7 @@ ROLES: dict[str, dict] = { "unet": {"label": "Unet / Diffusion Model", "category": "Loaders", "output": "MODEL"}, "lora": {"label": "LoRA", "category": "Loaders", "output": "MODEL", "input": "MODEL"}, "video_vae": {"label": "Video VAE", "category": "Loaders", "output": "VAE"}, + "preview_vae": {"label": "Live Preview VAE", "category": "Loaders", "output": "VAE"}, "audio_vae": {"label": "Audio VAE", "category": "Loaders", "output": "VAE"}, "clip": {"label": "CLIP / Text Encoder", "category": "Loaders", "output": "CLIP"}, "clip_vision": {"label": "CLIP Vision", "category": "Loaders", "output": "CLIP_VISION"}, @@ -357,6 +359,8 @@ def pipeline_ports(object_info: dict | None = None) -> list[dict]: if object_info: for cls, label in CORE_PORT_NODES: ports += ports_from_object_info(object_info, cls, label) + for p in ports: + p["label"] = pipeline_wiring.port_label(p["id"]) return ports diff --git a/movie_editor/backend/pipeline_wiring.py b/movie_editor/backend/pipeline_wiring.py index 73297094..5ba7c701 100644 --- a/movie_editor/backend/pipeline_wiring.py +++ b/movie_editor/backend/pipeline_wiring.py @@ -16,6 +16,7 @@ ROLE_WIRE_TARGETS: dict[str, list[tuple[str, Optional[str], str]]] = { "clip": [("CLIP", None, "FunPackStudio.clip")], "lora": [("MODEL", None, "FunPackStudio.model"), ("CLIP", None, "FunPackStudio.clip")], "video_vae": [("VAE", None, "FunPackLTXAVSceneChainSampler.vae")], + "preview_vae": [("VAE", None, "FunPackLTXAVSceneChainSampler.preview_vae")], "audio_vae": [("VAE", None, "LTXVAudioVAEDecode.audio_vae")], "audio_encoder": [("LATENT", None, "LTXVConcatAVLatent.audio_latent")], "empty_latent": [("LATENT", None, "FunPackStudio.latent")], @@ -45,6 +46,7 @@ DEFAULT_WIRES_BY_ROLE: dict[str, dict[str, str]] = { "unet": {"MODEL": "port:FunPackStudio.model"}, "clip": {"CLIP": "port:FunPackStudio.clip"}, "video_vae": {"VAE": "port:FunPackLTXAVSceneChainSampler.vae"}, + "preview_vae": {"VAE": "port:FunPackLTXAVSceneChainSampler.preview_vae"}, "audio_vae": {"VAE": "port:LTXVAudioVAEDecode.audio_vae"}, "audio_encoder": {"LATENT": "port:LTXVConcatAVLatent.audio_latent"}, "empty_latent": {"LATENT": "port:FunPackStudio.latent"}, @@ -63,6 +65,7 @@ PORT_LABELS: dict[str, str] = { "FunPackStudio.latent": "Studio · latent (forwards to Concat AV · video_latent)", "FunPackStudio.source_image": "Studio · source_image (Img2Video anchor)", "FunPackLTXAVSceneChainSampler.vae": "Chain Sampler · vae", + "FunPackLTXAVSceneChainSampler.preview_vae": "Chain Sampler · live preview", "LTXVConcatAVLatent.audio_latent": "Concat AV Latent · audio_latent", "LTXVAudioVAEDecode.audio_vae": "Audio VAE Decode · audio_vae", } @@ -75,6 +78,7 @@ OPEN_CORE_INPUTS: frozenset[tuple[str, str]] = frozenset({ ("studio", "source_image"), ("studio", "latent"), ("sampler", "vae"), + ("sampler", "preview_vae"), ("concat", "audio_latent"), ("audiodec", "audio_vae"), }) @@ -85,6 +89,7 @@ PORT_TO_OPEN_CORE: dict[str, tuple[str, str]] = { "FunPackStudio.source_image": ("studio", "source_image"), "FunPackStudio.latent": ("studio", "latent"), "FunPackLTXAVSceneChainSampler.vae": ("sampler", "vae"), + "FunPackLTXAVSceneChainSampler.preview_vae": ("sampler", "preview_vae"), "LTXVConcatAVLatent.audio_latent": ("concat", "audio_latent"), "LTXVAudioVAEDecode.audio_vae": ("audiodec", "audio_vae"), } diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js index 58b428ac..26d9eac3 100644 --- a/movie_editor/frontend/menubar.js +++ b/movie_editor/frontend/menubar.js @@ -166,10 +166,14 @@ const vaeOuts = (spec.outputs || []).filter((o) => o.type === "VAE"); if (!vaeOuts.length) continue; const outName = vaeOuts.find((o) => o.name === "VAE")?.name || vaeOuts[0].name; + const cls = s.node_class || "?"; + const name = (s.label || "").trim(); + const label = name && name !== cls ? `${name} — ${cls}` : cls; + const role = s.role_label || s.role; options.push({ value: `${s.id}\x1f${outName}`, - label: s.label || s.node_class || s.id, - hint: `${outName} · ${s.node_class}`, + label, + hint: [role, `${outName} output`].filter(Boolean).join(" · "), }); } if (!options.length) { diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index 90e975db..97b19af8 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -19,6 +19,74 @@ const expanded = new Set(); // slot ids currently expanded (collapsed by default) let linkMode = false; // selecting inputs to bind into one shared control let linkSel = []; // [{slotId, input, kind, choices, label}] + const LIVE_PREVIEW_PORT = "port:FunPackLTXAVSceneChainSampler.preview_vae"; + + function _clearLivePreviewWires(exceptSlotId, exceptOut) { + for (const s of config.slots) { + s.wires = s.wires || {}; + for (const outName of Object.keys(s.wires)) { + if (s.id === exceptSlotId && outName === exceptOut) continue; + const kept = wireTargets(s.wires[outName]).filter((t) => t !== LIVE_PREVIEW_PORT); + if (kept.length) s.wires[outName] = kept; + else delete s.wires[outName]; + } + } + } + + function _syncLivePreviewConfig(slotId, outName, wired) { + config.live_preview = { + enabled: false, + slot_id: null, + vae_output: "VAE", + ...(config.live_preview || {}), + }; + if (wired && slotId && outName) { + config.live_preview.enabled = true; + config.live_preview.slot_id = slotId; + config.live_preview.vae_output = outName; + } else if (!wired && config.live_preview.slot_id === slotId + && (config.live_preview.vae_output || "VAE") === outName) { + config.live_preview.enabled = false; + config.live_preview.slot_id = null; + } + } + + function _findLivePreviewWire() { + for (const s of config.slots) { + for (const [outName, raw] of Object.entries(s.wires || {})) { + if (wireTargets(raw).includes(LIVE_PREVIEW_PORT)) { + return { slotId: s.id, outName }; + } + } + } + return null; + } + + function _scanLivePreviewFromWires() { + const hit = _findLivePreviewWire(); + if (hit) _syncLivePreviewConfig(hit.slotId, hit.outName, true); + } + + function _afterLivePreviewWireChange(slot, outName) { + const targets = wireTargets(slot.wires?.[outName]); + if (targets.includes(LIVE_PREVIEW_PORT)) { + _clearLivePreviewWires(slot.id, outName); + if (!wireTargets(slot.wires[outName]).includes(LIVE_PREVIEW_PORT)) { + _addWire(slot.id, outName, LIVE_PREVIEW_PORT); + } + _syncLivePreviewConfig(slot.id, outName, true); + } else if (config.live_preview?.slot_id === slot.id + && (config.live_preview?.vae_output || "VAE") === outName) { + _syncLivePreviewConfig(slot.id, outName, false); + } + } + + function slotDisplayLabel(slot) { + const cls = slot.node_class || "?"; + const name = (slot.label || "").trim(); + if (name && name !== cls) return `${name} — ${cls}`; + return cls; + } function roleLabel(key) { const r = roles.find((x) => x.key === key); return r ? r.label : (key === "custom" ? "Node" : (key || "?")); } function slotName(slot) { return slot.label || slot.node_class; } @@ -112,6 +180,7 @@ _clearPortWires(portId, parsed.slotId, parsed.out); _addWire(parsed.slotId, parsed.out, "port:" + portId); } + _scanLivePreviewFromWires(); } function clearInternalCoreOverrides() { @@ -325,7 +394,7 @@ const roleSpan = el("span", "slot-role", roleText); roleSpan.title = "Click ✎ to rename this label"; head.append(roleSpan); - head.append(el("span", "slot-node", slotName(slot))); + head.append(el("span", "slot-node", slotDisplayLabel(slot))); const ren = el("button", "ic-btn", "✎"); ren.title = "Rename role label"; ren.onclick = async (e) => { e.stopPropagation(); @@ -533,10 +602,26 @@ 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 + " (not allowed)"); o.value = t; o.selected = true; sel.append(o); } - sel.onchange = async () => { _setWireTarget(slot, out.name, targets[i], sel.value); targets[i] = sel.value; reconcileOpenPortWiring(); await persist(); render(); }; + sel.onchange = async () => { + _setWireTarget(slot, out.name, targets[i], sel.value); + targets[i] = sel.value; + slot.wires[out.name] = targets; + _afterLivePreviewWireChange(slot, out.name); + reconcileOpenPortWiring(); + await persist(); + render(); + }; const rm = el("button", "btn ghost tiny wire-rm", "×"); rm.title = "Remove this wire"; - rm.onclick = async () => { _setWireTarget(slot, out.name, targets[i], ""); targets.splice(i, 1); reconcileOpenPortWiring(); await persist(); render(); }; + rm.onclick = async () => { + _setWireTarget(slot, out.name, targets[i], ""); + targets.splice(i, 1); + slot.wires[out.name] = targets; + _afterLivePreviewWireChange(slot, out.name); + reconcileOpenPortWiring(); + await persist(); + render(); + }; row.append(sel, rm); wrap.append(row); }); @@ -859,6 +944,10 @@ break; } } + if (cid === "sampler" && inp === "preview_vae") { + _clearLivePreviewWires(parsed.slotId, parsed.out); + _syncLivePreviewConfig(parsed.slotId, parsed.out, true); + } } else if (prev) { const old = _parseOutSource(prev); if (old) { @@ -869,6 +958,10 @@ } } } + if (cid === "sampler" && inp === "preview_vae") { + _clearLivePreviewWires(null, null); + config.live_preview = { ...(config.live_preview || {}), enabled: false, slot_id: null }; + } } } function coreCard(n) { @@ -953,6 +1046,7 @@ ? "Fixed FunPack path (Studio → Conditioning → Chain Sampler → decode). " + "Wire video LATENT to Studio · latent (not Concat · video_latent — that link is internal). " + "Wire IMAGE via Input Image Processing → Studio · source_image (Timeline default). " + + "Wire a fast VAE (e.g. taehv) to Chain Sampler · live preview for per-scene previews. " + "MODEL/CLIP may chain through patchers. Enable Full control to override core links." : "The fixed FunPack nodes and their wiring. Each input defaults to its built-in source — pick another to re-wire it."; sec.append(el("div", "links-hint", hint)); diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index 00c7688a..659a9b40 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -1131,7 +1131,16 @@ } canvas.append(ro); } - if (["queuing", "running", "pending"].includes(gen.state) && S.get().models?.live_preview?.enabled) { + if (["queuing", "running", "pending"].includes(gen.state)) { + const lp = S.get().models?.live_preview || {}; + const lpPort = "port:FunPackLTXAVSceneChainSampler.preview_vae"; + const lpWired = (S.get().models?.slots || []).some((s) => + Object.values(s.wires || {}).some((raw) => { + const arr = Array.isArray(raw) ? raw : raw ? [raw] : []; + return arr.includes(lpPort); + })); + const showLp = lp.enabled || lp.slot_id || lpWired; + if (showLp) { const lpLayer = el("div", "pm-live-preview"); _livePreviewEl = el("img", "pm-live-preview-img"); _livePreviewEl.alt = "Live preview"; @@ -1147,6 +1156,7 @@ lpLayer.append(_livePreviewEl, _livePreviewBadgeEl); canvas.append(lpLayer); } + } if (previewAsset?.kind === "image") { const layer = el("div", "pm-media-preview"); const label = el("div", "pm-media-preview-label", previewAsset.name || "Media preview"); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index d1ab0e2b..6bdf08a4 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -37,6 +37,42 @@ const listeners = new Set(); const SAVE_DEBOUNCE_MS = 5000; // discrete edits (dropdowns, toggles) const SAVE_SILENT_DEBOUNCE_MS = 20000; // typing in fields — flushed on blur / generate + const LIVE_PREVIEW_PORT = "port:FunPackLTXAVSceneChainSampler.preview_vae"; + + function _modelWireTargets(raw) { + if (!raw) return []; + return Array.isArray(raw) ? raw.slice() : [raw]; + } + + function _clearLivePreviewWiresInModels(models, exceptSlotId, exceptOut) { + for (const s of models.slots || []) { + s.wires = s.wires || {}; + for (const outName of Object.keys(s.wires)) { + if (s.id === exceptSlotId && outName === exceptOut) continue; + const kept = _modelWireTargets(s.wires[outName]).filter((t) => t !== LIVE_PREVIEW_PORT); + if (kept.length) s.wires[outName] = kept; + else delete s.wires[outName]; + } + } + } + + function _applyLivePreviewWire(models, slotId, vaeOutput) { + _clearLivePreviewWiresInModels(models, slotId, vaeOutput); + const slot = (models.slots || []).find((s) => s.id === slotId); + if (!slot) return; + slot.wires = slot.wires || {}; + const out = vaeOutput || "VAE"; + const targets = _modelWireTargets(slot.wires[out]); + if (!targets.includes(LIVE_PREVIEW_PORT)) targets.push(LIVE_PREVIEW_PORT); + slot.wires[out] = targets; + } + + function _livePreviewConfigured() { + const lp = state.models?.live_preview || {}; + if (lp.slot_id) return true; + return (state.models?.slots || []).some((s) => + Object.values(s.wires || {}).some((raw) => _modelWireTargets(raw).includes(LIVE_PREVIEW_PORT))); + } let saveTimer = null; let _localDirty = false; // local edits newer than the in-flight save snapshot let _commitPromise = null; // avoid overlapping saves stomping newer anchor edits @@ -2689,7 +2725,7 @@ }); } } catch (_) {} - if (state.models?.live_preview?.enabled) { + if (_livePreviewConfigured()) { try { const lp = await API.livePreview(); if (lp?.ready && lp.url) { @@ -3330,6 +3366,16 @@ ...(state.models.live_preview || {}), enabled: !!enabled, }; + if (!enabled) { + _clearLivePreviewWiresInModels(state.models, null, null); + state.models.live_preview.slot_id = null; + } else if (state.models.live_preview.slot_id) { + _applyLivePreviewWire( + state.models, + state.models.live_preview.slot_id, + state.models.live_preview.vae_output, + ); + } if (state.project) { state.project.models = JSON.parse(JSON.stringify(state.models)); } @@ -3350,6 +3396,7 @@ vae_output: vaeOutput || "VAE", enabled: true, }; + _applyLivePreviewWire(state.models, slotId, vaeOutput); if (state.project) { state.project.models = JSON.parse(JSON.stringify(state.models)); } diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index 12cf0d9e..42d2e76b 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -237,3 +237,28 @@ def test_live_preview_wires_preview_vae_separately(): assert graph["sampler"]["inputs"]["preview_vae"] == ["slot_pv", 0] assert any("preview_vae" in w for w in report["wired"]) assert report["blocking"] == [] + + +def test_live_preview_port_wire_from_models_slot(): + models = { + "full_control": True, + "slots": [ + {"id": "u", "node_class": "UnetLoader", "inputs": {}, "wires": {"MODEL": "port:FunPackStudio.model"}}, + {"id": "c", "node_class": "ClipLoader", "inputs": {}, "wires": {"CLIP": "port:FunPackStudio.clip"}}, + {"id": "v", "node_class": "VaeLoader", "inputs": {}, "wires": { + "VAE": ["port:FunPackLTXAVSceneChainSampler.vae", "port:LTXVAudioVAEDecode.audio_vae"], + }}, + {"id": "pv", "node_class": "VaeLoader", "inputs": {}, "wires": { + "VAE": "port:FunPackLTXAVSceneChainSampler.preview_vae", + }}, + {"id": "ip", "node_class": "ImgProc", "inputs": {}, + "wires": {"latent": "port:FunPackStudio.latent", + "Latent": "port:LTXVConcatAVLatent.audio_latent", + "output_image": "port:FunPackStudio.source_image"}, + "input_sources": {"vae": "out:v:VAE"}}, + {"id": "li", "node_class": "LoadImage", "inputs": {}, "wires": {"IMAGE": "node:ip:image"}}, + ], + } + graph, report = builder.build(OI, models, PARAMS) + assert graph["sampler"]["inputs"]["preview_vae"] == ["slot_pv", 0] + assert report["blocking"] == []