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:
co-authored by
Cursor
parent
bb48a3aed7
commit
59dd766704
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user