feat: 'Ready to generate?' — a machine check (ffmpeg, Pillow, GPU/bf16/xformers, models, missing nodes per starting point, failed modules) in Settings

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 12:49:40 +03:00
co-authored by Claude Sonnet 5.5
parent f2e8c6f002
commit a3327a37ae
7 changed files with 164 additions and 2 deletions
+17
View File
@@ -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;
};
+2 -1
View File
@@ -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");
+1
View File
@@ -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) {
+79
View File
@@ -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")]
+27 -1
View File
@@ -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.
+32
View File
@@ -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)
+6
View File
@@ -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")