From 93281a818bd929b5fdffe5e8fbebad5ce8eec966 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:07:07 +0300 Subject: [PATCH] Fix stale generate input and live preview visibility. Flush pending global prompt edits before save/generate so runs use the latest timeline text and anchors. Improve live preview wiring detection, progress polling, overlay stacking, and diagnostics when a VAE is configured. --- movie_editor/backend/builder.py | 13 +-- movie_editor/backend/live_preview.py | 35 ++++++-- movie_editor/frontend/inspector.js | 3 +- movie_editor/frontend/player.js | 54 +++++++++---- movie_editor/frontend/store.js | 117 +++++++++++++++++++-------- movie_editor/frontend/styles.css | 2 +- movie_editor/server.py | 36 ++++----- samplers.py | 9 ++- 8 files changed, 182 insertions(+), 87 deletions(-) diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index 7050ce90..3c21a1ef 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -23,6 +23,7 @@ import json from typing import Any, Optional from . import config +from . import live_preview from . import pipeline_wiring from .nodes import WIDGET_PRIMITIVES, connection_inputs, node_outputs, _combo_default @@ -202,26 +203,20 @@ def _wire_live_preview_vae( slot_def: dict, report: dict, ) -> None: - """Wire models.live_preview slot VAE output -> Chain Sampler preview_vae.""" - lp = (models_config or {}).get("live_preview") or {} - if not lp.get("enabled"): - return + """Wire preview VAE slot -> Chain Sampler preview_vae when configured.""" if "sampler" not in graph: return existing = graph["sampler"].get("inputs", {}).get("preview_vae") if isinstance(existing, list): return - sid = lp.get("slot_id") + sid, out_name = live_preview.preview_vae_wire(models_config) if not sid: - report["unsatisfied"].append( - "Live preview is enabled but no preview VAE is selected (View → Choose preview VAE…)." - ) return nid = slot_node_id.get(sid) if not nid or nid not in graph: report["unsatisfied"].append(f"Live preview VAE slot '{sid}' is not in the graph.") return - out_name = str(lp.get("vae_output") or "VAE") + out_name = str(out_name or "VAE") nd = slot_def.get(sid) or {} outs = node_outputs(nd) oidx = _output_index(nd, out_name) diff --git a/movie_editor/backend/live_preview.py b/movie_editor/backend/live_preview.py index 2811ac5c..7712f0f3 100644 --- a/movie_editor/backend/live_preview.py +++ b/movie_editor/backend/live_preview.py @@ -95,6 +95,12 @@ def preview_vae_wire(models: Any) -> tuple[Optional[str], Optional[str]]: 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") + co = (models.get("core_overrides") or {}).get("sampler") or {} + src = str(co.get("preview_vae") or "") + if src.startswith("out:"): + parts = src.split(":", 2) + if len(parts) >= 3 and parts[1]: + return str(parts[1]), str(parts[2] or "VAE") lp = models.get("live_preview") or {} sid = lp.get("slot_id") if sid: @@ -103,10 +109,27 @@ def preview_vae_wire(models: Any) -> tuple[Optional[str], Optional[str]]: 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")) + return bool(sid) + + +def api_payload(ui_prefix: str = "/funpack/movie") -> dict[str, Any]: + """Status dict for /api/live-preview and /api/progress.""" + import os + + snap = snapshot() + if not snap.get("active"): + return {"active": False} + frame_path = snap.get("frame_path") or "" + ready = bool(frame_path and os.path.isfile(frame_path)) + payload: dict[str, Any] = { + "active": True, + "ready": ready, + "scene_index": snap.get("scene_index", -1), + "scene_count": snap.get("scene_count", 0), + "updated_at": snap.get("updated_at", 0), + } + if ready: + ts = int(float(snap.get("updated_at") or 0) * 1000) + payload["url"] = f"{ui_prefix}/api/live-preview/frame?t={ts}" + return payload diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index b22d9efe..0307c2ad 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -920,8 +920,7 @@ if (_editing) return; const k = t.dataset?.k; if (k === "global-prompt") { - S.flushSave(); - render(S.get()); + S.flushPendingEdits().then(() => render(S.get())); return; } if (!S.flushSave()) render(S.get()); diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index 659a9b40..80e4fdcd 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -877,7 +877,36 @@ let _tcEl = null; // timecode span (updated live, not rebuilt) let _playBtnEl = null; // play/pause button (glyph updated live, not rebuilt) let _genMsgEl = null, _genBarEl = null, _genFillEl = null; // gen readout, updated live - let _livePreviewEl = null, _livePreviewBadgeEl = null; + let _livePreviewEl = null, _livePreviewBadgeEl = null, _livePreviewLayerEl = null; + + function _livePreviewConfigured(st) { + const m = st?.models || {}; + const lp = m.live_preview || {}; + if (lp.enabled || lp.slot_id) return true; + if ((m.core_overrides?.sampler?.preview_vae || "").startsWith("out:")) return true; + const port = "port:FunPackLTXAVSceneChainSampler.preview_vae"; + return (m.slots || []).some((s) => + Object.values(s.wires || {}).some((raw) => { + const arr = Array.isArray(raw) ? raw : raw ? [raw] : []; + return arr.includes(port); + })); + } + + function _ensureLivePreviewDom() { + if (_livePreviewLayerEl && _livePreviewEl) return; + const canvas = document.querySelector(".pm-canvas"); + if (!canvas) return; + const lpLayer = el("div", "pm-live-preview"); + _livePreviewEl = el("img", "pm-live-preview-img"); + _livePreviewEl.alt = "Live preview"; + _livePreviewEl.draggable = false; + _livePreviewEl.style.display = "none"; + _livePreviewBadgeEl = el("div", "pm-live-preview-badge"); + _livePreviewBadgeEl.style.display = "none"; + lpLayer.append(_livePreviewEl, _livePreviewBadgeEl); + canvas.append(lpLayer); + _livePreviewLayerEl = lpLayer; + } // Update the generation readout in place on progress ticks (no store re-render, so the // editor stays interactive — frames can be saved, effects added — while a job runs). @@ -890,9 +919,11 @@ _genFillEl.style.width = Math.min(100, Math.round((g.step / g.maxStep) * 100)) + "%"; } } - if (_livePreviewEl && g.livePreviewUrl) { - if (_livePreviewEl.src !== g.livePreviewUrl) _livePreviewEl.src = g.livePreviewUrl; - _livePreviewEl.style.display = ""; + if (g.livePreviewUrl) { + _ensureLivePreviewDom(); + if (_livePreviewLayerEl) _livePreviewLayerEl.style.display = ""; + if (_livePreviewEl && _livePreviewEl.src !== g.livePreviewUrl) _livePreviewEl.src = g.livePreviewUrl; + if (_livePreviewEl) _livePreviewEl.style.display = ""; } if (_livePreviewBadgeEl && g.liveScene >= 0 && g.liveSceneCount > 0) { _livePreviewBadgeEl.textContent = `Scene ${g.liveScene + 1}/${g.liveSceneCount}`; @@ -1112,7 +1143,7 @@ // store render; live progress ticks update it in place (see the gen-progress listener), // so the rest of the editor isn't rebuilt while a generation runs. _genMsgEl = _genBarEl = _genFillEl = null; - _livePreviewEl = _livePreviewBadgeEl = null; + _livePreviewEl = _livePreviewBadgeEl = _livePreviewLayerEl = null; if (["queuing", "running", "pending", "error"].includes(gen.state)) { const busy = gen.state !== "error"; const ro = el("div", "gen-readout" + (gen.state === "error" ? " error" : "")); @@ -1131,17 +1162,9 @@ } canvas.append(ro); } - 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) { + if (["queuing", "running", "pending"].includes(gen.state) && _livePreviewConfigured(st)) { const lpLayer = el("div", "pm-live-preview"); + _livePreviewLayerEl = lpLayer; _livePreviewEl = el("img", "pm-live-preview-img"); _livePreviewEl.alt = "Live preview"; _livePreviewEl.draggable = false; @@ -1156,7 +1179,6 @@ 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 6bdf08a4..97123fb5 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -69,7 +69,8 @@ function _livePreviewConfigured() { const lp = state.models?.live_preview || {}; - if (lp.slot_id) return true; + if (lp.slot_id || lp.enabled) return true; + if ((state.models?.core_overrides?.sampler?.preview_vae || "").startsWith("out:")) return true; return (state.models?.slots || []).some((s) => Object.values(s.wires || {}).some((raw) => _modelWireTargets(raw).includes(LIVE_PREVIEW_PORT))); } @@ -252,13 +253,36 @@ } let _globalApplyTimer = null; + let _globalApplyPromise = null; + let _pendingGlobalPromptText = null; + function scheduleGlobalPromptApply(text) { + _pendingGlobalPromptText = text; + const trimmed = String(text || "").trim(); + if (trimmed && trimmed !== buildGlobalPromptFromTimeline(state.project)) { + _localDirty = true; + _notifySaveChip(); + } clearTimeout(_globalApplyTimer); _globalApplyTimer = setTimeout(() => { + _globalApplyTimer = null; applyGlobalPromptQuiet(text).catch((e) => console.warn("Global prompt apply failed:", e)); }, 500); } + async function _flushPendingGlobalPrompt() { + clearTimeout(_globalApplyTimer); + _globalApplyTimer = null; + if (_globalApplyPromise) await _globalApplyPromise; + const pending = (_pendingGlobalPromptText || "").trim(); + if (!pending) return; + if (pending === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) { + _pendingGlobalPromptText = null; + return; + } + await applyGlobalPromptQuiet(pending, { force: true }); + } + function _normalizeSceneText(t) { return String(t || "").trim().replace(/\s+/g, " "); } @@ -506,30 +530,53 @@ return true; } - async function applyGlobalPromptQuiet(text) { + async function applyGlobalPromptQuiet(text, opts = {}) { if (!state.project) return false; const trimmed = String(text || "").trim(); - if (!trimmed) return false; - if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) return true; - _historyRecord(); - let res; - try { res = await API.parsePrompt(state.project.id, trimmed); } - catch (e) { - console.warn("Global prompt parse failed:", e); + const force = !!opts.force; + if (!trimmed) { + _pendingGlobalPromptText = null; return false; } - if (!await _distributeGlobalPrompt(trimmed, res)) return false; - clearTimeout(saveTimer); - saveTimer = null; - _localDirty = true; - scheduleSaveSilent(); - refreshPreview(true); - return true; + if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) { + _pendingGlobalPromptText = null; + return true; + } + if (_globalApplyPromise) return _globalApplyPromise; + _globalApplyPromise = (async () => { + try { + if (!force && _pendingGlobalPromptText != null && String(_pendingGlobalPromptText).trim() !== trimmed) { + return false; + } + _historyRecord(); + let res; + try { res = await API.parsePrompt(state.project.id, trimmed); } + catch (e) { + console.warn("Global prompt parse failed:", e); + return false; + } + if (!force && _pendingGlobalPromptText != null && String(_pendingGlobalPromptText).trim() !== trimmed) { + return false; + } + if (!await _distributeGlobalPrompt(trimmed, res)) return false; + clearTimeout(saveTimer); + saveTimer = null; + _localDirty = true; + scheduleSaveSilent(); + refreshPreview(true); + return true; + } finally { + _globalApplyPromise = null; + if (_pendingGlobalPromptText === trimmed) _pendingGlobalPromptText = null; + } + })(); + return _globalApplyPromise; } function _syncPromptPreview() { return (async () => { try { + await _flushPendingGlobalPrompt(); await flushSave(); await refreshPreview(true); } catch (e) { console.error("prompt preview sync failed", e); } @@ -2381,8 +2428,13 @@ throw last; } + async function flushPendingEdits() { + await _flushPendingGlobalPrompt(); + return flushSave(); + } + async function _flushSaveForGenerate() { - await flushSave(); + await flushPendingEdits(); if (_localDirty) { throw new Error( "Could not save your latest edits before generate (tunnel may have dropped). " @@ -2724,23 +2776,19 @@ msg: `${head} · sampling ${pr.value}/${pr.max}`, }); } + const lp = pr?.live_preview; + if (lp?.ready && lp.url) { + const sceneNote = (lp.scene_index >= 0 && lp.scene_count > 0) + ? ` · scene ${lp.scene_index + 1}/${lp.scene_count}` + : ""; + updateGenProgress({ + livePreviewUrl: lp.url, + liveScene: lp.scene_index, + liveSceneCount: lp.scene_count, + msg: `${(state.gen.msg || prefix).replace(/\s*·\s*scene \d+\/\d+$/, "")}${sceneNote}`, + }); + } } catch (_) {} - if (_livePreviewConfigured()) { - try { - const lp = await API.livePreview(); - if (lp?.ready && lp.url) { - const sceneNote = (lp.scene_index >= 0 && lp.scene_count > 0) - ? ` · scene ${lp.scene_index + 1}/${lp.scene_count}` - : ""; - updateGenProgress({ - livePreviewUrl: lp.url, - liveScene: lp.scene_index, - liveSceneCount: lp.scene_count, - msg: `${(state.gen.msg || prefix).replace(/\s*·\s*scene \d+\/\d+$/, "")}${sceneNote}`, - }); - } - } catch (_) {} - } }, 700); pollTimer = setInterval(async () => { if (_interrupted) { @@ -2857,6 +2905,7 @@ 10, ); if (!r.prompt_id) { set({ gen: { ...state.gen, state: "error", msg: "No prompt id returned." } }); return false; } + if (r.live_preview_note) console.warn("[live preview]", r.live_preview_note); _queueRenderPrompts(sceneIds); if (r.validation && state.project) { state.project.generation_meta = { @@ -3678,7 +3727,7 @@ isOverlayAudioTrack, isSeparatedAudioTrack, resizeScene, setSceneGapAfter, splitScene, snapFrames, snapFramesFloor, snapFramesCeil, sceneEffFrames, sceneEffFps, setSourceTrim, trimSceneLeft, slipScene, applyEnginePreset, ENGINE_PRESETS, undo, redo, - refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate, generateMontage, generateSelected, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelLink, setLivePreviewEnabled, setLivePreviewSlot, clearNotice, + refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, flushPendingEdits, generate, generateMontage, generateSelected, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelLink, setLivePreviewEnabled, setLivePreviewSlot, clearNotice, setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow, loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset, loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 770e646a..11fb8a18 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -448,7 +448,7 @@ input[type=number] { font-family: var(--font-mono); } .gen-readout .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); animation: pulse 1s infinite; flex: 0 0 auto; } .gen-readout.error { color: var(--danger); } .gen-readout.error .pulse { background: var(--danger); animation: none; } -.pm-live-preview { position: absolute; inset: 12px 12px 52px 12px; display: flex; align-items: center; justify-content: center; pointer-events: none; z-index: 1; } +.pm-live-preview { position: absolute; inset: 12px 12px 52px 12px; display: flex; align-items: center; justify-content: center; pointer-events: none; z-index: 3; } .pm-live-preview-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; border: 1px solid var(--line); box-shadow: 0 8px 32px #000a; background: #0006; } .pm-live-preview-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--amber-2); background: #000b; border: 1px solid var(--line); padding: 3px 8px; border-radius: 12px; } .gen-bar { width: 90px; height: 5px; background: #ffffff22; border-radius: 3px; overflow: hidden; flex: 0 0 auto; } diff --git a/movie_editor/server.py b/movie_editor/server.py index 317b1796..17716f48 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -1466,6 +1466,16 @@ if web is not None and PromptServer is not None: if report["blocking"]: detail = "Generation blocked — " + "; ".join(report["blocking"]) return web.json_response({"detail": detail, "report": report}, status=400) + live_preview_note = None + if live_preview.models_enabled(models_cfg): + samp_in = (graph.get("sampler") or {}).get("inputs") or {} + if not isinstance(samp_in.get("preview_vae"), list): + live_preview_note = ( + "Live preview VAE is configured but preview_vae is not wired — " + "wire a VAE loader to Chain Sampler · live preview in Models, then restart ComfyUI if you recently updated FunPack." + ) + elif not samp_in.get("funpack_live_preview"): + live_preview_note = "Live preview directory was not passed to the Chain Sampler." try: result = await bridge.queue_prompt(graph) except Exception as e: # noqa: BLE001 @@ -1483,6 +1493,8 @@ if web is not None and PromptServer is not None: "validation": validation, "prompt_repairs_cleared": prompt_changed, "reset_session": reset_session, + "live_preview": live_preview.models_enabled(models_cfg), + "live_preview_note": live_preview_note, }) @routes.get(UI_PREFIX + "/api/projects/{pid}/status/{prompt_id}") @@ -1532,27 +1544,15 @@ if web is not None and PromptServer is not None: @routes.get(UI_PREFIX + "/api/progress") async def _progress(_req): - return web.json_response(bridge.current_progress()) + payload = bridge.current_progress() + lp = live_preview.api_payload(UI_PREFIX) + if lp.get("active"): + payload["live_preview"] = lp + return web.json_response(payload) @routes.get(UI_PREFIX + "/api/live-preview") async def _live_preview_status(_req): - import os - snap = live_preview.snapshot() - if not snap.get("active"): - return web.json_response({"active": False}) - frame_path = snap.get("frame_path") or "" - ready = bool(frame_path and os.path.isfile(frame_path)) - payload = { - "active": True, - "ready": ready, - "scene_index": snap.get("scene_index", -1), - "scene_count": snap.get("scene_count", 0), - "updated_at": snap.get("updated_at", 0), - } - if ready: - ts = int(float(snap.get("updated_at") or 0) * 1000) - payload["url"] = f"{UI_PREFIX}/api/live-preview/frame?t={ts}" - return web.json_response(payload) + return web.json_response(live_preview.api_payload(UI_PREFIX)) @routes.get(UI_PREFIX + "/api/live-preview/frame") async def _live_preview_frame(_req): diff --git a/samplers.py b/samplers.py index 97fda2af..3ae41239 100644 --- a/samplers.py +++ b/samplers.py @@ -3705,7 +3705,11 @@ class FunPackLTXAVSceneChainSampler: return os.makedirs(preview_dir, exist_ok=True) tile = int(cfg.get("decode_tile_size") or 256) - decoded = self._decode_for_preview(preview_vae, scene_latent, decode_tile_size=tile) + try: + decoded = self._decode_for_preview(preview_vae, scene_latent, decode_tile_size=tile) + except Exception as e: + print(f"[FunPackSceneChain] live preview decode failed (scene {scene_index + 1}): {e}") + return path = os.path.join(preview_dir, f"scene_{int(scene_index):03d}.webp") current = os.path.join(preview_dir, "current.webp") if self._save_batch_preview( @@ -3719,6 +3723,9 @@ class FunPackLTXAVSceneChainSampler: except Exception: current = path _lp.publish(int(scene_index), current, scene_count) + print(f"[FunPackSceneChain] live preview scene {scene_index + 1}/{scene_count} -> {current}") + else: + print(f"[FunPackSceneChain] live preview save failed (scene {scene_index + 1})") def _save_batch_preview(self, decoded, path, max_frames=16, width=256): """Save a decoded video tensor [T,H,W,C] in 0..1 as a downscaled animated webp."""