feat: settings card export -- render the live pipeline as a PNG

MEDIUM-tier v4 feature ("Which model was that?" answered three days
later): a button in Models & Pipeline renders the currently loaded
pipeline as a PNG -- every slot's node class and typed-in values, wired
inputs marked as wired rather than printed as a stale value, and the
host facts (torch/CUDA/GPU/attention) that decide whether the setup
reproduces elsewhere. Downloadable, copyable to clipboard, and the
report is embedded in the PNG's own tEXt chunk for later machine
reading.

core/settings_card.py is a hard cut of v4's version, not a verbatim
port: v4 spent most of its length (_OWNED_BY, _EDITOR_DEFAULTS,
_pass_rows, _sampling_sections) describing FunPackStudio and the
LTX-AV Scene Chain Sampler's own hardcoded knob tables -- neither node
exists in v5's module-driven pipeline, so there is nothing left to
describe there. What's genuinely generic (walking a slot's real
inputs, detecting a wired one via core.graph.is_link, the PNG
renderer) is what's kept, adapted to v5's slot shape ({id, node,
group, inputs}, a wire arriving as [source_id, index] rather than a
separate input_sources/wires lookup) and v5's existing core/sysinfo.py
for host facts (whose field names already matched v4's needs).

New POST /api/settings-card route: stateless like /api/pipeline, the
CLIENT sends its own live pipeline (window.PipelineState.slots()) since
that's the one copy that includes any unsaved edit; the server supplies
only what it alone knows (host facts) and the render.

Frontend: app/legacy/api.js's settingsCard stub (present since the
Phase A port, explicitly marked "not built in v5 yet") replaced with a
real POST+blob call; app/legacy/models.js's Export-settings modal
ported from v4 near-verbatim, reading PS.slots()/project name instead
of a project id the old per-project models config needed. Verified
live: real pipeline, real host facts (this Mac's actual Apple MPS/
Python/ComfyUI versions), wired inputs correctly marked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-15 21:29:22 +03:00
co-authored by Claude Sonnet 5
parent 06621bb2e3
commit 6d496251f9
6 changed files with 698 additions and 1 deletions
+18 -1
View File
@@ -192,7 +192,24 @@
coreGraph: () => unsupported("v5's pipeline shape does not match v4's core-graph model yet"),
getModels: () => unsupported("the Models panel needs its own rewrite against v5's pipeline-slot shape"),
saveModels: () => unsupported("the Models panel needs its own rewrite against v5's pipeline-slot shape"),
settingsCard: async () => { throw new Error("settings card export is not built in v5 yet"); },
// The settings card is a PNG, not JSON -- fetched as a blob so the modal
// can show it, download it and put it on the clipboard from the one
// response. `slots` is the CALLER's live pipeline (v5's pipeline has no
// server-side copy to fall back on, see /api/pipeline's own docs) --
// window.PipelineState.slots() is what every other pipeline-editing
// caller already sends the same way.
settingsCard: async (slots, projectName, theme) => {
const res = await fetch(API("/api/settings-card"), {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slots: slots || [], project_name: projectName || null, theme: theme || "dark" }),
});
if (!res.ok) {
let payload = null;
try { payload = await res.json(); } catch (_) {}
throw new Error(readApiError(res, payload));
}
return res.blob();
},
refreshModels: () => unsupported("v5 has no model-file cache to refresh -- it reads the folders live"),
parseWorkflow: () => unsupported("workflow import is LOW priority, not built yet"),
applyWorkflow: () => unsupported("workflow import is LOW priority, not built yet"),
+91
View File
@@ -105,6 +105,90 @@
return field(widget.name, input, hint);
}
// ── export the pipeline as a picture ──────────────────────────────────────
// "Which model was that?" outlives the session that could answer it. The
// card is built on the SERVER (it knows torch/CUDA/GPU; the browser only
// knows the laptop showing the page) from the pipeline the CLIENT holds --
// v5's pipeline has no server-side copy of its own (see api.js's own note
// on /api/pipeline being stateless), so this sends PS.slots() up rather
// than a project id the way v4's version did.
function openSettingsCard() {
document.querySelectorAll(".sc-overlay").forEach((n) => n.remove());
const overlay = el("div", "modal-overlay sc-overlay");
const modal = el("div", "modal sc-modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Export settings"));
const hr = el("div", "modal-head-right");
const x = el("button", "btn ghost tiny", "✕");
const close = () => { if (url) URL.revokeObjectURL(url); overlay.remove(); };
x.onclick = close;
hr.append(x); head.append(hr);
const content = el("div", "modal-content sc-content");
const foot = el("div", "modal-foot sc-foot");
modal.append(head, content, foot);
overlay.append(modal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
let url = null, blob = null;
content.append(el("div", "pj-meta", "Rendering…"));
const status = el("div", "sc-status");
const dl = el("button", "btn primary tiny", "⤓ Download");
const cp = el("button", "btn ghost tiny", "⧉ Copy image");
const cl = el("button", "btn ghost tiny", "Close");
cl.onclick = close;
dl.disabled = true; cp.disabled = true;
foot.append(status, dl, cp, cl);
// The card is rendered in whatever theme the app is showing, because it
// is a document about this install and it should look like this install.
const theme = window.FunPackTheme?.resolved?.()
|| document.documentElement.getAttribute("data-theme") || "dark";
const project = window.Store?.get().project;
API.settingsCard(PS.slots() || [], project?.name, theme)
.then((b) => {
if (!overlay.isConnected) return;
blob = b; url = URL.createObjectURL(b);
clear(content);
const img = el("img", "sc-img");
img.src = url;
img.alt = "FunPack settings card";
content.append(img);
dl.disabled = false; cp.disabled = false;
})
.catch((e) => {
if (!overlay.isConnected) return;
clear(content);
content.append(el("div", "pj-meta", "Could not render the card: " + (e?.message || e)));
});
dl.onclick = () => {
if (!url) return;
const a = document.createElement("a");
a.href = url;
const base = (project?.name || "funpack-settings").replace(/[^\w.-]+/g, "_");
a.download = `${base}-settings.png`;
document.body.append(a); a.click(); a.remove();
};
cp.onclick = async () => {
// Clipboard image writes need a secure context, which a rental reached
// over plain http://<ip> is not. Say that, rather than failing
// silently -- Download still works.
status.textContent = "";
try {
if (!navigator.clipboard || typeof window.ClipboardItem !== "function") {
throw new Error("this browser/connection has no image clipboard");
}
await navigator.clipboard.write([new window.ClipboardItem({ "image/png": blob })]);
status.textContent = "Copied.";
} catch (e) {
status.textContent = "Could not copy (" + (e?.message || e) + "). Use Download.";
}
};
}
function groupsWithSlots() {
const order = [];
const seen = new Set();
@@ -222,6 +306,13 @@
_body.append(content);
_mounted = { content };
if (ctx && ctx.sub && ctx.sub.startsWith("node:")) openNodeId = ctx.sub.slice(5);
if (ctx && ctx.setActions) {
const card = el("button", "btn ghost tiny", "🖼 Export settings…");
card.title = "Render this pipeline as a PNG — loaders, typed-in node values, "
+ "and the host's torch / CUDA / attention";
card.onclick = openSettingsCard;
ctx.setActions([card]);
}
render();
PS.ensureLoaded().then(() => {
if (openNodeId) {
+36
View File
@@ -11,6 +11,7 @@ import json
from . import (backend_log, config, graph as graph_mod, log, media, nodes_manager, probe as probe_mod,
projects,
prompt_build,
settings_card,
shortcuts as shortcuts_mod,
sysinfo,
temp_files,
@@ -220,6 +221,41 @@ def register(routes, prefix=None):
"module": spec.id, "slots": preset["slots"]})
return web.json_response({"presets": found})
@routes.post(P + "/api/settings-card")
async def _settings_card(req):
"""Render the CALLER's pipeline as a PNG. See core/settings_card.py.
The pipeline is stateless server-side (same reason /api/pipeline is a
GET that always rebuilds the default graph): the client holds the one
live copy of what is actually loaded, including any unsaved edit, so
it is the client that sends `slots` here rather than this route
re-deriving them. Host facts (torch/CUDA/GPU) are the one thing the
SERVER knows and the browser does not, which is why those still come
from sysinfo.collect() rather than the request body.
"""
try:
body = await req.json()
except Exception: # noqa: BLE001
return web.json_response({"problems": ["that is not JSON"]}, status=400)
if not isinstance(body, dict):
return web.json_response(
{"problems": [f"a request is an object, not a {type(body).__name__}"]},
status=400)
slots = body.get("slots")
if slots is None:
slots = _pipeline()
malformed = graph_mod.shape_problems(slots)
if malformed:
return web.json_response({"problems": malformed}, status=400)
host = await asyncio.to_thread(sysinfo.collect)
report = settings_card.collect(slots, host, project_name=body.get("project_name"))
try:
png = await asyncio.to_thread(settings_card.render_png, report, body.get("theme") or "dark")
except Exception as exc: # noqa: BLE001 -- a bad font/Pillow install must not 500 opaquely
return web.json_response({"problems": [f"could not render the card: {exc}"]}, status=500)
return web.Response(body=png, content_type="image/png")
@routes.post(P + "/api/pipeline")
async def _pipeline_edit(req):
"""Replace or remove a slot, and say what that did to the graph.
+289
View File
@@ -0,0 +1,289 @@
"""Export the loaded pipeline as a PNG you can post next to a render.
"Which model was that?" is the most common question asked about a good
generation and the hardest to answer three days later. This renders the
answer as a picture: every slot's node class, its typed-in values, which
inputs are wired instead of typed, and the host facts (torch, CUDA, GPU,
attention backend) that decide whether the setup is reproducible at all on
another box.
Ported from v4's `movie_editor/backend/settings_card.py`, cut down hard: v4's
version spent most of its length (`_OWNED_BY`, `_EDITOR_DEFAULTS`,
`_pass_rows`, `_sampling_sections`, `_node_inputs`) describing FunPackStudio
and the LTX-AV Scene Chain Sampler's own hardcoded knob tables -- neither
node exists in v5's module-driven pipeline, so none of that has anything to
describe here. What is genuinely generic -- walking a slot's real inputs,
detecting a wired one, rendering the PNG -- is kept close to verbatim.
Two decisions worth knowing, both v4's:
* **Only inputs that were TYPED are listed as values.** A wired input's value
is whatever the upstream slot produces, so printing the stale widget still
sitting behind the socket would be a confident lie. Wired inputs are named
as wired instead of being dropped, because "this slot has a VAE input" is
part of the answer.
* **The JSON is embedded in the PNG's tEXt chunk** under `funpack_settings`,
so the picture is also machine-readable. Nothing reads it back yet; it
costs a few hundred bytes and it is the difference between a screenshot
and a record.
Rendering is best-effort about fonts only. Everything else is derived from
the pipeline the caller hands in, so this module never has to guess.
"""
from __future__ import annotations
import io
import json
from datetime import datetime
from .graph import is_link
# Straight from styles.css, so the card matches the app it was exported from
# rather than approximating it. Only the tokens a flat document needs.
THEMES = {
"dark": {
"bg": "#0c0b09", "panel": "#16140f", "text": "#ece7db", "muted": "#918a7a",
"faint": "#645e51", "line": "#2a261e", "accent": "#f3a93c",
},
"light": {
"bg": "#f5f7fa", "panel": "#ffffff", "text": "#16202c", "muted": "#59677a",
"faint": "#8d9aab", "line": "#d3dae3", "accent": "#2f7fd4",
},
}
# Inputs every node carries that say nothing about the setup.
_NOISE_INPUTS = {"control_after_generate"}
def _short(value):
if isinstance(value, bool):
return "on" if value else "off"
if value is None or value == "":
return "—"
return str(value)
def _rows_from_list(value):
"""A funpack_list widget's JSON array as [(label, value)], or None if it isn't one.
Detected by content rather than by asking object_info: the value has to
be parsed to be printed anyway, and a card that needs a live ComfyUI to
render is a card you cannot export from a broken install.
"""
if not isinstance(value, str) or not value.strip().startswith("["):
return None
try:
rows = json.loads(value)
except Exception:
return None
if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
return None
out = []
for i, row in enumerate(rows, 1):
parts = [f"{k}={_short(v)}" for k, v in row.items()]
out.append((f"[{i}]", " ".join(parts) if parts else "—"))
return out # [] for an empty list; the caller collapses that onto one line
def _host_rows(host: dict) -> list:
"""The facts that decide whether this setup reproduces elsewhere."""
host = host or {}
torch = host.get("torch") or {}
gpus = host.get("gpus") or []
rows = [
("PyTorch", _short(torch.get("version"))),
("CUDA", _short(torch.get("cuda"))),
("Attention", _short(torch.get("attention"))),
("Python", _short(host.get("python"))),
("ComfyUI", _short(host.get("comfyui"))),
]
for gpu in gpus:
label = _short(gpu.get("name"))
extra = [x for x in (gpu.get("capability"),
f"{gpu['vram_gb']} GB" if gpu.get("vram_gb") else None) if x]
rows.append(("GPU", f"{label} ({', '.join(extra)})" if extra else label))
if not gpus:
rows.append(("GPU", "none visible (CPU / MPS)" if not host.get("mps") else "Apple MPS"))
return rows
def collect(slots: list, host: dict, *, project_name=None, render=None) -> dict:
"""The card's content, as data. Rendering is a separate step so this is testable.
`slots` is the live pipeline exactly as `GET /api/pipeline`/`PS.slots()`
hand it around: `{id, node, group, inputs, roles?}` per slot, a wired
input arriving as `[source_slot_id, output_index]` (`core.graph.is_link`)
rather than a scalar. No FunPackStudio/Chain-Sampler shape to adapt --
v5's pipeline has no such nodes, so every slot is walked the same way.
"""
by_id = {s.get("id"): s for s in (slots or []) if isinstance(s, dict)}
sections = []
for slot in slots or []:
if not isinstance(slot, dict):
continue
group = slot.get("group")
node = slot.get("node") or ""
title = f"{group} · {slot.get('id')}" if group else str(slot.get("id") or node or "node")
rows = []
for name, value in (slot.get("inputs") or {}).items():
if name in _NOISE_INPUTS:
continue
if is_link(value):
source = by_id.get(value[0])
source_label = (source or {}).get("id") or value[0]
rows.append((name, f"‹wired from {source_label}›"))
continue
listed = _rows_from_list(value)
if listed is not None:
if not listed:
rows.append((name, "(none)"))
continue
rows.append((name, ""))
rows.extend((f" {k}", v) for k, v in listed)
continue
rows.append((name, _short(value)))
sections.append({"title": title, "node_class": node, "rows": rows})
head = []
if render:
head.append({"title": "Render", "node_class": "",
"rows": [(k, _short(v)) for k, v in render.items()]})
return {
"project": project_name or None,
"host": _host_rows(host),
"sections": head + sections,
"generated": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
# --- rendering ------------------------------------------------------------------------
_MONO_CANDIDATES = ("DejaVuSansMono.ttf", "Menlo.ttc", "consola.ttf",
"LiberationMono-Regular.ttf", "CourierNew.ttf")
_SANS_CANDIDATES = ("DejaVuSans.ttf", "HelveticaNeue.ttc", "arial.ttf",
"LiberationSans-Regular.ttf")
def _font(size, mono=False):
"""A real font at `size`, whatever this machine happens to have.
Pillow's bundled default is proportional; filenames read far better in
mono, so try the usual system faces first and fall back rather than
making the font a hard dependency.
"""
from PIL import ImageFont
for name in (_MONO_CANDIDATES if mono else _SANS_CANDIDATES):
try:
return ImageFont.truetype(name, size)
except Exception:
continue
try:
return ImageFont.load_default(size=size)
except Exception:
return ImageFont.load_default()
def _wrap(text, width_chars):
"""Hard-wrap on width. Model filenames have no spaces, so word wrapping
alone leaves them running off the card."""
text = str(text)
if len(text) <= width_chars:
return [text]
out, line = [], ""
for word in text.split(" "):
while len(word) > width_chars:
if line:
out.append(line); line = ""
out.append(word[:width_chars]); word = word[width_chars:]
if not line:
line = word
elif len(line) + 1 + len(word) <= width_chars:
line += " " + word
else:
out.append(line); line = word
if line:
out.append(line)
return out
WIDTH = 1080
PAD = 40
LABEL_W = 260
LINE_H = 24
VALUE_CHARS = 62 # fallback when the font cannot be measured
def render_png(report: dict, theme: str = "dark") -> bytes:
"""The card as PNG bytes, with the report embedded as a tEXt chunk."""
from PIL import Image, ImageDraw
from PIL.PngImagePlugin import PngInfo
pal = THEMES.get(str(theme).lower(), THEMES["dark"])
f_title = _font(30)
f_sub = _font(15)
f_head = _font(16)
f_val = _font(14, mono=True)
f_key = f_val
f_foot = _font(13)
try:
char_w = ImageDraw.Draw(Image.new("RGB", (1, 1))).textlength("M" * 20, font=f_val) / 20.0
wrap_at = max(20, int((WIDTH - PAD - (PAD + LABEL_W)) / char_w)) if char_w else VALUE_CHARS
except Exception:
wrap_at = VALUE_CHARS
blocks = [("title", report.get("project") or "FunPack pipeline")]
sub = report.get("generated")
if sub:
blocks.append(("sub", sub))
blocks.append(("head", "Host"))
for k, v in report.get("host") or []:
for i, piece in enumerate(_wrap(v, wrap_at)):
blocks.append(("row", (k if i == 0 else "", piece)))
for section in report.get("sections") or []:
cls = section.get("node_class")
blocks.append(("head", f"{section['title']} ({cls})" if cls else section["title"]))
for k, v in section.get("rows") or []:
if v == "":
blocks.append(("row", (k, "")))
continue
for i, piece in enumerate(_wrap(v, wrap_at)):
blocks.append(("row", (k if i == 0 else "", piece)))
height = PAD
for kind, _ in blocks:
height += {"title": 44, "sub": 30, "head": 34, "row": LINE_H}[kind]
height += 56 # footer rule + watermark
img = Image.new("RGB", (WIDTH, height), pal["bg"])
d = ImageDraw.Draw(img)
y = PAD
for kind, payload in blocks:
if kind == "title":
d.text((PAD, y), str(payload), font=f_title, fill=pal["text"]); y += 44
elif kind == "sub":
d.text((PAD, y), str(payload), font=f_sub, fill=pal["muted"]); y += 30
elif kind == "head":
y += 10
d.text((PAD, y), str(payload), font=f_head, fill=pal["accent"])
y += 24
else:
key, val = payload
if key:
d.text((PAD, y), str(key), font=f_key, fill=pal["muted"])
d.text((PAD + LABEL_W, y), str(val), font=f_val, fill=pal["text"])
y += LINE_H
y += 16
d.line([(PAD, y), (WIDTH - PAD, y)], fill=pal["line"], width=1)
y += 12
d.text((PAD, y), "FunPack", font=f_foot, fill=pal["faint"])
meta = PngInfo()
try:
meta.add_text("funpack_settings", json.dumps(report, ensure_ascii=False))
except Exception:
pass
buf = io.BytesIO()
img.save(buf, format="PNG", pnginfo=meta)
return buf.getvalue()
+112
View File
@@ -0,0 +1,112 @@
"""The settings card, over HTTP.
core/settings_card.py's own tests prove the rendering is correct in a vacuum;
these prove the route actually reaches it with a real request body and comes
back with real PNG bytes and the right content type -- reachability, the
same reason test_routes_pipeline.py exists.
"""
import socket
import threading
import pytest
pytest.importorskip("aiohttp")
from core import routes # noqa: E402
@pytest.fixture(scope="module")
def server(comfyui):
"""The real route table on a throwaway app, on a real socket.
No node registration needed here (unlike test_routes_pipeline.py) --
this route never asks ComfyUI's schema registry anything; it only walks
the slot list handed to it and renders host facts, both node-schema-free.
"""
from aiohttp import web as aioweb
app = aioweb.Application()
table = aioweb.RouteTableDef()
routes.register(table, prefix="/funpack")
app.add_routes(table)
holder = {}
def run():
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
runner = aioweb.AppRunner(app)
loop.run_until_complete(runner.setup())
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
loop.run_until_complete(site.start())
holder["port"] = site._server.sockets[0].getsockname()[1]
holder["loop"] = loop
holder["ready"].set()
loop.run_forever()
holder["ready"] = threading.Event()
thread = threading.Thread(target=run, daemon=True)
thread.start()
assert holder["ready"].wait(20), "the test server did not start"
yield holder["port"]
holder["loop"].call_soon_threadsafe(holder["loop"].stop)
def _raw_request(port, method, path, body_bytes):
head = (f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(body_bytes)}\r\nConnection: close\r\n\r\n").encode()
with socket.create_connection(("127.0.0.1", port), timeout=30) as sock:
sock.sendall(head + body_bytes)
chunks = []
while True:
got = sock.recv(65536)
if not got:
break
chunks.append(got)
raw = b"".join(chunks)
header, _, body = raw.partition(b"\r\n\r\n")
lines = header.split(b"\r\n")
status = int(lines[0].split()[1])
headers = {}
for line in lines[1:]:
if b":" in line:
k, _, v = line.partition(b":")
headers[k.strip().lower().decode()] = v.strip().decode()
return status, headers, body
def test_a_png_comes_back_for_a_real_pipeline(server):
import json
body = json.dumps({
"slots": [{"id": "model", "node": "FunPackDiffusionModelLoader",
"inputs": {"model_name": "h3.safetensors"}}],
"project_name": "Integration test", "theme": "dark",
}).encode()
status, headers, png = _raw_request(server, "POST", "/funpack/api/settings-card", body)
assert status == 200
assert headers.get("content-type") == "image/png"
assert png[:8] == b"\x89PNG\r\n\x1a\n"
def test_no_slots_falls_back_to_the_default_pipeline(server):
"""Same convention as /api/pipeline: an ABSENT slots key means 'use the
live default', an explicitly empty list means 'this pipeline has none'."""
status, headers, png = _raw_request(server, "POST", "/funpack/api/settings-card", b"{}")
assert status == 200
assert png[:8] == b"\x89PNG\r\n\x1a\n"
def test_a_malformed_pipeline_is_refused_not_rendered(server):
import json
body = json.dumps({"slots": [{"id": "dup"}, {"id": "dup"}]}).encode()
status, headers, raw = _raw_request(server, "POST", "/funpack/api/settings-card", body)
assert status == 400
assert b"problems" in raw
def test_a_non_object_body_is_a_400_not_a_500(server):
status, headers, raw = _raw_request(server, "POST", "/funpack/api/settings-card", b"[]")
assert status == 400
+152
View File
@@ -0,0 +1,152 @@
"""The settings card: what goes on it, and what deliberately does not.
The card exists to answer "which model was that?" months later, so the tests
that matter are about honesty -- a wired input must not be printed as if
someone typed it, and a value that is a list (a LoRA stack, say) must come
out as rows rather than as a wall of JSON.
Ported from v4's movie_editor/tests/test_settings_card.py, cut down the same
way core/settings_card.py itself was: no FunPackStudio/Chain-Sampler shape to
test here, since v5's pipeline has no such nodes -- every slot is walked the
same generic way, so there is one section kind, not several.
"""
import json
from core import settings_card as sc
HOST = {
"python": "3.12.4",
"comfyui": "0.30.1",
"torch": {"version": "2.12.0+cu128", "cuda": "12.8", "attention": "SageAttention 3.0"},
"gpus": [{"name": "RTX PRO 6000", "vram_gb": 95.0, "capability": "sm_120"}],
}
def _rows(report, title):
return dict(next(s for s in report["sections"] if s["title"] == title)["rows"])
# --- what is on the card --------------------------------------------------------------
def test_full_filenames_are_not_abbreviated():
"""A truncated checkpoint name is the one thing that makes the card useless."""
name = "MiniMax-H3-fl2va-bf16-Q6_K-a-really-long-name.gguf"
rep = sc.collect(
[{"id": "model", "node": "FunPackDiffusionModelLoader",
"inputs": {"model_name": name, "attention": "sage3"}}], HOST)
assert _rows(rep, "model")["model_name"] == name
def test_host_carries_torch_cuda_and_attention():
rep = sc.collect([], HOST)
host = dict(rep["host"])
assert host["PyTorch"] == "2.12.0+cu128"
assert host["CUDA"] == "12.8"
assert host["Attention"] == "SageAttention 3.0"
assert "sm_120" in host["GPU"] and "95.0 GB" in host["GPU"]
def test_a_cpu_only_box_says_so_rather_than_showing_nothing():
"""An absent GPU row reads as 'the probe failed'. Naming the absence does not."""
rep = sc.collect([], {"torch": {}, "gpus": []})
assert "none visible" in dict(rep["host"])["GPU"]
def test_lists_come_out_as_rows_and_settings():
"""A funpack_list widget's value is one JSON string. Printing it raw would
technically be complete and practically unreadable."""
rows = json.dumps([{"lora": "h3_turbo.safetensors", "strength": 1.0, "on": True},
{"lora": "style.safetensors", "strength": 0.65, "on": False}])
rep = sc.collect(
[{"id": "l", "node": "FunPackLoraLoader", "inputs": {"lora_list": rows}}], HOST)
text = " ".join(v for _, v in rep["sections"][0]["rows"])
assert "h3_turbo.safetensors" in text and "strength=1.0" in text
assert "style.safetensors" in text and "strength=0.65" in text
assert "on=off" in text # a disabled row is still on the card, marked disabled
def test_an_empty_list_says_none_instead_of_vanishing():
rep = sc.collect([{"id": "l", "node": "X", "inputs": {"lora_list": "[]"}}], HOST)
assert any("(none)" in v for _, v in rep["sections"][0]["rows"])
def test_a_string_that_merely_looks_like_json_is_left_alone():
"""A prompt beginning with '[' must not be mangled into fake list rows."""
rep = sc.collect([{"id": "n", "node": "X", "inputs": {"text": "[not json at all"}}], HOST)
assert _rows(rep, "n")["text"] == "[not json at all"
def test_custom_nodes_appear_with_their_class():
"""The class is what someone reproducing this has to go and install."""
rep = sc.collect([{"id": "n1", "node": "ImageUpscaleWithModel",
"inputs": {"factor": 2.0}}], HOST)
assert rep["sections"][0]["node_class"] == "ImageUpscaleWithModel"
assert _rows(rep, "n1")["factor"] == "2.0"
def test_a_group_prefixes_the_section_title():
rep = sc.collect([{"id": "clip", "node": "X", "group": "Loaders", "inputs": {}}], HOST)
assert rep["sections"][0]["title"] == "Loaders · clip"
# --- what must NOT be presented as a typed value --------------------------------------
def test_a_wired_input_is_marked_wired_not_its_stale_widget_value():
"""v5 encodes a wire directly in the input's own value -- [source_id, index] -- rather
than a separate input_sources/wires lookup. Printing it as a typed value would be a
confident lie: the widget behind a connected socket keeps its last typed value, and
generation ignores it."""
rep = sc.collect(
[{"id": "up", "node": "X", "inputs": {}},
{"id": "down", "node": "Y", "inputs": {"model": ["up", 0], "steps": 8}}], HOST)
rows = _rows(rep, "down")
assert "wired from up" in rows["model"]
assert rows["steps"] == "8"
def test_control_after_generate_is_dropped():
"""It is a UI affordance, not a setting -- it says nothing about the render."""
rep = sc.collect(
[{"id": "a", "node": "X", "inputs": {"seed": 42, "control_after_generate": "randomize"}}],
HOST)
assert "control_after_generate" not in _rows(rep, "a")
def test_booleans_read_as_on_off_not_python():
rep = sc.collect(
[{"id": "a", "node": "X", "inputs": {"sla": True, "fp16_accumulation": False}}], HOST)
rows = _rows(rep, "a")
assert rows["sla"] == "on" and rows["fp16_accumulation"] == "off"
# --- rendering --------------------------------------------------------------------------
def test_render_png_produces_a_real_png_with_the_report_embedded():
rep = sc.collect(
[{"id": "model", "node": "FunPackDiffusionModelLoader",
"inputs": {"model_name": "h3.safetensors"}}], HOST, project_name="My project")
png = sc.render_png(rep, "dark")
assert png[:8] == b"\x89PNG\r\n\x1a\n"
from PIL import Image
img = Image.open(__import__("io").BytesIO(png))
assert img.format == "PNG"
embedded = json.loads(img.text["funpack_settings"])
assert embedded["project"] == "My project"
assert embedded["sections"][0]["rows"] == [("model_name", "h3.safetensors")] or \
embedded["sections"][0]["rows"] == [["model_name", "h3.safetensors"]]
def test_light_theme_is_a_real_choice_not_a_silent_fallback():
rep = sc.collect([], HOST)
dark = sc.render_png(rep, "dark")
light = sc.render_png(rep, "light")
assert dark != light
def test_an_unknown_theme_falls_back_to_dark_rather_than_crashing():
rep = sc.collect([], HOST)
sc.render_png(rep, "not-a-real-theme") # must not raise