Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project are documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses semantic versioning before its first stable release.

## [Unreleased]

### Added

- `--aspect-ratio W:H` for `generate`, `edit`, and `batch`. The tested backend ignores the request's `size` field when framing the image, so the ratio is appended to the prompt as explicit framing guidance and the returned image is verified against it with `--size-policy`. Ratios from `1:3` to `3:1` are accepted; requests beyond that range are clamped by the backend and reported as a mismatch. `--aspect-ratio` and `--size` are mutually exclusive.

## [0.2.0] - 2026-09-15

### Changed
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ Each line can be either a JSON string prompt or an object with:

Per-job validation or backend failures are reported and later jobs continue unless `--fail-fast` is set. Any failed job produces exit status 1.

Image options such as `--size` and `--aspect-ratio` are CLI flags and apply to every job in the run.

## Useful Flags

- `--prompt-file prompt.txt`: read the prompt from a file
Expand All @@ -197,7 +199,8 @@ Per-job validation or backend failures are reported and later jobs continue unle
- `--background auto|opaque`: direct `background` parameter. Explicit `transparent` is unsupported on the tested backend and rejected locally.
- `--quality auto|low|medium|high`: direct `quality` parameter.
- `--size auto|WIDTHxHEIGHT`: requested dimensions; see constraints below.
- `--size-policy warn|error`: on a dimension mismatch, warn and save (default), or fail without writing the output.
- `--aspect-ratio W:H`: requested aspect ratio (for example `16:9`, `2:3`, `21:9`). The backend ignores the request's `size` field, so the ratio is sent as prompt guidance and the returned image is verified against it. Not allowed together with `--size`. See aspect ratios below.
- `--size-policy warn|error`: on a dimension or aspect-ratio mismatch, warn and save (default), or fail without writing the output.
- `--style-image PATH`: edit mode only. Use this image as the style reference; `--image` stays the content image and `--prompt` becomes optional extra guidance.
- `--output-format auto|png|webp`: output file format. Default: infer from `--out`; `.webp` writes WebP, everything else writes PNG.
- `--webp-quality 1..100`: WebP encoder quality. Default: `85`.
Expand Down Expand Up @@ -271,6 +274,38 @@ The accepted image preference values are:

Requested dimensions are not guaranteed. The CLI reports actual dimensions and warns on mismatches by default. `--size-policy error` rejects a mismatched output without saving it, even with `--force`; the generation has already occurred and may have consumed usage. The CLI does not resize or crop output to match the request.

### Aspect ratios

`--size` does not control the aspect ratio. The tested backend ignores the request's `size` field for framing and returns its own dimensions, so `--size 1024x1536` can come back as a square. What does control the ratio is the prompt: `--aspect-ratio W:H` prepends explicit framing guidance to the prompt and then verifies the returned image against that ratio using `--size-policy`.

```bash
codex-imagegen generate --prompt "A lighthouse at dusk" --out out.png --aspect-ratio 2:3
codex-imagegen generate --prompt "A wide banner" --out banner.png --aspect-ratio 21:9
```

The framing instruction leads the prompt, so the ratio is set before the scene is described:

```text
The frame must be in 2:3 portrait format, taller than it is wide.

A lighthouse at dusk
```

Ratios are accepted between `1:3` and `3:1`. Live checks on the tested backend returned the requested ratio every time, with the frame size following the ratio at a roughly constant pixel budget:

| Requested | Returned | Returned ratio |
| --- | --- | --- |
| `1:1` | `1254x1254` | 1.000 |
| `3:4` | `1086x1448` | 0.750 |
| `2:3` | `1024x1536` | 0.667 |
| `9:16` | `941x1672` | 0.563 |
| `1:2` | `887x1774` | 0.500 |
| `16:9` | `1672x941` | 1.777 |
| `21:9` | `1916x821` | 2.334 |
| `3:1` | `2172x724` | 3.000 |

Requests beyond 3:1 are rejected locally, because the backend clamps them to roughly 3:1 (a live `4:1` request came back at `2172x724`) and would otherwise surface as a mismatch only after spending usage. Because the exact returned size is chosen by the backend, treat `--aspect-ratio` as a way to get the framing right, not as a way to pin exact pixels; crop or pad afterwards if a fixed pixel size is required. The CLI never silently resizes or crops the returned image to hide a mismatch.

`n` is handled by the CLI by running one hosted image request per output path.
For edit jobs, repeated outputs may wait for the per-minute input-image quota window before retrying; if the bucket stays full, retries back off progressively.
Edit input images are compacted locally to WebP before upload by default. This keeps request bodies smaller and reduces pressure on the input-image quota.
Expand Down
90 changes: 83 additions & 7 deletions codex_imagegen_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
INPUT_IMAGE_RATE_LIMIT_DELAYS = (65.0, 130.0, 260.0, 300.0)
DEFAULT_INPUT_MAX_EDGE = 1536
DEFAULT_INPUT_WEBP_QUALITY = 90
# The backend honors an aspect ratio only as prompt guidance and clamps beyond 1:3..3:1.
ASPECT_RATIO_LIMIT = 3.0
# Live checks returned the requested ratio within 0.1%; 2% still rejects a real mismatch
# such as 2:3 (0.667) answered with 9:16 (0.563).
ASPECT_RATIO_TOLERANCE = 0.02


