Install missing pipeline nodes via ComfyUI-Manager on first project open.

This commit is contained in:
digital-garbage
2026-06-12 16:31:19 +03:00
parent 69ae9bacf4
commit 29b0b6f705
10 changed files with 603 additions and 21 deletions
+294
View File
@@ -0,0 +1,294 @@
"""Detect missing custom-node packs for the built-in FunPack pipeline and install
them through ComfyUI-Manager's queue (same path as the Manager UI)."""
from __future__ import annotations
import asyncio
import time
import uuid
from typing import Any, Optional
from . import config
from .builder import CORE
# Curated mapping: pack id (ComfyUI-Manager / registry) -> node classes used by the
# fixed core graph. FunPack and comfy-core primitives are excluded.
PIPELINE_PACKS: list[dict[str, Any]] = [
{
"id": "ComfyUI-LTXVideo",
"title": "ComfyUI-LTXVideo",
"classes": frozenset({
"LTXVConditioning",
"LTXVConcatAVLatent",
"LTXVSeparateAVLatent",
"LTXVAudioVAEDecode",
"LTXFloatToInt",
}),
"git_urls": ["https://github.com/Lightricks/ComfyUI-LTXVideo"],
},
{
"id": "comfyui-videohelpersuite",
"title": "Video Helper Suite",
"classes": frozenset({"VHS_VideoCombine"}),
"git_urls": ["https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite"],
},
{
"id": "comfyui-kjnodes",
"title": "KJNodes",
"classes": frozenset({"NormalizeAudioLoudness"}),
"git_urls": ["https://github.com/kijai/ComfyUI-KJNodes"],
},
]
_PACK_BY_ID = {p["id"]: p for p in PIPELINE_PACKS}
_ALL_MAPPED = frozenset().union(*(p["classes"] for p in PIPELINE_PACKS))
_install_jobs: dict[str, dict] = {}
def required_core_classes() -> list[str]:
return sorted(set(CORE.values()))
def missing_core_classes(object_info: dict | None) -> list[str]:
oi = object_info or {}
return sorted(cls for cls in CORE.values() if cls not in oi)
def missing_packs(object_info: dict | None) -> list[dict]:
missing = set(missing_core_classes(object_info))
out: list[dict] = []
for pack in PIPELINE_PACKS:
hit = pack["classes"] & missing
if not hit:
continue
out.append({
"id": pack["id"],
"title": pack["title"],
"missing_classes": sorted(hit),
})
return out
def unmapped_missing_classes(object_info: dict | None) -> list[str]:
missing = set(missing_core_classes(object_info))
return sorted(missing - _ALL_MAPPED)
def status_payload(object_info: dict | None, *, manager_available: bool) -> dict:
packs = missing_packs(object_info)
unmapped = unmapped_missing_classes(object_info)
return {
"manager_available": manager_available,
"missing_classes": missing_core_classes(object_info),
"missing_packs": packs,
"unmapped_classes": unmapped,
"needs_install": bool(packs),
"manual_urls": [
{"id": p["id"], "title": p["title"], "url": p["git_urls"][0]}
for p in PIPELINE_PACKS
if p["id"] in {x["id"] for x in packs}
],
}
async def manager_available() -> bool:
status, _ = await _manager_request("GET", "/manager/version")
if status == 200:
return True
status, _ = await _manager_request("GET", "/api/manager/version")
return status == 200
async def _manager_request(method: str, path: str, *, json_body: dict | None = None, text_body: str | None = None):
import aiohttp
base = config.comfy_base_url().rstrip("/")
url = f"{base}{path}"
headers: dict[str, str] = {}
kwargs: dict[str, Any] = {}
if json_body is not None:
headers["Content-Type"] = "application/json"
kwargs["json"] = json_body
elif text_body is not None:
headers["Content-Type"] = "text/plain"
kwargs["data"] = text_body
timeout = aiohttp.ClientTimeout(total=120)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(method, url, headers=headers, **kwargs) as resp:
text = await resp.text()
return resp.status, text
async def _fetch_manager_pack_meta(pack_id: str) -> dict | None:
for path in ("/customnode/getlist?mode=cache", "/api/customnode/getlist?mode=cache"):
status, body = await _manager_request("GET", path)
if status != 200:
continue
try:
import json as _json
data = _json.loads(body)
except Exception:
continue
node_packs = data.get("node_packs") or {}
for _key, meta in node_packs.items():
if not isinstance(meta, dict):
continue
if meta.get("id") == pack_id or meta.get("cnr_id") == pack_id or _key == pack_id:
return meta
return None
def _install_body(pack: dict, meta: dict | None) -> dict:
pid = pack["id"]
title = pack["title"]
if meta:
version = str(meta.get("version") or meta.get("ver") or "nightly")
selected = str(meta.get("selected_version") or "latest")
body: dict[str, Any] = {
"id": meta.get("id") or meta.get("cnr_id") or pid,
"title": meta.get("title") or title,
"name": meta.get("name") or title,
"version": version,
"selected_version": selected,
"channel": str(meta.get("channel") or "default"),
"mode": "cache",
"ui_id": pid,
"skip_post_install": False,
}
if meta.get("repository"):
body["repository"] = meta["repository"]
if meta.get("files"):
body["files"] = meta["files"]
return body
return {
"id": pid,
"title": title,
"name": title,
"version": "unknown",
"selected_version": "unknown",
"files": list(pack.get("git_urls") or []),
"install_type": "git-clone",
"channel": "default",
"mode": "cache",
"ui_id": pid,
"skip_post_install": False,
}
async def _queue_install(body: dict) -> tuple[bool, str]:
for path in ("/manager/queue/install", "/api/manager/queue/install"):
status, text = await _manager_request("POST", path, json_body=body)
if status in (200, 201):
return True, ""
if status == 403:
return False, "ComfyUI-Manager blocked the install (security policy). Open Manager and lower the security level, or install the packs manually."
if status == 404:
return False, text.strip() or "Pack not found in ComfyUI-Manager database."
return False, "ComfyUI-Manager install endpoint unavailable."
async def _start_manager_queue() -> None:
for path in ("/manager/queue/start", "/api/manager/queue/start"):
status, _ = await _manager_request("GET", path)
if status in (200, 201):
return
async def _manager_queue_status() -> dict:
for path in ("/manager/queue/status", "/api/manager/queue/status"):
status, body = await _manager_request("GET", path)
if status != 200:
continue
try:
import json as _json
data = _json.loads(body)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def create_install_job(pack_ids: list[str]) -> dict:
packs = []
for pid in pack_ids:
spec = _PACK_BY_ID.get(pid)
if spec:
packs.append({"id": pid, "title": spec["title"]})
job_id = uuid.uuid4().hex[:12]
job = {
"job_id": job_id,
"state": "queued",
"packs": packs,
"total": len(packs),
"done": 0,
"current_title": packs[0]["title"] if packs else "",
"error": "",
"started_at": time.time(),
}
_install_jobs[job_id] = job
return job
def get_install_job(job_id: str) -> Optional[dict]:
job = _install_jobs.get(job_id)
if not job:
return None
return {
"job_id": job["job_id"],
"state": job["state"],
"packs": job["packs"],
"total": job["total"],
"done": job["done"],
"current_title": job.get("current_title") or "",
"error": job.get("error") or "",
}
async def run_install_job(job_id: str, restart_fn) -> None:
job = _install_jobs.get(job_id)
if not job:
return
job["state"] = "installing"
try:
for i, pack in enumerate(job["packs"]):
pid = pack["id"]
spec = _PACK_BY_ID.get(pid)
if not spec:
continue
job["current_title"] = pack["title"]
job["done"] = i
meta = await _fetch_manager_pack_meta(pid)
body = _install_body(spec, meta)
ok, err = await _queue_install(body)
if not ok:
job["state"] = "error"
job["error"] = err or f"Failed to queue {pack['title']}."
return
await _start_manager_queue()
deadline = time.time() + 1800
while time.time() < deadline:
st = await _manager_queue_status()
done = int(st.get("done_count") or 0)
total = int(st.get("total_count") or 0) or job["total"]
processing = bool(st.get("is_processing"))
job["total"] = max(job["total"], total)
job["done"] = min(done, job["total"])
if job["packs"]:
idx = min(max(done, 0), len(job["packs"]) - 1)
job["current_title"] = job["packs"][idx]["title"]
if job["total"] > 0 and done >= job["total"] and not processing:
break
await asyncio.sleep(1.5)
else:
job["state"] = "error"
job["error"] = "Install timed out. Check ComfyUI-Manager queue in the main ComfyUI window."
return
job["done"] = job["total"]
job["state"] = "restarting"
job["current_title"] = ""
await asyncio.sleep(0.7)
restart_fn()
except Exception as e: # noqa: BLE001
job["state"] = "error"
job["error"] = str(e) or "Install failed."
+3
View File
@@ -109,6 +109,9 @@
allNodes: () => j("GET", API("/all-nodes")),
nodeSpec: (cls) => j("GET", API(`/node/${encodeURIComponent(cls)}`)),
pipelinePorts: () => j("GET", API("/pipeline-ports")),
pipelineDeps: () => j("GET", API("/pipeline-deps")),
pipelineDepsInstall: (packIds) => j("POST", API("/pipeline-deps/install"), { pack_ids: packIds }),
pipelineDepsInstallStatus: (jobId) => j("GET", API(`/pipeline-deps/install/${encodeURIComponent(jobId)}`)),
imageTargets: (pid) => j("GET", API("/image-targets" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))),
// Models config is per-project; pass the project id. Falls back to the global
// default route (used as the seed/template) when no project is given.
+2
View File
@@ -102,6 +102,7 @@
<script src="api.js"></script>
<script src="dom.js"></script>
<script src="restart_ui.js"></script>
<script src="pipeline_caps.js"></script>
<script src="tour_mode.js"></script>
<script src="view_bus.js"></script>
@@ -122,6 +123,7 @@
<script src="engine_settings.js"></script>
<script src="slot_picker.js"></script>
<script src="welcome.js"></script>
<script src="pipeline_setup.js"></script>
<script src="inspector.js"></script>
<script src="rating_picker.js"></script>
<script src="waveform.js"></script>
+4 -20
View File
@@ -78,27 +78,11 @@
}
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", message);
card.append(msg); ov.append(card); document.body.append(ov);
return msg;
return window.FunPackRestart?.showOverlay?.(message);
}
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) {
msgEl.textContent = "Still waiting on ComfyUI…\nIt may need a manual restart - check the console.";
}
setTimeout(tick, 2000);
};
setTimeout(tick, 3500);
window.FunPackRestart?.waitForReload?.(msgEl, startMs);
}
async function restartComfy() {
@@ -128,7 +112,7 @@
? `Updated ${res.before} → ${res.after}.\nRestarting ComfyUI…`
: "Already up to date.\nRestarting ComfyUI…";
} catch (e) {
document.querySelector(".restart-overlay")?.remove();
window.FunPackRestart?.removeOverlay?.();
alert("Update failed: " + (e.message || e));
return;
}
@@ -163,7 +147,7 @@
? `Switched to ${branch} (${res.before} → ${res.after}).\nRestarting ComfyUI…`
: `On ${branch}, already up to date.\nRestarting ComfyUI…`;
} catch (e) {
document.querySelector(".restart-overlay")?.remove();
window.FunPackRestart?.removeOverlay?.();
alert("Branch switch failed: " + (e.message || e));
return;
}
+176
View File
@@ -0,0 +1,176 @@
// Prompt to install missing custom-node packs for the built-in pipeline (via Manager).
(function () {
const { el } = window.dom;
const S = window.Store;
const API = window.MovieEditorAPI;
const LS_KEY = "funpack_pipeline_deps_dismissed";
let overlay = null;
let _pollTimer = null;
function dismissed() {
try { return localStorage.getItem(LS_KEY) === "1"; } catch (_) { return false; }
}
function setDismissed() {
try { localStorage.setItem(LS_KEY, "1"); } catch (_) {}
}
function closeModal() {
if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
overlay?.remove();
overlay = null;
}
function builtInPipelineActive(st) {
const m = st.models || {};
return !m.disable_core;
}
async function useOwnPipeline() {
if (!S.get().project) return;
const models = JSON.parse(JSON.stringify(S.get().models || { slots: [] }));
models.disable_core = true;
await API.saveModels(S.get().project.id, models);
await S.loadModels();
setDismissed();
closeModal();
}
function packList(deps) {
const ul = el("ul", "pipe-setup-list");
(deps.missing_packs || []).forEach((p) => {
const li = el("li", null, p.title);
if (p.missing_classes?.length) {
li.append(el("span", "pipe-setup-sub", p.missing_classes.join(", ")));
}
ul.append(li);
});
return ul;
}
function manualBlock(deps) {
const box = el("div", "pipe-setup-manual");
box.append(el("div", "pipe-setup-manual-title", "Install ComfyUI-Manager or add these repos under custom_nodes:"));
const ul = el("ul", "pipe-setup-list");
(deps.manual_urls || []).forEach((u) => {
const li = el("li");
const a = el("a", null, u.title);
a.href = u.url;
a.target = "_blank";
a.rel = "noopener";
li.append(a);
ul.append(li);
});
box.append(ul);
return box;
}
async function pollInstall(jobId, msgEl) {
try {
const st = await API.pipelineDepsInstallStatus(jobId);
if (st.state === "installing" || st.state === "queued") {
const n = Math.min(st.done + 1, st.total || 1);
const title = st.current_title || "…";
msgEl.textContent = `Downloading and installing missing nodes:\n${n} out of ${st.total || "?"} - ${title}`;
_pollTimer = setTimeout(() => pollInstall(jobId, msgEl), 1200);
return;
}
if (st.state === "restarting") {
msgEl.textContent = "Install complete.\nRestarting ComfyUI…";
window.FunPackRestart?.waitForReload?.(msgEl, Date.now());
return;
}
if (st.state === "error") {
window.FunPackRestart?.removeOverlay?.();
alert(st.error || "Install failed.");
return;
}
_pollTimer = setTimeout(() => pollInstall(jobId, msgEl), 1200);
} catch (e) {
window.FunPackRestart?.removeOverlay?.();
alert(String(e.message || e));
}
}
async function startInstall(deps) {
const ids = (deps.missing_packs || []).map((p) => p.id);
if (!ids.length) return;
closeModal();
const msg = window.FunPackRestart?.showOverlay?.(
"Downloading and installing missing nodes:\n1 out of " + ids.length + " - " + (deps.missing_packs[0]?.title || "…"),
);
if (!msg) return;
try {
const res = await API.pipelineDepsInstall(ids);
pollInstall(res.job_id, msg);
} catch (e) {
window.FunPackRestart?.removeOverlay?.();
alert("Install failed: " + (e.message || e));
}
}
function openModal(deps) {
closeModal();
overlay = el("div", "modal-overlay pipe-setup-overlay");
const modal = el("div", "modal pipe-setup-modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Pipeline setup"));
const closeBtn = el("button", "btn ghost", "Close");
closeBtn.type = "button";
closeBtn.onclick = () => closeModal();
const headRight = el("div", "modal-head-right");
headRight.append(closeBtn);
head.append(headRight);
modal.append(head);
const content = el("div", "modal-content");
content.append(el("p", "pipe-setup-lead",
"Nodes required for the built-in pipeline are missing. Do you want to install them?"));
content.append(packList(deps));
if (!deps.manager_available) {
content.append(el("div", "pipe-setup-warn",
"ComfyUI-Manager was not detected. Automatic install is unavailable."));
content.append(manualBlock(deps));
}
if ((deps.unmapped_classes || []).length) {
content.append(el("div", "pipe-setup-warn",
"Unmapped missing classes: " + deps.unmapped_classes.join(", ")));
}
modal.append(content);
const foot = el("div", "pipe-setup-foot");
if (deps.manager_available) {
const installBtn = el("button", "btn primary", "Install missing nodes");
installBtn.type = "button";
installBtn.onclick = () => startInstall(deps);
foot.append(installBtn);
}
const ownBtn = el("button", "btn", "No, I'll use my own pipeline");
ownBtn.type = "button";
ownBtn.onclick = () => useOwnPipeline();
foot.append(ownBtn);
const dismissBtn = el("button", "btn ghost", "Close");
dismissBtn.type = "button";
dismissBtn.onclick = () => closeModal();
foot.append(dismissBtn);
modal.append(foot);
overlay.append(modal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
document.body.append(overlay);
}
async function maybePrompt() {
if (window.__FUNPACK_TOUR__) return;
if (dismissed()) return;
const st = S.get();
if (!st.project || !builtInPipelineActive(st)) return;
let deps;
try { deps = await API.pipelineDeps(); } catch (_) { return; }
if (!deps?.needs_install) return;
openModal(deps);
}
window.PipelineSetup = { maybePrompt, close: closeModal };
})();
+37
View File
@@ -0,0 +1,37 @@
// Shared ComfyUI restart overlay + health polling (Update FunPack, pipeline installs).
(function () {
const { el } = window.dom;
function showOverlay(message) {
document.querySelector(".restart-overlay")?.remove();
const ov = el("div", "restart-overlay");
const card = el("div", "restart-card");
card.append(el("div", "restart-spin"));
const msg = el("div", "restart-msg", message);
card.append(msg);
ov.append(card);
document.body.append(ov);
return msg;
}
function waitForReload(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) {
msgEl.textContent = "Still waiting on ComfyUI…\nIt may need a manual restart - check the console.";
}
setTimeout(tick, 2000);
};
setTimeout(tick, 3500);
}
window.FunPackRestart = {
showOverlay,
waitForReload,
removeOverlay() { document.querySelector(".restart-overlay")?.remove(); },
};
})();
+1
View File
@@ -636,6 +636,7 @@
await loadModels();
_validateSceneRenders();
window.WelcomePage?.close?.();
window.PipelineSetup?.maybePrompt?.();
}
async function newProject(name) {
+12
View File
@@ -1250,6 +1250,18 @@ label.field.linked input, label.field.linked select { opacity: .6; }
/* Input-source arrow points left (← instead of →) */
.wire-row .wire-arrow.src { color: var(--amber); }
/* pipeline missing-nodes setup modal */
.pipe-setup-modal { max-width: 520px; width: min(520px, 96vw); }
.pipe-setup-lead { margin: 0 0 12px; color: var(--muted); font-size: 13px; line-height: 1.5; }
.pipe-setup-list { margin: 0 0 12px; padding-left: 18px; color: var(--text); font-size: 13px; }
.pipe-setup-list li { margin: 6px 0; }
.pipe-setup-sub { display: block; font-size: 11px; color: var(--faint); font-family: var(--font-mono); margin-top: 2px; }
.pipe-setup-warn { margin: 10px 0; padding: 8px 10px; border-radius: 6px; font-size: 12px; line-height: 1.45;
background: color-mix(in srgb, var(--amber) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--amber) 35%, transparent); color: var(--muted); }
.pipe-setup-manual-title { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.pipe-setup-foot { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 16px 16px; border-top: 1px solid var(--line); }
/* restart overlay */
.restart-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--ink-0) 88%, transparent); backdrop-filter: blur(3px); }
+33 -1
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, git_update, media, nodes, pipeline_caps, pipeline_wiring, projects, workflow_import
from .backend import bridge, builder, config, git_update, media, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, workflow_import
from .backend.nle_effects import zoompan_z_expr
from .backend.nle_overlays import build_overlay_video_filter, prepare_overlay_export
from .backend.timeline import (
@@ -1710,6 +1710,38 @@ if web is not None and PromptServer is not None:
print(f"[FunPack][movie] node-candidates({role}) failed:\n{traceback.format_exc()}")
raise web.HTTPInternalServerError(reason=f"candidates({role}) failed: {e}")
@routes.get(UI_PREFIX + "/api/pipeline-deps")
async def _pipeline_deps(_req):
try:
oi = await bridge.object_info()
except Exception:
oi = {}
mgr = await pipeline_deps.manager_available()
return web.json_response(pipeline_deps.status_payload(oi, manager_available=mgr))
@routes.post(UI_PREFIX + "/api/pipeline-deps/install")
async def _pipeline_deps_install(req):
import asyncio
if not await pipeline_deps.manager_available():
return web.json_response(
{"detail": "ComfyUI-Manager is not installed or not reachable."},
status=503,
)
body = await req.json() if req.can_read_body else {}
pack_ids = body.get("pack_ids") or body.get("packs") or []
if not isinstance(pack_ids, list) or not pack_ids:
return web.json_response({"detail": "pack_ids required."}, status=400)
job = pipeline_deps.create_install_job([str(x) for x in pack_ids])
asyncio.create_task(pipeline_deps.run_install_job(job["job_id"], _restart_comfy))
return web.json_response({"job_id": job["job_id"], "total": job["total"]})
@routes.get(UI_PREFIX + "/api/pipeline-deps/install/{job_id}")
async def _pipeline_deps_install_status(req):
job = pipeline_deps.get_install_job(req.match_info["job_id"])
if not job:
raise web.HTTPNotFound(reason="Install job not found")
return web.json_response(job)
@routes.get(UI_PREFIX + "/api/pipeline-ports")
async def _pipeline_ports(_req):
try:
+41
View File
@@ -0,0 +1,41 @@
"""Tests for built-in pipeline dependency detection."""
from movie_editor.backend import pipeline_deps
def test_no_missing_when_all_core_present():
oi = {cls: {} for cls in pipeline_deps.required_core_classes()}
assert pipeline_deps.missing_core_classes(oi) == []
assert pipeline_deps.missing_packs(oi) == []
st = pipeline_deps.status_payload(oi, manager_available=True)
assert st["needs_install"] is False
def test_missing_ltx_and_vhs_packs():
oi = {
"FunPackStudio": {},
"FunPackLTXAVSceneChainSampler": {},
"FunPackSaveRefinementLatent": {},
"FunPackRefinementKeyLoader": {},
"PrimitiveStringMultiline": {},
"PrimitiveInt": {},
"PrimitiveFloat": {},
}
missing = pipeline_deps.missing_core_classes(oi)
assert "LTXVConditioning" in missing
assert "VHS_VideoCombine" in missing
packs = pipeline_deps.missing_packs(oi)
ids = {p["id"] for p in packs}
assert "ComfyUI-LTXVideo" in ids
assert "comfyui-videohelpersuite" in ids
st = pipeline_deps.status_payload(oi, manager_available=False)
assert st["needs_install"] is True
assert st["manager_available"] is False
assert len(st["manual_urls"]) >= 2
def test_install_body_fallback_git():
pack = pipeline_deps._PACK_BY_ID["ComfyUI-LTXVideo"]
body = pipeline_deps._install_body(pack, None)
assert body["version"] == "unknown"
assert body["files"]
assert "github.com" in body["files"][0]