Merge branch 'feat/gpt-image-2-5': gpt-image-2.5 support, v0.2.0
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""imagent package — OpenAI gpt-image generation and editing for ComfyUI."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
+20
-21
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user