diff --git a/app/ui/features/settings/about.js b/app/ui/features/settings/about.js index 5c509e91..ba7f670c 100644 --- a/app/ui/features/settings/about.js +++ b/app/ui/features/settings/about.js @@ -24,3 +24,20 @@ export const about = (api) => function mount() { }); return body; }; + +/** "Is this machine ready to generate?": the rows core/readiness.py reports, worst first. */ +export const readiness = (api) => function mount() { + const body = c.region.stack({ gap: "sm", label: "Ready to generate" }); + const MARK = { ok: "✓", warn: "!", fail: "✗" }, ORDER = { fail: 0, warn: 1, ok: 2 }; + const run = async () => { + body.set([c.hint.default({ text: "Checking…" })]); + let rows; + try { rows = (await api.readiness()).rows; } catch (err) { return body.set([c.banner.warn({ text: `Could not check: ${err.message}` }), check]); } + const bad = rows.filter((r) => r.level !== "ok").length; + body.set([check, bad ? c.banner.warn({ text: `${bad} thing${bad > 1 ? "s" : ""} to look at.` }) : c.banner.info({ text: "Nothing in the way." }), + ...[...rows].sort((a, b) => ORDER[a.level] - ORDER[b.level]).map((r) => c.hint.default({ text: `${MARK[r.level]} ${r.text}` }))]); + }; + const check = c.button.sm({ label: "Check this machine", tone: "ghost", title: "Look for what a first run on a new GPU box trips over", onClick: run }); + body.set([c.hint.default({ text: "Run this on a new rental before the first generation: it checks ffmpeg, the GPU, model files, installed nodes and loaded modules." }), check]); + return body; +}; diff --git a/app/ui/features/settings/index.js b/app/ui/features/settings/index.js index e9aec560..0fea7c15 100644 --- a/app/ui/features/settings/index.js +++ b/app/ui/features/settings/index.js @@ -1,6 +1,6 @@ // Settings sections that sit in v4's window: the real ones, and stand-ins (named, grouped, saying what is coming) for the rest. import { composer as c } from "../../composer/composer.js"; -import { about } from "./about.js"; +import { about, readiness } from "./about.js"; import { appearance } from "./appearance.js"; import { models } from "./models.js"; import { engine } from "./engine.js"; @@ -17,6 +17,7 @@ export default { setup({ host, app }) { const add = (id, title, subtitle, icon, tone, mount, group = "", keywords = "") => host.add({ id, group, title, subtitle, icon, tone, mount, keywords: `${keywords} ${title}` }); add("about", "About FunPack", "", "◎", "accent", about(app.api), "", "version commit branch cpu memory gpu disk python torch"); + add("readiness", "Ready to generate?", "Check this machine before a first run: ffmpeg, GPU, models, nodes, modules.", "✓", "accent", readiness(app.api), "", "check preflight rental gpu ffmpeg models missing nodes"); add("appearance", "Appearance", "Light, dark, or follow the system.", "◐", "neutral", appearance(app.theme), "", "theme colour light dark auto"); add("editor", "Editor", "How the editor behaves. The open project remembers these, so they follow it to another machine.", "☰", "neutral", editor(app), "", "upscale autocomplete shortcuts ideas preferences"); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 29c41c90..b8836a33 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -25,6 +25,7 @@ export const api = { releaseModule: (id) => call("POST", "/api/control/release", { id }), searchNodes: (q, limit = 40) => call("GET", `/api/nodes/search?q=${encodeURIComponent(q || "")}&limit=${limit}`), pipelinePresets: () => call("GET", "/api/pipeline/presets"), + readiness: () => call("GET", "/api/readiness"), importWorkflow: (workflow) => call("POST", "/api/pipeline/import", { workflow }), /** The pipeline as a PNG (loaders, typed-in values, host torch/CUDA). -> a Blob. */ async settingsCard(slots, projectName, theme) { diff --git a/core/readiness.py b/core/readiness.py new file mode 100644 index 00000000..31b07eb1 --- /dev/null +++ b/core/readiness.py @@ -0,0 +1,79 @@ +"""Is this machine ready to generate? Plain checks of what a first run on a fresh GPU box trips over. + +Each check is one row: {"level": "ok" | "warn" | "fail", "text": a sentence that says what to do}. +`fail` = a generation or render will not work; `warn` = it works but not as it should. Nothing here raises: +a check that cannot run reports itself as a warning, because this is the screen to trust when things are off. +""" + +import importlib.util +import shutil +from typing import Callable, Iterable, List + +LOW_DISK_GB = 20 +MODEL_FOLDERS = (("diffusion_models", "diffusion models"), ("checkpoints", "checkpoints"), ("vae", "VAEs"), + ("text_encoders", "text encoders"), ("loras", "LoRAs")) + + +def row(level, text): + return {"level": level, "text": text} + + +def have(module) -> bool: + try: + return importlib.util.find_spec(module) is not None + except (ImportError, ValueError): + return False + + +def machine(disk_free_gb=None) -> List[dict]: + out = [] + for tool, why in (("ffmpeg", "the final render and previews"), ("ffprobe", "reading clip lengths")): + out.append(row("ok", f"{tool} found") if shutil.which(tool) else row("fail", f"{tool} is not installed: it is needed for {why}. Install ffmpeg on this machine.")) + out.append(row("ok", "Pillow found") if have("PIL") else row("fail", "Pillow is not installed: 'continue from the previous clip' cannot read the last frame. pip install pillow")) + try: + import torch + if not torch.cuda.is_available(): + out.append(row("warn", "No NVIDIA GPU is visible to torch: this machine can edit but not generate.")) + else: + props = torch.cuda.get_device_properties(0) + out.append(row("ok", f"{props.name}, {props.total_memory / 1024 ** 3:.0f} GB, sm_{props.major}{props.minor}")) + if not torch.cuda.is_bf16_supported(): + out.append(row("fail", "This GPU does not support bf16, which the H3 VAEs need.")) + if props.major >= 12 and have("xformers"): + out.append(row("warn", "xformers has no masked-attention kernel for this GPU: start ComfyUI with --disable-xformers --use-sage-attention.")) + except Exception as exc: # noqa: BLE001 + out.append(row("warn", f"torch could not be inspected ({exc}).")) + if not have("sageattention"): + out.append(row("warn", "sageattention is not installed: attention runs slower (and the H3 SLA setting has no fast path).")) + if disk_free_gb is not None and disk_free_gb < LOW_DISK_GB: + out.append(row("warn", f"Only {disk_free_gb} GB free: renders and previews fill a disk quickly (want {LOW_DISK_GB}+).")) + return out + + +def models(counts: dict) -> List[dict]: + """`counts`: model folder -> number of files ComfyUI lists there.""" + out = [] + if not counts.get("diffusion_models") and not counts.get("checkpoints"): + out.append(row("fail", "No model files: put a diffusion model (or checkpoint) in ComfyUI's models folder.")) + for key, label in MODEL_FOLDERS: + n = counts.get(key) + if n is None: + continue + out.append(row("ok" if n else "warn", f"{n} {label}" if n else f"No {label} found.")) + return out + + +def pipelines(presets: Iterable[dict], installed: Callable[[str], bool]) -> List[dict]: + """Nodes a starting point needs that this ComfyUI does not have (a custom-node pack is missing).""" + out = [] + for preset in presets: + missing = sorted({s["node"] for s in preset.get("slots", []) if not installed(s["node"])}) + if missing: + out.append(row("warn", f"“{preset.get('title') or preset.get('id')}” needs nodes that are not installed: {', '.join(missing)}. Install the pack that provides them (Settings ▸ Custom nodes).")) + return out or [row("ok", "Every starting point's nodes are installed")] + + +def modules(failed, quarantined) -> List[dict]: + out = [row("warn", f"{where} did not load: {why}") for where, why in failed] + out += [row("warn", f"Module {mid} is switched off after a failure: {e.get('reason', '')}. Release it in Settings ▸ Modules.") for mid, e in quarantined.items()] + return out or [row("ok", "Every module loaded")] diff --git a/core/routes.py b/core/routes.py index 13405273..4f2ef639 100644 --- a/core/routes.py +++ b/core/routes.py @@ -17,7 +17,7 @@ from . import (backend_log, config, control as control_mod, graph as graph_mod, story, sysinfo, temp_files, - update as update_mod, workflow_import, + readiness, update as update_mod, workflow_import, registry as registry_mod, serve as static, widgets) from .contract import CONTRACT_VERSION from .relations import order @@ -258,6 +258,32 @@ def register(routes, prefix=None): except (ValueError, TypeError, KeyError, AttributeError, IndexError) as exc: # an export that is not shaped like one return web.json_response({"why": str(exc)}, status=400) + @routes.get(P + "/api/readiness") + async def _readiness(_req): + """Plain checks of whether this machine can generate (core/readiness.py).""" + def collect(): + import nodes as comfy_nodes + import folder_paths + counts = {} + for key, _label in readiness.MODEL_FOLDERS: + try: + counts[key] = len(folder_paths.get_filename_list(key)) + except Exception: # noqa: BLE001 -- a folder this ComfyUI does not know + pass + presets = [{"id": "default", "title": "Default", "slots": [s for p in modules().providers("default_pipeline") for s in p[1]()]}] + for _spec, make in modules().providers("pipeline_presets"): + try: + presets += list(make() or []) + except Exception as exc: # noqa: BLE001 + log.failed("pipeline_presets", exc) + reg = modules() + rows = readiness.machine(sysinfo.collect().get("disk", {}).get("free_gb")) + rows += readiness.models(counts) + rows += readiness.pipelines(presets, lambda cls: cls in comfy_nodes.NODE_CLASS_MAPPINGS) + rows += readiness.modules(reg.failed, control_mod.quarantined(reg.specs.values())) + return rows + return web.json_response({"rows": await asyncio.to_thread(collect)}) + @routes.post(P + "/api/settings-card") async def _settings_card(req): """Render the CALLER's pipeline as a PNG. See core/settings_card.py. diff --git a/core/tests/test_readiness.py b/core/tests/test_readiness.py new file mode 100644 index 00000000..2bda81bd --- /dev/null +++ b/core/tests/test_readiness.py @@ -0,0 +1,32 @@ +from core import readiness as r + + +def levels(rows): + return [x["level"] for x in rows] + + +def test_no_model_files_is_a_failure_and_empty_folders_are_named(): + rows = r.models({"diffusion_models": 0, "checkpoints": 0, "vae": 2}) + assert rows[0]["level"] == "fail" and "No model files" in rows[0]["text"] + assert any("No diffusion models" in x["text"] for x in rows) and any(x["text"] == "2 VAEs" for x in rows) + assert "fail" not in levels(r.models({"diffusion_models": 1})) + + +def test_a_preset_naming_a_node_that_is_not_installed_says_which_and_where_to_get_it(): + presets = [{"id": "a", "title": "H3", "slots": [{"node": "Have"}, {"node": "ImageTransformKJ"}]}, {"id": "b", "slots": [{"node": "Have"}]}] + rows = r.pipelines(presets, lambda cls: cls == "Have") + assert len(rows) == 1 and "ImageTransformKJ" in rows[0]["text"] and "H3" in rows[0]["text"] + assert r.pipelines(presets[1:], lambda cls: True)[0]["level"] == "ok" + + +def test_modules_that_failed_or_are_switched_off_are_listed(): + rows = r.modules([("modules.x", "ImportError: nope")], {"reins": {"reason": "boom"}}) + assert len(rows) == 2 and "nope" in rows[0]["text"] and "reins" in rows[1]["text"] + assert r.modules([], {})[0]["level"] == "ok" + + +def test_the_machine_checks_never_raise_and_low_disk_is_a_warning(monkeypatch): + monkeypatch.setattr(r.shutil, "which", lambda name: None) + rows = r.machine(disk_free_gb=3) + assert any(x["level"] == "fail" and "ffmpeg" in x["text"] for x in rows) + assert any(x["level"] == "warn" and "3 GB free" in x["text"] for x in rows) diff --git a/core/tests/test_routes_pipeline.py b/core/tests/test_routes_pipeline.py index 77190df4..b584393b 100644 --- a/core/tests/test_routes_pipeline.py +++ b/core/tests/test_routes_pipeline.py @@ -535,6 +535,12 @@ def test_a_comfyui_workflow_imports_into_slots_that_build_a_real_graph(server, r assert _request(server, "POST", "/funpack/api/pipeline/import", {"workflow": {"nope": 1}})[0] == 400 +def test_the_readiness_check_answers_with_rows_and_says_which_starting_point_lacks_nodes(server, registered): + status, body = _request(server, "GET", "/funpack/api/readiness") + assert status == 200 and body["rows"] + assert all(r["level"] in ("ok", "warn", "fail") and r["text"] for r in body["rows"]) + + def test_the_manifest_says_which_modules_can_be_switched_and_a_quarantined_one_can_be_released(server): from core import control, registry status, body = _request(server, "GET", "/funpack/api/modules")