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:
co-authored by
Claude Opus 5
parent
a0fdc24142
commit
efae0c5807
@@ -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 —
|
||||
|
||||
@@ -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()
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user