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"] == []