diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fb4fde..51af9f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 — diff --git a/movie_editor/backend/settings_card.py b/movie_editor/backend/settings_card.py new file mode 100644 index 00000000..052b4fdc --- /dev/null +++ b/movie_editor/backend/settings_card.py @@ -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() diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index a6a87789..7f07f91c 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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 }), diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index f07e8ead..09619b40 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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:// 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()); }; } diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 2d90d2ed..2c183584 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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); } diff --git a/movie_editor/server.py b/movie_editor/server.py index 8a783cee..6c8e096d 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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()) diff --git a/movie_editor/tests/test_settings_card.py b/movie_editor/tests/test_settings_card.py new file mode 100644 index 00000000..5cde4a19 --- /dev/null +++ b/movie_editor/tests/test_settings_card.py @@ -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