.clawhubignore
.DS_Store .venv/ __pycache__/ *.pyc *.pyo *.pyd .pytest_cache/ .mypy_cache/ .env output/ temp/ test_output/ examples/ *.png *.jpg *.jpeg *.gif *.bmp *.pptx *.pdf
ningzimu/codex-ppt-skill · GitHub
Generate visually unified image-based PPT/PPTX decks from articles, reports, papers, notes, or outlines.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add ningzimu/codex-ppt-skill --skill codex-ppt설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
.clawhubignore.DS_Store .venv/ __pycache__/ *.pyc *.pyo *.pyd .pytest_cache/ .mypy_cache/ .env output/ temp/ test_output/ examples/ *.png *.jpg *.jpeg *.gif *.bmp *.pptx *.pdf
docs/backend-selection.md# Backend Selection Read this before confirming the image backend or generating the first sample slide. This skill supports two image backends: 1. Built-in image tool, preferred when available. Example tool names: Codex `image_gen`; OpenClaw `image_generate`. 2. Local API/CLI fallback, using `scripts/image_gen.py`. ## Decision Rules - Before recommending CLI/API fallback, actively check whether the built-in image generation tool is callable in the current environment. Do not infer availability only from the agent name or subscription context. - Prefer the built-in image tool when available. In Codex, this usually means the built-in `image_gen` tool. In OpenClaw, this may be `image_generate`. Resolution, quality, aspect ratio, or slide-edit requests alone do not require CLI/API fallback. Check which parameters the current tool actually exposes. - Do not identify the built-in tool as a specific GPT Image model unless the environment provides evidence. If exact model, size, or quality selection is required but the tool does not expose it, explain the limitation and select a compatible API/CLI fallback with the user. Successful image generation alone does not verify the actual model or requested parameters. - Use CLI/API fallback only when the built-in tool is unavailable, the built-in tool failed for a required capability, the user explicitly asks for API/CLI or a third-party image API/provider adapter, or the requested capability is unavailable in the built-in tool. - Do not recommend CLI/API fallback merely because it provides direct `--out` file paths, easier local file management, local config reuse, batch generation convenience, or simpler automation. - Before generating the first image, tell the user which tool availability you checked, which backend you plan to use, why fallback is or is not needed, and ask for confirmation. Do not treat being in a specific agent environment as proof that the built-in image tool is available. - CLI/API fallback loads `~/.codex-ppt-skill/.env` automatically. Run the CLI normally; do not manually parse `.env` or ask for configuration before an error. - Ask for `OPENAI_API_KEY` configuration only after you have intentionally selected CLI/API fallback and that fallback reports missing config, after authentication/base URL/model errors, or when the user explicitly wants to change API settings. Do not mention missing `OPENAI_API_KEY` while the Codex built-in image tool is available. Configure provided values with `scripts/codex_ppt_runtime.py config --api-key`. If CLI/API fallback is selected, read `cli-api-fallback.md` before generating images. For API key, base URL, model, and `.env` configuration, read `image-model-configuration.md` only after the fallback CLI reports missing or invalid configuration, or when the user explicitly wants to change those settings. ## Confirmation Text Built-in backend: ```text 我检查到当前环境可调用内置图片生成工具(Codex 通常是 image_gen,OpenClaw 通常是 image_generate),因此准备优先用内置工具生成样张,不切到本地 API/CLI fallback。可以开始生成 1 页样张吗? ``` CLI/API fallback: ```text 我检查后没有可用的内置图片生成工具,或内置工具缺少本页必需能力,因此准备使用本地 API/CLI fallback 生成样张,读取 ~/.codex-ppt-skill/.env 中的 OPENAI_BASE_URL / CODEX_PPT_IMAGE_MODEL 配置。可以开始生成 1 页样张吗? ``` Wait for confirmation before generating the sample slide. If the user questions the backend, resolve that before continuing.
docs/cli-api-fallback.md# CLI/API Fallback
Use this reference only after CLI/API fallback has been selected and confirmed with the user. The main `SKILL.md` owns the backend decision rules; this document owns fallback commands, runtime setup, image-input limits, editing, transparency, and troubleshooting.
Let `{skill_root}` mean the directory containing `SKILL.md`.
## Runtime Setup
CLI/API fallback commands use the shared runtime environment. Before running `scripts/assemble_ppt.py` or fallback image commands, make sure the shared runtime exists. If `~/.codex-ppt-skill/.venv/bin/python` is missing, or if importing script dependencies fails, create or refresh the environment:
```bash
python3 {skill_root}/scripts/codex_ppt_runtime.py bootstrap
```
This is an internal setup step for the skill. Do not ask the user to run it unless dependency installation fails and user approval or troubleshooting is required.
The fallback CLI loads `~/.codex-ppt-skill/.env` automatically for `OPENAI_API_KEY`, `OPENAI_BASE_URL`, and `CODEX_PPT_IMAGE_MODEL`. Do not manually parse `.env`. For API key, base URL, model, and config troubleshooting, read `image-model-configuration.md` only after the fallback CLI reports missing or invalid configuration, when the user explicitly wants to change those settings, or when a real API call reports authentication, permission, base URL, or model availability failure.
## Generate One Slide
Basic generation command:
```bash
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/image_gen.py generate \
--model gpt-image-2.5-flare \
--prompt-file {prompt_file} \
--size 2560x1440 \
--quality medium \
--out {base_dir}/{deck_name}/origin_image/slide_01.png
```
The fallback CLI defaults to `gpt-image-2.5-flare`. Select `--model gpt-image-2.5-sunburst` for Sunburst. It also accepts provider-prefixed names and older GPT Image models; verify provider support before using a model.
When generating from saved `prompts/slide_XX.json` files, use the job's `prompt` field only when the job does not require input images:
```bash
python3 -c 'import json, pathlib; print(json.loads(pathlib.Path("{base_dir}/{deck_name}/prompts/slide_01.json").read_text())["prompt"])' | \
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/image_gen.py generate \
--prompt-file - \
--size 2560x1440 \
--quality medium \
--out {base_dir}/{deck_name}/origin_image/slide_01.png
```
Before using this text-only `generate` path, inspect the assigned `prompts/slide_XX.json`. If `input_images` is non-empty or `requires_context_images` is true, this command is not sufficient because it does not attach those images. Use a selected backend/path that can pass the required images, such as the built-in image tool with the images visible in context or a CLI/API edit/image-input path that supplies every required source image. If no such path is available, stop and ask the user whether to switch backend. Do not generate a text-only replacement for a strict input asset.
## Capabilities And Sizes
The fallback CLI supports:
- `generate`: create one or more images from a prompt.
- `edit`: edit one or more existing images, optionally with a mask.
The fallback CLI defaults to 2K 16:9 landscape output, `2560x1440`, with `medium` quality. GPT Image 2.5 also supports `xhigh` and `max`; older models retain their existing quality limits. For 4K landscape slides, use `--size 3840x2160 --quality high` only when the user asks for 4K, text-heavy slides need sharper output, or the default result is blurry. For portrait assets, use `--size 2160x3840` only if the user requests portrait output. For GPT Image 2.5, output above `2560x1440` pixels is experimental; inspect the actual size and visual quality.
## Editing Slides
If a slide is mostly correct but has a localized issue, use the selected backend's edit capability when available. In CLI/API fallback mode:
```bash
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/image_gen.py edit \
--image {slide_path} \
--prompt {edit_prompt} \
--out {new_slide_path}
```
Replace the final slide only after validating the edited output.
## Transparent Backgrounds
Transparent-background requests:
- GPT Image 2.5 Flare and Sunburst support `--background transparent --output-format png` (or `webp`) through compatible APIs. JPEG cannot preserve transparency. The current AtlasCloud adapter does not forward `background` and accepts only PNG/JPEG; do not promise native transparency through that adapter.
- `gpt-image-2` does not support native transparent backgrounds; GPT Image 1 / 1.5 do. Preserve the selected model unless a switch has been agreed.
- In built-in mode, use native transparency if the tool supports it; otherwise use a flat chroma-key background and `scripts/remove_chroma_key.py` when appropriate.
## Assembly And Doctor
`assemble_ppt.py` supports `16:9` and `4:3`. Use `16:9` unless the user requests otherwise.
Run the API doctor only when troubleshooting fallback API access:
```bash
python3 {skill_root}/scripts/codex_ppt_runtime.py doctor --check-api
```
docs/image-model-configuration.md# Image Model Configuration
Use this reference only when the local API/CLI fallback is needed and the runtime config is missing or must be changed.
Do not manually parse `.env`. The fallback CLI loads the shared config automatically. Run the fallback command first, then use this document only if the CLI reports missing or invalid configuration.
Ask the user to configure or update settings only when:
- The fallback CLI reports missing `OPENAI_API_KEY`.
- The user explicitly wants to change API key, base URL, or model.
- A real API call fails with authentication, permission, base URL, or model-not-found errors.
## When Configuration Is Needed
Configure image API access only for API/CLI fallback image generation.
Typical cases:
- Codex is using a third-party API or OpenAI-compatible proxy for image generation.
- The skill is being used from Claude Code, OpenClaw, Hermes Agent, or another agent without Codex's built-in image tool.
If Codex is being used through a GPT subscription and the built-in image tool is available, do not ask the user to configure `gpt-image-2.5-flare`.
## Required And Optional Values
- `OPENAI_API_KEY` is required for real API/CLI fallback calls.
- `OPENAI_BASE_URL` is optional. When it is unset, the CLI uses the official OpenAI API. When it is set, the CLI uses the configured third-party provider base URL.
- `CODEX_PPT_IMAGE_MODEL` is optional. The default is `gpt-image-2.5-flare`. Use `gpt-image-2.5-sunburst` to select Sunburst, or a model name supported by the provider.
Configure provided API settings with `scripts/codex_ppt_runtime.py config --api-key`. The config command writes `~/.codex-ppt-skill/.env`.
## Official OpenAI Example
```bash
python3 {skill_root}/scripts/codex_ppt_runtime.py config \
--api-key "your-api-key" \
--model gpt-image-2.5-flare
```
## OpenAI-Compatible Provider Example
Use this shape for providers that implement the OpenAI Images API paths used by the fallback CLI.
```bash
python3 {skill_root}/scripts/codex_ppt_runtime.py config \
--api-key "your-provider-api-key" \
--base-url "https://xxxx.example.com/v1" \
--model gpt-image-2.5-flare
```
This produces the same effective runtime config as:
```env
OPENAI_API_KEY=your-provider-api-key
OPENAI_BASE_URL=https://xxxx.example.com/v1
CODEX_PPT_IMAGE_MODEL=gpt-image-2.5-flare
```
For OpenAI-compatible providers, `OPENAI_BASE_URL` should normally end at the provider's `/v1` root. Do not set it to `/images/generations`, `/images/edits`, or another terminal endpoint. The fallback CLI appends the image-generation or image-edit path through the OpenAI SDK.
Use the provider's model name only when the provider documents a custom name. Otherwise prefer `gpt-image-2.5-flare`.
## AtlasCloud Example
For AtlasCloud, set `--model` to the base model name. The CLI chooses the matching generation or editing model route internally. This example retains the known `gpt-image-2` route; check provider documentation before selecting a 2.5 model.
```bash
python3 {skill_root}/scripts/codex_ppt_runtime.py config \
--api-key "your-atlascloud-api-key" \
--base-url "https://api.atlascloud.ai/api/v1/model" \
--model openai/gpt-image-2
```
## Runtime Config
The config is written to:
```text
~/.codex-ppt-skill/.env
```
The file is created with mode `0600`. It is shared by Codex, Claude Code, OpenClaw, Hermes Agent, and other local agents.
Process environment variables override `.env` values. A command-line `--model` overrides `CODEX_PPT_IMAGE_MODEL` for that single command.
docs/outline-style-and-sample.md# Outline, Style, And Sample
Read this before writing or updating `outline.md`, offering visual styles, using files from `references/`, or generating/approving the sample slide.
If the user asks to save a finished deck style or a user-supplied image/PDF/PPT/PPTX style for future reuse, read `style-library.md`.
## Plan The Deck Outline
Create a concise `outline.md` draft before generating images. For each slide, define:
- Slide number
- Slide title
- 3-5 key points
- Optional visual idea
- Layout role and intent, such as cover, agenda, section divider, concept explanation, process, comparison, timeline, data evidence, architecture, case study, summary, or Q&A
- Required source images, if any, including the image path or attachment name, its role on the slide, and whether it is a strict input asset or only a style/layout reference
Save the draft to `{base_dir}/{deck_name}/outline.md` once the project directory is known. If the output directory is not known yet, show the outline in chat first and write it to `outline.md` immediately after creating the project directory.
Show the outline to the user for confirmation and wait for approval before moving to visual style selection or image generation, unless the user explicitly asked you to skip confirmation. If any slide lists required source images, explicitly ask the user to verify that each image is assigned to the correct slide and role before generation. If the user requests changes, update `outline.md` and ask for confirmation again.
Stop after writing the outline draft. At this point, report the `outline.md` path, slide count, required source images and their slide mapping, and that no slide images or PPTX have been generated yet. Do not proceed to `deck_spec.json`, `speech.md`, prompt preparation, style selection, backend selection, or sample generation until the user approves the outline.
If the user approved a sample slide, record that approved `slide_XX.png` path as the deck-level style reference. Later slide prompts and subagent handoffs should include it as a style-only reference so each page keeps the same palette, typography mood, density, texture, and visual identity without copying the sample's exact layout.
Recommended structure:
```text
Slide 1: Cover
Slide 2: Context / problem
Slide 3-7: Main argument or sections
Slide 8: Summary / recommendation / closing
```
For slides that use source images, add lines like:
```markdown
Slide 5: Experiment Results
- Key points: ...
- Required images:
- Main evidence figure; strict input asset; preserve data, axes, labels, legends, colors, and values

- Supporting model architecture; strict input asset; preserve labels and arrows

```
Use Markdown image syntax inside the `Required images` list whenever the asset is local and renderable in the outline. This lets the user visually verify the exact asset mapping during outline review. Keep the descriptive text next to each image so `prepare_slide_prompts.py` can convert the same asset into structured prompt input later.
## Confirm A Unified Visual Style
Before generating slide images, discuss the visual style with the user unless the user has already provided a clear style direction or reference material.
If the user has already specified a style, provided a style image, or provided a PDF/PPT/PPTX to use as style reference, do not force a 2-3 option style selection. Extract the usable style rules, briefly restate them, then proceed to backend confirmation and sample generation.
For PDF/PPT/PPTX style references, do not infer the visual system from document structure, outline text, XML, file metadata, or slide object hierarchy alone. First render or export representative pages/slides into real page images, inspect those rendered images, and derive the style from what is actually visible on the pages. If the file has multiple visual sections, inspect enough representative pages to capture the shared style and any section-specific variations.
When extracting style from reference material, separate content reuse from style reuse. Unless the user explicitly asks to reuse the source content, treat the provided image/PDF/PPT/PPTX as a style reference only.
If the user has not provided a clear style, prefer a multiple-choice question: offer 2-3 concrete style directions and mark one as your recommendation. Each style option should briefly specify:
- Color palette
- Layout system
- Typography direction
- Illustration or image treatment
- Decorative elements
- Density and whitespace rules
After the user chooses a style, create one final style direction and keep the visual identity consistent across all slide prompts. Keep color palette, typography, texture, icon/illustration language, and overall mood stable. Do not reuse the same layout on every page.
Reusable style references come from two locations:
- Built-in styles: the skill's `references/` directory, listed below. They ship with the skill and update with it.
- User custom styles: `${CODEX_PPT_HOME:-~/.codex-ppt-skill}/references/*.md`. They live outside the skill install so they survive skill updates and reinstalls.
Before offering or using reusable styles, list the user custom style directory (if it exists) and merge its `*.md` files with the built-in list below. User custom styles are discovered by scanning that directory; they are never registered in this document. If a user custom style has the same filename as a built-in style, the user custom file takes priority and replaces the built-in one.
Use style references as inspiration, not as rigid templates. Adapt the style to the topic and audience.
Important: a deck should have one coherent visual identity, not one repeated composition. Treat each reference as a style system: stable palette, typography, icon language, texture, and visual mood; variable page layout chosen from the slide's content role. `layout_blueprints` are candidate starting points only. Do not apply the same blueprint to every slide.
Available built-in references:
- `references/清爽专业风.md`
- `references/创意杂志风.md`
- `references/电子墨水杂志风.md`
- `references/数据仪表盘风.md`
- `references/科研答辩风.md`
- `references/复古扁平插画风.md`
- `references/手绘技术解释风.md`
- `references/手绘白板风.md`
- `references/温暖手工风.md`
- `references/麦肯锡风格.md`
- `references/党政红风格.md`
- `references/教学课件风.md`
This list only tracks built-in styles shipped with the skill. Do not add user custom styles here; they are saved to `${CODEX_PPT_HOME:-~/.codex-ppt-skill}/references/` via `docs/style-library.md` and discovered by directory scan, so they need no registration.
Example style confirmation:
```text
我建议用 A,因为它最适合这份内容的受众和表达目标。
A. 清爽专业风(推荐):浅色背景、蓝绿强调色、结构清晰,适合汇报、答辩和技术分享。
B. 创意杂志风:大标题、强图片、留白更大胆,适合分享和传播。
C. 数据仪表盘风:指标卡、图表感布局,适合数据密集型报告。
你选哪个?也可以指定要调整的配色、布局或插画方向,或者上传一张喜欢的 PPT 风格图片让我参考。
```
## Generate One Sample Slide For Approval
After the outline, style, and image backend are confirmed, generate exactly one sample slide image before full production.
Sample slide requirements:
- Use the confirmed style description.
- Prefer a representative content slide over the cover when possible.
- Demonstrate the intended deck rhythm: the sample should show how the chosen style adapts to a real content page, not just a generic fixed template.
- Save it directly as the intended final slide filename, such as `{base_dir}/{deck_name}/origin_image/slide_08.png`. In CLI/API fallback mode, use `scripts/image_gen.py generate --out` for that exact path.
- Show the sample image to the user.
- Ask the user to confirm the visual style, typography, layout density, and Chinese text quality.
Do not generate the full deck until the user approves the sample slide. If the user requests changes, revise the style description and regenerate that same `slide_XX.png` file first. Once approved, keep that file as the final slide for its page. Do not create `sample_slide.png` in `origin_image/`, because the assembly step is designed around final `slide_XX` filenames.
After the sample slide is approved, record the sample generation method in `deck_spec.json` before preparing full-deck jobs. This is the contract the parent passes to subagents so they use the same image-generation path as the sample, not a cheaper local rendering path. Include at least:
- `backend_used`: the confirmed backend label, such as `built-in image tool` or `scripts/image_gen.py`.
- `tool_name`: the actual tool or command used, such as `image_gen`, `image_generate`, or `scripts/image_gen.py`.
- `mode`: `generate` or `edit`.
- `prompt_source`: where the approved sample prompt came from.
- `size`, `quality`, and model/config details when the backend exposes them.
- `approved_sample_path`: the approved `origin_image/slide_XX.png` path.
- `input_context_preparation`: how local source/style images were made available, such as `view_image` for built-in mode.
- `handoff_rule`: subagents must use the same backend/tool/mode and return a blocker if that path is unavailable.
docs/project-assembly-and-reporting.md# Project Assembly And Reporting
Read this before initializing the project directory, writing speaker notes, assembling the PPT, or sending the final report.
## Project Directory
Use this output structure:
```text
{base_dir}/{deck_name}/
├── origin_image/
│ ├── slide_01.png
│ ├── slide_02.png
│ └── ...
├── prompts/
│ ├── slide_01.json
│ └── ...
├── slide_jobs.json
├── slide_run_state.json
├── deck_spec.json
├── outline.md
├── speech.md
└── {deck_name}.pptx
```
If the user did not specify a destination, use the current working directory or the directory that contains the source file.
You may initialize the directory structure with:
```bash
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/assemble_ppt.py {base_dir} {deck_name}.pptx --init
```
## Quality Check And Repair
Before assembling the PPT, inspect every slide image. Check:
- Text is readable and not garbled.
- Slide content matches the outline.
- Title and key points are not truncated.
- Visual style is consistent across slides.
- No page number appears unless the user requested one.
- Important elements do not overlap.
If a slide has severe text or layout issues, regenerate it with a more constrained prompt. If a slide is mostly correct but has a localized issue, use the selected backend's edit capability when available. In CLI/API fallback mode, use `scripts/image_gen.py edit --image {slide_path} --prompt ... --out {new_slide_path}` and replace the final slide only after validating the edited output.
## Speaker Notes
Make sure `outline.md` reflects the final confirmed deck outline. Do not recreate it from scratch here.
Create `speech.md` as presenter notes that a speaker can use directly. Do not write a brief summary of visible slide text. Write in the presentation language; for Chinese decks, speaker notes should be in Chinese.
For each slide, write only the spoken talk track directly under the slide heading, without an extra label. It is the script the presenter can read or closely follow. It should connect the slide to the deck's main story, explain the point the audience should take away, and include a natural transition at the end when useful.
Before writing the slide notes, choose a delivery style based on the deck content, audience, and purpose. The delivery style is not a label added after writing; it should shape the actual talk track, including how direct the claim is, how much background is explained, which examples are used, how quickly the speaker moves, and how transitions are phrased.
Common delivery styles:
- Technical explainer: patient, definition-first, example-driven, with careful visual walkthroughs.
- Research or paper reading: evidence-led, method/result/limitation oriented, with clear claims about what the audience should learn from each figure.
- Product or pitch deck: outcome-first, persuasive, focused on user pain, value, proof, and the next action.
- Training or workshop: step-by-step, checkpoint-driven, with small prompts for audience reflection or practice.
- Executive report: conclusion-first, concise, focused on decisions, risks, tradeoffs, and recommended actions.
Keep one deck-level delivery style consistent, but adapt the tone by slide role. For example, an opening slide can be more framing-oriented, a dense diagram slide can slow down for explanation, and a closing slide can become more action-oriented.
Length guidance:
- Title, agenda, and section-divider slides can be 1-2 short paragraphs.
- Normal content slides should usually be 2-5 short paragraphs, or roughly 150-400 Chinese characters for Chinese decks.
- Dense concept, architecture, data, or paper-explanation slides may need more, but split long material if the audience would lose the thread.
Use basic presentation craft in the talk track:
- Lead with the claim before the details.
- Explain visuals in the order the audience should look at them.
- Add examples, contrast, caveats, and "so what" implications instead of rereading the slide.
- Close with a natural bridge to the next slide when useful.
Write the talk track from the presenter's point of view, facing the audience. Avoid generic AI-style phrasing, canned summaries, and phrases that sound detached from the actual talk. The script should sound like a person explaining this specific deck in the room:
- Use natural first-person or speaker-facing phrasing when appropriate, such as "这里我想强调的是..." or "我们先看左边这个结构...".
- Ground each paragraph in the current slide's content and the surrounding deck narrative.
- Prefer concrete explanations, examples, and audience-oriented transitions over broad filler like "本页主要介绍了..." or "综上所述...".
- Do not mention that the notes were generated, inferred, or prepared by an AI.
Use headings that the assembly script can map back to slide numbers:
```markdown
## Slide 1: {Title}
{Presenter talk track for slide 1. For Chinese decks, write this in Chinese. Include an optional transition sentence at the end when useful.}
## Slide 2: {Title}
{Presenter talk track for slide 2}
```
## Assembly
Before running `scripts/assemble_ppt.py` or the CLI/API fallback scripts, make sure the shared runtime exists. If `~/.codex-ppt-skill/.venv/bin/python` is missing, or if importing script dependencies fails, create or refresh the environment:
```bash
python3 {skill_root}/scripts/codex_ppt_runtime.py bootstrap
```
This is an internal setup step for the skill. Do not ask the user to run these commands unless dependency installation fails and user approval or troubleshooting is required.
Run:
```bash
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/assemble_ppt.py {base_dir} {deck_name}.pptx --aspect-ratio 16:9
```
Important:
- `{base_dir}` is the parent directory of `{deck_name}/`.
- `{deck_name}.pptx` must match the project folder name.
- The script reads images from `{base_dir}/{deck_name}/origin_image/`.
- The script only reads final images named like `slide_01.png`, `slide_02.png`, etc.; drafts and sample files are ignored.
- Before running assembly, `slide_jobs.json` should show every generated slide as `recorded` and every approved sample slide as `accepted`. If any slide is `pending`, `dispatched`, or `blocked`, stop and report that state.
- If `{base_dir}/{deck_name}/speech.md` exists and uses `Slide N` headings, the script writes those notes into the corresponding PPT speaker notes.
- The script writes `{base_dir}/{deck_name}/{deck_name}.pptx`.
`assemble_ppt.py` supports `16:9` and `4:3`. Use `16:9` unless the user requests otherwise. `image_gen.py` loads `~/.codex-ppt-skill/.env` automatically for `OPENAI_API_KEY`, `OPENAI_BASE_URL`, and `CODEX_PPT_IMAGE_MODEL`. Run `python3 {skill_root}/scripts/codex_ppt_runtime.py doctor --check-api` when troubleshooting API access.
## Final Report
Report:
- Project directory
- PPT file path
- Slide image directory
- `outline.md` path
- `speech.md` path
- `slide_jobs.json` path
- Number of slides
- Confirm which image backend was used and that every non-sample slide result was recorded with `record_slide_result.py`.
- Confirm that speaker notes from `speech.md` were written into the PPT, if applicable
- Any slides that were regenerated, blocked, or still have known limitations
- If the deck's style is custom or noticeably adapted (extracted from user references, tuned during sampling, or otherwise not an unmodified built-in style), end with a one-sentence tip that the style can be saved to the personal style library for future reuse, for example: "如果你喜欢这套风格,可以说「保存这个风格」,我会把它存入个人风格库(`~/.codex-ppt-skill/references/`),以后可以直接复用,更新 skill 也不会丢失。" If the user agrees, read `docs/style-library.md`. Skip this tip when the deck used an unmodified built-in style.
## Prompting Principles
- Keep one global visual style fixed across the deck.
- Vary slide composition by page role; style consistency does not mean repeating the same layout.
- Use `layout_blueprints` as candidate patterns, not mandatory templates.
- Generate one slide per image request.
- Prefer concrete visual direction over generic words like "beautiful" or "professional".
- For dense content, split across more slides instead of crowding one slide.
- Prioritize clarity over decoration.
docs/slide-generation-and-subagents.md# Slide Generation And Subagents
Read this before full-deck image generation, preparing slide jobs, dispatching subagents, recording results, or handling blockers.
## Final Slide Image Generation
Generate one image per slide with the selected image backend. Every final `slide_XX.png` must be produced by the built-in image tool or by `scripts/image_gen.py`; programmatic rendering or hybrid text overlay is not acceptable for slide image creation.
After the outline, visual style, image backend, and sample slide have all been approved, create final downstream artifacts if they do not already exist:
- `deck_spec.json`
- `prompts/slide_XX.json`
- `speech.md`
Do not create these final downstream artifacts before outline approval. If the user explicitly asks for pre-approval planning files, use `.draft.` filenames and synchronize them after approval.
`deck_spec.json` must include `sample_generation_method` copied from the approved sample before `prepare_slide_prompts.py` is run. The helper copies that method into each `prompts/slide_XX.json` and into `slide_jobs.json`, so workers can see the exact backend, tool, mode, image context preparation, and output constraints used for the approved sample.
Before full production, create structured per-slide image jobs. Prefer the bundled deterministic helper:
```bash
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/prepare_slide_prompts.py \
--spec {base_dir}/{deck_name}/deck_spec.json \
--out-dir {base_dir}/{deck_name} \
--selected-backend "<confirmed backend label>" \
--force
```
The helper writes:
```text
{base_dir}/{deck_name}/
├── prompts/
│ ├── slide_01.json
│ ├── slide_02.json
│ └── ...
├── slide_jobs.json
└── slide_run_state.json
```
Each `prompts/slide_XX.json` is a self-contained slide job. It includes the slide number, title, output filename, input image list, whether context images are required, and the full prompt text. Use these JSON job files for built-in image generation, CLI/API fallback coordination, and subagent handoff. Do not create a separate job manifest unless the user explicitly asks for one.
The parent agent is responsible for packaging context before dispatch. A slide subagent only sees its assigned single-slide job, the images explicitly passed to it, and the handoff text. Do not assume the subagent knows the source article, full outline, previous slides, later slides, or any concept held only in the parent agent's conversation context.
For any slide that depends on cross-slide or source-wide meaning, write the necessary background directly into `deck_spec.json` before preparing prompts:
- Use deck-level `deck_context` for canonical concepts that multiple slides may need, such as the source summary, core claim, term list, taxonomy, characters, definitions, chronology, or required naming.
- Use slide-level `local_context` for page-specific facts that must be visible to that one worker, such as "summarize these six traits", "compare these two methods", "continue this three-step framework", or "use this exact quote".
- Expand references like "the six traits", "the above framework", "the previous conclusion", or "these examples" into explicit lists or definitions inside `deck_context` or `local_context`. Avoid leaving them as implicit pointers.
The goal is not to make subagents validate missing context. The goal is for the parent agent to hand each worker a complete enough task packet so the worker can simply execute the assigned page.
`slide_jobs.json` is the dispatch state file. It records each slide's prompt job, final output path, status, selected backend, sample generation method, subagent dispatch metadata, result provenance, and blocker state. Do not hand-edit slide statuses; use the bundled status scripts.
`deck_spec.json` may express `required_images` either as structured objects or as Markdown image reference strings. The helper extracts the image path from strings such as `strict input asset\n\n` and carries the surrounding text / alt text into the image role.
Use a structured visual brief for each slide. Image generation works best when the prompt separates canvas, style, layout, text, visual elements, and constraints instead of relying only on a long style paragraph.
Keep the deck visually coherent but vary slide layouts according to page semantics. Treat style references and `layout_blueprints` as candidate patterns, not fixed templates. Across a normal deck, deliberately mix suitable page types such as:
- cover / section divider
- context or problem framing
- process or timeline
- comparison or tradeoff
- data / evidence / KPI
- architecture or workflow diagram
- summary / conclusion / next steps
Avoid generating every slide as the same three-card layout. For each slide, choose a layout that fits its content and explain that choice in the `layout.intent` field.
```json
{
"type": "16:9 full-slide PowerPoint image",
"language": "Chinese",
"canvas": {
"aspect_ratio": "16:9",
"use_full_canvas": true,
"slide_number": "do not render a slide number"
},
"style": {
"name": "{confirmed style name}",
"visual_direction": "{same final style description for every slide}",
"color_palette": "{main colors and accent colors}",
"typography": "{font personality, hierarchy, weight, text alignment}",
"texture_and_finish": "{flat, paper, dashboard, editorial, whiteboard, etc.}",
"deck_consistency": "same palette, typography, icon language, texture, and mood across all slides"
},
"deck_context": {
"source_summary": "{brief source-wide summary}",
"core_claim": "{the deck's central thesis}",
"canonical_terms": ["{term 1}", "{term 2}", "{term 3}"]
},
"layout": {
"role": "{cover, agenda, section divider, concept, process, comparison, timeline, data evidence, architecture, case study, summary, Q&A, etc.}",
"intent": "{why this page uses this layout: cover, comparison, timeline, data evidence, workflow, summary, etc.}",
"composition": "{specific layout for this slide}",
"content_zones": "{title zone, body zone, visual zone, footer or callout zones}",
"variation_rule": "same style identity as the deck, but vary composition by slide role; do not repeat the same blueprint on adjacent slides unless the content is part of a deliberate repeated sequence",
"relationship_to_previous_slide": "{new layout, continuation layout, mirrored layout, or deliberate repeated sequence}",
"spacing": "clear hierarchy, coherent alignment, no overlapping elements"
},
"text": {
"title": "{slide title}",
"key_points": ["{point 1}", "{point 2}", "{point 3}"],
"text_quality": "render all Chinese text exactly, clearly, and without garbled characters"
},
"local_context": {
"required_background": "{facts, lists, definitions, comparisons, or prior-slide references this slide needs to be self-contained}"
},
"visual_elements": {
"main_visual": "{icons, diagram, chart, illustration, dashboard cards, collage, or other content-specific visual idea}",
"supporting_elements": "{arrows, cards, callouts, decorative elements, labels}"
},
"constraints": [
"The final image itself must contain the title and key points.",
"All text must be readable and correctly spelled.",
"Keep the confirmed style consistent with the rest of the deck.",
"No watermark, no unrelated logo, no extra slide number."
]
}
```
If preparing prompts manually instead of using `prepare_slide_prompts.py`, still save each full slide job under `{base_dir}/{deck_name}/prompts/slide_XX.json` before generation. The saved job must include `prompt`, `out`, and `input_images`, including any deck-level approved sample slide style reference and all slide-level source images with explicit role labels.
## Parallel Slide Generation With Subagents
After the user approves the sample slide and full-deck generation is authorized, slide subagents are mandatory whenever the current runtime can spawn them. Use one subagent per remaining slide image job. Do not generate the remaining deck sequentially merely for convenience. If subagents cannot be spawned, stop at the dispatch step and report a blocker instead of producing a lower-quality sequential deck.
Use the slide state scripts as the dispatch contract: the main agent spawns workers, then records dispatch and result state. A slide is not considered dispatched or complete until the relevant script records it.
Parent agent responsibilities:
- Own `outline.md`, `deck_spec.json`, `prompts/`, `origin_image/`, QA, `speech.md`, and final PPT assembly.
- Run `prepare_slide_prompts.py` or otherwise write full per-slide JSON jobs and `slide_jobs.json` before delegation.
- Run `slide_job_status.py` to see dispatch slots and pending slide ids before each batch.
- Ensure the approved sample slide is included in every non-sample job as a style-only input image when available.
- Ensure every dispatched slide job is self-contained. If a slide summarizes, compares, continues, or refers to deck-wide concepts, put the required concepts into `deck_context` or the slide's `local_context` before running `prepare_slide_prompts.py`.
- Ensure `sample_generation_method` is present in `deck_spec.json`, every `prompts/slide_XX.json`, and `slide_jobs.json`; it must describe the exact backend/tool/mode used to generate the approved sample.
- If the approved sample slide already exists and should not be regenerated, mark that slide in `deck_spec.json` with `sample_approved: true` or `approved_sample: true` before running `prepare_slide_prompts.py`; the helper records it as `accepted` when the final image file exists.
- In built-in `image_gen` mode, ensure every slide-level required local source image has already been inspected with `view_image` before any delegated job that depends on it.
- In CLI/API fallback mode, ensure each JSON job lists the required source images and that the selected CLI path can use them; if the CLI path cannot attach input images for a slide, do not delegate that slide as a text-only replacement for the asset.
- Spawn subagents with exactly one slide job each, up to `dispatch_slots_available`.
- Immediately after each successful spawn, run `record_slide_dispatch.py` with the real agent id and prompt path.
- After each worker returns, visually check its selected output, then run `record_slide_result.py` to copy the selected generated image into `origin_image/slide_XX.png` and record backend provenance.
- If a worker cannot use the selected image backend or cannot access required input images, run `record_slide_blocker.py` and report the blocker.
Subagent responsibilities:
- Read exactly the assigned `prompts/slide_XX.json`.
- Use the selected image backend only; do not switch between built-in image generation and CLI/API fallback.
- Follow the `sample_generation_method` from the assigned job. Use the same tool family, generation/edit mode, image context preparation, and model/config details that produced the approved sample.
- Generate the final slide candidate by calling the selected image generation backend. Do not create final slide images with local drawing, HTML/SVG/canvas screenshots, Pillow, python-pptx/PptxGenJS layouts, or manually composited text/image overlays.
- Treat the approved sample slide as style reference only.
- Treat any required source images as strict input assets and preserve their content according to the prompt.
- Inspect the generated candidate for text quality, style consistency, required-image inclusion, and layout issues before returning it.
- Return only the selected original generated image path, the backend used, and a one-sentence QA note.
Subagents must not edit `outline.md`, `deck_spec.json`, other slide job files, `origin_image/`, `speech.md`, or the final `.pptx`. The parent agent alone records selected outputs and performs final assembly.
Do not continue sequentially after the sample if subagents are part of the confirmed full-generation workflow and cannot be used. Stop and report the blocker, unless the user explicitly changes the requirement.
Dispatch loop:
```bash
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/slide_job_status.py \
{base_dir}/{deck_name}
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/record_slide_dispatch.py \
{base_dir}/{deck_name} \
--slide slide_02 \
--agent-id <agent id> \
--agent-nickname "<nickname if available>" \
--prompt-file prompts/slide_02.json
```
Result recording:
```bash
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/record_slide_result.py \
{base_dir}/{deck_name} \
--slide slide_02 \
--agent-id <agent id> \
--backend-used "built-in image tool" \
--selected-source /absolute/path/to/generated/slide_02.png \
--qa-note "Text readable; style matches the approved sample."
```
Blocker recording:
```bash
~/.codex-ppt-skill/.venv/bin/python {skill_root}/scripts/record_slide_blocker.py \
{base_dir}/{deck_name} \
--slide slide_02 \
--agent-id <agent id> \
--reason "selected image backend unavailable in worker"
```
Subagent handoff template lives in `../prompts/slide-worker.md`. Use that template instead of writing a new ad hoc worker prompt.
Save images as:
```text
{base_dir}/{deck_name}/origin_image/slide_01.png
{base_dir}/{deck_name}/origin_image/slide_02.png
...
```
After each image is generated, the parent agent should record it with `record_slide_result.py`, which copies it into `{base_dir}/{deck_name}/origin_image/` and rejects backend provenance that does not match the selected backend or sample generation method. Do not leave final slide images only in a temporary or default generated-images directory, and do not manually mark slide state complete.
In CLI/API fallback mode, read `cli-api-fallback.md` for text-only generation commands, image-input limitations, edit commands, transparency rules, and runtime troubleshooting.
Final slide image naming rules:
- Rename final slide images strictly by slide order: `slide_01.png`, `slide_02.png`, `slide_03.png`, ...
- Use zero-padded two-digit numbers for normal decks.
- The approved sample slide should already have the correct `slide_XX.png` filename and should be reused directly.
- Keep rejected variants, drafts, or reference images out of `origin_image/`. If you need to preserve them, place them in the project root or a separate `drafts/` directory.
- Before assembling, verify every expected `slide_XX.png` exists in `origin_image/`, there are no missing or extra final slide images, and `slide_job_status.py` shows all non-sample slide jobs as `recorded`.
For Chinese decks, explicitly ask the image backend to render Chinese text accurately and avoid garbled characters.
docs/style-library.md# Style Library
Read this when the user asks to save a finished deck style, a sample-slide style, or a user-supplied image/PDF/PPT/PPTX style into the reusable style library.
The goal is to save a reusable visual system, not the current deck's private content.
User custom styles are saved to `${CODEX_PPT_HOME:-~/.codex-ppt-skill}/references/`, outside the skill install directory, so they survive skill updates and reinstalls. Never write user custom styles into the skill's own `references/` directory; that directory is reserved for built-in styles shipped with the skill.
## When To Use
Use this workflow when the user says things like:
- Save this style.
- Add this PPT style to the style library.
- Let future decks use this style.
- Turn this image/PDF/PPT/PPTX style into a built-in reference.
- Save the style from the finished deck.
If the user only wants to use a style once, extract a temporary style description for the current deck instead of writing a new style file.
## Inspect The Visual Source
Use the actual visible pages as the source of truth.
- For a finished codex-ppt deck, inspect the final `origin_image/slide_XX.png` files or exported slide page images.
- For a sample slide, inspect the approved sample image.
- For user-provided image references, inspect the image itself.
- For PDF/PPT/PPTX references, first render or export representative pages/slides into real page images, then inspect those images. Do not infer the style from file structure, text, XML, metadata, or object hierarchy alone.
Inspect enough pages to capture the style system. Prefer at least one cover or opener, one ordinary content slide, one diagram/process/data slide when available, and one closing or summary slide. If the deck has obvious section-specific variants, record those variants inside the style file.
## Extract The Style System
Extract reusable visual rules:
- `style_name`: short reusable name.
- `best_for`: suitable scenarios and audiences.
- `visual_direction`: one concise description of the style identity.
- `canvas`: aspect ratio, background, composition, density, whitespace.
- `color_palette`: primary, secondary, accent, neutral colors, plus usage rules.
- `typography`: title, body, labels, hierarchy, alignment, text quality rules.
- `layout_patterns`: recurring page types and composition patterns.
- `layout_usage_rule`: how to vary layouts while keeping the same identity.
- `layout_blueprints`: 2-4 reusable composition blueprints, described semantically rather than copied from one slide.
- `visual_elements`: allowed and avoided icons, diagrams, cards, textures, decorations, photos, charts.
- `image_treatment`: how photos, screenshots, charts, or illustrations are handled.
- `rendering_constraints`: rules the image model should follow.
Do not save private or one-off content as style:
- Do not save the user's original article text, business data, personal information, customer names, private project names, paper results, exact quotes, or slide copy.
- Do not save source images or screenshots as required dependencies of the style file.
- Do not preserve identifiable logos or brand names unless the user explicitly asks for a reusable brand style.
- Do not make the style depend on external files; the style file must be self-contained.
## Name The Style
Name the file:
```text
${CODEX_PPT_HOME:-~/.codex-ppt-skill}/references/{style_name}.md
```
Create the directory first if it does not exist.
Naming rules:
- Prefer a short Chinese style name, usually 2-8 Chinese characters or a concise Chinese phrase.
- Name the reusable visual style, not the project, client, paper, or event.
- Avoid personal names, company names, customer names, paper titles, or temporary task names.
- Avoid vague names like `我的风格1`, `好看风`, or `新风格`.
- Good examples: `深色数据科技风`, `极简发布会风`, `柔和学术插画风`, `高密度咨询风`.
If the target filename already exists in the user style directory, ask whether to overwrite, merge, or choose a new name. If the filename matches a built-in style in the skill's `references/`, tell the user the custom file will take priority over the built-in style with the same name, and confirm that is intended before saving.
## Write The Style File
Match the structure of the built-in files in the skill's `references/`:
# {style_name}
**适用场景:**
- ...
- ...
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "{style_name}",
"best_for": "...",
"visual_direction": "...",
"canvas": {
"aspect_ratio": "16:9",
"background": "...",
"composition": "...",
"density": "..."
},
"color_palette": {
"primary": "...",
"secondary": "...",
"accent": "...",
"neutral": "...",
"rule": "..."
},
"typography": {
"title": "...",
"body": "...",
"labels": "...",
"text_quality": "..."
},
"layout_patterns": [
"...",
"..."
],
"layout_usage_rule": "...",
"layout_blueprints": [
{
"name": "...",
"sections": [
{"position": "...", "count": 1, "labels": ["..."]}
]
}
],
"visual_elements": {
"allowed": "...",
"avoid": "..."
},
"image_treatment": {
"photos": "...",
"screenshots": "...",
"charts": "...",
"illustrations": "..."
},
"rendering_constraints": [
"...",
"..."
]
}
```
The JSON should be directly reusable as a slide generation style brief. Keep it descriptive enough for future agents, but avoid embedding task-specific content.
## Discovery
No registration step is needed. Future style confirmation steps scan `${CODEX_PPT_HOME:-~/.codex-ppt-skill}/references/` and merge its files with the built-in style list, so the saved file is discoverable automatically. Do not edit `docs/outline-style-and-sample.md` or any other file inside the skill for a user custom style.
## Final Response
Report:
- The new style name.
- The saved file path under `${CODEX_PPT_HOME:-~/.codex-ppt-skill}/references/`.
- That the style is stored outside the skill install, so it survives skill updates and reinstalls.
- A one-sentence note on how to request it later, for example: "以后可以说:用「深色数据科技风」生成这份 PPT。"
docs/user-supplied-assets.md# User-Supplied Assets
Read this before using paper figures, experiment result charts, screenshots, logos, or other assets that must appear in the deck.
When the user provides paper figures, experiment result charts, screenshots, logos, or other assets that must appear in the deck, treat them as source assets, not as loose visual inspiration.
Recommended project-local asset location:
```text
{base_dir}/{deck_name}/assets/
├── figures/
│ ├── result_01.png
│ └── result_02.png
└── logos/
└── lab_logo.png
```
Do not place source assets in `origin_image/`; that directory is only for final `slide_XX.png` images.
For slides that must include a user-supplied figure:
- Record the exact asset path or attachment name in `outline.md` for that slide, preferably as a Markdown image reference inside that slide's `Required images` list, then ask the user to confirm the mapping before generation.
- Stay on the already selected image backend. Do not switch between built-in image generation and CLI/API fallback only because a slide includes source images.
- Use the selected backend's reference-image or edit capability when available, with the supplied figure visible as an input image.
- In built-in `image_gen` mode, every source image must be visible in the conversation context before generating any slide that depends on it. User attachments and images generated earlier in the thread already qualify. For local image paths, inspect each required image with `view_image` first, then generate or edit the slide.
- In built-in `image_gen` mode, `view_image` is the required way to make local image paths visible to the conversation before generation. It is not a filename parameter to `image_gen`; the generation prompt must still label the visible image by role, such as `Image 1: strict input asset` or `Image 2: approved sample slide style reference`.
- Ask the model to preserve the supplied figure's data, labels, axes, colors, and visual content, and only compose the surrounding slide layout, title, captions, callouts, and background.
- Do not ask the model to "redraw", "recreate", "imagine", or "generate a similar chart" for result figures unless the user explicitly wants a stylized redraw.
- After generation, inspect the output and ask the user to pay special attention to whether required figures were used correctly.
Example prompt fragment for a result-figure slide:
```json
{
"source_assets": [
{
"path": "{base_dir}/{deck_name}/assets/figures/result_01.png",
"usage": "embed as the main evidence figure",
"fidelity": "preserve the figure content; do not redraw or change data, axes, labels, colors, curves, bars, or legends"
}
],
"visual_elements": {
"main_visual": "place the supplied result_01.png as a large figure panel, with a short caption and two callouts around it"
},
"constraints": [
"Use the provided figure as an input image, not as a loose style reference.",
"Do not synthesize a replacement chart.",
"Keep all numerical values and labels in the supplied figure unchanged."
]
}
```
docs/workflow-gates-and-progress.md# Workflow Gates And Progress
Read this before creating downstream artifacts, advancing between phases, or reporting progress.
## Mandatory Phase Gates
This workflow has explicit approval gates. Do not advance to a later phase until the previous phase has been approved by the user, unless the user explicitly asks to skip that confirmation.
Phase order:
1. Source reading and asset extraction
2. Outline confirmation
3. Visual style confirmation
4. Image backend confirmation
5. One sample slide approval
6. Full slide generation
7. QA, speaker notes finalization, and PPT assembly
Hard rules:
- Before outline approval, do not create final `deck_spec.json`, `speech.md`, prompt job files, slide images, or `.pptx` files.
- If you need an internal planning artifact before approval, name it with `.draft.` such as `deck_spec.draft.json` or `speech.draft.md`, and clearly report that it is not final.
- Downstream artifacts (`deck_spec.json`, `prompts/`, `slide_jobs.json`, `speech.md`, final slide images, and `.pptx`) should be created only after the relevant gates have been approved.
- If the deck uses required source images, stop at outline confirmation and ask the user to verify the slide-to-image mapping before style selection or image generation.
## Visible Progress Plan
For non-trivial decks, keep a user-visible checklist with one active step:
1. Prepare source, outline, style, and backend decisions.
2. Generate and approve one sample slide.
3. Prepare slide jobs and slide state.
4. Dispatch slide subagents.
5. Record generated slide results.
6. QA, repair, notes, and PPT assembly.
Completion evidence:
- `Prepare source, outline, style, and backend decisions`: `outline.md` is approved and image backend is confirmed.
- `Generate and approve one sample slide`: one final `origin_image/slide_XX.png` is approved as the style reference.
- `Prepare slide jobs and slide state`: `prompts/slide_XX.json`, `slide_jobs.json`, and `slide_run_state.json` exist.
- `Dispatch slide subagents`: `slide_job_status.py` shows dispatchable slides and each spawned worker is recorded by `record_slide_dispatch.py`.
- `Record generated slide results`: each worker output is recorded by `record_slide_result.py`, which copies the selected image into `origin_image/slide_XX.png` and records backend provenance.
- `QA, repair, notes, and PPT assembly`: every expected final image exists, QA is complete, `speech.md` is final, and `{deck_name}.pptx` exists.
Do not mark a step complete just because the chat says it is complete; use real files or script-recorded state.
prompts/slide-worker.md# Slide Worker Prompt Use this template when dispatching a slide subagent after the sample slide is approved and full-deck generation is authorized. ```text Generate slide <N> for this codex-ppt deck. Deck dir: <absolute deck dir> Slide job file: <absolute deck dir>/prompts/slide_<NN>.json Output target owned by parent: <absolute deck dir>/origin_image/slide_<NN>.png Selected image backend: <built-in image tool OR CLI/API fallback> Sample generation method copied from the approved sample: - backend_used: <exact backend label recorded by parent> - tool_name: <image_gen OR image_generate OR scripts/image_gen.py> - mode: <generate OR edit> - model/config: <model, size, quality, or "built-in default" if not exposed> - prompt_source: <approved sample prompt source> - input_context_preparation: <how local images were made visible or attached> - approved_sample_path: <absolute path to approved origin_image/slide_XX.png> - handoff_rule: use this same backend/tool/mode; return a blocker if unavailable Input images already prepared by the parent: - <absolute path> - approved sample slide style reference; match style only, do not copy layout - <absolute path> - strict input asset; preserve labels/data/arrows/content Read the JSON job file, then follow its `prompt` field exactly. Use the selected image backend and the recorded sample generation method only. You must produce the final slide candidate by calling the selected image generation backend: - Built-in mode: use the built-in image generation/editing tool. - CLI/API fallback mode: use `scripts/image_gen.py` with the saved job prompt and required image inputs. Forbidden for final slide image creation: - local drawing or rendering scripts - Pillow-generated slides - SVG, HTML/CSS, or canvas screenshots - python-pptx/PptxGenJS/native PPT layout screenshots - manually composited text, card, chart, or image overlays If you cannot use the selected image backend, stop and return `blocker=<reason>` instead of creating a lower-quality replacement. If you cannot follow the recorded sample generation method, stop and return `blocker=<reason>` instead of switching tools. Do not edit slide job files, origin_image, speech.md, or assemble the PPT. Before returning, visually check: - Chinese text is readable and not garbled - style matches the approved sample slide - required source images are visibly included and not replaced by a similar redraw - no overlapping or truncated important content Return only: backend_used=<built-in image tool OR scripts/image_gen.py> selected_source=/absolute/path/to/$CODEX_HOME/generated_images/.../ig_*.png qa_note=<one sentence> ```
references/党政红风格.md# 党政红风格
**适用场景:**
- 党政机关工作汇报、专题学习与会议材料
- 政策宣讲、党建活动、年度总结与重点工作部署
- 国企、事业单位和公共服务机构的正式汇报
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "党政红风格",
"best_for": "党政机关、国企和事业单位的工作汇报、政策宣讲、党建学习、年度总结与重点工作部署",
"visual_direction": "solemn, authoritative, and optimistic Chinese public-sector presentation with a recognizable Chinese-red identity, restrained gold accents, strong Chinese typography, and a visual expression shaped by the subject rather than a fixed ceremonial layout",
"canvas": {
"aspect_ratio": "16:9",
"background": "choose red, warm ivory, a restrained gradient, a relevant photograph, or a subtle abstract treatment according to the page role and subject; any landscape, architecture, ribbon, skyline, or cultural motif is optional and must serve the content rather than become a default background",
"composition": "formal and balanced with a prominent title hierarchy and disciplined alignment; allow centered, asymmetric, image-led, modular, or spacious compositions when they better express the content",
"density": "medium information density with controlled whitespace, clear reading order, and enough supporting text or evidence to make each page useful"
},
"color_palette": {
"primary": "Chinese red #C41E3A for main titles, structural emphasis, and key labels",
"secondary": "deep red #9E1530 for contrast and warm ivory #FFF9F2 for the main canvas",
"accent": "restrained matte gold #D6A84B and warm amber #E9A11B for icons, connectors, and small title ornaments",
"neutral": "ink black #262626, dark gray #4A4A4A, pale warm gray #F3EEE7",
"rule": "use Chinese red to establish authority and gold as a restrained accent. Balance red, warm ivory, and neutral space according to page role; avoid glitter, glossy gradients, and festive red-and-gold decoration"
},
"typography": {
"title": "large bold Microsoft YaHei or Source Han Sans style Chinese sans-serif, visually strong, dignified, and immediately noticeable",
"body": "Microsoft YaHei or Source Han Sans style Chinese sans-serif, concise, highly readable, and aligned to a strict grid",
"labels": "bold white or dark-red text on restrained red or pale-gold blocks",
"text_quality": "all Chinese text must be exact, fully legible, non-garbled, and suitable for formal government reporting"
},
"title_system": {
"consistency_rule": "Keep the title hierarchy, typeface, weight, color logic, and spacing consistent across slides of the same page type.",
"cover": "Use a prominent, dignified title treatment that may be centered, offset, or integrated with a relevant visual according to the composition.",
"section_divider": "Use a concise title and a clear transition in scale, color, whitespace, or imagery without requiring a fixed navigation bar or ornament.",
"content_slide": "Keep the page topic immediately recognizable, but align the title with the composition and information structure; centered and left-aligned treatments are both valid.",
"flexibility": "Treat the title system as a hierarchy and consistency rule, not a fixed arrangement. Do not require a navigation strip, centered title, gold separator, numbering style, or identical decoration on every slide."
},
"layout_patterns": [
"cover or opener with a dominant title and a subject-relevant photographic, illustrative, architectural, landscape, or abstract visual",
"section divider with a concise statement and a clear transition created through scale, whitespace, color, or imagery",
"content explanation organized around the most important relationship, argument, process, comparison, or evidence",
"work deployment or policy interpretation page whose modules, sequence, and emphasis follow the source content",
"achievement or data page combining key figures, charts, images, and evidence-based captions as needed",
"summary or closing page that reinforces the central message with a strong but restrained visual conclusion"
],
"layout_usage_rule": "Keep the red-led identity, typography, and formal tone consistent while varying title placement, background treatment, imagery, information structure, and whitespace by page purpose. Do not force every slide into the same grid, module count, title position, or decorative motif; use lighter backgrounds when they improve readability.",
"layout_blueprints": [
{
"name": "封面 / 开场",
"sections": [
{"position": "primary visual field", "count": 1, "labels": ["prominent title and concise supporting information"]},
{"position": "supporting field", "count": 1, "labels": ["optional subject-relevant photograph, illustration, architecture, landscape, or abstract visual"]}
]
},
{
"name": "信息阐释 / 工作部署",
"sections": [
{"position": "title zone", "count": 1, "labels": ["clear page topic or guiding statement"]},
{"position": "main content field", "count": 1, "labels": ["the dominant argument, relationship, process, comparison, or evidence structure"]},
{"position": "supporting field", "count": 1, "labels": ["supporting text, data, images, or callouts selected according to the source"]}
]
},
{
"name": "成果 / 总结",
"sections": [
{"position": "primary message field", "count": 1, "labels": ["central conclusion, achievement, or call to action"]},
{"position": "evidence field", "count": 1, "labels": ["figures, chart, comparison, documentary image, or concise supporting points as appropriate"]}
]
}
],
"visual_elements": {
"allowed": "content-relevant photography, illustrations, landscapes, architecture, skylines, public-service scenes, people, abstract ribbons or color fields, restrained red-and-gold accents, formal information modules, simple icons, clean charts, and subtle cultural motifs; none of these elements is mandatory",
"avoid": "repeating the same landmark or ornament without content justification, invented or inaccurate official marks, excessive ceremonial decoration, thick shadows, glossy 3D text, glitter, festival or wedding motifs, cartoon styling, unrelated stock imagery, and decorative elements that compete with the message"
},
"image_treatment": {
"photos": "use relevant and credible documentary, landscape, architectural, public-service, or institutional imagery; it may be full-bleed, cropped, framed, or integrated with a restrained red treatment, but avoid heavy filters that obscure the subject",
"screenshots": "place inside clean warm-white frames with short source labels and no ornamental clutter",
"charts": "use red as the primary series, gold for one key highlight, and neutral gray for comparison; keep labels direct and readable",
"illustrations": "choose a flat, graphic, painterly, or restrained symbolic treatment according to the subject; do not impose a fixed landmark, ribbon, skyline, or landscape motif",
"official_symbols": "use a party emblem, national emblem, flag, seal, or other official symbol only when the content genuinely requires it and an accurate, authorized source asset is available; never invent, approximate, or redesign official symbols"
},
"rendering_constraints": [
"The slide must look like a formal Chinese government or public-sector report, not a festival poster or commercial advertisement.",
"Use large, dignified Chinese sans-serif typography resembling Microsoft YaHei or Source Han Sans.",
"Follow a consistent title system across slides of the same page type, while allowing cover, section-divider, and content-slide variants.",
"Let the page role and source content determine title placement, background, imagery, module count, and composition.",
"Do not force a navigation strip, centered title, gold separator, landscape, Great Wall, skyline, ribbon, or other specific motif onto every slide.",
"Use Chinese red #C41E3A as the main red and gold as a restrained supporting accent.",
"Keep gold restrained and matte; do not use glossy metallic or embossed 3D text.",
"Use exact readable Chinese text and a clear top-to-bottom or left-to-right hierarchy.",
"Do not invent, approximate, or redesign official marks, government logos, party emblems, national emblems, seals, flags, or institutional names.",
"No watermark and no unrelated logo."
]
}
```
references/创意杂志风.md# 创意杂志风
**适用场景:**
- 创意提案
- 品牌展示
- 设计作品集
- 文化活动
- 时尚发布
- 艺术展览
- 创意工作室介绍
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "创意杂志风",
"best_for": "需要强视觉记忆点、品牌个性或传播感的分享型演示",
"visual_direction": "high-end editorial magazine spread, bold asymmetry, art-directed composition, graphic tension, premium creative layout",
"canvas": {
"aspect_ratio": "16:9",
"background": "white, black, or deep charcoal with intentional negative space",
"composition": "asymmetric editorial layout with a large headline, image/collage zone, and 2-3 supporting text blocks",
"density": "strong contrast between dense editorial blocks and open whitespace"
},
"color_palette": {
"base": "black, white, and gray",
"accent": "one vivid accent such as neon pink #FF006E, lemon yellow #FFED00, or electric cyan #00F5FF",
"support": "optional lavender, coral, or muted fashion tones",
"rule": "use one dominant accent color consistently; do not make the palette chaotic"
},
"typography": {
"title": "oversized bold display sans-serif or editorial serif, can occupy 25-45% of the slide",
"body": "small clean sans-serif blocks with strong alignment",
"emphasis": "keywords may use accent color, rotated labels, vertical text, or extreme scale contrast",
"text_quality": "Chinese headline and key points must be exact and readable"
},
"layout_patterns": [
"oversized headline on one side with collage or abstract image block on the other",
"diagonal color block cutting across the slide",
"magazine cover style with title, subtitle, and three feature teasers",
"editorial grid with one large image crop and small annotation labels"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. Choose and adapt the composition according to each slide's semantic role; avoid repeating the same blueprint on adjacent slides unless it is a deliberate repeated sequence.",
"layout_blueprints": [
{
"name": "editorial hero spread",
"sections": [
{"position": "left 45%", "count": 1, "labels": ["oversized headline"]},
{"position": "right 55%", "count": 1, "labels": ["collage or abstract image zone"]},
{"position": "bottom-left", "count": 3, "labels": ["feature teaser 1", "feature teaser 2", "feature teaser 3"]}
]
},
{
"name": "cover story grid",
"sections": [
{"position": "top", "count": 1, "labels": ["large cover title"]},
{"position": "center", "count": 2, "labels": ["main visual block", "accent typography block"]},
{"position": "right edge", "count": 3, "labels": ["short editorial callouts"]}
]
}
],
"visual_elements": {
"allowed": "bold geometric blocks, cropped photography zones, halftone texture, thin rules, torn-paper collage edges, accent stickers, abstract shapes",
"avoid": "generic corporate icons, overly symmetrical layout, low-contrast text, too many accent colors"
},
"rendering_constraints": [
"The slide should feel designed, not templated.",
"Maintain legibility despite bold composition.",
"No watermark, no unrelated logo, no slide number unless explicitly requested."
]
}
```
references/复古扁平插画风.md# 复古扁平插画风
**适用场景:**
- 文化创意项目展示
- 品牌故事讲述
- 旅游景点介绍
- 复古产品发布
- 艺术设计作品集
- 创意活动宣传
- 生活方式类演示
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "复古扁平插画风",
"best_for": "文化创意、品牌故事、生活方式、城市旅游和带叙事感的主题演示",
"visual_direction": "retro flat vector illustration slide, cream paper texture, monoline black outlines, vintage palette, playful handcrafted design",
"canvas": {
"aspect_ratio": "16:9",
"background": "cream/off-white paper #F5F3E8 with subtle grain",
"composition": "panoramic illustration band plus structured content cards or labels",
"density": "moderate, decorative but still clear"
},
"color_palette": {
"base": "cream background and dark slate text #34495E",
"vintage_colors": "coral red #FF6B6B, mint green #95E1D3, mustard yellow #F9CA24, burnt orange #E17055, slate blue #6C7A89",
"line": "uniform black or deep charcoal outline",
"rule": "flat fills only, no glossy 3D, no heavy gradients"
},
"typography": {
"title": "bold retro serif or chunky vintage display type",
"body": "geometric sans-serif, clear and friendly",
"labels": "small badge labels with outlined shapes",
"text_quality": "Chinese text must be exact and readable"
},
"layout_patterns": [
"top panoramic flat illustration with bottom content cards",
"central retro title with three outlined icon cards",
"2.5D simplified scene with callout labels",
"vintage poster composition adapted to presentation readability"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. Choose and adapt the composition according to each slide's semantic role; avoid repeating the same blueprint on adjacent slides unless it is a deliberate repeated sequence.",
"layout_blueprints": [
{
"name": "panoramic illustration plus cards",
"sections": [
{"position": "top third", "count": 1, "labels": ["panoramic flat vector illustration"]},
{"position": "middle", "count": 1, "labels": ["large retro title"]},
{"position": "bottom", "count": 3, "labels": ["outlined content card 1", "outlined content card 2", "outlined content card 3"]}
]
},
{
"name": "retro poster diagram",
"sections": [
{"position": "center", "count": 1, "labels": ["main simplified scene or object"]},
{"position": "around center", "count": 4, "labels": ["callout label", "badge", "mini icon", "decorative note"]},
{"position": "bottom-right", "count": 1, "labels": ["summary plaque"]}
]
}
],
"visual_elements": {
"allowed": "flat vector scenes, 2-3px monoline outlines, simplified buildings, plants, clouds, badges, dotted textures, small geometric decorations",
"avoid": "photorealism, complex shadows, neon palette, overly detailed linework, unreadable decorative type"
},
"rendering_constraints": [
"The slide should feel like a polished retro illustration system, not a random cartoon.",
"Maintain consistent outline weight across objects.",
"No watermark, no unrelated logo, no slide number unless explicitly requested."
]
}
```
references/手绘技术解释风.md# 手绘技术解释风
**适用场景:**
- 中文技术文章配图
- 技术概念解释
- 课程课件
- 知识卡片
- 产品机制说明
- AI / 软件工程主题分享
- 需要降低理解门槛的复杂概念
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "手绘技术解释风",
"best_for": "中文技术文章、课程课件、复杂概念解释、知识卡片和软件工程/AI 主题的低压力说明图",
"visual_direction": "clean Chinese handdrawn technical explainer, near-white paper background, thin sketch lines, light pencil hatching, small precise central diagram, restrained pastel markers, lots of whitespace, calm educational tone",
"canvas": {
"aspect_ratio": "16:9",
"background": "near-white paper #FCFBF7, not yellow, no full-page border",
"composition": "small central explanatory diagram with sparse labels, surrounding whitespace, short title and minimal visible text",
"density": "low to moderate; one core idea per slide; avoid dense whiteboard clutter"
},
"color_palette": {
"line": "soft graphite #2F3437 or dark gray ink, thin and slightly irregular",
"accent": "pale blue #BFD7F1, sage green #CFE2D1, light peach #F4C7B8, pale lavender #D8C7EF",
"background": "near-white, clean, not kraft, not cream-heavy",
"rule": "pastel marks are used for emphasis only; keep the page calm and airy"
},
"typography": {
"title": "restrained handwritten Chinese title, medium-large but not poster-sized",
"body": "short handwritten Chinese labels, few words per label, easy to inspect",
"emphasis": "light underline, small bracket, soft marker highlight, or tiny note tag",
"text_quality": "Chinese text must be exact, sparse, and readable; avoid long paragraphs"
},
"layout_patterns": [
"central concept diagram with 3-4 short surrounding labels",
"before-after explanation with two small sketch panels",
"flow diagram with 3 steps and minimal arrows",
"mental model page with one metaphor object and short annotations",
"matrix or decision guide with sparse handwritten notes",
"summary page with one small character or object and three takeaway labels"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. Choose and adapt the composition according to each slide's semantic role; keep visual DNA stable while varying archetypes such as metaphor, process, comparison, matrix, and summary. Avoid repeating the same blueprint on adjacent slides unless it is a deliberate repeated sequence.",
"layout_blueprints": [
{
"name": "small central concept map",
"sections": [
{"position": "top-left", "count": 1, "labels": ["short handwritten title"]},
{"position": "center", "count": 1, "labels": ["small precise concept diagram"]},
{"position": "around center", "count": 4, "labels": ["label 1", "label 2", "label 3", "label 4"]},
{"position": "bottom-right", "count": 1, "labels": ["one-sentence takeaway"]}
]
},
{
"name": "technical before-after",
"sections": [
{"position": "top", "count": 1, "labels": ["short page title"]},
{"position": "left", "count": 1, "labels": ["before sketch panel"]},
{"position": "right", "count": 1, "labels": ["after sketch panel"]},
{"position": "between panels", "count": 1, "labels": ["thin handdrawn arrow"]},
{"position": "bottom", "count": 3, "labels": ["why it matters", "tradeoff", "next step"]}
]
},
{
"name": "one idea teaching card",
"sections": [
{"position": "center-left", "count": 1, "labels": ["metaphor object or tiny engineer/reader character"]},
{"position": "center-right", "count": 3, "labels": ["core idea", "common mistake", "useful rule"]},
{"position": "background", "count": 1, "labels": ["very light pencil hatching and pastel highlight"]}
]
}
],
"visual_elements": {
"allowed": "thin handdrawn arrows, small diagrams, pencil hatching, pastel marker blocks, bracket notes, simple software/AI icons, tiny engineer or reader character at most once",
"avoid": "messy whiteboard frame, marker tray, large cartoon characters, dense handwriting, yellowed paper, decorative stickers, poster-scale title, full-page border, digital UI cards"
},
"rendering_constraints": [
"The slide should feel like a calm handdrawn technical article illustration, not a brainstorming whiteboard.",
"Keep the central drawing small and precise with generous empty space.",
"Use minimal Chinese text and make every visible word correct.",
"No watermark, no unrelated logo, no slide number unless explicitly requested."
]
}
```
references/手绘白板风.md# 手绘白板风
**适用场景:**
- 教学讲解
- 培训课程
- 头脑风暴
- 概念说明
- 技术分享
- 内部研讨
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "手绘白板风",
"best_for": "概念解释、技术分享、培训课程和需要亲和力的思路拆解",
"visual_direction": "realistic whiteboard explanation slide, marker handwriting, sketched diagrams, friendly teaching atmosphere, authentic whiteboard details",
"canvas": {
"aspect_ratio": "16:9",
"background": "warm off-white whiteboard surface #FAFAF5 with subtle marker smudges",
"composition": "freeform but organized whiteboard layout with title, three concept blocks, arrows, and small doodle illustrations",
"density": "moderate, readable, brainstorming feel without chaos"
},
"color_palette": {
"primary": "black marker for main text",
"accent": "red #E74C3C, blue #3498DB, orange #F39C12 marker annotations",
"support": "green #27AE60, purple #9B59B6, yellow sticky note #FFF9C4",
"rule": "colors should look like real marker ink, not digital neon"
},
"typography": {
"title": "large neat handwritten Chinese marker style",
"body": "clear handwritten marker text, slightly irregular but readable",
"emphasis": "circle, underline, boxed words, sticky-note comments",
"text_quality": "Chinese handwriting must remain accurate and legible"
},
"layout_patterns": [
"three hand-drawn boxes connected by arrows",
"central concept bubble with surrounding notes",
"simple process diagram with sketches and annotations",
"left explanation list plus right hand-drawn diagram"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. Choose and adapt the composition according to each slide's semantic role; avoid repeating the same blueprint on adjacent slides unless it is a deliberate repeated sequence.",
"layout_blueprints": [
{
"name": "whiteboard process diagram",
"sections": [
{"position": "top-left", "count": 1, "labels": ["handwritten title"]},
{"position": "center", "count": 3, "labels": ["step 1 box", "step 2 box", "step 3 box"]},
{"position": "between boxes", "count": 2, "labels": ["hand-drawn arrow", "hand-drawn arrow"]},
{"position": "right edge", "count": 1, "labels": ["sticky-note takeaway"]}
]
},
{
"name": "concept map",
"sections": [
{"position": "center", "count": 1, "labels": ["main concept bubble"]},
{"position": "around center", "count": 4, "labels": ["supporting idea", "risk note", "example sketch", "action item"]},
{"position": "bottom", "count": 1, "labels": ["underlined summary"]}
]
}
],
"visual_elements": {
"allowed": "whiteboard frame, marker tray, colored markers, arrows, boxes, clouds, sticky notes, doodle icons, underlines, circled keywords",
"avoid": "messy illegible handwriting, childish clutter, photoreal people, digital UI cards"
},
"rendering_constraints": [
"The slide should look like a real whiteboard captured cleanly for a presentation.",
"Keep all Chinese text readable despite handwritten style.",
"No watermark, no unrelated logo, no slide number unless explicitly requested."
]
}
```
references/教学课件风.md# 教学课件风
**适用场景:**
- 高校课程、专题讲座与课堂教学
- 技术培训、知识科普与专业能力建设
- 概念讲解、体系梳理、案例分析与研究进展介绍
- 需要同时呈现文字、图解、图片和数据的教学型演示
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "教学课件风",
"best_for": "高校课程、专题讲座、技术培训、知识科普、概念讲解、体系梳理、案例分析和研究进展介绍",
"visual_direction": "clear and credible academic courseware with structured knowledge communication, evidence-supported explanation, discipline-appropriate visuals, and a professional classroom tone shaped by the lesson rather than a fixed technical template",
"canvas": {
"aspect_ratio": "16:9",
"background": "white or very light cool gray with restrained pale-blue structural accents",
"composition": "structured teaching slide with a clear title hierarchy and a content-driven combination of text, images, diagrams, charts, formulas, source material, annotations, or comparisons selected according to the lesson objective",
"density": "balanced teaching density: substantial enough to support explanation, but never sparse, paragraph-heavy, or crowded; use strong grouping, alignment, and readable whitespace"
},
"color_palette": {
"primary": "deep academic navy #0B2E6D and clear blue #1769AA",
"secondary": "light blue #EAF3FB and cool gray #F3F6F9",
"accent": "choose a small set of restrained auxiliary colors according to the subject matter, source material, and semantic categories",
"neutral": "ink black #1F2937, body gray #4B5563, border gray #D7E0EA",
"rule": "use the blue system to establish the teaching structure; select auxiliary colors only when the content needs semantic distinction, then apply them consistently rather than decoratively"
},
"typography": {
"title": "large bold Chinese sans-serif resembling Microsoft YaHei or Source Han Sans, usually dark navy, concise and immediately readable",
"body": "compact but readable Chinese sans-serif with clear indentation and short explanatory phrases",
"labels": "bold short labels with strong contrast, used when hierarchy or semantic categories need clarification rather than as mandatory card decoration",
"text_quality": "all Chinese text, Latin terms, formulas, units, and data labels must be exact, readable, and non-garbled"
},
"title_system": {
"consistency_rule": "Keep title typeface, weight, color logic, alignment, and spacing consistent across slides of the same page type.",
"cover": "May combine a large course title with one or more subject-relevant visuals and concise presenter or course information; choose the visual structure according to the discipline and topic.",
"section_divider": "May use a concise section title with stronger whitespace and a restrained academic cue.",
"content_slide": "Keep the page topic immediately recognizable, but let title alignment, supporting accents, and surrounding composition adapt to the teaching content.",
"closing": "Use a concise acknowledgement or closing statement together with a subject-relevant visual or summary echo; keep the page informative and visually complete rather than leaving a nearly empty text-only slide.",
"flexibility": "Treat the title system as a consistency guide rather than a fixed template. Do not force every slide into the same header, rule, alignment, or decorative treatment."
},
"layout_patterns": [
"course cover combining a clear title hierarchy with subject-relevant visual material and concise course information",
"concept explanation using the most suitable combination of diagrams, images, definitions, formulas, source excerpts, or annotated examples",
"sequence, process, development, or causal explanation with a clear viewing order",
"comparison or relationship page whose structure follows the concepts being contrasted or connected",
"case, text, artwork, experiment, event, or application analysis supported by relevant evidence and explanation",
"overview or synthesis page organizing examples, themes, findings, or knowledge relationships",
"knowledge summary using a concise framework, conclusion, or takeaway",
"closing or acknowledgement page combining a short closing statement with a restrained subject-relevant visual"
],
"layout_usage_rule": "Choose the layout that best supports the teaching objective and viewing sequence. Every slide should combine meaningful visual explanation with readable text; use one strong visual or several complementary photographs, diagrams, charts, maps, formulas, documents, artworks, screenshots, or annotated examples according to the discipline and content. Keep the visual language coherent without forcing every page into cards, equal columns, or one repeated composition.",
"layout_blueprints": [
{
"name": "概念 / 知识讲解",
"sections": [
{"position": "title area", "count": 1, "labels": ["lesson question or core concept"]},
{"position": "primary teaching field", "count": 1, "labels": ["the most suitable visual and explanatory structure for the concept"]},
{"position": "supporting field", "count": 1, "labels": ["definitions, examples, evidence, annotations, or implications as needed"]}
]
},
{
"name": "过程 / 关系讲解",
"sections": [
{"position": "title area", "count": 1, "labels": ["process, development, comparison, or relationship topic"]},
{"position": "primary relationship field", "count": 1, "labels": ["sequence, cause, contrast, hierarchy, interaction, or transformation"]},
{"position": "supporting field", "count": 1, "labels": ["conditions, examples, evidence, annotations, or summary as needed"]}
]
},
{
"name": "案例 / 材料 / 证据",
"sections": [
{"position": "context field", "count": 1, "labels": ["case, text, event, artwork, experiment, or application context"]},
{"position": "evidence field", "count": 1, "labels": ["relevant visual material, source excerpt, data, observations, or comparison"]},
{"position": "teaching conclusion field", "count": 1, "labels": ["interpretation, lesson learned, or takeaway"]}
]
}
],
"visual_elements": {
"allowed": "discipline-relevant photographs, artworks, archival documents, source excerpts, maps, formulas, tables, charts, diagrams, screenshots, multi-image evidence groups, annotated images, timelines, relationship structures, restrained icons, and subtle academic background texture; cards and grids may be used when they genuinely clarify the material but are not the default",
"avoid": "text-only pages, purely decorative illustration, repetitive card grids, default three-column layouts, generic AI-looking imagery, unsupported futuristic or technical styling, random 3D icons, playful stickers, excessive gradients, heavy shadows, glossy elements, inconsistent icon styles, dense ungrouped text, unrelated stock imagery, and ornamental layouts that weaken the teaching sequence"
},
"image_treatment": {
"photos": "use relevant and credible course or case images as evidence, crop them consistently, and pair them with concise explanatory labels; one page may use a single main image or several complementary images when they explain different aspects of the topic",
"screenshots": "place inside clean frames and preserve important interface details, annotations, and labels",
"charts": "use the palette semantically, retain accurate axes and values, and emphasize the comparison or trend being taught",
"illustrations": "choose diagrams, maps, explanatory drawings, documentary collage, symbolic graphics, or other illustration treatments appropriate to the discipline and lesson; avoid decorative scenes that do not teach anything",
"discipline_materials": "treat formulas, artworks, historical documents, literary excerpts, medical images, scientific figures, and other subject-specific materials as valid visual evidence when relevant and accurately represented"
},
"rendering_constraints": [
"The slide must look like professional academic courseware, not a marketing pitch deck.",
"Prioritize teaching sequence, conceptual clarity, and readable evidence over visual decoration.",
"Every slide must contain both meaningful visual content and readable text; visuals should explain, demonstrate, compare, or provide evidence rather than merely decorate.",
"Visual content may include photographs, diagrams, charts, maps, formulas, artworks, documents, source excerpts, screenshots, or other discipline-appropriate material; it does not always mean a decorative illustration.",
"Avoid both underfilled slides and long blocks of prose. Convert dense explanations into diagrams, image groups, structured labels, or concise teaching points.",
"Keep related information visibly grouped and maintain a clear viewing order.",
"Avoid tiny text; split or simplify content when it cannot remain readable at presentation distance.",
"Use semantic accent colors consistently across the deck.",
"Do not force every slide into repeated cards, equal columns, a fixed module count, or an identical information grid.",
"Avoid an obvious AI-generated look: no fabricated text inside images, implausible photo details, generic glowing technology effects, random decorative objects, or repetitive template-like compositions.",
"Do not invent university, school, laboratory, company, or course logos; use only user-provided identity assets.",
"Do not inherit topics, examples, imagery, terminology, or organizations from a style reference unless they are required by the source content.",
"No watermark and no unrelated logo."
]
}
```
references/数据仪表盘风.md# 数据仪表盘风
**适用场景:**
- 数据分析报告
- 业绩展示
- KPI 汇报
- 实时数据展示
- 商业智能BI
- 运营数据看板
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "数据仪表盘风",
"best_for": "数据密集型汇报、运营分析、KPI 复盘和业务洞察展示",
"visual_direction": "bright modern SaaS analytics dashboard, clean BI interface, lightweight data cards, precise charts, professional and non-oppressive",
"canvas": {
"aspect_ratio": "16:9",
"background": "white #FFFFFF, very light blue-gray #F5F8FC, or mist gray #F8FAFC",
"composition": "dashboard grid with title area, KPI cards, chart panels, and status list",
"density": "medium-high information density with enough card spacing and clear grouping"
},
"color_palette": {
"primary": "data blue #2563EB or #1976D2",
"secondary": "cyan #06B6D4 and soft purple #8B5CF6",
"status": "green #10B981, orange #F59E0B, red #EF4444 used sparingly",
"text": "deep navy #0F172A and neutral gray #64748B",
"rule": "avoid dark control-room backgrounds, heavy neon, and oppressive black panels"
},
"typography": {
"title": "bold clean sans-serif, dashboard header style",
"numbers": "large tabular numerals for KPI values",
"labels": "small but readable sans-serif chart labels",
"text_quality": "Chinese labels and KPI names must be exact and legible"
},
"layout_patterns": [
"top KPI strip with 3-4 cards and trend arrows",
"main area split into workflow cards and a line chart",
"lower area with donut chart, bar chart, and recent activity table",
"large central insight card surrounded by supporting metrics"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. Choose and adapt the composition according to each slide's semantic role; avoid repeating the same blueprint on adjacent slides unless it is a deliberate repeated sequence.",
"layout_blueprints": [
{
"name": "SaaS BI overview",
"sections": [
{"position": "top-left", "count": 1, "labels": ["dashboard title and subtitle"]},
{"position": "top-right", "count": 4, "labels": ["KPI card 1", "KPI card 2", "KPI card 3", "KPI card 4"]},
{"position": "middle-left", "count": 3, "labels": ["process card 1", "process card 2", "process card 3"]},
{"position": "middle-right", "count": 1, "labels": ["line chart panel"]},
{"position": "bottom", "count": 3, "labels": ["donut chart", "bar chart", "recent records table"]}
]
},
{
"name": "single insight dashboard",
"sections": [
{"position": "center", "count": 1, "labels": ["large insight card"]},
{"position": "surrounding", "count": 4, "labels": ["supporting metric", "trend sparkline", "status list", "risk indicator"]}
]
}
],
"visual_elements": {
"allowed": "white cards, soft shadows, pale borders, line charts, bar charts, progress rings, KPI cards, status dots, trend arrows, mini sparklines",
"avoid": "dark monitoring wall, dense cyberpunk glow, unreadable tiny table text, random numbers without structure"
},
"rendering_constraints": [
"The slide should look like a polished SaaS analytics product screenshot adapted for presentation.",
"Charts should be visually plausible and organized, even when illustrative.",
"No watermark, no unrelated logo, no slide number unless explicitly requested."
]
}
```
references/清爽专业风.md# 清爽专业风
**适用场景:**
- 毕业答辩
- 工作总结
- 工作 review
- 技术分享
- 项目复盘
- 晋升述职
- 阶段性成果汇报
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "清爽专业风",
"best_for": "毕业答辩、工作总结、工作 review、技术分享、项目复盘和晋升述职等需要清晰表达过程、成果、问题和下一步的场景",
"visual_direction": "clean modern professional deck, calm technical presentation, structured evidence-driven layout, readable pragmatic visual system, light background with crisp hierarchy",
"canvas": {
"aspect_ratio": "16:9",
"background": "warm white #FFFFFF, light gray #F8FAFC, or very pale blue #F6F9FF",
"composition": "title zone, structured content zone, evidence/diagram zone, and concise takeaway zone",
"density": "medium information density, enough room for technical details without visual clutter"
},
"color_palette": {
"primary": "professional blue #2563EB or slate blue #334155",
"secondary": "calm teal #0F766E or muted cyan #0891B2",
"accent": "soft amber #F59E0B for highlights and risk/attention notes",
"neutral": "slate gray #475569, light border #E2E8F0, pale card background #F8FAFC",
"rule": "use restrained professional colors; avoid playful neon, luxury gold, or heavy dark backgrounds"
},
"typography": {
"title": "clear bold sans-serif, report-like and authoritative",
"body": "clean sans-serif, strong hierarchy, left-aligned for readability",
"labels": "small but readable labels for timeline, evidence, metrics, and code/process diagrams",
"text_quality": "Chinese text must be exact, readable, and suitable for formal reporting"
},
"layout_patterns": [
"problem-process-result-next steps",
"timeline plus milestone cards",
"technical architecture diagram with key takeaways",
"summary dashboard with achievements, issues, learnings, and plan",
"defense slide with research question, method, evidence, and conclusion"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. Choose and adapt the composition according to each slide's semantic role; avoid repeating the same blueprint on adjacent slides unless it is a deliberate repeated sequence.",
"layout_blueprints": [
{
"name": "work review summary",
"sections": [
{"position": "top", "count": 1, "labels": ["slide title and context subtitle"]},
{"position": "left column", "count": 3, "labels": ["目标", "完成情况", "关键结果"]},
{"position": "right column", "count": 2, "labels": ["问题与风险", "下一步计划"]},
{"position": "bottom", "count": 1, "labels": ["one-sentence takeaway"]}
]
},
{
"name": "technical sharing flow",
"sections": [
{"position": "top-left", "count": 1, "labels": ["topic title and motivation"]},
{"position": "center", "count": 1, "labels": ["architecture / workflow / method diagram"]},
{"position": "right", "count": 3, "labels": ["核心机制", "实践经验", "注意事项"]},
{"position": "bottom", "count": 3, "labels": ["before", "after", "impact"]}
]
},
{
"name": "graduation defense evidence slide",
"sections": [
{"position": "top", "count": 1, "labels": ["研究问题 / 答辩主题"]},
{"position": "left", "count": 1, "labels": ["方法路线图"]},
{"position": "center", "count": 2, "labels": ["实验/项目证据", "关键数据"]},
{"position": "right", "count": 1, "labels": ["结论与贡献"]},
{"position": "bottom", "count": 1, "labels": ["限制与后续工作"]}
]
}
],
"visual_elements": {
"allowed": "timeline, checklist, progress bars, architecture blocks, process arrows, evidence cards, metric badges, issue/risk callouts, code-like panels, simple icons",
"avoid": "overly decorative poster layout, random stock photos, cute stickers, dense unreadable tables, exaggerated marketing style"
},
"rendering_constraints": [
"The slide should look suitable for a real workplace review, thesis defense, or technical sharing session.",
"Prioritize clarity, evidence, and logical flow over decoration.",
"All diagrams and labels should feel purposeful and related to the slide content.",
"No watermark, no unrelated logo, no slide number unless explicitly requested."
]
}
```
references/温暖手工风.md# 温暖手工风
**适用场景:**
- 儿童教育
- 文化活动
- 手工艺展示
- 温馨主题
- 亲子活动
- 艺术工作坊
- 社区公益项目
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "温暖手工风",
"best_for": "教育、公益、文化、成长、社区和需要温度感的主题演示",
"visual_direction": "warm handmade paper collage slide, tactile paper craft, gentle human-centered storytelling, cozy scrapbook composition",
"canvas": {
"aspect_ratio": "16:9",
"background": "warm cream paper #F5F1E8 or pale wood #E8DCC8 with subtle paper fibers",
"composition": "paper cutout title area, 3 paper note cards for key points, gentle illustration accents",
"density": "airy and comforting, generous breathing room"
},
"color_palette": {
"primary": "warm brown #A67C52 and deep cocoa #5C4033 for text",
"soft_colors": "dusty pink #F5C4B8, sage green #B8D4A8, sky blue #A8D8EA, sunset orange #FFA574, lavender #C9B1D0",
"surface": "cream, handmade paper, kraft paper, pastel sticky notes",
"rule": "all colors should feel soft, matte, and paper-like"
},
"typography": {
"title": "rounded handwritten or friendly display style",
"body": "clear friendly handwritten or rounded sans-serif Chinese text",
"emphasis": "paper labels, stitched tags, small handwritten captions",
"text_quality": "Chinese text must be accurate and legible"
},
"layout_patterns": [
"three torn-paper cards aligned naturally across the slide",
"scrapbook title ribbon with small illustrated stickers",
"central handmade diagram with taped labels",
"soft paper collage with one warm illustration and supporting notes"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. Choose and adapt the composition according to each slide's semantic role; avoid repeating the same blueprint on adjacent slides unless it is a deliberate repeated sequence.",
"layout_blueprints": [
{
"name": "paper note cards",
"sections": [
{"position": "top", "count": 1, "labels": ["torn-paper title ribbon"]},
{"position": "center", "count": 3, "labels": ["paper card 1", "paper card 2", "paper card 3"]},
{"position": "corners", "count": 4, "labels": ["small sticker", "tape strip", "plant doodle", "paper clip"]}
]
},
{
"name": "scrapbook story",
"sections": [
{"position": "left", "count": 1, "labels": ["warm illustration or paper collage scene"]},
{"position": "right", "count": 3, "labels": ["key point note", "example note", "takeaway note"]},
{"position": "bottom", "count": 1, "labels": ["handwritten closing line"]}
]
}
],
"visual_elements": {
"allowed": "paper texture, torn edges, tape, paper clips, buttons, watercolor blocks, simple doodles, plant stickers, soft shadows",
"avoid": "glossy plastic look, corporate sharp edges, harsh black text, neon colors, overly childish clutter"
},
"rendering_constraints": [
"The slide should feel handmade but still presentation-ready.",
"Paper shadows and textures should be subtle and not reduce text legibility.",
"No watermark, no unrelated logo, no slide number unless explicitly requested."
]
}
```
references/电子墨水杂志风.md# 电子墨水杂志风
**适用场景:**
- 线下分享
- 行业内部讲话
- AI / 科技产品发布
- Demo day
- 个人观点型演讲
- 非虚构叙事
- 需要强节奏感的主题演讲
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "电子墨水杂志风",
"best_for": "线下演讲、观点分享、AI/科技发布、非虚构叙事和需要强个人表达的横向演示",
"visual_direction": "electronic ink editorial presentation, premium magazine layout, serif headline, sans-serif body, monospace metadata, restrained WebGL-like ink flow background only as subtle texture, strong hero/non-hero rhythm",
"canvas": {
"aspect_ratio": "16:9",
"background": "off-white #F7F4EA, ink black #111111, deep indigo #1E2A78, forest ink #163B2F, kraft paper #D8C3A5, or dune sand #D9C29E depending on theme",
"composition": "editorial grid with strong margins, magazine-like hierarchy, hero pages alternating with quieter content pages",
"density": "moderate to low information density, designed for stage readability and narrative pacing"
},
"color_palette": {
"base": "paper-like light background or deep ink background",
"primary": "ink black, off-white, or deep indigo",
"accent": "one restrained accent such as vermilion, electric blue, forest green, or sand gold",
"metadata": "muted gray or low-contrast monochrome",
"rule": "use a curated theme palette; avoid random custom colors and decorative gradients"
},
"typography": {
"title": "large editorial serif Chinese headline or elegant high-contrast display type",
"body": "clean sans-serif, short paragraphs or compact bullets",
"metadata": "small monospace labels for chapter, date, source, index, or tags",
"text_quality": "Chinese text must be exact, readable, and typeset like a magazine spread"
},
"layout_patterns": [
"hero cover with oversized serif headline and subtle ink-flow background",
"chapter divider with one provocative sentence and metadata strip",
"data poster with one huge number, footnote, and tiny supporting labels",
"left text right image with image treated as editorial photography or abstract ink plate",
"big quote page with pull quote, source line, and large whitespace",
"before-after comparison with two editorial columns"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. Choose and adapt the composition according to each slide's semantic role; alternate hero and non-hero pages to create rhythm, and avoid repeating the same blueprint on adjacent slides unless it is a deliberate repeated sequence.",
"layout_blueprints": [
{
"name": "hero editorial opener",
"sections": [
{"position": "left or center", "count": 1, "labels": ["oversized serif headline"]},
{"position": "top or side edge", "count": 3, "labels": ["chapter label", "date/source", "short metadata"]},
{"position": "background", "count": 1, "labels": ["subtle ink-flow or paper texture"]},
{"position": "bottom", "count": 1, "labels": ["one-line thesis"]}
]
},
{
"name": "magazine argument spread",
"sections": [
{"position": "top-left", "count": 1, "labels": ["slide title"]},
{"position": "left column", "count": 2, "labels": ["argument block", "evidence block"]},
{"position": "right half", "count": 1, "labels": ["editorial image, diagram, or abstract visual plate"]},
{"position": "bottom", "count": 1, "labels": ["monospace metadata strip"]}
]
},
{
"name": "data broadsheet",
"sections": [
{"position": "center-left", "count": 1, "labels": ["huge number or keyword"]},
{"position": "right column", "count": 3, "labels": ["supporting fact 1", "supporting fact 2", "supporting fact 3"]},
{"position": "bottom-left", "count": 1, "labels": ["source / caveat / time range"]}
]
}
],
"visual_elements": {
"allowed": "editorial rules, thin dividers, paper grain, subtle ink-flow texture, cropped photography zones, metadata strips, chapter marks, pull quotes, huge numerals",
"avoid": "template-like cards, shiny corporate gradients, cute illustrations, dense tables, dashboard overload, generic stock-photo collage"
},
"rendering_constraints": [
"The slide should feel like an electronic magazine page adapted for a stage presentation.",
"Use large readable typography and strong whitespace.",
"Do not turn every page into a hero cover; alternate page intensity across the deck.",
"No watermark, no unrelated logo, no slide number unless explicitly requested."
]
}
```
references/科研答辩风.md# 科研答辩风
**适用场景:**
- 科研项目申报答辩
- 基金申请与重点专项汇报
- 中期检查与结题验收
- 论文开题、预答辩和毕业答辩
- 课题分解、技术路线、研究基础、风险分析和预期成果展示
- 高校、科研院所、实验室和产学研项目的正式汇报
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "科研答辩风",
"best_for": "科研项目申报、基金答辩、课题中期检查、结题验收、论文答辩和实验室阶段性成果汇报等正式学术场景",
"visual_direction": "formal Chinese academic research defense deck, authoritative but adaptable project presentation, evidence-driven layout, structured scientific diagrams, concise conclusions, and disciplined visual hierarchy",
"canvas": {
"aspect_ratio": "16:9",
"background": "clean white #FFFFFF with subtle light gray #F5F7FA content background",
"composition": "structured academic slide with title area, evidence area, explanatory diagrams, tables, callouts, or conclusion area chosen according to slide content",
"density": "medium-high to high information density, suitable for serious academic defense, while keeping clear reading order and alignment"
},
"color_palette": {
"primary": "deep academic blue #003F8F or #004A9F",
"secondary": "research blue #0B5CAD and pale blue #EAF2FF",
"accent": "formal red #B5121B or #C00000 used sparingly for key conclusions, risks, breakthroughs, and emphasized phrases",
"neutral": "black #111111, dark gray #333333, light border gray #D8DEE8, pale table fill #F3F6FA",
"optional": "muted gold #F3DFA2 only for cover or major title emphasis",
"rule": "prefer blue for structure and red for critical arguments, but adapt emphasis to the topic and source material; avoid colorful decorative palettes, heavy gradients, playful icons, and casual illustration styles"
},
"typography": {
"title": "bold Chinese sans-serif, large, formal, report-like, left aligned",
"section_labels": "white bold Chinese text on deep blue blocks or arrow labels",
"body": "dense but readable Chinese sans-serif, mostly black, key phrases in red bold",
"tables": "compact academic table typography with clear header rows and restrained category emphasis",
"text_quality": "Chinese text must be exact, readable, non-garbled, and suitable for formal research defense"
},
"page_system": {
"header": "usually use a clear title area with optional section number and optional project/institution mark; cover pages and divider pages may use a different composition",
"divider": "optional thin horizontal rule, blue segment, or subtle academic separator when it helps hierarchy",
"body": "structured content area with light borders, module labels, evidence cards, diagrams, or tables as needed",
"footer": "optional one-sentence takeaway strip or conclusion line when the slide needs a strong final claim",
"logo_rule": "use only user-provided logos or a neutral text placeholder such as 项目标识; do not invent real institution logos or unrelated brand names"
},
"layout_patterns": [
"research background with literature timeline, policy/report evidence, and problem statement",
"research status with left-side evidence collage and right-side challenge comparison",
"topic decomposition with multi-column task chain and right-side vertical research path",
"research content page with three evidence cards and supporting mechanism images",
"core objective page with arrow label, large target sentence, technical route, and mechanism diagram",
"platform support page with equipment photos, prior achievements, certificates, and bullet evidence",
"risk analysis four-quadrant grid with risk type, risk judgment, mitigation evidence, and bottom conclusion",
"expected outcomes table with category, measurable indicators, and assessment method"
],
"layout_usage_rule": "Use these patterns as flexible starting points, not mandatory templates. Keep the deck's academic tone, disciplined hierarchy, restrained palette, and evidence-first logic, while adapting the body layout to each slide's role and source material.",
"layout_blueprints": [
{
"name": "研究背景 / 研究现状",
"sections": [
{"position": "top", "count": 1, "labels": ["one-sentence problem or status statement with optional emphasized phrases"]},
{"position": "left large area", "count": 1, "labels": ["literature timeline / technical evolution / evidence collage"]},
{"position": "right column", "count": 1, "labels": ["report evidence / industry challenge / application pain point"]},
{"position": "bottom", "count": 1, "labels": ["optional takeaway conclusion"]}
]
},
{
"name": "课题分解 / 技术路线",
"sections": [
{"position": "top", "count": 1, "labels": ["project objective sentence"]},
{"position": "middle", "count": 3, "labels": ["课题一", "课题二", "课题三"]},
{"position": "left side", "count": 2, "labels": ["应用基础研究", "关键任务核心内容"]},
{"position": "right side", "count": 4, "labels": ["性能研究", "应用验证", "深入探索", "关系研究"]},
{"position": "bottom", "count": 1, "labels": ["chain-summary sentence"]}
]
},
{
"name": "核心目标 / 研究目标",
"sections": [
{"position": "top-left", "count": 1, "labels": ["blue arrow label: 核心目标 or 研究目标"]},
{"position": "top-right", "count": 1, "labels": ["target sentence with key phrases"]},
{"position": "middle", "count": 2, "labels": ["content detail box 1", "content detail box 2"]},
{"position": "bottom", "count": 3, "labels": ["method diagram", "mechanism image", "application validation image"]},
{"position": "bottom-center", "count": 1, "labels": ["technical route statement"]}
]
},
{
"name": "研究内容 / 证据展示",
"sections": [
{"position": "top", "count": 1, "labels": ["research content sentence with key variables"]},
{"position": "middle", "count": 3, "labels": ["研究内容一", "研究内容二", "研究内容三"]},
{"position": "inside each card", "count": 3, "labels": ["blue title bar", "scientific figure or chart", "short evidence caption"]},
{"position": "bottom", "count": 1, "labels": ["integrated conclusion or mechanism statement"]}
]
},
{
"name": "风险分析",
"sections": [
{"position": "top", "count": 1, "labels": ["risk control thesis statement"]},
{"position": "middle grid", "count": 4, "labels": ["技术风险", "安全风险", "性能风险", "管理风险"]},
{"position": "bottom", "count": 1, "labels": ["supporting scientific images and final conclusion"]}
]
},
{
"name": "预期成果 / 考核指标",
"sections": [
{"position": "top", "count": 3, "labels": ["高价值", "全覆盖", "可量化"]},
{"position": "middle", "count": 1, "labels": ["large table: 类别 / 具体指标 / 考核方式"]},
{"position": "left table column", "count": 4, "labels": ["理论创新与知识产权", "技术突破与材料开发", "人才培养与学术影响", "长远发展潜力"]}
]
}
],
"visual_elements": {
"allowed": "academic tables, blue arrow labels, restrained red emphasis text, flow arrows, technical route diagrams, paper figure collages, mechanism diagrams, laboratory or equipment photos, certificates, report thumbnails, structured grids, light shadows",
"avoid": "minimalist empty pages, marketing hero layouts, cartoon illustrations, decorative gradients, random icons, playful stickers, excessive whitespace, vague stock photos, fruits, animals, people cutouts, unrelated decorative objects"
},
"writing_style": {
"sentence_patterns": [
"系统研究……并揭示……机制",
"构建……体系,实现……精准调控",
"围绕……形成完整闭环研究链条",
"通过……验证……性能",
"可有效规避或化解……风险",
"所有成果均设置具体、可测量指标"
],
"emphasis_rule": "Use red bold text selectively for the scientific problem, key breakthrough, risk judgment, measurable outcome, or page-level conclusion; do not over-highlight every sentence."
},
"rendering_constraints": [
"The slide must look like a serious Chinese academic research defense deck, not a commercial pitch deck.",
"Keep a recognizable academic presentation system across slides, but do not force every page into the same header or grid.",
"Every page should have a clear top-to-bottom or left-to-right reading order.",
"Use high-density scientific content, but keep text blocks aligned and readable.",
"Use blue structural labels and red conclusion phrases when useful, without making the page mechanically blue-red.",
"Do not include fruit, people, animals, random objects, fictional brand marks, or unrelated decorative icons.",
"No watermark, no unrelated logo, no fake real institution logo, no extra slide number unless requested."
]
}
```
references/麦肯锡风格.md# 麦肯锡风格
**适用场景:**
- 商业咨询型 PPT / PPTX 整套演示
- PPT 封面页、章节页、关键观点页
- 咨询分析页、框架页、方法论页
- 流程图、路线图、矩阵图、价值链、五力模型、2x2 定位图
- 结论页、建议页、执行路线页
- 商业策略、增长、转型、组织、效率、机会、风险等主题
- 需要“文字主导 + 商业隐喻 + 咨询报告质感”的视觉型演示
**GPT-Image-2 风格 Brief:**
```json
{
"type": "16:9 full-slide PowerPoint image",
"style_name": "麦肯锡风格",
"best_for": "商业咨询型 PPT / PPTX 整套演示,包括封面页、章节页、关键观点页、咨询分析页、框架页、方法论页、流程图、路线图、矩阵图、价值链、五力模型、2x2 定位图、结论页、建议页和执行路线页;适合商业策略、增长、转型、组织、效率、机会、风险等需要麦肯锡式理性气质、高端咨询报告视觉、标题信息重构、商业隐喻和清晰商业叙事的场景",
"visual_direction": "McKinsey / BCG / Bain style rational consulting visual, premium executive report cover, modernist typographic poster, art-directed business metaphor, reconstructed title as architectural structure, precision annotation system, sharp Swiss grid, light blue-gray white palette, clean corporate editorial design, boardroom-ready restraint with real graphic tension",
"canvas": {
"aspect_ratio": "16:9",
"background": "white or very pale cool gray field with optional subtle paper grain, light print texture, precise thin grid, quiet margin system, and restrained report-like metadata; avoid turning metadata into a repeated slide template",
"composition": "first decide the PPT slide role: title/divider/key-message slides use one giant reconstructed core word, one dominant metaphor, strong negative space, and an intentional focal axis, vanishing point, or typographic architecture; analysis/framework/process slides use one core analytical structure and 3-6 ordered modules",
"density": "title/divider/key-message slides are low-density with strong whitespace and one obvious center; analysis/framework/process slides are medium density, richer but still ordered, scannable, and built around one reading path"
},
"color_palette": {
"primary": "white, off-white, light cool gray, pale blue-gray, ink blue-gray #243447 used as text or hairline emphasis",
"secondary": "restrained consulting blue-gray, steel blue, mist blue, precise hairline gray",
"accent": "very small restrained blue, steel blue, or muted gray-blue only for key node, value point, end point, decision point, or important arrow; avoid orange as a default accent",
"neutral": "near-black #111111 only for small text, slate gray #475569, cool gray #6B7280, light border gray #D8DEE8, very pale background gray #F6F8FA",
"rule": "colors must feel rational, premium, controlled, and light; avoid large dark navy blocks, orange-led accents, blue-purple neon, cyberpunk glow, fancy gradients, rainbow colors, high-saturation advertising colors, and large warm backgrounds"
},
"typography": {
"title": "modern, clear, restrained, stable consulting-report typography; the main visual title should be custom-reconstructed with thin structural strokes, semi-hollow forms, split strokes, extended strokes, modular cuts, embedded grids, or architectural skeletons; keep it readable and avoid crude bold filled type",
"body": "short precise Chinese/English labels, no long paragraphs, clear hierarchy, micro-typography for tags, variables, legends, nodes, occasional footers, and conclusions",
"labels": "use restrained English classification labels such as STRATEGY, MARKET INSIGHT, BUSINESS MODEL, OPERATING MODEL, GROWTH SYSTEM, TRANSFORMATION, INDUSTRY VIEW, METHODOLOGY, RECOMMENDATION, ROADMAP, 2026",
"text_quality": "main title, subtitle, brand names, people names, terms, English capitalization, and Chinese characters must be accurate; preserve the user's core input term; do not garble Chinese or invent unrelated labels"
},
"layout_patterns": [
"PPT title or divider slide: top information bar with full title, English category tags, year or section number; central giant reconstructed core word; 2-3 edge anchor concepts; optional short takeaway only when it strengthens the page",
"PPT key-message slide: extract a 2-8 character Chinese core word or 1-4 word English phrase as the main visual, keep the full claim as smaller subtitle text, and use only one dominant metaphor",
"PPT title/key-message slide: fuse one business metaphor into the typography itself, such as path through strokes, funnel in negative space, matrix grid as character skeleton, data flow emerging from the word, fault line cutting the word, or architecture becoming the word body",
"PPT title/key-message slide: make the core word feel engineered and art-directed: letter strokes may be cropped, extended, split, semi-transparent, line-drawn, or connected to crosshairs, target marks, measurement lines, nodes, and micro labels",
"PPT title/key-message slide: create graphic tension through scale contrast, asymmetrical balance, precise alignment, restrained perspective rays, one focal point, and large quiet whitespace; avoid flat centered template composition",
"PPT title/divider archetype: typographic science/report slide with ultra-thin Chinese character strokes, molecular or data-path particles crossing the word, side annotations, and optional evidence labels",
"PPT key-message archetype: executive insight slide with a bold but controlled headline, one ribbon/path/spiral/arrow metaphor, vertical outcome scale, small right-side labels, and wide blank field",
"PPT title/divider archetype: modernist typographic poster slide with oversized English or Chinese letterforms, curved line systems, crop marks, circular labels, and sparse deck metadata",
"PPT analysis/framework slide: top claim area, central structure such as process, matrix, system, path, funnel, value chain, or layered architecture, side modules for insight, variables, constraints, risks, and opportunities, and an optional recommendation area if the slide needs a takeaway",
"PPT analysis/process slide: use a clear reading path from left to right, top to bottom, problem to solution, input to output, current state to opportunity, or mechanism to result",
"PPT framework slide: keep one large analytical mechanism as the hero and use micro annotations, numbered labels, hairline connectors, and restrained side notes instead of icon-heavy cards",
"PPT analysis archetype: BCG-style matrix, five-forces map, value-chain map, operating-model map, roadmap, or 2x2 decision map with one dominant diagram, disciplined sidebars, small methodology labels, and at most one dark emphasis block",
"metaphor library: choose exactly one most accurate dominant metaphor from funnel, path, ladder, matrix, coordinate, flywheel, node network, data flow, threshold, window, defense line, fault line, container, compass, or architecture",
"business metaphor matching: funnel for conversion and filtering; path for strategy and transformation; ladder for maturity and value upgrade; matrix for positioning and priority; coordinate for market map and risk-return; flywheel for growth loops; node network for system collaboration; data flow for automation and efficiency; threshold for gating and risk control; window for timing and opportunity; defense line for governance and compliance; fault line for structural shifts; container for value pools; compass for direction choice; architecture for organization and capability base"
],
"layout_usage_rule": "Use layout_blueprints as candidate starting points only. First identify the PPT slide role: title slide, section divider, key-message slide, analysis/framework slide, process/roadmap slide, matrix/positioning slide, or summary/recommendation slide. For title, divider, and key-message slides, use one giant reconstructed title, one dominant metaphor, low information density, strong whitespace, very few auxiliary words, and a visible graphic idea in the typography itself. For analysis, framework, process, and recommendation slides, use 3-6 main modules, one core structure, one dominant metaphor, concise labels, small legends or numbering, and a clear reading path. Keep a McKinsey-like rational, professional, restrained, premium identity while varying slide layouts across the deck. The slide should feel designed, not merely organized: use optical balance, scale contrast, precise margins, micro labels, and one memorable visual decision. Do not turn a secondary motif, footer, icon, callout strip, or module arrangement into a repeated master layout. Avoid default card grids, generic flowchart templates, and slides that only look organized but have no visual concept.",
"layout_blueprints": [
{
"name": "PPT 封面页 / 章节页: 重构标题 + 单一商业隐喻",
"sections": [
{"position": "top", "count": 1, "labels": ["完整标题 / 英文分类标签 / 年份或编号"]},
{"position": "center", "count": 1, "labels": ["giant reconstructed core word"]},
{"position": "inside typography", "count": 1, "labels": ["one dominant metaphor fused into typography"]},
{"position": "left and right edge", "count": 3, "labels": ["起点词", "终点词", "价值词"]},
{"position": "optional bottom or side", "count": 1, "labels": ["short takeaway only when useful"]}
]
},
{
"name": "PPT 关键观点页: 字体结构 + 咨询报告系统",
"sections": [
{"position": "top-left", "count": 1, "labels": ["small complete title system"]},
{"position": "top-right", "count": 4, "labels": ["STRATEGY", "INSIGHT", "REPORT COVER", "2026"]},
{"position": "center 70%", "count": 1, "labels": ["large typographic architecture"]},
{"position": "background", "count": 1, "labels": ["thin grid, nodes, coordinates, hairline rules"]},
{"position": "optional edge metadata", "count": 3, "labels": ["CLARITY", "STRUCTURE", "IMPACT or slide-specific tags"]}
]
},
{
"name": "PPT 分析页: 核心结构 + 左右辅助模块",
"sections": [
{"position": "top", "count": 1, "labels": ["title, subtitle, classification tags"]},
{"position": "center", "count": 1, "labels": ["core flow / matrix / system / funnel / value chain"]},
{"position": "left", "count": 2, "labels": ["background insight", "key variable"]},
{"position": "right", "count": 2, "labels": ["risk or constraint", "opportunity or action"]},
{"position": "optional bottom or right rail", "count": 1, "labels": ["1-3 concise conclusions when needed"]}
]
},
{
"name": "PPT 框架页: 矩阵 / 坐标 / 路线图",
"sections": [
{"position": "top", "count": 1, "labels": ["complete analytical title and short subtitle"]},
{"position": "main area", "count": 1, "labels": ["2x2 matrix / coordinate map / roadmap"]},
{"position": "inside structure", "count": 4, "labels": ["category", "priority", "risk", "opportunity"]},
{"position": "side rail", "count": 1, "labels": ["methodology notes and legend"]},
{"position": "optional bottom or side note", "count": 1, "labels": ["executive implication when needed"]}
]
}
],
"visual_elements": {
"allowed": "custom typographic reconstruction, thin rules, precision grid, small nodes, coordinates, occasional target marks, crosshair marks, measurement lines, path lines, subtle vanishing rays, restrained arrows, faint perspective, variable report metadata, small numbering, minimal business diagrams, restrained chart fragments, one dark emphasis area when analytically necessary, paper grain, light print texture, subtle shadows, small legends, micro annotations",
"avoid": "ordinary big title, no title reconstruction, no visual metaphor, disconnected metaphor and text, unrelated background decoration, generic PPT template, default rounded card grid, repeated decorative motif across all slides, repeated header/footer treatment across all slides, repetitive icon rows, icon-heavy modules, business people icons, handshake icons, cartoon characters, robot avatars, sci-fi blue-purple background, uncontrolled complex infographic, content with no hierarchy, overly thick or ornate typography, unreadable main title, overfilled composition, cheap self-media poster feel"
},
"rendering_constraints": [
"The image must feel like a premium top-tier consulting-company visual work, not an official McKinsey template, not an ordinary PPT page, and not a social-media template.",
"Text must be part of the structure, not just placed on top of the layout.",
"The slide must have a visible graphic idea: a typographic structure, focal axis, engineered metaphor, or analytical mechanism that would still be recognizable without decorative labels.",
"If the source title is long, extract a core visual word or phrase and preserve the complete title as smaller text in the header, side title system, or subtitle.",
"Choose exactly one dominant metaphor for PPT title, divider, and key-message slides; do not stack multiple unrelated metaphors.",
"For PPT analysis, framework, process, roadmap, and recommendation slides, all modules must unfold around one dominant metaphor and one core analytical structure.",
"For PPT title, divider, and key-message slides, allow only 1 giant main visual title, 1 complete small title, 2-4 English tags, 1 dominant metaphor, optional short conclusion, and a few thin lines, nodes, numbering, or grid marks.",
"For PPT title, divider, and key-message slides, forbid 5 or more modules, 3 or more charts, long explanations, dense icons, multiple conclusion boxes, and full process breakdown.",
"For PPT analysis, framework, process, roadmap, and recommendation slides, use 3-6 main modules, 1 core large structure, 1-2 auxiliary small structures, key data, labels, numbering, annotations, and optional recommendation or implication area.",
"For PPT analysis, framework, process, roadmap, and recommendation slides, forbid 8 or more modules, too many colors, too many icons, unlayered text, long paragraphs inside every module, and data/graphics without logical relationship.",
"Use precise thin lines, small labels, grid, nodes, and a restrained light blue-gray-white palette; avoid large areas of heavy dark blue.",
"Avoid making every page a neat card layout; use scale, negative space, alignment, and typographic reconstruction to create design value.",
"Across a deck, keep palette, typography, line language, and report discipline consistent, but vary the dominant structure, secondary motif, and module arrangement according to each slide's role.",
"Even if most decoration is removed, the core structure and business logic must still stand.",
"Do not invent a real McKinsey logo, client logo, confidential label, proprietary watermark, unrelated logo, or watermark."
]
}
```
requirements.txtpython-pptx>=1.0.2 Pillow>=10.0.0 openai>=2.0.0 filelock>=3.16.0
scripts/assemble_ppt.py#!/usr/bin/env python3
"""
PPT 组装脚本
将一个目录中的幻灯片图片组装成 PowerPoint 演示文稿。
每张图片会被插入为一页幻灯片,充满整个页面。
"""
import argparse
import os
import re
import sys
import tempfile
from typing import Dict, List, Optional, Tuple
def dependency_hint() -> str:
runtime_home = os.path.expanduser(os.environ.get("CODEX_PPT_HOME", "~/.codex-ppt-skill"))
python = os.path.join(
runtime_home,
".venv",
"Scripts" if os.name == "nt" else "bin",
"python.exe" if os.name == "nt" else "python",
)
runtime_script = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"codex_ppt_runtime.py",
)
return (
f"请运行: python3 {runtime_script} bootstrap\n"
f"或直接运行: {python} -m pip install -r "
f"{os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'requirements.txt')}"
)
def get_slide_images(ppt_project_dir: str) -> List[str]:
"""
获取幻灯片图片文件列表,按页码排序
Args:
ppt_project_dir: PPT 项目目录(包含 origin_image 子目录)
Returns:
按顺序排列的图片文件路径列表
"""
# 从 origin_image 子目录读取
origin_image_dir = os.path.join(ppt_project_dir, "origin_image")
if not os.path.exists(origin_image_dir):
print(f"错误:origin_image 目录不存在: {origin_image_dir}")
return []
print(f"从 origin_image 目录读取图片: {origin_image_dir}")
# 支持的图片格式
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp'}
# 只获取正式幻灯片图片,避免 sample_slide.png、草稿图或参考图被误装入 PPT。
slide_name_pattern = re.compile(r"^slide_(\d+)\.(png|jpe?g|gif|bmp)$", re.IGNORECASE)
image_files = []
for file in os.listdir(origin_image_dir):
file_path = os.path.join(origin_image_dir, file)
if os.path.isfile(file_path):
ext = os.path.splitext(file)[1].lower()
if ext in image_extensions and slide_name_pattern.match(file):
image_files.append(file_path)
def slide_sort_key(path: str) -> Tuple[int, str]:
filename = os.path.basename(path)
stem = os.path.splitext(filename)[0]
return (int(re.search(r"^slide_(\d+)", stem).group(1)), filename.lower())
# 按页码排序,避免 slide_10 排在 slide_2 前面。
# 同页码多文件时按文件名稳定排序。
image_files.sort(key=slide_sort_key)
return image_files
def load_speaker_notes(ppt_project_dir: str) -> Dict[int, str]:
"""
从 speech.md 读取每页演讲备注。
支持以下标题格式:
- ## Slide 1: 标题
- ### Slide 1: 标题
- ## 第 1 页:标题
"""
speech_path = os.path.join(ppt_project_dir, "speech.md")
if not os.path.exists(speech_path):
return {}
with open(speech_path, "r", encoding="utf-8") as f:
content = f.read()
notes: Dict[int, str] = {}
current_slide: Optional[int] = None
current_lines: List[str] = []
heading_pattern = re.compile(r"^#{2,4}\s*(?:Slide\s*(\d+)|第\s*(\d+)\s*页)\b.*$", re.IGNORECASE)
def flush_current() -> None:
if current_slide is None:
return
text = "\n".join(current_lines).strip()
if text:
notes[current_slide] = text
for line in content.splitlines():
match = heading_pattern.match(line.strip())
if match:
flush_current()
current_slide = int(match.group(1) or match.group(2))
current_lines = []
continue
if current_slide is not None:
current_lines.append(line)
flush_current()
return notes
def compress_image_if_needed(
image_path: str,
max_size_mb: float = 2.0,
quality_step: int = 5
) -> Optional[str]:
"""
如果图片超过指定大小,压缩图片并返回临时文件路径
Args:
image_path: 原始图片路径
max_size_mb: 最大文件大小(MB)
quality_step: 每次降低的质量步进
Returns:
str: 如果需要压缩,返回临时文件路径;否则返回 None
"""
max_size_bytes = max_size_mb * 1024 * 1024
# 检查原始文件大小
file_size = os.path.getsize(image_path)
if file_size <= max_size_bytes:
# 不需要压缩
return None
print(f" 图片大小 {file_size / 1024 / 1024:.2f}MB,需要压缩...")
try:
from PIL import Image
# 打开图片
img = Image.open(image_path)
# 转换 RGBA 到 RGB(如果需要保存为 JPEG)
if img.mode in ('RGBA', 'LA', 'P'):
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
img = background
# 创建临时文件
temp_fd, temp_path = tempfile.mkstemp(suffix='.jpg')
os.close(temp_fd)
# 从高质量开始尝试压缩
quality = 95
while quality > 20:
img.save(temp_path, 'JPEG', quality=quality, optimize=True)
compressed_size = os.path.getsize(temp_path)
if compressed_size <= max_size_bytes:
print(f" 压缩成功: {compressed_size / 1024 / 1024:.2f}MB (质量: {quality})")
return temp_path
quality -= quality_step
# 如果还是太大,尝试调整尺寸
print(f" 质量压缩不足,尝试缩小尺寸...")
scale = 0.9
while scale > 0.3:
new_width = int(img.width * scale)
new_height = int(img.height * scale)
resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
resized_img.save(temp_path, 'JPEG', quality=85, optimize=True)
compressed_size = os.path.getsize(temp_path)
if compressed_size <= max_size_bytes:
print(f" 压缩成功: {compressed_size / 1024 / 1024:.2f}MB (缩放: {scale:.0%})")
return temp_path
scale -= 0.1
# 实在压不下去了,返回最后的结果
print(f" 警告:无法压缩到 {max_size_mb}MB 以下,使用最小尺寸版本")
return temp_path
except ImportError:
print("错误:未安装 Pillow 库")
print(dependency_hint())
return None
except Exception as e:
print(f" 警告:图片压缩失败: {e}")
if 'temp_path' in locals() and os.path.exists(temp_path):
os.remove(temp_path)
return None
def create_presentation(
image_files: List[str],
output_path: str,
aspect_ratio: str = "16:9",
speaker_notes: Optional[Dict[int, str]] = None,
) -> bool:
"""
创建 PowerPoint 演示文稿
Args:
image_files: 幻灯片图片文件列表
output_path: 输出 PPT 文件路径
aspect_ratio: 幻灯片宽高比(16:9 或 4:3)
Returns:
bool: 成功返回 True,失败返回 False
"""
try:
try:
from pptx import Presentation
from pptx.util import Inches
except ImportError:
print("错误:未安装 python-pptx 库")
print(dependency_hint())
return False
# 创建演示文稿
prs = Presentation()
# 设置幻灯片尺寸
if aspect_ratio == "16:9":
prs.slide_width = Inches(10)
prs.slide_height = Inches(5.625)
elif aspect_ratio == "4:3":
prs.slide_width = Inches(10)
prs.slide_height = Inches(7.5)
else:
print(f"警告:不支持的宽高比 {aspect_ratio},使用默认值 16:9")
prs.slide_width = Inches(10)
prs.slide_height = Inches(5.625)
speaker_notes = speaker_notes or {}
# 添加每一页幻灯片
temp_files_to_cleanup = []
for i, image_path in enumerate(image_files, 1):
if not os.path.exists(image_path):
print(f"警告:图片文件不存在: {image_path}")
continue
# 压缩图片(如果需要)
compressed_path = compress_image_if_needed(image_path, max_size_mb=2.0)
# 使用压缩后的图片或原图
image_to_use = compressed_path if compressed_path else image_path
# 记录临时文件以便后续清理
if compressed_path:
temp_files_to_cleanup.append(compressed_path)
# 使用空白布局(索引 6)
blank_slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(blank_slide_layout)
# 将图片添加到幻灯片,充满整个页面
slide.shapes.add_picture(
image_to_use,
left=0,
top=0,
width=prs.slide_width,
height=prs.slide_height
)
note_text = speaker_notes.get(i)
if note_text:
notes_frame = slide.notes_slide.notes_text_frame
notes_frame.clear()
notes_frame.text = note_text
print(f"✓ 已添加第 {i} 页: {os.path.basename(image_path)}")
# 确保输出目录存在
output_dir = os.path.dirname(output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir)
# 保存演示文稿
prs.save(output_path)
print(f"\n✓ PPT 文件已保存: {output_path}")
print(f" 总页数: {len(image_files)}")
if speaker_notes:
matched_notes = sum(1 for i in range(1, len(image_files) + 1) if speaker_notes.get(i))
print(f" 已写入备注: {matched_notes}/{len(image_files)} 页")
# 清理临时文件
for temp_file in temp_files_to_cleanup:
try:
if os.path.exists(temp_file):
os.remove(temp_file)
except Exception as e:
print(f"警告:清理临时文件失败 {temp_file}: {e}")
return True
except Exception as e:
print(f"错误:创建 PPT 失败: {e}")
import traceback
traceback.print_exc()
# 即使失败也要清理临时文件
if 'temp_files_to_cleanup' in locals():
for temp_file in temp_files_to_cleanup:
try:
if os.path.exists(temp_file):
os.remove(temp_file)
except:
pass
return False
def main():
parser = argparse.ArgumentParser(
description='将幻灯片图片组装成 PowerPoint 演示文稿',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
示例用法:
# 基本用法
# 会在 /path/to/base/ 下查找 MyPresentation/ 文件夹
# 从 MyPresentation/origin_image/ 读取图片
# 将 PPT 保存为 MyPresentation/MyPresentation.pptx
python assemble_ppt.py /path/to/base/ MyPresentation.pptx
# 指定 4:3 宽高比
python assemble_ppt.py /path/to/base/ MyPresentation.pptx --aspect-ratio 4:3
# 只初始化目录,不生成 PPT
python assemble_ppt.py /path/to/base/ MyPresentation.pptx --init
文件夹结构要求:
/path/to/base/MyPresentation/
├── origin_image/
│ ├── slide_01.png
│ ├── slide_02.png
│ └── ...
├── speech.md (可选,将写入 PPT 备注)
└── MyPresentation.pptx (将在此生成)
注意:
- 图片文件必须放在 origin_image 子目录中
- 只会读取 slide_01.png、slide_02.png 这类正式图片,其他图片会被忽略
- 图片文件按页码排序
- 建议图片文件命名为: slide_01.png, slide_02.png, ...
- 每张图片会充满整个幻灯片页面
- 如果项目目录下存在 speech.md,会按 Slide N 标题写入每页备注
'''
)
parser.add_argument('base_dir', help='基础目录(PPT 项目文件夹的父目录)')
parser.add_argument('output', help='输出 PPT 文件名 (.pptx)')
parser.add_argument('--aspect-ratio', '--ar',
choices=['16:9', '4:3'],
default='16:9',
help='幻灯片宽高比 (默认: 16:9)')
parser.add_argument('--init',
action='store_true',
help='只创建 PPT 项目目录和 origin_image 子目录,不生成 PPT')
args = parser.parse_args()
# 确保输出文件有 .pptx 扩展名
output_filename = args.output
if not output_filename.lower().endswith('.pptx'):
output_filename += '.pptx'
# 获取 PPT 名称(不含扩展名)
ppt_name = os.path.splitext(os.path.basename(output_filename))[0]
# 构建 PPT 项目目录
ppt_project_dir = os.path.join(args.base_dir, ppt_name)
origin_image_dir = os.path.join(ppt_project_dir, "origin_image")
if args.init:
os.makedirs(origin_image_dir, exist_ok=True)
print(f"✓ PPT 项目目录已准备好: {ppt_project_dir}")
print(f"✓ 幻灯片图片目录已准备好: {origin_image_dir}")
sys.exit(0)
if not os.path.exists(ppt_project_dir):
print(f"错误:PPT 项目目录不存在: {ppt_project_dir}")
print("如需初始化目录,请添加 --init 参数")
sys.exit(1)
if not os.path.exists(origin_image_dir):
print(f"错误:origin_image 目录不存在: {origin_image_dir}")
print("如需初始化目录,请添加 --init 参数")
sys.exit(1)
# 设置输出路径
output_path = os.path.join(ppt_project_dir, output_filename)
# 获取幻灯片图片
print(f"正在扫描 PPT 项目目录: {ppt_project_dir}")
image_files = get_slide_images(ppt_project_dir)
if not image_files:
print("错误:未找到任何图片文件")
print("支持的格式: .png, .jpg, .jpeg, .gif, .bmp")
print(f"\n请将幻灯片图片放置在: {origin_image_dir}/")
sys.exit(1)
print(f"找到 {len(image_files)} 张幻灯片图片\n")
speaker_notes = load_speaker_notes(ppt_project_dir)
if speaker_notes:
print(f"找到 {len(speaker_notes)} 页备注: {os.path.join(ppt_project_dir, 'speech.md')}\n")
# 创建演示文稿
print(f"正在创建 PPT (宽高比: {args.aspect_ratio})...")
print("-" * 50)
success = create_presentation(image_files, output_path, args.aspect_ratio, speaker_notes)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
scripts/codex_ppt_runtime.py#!/usr/bin/env python3
"""Manage the cross-agent runtime for codex-ppt.
The runtime lives outside any specific agent installation so Codex, Claude Code,
OpenClaw, Hermes Agent, and other local agents can share one virtual
environment and one API configuration.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
from typing import Dict, Iterable, Optional
import urllib.error
import urllib.parse
import urllib.request
import venv
DEFAULT_RUNTIME_HOME = "~/.codex-ppt-skill"
DEFAULT_MODEL = "gpt-image-2.5-flare"
ENV_FIELDS = ("OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_PPT_IMAGE_MODEL")
def _runtime_home() -> Path:
return Path(os.getenv("CODEX_PPT_HOME", DEFAULT_RUNTIME_HOME)).expanduser()
def _skill_root() -> Path:
return Path(__file__).resolve().parents[1]
def _venv_python(home: Path) -> Path:
if os.name == "nt":
return home / ".venv" / "Scripts" / "python.exe"
return home / ".venv" / "bin" / "python"
def _env_path(home: Path) -> Path:
return home / ".env"
def _requirements_path() -> Path:
return _skill_root() / "requirements.txt"
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _mask_secret(value: str) -> str:
if not value:
return ""
if len(value) <= 8:
return "****"
return f"{value[:4]}...{value[-4:]}"
def _parse_env_file(path: Path) -> Dict[str, str]:
if not path.exists():
return {}
values: Dict[str, str] = {}
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key:
values[key] = value
return values
def _quote_env_value(value: str) -> str:
if not value:
return ""
if any(ch.isspace() for ch in value) or "#" in value or '"' in value:
return json.dumps(value)
return value
def _write_env_file(path: Path, values: Dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = [
"# codex-ppt shared runtime configuration",
"# Used by Codex, Claude Code, OpenClaw, Hermes Agent, and other local agents.",
]
for key in ENV_FIELDS:
value = values.get(key, "")
if value:
lines.append(f"{key}={_quote_env_value(value)}")
lines.append("")
path.write_text("\n".join(lines), encoding="utf-8")
try:
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
def _load_env_values(home: Path) -> Dict[str, str]:
values = _parse_env_file(_env_path(home))
for key in ENV_FIELDS:
if os.getenv(key):
values[key] = os.environ[key]
return values
def _ensure_dirs(home: Path) -> None:
for child in (home, home / "cache", home / "logs"):
child.mkdir(parents=True, exist_ok=True)
def _bootstrap(args: argparse.Namespace) -> int:
home = _runtime_home()
_ensure_dirs(home)
python = _venv_python(home)
if not python.exists():
print(f"Creating virtual environment: {home / '.venv'}")
venv.EnvBuilder(with_pip=True, clear=False).create(home / ".venv")
else:
print(f"Virtual environment already exists: {home / '.venv'}")
requirements = _requirements_path()
if not requirements.exists():
_die(f"requirements.txt not found: {requirements}")
cmd = [str(python), "-m", "pip", "install", "-r", str(requirements)]
if args.upgrade:
cmd.insert(4, "-U")
print(f"Installing dependencies from: {requirements}")
subprocess.run(cmd, check=True)
print(f"Runtime ready: {home}")
return 0
def _config(args: argparse.Namespace) -> int:
home = _runtime_home()
_ensure_dirs(home)
env_file = _env_path(home)
values = _parse_env_file(env_file)
if args.api_key:
values["OPENAI_API_KEY"] = args.api_key
if args.base_url is not None:
values["OPENAI_BASE_URL"] = args.base_url.strip()
if args.model is not None:
values["CODEX_PPT_IMAGE_MODEL"] = args.model.strip()
if args.clear_base_url:
values.pop("OPENAI_BASE_URL", None)
_write_env_file(env_file, values)
print(f"Wrote {env_file}")
for key in ENV_FIELDS:
value = values.get(key, "")
if key == "OPENAI_API_KEY":
value = _mask_secret(value)
print(f"{key}={value or '<unset>'}")
return 0
def _check_python_imports(python: Path) -> bool:
if not python.exists():
print(f"venv: missing ({python})")
return False
code = "import openai, PIL, pptx; print('imports ok')"
proc = subprocess.run(
[str(python), "-c", code],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if proc.returncode != 0:
print("dependencies: missing or broken")
if proc.stderr.strip():
print(proc.stderr.strip())
return False
print("dependencies: ok")
return True
def _models_request(base_url: str, api_key: str, timeout: int) -> bool:
endpoint = base_url.rstrip("/") + "/models"
req = urllib.request.Request(
endpoint,
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
print(f"models endpoint: HTTP {resp.status}")
return 200 <= resp.status < 300
except urllib.error.HTTPError as exc:
body = exc.read(800).decode("utf-8", "replace")
print(f"models endpoint: HTTP {exc.code}")
if body:
print(body)
return False
except Exception as exc:
print(f"models endpoint: {exc.__class__.__name__}: {exc}")
return False
def _is_atlascloud_base_url(base_url: str) -> bool:
hostname = urllib.parse.urlparse(base_url).hostname or ""
return "atlascloud.ai" in hostname.lower()
def _doctor(args: argparse.Namespace) -> int:
home = _runtime_home()
env_file = _env_path(home)
print(f"runtime home: {home}")
print(f"env file: {env_file} ({'exists' if env_file.exists() else 'missing'})")
print(f"venv python: {_venv_python(home)}")
ok = True
ok = _check_python_imports(_venv_python(home)) and ok
values = _load_env_values(home)
api_key = values.get("OPENAI_API_KEY", "")
base_url = values.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
model = values.get("CODEX_PPT_IMAGE_MODEL", DEFAULT_MODEL)
print(f"OPENAI_API_KEY={'set (' + _mask_secret(api_key) + ')' if api_key else '<unset>'}")
print(f"OPENAI_BASE_URL={base_url}")
print(f"CODEX_PPT_IMAGE_MODEL={model}")
if "gpt-image-" not in model:
print("model check: warning, model name should contain 'gpt-image-'")
ok = False
else:
print("model check: ok")
if args.check_api:
if not api_key:
print("api check: skipped, OPENAI_API_KEY is unset")
ok = False
elif _is_atlascloud_base_url(base_url):
print("api check: AtlasCloud provider detected; skipping /models probe")
else:
ok = _models_request(base_url, api_key, args.timeout) and ok
return 0 if ok else 1
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Manage the codex-ppt shared runtime")
subparsers = parser.add_subparsers(dest="command", required=True)
bootstrap = subparsers.add_parser("bootstrap", help="Create shared venv and install deps")
bootstrap.add_argument("--upgrade", action="store_true")
bootstrap.set_defaults(func=_bootstrap)
config = subparsers.add_parser("config", help="Write or update shared .env")
config.add_argument("--api-key")
config.add_argument("--base-url")
config.add_argument("--clear-base-url", action="store_true")
config.add_argument("--model")
config.set_defaults(func=_config)
doctor = subparsers.add_parser("doctor", help="Check runtime and optional API access")
doctor.add_argument("--check-api", action="store_true")
doctor.add_argument("--timeout", type=int, default=30)
doctor.set_defaults(func=_doctor)
return parser
def main(argv: Optional[Iterable[str]] = None) -> int:
parser = _build_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
scripts/image_gen.py#!/usr/bin/env python3
"""Fallback CLI for codex-ppt image generation or editing with GPT Image models.
Used when Codex's built-in image tool is unavailable, when the user explicitly
opts into API mode, or needs output controls exposed by the API.
Defaults to gpt-image-2.5-flare and a structured prompt augmentation workflow.
Reads OPENAI_API_KEY, and optionally OPENAI_BASE_URL for provider adapters or
OpenAI-compatible proxy providers.
"""
from __future__ import annotations
import argparse
import asyncio
import base64
from io import BytesIO
import json
import os
from pathlib import Path
import re
import sys
import time
from typing import Any, Dict, Iterable, List, Optional, Tuple
from urllib.parse import urlparse
from image_providers import create_image_provider
from image_providers.atlascloud import atlascloud_model_for_operation
DEFAULT_MODEL = "gpt-image-2.5-flare"
DEFAULT_SIZE = "2560x1440"
DEFAULT_QUALITY = "medium"
DEFAULT_OUTPUT_FORMAT = "png"
DEFAULT_CONCURRENCY = 5
DEFAULT_DOWNSCALE_SUFFIX = "-web"
DEFAULT_OUTPUT_PATH = "output/imagegen/output.png"
GPT_IMAGE_MODEL_PREFIX = "gpt-image-"
ALLOWED_LEGACY_SIZES = {"1024x1024", "1536x1024", "1024x1536", "auto"}
ALLOWED_QUALITIES = {"low", "medium", "high", "xhigh", "max", "auto"}
ALLOWED_BACKGROUNDS = {"transparent", "opaque", "auto", None}
ALLOWED_INPUT_FIDELITIES = {"low", "high", None}
GPT_IMAGE_2_MIN_PIXELS = 655_360
GPT_IMAGE_2_MAX_PIXELS = 8_294_400
GPT_IMAGE_2_MAX_EDGE = 3840
GPT_IMAGE_2_MAX_RATIO = 3.0
MAX_IMAGE_BYTES = 50 * 1024 * 1024
MAX_BATCH_JOBS = 500
DEFAULT_RUNTIME_HOME = "~/.codex-ppt-skill"
ENV_FIELDS = ("OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_PPT_IMAGE_MODEL")
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _warn(message: str) -> None:
print(f"Warning: {message}", file=sys.stderr)
def _runtime_home() -> Path:
return Path(os.getenv("CODEX_PPT_HOME", DEFAULT_RUNTIME_HOME)).expanduser()
def _runtime_env_path() -> Path:
return _runtime_home() / ".env"
def _load_runtime_env() -> None:
path = _runtime_env_path()
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if key not in ENV_FIELDS or os.getenv(key):
continue
value = value.strip().strip('"').strip("'")
os.environ[key] = value
def _default_model() -> str:
return os.getenv("CODEX_PPT_IMAGE_MODEL", DEFAULT_MODEL)
def _api_base_url() -> Optional[str]:
return os.getenv("OPENAI_BASE_URL") or None
def _api_target_label() -> str:
base_url = _api_base_url()
if base_url:
if _is_atlascloud_base_url(base_url):
return f"AtlasCloud provider adapter (OPENAI_BASE_URL={base_url})"
return f"third-party image API or OpenAI-compatible proxy (OPENAI_BASE_URL={base_url})"
return "official OpenAI API (OPENAI_BASE_URL unset)"
def _is_atlascloud_base_url(base_url: str) -> bool:
hostname = urlparse(base_url).hostname or ""
return "atlascloud.ai" in hostname.lower()
def _preview_endpoint(kind: str) -> str:
base_url = _api_base_url()
if base_url and _is_atlascloud_base_url(base_url):
return "/api/v1/model/generateImage"
if kind == "edit":
return "/v1/images/edits"
return "/v1/images/generations"
def _preview_model(model: str, kind: str) -> str:
base_url = _api_base_url()
if base_url and _is_atlascloud_base_url(base_url):
operation = "edit" if kind == "edit" else "text-to-image"
return atlascloud_model_for_operation(model, operation)
return model
def _runtime_python_path() -> str:
home = _runtime_home()
if os.name == "nt":
return str(home / ".venv" / "Scripts" / "python.exe")
return str(home / ".venv" / "bin" / "python")
def _skill_root() -> Path:
return Path(__file__).resolve().parents[1]
def _dependency_hint(package: str, *, upgrade: bool = False) -> str:
package_arg = f"-U {package}" if upgrade else package
runtime_python = _runtime_python_path()
requirements = _skill_root() / "requirements.txt"
return (
"Install codex-ppt dependencies in the shared runtime first, for example "
f"`python3 {_skill_root() / 'scripts' / 'codex_ppt_runtime.py'} bootstrap`, "
f"or install {package} directly with `{runtime_python} -m pip install "
f"{package_arg}`. Requirements file: `{requirements}`."
)
def _ensure_api_key(dry_run: bool) -> None:
if os.getenv("OPENAI_API_KEY"):
print(f"OPENAI_API_KEY is set. API target: {_api_target_label()}.", file=sys.stderr)
return
if dry_run:
_warn(f"OPENAI_API_KEY is not set; dry-run only. API target: {_api_target_label()}.")
return
runtime_script = _skill_root() / "scripts" / "codex_ppt_runtime.py"
config_doc = _skill_root() / "docs" / "image-model-configuration.md"
base_url = _api_base_url()
model = _default_model()
if base_url:
command = (
f'python3 {runtime_script} config --api-key "your-api-key" '
f'--base-url "{base_url}" --model {model}'
)
target_hint = f"Detected third-party OpenAI-compatible API via OPENAI_BASE_URL={base_url}."
else:
command = f'python3 {runtime_script} config --api-key "your-api-key" --model {model}'
target_hint = "Detected official OpenAI API mode because OPENAI_BASE_URL is not set."
_die(
"OPENAI_API_KEY is not set for codex-ppt CLI/API fallback.\n"
f"{target_hint}\n"
"Use the built-in image tool if it is available. Otherwise configure the shared runtime once:\n"
f" {command}\n"
"To use a third-party proxy, set OPENAI_BASE_URL and the provider's model name.\n"
f"Details: {config_doc}"
)
def _read_prompt(prompt: Optional[str], prompt_file: Optional[str]) -> str:
if prompt and prompt_file:
_die("Use --prompt or --prompt-file, not both.")
if prompt_file:
if prompt_file == "-":
return sys.stdin.read().strip()
path = Path(prompt_file)
if not path.exists():
_die(f"Prompt file not found: {path}")
return path.read_text(encoding="utf-8").strip()
if prompt:
return prompt.strip()
_die("Missing prompt. Use --prompt or --prompt-file.")
return "" # unreachable
def _check_image_paths(paths: Iterable[str]) -> List[Path]:
resolved: List[Path] = []
for raw in paths:
path = Path(raw)
if not path.exists():
_die(f"Image file not found: {path}")
if path.stat().st_size > MAX_IMAGE_BYTES:
_warn(f"Image exceeds 50MB limit: {path}")
resolved.append(path)
return resolved
def _normalize_output_format(fmt: Optional[str]) -> str:
if not fmt:
return DEFAULT_OUTPUT_FORMAT
fmt = fmt.lower()
if fmt not in {"png", "jpeg", "jpg", "webp"}:
_die("output-format must be png, jpeg, jpg, or webp.")
return "jpeg" if fmt == "jpg" else fmt
def _parse_size(size: str) -> Optional[Tuple[int, int]]:
match = re.fullmatch(r"([1-9][0-9]*)x([1-9][0-9]*)", size)
if not match:
return None
return int(match.group(1)), int(match.group(2))
def _validate_gpt_image_2_size(size: str) -> None:
if size == "auto":
return
parsed = _parse_size(size)
if parsed is None:
_die("size must be auto or WIDTHxHEIGHT, for example 1024x1024.")
width, height = parsed
max_edge = max(width, height)
min_edge = min(width, height)
total_pixels = width * height
if max_edge > GPT_IMAGE_2_MAX_EDGE:
_die("GPT Image 2 / 2.5 size maximum edge length must be less than or equal to 3840px.")
if width % 16 != 0 or height % 16 != 0:
_die("GPT Image 2 / 2.5 size width and height must be multiples of 16px.")
if max_edge / min_edge > GPT_IMAGE_2_MAX_RATIO:
_die("GPT Image 2 / 2.5 size long edge to short edge ratio must not exceed 3:1.")
if total_pixels < GPT_IMAGE_2_MIN_PIXELS or total_pixels > GPT_IMAGE_2_MAX_PIXELS:
_die(
"GPT Image 2 / 2.5 size total pixels must be at least 655,360 and no more than 8,294,400."
)
def _validate_size(size: str, model: str) -> None:
if _is_gpt_image_2_model(model) or _is_gpt_image_2_5_model(model):
_validate_gpt_image_2_size(size)
return
if size not in ALLOWED_LEGACY_SIZES:
_die(
"size must be one of 1024x1024, 1536x1024, 1024x1536, or auto for this GPT Image model."
)
def _validate_quality(quality: str, model: str) -> None:
if quality not in ALLOWED_QUALITIES:
_die("quality must be one of low, medium, high, xhigh, max, or auto.")
if quality in {"xhigh", "max"} and not _is_gpt_image_2_5_model(model):
_die("xhigh and max quality require gpt-image-2.5-flare or gpt-image-2.5-sunburst.")
def _validate_background(background: Optional[str]) -> None:
if background not in ALLOWED_BACKGROUNDS:
_die("background must be one of transparent, opaque, or auto.")
def _validate_input_fidelity(input_fidelity: Optional[str]) -> None:
if input_fidelity not in ALLOWED_INPUT_FIDELITIES:
_die("input-fidelity must be one of low or high.")
def _validate_model(model: str) -> None:
if GPT_IMAGE_MODEL_PREFIX not in model:
_die(
"model must be a GPT Image model name containing 'gpt-image-' "
"(for example gpt-image-2.5-flare, gpt-image-2.5-sunburst, gpt-image-2, "
"gpt-image-1, or gpt-image-1-mini)."
)
def _is_gpt_image_2_model(model: str) -> bool:
return bool(re.search(r"(?:^|/)gpt-image-2(?:-\d{4}-\d{2}-\d{2})?(?:/|$)", model))
def _is_gpt_image_2_5_model(model: str) -> bool:
return bool(re.search(
r"(?:^|/)gpt-image-2\.5-(?:flare|sunburst)(?:-\d{4}-\d{2}-\d{2})?(?:/|$)",
model,
))
def _validate_transparency(background: Optional[str], output_format: str) -> None:
if background == "transparent" and output_format not in {"png", "webp"}:
_die("transparent background requires output-format png or webp.")
def _validate_model_specific_options(
*,
model: str,
background: Optional[str],
input_fidelity: Optional[str] = None,
) -> None:
if not _is_gpt_image_2_model(model):
return
if background == "transparent":
_die(
"transparent backgrounds are not supported in gpt-image-2. "
"Use --model gpt-image-2.5-flare --background transparent --output-format png instead."
)
if input_fidelity is not None:
_die(
"input_fidelity is not supported in gpt-image-2 because image inputs always use high fidelity for this model."
)
def _validate_generate_payload(payload: Dict[str, Any]) -> None:
model = str(payload.get("model", DEFAULT_MODEL))
_validate_model(model)
n = int(payload.get("n", 1))
if n < 1 or n > 10:
_die("n must be between 1 and 10")
size = str(payload.get("size", DEFAULT_SIZE))
quality = str(payload.get("quality", DEFAULT_QUALITY))
background = payload.get("background")
_validate_size(size, model)
_validate_quality(quality, model)
_validate_background(background)
_validate_model_specific_options(model=model, background=background)
oc = payload.get("output_compression")
if oc is not None and not (0 <= int(oc) <= 100):
_die("output_compression must be between 0 and 100")
def _build_output_paths(
out: str,
output_format: str,
count: int,
out_dir: Optional[str],
) -> List[Path]:
ext = "." + output_format
if out_dir:
out_base = Path(out_dir)
out_base.mkdir(parents=True, exist_ok=True)
return [out_base / f"image_{i}{ext}" for i in range(1, count + 1)]
out_path = Path(out)
if out_path.exists() and out_path.is_dir():
out_path.mkdir(parents=True, exist_ok=True)
return [out_path / f"image_{i}{ext}" for i in range(1, count + 1)]
if out_path.suffix == "":
out_path = out_path.with_suffix(ext)
elif output_format and out_path.suffix.lstrip(".").lower() != output_format:
_warn(
f"Output extension {out_path.suffix} does not match output-format {output_format}."
)
if count == 1:
return [out_path]
return [
out_path.with_name(f"{out_path.stem}-{i}{out_path.suffix}")
for i in range(1, count + 1)
]
def _augment_prompt(args: argparse.Namespace, prompt: str) -> str:
fields = _fields_from_args(args)
return _augment_prompt_fields(args.augment, prompt, fields)
def _augment_prompt_fields(augment: bool, prompt: str, fields: Dict[str, Optional[str]]) -> str:
if not augment:
return prompt
sections: List[str] = []
if fields.get("use_case"):
sections.append(f"Use case: {fields['use_case']}")
sections.append(f"Primary request: {prompt}")
if fields.get("scene"):
sections.append(f"Scene/background: {fields['scene']}")
if fields.get("subject"):
sections.append(f"Subject: {fields['subject']}")
if fields.get("style"):
sections.append(f"Style/medium: {fields['style']}")
if fields.get("composition"):
sections.append(f"Composition/framing: {fields['composition']}")
if fields.get("lighting"):
sections.append(f"Lighting/mood: {fields['lighting']}")
if fields.get("palette"):
sections.append(f"Color palette: {fields['palette']}")
if fields.get("materials"):
sections.append(f"Materials/textures: {fields['materials']}")
if fields.get("text"):
sections.append(f"Text (verbatim): \"{fields['text']}\"")
if fields.get("constraints"):
sections.append(f"Constraints: {fields['constraints']}")
if fields.get("negative"):
sections.append(f"Avoid: {fields['negative']}")
return "\n".join(sections)
def _fields_from_args(args: argparse.Namespace) -> Dict[str, Optional[str]]:
return {
"use_case": getattr(args, "use_case", None),
"scene": getattr(args, "scene", None),
"subject": getattr(args, "subject", None),
"style": getattr(args, "style", None),
"composition": getattr(args, "composition", None),
"lighting": getattr(args, "lighting", None),
"palette": getattr(args, "palette", None),
"materials": getattr(args, "materials", None),
"text": getattr(args, "text", None),
"constraints": getattr(args, "constraints", None),
"negative": getattr(args, "negative", None),
}
def _print_request(payload: dict) -> None:
print(json.dumps(payload, indent=2, sort_keys=True))
def _decode_and_write(images: List[str], outputs: List[Path], force: bool) -> None:
for idx, image_b64 in enumerate(images):
if idx >= len(outputs):
break
out_path = outputs[idx]
if out_path.exists() and not force:
_die(f"Output already exists: {out_path} (use --force to overwrite)")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(base64.b64decode(image_b64))
print(f"Wrote {out_path}")
def _derive_downscale_path(path: Path, suffix: str) -> Path:
if suffix and not suffix.startswith("-") and not suffix.startswith("_"):
suffix = "-" + suffix
return path.with_name(f"{path.stem}{suffix}{path.suffix}")
def _downscale_image_bytes(image_bytes: bytes, *, max_dim: int, output_format: str) -> bytes:
try:
from PIL import Image
except Exception:
_die(f"Downscaling requires Pillow. {_dependency_hint('pillow')}")
if max_dim < 1:
_die("--downscale-max-dim must be >= 1")
with Image.open(BytesIO(image_bytes)) as img:
img.load()
w, h = img.size
scale = min(1.0, float(max_dim) / float(max(w, h)))
target = (max(1, int(round(w * scale))), max(1, int(round(h * scale))))
resized = img if target == (w, h) else img.resize(target, Image.Resampling.LANCZOS)
fmt = output_format.lower()
if fmt == "jpg":
fmt = "jpeg"
if fmt == "jpeg":
if resized.mode in ("RGBA", "LA") or ("transparency" in getattr(resized, "info", {})):
bg = Image.new("RGB", resized.size, (255, 255, 255))
bg.paste(resized.convert("RGBA"), mask=resized.convert("RGBA").split()[-1])
resized = bg
else:
resized = resized.convert("RGB")
out = BytesIO()
resized.save(out, format=fmt.upper())
return out.getvalue()
def _decode_write_and_downscale(
images: List[str],
outputs: List[Path],
*,
force: bool,
downscale_max_dim: Optional[int],
downscale_suffix: str,
output_format: str,
) -> None:
for idx, image_b64 in enumerate(images):
if idx >= len(outputs):
break
out_path = outputs[idx]
if out_path.exists() and not force:
_die(f"Output already exists: {out_path} (use --force to overwrite)")
out_path.parent.mkdir(parents=True, exist_ok=True)
raw = base64.b64decode(image_b64)
out_path.write_bytes(raw)
print(f"Wrote {out_path}")
if downscale_max_dim is None:
continue
derived = _derive_downscale_path(out_path, downscale_suffix)
if derived.exists() and not force:
_die(f"Output already exists: {derived} (use --force to overwrite)")
derived.parent.mkdir(parents=True, exist_ok=True)
resized = _downscale_image_bytes(raw, max_dim=downscale_max_dim, output_format=output_format)
derived.write_bytes(resized)
print(f"Wrote {derived}")
def _slugify(value: str) -> str:
value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "-", value)
value = re.sub(r"-{2,}", "-", value).strip("-")
return value[:60] if value else "job"
def _normalize_job(job: Any, idx: int) -> Dict[str, Any]:
if isinstance(job, str):
prompt = job.strip()
if not prompt:
_die(f"Empty prompt at job {idx}")
return {"prompt": prompt}
if isinstance(job, dict):
if "prompt" not in job or not str(job["prompt"]).strip():
_die(f"Missing prompt for job {idx}")
return job
_die(f"Invalid job at index {idx}: expected string or object.")
return {} # unreachable
def _read_jobs_jsonl(path: str) -> List[Dict[str, Any]]:
p = Path(path)
if not p.exists():
_die(f"Input file not found: {p}")
jobs: List[Dict[str, Any]] = []
for line_no, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
try:
item: Any
if line.startswith("{"):
item = json.loads(line)
else:
item = line
jobs.append(_normalize_job(item, idx=line_no))
except json.JSONDecodeError as exc:
_die(f"Invalid JSON on line {line_no}: {exc}")
if not jobs:
_die("No jobs found in input file.")
if len(jobs) > MAX_BATCH_JOBS:
_die(f"Too many jobs ({len(jobs)}). Max is {MAX_BATCH_JOBS}.")
return jobs
def _merge_non_null(dst: Dict[str, Any], src: Dict[str, Any]) -> Dict[str, Any]:
merged = dict(dst)
for k, v in src.items():
if v is not None:
merged[k] = v
return merged
def _job_output_paths(
*,
out_dir: Path,
output_format: str,
idx: int,
prompt: str,
n: int,
explicit_out: Optional[str],
) -> List[Path]:
out_dir.mkdir(parents=True, exist_ok=True)
ext = "." + output_format
if explicit_out:
base = Path(explicit_out)
if base.suffix == "":
base = base.with_suffix(ext)
elif base.suffix.lstrip(".").lower() != output_format:
_warn(
f"Job {idx}: output extension {base.suffix} does not match output-format {output_format}."
)
base = out_dir / base.name
else:
slug = _slugify(prompt[:80])
base = out_dir / f"{idx:03d}-{slug}{ext}"
if n == 1:
return [base]
return [
base.with_name(f"{base.stem}-{i}{base.suffix}")
for i in range(1, n + 1)
]
async def _run_generate_batch(args: argparse.Namespace) -> int:
jobs = _read_jobs_jsonl(args.input)
out_dir = Path(args.out_dir)
base_fields = _fields_from_args(args)
base_payload = {
"model": args.model,
"n": args.n,
"size": args.size,
"quality": args.quality,
"background": args.background,
"output_format": args.output_format,
"output_compression": args.output_compression,
"moderation": args.moderation,
}
if args.dry_run:
for i, job in enumerate(jobs, start=1):
prompt = str(job["prompt"]).strip()
fields = _merge_non_null(base_fields, job.get("fields", {}))
# Allow flat job keys as well (use_case, scene, etc.)
fields = _merge_non_null(fields, {k: job.get(k) for k in base_fields.keys()})
augmented = _augment_prompt_fields(args.augment, prompt, fields)
job_payload = dict(base_payload)
job_payload["prompt"] = augmented
job_payload = _merge_non_null(job_payload, {k: job.get(k) for k in base_payload.keys()})
job_payload = {k: v for k, v in job_payload.items() if v is not None}
_validate_generate_payload(job_payload)
effective_output_format = _normalize_output_format(job_payload.get("output_format"))
_validate_transparency(job_payload.get("background"), effective_output_format)
job_payload["output_format"] = effective_output_format
n = int(job_payload.get("n", 1))
outputs = _job_output_paths(
out_dir=out_dir,
output_format=effective_output_format,
idx=i,
prompt=prompt,
n=n,
explicit_out=job.get("out"),
)
downscaled = None
if args.downscale_max_dim is not None:
downscaled = [
str(_derive_downscale_path(p, args.downscale_suffix)) for p in outputs
]
_print_request(
{
"endpoint": _preview_endpoint("generate"),
"job": i,
"outputs": [str(p) for p in outputs],
"outputs_downscaled": downscaled,
**{
**job_payload,
"model": _preview_model(str(job_payload["model"]), "generate"),
},
}
)
return 0
provider = create_image_provider(api_key=os.getenv("OPENAI_API_KEY"), base_url=_api_base_url())
sem = asyncio.Semaphore(args.concurrency)
any_failed = False
async def run_job(i: int, job: Dict[str, Any]) -> Tuple[int, Optional[str]]:
nonlocal any_failed
prompt = str(job["prompt"]).strip()
job_label = f"[job {i}/{len(jobs)}]"
fields = _merge_non_null(base_fields, job.get("fields", {}))
fields = _merge_non_null(fields, {k: job.get(k) for k in base_fields.keys()})
augmented = _augment_prompt_fields(args.augment, prompt, fields)
payload = dict(base_payload)
payload["prompt"] = augmented
payload = _merge_non_null(payload, {k: job.get(k) for k in base_payload.keys()})
payload = {k: v for k, v in payload.items() if v is not None}
n = int(payload.get("n", 1))
_validate_generate_payload(payload)
effective_output_format = _normalize_output_format(payload.get("output_format"))
_validate_transparency(payload.get("background"), effective_output_format)
payload["output_format"] = effective_output_format
outputs = _job_output_paths(
out_dir=out_dir,
output_format=effective_output_format,
idx=i,
prompt=prompt,
n=n,
explicit_out=job.get("out"),
)
try:
async with sem:
print(f"{job_label} starting", file=sys.stderr)
started = time.time()
images = await provider.generate_batch(
payload,
attempts=args.max_attempts,
job_label=job_label,
)
elapsed = time.time() - started
print(f"{job_label} completed in {elapsed:.1f}s", file=sys.stderr)
_decode_write_and_downscale(
images,
outputs,
force=args.force,
downscale_max_dim=args.downscale_max_dim,
downscale_suffix=args.downscale_suffix,
output_format=effective_output_format,
)
return i, None
except Exception as exc:
any_failed = True
print(f"{job_label} failed: {exc}", file=sys.stderr)
if args.fail_fast:
raise
return i, str(exc)
tasks = [asyncio.create_task(run_job(i, job)) for i, job in enumerate(jobs, start=1)]
try:
await asyncio.gather(*tasks)
except Exception:
for t in tasks:
if not t.done():
t.cancel()
raise
return 1 if any_failed else 0
def _generate_batch(args: argparse.Namespace) -> None:
exit_code = asyncio.run(_run_generate_batch(args))
if exit_code:
raise SystemExit(exit_code)
def _generate(args: argparse.Namespace) -> None:
prompt = _read_prompt(args.prompt, args.prompt_file)
prompt = _augment_prompt(args, prompt)
payload = {
"model": args.model,
"prompt": prompt,
"n": args.n,
"size": args.size,
"quality": args.quality,
"background": args.background,
"output_format": args.output_format,
"output_compression": args.output_compression,
"moderation": args.moderation,
}
payload = {k: v for k, v in payload.items() if v is not None}
output_format = _normalize_output_format(args.output_format)
_validate_transparency(args.background, output_format)
payload["output_format"] = output_format
output_paths = _build_output_paths(args.out, output_format, args.n, args.out_dir)
downscaled = None
if args.downscale_max_dim is not None:
downscaled = [str(_derive_downscale_path(p, args.downscale_suffix)) for p in output_paths]
if args.dry_run:
_print_request(
{
"endpoint": _preview_endpoint("generate"),
"outputs": [str(p) for p in output_paths],
"outputs_downscaled": downscaled,
**{
**payload,
"model": _preview_model(str(payload["model"]), "generate"),
},
}
)
return
print(
"Calling Image API (generation). This can take up to a couple of minutes.",
file=sys.stderr,
)
started = time.time()
provider = create_image_provider(api_key=os.getenv("OPENAI_API_KEY"), base_url=_api_base_url())
images = provider.generate(payload)
elapsed = time.time() - started
print(f"Generation completed in {elapsed:.1f}s.", file=sys.stderr)
_decode_write_and_downscale(
images,
output_paths,
force=args.force,
downscale_max_dim=args.downscale_max_dim,
downscale_suffix=args.downscale_suffix,
output_format=output_format,
)
def _edit(args: argparse.Namespace) -> None:
prompt = _read_prompt(args.prompt, args.prompt_file)
prompt = _augment_prompt(args, prompt)
image_paths = _check_image_paths(args.image)
mask_path = Path(args.mask) if args.mask else None
if mask_path:
if not mask_path.exists():
_die(f"Mask file not found: {mask_path}")
if mask_path.suffix.lower() != ".png":
_warn(f"Mask should be a PNG with an alpha channel: {mask_path}")
if mask_path.stat().st_size > MAX_IMAGE_BYTES:
_warn(f"Mask exceeds 50MB limit: {mask_path}")
payload = {
"model": args.model,
"prompt": prompt,
"n": args.n,
"size": args.size,
"quality": args.quality,
"background": args.background,
"output_format": args.output_format,
"output_compression": args.output_compression,
"input_fidelity": args.input_fidelity,
"moderation": args.moderation,
}
payload = {k: v for k, v in payload.items() if v is not None}
output_format = _normalize_output_format(args.output_format)
_validate_transparency(args.background, output_format)
payload["output_format"] = output_format
_validate_input_fidelity(args.input_fidelity)
output_paths = _build_output_paths(args.out, output_format, args.n, args.out_dir)
downscaled = None
if args.downscale_max_dim is not None:
downscaled = [str(_derive_downscale_path(p, args.downscale_suffix)) for p in output_paths]
if args.dry_run:
payload_preview = dict(payload)
payload_preview["image"] = [str(p) for p in image_paths]
if mask_path:
payload_preview["mask"] = str(mask_path)
_print_request(
{
"endpoint": _preview_endpoint("edit"),
"outputs": [str(p) for p in output_paths],
"outputs_downscaled": downscaled,
**{
**payload_preview,
"model": _preview_model(str(payload_preview["model"]), "edit"),
},
}
)
return
print(
f"Calling Image API (edit) with {len(image_paths)} image(s).",
file=sys.stderr,
)
started = time.time()
provider = create_image_provider(api_key=os.getenv("OPENAI_API_KEY"), base_url=_api_base_url())
images = provider.edit(payload, image_paths, mask_path)
elapsed = time.time() - started
print(f"Edit completed in {elapsed:.1f}s.", file=sys.stderr)
_decode_write_and_downscale(
images,
output_paths,
force=args.force,
downscale_max_dim=args.downscale_max_dim,
downscale_suffix=args.downscale_suffix,
output_format=output_format,
)
def _add_shared_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--model", default=_default_model())
parser.add_argument("--prompt")
parser.add_argument("--prompt-file")
parser.add_argument("--n", type=int, default=1)
parser.add_argument("--size", default=DEFAULT_SIZE)
parser.add_argument("--quality", default=DEFAULT_QUALITY)
parser.add_argument("--background")
parser.add_argument("--output-format")
parser.add_argument("--output-compression", type=int)
parser.add_argument("--moderation")
parser.add_argument("--out", default=DEFAULT_OUTPUT_PATH)
parser.add_argument("--out-dir")
parser.add_argument("--force", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--augment", dest="augment", action="store_true")
parser.add_argument("--no-augment", dest="augment", action="store_false")
parser.set_defaults(augment=True)
# Prompt augmentation hints
parser.add_argument("--use-case")
parser.add_argument("--scene")
parser.add_argument("--subject")
parser.add_argument("--style")
parser.add_argument("--composition")
parser.add_argument("--lighting")
parser.add_argument("--palette")
parser.add_argument("--materials")
parser.add_argument("--text")
parser.add_argument("--constraints")
parser.add_argument("--negative")
# Post-processing (optional): generate an additional downscaled copy for fast web loading.
parser.add_argument("--downscale-max-dim", type=int)
parser.add_argument("--downscale-suffix", default=DEFAULT_DOWNSCALE_SUFFIX)
def main() -> int:
_load_runtime_env()
parser = argparse.ArgumentParser(
description="Fallback CLI for explicit image generation or editing via GPT Image models"
)
subparsers = parser.add_subparsers(dest="command", required=True)
gen_parser = subparsers.add_parser("generate", help="Create a new image")
_add_shared_args(gen_parser)
gen_parser.set_defaults(func=_generate)
batch_parser = subparsers.add_parser(
"generate-batch",
help="Generate multiple prompts concurrently (JSONL input)",
)
_add_shared_args(batch_parser)
batch_parser.add_argument("--input", required=True, help="Path to JSONL file (one job per line)")
batch_parser.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY)
batch_parser.add_argument("--max-attempts", type=int, default=3)
batch_parser.add_argument("--fail-fast", action="store_true")
batch_parser.set_defaults(func=_generate_batch)
edit_parser = subparsers.add_parser("edit", help="Edit an existing image")
_add_shared_args(edit_parser)
edit_parser.add_argument("--image", action="append", required=True)
edit_parser.add_argument("--mask")
edit_parser.add_argument("--input-fidelity")
edit_parser.set_defaults(func=_edit)
args = parser.parse_args()
if args.n < 1 or args.n > 10:
_die("--n must be between 1 and 10")
if getattr(args, "concurrency", 1) < 1 or getattr(args, "concurrency", 1) > 25:
_die("--concurrency must be between 1 and 25")
if getattr(args, "max_attempts", 3) < 1 or getattr(args, "max_attempts", 3) > 10:
_die("--max-attempts must be between 1 and 10")
if args.output_compression is not None and not (0 <= args.output_compression <= 100):
_die("--output-compression must be between 0 and 100")
if args.command == "generate-batch" and not args.out_dir:
_die("generate-batch requires --out-dir")
if getattr(args, "downscale_max_dim", None) is not None and args.downscale_max_dim < 1:
_die("--downscale-max-dim must be >= 1")
_validate_model(args.model)
_validate_size(args.size, args.model)
_validate_quality(args.quality, args.model)
_validate_background(args.background)
_validate_model_specific_options(
model=args.model,
background=args.background,
input_fidelity=getattr(args, "input_fidelity", None),
)
_ensure_api_key(args.dry_run)
args.func(args)
return 0
if __name__ == "__main__":
raise SystemExit(main())
scripts/image_providers/__init__.py"""Image API providers for the codex-ppt fallback CLI.""" from .base import ImageProvider from .factory import create_image_provider __all__ = ["ImageProvider", "create_image_provider"]
scripts/image_providers/atlascloud.pyfrom __future__ import annotations
import asyncio
import base64
import json
import mimetypes
from pathlib import Path
import sys
import time
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse
from urllib.request import Request, urlopen as default_urlopen
from .base import ImageProvider
UrlOpen = Callable[..., Any]
USER_AGENT = "codex-ppt-skill/0.1 (+https://github.com/ningzimu/codex-ppt-skill)"
class AtlasCloudImageProvider(ImageProvider):
_FINISHED_STATUSES = {"completed", "succeeded"}
_FAILED_STATUSES = {"failed"}
def __init__(
self,
*,
api_key: Optional[str],
base_url: Optional[str],
urlopen: UrlOpen = default_urlopen,
sleep: Callable[[float], None] = time.sleep,
poll_interval: float = 2.0,
max_polls: int = 120,
) -> None:
self.api_key = api_key
self.model_base_url = _model_base_url(base_url)
self._urlopen = urlopen
self._sleep = sleep
self.poll_interval = poll_interval
self.max_polls = max_polls
def generate(self, payload: Dict[str, Any]) -> List[str]:
count = int(payload.get("n", 1))
outputs: List[str] = []
for _ in range(count):
outputs.extend(self._submit_and_collect(payload, operation="text-to-image"))
return outputs
def edit(
self,
payload: Dict[str, Any],
image_paths: List[Path],
mask_path: Optional[Path],
) -> List[str]:
if mask_path is not None:
raise ValueError("AtlasCloud image edit does not support --mask.")
count = int(payload.get("n", 1))
edit_payload = dict(payload)
edit_payload["images"] = [_image_to_data_url(path) for path in image_paths]
outputs: List[str] = []
for _ in range(count):
outputs.extend(self._submit_and_collect(edit_payload, operation="edit"))
return outputs
async def generate_batch(
self,
payload: Dict[str, Any],
*,
attempts: int,
job_label: str,
) -> List[str]:
last_exc: Optional[Exception] = None
for attempt in range(1, attempts + 1):
try:
return await asyncio.to_thread(self.generate, payload)
except Exception as exc:
last_exc = exc
if attempt == attempts:
raise
sleep_s = min(60.0, 2.0**attempt)
print(
f"{job_label} attempt {attempt}/{attempts} failed ({exc.__class__.__name__}); retrying in {sleep_s:.1f}s",
file=sys.stderr,
)
await asyncio.sleep(sleep_s)
raise last_exc or RuntimeError("unknown error")
def _submit_and_collect(self, payload: Dict[str, Any], *, operation: str) -> List[str]:
request_payload = self._atlas_payload(payload, operation=operation)
submitted = self._request_json(
"POST",
f"{self.model_base_url}/generateImage",
request_payload,
)
prediction_id = _prediction_id(submitted)
if not prediction_id:
raise RuntimeError("AtlasCloud response did not include a prediction id.")
result_url = _prediction_result_url(submitted) or f"{self.model_base_url}/result/{prediction_id}"
result = self._poll_prediction(result_url)
outputs = result.get("outputs")
if not isinstance(outputs, list) or not outputs:
raise RuntimeError("AtlasCloud prediction completed without outputs.")
return [self._output_to_b64(str(output)) for output in outputs]
def _poll_prediction(self, url: str) -> Dict[str, Any]:
last: Dict[str, Any] = {}
for _ in range(self.max_polls):
last = self._request_json("GET", url)
status = str(last.get("status", "")).lower()
if status in self._FINISHED_STATUSES:
return last
if status in self._FAILED_STATUSES:
raise RuntimeError(f"AtlasCloud prediction failed: {last}")
self._sleep(self.poll_interval)
raise TimeoutError(f"AtlasCloud prediction timed out: {last}")
def _atlas_payload(self, payload: Dict[str, Any], *, operation: str) -> Dict[str, Any]:
output_format = payload.get("output_format")
if output_format not in (None, "png", "jpeg"):
raise ValueError("AtlasCloud supports output_format png or jpeg.")
body: Dict[str, Any] = {
"model": atlascloud_model_for_operation(
str(payload.get("model", "gpt-image-2")),
operation,
),
"prompt": payload["prompt"],
"enable_sync_mode": False,
"enable_base64_output": True,
}
for key in ("size", "quality", "output_format"):
value = payload.get(key)
if value is not None and value != "auto":
body[key] = value
if operation == "edit":
body["images"] = payload["images"]
return body
def _request_json(
self,
method: str,
url: str,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
data = json.dumps(payload).encode("utf-8") if payload is not None else None
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
"User-Agent": USER_AGENT,
}
if payload is not None:
headers["Content-Type"] = "application/json"
request = Request(url, data=data, headers=headers, method=method)
with self._urlopen(request, timeout=60) as response:
parsed = json.loads(response.read().decode("utf-8"))
if not isinstance(parsed, dict):
raise RuntimeError(f"Unexpected AtlasCloud response: {parsed}")
code = parsed.get("code")
if code is not None and code not in (0, 200, "0", "200"):
raise RuntimeError(f"AtlasCloud API error: {parsed}")
data_obj = parsed.get("data", parsed)
if not isinstance(data_obj, dict):
raise RuntimeError(f"Unexpected AtlasCloud response data: {parsed}")
return data_obj
def _output_to_b64(self, value: str) -> str:
if value.startswith("data:") and "," in value:
return value.split(",", 1)[1]
parsed = urlparse(value)
if parsed.scheme in {"http", "https"}:
request = Request(value, headers={"User-Agent": USER_AGENT}, method="GET")
with self._urlopen(request, timeout=60) as response:
return base64.b64encode(response.read()).decode("ascii")
return value
def _model_base_url(base_url: Optional[str]) -> str:
if not base_url:
return "https://api.atlascloud.ai/api/v1/model"
parsed = urlparse(base_url)
origin = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else ""
path = parsed.path.rstrip("/")
marker = "/api/v1/model"
if marker in path:
prefix = path[: path.index(marker) + len(marker)]
return f"{origin}{prefix}"
if origin:
return f"{origin}{marker}"
return base_url.rstrip("/")
def atlascloud_model_for_operation(model: str, operation: str) -> str:
suffix = "edit" if operation == "edit" else "text-to-image"
base = model.rstrip("/")
for existing_suffix in ("/text-to-image", "/edit"):
if base.endswith(existing_suffix):
base = base[: -len(existing_suffix)]
break
if "/" not in base:
base = f"openai/{base}"
return f"{base}/{suffix}"
def _prediction_id(data: Dict[str, Any]) -> Optional[str]:
value = data.get("id") or data.get("prediction_id")
return str(value) if value else None
def _prediction_result_url(data: Dict[str, Any]) -> Optional[str]:
urls = data.get("urls")
if not isinstance(urls, dict):
return None
value = urls.get("get")
return str(value) if value else None
def _image_to_data_url(path: Path) -> str:
mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{encoded}"
scripts/image_providers/base.pyfrom __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Optional
class ImageProvider(ABC):
"""Common API shape used by image_gen.py command handlers."""
@abstractmethod
def generate(self, payload: Dict[str, Any]) -> List[str]:
"""Generate images and return base64-encoded image payloads."""
@abstractmethod
def edit(
self,
payload: Dict[str, Any],
image_paths: List[Path],
mask_path: Optional[Path],
) -> List[str]:
"""Edit input images and return base64-encoded image payloads."""
@abstractmethod
async def generate_batch(
self,
payload: Dict[str, Any],
*,
attempts: int,
job_label: str,
) -> List[str]:
"""Generate one batch job with provider-specific retry behavior."""
scripts/image_providers/factory.pyfrom __future__ import annotations
from typing import Optional
from urllib.parse import urlparse
from .atlascloud import AtlasCloudImageProvider
from .base import ImageProvider
from .openai_compatible import OpenAICompatibleImageProvider
def create_image_provider(*, api_key: Optional[str], base_url: Optional[str]) -> ImageProvider:
if _is_atlascloud_base_url(base_url):
return AtlasCloudImageProvider(api_key=api_key, base_url=base_url)
return OpenAICompatibleImageProvider(api_key=api_key, base_url=base_url)
def _is_atlascloud_base_url(base_url: Optional[str]) -> bool:
if not base_url:
return False
hostname = urlparse(base_url).hostname or ""
return "atlascloud.ai" in hostname.lower()
scripts/image_providers/openai_compatible.pyfrom __future__ import annotations
import asyncio
import os
from pathlib import Path
import re
import sys
from typing import Any, Callable, Dict, List, Optional
from .base import ImageProvider
ClientFactory = Callable[[], Any]
DEFAULT_RUNTIME_HOME = "~/.codex-ppt-skill"
def _runtime_home() -> Path:
return Path(os.getenv("CODEX_PPT_HOME", DEFAULT_RUNTIME_HOME)).expanduser()
def _runtime_python_path() -> str:
home = _runtime_home()
if os.name == "nt":
return str(home / ".venv" / "Scripts" / "python.exe")
return str(home / ".venv" / "bin" / "python")
def _skill_root() -> Path:
return Path(__file__).resolve().parents[2]
def _dependency_hint(package: str, *, upgrade: bool = False) -> str:
package_arg = f"-U {package}" if upgrade else package
runtime_python = _runtime_python_path()
requirements = _skill_root() / "requirements.txt"
return (
"Install codex-ppt dependencies in the shared runtime first, for example "
f"`python3 {_skill_root() / 'scripts' / 'codex_ppt_runtime.py'} bootstrap`, "
f"or install {package} directly with `{runtime_python} -m pip install "
f"{package_arg}`. Requirements file: `{requirements}`."
)
def _extract_retry_after_seconds(exc: Exception) -> Optional[float]:
for attr in ("retry_after", "retry_after_seconds"):
val = getattr(exc, attr, None)
if isinstance(val, (int, float)) and val >= 0:
return float(val)
msg = str(exc)
m = re.search(r"retry[- ]after[:= ]+([0-9]+(?:\\.[0-9]+)?)", msg, re.IGNORECASE)
if m:
try:
return float(m.group(1))
except Exception:
return None
return None
def _is_rate_limit_error(exc: Exception) -> bool:
name = exc.__class__.__name__.lower()
if "ratelimit" in name or "rate_limit" in name:
return True
msg = str(exc).lower()
return "429" in msg or "rate limit" in msg or "too many requests" in msg
def _is_transient_error(exc: Exception) -> bool:
if _is_rate_limit_error(exc):
return True
name = exc.__class__.__name__.lower()
if "timeout" in name or "timedout" in name or "tempor" in name:
return True
msg = str(exc).lower()
return "timeout" in msg or "timed out" in msg or "connection reset" in msg
async def _generate_one_with_retries(
client: Any,
payload: Dict[str, Any],
*,
attempts: int,
job_label: str,
) -> Any:
last_exc: Optional[Exception] = None
for attempt in range(1, attempts + 1):
try:
return await client.images.generate(**payload)
except Exception as exc:
last_exc = exc
if not _is_transient_error(exc):
raise
if attempt == attempts:
raise
sleep_s = _extract_retry_after_seconds(exc)
if sleep_s is None:
sleep_s = min(60.0, 2.0**attempt)
print(
f"{job_label} attempt {attempt}/{attempts} failed ({exc.__class__.__name__}); retrying in {sleep_s:.1f}s",
file=sys.stderr,
)
await asyncio.sleep(sleep_s)
raise last_exc or RuntimeError("unknown error")
class OpenAICompatibleImageProvider(ImageProvider):
def __init__(
self,
*,
api_key: Optional[str],
base_url: Optional[str],
client_factory: Optional[ClientFactory] = None,
async_client_factory: Optional[ClientFactory] = None,
) -> None:
self.api_key = api_key
self.base_url = base_url
self._client_factory = client_factory
self._async_client_factory = async_client_factory
self._async_client: Optional[Any] = None
def generate(self, payload: Dict[str, Any]) -> List[str]:
result = self._create_client().images.generate(**payload)
return [item.b64_json for item in result.data]
def edit(
self,
payload: Dict[str, Any],
image_paths: List[Path],
mask_path: Optional[Path],
) -> List[str]:
with _open_files(image_paths) as image_files, _open_mask(mask_path) as mask_file:
request = dict(payload)
request["image"] = image_files if len(image_files) > 1 else image_files[0]
if mask_file is not None:
request["mask"] = mask_file
result = self._create_client().images.edit(**request)
return [item.b64_json for item in result.data]
async def generate_batch(
self,
payload: Dict[str, Any],
*,
attempts: int,
job_label: str,
) -> List[str]:
result = await _generate_one_with_retries(
self._create_async_client(),
payload,
attempts=attempts,
job_label=job_label,
)
return [item.b64_json for item in result.data]
def _create_client(self) -> Any:
if self._client_factory is not None:
return self._client_factory()
try:
from openai import OpenAI
except ImportError as exc:
raise RuntimeError(
f"openai SDK not installed in the active environment. {_dependency_hint('openai')}"
) from exc
return OpenAI(api_key=self.api_key, base_url=self.base_url)
def _create_async_client(self) -> Any:
if self._async_client is not None:
return self._async_client
if self._async_client_factory is not None:
self._async_client = self._async_client_factory()
return self._async_client
try:
from openai import AsyncOpenAI
except ImportError as exc:
try:
import openai as _openai # noqa: F401
except ImportError:
raise RuntimeError(
f"openai SDK not installed in the active environment. {_dependency_hint('openai')}"
) from exc
raise RuntimeError(
"AsyncOpenAI not available in this openai SDK version. "
f"{_dependency_hint('openai', upgrade=True)}"
) from exc
self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
return self._async_client
def _open_files(paths: List[Path]):
return _FileBundle(paths)
def _open_mask(mask_path: Optional[Path]):
if mask_path is None:
return _NullContext()
return _SingleFile(mask_path)
class _NullContext:
def __enter__(self):
return None
def __exit__(self, exc_type, exc, tb):
return False
class _SingleFile:
def __init__(self, path: Path):
self._path = path
self._handle = None
def __enter__(self):
self._handle = self._path.open("rb")
return self._handle
def __exit__(self, exc_type, exc, tb):
if self._handle:
try:
self._handle.close()
except Exception:
pass
return False
class _FileBundle:
def __init__(self, paths: List[Path]):
self._paths = paths
self._handles: List[object] = []
def __enter__(self):
self._handles = [p.open("rb") for p in self._paths]
return self._handles
def __exit__(self, exc_type, exc, tb):
for handle in self._handles:
try:
handle.close()
except Exception:
pass
return False
scripts/prepare_slide_prompts.py#!/usr/bin/env python3
"""Prepare per-slide image generation jobs for codex-ppt.
This script is deterministic. It does not call an image model. It turns a
structured deck spec into one self-contained JSON job file per slide.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
from typing import Any, Dict, Iterable, List, Optional
from slide_run_state import (
DEFAULT_MAX_CONCURRENT_SLIDES,
now_iso,
rel_to_deck,
save_jobs,
set_run_status,
)
def _die(message: str) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(1)
def _read_json(path: Path) -> Dict[str, Any]:
if not path.exists():
_die(f"Spec file not found: {path}")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
_die(f"Invalid JSON in {path}: {exc}")
if not isinstance(data, dict):
_die("Deck spec must be a JSON object.")
return data
def _as_list(value: Any) -> List[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _string_list(value: Any) -> List[str]:
return [str(item).strip() for item in _as_list(value) if str(item).strip()]
_MARKDOWN_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def _parse_markdown_image(value: str) -> Optional[Dict[str, str]]:
match = _MARKDOWN_IMAGE_RE.search(value)
if not match:
return None
alt_text = match.group(1).strip()
path = match.group(2).strip()
description = value[: match.start()].strip(" \t\n\r:-;")
role_parts = [part for part in [description, alt_text] if part]
return {
"path": path,
"role": " — ".join(role_parts) if role_parts else "reference image",
}
def _resolve_image_path(path: str, *, base_dir: Path) -> str:
path = path.strip()
if not path:
return path
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", path):
return path
candidate = Path(path)
if candidate.is_absolute():
return str(candidate)
return str((base_dir / candidate).resolve())
def _normalize_input_image(entry: Any, *, slide_number: int, image_index: int, base_dir: Path) -> Dict[str, Any]:
if isinstance(entry, dict):
image = dict(entry)
raw_path = image.get("path") or image.get("attachment") or image.get("markdown")
if isinstance(raw_path, str):
parsed = _parse_markdown_image(raw_path)
if parsed:
image["path"] = parsed["path"]
image.setdefault("role", parsed["role"])
if isinstance(image.get("path"), str):
image["path"] = _resolve_image_path(image["path"], base_dir=base_dir)
return image
if isinstance(entry, str):
parsed = _parse_markdown_image(entry)
if not parsed:
_die(
f"Slide {slide_number}: required_images entry {image_index} must be an "
"object or a Markdown image reference like ."
)
lowered = entry.lower()
fidelity = ""
if "strict input asset" in lowered:
fidelity = "strict input asset; preserve the supplied image content"
return {
"path": _resolve_image_path(parsed["path"], base_dir=base_dir),
"role": parsed["role"],
"fidelity": fidelity,
}
_die(f"Slide {slide_number}: required_images entry {image_index} has unsupported type.")
def _slide_images(slide: Dict[str, Any], *, slide_number: int, base_dir: Path) -> List[Dict[str, Any]]:
images: List[Dict[str, Any]] = []
for index, image in enumerate(_as_list(slide.get("required_images") or slide.get("input_images")), start=1):
images.append(_normalize_input_image(image, slide_number=slide_number, image_index=index, base_dir=base_dir))
return images
def _sample_generation_method(spec: Dict[str, Any], *, base_dir: Path) -> Optional[Dict[str, Any]]:
method = spec.get("sample_generation_method") or spec.get("image_generation_method")
if method is None:
return None
if not isinstance(method, dict):
_die("sample_generation_method must be an object when present.")
method = dict(method)
for key in ("approved_sample_path", "sample_slide_path", "sample_output_path"):
if isinstance(method.get(key), str):
method[key] = _resolve_image_path(method[key], base_dir=base_dir)
return method
def _method_backend_label(method: Optional[Dict[str, Any]]) -> Optional[str]:
if not method:
return None
for key in ("backend_used", "selected_backend", "backend", "tool_name"):
value = method.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _format_block(title: str, value: Any) -> str:
if value is None or value == "" or value == [] or value == {}:
return ""
if isinstance(value, (dict, list)):
body = json.dumps(value, ensure_ascii=False, indent=2)
else:
body = str(value).strip()
return f"## {title}\n{body}\n"
def _format_input_images(images: Iterable[Dict[str, Any]]) -> str:
lines: List[str] = []
for idx, image in enumerate(images, start=1):
path = str(image.get("path") or image.get("attachment") or "").strip()
role = str(image.get("role") or "reference image").strip()
fidelity = str(image.get("fidelity") or image.get("constraints") or "").strip()
if not path:
_die(f"Input image {idx} is missing path or attachment.")
if fidelity:
lines.append(f"- Image {idx}: {path} — {role}; {fidelity}")
else:
lines.append(f"- Image {idx}: {path} — {role}")
return "\n".join(lines)
def _slide_number(slide: Dict[str, Any], fallback: int) -> int:
raw = slide.get("number", fallback)
try:
number = int(raw)
except (TypeError, ValueError):
_die(f"Invalid slide number: {raw}")
if number <= 0:
_die(f"Slide number must be positive: {number}")
return number
def _build_prompt(
*,
deck: Dict[str, Any],
slide: Dict[str, Any],
number: int,
global_style_reference: Optional[Dict[str, Any]],
base_dir: Path,
) -> str:
title = str(slide.get("title") or f"Slide {number}").strip()
style = deck.get("style", {})
images: List[Dict[str, Any]] = []
if global_style_reference:
images.append(global_style_reference)
images.extend(_slide_images(slide, slide_number=number, base_dir=base_dir))
required_background = {
key: value
for key, value in {
"deck_context": deck.get("deck_context"),
"slide_local_context": slide.get("local_context"),
}.items()
if value not in (None, "", [], {})
}
prompt_parts = [
"# Codex PPT Slide Image Prompt\n",
_format_block("Canvas", {
"type": "16:9 full-slide PowerPoint image",
"language": deck.get("language", "Chinese"),
"slide_number": number,
"render_slide_number": False,
}),
_format_block("Deck Goal", deck.get("goal")),
_format_block("Required Background", required_background),
_format_block("Global Style", style),
]
if images:
prompt_parts.append("## Input Images\n")
prompt_parts.append(_format_input_images(images))
prompt_parts.append("\n")
prompt_parts.extend(
[
_format_block("Slide", {
"number": number,
"title": title,
"role": slide.get("role"),
"intent": slide.get("intent"),
}),
_format_block("Text", {
"title": title,
"key_points": _string_list(slide.get("key_points")),
"speaker_focus": slide.get("speaker_focus"),
}),
_format_block("Layout", slide.get("layout")),
_format_block("Visual Elements", slide.get("visual_elements")),
_format_block("Source Image Rules", slide.get("source_image_rules")),
_format_block("Constraints", _string_list(slide.get("constraints"))),
]
)
if global_style_reference:
prompt_parts.append(
"## Style Reference Rule\n"
"Use Image 1 as the approved sample-slide style reference. Match its palette, "
"typography mood, density, texture, and overall visual identity. Do not copy "
"its exact layout unless this slide's layout explicitly asks for it.\n"
)
if images:
prompt_parts.append(
"## Input Image Handling Rules\n"
"For any input image marked as a strict input asset, include it visibly and "
"preserve its content. Do not redraw, replace, relabel, or invent a similar "
"figure. Scale and crop only as needed for composition while keeping the "
"important labels, arrows, data, and relationships recognizable.\n"
)
prompt_parts.append(
"## Universal Constraints\n"
"- The final image itself must contain the title and key points.\n"
"- Render Chinese text exactly and legibly; avoid garbled characters.\n"
"- Keep the confirmed deck style consistent while varying layout by slide role.\n"
"- No watermark, unrelated logo, or extra slide number.\n"
)
return "\n".join(part for part in prompt_parts if part)
def _job_images(
slide: Dict[str, Any],
*,
number: int,
global_style_reference: Optional[Dict[str, Any]],
base_dir: Path,
) -> List[Dict[str, Any]]:
images: List[Dict[str, Any]] = []
if global_style_reference:
images.append(global_style_reference)
images.extend(_slide_images(slide, slide_number=number, base_dir=base_dir))
return images
def _write_template(path: Path) -> None:
template = {
"deck_name": "example-deck",
"language": "Chinese",
"goal": "Explain the core idea of the source article.",
"deck_context": {
"source_summary": "Short source-wide summary that workers may need when a slide refers to the broader article.",
"core_claim": "The central thesis that should stay consistent across the deck.",
"canonical_terms": ["Term one", "Term two", "Term three"],
},
"selected_image_backend": "built-in image tool",
"max_concurrent_slides": 6,
"sample_generation_method": {
"backend_used": "built-in image tool",
"tool_name": "image_gen",
"mode": "generate",
"prompt_source": "the approved sample slide job prompt",
"size": "16:9 landscape, 2560x1440 target",
"quality": "medium",
"approved_sample_path": "/absolute/path/to/approved-sample-slide.png",
"input_context_preparation": "view_image local required images before built-in generation",
"handoff_rule": "Subagents must use this same backend/tool/mode; return a blocker if unavailable.",
},
"style": {
"name": "手绘技术解释风",
"visual_direction": "clean hand-drawn technical explainer",
"color_palette": "white background, black marker lines, pale yellow highlights",
"typography": "large readable Chinese headings, compact handwritten annotations",
},
"approved_style_reference": {
"path": "/absolute/path/to/approved-sample-slide.png",
"role": "approved sample slide style reference",
"fidelity": "match style only; do not copy layout or content",
},
"slides": [
{
"number": 1,
"title": "Cover",
"role": "cover",
"intent": "Open the talk",
"key_points": ["Point one", "Point two"],
"local_context": {
"required_background": "Facts, lists, definitions, comparisons, or prior-slide references this slide needs to be self-contained.",
},
"layout": {"composition": "large title with one supporting visual"},
"visual_elements": {"main_visual": "topic-specific hand-drawn metaphor"},
"constraints": ["Keep text sparse"],
"sample_approved": True,
},
{
"number": 2,
"title": "Evidence",
"role": "data evidence",
"intent": "Explain a supplied result figure",
"key_points": ["Preserve the original figure", "Add two callouts"],
"required_images": [
{
"path": "/absolute/path/to/result_01.png",
"role": "strict input asset and main evidence figure",
"fidelity": "preserve data, axes, labels, legends, colors, and values",
},
"strict input asset and comparison chart\n\n",
],
"layout": {"composition": "source figure left, explanation cards right"},
},
],
}
path.write_text(json.dumps(template, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--spec", help="Deck spec JSON file.")
parser.add_argument("--out-dir", help="Deck project directory.")
parser.add_argument("--write-template", help="Write an example deck spec JSON and exit.")
parser.add_argument(
"--selected-backend",
help="Confirmed image backend label, such as `built-in image tool` or `scripts/image_gen.py`.",
)
parser.add_argument(
"--max-concurrent-slides",
type=int,
default=None,
help=f"Maximum slide subagents to dispatch at once. Defaults to {DEFAULT_MAX_CONCURRENT_SLIDES}.",
)
parser.add_argument("--force", action="store_true", help="Overwrite existing prompt files.")
args = parser.parse_args()
if args.write_template:
_write_template(Path(args.write_template))
return 0
if not args.spec or not args.out_dir:
_die("Use --spec and --out-dir, or --write-template.")
spec_path = Path(args.spec)
spec = _read_json(spec_path)
spec_dir = spec_path.resolve().parent
slides = spec.get("slides")
if not isinstance(slides, list) or not slides:
_die("Deck spec must include a non-empty slides array.")
numbered_slides: List[tuple[int, Dict[str, Any], int]] = []
seen_slide_numbers: Dict[int, int] = {}
for fallback, slide in enumerate(slides, start=1):
if not isinstance(slide, dict):
_die(f"Slide entry {fallback} must be an object.")
number = _slide_number(slide, fallback)
if number in seen_slide_numbers:
_die(
f"Duplicate slide number {number}: slide entries "
f"{seen_slide_numbers[number]} and {fallback} would both write slide_{number:02d}.json."
)
seen_slide_numbers[number] = fallback
numbered_slides.append((fallback, slide, number))
out_dir = Path(args.out_dir)
prompts_dir = out_dir / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "origin_image").mkdir(parents=True, exist_ok=True)
global_style_reference = spec.get("approved_style_reference")
if global_style_reference is not None and not isinstance(global_style_reference, dict):
_die("approved_style_reference must be an object when present.")
if global_style_reference and isinstance(global_style_reference.get("path"), str):
global_style_reference = dict(global_style_reference)
global_style_reference["path"] = _resolve_image_path(global_style_reference["path"], base_dir=spec_dir)
sample_generation_method = _sample_generation_method(spec, base_dir=spec_dir)
max_concurrent_slides = args.max_concurrent_slides
if max_concurrent_slides is None:
max_concurrent_slides = int(spec.get("max_concurrent_slides", DEFAULT_MAX_CONCURRENT_SLIDES))
if max_concurrent_slides < 1:
_die("max_concurrent_slides must be >= 1.")
selected_backend = (
args.selected_backend
or spec.get("selected_image_backend")
or spec.get("image_backend")
or _method_backend_label(sample_generation_method)
)
slide_job_entries: List[Dict[str, Any]] = []
for fallback, slide, number in numbered_slides:
use_style_reference = bool(slide.get("use_approved_style_reference", True))
slide_style_reference = global_style_reference if use_style_reference else None
prompt = _build_prompt(
deck=spec,
slide=slide,
number=number,
global_style_reference=slide_style_reference,
base_dir=spec_dir,
)
images = _job_images(slide, number=number, global_style_reference=slide_style_reference, base_dir=spec_dir)
job = {
"slide": number,
"title": slide.get("title", f"Slide {number}"),
"prompt": prompt,
"out": f"slide_{number:02d}.png",
"input_images": images,
"requires_context_images": bool(images),
"expected_backend": selected_backend,
"sample_generation_method": sample_generation_method,
"generation_contract": {
"must_use_selected_image_backend": True,
"must_match_sample_generation_method": bool(sample_generation_method),
"forbidden_final_image_methods": [
"local drawing/rendering scripts",
"Pillow-generated slides",
"SVG/HTML/CSS/canvas screenshots",
"python-pptx/PptxGenJS/native PPT layout screenshots",
"manually composited text/image overlays",
],
"must_return": ["backend_used", "selected_source", "qa_note"],
},
}
prompt_path = prompts_dir / f"slide_{number:02d}.json"
if prompt_path.exists() and not args.force:
_die(f"Slide job file already exists: {prompt_path} (use --force)")
prompt_path.write_text(json.dumps(job, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
slide_id = f"slide_{number:02d}"
final_image = out_dir / "origin_image" / f"{slide_id}.png"
sample_approved = bool(slide.get("sample_approved") or slide.get("approved_sample"))
status = "accepted" if sample_approved and final_image.exists() else "pending"
slide_job_entries.append(
{
"slide_id": slide_id,
"number": number,
"title": slide.get("title", f"Slide {number}"),
"job": rel_to_deck(out_dir, prompt_path),
"out": rel_to_deck(out_dir, final_image),
"input_images": images,
"requires_context_images": bool(images),
"status": status,
"dispatch": None,
"result": {
"final_image": rel_to_deck(out_dir, final_image),
"accepted_sample": True,
}
if status == "accepted"
else None,
"blocker": None,
}
)
slide_jobs = {
"run_status": "jobs_prepared",
"deck_name": spec.get("deck_name"),
"selected_backend": selected_backend,
"sample_generation_method": sample_generation_method,
"max_concurrent_slides": max_concurrent_slides,
"slides": slide_job_entries,
"updated_at": now_iso(),
}
save_jobs(out_dir, slide_jobs)
set_run_status(out_dir, "jobs_prepared", "prepared slide prompt jobs")
print(f"Wrote {len(slides)} slide job file(s) to {prompts_dir}")
print(f"Wrote slide job state to {out_dir / 'slide_jobs.json'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
scripts/record_slide_blocker.py#!/usr/bin/env python3
"""Record a blocker that prevents a codex-ppt slide job from completing."""
from __future__ import annotations
import argparse
from slide_run_state import (
deck_dir_from_target,
find_slide,
locked_jobs,
now_iso,
set_run_status,
update_jobs_run_status,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("deck", help="Deck directory or slide_jobs.json")
parser.add_argument("--slide", required=True, help="slide_01 or 1")
parser.add_argument("--reason", required=True)
parser.add_argument("--agent-id")
args = parser.parse_args()
deck_dir = deck_dir_from_target(args.deck)
with locked_jobs(deck_dir) as jobs:
slide = find_slide(jobs, args.slide)
if slide.get("status") in {"recorded", "accepted"}:
raise SystemExit(f"{slide['slide_id']} is already complete; refusing to mark it blocked.")
slide["blocker"] = {
"agent_id": args.agent_id,
"reason": args.reason,
"blocked_at": now_iso(),
}
slide["status"] = "blocked"
update_jobs_run_status(jobs)
jobs["run_status"] = "blocked"
slide_id = slide["slide_id"]
set_run_status(deck_dir, "blocked", f"{slide_id}: {args.reason}")
print(f"{slide_id} -> blocked")
return 0
if __name__ == "__main__":
raise SystemExit(main())
scripts/record_slide_dispatch.py#!/usr/bin/env python3
"""Record that a codex-ppt slide job was dispatched to a subagent."""
from __future__ import annotations
import argparse
from pathlib import Path
from slide_run_state import (
deck_dir_from_target,
find_slide,
locked_jobs,
now_iso,
rel_to_deck,
resolve_deck_path,
set_run_status,
sha256_file,
update_jobs_run_status,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("deck", help="Deck directory or slide_jobs.json")
parser.add_argument("--slide", required=True, help="slide_01 or 1")
parser.add_argument("--agent-id", required=True)
parser.add_argument("--agent-nickname")
parser.add_argument("--prompt-file", help="Prompt file path. Defaults to the job file in slide_jobs.json.")
args = parser.parse_args()
deck_dir = deck_dir_from_target(args.deck)
with locked_jobs(deck_dir) as jobs:
slide = find_slide(jobs, args.slide)
if slide.get("status") != "pending":
raise SystemExit(f"{slide['slide_id']} must be pending before dispatch; got {slide.get('status')}")
prompt_ref = args.prompt_file or slide.get("job")
if not prompt_ref:
raise SystemExit(f"{slide['slide_id']} has no prompt job path.")
prompt_path = resolve_deck_path(deck_dir, prompt_ref)
try:
prompt_path.relative_to(deck_dir)
except ValueError as exc:
raise SystemExit(f"Prompt file must live inside deck dir: {prompt_path}") from exc
if not prompt_path.exists():
raise SystemExit(f"Prompt file does not exist: {prompt_path}")
slide["dispatch"] = {
"agent_id": args.agent_id,
"agent_nickname": args.agent_nickname,
"prompt": rel_to_deck(deck_dir, prompt_path),
"prompt_sha256": sha256_file(prompt_path),
"dispatched_at": now_iso(),
}
slide["status"] = "dispatched"
update_jobs_run_status(jobs)
run_status = jobs.get("run_status")
if run_status == "slides_dispatched":
set_run_status(deck_dir, "slides_dispatched", "all slide jobs dispatched")
print(f"{slide['slide_id']} -> dispatched")
return 0
if __name__ == "__main__":
raise SystemExit(main())
scripts/record_slide_result.py#!/usr/bin/env python3
"""Record and copy a codex-ppt slide image generated by a subagent."""
from __future__ import annotations
import argparse
import json
import re
import shutil
from pathlib import Path
from slide_run_state import (
deck_dir_from_target,
ensure_file,
find_slide,
locked_jobs,
now_iso,
rel_to_deck,
resolve_deck_path,
set_run_status,
sha256_file,
update_jobs_run_status,
)
FORBIDDEN_BACKEND_TERMS = (
"pillow",
"html",
"svg",
"canvas",
"python-pptx",
"pptxgenjs",
"manual",
"script render",
)
def _validate_backend(backend: str) -> str:
value = backend.strip()
if not value:
raise SystemExit("--backend-used must not be empty")
lowered = value.lower()
for term in FORBIDDEN_BACKEND_TERMS:
if term in lowered:
raise SystemExit(f"Forbidden backend for final slide image: {backend}")
if "image" not in lowered and "built-in" not in lowered:
raise SystemExit(
"--backend-used must name the selected image backend, such as "
"`built-in image tool` or `scripts/image_gen.py`."
)
return value
def _backend_family(value: str) -> str:
lowered = value.lower()
if "image_gen.py" in lowered or "scripts/image_gen" in lowered:
return "cli-api-fallback"
if "cli" in lowered or "api" in lowered or "fallback" in lowered:
return "cli-api-fallback"
if "built-in" in lowered or "builtin" in lowered or "image_generate" in lowered:
return "built-in-image-tool"
if "image_gen" in lowered:
return "built-in-image-tool"
return ""
def _normalized_backend_label(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", value.lower())
def _method_backend_labels(method: object) -> list[str]:
if not isinstance(method, dict):
return []
labels: list[str] = []
for key in ("backend_used", "selected_backend", "backend", "tool_name"):
value = method.get(key)
if isinstance(value, str) and value.strip():
labels.append(value.strip())
return labels
def _load_prompt_job(deck_dir: Path, slide: dict) -> dict:
job_ref = slide.get("job")
if not isinstance(job_ref, str) or not job_ref.strip():
return {}
path = resolve_deck_path(deck_dir, job_ref)
if not path.exists():
return {}
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
def _expected_backend_labels(jobs: dict, slide: dict, prompt_job: dict) -> list[str]:
labels: list[str] = []
for value in (jobs.get("selected_backend"), prompt_job.get("expected_backend")):
if isinstance(value, str) and value.strip():
labels.append(value.strip())
labels.extend(_method_backend_labels(jobs.get("sample_generation_method")))
labels.extend(_method_backend_labels(prompt_job.get("sample_generation_method")))
unique_labels: list[str] = []
for label in labels:
if label not in unique_labels:
unique_labels.append(label)
return unique_labels
def _matched_expected_backend(backend_used: str, expected_labels: list[str]) -> str | None:
if not expected_labels:
return None
used_normalized = _normalized_backend_label(backend_used)
used_family = _backend_family(backend_used)
for label in expected_labels:
if _normalized_backend_label(label) == used_normalized:
return label
expected_family = _backend_family(label)
if expected_family and expected_family == used_family:
return label
raise SystemExit(
"Backend mismatch: worker reported "
f"`{backend_used}`, but slide job expects one of: {', '.join(expected_labels)}"
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("deck", help="Deck directory or slide_jobs.json")
parser.add_argument("--slide", required=True, help="slide_01 or 1")
parser.add_argument("--agent-id", required=True)
parser.add_argument("--backend-used", required=True)
parser.add_argument("--selected-source", required=True, help="Generated image selected by the worker.")
parser.add_argument("--qa-note", required=True)
args = parser.parse_args()
deck_dir = deck_dir_from_target(args.deck)
with locked_jobs(deck_dir) as jobs:
slide = find_slide(jobs, args.slide)
if slide.get("status") != "dispatched":
raise SystemExit(f"{slide['slide_id']} must be dispatched before result recording; got {slide.get('status')}")
dispatch = slide.get("dispatch") or {}
if dispatch.get("agent_id") != args.agent_id:
raise SystemExit(
f"Agent id mismatch for {slide['slide_id']}: dispatch={dispatch.get('agent_id')} result={args.agent_id}"
)
backend_used = _validate_backend(args.backend_used)
prompt_job = _load_prompt_job(deck_dir, slide)
expected_backend_labels = _expected_backend_labels(jobs, slide, prompt_job)
matched_expected_backend = _matched_expected_backend(backend_used, expected_backend_labels)
source = ensure_file(Path(args.selected_source).expanduser().resolve(), "selected source image")
out_ref = slide.get("out") or f"origin_image/{slide['slide_id']}.png"
target = resolve_deck_path(deck_dir, out_ref)
try:
target.relative_to(deck_dir)
except ValueError as exc:
raise SystemExit(f"Final slide image must live inside deck dir: {target}") from exc
target.parent.mkdir(parents=True, exist_ok=True)
if source != target:
shutil.copy2(source, target)
slide["result"] = {
"agent_id": args.agent_id,
"backend_used": backend_used,
"selected_source": str(source),
"selected_source_sha256": sha256_file(source),
"final_image": rel_to_deck(deck_dir, target),
"final_image_sha256": sha256_file(target),
"expected_backend": matched_expected_backend,
"expected_backend_labels": expected_backend_labels,
"sample_generation_method_matched": bool(matched_expected_backend),
"qa_note": args.qa_note,
"recorded_at": now_iso(),
}
slide["status"] = "recorded"
update_jobs_run_status(jobs)
run_status = jobs.get("run_status")
slide_id = slide["slide_id"]
if run_status == "slides_recorded":
set_run_status(deck_dir, "slides_recorded", "all slide images recorded")
print(json.dumps({"slide_id": slide_id, "status": "recorded"}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
scripts/remove_chroma_key.py#!/usr/bin/env python3
"""Remove a solid chroma-key background from a codex-ppt slide image.
This helper supports the CLI/API transparent fallback workflow: generate an
image on a flat key color, then convert that key color to alpha.
"""
from __future__ import annotations
import argparse
from io import BytesIO
from pathlib import Path
import re
from statistics import median
import sys
from typing import Tuple
Color = Tuple[int, int, int]
KEY_DOMINANCE_THRESHOLD = 16.0
ALPHA_NOISE_FLOOR = 8
DEFAULT_RUNTIME_HOME = "~/.codex-ppt-skill"
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _dependency_hint(package: str) -> str:
home = Path(os.getenv("CODEX_PPT_HOME", DEFAULT_RUNTIME_HOME)).expanduser()
if os.name == "nt":
python = home / ".venv" / "Scripts" / "python.exe"
else:
python = home / ".venv" / "bin" / "python"
skill_root = Path(__file__).resolve().parents[1]
return (
"Install codex-ppt dependencies in the shared runtime first, for example "
f"`python3 {skill_root / 'scripts' / 'codex_ppt_runtime.py'} bootstrap`, "
f"or install {package} directly with `{python} -m pip install {package}`."
)
def _load_pillow():
try:
from PIL import Image, ImageFilter
except ImportError:
_die(f"Pillow is required for chroma-key removal. {_dependency_hint('pillow')}")
return Image, ImageFilter
def _parse_key_color(raw: str) -> Color:
value = raw.strip()
match = re.fullmatch(r"#?([0-9a-fA-F]{6})", value)
if not match:
_die("key color must be a hex RGB value like #00ff00.")
hex_value = match.group(1)
return (
int(hex_value[0:2], 16),
int(hex_value[2:4], 16),
int(hex_value[4:6], 16),
)
def _validate_args(args: argparse.Namespace) -> None:
if args.tolerance < 0 or args.tolerance > 255:
_die("--tolerance must be between 0 and 255.")
if args.transparent_threshold < 0 or args.transparent_threshold > 255:
_die("--transparent-threshold must be between 0 and 255.")
if args.opaque_threshold < 0 or args.opaque_threshold > 255:
_die("--opaque-threshold must be between 0 and 255.")
if args.soft_matte and args.transparent_threshold >= args.opaque_threshold:
_die("--transparent-threshold must be lower than --opaque-threshold.")
if args.edge_feather < 0 or args.edge_feather > 64:
_die("--edge-feather must be between 0 and 64.")
if args.edge_contract < 0 or args.edge_contract > 16:
_die("--edge-contract must be between 0 and 16.")
src = Path(args.input)
if not src.exists():
_die(f"Input image not found: {src}")
out = Path(args.out)
if out.exists() and not args.force:
_die(f"Output already exists: {out} (use --force to overwrite)")
if out.suffix.lower() not in {".png", ".webp"}:
_die("--out must end in .png or .webp so the alpha channel is preserved.")
def _channel_distance(a: Color, b: Color) -> int:
return max(abs(a[0] - b[0]), abs(a[1] - b[1]), abs(a[2] - b[2]))
def _clamp_channel(value: float) -> int:
return max(0, min(255, int(round(value))))
def _smoothstep(value: float) -> float:
value = max(0.0, min(1.0, value))
return value * value * (3.0 - 2.0 * value)
def _soft_alpha(distance: int, transparent_threshold: float, opaque_threshold: float) -> int:
if distance <= transparent_threshold:
return 0
if distance >= opaque_threshold:
return 255
ratio = (float(distance) - transparent_threshold) / (
opaque_threshold - transparent_threshold
)
return _clamp_channel(255.0 * _smoothstep(ratio))
def _dominance_alpha(rgb: Color, key: Color) -> int:
spill_channels = _spill_channels(key)
if not spill_channels:
return 255
channels = [float(value) for value in rgb]
non_spill = [idx for idx in range(3) if idx not in spill_channels]
key_strength = (
min(channels[idx] for idx in spill_channels)
if len(spill_channels) > 1
else channels[spill_channels[0]]
)
non_key_strength = max((channels[idx] for idx in non_spill), default=0.0)
dominance = key_strength - non_key_strength
if dominance <= 0:
return 255
denominator = max(1.0, float(max(key)) - non_key_strength)
alpha = 1.0 - min(1.0, dominance / denominator)
return _clamp_channel(alpha * 255.0)
def _spill_channels(key: Color) -> list[int]:
key_max = max(key)
if key_max < 128:
return []
return [idx for idx, value in enumerate(key) if value >= key_max - 16 and value >= 128]
def _key_channel_dominance(rgb: Color, key: Color) -> float:
spill_channels = _spill_channels(key)
if not spill_channels:
return 0.0
channels = [float(value) for value in rgb]
non_spill = [idx for idx in range(3) if idx not in spill_channels]
key_strength = (
min(channels[idx] for idx in spill_channels)
if len(spill_channels) > 1
else channels[spill_channels[0]]
)
non_key_strength = max((channels[idx] for idx in non_spill), default=0.0)
return key_strength - non_key_strength
def _looks_key_colored(rgb: Color, key: Color, distance: int) -> bool:
if distance <= 32:
return True
spill_channels = _spill_channels(key)
if not spill_channels:
return True
return _key_channel_dominance(rgb, key) >= KEY_DOMINANCE_THRESHOLD
def _cleanup_spill(rgb: Color, key: Color, alpha: int = 255) -> Color:
if alpha >= 252:
return rgb
spill_channels = _spill_channels(key)
if not spill_channels:
return rgb
channels = [float(value) for value in rgb]
non_spill = [idx for idx in range(3) if idx not in spill_channels]
if non_spill:
anchor = max(channels[idx] for idx in non_spill)
cap = max(0.0, anchor - 1.0)
for idx in spill_channels:
if channels[idx] > cap:
channels[idx] = cap
return (
_clamp_channel(channels[0]),
_clamp_channel(channels[1]),
_clamp_channel(channels[2]),
)
def _apply_alpha_to_image(
image,
*,
key: Color,
tolerance: int,
spill_cleanup: bool,
soft_matte: bool,
transparent_threshold: float,
opaque_threshold: float,
) -> int:
pixels = image.load()
width, height = image.size
transparent = 0
for y in range(height):
for x in range(width):
red, green, blue, alpha = pixels[x, y]
rgb = (red, green, blue)
distance = _channel_distance(rgb, key)
key_like = _looks_key_colored(rgb, key, distance)
output_alpha = (
min(
_soft_alpha(distance, transparent_threshold, opaque_threshold),
_dominance_alpha(rgb, key),
)
if soft_matte and key_like
else (0 if distance <= tolerance else 255)
)
output_alpha = int(round(output_alpha * (alpha / 255.0)))
if 0 < output_alpha <= ALPHA_NOISE_FLOOR:
output_alpha = 0
if output_alpha == 0:
pixels[x, y] = (0, 0, 0, 0)
transparent += 1
continue
if spill_cleanup and key_like:
red, green, blue = _cleanup_spill(rgb, key, output_alpha)
pixels[x, y] = (red, green, blue, output_alpha)
return transparent
def _contract_alpha(image, pixels: int):
if pixels == 0:
return image
_, ImageFilter = _load_pillow()
alpha = image.getchannel("A")
for _ in range(pixels):
alpha = alpha.filter(ImageFilter.MinFilter(3))
image.putalpha(alpha)
return image
def _apply_edge_feather(image, radius: float):
if radius == 0:
return image
_, ImageFilter = _load_pillow()
alpha = image.getchannel("A")
alpha = alpha.filter(ImageFilter.GaussianBlur(radius=radius))
image.putalpha(alpha)
return image
def _encode_image(image, output_format: str) -> bytes:
out = BytesIO()
image.save(out, format=output_format.upper())
return out.getvalue()
def _alpha_counts(image) -> tuple[int, int, int]:
pixels = image.load()
width, height = image.size
total = 0
transparent = 0
partial = 0
for y in range(height):
for x in range(width):
alpha = pixels[x, y][3]
total += 1
if alpha == 0:
transparent += 1
elif alpha < 255:
partial += 1
return total, transparent, partial
def _sample_border_key(image, mode: str) -> Color:
width, height = image.size
pixels = image.load()
samples: list[Color] = []
if mode == "corners":
patch = max(1, min(width, height, 12))
boxes = [
(0, 0, patch, patch),
(width - patch, 0, width, patch),
(0, height - patch, patch, height),
(width - patch, height - patch, width, height),
]
for left, top, right, bottom in boxes:
for y in range(top, bottom):
for x in range(left, right):
red, green, blue = pixels[x, y][:3]
samples.append((red, green, blue))
else:
band = max(1, min(width, height, 6))
step = max(1, min(width, height) // 256)
for x in range(0, width, step):
for y in range(band):
red, green, blue = pixels[x, y][:3]
samples.append((red, green, blue))
red, green, blue = pixels[x, height - 1 - y][:3]
samples.append((red, green, blue))
for y in range(0, height, step):
for x in range(band):
red, green, blue = pixels[x, y][:3]
samples.append((red, green, blue))
red, green, blue = pixels[width - 1 - x, y][:3]
samples.append((red, green, blue))
if not samples:
_die("Could not sample background key color from image border.")
return (
int(round(median(sample[0] for sample in samples))),
int(round(median(sample[1] for sample in samples))),
int(round(median(sample[2] for sample in samples))),
)
def _remove_chroma_key(args: argparse.Namespace) -> None:
Image, _ = _load_pillow()
src = Path(args.input)
out = Path(args.out)
with Image.open(src) as image:
rgba = image.convert("RGBA")
key = (
_sample_border_key(rgba, args.auto_key)
if args.auto_key != "none"
else _parse_key_color(args.key_color)
)
transparent = _apply_alpha_to_image(
rgba,
key=key,
tolerance=args.tolerance,
spill_cleanup=args.spill_cleanup,
soft_matte=args.soft_matte,
transparent_threshold=args.transparent_threshold,
opaque_threshold=args.opaque_threshold,
)
rgba = _contract_alpha(rgba, args.edge_contract)
rgba = _apply_edge_feather(rgba, args.edge_feather)
total, transparent_after, partial_after = _alpha_counts(rgba)
out.parent.mkdir(parents=True, exist_ok=True)
output_format = "PNG" if out.suffix.lower() == ".png" else "WEBP"
out.write_bytes(_encode_image(rgba, output_format))
print(f"Wrote {out}")
print(f"Key color: #{key[0]:02x}{key[1]:02x}{key[2]:02x}")
print(f"Transparent pixels: {transparent_after}/{total}")
print(f"Partially transparent pixels: {partial_after}/{total}")
if transparent == 0:
print("Warning: no pixels matched the key color before feathering.", file=sys.stderr)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Remove a solid chroma-key background and write an image with alpha."
)
parser.add_argument("--input", required=True, help="Input image path.")
parser.add_argument("--out", required=True, help="Output .png or .webp path.")
parser.add_argument(
"--key-color",
default="#00ff00",
help="Hex RGB key color to remove, for example #00ff00.",
)
parser.add_argument(
"--tolerance",
type=int,
default=12,
help="Hard-key per-channel tolerance for matching the key color, 0-255.",
)
parser.add_argument(
"--auto-key",
choices=["none", "corners", "border"],
default="none",
help="Sample the key color from image corners or border instead of --key-color.",
)
parser.add_argument(
"--soft-matte",
action="store_true",
help="Use a smooth alpha ramp between transparent and opaque thresholds.",
)
parser.add_argument(
"--transparent-threshold",
type=float,
default=12.0,
help="Soft-matte distance at or below which pixels become fully transparent.",
)
parser.add_argument(
"--opaque-threshold",
type=float,
default=96.0,
help="Soft-matte distance at or above which pixels become fully opaque.",
)
parser.add_argument(
"--edge-feather",
type=float,
default=0.0,
help="Optional alpha blur radius for softened edges, 0-64.",
)
parser.add_argument(
"--edge-contract",
type=int,
default=0,
help="Shrink the visible alpha matte by this many pixels before feathering.",
)
parser.add_argument(
"--spill-cleanup",
dest="spill_cleanup",
action="store_true",
help="Reduce obvious key-color spill on opaque pixels.",
)
parser.add_argument(
"--despill",
dest="spill_cleanup",
action="store_true",
help="Alias for --spill-cleanup; decontaminate key-color edge spill.",
)
parser.add_argument("--force", action="store_true", help="Overwrite an existing output file.")
return parser
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
_validate_args(args)
_remove_chroma_key(args)
if __name__ == "__main__":
main()
scripts/slide_job_status.py#!/usr/bin/env python3
"""Print codex-ppt slide generation job status."""
from __future__ import annotations
import argparse
import json
from collections import Counter, defaultdict
from slide_run_state import (
active_slides,
deck_dir_from_target,
dispatch_slots_available,
dispatchable_slides,
load_jobs,
load_run_state,
max_concurrent_slides,
slide_ids,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("deck", help="Deck directory or slide_jobs.json")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
deck_dir = deck_dir_from_target(args.deck)
jobs = load_jobs(deck_dir)
state = load_run_state(deck_dir)
by_status = defaultdict(list)
for slide in jobs.get("slides", []):
by_status[slide.get("status", "unknown")].append(slide.get("slide_id"))
summary = {
"deck_dir": str(deck_dir),
"run_status": state.get("status"),
"slide_count": len(jobs.get("slides", [])),
"selected_backend": jobs.get("selected_backend"),
"max_concurrent_slides": max_concurrent_slides(jobs),
"active_dispatches": slide_ids(active_slides(jobs)),
"dispatch_slots_available": dispatch_slots_available(jobs),
"dispatchable_slides": slide_ids(dispatchable_slides(jobs)),
"counts": dict(Counter(slide.get("status", "unknown") for slide in jobs.get("slides", []))),
"slides": dict(sorted(by_status.items())),
}
if args.json:
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
print(f"deck_dir={summary['deck_dir']}")
print(f"run_status={summary['run_status']}")
print(f"selected_backend={summary['selected_backend'] or '-'}")
print(f"max_concurrent_slides={summary['max_concurrent_slides']}")
print(f"active_dispatches={', '.join(summary['active_dispatches']) if summary['active_dispatches'] else '-'}")
print(f"dispatch_slots_available={summary['dispatch_slots_available']}")
print(f"dispatchable_slides={', '.join(summary['dispatchable_slides']) if summary['dispatchable_slides'] else '-'}")
for status, slides in summary["slides"].items():
print(f"{status}: {', '.join(slides) if slides else '-'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
scripts/slide_run_state.py#!/usr/bin/env python3
"""State helpers for codex-ppt slide generation runs."""
from __future__ import annotations
import hashlib
import json
import os
import tempfile
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List
from filelock import FileLock
ACTIVE_SLIDE_STATUSES = {"dispatched"}
DISPATCHABLE_SLIDE_STATUSES = {"pending"}
TERMINAL_SLIDE_STATUSES = {"recorded", "accepted", "blocked"}
DEFAULT_MAX_CONCURRENT_SLIDES = 6
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def read_json(path: Path, default: Any = None) -> Any:
path = Path(path)
if not path.exists():
if default is not None:
return default
raise FileNotFoundError(path)
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: Any) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
body = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(body)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
@contextmanager
def locked_json(path: Path, default: Any = None):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
lock_path = path.with_name(f".{path.name}.lock")
with FileLock(str(lock_path)):
data = read_json(path, default=default)
try:
yield data
except Exception:
raise
else:
write_json(path, data)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def deck_dir_from_target(target: str) -> Path:
path = Path(target).expanduser().resolve()
if path.is_dir():
return path
if path.name == "slide_jobs.json":
return path.parent
raise ValueError(f"Expected deck directory or slide_jobs.json: {target}")
def slide_jobs_path(deck_dir: Path) -> Path:
return Path(deck_dir) / "slide_jobs.json"
def run_state_path(deck_dir: Path) -> Path:
return Path(deck_dir) / "slide_run_state.json"
def load_jobs(deck_dir: Path) -> Dict[str, Any]:
return read_json(slide_jobs_path(deck_dir))
def save_jobs(deck_dir: Path, jobs: Dict[str, Any]) -> None:
write_json(slide_jobs_path(deck_dir), jobs)
def locked_jobs(deck_dir: Path):
return locked_json(slide_jobs_path(deck_dir))
def load_run_state(deck_dir: Path) -> Dict[str, Any]:
return read_json(run_state_path(deck_dir), default={"status": "created", "history": []})
def save_run_state(deck_dir: Path, state: Dict[str, Any]) -> None:
write_json(run_state_path(deck_dir), state)
def locked_run_state(deck_dir: Path) -> Any:
return locked_json(run_state_path(deck_dir), default={"status": "created", "history": []})
def set_run_status(deck_dir: Path, status: str, note: str | None = None) -> Dict[str, Any]:
with locked_run_state(deck_dir) as state:
if state.get("status") != status:
state.setdefault("history", []).append(
{"from": state.get("status"), "to": status, "at": now_iso(), "note": note}
)
state["status"] = status
state["updated_at"] = now_iso()
return state
def normalize_slide_id(value: Any) -> str:
text = str(value).strip()
if text.startswith("slide_"):
suffix = text.removeprefix("slide_")
if suffix.isdigit():
return f"slide_{int(suffix):02d}"
if text.isdigit():
return f"slide_{int(text):02d}"
raise ValueError(f"Invalid slide id: {value}")
def find_slide(jobs: Dict[str, Any], slide: Any) -> Dict[str, Any]:
slide_id = normalize_slide_id(slide)
for entry in jobs.get("slides", []):
if entry.get("slide_id") == slide_id or str(entry.get("number")) == str(slide):
return entry
raise KeyError(f"Slide not found in slide_jobs.json: {slide_id}")
def resolve_deck_path(deck_dir: Path, value: str) -> Path:
path = Path(value)
if not path.is_absolute():
path = Path(deck_dir) / path
return path.resolve()
def rel_to_deck(deck_dir: Path, value: Path) -> str:
return Path(value).resolve().relative_to(Path(deck_dir).resolve()).as_posix()
def ensure_file(path: Path, label: str) -> Path:
path = Path(path)
if not path.exists() or not path.is_file():
raise FileNotFoundError(f"Missing {label}: {path}")
return path
def active_slides(jobs: Dict[str, Any]) -> List[Dict[str, Any]]:
return [slide for slide in jobs.get("slides", []) if slide.get("status") in ACTIVE_SLIDE_STATUSES]
def dispatchable_slides(jobs: Dict[str, Any]) -> List[Dict[str, Any]]:
return [slide for slide in jobs.get("slides", []) if slide.get("status") in DISPATCHABLE_SLIDE_STATUSES]
def max_concurrent_slides(jobs: Dict[str, Any]) -> int:
value = jobs.get("max_concurrent_slides", DEFAULT_MAX_CONCURRENT_SLIDES)
try:
value = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid max_concurrent_slides: {value}") from exc
if value < 1:
raise ValueError("max_concurrent_slides must be >= 1")
return value
def dispatch_slots_available(jobs: Dict[str, Any]) -> int:
return max(0, max_concurrent_slides(jobs) - len(active_slides(jobs)))
def update_jobs_run_status(jobs: Dict[str, Any]) -> None:
slides = jobs.get("slides", [])
if slides and all(slide.get("status") in {"dispatched", "recorded", "accepted"} for slide in slides):
jobs["run_status"] = "slides_dispatched"
if slides and all(slide.get("status") in {"recorded", "accepted"} for slide in slides):
jobs["run_status"] = "slides_recorded"
if slides and all(slide.get("status") in TERMINAL_SLIDE_STATUSES for slide in slides):
if any(slide.get("status") == "blocked" for slide in slides):
jobs["run_status"] = "blocked"
jobs["updated_at"] = now_iso()
def slide_ids(slides: Iterable[Dict[str, Any]]) -> List[str]:
return [str(slide.get("slide_id")) for slide in slides]
SKILL.md---
name: codex-ppt
description: Generate visually unified image-based PPT/PPTX decks from articles, reports, papers, notes, or outlines.
metadata:
openclaw:
requires:
bins:
- python3
primaryEnv: OPENAI_API_KEY
envVars:
- name: OPENAI_API_KEY
required: false
description: API key for CLI fallback.
- name: OPENAI_BASE_URL
required: false
description: API base URL.
- name: CODEX_PPT_IMAGE_MODEL
required: false
description: Image model, defaults to gpt-image-2.5-flare.
- name: CODEX_PPT_HOME
required: false
description: Runtime home override.
homepage: https://github.com/ningzimu/codex-ppt-skill
---
# Codex PPT
## Overview
This skill creates image-based PowerPoint decks from source material. Each slide is a complete 16:9 generated image. Final images are assembled into `.pptx` with `scripts/assemble_ppt.py`.
Use this when the user wants a visually unified presentation and accepts full-slide image pages. Do not use it when every textbox, chart, or shape must remain separately editable.
Prefer the built-in image generation/editing tool. Use `scripts/image_gen.py` only when the built-in backend is unavailable, lacks a required capability, or the user explicitly asks for API/CLI mode.
## Hard Constraints
- Read the relevant `Reference Map` files before each phase. This file is the orchestration contract; detailed rules live in `docs/` and worker prompts in `prompts/`.
- Respect approval gates. Do not create final `deck_spec.json`, `speech.md`, prompt jobs, slide images, or `.pptx` before the approvals in `docs/workflow-gates-and-progress.md`.
- After the user approves the sample slide and authorizes full-deck generation, every remaining slide image job must be dispatched to a slide subagent whenever subagents are available.
- The main agent owns orchestration, prompt jobs, state recording, QA, speaker notes, and assembly. Do not silently replace available slide subagents with sequential production.
- Every final `origin_image/slide_XX.png` must be generated by the selected image backend: built-in image generation/editing tool or `scripts/image_gen.py`.
- Local drawing, Pillow, SVG, HTML/CSS/canvas screenshots, python-pptx/PptxGenJS layouts, and manual overlays are failure modes, not fallbacks.
- The selected image backend must stay fixed after backend confirmation. Do not let subagents switch backend for convenience.
- After sample approval, record how the approved sample was generated and pass that exact method to every slide subagent.
- Slide dispatch and result state must be recorded with the bundled scripts. Chat messages alone do not make a slide dispatched or complete.
- If a required subagent, image backend, or required-image path is unavailable, stop and report a blocker with the slide id and evidence. Do not create a lower-quality replacement.
## Visible Progress
For non-trivial decks, keep a user-visible checklist with one active step. Canonical completion evidence is in `docs/workflow-gates-and-progress.md`.
Default visible steps:
1. Prepare source, outline, style, and backend decisions.
2. Generate and approve one sample slide.
3. Prepare slide jobs and slide state.
4. Dispatch slide subagents.
5. Record generated slide results.
6. QA, repair, notes, and PPT assembly.
Do not mark a step complete from chat alone; use real files or script-recorded state.
## Default Workflow
1. Understand the source content.
- Identify topic, audience, goal, page count, style/brand constraints, and sections to include or exclude.
- If no page count is specified, choose a practical count. Typical decks are 8-12 slides.
2. Plan the deck outline.
- Before writing or updating `outline.md`, read `docs/workflow-gates-and-progress.md` and `docs/outline-style-and-sample.md`.
- Draft slide roles and required source images. Ask for confirmation, then stop before style, backend, sample, or downstream artifacts until approved.
3. Confirm a unified visual style.
- Before offering style options or using files from `references/`, read `docs/outline-style-and-sample.md`.
- Offer 2-3 concrete style directions, recommend one, wait for confirmation, then keep one visual identity while varying layouts by page role.
4. Confirm the image backend.
- Before generating any slide image, read `docs/backend-selection.md`.
- Check whether a built-in image tool is callable, state what you checked, name the backend, explain fallback status, and wait for confirmation.
- If CLI/API fallback is selected, read `docs/cli-api-fallback.md`. Read `docs/image-model-configuration.md` only after config errors or explicit API-setting requests.
5. Generate one sample slide for approval.
- Before generating or approving the sample slide, read `docs/outline-style-and-sample.md`.
- Generate exactly one representative sample after outline, style, and backend are confirmed. Do not generate the full deck until approved.
- After approval, record `sample_generation_method` in `deck_spec.json` so jobs and subagents inherit the same path.
6. Create the project directory.
- Before initializing folders or assembling files, read `docs/project-assembly-and-reporting.md`.
- If no destination is specified, use the current working directory or the source file directory.
7. Prepare user-supplied assets.
- Before using paper figures, charts, screenshots, logos, or other required assets, read `docs/user-supplied-assets.md`.
- Treat required assets as strict inputs and confirm slide-to-asset mapping before generation.
8. Generate all slide images.
- Before full-deck image generation, read `docs/slide-generation-and-subagents.md`.
- Create per-slide jobs with `scripts/prepare_slide_prompts.py` or saved `prompts/slide_XX.json` files.
- Every final image must come from the selected backend and be recorded with bundled state scripts.
9. Dispatch slide subagents.
- Before dispatching or replacing slide workers, read `docs/slide-generation-and-subagents.md` and `prompts/slide-worker.md`.
- Use one subagent per remaining slide job whenever possible. If required subagents cannot be spawned, stop and report a blocker unless the user changes the workflow.
10. Quality check and repair.
- Before QA or assembly, read `docs/project-assembly-and-reporting.md`.
- Inspect every slide before assembly: text, outline match, truncation, style, unwanted page numbers, overlaps, and required assets.
- Regenerate severe failures with a tighter prompt. Use backend editing for localized issues when available.
- For CLI/API fallback edit commands, read `docs/cli-api-fallback.md`. Replace the final slide only after validating the edited output.
11. Write speaker notes and assemble the PPT.
- Before writing `speech.md` or running assembly, read `docs/project-assembly-and-reporting.md`.
- Make sure `outline.md` reflects the final confirmed deck outline. Use `speech.md` headings that map to `Slide N`.
- Before assembly, ensure `slide_jobs.json` shows generated slides as `recorded` and approved samples as `accepted`. If any slide is `pending`, `dispatched`, or `blocked`, stop.
12. Report the result.
- Use the final report checklist in `docs/project-assembly-and-reporting.md`.
- Include paths, slide count, backend used, recorded-result status, and any limitations or blockers.
13. Save reusable styles.
- If asked to save the current deck style or a supplied image/PDF/PPT/PPTX style, read `docs/style-library.md`.
- If the final deck used a custom or adapted style, proactively offer to save it in the final report, per `docs/project-assembly-and-reporting.md`. User custom styles are stored in `${CODEX_PPT_HOME:-~/.codex-ppt-skill}/references/`, outside the skill install.
## Subagent Dispatch
Slide subagents are mandatory after sample approval whenever the runtime can spawn them. The main agent prepares jobs and records state; each worker handles exactly one `prompts/slide_XX.json` job and returns only selected image path, backend, and QA note.
Use `docs/slide-generation-and-subagents.md` for dispatch, commands, result recording, blockers, and backend provenance. Use `prompts/slide-worker.md` as the handoff template.
Subagents must not edit `outline.md`, `deck_spec.json`, other slide jobs, `origin_image/`, `speech.md`, or the final `.pptx`. The parent records outputs and assembles.
## Acceptance Criteria
- Output is a valid `.pptx`.
- Each expected final slide image exists under `origin_image/slide_XX.png`.
- Every final slide image was generated by the confirmed backend and recorded through `record_slide_result.py`, except an approved sample marked accepted by run state.
- `outline.md` reflects the approved deck outline.
- `speech.md` exists when speaker notes are expected, and assembly writes those notes into the PPT.
- `slide_jobs.json` and `slide_run_state.json` reflect the final state.
- Required source images are visibly represented, or a blocker is reported.
- If blocked, the final response identifies phase, slide id, evidence path, and unfinished reason; do not call the deck complete.
## Reference Map
- `docs/workflow-gates-and-progress.md`: approval gates, progress, completion evidence.
- `docs/backend-selection.md`: backend decision rules and confirmation text.
- `docs/outline-style-and-sample.md`: outline, style, sample rules, prompt examples.
- `docs/user-supplied-assets.md`: strict handling for required source assets.
- `docs/slide-generation-and-subagents.md`: jobs, dispatch, result recording, blockers, provenance.
- `docs/cli-api-fallback.md`: fallback runtime, generation/edit commands, image limits, troubleshooting.
- `docs/image-model-configuration.md`: API key, base URL, model, `.env`; read only when config is needed.
- `docs/project-assembly-and-reporting.md`: project directory, notes, assembly, final report, prompting principles.
- `prompts/slide-worker.md`: slide subagent handoff template.
- `references/*.md`: built-in visual style references. User custom styles live in `${CODEX_PPT_HOME:-~/.codex-ppt-skill}/references/` and take priority over built-in styles with the same name.
## Documentation and Updates
For source, docs, install, config, and examples, see [ningzimu/codex-ppt-skill](https://github.com/ningzimu/codex-ppt-skill).