Add GPT Image 2.6 variants, edit guides, and native GIF/SVG outputs
This commit is contained in:
@@ -46,9 +46,9 @@ This node pack currently includes the following nodes:
|
||||
- Supports up to 14 optional input/reference images.
|
||||
- Supports optional Google web-search grounding.
|
||||
- **GPT Image Text-to-Image / Image-to-Image**
|
||||
- Two nodes with a model selector: GPT Image 2, GPT Image 2.5 Flare, and GPT Image 2.5 Sunburst.
|
||||
- Model-aware aspect ratio/resolution dropdowns and GPT Image 2.5 background controls.
|
||||
- RGB IMAGE plus transparency MASK outputs; existing GPT Image 2 workflow IDs are preserved.
|
||||
- Two nodes with a model selector: GPT Image 2, 2.5 Flare/Sunburst, and 2.6 Flare/Sunburst/Economy.
|
||||
- Model-aware formats, aspect ratios, resolution and background controls; 2.6 Flare/Sunburst mask or transparent-markup editing.
|
||||
- RGB IMAGE plus transparency MASK outputs, original file paths, SVG text and metadata. GIF preserves all frames and timing; SVG uses file/text outputs. Existing workflow IDs and first two output slots are preserved.
|
||||
- Image-to-image accepts up to 16 input/reference images via ComfyUI batch.
|
||||
- **Seedream Text-to-Image / Edit**
|
||||
- Text-to-image and image-editing node for Seedream models.
|
||||
@@ -142,12 +142,12 @@ This node pack currently includes the following nodes:
|
||||
Use this section after reviewing Current Available Nodes.
|
||||
|
||||
- Full docs index (all nodes): [`web/docs/README.md`](web/docs/README.md)
|
||||
- GPT Image 2 / 2.5 status: both modes are implemented and documented. Windows ComfyUI/live verification of 2.5 is pending.
|
||||
- GPT Image 2 / 2.5 / 2.6 status: both modes are implemented and documented. Mac payload/widget/schema checks pass; Windows ComfyUI and paid-provider verification remain pending.
|
||||
- Kling 3.0 motion status: implemented in this repo with both the node doc and API spec linked below.
|
||||
- Grok Imagine status: `T2I`, `I2I`, `T2V`, and `I2V` are implemented in this repo.
|
||||
- Image generation node docs:
|
||||
- [`web/docs/KIE_GPTImage2_TextToImage.md`](web/docs/KIE_GPTImage2_TextToImage.md) - GPT Image 2 / 2.5 text-to-image node reference.
|
||||
- [`web/docs/KIE_GPTImage2_ImageToImage.md`](web/docs/KIE_GPTImage2_ImageToImage.md) - GPT Image 2 / 2.5 image-to-image node reference.
|
||||
- [`web/docs/KIE_GPTImage2_TextToImage.md`](web/docs/KIE_GPTImage2_TextToImage.md) - GPT Image 2 / 2.5 / 2.6 text-to-image node reference.
|
||||
- [`web/docs/KIE_GPTImage2_ImageToImage.md`](web/docs/KIE_GPTImage2_ImageToImage.md) - GPT Image 2 / 2.5 / 2.6 image-to-image node reference.
|
||||
- [`web/docs/KIE_GrokImagine_T2I.md`](web/docs/KIE_GrokImagine_T2I.md) - Grok Imagine text-to-image node reference.
|
||||
- [`web/docs/KIE_GrokImagine_I2I.md`](web/docs/KIE_GrokImagine_I2I.md) - Grok Imagine image-to-image node reference.
|
||||
- [`web/docs/KIE_GrokImagine_T2V.md`](web/docs/KIE_GrokImagine_T2V.md) - Grok Imagine text-to-video node reference.
|
||||
|
||||
+22
-1
@@ -27,6 +27,27 @@ function syncOptions(node, normalize = false) {
|
||||
: [options.resolutions[widgets.aspect_ratio?.value] ?? []]));
|
||||
if (resolutions.length) update("resolution", resolutions);
|
||||
update("background", union(candidates.map((options) => options.backgrounds)));
|
||||
update("output_format", union(candidates.map((options) => options.formats ?? ["png"])));
|
||||
update("frame_count", union(candidates.map((options) => options.frame_counts ?? ["9"])));
|
||||
const formats = isLinked("output_format")
|
||||
? union(candidates.map((options) => options.formats ?? ["png"]))
|
||||
: [widgets.output_format?.value ?? "png"];
|
||||
const advanced = candidates.some((options) => options.advanced);
|
||||
const gif = formats.includes("gif");
|
||||
const visible = {
|
||||
quality: advanced, complexity: advanced, moderation: advanced,
|
||||
output_compression: advanced && formats.some((format) => ["jpeg", "webp"].includes(format)),
|
||||
svg_model: advanced && formats.includes("svg"),
|
||||
frame_count: gif, frame_duration_ms: gif,
|
||||
gif_size: gif && advanced && node.comfyClass === "KIE_GPTImage2_TextToImage",
|
||||
};
|
||||
for (const [name, show] of Object.entries(visible)) {
|
||||
const widget = widgets[name];
|
||||
if (!widget || isLinked(name)) continue;
|
||||
widget.kieOriginal ??= { type: widget.type, computeSize: widget.computeSize };
|
||||
widget.type = show ? widget.kieOriginal.type : "hidden";
|
||||
widget.computeSize = show ? widget.kieOriginal.computeSize : () => [0, -4];
|
||||
}
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
@@ -54,7 +75,7 @@ app.registerExtension({
|
||||
return result;
|
||||
};
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (!["model", "aspect_ratio"].includes(widget.name)) continue;
|
||||
if (!["model", "aspect_ratio", "output_format"].includes(widget.name)) continue;
|
||||
const original = widget.callback;
|
||||
widget.callback = function (...args) {
|
||||
const result = original?.apply(this, args);
|
||||
|
||||
+33
-14
@@ -1,4 +1,4 @@
|
||||
"""GPT Image 2 / 2.5 text-to-image and image-to-image helpers."""
|
||||
"""GPT Image 2 / 2.5 / 2.6 text-to-image and image-to-image helpers."""
|
||||
|
||||
import time
|
||||
|
||||
@@ -7,7 +7,8 @@ import torch
|
||||
from .auth import _load_api_key
|
||||
from .credits import _log_remaining_credits
|
||||
from .http import TransientKieError
|
||||
from .images import _download_image, _image_bytes_to_tensor_and_mask
|
||||
from .images import _download_image
|
||||
from .gpt_image_media import collect_results, prepare_edit_guide
|
||||
from .gpt_image_options import DEFAULT_MODEL, MAX_IMAGE_COUNT, build_gpt_image_payload
|
||||
from .jobs import _create_task, _poll_task_until_complete
|
||||
from .log import _log
|
||||
@@ -23,7 +24,7 @@ def _run_gpt_image2_payload(
|
||||
timeout_s: int,
|
||||
log: bool,
|
||||
create_label: str,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
) -> tuple:
|
||||
api_key = _load_api_key()
|
||||
_log(log, f"Creating {create_label} task...")
|
||||
start_time = time.time()
|
||||
@@ -42,12 +43,16 @@ def _run_gpt_image2_payload(
|
||||
|
||||
result_urls = _extract_result_urls(record_data)
|
||||
_log(log, f"Result URLs: {result_urls}")
|
||||
_log(log, f"Downloading result image from {result_urls[0]}...")
|
||||
image_bytes = _download_image(result_urls[0])
|
||||
image_tensor, mask_tensor = _image_bytes_to_tensor_and_mask(image_bytes)
|
||||
_log(log, "Image downloaded and decoded.")
|
||||
import folder_paths
|
||||
|
||||
result = collect_results(
|
||||
((url, _download_image(url)) for url in result_urls),
|
||||
task_id=task_id, output_format=payload["input"].get("output_format"),
|
||||
output_dir=folder_paths.get_output_directory(),
|
||||
)
|
||||
_log(log, "Original output files saved and decoded.")
|
||||
_log_remaining_credits(log, record_data, api_key, _log)
|
||||
return image_tensor, mask_tensor
|
||||
return result
|
||||
|
||||
|
||||
def run_gpt_image2_text_to_image(
|
||||
@@ -63,11 +68,12 @@ def run_gpt_image2_text_to_image(
|
||||
retry_backoff_s: float = 3.0,
|
||||
model: str = DEFAULT_MODEL,
|
||||
background: str = "opaque",
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Run the selected GPT Image text-to-image model; return RGB and mask."""
|
||||
**options,
|
||||
) -> tuple:
|
||||
"""Run the selected GPT Image text-to-image model; return image frames, masks, original paths, SVG text, and metadata."""
|
||||
payload = build_gpt_image_payload(
|
||||
model=model, mode="text-to-image", prompt=prompt,
|
||||
aspect_ratio=aspect_ratio, resolution=resolution, background=background,
|
||||
aspect_ratio=aspect_ratio, resolution=resolution, background=background, **options,
|
||||
)
|
||||
|
||||
attempts = max_retries + 1 if retry_on_fail else 1
|
||||
@@ -106,13 +112,23 @@ def run_gpt_image2_image_to_image(
|
||||
retry_backoff_s: float = 3.0,
|
||||
model: str = DEFAULT_MODEL,
|
||||
background: str = "opaque",
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Run the selected GPT Image editing model; return RGB and mask."""
|
||||
mask=None,
|
||||
markup=None,
|
||||
markup_mask=None,
|
||||
**options,
|
||||
) -> tuple:
|
||||
"""Run the selected GPT Image editing model; return image frames, masks, original paths, SVG text, and metadata."""
|
||||
payload = build_gpt_image_payload(
|
||||
model=model, mode="image-to-image", prompt=prompt,
|
||||
aspect_ratio=aspect_ratio, resolution=resolution, background=background,
|
||||
aspect_ratio=aspect_ratio, resolution=resolution, background=background, **options,
|
||||
)
|
||||
images = _validate_image_tensor_batch(images)
|
||||
if min(images.shape[1:3]) <= 0 or not torch.isfinite(images).all() or (images < 0).any() or (images > 1).any():
|
||||
raise RuntimeError("Source images must have nonzero dimensions and finite values between 0 and 1.")
|
||||
guide = prepare_edit_guide(
|
||||
images=images, model=model, output_format=payload["input"].get("output_format", "png"),
|
||||
mask=mask, markup=markup, markup_mask=markup_mask,
|
||||
)
|
||||
total_images = images.shape[0]
|
||||
if total_images > MAX_IMAGE_COUNT and model != DEFAULT_MODEL:
|
||||
raise RuntimeError(f"{model} accepts at most {MAX_IMAGE_COUNT} source images; received {total_images}.")
|
||||
@@ -141,6 +157,9 @@ def run_gpt_image2_image_to_image(
|
||||
_log(log, f"Image {idx + 1} upload success: {_truncate_url(image_url)}")
|
||||
|
||||
payload["input"]["input_urls"] = image_urls
|
||||
if guide is not None:
|
||||
field, png_bytes = guide
|
||||
payload["input"][field] = _upload_image(api_key, png_bytes)
|
||||
|
||||
return _run_gpt_image2_payload(
|
||||
payload=payload,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Alpha edit guides and lossless output handling for the GPT Image nodes."""
|
||||
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageSequence
|
||||
import torch
|
||||
|
||||
|
||||
def _mask_plane(mask, height, width, name):
|
||||
if not isinstance(mask, torch.Tensor):
|
||||
raise RuntimeError(f"{name} must be a ComfyUI MASK tensor.")
|
||||
if mask.ndim == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
if tuple(mask.shape) != (1, height, width):
|
||||
raise RuntimeError(f"{name} must contain exactly one mask matching the first image ({width}x{height}).")
|
||||
if not torch.isfinite(mask).all() or (mask < 0).any() or (mask > 1).any():
|
||||
raise RuntimeError(f"{name} must contain finite values between 0 and 1.")
|
||||
return mask[0].detach().cpu().float().numpy()
|
||||
|
||||
|
||||
def prepare_edit_guide(*, images, model, output_format, mask=None, markup=None, markup_mask=None):
|
||||
"""Return a provider field and encoded PNG, validating before auth/uploads."""
|
||||
if mask is not None and (markup is not None or markup_mask is not None):
|
||||
raise RuntimeError("Connect either mask or markup, never both.")
|
||||
if mask is None and markup is None and markup_mask is None:
|
||||
return None
|
||||
if model not in ("GPT Image 2.6 Flare", "GPT Image 2.6 Sunburst"):
|
||||
raise RuntimeError("Mask and markup editing require GPT Image 2.6 Flare or Sunburst.")
|
||||
height, width = images.shape[1:3]
|
||||
if mask is not None:
|
||||
if output_format in ("gif", "svg"):
|
||||
raise RuntimeError("Masks cannot be used with GIF or SVG output.")
|
||||
alpha = 1.0 - _mask_plane(mask, height, width, "mask")
|
||||
rgba = np.ones((height, width, 4), dtype=np.float32)
|
||||
rgba[:, :, 3] = alpha
|
||||
field = "mask_url"
|
||||
else:
|
||||
if not isinstance(markup, torch.Tensor) or markup.ndim != 4 or markup.shape[0] != 1 or tuple(markup.shape[1:3]) != (height, width) or markup.shape[3] not in (3, 4):
|
||||
raise RuntimeError("markup must be one RGB/RGBA IMAGE matching the first source image dimensions.")
|
||||
if not torch.isfinite(markup).all() or (markup < 0).any() or (markup > 1).any():
|
||||
raise RuntimeError("markup must contain finite values between 0 and 1.")
|
||||
pixels = markup[0].detach().cpu().float().numpy()
|
||||
if markup.shape[3] == 4:
|
||||
if markup_mask is not None:
|
||||
raise RuntimeError("RGBA markup already has alpha; disconnect markup_mask.")
|
||||
rgba = pixels.copy()
|
||||
else:
|
||||
if markup_mask is None:
|
||||
raise RuntimeError("RGB markup requires markup_mask (1 = transparent). Connect both outputs of Load Image for an annotation PNG.")
|
||||
alpha = 1.0 - _mask_plane(markup_mask, height, width, "markup_mask")
|
||||
rgba = np.concatenate((pixels, alpha[:, :, None]), axis=2)
|
||||
if not (rgba[:, :, 3] < 1).any() or not (rgba[:, :, 3] > 0).any():
|
||||
raise RuntimeError("Markup must contain visible annotations and transparent pixels; do not flatten it onto the source image.")
|
||||
field = "markup"
|
||||
output = BytesIO()
|
||||
Image.fromarray(np.round(rgba * 255).astype(np.uint8), "RGBA").save(output, format="PNG")
|
||||
data = output.getvalue()
|
||||
if len(data) >= 30 * 1024 * 1024:
|
||||
raise RuntimeError("Edit guide PNG exceeds Kie's 30 MB upload limit.")
|
||||
return field, data
|
||||
|
||||
|
||||
def decode_result(data, expected_format):
|
||||
"""Decode native output; SVG has no fabricated raster preview."""
|
||||
if expected_format == "svg":
|
||||
try:
|
||||
text = data.decode("utf-8-sig")
|
||||
if "<!DOCTYPE" in text.upper() or "<!ENTITY" in text.upper():
|
||||
raise ValueError("XML declarations with entities are not supported")
|
||||
root = ET.fromstring(text)
|
||||
if root.tag not in ("svg", "{http://www.w3.org/2000/svg}svg"):
|
||||
raise ValueError("not an SVG root")
|
||||
except (UnicodeError, ET.ParseError, ValueError) as exc:
|
||||
raise RuntimeError("Provider did not return a valid UTF-8 SVG document.") from exc
|
||||
return None, None, text, {"format": "svg", "mime_type": "image/svg+xml"}
|
||||
frames, masks, durations = [], [], []
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
actual = (source.format or "").lower()
|
||||
if actual not in ("png", "jpeg", "webp", "gif"):
|
||||
raise RuntimeError(f"Unsupported provider output format: {actual}.")
|
||||
if expected_format is not None and actual != expected_format:
|
||||
raise RuntimeError(f"Requested {expected_format}, but provider returned {actual}.")
|
||||
loop = source.info.get("loop")
|
||||
for frame in ImageSequence.Iterator(source):
|
||||
rgba = np.array(frame.convert("RGBA"), dtype=np.float32) / 255.0
|
||||
frames.append(torch.from_numpy(rgba[:, :, :3].copy()))
|
||||
masks.append(torch.from_numpy(1.0 - rgba[:, :, 3]))
|
||||
durations.append(frame.info.get("duration", 0))
|
||||
metadata = {"format": actual, "mime_type": f"image/{actual}",
|
||||
"width": source.width, "height": source.height,
|
||||
"frame_count": len(frames)}
|
||||
if actual == "gif":
|
||||
metadata.update(frame_durations_ms=durations, loop=loop)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError("Failed to decode provider image output.") from exc
|
||||
return torch.stack(frames), torch.stack(masks), "", metadata
|
||||
|
||||
|
||||
def collect_results(downloads, *, task_id, output_format, output_dir):
|
||||
"""Save every original; return IMAGE, MASK, paths JSON, SVG text, metadata JSON."""
|
||||
directory = Path(output_dir) / "kie_gpt_image"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
batch, masks, paths, vectors, records = [], [], [], [], []
|
||||
prefix = uuid.uuid4().hex
|
||||
for index, (url, data) in enumerate(downloads):
|
||||
image, mask, svg, metadata = decode_result(data, output_format)
|
||||
path = directory / f"{prefix}_{index}.{metadata['format']}"
|
||||
path.write_bytes(data)
|
||||
paths.append(str(path.resolve()))
|
||||
records.append({**metadata, "path": paths[-1], "url": url, "bytes": len(data)})
|
||||
if image is not None:
|
||||
batch.append(image)
|
||||
masks.append(mask)
|
||||
if svg:
|
||||
vectors.append(svg)
|
||||
if not records:
|
||||
raise RuntimeError("Provider returned no output files.")
|
||||
if batch and any(item.shape[1:] != batch[0].shape[1:] for item in batch):
|
||||
raise RuntimeError(f"Output sizes differ and cannot form an IMAGE batch. Originals saved: {paths}")
|
||||
# Multiple vector documents remain separately recoverable without invalid XML concatenation.
|
||||
svg_text = vectors[0] if len(vectors) == 1 else json.dumps(vectors) if vectors else ""
|
||||
return (torch.cat(batch) if batch else None, torch.cat(masks) if masks else None,
|
||||
json.dumps(paths), svg_text, json.dumps({"task_id": task_id, "outputs": records}))
|
||||
+107
-24
@@ -1,69 +1,152 @@
|
||||
"""Pinned Kie GPT Image contracts, shared by validation and widget metadata."""
|
||||
"""Pinned Kie GPT Image contracts shared by validation and widget metadata."""
|
||||
|
||||
DEFAULT_MODEL = "GPT Image 2"
|
||||
MODEL_PREFIXES = {
|
||||
DEFAULT_MODEL: "gpt-image-2",
|
||||
"GPT Image 2.5 Flare": "gpt-image-2-5-flare",
|
||||
"GPT Image 2.5 Sunburst": "gpt-image-2-5-sunburst",
|
||||
"GPT Image 2.6 Flare": "gpt-image-2-6/flare",
|
||||
"GPT Image 2.6 Sunburst": "gpt-image-2-6/sunburst",
|
||||
"GPT Image 2.6 Economy": "gpt-image-2-6/economy",
|
||||
}
|
||||
MODEL_OPTIONS = list(MODEL_PREFIXES)
|
||||
LEGACY_ASPECT_RATIOS = ["auto", "1:1", "9:16", "16:9", "4:3", "3:4"]
|
||||
ASPECT_RATIO_OPTIONS = [
|
||||
*LEGACY_ASPECT_RATIOS, "3:2", "2:3", "21:9", "27:16", "16:27", "9:8", "8:9",
|
||||
]
|
||||
V25_RATIOS = [*LEGACY_ASPECT_RATIOS, "3:2", "2:3", "21:9", "27:16", "16:27", "9:8", "8:9"]
|
||||
V26_RATIOS = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "5:4", "4:5", "16:9", "9:16", "2:1", "1:2", "21:9", "9:21", "3:1", "1:3"]
|
||||
ECONOMY_RATIOS = ["auto", "1:1", "16:9", "9:16", "3:2", "2:3", "21:9", "4:3"]
|
||||
ASPECT_RATIO_OPTIONS = list(dict.fromkeys(V25_RATIOS + V26_RATIOS))
|
||||
RESOLUTION_OPTIONS = ["1K", "2K", "4K"]
|
||||
BACKGROUND_OPTIONS = ["opaque", "transparent", "auto"]
|
||||
QUALITY_OPTIONS = ["medium", "low", "high", "xhigh", "max", "auto"]
|
||||
FORMAT_OPTIONS = ["png", "jpeg", "webp", "gif", "svg"]
|
||||
SVG_MODELS = ["gpt-5.6-luna", "gpt-6-sol", "gpt-5.6-terra"]
|
||||
ONE_K_ONLY_RATIOS = {"27:16", "16:27", "9:8", "8:9"}
|
||||
PROMPT_MAX_LENGTH = 20000
|
||||
MAX_IMAGE_COUNT = 16
|
||||
|
||||
|
||||
def model_widget_options() -> dict:
|
||||
"""Expose the same compatibility rules to the frontend without a second table."""
|
||||
def model_widget_options(mode="text-to-image") -> dict:
|
||||
options = {}
|
||||
for model in MODEL_OPTIONS:
|
||||
legacy = model == DEFAULT_MODEL
|
||||
ratios = LEGACY_ASPECT_RATIOS if legacy else ASPECT_RATIO_OPTIONS
|
||||
modern = model.startswith("GPT Image 2.6")
|
||||
economy = model.endswith("Economy")
|
||||
ratios = ECONOMY_RATIOS if economy else V26_RATIOS if modern else LEGACY_ASPECT_RATIOS if legacy else V25_RATIOS
|
||||
resolutions = {}
|
||||
for ratio in ratios:
|
||||
if (legacy and ratio == "auto") or (not legacy and ratio in ONE_K_ONLY_RATIOS):
|
||||
if economy or (legacy and ratio == "auto") or (not modern and ratio in ONE_K_ONLY_RATIOS):
|
||||
resolutions[ratio] = ["1K"]
|
||||
elif legacy and ratio == "1:1":
|
||||
resolutions[ratio] = ["1K", "2K"]
|
||||
else:
|
||||
resolutions[ratio] = list(RESOLUTION_OPTIONS)
|
||||
formats = (["png", "gif"] if mode == "text-to-image" else ["png"]) if economy else FORMAT_OPTIONS if modern else ["png"]
|
||||
options[model] = {
|
||||
"aspect_ratios": list(ratios),
|
||||
"resolutions": resolutions,
|
||||
"aspect_ratios": list(ratios), "resolutions": resolutions,
|
||||
"backgrounds": ["opaque"] if legacy else list(BACKGROUND_OPTIONS),
|
||||
"formats": list(formats), "advanced": modern and not economy,
|
||||
"economy": economy,
|
||||
"frame_counts": ["auto", "4", "9", "16", "25", "36", "64"] if economy else [str(n) for n in range(2, 17)],
|
||||
}
|
||||
return options
|
||||
|
||||
|
||||
def _integer(value, name, minimum, maximum):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
||||
raise RuntimeError(f"{name} must be an integer from {minimum} to {maximum}.")
|
||||
return value
|
||||
|
||||
|
||||
def build_gpt_image_payload(
|
||||
*, model: str, mode: str, prompt: str, aspect_ratio: str,
|
||||
resolution: str, background: str,
|
||||
resolution: str, background: str, output_format="png", quality="medium",
|
||||
complexity="high", moderation="low", output_compression=100,
|
||||
frame_count="9", frame_duration_ms=200, gif_size="auto", svg_model="gpt-5.6-luna",
|
||||
) -> dict:
|
||||
"""Validate before any upload/submission; I2I URLs are attached after upload."""
|
||||
"""Only active controls are serialized; inactive widget values are ignored."""
|
||||
if model not in MODEL_PREFIXES:
|
||||
raise RuntimeError(f"Unknown GPT Image model: {model!r}. Choose a model from the dropdown.")
|
||||
raise RuntimeError(f"Unknown GPT Image model: {model!r}.")
|
||||
if mode not in ("text-to-image", "image-to-image"):
|
||||
raise RuntimeError(f"Unknown GPT Image mode: {mode!r}.")
|
||||
options = model_widget_options(mode)[model]
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
raise RuntimeError("Prompt is required.")
|
||||
if len(prompt) > PROMPT_MAX_LENGTH:
|
||||
raise RuntimeError(f"Prompt exceeds the maximum length of {PROMPT_MAX_LENGTH} characters.")
|
||||
options = model_widget_options()[model]
|
||||
if aspect_ratio not in options["aspect_ratios"]:
|
||||
raise RuntimeError(f"{model} does not support aspect_ratio {aspect_ratio!r}.")
|
||||
allowed = options["resolutions"][aspect_ratio]
|
||||
if resolution not in allowed:
|
||||
raise RuntimeError(
|
||||
f"{model} with aspect_ratio {aspect_ratio!r} requires resolution: {', '.join(allowed)}."
|
||||
)
|
||||
limit = 32000 if options["advanced"] else PROMPT_MAX_LENGTH
|
||||
if len(prompt) > limit:
|
||||
raise RuntimeError(f"Prompt exceeds the maximum length of {limit} characters.")
|
||||
if output_format not in options["formats"]:
|
||||
raise RuntimeError(f"{model} {mode} supports output formats: {', '.join(options['formats'])}.")
|
||||
if background not in options["backgrounds"]:
|
||||
raise RuntimeError(f"{model} supports background: {', '.join(options['backgrounds'])}.")
|
||||
inputs = {"prompt": prompt, "aspect_ratio": aspect_ratio, "resolution": resolution}
|
||||
inputs = {"prompt": prompt}
|
||||
# Standard GIF uses per-frame size, not the static resolution table.
|
||||
if output_format != "gif" or options["economy"]:
|
||||
if aspect_ratio not in options["aspect_ratios"]:
|
||||
raise RuntimeError(f"{model} does not support aspect_ratio {aspect_ratio!r}.")
|
||||
if resolution not in options["resolutions"][aspect_ratio]:
|
||||
raise RuntimeError(f"{model} with aspect_ratio {aspect_ratio!r} does not support {resolution}.")
|
||||
inputs["aspect_ratio"] = aspect_ratio
|
||||
if not options["economy"]:
|
||||
inputs["resolution"] = resolution
|
||||
if model != DEFAULT_MODEL:
|
||||
inputs["background"] = background
|
||||
if model.startswith("GPT Image 2.6"):
|
||||
inputs["output_format"] = output_format
|
||||
if options["advanced"]:
|
||||
for name, value, allowed in (("quality", quality, QUALITY_OPTIONS), ("complexity", complexity, ["low", "medium", "high"]), ("moderation", moderation, ["auto", "low"])):
|
||||
if value not in allowed:
|
||||
raise RuntimeError(f"Invalid {name}: {value!r}.")
|
||||
inputs[name] = value
|
||||
if background == "transparent" and output_format == "jpeg":
|
||||
raise RuntimeError("JPEG cannot preserve transparency; choose PNG or WebP.")
|
||||
if output_format in ("jpeg", "webp"):
|
||||
inputs["output_compression"] = _integer(output_compression, "output_compression", 0, 100)
|
||||
if output_format == "svg":
|
||||
if svg_model not in SVG_MODELS:
|
||||
raise RuntimeError("Unknown SVG author model.")
|
||||
inputs["svg"] = {"model": svg_model}
|
||||
if output_format == "gif":
|
||||
count = str(frame_count)
|
||||
if count not in options["frame_counts"]:
|
||||
raise RuntimeError(f"frame_count must be one of {options['frame_counts']}.")
|
||||
inputs["frame_count"] = count if options["economy"] else int(count)
|
||||
duration = _integer(frame_duration_ms, "frame_duration_ms", 20, 2000)
|
||||
if duration % 10:
|
||||
raise RuntimeError("frame_duration_ms must be a multiple of 10.")
|
||||
inputs["frame_duration_ms"] = duration
|
||||
if options["advanced"] and mode == "text-to-image":
|
||||
validate_gif_size(gif_size, int(count))
|
||||
inputs["size"] = gif_size
|
||||
return {"model": f"{MODEL_PREFIXES[model]}-{mode}", "input": inputs}
|
||||
|
||||
|
||||
def validate_gif_size(size, count):
|
||||
if size == "auto":
|
||||
return
|
||||
try:
|
||||
width, height = map(int, size.split("x"))
|
||||
except (ValueError, AttributeError):
|
||||
raise RuntimeError("GIF size must be auto or WIDTHxHEIGHT, e.g. 512x512.") from None
|
||||
if min(width, height) <= 0 or width % 16 or height % 16:
|
||||
raise RuntimeError("GIF frame dimensions must be positive multiples of 16.")
|
||||
cols, rows = (4, 2) if count <= 8 else (3, 3) if count == 9 else (4, 3) if count <= 12 else (4, 4)
|
||||
for c, r in ((cols, rows), (rows, cols)):
|
||||
w, h = width * c, height * r
|
||||
if max(w, h) <= 3840 and w * h <= 8294400 and max(w, h) <= 3 * min(w, h):
|
||||
return
|
||||
raise RuntimeError("GIF sprite sheet exceeds provider limits; reduce frame size or count.")
|
||||
|
||||
|
||||
def generation_widgets():
|
||||
"""Append after legacy widgets to preserve saved workflow ordering."""
|
||||
return {
|
||||
"output_format": (FORMAT_OPTIONS, {"default": "png"}),
|
||||
"quality": (QUALITY_OPTIONS, {"default": "medium"}),
|
||||
"complexity": (["high", "medium", "low"], {"default": "high"}),
|
||||
"moderation": (["low", "auto"], {"default": "low"}),
|
||||
"output_compression": ("INT", {"default": 100, "min": 0, "max": 100}),
|
||||
"frame_count": (["9", "auto", *[str(n) for n in range(2, 17) if n != 9], "25", "36", "64"], {"default": "9"}),
|
||||
"frame_duration_ms": ("INT", {"default": 200, "min": 20, "max": 2000, "step": 10}),
|
||||
"gif_size": ("STRING", {"default": "auto", "tooltip": "2.6 Flare/Sunburst T2I GIF only: auto or frame pixels, e.g. 512x512."}),
|
||||
"svg_model": (SVG_MODELS, {"default": SVG_MODELS[0]}),
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ from .kie_api.gpt_image_options import (
|
||||
DEFAULT_MODEL as GPT_IMAGE_DEFAULT_MODEL,
|
||||
MODEL_OPTIONS as GPT_IMAGE_MODEL_OPTIONS,
|
||||
model_widget_options as gpt_image_widget_options,
|
||||
generation_widgets as gpt_image_generation_widgets,
|
||||
)
|
||||
from .kie_api.gpt_image2 import (
|
||||
run_gpt_image2_image_to_image,
|
||||
@@ -353,12 +354,12 @@ class KIE_GPTImage2_TextToImage:
|
||||
HELP = """
|
||||
KIE GPT Image (Text-to-Image)
|
||||
|
||||
Generate an image from a text prompt using GPT Image 2, GPT Image 2.5 Flare, or GPT Image 2.5 Sunburst.
|
||||
Generate an image from a text prompt using GPT Image 2, 2.5 Flare/Sunburst, or 2.6 Flare/Sunburst/Economy.
|
||||
|
||||
Inputs:
|
||||
- prompt: Text prompt (required, up to 20,000 chars)
|
||||
- model: GPT Image 2 / GPT Image 2.5 Flare / GPT Image 2.5 Sunburst
|
||||
- background: opaque, transparent, auto (2.5 only)
|
||||
- prompt: Text prompt (required, 32,000 chars for 2.6 Flare/Sunburst; otherwise 20,000)
|
||||
- model: GPT Image 2 / 2.5 Flare / 2.5 Sunburst / 2.6 Flare / 2.6 Sunburst / 2.6 Economy
|
||||
- background: opaque, transparent, auto (2.5/2.6)
|
||||
- aspect_ratio: Choices depend on the model
|
||||
- resolution: 1K, 2K, or 4K
|
||||
- log: Console logging on/off
|
||||
@@ -366,6 +367,10 @@ Inputs:
|
||||
Outputs:
|
||||
- IMAGE: RGB image tensor (BHWC float32 0-1)
|
||||
- MASK: Transparency mask (BHW, 1 = transparent, 0 = opaque)
|
||||
- file_paths: JSON array of original files saved under output/kie_gpt_image
|
||||
- svg: SVG text (empty for raster outputs)
|
||||
- metadata: JSON with task ID, formats, dimensions and GIF frame durations
|
||||
- GIF returns all frames in IMAGE/MASK; SVG returns no IMAGE/MASK. Use its text/file outputs.
|
||||
|
||||
Notes:
|
||||
- GPT Image 2: auto requires 1K; 1:1 does not support 4K.
|
||||
@@ -389,13 +394,14 @@ Notes:
|
||||
"kie_model_options": gpt_image_widget_options(),
|
||||
}),
|
||||
"background": (GPT_IMAGE_BACKGROUND_OPTIONS, {
|
||||
"default": "opaque", "tooltip": "GPT Image 2.5 only. Use MASK to preserve transparency.",
|
||||
"default": "opaque", "tooltip": "GPT Image 2.5/2.6. Use MASK to preserve transparency.",
|
||||
}),
|
||||
**gpt_image_generation_widgets(),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = ("image", "mask", "file_paths", "svg", "metadata")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "kie/api"
|
||||
|
||||
@@ -412,11 +418,13 @@ Notes:
|
||||
retry_backoff_s: float = 3.0,
|
||||
model: str = GPT_IMAGE_DEFAULT_MODEL,
|
||||
background: str = "opaque",
|
||||
**options,
|
||||
):
|
||||
return run_gpt_image2_text_to_image(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
background=background,
|
||||
**options,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
poll_interval_s=poll_interval_s,
|
||||
@@ -432,20 +440,27 @@ class KIE_GPTImage2_ImageToImage:
|
||||
HELP = """
|
||||
KIE GPT Image (Image-to-Image)
|
||||
|
||||
Generate an edited or transformed image from a prompt and source images using GPT Image 2, GPT Image 2.5 Flare, or GPT Image 2.5 Sunburst.
|
||||
Generate an edited or transformed image from a prompt and source images using GPT Image 2, 2.5 Flare/Sunburst, or 2.6 Flare/Sunburst/Economy.
|
||||
|
||||
Inputs:
|
||||
- prompt: Text prompt (required, up to 20,000 chars)
|
||||
- prompt: Text prompt (required, 32,000 chars for 2.6 Flare/Sunburst; otherwise 20,000)
|
||||
- images: Source image batch (up to 16 images)
|
||||
- model: GPT Image 2 / GPT Image 2.5 Flare / GPT Image 2.5 Sunburst
|
||||
- background: opaque, transparent, auto (2.5 only)
|
||||
- model: GPT Image 2 / 2.5 Flare / 2.5 Sunburst / 2.6 Flare / 2.6 Sunburst / 2.6 Economy
|
||||
- background: opaque, transparent, auto (2.5/2.6)
|
||||
- aspect_ratio: Choices depend on the model
|
||||
- resolution: 1K, 2K, or 4K
|
||||
- log: Console logging on/off
|
||||
- mask: Optional MASK, 1 = edit area on first source (2.6 Flare/Sunburst only)
|
||||
- markup + markup_mask: Separate annotation IMAGE and transparency MASK, same size as first source
|
||||
- Mask and markup are mutually exclusive. Mask is unavailable with GIF/SVG.
|
||||
|
||||
Outputs:
|
||||
- IMAGE: RGB image tensor (BHWC float32 0-1)
|
||||
- MASK: Transparency mask (BHW, 1 = transparent, 0 = opaque)
|
||||
- file_paths: JSON array of original files saved under output/kie_gpt_image
|
||||
- svg: SVG text (empty for raster outputs)
|
||||
- metadata: JSON with task ID, formats, dimensions and GIF frame durations
|
||||
- GIF returns all frames in IMAGE/MASK; SVG returns no IMAGE/MASK. Use its text/file outputs.
|
||||
|
||||
Notes:
|
||||
- GPT Image 2: auto requires 1K; 1:1 does not support 4K.
|
||||
@@ -467,16 +482,20 @@ Notes:
|
||||
# Append widgets: old workflows serialize values in this order.
|
||||
"model": (GPT_IMAGE_MODEL_OPTIONS, {
|
||||
"default": GPT_IMAGE_DEFAULT_MODEL,
|
||||
"kie_model_options": gpt_image_widget_options(),
|
||||
"kie_model_options": gpt_image_widget_options("image-to-image"),
|
||||
}),
|
||||
"background": (GPT_IMAGE_BACKGROUND_OPTIONS, {
|
||||
"default": "opaque", "tooltip": "GPT Image 2.5 only. Use MASK to preserve transparency.",
|
||||
"default": "opaque", "tooltip": "GPT Image 2.5/2.6. Use MASK to preserve transparency.",
|
||||
}),
|
||||
**gpt_image_generation_widgets(),
|
||||
"mask": ("MASK", {"tooltip": "Edit first source image where mask is 1. Cannot combine with markup."}),
|
||||
"markup": ("IMAGE", {"tooltip": "Separate annotation layer, same dimensions as first source. RGB requires markup_mask."}),
|
||||
"markup_mask": ("MASK", {"tooltip": "Annotation transparency: 1 = transparent. Connect Load Image MASK for the annotation PNG."}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = ("image", "mask", "file_paths", "svg", "metadata")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "kie/api"
|
||||
|
||||
@@ -494,11 +513,13 @@ Notes:
|
||||
retry_backoff_s: float = 3.0,
|
||||
model: str = GPT_IMAGE_DEFAULT_MODEL,
|
||||
background: str = "opaque",
|
||||
**options,
|
||||
):
|
||||
return run_gpt_image2_image_to_image(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
background=background,
|
||||
**options,
|
||||
images=images,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
|
||||
@@ -24,12 +24,22 @@ function fixture() {
|
||||
backgrounds: ["opaque", "transparent", "auto"],
|
||||
},
|
||||
};
|
||||
rules["GPT Image 2.6 Flare"] = {
|
||||
aspect_ratios: ["auto", "5:4"], resolutions: {auto: ["1K", "2K", "4K"], "5:4": ["1K", "2K", "4K"]},
|
||||
backgrounds: ["opaque", "transparent", "auto"], formats: ["png", "jpeg", "webp", "gif", "svg"],
|
||||
advanced: true, frame_counts: ["2", "9", "16"],
|
||||
};
|
||||
rules["GPT Image 2.6 Economy"] = {
|
||||
aspect_ratios: ["auto", "1:1"], resolutions: {auto: ["1K"], "1:1": ["1K"]},
|
||||
backgrounds: ["opaque", "transparent", "auto"], formats: ["png", "gif"],
|
||||
advanced: false, economy: true, frame_counts: ["auto", "4", "9", "16", "64"],
|
||||
};
|
||||
const name = "KIE_GPTImage2_TextToImage";
|
||||
extension.beforeRegisterNodeDef(null, {
|
||||
name, input: { optional: { model: [Object.keys(rules), { kie_model_options: rules }] } },
|
||||
});
|
||||
const widgets = Object.entries({ aspect_ratio: "auto", resolution: "1K", model: "GPT Image 2", background: "opaque" })
|
||||
.map(([name, value]) => ({ name, value, options: {} }));
|
||||
const widgets = Object.entries({ aspect_ratio: "auto", resolution: "1K", model: "GPT Image 2", background: "opaque", output_format: "png", quality: "medium", complexity: "high", moderation: "low", output_compression: 100, frame_count: "9", frame_duration_ms: 200, gif_size: "auto", svg_model: "gpt-5.6-luna" })
|
||||
.map(([name, value]) => ({ name, value, options: {}, type: "combo" }));
|
||||
let configured = false;
|
||||
const node = { comfyClass: name, widgets, inputs: [], onConfigure() { configured = true; } };
|
||||
extension.nodeCreated(node);
|
||||
@@ -81,3 +91,37 @@ test("explicit model changes normalize incompatible selections", () => {
|
||||
assert.equal(get("aspect_ratio").value, "auto");
|
||||
assert.equal(get("background").value, "opaque");
|
||||
});
|
||||
|
||||
|
||||
test("2.6 format controls follow model and output choices", () => {
|
||||
const { node, get } = fixture();
|
||||
get("model").value = "GPT Image 2.6 Flare";
|
||||
get("model").callback();
|
||||
assert.equal(get("quality").type, "combo");
|
||||
assert.equal(get("frame_count").type, "hidden");
|
||||
get("output_format").value = "gif";
|
||||
get("output_format").callback();
|
||||
assert.equal(get("frame_count").type, "combo");
|
||||
assert.equal(get("gif_size").type, "combo");
|
||||
assert.equal(get("svg_model").type, "hidden");
|
||||
get("model").value = "GPT Image 2.6 Economy";
|
||||
get("model").callback();
|
||||
assert.equal(get("quality").type, "hidden");
|
||||
assert.equal(get("gif_size").type, "hidden");
|
||||
assert.ok(get("frame_count").options.values.includes("64"));
|
||||
get("model").value = "GPT Image 2";
|
||||
get("model").callback();
|
||||
assert.equal(get("output_format").value, "png");
|
||||
});
|
||||
|
||||
test("connected output format exposes all applicable controls", async () => {
|
||||
const { node, get } = fixture();
|
||||
get("model").value = "GPT Image 2.6 Flare";
|
||||
get("model").callback();
|
||||
node.inputs.push({name: "output_format", link: 1});
|
||||
node.onConnectionsChange();
|
||||
await new Promise(queueMicrotask);
|
||||
assert.equal(get("frame_count").type, "combo");
|
||||
assert.equal(get("svg_model").type, "combo");
|
||||
assert.equal(get("output_compression").type, "combo");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Inspect node schemas without importing torch or the ComfyUI runtime."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from kie_api import gpt_image_options as options
|
||||
|
||||
|
||||
class GPTImageNodeSchemaTests(unittest.TestCase):
|
||||
def schemas(self):
|
||||
tree = ast.parse((Path(__file__).resolve().parents[1] / "nodes.py").read_text())
|
||||
namespace = dict(
|
||||
GPT_IMAGE2_ASPECT_RATIO_OPTIONS=options.ASPECT_RATIO_OPTIONS,
|
||||
GPT_IMAGE2_RESOLUTION_OPTIONS=options.RESOLUTION_OPTIONS,
|
||||
GPT_IMAGE_MODEL_OPTIONS=options.MODEL_OPTIONS,
|
||||
GPT_IMAGE_DEFAULT_MODEL=options.DEFAULT_MODEL,
|
||||
GPT_IMAGE_BACKGROUND_OPTIONS=options.BACKGROUND_OPTIONS,
|
||||
gpt_image_widget_options=options.model_widget_options,
|
||||
gpt_image_generation_widgets=options.generation_widgets,
|
||||
)
|
||||
for cls in tree.body:
|
||||
if not isinstance(cls, ast.ClassDef) or cls.name not in ("KIE_GPTImage2_TextToImage", "KIE_GPTImage2_ImageToImage"):
|
||||
continue
|
||||
method = next(item for item in cls.body if isinstance(item, ast.FunctionDef) and item.name == "INPUT_TYPES")
|
||||
method.decorator_list = []
|
||||
exec(compile(ast.Module(body=[method], type_ignores=[]), "node-schema", "exec"), namespace)
|
||||
constants = {item.targets[0].id: ast.literal_eval(item.value) for item in cls.body
|
||||
if isinstance(item, ast.Assign) and item.targets[0].id in ("RETURN_TYPES", "RETURN_NAMES")}
|
||||
yield cls.name, namespace["INPUT_TYPES"](None), constants
|
||||
|
||||
def test_old_widget_and_output_order_preserved(self):
|
||||
for name, schema, constants in self.schemas():
|
||||
self.assertEqual(list(schema["optional"])[:5], ["aspect_ratio", "resolution", "log", "model", "background"])
|
||||
self.assertEqual(constants["RETURN_TYPES"], ("IMAGE", "MASK", "STRING", "STRING", "STRING"))
|
||||
self.assertEqual(constants["RETURN_NAMES"][:2], ("image", "mask"))
|
||||
self.assertEqual(schema["optional"]["model"][1]["default"], "GPT Image 2")
|
||||
|
||||
def test_mode_specific_economy_formats_and_typed_edit_guides(self):
|
||||
for name, schema, _ in self.schemas():
|
||||
rules = schema["optional"]["model"][1]["kie_model_options"]
|
||||
if name.endswith("ImageToImage"):
|
||||
self.assertEqual(rules["GPT Image 2.6 Economy"]["formats"], ["png"])
|
||||
self.assertEqual(schema["optional"]["mask"][0], "MASK")
|
||||
self.assertEqual(schema["optional"]["markup"][0], "IMAGE")
|
||||
self.assertEqual(schema["optional"]["markup_mask"][0], "MASK")
|
||||
else:
|
||||
self.assertEqual(rules["GPT Image 2.6 Economy"]["formats"], ["png", "gif"])
|
||||
self.assertNotIn("mask", schema["optional"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -53,5 +53,62 @@ class GPTImageOptionsTests(unittest.TestCase):
|
||||
self.payload(**override)
|
||||
|
||||
|
||||
class GPTImage26OptionsTests(unittest.TestCase):
|
||||
def payload(self, **overrides):
|
||||
args = dict(model="GPT Image 2.6 Flare", mode="text-to-image", prompt="A poster",
|
||||
aspect_ratio="auto", resolution="1K", background="opaque")
|
||||
args.update(overrides)
|
||||
return build_gpt_image_payload(**args)
|
||||
|
||||
def test_six_routes_and_economy_omissions(self):
|
||||
for variant in ("Flare", "Sunburst", "Economy"):
|
||||
for mode in ("text-to-image", "image-to-image"):
|
||||
result = self.payload(model=f"GPT Image 2.6 {variant}", mode=mode)
|
||||
self.assertEqual(result["model"], f"gpt-image-2-6/{variant.lower()}-{mode}")
|
||||
if variant == "Economy":
|
||||
self.assertEqual(set(result["input"]), {"prompt", "aspect_ratio", "background", "output_format"})
|
||||
else:
|
||||
self.assertEqual(result["input"]["quality"], "medium")
|
||||
|
||||
def test_limits_do_not_leak_between_versions(self):
|
||||
self.payload(prompt="a" * 32000, aspect_ratio="5:4", resolution="4K")
|
||||
for changes in (dict(prompt="a" * 32001), dict(aspect_ratio="27:16"),
|
||||
dict(model="GPT Image 2.6 Economy", prompt="a" * 20001),
|
||||
dict(model="GPT Image 2.6 Economy", resolution="2K"),
|
||||
dict(model="GPT Image 2.6 Economy", mode="image-to-image", output_format="gif")):
|
||||
with self.subTest(changes=changes.keys()), self.assertRaises(RuntimeError):
|
||||
self.payload(**changes)
|
||||
|
||||
def test_gif_types_and_budget(self):
|
||||
result = self.payload(output_format="gif", gif_size="512x512", frame_count="16")
|
||||
self.assertEqual(result["input"]["frame_count"], 16)
|
||||
self.assertNotIn("resolution", result["input"])
|
||||
self.assertNotIn("aspect_ratio", result["input"])
|
||||
result = self.payload(model="GPT Image 2.6 Economy", output_format="gif", frame_count="64")
|
||||
self.assertEqual(result["input"]["frame_count"], "64")
|
||||
self.assertNotIn("size", result["input"])
|
||||
for overrides in (dict(gif_size="1024x1024", frame_count="2"),
|
||||
dict(gif_size="768x768", frame_count="16"),
|
||||
dict(gif_size="1:1"), dict(frame_count="64"),
|
||||
dict(frame_duration_ms=21), dict(frame_duration_ms=True)):
|
||||
with self.subTest(overrides=overrides), self.assertRaises(RuntimeError):
|
||||
self.payload(output_format="gif", **overrides)
|
||||
|
||||
def test_inactive_values_are_not_sent(self):
|
||||
result = self.payload(frame_count="garbage", gif_size="garbage", svg_model="garbage")
|
||||
for name in ("frame_count", "size", "svg", "output_compression"):
|
||||
self.assertNotIn(name, result["input"])
|
||||
result = self.payload(output_format="svg")
|
||||
self.assertEqual(result["input"]["svg"], {"model": "gpt-5.6-luna"})
|
||||
result = self.payload(output_format="webp", output_compression=70)
|
||||
self.assertEqual(result["input"]["output_compression"], 70)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.payload(output_format="jpeg", background="transparent")
|
||||
|
||||
def test_edit_gif_omits_unverified_size_field(self):
|
||||
result = self.payload(mode="image-to-image", output_format="gif", gif_size="bad")
|
||||
self.assertNotIn("size", result["input"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -56,5 +56,109 @@ class GPTImageRuntimeTests(unittest.TestCase):
|
||||
self.assertEqual(payload["input"]["input_urls"], ["https://a", "https://b"])
|
||||
|
||||
|
||||
class GPTImage26MediaTests(unittest.TestCase):
|
||||
def guide(self, **kwargs):
|
||||
from kie_api.gpt_image_media import prepare_edit_guide
|
||||
args = dict(images=torch.zeros((1, 2, 3, 3)), model="GPT Image 2.6 Flare", output_format="png")
|
||||
args.update(kwargs)
|
||||
return prepare_edit_guide(**args)
|
||||
|
||||
def test_mask_png_alpha_is_inverse_of_edit_mask(self):
|
||||
mask = torch.tensor([[[0., 1., 0.5], [1., 0., 1.]]])
|
||||
field, data = self.guide(mask=mask)
|
||||
self.assertEqual(field, "mask_url")
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
self.assertEqual(image.mode, "RGBA")
|
||||
self.assertEqual(list(image.getchannel("A").getdata()), [255, 0, 128, 0, 255, 0])
|
||||
|
||||
def test_markup_preserves_color_and_transparency(self):
|
||||
annotation = torch.zeros((1, 2, 3, 3))
|
||||
annotation[..., 0] = 1
|
||||
mask = torch.tensor([[[1., 0., 1.], [1., 1., 1.]]])
|
||||
field, data = self.guide(markup=annotation, markup_mask=mask)
|
||||
self.assertEqual(field, "markup")
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
self.assertEqual(image.getpixel((1, 0)), (255, 0, 0, 255))
|
||||
self.assertEqual(image.getpixel((0, 0)), (255, 0, 0, 0))
|
||||
|
||||
def test_bad_guides_fail_before_upload(self):
|
||||
bad_cases = [dict(mask=torch.zeros((2, 2, 3))), dict(mask=torch.zeros((1, 3, 2))),
|
||||
dict(mask=torch.full((1, 2, 3), float("nan"))),
|
||||
dict(mask=torch.ones((1, 2, 3)), markup=torch.zeros((1, 2, 3, 3))),
|
||||
dict(markup=torch.zeros((1, 2, 3, 3))),
|
||||
dict(markup=torch.zeros((1, 2, 3, 3)), markup_mask=torch.zeros((1, 2, 3))),
|
||||
dict(mask=torch.zeros((1, 2, 3)), output_format="gif"),
|
||||
dict(mask=torch.zeros((1, 2, 3)), output_format="svg"),
|
||||
dict(mask=torch.zeros((1, 2, 3)), model="GPT Image 2.6 Economy")]
|
||||
with patch.object(gpt_image2, "_load_api_key") as auth:
|
||||
for overrides in bad_cases:
|
||||
args = dict(prompt="Change selected item", images=torch.zeros((1, 2, 3, 3)),
|
||||
model="GPT Image 2.6 Flare", aspect_ratio="auto", resolution="1K")
|
||||
args.update(overrides)
|
||||
with self.subTest(keys=overrides.keys()), self.assertRaises(RuntimeError):
|
||||
gpt_image2.run_gpt_image2_image_to_image(**args)
|
||||
auth.assert_not_called()
|
||||
|
||||
def test_guide_upload_is_separate_from_reference_urls(self):
|
||||
with patch.object(gpt_image2, "_load_api_key", return_value="test"), \
|
||||
patch.object(gpt_image2, "_upload_image", side_effect=["https://source", "https://mask"]), \
|
||||
patch.object(gpt_image2, "_run_gpt_image2_payload", return_value=()) as run:
|
||||
gpt_image2.run_gpt_image2_image_to_image(
|
||||
prompt="Edit", images=torch.zeros((1, 2, 3, 3)), mask=torch.ones((1, 2, 3)),
|
||||
model="GPT Image 2.6 Sunburst", aspect_ratio="auto", resolution="1K", log=False)
|
||||
inputs = run.call_args.kwargs["payload"]["input"]
|
||||
self.assertEqual(inputs["input_urls"], ["https://source"])
|
||||
self.assertEqual(inputs["mask_url"], "https://mask")
|
||||
|
||||
def test_gif_frames_timing_and_original_bytes_survive(self):
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from kie_api.gpt_image_media import collect_results
|
||||
data = BytesIO()
|
||||
Image.new("RGB", (3, 2), "red").save(data, format="GIF", save_all=True,
|
||||
append_images=[Image.new("RGB", (3, 2), "blue")], duration=[100, 230], loop=0)
|
||||
with TemporaryDirectory() as directory:
|
||||
image, mask, paths, svg, metadata = collect_results(
|
||||
[("https://result", data.getvalue())], task_id="test", output_format="gif", output_dir=directory)
|
||||
self.assertEqual(tuple(image.shape), (2, 2, 3, 3))
|
||||
self.assertEqual(tuple(mask.shape), (2, 2, 3))
|
||||
self.assertEqual(Path(json.loads(paths)[0]).read_bytes(), data.getvalue())
|
||||
record = json.loads(metadata)["outputs"][0]
|
||||
self.assertEqual(record["frame_durations_ms"], [100, 230])
|
||||
self.assertEqual(record["loop"], 0)
|
||||
self.assertEqual(svg, "")
|
||||
|
||||
def test_svg_is_native_text_and_file_without_fake_image(self):
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from kie_api.gpt_image_media import collect_results, decode_result
|
||||
data = b'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><path d="M0 0L10 10"/></svg>'
|
||||
with TemporaryDirectory() as directory:
|
||||
image, mask, paths, svg, metadata = collect_results(
|
||||
[("https://result", data)], task_id="test", output_format="svg", output_dir=directory)
|
||||
self.assertIsNone(image)
|
||||
self.assertIsNone(mask)
|
||||
self.assertEqual(svg, data.decode())
|
||||
self.assertEqual(Path(json.loads(paths)[0]).read_bytes(), data)
|
||||
with self.assertRaises(RuntimeError):
|
||||
decode_result(b'<html>error</html>', "svg")
|
||||
with self.assertRaises(RuntimeError):
|
||||
decode_result(data, "png")
|
||||
|
||||
def test_all_raster_result_urls_are_retained(self):
|
||||
import json
|
||||
from tempfile import TemporaryDirectory
|
||||
from kie_api.gpt_image_media import collect_results
|
||||
data = BytesIO()
|
||||
Image.new("RGB", (3, 2), "red").save(data, format="PNG")
|
||||
with TemporaryDirectory() as directory:
|
||||
result = collect_results([("https://a", data.getvalue()), ("https://b", data.getvalue())],
|
||||
task_id="test", output_format="png", output_dir=directory)
|
||||
self.assertEqual(result[0].shape[0], 2)
|
||||
self.assertEqual(len(json.loads(result[2])), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# KIE GPT Image (Image-to-Image)
|
||||
|
||||
Generate or edit images with GPT Image 2, GPT Image 2.5 Flare, or GPT Image 2.5 Sunburst.
|
||||
Use GPT Image 2, 2.5 Flare/Sunburst, or 2.6 Flare/Sunburst/Economy in the existing node. The baseline inputs below describe 2/2.5; the 2.6 additions are documented afterward.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -49,7 +49,7 @@ Pinned ratios follow the actual schema `enum` and Kie model page, not extra labe
|
||||
|
||||
## Verification
|
||||
|
||||
Mac checks cover syntax and static review only. No paid generation or ComfyUI runtime verification has been performed here.
|
||||
Baseline Mac checks cover syntax and static review; see the 2.6 verification section for additional lightweight tests. No paid generation or ComfyUI runtime verification has been performed here.
|
||||
|
||||
On Windows, from this repository using the ComfyUI Python environment:
|
||||
|
||||
@@ -58,3 +58,61 @@ python -m unittest discover -s tests -p "test_gpt_image*.py"
|
||||
```
|
||||
|
||||
Load an older GPT Image 2 workflow and verify its prompt, ratio, resolution, logging and image connection. Generate with each 2.5 model in both modes; exercise auto at 2K/4K, 1K-only ratios, invalid connected inputs, and transparency via Join Image with Alpha. Save/reload and verify selections. These UI/live checks remain pending.
|
||||
|
||||
## GPT Image 2.6
|
||||
|
||||
The existing model dropdown also includes **GPT Image 2.6 Flare**, **Sunburst**, and **Economy**. Flare/Sunburst support prompts up to 32,000 characters; Economy remains at 20,000 and 1K. Model and mode determine the permitted output formats. New controls are appended after existing widgets; inactive advanced controls are hidden and omitted from requests.
|
||||
|
||||
| Control | Meaning |
|
||||
|---|---|
|
||||
| output_format | Flare/Sunburst: PNG, JPEG, WebP, GIF, SVG. Economy choices depend on mode. |
|
||||
| quality | Rendering detail: medium (default), low, high, xhigh, max, auto. Flare/Sunburst only. |
|
||||
| complexity | Planning effort: high (default), medium, low. Independent of quality. |
|
||||
| moderation | Kie filtering level: low (provider default) or auto. |
|
||||
| output_compression | JPEG/WebP only, 0–100. |
|
||||
| frame_count | GIF only: Flare/Sunburst 2–16; Economy auto, 4, 9, 16, 25, 36, 64. |
|
||||
| frame_duration_ms | GIF only: 20–2000, multiples of 10. Default 200. |
|
||||
| gif_size | Flare/Sunburst T2I GIF only: auto or frame dimensions such as 512x512. |
|
||||
| svg_model | SVG author model: gpt-5.6-luna (default), gpt-6-sol, gpt-5.6-terra. |
|
||||
|
||||
2.6 Flare/Sunburst aspect ratios: auto, 1:1, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 16:9, 9:16, 2:1, 1:2, 21:9, 9:21, 3:1, 1:3. Economy: auto, 1:1, 16:9, 9:16, 3:2, 2:3, 21:9, 4:3. Older model ratios/restrictions remain unchanged.
|
||||
|
||||
Resolution tiers are provider labels: square 4K is 2880×2880, not 4096×4096. Standard GIF requests omit static aspect ratio/resolution. T2I uses gif_size and validates the provider sprite-sheet budget; 512x512 works for all supported frame counts. The I2I contract does not expose size, so gif_size is ignored there. Economy omits resolution from the payload and uses its own GIF frame-count encoding.
|
||||
|
||||
Flare/Sunburst use token billing, including extra author-model usage for SVG. No pricing estimate is treated as a spending cap. Kie lists SVG editing even though OpenAI's direct editing guide does not; this implementation follows Kie's contract. Kie's transparency documentation is inconsistent about resolution and GIF/SVG. These combinations need live verification; JPEG with transparent background is rejected locally.
|
||||
|
||||
### New outputs and saved files
|
||||
|
||||
The first two sockets remain IMAGE and MASK. The three appended STRING sockets are:
|
||||
|
||||
- **file_paths:** JSON array of absolute paths to every original provider file, automatically saved under ComfyUI's `output/kie_gpt_image/` directory using unique names.
|
||||
- **svg:** Native SVG XML text, empty for raster outputs. If a provider returns multiple SVG documents, this is a JSON array of their texts; individual files remain available through file_paths.
|
||||
- **metadata:** JSON containing task_id and an outputs array with paths, URLs, format, MIME type and byte size. Raster records include dimensions/frame count; GIF records include per-frame durations and loop metadata.
|
||||
|
||||
GIF IMAGE/MASK outputs contain every decoded frame in order. Saving the IMAGE batch alone loses animation timing; use the original GIF file, or use metadata timings when rebuilding animation. SVG outputs have **no IMAGE or MASK** (`None`); connect only the STRING outputs for SVG workflows. No rasterization dependency is installed and no placeholder image is generated. SVG text is returned as data, not rendered or executed.
|
||||
|
||||
All original files are retained. Multiple raster outputs must have matching dimensions to form a ComfyUI IMAGE batch; if they differ, the error identifies the saved originals. Output content is checked against the selected 2.6 format, so an unexpected raster response is not silently accepted as SVG/GIF.
|
||||
|
||||
### 2.6 verification
|
||||
|
||||
Mac verification covers Python syntax, dependency-free payload tests, node schema checks, and headless widget lifecycle tests. Torch/Pillow runtime tests are supplied for the Windows ComfyUI environment; they are not executed on the Mac. No paid 2.6 generation has been performed.
|
||||
|
||||
Run in this repository on Windows using ComfyUI's Python interpreter:
|
||||
|
||||
```text
|
||||
python -m unittest discover -s tests -p "test_gpt_image*.py"
|
||||
```
|
||||
|
||||
Then verify six routes with small PNG requests, both edit-guide paths, a short GIF (inspect animation and timings), and an SVG (inspect its file/text). Verify saved 2/2.5 workflows and switching models/formats, including connected selectors. Kie SVG editing and transparency beyond 1K remain provider smoke-test cases. Reload ComfyUI and refresh the browser before testing.
|
||||
|
||||
Sources checked 2026-11-11: [Kie market and live API tabs](https://kie.ai/gpt-image-2-6), [Flare T2I](https://docs.kie.ai/market/gpt/gpt-image-2-6-flare-text-to-image), [Flare I2I](https://docs.kie.ai/market/gpt/gpt-image-2-6-flare-image-to-image), [Economy T2I](https://docs.kie.ai/market/gpt/gpt-image-2-6-economy-text-to-image), [Economy I2I](https://docs.kie.ai/market/gpt/gpt-image-2-6-economy-image-to-image).
|
||||
|
||||
### Connecting an edit guide
|
||||
|
||||
Economy I2I returns PNG only. Mask and markup inputs are available for 2.6 Flare/Sunburst; older models and Economy reject connected guides before any upload.
|
||||
|
||||
For **mask editing**, connect a single ComfyUI MASK matching the first source image: 1 means edit, 0 means preserve. The node encodes it as an RGBA PNG with alpha = 1 − mask and sends its uploaded URL as mask_url. Masks cannot be combined with GIF/SVG output.
|
||||
|
||||
For **markup editing**, create a separate transparent annotation PNG with a visible rectangle, circle, or arrow. Load that PNG separately: connect its IMAGE to markup and its MASK to markup_mask. Both must match the first source image dimensions. RGB markup requires markup_mask; an RGBA tensor may instead carry its own alpha, in which case leave markup_mask disconnected. Do not flatten annotations onto the photograph. The node uploads the annotation as markup. Explain the change in the prompt, e.g. “Replace the mug inside the red rectangle with a blue mug; preserve the table and lighting.”
|
||||
|
||||
Never connect mask and markup together. Validation rejects mismatched dimensions, batches of guides, missing annotation alpha, nonfinite/out-of-range values, and entirely opaque or invisible markup. Mask/markup apply to the first reference; other source images remain references. These are model guidance, not a guarantee that every pixel outside the region is preserved.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# KIE GPT Image (Text-to-Image)
|
||||
|
||||
Generate or edit images with GPT Image 2, GPT Image 2.5 Flare, or GPT Image 2.5 Sunburst.
|
||||
Use GPT Image 2, 2.5 Flare/Sunburst, or 2.6 Flare/Sunburst/Economy in the existing node. The baseline inputs below describe 2/2.5; the 2.6 additions are documented afterward.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -47,7 +47,7 @@ Pinned ratios follow the actual schema `enum` and Kie model page, not extra labe
|
||||
|
||||
## Verification
|
||||
|
||||
Mac checks cover syntax and static review only. No paid generation or ComfyUI runtime verification has been performed here.
|
||||
Baseline Mac checks cover syntax and static review; see the 2.6 verification section for additional lightweight tests. No paid generation or ComfyUI runtime verification has been performed here.
|
||||
|
||||
On Windows, from this repository using the ComfyUI Python environment:
|
||||
|
||||
@@ -56,3 +56,53 @@ python -m unittest discover -s tests -p "test_gpt_image*.py"
|
||||
```
|
||||
|
||||
Load an older GPT Image 2 workflow and verify its prompt, ratio, resolution, logging and image connection. Generate with each 2.5 model in both modes; exercise auto at 2K/4K, 1K-only ratios, invalid connected inputs, and transparency via Join Image with Alpha. Save/reload and verify selections. These UI/live checks remain pending.
|
||||
|
||||
## GPT Image 2.6
|
||||
|
||||
The existing model dropdown also includes **GPT Image 2.6 Flare**, **Sunburst**, and **Economy**. Flare/Sunburst support prompts up to 32,000 characters; Economy remains at 20,000 and 1K. Model and mode determine the permitted output formats. New controls are appended after existing widgets; inactive advanced controls are hidden and omitted from requests.
|
||||
|
||||
| Control | Meaning |
|
||||
|---|---|
|
||||
| output_format | Flare/Sunburst: PNG, JPEG, WebP, GIF, SVG. Economy choices depend on mode. |
|
||||
| quality | Rendering detail: medium (default), low, high, xhigh, max, auto. Flare/Sunburst only. |
|
||||
| complexity | Planning effort: high (default), medium, low. Independent of quality. |
|
||||
| moderation | Kie filtering level: low (provider default) or auto. |
|
||||
| output_compression | JPEG/WebP only, 0–100. |
|
||||
| frame_count | GIF only: Flare/Sunburst 2–16; Economy auto, 4, 9, 16, 25, 36, 64. |
|
||||
| frame_duration_ms | GIF only: 20–2000, multiples of 10. Default 200. |
|
||||
| gif_size | Flare/Sunburst T2I GIF only: auto or frame dimensions such as 512x512. |
|
||||
| svg_model | SVG author model: gpt-5.6-luna (default), gpt-6-sol, gpt-5.6-terra. |
|
||||
|
||||
2.6 Flare/Sunburst aspect ratios: auto, 1:1, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 16:9, 9:16, 2:1, 1:2, 21:9, 9:21, 3:1, 1:3. Economy: auto, 1:1, 16:9, 9:16, 3:2, 2:3, 21:9, 4:3. Older model ratios/restrictions remain unchanged.
|
||||
|
||||
Resolution tiers are provider labels: square 4K is 2880×2880, not 4096×4096. Standard GIF requests omit static aspect ratio/resolution. T2I uses gif_size and validates the provider sprite-sheet budget; 512x512 works for all supported frame counts. The I2I contract does not expose size, so gif_size is ignored there. Economy omits resolution from the payload and uses its own GIF frame-count encoding.
|
||||
|
||||
Flare/Sunburst use token billing, including extra author-model usage for SVG. No pricing estimate is treated as a spending cap. Kie lists SVG editing even though OpenAI's direct editing guide does not; this implementation follows Kie's contract. Kie's transparency documentation is inconsistent about resolution and GIF/SVG. These combinations need live verification; JPEG with transparent background is rejected locally.
|
||||
|
||||
### New outputs and saved files
|
||||
|
||||
The first two sockets remain IMAGE and MASK. The three appended STRING sockets are:
|
||||
|
||||
- **file_paths:** JSON array of absolute paths to every original provider file, automatically saved under ComfyUI's `output/kie_gpt_image/` directory using unique names.
|
||||
- **svg:** Native SVG XML text, empty for raster outputs. If a provider returns multiple SVG documents, this is a JSON array of their texts; individual files remain available through file_paths.
|
||||
- **metadata:** JSON containing task_id and an outputs array with paths, URLs, format, MIME type and byte size. Raster records include dimensions/frame count; GIF records include per-frame durations and loop metadata.
|
||||
|
||||
GIF IMAGE/MASK outputs contain every decoded frame in order. Saving the IMAGE batch alone loses animation timing; use the original GIF file, or use metadata timings when rebuilding animation. SVG outputs have **no IMAGE or MASK** (`None`); connect only the STRING outputs for SVG workflows. No rasterization dependency is installed and no placeholder image is generated. SVG text is returned as data, not rendered or executed.
|
||||
|
||||
All original files are retained. Multiple raster outputs must have matching dimensions to form a ComfyUI IMAGE batch; if they differ, the error identifies the saved originals. Output content is checked against the selected 2.6 format, so an unexpected raster response is not silently accepted as SVG/GIF.
|
||||
|
||||
### 2.6 verification
|
||||
|
||||
Mac verification covers Python syntax, dependency-free payload tests, node schema checks, and headless widget lifecycle tests. Torch/Pillow runtime tests are supplied for the Windows ComfyUI environment; they are not executed on the Mac. No paid 2.6 generation has been performed.
|
||||
|
||||
Run in this repository on Windows using ComfyUI's Python interpreter:
|
||||
|
||||
```text
|
||||
python -m unittest discover -s tests -p "test_gpt_image*.py"
|
||||
```
|
||||
|
||||
Then verify six routes with small PNG requests, both edit-guide paths, a short GIF (inspect animation and timings), and an SVG (inspect its file/text). Verify saved 2/2.5 workflows and switching models/formats, including connected selectors. Kie SVG editing and transparency beyond 1K remain provider smoke-test cases. Reload ComfyUI and refresh the browser before testing.
|
||||
|
||||
Sources checked 2026-11-11: [Kie market and live API tabs](https://kie.ai/gpt-image-2-6), [Flare T2I](https://docs.kie.ai/market/gpt/gpt-image-2-6-flare-text-to-image), [Flare I2I](https://docs.kie.ai/market/gpt/gpt-image-2-6-flare-image-to-image), [Economy T2I](https://docs.kie.ai/market/gpt/gpt-image-2-6-economy-text-to-image), [Economy I2I](https://docs.kie.ai/market/gpt/gpt-image-2-6-economy-image-to-image).
|
||||
|
||||
Economy T2I permits PNG or GIF; SVG, quality, complexity, and custom GIF frame size are unavailable for Economy.
|
||||
|
||||
Reference in New Issue
Block a user