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]