Fix preview segments and pipeline persistence; add git update UI.

Pass live render params to preview-segment URLs so playback works right after generate, sync scene renders and models on save/generate so wiring and deleted nodes stick, and expose FunPack pull/checkout from the menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
digital-garbage
2026-06-11 23:05:39 +03:00
co-authored by Cursor
parent bb48a3aed7
commit 59dd766704
12 changed files with 722 additions and 49 deletions
+18 -15
View File
@@ -406,20 +406,24 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No
graph[dnode]["inputs"][dinput] = [sid, oidx]
report["wired"].append(f"{s.get('node_class')}.{out_name} -> {dnode}.{dinput}")
# 4b. core input overrides (full-control only — guided mode keeps the fixed core path).
if not pipeline_wiring.wiring_locked(models_config):
for cid, ovs in ((models_config or {}).get("core_overrides") or {}).items():
if cid not in graph:
# 4b. core input overrides. In guided mode only loader-facing open ports may be
# overridden (e.g. Studio · latent from a custom node); internal core links stay fixed.
for cid, ovs in ((models_config or {}).get("core_overrides") or {}).items():
if cid not in graph:
continue
locked = pipeline_wiring.wiring_locked(models_config)
for inp, source in (ovs or {}).items():
if not source:
continue
for inp, source in (ovs or {}).items():
if not source:
continue
src = _resolve_source(source, slot_node_id, slot_def, object_info)
if src:
graph[cid]["inputs"][inp] = list(src)
report["wired"].append(f"{source} -> {cid}.{inp} (core override)")
else:
report["unsatisfied"].append(f"core override {cid}.{inp}: '{source}' could not be resolved.")
if locked and (cid, inp) not in pipeline_wiring.OPEN_CORE_INPUTS:
continue
src = _resolve_source(source, slot_node_id, slot_def, object_info)
if src:
graph[cid]["inputs"][inp] = list(src)
report["wired"].append(f"{source} -> {cid}.{inp} (core override)")
else:
report["unsatisfied"].append(
f"core override {cid}.{inp}: '{source}' could not be resolved.")
# 5. auto-wire remaining unbound typed inputs by unique producer.
producers = _producers(graph, slots, slot_node_id, slot_def, object_info)
@@ -519,7 +523,6 @@ def core_graph(object_info: dict, models_config: dict | None = None) -> list[dic
for inp, (src, idx) in CORE_LINKS.get(cid, {}).items():
t = _input_type(cid, inp) or "*"
builtin = f"{src} · {_out_name(src, idx)}"
is_internal = True
inputs.append({"name": inp, "type": t, "from": "internal", "detail": builtin,
"value": ov.get(inp, ""), "options": _options(t, builtin, cid),
"locked": locked})
@@ -528,7 +531,7 @@ def core_graph(object_info: dict, models_config: dict | None = None) -> list[dic
inputs.append({"name": inp, "type": t, "from": "loader", "required": req,
"detail": builtin + ("" if req else " (optional)"),
"value": ov.get(inp, ""), "options": _options(t, "(auto-wire from loaders)", cid),
"locked": locked})
"locked": False})
outputs = []
for i, o in enumerate(node_outputs(nd or {})):
dests = [f"{d} · {di}" for (oi, d, di) in rev.get(cid, []) if oi == i]
+160
View File
@@ -0,0 +1,160 @@
"""Git pull / branch switch for the FunPack repo (ComfyUI custom node root)."""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
_BRANCH_PRIORITY = ("dev", "cutting_edge", "main")
class GitUpdateError(RuntimeError):
pass
def _run_git(*args: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
if not shutil.which("git"):
raise GitUpdateError("git not found on PATH.")
if not (REPO_ROOT / ".git").exists():
raise GitUpdateError("FunPack is not a git checkout (no .git directory).")
proc = subprocess.run(
["git", *args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=timeout,
)
return proc
def _current_branch() -> str:
proc = _run_git("rev-parse", "--abbrev-ref", "HEAD")
if proc.returncode != 0:
raise GitUpdateError((proc.stderr or proc.stdout or "git rev-parse failed").strip())
return (proc.stdout or "").strip()
def _current_commit() -> str:
proc = _run_git("rev-parse", "--short", "HEAD")
if proc.returncode != 0:
raise GitUpdateError((proc.stderr or proc.stdout or "git rev-parse failed").strip())
return (proc.stdout or "").strip()
def _is_dirty() -> bool:
proc = _run_git("status", "--porcelain")
if proc.returncode != 0:
raise GitUpdateError((proc.stderr or "git status failed").strip())
return bool((proc.stdout or "").strip())
def _ahead_behind(branch: str) -> tuple[int, int]:
proc = _run_git("rev-list", "--left-right", "--count", f"HEAD...origin/{branch}")
if proc.returncode != 0:
return 0, 0
parts = (proc.stdout or "").strip().split()
if len(parts) != 2:
return 0, 0
try:
return int(parts[0]), int(parts[1])
except ValueError:
return 0, 0
def _list_branches() -> list[str]:
proc = _run_git("branch", "-a", "--format=%(refname:short)")
if proc.returncode != 0:
raise GitUpdateError((proc.stderr or "git branch failed").strip())
names: set[str] = set()
for raw in (proc.stdout or "").splitlines():
line = raw.strip()
if not line or line == "HEAD" or line.endswith("/HEAD"):
continue
if line.startswith("origin/"):
names.add(line[7:])
elif not line.startswith("remotes/"):
names.add(line)
ordered = [b for b in _BRANCH_PRIORITY if b in names]
rest = sorted(b for b in names if b not in _BRANCH_PRIORITY)
return ordered + rest
def status() -> dict:
"""Current branch, switchable branches, and update availability."""
try:
branch = _current_branch()
commit = _current_commit()
dirty = _is_dirty()
branches = _list_branches()
fetch = _run_git("fetch", "--prune", "origin")
fetch_ok = fetch.returncode == 0
ahead, behind = _ahead_behind(branch) if fetch_ok else (0, 0)
return {
"ok": True,
"branch": branch,
"commit": commit,
"dirty": dirty,
"branches": branches,
"ahead": ahead,
"behind": behind,
"fetch_ok": fetch_ok,
"repo": str(REPO_ROOT),
}
except GitUpdateError as e:
return {"ok": False, "detail": str(e)}
def pull(branch: str | None = None) -> dict:
"""Fast-forward pull from origin on the given branch (current if omitted)."""
branch = (branch or _current_branch()).strip()
if not branch:
raise GitUpdateError("Could not determine current branch.")
if _is_dirty():
raise GitUpdateError("Working tree has local changes. Commit or stash them before updating.")
before = _current_commit()
fetch = _run_git("fetch", "--prune", "origin")
if fetch.returncode != 0:
raise GitUpdateError((fetch.stderr or fetch.stdout or "git fetch failed").strip())
if branch != _current_branch():
co = _run_git("checkout", branch)
if co.returncode != 0:
raise GitUpdateError((co.stderr or co.stdout or "git checkout failed").strip())
pull_proc = _run_git("pull", "--ff-only", "origin", branch)
if pull_proc.returncode != 0:
msg = (pull_proc.stderr or pull_proc.stdout or "git pull failed").strip()
raise GitUpdateError(msg)
after = _current_commit()
return {
"branch": branch,
"before": before,
"after": after,
"updated": before != after,
"output": (pull_proc.stdout or "").strip(),
}
def checkout(branch: str, *, pull_after: bool = True) -> dict:
"""Switch branch, optionally pull, return combined result."""
branch = (branch or "").strip()
if not branch:
raise GitUpdateError("Branch name is required.")
branches = _list_branches()
if branch not in branches:
raise GitUpdateError(f'Branch "{branch}" is not available locally or on origin.')
if _is_dirty():
raise GitUpdateError("Working tree has local changes. Commit or stash them before switching branches.")
before_branch = _current_branch()
before_commit = _current_commit()
if branch != before_branch:
co = _run_git("checkout", branch)
if co.returncode != 0:
raise GitUpdateError((co.stderr or co.stdout or "git checkout failed").strip())
result = {"branch": branch, "before_branch": before_branch, "before": before_commit}
if pull_after:
pulled = pull(branch)
result.update(pulled)
else:
result["after"] = _current_commit()
result["updated"] = result["before"] != result["after"]
return result
+26
View File
@@ -67,6 +67,28 @@ PORT_LABELS: dict[str, str] = {
"LTXVAudioVAEDecode.audio_vae": "Audio VAE Decode · audio_vae",
}
# Built-in core inputs fed by user loaders (not fixed internal core links).
# core_overrides for these apply even in guided mode; internal links stay fixed.
OPEN_CORE_INPUTS: frozenset[tuple[str, str]] = frozenset({
("studio", "model"),
("studio", "clip"),
("studio", "source_image"),
("studio", "latent"),
("sampler", "vae"),
("concat", "audio_latent"),
("audiodec", "audio_vae"),
})
PORT_TO_OPEN_CORE: dict[str, tuple[str, str]] = {
"FunPackStudio.model": ("studio", "model"),
"FunPackStudio.clip": ("studio", "clip"),
"FunPackStudio.source_image": ("studio", "source_image"),
"FunPackStudio.latent": ("studio", "latent"),
"FunPackLTXAVSceneChainSampler.vae": ("sampler", "vae"),
"LTXVConcatAVLatent.audio_latent": ("concat", "audio_latent"),
"LTXVAudioVAEDecode.audio_vae": ("audiodec", "audio_vae"),
}
def models_dict(models: Any) -> dict:
return models if isinstance(models, dict) else {}
@@ -223,6 +245,10 @@ def wiring_rules_payload() -> dict[str, Any]:
"default_wires": DEFAULT_WIRES_BY_ROLE,
"default_input_sources": DEFAULT_INPUT_SOURCES_BY_ROLE,
"port_labels": PORT_LABELS,
"open_core_ports": [
{"core_id": cid, "input": inp, "port": port}
for port, (cid, inp) in PORT_TO_OPEN_CORE.items()
],
}
+17 -2
View File
@@ -104,6 +104,9 @@
applyWorkflow: (pid, workflow, bindings) =>
j("POST", API(`/projects/${pid}/workflow/apply`), { workflow, bindings }),
restart: () => j("POST", API("/restart")),
gitStatus: () => j("GET", API("/git/status")),
gitUpdate: (branch) => j("POST", API("/git/update"), branch ? { branch } : {}),
gitCheckout: (branch) => j("POST", API("/git/checkout"), { branch }),
// generate (a single scene, or an explicit run of scene ids = one chain request)
generate: (id, onlyScene, sceneIds, resetSession) =>
@@ -121,8 +124,20 @@
resultUrl: (id, m) =>
API(`/projects/${id}/result?filename=${encodeURIComponent(m.filename)}`) +
`&subfolder=${encodeURIComponent(m.subfolder || "")}&type=${encodeURIComponent(m.type || "output")}`,
previewSegmentUrl: (id, sceneId) =>
API(`/projects/${id}/preview-segment/${encodeURIComponent(sceneId)}`),
previewSegmentUrl: (id, sceneId, spec) => {
let u = API(`/projects/${id}/preview-segment/${encodeURIComponent(sceneId)}`);
const m = spec?.media;
if (m?.filename) {
const q = new URLSearchParams({
filename: m.filename,
subfolder: m.subfolder || "",
type: m.type || "output",
render_in: String(spec.renderIn != null ? spec.renderIn : 0),
});
u += "?" + q.toString();
}
return u;
},
};
window.MovieEditorAPI = ClientAPI;
+103 -8
View File
@@ -18,26 +18,109 @@
return inp;
})();
async function restartComfy() {
if (!confirm("Restart ComfyUI now?\n\nThe server will be down for ~10–40s and any running generation will be lost. This page reloads automatically when it's back.")) return;
async function refreshGitStatus() {
try { _gitStatus = await window.MovieEditorAPI.gitStatus(); }
catch (_) { _gitStatus = { ok: false }; }
if (openName === "FunPack") render();
}
function _restartOverlay(message) {
const ov = el("div", "restart-overlay");
const card = el("div", "restart-card");
card.append(el("div", "restart-spin"));
const msg = el("div", "restart-msg", "Restarting ComfyUI…\nThis page will reload when it's back.");
const msg = el("div", "restart-msg", message);
card.append(msg); ov.append(card); document.body.append(ov);
try { await window.MovieEditorAPI.restart(); } catch (_) { /* connection drops as it execv's — expected */ }
const start = Date.now();
return msg;
}
function _waitForComfyReload(msgEl, startMs) {
const start = startMs || Date.now();
const tick = async () => {
try {
const h = await window.MovieEditorAPI.health();
if (h && h.ok) { location.reload(); return; }
} catch (_) { /* still down */ }
if (Date.now() - start > 90000) msg.textContent = "Still waiting on ComfyUI…\nIt may need a manual restart — check the console.";
if (Date.now() - start > 90000) {
msgEl.textContent = "Still waiting on ComfyUI…\nIt may need a manual restart - check the console.";
}
setTimeout(tick, 2000);
};
setTimeout(tick, 3500); // give it a moment to actually go down first
setTimeout(tick, 3500);
}
async function restartComfy() {
if (!confirm("Restart ComfyUI now?\n\nThe server will be down for ~10-40s and any running generation will be lost. This page reloads automatically when it's back.")) return;
const msg = _restartOverlay("Restarting ComfyUI…\nThis page will reload when it's back.");
try { await window.MovieEditorAPI.restart(); } catch (_) { /* connection drops as it execv's - expected */ }
_waitForComfyReload(msg, Date.now());
}
async function updateFunpackAndReload() {
let gs = _gitStatus;
if (!gs?.ok) {
try { gs = await window.MovieEditorAPI.gitStatus(); _gitStatus = gs; } catch (e) { alert(String(e.message || e)); return; }
}
if (!gs?.ok) { alert(gs?.detail || "Git status unavailable for this install."); return; }
if (gs.dirty) {
alert("Local changes detected in the FunPack folder.\nCommit or stash them before updating from git.");
return;
}
const branch = gs.branch || "dev";
const behind = gs.behind > 0 ? `\n\n${gs.behind} commit(s) available on origin/${branch}.` : "";
if (!confirm(`Pull latest "${branch}" from origin and restart ComfyUI?\n\nAny running generation will be lost.${behind}`)) return;
const msg = _restartOverlay(`Pulling origin/${branch}…\nComfyUI will restart when the pull finishes.`);
try {
const res = await window.MovieEditorAPI.gitUpdate(branch);
msg.textContent = res.updated
? `Updated ${res.before} → ${res.after}.\nRestarting ComfyUI…`
: "Already up to date.\nRestarting ComfyUI…";
} catch (e) {
document.querySelector(".restart-overlay")?.remove();
alert("Update failed: " + (e.message || e));
return;
}
_waitForComfyReload(msg, Date.now());
}
async function switchFunpackBranch() {
closeAll();
let gs = _gitStatus;
if (!gs?.ok) {
try { gs = await window.MovieEditorAPI.gitStatus(); _gitStatus = gs; } catch (e) { alert(String(e.message || e)); return; }
}
if (!gs?.ok) { alert(gs?.detail || "Git status unavailable for this install."); return; }
if (gs.dirty) {
alert("Local changes detected in the FunPack folder.\nCommit or stash them before switching branches.");
return;
}
const branches = gs.branches || [];
if (!branches.length) { alert("No git branches found."); return; }
const cur = gs.branch;
if (!window.SlotPicker) return;
window.SlotPicker.open({
title: "Switch FunPack branch",
options: branches.map((b) => ({ value: b, label: b, hint: b === cur ? "current" : "" })),
onPick: async (branch) => {
if (branch === cur) return;
if (!confirm(`Switch to "${branch}", pull from origin, and restart ComfyUI?\n\nAny running generation will be lost.`)) return;
const msg = _restartOverlay(`Switching to ${branch}…\nComfyUI will restart when ready.`);
try {
const res = await window.MovieEditorAPI.gitCheckout(branch);
msg.textContent = res.updated
? `Switched to ${branch} (${res.before} → ${res.after}).\nRestarting ComfyUI…`
: `On ${branch}, already up to date.\nRestarting ComfyUI…`;
} catch (e) {
document.querySelector(".restart-overlay")?.remove();
alert("Branch switch failed: " + (e.message || e));
return;
}
_waitForComfyReload(msg, Date.now());
},
});
}
let _gitStatus = null;
function promptNewProject() {
if (window.SlotPicker?.openPrompt) {
window.SlotPicker.openPrompt({
@@ -60,6 +143,12 @@
const recent = (st.projects || []).slice(0, 8).map((p) => ({
label: p.name, hint: `${p.scene_count}▦`, action: () => S.loadProject(p.id),
}));
const git = _gitStatus;
const gitOk = !!git?.ok;
const gitBranchLine = gitOk
? `Branch: ${git.branch}${git.commit ? " @ " + git.commit : ""}${git.dirty ? " (local changes)" : ""}`
: (git?.detail ? `Git: ${git.detail}` : "Git: unavailable");
const updateHint = gitOk && git.behind > 0 ? `${git.behind}↓` : "⬇⟳";
return {
File: [
{ label: "New Project", hint: "⌘N", action: promptNewProject },
@@ -111,6 +200,12 @@
{ label: st.resetSessionArmed ? "Reset Studio session ✓ armed — click to cancel" : "Reset Studio session",
disabled: !hasProject() || !studioOn, action: () => S.resetStudioSession() },
{ sep: true },
{ menulabel: "Code updates" },
{ label: gitBranchLine, disabled: true },
{ label: "Switch branch…", disabled: !gitOk || git?.dirty, action: switchFunpackBranch },
{ label: gitOk && git.behind > 0 ? `Update FunPack and reload (${git.behind} new)` : "Update FunPack and reload",
hint: updateHint, disabled: !gitOk || git?.dirty, action: updateFunpackAndReload },
{ sep: true },
{ menulabel: "Libraries (in ComfyUI Studio)" },
{ label: "Open ComfyUI", hint: "↗", action: () => window.open("/", "_blank") },
{ label: "Restart ComfyUI", hint: "⟳", danger: true, action: restartComfy },
@@ -147,7 +242,7 @@
function closeAll() { openName = null; veil.hidden = true; render(); }
function openMenu(name) { openName = name; veil.hidden = false; render(); }
function openMenu(name) { openName = name; veil.hidden = false; if (name === "FunPack") refreshGitStatus(); render(); }
function render() {
const spec = menuSpec();
+82 -3
View File
@@ -70,6 +70,63 @@
});
}
function portToOpenCore(portId) {
const hit = (wiringRules.open_core_ports || []).find((p) => p.port === portId);
return hit ? [hit.core_id, hit.input] : null;
}
function _clearPortWires(portId, exceptSlotId, exceptOut) {
const portTarget = "port:" + portId;
for (const slot of config.slots) {
slot.wires = slot.wires || {};
for (const outName of Object.keys(slot.wires)) {
if (slot.id === exceptSlotId && outName === exceptOut) continue;
slot.wires[outName] = wireTargets(slot.wires[outName]).filter((t) => t !== portTarget);
}
}
}
// Keep core_overrides and slot port wires in sync so loader wiring survives reload.
function reconcileOpenPortWiring() {
config.core_overrides = config.core_overrides || {};
for (const slot of config.slots) {
for (const [outName, raw] of Object.entries(slot.wires || {})) {
for (const t of wireTargets(raw)) {
if (!t.startsWith("port:")) continue;
const portId = t.slice(5);
const map = portToOpenCore(portId);
if (!map) continue;
const [cid, inp] = map;
config.core_overrides[cid] = config.core_overrides[cid] || {};
config.core_overrides[cid][inp] = `out:${slot.id}:${outName}`;
}
}
}
for (const entry of wiringRules.open_core_ports || []) {
const portId = entry.port;
const cid = entry.core_id;
const inp = entry.input;
const src = config.core_overrides?.[cid]?.[inp];
const parsed = _parseOutSource(src);
if (!parsed) continue;
_clearPortWires(portId, parsed.slotId, parsed.out);
_addWire(parsed.slotId, parsed.out, "port:" + portId);
}
}
function clearInternalCoreOverrides() {
if (!config.core_overrides) return;
const open = new Set(
(wiringRules.open_core_ports || []).map((p) => `${p.core_id}.${p.input}`),
);
for (const cid of Object.keys(config.core_overrides)) {
for (const inp of Object.keys(config.core_overrides[cid] || {})) {
if (!open.has(`${cid}.${inp}`)) delete config.core_overrides[cid][inp];
}
if (!Object.keys(config.core_overrides[cid] || {}).length) delete config.core_overrides[cid];
}
}
function allowedSources(slot, ci) {
const all = sources(slot, ci.type);
if (!pipelineLocked()) return all;
@@ -476,10 +533,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 + " (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; await persist(); render(); };
sel.onchange = async () => { _setWireTarget(slot, out.name, targets[i], sel.value); targets[i] = sel.value; 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); await persist(); render(); };
rm.onclick = async () => { _setWireTarget(slot, out.name, targets[i], ""); targets.splice(i, 1); reconcileOpenPortWiring(); await persist(); render(); };
row.append(sel, rm);
wrap.append(row);
});
@@ -790,8 +847,29 @@
function setCoreOverride(cid, inp, value) {
config.core_overrides = config.core_overrides || {};
config.core_overrides[cid] = config.core_overrides[cid] || {};
const prev = config.core_overrides[cid][inp];
if (value) config.core_overrides[cid][inp] = value;
else delete config.core_overrides[cid][inp];
const parsed = _parseOutSource(value);
if (parsed) {
for (const entry of wiringRules.open_core_ports || []) {
if (entry.core_id === cid && entry.input === inp) {
_clearPortWires(entry.port, parsed.slotId, parsed.out);
_addWire(parsed.slotId, parsed.out, "port:" + entry.port);
break;
}
}
} else if (prev) {
const old = _parseOutSource(prev);
if (old) {
for (const entry of wiringRules.open_core_ports || []) {
if (entry.core_id === cid && entry.input === inp) {
_removeWire(old.slotId, old.out, "port:" + entry.port);
break;
}
}
}
}
}
function coreCard(n) {
const card = el("div", "slot-card open" + (n.installed ? "" : " slot-bad"));
@@ -852,7 +930,7 @@
: "Guided wiring ON — loaders wire only to their built-in ports. Click for full manual control.";
fc.onclick = async () => {
config.full_control = !config.full_control;
if (!config.full_control) config.core_overrides = {};
if (!config.full_control) clearInternalCoreOverrides();
await persist();
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || coreNodes; } catch (_) {}
render();
@@ -935,6 +1013,7 @@
wiringRules = pp.wiring || {};
} catch (_) { ports = []; coreProducers = []; requirements = []; wiringRules = {}; }
try { config = await API.getModels(window.Store?.get().project?.id); } catch (_) { config = { slots: [] }; }
reconcileOpenPortWiring();
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || []; } catch (_) { coreNodes = []; }
await prewarmSpecs();
+66 -7
View File
@@ -17,6 +17,7 @@
// ── preloaded video pool ─────────────────────────────────────────────────────
const stage = document.createElement("div"); stage.className = "pm-stage";
const pool = new Map(); // url -> <video>
const _urlClips = new Map(); // url -> Set<clip> (for segment load fallback)
let _active = null; // visible <video>
let _seekPending = null; // offset to apply once the active video has metadata
let _playPending = false;
@@ -112,6 +113,32 @@
return _active.currentTime - _clipInSec(_currentClip);
}
function _registerUrlClip(url, clip) {
if (!url || !clip) return;
if (!_urlClips.has(url)) _urlClips.set(url, new Set());
_urlClips.get(url).add(clip);
}
function _unregisterUrlClip(url, clip) {
const set = _urlClips.get(url);
if (!set) return;
set.delete(clip);
if (!set.size) _urlClips.delete(url);
}
function _fallbackSegmentClip(clip) {
if (!clip?.streamUrl || clip._segFallback) return;
clip._segFallback = true;
const badUrl = clip.streamUrl;
clip.streamUrl = null;
_unregisterUrlClip(badUrl, clip);
if (_currentClip === clip) {
const offset = Math.max(0, _phSec - clip.startSec);
_goto(clip, offset, _playing);
}
_lastSegHash = "";
}
// Multi-scene chain outputs share one ComfyUI file; deep seeks fail in the browser
// unless the MP4 is faststart-trimmed like export. Serve per-scene segments instead.
function _assignChainPreviewSegments(st, clips) {
@@ -119,7 +146,7 @@
if (!pid || !API.previewSegmentUrl) return;
const groups = new Map();
for (const c of clips) {
if (c.pending || c.ghost || c.binUrl || !c.sceneId) continue;
if (c.pending || c.ghost || c.binUrl || !c.sceneId || !c.media) continue;
const k = _mediaChainKey(c);
if (!k) continue;
if (!groups.has(k)) groups.set(k, []);
@@ -127,7 +154,12 @@
}
for (const list of groups.values()) {
if (list.length < 2) continue;
for (const c of list) c.streamUrl = API.previewSegmentUrl(pid, c.sceneId);
for (const c of list) {
const sc = S.scene(c.sceneId);
const sourceIn = sc?.source_in || 0;
const renderIn = Math.max(0, (c.inSec || 0) - sourceIn);
c.streamUrl = API.previewSegmentUrl(pid, c.sceneId, { media: c.media, renderIn });
}
}
}
@@ -369,9 +401,12 @@
}
}
function _ensureVideo(url) {
function _ensureVideo(url, clip) {
let v = pool.get(url);
if (v) return v;
if (v) {
if (clip) _registerUrlClip(url, clip);
return v;
}
const viewport = document.createElement("div");
viewport.className = "pm-fx-viewport";
const zoom = document.createElement("div");
@@ -393,17 +428,26 @@
if (_currentClip) _applyFx(_currentClip, Math.max(0, _phSec - _currentClip.startSec));
});
v.addEventListener("seeked", () => { _onVideoSeeked(v); });
v.addEventListener("error", () => {
const clips = _urlClips.get(url);
if (clips) [...clips].forEach((c) => _fallbackSegmentClip(c));
});
v.addEventListener("ended", () => {
if (v !== _active || _isSeeking() || _clipBoundaryToken) return;
_advance();
});
stage.append(viewport); pool.set(url, v);
if (clip) _registerUrlClip(url, clip);
return v;
}
function _syncPool() {
_urlClips.clear();
const urls = new Set(_clips.filter((c) => c.media || c.binUrl || c.streamUrl).map((c) => _clipUrl(c)));
urls.forEach((u) => _ensureVideo(u));
urls.forEach((u) => {
const clip = _clips.find((c) => _clipUrl(c) === u);
_ensureVideo(u, clip);
});
for (const [u, v] of [...pool]) {
if (urls.has(u)) continue;
v.pause();
@@ -420,6 +464,7 @@
v._pmFx?.viewport.remove();
}
pool.clear(); _active = null; _currentClip = null; _seekPending = null; _playPending = false;
_urlClips.clear();
_clipBoundaryToken = 0;
_pendingSeekSec = null;
_scrubDepth = 0;
@@ -536,7 +581,7 @@
return;
}
if (!clip.media && !clip.binUrl && !clip.streamUrl) return;
const v = _ensureVideo(_clipUrl(clip));
const v = _ensureVideo(_clipUrl(clip), clip);
_currentClip = clip; _setActive(v);
_applyClipUi(clip);
const target = _clipInSec(clip) + Math.max(0, offset);
@@ -881,6 +926,17 @@
let _lastSegHash = "";
let _lastProjectId = null;
function _ensureStageMounted() {
if (!_clips.length || !body) return;
if (body.contains(stage)) return;
let canvas = body.querySelector(".pm-canvas");
if (!canvas) {
canvas = el("div", "pm-canvas");
body.append(canvas);
}
if (!canvas.contains(stage)) canvas.insertBefore(stage, canvas.firstChild);
}
// ── full render (store subscription) ──────────────────────────────────────
function render(st) {
// Project switch: drop the whole pool and reset.
@@ -917,7 +973,10 @@
// Rebuilding the monitor DOM during playback detaches <video> nodes and kills multi-scene
// chain preview mid-stream (often visible from scene 3 onward once store ticks catch up).
if (_scrubDepth > 0 || _playing) return;
if (_scrubDepth > 0 || _playing) {
_ensureStageMounted();
return;
}
clear(body);
+10 -1
View File
@@ -49,6 +49,9 @@
if (!state.project) return;
state.project.scene_renders = JSON.parse(JSON.stringify(state.sceneRenders || {}));
state.project.scene_ghosts = JSON.parse(JSON.stringify(state.sceneGhosts || []));
// Keep project.models aligned with the Models modal / saveModels API so timeline
// autosave (commit before generate) cannot resurrect deleted pipeline nodes.
state.project.models = JSON.parse(JSON.stringify(state.models || { slots: [] }));
}
function _historyRecord() {
@@ -605,6 +608,7 @@
state.gen = { state: "idle", promptId: null, media: [], msg: "" };
state.sceneRenders = JSON.parse(JSON.stringify(state.project.scene_renders || {}));
state.sceneGhosts = JSON.parse(JSON.stringify(state.project.scene_ghosts || []));
state.models = JSON.parse(JSON.stringify(state.project.models || { slots: [] }));
if (_clearOrphanRatings()) {
_syncEditorStateToProject();
scheduleSaveSilent();
@@ -616,7 +620,7 @@
syncGlobalPromptFromTimeline();
_syncSeparatedAudioTracks();
refreshPreview();
loadModels();
await loadModels();
_validateSceneRenders();
}
@@ -2022,6 +2026,8 @@
if (recordedSceneIds.length) {
_validateRendersToken++;
state.notice = "";
_syncEditorStateToProject();
scheduleSaveSilent();
}
}
@@ -2588,6 +2594,9 @@
// ── pluggable models / exposed controls ──────────────────────────────────────
async function loadModels() {
try { state.models = await API.getModels(state.project?.id); } catch (_) { state.models = { slots: [] }; }
if (state.project) {
state.project.models = JSON.parse(JSON.stringify(state.models || { slots: [] }));
}
if (window.PipelineCaps && !window.PipelineCaps.usesFunpackStudio(state)) {
_resetSessionPending = false;
state.resetSessionArmed = false;
+63 -6
View File
@@ -19,7 +19,7 @@ except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from .backend import bridge, builder, config, media, nodes, pipeline_caps, pipeline_wiring, projects, workflow_import
from .backend import bridge, builder, config, git_update, media, nodes, pipeline_caps, pipeline_wiring, projects, workflow_import
from .backend.nle_effects import zoompan_z_expr
from .backend.timeline import (
Project,
@@ -149,12 +149,35 @@ def _ffmpeg_trim_clip(src_path: str, out_path: str, inn, dur) -> None:
raise RuntimeError((proc.stderr or "ffmpeg failed")[-1000:])
def _scene_playback_clip_spec(project: Project, scene_id: str) -> dict:
def _playback_render_from_query(query) -> Optional[dict]:
"""Optional live render override from preview-segment query params."""
filename = query.get("filename") if query else None
if not filename:
return None
return {
"inSec": float(query.get("render_in") or 0),
"media": {
"filename": filename,
"subfolder": query.get("subfolder") or "",
"type": query.get("type") or "output",
},
}
def _scene_playback_clip_spec(
project: Project,
scene_id: str,
*,
render_override: Optional[dict] = None,
) -> dict:
"""Export-matching trim spec for one scene's preview segment."""
scene = next((s for s in project.scenes if s.id == scene_id), None)
if not scene or scene.excluded:
raise KeyError(f"scene {scene_id}")
render = (project.scene_renders or {}).get(scene_id) or {}
if render_override is not None:
render = render_override
else:
render = (project.scene_renders or {}).get(scene_id) or {}
media = render.get("media") or {}
if not media.get("filename"):
raise KeyError(f"no render for {scene_id}")
@@ -314,8 +337,11 @@ def _parse_prompt_variants(prompt: str, seed: int) -> tuple[dict, dict]:
def _project_models(p: Optional[Project]) -> dict:
"""The project's own pipeline config, or the global default when it has none yet."""
m = getattr(p, "models", None) or {}
if m.get("slots") or m.get("links") or m.get("core_overrides") or m.get("disable_core"):
m = getattr(p, "models", None)
if not isinstance(m, dict):
return nodes.load_models()
# Treat slots: [] as intentional (user removed all loaders) — do not fall back to global.
if "slots" in m or m.get("links") or m.get("core_overrides") or m.get("disable_core") or m.get("full_control"):
return m
return nodes.load_models()
@@ -1432,8 +1458,9 @@ if web is not None and PromptServer is not None:
pid = req.match_info["pid"]
scene_id = req.match_info["scene_id"]
proj = _project_or_404(pid)
render_override = _playback_render_from_query(req.query)
try:
clip = _scene_playback_clip_spec(proj, scene_id)
clip = _scene_playback_clip_spec(proj, scene_id, render_override=render_override)
except KeyError as e:
return web.json_response({"detail": str(e)}, status=404)
key = _preview_segment_cache_key(pid, scene_id, clip)
@@ -1609,6 +1636,36 @@ if web is not None and PromptServer is not None:
raise web.HTTPNotFound(reason="Unknown node class")
return web.json_response(spec)
@routes.get(UI_PREFIX + "/api/git/status")
async def _git_status(_req):
return web.json_response(git_update.status())
@routes.post(UI_PREFIX + "/api/git/update")
async def _git_update(req):
import asyncio
body = await req.json() if req.can_read_body else {}
branch = body.get("branch")
try:
result = git_update.pull(str(branch).strip() if branch else None)
except git_update.GitUpdateError as e:
return web.json_response({"detail": str(e)}, status=400)
asyncio.get_event_loop().call_later(0.7, _restart_comfy)
return web.json_response({"restarting": True, **result})
@routes.post(UI_PREFIX + "/api/git/checkout")
async def _git_checkout(req):
import asyncio
body = await req.json() if req.can_read_body else {}
branch = str(body.get("branch") or "").strip()
if not branch:
return web.json_response({"detail": "Branch name is required."}, status=400)
try:
result = git_update.checkout(branch, pull_after=True)
except git_update.GitUpdateError as e:
return web.json_response({"detail": str(e)}, status=400)
asyncio.get_event_loop().call_later(0.7, _restart_comfy)
return web.json_response({"restarting": True, **result})
@routes.post(UI_PREFIX + "/api/restart")
async def _restart(_req):
import asyncio
+118
View File
@@ -0,0 +1,118 @@
"""Git update helpers for the Movie Editor."""
from types import SimpleNamespace
import pytest
from movie_editor.backend import git_update
def _proc(code=0, stdout="", stderr=""):
return SimpleNamespace(returncode=code, stdout=stdout, stderr=stderr)
def test_status_reports_branch(monkeypatch):
calls = []
def fake_run(*args, **kwargs):
calls.append(args)
if args == ("rev-parse", "--abbrev-ref", "HEAD"):
return _proc(stdout="dev\n")
if args == ("rev-parse", "--short", "HEAD"):
return _proc(stdout="abc1234\n")
if args == ("status", "--porcelain"):
return _proc(stdout="")
if args == ("branch", "-a", "--format=%(refname:short)"):
return _proc(stdout="dev\norigin/dev\norigin/main\n")
if args == ("fetch", "--prune", "origin"):
return _proc()
if args == ("rev-list", "--left-right", "--count", "HEAD...origin/dev"):
return _proc(stdout="0\t2\n")
return _proc(code=1, stderr="unexpected")
monkeypatch.setattr(git_update, "_run_git", fake_run)
st = git_update.status()
assert st["ok"] is True
assert st["branch"] == "dev"
assert st["commit"] == "abc1234"
assert st["behind"] == 2
assert st["branches"] == ["dev", "main"]
def test_pull_ff_only(monkeypatch):
state = {"branch": "dev", "commit": "aaa"}
def fake_run(*args, **kwargs):
if args == ("rev-parse", "--abbrev-ref", "HEAD"):
return _proc(stdout=f"{state['branch']}\n")
if args == ("rev-parse", "--short", "HEAD"):
return _proc(stdout=f"{state['commit']}\n")
if args == ("status", "--porcelain"):
return _proc(stdout="")
if args == ("fetch", "--prune", "origin"):
return _proc()
if args == ("pull", "--ff-only", "origin", "dev"):
state["commit"] = "bbb"
return _proc(stdout="Already up to date.\n")
return _proc(code=1, stderr="unexpected")
monkeypatch.setattr(git_update, "_run_git", fake_run)
result = git_update.pull("dev")
assert result["branch"] == "dev"
assert result["before"] == "aaa"
assert result["after"] == "bbb"
assert result["updated"] is True
def test_pull_rejects_dirty_tree(monkeypatch):
def fake_run(*args, **kwargs):
if args == ("rev-parse", "--abbrev-ref", "HEAD"):
return _proc(stdout="dev\n")
if args == ("status", "--porcelain"):
return _proc(stdout=" M movie_editor/server.py\n")
return _proc(code=1, stderr="unexpected")
monkeypatch.setattr(git_update, "_run_git", fake_run)
with pytest.raises(git_update.GitUpdateError, match="local changes"):
git_update.pull("dev")
def test_checkout_unknown_branch(monkeypatch):
def fake_run(*args, **kwargs):
if args == ("branch", "-a", "--format=%(refname:short)"):
return _proc(stdout="dev\n")
if args == ("status", "--porcelain"):
return _proc(stdout="")
return _proc(code=1, stderr="unexpected")
monkeypatch.setattr(git_update, "_run_git", fake_run)
with pytest.raises(git_update.GitUpdateError, match="not available"):
git_update.checkout("missing-branch", pull_after=False)
def test_checkout_switches_branch(monkeypatch):
state = {"branch": "dev", "commit": "aaa"}
def fake_run(*args, **kwargs):
if args == ("branch", "-a", "--format=%(refname:short)"):
return _proc(stdout="dev\ncutting_edge\n")
if args == ("status", "--porcelain"):
return _proc(stdout="")
if args == ("rev-parse", "--abbrev-ref", "HEAD"):
return _proc(stdout=f"{state['branch']}\n")
if args == ("rev-parse", "--short", "HEAD"):
return _proc(stdout=f"{state['commit']}\n")
if args == ("checkout", "cutting_edge"):
state["branch"] = "cutting_edge"
return _proc()
return _proc(code=1, stderr="unexpected")
monkeypatch.setattr(git_update, "_run_git", fake_run)
result = git_update.checkout("cutting_edge", pull_after=False)
assert result["branch"] == "cutting_edge"
assert result["before_branch"] == "dev"
+30 -6
View File
@@ -119,7 +119,30 @@ def test_duplicate_port_wire_blocked():
assert any("already wired" in e for e in errs)
def test_core_overrides_ignored_when_guided():
def test_core_overrides_open_port_apply_when_guided():
models = {
"full_control": False,
"core_overrides": {"studio": {"latent": "out:custom:LATENT"}},
"slots": [
{"id": "u", "node_class": "UnetLoader", "inputs": {}, "role": "unet",
"wires": {"MODEL": "port:FunPackStudio.model"}},
{"id": "c", "node_class": "ClipLoader", "inputs": {}, "role": "clip",
"wires": {"CLIP": "port:FunPackStudio.clip"}},
{"id": "v", "node_class": "VaeLoader", "inputs": {}, "role": "video_vae",
"wires": {"VAE": "port:FunPackLTXAVSceneChainSampler.vae"}},
{"id": "av", "node_class": "VaeLoader", "inputs": {}, "role": "audio_vae",
"wires": {"VAE": "port:LTXVAudioVAEDecode.audio_vae"}},
{"id": "ae", "node_class": "ImgProc", "inputs": {}, "role": "audio_encoder",
"wires": {"Latent": "port:LTXVConcatAVLatent.audio_latent"}},
{"id": "custom", "node_class": "EmptyLatent", "inputs": {}, "role": "custom",
"wires": {"LATENT": "port:FunPackStudio.latent"}},
],
}
graph, _report = builder.build(OI, models, PARAMS)
assert graph["studio"]["inputs"]["latent"] == ["slot_custom", 0]
def test_core_overrides_internal_still_ignored_when_guided():
models = {
"full_control": False,
"core_overrides": {"sampler": {"vae": "out:bad:0"}},
@@ -143,8 +166,9 @@ def test_core_overrides_ignored_when_guided():
def test_core_graph_marks_locked_inputs():
nodes = builder.core_graph(OI, {"slots": [], "full_control": False})
studio = next(n for n in nodes if n["id"] == "studio")
sampler_in = next(i for i in studio["inputs"] if i["name"] == "model")
assert sampler_in["locked"] is True
concat = next(n for n in nodes if n["id"] == "concat")
aud = next(i for i in concat["inputs"] if i["name"] == "audio_latent")
assert aud["locked"] is True
model_in = next(i for i in studio["inputs"] if i["name"] == "model")
latent_in = next(i for i in studio["inputs"] if i["name"] == "latent")
pos_in = next(i for i in studio["inputs"] if i["name"] == "positive_prompt")
assert model_in["locked"] is False
assert latent_in["locked"] is False
assert pos_in["locked"] is True
+29 -1
View File
@@ -9,6 +9,8 @@ from movie_editor.server import (
_clip_needs_trim,
_parse_has_scenes,
_parse_prompt_variants,
_playback_render_from_query,
_project_models,
_resolve_run_seed,
_run_sampler_inputs,
_run_studio_inputs,
@@ -303,4 +305,30 @@ def test_scene_playback_clip_spec_chain_offsets():
assert c2["in"] == 3.88 + 0.5
assert c3["in"] == 7.4
assert c3["dur"] == 2.5
assert c1["filename"] == c2["filename"] == c3["filename"] == "chain.mp4"
assert c1["filename"] == c2["filename"] == c3["filename"] == "chain.mp4"
def test_scene_playback_clip_spec_live_render_query():
p = _project(scenes=[{"id": "s1", "text": "a"}, {"id": "s2", "text": "b", "source_in": 0.5}])
p.scene_renders = {}
override = _playback_render_from_query({
"filename": "fresh.mp4",
"subfolder": "out",
"type": "output",
"render_in": "3.88",
})
c = _scene_playback_clip_spec(p, "s2", render_override=override)
assert c["filename"] == "fresh.mp4"
assert c["subfolder"] == "out"
assert c["in"] == 3.88 + 0.5
assert _playback_render_from_query({}) is None
def test_project_models_keeps_empty_slots(monkeypatch):
from movie_editor.backend import nodes
monkeypatch.setattr(nodes, "load_models", lambda: {"slots": [{"id": "global", "role": "unet"}]})
p = _project(scenes=[{"id": "s1", "text": "a"}])
p.models = {"slots": [], "full_control": False}
assert _project_models(p)["slots"] == []
assert _project_models(p)["slots"] != nodes.load_models()["slots"]