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:
co-authored by
Claude Sonnet 5.5
parent
f2e8c6f002
commit
a3327a37ae
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user