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