feat(editor): export the pipeline as a settings PNG

Loaders, LoRAs, typed-in custom-node values and host torch/CUDA/GPU.
Wired inputs are marked, not printed. Themed, watermarked, and carries
the report as JSON in a tEXt chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-23 00:16:18 +03:00
co-authored by Claude Opus 5
parent a0fdc24142
commit efae0c5807
7 changed files with 644 additions and 3 deletions
+7
View File
@@ -3,6 +3,13 @@
## [Unreleased]
### Added
- **Models & Pipeline ▸ Export settings…** renders the loaded pipeline as a PNG: every
loader with its full filename, every LoRA with its weight, the typed-in values of any
custom node, and the host's PyTorch / CUDA / attention / GPU. Only inputs that were typed
are printed — a wired input is marked `‹wired›` rather than showing the stale widget
behind the socket. The card is drawn in the theme the app is showing, watermarked with the
FunPack version, and carries the same data as JSON in a tEXt chunk. The dialog previews it
with Download and Copy image.
- **Quality sharpness now works with a stock KSampler.** The unsharp mask that recovers fine
detail lived inside Hybrid Euler 2S and Distilled Flow only. It reads the current x0
prediction, the previous one and the step's sigma — nothing that needs a sampler's loop —
+299
View File
@@ -0,0 +1,299 @@
"""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 loader with
its full filename, every LoRA with its weight, the typed-in values of any custom node, and
the host facts (torch, CUDA, attention) that decide whether the setup is reproducible at all
on another box.
Two decisions worth knowing:
* **Only inputs that were TYPED are listed.** A wired input's value is whatever the upstream
node produced, 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
node 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 project's own
models config, so this module never has to guess.
"""
from __future__ import annotations
import io
import json
from datetime import datetime
# 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 _is_wired(slot: dict, name: str, slots: list) -> bool:
"""Whether `name` is fed by something rather than typed.
Two ways an input can be fed: the slot names its own source (`input_sources`), or another
slot wires an output at it. "auto" is not a source — it is the absence of a choice.
"""
src = (slot.get("input_sources") or {}).get(name)
if src and src != "auto":
return True
target = f"node:{slot.get('id')}:{name}"
for other in slots or []:
if other is slot or other.get("id") == slot.get("id"):
continue
for raw in (other.get("wires") or {}).values():
targets = raw if isinstance(raw, list) else ([raw] if raw else [])
if target in targets:
return True
return False
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):
# The first string field is the identity of the row (the LoRA file, the encoder);
# everything else is its settings, printed after it in declaration order.
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 _short(value):
if isinstance(value, bool):
return "on" if value else "off"
if value is None or value == "":
return "—"
return str(value)
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)"))
return rows
def collect(models: dict, host: dict, *, project_name=None, version=None,
codename=None) -> dict:
"""The card's content, as data. Rendering is a separate step so this is testable."""
slots = (models or {}).get("slots") or []
sections = []
for slot in slots:
title = (slot.get("label") or slot.get("role_label")
or slot.get("node_class") or slot.get("id") or "node")
rows = []
for name, value in (slot.get("inputs") or {}).items():
if name in _NOISE_INPUTS:
continue
if _is_wired(slot, name, slots):
rows.append((name, "‹wired›"))
continue
listed = _rows_from_list(value)
if listed is not None:
# An empty list is one fact, not a heading with nothing under it.
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": str(title),
"node_class": str(slot.get("node_class") or ""),
"rows": rows,
})
return {
"project": project_name or None,
"family": (models or {}).get("model_family") or None,
"host": _host_rows(host),
"sections": sections,
"version": version or "",
"codename": codename or "",
"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_key = _font(14, mono=True)
f_val = _font(14, mono=True)
f_foot = _font(13)
# Wrap on what the value column actually holds, not on a guess: the fonts differ per
# machine, and a fixed character count either wraps a filename that fitted or lets one
# run off the edge.
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
# Lay the document out first so the canvas is exactly as tall as the content — a card
# with a field of empty space below it looks like something failed to render.
blocks = [("title", report.get("project") or "Models & Pipeline")]
sub = " · ".join(x for x in (report.get("family"), report.get("generated")) if x)
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
mark = "FunPack"
if report.get("version"):
mark += f" {report['version']}"
if report.get("codename"):
mark += f" · {report['codename']}"
d.text((PAD, y), mark, 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()
+10
View File
@@ -134,6 +134,16 @@
// default route (used as the seed/template) when no project is given.
coreGraph: (pid) => j("GET", API("/core-graph" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))),
getModels: (pid) => j("GET", API(pid ? `/projects/${pid}/models` : "/models")),
// 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.
settingsCard: async (pid, theme) => {
const q = new URLSearchParams();
if (pid) q.set("pid", pid);
if (theme) q.set("theme", theme);
const r = await fetch(API("/settings-card") + (q.toString() ? `?${q}` : ""));
if (!r.ok) throw new Error(await r.text().catch(() => r.statusText));
return r.blob();
},
saveModels: (pid, data) => j("PUT", API(pid ? `/projects/${pid}/models` : "/models"), data),
refreshModels: () => j("POST", API("/models/refresh")),
parseWorkflow: (workflow) => j("POST", API("/workflow/parse"), { workflow }),
+86 -2
View File
@@ -1318,6 +1318,86 @@
document.addEventListener("mousedown", away, true);
}
// ── export settings 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) and comes back as one PNG that is shown, saved or copied from here.
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(project?.id, 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.";
}
};
}
// Setup-node modal: pick the node by search, then set widget values AND wire its
// outputs / input sources before it lands in the pipeline. On Add the new node's
// page opens (multi-wire, expose, bypass live there).
@@ -2654,7 +2734,11 @@
imp.onclick = () => window.WorkflowImportWizard?.open();
const refresh = el("button", "btn ghost tiny", "↻ Refresh model list");
refresh.onclick = refreshList;
ctx.setActions([imp, refresh]);
const card = el("button", "btn ghost tiny", "🖼 Export settings…");
card.title = "Render this pipeline as a PNG — loaders, LoRAs, typed-in node values, "
+ "and the host's torch / CUDA / attention";
card.onclick = openSettingsCard;
ctx.setActions([imp, refresh, card]);
loadAll()
.then(() => {
if (!container || !container.isConnected) return;
@@ -2689,7 +2773,7 @@
endEdit();
_directNodeId = null;
_openNodeOnMount = null;
document.querySelectorAll(".mn-role-pop, .ns-overlay").forEach((n) => n.remove());
document.querySelectorAll(".mn-role-pop, .ns-overlay, .sc-overlay").forEach((n) => n.remove());
};
}
+9
View File
@@ -2718,3 +2718,12 @@ body.tour-mode { overflow: hidden; }
background: color-mix(in srgb, var(--accent) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line));
}
/* Export-settings card modal. The image is the content, so the box is sized to it and the
picture scrolls rather than being squeezed — a card you cannot read is not a preview. */
.sc-modal { width: min(1140px, 94vw); max-height: 88vh; display: flex; flex-direction: column; }
.sc-content { flex: 1; min-height: 0; overflow: auto; background: var(--ink-0); }
.sc-img { display: block; width: 100%; height: auto; border: 1px solid var(--line); border-radius: 6px; }
.sc-foot { display: flex; align-items: center; gap: 8px; padding: 12px 16px 14px;
border-top: 1px solid var(--line); background: var(--surface-deep-2); }
.sc-status { flex: 1; font-size: 11px; color: var(--muted); }
+26 -1
View File
@@ -20,7 +20,7 @@ except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from .backend import bridge, builder, config, custom_nodes, git_update, media, model_probe, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, sysinfo, workflow_import
from .backend import bridge, builder, config, custom_nodes, git_update, media, model_probe, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, settings_card, sysinfo, workflow_import
from .backend.nle_effects import geometry_filters, reverse_refusal, zoompan_z_expr
from .backend.nle_overlays import build_overlay_video_filter, prepare_overlay_export
from .backend.timeline import (
@@ -2492,6 +2492,31 @@ if web is not None and PromptServer is not None:
# machines and the host is the one worth looking at.
return web.json_response(sysinfo.collect())
@routes.get(UI_PREFIX + "/api/settings-card")
async def _settings_card(req):
import asyncio
# to_thread for the same reason every other slow route uses it: sysinfo shells out
# to the OS (and can wait on nvidia), and one blocking second here stalls every
# preview stream on the page.
pid = req.query.get("pid")
theme = req.query.get("theme") or "dark"
p = projects.get(pid) if pid else None
models_cfg = _project_models(p) if pid else nodes.load_models()
name = getattr(p, "name", None) if p else None
def _build():
st = git_update.status()
report = settings_card.collect(
models_cfg, sysinfo.collect(), project_name=name,
version=st.get("version"), codename=st.get("codename"))
return settings_card.render_png(report, theme)
try:
png = await asyncio.to_thread(_build)
except Exception as e:
raise web.HTTPInternalServerError(reason=f"Could not render the card: {e}")
return web.Response(body=png, content_type="image/png")
@routes.get(UI_PREFIX + "/api/git/status")
async def _git_status(_req):
return web.json_response(git_update.status())
+207
View File
@@ -0,0 +1,207 @@
"""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 of LoRAs must come out as LoRAs rather than as a wall of JSON.
"""
import json
import pytest
from movie_editor.backend 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 _models(*slots):
return {"model_family": "minimax_h3", "slots": list(slots)}
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(_models({"id": "a", "role_label": "Diffusion model",
"node_class": "FunPackDiffusionModelLoader",
"inputs": {"model_name": name, "attention": "sage3"}}), HOST)
assert _rows(rep, "Diffusion model")["model_name"] == name
def test_host_carries_torch_cuda_and_attention():
rep = sc.collect(_models(), 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(_models(), {"torch": {}, "gpus": []})
assert "none visible" in dict(rep["host"])["GPU"]
def test_loras_come_out_as_files_and_weights():
"""The LoRA list is one JSON string widget. Printing it raw would technically be
complete and practically unreadable."""
rows = json.dumps([{"lora": "h3_turbo.safetensors", "type": "model", "strength": 1.0,
"on": True},
{"lora": "style.safetensors", "type": "both", "strength": 0.65,
"on": False}])
rep = sc.collect(_models({"id": "l", "role_label": "LoRA", "node_class": "FunPackLoraLoader",
"inputs": {"lora_list": rows}}), HOST)
text = " ".join(v for _, v in next(s for s in rep["sections"])["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(_models({"id": "l", "role_label": "LoRA", "node_class": "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(_models({"id": "n", "role_label": "Note", "node_class": "X",
"inputs": {"text": "[not json at all"}}), HOST)
assert _rows(rep, "Note")["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(_models({"id": "n1", "label": "Upscale",
"node_class": "ImageUpscaleWithModel",
"inputs": {"factor": 2.0}}), HOST)
assert rep["sections"][0]["node_class"] == "ImageUpscaleWithModel"
assert _rows(rep, "Upscale")["factor"] == "2.0"
# --- what must NOT be presented as a typed value --------------------------------------
def test_an_input_with_a_named_source_is_marked_wired():
"""The widget behind a connected socket keeps its last typed value, and generation
ignores it. Printing it as the setting would be a confident lie."""
rep = sc.collect(_models({"id": "a", "role_label": "Sampler", "node_class": "X",
"inputs": {"model": "stale.safetensors", "steps": 8},
"input_sources": {"model": "out:fp_unet:MODEL"}}), HOST)
rows = _rows(rep, "Sampler")
assert rows["model"] == "‹wired›"
assert rows["steps"] == "8"
def test_an_input_fed_by_another_slots_wire_is_marked_wired():
"""The other direction: the producer names the target, and the consumer says nothing."""
rep = sc.collect(_models(
{"id": "up", "role_label": "Loader", "node_class": "X", "inputs": {},
"wires": {"MODEL": ["node:down:model"]}},
{"id": "down", "role_label": "Sampler", "node_class": "Y",
"inputs": {"model": "stale.safetensors"}}), HOST)
assert _rows(rep, "Sampler")["model"] == "‹wired›"
def test_auto_is_not_a_source():
"""'auto' means no choice was made, so the widget IS the value."""
rep = sc.collect(_models({"id": "a", "role_label": "Node", "node_class": "X",
"inputs": {"vae": "ae.safetensors"},
"input_sources": {"vae": "auto"}}), HOST)
assert _rows(rep, "Node")["vae"] == "ae.safetensors"
def test_control_after_generate_is_dropped():
"""It is a UI affordance, not a setting — it says nothing about the render."""
rep = sc.collect(_models({"id": "a", "role_label": "Node", "node_class": "X",
"inputs": {"seed": 42, "control_after_generate": "randomize"}}),
HOST)
assert "control_after_generate" not in _rows(rep, "Node")
def test_booleans_read_as_on_off_not_python():
rep = sc.collect(_models({"id": "a", "role_label": "Node", "node_class": "X",
"inputs": {"sla": True, "fp16_accumulation": False}}), HOST)
rows = _rows(rep, "Node")
assert rows["sla"] == "on" and rows["fp16_accumulation"] == "off"
# --- rendering ------------------------------------------------------------------------
@pytest.fixture
def report():
return sc.collect(
_models({"id": "a", "role_label": "Diffusion model",
"node_class": "FunPackDiffusionModelLoader",
"inputs": {"model_name": "x.safetensors", "attention": "sage3"}}),
HOST, project_name="Night Drive", version="4.0.0", codename="Blinding Blackout")
@pytest.mark.parametrize("theme", ["dark", "light"])
def test_renders_a_png_in_each_theme(report, theme):
png = sc.render_png(report, theme)
assert png[:8] == b"\x89PNG\r\n\x1a\n"
def test_the_two_themes_are_actually_different(report):
"""A theme parameter that renders the same picture is a parameter that does nothing."""
assert sc.render_png(report, "dark") != sc.render_png(report, "light")
def test_an_unknown_theme_falls_back_rather_than_failing(report):
assert sc.render_png(report, "chartreuse")[:8] == b"\x89PNG\r\n\x1a\n"
def test_the_card_grows_with_its_content(report):
"""Height is laid out from the content, so a long pipeline must not be cropped and a
short one must not sit above a field of empty space."""
from PIL import Image
import io
big = sc.collect(_models(*[
{"id": f"n{i}", "role_label": f"Node {i}", "node_class": "X", "inputs": {"v": i}}
for i in range(20)]), HOST)
short = Image.open(io.BytesIO(sc.render_png(report, "dark"))).height
tall = Image.open(io.BytesIO(sc.render_png(big, "dark"))).height
assert tall > short
def test_the_report_is_embedded_for_machines(report):
"""The picture is also the record: the JSON rides in a tEXt chunk."""
from PIL import Image
import io
img = Image.open(io.BytesIO(sc.render_png(report, "dark")))
payload = json.loads(img.text["funpack_settings"])
assert payload["sections"][0]["node_class"] == "FunPackDiffusionModelLoader"
def test_the_watermark_names_the_version(report):
"""It is what tells you whether the card predates a behaviour change."""
from PIL import Image
import io
# Rendered text is not readable back out of pixels; the embedded record is the
# checkable half, and the footer is drawn from these same two fields.
img = Image.open(io.BytesIO(sc.render_png(report, "dark")))
payload = json.loads(img.text["funpack_settings"])
assert payload["version"] == "4.0.0" and payload["codename"] == "Blinding Blackout"
def test_long_values_wrap_instead_of_running_off_the_card():
lines = sc._wrap("a" * 200, 40)
assert all(len(x) <= 40 for x in lines)
assert "".join(lines) == "a" * 200