Merge branch 'feat/gpt-image-2-5': gpt-image-2.5 support, v0.2.0

This commit is contained in:
Alexander Garzon
2026-09-14 11:31:55 -04:00
13 changed files with 273 additions and 222 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## v0.2.0 — 2026-09-14
### 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.
- **`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
- **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.
+28 -13
View File
@@ -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. |
| ↳↳ `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` |
| `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`.** 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` |
| `output_compression` | INT | 0–100. Applied only for `jpeg` and `webp`. |
| `moderation` | COMBO | `auto` (default), `low`. Sent only when not `auto`. |
@@ -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.
---
@@ -89,12 +96,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,7 +108,16 @@ 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.
**Output:** `image` (IMAGE tensor, batch of n). Errors are logged to the ComfyUI console.
> 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.
**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.
+1 -1
View File
@@ -1,3 +1,3 @@
"""imagent package — OpenAI gpt-image generation and editing for ComfyUI."""
__version__ = "0.1.0"
__version__ = "0.2.0"
+20 -21
View File
@@ -35,19 +35,20 @@ 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, 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"):
@@ -56,44 +57,43 @@ 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,
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.
`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, 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,21 +101,20 @@ 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)
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))
+5 -6
View File
@@ -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"}
+32 -24
View File
@@ -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))
+10 -22
View File
@@ -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",
@@ -71,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
@@ -79,10 +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,
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)
return IO.NodeOutput(img, out_mask)
+13 -15
View File
@@ -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,
@@ -96,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)
+34 -36
View File
@@ -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,47 +10,44 @@ 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
# 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"]
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 +83,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)
+7 -2
View File
@@ -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)
+28 -12
View File
@@ -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():
+38 -40
View File
@@ -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"]
@@ -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"
@@ -117,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)
@@ -126,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
@@ -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
# ---------------------------------------------------------------------------
@@ -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()])
@@ -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))
@@ -241,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")
@@ -253,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)
@@ -263,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)
@@ -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
+29 -30
View File
@@ -10,23 +10,35 @@ 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.QUALITIES == ["auto", "low", "medium", "high"]
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"]
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 +47,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 +67,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)