forked from arechen/EapilSkillMarket
Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
1 parent
2fb1818ebf
commit
b397f552f0
37 files changed
+889
-233
No files matched your search
@@ -2,8 +2,8 @@
|
||||
"sourceId": "docx",
|
||||
"repo": "https://github.com/anthropics/skills.git",
|
||||
"ref": "main",
|
||||
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
|
||||
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/docx",
|
||||
"syncedAt": "2026-10-10T16:00:00Z"
|
||||
"syncedAt": "2026-10-29T16:00:00Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "find-skills",
|
||||
"repo": "https://github.com/vercel-labs/skills.git",
|
||||
"ref": "main",
|
||||
"commit": "699d35b21bd3c97dbc0edeae062b2698898bb2e5",
|
||||
"commit": "92a9a34ac3bf88e695e468b90c7bf32f163d32c8",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/find-skills",
|
||||
"syncedAt": "2026-10-28T15:59:59Z"
|
||||
"syncedAt": "2026-10-29T16:00:00Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "grill-me",
|
||||
"repo": "https://github.com/mattpocock/skills.git",
|
||||
"ref": "main",
|
||||
"commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c",
|
||||
"commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills/productivity",
|
||||
"syncedAt": "2026-10-27T07:55:04Z"
|
||||
"syncedAt": "2026-10-29T16:00:00Z"
|
||||
}
|
||||
@@ -25,6 +25,6 @@ Word each question so "yes" accepts your recommended answer.
|
||||
|
||||
Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
|
||||
|
||||
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
|
||||
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent with a fresh context window to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
|
||||
|
||||
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-10-28T16:03:24Z"
|
||||
"syncedAt": "2026-10-29T16:03:34Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "f922205f9e860de24673269f81dea51e9e33b0cb",
|
||||
"commit": "79107ebd94e53c69865c1a49ac734ba8004491ba",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-10-28T15:59:59Z"
|
||||
"syncedAt": "2026-10-29T16:00:00Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "pdf",
|
||||
"repo": "https://github.com/anthropics/skills.git",
|
||||
"ref": "main",
|
||||
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
|
||||
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/pdf",
|
||||
"syncedAt": "2026-10-10T16:00:00Z"
|
||||
"syncedAt": "2026-10-29T16:00:00Z"
|
||||
}
|
||||
@@ -106,7 +106,7 @@ Why you'd choose it, and where it isn't the right fit → [Why PPT Master](./doc
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### This is a tool, not a wishing well
|
||||
> `harness + model = agent` — PPT Master only owns the workflow; the model sets the ceiling. Recommended: **Kimi K3 (or Claude) with a large context window (~1M tokens) + AI image generation (`gpt-image-2` or Google `gemini-nano-banana-2.1`)**; other models can run the pipeline, with a quality gap.
|
||||
> `harness + model = agent` — PPT Master only owns the workflow; the model sets the ceiling. Recommended: **Kimi K3 (or Claude) with a large context window (~1M tokens) + AI image generation (`gpt-image-2.5-sunburst` or Google `gemini-nano-banana-2.1`)**; other models can run the pipeline, with a quality gap.
|
||||
>
|
||||
> And don't expect a finished, perfect deck in one shot. The tool's value is taking most of the tedious work off your plate; the polishing that's left is yours — a natively editable deck exists precisely so you can keep working on it, not a flat image you can't touch. The cheaper the model, the more there is to do; if results disappoint, upgrade the model first, then check your usage against [Getting Started](./docs/getting-started.md) and the [example projects](https://hugohe3.github.io/ppt-master-examples/).
|
||||
|
||||
@@ -165,7 +165,7 @@ Knowing how to use Python and AI agents will matter more and more, and this proj
|
||||
|
||||
### 1. Prerequisites
|
||||
|
||||
**All you need to install is [Python](https://www.python.org/downloads/) 3.10+.** Everything else comes with one line — `pip install -r requirements.txt` — after you download the project in Step 3.
|
||||
**All you need to install is [Python](https://www.python.org/downloads/) 3.10–3.14.** Everything else comes with one line — `pip install -r requirements.txt` — after you download the project in Step 3.
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows</strong> — see the dedicated <a href="./docs/windows-installation.md">step-by-step guide</a> ⚠️</summary>
|
||||
@@ -217,7 +217,7 @@ Never used one of these? Don't worry — in this project they play exactly one r
|
||||
| **IDE plugin / extension** | [Claude Code](https://claude.ai/code) (VS Code / JetBrains extension), [GitHub Copilot](https://github.com/features/copilot), [Cline](https://cline.bot/), etc. | Installed inside hosts like VS Code or JetBrains |
|
||||
| **CLI agent** | [Claude Code](https://claude.ai/code) CLI, [Codex CLI](https://github.com/openai/codex), Gemini CLI, etc. | Runs in the terminal; suits scripting, remote, or server use |
|
||||
|
||||
> **Model recommendation**: for the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the pipeline, paired with AI image generation — **`gpt-image-2`** (OpenAI) or **`gemini-nano-banana-2.1`** (Google). Kimi Code, the project sponsor, is a great pick for pay-as-you-go access.
|
||||
> **Model recommendation**: for the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the pipeline, paired with AI image generation — **`gpt-image-2.5-sunburst`** (OpenAI) or **`gemini-nano-banana-2.1`** (Google). Kimi Code, the project sponsor, is a great pick for pay-as-you-go access.
|
||||
|
||||
**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.ai/register?aff=ppt-master)**, **[APIKEY.FAN](https://apikey.fan/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.host/register?aff=WMLJ)** offer pay-as-you-go access to Claude, GPT, Gemini and more — no subscription required, with exclusive discounts for our users (details at the top of this page).
|
||||
|
||||
@@ -318,7 +318,7 @@ Whatever you state explicitly is followed; whatever you leave unspecified the ag
|
||||
|
||||
Two paths for non-user images, mixable per image in the same deck:
|
||||
|
||||
**A) AI generation** — use the agent host's native image tool when available, or `image_gen.py` with `IMAGE_BACKEND` plus the provider's `*_API_KEY`. Host-native generation needs no separate provider image API key; ask the agent to use its own image tool. Run `python3 skills/ppt-master/scripts/image_gen.py --list-backends` for the configured-provider path. `gpt-image-2` is currently the best default.
|
||||
**A) AI generation** — use the agent host's native image tool when available, or `image_gen.py` with `IMAGE_BACKEND` plus the provider's `*_API_KEY`. Host-native generation needs no separate provider image API key; ask the agent to use its own image tool. Run `python3 skills/ppt-master/scripts/image_gen.py --list-backends` for the configured-provider path. `gpt-image-2.5-sunburst` is the current OpenAI default.
|
||||
|
||||
**B) Web image search** — `image_search.py`. **Zero-config works**; configure `PEXELS_API_KEY` / `PIXABAY_API_KEY` (both free) for consistently higher-quality results:
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "6fbac39e1d06a66ce3a8724b9366cb878b793f99",
|
||||
"commit": "15baa7c570f12c78cd9ac6ead897724571e32e87",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-10-28T15:59:59Z"
|
||||
"syncedAt": "2026-10-29T16:00:00Z"
|
||||
}
|
||||
@@ -43,9 +43,9 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# IMAGE_BACKEND=openai
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# OPENAI_MODEL=gpt-image-2
|
||||
# OPENAI_MODEL=gpt-image-2.5-sunburst
|
||||
# Legacy ChatGPT image API model / 旧版 ChatGPT 生图 API 模型
|
||||
# OPENAI_MODEL=chatgpt-image-latest # Deprecated by OpenAI / OpenAI 已弃用
|
||||
# OPENAI_MODEL=chatgpt-image-latest # Removed by OpenAI on 2026-12-01 / OpenAI 将于 2026-12-01 下线
|
||||
# OPENAI_BASE_URL=http://127.0.0.1:3000/v1
|
||||
# OpenAI-compatible providers may need these compatibility knobs.
|
||||
# OpenAI-compatible 第三方平台可按需开启以下兼容参数。
|
||||
@@ -59,8 +59,8 @@
|
||||
# OPENAI_OUTPUT_FORMAT=png
|
||||
# jpeg/webp only, 0-100
|
||||
# OPENAI_OUTPUT_COMPRESSION=80
|
||||
# GPT Image: auto / opaque / transparent (png/webp only; gpt-image-2 support is in preview)
|
||||
# GPT Image:auto / opaque / transparent(透明背景仅限 png/webp;gpt-image-2 为预览支持)
|
||||
# GPT Image: auto / opaque / transparent (png/webp only; gpt-image-2 is preview-only and may return 400)
|
||||
# GPT Image:auto / opaque / transparent(透明背景仅限 png/webp;gpt-image-2 仅预览支持,可能返回 400)
|
||||
# OPENAI_BACKGROUND=auto
|
||||
# auto / low
|
||||
# OPENAI_MODERATION=auto
|
||||
|
||||
@@ -8,7 +8,7 @@ Want to support the project or discuss a partnership? See the [sponsorship guide
|
||||
|
||||
## Current Model Recommendation
|
||||
|
||||
For the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the workflow, paired with **`gpt-image-2`** (OpenAI) or **`gemini-nano-banana-2.1`** (Google) for AI image generation. If model capability is limiting output quality, upgrade the driving model before weakening the workflow or its quality requirements.
|
||||
For the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the workflow, paired with **`gpt-image-2.5-sunburst`** (OpenAI) or **`gemini-nano-banana-2.1`** (Google) for AI image generation. If model capability is limiting output quality, upgrade the driving model before weakening the workflow or its quality requirements.
|
||||
|
||||
## Kimi
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ PPT Master 始终免费开源。以下赞助方共同支持项目的持续维护
|
||||
|
||||
## 当前模型推荐
|
||||
|
||||
追求最佳效果,语言模型选 **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)**(或 Claude)驱动流程,搭配 AI 生图模型 **`gpt-image-2`**(OpenAI)或 **`gemini-nano-banana-2.1`**(Google)。如果模型能力正在拖累产出质量,应先升级驱动模型,而不是削弱工作流或质量要求。
|
||||
追求最佳效果,语言模型选 **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)**(或 Claude)驱动流程,搭配 AI 生图模型 **`gpt-image-2.5-sunburst`**(OpenAI)或 **`gemini-nano-banana-2.1`**(Google)。如果模型能力正在拖累产出质量,应先升级驱动模型,而不是削弱工作流或质量要求。
|
||||
|
||||
## Kimi
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ Write `project/images/image_prompts.json`:
|
||||
| `items[].type` | no | One of the 11 internal-composition types for a local structural infographic when a template genuinely fits; omitted for §4.1 E prose, `hero_page`, sheets, and single-subject/portrait |
|
||||
| `items[].page_role` | yes | `local` (default) or `hero_page` |
|
||||
| `items[].text_policy` | yes | `none` or `embedded`, judged per image (§5.3) |
|
||||
| `items[].aspect_ratio` | yes | Passed to `image_gen.py --aspect_ratio`; every backend accepts a subset of the CLI union (e.g. gemini has no `3:1`), and `--manifest` fails the item before any request when the resolved backend rejects its ratio |
|
||||
| `items[].aspect_ratio` | yes | The target shape as any positive `W:H` (e.g. `16:9`, `4:1`, `1.85:1`), derived from the placement, never narrowed to a backend. The backend outputs the nearest size its model allows and the manifest keeps this value; Executor fits the actual image through the crop policy, and a sheet is sliced from its actual size |
|
||||
| `items[].prompt` | yes | The assembled paragraph |
|
||||
| `items[].image_size` | no | `512px` / `1K` / `2K` / `4K` |
|
||||
| `items[].model` | no | Per-item backend model override |
|
||||
|
||||
@@ -49,7 +49,7 @@ python3 scripts/image_gen.py --list-backends
|
||||
|
||||
Backends are grouped into Core / Extended / Experimental tiers. Run `python3 scripts/image_gen.py --list-backends` for the current list.
|
||||
|
||||
Each backend accepts its own subset of `--aspect_ratio` values; the authority is the `VALID_ASPECT_RATIOS` constant in its `scripts/image_backends/backend_<name>.py`, and `--manifest` rejects an unsupported ratio before any request. The default Gemini model, `gemini-nano-banana-2.1`, takes `1:1 1:4 1:8 2:3 3:2 3:4 4:1 4:3 4:5 5:4 8:1 9:16 16:9 21:9` (including extreme ratios, no `3:1`) at `1K/2K/4K`, but not `512px`. `gemini-3.1-flash-image` retains the same ratios and also supports `512px`; `gemini-2.5-flash-image` takes only `1:1 2:3 3:2 3:4 4:3 4:5 5:4 9:16 16:9 21:9` at `1K`.
|
||||
`--aspect_ratio` and manifest `aspect_ratio` accept any positive `W:H`, decimals included (`1.85:1`); only a malformed value fails before any request. A ratio never fails a request: each backend outputs the nearest size its model allows and prints `Ratio: requested … -> output …` when the output differs by more than 1%; the manifest keeps the requested ratio and downstream placement crops to it. Backends whose models take free width and height (OpenAI GPT Image 2/2.5, MiniMax, Qwen, Volcengine, Zhipu, Tencent) keep their listed size for a listed ratio and fit any other ratio or tier to the model's official limits (`fit_size` in `backend_common.py`), at the tier's 1:1 pixel budget, clamping a ratio beyond the model's maximum. Backends with a fixed ratio set (Gemini, OpenRouter, fal, BFL, Ideogram, Replicate, Stability, ModelScope, SiliconFlow) send their nearest listed ratio. The default Gemini model, `gemini-nano-banana-2.1`, takes `1:1 1:4 1:8 2:3 3:2 3:4 4:1 4:3 4:5 5:4 8:1 9:16 16:9 21:9` (including extreme ratios, no `3:1`) at `1K/2K/4K`, but not `512px`. `gemini-3.1-flash-image` retains the same ratios and also supports `512px`; `gemini-2.5-flash-image` takes only `1:1 2:3 3:2 3:4 4:3 4:5 5:4 9:16 16:9 21:9` at `1K`.
|
||||
|
||||
Backend selection:
|
||||
|
||||
@@ -78,7 +78,7 @@ Example `.env`:
|
||||
```env
|
||||
IMAGE_BACKEND=openai
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
OPENAI_MODEL=gpt-image-2
|
||||
OPENAI_MODEL=gpt-image-2.5-sunburst
|
||||
# Optional proxy
|
||||
# OPENAI_BASE_URL=http://127.0.0.1:3000/v1
|
||||
# OpenAI-compatible provider knobs:
|
||||
@@ -90,7 +90,7 @@ OPENAI_MODEL=gpt-image-2
|
||||
# OPENAI_OUTPUT_FORMAT=png
|
||||
# jpeg/webp only, 0-100
|
||||
# OPENAI_OUTPUT_COMPRESSION=80
|
||||
# GPT Image: auto / opaque / transparent (png/webp only; gpt-image-2 support is in preview)
|
||||
# GPT Image: auto / opaque / transparent (png/webp only; gpt-image-2 is preview-only and may return 400)
|
||||
# OPENAI_BACKGROUND=auto
|
||||
# auto / low
|
||||
# OPENAI_MODERATION=auto
|
||||
@@ -101,21 +101,21 @@ Example process environment:
|
||||
```bash
|
||||
export IMAGE_BACKEND=openai
|
||||
export OPENAI_API_KEY=sk-xxx
|
||||
export OPENAI_MODEL=gpt-image-2
|
||||
export OPENAI_MODEL=gpt-image-2.5-sunburst
|
||||
export OPENAI_OUTPUT_FORMAT=png
|
||||
```
|
||||
|
||||
Current process environment wins over `.env`.
|
||||
|
||||
OpenAI backend notes:
|
||||
- `gpt-image-2` is the default OpenAI model.
|
||||
- `gpt-image-2.5-sunburst` is the default OpenAI model. `gpt-image-2.5-flare` shares its token rates; OpenAI positions Sunburst for editing precision and Flare for fast everyday generation ([models](https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst)).
|
||||
- Requests are sent with plain `requests.post()` to improve compatibility with
|
||||
OpenAI-compatible proxies that block the OpenAI SDK's `httpx` transport.
|
||||
- For `gpt-image-2`, `image_size=512px` means a low-quality draft preset, not a literal 512px edge. The model requires both edges to be multiples of 16px, a long:short ratio no greater than 3:1, and total pixels between 655,360 and 8,294,400.
|
||||
- `OPENAI_BACKGROUND=transparent` is supported by `gpt-image-2` (in preview), `gpt-image-2.5-sunburst`, and `gpt-image-2.5-flare`, including their published snapshots; use PNG (the default) or WebP, not JPEG.
|
||||
- For `gpt-image-2`, `image_size=512px` means a low-quality draft preset, not a literal 512px edge. The model requires both edges to be multiples of 16px, a long:short ratio no greater than 3:1, and total pixels between 655,360 and 8,294,400. The 2.5 models share these rules and sizes; unlisted ratios are fitted to them, so `4:1` at 1K becomes `1776x592` (3:1).
|
||||
- `OPENAI_BACKGROUND=transparent` is supported by `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their published snapshots. For `gpt-image-2` it is in preview: a key without preview access gets HTTP 400 `Transparent background is not supported for this model.`, which fails the item without retries. Use PNG (the default) or WebP, not JPEG.
|
||||
- `OPENAI_QUALITY=xhigh` and `max` are accepted only for `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their published snapshots; older GPT Image models retain `auto/omit/low/medium/high`. See the [Images API parameters](https://developers.openai.com/api/reference/resources/images/methods/generate).
|
||||
- For reference edits, `OPENAI_INPUT_FIDELITY` accepts `high/low` on `gpt-image-1` and `gpt-image-1.5`, but only `low` on `gpt-image-1-mini`. Leave it unset for `gpt-image-2` and the two 2.5 models (including snapshots); this backend keeps the existing omission requirement for 2.5 because its support is not explicitly documented in the [edit API parameters](https://developers.openai.com/api/reference/resources/images/methods/edit).
|
||||
- OpenAI schedules `gpt-image-1` shutdown for 2026-10-23 and `gpt-image-1-mini` / `gpt-image-1.5` for 2026-12-01; see [deprecations](https://developers.openai.com/api/docs/deprecations).
|
||||
- OpenAI shut down `gpt-image-1` on 2026-10-23 and DALL·E 2/3 on 2026-05-12, and removes `gpt-image-1-mini`, `gpt-image-1.5` (including `gpt-image-1.5-2025-12-16`) and `chatgpt-image-latest` on 2026-12-01, naming the two 2.5 models as replacements. The backend keeps their size and option handling for OpenAI-compatible providers that still serve them; see [deprecations](https://developers.openai.com/api/docs/deprecations).
|
||||
- If `OPENAI_OUTPUT_FORMAT=jpeg` or `webp`, generated files use `.jpg` or `.webp` extensions instead of `.png`.
|
||||
- OpenAI-compatible providers that reject OpenAI-specific fields can use `OPENAI_RESPONSE_FORMAT=omit`, `OPENAI_QUALITY=omit`, and `OPENAI_SIZE_PRESET=<preset>`. Valid response formats are `auto`, `b64_json`, `url`, and `omit`; valid size presets are `auto`, `legacy`, `gpt-image`, `gpt-image-2`, and `dall-e-2`.
|
||||
|
||||
|
||||
+7
-2
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
poll_json,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
@@ -180,6 +182,9 @@ def generate(prompt: str,
|
||||
output_dir: str = None, filename: str = None,
|
||||
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||
"""Generate an image with retries using the BFL backend."""
|
||||
requested_ratio = aspect_ratio
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
resolved_model = model or os.environ.get("BFL_MODEL") or DEFAULT_MODEL
|
||||
_resolve_request_options(aspect_ratio, image_size, resolved_model)
|
||||
api_key = require_api_key(
|
||||
@@ -208,9 +213,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+199
-5
@@ -21,9 +21,14 @@ if __name__ == "__main__":
|
||||
|
||||
import base64
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from email.utils import parsedate_to_datetime
|
||||
from fractions import Fraction
|
||||
|
||||
import requests
|
||||
|
||||
@@ -35,6 +40,9 @@ except ImportError:
|
||||
|
||||
|
||||
MAX_RETRIES = 3
|
||||
# Longest provider-requested wait honored in-process; a longer Retry-After ends
|
||||
# the backend's retries and the manifest run instead of blocking for hours.
|
||||
MAX_RETRY_WAIT = 300
|
||||
RETRY_BASE_DELAY = 10
|
||||
RETRY_BACKOFF = 2
|
||||
|
||||
@@ -56,6 +64,10 @@ _GLOBAL_PERMANENT_ERROR_MARKERS = (
|
||||
"insufficient credits",
|
||||
"insufficient balance",
|
||||
"insufficient_quota",
|
||||
"credit_balance_exhausted",
|
||||
"organization_spend_limit_exceeded",
|
||||
"project_spend_limit_exceeded",
|
||||
"organization_usage_limit_exceeded",
|
||||
"exceeded your current quota",
|
||||
"payment required",
|
||||
"billing is not enabled",
|
||||
@@ -109,7 +121,9 @@ def resolve_output_path(prompt: str, output_dir: str = None,
|
||||
filename: str = None, ext: str = ".png") -> str:
|
||||
"""Compute the final output file path based on parameters."""
|
||||
if filename:
|
||||
file_name = os.path.splitext(filename)[0]
|
||||
# Drop only a real image extension; dots inside a stem ("v2.5") stay.
|
||||
stem, suffix = os.path.splitext(filename)
|
||||
file_name = stem if suffix.lower() in EXT_TO_PIL_FORMAT else filename
|
||||
else:
|
||||
safe = "".join(c for c in prompt if c.isalnum() or c in (" ", "_")).rstrip()
|
||||
safe = safe.replace(" ", "_").lower()[:30]
|
||||
@@ -331,6 +345,136 @@ def report_resolution(path: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
TIER_PIXELS = {
|
||||
"512px": 512 * 512,
|
||||
"1K": 1024 * 1024,
|
||||
"2K": 2048 * 2048,
|
||||
"4K": 4096 * 4096,
|
||||
}
|
||||
|
||||
|
||||
def parse_aspect_ratio(value) -> Fraction:
|
||||
"""Parse any positive `W:H` (integers or decimals, e.g. `7:2`, `1.85:1`) exactly."""
|
||||
text = str(value).strip()
|
||||
parts = text.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid aspect ratio '{value}'. Expected W:H, e.g. 16:9 or 1.85:1.")
|
||||
try:
|
||||
width, height = (Fraction(Decimal(part.strip())) for part in parts)
|
||||
except (InvalidOperation, ValueError, OverflowError) as exc:
|
||||
raise ValueError(
|
||||
f"Invalid aspect ratio '{value}'. Expected W:H with positive numbers."
|
||||
) from exc
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(f"Invalid aspect ratio '{value}'. Both sides must be positive.")
|
||||
return width / height
|
||||
|
||||
|
||||
def _log_ratio(ratio: Fraction) -> float:
|
||||
"""Natural log of a ratio that stays finite for any exact W:H, however extreme."""
|
||||
return math.log(ratio.numerator) - math.log(ratio.denominator)
|
||||
|
||||
|
||||
def bounded_ratio(value) -> float:
|
||||
"""Return a W:H (string or Fraction) as a float clamped to 1e-6..1e6 for size math."""
|
||||
ratio = parse_aspect_ratio(value) if isinstance(value, str) else Fraction(value)
|
||||
return float(min(max(ratio, Fraction(1, 10**6)), Fraction(10**6)))
|
||||
|
||||
|
||||
def match_aspect_ratio(value, choices) -> str | None:
|
||||
"""Return the choice numerically equal to `value` (`32:18` matches `16:9`), else None."""
|
||||
target = parse_aspect_ratio(value)
|
||||
for choice in choices:
|
||||
if parse_aspect_ratio(choice) == target:
|
||||
return choice
|
||||
return None
|
||||
|
||||
|
||||
def nearest_aspect_ratio(value, choices) -> str:
|
||||
"""Return the choice whose ratio is closest to `value` on a log scale."""
|
||||
exact = match_aspect_ratio(value, choices)
|
||||
if exact is not None:
|
||||
return exact
|
||||
target = _log_ratio(parse_aspect_ratio(value))
|
||||
return min(choices, key=lambda choice: abs(_log_ratio(parse_aspect_ratio(choice)) - target))
|
||||
|
||||
|
||||
def fit_size(
|
||||
ratio,
|
||||
*,
|
||||
pixels: int,
|
||||
step: int = 1,
|
||||
min_edge: int = 1,
|
||||
max_edge: int | None = None,
|
||||
min_pixels: int | None = None,
|
||||
max_pixels: int | None = None,
|
||||
max_ratio: float | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Compute a legal `(width, height)` for any ratio under a model's size rules.
|
||||
|
||||
The ratio is clamped to what `max_ratio` and the edge limits allow (orientation
|
||||
kept), the pixel budget is clamped into `[min_pixels, max_pixels]`, and both
|
||||
edges are rounded to `step`. Raises ValueError only when the rules themselves
|
||||
admit no size.
|
||||
"""
|
||||
r = bounded_ratio(ratio)
|
||||
limit = max_ratio or math.inf
|
||||
lo_edge = math.ceil(min_edge / step) * step
|
||||
hi_edge = math.floor(max_edge / step) * step if max_edge else None
|
||||
if hi_edge:
|
||||
limit = min(limit, hi_edge / lo_edge)
|
||||
r = min(max(r, 1 / limit), limit)
|
||||
|
||||
budget = pixels
|
||||
if min_pixels:
|
||||
budget = max(budget, min_pixels)
|
||||
if max_pixels:
|
||||
budget = min(budget, max_pixels)
|
||||
width, height = math.sqrt(budget * r), math.sqrt(budget / r)
|
||||
short, long_ = min(width, height), max(width, height)
|
||||
if short < lo_edge:
|
||||
long_, short = long_ * lo_edge / short, lo_edge
|
||||
if hi_edge and long_ > hi_edge:
|
||||
short, long_ = short * hi_edge / long_, hi_edge
|
||||
|
||||
def snap(edge: float) -> int:
|
||||
snapped = max(lo_edge, round(edge / step) * step)
|
||||
return min(snapped, hi_edge) if hi_edge else snapped
|
||||
|
||||
short, long_ = snap(short), snap(long_)
|
||||
while max_pixels and short * long_ > max_pixels and short > lo_edge:
|
||||
long_ -= step
|
||||
short = snap(long_ / r if r >= 1 else long_ * r)
|
||||
while min_pixels and short * long_ < min_pixels and (not hi_edge or long_ < hi_edge):
|
||||
long_ += step
|
||||
short = snap(long_ / r if r >= 1 else long_ * r)
|
||||
# Rounding can push a clamped ratio just past the limit; pull it back inside.
|
||||
while long_ / short > limit:
|
||||
widened = short + step
|
||||
if (not max_pixels or widened * long_ <= max_pixels) and (not hi_edge or widened <= hi_edge):
|
||||
short = widened
|
||||
else:
|
||||
long_ -= step
|
||||
|
||||
width, height = (long_, short) if r >= 1 else (short, long_)
|
||||
area = width * height
|
||||
if (max_pixels and area > max_pixels) or (min_pixels and area < min_pixels) or long_ / short > limit:
|
||||
raise ValueError(
|
||||
f"No legal size for ratio {ratio} under the model's limits "
|
||||
f"(got {width}x{height}, pixels {min_pixels}-{max_pixels})."
|
||||
)
|
||||
return width, height
|
||||
|
||||
|
||||
def note_aspect_ratio(requested, actual) -> None:
|
||||
"""Print the adaptation when the output ratio differs from the requested one by >1%."""
|
||||
drift = _log_ratio(parse_aspect_ratio(actual)) - _log_ratio(parse_aspect_ratio(requested))
|
||||
if abs(drift) > math.log(1.01):
|
||||
print(f" Ratio: requested {requested} -> output {actual} "
|
||||
"(nearest the model allows; crop downstream)")
|
||||
|
||||
|
||||
def normalize_image_size(image_size: str) -> str:
|
||||
"""Normalize image size input to standard format."""
|
||||
s = image_size.strip()
|
||||
@@ -431,10 +575,34 @@ def is_rate_limit_error(exc: Exception) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def retry_delay(attempt: int, rate_limited: bool) -> int:
|
||||
"""Return the retry delay for a given attempt."""
|
||||
def retry_after_seconds(exc: Exception | None) -> int | None:
|
||||
"""Return the server-requested wait on `exc` or any error it was raised from."""
|
||||
seen = set()
|
||||
while exc is not None and id(exc) not in seen:
|
||||
seen.add(id(exc))
|
||||
retry_after = getattr(exc, "retry_after", None)
|
||||
if retry_after is not None:
|
||||
return retry_after
|
||||
exc = exc.__cause__ or exc.__context__
|
||||
return None
|
||||
|
||||
|
||||
def retry_delay(attempt: int, rate_limited: bool, exc: Exception = None) -> int:
|
||||
"""Return the retry delay for a given attempt.
|
||||
|
||||
A server-provided Retry-After (kept on `exc` by `http_error`) wins; otherwise
|
||||
rate limits back off exponentially with jitter. A Retry-After beyond
|
||||
MAX_RETRY_WAIT re-raises `exc`: retrying early only burns requests, so the
|
||||
caller (the manifest runner) decides to stop instead.
|
||||
"""
|
||||
retry_after = retry_after_seconds(exc)
|
||||
if retry_after is not None:
|
||||
if retry_after > MAX_RETRY_WAIT:
|
||||
raise exc
|
||||
return retry_after
|
||||
if rate_limited:
|
||||
return RETRY_BASE_DELAY * (RETRY_BACKOFF ** attempt)
|
||||
delay = RETRY_BASE_DELAY * (RETRY_BACKOFF ** attempt)
|
||||
return delay + random.randint(0, delay // 4)
|
||||
return 5
|
||||
|
||||
|
||||
@@ -461,12 +629,38 @@ def require_api_key(*candidates: str, message: str) -> str:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def _parse_retry_after(headers) -> int | None:
|
||||
"""Read Retry-After (seconds or HTTP date) or retry-after-ms as whole seconds."""
|
||||
if not headers:
|
||||
return None
|
||||
millis = headers.get("retry-after-ms")
|
||||
if isinstance(millis, str) and millis:
|
||||
try:
|
||||
return max(0, math.ceil(float(millis) / 1000))
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
value = headers.get("retry-after")
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
return max(0, math.ceil(float(value)))
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
try:
|
||||
retry_at = parsedate_to_datetime(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return max(0, math.ceil(retry_at.timestamp() - time.time()))
|
||||
|
||||
|
||||
def http_error(response: requests.Response, label: str) -> RuntimeError:
|
||||
"""Convert an HTTP response into a readable RuntimeError."""
|
||||
body = response.text.strip()
|
||||
if len(body) > 500:
|
||||
body = body[:500] + "..."
|
||||
return RuntimeError(f"{label} failed ({response.status_code}): {body}")
|
||||
error = RuntimeError(f"{label} failed ({response.status_code}): {body}")
|
||||
error.retry_after = _parse_retry_after(getattr(response, "headers", None))
|
||||
return error
|
||||
|
||||
|
||||
def poll_json(
|
||||
|
||||
+7
-2
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -145,6 +147,9 @@ def generate(prompt: str,
|
||||
output_dir: str = None, filename: str = None,
|
||||
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||
"""Generate an image with retries using the fal.ai backend."""
|
||||
requested_ratio = aspect_ratio
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
resolved_model = model or os.environ.get("FAL_MODEL") or DEFAULT_MODEL
|
||||
_resolve_request_options(aspect_ratio, image_size, resolved_model)
|
||||
api_key = require_api_key(
|
||||
@@ -174,9 +179,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+13
-5
@@ -39,7 +39,9 @@ from image_backends.backend_common import (
|
||||
MAX_RETRIES,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
save_image_bytes,
|
||||
@@ -301,8 +303,14 @@ def generate(prompt: str,
|
||||
|
||||
image_size = normalize_image_size(image_size)
|
||||
|
||||
if aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
|
||||
requested_ratio = aspect_ratio
|
||||
ratios = (
|
||||
GEMINI_2_5_VALID_ASPECT_RATIOS
|
||||
if _model_id(model) in GEMINI_2_5_FLASH_IMAGE_MODELS
|
||||
else VALID_ASPECT_RATIOS
|
||||
)
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, ratios)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
|
||||
if image_size not in VALID_IMAGE_SIZES:
|
||||
raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
|
||||
@@ -326,16 +334,16 @@ def generate(prompt: str,
|
||||
if is_permanent_error(e):
|
||||
raise
|
||||
if attempt < max_retries and is_rate_limit_error(e):
|
||||
delay = retry_delay(attempt, rate_limited=True)
|
||||
delay = retry_delay(attempt, rate_limited=True, exc=last_error)
|
||||
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
|
||||
f"Waiting {delay}s before retry...")
|
||||
time.sleep(delay)
|
||||
elif attempt < max_retries:
|
||||
delay = retry_delay(attempt, rate_limited=False)
|
||||
delay = retry_delay(attempt, rate_limited=False, exc=last_error)
|
||||
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
||||
f"Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
break
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+7
-2
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -150,6 +152,9 @@ def generate(prompt: str,
|
||||
output_dir: str = None, filename: str = None,
|
||||
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||
"""Generate an image with retries using the Ideogram backend."""
|
||||
requested_ratio = aspect_ratio
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, ASPECT_RATIO_MAP)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
resolved_model = model or os.environ.get("IDEOGRAM_MODEL") or DEFAULT_MODEL
|
||||
_resolve_request_options(aspect_ratio, image_size, resolved_model)
|
||||
api_key = require_api_key(
|
||||
@@ -178,9 +183,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+21
-11
@@ -37,7 +37,11 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
TIER_PIXELS,
|
||||
fit_size,
|
||||
match_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -120,20 +124,25 @@ def _resolve_dimensions(aspect_ratio: str, image_size: str) -> tuple[int, int]:
|
||||
"""Resolve width and height from the unified aspect_ratio/image_size pair."""
|
||||
normalized = normalize_image_size(image_size)
|
||||
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized)
|
||||
if sizes is None:
|
||||
if sizes is None and normalized not in TIER_PIXELS:
|
||||
supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
|
||||
raise ValueError(
|
||||
f"Unsupported image size '{image_size}' for MiniMax backend. "
|
||||
f"image-01 supports these logical sizes: {supported_sizes}."
|
||||
)
|
||||
dimensions = sizes.get(aspect_ratio)
|
||||
if not dimensions:
|
||||
supported = sorted(sizes)
|
||||
raise ValueError(
|
||||
f"Unsupported aspect ratio '{aspect_ratio}' for MiniMax backend. "
|
||||
f"Supported: {supported}"
|
||||
)
|
||||
return dimensions
|
||||
listed = match_aspect_ratio(aspect_ratio, sizes or {})
|
||||
if listed:
|
||||
return sizes[listed]
|
||||
# Any other ratio or tier is fitted to the model's size rules.
|
||||
budget = sizes["1:1"][0] * sizes["1:1"][1] if sizes else TIER_PIXELS[normalized]
|
||||
width, height = fit_size(
|
||||
aspect_ratio,
|
||||
pixels=budget,
|
||||
step=8,
|
||||
min_edge=512,
|
||||
max_edge=2048,
|
||||
)
|
||||
return width, height
|
||||
|
||||
|
||||
def _extract_image_bytes(payload: dict) -> bytes | None:
|
||||
@@ -161,6 +170,7 @@ def _generate_image(api_key: str, prompt: str,
|
||||
"""Generate one image with the MiniMax backend."""
|
||||
model = _validate_model(model)
|
||||
width, height = _resolve_dimensions(aspect_ratio, image_size)
|
||||
note_aspect_ratio(aspect_ratio, f"{width}:{height}")
|
||||
url = _resolve_url(base_url)
|
||||
|
||||
headers = {
|
||||
@@ -247,9 +257,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+8
-2
@@ -18,7 +18,9 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -154,6 +156,10 @@ def generate(prompt: str,
|
||||
"""Generate an image with retries using the ModelScope backend."""
|
||||
resolved_model = _resolve_model(model)
|
||||
normalized_size = normalize_image_size(image_size)
|
||||
requested_ratio = aspect_ratio
|
||||
ratios = ASPECT_RATIO_SIZE_MAP.get(normalized_size) or ASPECT_RATIO_SIZE_MAP["1K"]
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, ratios)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
_resolve_size(aspect_ratio, normalized_size)
|
||||
api_key = require_api_key(
|
||||
"MODELSCOPE_API_KEY",
|
||||
@@ -181,9 +187,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+49
-18
@@ -8,7 +8,7 @@ Used by image_gen.py as a backend module.
|
||||
Configuration keys:
|
||||
OPENAI_API_KEY (required) API key
|
||||
OPENAI_BASE_URL (optional) Custom API endpoint (e.g. http://127.0.0.1:3000/v1)
|
||||
OPENAI_MODEL (optional) Model name (default: gpt-image-2)
|
||||
OPENAI_MODEL (optional) Model name (default: gpt-image-2.5-sunburst)
|
||||
OPENAI_SIZE_PRESET (optional) auto, legacy, gpt-image, gpt-image-2, or dall-e-2
|
||||
OPENAI_RESPONSE_FORMAT (optional) auto, b64_json, url, or omit
|
||||
OPENAI_QUALITY (optional) auto, omit, low, medium, high, xhigh, max, standard, or hd
|
||||
@@ -54,10 +54,15 @@ import requests
|
||||
from image_backends.backend_common import (
|
||||
MAX_RETRIES,
|
||||
download_image,
|
||||
fit_size,
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
match_aspect_ratio,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
parse_aspect_ratio,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
save_image_bytes,
|
||||
@@ -69,7 +74,8 @@ from image_backends.backend_common import (
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
# Aspect ratio -> DALL-E 3 / legacy compatible size mapping.
|
||||
# Unknown OpenAI-compatible models use this table to preserve old behavior.
|
||||
# Unknown OpenAI-compatible models use this table to preserve old behavior;
|
||||
# other ratios take the nearest entry.
|
||||
LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE = {
|
||||
"1:1": "1024x1024",
|
||||
"16:9": "1792x1024",
|
||||
@@ -100,6 +106,8 @@ GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE = {
|
||||
|
||||
# GPT Image 2 supports flexible sizes when both edges are multiples of 16,
|
||||
# the edge ratio is <= 3:1, and the total pixels are within model limits.
|
||||
# Listed ratios use these sizes; any other ratio is fitted to the rules at
|
||||
# the tier's 1:1 pixel budget (beyond 3:1 it is clamped to 3:1).
|
||||
GPT_IMAGE_2_SIZES = {
|
||||
"512px": {
|
||||
"1:1": "1024x1024", "16:9": "1280x720", "9:16": "720x1280",
|
||||
@@ -134,8 +142,6 @@ DALL_E_2_SIZE_BY_IMAGE_SIZE = {
|
||||
"4K": "1024x1024",
|
||||
}
|
||||
|
||||
VALID_ASPECT_RATIOS = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys())
|
||||
|
||||
# image_size -> quality mapping
|
||||
IMAGE_SIZE_TO_QUALITY = {
|
||||
"512px": "low",
|
||||
@@ -144,7 +150,7 @@ IMAGE_SIZE_TO_QUALITY = {
|
||||
"4K": "high",
|
||||
}
|
||||
|
||||
DEFAULT_MODEL = "gpt-image-2"
|
||||
DEFAULT_MODEL = "gpt-image-2.5-sunburst"
|
||||
|
||||
GPT_IMAGE_2_MIN_PIXELS = 655_360
|
||||
GPT_IMAGE_2_MAX_PIXELS = 8_294_400
|
||||
@@ -250,6 +256,19 @@ def _validate_gpt_image_2_size(size: str) -> None:
|
||||
raise ValueError(f"Invalid gpt-image-2 size '{size}': {', '.join(errors)}")
|
||||
|
||||
|
||||
def _nearest_listed_size(aspect_ratio: str, table: dict[str, str]) -> str:
|
||||
"""Keep a listed ratio's size; otherwise pick the size whose real W:H is nearest.
|
||||
|
||||
Legacy keys are nominal (5:4 maps to 1536x1024), so unlisted ratios compare
|
||||
against the sizes themselves, not the keys.
|
||||
"""
|
||||
listed = match_aspect_ratio(aspect_ratio, table)
|
||||
if listed:
|
||||
return table[listed]
|
||||
sizes = {size.replace("x", ":"): size for size in table.values()}
|
||||
return sizes[nearest_aspect_ratio(aspect_ratio, sizes)]
|
||||
|
||||
|
||||
def _select_size(
|
||||
model: str,
|
||||
aspect_ratio: str,
|
||||
@@ -261,16 +280,31 @@ def _select_size(
|
||||
if preset in {"gpt-image-2"} or (
|
||||
preset == "auto" and (_is_gpt_image_2(model) or _is_gpt_image_2_5(model))
|
||||
):
|
||||
size = GPT_IMAGE_2_SIZES[image_size][aspect_ratio]
|
||||
sizes = GPT_IMAGE_2_SIZES[image_size]
|
||||
listed = match_aspect_ratio(aspect_ratio, sizes)
|
||||
if listed:
|
||||
size = sizes[listed]
|
||||
else:
|
||||
square_w, square_h = _parse_size(sizes["1:1"])
|
||||
width, height = fit_size(
|
||||
aspect_ratio,
|
||||
pixels=square_w * square_h,
|
||||
step=16,
|
||||
max_edge=GPT_IMAGE_2_MAX_EDGE,
|
||||
min_pixels=GPT_IMAGE_2_MIN_PIXELS,
|
||||
max_pixels=GPT_IMAGE_2_MAX_PIXELS,
|
||||
max_ratio=GPT_IMAGE_2_MAX_RATIO,
|
||||
)
|
||||
size = f"{width}x{height}"
|
||||
_validate_gpt_image_2_size(size)
|
||||
return size
|
||||
if preset in {"gpt-image", "gpt-image-legacy"} or (
|
||||
preset == "auto" and _is_gpt_image_model(model)
|
||||
):
|
||||
return GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE[aspect_ratio]
|
||||
return _nearest_listed_size(aspect_ratio, GPT_IMAGE_LEGACY_ASPECT_RATIO_TO_SIZE)
|
||||
if preset in {"dall-e-2", "dalle-2"} or (preset == "auto" and _is_dall_e_2(model)):
|
||||
return DALL_E_2_SIZE_BY_IMAGE_SIZE[image_size]
|
||||
return LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE[aspect_ratio]
|
||||
return _nearest_listed_size(aspect_ratio, LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE)
|
||||
|
||||
|
||||
def _supports_response_format(model: str) -> bool:
|
||||
@@ -527,6 +561,7 @@ def _generate_image(api_key: str, prompt: str,
|
||||
print(f" Model: {model}")
|
||||
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||
print(f" Size: {size} (from aspect_ratio={aspect_ratio})")
|
||||
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
|
||||
if size_preset and size_preset != "auto":
|
||||
print(f" Size Preset: {size_preset}")
|
||||
if quality is not None:
|
||||
@@ -628,6 +663,7 @@ def _edit_image(api_key: str, prompt: str, reference_image: str,
|
||||
print(f" Reference: {reference_image}")
|
||||
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
|
||||
print(f" Size: {size} (from aspect_ratio={aspect_ratio})")
|
||||
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
|
||||
if size_preset and size_preset != "auto":
|
||||
print(f" Size Preset: {size_preset}")
|
||||
if quality is not None:
|
||||
@@ -712,7 +748,7 @@ def generate(prompt: str,
|
||||
image_size: Image size, mapped to OpenAI quality
|
||||
output_dir: Output directory
|
||||
filename: Output filename (without extension)
|
||||
model: Model name (default: gpt-image-2)
|
||||
model: Model name (default: gpt-image-2.5-sunburst)
|
||||
max_retries: Maximum number of retries
|
||||
reference_image: Optional source image path. When set, the request
|
||||
goes to /v1/images/edits (image-to-image) instead of
|
||||
@@ -726,12 +762,7 @@ def generate(prompt: str,
|
||||
|
||||
image_size = normalize_image_size(image_size)
|
||||
|
||||
if aspect_ratio not in LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE:
|
||||
supported = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys())
|
||||
raise ValueError(
|
||||
f"Unsupported aspect ratio '{aspect_ratio}' for OpenAI backend. "
|
||||
f"Supported: {supported}"
|
||||
)
|
||||
parse_aspect_ratio(aspect_ratio)
|
||||
|
||||
_validate_request_options(
|
||||
model,
|
||||
@@ -762,16 +793,16 @@ def generate(prompt: str,
|
||||
if is_permanent_error(e):
|
||||
raise
|
||||
if attempt < max_retries and is_rate_limit_error(e):
|
||||
delay = retry_delay(attempt, rate_limited=True)
|
||||
delay = retry_delay(attempt, rate_limited=True, exc=e)
|
||||
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
|
||||
f"Waiting {delay}s before retry...")
|
||||
time.sleep(delay)
|
||||
elif attempt < max_retries:
|
||||
delay = retry_delay(attempt, rate_limited=False)
|
||||
delay = retry_delay(attempt, rate_limited=False, exc=e)
|
||||
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
||||
f"Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
break
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+8
-5
@@ -36,7 +36,9 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
save_image_bytes,
|
||||
@@ -189,8 +191,9 @@ def generate(prompt: str,
|
||||
|
||||
image_size = normalize_image_size(image_size)
|
||||
|
||||
if aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
|
||||
requested_ratio = aspect_ratio
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
|
||||
if image_size not in VALID_IMAGE_SIZES:
|
||||
raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
|
||||
@@ -211,16 +214,16 @@ def generate(prompt: str,
|
||||
if is_permanent_error(e):
|
||||
raise
|
||||
if attempt < max_retries and is_rate_limit_error(e):
|
||||
delay = retry_delay(attempt, rate_limited=True)
|
||||
delay = retry_delay(attempt, rate_limited=True, exc=last_error)
|
||||
print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
|
||||
f"Waiting {delay}s before retry...")
|
||||
time.sleep(delay)
|
||||
elif attempt < max_retries:
|
||||
delay = retry_delay(attempt, rate_limited=False)
|
||||
delay = retry_delay(attempt, rate_limited=False, exc=last_error)
|
||||
print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
||||
f"Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
break
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+24
-11
@@ -24,6 +24,7 @@ if __name__ == "__main__":
|
||||
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend qwen")
|
||||
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
TIER_PIXELS,
|
||||
fit_size,
|
||||
match_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -110,20 +115,27 @@ def _resolve_size(aspect_ratio: str, image_size: str) -> str:
|
||||
"""Resolve the target resolution for a ratio and logical size preset."""
|
||||
normalized = normalize_image_size(image_size)
|
||||
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized)
|
||||
if sizes is None:
|
||||
if sizes is None and normalized not in TIER_PIXELS:
|
||||
supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
|
||||
raise ValueError(
|
||||
f"Unsupported image size '{image_size}' for Qwen backend. "
|
||||
f"qwen-image-2.0-pro supports these logical sizes: {supported_sizes}."
|
||||
)
|
||||
size = sizes.get(aspect_ratio)
|
||||
if not size:
|
||||
supported = sorted(sizes)
|
||||
raise ValueError(
|
||||
f"Unsupported aspect ratio '{aspect_ratio}' for Qwen backend. "
|
||||
f"Supported: {supported}"
|
||||
)
|
||||
return size
|
||||
listed = match_aspect_ratio(aspect_ratio, sizes or {})
|
||||
if listed:
|
||||
return sizes[listed]
|
||||
# Any other ratio or tier is fitted to the model's size rules.
|
||||
budget = math.prod(map(int, sizes["1:1"].split("*"))) if sizes else TIER_PIXELS[normalized]
|
||||
width, height = fit_size(
|
||||
aspect_ratio,
|
||||
pixels=budget,
|
||||
step=32,
|
||||
max_edge=2688,
|
||||
min_pixels=512 * 512,
|
||||
max_pixels=2048 * 2048,
|
||||
max_ratio=7 / 3,
|
||||
)
|
||||
return f"{width}*{height}"
|
||||
|
||||
|
||||
def _generate_image(api_key: str, prompt: str,
|
||||
@@ -133,6 +145,7 @@ def _generate_image(api_key: str, prompt: str,
|
||||
"""Generate one image with the Qwen backend."""
|
||||
model = _validate_model(model)
|
||||
size = _resolve_size(aspect_ratio, image_size)
|
||||
note_aspect_ratio(aspect_ratio, size.replace("*", ":"))
|
||||
url = _resolve_url(base_url)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
@@ -217,9 +230,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+7
-2
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
poll_json,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
@@ -168,6 +170,9 @@ def generate(prompt: str,
|
||||
output_dir: str = None, filename: str = None,
|
||||
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||
"""Generate an image with retries using the Replicate backend."""
|
||||
requested_ratio = aspect_ratio
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
resolved_model = model or os.environ.get("REPLICATE_MODEL") or DEFAULT_MODEL
|
||||
_resolve_request_options(aspect_ratio, image_size, resolved_model)
|
||||
api_key = require_api_key(
|
||||
@@ -197,9 +202,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+8
-2
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -148,6 +150,10 @@ def generate(prompt: str,
|
||||
resolved_model = model or os.environ.get("SILICONFLOW_MODEL") or DEFAULT_MODEL
|
||||
_validate_model(resolved_model)
|
||||
normalized_size = normalize_image_size(image_size)
|
||||
requested_ratio = aspect_ratio
|
||||
ratios = ASPECT_RATIO_SIZE_MAP.get(normalized_size) or ASPECT_RATIO_SIZE_MAP["1K"]
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, ratios)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
_resolve_size(aspect_ratio, normalized_size)
|
||||
api_key = require_api_key(
|
||||
"SILICONFLOW_API_KEY",
|
||||
@@ -175,9 +181,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+7
-2
@@ -34,7 +34,9 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
nearest_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
report_resolution,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
@@ -140,6 +142,9 @@ def generate(prompt: str,
|
||||
output_dir: str = None, filename: str = None,
|
||||
model: str = None, max_retries: int = MAX_RETRIES) -> str:
|
||||
"""Generate an image with retries using the Stability backend."""
|
||||
requested_ratio = aspect_ratio
|
||||
aspect_ratio = nearest_aspect_ratio(aspect_ratio, VALID_ASPECT_RATIOS)
|
||||
note_aspect_ratio(requested_ratio, aspect_ratio)
|
||||
base_url = os.environ.get("STABILITY_BASE_URL") or DEFAULT_BASE_URL
|
||||
resolved_model = model or os.environ.get("STABILITY_MODEL")
|
||||
_validate_request_options(aspect_ratio, image_size)
|
||||
@@ -169,9 +174,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+28
-28
@@ -40,7 +40,10 @@ from image_backends.backend_common import ( # noqa: E402
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
bounded_ratio,
|
||||
fit_size,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -57,6 +60,12 @@ MODEL_ENDPOINTS = {
|
||||
}
|
||||
SUPPORTED_MODELS = set(MODEL_ENDPOINTS)
|
||||
IMAGE_SIZE_PIXELS = {"512px": 512, "1K": 1024, "2K": 2048, "4K": 4096}
|
||||
# Official output limits; a size outside them is refitted, never rejected.
|
||||
MODEL_SIZE_LIMITS = {
|
||||
DEFAULT_MODEL: {"min_edge": 512, "max_edge": 2048, "max_pixels": 1024 * 1024},
|
||||
"seedream-image-v5.0-pro": {"min_pixels": 921_600, "max_pixels": 4_624_220, "max_ratio": 16},
|
||||
"seedream-image-v5.0-lite": {"min_pixels": 3_686_400, "max_pixels": 16_777_216, "max_ratio": 16},
|
||||
}
|
||||
|
||||
|
||||
def _validate_model(model: str) -> str:
|
||||
@@ -80,6 +89,17 @@ def _resolve_url(base_url: str, model: str = DEFAULT_MODEL) -> str:
|
||||
return base + MODEL_ENDPOINTS[model]
|
||||
|
||||
|
||||
def _within_limits(width: int, height: int, limits: dict) -> bool:
|
||||
"""Return whether a size satisfies one model's output limits."""
|
||||
long_edge, short_edge = max(width, height), min(width, height)
|
||||
return (
|
||||
short_edge >= limits.get("min_edge", 1)
|
||||
and long_edge <= limits.get("max_edge", long_edge)
|
||||
and limits.get("min_pixels", 0) <= width * height <= limits.get("max_pixels", width * height)
|
||||
and long_edge / short_edge <= limits.get("max_ratio", math.inf)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_size(aspect_ratio: str, image_size: str, model: str = DEFAULT_MODEL) -> str:
|
||||
"""Resolve a pixel size from the requested area and aspect ratio."""
|
||||
normalized = normalize_image_size(image_size)
|
||||
@@ -89,38 +109,17 @@ def _resolve_size(aspect_ratio: str, image_size: str, model: str = DEFAULT_MODEL
|
||||
f"Unsupported image size '{image_size}' for Tencent backend. "
|
||||
f"Supported logical sizes: {list(IMAGE_SIZE_PIXELS)}."
|
||||
)
|
||||
try:
|
||||
ratio_width, ratio_height = (int(value) for value in aspect_ratio.split(":"))
|
||||
if ratio_width <= 0 or ratio_height <= 0:
|
||||
raise ValueError
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Unsupported aspect ratio '{aspect_ratio}' for Tencent backend. "
|
||||
"Use positive width:height integers, such as 16:9."
|
||||
) from exc
|
||||
|
||||
ratio = bounded_ratio(aspect_ratio)
|
||||
if model == "seedream-image-v5.0-lite":
|
||||
pixels = max(pixels, 2048)
|
||||
elif model == "seedream-image-v5.0-pro" and normalized not in {"1K", "2K"}:
|
||||
raise ValueError(
|
||||
f"Unsupported image size '{image_size}' for Seedream pro. "
|
||||
"Use 1K or 2K; 4K requires seedream-image-v5.0-lite."
|
||||
)
|
||||
|
||||
ratio = ratio_width / ratio_height
|
||||
step = 16 if model == DEFAULT_MODEL else 1
|
||||
width = math.floor(pixels * math.sqrt(ratio) / step) * step
|
||||
height = math.floor(pixels / math.sqrt(ratio) / step) * step
|
||||
if model == DEFAULT_MODEL and (
|
||||
pixels > 1024
|
||||
or not (512 <= width <= 2048 and 512 <= height <= 2048)
|
||||
or width * height > 1024 * 1024
|
||||
):
|
||||
raise ValueError(
|
||||
f"Unsupported image size '{image_size}' / aspect ratio '{aspect_ratio}' for Hy-Image-3.0. "
|
||||
"Width and height must each be 512-2048 pixels in multiples of 16, "
|
||||
"with area <= 1024x1024. Use 1K with a ratio from 1:4 to 4:1, or 512px at 1:1."
|
||||
)
|
||||
limits = MODEL_SIZE_LIMITS[model]
|
||||
if not _within_limits(width, height, limits):
|
||||
# Tiers or ratios beyond the model's limits move to the nearest legal size.
|
||||
width, height = fit_size(ratio, pixels=pixels * pixels, step=step, **limits)
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
@@ -141,6 +140,7 @@ def _generate_image(api_key: str, prompt: str,
|
||||
"""Generate one image with the Tencent backend."""
|
||||
model = _validate_model(model)
|
||||
size = _resolve_size(aspect_ratio, image_size, model)
|
||||
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
|
||||
url = _resolve_url(base_url, model)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
@@ -236,9 +236,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+23
-11
@@ -24,6 +24,7 @@ if __name__ == "__main__":
|
||||
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend volcengine")
|
||||
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
TIER_PIXELS,
|
||||
fit_size,
|
||||
match_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -95,20 +100,26 @@ def _resolve_size(aspect_ratio: str, image_size: str) -> str:
|
||||
"""Resolve the target resolution for a ratio and logical size preset."""
|
||||
normalized = normalize_image_size(image_size)
|
||||
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized)
|
||||
if sizes is None:
|
||||
if sizes is None and normalized not in TIER_PIXELS:
|
||||
supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
|
||||
raise ValueError(
|
||||
f"Unsupported image size '{image_size}' for Volcengine backend. "
|
||||
f"Seedream 4.5 supports these sizes: {supported_sizes}."
|
||||
)
|
||||
size = sizes.get(aspect_ratio)
|
||||
if not size:
|
||||
supported = sorted(sizes)
|
||||
raise ValueError(
|
||||
f"Unsupported aspect ratio '{aspect_ratio}' for Volcengine backend. "
|
||||
f"Supported: {supported}"
|
||||
)
|
||||
return size
|
||||
listed = match_aspect_ratio(aspect_ratio, sizes or {})
|
||||
if listed:
|
||||
return sizes[listed]
|
||||
# Any other ratio or tier is fitted to the model's size rules.
|
||||
budget = math.prod(map(int, sizes["1:1"].split("x"))) if sizes else TIER_PIXELS[normalized]
|
||||
width, height = fit_size(
|
||||
aspect_ratio,
|
||||
pixels=budget,
|
||||
step=32,
|
||||
min_pixels=3_686_400,
|
||||
max_pixels=16_777_216,
|
||||
max_ratio=16,
|
||||
)
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def _generate_image(api_key: str, prompt: str,
|
||||
@@ -118,6 +129,7 @@ def _generate_image(api_key: str, prompt: str,
|
||||
"""Generate one image with the Volcengine backend."""
|
||||
model = _validate_model(model)
|
||||
size = _resolve_size(aspect_ratio, image_size)
|
||||
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
|
||||
url = _resolve_url(base_url)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
@@ -196,9 +208,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
+23
-11
@@ -24,6 +24,7 @@ if __name__ == "__main__":
|
||||
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend zhipu")
|
||||
raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
|
||||
http_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
TIER_PIXELS,
|
||||
fit_size,
|
||||
match_aspect_ratio,
|
||||
normalize_image_size,
|
||||
note_aspect_ratio,
|
||||
require_api_key,
|
||||
resolve_output_path,
|
||||
retry_delay,
|
||||
@@ -96,20 +101,26 @@ def _resolve_size(aspect_ratio: str, image_size: str) -> str:
|
||||
"""Resolve the target resolution for a ratio and logical size preset."""
|
||||
normalized = normalize_image_size(image_size)
|
||||
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized)
|
||||
if sizes is None:
|
||||
if sizes is None and normalized not in TIER_PIXELS:
|
||||
supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
|
||||
raise ValueError(
|
||||
f"Unsupported image size '{image_size}' for Zhipu backend. "
|
||||
f"GLM-Image supports these logical sizes: {supported_sizes}."
|
||||
)
|
||||
size = sizes.get(aspect_ratio)
|
||||
if not size:
|
||||
supported = sorted(sizes)
|
||||
raise ValueError(
|
||||
f"Unsupported aspect ratio '{aspect_ratio}' for Zhipu backend. "
|
||||
f"Supported: {supported}"
|
||||
)
|
||||
return size
|
||||
listed = match_aspect_ratio(aspect_ratio, sizes or {})
|
||||
if listed:
|
||||
return sizes[listed]
|
||||
# Any other ratio or tier is fitted to the model's size rules.
|
||||
budget = math.prod(map(int, sizes["1:1"].split("x"))) if sizes else TIER_PIXELS[normalized]
|
||||
width, height = fit_size(
|
||||
aspect_ratio,
|
||||
pixels=budget,
|
||||
step=32,
|
||||
min_edge=512,
|
||||
max_edge=2048,
|
||||
max_pixels=2**22,
|
||||
)
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def _generate_image(api_key: str, prompt: str,
|
||||
@@ -119,6 +130,7 @@ def _generate_image(api_key: str, prompt: str,
|
||||
"""Generate one image with the Zhipu backend."""
|
||||
model = _validate_model(model)
|
||||
size = _resolve_size(aspect_ratio, image_size)
|
||||
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
|
||||
url = _resolve_url(base_url)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
@@ -192,9 +204,9 @@ def generate(prompt: str,
|
||||
if attempt >= max_retries:
|
||||
break
|
||||
limited = is_rate_limit_error(exc)
|
||||
delay = retry_delay(attempt, rate_limited=limited)
|
||||
delay = retry_delay(attempt, rate_limited=limited, exc=last_error)
|
||||
label = "Rate limit hit" if limited else f"Error: {exc}"
|
||||
print(f"\n [WARN] {label}. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
|
||||
raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}") from last_error
|
||||
@@ -92,15 +92,6 @@ DEPRECATED_IMAGE_KEYS = {
|
||||
"IMAGE_BASE_URL",
|
||||
}
|
||||
|
||||
# All aspect ratios accepted by the unified CLI
|
||||
# (each backend validates its own subset internally)
|
||||
ALL_ASPECT_RATIOS = [
|
||||
"1:1", "1:2", "1:3", "1:4", "1:8",
|
||||
"2:1", "2:3", "3:1", "3:2", "3:4", "4:1", "4:3",
|
||||
"4:5", "5:4", "8:1", "9:16", "9:21", "10:16",
|
||||
"16:9", "16:10", "21:9",
|
||||
]
|
||||
|
||||
ALL_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
|
||||
|
||||
BACKEND_REGISTRY = {
|
||||
@@ -117,7 +108,7 @@ BACKEND_REGISTRY = {
|
||||
"module": "backend_openai",
|
||||
"tier": "core",
|
||||
"label": "OpenAI / OpenAI-compatible",
|
||||
"default_model": "gpt-image-2",
|
||||
"default_model": "gpt-image-2.5-sunburst",
|
||||
"default_image_size": "1K",
|
||||
"key_hint": "OPENAI_API_KEY",
|
||||
"aliases": ["openai-compatible", "openai_compatible"],
|
||||
@@ -384,19 +375,15 @@ def _print_backend_list() -> None:
|
||||
_print_backend_resolution()
|
||||
|
||||
|
||||
def _check_backend_aspect_ratio(backend_module, aspect_ratio: str) -> None:
|
||||
"""Fail before the request when the resolved backend rejects this ratio.
|
||||
def _aspect_ratio_arg(value: str) -> str:
|
||||
"""argparse type: any positive W:H, kept as written for the backend to fit."""
|
||||
from image_backends.backend_common import parse_aspect_ratio
|
||||
|
||||
``ALL_ASPECT_RATIOS`` is the union across backends; each backend module
|
||||
may narrow it with ``VALID_ASPECT_RATIOS``.
|
||||
"""
|
||||
valid = getattr(backend_module, "VALID_ASPECT_RATIOS", None)
|
||||
if valid and aspect_ratio not in valid:
|
||||
name = getattr(backend_module, "__name__", "backend").rsplit(".", 1)[-1]
|
||||
raise ValueError(
|
||||
f"aspect_ratio '{aspect_ratio}' is not supported by {name}. "
|
||||
f"Valid for this backend: {list(valid)}"
|
||||
)
|
||||
try:
|
||||
parse_aspect_ratio(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_backend() -> tuple[object, str]:
|
||||
@@ -595,7 +582,7 @@ def load_manifest(path: str) -> dict:
|
||||
Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`,
|
||||
`page_role`, `text_policy`, `slice_grid`, `slice_names`, `last_error`.
|
||||
"""
|
||||
from image_backends.backend_common import normalize_image_size
|
||||
from image_backends.backend_common import normalize_image_size, parse_aspect_ratio
|
||||
|
||||
try:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
@@ -672,11 +659,10 @@ def load_manifest(path: str) -> dict:
|
||||
f"{prefix} status '{item['status']}' is invalid. "
|
||||
f"Valid: {sorted(VALID_STATUSES)}"
|
||||
)
|
||||
if item["aspect_ratio"] not in ALL_ASPECT_RATIOS:
|
||||
raise ValueError(
|
||||
f"{prefix} aspect_ratio '{item['aspect_ratio']}' is invalid. "
|
||||
f"Valid: {ALL_ASPECT_RATIOS}"
|
||||
)
|
||||
try:
|
||||
parse_aspect_ratio(item["aspect_ratio"])
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{prefix} {exc}") from exc
|
||||
if "image_size" in item:
|
||||
image_size = item["image_size"]
|
||||
if not isinstance(image_size, str) or not image_size.strip():
|
||||
@@ -935,9 +921,11 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
output_dir = str(manifest_output_dir)
|
||||
|
||||
from image_backends.backend_common import (
|
||||
MAX_RETRY_WAIT,
|
||||
is_global_permanent_error,
|
||||
is_permanent_error,
|
||||
is_rate_limit_error,
|
||||
retry_after_seconds,
|
||||
validate_image_file,
|
||||
)
|
||||
|
||||
@@ -992,7 +980,6 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
def _one(idx: int):
|
||||
item = items[idx]
|
||||
try:
|
||||
_check_backend_aspect_ratio(backend_module, item["aspect_ratio"])
|
||||
saved_path = backend_module.generate(
|
||||
prompt=item["prompt"],
|
||||
aspect_ratio=item["aspect_ratio"],
|
||||
@@ -1020,6 +1007,7 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
)
|
||||
|
||||
rate_limited = False
|
||||
server_wait = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as ex:
|
||||
futures = [ex.submit(_one, i) for i in batch_idx]
|
||||
for fut in concurrent.futures.as_completed(futures):
|
||||
@@ -1050,6 +1038,7 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
)
|
||||
elif is_rate_limit_error(exc):
|
||||
rate_limited = True
|
||||
server_wait = max(server_wait, retry_after_seconds(exc) or 0)
|
||||
attempts = rate_limit_attempts.get(idx, 0) + 1
|
||||
rate_limit_attempts[idx] = attempts
|
||||
if (
|
||||
@@ -1095,6 +1084,13 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
"Stopping new batches; untouched items remain retryable.\n"
|
||||
)
|
||||
break
|
||||
if rate_limited and server_wait > MAX_RETRY_WAIT:
|
||||
stopped_for_rate_limit = True
|
||||
print(
|
||||
f"\n Provider asked to wait {server_wait}s (over {MAX_RETRY_WAIT}s). "
|
||||
"Stopping now; rerun the manifest later, unfinished items remain retryable.\n"
|
||||
)
|
||||
break
|
||||
if stopped_for_rate_limit:
|
||||
print(
|
||||
"\n Persistent rate limit reached the run boundary. "
|
||||
@@ -1103,12 +1099,14 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
break
|
||||
if rate_limited and current > 1 and queue:
|
||||
new_current = max(1, current // 2)
|
||||
# Wait at least as long as the provider asked (Retry-After), never under 10s.
|
||||
pause = max(10, server_wait)
|
||||
print(
|
||||
f"\n ⚠ Rate-limit hit — concurrency {current} → {new_current}, "
|
||||
"pausing 10s before next batch\n"
|
||||
f"pausing {pause}s before next batch\n"
|
||||
)
|
||||
current = new_current
|
||||
time.sleep(10)
|
||||
time.sleep(pause)
|
||||
elif queue:
|
||||
time.sleep(2)
|
||||
|
||||
@@ -1255,8 +1253,11 @@ def main() -> None:
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--aspect_ratio", default="1:1", choices=ALL_ASPECT_RATIOS,
|
||||
help=f"Aspect ratio. Default: 1:1."
|
||||
"--aspect_ratio", default="1:1", type=_aspect_ratio_arg,
|
||||
help=(
|
||||
"Requested aspect ratio, any positive W:H (e.g. 16:9, 4:1, 1.85:1). "
|
||||
"Each backend outputs the nearest size its model allows. Default: 1:1."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--image_size", default=None,
|
||||
@@ -1422,7 +1423,10 @@ def main() -> None:
|
||||
sys.exit(130)
|
||||
md_path = render_manifest_md_to_file(args.manifest, manifest)
|
||||
print(f"Rendered Markdown sidecar: {md_path}")
|
||||
sys.exit(1 if failed else 0)
|
||||
# A run stopped early (billing, rate limit) leaves retryable items without
|
||||
# failing any; it must still exit non-zero.
|
||||
unfinished = any(item["status"] in RETRYABLE_STATUSES for item in manifest["items"])
|
||||
sys.exit(1 if failed or unfinished else 0)
|
||||
|
||||
# Single-image mode. Backfill the historical default prompt only here, so
|
||||
# plain generation is byte-for-byte unchanged while edit mode still requires
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for shared image-backend naming, error, and retry helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from email.utils import formatdate
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from image_backends import backend_common # noqa: E402
|
||||
|
||||
|
||||
def _response(status: int, body: str, headers: dict | None = None) -> SimpleNamespace:
|
||||
import requests
|
||||
|
||||
return SimpleNamespace(
|
||||
status_code=status,
|
||||
text=body,
|
||||
headers=requests.structures.CaseInsensitiveDict(headers or {}),
|
||||
)
|
||||
|
||||
|
||||
class OutputPathTests(unittest.TestCase):
|
||||
def test_cli_filename_keeps_dotted_stem(self) -> None:
|
||||
path = backend_common.resolve_output_path("p", "/out", "gpt-image-2.5-sunburst_hero")
|
||||
self.assertEqual(path, os.path.join("/out", "gpt-image-2.5-sunburst_hero.png"))
|
||||
|
||||
def test_image_extension_is_replaced(self) -> None:
|
||||
path = backend_common.resolve_output_path("p", "/out", "hero.v2.PNG", ".webp")
|
||||
self.assertEqual(path, os.path.join("/out", "hero.v2.webp"))
|
||||
|
||||
|
||||
class BillingErrorTests(unittest.TestCase):
|
||||
def test_openai_billing_codes_are_global_permanent(self) -> None:
|
||||
for code in (
|
||||
"credit_balance_exhausted",
|
||||
"organization_spend_limit_exceeded",
|
||||
"project_spend_limit_exceeded",
|
||||
"organization_usage_limit_exceeded",
|
||||
):
|
||||
with self.subTest(code=code):
|
||||
exc = backend_common.http_error(
|
||||
_response(429, '{"error": {"code": "%s"}}' % code), "OpenAI image generation"
|
||||
)
|
||||
self.assertTrue(backend_common.is_global_permanent_error(exc))
|
||||
self.assertFalse(backend_common.is_rate_limit_error(exc))
|
||||
|
||||
def test_plain_rate_limit_stays_retryable(self) -> None:
|
||||
exc = backend_common.http_error(
|
||||
_response(429, '{"error": {"code": "rate_limit_exceeded"}}'), "OpenAI image generation"
|
||||
)
|
||||
self.assertFalse(backend_common.is_permanent_error(exc))
|
||||
self.assertTrue(backend_common.is_rate_limit_error(exc))
|
||||
|
||||
|
||||
class RetryDelayTests(unittest.TestCase):
|
||||
def test_retry_after_seconds_wins(self) -> None:
|
||||
exc = backend_common.http_error(_response(429, "slow down", {"Retry-After": "7"}), "x")
|
||||
self.assertEqual(backend_common.retry_delay(2, rate_limited=True, exc=exc), 7)
|
||||
|
||||
def test_retry_after_ms_rounds_up(self) -> None:
|
||||
exc = backend_common.http_error(_response(429, "slow down", {"retry-after-ms": "1500"}), "x")
|
||||
self.assertEqual(exc.retry_after, 2)
|
||||
|
||||
def test_retry_after_http_date(self) -> None:
|
||||
with mock.patch.object(backend_common.time, "time", return_value=1_000_000.0):
|
||||
exc = backend_common.http_error(
|
||||
_response(503, "busy", {"Retry-After": formatdate(1_000_030, usegmt=True)}), "x"
|
||||
)
|
||||
self.assertEqual(exc.retry_after, 30)
|
||||
|
||||
def test_unrepresentable_retry_after_is_ignored(self) -> None:
|
||||
for value in ("inf", "1e309", "nan", "soon"):
|
||||
with self.subTest(value=value):
|
||||
exc = backend_common.http_error(_response(429, "slow down", {"Retry-After": value}), "x")
|
||||
self.assertIsNone(exc.retry_after)
|
||||
|
||||
def test_retry_after_is_capped_and_found_through_the_cause_chain(self) -> None:
|
||||
cause = backend_common.http_error(_response(429, "slow down", {"Retry-After": "86400"}), "x")
|
||||
try:
|
||||
raise RuntimeError("Failed after 4 attempts") from cause
|
||||
except RuntimeError as wrapped:
|
||||
self.assertEqual(backend_common.retry_after_seconds(wrapped), 86400)
|
||||
with self.assertRaises(RuntimeError):
|
||||
backend_common.retry_delay(0, rate_limited=True, exc=wrapped)
|
||||
at_cap = backend_common.http_error(_response(429, "slow down", {"Retry-After": "300"}), "x")
|
||||
self.assertEqual(backend_common.retry_delay(0, rate_limited=True, exc=at_cap), 300)
|
||||
|
||||
def test_backend_stops_retrying_when_the_wait_exceeds_the_cap(self) -> None:
|
||||
from image_backends import backend_openai
|
||||
|
||||
limited = _response(429, '{"error": {"code": "rate_limit_exceeded"}}', {"Retry-After": "86400"})
|
||||
limited.ok = False
|
||||
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "test"}), \
|
||||
mock.patch.object(backend_openai.requests, "post", return_value=limited) as post, \
|
||||
mock.patch.object(backend_openai.time, "sleep") as sleep, \
|
||||
mock.patch("sys.stdout"):
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
backend_openai.generate("p")
|
||||
self.assertEqual(post.call_count, 1)
|
||||
sleep.assert_not_called()
|
||||
self.assertEqual(backend_common.retry_after_seconds(raised.exception), 86400)
|
||||
|
||||
def test_non_openai_backend_waits_for_retry_after(self) -> None:
|
||||
from image_backends import backend_qwen
|
||||
|
||||
limited = _response(429, '{"code": "Throttling.RateQuota"}', {"Retry-After": "42"})
|
||||
limited.json = lambda: {"code": "Throttling.RateQuota"}
|
||||
limited.ok = False
|
||||
with mock.patch.dict(os.environ, {"QWEN_API_KEY": "test"}), \
|
||||
mock.patch.object(backend_qwen.requests, "post", return_value=limited), \
|
||||
mock.patch.object(backend_qwen.time, "sleep") as sleep, \
|
||||
mock.patch("sys.stdout"):
|
||||
with self.assertRaises(RuntimeError):
|
||||
backend_qwen.generate("p", max_retries=1)
|
||||
sleep.assert_called_once_with(42)
|
||||
|
||||
def test_rate_limit_backoff_has_bounded_jitter(self) -> None:
|
||||
exc = backend_common.http_error(_response(429, "slow down"), "x")
|
||||
self.assertIsNone(exc.retry_after)
|
||||
delays = {backend_common.retry_delay(1, rate_limited=True, exc=exc) for _ in range(200)}
|
||||
self.assertTrue(all(20 <= delay <= 25 for delay in delays))
|
||||
self.assertGreater(len(delays), 1)
|
||||
|
||||
|
||||
class AspectRatioTests(unittest.TestCase):
|
||||
def test_any_positive_ratio_parses_exactly(self) -> None:
|
||||
self.assertEqual(backend_common.parse_aspect_ratio("1.85:1"), backend_common.parse_aspect_ratio("37:20"))
|
||||
self.assertEqual(backend_common.parse_aspect_ratio(" 32 : 18 "), backend_common.parse_aspect_ratio("16:9"))
|
||||
for value in ("abc", "16:9:1", "0:1", "1:0", "-1:2", "inf:1", "nan:1", ""):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "Invalid aspect ratio"):
|
||||
backend_common.parse_aspect_ratio(value)
|
||||
|
||||
def test_nearest_ratio_prefers_exact_then_log_distance(self) -> None:
|
||||
choices = ["1:1", "16:9", "9:16", "21:9"]
|
||||
self.assertEqual(backend_common.nearest_aspect_ratio("32:18", choices), "16:9")
|
||||
self.assertEqual(backend_common.nearest_aspect_ratio("4:1", choices), "21:9")
|
||||
self.assertEqual(backend_common.nearest_aspect_ratio("1:4", choices), "9:16")
|
||||
|
||||
def test_fit_size_clamps_ratio_and_keeps_orientation(self) -> None:
|
||||
rules = {"step": 8, "min_edge": 512, "max_edge": 2048}
|
||||
self.assertEqual(backend_common.fit_size("10:1", pixels=1024 * 1024, **rules), (2048, 512))
|
||||
self.assertEqual(backend_common.fit_size("1:10", pixels=4096 * 4096, **rules), (512, 2048))
|
||||
width, height = backend_common.fit_size("1:20", pixels=2048 * 2048, min_pixels=3_686_400,
|
||||
max_pixels=16_777_216, max_ratio=16)
|
||||
self.assertAlmostEqual(height / width, 16, delta=0.1)
|
||||
self.assertTrue(3_686_400 <= width * height <= 16_777_216)
|
||||
|
||||
def test_fit_size_rounding_never_exceeds_the_ratio_limit(self) -> None:
|
||||
width, height = backend_common.fit_size("1000:1", pixels=4096 * 4096, min_pixels=921_600,
|
||||
max_pixels=4_624_220, max_ratio=16)
|
||||
self.assertLessEqual(width / height, 16)
|
||||
self.assertLessEqual(width * height, 4_624_220)
|
||||
|
||||
def test_extreme_integer_ratios_stay_finite(self) -> None:
|
||||
huge, tiny = f"{10**309}:1", f"1:{10**324}"
|
||||
self.assertEqual(backend_common.fit_size(huge, pixels=1024 * 1024, step=16, max_ratio=3), (1776, 592))
|
||||
self.assertEqual(backend_common.nearest_aspect_ratio(tiny, ["1:1", "1:3"]), "1:3")
|
||||
backend_common.note_aspect_ratio(tiny, "1:3")
|
||||
|
||||
|
||||
class OpenAISizeTests(unittest.TestCase):
|
||||
def test_listed_ratios_keep_their_table_sizes(self) -> None:
|
||||
from image_backends import backend_openai
|
||||
|
||||
for tier, sizes in backend_openai.GPT_IMAGE_2_SIZES.items():
|
||||
for ratio, size in sizes.items():
|
||||
with self.subTest(tier=tier, ratio=ratio):
|
||||
self.assertEqual(backend_openai._select_size("gpt-image-2.5-sunburst", ratio, tier), size)
|
||||
|
||||
def test_any_ratio_fits_gpt_image_2_rules(self) -> None:
|
||||
from image_backends import backend_openai
|
||||
|
||||
for ratio in ("4:1", "1:4", "8:1", "3:1", "2:1", "1:2", "16:10", "10:16", "9:21", "1.85:1", "7:2"):
|
||||
for tier in ("512px", "1K", "2K", "4K"):
|
||||
with self.subTest(ratio=ratio, tier=tier):
|
||||
size = backend_openai._select_size("gpt-image-2", ratio, tier)
|
||||
backend_openai._validate_gpt_image_2_size(size)
|
||||
self.assertEqual(backend_openai._select_size("gpt-image-2.5-flare", "4:1", "1K"), "1776x592")
|
||||
|
||||
def test_legacy_and_unknown_models_use_their_nearest_entry(self) -> None:
|
||||
from image_backends import backend_openai
|
||||
|
||||
self.assertEqual(backend_openai._select_size("gpt-image-1.5", "4:1", "1K"), "1536x1024")
|
||||
self.assertEqual(backend_openai._select_size("compatible-model", "1:4", "1K"), "1024x1792")
|
||||
# Unlisted ratios compare against real sizes, not nominal keys (5:4 -> 1536x1024).
|
||||
self.assertEqual(backend_openai._select_size("gpt-image-1.5", "1.2:1", "1K"), "1024x1024")
|
||||
self.assertEqual(backend_openai._select_size("compatible-model", "1.24:1", "1K"), "1536x1024")
|
||||
self.assertEqual(backend_openai._select_size("gpt-image-1.5", "5:4", "1K"), "1536x1024")
|
||||
|
||||
|
||||
class ManifestAspectRatioTests(unittest.TestCase):
|
||||
def _load(self, ratio: str) -> dict:
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
import image_gen
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "image_prompts.json"
|
||||
item = {"filename": "a.png", "prompt": "p", "aspect_ratio": ratio, "status": "Pending",
|
||||
"page_role": "local", "text_policy": "none"}
|
||||
path.write_text(json.dumps({"items": [item]}), encoding="utf-8")
|
||||
return image_gen.load_manifest(str(path))
|
||||
|
||||
def test_manifest_keeps_any_requested_ratio(self) -> None:
|
||||
for ratio in ("4:1", "1.85:1", "7:2"):
|
||||
with self.subTest(ratio=ratio):
|
||||
self.assertEqual(self._load(ratio)["items"][0]["aspect_ratio"], ratio)
|
||||
|
||||
def test_manifest_rejects_malformed_ratio(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Invalid aspect ratio"):
|
||||
self._load("0:1")
|
||||
+35
-18
@@ -17,7 +17,7 @@ if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import image_gen # noqa: E402
|
||||
from image_backends import backend_tencent # noqa: E402
|
||||
from image_backends import backend_common, backend_tencent # noqa: E402
|
||||
|
||||
|
||||
class TencentBackendTests(unittest.TestCase):
|
||||
@@ -103,15 +103,14 @@ class TencentBackendTests(unittest.TestCase):
|
||||
with self.subTest(ratio=ratio, size=logical_size):
|
||||
self.assertEqual(backend_tencent._resolve_size(ratio, logical_size), resolution)
|
||||
|
||||
def test_hy_rejects_impossible_sizes_before_request(self) -> None:
|
||||
for ratio, logical_size in (
|
||||
("1:1", "2K"), ("1:1", "4K"), ("16:9", "512px"),
|
||||
("1:8", "1K"), ("8:1", "1K"),
|
||||
def test_hy_refits_out_of_range_sizes(self) -> None:
|
||||
for ratio, logical_size, resolution in (
|
||||
("1:1", "2K", "1024x1024"), ("1:1", "4K", "1024x1024"), ("16:9", "512px", "912x512"),
|
||||
("1:8", "1K", "512x2048"), ("8:1", "1K", "2048x512"), ("1.85:1", "1K", "1392x752"),
|
||||
):
|
||||
with self.subTest(ratio=ratio, size=logical_size):
|
||||
with self.assertRaisesRegex(ValueError, "512-2048.*multiples of 16.*1024x1024"):
|
||||
backend_tencent.generate("test", aspect_ratio=ratio, image_size=logical_size)
|
||||
self.post.assert_not_called()
|
||||
backend_tencent.generate("test", aspect_ratio=ratio, image_size=logical_size)
|
||||
self.assertEqual(self.post.call_args.kwargs["json"]["size"], resolution)
|
||||
|
||||
def test_seedream_lite_upgrades_small_sizes_once(self) -> None:
|
||||
for logical_size in ("1K", "512px"):
|
||||
@@ -130,25 +129,42 @@ class TencentBackendTests(unittest.TestCase):
|
||||
("seedream-image-v5.0-lite", "2K", 2048),
|
||||
("seedream-image-v5.0-lite", "4K", 4096),
|
||||
):
|
||||
for ratio in image_gen.ALL_ASPECT_RATIOS:
|
||||
for ratio in ("1:1", "1:4", "1:8", "3:2", "4:1", "8:1", "9:16", "16:9", "21:9", "16:10", "1.85:1"):
|
||||
with self.subTest(model=model, size=logical_size, ratio=ratio):
|
||||
size = backend_tencent._resolve_size(ratio, logical_size, model)
|
||||
width, height = map(int, size.split("x"))
|
||||
ratio_width, ratio_height = map(int, ratio.split(":"))
|
||||
expected = float(backend_common.parse_aspect_ratio(ratio))
|
||||
self.assertAlmostEqual(width * height / side ** 2, 1.0, delta=0.004)
|
||||
self.assertAlmostEqual(width / height, ratio_width / ratio_height, delta=0.025)
|
||||
self.assertAlmostEqual(width / height, expected, delta=0.025)
|
||||
|
||||
def test_seedream_pro_rejects_512px_and_4k(self) -> None:
|
||||
for logical_size in ("512px", "4K"):
|
||||
def test_extreme_ratios_stay_within_every_model_limit(self) -> None:
|
||||
for model in backend_tencent.SUPPORTED_MODELS:
|
||||
for ratio in ("1000:1", "1:1000", f"{10**309}:1"):
|
||||
for logical_size in ("512px", "1K", "2K", "4K"):
|
||||
with self.subTest(model=model, ratio=ratio[:12], size=logical_size):
|
||||
size = backend_tencent._resolve_size(ratio, logical_size, model)
|
||||
width, height = map(int, size.split("x"))
|
||||
limits = backend_tencent.MODEL_SIZE_LIMITS[model]
|
||||
self.assertTrue(backend_tencent._within_limits(width, height, limits))
|
||||
|
||||
def test_seedream_clamps_ratio_beyond_16_to_1(self) -> None:
|
||||
for model in ("seedream-image-v5.0-pro", "seedream-image-v5.0-lite"):
|
||||
with self.subTest(model=model):
|
||||
width, height = map(int, backend_tencent._resolve_size("1:20", "2K", model).split("x"))
|
||||
limits = backend_tencent.MODEL_SIZE_LIMITS[model]
|
||||
self.assertAlmostEqual(height / width, 16, delta=0.1)
|
||||
self.assertTrue(limits["min_pixels"] <= width * height <= limits["max_pixels"])
|
||||
|
||||
def test_seedream_pro_refits_512px_and_4k_into_its_area(self) -> None:
|
||||
for logical_size, resolution in (("512px", "960x960"), ("4K", "2150x2150")):
|
||||
with self.subTest(size=logical_size):
|
||||
with self.assertRaisesRegex(ValueError, "Use 1K or 2K"):
|
||||
backend_tencent.generate("test", model="seedream-image-v5.0-pro", image_size=logical_size)
|
||||
self.post.assert_not_called()
|
||||
backend_tencent.generate("test", model="seedream-image-v5.0-pro", image_size=logical_size)
|
||||
self.assertEqual(self.post.call_args.kwargs["json"]["size"], resolution)
|
||||
|
||||
def test_invalid_ratio_and_size_fail_before_request(self) -> None:
|
||||
for ratio in ("invalid", "0:1", "1:0", "-1:2", "1:2:3"):
|
||||
with self.subTest(ratio=ratio):
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported aspect ratio"):
|
||||
with self.assertRaisesRegex(ValueError, "Invalid aspect ratio"):
|
||||
backend_tencent.generate("test", aspect_ratio=ratio)
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported image size"):
|
||||
backend_tencent.generate("test", image_size="3K")
|
||||
@@ -242,7 +258,8 @@ class TencentBackendTests(unittest.TestCase):
|
||||
def test_rate_limit_uses_shared_exponential_backoff(self) -> None:
|
||||
limited = Mock(status_code=429, text='{"error": {"code": "ConcurrencyLimit"}}')
|
||||
self.post.side_effect = [limited, limited, self.response]
|
||||
backend_tencent.generate("test")
|
||||
with patch.object(backend_common.random, "randint", return_value=0):
|
||||
backend_tencent.generate("test")
|
||||
self.assertEqual(self.post.call_count, 3)
|
||||
self.assertEqual([call.args[0] for call in self.sleep.call_args_list], [10, 20])
|
||||
|
||||
|
||||
+94
@@ -50,3 +50,97 @@ class ManifestOutputNameTests(unittest.TestCase):
|
||||
self.assertEqual(first.getpixel((0, 0)), (255, 0, 0))
|
||||
self.assertEqual(second.getpixel((0, 0)), (0, 0, 255))
|
||||
|
||||
|
||||
|
||||
class ManifestRetryAfterTests(unittest.TestCase):
|
||||
def test_rate_limited_batch_waits_for_server_retry_after(self) -> None:
|
||||
from unittest import mock
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
calls: dict[str, int] = {}
|
||||
|
||||
def generate(**kwargs: object) -> str:
|
||||
name = str(kwargs["filename"])
|
||||
calls[name] = calls.get(name, 0) + 1
|
||||
if name == "a.png" and calls[name] == 1:
|
||||
headers = requests.structures.CaseInsensitiveDict({"Retry-After": "37"})
|
||||
cause = backend_common.http_error(
|
||||
SimpleNamespace(status_code=429, text="rate limit", headers=headers),
|
||||
"OpenAI image generation",
|
||||
)
|
||||
raise RuntimeError(f"Failed after 4 attempts. Last error: {cause}") from cause
|
||||
path = Path(str(kwargs["output_dir"])) / name
|
||||
Image.new("RGB", (8, 8)).save(path, format="PNG")
|
||||
return str(path)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manifest_path = Path(tmp) / "image_prompts.json"
|
||||
items = [
|
||||
{"filename": name, "prompt": "p", "aspect_ratio": "1:1", "status": "Pending",
|
||||
"page_role": "local", "text_policy": "none"}
|
||||
for name in ("a.png", "b.png")
|
||||
]
|
||||
manifest_path.write_text(json.dumps({"items": items}), encoding="utf-8")
|
||||
manifest = image_gen.load_manifest(str(manifest_path))
|
||||
with mock.patch.object(image_gen.time, "sleep") as sleep, \
|
||||
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
image_gen._run_manifest(
|
||||
manifest, str(manifest_path), SimpleNamespace(generate=generate),
|
||||
initial_concurrency=2, image_size="1K", output_dir=tmp, model=None,
|
||||
)
|
||||
self.assertIn(mock.call(37), sleep.call_args_list)
|
||||
self.assertEqual([item["status"] for item in manifest["items"]], ["Generated", "Generated"])
|
||||
|
||||
def test_retry_after_beyond_the_cap_stops_the_run_instead_of_sleeping(self) -> None:
|
||||
from unittest import mock
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
def generate(**kwargs: object) -> str:
|
||||
name = str(kwargs["filename"])
|
||||
if name == "a.png":
|
||||
headers = requests.structures.CaseInsensitiveDict({"Retry-After": str(10**20)})
|
||||
cause = backend_common.http_error(
|
||||
SimpleNamespace(status_code=429, text="rate limit", headers=headers),
|
||||
"OpenAI image generation",
|
||||
)
|
||||
raise RuntimeError(f"Failed after 4 attempts. Last error: {cause}") from cause
|
||||
path = Path(str(kwargs["output_dir"])) / name
|
||||
Image.new("RGB", (8, 8)).save(path, format="PNG")
|
||||
return str(path)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manifest_path = Path(tmp) / "image_prompts.json"
|
||||
items = [
|
||||
{"filename": name, "prompt": "p", "aspect_ratio": "1:1", "status": "Pending",
|
||||
"page_role": "local", "text_policy": "none"}
|
||||
for name in ("a.png", "b.png", "c.png")
|
||||
]
|
||||
manifest_path.write_text(json.dumps({"items": items}), encoding="utf-8")
|
||||
manifest = image_gen.load_manifest(str(manifest_path))
|
||||
with mock.patch.object(image_gen.time, "sleep") as sleep, \
|
||||
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
result = image_gen._run_manifest(
|
||||
manifest, str(manifest_path), SimpleNamespace(generate=generate),
|
||||
initial_concurrency=2, image_size="1K", output_dir=tmp, model=None,
|
||||
)
|
||||
sleep.assert_not_called()
|
||||
self.assertEqual(result, (1, 0, 0))
|
||||
saved = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
statuses = {item["filename"]: item["status"] for item in saved["items"]}
|
||||
self.assertEqual(statuses, {"a.png": "Pending", "b.png": "Generated", "c.png": "Pending"})
|
||||
|
||||
argv = ["image_gen.py", "--manifest", str(manifest_path), "--backend", "openai"]
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(image_gen, "_load_image_env_file"), \
|
||||
mock.patch.object(image_gen, "_validate_runtime_config"), \
|
||||
mock.patch.object(image_gen, "_resolve_backend",
|
||||
return_value=(SimpleNamespace(generate=generate), "openai")), \
|
||||
mock.patch.object(image_gen.time, "sleep"), \
|
||||
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as stopped:
|
||||
image_gen.main()
|
||||
self.assertEqual(stopped.exception.code, 1)
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "pptx",
|
||||
"repo": "https://github.com/anthropics/skills.git",
|
||||
"ref": "main",
|
||||
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
|
||||
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/pptx",
|
||||
"syncedAt": "2026-10-10T16:00:00Z"
|
||||
"syncedAt": "2026-10-29T16:00:00Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "xlsx",
|
||||
"repo": "https://github.com/anthropics/skills.git",
|
||||
"ref": "main",
|
||||
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44",
|
||||
"commit": "ca45fc496a3436251f191354091d557da31c15b5",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/xlsx",
|
||||
"syncedAt": "2026-10-10T16:00:00Z"
|
||||
"syncedAt": "2026-10-29T16:00:00Z"
|
||||
}
|
||||
Reference in new issue
Block a user