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 <cursoragent@cursor.com>
This commit is contained in:
digital-garbage
2026-06-12 23:51:13 +03:00
co-authored by Cursor
parent 2761d6d3ac
commit 3fd08dcd13
9 changed files with 239 additions and 9 deletions
+10 -2
View File
@@ -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
+33
View File
@@ -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"))
+4
View File
@@ -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
+5
View File
@@ -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"),
}
+6 -2
View File
@@ -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) {
+97 -3
View File
@@ -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));
+11 -1
View File
@@ -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");
+48 -1
View File
@@ -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));
}
+25
View File
@@ -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"] == []