Two pieces of the "port v4's exact UI onto v5's backend" plan: - core/contract.py: new optional ModuleSpec.category field (closed set: continuity, guidance, conditioning, sampling, post, system), validated in core/schema.py, threaded through core/registry.py's FIELDS and to_manifest(). Presentation grouping, independent of STAGE (execution order) -- a module can be stage="sampling" and category="guidance" at once. Only the 4 existing modules that actually render a settings panel (alg, sharpen, appearance, full_control) got a category assigned; the other 12 have none to categorize (no SETTINGS block) and stay uncategorised, which is valid. - v4's frontend (movie_editor/frontend/*, ~29k lines, 61 files) copied verbatim into app/legacy/. core/routes.py's "/" route now serves legacy/index.html instead of the composer entry point; a <base> tag in that file resolves its own relative script/link tags against /funpack/app/legacy/ regardless of what URL it's served at, so every file moved as a unit with zero per-file path edits. Verified live: all 61 assets load 200 OK, the real v4 onboarding screen renders pixel-for- pixel (it's the actual DOM/CSS, not a reimplementation), and several v4 API calls (/api/media, /api/projects, /api/git/status) already succeed against v5's real backend by coincidence of shared route naming -- the rest 404/405 as expected, since api.js's ~90 functions haven't been rewired to v5's actual routes yet (next piece of this plan). The old composer entry point (app/index.html, app/composer/**) stays in the tree, unreferenced, until a later cleanup pass once nothing imports it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
109 lines
5.3 KiB
Python
109 lines
5.3 KiB
Python
"""What a module declares, and the vocabulary it may use.
|
|
|
|
Core holds no list of features. A module announces itself and this file says
|
|
what a valid announcement looks like -- nothing here names an implementation.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
# Bumped when the shape of an announcement changes. A module declaring an older
|
|
# version is not quietly adapted: it is refused, because guessing at what an old
|
|
# declaration meant is how silent behaviour changes happen.
|
|
CONTRACT_VERSION = 1
|
|
|
|
# Closed set. A type outside it has no renderer and no validation, so accepting
|
|
# one would mean rendering something approximate.
|
|
# A setting is a PREFERENCE. Anything that is data -- an image, a drawn mask,
|
|
# a video -- travels as a ComfyUI socket instead, because it is a tensor the app
|
|
# uploads and the graph wires, not a value that belongs in a JSON payload. That
|
|
# is why there is no "image" or "mask" here despite both being planned features.
|
|
TYPES = frozenset({"bool", "int", "float", "enum", "text", "multiline", "path", "color"})
|
|
|
|
NUMERIC = frozenset({"int", "float"})
|
|
|
|
# Which renderers each type allows, first being the default. A hint outside its
|
|
# type's list is refused at import: the alternative is a panel that silently
|
|
# renders something other than what was asked for.
|
|
UI_HINTS: Dict[str, List[str]] = {
|
|
"bool": ["checkboxRow", "toggle"],
|
|
"int": ["number", "slider", "stepper", "macroSlider"],
|
|
"float": ["number", "slider", "stepper", "macroSlider"],
|
|
"enum": ["select", "segmented", "radioGroup", "filterList", "wheel"],
|
|
"text": ["input", "search"],
|
|
"multiline": ["textarea", "autoTextarea"],
|
|
"path": ["filterList"],
|
|
"color": ["swatch"],
|
|
}
|
|
|
|
# Ordering is coarse-grained by stage, then by declared relations within it.
|
|
# Numbers are deliberately absent: a priority number is a global namespace, so
|
|
# inserting one module means renumbering the others.
|
|
STAGES: List[str] = ["load", "conditioning", "latent", "guidance", "sampling", "post"]
|
|
|
|
# Presentation grouping, independent of STAGE: stage is execution order, category
|
|
# is "what is this module FOR" -- a module can be stage="sampling" and
|
|
# category="continuity" at once. Closed set for the same reason TYPES is closed:
|
|
# an unrecognised category has no place in the UI to render into. Empty/absent is
|
|
# valid and means "uncategorised", never a hard error -- most modules will not
|
|
# need this until the panel that groups by it actually exists.
|
|
CATEGORIES = frozenset({"continuity", "guidance", "conditioning", "sampling", "post", "system"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModuleSpec:
|
|
"""One module's announcement, after validation."""
|
|
|
|
id: str
|
|
title: str
|
|
mount: str
|
|
settings: Dict[str, dict] = field(default_factory=dict)
|
|
requires: List[str] = field(default_factory=list) # model traits
|
|
after: List[str] = field(default_factory=list) # module ids
|
|
before: List[str] = field(default_factory=list)
|
|
stage: str = "sampling"
|
|
category: str = "" # "" means uncategorised
|
|
# ComfyUI node classes this module contributes. Kept out of `settings` on
|
|
# purpose: a setting is a preference, a node is graph structure, and letting
|
|
# a new setting change a node's socket list would rot every saved workflow.
|
|
nodes: List[type] = field(default_factory=list)
|
|
# A callable(model) -> iterable[str], contributing traits core cannot read on
|
|
# its own. This is how a model's own module teaches the system to recognise
|
|
# it, so supporting a new model is a new folder and never an edit to core.
|
|
traits: Optional[Callable] = None
|
|
# Named capabilities this module offers to OTHER modules. Core never reads a
|
|
# name out of here and never defines one: it is a lookup, so a node can ask
|
|
# "who can build one of these" without core learning what the thing is.
|
|
provides: Dict[str, Callable] = field(default_factory=dict)
|
|
# Hook points a sampler must offer before this module can run. Empty means
|
|
# "any sampler will do", the same narrowing rule traits follow.
|
|
hooks: List[str] = field(default_factory=list)
|
|
ui: Optional[str] = None # served path to its ui.js
|
|
status: str = "experimental" # or "proven"
|
|
source: str = "" # dotted import path
|
|
|
|
def defaults(self) -> Dict[str, Any]:
|
|
"""The values a headless run gets when no panel has been rendered.
|
|
|
|
Derived from the same declaration the panel renders, so the two cannot
|
|
disagree about what a setting means when nobody has touched it.
|
|
"""
|
|
return {key: spec["default"] for key, spec in self.settings.items()}
|
|
|
|
def to_manifest(self) -> dict:
|
|
# `nodes` is deliberately absent: it is not JSON, and the browser has no
|
|
# use for it. The graph is ComfyUI's surface, the manifest is the app's.
|
|
return {
|
|
"id": self.id,
|
|
"title": self.title,
|
|
"mount": self.mount,
|
|
"settings": self.settings,
|
|
"requires": list(self.requires),
|
|
"after": list(self.after),
|
|
"before": list(self.before),
|
|
"stage": self.stage,
|
|
"category": self.category,
|
|
"ui": self.ui,
|
|
"status": self.status,
|
|
}
|