class CliError(Exception):
Expand Down Expand Up @@ -665,7 +670,7 @@ def _native_payload(
) -> dict[str, Any]:
payload = {
"model": NATIVE_REQUEST_MODEL,
"prompt": prompt,
"prompt": _prompt_with_aspect_ratio(prompt, args),
"size": args.size,
"quality": args.quality,
"background": args.background,
Expand All @@ -684,7 +689,9 @@ def _responses_payload(
mode: str,
images: Sequence[Path] | None = None,
) -> dict[str, Any]:
content: list[dict[str, Any]] = [{"type": "input_text", "text": prompt}]
content: list[dict[str, Any]] = [
{"type": "input_text", "text": _prompt_with_aspect_ratio(prompt, args)}
]
if images:
if len(images) > MAX_EDIT_IMAGES:
raise CliError(f"Edit supports at most {MAX_EDIT_IMAGES} images.")
Expand Down Expand Up @@ -732,6 +739,7 @@ def _write_response_image(
webp_quality: int,
requested_size: str = "auto",
size_policy: str = "warn",
requested_aspect_ratio: str | None = None,
) -> Path:
_check_output(output_path, force)
if len(encoded) > ((MAX_IMAGE_BYTES + 2) // 3) * 4:
Expand All @@ -750,12 +758,20 @@ def _write_response_image(
actual_size = f"{image.width}x{image.height}"
except (OSError, ValueError, Image.DecompressionBombError) as exc:
raise CliError(f"Image generation returned an invalid image: {exc}") from exc
if requested_size != "auto" and requested_size != actual_size:
message = f"Requested {requested_size}, backend returned {actual_size} for {output_path}."
mismatch = None
if requested_aspect_ratio is not None:
if _aspect_ratio_mismatch(requested_aspect_ratio, actual_size):
mismatch = (
f"Requested aspect ratio {requested_aspect_ratio}, backend returned "
f"{actual_size} for {output_path}."
)
elif requested_size != "auto" and requested_size != actual_size:
mismatch = f"Requested {requested_size}, backend returned {actual_size} for {output_path}."
if mismatch is not None:
if size_policy == "error":
raise CliError(message + " Output was not written (--size-policy error).")
raise CliError(mismatch + " Output was not written (--size-policy error).")
_warn(
message
mismatch
+ " Saving the original dimensions; use --size-policy error to reject mismatches."
)
_write_image_bytes(
Expand Down Expand Up @@ -960,6 +976,7 @@ def _call_backend(
webp_quality=args.webp_quality,
requested_size=args.size,
size_policy=args.size_policy,
requested_aspect_ratio=args.aspect_ratio,
)
)
# Publish each successful path immediately, even if a later image fails.
Expand Down Expand Up @@ -1195,12 +1212,23 @@ def _add_image_args(parser: argparse.ArgumentParser) -> None:
default="auto",
help="Direct quality parameter.",
)
parser.add_argument(
ratio_group = parser.add_mutually_exclusive_group()
ratio_group.add_argument(
"--size",
type=_parse_size,
default="auto",
help="Requested image dimensions: auto or WIDTHxHEIGHT. Backend may return a different size.",
)
ratio_group.add_argument(
"--aspect-ratio",
type=_parse_aspect_ratio,
default=None,
help=(
"Requested aspect ratio as W:H (for example 16:9, 2:3, 21:9). "
"Sent as prompt guidance because the backend ignores the size field; "
"the result is verified against the requested ratio. Not allowed with --size."
),
)
parser.add_argument(
"--size-policy",
choices=["warn", "error"],
Expand Down Expand Up @@ -1260,6 +1288,54 @@ def _parse_size(value: str) -> str:
)


def _parse_aspect_ratio(value: str) -> str:
match = re.fullmatch(r"([1-9][0-9]{0,2}):([1-9][0-9]{0,2})", value)
if match:
width, height = map(int, match.groups())
ratio = width / height
if 1 / ASPECT_RATIO_LIMIT <= ratio <= ASPECT_RATIO_LIMIT:
return f"{width}:{height}"
raise argparse.ArgumentTypeError(
"Aspect ratio must be W:H between 1:3 and 3:1, for example 16:9, 2:3, or 21:9."
)


def _aspect_ratio_value(aspect_ratio: str) -> float:
width, height = aspect_ratio.split(":")
return int(width) / int(height)


def _prompt_with_aspect_ratio(prompt: str, args: argparse.Namespace) -> str:
ratio = getattr(args, "aspect_ratio", None)
return _aspect_ratio_prompt(prompt, ratio) if ratio else prompt


def _aspect_ratio_prompt(prompt: str, aspect_ratio: str) -> str:
"""Ask for the ratio in words: the backend ignores the request's size field.

The instruction leads the prompt so the framing is set before the scene is
described, matching how the style-library templates order ratio constraints.
"""
value = _aspect_ratio_value(aspect_ratio)
if value == 1:
instruction = "The frame must be a 1:1 square."
elif value > 1:
instruction = (
f"The frame must be in {aspect_ratio} landscape format, wider than it is tall."
)
else:
instruction = (
f"The frame must be in {aspect_ratio} portrait format, taller than it is wide."
)
return f"{instruction}\n\n{prompt}"


def _aspect_ratio_mismatch(aspect_ratio: str, size: str) -> bool:
width, height = (int(part) for part in size.split("x"))
requested = _aspect_ratio_value(aspect_ratio)
return abs(width / height - requested) / requested > ASPECT_RATIO_TOLERANCE


def _validate_common(args: argparse.Namespace) -> Path:
if not math.isfinite(args.timeout) or args.timeout <= 0:
raise CliError("--timeout must be a positive finite number.")
Expand Down
Loading