4 Commits
Author SHA1 Message Date
Alexander Garzon 012c391cbe Merge branch 'feat/gpt-image-2-5': gpt-image-2.5 support, v0.2.0 2026-09-14 11:31:55 -04:00
Alexander Garzon 5b35ba1961 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.
2026-09-14 11:31:50 -04:00
Alexander Garzon e8fdd92f21 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.
2026-09-14 11:31:32 -04:00
Alexander Garzon b4fcc11fd6 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.
2026-09-13 21:59:40 -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)