Files
olivv-cs-ComfyUI-FunPack/core/routes.py
T
digital-garbageandClaude Opus 5 9dcd94c156 app, core: a project of scenes, and the timeline that draws it
The app could run a pipeline and had nowhere to keep what it was making. A
project is an ordered list of scenes, one JSON file each, and it is the only
part of the app that has to survive a reload.

A scene owns its prompt text and the result it produced, and nothing else. v4's
Scene grew three dozen fields; each belongs to the feature that needs it. Per
scene length and fps are deliberately absent — a regenerate uses the project's
values, so a scene cropped on the timeline comes back whole.

The prompt box edits the current scene rather than holding its own copy, so
what is on screen and what a run uses cannot disagree.

app: the region toggles are named buttons in the timeline bar

v4's arrangement. The kit's fallback is one glyph on a rail at each edge of the
window, which is right for a kit that cannot know what its panels are called and
wrong here, where they have names and a bar to put them in.

A pressed toggle was styled only inside a button group, so a standalone one
carried aria-pressed and looked identical either way.

gallery.strip is selectable like its siblings: a filmstrip is the one shape
whose whole job is saying which of these you are on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:03:19 +03:00

369 lines
16 KiB
Python

"""Route registration on ComfyUI's aiohttp server.
`register()` takes the route table rather than reaching for PromptServer itself,
so the real routes can be mounted on a throwaway app in a test. The handlers are
thin adapters over pure functions in `serve`.
"""
import json
from . import (config, graph as graph_mod, log, projects,
registry as registry_mod, serve as static, widgets)
from .contract import CONTRACT_VERSION
from .relations import order
from .traits import split
# Two independent imports, two independent guards: aiohttp is installable on its
# own, and folding them together nulled `web` whenever ComfyUI's `server` was
# absent -- which is every test run.
try:
from aiohttp import web
except Exception:
web = None
try:
from server import PromptServer
except Exception: # not running inside ComfyUI
PromptServer = None
def _respond(served):
if served.status != 200:
return web.Response(status=served.status, headers=served.headers)
return web.Response(
body=served.body,
content_type=served.content_type,
headers=served.headers,
)
def _serve_under(req, root, allowed):
# match_info keeps dot-segments intact for a request as it arrives on the
# wire (aiohttp does not collapse them during matching), so a traversal
# reaches resolve() and is refused there. Note that aiohttp's own TestClient
# normalises the URL before sending -- a test written with it cannot see
# this path at all, which is why test_routes.py uses a raw socket.
return _respond(static.serve(root, req.match_info["tail"], allowed))
def modules(rescan=False):
"""The one shared scan. Held in `registry` so the nodes and the app cannot
end up describing different sets of modules in the same session."""
return registry_mod.current(rescan)
def manifest(traits=None):
"""What the browser is told: only modules that loaded and validated.
A module that failed is absent from `modules` entirely -- the UI renders
what announced itself, so absence here is what makes it absent on screen.
`failed` is carried alongside for the modules dump, never for rendering.
"""
reg = modules()
specs = list(reg.specs.values())
incompatible = []
if traits is not None:
specs, incompatible = split(specs, traits)
ordered, rejected = order(specs)
return {
"contract": CONTRACT_VERSION,
"modules": [spec.to_manifest() for spec in ordered],
"failed": (
[{"where": where, "why": why} for where, why in reg.failed]
+ [{"where": spec.source, "why": why} for spec, why in rejected]
),
"incompatible": [
{"id": spec.id, "requires": spec.requires} for spec in incompatible
],
}
def register(routes, prefix=None):
"""Attach FunPack's routes to an aiohttp route table."""
P = config.UI_PREFIX if prefix is None else prefix
@routes.get(P + "/api/health")
async def _health(_req):
return web.json_response({"ok": True})
@routes.get(P + "/api/modules")
async def _modules(req):
raw = req.query.get("traits")
traits = [t for t in raw.split(",") if t] if raw is not None else None
return web.json_response(manifest(traits))
def _pipeline():
"""Whatever module offers a default pipeline, or nothing.
Core does not hold one: a default pipeline is feature content, and the
point of it being data is that it can be replaced by other data.
"""
for _spec, make in modules().providers("default_pipeline"):
return make()
return []
def _sinks():
"""Every place a module says the app's settings can be put."""
found = []
for _spec, make in modules().providers("settings_sink"):
try:
sink = make()
except Exception as exc: # noqa: BLE001
log.failed("settings_sink", exc)
continue
if isinstance(sink, dict):
found.append(sink)
return found
@routes.get(P + "/api/pipeline")
async def _pipeline_get(_req):
slots = _pipeline()
prompt, incomplete = graph_mod.build(slots)
return web.json_response({"slots": slots, "refused": [],
"incomplete": incomplete,
"queueable": not incomplete})
@routes.post(P + "/api/pipeline")
async def _pipeline_edit(req):
"""Replace or remove a slot, and say what that did to the graph.
This is what makes "a built-in node can be swapped or removed" a thing
the running server can do rather than a property of a library nothing
calls. Refusals come back as data, so the app can show the reason.
"""
try:
body = await req.json()
except Exception: # noqa: BLE001
return web.json_response({"problems": ["that is not JSON"]}, status=400)
# Everything below reads named fields off the body, so the body has to
# be a thing with names. `[]` and `"slots"` are both valid JSON and
# neither is a request; asking them for `.get` is an AttributeError and
# a 500 nothing can read.
if not isinstance(body, dict):
return web.json_response(
{"problems": [f"a request is an object, not a {type(body).__name__}"],
"queueable": False}, status=400)
# `or` would resurrect the default here: an explicitly empty pipeline is
# falsy, and a client that has removed every slot is entitled to be told
# it has none rather than handed the defaults back.
slots = body.get("slots")
if slots is None:
slots = _pipeline()
action, slot_id = body.get("action"), body.get("slot")
# A malformed payload is a bad REQUEST, not an unfinished pipeline --
# so it comes back as a refusal with a reason and a 400, rather than
# among the "you have not picked a model yet" notes.
malformed = graph_mod.shape_problems(slots)
if malformed:
return web.json_response(
{"refused": malformed, "incomplete": [], "queueable": False,
"slots": [], "prompt": None},
status=400)
# An id and a node name are strings, and only a string can be looked up
# in the dicts below: `{"slot": ["a"]}` reaches a `in`-on-dict as an
# unhashable key and the refusal turns into a 500. The slots array is
# shape-checked above; these two fields are read from the same body and
# were not.
if action in ("replace", "remove") and not isinstance(slot_id, str):
return web.json_response(
{"problems": [f"which slot to {action} is named by a string, "
f"not a {type(slot_id).__name__}"],
"queueable": False}, status=400)
if action == "replace" and not isinstance(body.get("node"), str):
return web.json_response(
{"problems": [f"a node is named by a string, not a "
f"{type(body.get('node')).__name__}"],
"queueable": False}, status=400)
if action == "replace":
slots, problems = graph_mod.replace(slots, slot_id, body.get("node"))
elif action == "remove":
slots, problems = graph_mod.remove(slots, slot_id)
elif action in (None, "check"):
problems = []
else:
return web.json_response({"problems": [f"unknown action {action!r}"]}, status=400)
# Two different things, kept apart. "refused" means the edit did not
# happen. "incomplete" means it did and the pipeline still is not ready
# -- an unset file picker on a fresh install is the normal case, not a
# failed edit, and an app that showed them together would say the wrong
# thing about both.
# Values the app is showing for named inputs -- the prompt box, and
# whatever else the pipeline says belongs on the main window. Addressed
# by slot rather than sent as a whole pipeline: the window that owns the
# STRUCTURE and a control that owns one VALUE then cannot disagree about
# the slots between them.
edits = body.get("inputs")
if edits is not None:
slots, refused = graph_mod.override(slots, edits)
problems = list(problems) + refused
# What the UI holds, on its way into the graph. Sent with the pipeline
# rather than held on the server: two stores of "what the user picked"
# is two answers to what a run used, and the one believed would be
# whichever was written last.
values, notes = body.get("values"), []
if values is not None:
if not isinstance(values, dict):
return web.json_response(
{"problems": [f"settings are an object keyed by module id, "
f"not a {type(values).__name__}"],
"queueable": False}, status=400)
slots, placed = graph_mod.place(slots, json.dumps(values), _sinks())
# Said, not swallowed. A pipeline with nothing to accept them is a
# legitimate pipeline, so this does not stop the run -- but a panel
# full of switches that do nothing has to say so somewhere, and
# beside Generate is where the run is started.
if values and not placed:
notes.append("nothing in this pipeline accepts module settings, "
"so what is set in the panels will not be applied")
prompt, incomplete = graph_mod.build(slots)
return web.json_response({
"slots": slots,
"refused": problems,
"incomplete": incomplete,
"notes": notes,
"queueable": not (problems or incomplete),
"prompt": prompt if not (problems or incomplete) else None,
})
@routes.get(P + "/api/nodes")
async def _nodes(req):
"""What the nodes in a pipeline look like to someone editing them.
Asked for by name -- `?classes=A,B,C` -- rather than served whole.
ComfyUI's own /object_info answers with every installed node, which on a
machine with a few packs is megabytes, and a pipeline needs a dozen.
A node that is not installed comes back as null rather than missing, so
the app can tell "this slot points at something absent" from "I did not
ask about that one".
"""
raw = req.query.get("classes") or ""
wanted = [name for name in (part.strip() for part in raw.split(",")) if name]
if not wanted:
return web.json_response({"nodes": {}})
# Bounded: the query string is the caller's, and describing a thousand
# nodes one by one on the event loop is a request that stops the server
# answering anything else.
if len(wanted) > 200:
return web.json_response(
{"problems": [f"asked about {len(wanted)} nodes at once; 200 is the limit"]},
status=400)
return web.json_response({"nodes": widgets.describe_all(wanted)})
@routes.get(P + "/api/nodes/search")
async def _nodes_search(req):
"""What could go in a slot, by name.
Separate from the route above because it answers a different question:
that one describes nodes the caller already named, this one finds the
name. Bounded the same way and for the same reason -- the query string
is the caller's, and the answer is built on the event loop.
"""
try:
limit = int(req.query.get("limit", 40))
except ValueError:
limit = 40
return web.json_response(
widgets.search(req.query.get("q", ""), max(1, min(limit, 200))))
# ── projects ──────────────────────────────────────────────────────────
# What the timeline IS. A run produces one clip; a project is the ordered
# list of them the user is making, and the only part of the app that has to
# survive a reload.
async def _body(req):
"""The request's JSON object, or None. A body that is not an object is
not a project edit -- `[]` and `"x"` both reach `.get` otherwise."""
try:
data = await req.json()
except Exception: # noqa: BLE001 - a malformed body is the caller's
return None
return data if isinstance(data, dict) else None
@routes.get(P + "/api/projects")
async def _projects_list(_req):
return web.json_response({"projects": projects.listing()})
@routes.post(P + "/api/projects")
async def _projects_create(req):
body = await _body(req) or {}
return web.json_response(projects.create(body.get("name")).to_dict())
@routes.get(P + "/api/projects/{pid}")
async def _projects_get(req):
found = projects.get(req.match_info["pid"])
if found is None:
return web.json_response({"problems": ["no such project"]}, status=404)
return web.json_response(found.to_dict())
@routes.put(P + "/api/projects/{pid}")
async def _projects_save(req):
pid = req.match_info["pid"]
body = await _body(req)
if body is None:
return web.json_response({"problems": ["expected a project object"]}, status=400)
if projects.get(pid) is None:
return web.json_response({"problems": ["no such project"]}, status=404)
# The id comes from the URL, never the body: a PUT that names its own
# target is a PUT that can write over a different project.
return web.json_response(
projects.save(projects.Project.from_dict({**body, "id": pid})).to_dict())
@routes.delete(P + "/api/projects/{pid}")
async def _projects_delete(req):
if not projects.delete(req.match_info["pid"]):
return web.json_response({"problems": ["no such project"]}, status=404)
return web.json_response({"deleted": True})
@routes.get(P + "/api/log")
async def _log(req):
level = req.query.get("level") or None
try:
limit = int(req.query.get("limit", log.HISTORY))
except ValueError:
limit = log.HISTORY
return web.json_response({"levels": list(log.LEVELS),
"records": log.history(level, limit)})
@routes.get(P + "/app/{tail:.*}")
async def _app_asset(req):
return _serve_under(req, config.APP_DIR, config.APP_EXTS)
@routes.get(P + "/modules/{tail:.*}")
async def _module_asset(req):
return _serve_under(req, config.MODULES_DIR, config.MODULE_EXTS)
@routes.get(P + "/")
async def _index(_req):
return _respond(static.serve(config.APP_DIR, "index.html", config.APP_EXTS))
@routes.get(P)
async def _index_bare(_req):
raise web.HTTPFound(P + "/")
return routes
if web is not None and PromptServer is not None:
# `PromptServer.instance` only exists once a server has been constructed.
# Importing `server` successfully is not the same as running inside one --
# a test run with ComfyUI on the path gets the class and no instance -- and
# an unguarded attribute here takes the whole pack down with it.
try:
register(PromptServer.instance.routes)
except Exception as exc: # noqa: BLE001
log.failed("route registration", exc)
else:
log.info("routes", f"serving the app at {config.UI_PREFIX}/")