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:
co-authored by
Claude Sonnet 5
parent
06621bb2e3
commit
6d496251f9
+18
-1
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user