From b4fcc11fd6fbe561c66eb9c376340a7bc8e289cc Mon Sep 17 00:00:00 2001 From: Alexander Garzon Date: Sun, 13 Sep 2026 21:59:40 -0400 Subject: [PATCH 1/3] feat: add gpt-image-2.5 models and retire gpt-image-1.x Add gpt-image-2.5-flare (new default) and gpt-image-2.5-sunburst, released 2026-09-08. Both support custom WxH sizes, transparent backgrounds and the new xhigh/max quality tiers; gpt-image-2 falls back to high when an extended tier is selected. Retire gpt-image-1 and gpt-image-1.5 ahead of their 2026-10-23 and 2026-12-01 shutdown dates. That drops the input_fidelity parameter with them: gpt-image-2 and 2.5 always use high input fidelity and error if it is sent, so no shipped model can use it. With every remaining model accepting custom sizes, the custom_size capability flag became constant-true; removing it collapses resolve_size to a validation call and drops its unused model argument. BREAKING CHANGE: saved workflows selecting gpt-image-1 or gpt-image-1.5 must have their model widget re-picked. --- CHANGELOG.md | 17 +++++++++++ README.md | 21 ++++++------- imagent/build.py | 14 ++++----- imagent/client.py | 11 ++++--- imagent/node_edit.py | 25 ++++------------ imagent/node_generate.py | 21 ++++++------- imagent/params.py | 64 +++++++++++++++++++--------------------- tests/test_client.py | 9 ++++-- tests/test_nodes.py | 62 +++++++++++++++++++------------------- tests/test_params.py | 55 ++++++++++++++++------------------ 10 files changed, 147 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8e0d5..1e12381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +### Added + +- **gpt-image-2.5** — `gpt-image-2.5-flare` (new default) and `gpt-image-2.5-sunburst`, with + custom `WxH` sizes, `transparent` background, and the new `xhigh` / `max` quality tiers. + gpt-image-2 falls back to `high` when an extended tier is selected. + +### Removed + +- **gpt-image-1 and gpt-image-1.5** — OpenAI shuts them down on 2026-10-23 and 2026-12-01 + respectively. **Saved workflows that select either model will need their `model` widget + re-picked.** +- **`input_fidelity`** — it only ever applied to gpt-image-1.x; gpt-image-2 and 2.5 always use + high input fidelity and error if the parameter is sent. The widget is gone from the edit node + and the parameter is no longer part of `build.run_edit()`. + ## v0.1.0 — 2026-06-08 First public release. diff --git a/README.md b/README.md index fcf2b87..39253d3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ✨ Features - 🖼️ **Two nodes** — **Imagent: OpenAI Image** (text-to-image) and **Imagent: OpenAI Image Edit** (edit, inpaint, multi-reference compose). -- 🤖 **Current gpt-image models** — `gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`. +- 🤖 **Current gpt-image models** — `gpt-image-2.5-flare`, `gpt-image-2.5-sunburst`, `gpt-image-2`. - ✍️ **Text-to-image** with full control over size, quality, background, and output format. - 🎨 **Edit + inpaint (mask)** — supply a mask to repaint a specific region; white pixels mark the area to edit. - 🔗 **Multi-reference compositing** — feed up to 16 reference images to the edit node (auto-growing input). @@ -63,11 +63,11 @@ Text-to-image generation via `images.generate`. | Parameter | Type | Values / Notes | |---|---|---| | `prompt` | STRING | Text description of the image to generate | -| `model` | **DynamicCombo** | `gpt-image-2` (default), `gpt-image-1.5`, `gpt-image-1`. Switching the model swaps the options below. | -| ↳ `size` | COMBO | **gpt-image-2:** `auto`, the three base sizes, five high-res presets, and `custom`. **gpt-image-1.x:** `auto` + the three base sizes only. | +| `model` | **DynamicCombo** | `gpt-image-2.5-flare` (default), `gpt-image-2.5-sunburst`, `gpt-image-2`. Switching the model swaps the options below. | +| ↳ `size` | COMBO | `auto`, the three base sizes, five high-res presets, and `custom`. | | ↳↳ `custom_width` / `custom_height` | INT | **Appear only when `size = custom`.** 1024–3840, step 16; both multiples of 16; aspect ≤ 3:1; total pixels 655,360–8,294,400. | -| ↳ `background` | COMBO | **gpt-image-2:** `auto`, `opaque`. **gpt-image-1.x:** `auto`, `opaque`, `transparent` (needs `png`/`webp`). | -| `quality` | COMBO | `auto`, `low`, `medium`, `high` | +| ↳ `background` | COMBO | **gpt-image-2.5:** `auto`, `opaque`, `transparent` (needs `png`/`webp`). **gpt-image-2:** `auto`, `opaque`. | +| `quality` | COMBO | `auto`, `low`, `medium`, `high`, plus `xhigh` / `max` on gpt-image-2.5 (other models fall back to `high`). | | `output_format` | COMBO | `png`, `jpeg`, `webp` | | `output_compression` | INT | 0–100. Applied only for `jpeg` and `webp`. | | `moderation` | COMBO | `auto` (default), `low`. Sent only when not `auto`. | @@ -89,12 +89,11 @@ Image editing, inpainting, and multi-reference compositing via `images.edit`. |---|---|---| | `prompt` | STRING | Description of the desired edit | | `model` | **DynamicCombo** | Same three models; switching swaps the options below. | -| ↳ `size` | COMBO | Same as the generate node (hi-res + `custom` on gpt-image-2; base sizes on gpt-image-1.x). | -| ↳↳ `custom_width` / `custom_height` | INT | **Appear only when `size = custom`** (gpt-image-2). Same rules as the generate node. | -| ↳ `background` | COMBO | gpt-image-2: `auto`/`opaque`; gpt-image-1.x: adds `transparent`. | -| ↳ `input_fidelity` | COMBO | **gpt-image-1.x only.** `high` (default) or `low` — how closely to follow the reference image. | +| ↳ `size` | COMBO | Same as the generate node. | +| ↳↳ `custom_width` / `custom_height` | INT | **Appear only when `size = custom`.** Same rules as the generate node. | +| ↳ `background` | COMBO | gpt-image-2.5: `auto`/`opaque`/`transparent`; gpt-image-2: `auto`/`opaque`. | | `images` | IMAGE (auto-grow) | Reference image(s) to edit — grows up to **16** slots; at least one required. | -| `quality` | COMBO | `auto`, `low`, `medium`, `high` | +| `quality` | COMBO | `auto`, `low`, `medium`, `high`, plus `xhigh` / `max` on gpt-image-2.5. | | `output_format` | COMBO | `png`, `jpeg`, `webp` | | `moderation` | COMBO | `auto` (default), `low` | | `n` | INT | 1–8 images per call | @@ -102,6 +101,8 @@ Image editing, inpainting, and multi-reference compositing via `images.edit`. Rows marked ↳ live inside the dynamic `model` widget; ↳↳ rows appear only when their parent option is selected. +> No `input_fidelity` widget: every model shipped here always uses high input fidelity, and passing the parameter is an error. It only applied to the retired gpt-image-1.x models. + **Output:** `image` (IMAGE tensor, batch of n). Errors are logged to the ComfyUI console. **Quick-start recipes:** diff --git a/imagent/build.py b/imagent/build.py index 856617d..eacc5c5 100644 --- a/imagent/build.py +++ b/imagent/build.py @@ -43,11 +43,11 @@ def run_generate(*, prompt, model, size, quality, background, output_format, n, log.warning("imagent: %s", _NO_KEY) return (image_io.empty_image(), _NO_KEY) try: - resolved_size = params_mod.resolve_size(size, custom_width, custom_height, model) + resolved_size = params_mod.resolve_size(size, custom_width, custom_height) resolved_background = params_mod.resolve_background(background, model) kwargs = { "model": model, "prompt": prompt, "n": n, - "size": resolved_size, "quality": quality, + "size": resolved_size, "quality": params_mod.resolve_quality(quality, model), "background": resolved_background, "output_format": output_format, } if output_format in ("jpeg", "webp"): @@ -66,7 +66,7 @@ def run_generate(*, prompt, model, size, quality, background, output_format, n, def run_edit(*, prompt, model, size, quality, background, output_format, n, - images, mask=None, input_fidelity="high", moderation="auto", + images, mask=None, moderation="auto", custom_width=1024, custom_height=1024): """Image edit / inpaint / multi-reference via images.edit. @@ -91,9 +91,8 @@ def run_edit(*, prompt, model, size, quality, background, output_format, n, return (image_io.empty_image(), msg) try: - resolved_size = params_mod.resolve_size(size, custom_width, custom_height, model) + resolved_size = params_mod.resolve_size(size, custom_width, custom_height) resolved_background = params_mod.resolve_background(background, model) - caps = params_mod.capabilities_for(model) # Downscale oversized references to fit the API's input pixel budget. ref_pils = [image_io.downscale_pil_to_pixel_limit(image_io.tensor_to_pil(t)[0]) for t in refs] @@ -101,13 +100,12 @@ def run_edit(*, prompt, model, size, quality, background, output_format, n, kwargs = { "model": model, "prompt": prompt, "image": [_named_png(p, f"ref_{i}.png") for i, p in enumerate(ref_pils)], - "n": n, "size": resolved_size, "quality": quality, + "n": n, "size": resolved_size, + "quality": params_mod.resolve_quality(quality, model), "background": resolved_background, "output_format": output_format, } if mask is not None: kwargs["mask"] = image_io.mask_to_named_png(ref_pils[0], mask, "mask.png") - if caps["input_fidelity"]: - kwargs["input_fidelity"] = input_fidelity if moderation != "auto": kwargs["moderation"] = moderation resp = oai.images.edit(**kwargs) diff --git a/imagent/client.py b/imagent/client.py index 6ce6797..5506901 100644 --- a/imagent/client.py +++ b/imagent/client.py @@ -19,15 +19,14 @@ log = logging.getLogger("imagent") # config.json lives at the repo root, next to this package directory. _CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json" -# Current OpenAI image models, verified 2026-06-07. Curated to the three current -# GPT image generations (1, 1.5, 2); dated snapshots and -mini are intentionally -# omitted. DALL-E excluded (shut down 2026-05-12). +# Current OpenAI image models, verified 2026-09-13. Dated snapshots, -mini and +# models with an announced shutdown date are intentionally omitted. MODELS = [ + "gpt-image-2.5-flare", + "gpt-image-2.5-sunburst", "gpt-image-2", - "gpt-image-1.5", - "gpt-image-1", ] -DEFAULT_MODEL = "gpt-image-2" +DEFAULT_MODEL = "gpt-image-2.5-flare" _PLACEHOLDERS = {"", "sk-...", "your_openai_api_key_here"} diff --git a/imagent/node_edit.py b/imagent/node_edit.py index 517f849..a04d30d 100644 --- a/imagent/node_edit.py +++ b/imagent/node_edit.py @@ -9,24 +9,11 @@ from comfy_api.latest import IO from . import build from . import params as params_mod -from .node_generate import _gpt_image_2_inputs, unpack_size +from .node_generate import _gpt_image_2_inputs, _gpt_image_25_inputs, unpack_size _MAX_REFS = 16 -def _legacy_edit_inputs(): - """gpt-image-1 / gpt-image-1.5: base sizes, transparent allowed, input_fidelity.""" - return [ - IO.Combo.Input("size", options=params_mod.SIZES_BASE, default="auto", - tooltip="Output size (base sizes supported by gpt-image-1.x)."), - IO.Combo.Input("background", options=params_mod.BACKGROUNDS, default="auto", - tooltip="Background handling. 'transparent' needs png or webp output."), - IO.Combo.Input("input_fidelity", options=params_mod.INPUT_FIDELITIES, default="high", - tooltip="How closely to preserve the reference image: 'high' " - "(default) or 'low'."), - ] - - class OpenAIImageEdit(IO.ComfyNode): @classmethod def define_schema(cls): @@ -44,9 +31,9 @@ class OpenAIImageEdit(IO.ComfyNode): "model", tooltip="OpenAI gpt-image model. Switching this changes the options below.", options=[ + IO.DynamicCombo.Option("gpt-image-2.5-flare", _gpt_image_25_inputs()), + IO.DynamicCombo.Option("gpt-image-2.5-sunburst", _gpt_image_25_inputs()), IO.DynamicCombo.Option("gpt-image-2", _gpt_image_2_inputs()), - IO.DynamicCombo.Option("gpt-image-1.5", _legacy_edit_inputs()), - IO.DynamicCombo.Option("gpt-image-1", _legacy_edit_inputs()), ], ), IO.Autogrow.Input( @@ -59,7 +46,8 @@ class OpenAIImageEdit(IO.ComfyNode): "(if used) applies to the first image and requires exactly one image."), IO.Combo.Input("quality", options=params_mod.QUALITIES, default="auto", tooltip="Rendering quality. 'auto' lets the model decide; " - "higher quality costs more."), + "higher quality costs more. 'xhigh' and 'max' need " + "gpt-image-2.5 and fall back to 'high' elsewhere."), IO.Combo.Input("output_format", options=params_mod.FORMATS, default="png", tooltip="Image file format returned by the API."), IO.Combo.Input("moderation", options=params_mod.MODERATIONS, default="auto", @@ -82,7 +70,6 @@ class OpenAIImageEdit(IO.ComfyNode): img, _info = build.run_edit( prompt=prompt, model=model["model"], size=size, quality=quality, background=model["background"], output_format=output_format, n=n, - images=ref_tensors, mask=mask, - input_fidelity=model.get("input_fidelity", "high"), moderation=moderation, + images=ref_tensors, mask=mask, moderation=moderation, custom_width=custom_width, custom_height=custom_height) return IO.NodeOutput(img) diff --git a/imagent/node_generate.py b/imagent/node_generate.py index 2d1d396..41159d0 100644 --- a/imagent/node_generate.py +++ b/imagent/node_generate.py @@ -40,11 +40,10 @@ def _gpt_image_2_inputs(): ] -def _legacy_inputs(): - """Per-model widgets for gpt-image-1 / gpt-image-1.5: base sizes, transparent allowed.""" +def _gpt_image_25_inputs(): + """Per-model widgets for gpt-image-2.5: hi-res/custom sizes, transparent allowed.""" return [ - IO.Combo.Input("size", options=params_mod.SIZES_BASE, default="auto", - tooltip="Output size (base sizes supported by gpt-image-1.x)."), + _size_combo_gpt2(), IO.Combo.Input("background", options=params_mod.BACKGROUNDS, default="auto", tooltip="Background handling. 'transparent' needs png or webp output."), ] @@ -53,13 +52,10 @@ def _legacy_inputs(): def unpack_size(model: dict): """Pull (size, custom_width, custom_height) from a model dict. - For gpt-image-2 `size` is a nested DynamicCombo (dict); for gpt-image-1.x it's - a plain combo (string). + `size` is itself a nested DynamicCombo, so it arrives as a dict. """ sz = model["size"] - if isinstance(sz, dict): - return sz["size"], sz.get("custom_width", 1024), sz.get("custom_height", 1024) - return sz, 1024, 1024 + return sz["size"], sz.get("custom_width", 1024), sz.get("custom_height", 1024) class OpenAIImageGenerate(IO.ComfyNode): @@ -78,14 +74,15 @@ class OpenAIImageGenerate(IO.ComfyNode): "model", tooltip="OpenAI gpt-image model. Switching this changes the options below.", options=[ + IO.DynamicCombo.Option("gpt-image-2.5-flare", _gpt_image_25_inputs()), + IO.DynamicCombo.Option("gpt-image-2.5-sunburst", _gpt_image_25_inputs()), IO.DynamicCombo.Option("gpt-image-2", _gpt_image_2_inputs()), - IO.DynamicCombo.Option("gpt-image-1.5", _legacy_inputs()), - IO.DynamicCombo.Option("gpt-image-1", _legacy_inputs()), ], ), IO.Combo.Input("quality", options=params_mod.QUALITIES, default="auto", tooltip="Rendering quality. 'auto' lets the model decide; " - "higher quality costs more."), + "higher quality costs more. 'xhigh' and 'max' need " + "gpt-image-2.5 and fall back to 'high' elsewhere."), IO.Combo.Input("output_format", options=params_mod.FORMATS, default="png", tooltip="Image file format returned by the API."), IO.Int.Input("output_compression", default=100, min=0, max=100, diff --git a/imagent/params.py b/imagent/params.py index ab566e0..df35745 100644 --- a/imagent/params.py +++ b/imagent/params.py @@ -1,8 +1,8 @@ """Parameter constants, per-model capabilities, and size validation. Public entry points: capabilities_for(), validate_custom_dimensions(), resolve_size(), -resolve_background(); plus the SIZE_PRESETS / QUALITIES / BACKGROUNDS / FORMATS / -INPUT_FIDELITIES / MODERATIONS lists and the CUSTOM_DIM_* widget bounds. +resolve_quality(), resolve_background(); plus the SIZE_PRESETS / QUALITIES / +BACKGROUNDS / FORMATS / MODERATIONS lists and the CUSTOM_DIM_* widget bounds. """ from __future__ import annotations @@ -10,16 +10,13 @@ import logging log = logging.getLogger("imagent") -# Size presets. The base sizes work on every gpt-image model; the high-res presets -# and 'custom' are gpt-image-2 only (gated in resolve_size to stay cross-model safe). +# Size presets, all supported by every model in the catalog. _BASE_SIZES = ["auto", "1024x1024", "1024x1536", "1536x1024"] _GPT2_SIZES = ["2048x2048", "2048x1152", "1152x2048", "3840x2160", "2160x3840"] -_GPT2_ONLY_SIZES = set(_GPT2_SIZES) SIZE_PRESETS = [*_BASE_SIZES, *_GPT2_SIZES, "custom"] -# Per-model size option lists for the IO DynamicCombo (what each model can pick). -SIZES_BASE = list(_BASE_SIZES) # gpt-image-1 / 1.5 -SIZES_GPT2 = [*_BASE_SIZES, *_GPT2_SIZES, "custom"] # gpt-image-2 +# Size option list for the IO DynamicCombo (what each model can pick). +SIZES_GPT2 = [*_BASE_SIZES, *_GPT2_SIZES, "custom"] # gpt-image-2 / 2.5 # custom_width / custom_height INT-widget bounds (mirror the official node). CUSTOM_DIM_MIN, CUSTOM_DIM_MAX, CUSTOM_DIM_STEP = 1024, 3840, 16 @@ -28,29 +25,27 @@ CUSTOM_DIM_MIN, CUSTOM_DIM_MAX, CUSTOM_DIM_STEP = 1024, 3840, 16 BACKGROUNDS = ["auto", "opaque", "transparent"] BACKGROUNDS_GPT2 = ["auto", "opaque"] -QUALITIES = ["auto", "low", "medium", "high"] +# 'xhigh' and 'max' exist only on gpt-image-2.5 (gated in resolve_quality). +_EXTENDED_QUALITIES = {"xhigh", "max"} +QUALITIES = ["auto", "low", "medium", "high", "xhigh", "max"] FORMATS = ["png", "jpeg", "webp"] -INPUT_FIDELITIES = ["high", "low"] MODERATIONS = ["auto", "low"] # Widget bounds shared by both nodes' IO schemas. N_MIN, N_MAX = 1, 8 # Per-model capability flags. Keys (all default False for unknown models): -# custom_size - arbitrary WxH sizes (gpt-image-2 only) -# input_fidelity - edit input_fidelity param (gpt-image-1.x) # transparent_background - background="transparent" (NOT gpt-image-2, which errors) -_DEFAULT_CAPS = { - "custom_size": False, "input_fidelity": False, "transparent_background": False, -} +# extended_quality - quality="xhigh" / "max" (gpt-image-2.5 only) +_DEFAULT_CAPS = {"transparent_background": False, "extended_quality": False} CAPABILITIES: dict[str, dict[str, bool]] = { - "gpt-image-2": {"custom_size": True, "input_fidelity": False, "transparent_background": False}, - "gpt-image-1.5": {"custom_size": False, "input_fidelity": True, "transparent_background": True}, - "gpt-image-1": {"custom_size": False, "input_fidelity": True, "transparent_background": True}, + "gpt-image-2.5-sunburst": {"transparent_background": True, "extended_quality": True}, + "gpt-image-2.5-flare": {"transparent_background": True, "extended_quality": True}, + "gpt-image-2": {"transparent_background": False, "extended_quality": False}, } -# gpt-image-2 custom-resolution constraints (mirror the official node, 2026-06-08). +# Custom-resolution constraints, shared by gpt-image-2 and gpt-image-2.5. _MAX_EDGE = 3840 _MIN_TOTAL_PX = 655_360 _MAX_TOTAL_PX = 8_294_400 @@ -86,29 +81,30 @@ def validate_custom_dimensions(width: int, height: int) -> str: return f"{w}x{h}" -def resolve_size(size: str, custom_width: int, custom_height: int, model: str) -> str: - """Resolve the size parameter to a value the selected model accepts. - - 'custom' and the high-res presets are gpt-image-2 only; for other models they - fall back to 'auto' so switching models never sends an unsupported size. - """ - caps = capabilities_for(model) +def resolve_size(size: str, custom_width: int, custom_height: int) -> str: + """Resolve the size parameter, validating 'custom' against the WxH rules.""" if size == "custom": - if not caps["custom_size"]: - log.info("imagent: %s does not support custom sizes; using 'auto'.", model) - return "auto" return validate_custom_dimensions(custom_width, custom_height) - if size in _GPT2_ONLY_SIZES and not caps["custom_size"]: - log.info("imagent: size %s is gpt-image-2 only; using 'auto' for %s.", size, model) - return "auto" return size +def resolve_quality(quality: str, model: str) -> str: + """Drop an unsupported extended quality tier to 'high'. + + 'xhigh' and 'max' are gpt-image-2.5 only; older models error on them, so they + fall back to 'high' when the model is switched. + """ + if quality in _EXTENDED_QUALITIES and not capabilities_for(model)["extended_quality"]: + log.info("imagent: %s does not support quality '%s'; using 'high'.", model, quality) + return "high" + return quality + + def resolve_background(background: str, model: str) -> str: """Drop an unsupported transparent background to 'auto'. - gpt-image-2 errors on background='transparent'; only gpt-image-1.x supports - it. Falling back to 'auto' keeps the node compatible across model switches. + gpt-image-2 errors on background='transparent'; gpt-image-2.5 supports it. + Falling back to 'auto' keeps the node compatible across model switches. """ if background == "transparent" and not capabilities_for(model)["transparent_background"]: log.info("imagent: %s does not support a transparent background; using 'auto'.", model) diff --git a/tests/test_client.py b/tests/test_client.py index 417a644..2ca4c6f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -32,10 +32,15 @@ def test_placeholder_key_is_ignored(monkeypatch, tmp_path): def test_model_catalog_defaults(): - assert client.DEFAULT_MODEL == "gpt-image-2" - assert set(client.MODELS) == {"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} + assert client.DEFAULT_MODEL == "gpt-image-2.5-flare" + assert set(client.MODELS) == {"gpt-image-2", "gpt-image-2.5-flare", + "gpt-image-2.5-sunburst"} assert "gpt-image-1-mini" not in client.MODELS assert "gpt-image-2-2026-04-21" not in client.MODELS + assert "gpt-image-2.5-flare-2026-09-08" not in client.MODELS + # Retired: both shut down before this release's models do. + assert "gpt-image-1" not in client.MODELS + assert "gpt-image-1.5" not in client.MODELS assert all("dall-e" not in m for m in client.MODELS) diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 4b725c7..fe08306 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -70,11 +70,25 @@ def test_generate_drops_transparent_background_for_gpt_image_2(monkeypatch): assert rec["generate"]["background"] == "auto" # gpt-image-2 can't do transparent -def test_generate_keeps_transparent_background_for_gpt_image_1(monkeypatch): +def test_generate_extended_quality_gated_to_gpt_image_25(monkeypatch): rec = {} monkeypatch.setattr(client, "get_client", lambda: FakeClient(rec)) run_generate( - prompt="x", model="gpt-image-1", size="auto", quality="auto", + prompt="x", model="gpt-image-2.5-sunburst", size="auto", quality="max", + background="auto", output_format="png", n=1) + assert rec["generate"]["quality"] == "max" + rec.clear() + run_generate( + prompt="x", model="gpt-image-2", size="auto", quality="max", + background="auto", output_format="png", n=1) + assert rec["generate"]["quality"] == "high" + + +def test_generate_keeps_transparent_background_for_gpt_image_25(monkeypatch): + rec = {} + monkeypatch.setattr(client, "get_client", lambda: FakeClient(rec)) + run_generate( + prompt="x", model="gpt-image-2.5-flare", size="auto", quality="auto", background="transparent", output_format="png", n=1) assert rec["generate"]["background"] == "transparent" @@ -144,20 +158,6 @@ def test_generate_custom_dimensions_for_gpt_image_2(monkeypatch): assert rec["generate"]["size"] == "2048x1152" -def test_generate_hires_preset_gated_to_gpt_image_2(monkeypatch): - rec = {} - monkeypatch.setattr(client, "get_client", lambda: FakeClient(rec)) - run_generate( - prompt="x", model="gpt-image-1", size="2048x2048", quality="auto", - background="auto", output_format="png", n=1) - assert rec["generate"]["size"] == "auto" # gpt-image-1 can't do hi-res - rec.clear() - run_generate( - prompt="x", model="gpt-image-2", size="2048x2048", quality="auto", - background="auto", output_format="png", n=1) - assert rec["generate"]["size"] == "2048x2048" - - # --------------------------------------------------------------------------- # OpenAIImageEdit tests # --------------------------------------------------------------------------- @@ -194,22 +194,6 @@ def test_edit_includes_mask_when_provided(monkeypatch): assert "mask" in rec["edit"] -def test_edit_input_fidelity_only_for_1x(monkeypatch): - rec = {} - monkeypatch.setattr(client, "get_client", lambda: FakeClient(rec)) - run_edit( - prompt="x", model="gpt-image-1", size="auto", quality="auto", - background="auto", output_format="png", n=1, - images=[_img_tensor()], input_fidelity="high") - assert rec["edit"]["input_fidelity"] == "high" - rec.clear() - run_edit( - prompt="x", model="gpt-image-2", size="auto", quality="auto", - background="auto", output_format="png", n=1, - images=[_img_tensor()], input_fidelity="high") - assert "input_fidelity" not in rec["edit"] - - def test_edit_moderation_only_when_not_auto(monkeypatch): rec = {} monkeypatch.setattr(client, "get_client", lambda: FakeClient(rec)) @@ -271,6 +255,20 @@ def test_edit_mask_with_multiple_refs_returns_error(monkeypatch): assert info.startswith("Error") +def test_model_widget_matches_catalog_and_gpt_image_25_options(): + import pytest + pytest.importorskip("comfy_api") + from imagent.node_edit import OpenAIImageEdit + from imagent.node_generate import OpenAIImageGenerate + for node in (OpenAIImageGenerate, OpenAIImageEdit): + model_input = next(i for i in node.define_schema().inputs if i.id == "model") + assert [o.key for o in model_input.options] == client.MODELS + for key in ("gpt-image-2.5-flare", "gpt-image-2.5-sunburst"): + subs = {i.id: i for i in next(o for o in model_input.options if o.key == key).inputs} + assert "transparent" in subs["background"].options + assert "custom" in [o.key for o in subs["size"].options] + + def test_extension_registers_both_nodes(): # Registration uses the comfy_api V3 entrypoint, so this needs ComfyUI present. import pytest diff --git a/tests/test_params.py b/tests/test_params.py index 7d586d0..295e268 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -11,22 +11,32 @@ def test_constants(): for hires in ("2048x2048", "3840x2160", "2160x3840"): assert hires in params.SIZE_PRESETS assert (params.CUSTOM_DIM_MIN, params.CUSTOM_DIM_MAX, params.CUSTOM_DIM_STEP) == (1024, 3840, 16) - assert params.QUALITIES == ["auto", "low", "medium", "high"] + assert params.QUALITIES == ["auto", "low", "medium", "high", "xhigh", "max"] assert params.BACKGROUNDS == ["auto", "opaque", "transparent"] assert params.FORMATS == ["png", "jpeg", "webp"] assert params.MODERATIONS == ["auto", "low"] def test_capabilities_for_known_and_unknown(): - assert params.capabilities_for("gpt-image-2")["custom_size"] is True - assert params.capabilities_for("gpt-image-2")["input_fidelity"] is False - assert params.capabilities_for("gpt-image-2")["transparent_background"] is False - assert params.capabilities_for("gpt-image-1")["input_fidelity"] is True - assert params.capabilities_for("gpt-image-1")["custom_size"] is False - assert params.capabilities_for("gpt-image-1")["transparent_background"] is True - assert params.capabilities_for("gpt-image-1.5")["transparent_background"] is True + assert params.capabilities_for("gpt-image-2") == { + "transparent_background": False, "extended_quality": False} assert params.capabilities_for("mystery") == { - "custom_size": False, "input_fidelity": False, "transparent_background": False} + "transparent_background": False, "extended_quality": False} + + +@pytest.mark.parametrize("model", ["gpt-image-2.5-flare", "gpt-image-2.5-sunburst"]) +def test_gpt_image_25_capabilities(model): + assert params.capabilities_for(model) == { + "transparent_background": True, "extended_quality": True} + + +def test_resolve_quality_gates_extended_tiers(): + assert params.resolve_quality("max", "gpt-image-2.5-sunburst") == "max" + assert params.resolve_quality("xhigh", "gpt-image-2.5-flare") == "xhigh" + assert params.resolve_quality("xhigh", "gpt-image-2") == "high" + assert params.resolve_quality("max", "mystery") == "high" + assert params.resolve_quality("high", "gpt-image-2") == "high" + assert params.resolve_quality("auto", "gpt-image-2") == "auto" def test_resolve_background_drops_transparent_for_gpt_image_2(): @@ -35,11 +45,6 @@ def test_resolve_background_drops_transparent_for_gpt_image_2(): assert params.resolve_background("auto", "gpt-image-2") == "auto" -def test_resolve_background_keeps_transparent_for_gpt_image_1x(): - assert params.resolve_background("transparent", "gpt-image-1") == "transparent" - assert params.resolve_background("transparent", "gpt-image-1.5") == "transparent" - - def test_validate_custom_dimensions_ok(): assert params.validate_custom_dimensions(1536, 864) == "1536x864" assert params.validate_custom_dimensions(2048, 1152) == "2048x1152" @@ -60,18 +65,10 @@ def test_validate_custom_dimensions_rejects(w, h): params.validate_custom_dimensions(w, h) -def test_resolve_size_passthrough_for_base_presets(): - assert params.resolve_size("1024x1024", 1024, 1024, "gpt-image-2") == "1024x1024" - assert params.resolve_size("auto", 1024, 1024, "gpt-image-1") == "auto" - - -def test_resolve_size_custom_requires_capability(): - assert params.resolve_size("custom", 1536, 864, "gpt-image-2") == "1536x864" - assert params.resolve_size("custom", 1536, 864, "gpt-image-1") == "auto" - - -def test_resolve_size_hires_presets_gated_to_gpt_image_2(): - assert params.resolve_size("2048x2048", 1024, 1024, "gpt-image-2") == "2048x2048" - assert params.resolve_size("3840x2160", 1024, 1024, "gpt-image-2") == "3840x2160" - assert params.resolve_size("2048x2048", 1024, 1024, "gpt-image-1") == "auto" - assert params.resolve_size("3840x2160", 1024, 1024, "gpt-image-1.5") == "auto" +def test_resolve_size_passes_presets_through_and_validates_custom(): + assert params.resolve_size("auto", 1024, 1024) == "auto" + assert params.resolve_size("1024x1024", 1024, 1024) == "1024x1024" + assert params.resolve_size("3840x2160", 1024, 1024) == "3840x2160" + assert params.resolve_size("custom", 1536, 864) == "1536x864" + with pytest.raises(ValueError): + params.resolve_size("custom", 1000, 1000) From e8fdd92f2163bd684f58f76e46e09c1e0b6bf01a Mon Sep 17 00:00:00 2001 From: Alexander Garzon Date: Mon, 14 Sep 2026 11:31:32 -0400 Subject: [PATCH 2/3] fix: carry alpha out as a MASK output and lower the custom size floor Manual testing in ComfyUI surfaced two defects in the gpt-image-2.5 work. background=transparent was silently useless: the API returned a correct RGBA PNG, but pil_to_tensor ran convert("RGB") on every response and composited the alpha onto black. Both nodes now output (IMAGE, MASK), with the alpha carried on the mask as 1 - alpha to match the LoadImage convention, so Join Image with Alpha consumes it directly. The three response decode helpers collapsed into response_item_to_pil, since the old path already decoded each item to a PIL image and then discarded the alpha -- routing through a single decode was smaller than adding a second one. CUSTOM_DIM_MIN was 1024 while validate_custom_dimensions() accepts a short edge down to 480 (1440x480 at the 3:1 aspect limit). The widget was a second, stricter gate that disagreed with the validator, so legal sizes like 1536x864 snapped up to 1024 and were unreachable from the UI. The floor is now derived from what the validator accepts. The existing tests missed both: they assert on request kwargs and never checked that alpha survives the response, and they called run_generate() directly, bypassing the widget bounds entirely. Verified against the live API (transparent output recomposes to a fully transparent corner pixel) and by manual testing in ComfyUI. --- CHANGELOG.md | 11 ++++++++ README.md | 20 +++++++++++--- imagent/build.py | 27 +++++++++---------- imagent/image_io.py | 56 +++++++++++++++++++++++----------------- imagent/node_edit.py | 7 ++--- imagent/node_generate.py | 7 ++--- imagent/params.py | 6 +++-- tests/test_image_io.py | 40 +++++++++++++++++++--------- tests/test_nodes.py | 16 ++++++------ tests/test_params.py | 4 ++- 10 files changed, 125 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e12381..b1635dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ - **gpt-image-2.5** — `gpt-image-2.5-flare` (new default) and `gpt-image-2.5-sunburst`, with custom `WxH` sizes, `transparent` background, and the new `xhigh` / `max` quality tiers. gpt-image-2 falls back to `high` when an extended tier is selected. +- **`mask` output on both nodes** — ComfyUI IMAGE is RGB, so a `transparent` background used to + be flattened onto black and lost. Alpha now comes out as a MASK (`1 - alpha`, matching + `LoadImage`); pair it with **Join Image with Alpha** to save a transparent PNG. Opaque output + yields an all-zero mask. **Both nodes gained a second output, so saved workflows will show the + node as changed.** + +### Fixed + +- **`custom_width` / `custom_height` minimum lowered from 1024 to 480** — the widget floor was + stricter than `validate_custom_dimensions()`, so legal sizes such as `1536x864` and `1440x480` + could not be entered and silently snapped up to 1024. ### Removed diff --git a/README.md b/README.md index 39253d3..316ea57 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Text-to-image generation via `images.generate`. | `prompt` | STRING | Text description of the image to generate | | `model` | **DynamicCombo** | `gpt-image-2.5-flare` (default), `gpt-image-2.5-sunburst`, `gpt-image-2`. Switching the model swaps the options below. | | ↳ `size` | COMBO | `auto`, the three base sizes, five high-res presets, and `custom`. | -| ↳↳ `custom_width` / `custom_height` | INT | **Appear only when `size = custom`.** 1024–3840, step 16; both multiples of 16; aspect ≤ 3:1; total pixels 655,360–8,294,400. | +| ↳↳ `custom_width` / `custom_height` | INT | **Appear only when `size = custom`.** 480–3840, step 16; both multiples of 16; aspect ≤ 3:1; total pixels 655,360–8,294,400. | | ↳ `background` | COMBO | **gpt-image-2.5:** `auto`, `opaque`, `transparent` (needs `png`/`webp`). **gpt-image-2:** `auto`, `opaque`. | | `quality` | COMBO | `auto`, `low`, `medium`, `high`, plus `xhigh` / `max` on gpt-image-2.5 (other models fall back to `high`). | | `output_format` | COMBO | `png`, `jpeg`, `webp` | @@ -77,7 +77,14 @@ Rows marked ↳ live inside the dynamic `model` widget (appear only for models t > No `seed` widget: OpenAI's image API has no seed, so it can't make outputs reproducible. ComfyUI's normal input-based caching applies — re-queuing an unchanged graph returns the cached image; change the prompt (or any input) to regenerate. -**Output:** `image` (IMAGE tensor, batch of n). Errors are logged to the ComfyUI console. +**Outputs:** `image` (IMAGE tensor, batch of n) and `mask` (MASK tensor). Errors are logged to the ComfyUI console. + +ComfyUI's IMAGE type is RGB, so when `background = transparent` the alpha channel comes out on +`mask` instead — following the `LoadImage` convention, `mask = 1 - alpha`, meaning a transparent +region reads as selected (1.0). The `image` preview will look black in those regions; that is the +alpha being discarded for display, not a failed generation. To get a transparent file, feed +`image` and `mask` into **Join Image with Alpha** (it inverts the mask internally, so no +`InvertMask` in between) and save the result. Opaque output gives an all-zero mask. --- @@ -103,7 +110,14 @@ Rows marked ↳ live inside the dynamic `model` widget; ↳↳ rows appear only > No `input_fidelity` widget: every model shipped here always uses high input fidelity, and passing the parameter is an error. It only applied to the retired gpt-image-1.x models. -**Output:** `image` (IMAGE tensor, batch of n). Errors are logged to the ComfyUI console. +**Outputs:** `image` (IMAGE tensor, batch of n) and `mask` (MASK tensor). Errors are logged to the ComfyUI console. + +ComfyUI's IMAGE type is RGB, so when `background = transparent` the alpha channel comes out on +`mask` instead — following the `LoadImage` convention, `mask = 1 - alpha`, meaning a transparent +region reads as selected (1.0). The `image` preview will look black in those regions; that is the +alpha being discarded for display, not a failed generation. To get a transparent file, feed +`image` and `mask` into **Join Image with Alpha** (it inverts the mask internally, so no +`InvertMask` in between) and save the result. Opaque output gives an all-zero mask. **Quick-start recipes:** - **Edit:** connect one image to `images`, write a `prompt`, leave `mask` disconnected. diff --git a/imagent/build.py b/imagent/build.py index eacc5c5..d9d0e7e 100644 --- a/imagent/build.py +++ b/imagent/build.py @@ -35,13 +35,14 @@ def run_generate(*, prompt, model, size, quality, background, output_format, n, output_compression=100): """Text-to-image via images.generate. - Returns (image_tensor, info_string). Never raises: a failed call yields an - empty image plus a friendly error so a bad call can't crash the queue. + Returns (image_tensor, mask_tensor, info_string); the mask carries the + alpha channel when background='transparent'. Never raises: a failed call + yields an empty image plus a friendly error so a bad call can't crash the queue. """ oai = client_mod.get_client() if oai is None: log.warning("imagent: %s", _NO_KEY) - return (image_io.empty_image(), _NO_KEY) + return (image_io.empty_image(), image_io.empty_mask(), _NO_KEY) try: resolved_size = params_mod.resolve_size(size, custom_width, custom_height) resolved_background = params_mod.resolve_background(background, model) @@ -56,13 +57,13 @@ def run_generate(*, prompt, model, size, quality, background, output_format, n, kwargs["moderation"] = moderation resp = oai.images.generate(**kwargs) if not resp.data: - return (image_io.empty_image(), _NO_IMAGES) + return (image_io.empty_image(), image_io.empty_mask(), _NO_IMAGES) info = getattr(resp.data[0], "revised_prompt", None) \ or f"Generated {len(resp.data)} image(s) with {model}." - return (image_io.batch_from_response_data(resp.data), info) + return (*image_io.batch_from_response_data(resp.data), info) except Exception as exc: # noqa: BLE001 - node must not crash the queue log.exception("imagent: image generation failed") - return (image_io.empty_image(), client_mod.friendly_error(exc)) + return (image_io.empty_image(), image_io.empty_mask(), client_mod.friendly_error(exc)) def run_edit(*, prompt, model, size, quality, background, output_format, n, @@ -71,24 +72,24 @@ def run_edit(*, prompt, model, size, quality, background, output_format, n, """Image edit / inpaint / multi-reference via images.edit. `images` is an iterable of IMAGE tensors (None entries are ignored). - Returns (image_tensor, info_string); never raises (see run_generate). + Returns (image_tensor, mask_tensor, info_string); never raises (see run_generate). """ oai = client_mod.get_client() if oai is None: log.warning("imagent: %s", _NO_KEY) - return (image_io.empty_image(), _NO_KEY) + return (image_io.empty_image(), image_io.empty_mask(), _NO_KEY) refs = [t for t in (images or []) if t is not None] if not refs: msg = "Error: OpenAIImageEdit requires at least one reference image." log.warning("imagent: %s", msg) - return (image_io.empty_image(), msg) + return (image_io.empty_image(), image_io.empty_mask(), msg) if mask is not None and len(refs) > 1: msg = ("Error: mask inpainting requires a single reference image " "(connect only one image).") log.warning("imagent: %s", msg) - return (image_io.empty_image(), msg) + return (image_io.empty_image(), image_io.empty_mask(), msg) try: resolved_size = params_mod.resolve_size(size, custom_width, custom_height) @@ -110,10 +111,10 @@ def run_edit(*, prompt, model, size, quality, background, output_format, n, kwargs["moderation"] = moderation resp = oai.images.edit(**kwargs) if not resp.data: - return (image_io.empty_image(), _NO_IMAGES) + return (image_io.empty_image(), image_io.empty_mask(), _NO_IMAGES) info = getattr(resp.data[0], "revised_prompt", None) \ or f"Edited image with {model}." - return (image_io.batch_from_response_data(resp.data), info) + return (*image_io.batch_from_response_data(resp.data), info) except Exception as exc: # noqa: BLE001 - node must not crash the queue log.exception("imagent: image edit failed") - return (image_io.empty_image(), client_mod.friendly_error(exc)) + return (image_io.empty_image(), image_io.empty_mask(), client_mod.friendly_error(exc)) diff --git a/imagent/image_io.py b/imagent/image_io.py index 2426674..08c3f53 100644 --- a/imagent/image_io.py +++ b/imagent/image_io.py @@ -1,9 +1,8 @@ """Conversions between ComfyUI tensors, PIL images, masks, and base64 payloads. -Public helpers: pil_to_tensor, tensor_to_pil, b64_to_tensor, empty_image, -pil_to_png_bytes, mask_to_alpha_png_bytes, mask_to_named_png, -downscale_pil_to_pixel_limit, url_to_tensor, response_item_to_tensor, -batch_from_response_data. +Public helpers: pil_to_tensor, pil_to_image_and_mask, tensor_to_pil, empty_image, +empty_mask, pil_to_png_bytes, mask_to_alpha_png_bytes, mask_to_named_png, +downscale_pil_to_pixel_limit, response_item_to_pil, batch_from_response_data. """ from __future__ import annotations @@ -33,11 +32,19 @@ def tensor_to_pil(image_tensor: torch.Tensor) -> list[Image.Image]: return out -def b64_to_tensor(b64_str: str) -> torch.Tensor: - """Base64 PNG/JPEG/WebP -> [1, H, W, 3] float32 tensor.""" - data = base64.b64decode(b64_str) - pil = Image.open(io.BytesIO(data)) - return pil_to_tensor(pil) +def pil_to_image_and_mask(pil_image: Image.Image) -> tuple[torch.Tensor, torch.Tensor]: + """PIL image -> (IMAGE [1, H, W, 3], MASK [1, H, W]). + + Alpha is carried out as a MASK because ComfyUI IMAGE is RGB-only: following + the LoadImage convention, mask = 1 - alpha, so a transparent region reads as + selected (1.0). Images without an alpha channel yield an all-zero mask. + """ + if "A" in pil_image.getbands(): + alpha = np.array(pil_image.getchannel("A")).astype(np.float32) / 255.0 + mask = 1.0 - torch.from_numpy(alpha)[None, ] + else: + mask = empty_mask(pil_image.height, pil_image.width) + return pil_to_tensor(pil_image), mask def empty_image(height: int = 512, width: int = 512) -> torch.Tensor: @@ -45,6 +52,11 @@ def empty_image(height: int = 512, width: int = 512) -> torch.Tensor: return torch.zeros((1, height, width, 3), dtype=torch.float32) +def empty_mask(height: int = 512, width: int = 512) -> torch.Tensor: + """All-zero (fully opaque) MASK tensor.""" + return torch.zeros((1, height, width), dtype=torch.float32) + + def pil_to_png_bytes(pil_image: Image.Image) -> bytes: """PIL image -> PNG bytes.""" buf = io.BytesIO() @@ -96,27 +108,23 @@ def downscale_pil_to_pixel_limit(pil_image: Image.Image, return pil_image.resize((max(1, int(w * scale)), max(1, int(h * scale)))) -def url_to_tensor(url: str) -> torch.Tensor: - """Download an image URL and convert it to a [1, H, W, 3] tensor.""" - import urllib.request - with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 - caller owns URL provenance - data = resp.read() - return pil_to_tensor(Image.open(io.BytesIO(data))) - - -def response_item_to_tensor(item) -> torch.Tensor: - """Convert an OpenAI image response item (b64_json or url) to a tensor.""" +def response_item_to_pil(item) -> Image.Image: + """Decode an OpenAI image response item (b64_json or url) to a PIL image.""" b64 = getattr(item, "b64_json", None) if b64: - return b64_to_tensor(b64) + return Image.open(io.BytesIO(base64.b64decode(b64))) url = getattr(item, "url", None) if url: - return url_to_tensor(url) + import urllib.request + with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 - caller owns URL provenance + return Image.open(io.BytesIO(resp.read())) raise ValueError("response item has neither b64_json nor url") -def batch_from_response_data(data: list) -> torch.Tensor: - """OpenAI response `.data` list -> [B, H, W, 3] batch tensor (b64 or url items).""" +def batch_from_response_data(data: list) -> tuple[torch.Tensor, torch.Tensor]: + """OpenAI response `.data` list -> ([B, H, W, 3] IMAGE, [B, H, W] MASK) batches.""" if not data: raise ValueError("data must contain at least one response item") - return torch.cat([response_item_to_tensor(d) for d in data], dim=0) + pairs = [pil_to_image_and_mask(response_item_to_pil(d)) for d in data] + return (torch.cat([img for img, _ in pairs], dim=0), + torch.cat([mask for _, mask in pairs], dim=0)) diff --git a/imagent/node_edit.py b/imagent/node_edit.py index a04d30d..926f9ad 100644 --- a/imagent/node_edit.py +++ b/imagent/node_edit.py @@ -59,7 +59,8 @@ class OpenAIImageEdit(IO.ComfyNode): tooltip="Inpaint mask: white marks the region to edit. Requires a " "single reference image."), ], - outputs=[IO.Image.Output(display_name="image")], + outputs=[IO.Image.Output(display_name="image"), + IO.Mask.Output(display_name="mask")], ) @classmethod @@ -67,9 +68,9 @@ class OpenAIImageEdit(IO.ComfyNode): mask=None) -> IO.NodeOutput: size, custom_width, custom_height = unpack_size(model) ref_tensors = [t for t in (images or {}).values() if t is not None] - img, _info = build.run_edit( + img, out_mask, _info = build.run_edit( prompt=prompt, model=model["model"], size=size, quality=quality, background=model["background"], output_format=output_format, n=n, images=ref_tensors, mask=mask, moderation=moderation, custom_width=custom_width, custom_height=custom_height) - return IO.NodeOutput(img) + return IO.NodeOutput(img, out_mask) diff --git a/imagent/node_generate.py b/imagent/node_generate.py index 41159d0..68d994d 100644 --- a/imagent/node_generate.py +++ b/imagent/node_generate.py @@ -93,16 +93,17 @@ class OpenAIImageGenerate(IO.ComfyNode): IO.Int.Input("n", default=1, min=params_mod.N_MIN, max=params_mod.N_MAX, step=1, tooltip="How many images to generate (1-8)."), ], - outputs=[IO.Image.Output(display_name="image")], + outputs=[IO.Image.Output(display_name="image"), + IO.Mask.Output(display_name="mask")], ) @classmethod def execute(cls, prompt, model, quality, output_format, output_compression, moderation, n) -> IO.NodeOutput: size, custom_width, custom_height = unpack_size(model) - img, _info = build.run_generate( + img, mask, _info = build.run_generate( prompt=prompt, model=model["model"], size=size, quality=quality, background=model["background"], output_format=output_format, n=n, moderation=moderation, custom_width=custom_width, custom_height=custom_height, output_compression=output_compression) - return IO.NodeOutput(img) + return IO.NodeOutput(img, mask) diff --git a/imagent/params.py b/imagent/params.py index df35745..a0e3419 100644 --- a/imagent/params.py +++ b/imagent/params.py @@ -18,8 +18,10 @@ SIZE_PRESETS = [*_BASE_SIZES, *_GPT2_SIZES, "custom"] # Size option list for the IO DynamicCombo (what each model can pick). SIZES_GPT2 = [*_BASE_SIZES, *_GPT2_SIZES, "custom"] # gpt-image-2 / 2.5 -# custom_width / custom_height INT-widget bounds (mirror the official node). -CUSTOM_DIM_MIN, CUSTOM_DIM_MAX, CUSTOM_DIM_STEP = 1024, 3840, 16 +# custom_width / custom_height INT-widget bounds. The floor is the smallest edge +# validate_custom_dimensions() can accept (480x1440 at the 3:1 aspect limit); a +# higher floor would block sizes the validator considers legal. +CUSTOM_DIM_MIN, CUSTOM_DIM_MAX, CUSTOM_DIM_STEP = 480, 3840, 16 # Per-model background option lists (gpt-image-2 cannot do transparent). BACKGROUNDS = ["auto", "opaque", "transparent"] diff --git a/tests/test_image_io.py b/tests/test_image_io.py index 021e37b..ef8cdb0 100644 --- a/tests/test_image_io.py +++ b/tests/test_image_io.py @@ -24,9 +24,26 @@ def test_pil_to_tensor_shape_and_range(): assert abs(float(t[0, 0, 0, 0]) - 1.0) < 1e-6 # red channel -def test_b64_to_tensor_roundtrip(): - t = image_io.b64_to_tensor(_png_b64(size=(32, 48))) - assert t.shape == (1, 48, 32, 3) +def test_response_item_to_pil_roundtrip(): + item = types.SimpleNamespace(b64_json=_png_b64(size=(32, 48)), url=None) + assert image_io.response_item_to_pil(item).size == (32, 48) + + +def test_rgba_alpha_becomes_mask(): + rgba = Image.new("RGBA", (4, 4), (255, 0, 0, 255)) + rgba.putalpha(Image.fromarray( + np.array([[0, 0, 255, 255]] * 4, dtype=np.uint8), "L")) + img, mask = image_io.pil_to_image_and_mask(rgba) + assert img.shape == (1, 4, 4, 3) + assert mask.shape == (1, 4, 4) + assert float(mask[0, 0, 0]) == 1.0 # transparent -> selected + assert float(mask[0, 0, 3]) == 0.0 # opaque -> unselected + + +def test_rgb_without_alpha_yields_zero_mask(): + _img, mask = image_io.pil_to_image_and_mask(Image.new("RGB", (8, 4), (1, 2, 3))) + assert mask.shape == (1, 4, 8) + assert float(mask.abs().sum()) == 0.0 def test_empty_image_is_black_rgb(): @@ -69,13 +86,12 @@ def test_downscale_pil_to_pixel_limit_keeps_small_image(): assert out.size == (64, 48) # unchanged -def test_response_item_to_tensor_handles_b64(): +def test_response_item_to_pil_handles_b64(): item = types.SimpleNamespace(b64_json=_png_b64(size=(20, 10)), url=None) - t = image_io.response_item_to_tensor(item) - assert t.shape == (1, 10, 20, 3) + assert image_io.response_item_to_pil(item).size == (20, 10) -def test_response_item_to_tensor_handles_url(monkeypatch): +def test_response_item_to_pil_handles_url(monkeypatch): png = base64.b64decode(_png_b64(size=(12, 6))) class _FakeResp: @@ -90,21 +106,21 @@ def test_response_item_to_tensor_handles_url(monkeypatch): monkeypatch.setattr("urllib.request.urlopen", lambda url, **kw: _FakeResp()) item = types.SimpleNamespace(b64_json=None, url="http://example/x.png") - t = image_io.response_item_to_tensor(item) - assert t.shape == (1, 6, 12, 3) + assert image_io.response_item_to_pil(item).size == (12, 6) def test_batch_from_response_data_concatenates_b64_items(): data = [types.SimpleNamespace(b64_json=_png_b64(), url=None) for _ in range(3)] - batch = image_io.batch_from_response_data(data) + batch, masks = image_io.batch_from_response_data(data) assert batch.shape[0] == 3 + assert masks.shape[0] == 3 -def test_response_item_to_tensor_raises_without_payload(): +def test_response_item_to_pil_raises_without_payload(): import pytest item = types.SimpleNamespace(b64_json=None, url=None) with pytest.raises(ValueError): - image_io.response_item_to_tensor(item) + image_io.response_item_to_pil(item) def test_mask_3d_shape_is_handled(): diff --git a/tests/test_nodes.py b/tests/test_nodes.py index fe08306..503ebf4 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -37,7 +37,7 @@ class FakeClient: def test_generate_missing_key_returns_empty(monkeypatch): monkeypatch.setattr(client, "get_client", lambda: None) - img, info = run_generate( + img, _mask, info = run_generate( prompt="x", model="gpt-image-2", size="auto", quality="auto", background="auto", output_format="png", n=1) assert img.shape == (1, 512, 512, 3) @@ -47,7 +47,7 @@ def test_generate_missing_key_returns_empty(monkeypatch): def test_generate_sends_expected_params_and_decodes(monkeypatch): rec = {} monkeypatch.setattr(client, "get_client", lambda: FakeClient(rec)) - img, info = run_generate( + img, _mask, info = run_generate( prompt="a cat", model="gpt-image-2", size="1024x1024", quality="high", background="opaque", output_format="png", n=2) sent = rec["generate"] @@ -131,7 +131,7 @@ def test_generate_api_error_returns_empty(monkeypatch): monkeypatch.setattr( FakeImages, "generate", lambda self, **kw: (_ for _ in ()).throw(RuntimeError("network error"))) - img, info = run_generate( + img, _mask, info = run_generate( prompt="x", model="gpt-image-2", size="auto", quality="auto", background="auto", output_format="png", n=1) assert img.shape == (1, 512, 512, 3) @@ -140,7 +140,7 @@ def test_generate_api_error_returns_empty(monkeypatch): def test_generate_invalid_custom_size_returns_error(monkeypatch): monkeypatch.setattr(client, "get_client", lambda: FakeClient({})) - img, info = run_generate( + img, _mask, info = run_generate( prompt="x", model="gpt-image-2", size="custom", quality="auto", background="auto", output_format="png", n=1, custom_width=1000, custom_height=1000) # 1000 is not a multiple of 16 @@ -171,7 +171,7 @@ def _img_tensor(h=16, w=16): def test_edit_sends_image_list_and_decodes(monkeypatch): rec = {} monkeypatch.setattr(client, "get_client", lambda: FakeClient(rec)) - img, info = run_edit( + img, _mask, info = run_edit( prompt="make it snowy", model="gpt-image-2", size="auto", quality="auto", background="auto", output_format="png", n=1, images=[_img_tensor(), _img_tensor()]) @@ -225,7 +225,7 @@ def test_edit_downscales_large_reference(monkeypatch): def test_edit_missing_image_returns_error(monkeypatch): monkeypatch.setattr(client, "get_client", lambda: FakeClient({})) - img, info = run_edit( + img, _mask, info = run_edit( prompt="x", model="gpt-image-2", size="auto", quality="auto", background="auto", output_format="png", n=1, images=[]) assert info.startswith("Error") @@ -237,7 +237,7 @@ def test_edit_api_error_returns_empty(monkeypatch): monkeypatch.setattr( FakeImages, "edit", lambda self, **kw: (_ for _ in ()).throw(RuntimeError("network error"))) - img, info = run_edit( + img, _mask, info = run_edit( prompt="x", model="gpt-image-2", size="auto", quality="auto", background="auto", output_format="png", n=1, images=[_img_tensor()]) assert img.shape == (1, 512, 512, 3) @@ -247,7 +247,7 @@ def test_edit_api_error_returns_empty(monkeypatch): def test_edit_mask_with_multiple_refs_returns_error(monkeypatch): monkeypatch.setattr(client, "get_client", lambda: FakeClient({})) mask = torch.zeros((16, 16), dtype=torch.float32) - img, info = run_edit( + img, _mask, info = run_edit( prompt="x", model="gpt-image-2", size="auto", quality="auto", background="auto", output_format="png", n=1, images=[_img_tensor(), _img_tensor()], mask=mask) diff --git a/tests/test_params.py b/tests/test_params.py index 295e268..e969a42 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -10,7 +10,9 @@ def test_constants(): assert base in params.SIZE_PRESETS for hires in ("2048x2048", "3840x2160", "2160x3840"): assert hires in params.SIZE_PRESETS - assert (params.CUSTOM_DIM_MIN, params.CUSTOM_DIM_MAX, params.CUSTOM_DIM_STEP) == (1024, 3840, 16) + assert (params.CUSTOM_DIM_MIN, params.CUSTOM_DIM_MAX, params.CUSTOM_DIM_STEP) == (480, 3840, 16) + # The widget floor must not reject a size the validator accepts (the 3:1 edge case). + assert params.validate_custom_dimensions(1440, params.CUSTOM_DIM_MIN) == "1440x480" assert params.QUALITIES == ["auto", "low", "medium", "high", "xhigh", "max"] assert params.BACKGROUNDS == ["auto", "opaque", "transparent"] assert params.FORMATS == ["png", "jpeg", "webp"] From 5b35ba19611d2e2fda5cb3b831da09c6351ab98d Mon Sep 17 00:00:00 2001 From: Alexander Garzon Date: Mon, 14 Sep 2026 11:31:50 -0400 Subject: [PATCH 3/3] chore(release): v0.2.0 Breaking: gpt-image-1 and gpt-image-1.5 are gone, input_fidelity is gone, and both nodes gained a second output (mask), so saved workflows need the model widget re-picked and will show the nodes as changed. --- CHANGELOG.md | 2 +- imagent/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1635dd..f1e252b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## v0.2.0 — 2026-09-14 ### Added diff --git a/imagent/__init__.py b/imagent/__init__.py index c27ddf0..c415e30 100644 --- a/imagent/__init__.py +++ b/imagent/__init__.py @@ -1,3 +1,3 @@ """imagent package — OpenAI gpt-image generation and editing for ComfyUI.""" -__version__ = "0.1.0" +__version__ = "0.2.0"