diff --git a/movie_editor/backend/pipeline_deps.py b/movie_editor/backend/pipeline_deps.py
new file mode 100644
index 00000000..01b32ce7
--- /dev/null
+++ b/movie_editor/backend/pipeline_deps.py
@@ -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."
diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js
index 4725dec5..8434b374 100644
--- a/movie_editor/frontend/api.js
+++ b/movie_editor/frontend/api.js
@@ -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.
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index 9b5f3286..690429b6 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -102,6 +102,7 @@
+
@@ -122,6 +123,7 @@
+
diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js
index 7d097bf4..3941ef29 100644
--- a/movie_editor/frontend/menubar.js
+++ b/movie_editor/frontend/menubar.js
@@ -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;
}
diff --git a/movie_editor/frontend/pipeline_setup.js b/movie_editor/frontend/pipeline_setup.js
new file mode 100644
index 00000000..5b55eb73
--- /dev/null
+++ b/movie_editor/frontend/pipeline_setup.js
@@ -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 };
+})();
diff --git a/movie_editor/frontend/restart_ui.js b/movie_editor/frontend/restart_ui.js
new file mode 100644
index 00000000..baf86384
--- /dev/null
+++ b/movie_editor/frontend/restart_ui.js
@@ -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(); },
+ };
+})();
diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js
index f476ce4c..cdf28c77 100644
--- a/movie_editor/frontend/store.js
+++ b/movie_editor/frontend/store.js
@@ -636,6 +636,7 @@
await loadModels();
_validateSceneRenders();
window.WelcomePage?.close?.();
+ window.PipelineSetup?.maybePrompt?.();
}
async function newProject(name) {
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index 2e71d342..b1f885bc 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -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); }
diff --git a/movie_editor/server.py b/movie_editor/server.py
index b492a8e2..0ea361ad 100644
--- a/movie_editor/server.py
+++ b/movie_editor/server.py
@@ -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:
diff --git a/movie_editor/tests/test_pipeline_deps.py b/movie_editor/tests/test_pipeline_deps.py
new file mode 100644
index 00000000..3a2be3e8
--- /dev/null
+++ b/movie_editor/tests/test_pipeline_deps.py
@@ -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]