From 6d496251f9983af598e02099b3bc47b0f50d094c Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:29:22 +0300 Subject: [PATCH] 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 --- app/legacy/api.js | 19 +- app/legacy/models.js | 91 ++++++++ core/routes.py | 36 +++ core/settings_card.py | 289 ++++++++++++++++++++++++ core/tests/test_routes_settings_card.py | 112 +++++++++ core/tests/test_settings_card.py | 152 +++++++++++++ 6 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 core/settings_card.py create mode 100644 core/tests/test_routes_settings_card.py create mode 100644 core/tests/test_settings_card.py diff --git a/app/legacy/api.js b/app/legacy/api.js index 456c94ed..603551e6 100644 --- a/app/legacy/api.js +++ b/app/legacy/api.js @@ -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"), diff --git a/app/legacy/models.js b/app/legacy/models.js index f6505176..c00401f5 100644 --- a/app/legacy/models.js +++ b/app/legacy/models.js @@ -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:// 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) { diff --git a/core/routes.py b/core/routes.py index 88152b41..b5e08f2f 100644 --- a/core/routes.py +++ b/core/routes.py @@ -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. diff --git a/core/settings_card.py b/core/settings_card.py new file mode 100644 index 00000000..1a1eb0c1 --- /dev/null +++ b/core/settings_card.py @@ -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() diff --git a/core/tests/test_routes_settings_card.py b/core/tests/test_routes_settings_card.py new file mode 100644 index 00000000..c398ecc5 --- /dev/null +++ b/core/tests/test_routes_settings_card.py @@ -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 diff --git a/core/tests/test_settings_card.py b/core/tests/test_settings_card.py new file mode 100644 index 00000000..43b001b0 --- /dev/null +++ b/core/tests/test_settings_card.py @@ -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