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:
KeyInfo Bot committed 2026-10-30 00:03:35 +08:00
1 parent 2fb1818ebf
commit b397f552f0
37 files changed
+882 -226

No files matched your search

+25 -25
View File
@@ -1,13 +1,22 @@
{ {
"sources": [ "sources": [
{
"id": "hallmark",
"pluginName": "hallmark",
"repo": "https://github.com/Nutlope/hallmark.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "8f6969f8922caabde9de1784959eae11ddf68073",
"syncedAt": "2026-10-29T15:25:46Z"
},
{ {
"id": "docx", "id": "docx",
"pluginName": "docx", "pluginName": "docx",
"repo": "https://github.com/anthropics/skills.git", "repo": "https://github.com/anthropics/skills.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", "commit": "ca45fc496a3436251f191354091d557da31c15b5",
"syncedAt": "2026-10-10T16:00:00Z" "syncedAt": "2026-10-29T16:00:00Z"
}, },
{ {
"id": "pdf", "id": "pdf",
@@ -15,8 +24,8 @@
"repo": "https://github.com/anthropics/skills.git", "repo": "https://github.com/anthropics/skills.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", "commit": "ca45fc496a3436251f191354091d557da31c15b5",
"syncedAt": "2026-10-10T16:00:00Z" "syncedAt": "2026-10-29T16:00:00Z"
}, },
{ {
"id": "pptx", "id": "pptx",
@@ -24,8 +33,8 @@
"repo": "https://github.com/anthropics/skills.git", "repo": "https://github.com/anthropics/skills.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", "commit": "ca45fc496a3436251f191354091d557da31c15b5",
"syncedAt": "2026-10-10T16:00:00Z" "syncedAt": "2026-10-29T16:00:00Z"
}, },
{ {
"id": "xlsx", "id": "xlsx",
@@ -33,8 +42,8 @@
"repo": "https://github.com/anthropics/skills.git", "repo": "https://github.com/anthropics/skills.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", "commit": "ca45fc496a3436251f191354091d557da31c15b5",
"syncedAt": "2026-10-10T16:00:00Z" "syncedAt": "2026-10-29T16:00:00Z"
}, },
{ {
"id": "find-skills", "id": "find-skills",
@@ -42,8 +51,8 @@
"repo": "https://github.com/vercel-labs/skills.git", "repo": "https://github.com/vercel-labs/skills.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "699d35b21bd3c97dbc0edeae062b2698898bb2e5", "commit": "92a9a34ac3bf88e695e468b90c7bf32f163d32c8",
"syncedAt": "2026-10-28T15:59:59Z" "syncedAt": "2026-10-29T16:00:00Z"
}, },
{ {
"id": "generate-agents-md", "id": "generate-agents-md",
@@ -177,8 +186,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "6fbac39e1d06a66ce3a8724b9366cb878b793f99", "commit": "15baa7c570f12c78cd9ac6ead897724571e32e87",
"syncedAt": "2026-10-28T15:59:59Z" "syncedAt": "2026-10-29T16:00:00Z"
}, },
{ {
"id": "grill-me", "id": "grill-me",
@@ -186,8 +195,8 @@
"repo": "https://github.com/mattpocock/skills.git", "repo": "https://github.com/mattpocock/skills.git",
"ref": "main", "ref": "main",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c", "commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa",
"syncedAt": "2026-10-27T07:55:04Z" "syncedAt": "2026-10-29T16:00:00Z"
}, },
{ {
"id": "updating-gitea-repositories", "id": "updating-gitea-repositories",
@@ -258,8 +267,8 @@
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "f922205f9e860de24673269f81dea51e9e33b0cb", "commit": "79107ebd94e53c69865c1a49ac734ba8004491ba",
"syncedAt": "2026-10-28T15:59:59Z" "syncedAt": "2026-10-29T16:00:00Z"
}, },
{ {
"id": "shuorenhua", "id": "shuorenhua",
@@ -269,15 +278,6 @@
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "e33d5eb6420ba3468043791a57d71c153b0a8691", "commit": "e33d5eb6420ba3468043791a57d71c153b0a8691",
"syncedAt": "2026-10-22T16:00:00Z" "syncedAt": "2026-10-22T16:00:00Z"
},
{
"id": "hallmark",
"pluginName": "hallmark",
"repo": "https://github.com/Nutlope/hallmark.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "8f6969f8922caabde9de1784959eae11ddf68073",
"syncedAt": "2026-10-29T15:25:46Z"
} }
] ]
} }
@@ -2,8 +2,8 @@
"sourceId": "docx", "sourceId": "docx",
"repo": "https://github.com/anthropics/skills.git", "repo": "https://github.com/anthropics/skills.git",
"ref": "main", "ref": "main",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", "commit": "ca45fc496a3436251f191354091d557da31c15b5",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/docx", "sourcePath": "skills/docx",
"syncedAt": "2026-10-10T16:00:00Z" "syncedAt": "2026-10-29T16:00:00Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "find-skills", "sourceId": "find-skills",
"repo": "https://github.com/vercel-labs/skills.git", "repo": "https://github.com/vercel-labs/skills.git",
"ref": "main", "ref": "main",
"commit": "699d35b21bd3c97dbc0edeae062b2698898bb2e5", "commit": "92a9a34ac3bf88e695e468b90c7bf32f163d32c8",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/find-skills", "sourcePath": "skills/find-skills",
"syncedAt": "2026-10-28T15:59:59Z" "syncedAt": "2026-10-29T16:00:00Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "grill-me", "sourceId": "grill-me",
"repo": "https://github.com/mattpocock/skills.git", "repo": "https://github.com/mattpocock/skills.git",
"ref": "main", "ref": "main",
"commit": "59db609eb1e18aaf59b63284d0e52255aff0c63c", "commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills/productivity", "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. 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. 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浏览器自动化操作", "name": "playwright浏览器自动化操作",
"version": "20260605", "version": "20260605",
"keySource": "none", "keySource": "none",
"syncedAt": "2026-10-28T16:03:24Z" "syncedAt": "2026-10-29T16:03:34Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "next-skills", "sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"commit": "f922205f9e860de24673269f81dea51e9e33b0cb", "commit": "79107ebd94e53c69865c1a49ac734ba8004491ba",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills", "sourcePath": "skills",
"syncedAt": "2026-10-28T15:59:59Z" "syncedAt": "2026-10-29T16:00:00Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "pdf", "sourceId": "pdf",
"repo": "https://github.com/anthropics/skills.git", "repo": "https://github.com/anthropics/skills.git",
"ref": "main", "ref": "main",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", "commit": "ca45fc496a3436251f191354091d557da31c15b5",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/pdf", "sourcePath": "skills/pdf",
"syncedAt": "2026-10-10T16:00:00Z" "syncedAt": "2026-10-29T16:00:00Z"
} }
+4 -4
View File
@@ -106,7 +106,7 @@ Why you'd choose it, and where it isn't the right fit → [Why PPT Master](./doc
> [!IMPORTANT] > [!IMPORTANT]
> ### This is a tool, not a wishing well > ### 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/). > 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 ### 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> <details>
<summary><strong>Windows</strong> — see the dedicated <a href="./docs/windows-installation.md">step-by-step guide</a> ⚠️</summary> <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 | | **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 | | **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). **🔑 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: 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: **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", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "6fbac39e1d06a66ce3a8724b9366cb878b793f99", "commit": "15baa7c570f12c78cd9ac6ead897724571e32e87",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-10-28T15:59:59Z" "syncedAt": "2026-10-29T16:00:00Z"
} }
@@ -43,9 +43,9 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# IMAGE_BACKEND=openai # IMAGE_BACKEND=openai
# OPENAI_API_KEY=sk-xxx # OPENAI_API_KEY=sk-xxx
# OPENAI_MODEL=gpt-image-2 # OPENAI_MODEL=gpt-image-2.5-sunburst
# Legacy ChatGPT image API model / 旧版 ChatGPT 生图 API 模型 # 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_BASE_URL=http://127.0.0.1:3000/v1
# OpenAI-compatible providers may need these compatibility knobs. # OpenAI-compatible providers may need these compatibility knobs.
# OpenAI-compatible 第三方平台可按需开启以下兼容参数。 # OpenAI-compatible 第三方平台可按需开启以下兼容参数。
@@ -59,8 +59,8 @@
# OPENAI_OUTPUT_FORMAT=png # OPENAI_OUTPUT_FORMAT=png
# jpeg/webp only, 0-100 # jpeg/webp only, 0-100
# OPENAI_OUTPUT_COMPRESSION=80 # 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)
# GPT Image:auto / opaque / transparent(透明背景仅限 png/webp;gpt-image-2 为预览支持) # GPT Image:auto / opaque / transparent(透明背景仅限 png/webp;gpt-image-2 仅预览支持,可能返回 400)
# OPENAI_BACKGROUND=auto # OPENAI_BACKGROUND=auto
# auto / low # auto / low
# OPENAI_MODERATION=auto # OPENAI_MODERATION=auto
@@ -8,7 +8,7 @@ Want to support the project or discuss a partnership? See the [sponsorship guide
## Current Model Recommendation ## 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 ## 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 ## 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[].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[].page_role` | yes | `local` (default) or `hero_page` |
| `items[].text_policy` | yes | `none` or `embedded`, judged per image (§5.3) | | `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[].prompt` | yes | The assembled paragraph |
| `items[].image_size` | no | `512px` / `1K` / `2K` / `4K` | | `items[].image_size` | no | `512px` / `1K` / `2K` / `4K` |
| `items[].model` | no | Per-item backend model override | | `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. 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: Backend selection:
@@ -78,7 +78,7 @@ Example `.env`:
```env ```env
IMAGE_BACKEND=openai IMAGE_BACKEND=openai
OPENAI_API_KEY=sk-xxx OPENAI_API_KEY=sk-xxx
OPENAI_MODEL=gpt-image-2 OPENAI_MODEL=gpt-image-2.5-sunburst
# Optional proxy # Optional proxy
# OPENAI_BASE_URL=http://127.0.0.1:3000/v1 # OPENAI_BASE_URL=http://127.0.0.1:3000/v1
# OpenAI-compatible provider knobs: # OpenAI-compatible provider knobs:
@@ -90,7 +90,7 @@ OPENAI_MODEL=gpt-image-2
# OPENAI_OUTPUT_FORMAT=png # OPENAI_OUTPUT_FORMAT=png
# jpeg/webp only, 0-100 # jpeg/webp only, 0-100
# OPENAI_OUTPUT_COMPRESSION=80 # 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 # OPENAI_BACKGROUND=auto
# auto / low # auto / low
# OPENAI_MODERATION=auto # OPENAI_MODERATION=auto
@@ -101,21 +101,21 @@ Example process environment:
```bash ```bash
export IMAGE_BACKEND=openai export IMAGE_BACKEND=openai
export OPENAI_API_KEY=sk-xxx 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 export OPENAI_OUTPUT_FORMAT=png
``` ```
Current process environment wins over `.env`. Current process environment wins over `.env`.
OpenAI backend notes: 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 - Requests are sent with plain `requests.post()` to improve compatibility with
OpenAI-compatible proxies that block the OpenAI SDK's `httpx` transport. 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. - 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` (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. - `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). - `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). - 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`. - 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`. - 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`.
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
poll_json, poll_json,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
@@ -180,6 +182,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None, output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str: model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the BFL backend.""" """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 resolved_model = model or os.environ.get("BFL_MODEL") or DEFAULT_MODEL
_resolve_request_options(aspect_ratio, image_size, resolved_model) _resolve_request_options(aspect_ratio, image_size, resolved_model)
api_key = require_api_key( api_key = require_api_key(
@@ -208,9 +213,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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,9 +21,14 @@ if __name__ == "__main__":
import base64 import base64
import io import io
import math
import os import os
import random
import re import re
import time import time
from decimal import Decimal, InvalidOperation
from email.utils import parsedate_to_datetime
from fractions import Fraction
import requests import requests
@@ -35,6 +40,9 @@ except ImportError:
MAX_RETRIES = 3 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_BASE_DELAY = 10
RETRY_BACKOFF = 2 RETRY_BACKOFF = 2
@@ -56,6 +64,10 @@ _GLOBAL_PERMANENT_ERROR_MARKERS = (
"insufficient credits", "insufficient credits",
"insufficient balance", "insufficient balance",
"insufficient_quota", "insufficient_quota",
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
"exceeded your current quota", "exceeded your current quota",
"payment required", "payment required",
"billing is not enabled", "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: filename: str = None, ext: str = ".png") -> str:
"""Compute the final output file path based on parameters.""" """Compute the final output file path based on parameters."""
if filename: 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: else:
safe = "".join(c for c in prompt if c.isalnum() or c in (" ", "_")).rstrip() safe = "".join(c for c in prompt if c.isalnum() or c in (" ", "_")).rstrip()
safe = safe.replace(" ", "_").lower()[:30] safe = safe.replace(" ", "_").lower()[:30]
@@ -331,6 +345,136 @@ def report_resolution(path: str) -> None:
pass 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: def normalize_image_size(image_size: str) -> str:
"""Normalize image size input to standard format.""" """Normalize image size input to standard format."""
s = image_size.strip() 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: def retry_after_seconds(exc: Exception | None) -> int | None:
"""Return the retry delay for a given attempt.""" """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: 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 return 5
@@ -461,12 +629,38 @@ def require_api_key(*candidates: str, message: str) -> str:
raise ValueError(message) 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: def http_error(response: requests.Response, label: str) -> RuntimeError:
"""Convert an HTTP response into a readable RuntimeError.""" """Convert an HTTP response into a readable RuntimeError."""
body = response.text.strip() body = response.text.strip()
if len(body) > 500: if len(body) > 500:
body = 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( def poll_json(
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, retry_delay,
@@ -145,6 +147,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None, output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str: model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the fal.ai backend.""" """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 resolved_model = model or os.environ.get("FAL_MODEL") or DEFAULT_MODEL
_resolve_request_options(aspect_ratio, image_size, resolved_model) _resolve_request_options(aspect_ratio, image_size, resolved_model)
api_key = require_api_key( api_key = require_api_key(
@@ -174,9 +179,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -39,7 +39,9 @@ from image_backends.backend_common import (
MAX_RETRIES, MAX_RETRIES,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
resolve_output_path, resolve_output_path,
retry_delay, retry_delay,
save_image_bytes, save_image_bytes,
@@ -301,8 +303,14 @@ def generate(prompt: str,
image_size = normalize_image_size(image_size) image_size = normalize_image_size(image_size)
if aspect_ratio not in VALID_ASPECT_RATIOS: requested_ratio = aspect_ratio
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}") 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: if image_size not in VALID_IMAGE_SIZES:
raise ValueError(f"Invalid image size '{image_size}'. Valid: {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): if is_permanent_error(e):
raise raise
if attempt < max_retries and is_rate_limit_error(e): 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}). " print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
f"Waiting {delay}s before retry...") f"Waiting {delay}s before retry...")
time.sleep(delay) time.sleep(delay)
elif attempt < max_retries: 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}. " print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay}s...") f"Retrying in {delay}s...")
time.sleep(delay) time.sleep(delay)
else: else:
break 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
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, retry_delay,
@@ -150,6 +152,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None, output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str: model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the Ideogram backend.""" """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 resolved_model = model or os.environ.get("IDEOGRAM_MODEL") or DEFAULT_MODEL
_resolve_request_options(aspect_ratio, image_size, resolved_model) _resolve_request_options(aspect_ratio, image_size, resolved_model)
api_key = require_api_key( api_key = require_api_key(
@@ -178,9 +183,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -37,7 +37,11 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
TIER_PIXELS,
fit_size,
match_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, 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.""" """Resolve width and height from the unified aspect_ratio/image_size pair."""
normalized = normalize_image_size(image_size) normalized = normalize_image_size(image_size)
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized) 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) supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
raise ValueError( raise ValueError(
f"Unsupported image size '{image_size}' for MiniMax backend. " f"Unsupported image size '{image_size}' for MiniMax backend. "
f"image-01 supports these logical sizes: {supported_sizes}." f"image-01 supports these logical sizes: {supported_sizes}."
) )
dimensions = sizes.get(aspect_ratio) listed = match_aspect_ratio(aspect_ratio, sizes or {})
if not dimensions: if listed:
supported = sorted(sizes) return sizes[listed]
raise ValueError( # Any other ratio or tier is fitted to the model's size rules.
f"Unsupported aspect ratio '{aspect_ratio}' for MiniMax backend. " budget = sizes["1:1"][0] * sizes["1:1"][1] if sizes else TIER_PIXELS[normalized]
f"Supported: {supported}" width, height = fit_size(
aspect_ratio,
pixels=budget,
step=8,
min_edge=512,
max_edge=2048,
) )
return dimensions return width, height
def _extract_image_bytes(payload: dict) -> bytes | None: 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.""" """Generate one image with the MiniMax backend."""
model = _validate_model(model) model = _validate_model(model)
width, height = _resolve_dimensions(aspect_ratio, image_size) width, height = _resolve_dimensions(aspect_ratio, image_size)
note_aspect_ratio(aspect_ratio, f"{width}:{height}")
url = _resolve_url(base_url) url = _resolve_url(base_url)
headers = { headers = {
@@ -247,9 +257,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -18,7 +18,9 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, retry_delay,
@@ -154,6 +156,10 @@ def generate(prompt: str,
"""Generate an image with retries using the ModelScope backend.""" """Generate an image with retries using the ModelScope backend."""
resolved_model = _resolve_model(model) resolved_model = _resolve_model(model)
normalized_size = normalize_image_size(image_size) 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) _resolve_size(aspect_ratio, normalized_size)
api_key = require_api_key( api_key = require_api_key(
"MODELSCOPE_API_KEY", "MODELSCOPE_API_KEY",
@@ -181,9 +187,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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,7 +8,7 @@ Used by image_gen.py as a backend module.
Configuration keys: Configuration keys:
OPENAI_API_KEY (required) API key OPENAI_API_KEY (required) API key
OPENAI_BASE_URL (optional) Custom API endpoint (e.g. http://127.0.0.1:3000/v1) 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_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_RESPONSE_FORMAT (optional) auto, b64_json, url, or omit
OPENAI_QUALITY (optional) auto, omit, low, medium, high, xhigh, max, standard, or hd 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 ( from image_backends.backend_common import (
MAX_RETRIES, MAX_RETRIES,
download_image, download_image,
fit_size,
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
match_aspect_ratio,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
parse_aspect_ratio,
resolve_output_path, resolve_output_path,
retry_delay, retry_delay,
save_image_bytes, save_image_bytes,
@@ -69,7 +74,8 @@ from image_backends.backend_common import (
# ╚══════════════════════════════════════════════════════════════════╝ # ╚══════════════════════════════════════════════════════════════════╝
# Aspect ratio -> DALL-E 3 / legacy compatible size mapping. # 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 = { LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE = {
"1:1": "1024x1024", "1:1": "1024x1024",
"16:9": "1792x1024", "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, # 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. # 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 = { GPT_IMAGE_2_SIZES = {
"512px": { "512px": {
"1:1": "1024x1024", "16:9": "1280x720", "9:16": "720x1280", "1:1": "1024x1024", "16:9": "1280x720", "9:16": "720x1280",
@@ -134,8 +142,6 @@ DALL_E_2_SIZE_BY_IMAGE_SIZE = {
"4K": "1024x1024", "4K": "1024x1024",
} }
VALID_ASPECT_RATIOS = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys())
# image_size -> quality mapping # image_size -> quality mapping
IMAGE_SIZE_TO_QUALITY = { IMAGE_SIZE_TO_QUALITY = {
"512px": "low", "512px": "low",
@@ -144,7 +150,7 @@ IMAGE_SIZE_TO_QUALITY = {
"4K": "high", "4K": "high",
} }
DEFAULT_MODEL = "gpt-image-2" DEFAULT_MODEL = "gpt-image-2.5-sunburst"
GPT_IMAGE_2_MIN_PIXELS = 655_360 GPT_IMAGE_2_MIN_PIXELS = 655_360
GPT_IMAGE_2_MAX_PIXELS = 8_294_400 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)}") 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( def _select_size(
model: str, model: str,
aspect_ratio: str, aspect_ratio: str,
@@ -261,16 +280,31 @@ def _select_size(
if preset in {"gpt-image-2"} or ( if preset in {"gpt-image-2"} or (
preset == "auto" and (_is_gpt_image_2(model) or _is_gpt_image_2_5(model)) 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) _validate_gpt_image_2_size(size)
return size return size
if preset in {"gpt-image", "gpt-image-legacy"} or ( if preset in {"gpt-image", "gpt-image-legacy"} or (
preset == "auto" and _is_gpt_image_model(model) 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)): 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 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: 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" Model: {model}")
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}") print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
print(f" Size: {size} (from aspect_ratio={aspect_ratio})") print(f" Size: {size} (from aspect_ratio={aspect_ratio})")
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
if size_preset and size_preset != "auto": if size_preset and size_preset != "auto":
print(f" Size Preset: {size_preset}") print(f" Size Preset: {size_preset}")
if quality is not None: 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" Reference: {reference_image}")
print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}") print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
print(f" Size: {size} (from aspect_ratio={aspect_ratio})") print(f" Size: {size} (from aspect_ratio={aspect_ratio})")
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
if size_preset and size_preset != "auto": if size_preset and size_preset != "auto":
print(f" Size Preset: {size_preset}") print(f" Size Preset: {size_preset}")
if quality is not None: if quality is not None:
@@ -712,7 +748,7 @@ def generate(prompt: str,
image_size: Image size, mapped to OpenAI quality image_size: Image size, mapped to OpenAI quality
output_dir: Output directory output_dir: Output directory
filename: Output filename (without extension) 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 max_retries: Maximum number of retries
reference_image: Optional source image path. When set, the request reference_image: Optional source image path. When set, the request
goes to /v1/images/edits (image-to-image) instead of 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) image_size = normalize_image_size(image_size)
if aspect_ratio not in LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE: parse_aspect_ratio(aspect_ratio)
supported = list(LEGACY_COMPAT_ASPECT_RATIO_TO_SIZE.keys())
raise ValueError(
f"Unsupported aspect ratio '{aspect_ratio}' for OpenAI backend. "
f"Supported: {supported}"
)
_validate_request_options( _validate_request_options(
model, model,
@@ -762,16 +793,16 @@ def generate(prompt: str,
if is_permanent_error(e): if is_permanent_error(e):
raise raise
if attempt < max_retries and is_rate_limit_error(e): 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}). " print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
f"Waiting {delay}s before retry...") f"Waiting {delay}s before retry...")
time.sleep(delay) time.sleep(delay)
elif attempt < max_retries: 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}. " print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay}s...") f"Retrying in {delay}s...")
time.sleep(delay) time.sleep(delay)
else: else:
break 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
@@ -36,7 +36,9 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
resolve_output_path, resolve_output_path,
retry_delay, retry_delay,
save_image_bytes, save_image_bytes,
@@ -189,8 +191,9 @@ def generate(prompt: str,
image_size = normalize_image_size(image_size) image_size = normalize_image_size(image_size)
if aspect_ratio not in VALID_ASPECT_RATIOS: requested_ratio = aspect_ratio
raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}") 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: if image_size not in VALID_IMAGE_SIZES:
raise ValueError(f"Invalid image size '{image_size}'. Valid: {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): if is_permanent_error(e):
raise raise
if attempt < max_retries and is_rate_limit_error(e): 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}). " print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
f"Waiting {delay}s before retry...") f"Waiting {delay}s before retry...")
time.sleep(delay) time.sleep(delay)
elif attempt < max_retries: 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}. " print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay}s...") f"Retrying in {delay}s...")
time.sleep(delay) time.sleep(delay)
else: else:
break 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,6 +24,7 @@ if __name__ == "__main__":
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend qwen") 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) raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
import math
import os import os
import time import time
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
TIER_PIXELS,
fit_size,
match_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, 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.""" """Resolve the target resolution for a ratio and logical size preset."""
normalized = normalize_image_size(image_size) normalized = normalize_image_size(image_size)
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized) 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) supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
raise ValueError( raise ValueError(
f"Unsupported image size '{image_size}' for Qwen backend. " f"Unsupported image size '{image_size}' for Qwen backend. "
f"qwen-image-2.0-pro supports these logical sizes: {supported_sizes}." f"qwen-image-2.0-pro supports these logical sizes: {supported_sizes}."
) )
size = sizes.get(aspect_ratio) listed = match_aspect_ratio(aspect_ratio, sizes or {})
if not size: if listed:
supported = sorted(sizes) return sizes[listed]
raise ValueError( # Any other ratio or tier is fitted to the model's size rules.
f"Unsupported aspect ratio '{aspect_ratio}' for Qwen backend. " budget = math.prod(map(int, sizes["1:1"].split("*"))) if sizes else TIER_PIXELS[normalized]
f"Supported: {supported}" 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 size return f"{width}*{height}"
def _generate_image(api_key: str, prompt: str, 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.""" """Generate one image with the Qwen backend."""
model = _validate_model(model) model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size) size = _resolve_size(aspect_ratio, image_size)
note_aspect_ratio(aspect_ratio, size.replace("*", ":"))
url = _resolve_url(base_url) url = _resolve_url(base_url)
headers = { headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
@@ -217,9 +230,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
poll_json, poll_json,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
@@ -168,6 +170,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None, output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str: model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the Replicate backend.""" """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 resolved_model = model or os.environ.get("REPLICATE_MODEL") or DEFAULT_MODEL
_resolve_request_options(aspect_ratio, image_size, resolved_model) _resolve_request_options(aspect_ratio, image_size, resolved_model)
api_key = require_api_key( api_key = require_api_key(
@@ -197,9 +202,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -35,7 +35,9 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, retry_delay,
@@ -148,6 +150,10 @@ def generate(prompt: str,
resolved_model = model or os.environ.get("SILICONFLOW_MODEL") or DEFAULT_MODEL resolved_model = model or os.environ.get("SILICONFLOW_MODEL") or DEFAULT_MODEL
_validate_model(resolved_model) _validate_model(resolved_model)
normalized_size = normalize_image_size(image_size) 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) _resolve_size(aspect_ratio, normalized_size)
api_key = require_api_key( api_key = require_api_key(
"SILICONFLOW_API_KEY", "SILICONFLOW_API_KEY",
@@ -175,9 +181,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -34,7 +34,9 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
nearest_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
report_resolution, report_resolution,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
@@ -140,6 +142,9 @@ def generate(prompt: str,
output_dir: str = None, filename: str = None, output_dir: str = None, filename: str = None,
model: str = None, max_retries: int = MAX_RETRIES) -> str: model: str = None, max_retries: int = MAX_RETRIES) -> str:
"""Generate an image with retries using the Stability backend.""" """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 base_url = os.environ.get("STABILITY_BASE_URL") or DEFAULT_BASE_URL
resolved_model = model or os.environ.get("STABILITY_MODEL") resolved_model = model or os.environ.get("STABILITY_MODEL")
_validate_request_options(aspect_ratio, image_size) _validate_request_options(aspect_ratio, image_size)
@@ -169,9 +174,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -40,7 +40,10 @@ from image_backends.backend_common import ( # noqa: E402
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
bounded_ratio,
fit_size,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, retry_delay,
@@ -57,6 +60,12 @@ MODEL_ENDPOINTS = {
} }
SUPPORTED_MODELS = set(MODEL_ENDPOINTS) SUPPORTED_MODELS = set(MODEL_ENDPOINTS)
IMAGE_SIZE_PIXELS = {"512px": 512, "1K": 1024, "2K": 2048, "4K": 4096} 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: 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] 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: 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.""" """Resolve a pixel size from the requested area and aspect ratio."""
normalized = normalize_image_size(image_size) 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"Unsupported image size '{image_size}' for Tencent backend. "
f"Supported logical sizes: {list(IMAGE_SIZE_PIXELS)}." f"Supported logical sizes: {list(IMAGE_SIZE_PIXELS)}."
) )
try: ratio = bounded_ratio(aspect_ratio)
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
if model == "seedream-image-v5.0-lite": if model == "seedream-image-v5.0-lite":
pixels = max(pixels, 2048) 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 step = 16 if model == DEFAULT_MODEL else 1
width = math.floor(pixels * math.sqrt(ratio) / step) * step width = math.floor(pixels * math.sqrt(ratio) / step) * step
height = math.floor(pixels / math.sqrt(ratio) / step) * step height = math.floor(pixels / math.sqrt(ratio) / step) * step
if model == DEFAULT_MODEL and ( limits = MODEL_SIZE_LIMITS[model]
pixels > 1024 if not _within_limits(width, height, limits):
or not (512 <= width <= 2048 and 512 <= height <= 2048) # Tiers or ratios beyond the model's limits move to the nearest legal size.
or width * height > 1024 * 1024 width, height = fit_size(ratio, pixels=pixels * pixels, step=step, **limits)
):
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."
)
return f"{width}x{height}" return f"{width}x{height}"
@@ -141,6 +140,7 @@ def _generate_image(api_key: str, prompt: str,
"""Generate one image with the Tencent backend.""" """Generate one image with the Tencent backend."""
model = _validate_model(model) model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size, model) size = _resolve_size(aspect_ratio, image_size, model)
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
url = _resolve_url(base_url, model) url = _resolve_url(base_url, model)
headers = { headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
@@ -236,9 +236,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -24,6 +24,7 @@ if __name__ == "__main__":
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend volcengine") 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) raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
import math
import os import os
import time import time
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
TIER_PIXELS,
fit_size,
match_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, 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.""" """Resolve the target resolution for a ratio and logical size preset."""
normalized = normalize_image_size(image_size) normalized = normalize_image_size(image_size)
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized) 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) supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
raise ValueError( raise ValueError(
f"Unsupported image size '{image_size}' for Volcengine backend. " f"Unsupported image size '{image_size}' for Volcengine backend. "
f"Seedream 4.5 supports these sizes: {supported_sizes}." f"Seedream 4.5 supports these sizes: {supported_sizes}."
) )
size = sizes.get(aspect_ratio) listed = match_aspect_ratio(aspect_ratio, sizes or {})
if not size: if listed:
supported = sorted(sizes) return sizes[listed]
raise ValueError( # Any other ratio or tier is fitted to the model's size rules.
f"Unsupported aspect ratio '{aspect_ratio}' for Volcengine backend. " budget = math.prod(map(int, sizes["1:1"].split("x"))) if sizes else TIER_PIXELS[normalized]
f"Supported: {supported}" width, height = fit_size(
aspect_ratio,
pixels=budget,
step=32,
min_pixels=3_686_400,
max_pixels=16_777_216,
max_ratio=16,
) )
return size return f"{width}x{height}"
def _generate_image(api_key: str, prompt: str, 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.""" """Generate one image with the Volcengine backend."""
model = _validate_model(model) model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size) size = _resolve_size(aspect_ratio, image_size)
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
url = _resolve_url(base_url) url = _resolve_url(base_url)
headers = { headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
@@ -196,9 +208,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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
@@ -24,6 +24,7 @@ if __name__ == "__main__":
print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend zhipu") 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) raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
import math
import os import os
import time import time
@@ -35,7 +36,11 @@ from image_backends.backend_common import (
http_error, http_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
TIER_PIXELS,
fit_size,
match_aspect_ratio,
normalize_image_size, normalize_image_size,
note_aspect_ratio,
require_api_key, require_api_key,
resolve_output_path, resolve_output_path,
retry_delay, 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.""" """Resolve the target resolution for a ratio and logical size preset."""
normalized = normalize_image_size(image_size) normalized = normalize_image_size(image_size)
sizes = ASPECT_RATIO_SIZE_MAP.get(normalized) 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) supported_sizes = ", ".join(ASPECT_RATIO_SIZE_MAP)
raise ValueError( raise ValueError(
f"Unsupported image size '{image_size}' for Zhipu backend. " f"Unsupported image size '{image_size}' for Zhipu backend. "
f"GLM-Image supports these logical sizes: {supported_sizes}." f"GLM-Image supports these logical sizes: {supported_sizes}."
) )
size = sizes.get(aspect_ratio) listed = match_aspect_ratio(aspect_ratio, sizes or {})
if not size: if listed:
supported = sorted(sizes) return sizes[listed]
raise ValueError( # Any other ratio or tier is fitted to the model's size rules.
f"Unsupported aspect ratio '{aspect_ratio}' for Zhipu backend. " budget = math.prod(map(int, sizes["1:1"].split("x"))) if sizes else TIER_PIXELS[normalized]
f"Supported: {supported}" width, height = fit_size(
aspect_ratio,
pixels=budget,
step=32,
min_edge=512,
max_edge=2048,
max_pixels=2**22,
) )
return size return f"{width}x{height}"
def _generate_image(api_key: str, prompt: str, 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.""" """Generate one image with the Zhipu backend."""
model = _validate_model(model) model = _validate_model(model)
size = _resolve_size(aspect_ratio, image_size) size = _resolve_size(aspect_ratio, image_size)
note_aspect_ratio(aspect_ratio, size.replace("x", ":"))
url = _resolve_url(base_url) url = _resolve_url(base_url)
headers = { headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
@@ -192,9 +204,9 @@ def generate(prompt: str,
if attempt >= max_retries: if attempt >= max_retries:
break break
limited = is_rate_limit_error(exc) 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}" label = "Rate limit hit" if limited else f"Error: {exc}"
print(f"\n [WARN] {label}. Retrying in {delay}s...") print(f"\n [WARN] {label}. Retrying in {delay}s...")
time.sleep(delay) 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", "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"] ALL_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
BACKEND_REGISTRY = { BACKEND_REGISTRY = {
@@ -117,7 +108,7 @@ BACKEND_REGISTRY = {
"module": "backend_openai", "module": "backend_openai",
"tier": "core", "tier": "core",
"label": "OpenAI / OpenAI-compatible", "label": "OpenAI / OpenAI-compatible",
"default_model": "gpt-image-2", "default_model": "gpt-image-2.5-sunburst",
"default_image_size": "1K", "default_image_size": "1K",
"key_hint": "OPENAI_API_KEY", "key_hint": "OPENAI_API_KEY",
"aliases": ["openai-compatible", "openai_compatible"], "aliases": ["openai-compatible", "openai_compatible"],
@@ -384,19 +375,15 @@ def _print_backend_list() -> None:
_print_backend_resolution() _print_backend_resolution()
def _check_backend_aspect_ratio(backend_module, aspect_ratio: str) -> None: def _aspect_ratio_arg(value: str) -> str:
"""Fail before the request when the resolved backend rejects this ratio. """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 try:
may narrow it with ``VALID_ASPECT_RATIOS``. parse_aspect_ratio(value)
""" except ValueError as exc:
valid = getattr(backend_module, "VALID_ASPECT_RATIOS", None) raise argparse.ArgumentTypeError(str(exc)) from exc
if valid and aspect_ratio not in valid: return value
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)}"
)
def _resolve_backend() -> tuple[object, str]: def _resolve_backend() -> tuple[object, str]:
@@ -595,7 +582,7 @@ def load_manifest(path: str) -> dict:
Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`, Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`,
`page_role`, `text_policy`, `slice_grid`, `slice_names`, `last_error`. `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: try:
data = json.loads(Path(path).read_text(encoding="utf-8")) 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"{prefix} status '{item['status']}' is invalid. "
f"Valid: {sorted(VALID_STATUSES)}" f"Valid: {sorted(VALID_STATUSES)}"
) )
if item["aspect_ratio"] not in ALL_ASPECT_RATIOS: try:
raise ValueError( parse_aspect_ratio(item["aspect_ratio"])
f"{prefix} aspect_ratio '{item['aspect_ratio']}' is invalid. " except ValueError as exc:
f"Valid: {ALL_ASPECT_RATIOS}" raise ValueError(f"{prefix} {exc}") from exc
)
if "image_size" in item: if "image_size" in item:
image_size = item["image_size"] image_size = item["image_size"]
if not isinstance(image_size, str) or not image_size.strip(): 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) output_dir = str(manifest_output_dir)
from image_backends.backend_common import ( from image_backends.backend_common import (
MAX_RETRY_WAIT,
is_global_permanent_error, is_global_permanent_error,
is_permanent_error, is_permanent_error,
is_rate_limit_error, is_rate_limit_error,
retry_after_seconds,
validate_image_file, validate_image_file,
) )
@@ -992,7 +980,6 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
def _one(idx: int): def _one(idx: int):
item = items[idx] item = items[idx]
try: try:
_check_backend_aspect_ratio(backend_module, item["aspect_ratio"])
saved_path = backend_module.generate( saved_path = backend_module.generate(
prompt=item["prompt"], prompt=item["prompt"],
aspect_ratio=item["aspect_ratio"], aspect_ratio=item["aspect_ratio"],
@@ -1020,6 +1007,7 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
) )
rate_limited = False rate_limited = False
server_wait = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as ex: with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as ex:
futures = [ex.submit(_one, i) for i in batch_idx] futures = [ex.submit(_one, i) for i in batch_idx]
for fut in concurrent.futures.as_completed(futures): 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): elif is_rate_limit_error(exc):
rate_limited = True rate_limited = True
server_wait = max(server_wait, retry_after_seconds(exc) or 0)
attempts = rate_limit_attempts.get(idx, 0) + 1 attempts = rate_limit_attempts.get(idx, 0) + 1
rate_limit_attempts[idx] = attempts rate_limit_attempts[idx] = attempts
if ( if (
@@ -1095,6 +1084,13 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
"Stopping new batches; untouched items remain retryable.\n" "Stopping new batches; untouched items remain retryable.\n"
) )
break 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: if stopped_for_rate_limit:
print( print(
"\n Persistent rate limit reached the run boundary. " "\n Persistent rate limit reached the run boundary. "
@@ -1103,12 +1099,14 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
break break
if rate_limited and current > 1 and queue: if rate_limited and current > 1 and queue:
new_current = max(1, current // 2) 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( print(
f"\n ⚠ Rate-limit hit — concurrency {current} → {new_current}, " 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 current = new_current
time.sleep(10) time.sleep(pause)
elif queue: elif queue:
time.sleep(2) time.sleep(2)
@@ -1255,8 +1253,11 @@ def main() -> None:
) )
) )
parser.add_argument( parser.add_argument(
"--aspect_ratio", default="1:1", choices=ALL_ASPECT_RATIOS, "--aspect_ratio", default="1:1", type=_aspect_ratio_arg,
help=f"Aspect ratio. Default: 1:1." 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( parser.add_argument(
"--image_size", default=None, "--image_size", default=None,
@@ -1422,7 +1423,10 @@ def main() -> None:
sys.exit(130) sys.exit(130)
md_path = render_manifest_md_to_file(args.manifest, manifest) md_path = render_manifest_md_to_file(args.manifest, manifest)
print(f"Rendered Markdown sidecar: {md_path}") 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 # Single-image mode. Backfill the historical default prompt only here, so
# plain generation is byte-for-byte unchanged while edit mode still requires # plain generation is byte-for-byte unchanged while edit mode still requires
@@ -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")
@@ -17,7 +17,7 @@ if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR)) sys.path.insert(0, str(SCRIPTS_DIR))
import image_gen # noqa: E402 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): class TencentBackendTests(unittest.TestCase):
@@ -103,15 +103,14 @@ class TencentBackendTests(unittest.TestCase):
with self.subTest(ratio=ratio, size=logical_size): with self.subTest(ratio=ratio, size=logical_size):
self.assertEqual(backend_tencent._resolve_size(ratio, logical_size), resolution) self.assertEqual(backend_tencent._resolve_size(ratio, logical_size), resolution)
def test_hy_rejects_impossible_sizes_before_request(self) -> None: def test_hy_refits_out_of_range_sizes(self) -> None:
for ratio, logical_size in ( for ratio, logical_size, resolution in (
("1:1", "2K"), ("1:1", "4K"), ("16:9", "512px"), ("1:1", "2K", "1024x1024"), ("1:1", "4K", "1024x1024"), ("16:9", "512px", "912x512"),
("1:8", "1K"), ("8:1", "1K"), ("1:8", "1K", "512x2048"), ("8:1", "1K", "2048x512"), ("1.85:1", "1K", "1392x752"),
): ):
with self.subTest(ratio=ratio, size=logical_size): 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) backend_tencent.generate("test", aspect_ratio=ratio, image_size=logical_size)
self.post.assert_not_called() self.assertEqual(self.post.call_args.kwargs["json"]["size"], resolution)
def test_seedream_lite_upgrades_small_sizes_once(self) -> None: def test_seedream_lite_upgrades_small_sizes_once(self) -> None:
for logical_size in ("1K", "512px"): 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", "2K", 2048),
("seedream-image-v5.0-lite", "4K", 4096), ("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): with self.subTest(model=model, size=logical_size, ratio=ratio):
size = backend_tencent._resolve_size(ratio, logical_size, model) size = backend_tencent._resolve_size(ratio, logical_size, model)
width, height = map(int, size.split("x")) 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 / 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: def test_extreme_ratios_stay_within_every_model_limit(self) -> None:
for logical_size in ("512px", "4K"): 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.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) backend_tencent.generate("test", model="seedream-image-v5.0-pro", image_size=logical_size)
self.post.assert_not_called() self.assertEqual(self.post.call_args.kwargs["json"]["size"], resolution)
def test_invalid_ratio_and_size_fail_before_request(self) -> None: def test_invalid_ratio_and_size_fail_before_request(self) -> None:
for ratio in ("invalid", "0:1", "1:0", "-1:2", "1:2:3"): for ratio in ("invalid", "0:1", "1:0", "-1:2", "1:2:3"):
with self.subTest(ratio=ratio): 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) backend_tencent.generate("test", aspect_ratio=ratio)
with self.assertRaisesRegex(ValueError, "Unsupported image size"): with self.assertRaisesRegex(ValueError, "Unsupported image size"):
backend_tencent.generate("test", image_size="3K") backend_tencent.generate("test", image_size="3K")
@@ -242,6 +258,7 @@ class TencentBackendTests(unittest.TestCase):
def test_rate_limit_uses_shared_exponential_backoff(self) -> None: def test_rate_limit_uses_shared_exponential_backoff(self) -> None:
limited = Mock(status_code=429, text='{"error": {"code": "ConcurrencyLimit"}}') limited = Mock(status_code=429, text='{"error": {"code": "ConcurrencyLimit"}}')
self.post.side_effect = [limited, limited, self.response] self.post.side_effect = [limited, limited, self.response]
with patch.object(backend_common.random, "randint", return_value=0):
backend_tencent.generate("test") backend_tencent.generate("test")
self.assertEqual(self.post.call_count, 3) self.assertEqual(self.post.call_count, 3)
self.assertEqual([call.args[0] for call in self.sleep.call_args_list], [10, 20]) self.assertEqual([call.args[0] for call in self.sleep.call_args_list], [10, 20])
@@ -50,3 +50,97 @@ class ManifestOutputNameTests(unittest.TestCase):
self.assertEqual(first.getpixel((0, 0)), (255, 0, 0)) self.assertEqual(first.getpixel((0, 0)), (255, 0, 0))
self.assertEqual(second.getpixel((0, 0)), (0, 0, 255)) 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", "sourceId": "pptx",
"repo": "https://github.com/anthropics/skills.git", "repo": "https://github.com/anthropics/skills.git",
"ref": "main", "ref": "main",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", "commit": "ca45fc496a3436251f191354091d557da31c15b5",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/pptx", "sourcePath": "skills/pptx",
"syncedAt": "2026-10-10T16:00:00Z" "syncedAt": "2026-10-29T16:00:00Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "xlsx", "sourceId": "xlsx",
"repo": "https://github.com/anthropics/skills.git", "repo": "https://github.com/anthropics/skills.git",
"ref": "main", "ref": "main",
"commit": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", "commit": "ca45fc496a3436251f191354091d557da31c15b5",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/xlsx", "sourcePath": "skills/xlsx",
"syncedAt": "2026-10-10T16:00:00Z" "syncedAt": "2026-10-29T16:00:00Z"
} }