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>
113 lines
3.9 KiB
Python
113 lines
3.9 KiB
Python
"""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
|