From b6fa5b177d5e456e5dfdedd24d1fe3f2ea68566d Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:15:30 +0300 Subject: [PATCH] feat: About panel reports the host's hardware and software MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About showed only version/commit/branch. Add the facts you actually want to check on a fresh rental: chip, memory, GPU (name, VRAM, compute capability), free disk, OS, ComfyUI, Python, torch (+CUDA), and which fast-attention backend is importable — the last one answers "is sage actually installed here" without reading the launch args. Everything describes the machine ComfyUI runs on, not the browser. On a rental those differ, and the host is the one worth looking at, so the panel says so in a line under the facts. Every probe is individually guarded and degrades to a dash: About is the one screen that should still render when the install is broken, so no missing psutil, torch, or shell binary may raise. The frontend optional-chains the call, so a frontend sharing settings_window.js without the endpoint keeps the old version-only panel instead of erroring. Facts move to a two-column grid; values now range from "3.14" to a full GPU name, and only a shared column keeps the keys aligned. Co-Authored-By: Claude Opus 5 --- movie_editor/backend/sysinfo.py | 182 +++++++++++++++++++++++ movie_editor/frontend/api.js | 1 + movie_editor/frontend/settings_window.js | 70 ++++++++- movie_editor/frontend/styles.css | 19 ++- movie_editor/server.py | 8 +- movie_editor/tests/test_sysinfo.py | 71 +++++++++ 6 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 movie_editor/backend/sysinfo.py create mode 100644 movie_editor/tests/test_sysinfo.py diff --git a/movie_editor/backend/sysinfo.py b/movie_editor/backend/sysinfo.py new file mode 100644 index 00000000..ec1733a8 --- /dev/null +++ b/movie_editor/backend/sysinfo.py @@ -0,0 +1,182 @@ +"""Host facts for the About panel — the machine ComfyUI runs on, not the browser's. + +That distinction is the whole point on a rental: the frontend already knows the laptop it +is displayed on, and nobody cares. What matters is the box doing the sampling — which GPU, +how much VRAM, which torch, how much disk is left for outputs. + +Everything here is best-effort and individually guarded: a missing probe returns None and +the panel renders a dash. Nothing in this module may raise, because the About panel is the +one screen that should still work when the rest of the install is broken. +""" +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _bytes_gb(n): + """Bytes -> GB as a float, using the 1024 base the OS's own About panels use.""" + try: + return round(float(n) / (1024 ** 3), 2) + except Exception: + return None + + +def _cpu_name(): + """A human CPU model string. platform.processor() is famously empty on Linux and + just 'arm' on Apple silicon, so ask the OS directly first.""" + try: + if sys.platform == "darwin": + out = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"], + capture_output=True, text=True, timeout=3) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + elif sys.platform.startswith("linux"): + cpuinfo = Path("/proc/cpuinfo") + if cpuinfo.exists(): + for line in cpuinfo.read_text(errors="replace").splitlines(): + if line.lower().startswith("model name"): + return line.split(":", 1)[1].strip() + except Exception: + pass + return platform.processor() or platform.machine() or None + + +def _cpu(): + info = {"name": _cpu_name(), "arch": platform.machine() or None, + "cores": None, "threads": os.cpu_count()} + try: + import psutil # a ComfyUI requirement, so normally present + info["cores"] = psutil.cpu_count(logical=False) + info["threads"] = psutil.cpu_count(logical=True) or info["threads"] + except Exception: + pass + return info + + +def _memory(): + try: + import psutil + vm = psutil.virtual_memory() + return {"total_gb": _bytes_gb(vm.total), "available_gb": _bytes_gb(vm.available)} + except Exception: + pass + try: # stdlib fallback (Linux/macOS) + total = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") + return {"total_gb": _bytes_gb(total), "available_gb": None} + except Exception: + return {"total_gb": None, "available_gb": None} + + +def _gpus(): + """Every visible CUDA/ROCm device. Empty list on a CPU-only box (the local Mac).""" + out = [] + try: + import torch + if not torch.cuda.is_available(): + return out + for i in range(torch.cuda.device_count()): + entry = {"name": None, "vram_gb": None, "capability": None} + try: + entry["name"] = torch.cuda.get_device_name(i) + except Exception: + pass + try: + props = torch.cuda.get_device_properties(i) + entry["vram_gb"] = _bytes_gb(props.total_memory) + entry["capability"] = f"sm_{props.major}{props.minor}" + except Exception: + pass + out.append(entry) + except Exception: + pass + return out + + +def _mps(): + """Apple-silicon GPU, which reports through MPS rather than CUDA.""" + try: + import torch + if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available(): + return True + except Exception: + pass + return False + + +def _disk(): + """Free space where FunPack lives — the volume that fills up with renders.""" + try: + usage = shutil.disk_usage(str(REPO_ROOT)) + return {"total_gb": _bytes_gb(usage.total), "free_gb": _bytes_gb(usage.free)} + except Exception: + return {"total_gb": None, "free_gb": None} + + +def _os_name(): + try: + if sys.platform == "darwin": + return f"macOS {platform.mac_ver()[0]}".strip() + if sys.platform.startswith("win"): + return f"Windows {platform.release()}" + # Prefer the distro's own pretty name over the kernel version. + osr = Path("/etc/os-release") + if osr.exists(): + for line in osr.read_text(errors="replace").splitlines(): + if line.startswith("PRETTY_NAME="): + return line.split("=", 1)[1].strip().strip('"') + return f"{platform.system()} {platform.release()}" + except Exception: + return None + + +def _torch(): + info = {"version": None, "cuda": None, "attention": None} + try: + import torch + info["version"] = torch.__version__ + info["cuda"] = getattr(torch.version, "cuda", None) + except Exception: + pass + # Which fast-attention backend is actually importable. This is the line that answers + # "is sage actually installed on this rental?" without digging through the launch args. + for mod, label in (("sageattention", "SageAttention"), ("flash_attn", "FlashAttention"), + ("xformers", "xformers")): + try: + m = __import__(mod) + info["attention"] = f"{label} {getattr(m, '__version__', '')}".strip() + break + except Exception: + continue + return info + + +def _comfy_version(): + try: + import comfyui_version + return comfyui_version.__version__ + except Exception: + return None + + +def collect(): + """The whole About payload. Static enough that the frontend caches it per load.""" + gpus = _gpus() + return { + "host": platform.node() or None, + "os": _os_name(), + "cpu": _cpu(), + "memory": _memory(), + "gpus": gpus, + "mps": _mps() if not gpus else False, + "disk": _disk(), + "python": platform.python_version(), + "torch": _torch(), + "comfyui": _comfy_version(), + } diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index f1494bb1..e1a4bfce 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -135,6 +135,7 @@ applyWorkflow: (pid, workflow, bindings) => j("POST", API(`/projects/${pid}/workflow/apply`), { workflow, bindings }), restart: () => j("POST", API("/restart")), + systemInfo: () => j("GET", API("/system/info")), gitStatus: () => j("GET", API("/git/status")), gitUpdate: (branch) => j("POST", API("/git/update"), branch ? { branch } : {}), gitCheckout: (branch) => j("POST", API("/git/checkout"), { branch }), diff --git a/movie_editor/frontend/settings_window.js b/movie_editor/frontend/settings_window.js index 302d0c3b..2999b117 100644 --- a/movie_editor/frontend/settings_window.js +++ b/movie_editor/frontend/settings_window.js @@ -184,7 +184,8 @@ register({ id: "about", group: "", order: 0, title: "About FunPack", subtitle: "", - keywords: "about version commit branch copyright info funpack cutting room", + keywords: "about version commit branch copyright info funpack cutting room " + + "cpu chip memory ram gpu graphics vram disk storage system os python torch cuda specs hardware", iconBg: "linear-gradient(180deg,#ffc36b,#e0891f)", icon: '', mount(body, ctx) { @@ -192,6 +193,54 @@ const wrap = el("div", "sw-about"); body.append(wrap); + // Host facts describe the machine ComfyUI runs on, which on a rental is NOT the + // machine showing this window. Fetched once per mount; nothing here changes while + // the window is open. Null until it arrives (or forever, if the backend is older). + let sys = null; + + const gb = (n) => (n == null ? null : `${n} GB`); + + function hardwareFacts(fact) { + if (!sys) return; + const cpu = sys.cpu || {}; + const cores = cpu.cores && cpu.threads && cpu.cores !== cpu.threads + ? `${cpu.cores}C/${cpu.threads}T` + : (cpu.threads ? `${cpu.threads}C` : null); + fact("Chip", [cpu.name, cores].filter(Boolean).join(" · ") || cpu.arch); + + const mem = sys.memory || {}; + fact("Memory", mem.available_gb != null && mem.total_gb != null + ? `${mem.available_gb} GB free of ${mem.total_gb} GB` + : gb(mem.total_gb)); + + const gpus = sys.gpus || []; + if (gpus.length) { + gpus.forEach((g, i) => { + const label = gpus.length > 1 ? `Graphics ${i}` : "Graphics"; + fact(label, [g.name, gb(g.vram_gb), g.capability].filter(Boolean).join(" · ")); + }); + } else { + // No CUDA device: say which kind of nothing, since "—" reads like a bug. + fact("Graphics", sys.mps ? "Apple GPU (MPS)" : "CPU only (no CUDA device)"); + } + + const disk = sys.disk || {}; + fact("Storage", disk.free_gb != null && disk.total_gb != null + ? `${disk.free_gb} GB available of ${disk.total_gb} GB` + : gb(disk.total_gb)); + } + + function softwareFacts(fact) { + if (!sys) return; + fact("System", sys.os); + fact("ComfyUI", sys.comfyui); + fact("Python", sys.python); + const t = sys.torch || {}; + fact("Torch", [t.version, t.cuda ? `CUDA ${t.cuda}` : null].filter(Boolean).join(" · ")); + if (t.attention) fact("Attention", t.attention); + if (sys.host) fact("Host", sys.host); + } + function render() { clear(wrap); const git = G ? G.get() : null; @@ -208,11 +257,25 @@ r.append(el("span", "sw-about-k", k), el("span", "sw-about-v", v || "—")); facts.append(r); }; + const group = (label) => facts.append(el("div", "sw-about-group", label)); + fact("Version", git?.version); fact("Commit", git?.ok ? git.commit + (git.dirty ? " (local changes)" : "") : null); fact("Branch", git?.ok ? git.branch : null); + + if (sys) { + group("Hardware"); + hardwareFacts(fact); + group("Software"); + softwareFacts(fact); + } wrap.append(facts); + if (sys) { + wrap.append(el("div", "sw-about-hint", + "The machine ComfyUI runs on — not this browser.")); + } + const upd = el("button", "btn ghost tiny", "Software Update…"); upd.onclick = () => ctx.openSection("system"); wrap.append(upd); @@ -222,6 +285,11 @@ render(); if (G?.refresh) G.refresh().then(() => { if (wrap.isConnected) render(); }).catch(() => {}); + // Optional-chained: a frontend sharing this file without the endpoint (or an older + // backend) simply keeps the version-only About instead of erroring. + window.MovieEditorAPI?.systemInfo?.() + .then((info) => { sys = info; if (wrap.isConnected) render(); }) + .catch(() => {}); }, }); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 202216ee..67dc5e0b 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1724,10 +1724,21 @@ body.tour-mode { overflow: hidden; } text-shadow: 0 0 24px rgba(243,169,60,.45); margin-bottom: 10px; } .sw-about-name { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -.02em; } .sw-about-sub { font-size: 13px; color: var(--muted); margin-bottom: 18px; } -.sw-about-facts { display: flex; flex-direction: column; gap: 4px; margin-bottom: 18px; } -.sw-about-fact { display: flex; gap: 14px; justify-content: center; font-size: 12.5px; } -.sw-about-k { color: var(--faint); min-width: 70px; text-align: right; } -.sw-about-v { color: var(--text); font-family: var(--font-mono); text-align: left; min-width: 70px; } +/* Two-column grid rather than centred rows: values now run from short ("3.14") to long + ("NVIDIA RTX PRO 6000 Blackwell · 95.5 GB · sm_120"), and only a shared column keeps + the keys aligned. `display: contents` lets each fact's two spans land in that grid. */ +.sw-about-facts { display: inline-grid; grid-template-columns: auto auto; gap: 4px 14px; + align-items: baseline; margin-bottom: 14px; font-size: 12.5px; } +.sw-about-fact { display: contents; } +.sw-about-k { color: var(--faint); text-align: right; white-space: nowrap; } +.sw-about-v { color: var(--text); font-family: var(--font-mono); text-align: left; + max-width: 320px; overflow-wrap: anywhere; } +.sw-about-group { grid-column: 1 / -1; margin-top: 12px; padding-bottom: 3px; + border-bottom: 1px solid var(--line, rgba(255,255,255,.08)); + font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; + color: var(--faint); text-align: left; } +.sw-about-hint { max-width: 340px; margin: 0 0 16px; font-size: 11px; color: var(--faint); + line-height: 1.45; text-wrap: balance; } .sw-about-copy { margin-top: 22px; font-size: 11px; color: var(--faint); } /* ── Models & Pipeline: inner sidebar layout inside the Settings window ── */ diff --git a/movie_editor/server.py b/movie_editor/server.py index d0c6e0dd..6b1e0e3c 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_deps, pipeline_wiring, projects, workflow_import +from .backend import bridge, builder, config, git_update, media, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, sysinfo, 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 ( @@ -2427,6 +2427,12 @@ 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/system/info") + async def _system_info(_req): + # Describes the ComfyUI host, not the browser — on a rental those are different + # machines and the host is the one worth looking at. + return web.json_response(sysinfo.collect()) + @routes.get(UI_PREFIX + "/api/git/status") async def _git_status(_req): return web.json_response(git_update.status()) diff --git a/movie_editor/tests/test_sysinfo.py b/movie_editor/tests/test_sysinfo.py new file mode 100644 index 00000000..e2cba0eb --- /dev/null +++ b/movie_editor/tests/test_sysinfo.py @@ -0,0 +1,71 @@ +"""Host facts for the About panel. + +The contract that matters is robustness, not accuracy: About is the one screen that should +still render on a half-broken install, so every probe must degrade to None rather than +raise. These tests therefore break the probes on purpose and assert the shape survives. +""" +import builtins + +import pytest + +from movie_editor.backend import sysinfo + + +def test_collect_returns_the_full_shape(): + info = sysinfo.collect() + for key in ("host", "os", "cpu", "memory", "gpus", "disk", "python", "torch", "comfyui"): + assert key in info, f"About reads {key!r} directly" + assert isinstance(info["gpus"], list) + assert isinstance(info["cpu"], dict) + # python is the one fact that can always be determined. + assert info["python"] + + +def test_collect_survives_every_import_failing(monkeypatch): + """psutil and torch are both optional from this module's point of view.""" + real_import = builtins.__import__ + + def _boom(name, *args, **kwargs): + if name in {"psutil", "torch", "comfyui_version"} or name.startswith("torch."): + raise ImportError(f"no {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _boom) + info = sysinfo.collect() + assert info["gpus"] == [] + assert info["mps"] is False + assert info["torch"]["version"] is None + assert info["comfyui"] is None + assert info["python"], "python version must survive a torch-less install" + + +def test_collect_survives_a_failing_subprocess(monkeypatch): + """_cpu_name shells out on macOS/Linux; a missing binary must not kill the panel.""" + def _boom(*args, **kwargs): + raise OSError("no such binary") + + monkeypatch.setattr(sysinfo.subprocess, "run", _boom) + assert "cpu" in sysinfo.collect() + + +def test_collect_survives_an_unreadable_disk(monkeypatch): + monkeypatch.setattr(sysinfo.shutil, "disk_usage", lambda _p: (_ for _ in ()).throw(OSError)) + assert sysinfo.collect()["disk"] == {"total_gb": None, "free_gb": None} + + +@pytest.mark.parametrize("value,expected", [ + (1024 ** 3, 1.0), + (0, 0.0), + (None, None), + ("nonsense", None), +]) +def test_bytes_gb(value, expected): + assert sysinfo._bytes_gb(value) == expected + + +def test_mps_is_not_reported_when_a_cuda_gpu_exists(monkeypatch): + """`mps` exists to explain an EMPTY graphics row; with CUDA present it must stay off + so the panel never claims both.""" + monkeypatch.setattr(sysinfo, "_gpus", lambda: [{"name": "Fake", "vram_gb": 8.0, "capability": "sm_120"}]) + monkeypatch.setattr(sysinfo, "_mps", lambda: True) + assert sysinfo.collect()["mps"] is False