diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8e0d5..f1e252b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index fcf2b87..316ea57 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. | -| ↳↳ `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. 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" diff --git a/imagent/build.py b/imagent/build.py index 856617d..d9d0e7e 100644 --- a/imagent/build.py +++ b/imagent/build.py @@ -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)) 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/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 517f849..926f9ad 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", @@ -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) diff --git a/imagent/node_generate.py b/imagent/node_generate.py index 2d1d396..68d994d 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, @@ -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) diff --git a/imagent/params.py b/imagent/params.py index ab566e0..a0e3419 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,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) 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_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 4b725c7..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"] @@ -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 diff --git a/tests/test_params.py b/tests/test_params.py index 7d586d0..e969a42 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -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)