Files
Ewald a4154ccf05 Fix P0 bugs, remove Append V2/Lens Artifacts, and fix crop reload reset
- Fix batch bypass tuple handling, memory budget device-awareness, grain
  RNG device, roto payload limits, preview temp-file cleanup, and route
  double-registration.
- Add native VIDEO output (fps/audio) to effect nodes that previously
  only returned IMAGE/MASK when fed a VIDEO input.
- Split the oversized src/preview/ops/geometry.ts and add a starting
  golden-fixture test harness (spherize, transform) comparing backend
  and frontend math.
- Remove ImageOpsAppendV2 (Autogrow) and ImageOpsLensArtifacts; inline
  Append's helper back into its single caller.
- Collapse the Color Correct curve widget behind a toggle so the node
  no longer grows too tall.
- Fix Crop/generic-node video preview playback running at the RAF rate
  instead of the source video's real fps.
- Fix Crop losing its saved output size after a workflow reload:
  syncCropWidgets recomputed height from width and a preset aspect
  ratio on every resync (including onConfigure on load), discarding
  the restored value. It now only refreshes aspect-ratio bookkeeping
  on a resync.
2026-09-30 12:49:42 +02:00

341 lines
14 KiB
Python

"""Regenerate example/All Nodes MIO.json from the live node schemas.
Every ImageOps node is placed in the graph and wired to an input. Run it inside a
ComfyUI environment so the pack can import comfy_api:
python scripts/generate_example_workflow.py [--check]
--check exits with an error when the committed file differs from the generated one.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
PACK_ROOT = Path(__file__).resolve().parent.parent
COMFYUI_ROOT = Path(os.environ.get("COMFYUI_ROOT", PACK_ROOT.parent.parent)).resolve()
EXAMPLE_PATH = PACK_ROOT / "example" / "All Nodes MIO.json"
SOURCE_IMAGE = "example.png"
SOURCE_SIZE = 768
WORKFLOW_ID = "5b0c2f5e-6f0a-4c52-9a7e-1d1f6f0a1e01"
# node type -> {input name: (source node type, output name)}. Every other `image` input is fed by LoadImage.
LINKS = {
"ImageOpsMerge": {"A": ("LoadImage", "IMAGE"), "B": ("ImageOpsRamp", "image")},
"ImageOpsComp": {"image_1": ("LoadImage", "IMAGE"), "image_2": ("ImageOpsConstant", "image")},
"ImageOpsCropStitch": {
"original": ("LoadImage", "IMAGE"),
"crop": ("ImageOpsCrop", "image"),
"crop_mask": ("ImageOpsCrop", "mask"),
"crop_bbox": ("ImageOpsCrop", "bbox"),
},
"ImageOpsDistort": {"displacement": ("ImageOpsNoise", "image")},
"ImageOpsDefocus": {"depth": ("ImageOpsRamp", "image")},
"ImageOpsAppend": {"image_1": ("LoadImage", "IMAGE"), "image_2": ("ImageOpsNoise", "image")},
"ImageOpsFrameRange": {"image": ("ImageOpsAppend", "image")},
"ImageOpsPreview": {"image": ("ImageOpsMerge", "image"), "image_b": ("LoadImage", "IMAGE")},
}
# Widget values that differ from the schema default so the example shows something.
OVERRIDES = {
"ImageOpsColorAjust": {"contrast": 10, "saturation": 15},
"ImageOpsTransform": {"rotate_deg": 8.0, "scale": 1.1},
"ImageOpsCrop": {"width": 512, "height": 512, "crop_scale": 0.6},
"ImageOpsPreview": {"compare_mode": "wipe"},
"ImageOpsMaskConvert": {"reverse": True, "mask_source": "luma"},
"ImageOpsConstant": {"width": SOURCE_SIZE, "height": SOURCE_SIZE},
"ImageOpsRamp": {"width": SOURCE_SIZE, "height": SOURCE_SIZE},
"ImageOpsNoise": {"width": SOURCE_SIZE, "height": SOURCE_SIZE},
"ImageOpsComp": {
"layers_json": json.dumps(
{
"version": 1,
"layers": [
{"slot": "image_1", "center_x": 0.5, "center_y": 0.5, "scale": 1, "rotate_deg": 0, "opacity": 1, "mode": "over", "enabled": True},
{"slot": "image_2", "center_x": 0.5, "center_y": 0.5, "scale": 0.5, "rotate_deg": 0, "opacity": 0.8, "mode": "screen", "enabled": True},
],
},
separators=(",", ":"),
)
},
}
# ImageOpsComp builds its layer sockets in the UI, so they are not part of its schema.
DYNAMIC_SOCKETS = {
"ImageOpsComp": [
("image_1", "IMAGE,VIDEO", "Images/Video 1"),
("mask_1", "MASK", None),
("image_2", "IMAGE,VIDEO", "Images/Video 2"),
("mask_2", "MASK", None),
]
}
GROUPS = [
("IMAGE SOURCE", ["LoadImage", "ImageOpsConstant", "ImageOpsRamp", "ImageOpsNoise"]),
("IMAGE MASK AND CHANNEL", ["ImageOpsChannel", "ImageOpsMaskConvert", "ImageOpsKeyer", "ImageOpsRoto", "ImageOpsClamp", "ImageOpsInvert"]),
("IMAGE TRANSFORMATION ( MOVE / ROTATE / SCALE / DISTORT )", ["ImageOpsCrop", "ImageOpsCropStitch", "ImageOpsTransform", "ImageOpsCornerPin", "ImageOpsPadOut", "ImageOpsDistort", "ImageOpsSpherize", "ImageOpsCameraShake"]),
("IMAGE EFFECT", ["ImageOpsBlur", "ImageOpsColorAjust", "ImageOpsGrain", "ImageOpsVignette", "ImageOpsChromaticAberration", "ImageOpsBloom", "ImageOpsDefocus"]),
("IMAGE TEXT AND DRAW", ["ImageOpsText", "ImageOpsDraw"]),
("IMAGE COMPOSITION", ["ImageOpsMerge", "ImageOpsComp", "ImageOpsPreview"]),
("IMAGE TEMPORAL AND TIMELINE", ["ImageOpsAppend", "ImageOpsFrameRange"]),
]
NODE_SIZE = (470, 960)
LOAD_IMAGE_SIZE = (340, 420)
COLUMN_STEP = 520
ROW_STEP = 1120
GROUP_PADDING = 60
GROUP_TITLE_HEIGHT = 90
WIDGET_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", "COLOR", "CURVE", "COLORS"}
CURVE_DEFAULT = {"points": [[0, 0], [1, 1]], "interpolation": "monotone_cubic"}
LOAD_IMAGE_DEFINITION = {
"name": "LoadImage",
"input_order": {"required": ["image", "upload"]},
"input": {"required": {"image": ["COMBO", {"default": SOURCE_IMAGE}], "upload": ["IMAGEUPLOAD", {}]}},
"output": ["IMAGE", "MASK"],
"output_name": ["IMAGE", "MASK"],
"display_name": "Load Image",
}
def load_extension():
"""Import the pack the way ComfyUI does, on the CPU."""
sys.path.insert(0, str(COMFYUI_ROOT))
from comfy.cli_args import args
args.cpu = True
spec = importlib.util.spec_from_file_location("imageops_example", PACK_ROOT / "__init__.py", submodule_search_locations=[str(PACK_ROOT)])
module = importlib.util.module_from_spec(spec)
sys.modules["imageops_example"] = module
spec.loader.exec_module(module)
return module
def node_definitions(extension) -> dict[str, dict]:
definitions = {}
for node in extension.NODES:
info = node.GET_NODE_INFO_V1()
info = info if isinstance(info, dict) else vars(info)
definitions[info["name"]] = info
definitions["LoadImage"] = LOAD_IMAGE_DEFINITION
return definitions
def _inputs_by_kind(definition: dict):
"""Split a definition into ordered (name, type, options, required) socket and widget inputs."""
sockets, widgets = [], []
for group in ("required", "optional"):
for name in definition["input_order"].get(group, []):
entry = definition["input"][group][name]
input_type, options = entry[0], (entry[1] if len(entry) > 1 else {})
record = (name, input_type, options, group == "required")
if input_type == "IMAGEUPLOAD":
continue
is_widget = (input_type in WIDGET_TYPES or isinstance(input_type, list)) and not options.get("forceInput")
(widgets if is_widget else sockets).append(record)
return sockets, widgets
def widget_values(node_type: str, definition: dict) -> dict[str, object]:
_, widgets = _inputs_by_kind(definition)
values = {}
for name, input_type, options, _ in widgets:
if input_type == "CURVE":
values[name] = CURVE_DEFAULT
elif input_type == "COMBO" and "default" not in options:
values[name] = options["options"][0]
else:
values[name] = options.get("default", "")
overrides = OVERRIDES.get(node_type, {})
assert set(overrides) <= set(values), f"{node_type}: overrides for unknown widgets {set(overrides) - set(values)}"
values.update(overrides)
return values
def resolve_links(definitions: dict[str, dict]) -> dict[str, dict[str, tuple[str, str]]]:
"""node type -> {input name: (source type, output name)} for every wired input."""
resolved = {}
for node_type, definition in definitions.items():
if node_type == "LoadImage":
continue
sockets = {name for name, _, _, _ in _inputs_by_kind(definition)[0]}
wired = dict(LINKS.get(node_type, {}))
if "image" in sockets and "image" not in wired:
wired["image"] = ("LoadImage", "IMAGE")
resolved[node_type] = wired
return resolved
def _layout(definitions: dict[str, dict]) -> tuple[dict[str, tuple[float, float, int, int]], list[dict]]:
positions, groups = {}, []
y = 0
for group_id, (title, members) in enumerate(GROUPS, start=1):
members = [m for m in members if m in definitions]
top = y + GROUP_TITLE_HEIGHT
width = 2 * GROUP_PADDING + len(members) * COLUMN_STEP - (COLUMN_STEP - NODE_SIZE[0])
for column, member in enumerate(members):
size = LOAD_IMAGE_SIZE if member == "LoadImage" else NODE_SIZE
positions[member] = (GROUP_PADDING + column * COLUMN_STEP, top, size[0], size[1])
groups.append(
{
"id": group_id,
"title": title,
"bounding": [0, y, width, GROUP_TITLE_HEIGHT + NODE_SIZE[1] + GROUP_PADDING],
"color": "#3f789e",
"font_size": 24,
"flags": {},
}
)
y += ROW_STEP + GROUP_TITLE_HEIGHT
return positions, groups
def _execution_order(node_ids: dict[str, int], links: dict[str, dict[str, tuple[str, str]]]) -> dict[str, int]:
remaining = {t: {src for src, _ in wired.values()} for t, wired in links.items()}
remaining["LoadImage"] = set()
order, done = {}, set()
while remaining:
ready = sorted((t for t, deps in remaining.items() if deps <= done), key=lambda t: node_ids[t])
assert ready, "cycle in the example graph"
for node_type in ready:
order[node_type] = len(order)
done.add(node_type)
del remaining[node_type]
return order
def build_workflow(definitions: dict[str, dict], version: str, comfy_version: str, frontend_version: str) -> dict:
positions, groups = _layout(definitions)
types = [t for _, members in GROUPS for t in members if t in definitions]
assert set(types) == set(definitions), f"nodes missing from GROUPS: {set(definitions) - set(types)}"
node_ids = {node_type: index for index, node_type in enumerate(types, start=1)}
links_by_node = resolve_links(definitions)
order = _execution_order(node_ids, links_by_node)
link_rows, link_ids = [], {}
inputs_of, outputs_of = {}, {t: {} for t in types}
for node_type in types:
definition = definitions[node_type]
sockets = [(n, t, o, r) for n, t, o, r in _inputs_by_kind(definition)[0]]
for name, socket_type, label in DYNAMIC_SOCKETS.get(node_type, []):
sockets.append((name, socket_type, {"display_name": label} if label else {}, False))
serialized = []
for name, socket_type, options, required in sockets:
entry = {"name": name, "type": socket_type}
if options.get("display_name"):
entry = {"label": options["display_name"], **entry}
if not required:
entry["shape"] = 7
source = links_by_node.get(node_type, {}).get(name)
entry["link"] = None
if source:
source_type, output_name = source
source_def = definitions[source_type]
slot = source_def["output_name"].index(output_name)
link_id = len(link_rows) + 1
link_rows.append([link_id, node_ids[source_type], slot, node_ids[node_type], len(serialized), source_def["output"][slot]])
entry["link"] = link_id
outputs_of[source_type].setdefault(slot, []).append(link_id)
serialized.append(entry)
inputs_of[node_type] = serialized
nodes = []
for node_type in types:
definition = definitions[node_type]
x, y, width, height = positions[node_type]
outputs = []
for slot, (out_name, out_type) in enumerate(zip(definition["output_name"], definition["output"])):
linked = outputs_of[node_type].get(slot)
output = {"name": out_name, "type": out_type, "links": linked}
if linked:
output["slot_index"] = slot
outputs.append(output)
core = node_type == "LoadImage"
node = {
"id": node_ids[node_type],
"type": node_type,
"pos": [x, y],
"size": [width, height],
"flags": {},
"order": order[node_type],
"mode": 0,
"inputs": inputs_of[node_type],
"outputs": outputs,
"properties": {
"cnr_id": "comfy-core" if core else "majoor-imageops",
"ver": comfy_version if core else version,
"Node name for S&R": node_type,
},
"widgets_values": [SOURCE_IMAGE, "image"] if core else list(widget_values(node_type, definition).values()),
}
if core:
node["inputs"] = []
nodes.append(node)
return {
"id": WORKFLOW_ID,
"revision": 0,
"last_node_id": len(nodes),
"last_link_id": len(link_rows),
"nodes": nodes,
"links": link_rows,
"groups": groups,
"config": {},
"extra": {"ds": {"scale": 0.12, "offset": [200, 200]}, "frontendVersion": frontend_version},
"version": 0.4,
}
def build_prompt(definitions: dict[str, dict], workflow: dict) -> dict:
"""The same graph in API format, used to execute the example without the frontend."""
type_by_id = {node["id"]: node["type"] for node in workflow["nodes"]}
prompt = {}
for node in workflow["nodes"]:
node_type = node["type"]
inputs = {}
if node_type == "LoadImage":
inputs["image"] = SOURCE_IMAGE
else:
inputs.update(widget_values(node_type, definitions[node_type]))
for socket in node["inputs"]:
if socket["link"] is not None:
link = next(row for row in workflow["links"] if row[0] == socket["link"])
inputs[socket["name"]] = [str(link[1]), link[2]]
prompt[str(node["id"])] = {"class_type": node_type, "inputs": inputs}
assert {v["class_type"] for v in prompt.values()} == set(type_by_id.values())
return prompt
def read_versions() -> tuple[str, str, str]:
project_version = next(line.split('"')[1] for line in (PACK_ROOT / "pyproject.toml").read_text(encoding="utf-8").splitlines() if line.startswith("version = "))
comfy_version = re.search(r'__version__ = "([^"]+)"', (COMFYUI_ROOT / "comfyui_version.py").read_text(encoding="utf-8")).group(1)
frontend = next(line.split("==")[1].strip() for line in (COMFYUI_ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines() if line.startswith("comfyui-frontend-package"))
return project_version, comfy_version, frontend
def generate() -> tuple[dict, dict, dict]:
definitions = node_definitions(load_extension())
workflow = build_workflow(definitions, *read_versions())
return definitions, workflow, build_prompt(definitions, workflow)
def render(workflow: dict) -> str:
return json.dumps(workflow, indent=2, ensure_ascii=False) + "\n"
if __name__ == "__main__":
text = render(generate()[1])
if "--check" in sys.argv:
if EXAMPLE_PATH.read_text(encoding="utf-8") != text:
sys.exit(f"{EXAMPLE_PATH} is stale. Run: python scripts/generate_example_workflow.py")
else:
EXAMPLE_PATH.write_text(text, encoding="utf-8", newline="\n")
print(f"Wrote {EXAMPLE_PATH}")