Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-08-12 00:02:07 +08:00
parent 572930a9e0
commit 7a846d93c6
232 changed files with 8709 additions and 346 deletions
+8 -8
View File
@@ -42,8 +42,8 @@
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
"adapter": "codex-plugin",
"commit": "11ddc0c9813c8f75365cd5be2f753df08712f154",
"syncedAt": "2026-08-08T16:00:01Z"
"commit": "b2ca50b8809d4dbc9f664b398938fddede320001",
"syncedAt": "2026-08-11T16:00:01Z"
},
{
"id": "taste-skill",
@@ -60,8 +60,8 @@
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "deda4df80fb350230b2fce2b575e769a90cae076",
"syncedAt": "2026-08-10T15:59:59Z"
"commit": "41bbc12cfd39ed8d9cb8da04275479ee7ecc0612",
"syncedAt": "2026-08-11T16:00:01Z"
},
{
"id": "frontend-slides",
@@ -96,8 +96,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "182c6b8229a44990cdc5b394545f90992be377d6",
"syncedAt": "2026-08-10T15:59:59Z"
"commit": "4e6ecbcb0dc079efebd3c79b775c0f02581509fe",
"syncedAt": "2026-08-11T16:00:01Z"
},
{
"id": "next-skills",
@@ -105,8 +105,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
"commit": "1722e45c3957d1e6ce2aaff482a9b9ece28864a1",
"syncedAt": "2026-08-10T15:59:59Z"
"commit": "bbec4fe2bd7222aaa9b4de58269d821ab128fab8",
"syncedAt": "2026-08-11T16:00:01Z"
}
]
}
@@ -2,8 +2,8 @@
"sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
"commit": "11ddc0c9813c8f75365cd5be2f753df08712f154",
"commit": "b2ca50b8809d4dbc9f664b398938fddede320001",
"adapter": "codex-plugin",
"sourcePath": "plugins/caveman",
"syncedAt": "2026-08-08T16:00:01Z"
"syncedAt": "2026-08-11T16:00:01Z"
}
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-08-10T16:02:03Z"
"syncedAt": "2026-08-11T16:02:06Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "1722e45c3957d1e6ce2aaff482a9b9ece28864a1",
"commit": "bbec4fe2bd7222aaa9b4de58269d821ab128fab8",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-08-10T15:59:59Z"
"syncedAt": "2026-08-11T16:00:01Z"
}
@@ -96,8 +96,8 @@ hear those words.
transcript of the loop.
- **Only surface a question for a genuine fork:** a fix that would change
behavior, a security-sensitive read, or a route that's dynamic by design (a
runtime-prefetch candidate, not a shell to grow). A clean instant fix is not a
fork — keep going. With no one to ask (an unattended run), don't block: take
per-link-prefetch candidate, not a shell to grow). A clean instant fix is not
a fork — keep going. With no one to ask (an unattended run), don't block: take
the safe default and note the assumption — for a cache-freshness choice,
defer the read behind `<Suspense>` (always fresh, still instant) rather than
guess a `cacheLife`.
@@ -359,10 +359,10 @@ this gate is as machine-checkable as the others. Detail:
**When URL data can't be pushed down** (for example, the whole page depends on
`params`, `searchParams`, or the full URL), there may be no meaningful static
shell to grow. Don't force one. Runtime prefetching can make the soft
shell to grow. Don't force one. Per-link prefetching can make the soft
navigation instant, but it is outside this optimizer loop: it requires Partial
Prefetching, a `<Link prefetch={true}>`, and cached URL-dependent content. See
[Runtime Prefetching](https://nextjs.org/docs/app/guides/runtime-prefetching)
[Optimizing prefetching](https://nextjs.org/docs/app/guides/optimizing-prefetching)
and pattern 10 in `reference/patterns.md` for the requirements, cost trade-offs,
manual prefetch caveat, and `instant()` test gotchas.
@@ -463,5 +463,5 @@ during an incremental rollout and keep checking any other target routes.
That skill moves the app onto the better prefetching model: shared App Shell
prefetches by default, fewer duplicated full-prefetch requests for visible
links, a link audit for existing `<Link prefetch={true}>` usage, and optional
per-link runtime prefetching only where URL-specific content is worth the
per-link prefetching only where URL-specific content is worth the
extra server work.
@@ -323,9 +323,9 @@ Prefer per-component boundaries inside the page (patterns #1#5) over one big
Patterns 19 grow a **static shell** by moving dynamic reads behind boundaries. Session data from `cookies()` and `headers()` is handled by the earlier patterns. URL data is different: `params`, `searchParams`, and the full URL belong to one link, while the App Shell is shared by every link to the route.
If the whole route depends on URL data, pushing the read lower may leave no meaningful shared shell to commit. That is the optimizer's stop point, not another shell refactor. Return to `SKILL.md` after the optimization loop for the optional runtime-prefetch follow-up.
If the whole route depends on URL data, pushing the read lower may leave no meaningful shared shell to commit. That is the optimizer's stop point, not another shell refactor. Return to `SKILL.md` after the optimization loop for the optional per-link-prefetch follow-up.
Runtime prefetching is the only way for this soft navigation to commit the
Per-link prefetching is the only way for this soft navigation to commit the
URL-specific content before the click. It has **three requirements**:
```tsx
@@ -347,11 +347,11 @@ Under `instant()` the runtime entry is what commits, so the real content, not a
Gotchas (each cost real debugging time):
- **The full prefetch is mandatory.** With App Shells enabled an auto/PPR prefetch bails before the runtime spawn (`subtreeHasSpeculativePrefetch`); use `<Link prefetch={true}>` for normal links, or keep an existing manual full-prefetch abstraction if the app already owns one. If the route is still RED after caching the URL-dependent content, the navigation may still be doing an auto prefetch.
- **Partial Prefetching must be adopted for the destination.** Runtime prefetching rides on the Partial Prefetching path. If the route is still RED after caching the URL-dependent content, check whether the link is still doing an auto prefetch or whether the destination never adopted Partial Prefetching.
- **Partial Prefetching must be adopted for the destination.** Per-link prefetching uses the Partial Prefetching path. If the route is still RED after caching the URL-dependent content, check whether the link is still doing an auto prefetch or whether the destination never adopted Partial Prefetching.
- **Prefetch the canonical URL.** A link whose href 307-redirects (a `/foo` that canonicalizes to `/`) can't be prefetched — the prefetch receives the redirect, not the tree. Point the link and the prefetch at the final URL.
- **Don't blanket the full prefetch.** It fetches _all_ the target's dynamic data; enabling it for every visible link is wasteful. Scope `prefetch={true}` to the runtime-prefetch targets only, using the [runtime prefetching trade-offs](https://nextjs.org/docs/app/guides/runtime-prefetching#per-link-prefetching-trade-offs) and [hover-triggered prefetch](https://nextjs.org/docs/app/guides/prefetching#hover-triggered-prefetch) when many links are visible.
- **Don't blanket the full prefetch.** It fetches _all_ the target's dynamic data; enabling it for every visible link is wasteful. Scope `prefetch={true}` to the per-link-prefetch targets only, using the [trade-offs](https://nextjs.org/docs/app/guides/optimizing-prefetching#trade-offs) and [hover-triggered prefetch](https://nextjs.org/docs/app/guides/prefetching#hover-triggered-prefetch) when many links are visible.
- **Marker must be a committed node, not RSC bytes.** The content is often a client component, so its text isn't in the prefetch response — assert a `data-testid` that renders when the client subtree commits, not a substring of the stream.
Prefer a static shell (patterns 19) whenever the URL-data read can move: it's cheaper than a runtime prefetch and also covers hard load. Runtime prefetching is only for URL-data reads that genuinely can't move, or routes whose useful content is all URL-specific.
Prefer a static shell (patterns 19) whenever the URL-data read can move: it's cheaper than a per-link prefetch and also covers hard load. Per-link prefetching is only for URL-data reads that genuinely can't move, or routes whose useful content is all URL-specific.
**Insight:** [dynamic data during prefetching](https://nextjs.org/docs/messages/instant-link-prefetch-partial).
@@ -68,11 +68,11 @@ Then, for each one:
export const prefetch = 'partial'
```
If the route reads URL data (`params`, `searchParams`), the default link still warms only its skeleton (the guide's [URL data](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#url-data) section), so it's a runtime-prefetch candidate for step 5, not a finished adoption. Keep `prefetch={true}` on its links and mark the route:
If the route reads URL data (`params`, `searchParams`), the default link still warms only its skeleton (the guide's [URL data](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#url-data) section), so it's a per-link-prefetch candidate for step 5, not a finished adoption. Keep `prefetch={true}` on its links and mark the route:
```tsx
// TODO(runtime-prefetch): assess with the user whether URL data should resolve before click.
// See: https://nextjs.org/docs/app/guides/runtime-prefetching
// TODO(per-link-prefetch): assess with the user whether URL data should resolve before click.
// See: https://nextjs.org/docs/app/guides/optimizing-prefetching
export const prefetch = 'partial'
```
@@ -87,13 +87,13 @@ Then, for each one:
Once every audited destination has `prefetch = 'partial'`, finish in two moves.
1. **Enable the flag globally.** Set `partialPrefetching: true` in `next.config.ts` (alongside `cacheComponents: true`). Every route is adopted now, so every link is good.
2. **Strip the redundant `prefetch = 'partial'` exports.** Run the first-party `remove-partial-prefetch` codemod rather than a text find-and-replace. It removes only `export const prefetch = 'partial'` and its generated Partial Prefetching guide comment. It leaves other values such as `prefetch = 'force-disabled'` in place, along with your `TODO(runtime-prefetch)` markers and their Runtime Prefetching guide links, which wait for step 5.
2. **Strip the redundant `prefetch = 'partial'` exports.** Run the first-party `remove-partial-prefetch` codemod rather than a text find-and-replace. It removes only `export const prefetch = 'partial'` and its generated Partial Prefetching guide comment. It leaves other values such as `prefetch = 'force-disabled'` in place, along with your `TODO(per-link-prefetch)` markers and their Optimizing prefetching guide links, which wait for step 5.
```bash
npx @next/codemod@latest remove-partial-prefetch ./app
```
The codemod refuses to run on a dirty working tree. Commit or stash unrelated work first, or pass `--force` to let its edits land alongside your WIP. If the codemod isn't available (older `@next/codemod`, sandboxed environment, offline run), reproduce it by hand by removing `export const prefetch = 'partial'` and its generated Partial Prefetching guide comment from every `app/**/{page,layout}.{js,jsx,ts,tsx}` — leave other `prefetch` values in place, and leave the `TODO(runtime-prefetch)` markers and Runtime Prefetching guide links where they are. Don't hand-edit when the codemod can run.
The codemod refuses to run on a dirty working tree. Commit or stash unrelated work first, or pass `--force` to let its edits land alongside your WIP. If the codemod isn't available (older `@next/codemod`, sandboxed environment, offline run), reproduce it by hand by removing `export const prefetch = 'partial'` and its generated Partial Prefetching guide comment from every `app/**/{page,layout}.{js,jsx,ts,tsx}` — leave other `prefetch` values in place, and leave the `TODO(per-link-prefetch)` markers and Optimizing prefetching guide links where they are. Don't hand-edit when the codemod can run.
## step 3: sweep for URL-data insights (after enabling)
@@ -116,24 +116,24 @@ Checklist before checking in with the user:
- **An empty sweep is expected when Cache Components adoption finished cleanly.** A quiet log is success, not a missing signal. If you deliberately probe the validation path, use a `generateStaticParams` route with `params` read inside `<Suspense>` but before the URL-specific leaf boundary; other shapes may surface `blocking-prerender-*` instead.
- The App Shells are real: for each route you changed, confirm the first paint after a navigation shows the intended shared content, not an empty shell or a stuck fallback. A `<Suspense>` around the whole page body passes validation with an empty shell, which defeats the point.
- The insights validate shell _structure_, not that a prefetch actually happened. Confirm on the production run (prefetching is prod-only) that navigating a changed link lands on the shared shell instantly.
- **If the app prefetches imperatively**, the insight sweep does not cover it, so an empty sweep is not proof the prefetch survived the flag. Verify the call under `next start`: compare the `_rsc` prefetch response or resource timing before/after, and make sure any intentionally preserved full prefetch still carries the data the old call was warming. If it now returns only the App Shell, migrate that call site using the same decision as the nearest `<Link prefetch={true}>` destination — cache the data, or move runtime-prefetch behavior to a docs-supported `<Link prefetch={true}>`.
- **If the app prefetches imperatively**, the insight sweep does not cover it, so an empty sweep is not proof the prefetch survived the flag. Verify the call under `next start`: compare the `_rsc` prefetch response or resource timing before/after, and make sure any intentionally preserved full prefetch still carries the data the old call was warming. If it now returns only the App Shell, migrate that call site using the same decision as the nearest `<Link prefetch={true}>` destination — cache the data, or move per-link-prefetch behavior to a docs-supported `<Link prefetch={true}>`.
- **Before blaming a broken route on the flag, reproduce it with `partialPrefetching` off** (or on the pre-flag branch). The flag surfaces existing issues — a fragile request-time auth gate, a rewrite, deployment skew — earlier and more visibly, but rarely causes them. If it breaks flag-off too, it isn't a Partial Prefetching problem; fix it there, not here.
- `next build` still passes.
Then check in with the user. Speak their language — no insight slugs or step labels.
- What you did: which links you audited, which destinations you adopted, and what each link now prefetches.
- What changed: dropped props, `use cache` boundaries added, and which routes carry a `TODO(runtime-prefetch)` marker for later.
- What changed: dropped props, `use cache` boundaries added, and which routes carry a `TODO(per-link-prefetch)` marker for later.
- Demo against a production run. Prefetching is limited in development, so `next dev` won't show the result — run `next build` and `next start`, and hand the user that URL. That run needs the app's real environment (database, auth, secrets), and a partial or stale install or leftover generated artifacts can fail the build for reasons unrelated to the adoption. Set the expectation up front that verification is a complete, credentialed production run, not a quick check.
- Show, don't tell: drive one link live in the headed browser against the production server, so they see the shared App Shell paint instantly and the URL-specific region stream in. Attach before/after screenshots only when a live browser isn't possible.
- Give them the click-through: a table of each changed route — the link to click, and what to expect after the click (what paints instantly, what streams in) — so they can verify each result themselves.
- The question: "Want to commit this (or open the PR) before we look at which routes should also prefetch their URL-specific content?" Wait for the answer — adoption and runtime prefetching read best as their own changes.
- The question: "Want to commit this (or open the PR) before we look at which routes should also prefetch their URL-specific content?" Wait for the answer — adoption and per-link prefetching read best as their own changes.
## step 5: runtime prefetching (optional)
## step 5: per-link prefetching (optional)
The audit marked the candidates instead of deciding them. Grep for `TODO(runtime-prefetch)` and walk the list with the user in one conversation. The question per route is whether they want the URL-dependent content prefetched ahead of the click, or streaming in after navigation is fine. A runtime prefetch costs a server invocation per prefetchable link — the guide's [per-link prefetching trade-offs](https://nextjs.org/docs/app/guides/runtime-prefetching#per-link-prefetching-trade-offs) section is the checklist. Don't make these calls alone.
The audit marked the candidates instead of deciding them. Grep for `TODO(per-link-prefetch)` and walk the list with the user in one conversation. The question per route is whether they want the URL-dependent content prefetched ahead of the click, or streaming in after navigation is fine. A per-link prefetch costs a server invocation per prefetchable link — the guide's [trade-offs](https://nextjs.org/docs/app/guides/optimizing-prefetching#trade-offs) section is the checklist. Don't make these calls alone.
Where the answer is yes, follow the [runtime prefetching guide](https://nextjs.org/docs/app/guides/runtime-prefetching): keep [`<Link prefetch={true}>`](https://nextjs.org/docs/app/api-reference/components/link#prefetch) on the links that should resolve more than the App Shell, and cache the content behind the URL-data read using the guide's patterns (`use cache` with the runtime value passed in, or `use cache: private` for per-user data). Each per-link prefetch is a server render when the destination needs non-static data, so use the guide's [per-link trade-offs](https://nextjs.org/docs/app/guides/runtime-prefetching#per-link-prefetching-trade-offs) to decide when viewport prefetching is worth it and when [hover-triggered prefetch](https://nextjs.org/docs/app/guides/prefetching#hover-triggered-prefetch) is a better fit. Where it's no, delete the marker and leave the route on the App Shell default. Either way no `TODO(runtime-prefetch)` marker survives this step. Confirm the opted-in links against a production run (`next build` and `next start` — the runtime prefetch fires there, not in `next dev`), give the user the same click-through for them, and keep this as its own commit or PR.
Where the answer is yes, follow the [Optimizing prefetching guide](https://nextjs.org/docs/app/guides/optimizing-prefetching): keep [`<Link prefetch={true}>`](https://nextjs.org/docs/app/api-reference/components/link#prefetch) on the links that should resolve more than the App Shell, and cache the content behind the URL-data read using the guide's patterns (`use cache` with the runtime value passed in, or `use cache: private` for per-user data). Each per-link prefetch is a server render when the destination needs non-static data, so use the guide's [per-link trade-offs](https://nextjs.org/docs/app/guides/optimizing-prefetching#trade-offs) to decide when viewport prefetching is worth it and when [hover-triggered prefetch](https://nextjs.org/docs/app/guides/prefetching#hover-triggered-prefetch) is a better fit. Where it's no, delete the marker and leave the route on the App Shell default. Either way no `TODO(per-link-prefetch)` marker survives this step. Confirm the opted-in links against a production run (`next build` and `next start` — the per-link prefetch runs there, not in `next dev`), give the user the same click-through for them, and keep this as its own commit or PR.
## further reading
+5 -5
View File
@@ -13,7 +13,7 @@
English | [中文](./README_CN.md)
<details open>
<summary>This project is kept free and open source with the support of <a href="https://www.kimi.com/code/?aff=ppt-master">Kimi</a>, <a href="https://www.packyapi.ai/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a>, <a href="https://runapi.co/register?aff=WMLJ">RunAPI</a>, <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624">YouYun ZhiSuan</a> and other sponsors.</summary>
<summary>This project is kept free and open source with the support of <a href="https://www.kimi.com/code/?aff=ppt-master">Kimi</a>, <a href="https://www.packyapi.ai/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a>, <a href="https://runapi.host/register?aff=WMLJ">RunAPI</a>, <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624">YouYun ZhiSuan</a> and other sponsors.</summary>
<p align="center">
<a href="https://www.kimi.com/code/?aff=ppt-master"><img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" alt="Kimi" width="100%"></a>
@@ -35,8 +35,8 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring this
<td>Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is a professional enterprise-grade AI relay service committed to stable, efficient, and low-cost AI access for businesses and developers. The platform supports mainstream models including Claude, OpenAI, and Gemini, with prices as low as <strong>7% of official rates</strong>. Register through <a href="https://apikey.fun/register?aff=PPT-MASTER">our dedicated link</a> for an exclusive perk: <strong>up to 5% off on top-ups, permanently</strong>.</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a></td>
<td>Thanks to RunAPI for sponsoring this project! RunAPI is an efficient and stable API platform — a single API Key gives you access to 150+ leading models, including OpenAI, Claude, Gemini, DeepSeek, and Grok, at prices as low as <strong>10% of official rates</strong>, with exceptional stability and seamless compatibility with tools like Claude Code. RunAPI offers an exclusive perk for PPT Master users: register and contact an administrator via <a href="https://runapi.co/register?aff=WMLJ">our dedicated link</a> to claim <strong>¥7 in free credit</strong>.</td>
<td width="180"><a href="https://runapi.host/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a></td>
<td>Thanks to RunAPI for sponsoring this project! RunAPI is an efficient and stable API platform — a single API Key gives you access to 150+ leading models, including OpenAI, Claude, Gemini, DeepSeek, and Grok, at prices as low as <strong>10% of official rates</strong>, with exceptional stability and seamless compatibility with tools like Claude Code. RunAPI offers an exclusive perk for PPT Master users: register and contact an administrator via <a href="https://runapi.host/register?aff=WMLJ">our dedicated link</a> to claim <strong>¥7 in free credit</strong>.</td>
</tr>
<tr>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624"><img src="docs/assets/sponsors/youyun.png" alt="YouYun ZhiSuan" width="150"></a></td>
@@ -235,7 +235,7 @@ Never used one of these? Don't worry — in this project they play exactly one r
> **Model recommendation**: for the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the pipeline, paired with AI image generation — **`gpt-image-2`** (OpenAI) or **`gemini-3.1-flash-image`** (Google). Kimi Code, the project sponsor, is a great pick for pay-as-you-go access.
**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.ai/register?aff=ppt-master)**, **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.co/register?aff=WMLJ)** offer pay-as-you-go access to Claude, GPT, Gemini and more — no subscription required, with exclusive discounts for our users (details at the top of this page).
**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.ai/register?aff=ppt-master)**, **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.host/register?aff=WMLJ)** offer pay-as-you-go access to Claude, GPT, Gemini and more — no subscription required, with exclusive discounts for our users (details at the top of this page).
**🔀 Juggling several providers?** Once you hold keys from more than one of them, [cc-switch](https://github.com/farion1231/cc-switch) — a cross-platform desktop app — lets you one-click switch API providers for Claude Code, Codex, Gemini CLI and more, no manual config editing.
@@ -414,7 +414,7 @@ PPT Master is currently built and maintained primarily by me. Every new template
&nbsp;
<a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" height="40" /></a>
&nbsp;
<a href="https://runapi.co/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" height="40" /></a>
<a href="https://runapi.host/register?aff=WMLJ"><img src="docs/assets/sponsors/runapi.png" alt="RunAPI" height="40" /></a>
&nbsp;
<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624"><img src="docs/assets/sponsors/youyun.png" alt="YouYun ZhiSuan" height="40" /></a>
&nbsp;
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "182c6b8229a44990cdc5b394545f90992be377d6",
"commit": "4e6ecbcb0dc079efebd3c79b775c0f02581509fe",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-08-10T15:59:59Z"
"syncedAt": "2026-08-11T16:00:01Z"
}
@@ -36,9 +36,9 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring PPT M
### RunAPI
<a href="https://runapi.co/register?aff=WMLJ"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a>
<a href="https://runapi.host/register?aff=WMLJ"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a>
[RunAPI](https://runapi.co/register?aff=WMLJ) provides access to 150+ models, including OpenAI, Claude, Gemini, DeepSeek, and Grok, through one API key. Register through the dedicated link and contact an administrator to claim **¥7 in free credit**.
[RunAPI](https://runapi.host/register?aff=WMLJ) provides access to 150+ models, including OpenAI, Claude, Gemini, DeepSeek, and Grok, through one API key. Register through the dedicated link and contact an administrator to claim **¥7 in free credit**.
### YouYun ZhiSuan
@@ -36,9 +36,9 @@ PPT Master 始终免费开源。以下赞助方共同支持项目的持续维护
### RunAPI
<a href="https://runapi.co/register?aff=WMLJ"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a>
<a href="https://runapi.host/register?aff=WMLJ"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/runapi.png" alt="RunAPI" width="150"></a>
[RunAPI](https://runapi.co/register?aff=WMLJ) 通过一个 API Key 提供 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型。通过专属链接注册并联系管理员,即可领取 **¥7 免费额度**。
[RunAPI](https://runapi.host/register?aff=WMLJ) 通过一个 API Key 提供 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型。通过专属链接注册并联系管理员,即可领取 **¥7 免费额度**。
### 优云智算
@@ -13,11 +13,14 @@ before the page plan is frozen, not only when a deck is already exported.
| What the deck needs | Reach for | Decided at |
|---|---|---|
| Reveal content in step with the narration | Per-element object animation — `-a auto` for generic entrance reveals, or an `animations.json` sidecar for explicit enter/emphasize/move/exit/static lifecycle choreography | Post-processing; §2, §4, [`customize-animations`](../workflows/stages/customize-animations.md) |
| A generic deck-wide entrance build | `-a auto`; with the default `after-previous` Start mode, groups use fixed `--animation-stagger` timing rather than narration cues | Post-processing; §2, §4 |
| Explicit object lifecycle choreography | An `animations.json` sidecar for selected enter/emphasize/move/exit/static duties, order, Start mode, and timing | Post-processing; §2, §4, [`customize-animations`](../workflows/stages/customize-animations.md) |
| Object reveals semantically synchronized to recorded narration | Narration-cue sync derives `narration_animations.json` from canonical `animations.json`, page-local SRT, and `narration_timing.json`; `-a auto` alone does not provide this mapping | Audio stage; [`generate-audio`](../workflows/stages/generate-audio.md) |
| A continuous action — slide-in, flip, camera push-in, progressive reveal, camera pan | **Morph: author the action as two static pages, then select Morph and add explicit pairs when identity must be deterministic.** There is no keyframe timeline anywhere in this pipeline; the difference between two ordinary editable slides *is* the animation | **Page authoring (Step 6), then motion post-processing** — §2.1, §3.1 |
| A static full-bleed page that should stop looking frozen | Consider slow `path_*` motion on a visually subordinate image or atmospheric layer; §4.1 gives one starting recipe | Post-processing; §4.1 |
| Carousel, counting numerals, parallax depth, click-to-reveal flip card | Four recurring recipes assembled from the mechanisms above | §4.2 — the carousel and odometer both need paired pages |
| Kiosk or unattended playback | `--auto-advance <seconds>`, optionally with `-t none` | Export; §3 |
| A transition or object animation needs an audible cue | Optional `transition.sound` or object-animation `sound`; select it only after the visual motion solution is complete, then sync the chosen global-library ids into the project. For direct narrated MP4 delivery, [`generate-audio`](../workflows/stages/generate-audio.md) selects either the verified native-export mix or explicit real-time slideshow capture; never combine them | Post-motion; §2.2 |
| Nothing should move | `-t none`, and leave per-element animation at its default `none` | Export; §1 |
**Hard rule — Morph geometry is an authoring decision; pairing is a later
@@ -40,6 +43,7 @@ tell; each capability above earns its place per page, not per deck.
|---|---|---|
| Page transition | CLI: `fade`, 0.4s | Calm baseline that suits most decks; the public Python builder retains its legacy 0.5s default |
| Per-element animation | **`none` (off)** | A page appears as a whole. Auto-firing element builds are an unsolicited "AI deck" tell, so object animation is opt-in. Turn on the content-aware canonical entrance policy with `-a auto`, or select one PowerPoint-native `entrance_*`, `emphasis_*`, `path_*`, or `exit_*` key explicitly |
| Sound effects | **`none` (off)** | No global sound is copied and no `<project>/sounds/` directory is created unless a resolved transition or object-animation cue actually selects one |
To regenerate a deck with different settings, rerun the final checker when its current matching report is absent or stale, then rerun `svg_to_pptx.py` against the same `svg_output/`; the content-generation LLM need not rerun unless authored SVG requires repair. `-s final` is reserved for diagnostic comparison and is not a supported release source. To turn per-element animation on for the whole deck, pass `-a auto`.
@@ -155,7 +159,9 @@ Rules:
`decelerate`; `restart` is `always`, `when-not-active`, or `never`;
`after_effect` is `none`, `dim` (with `color`), `hide`, or
`hide-on-next-click`; `sound` is a project-relative or absolute `.m4a`,
`.mp3`, or `.wav` path.
`.mp3`, or `.wav` path. New generated configurations use a project-relative
path. A bundled library choice first follows §2.2 and resolves to a
project-local `.wav`; never point new output at `templates/sounds/`.
- `Speed` and smooth start/end are not duplicate sidecar fields: they are
derived from `duration` and `accelerate`/`decelerate`.
- This is the complete parameter surface for the generated top-level-group
@@ -238,6 +244,51 @@ The generated names follow Microsoft's
OOXML object types. Missing, structural, moved, ambiguous, or mismatched
targets fail instead of falling back to automatic Morph matching.
### 2.2 On-Demand Sound Selection
**Hard rule — select after motion, materialize after selection**: sound is not a
Strategist resource and does not belong in `design_spec.md`, `spec_lock.md`, or
pre-SVG resource preparation. First complete the SVG roster and resolve the
transition/object-motion solution. Only when a specific cue is then selected,
copy its global-library file into the project and reference that local copy.
| Source | Action |
|---|---|
| Bundled CC0 library | Discover ids with `sound_sync.py list`, sync only the selected ids, then use the corresponding `sounds/<namespace>/<file>.wav` paths |
| User-provided audio already inside the project | Reference its existing project-relative `.m4a`, `.mp3`, or `.wav` path for object animation; a transition sound uses `.wav` |
| External absolute file | The low-level object-animation path remains compatible, but new generated projects should copy or sync the intended file into the project and use a relative path |
| No concrete auditory cue job | Keep `sound` omitted; do not create `<project>/sounds/` and do not copy the library |
```bash
# Inspect ids only after the visual motion solution exists
python3 skills/ppt-master/scripts/sound_sync.py list
python3 skills/ppt-master/scripts/sound_sync.py list --query <term>
# Materialize only the chosen ids
python3 skills/ppt-master/scripts/sound_sync.py \
<project_path> <namespace>/<sound_id> [<namespace>/<sound_id> ...]
```
`sound_sync.py` is the only bundled-library materialization path. Stable ids
include their namespace; copied files remain under
`<project_path>/sounds/<namespace>/`. The exporter never reads the global
`templates/sounds/` library directly, and sidecars store paths rather than
library ids.
**Default — silence (may override for a specific cue)**: do not add sound to
demonstrate capability or spread it across a deck for coverage. A sound may
support a named transition, reveal, confirmation, warning, or drawn/moving
gesture after the corresponding visual behavior is already selected.
**Hard rule — PPTX and MP4 are separate sound deliveries**: sound fields and
package read-back prove the editable PPTX contains the intended native cue;
they do not prove PowerPoint's video encoder placed it in the MP4 audio track.
For direct narrated MP4 delivery with resolved cues, follow `generate-audio`
and choose exactly one branch: mix from the final narrated trace plus final
PPTX after native encoding, or explicitly capture the live PowerPoint Slide
Show with system audio. Never mix the capture again. Keep post-production gain
and limiter settings out of `animations.json`.
---
## 3. Page Transitions
@@ -304,6 +355,13 @@ carry a real PowerPoint effect in `mc:Choice` and a `fade` fallback for older
consumers; validation requires the requested primary effect and never accepts
the fallback as a silent substitute.
An optional `transition.sound` adds one `.wav` cue to the transition. It is a
sidecar field rather than a CLI flag. Bundled choices must first be synced by
§2.2 and referenced through their project-relative path. `effect: none` may
still carry a transition sound and/or automatic advance without restoring a
visual effect. A slide-level `transition.sound: null` explicitly clears an
inherited default transition sound for that page.
Flags:
- `-t/--transition` — native effect name, compatibility input, or `none` for no visual transition. Default: `fade`. `none` does not remove an explicitly configured automatic advance.
@@ -441,7 +499,7 @@ Flags: `-a/--animation` selects effect/mode; `--animation-trigger` selects Start
`--animation-config` selects a sidecar; `--no-animations` disables page/object
motion but preserves narration audio and recorded advance timing.
> Note: `--recorded-narration` rejects `on-click` and `trigger_shape`. When either animation sidecar exists, narrated export selects `narration_animations.json`; canonical `animations.json` without that derived file remains a synchronization error. Without sidecars, pass `--inherit-motion-from <base_postflight_report>` for the base deck motion. Pass `--animation-config animations.json` for canonical animation, or `--no-animations` to remove page and object motion.
> Note: `--recorded-narration` rejects `on-click` and `trigger_shape`. Narration-cue sync uses `narration_animations.json` and blocks when only canonical `animations.json` exists. Narration-independent custom motion explicitly passes `--animation-config animations.json`, even when a derived sidecar also exists. With no sidecar, pass `--inherit-motion-from <base_postflight_report>`; explicit all-motion-off uses `--no-animations`.
### 4.1 Slow ambient motion — the page that breathes
@@ -537,6 +595,12 @@ object-animation timing before and after their allowed edits, then run
structural package validation; they do not author or normalize animation
effects.
**Validation boundary**: these checks prove PPTX timing, relationships, and
embedded sound parts. They are not final-video audio acceptance. The
native-export branch requires a triggered `video_sound_mix.py` receipt; the
slideshow-capture branch requires the human picture/audio/all-cue acceptance
owned by `generate-audio` and never claims that receipt.
---
## 7. Video Adaptation Contract
@@ -547,6 +611,13 @@ locks identity, order, effect, direction, and timing; video may refine only its
declared renderer parameters. Unsupported families fail visibly. See
[`video-motion-plan.md`](../scripts/docs/video-motion-plan.md).
On the native-export mix branch, direct narrated video sound uses the final
resolved trace for cue order and offsets, the final PPTX relationships for the
exact embedded audio bytes, and page-level narration correlation for the
exported-video clock. It never reads sound timing from a raw sidecar or
filename. The explicit slideshow-capture branch records PowerPoint's real-time
playback instead and does not consume the trace for sound mixing.
---
## 8. Limitations
@@ -556,6 +627,11 @@ declared renderer parameters. Unsupported families fail visibly. See
not create object anchors.
- PowerPoint OOXML is the compatibility target; other presentation apps may
reinterpret individual native behavior trees.
- PowerPoint's native MP4 encoder may omit transition and object-animation
sounds even when the PPTX package is valid. Direct sound-enabled MP4 delivery
therefore uses either the post-export mix or the explicit real-time
slideshow-capture contract owned by `generate-audio`; the branches never
stack.
- Direct-PPTX routes preserve unknown transition `AlternateContent`; timing
edits keep Choice and Fallback advance attributes synchronized.
@@ -13,7 +13,7 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared
| Any semantic cell grid, including a table-family reference | [`executor-table.md`](./executor-table.md) |
| A page will use a preset pattern fill or an independent object is explicitly selected as native-ready | [`native-data-interface.md`](./native-data-interface.md) before emitting the pattern or replacement metadata |
| Any image/formula | [`executor-image.md`](./executor-image.md) + [`image-layout-spec.md`](./image-layout-spec.md) + [`image-layout-patterns.md`](./image-layout-patterns.md) + [`svg-image-embedding.md`](./svg-image-embedding.md) |
| Any `Status: Sourced` web image | [`executor-web-image.md`](./executor-web-image.md), after `executor-image.md` |
| Any placed image is `Status: Sourced` or its filename has an `image_sources.json` record | [`executor-web-image.md`](./executor-web-image.md), after `executor-image.md` |
| Effective Speaker Notes outcome is enabled after all SVG pages pass | [`executor-notes.md`](./executor-notes.md) |
Evaluate branches from each object's actual information model, not only from a Chart/Table reference. A catalog family selects construction guidance but never native readiness; `Native-ready` is an independent object-level decision. Page-local qualitative geometry also never implies package-level `pptx_structure.mode: structured`.
@@ -12,12 +12,12 @@ Conditional Executor authority for image status handling, placement, crop behavi
Handle images by status; enum and lifecycle: [`svg-image-embedding.md`](svg-image-embedding.md).
**Mode boundary**: Default keeps Strategist → Executor with no downstream acquisition/reselection. Quick substitutes the main agent's prepared active-context resources for §VIII/lock below; the same boundary starts at SVG authoring.
**Mode boundary**: Default Executor consumes only prepared §VIII/lock assets. Quick enters the same consume-only boundary at SVG authoring from prepared active-context resources. Derivatives already exist; native treatments remain SVG work.
| Status | Source | Handling |
|--------|--------|----------|
| **Existing** | User-provided | Reference images directly from `../images/` directory |
| **Generated** | Generated by Image_Generator | Reference images directly from `../images/` directory |
| **Generated** | Generated by Image_Generator | Reference from `../images/`; manifest-backed files load [`executor-web-image.md`](./executor-web-image.md) |
| **Sourced** | Web-acquired by Image_Searcher | Reference from `../images/`. **Read [`image_sources.json`](image-searcher.md) to decide attribution** — load [`executor-web-image.md`](./executor-web-image.md). |
| **Rendered** | Deterministic formula PNG | Reference from `../images/`; use a legal anchor with `meet` (centered default: `xMidYMid meet`) |
| **Needs-Manual** | Acquisition or suitability remains unresolved | Default uses a placeholder until Step 7. Quick blocks every required row in this status; file presence alone does not bypass it. |
@@ -2,13 +2,13 @@
# Executor Web-image Attribution Branch
Conditional Executor authority for inline attribution on web-sourced images.
Conditional Executor authority for inline attribution on web-sourced images and their prepared derivatives.
**Trigger**: load for any placed `Status: Sourced` image. Quick Generate uses the same `image_sources.json` contract without interaction.
**Trigger**: load for any placed `Status: Sourced` image or any placed prepared derivative whose filename has a copied `image_sources.json` record. Quick Generate uses the same manifest contract without interaction.
## 1. Inline Attribution for Sourced Images
Whenever the slide uses an image with `Status: Sourced`, look up the corresponding entry in `project/images/image_sources.json` and act on `license_tier`:
Whenever the slide uses a `Status: Sourced` image or a prepared derivative backed by `image_sources.json`, look up the corresponding filename entry and act on `license_tier`:
| `license_tier` | Action on this slide |
|---|---|
@@ -1,6 +1,6 @@
> See [`image-generator.md`](./image-generator.md) and [`image-searcher.md`](./image-searcher.md) for path-specific behavior.
# Image Acquisition Common Reference
# Image Acquisition and Preparation Common Reference
Shared baseline for both acquisition paths. Path-specific behavior lives in the path's own reference.
@@ -8,7 +8,7 @@ Shared baseline for both acquisition paths. Path-specific behavior lives in the
## 1. Trigger Condition
Active when at least one resource row has `Acquire Via: ai` / `web` / `slice`. Rows with `user` / `formula` / `placeholder` are tracked but skipped by these acquisition roles.
Active when at least one resource row has `Acquire Via: ai` / `web` / `slice`, or when any §VIII / Quick active-context resource is a pending prepared derivative. Canonical rows with `user` / `formula` / `placeholder` are tracked but skipped by acquisition roles.
| Mode | Trigger |
|---|---|
@@ -26,18 +26,43 @@ Default Generate uses Strategist-owned `design_spec.md §VIII` plus its lock pro
|---|---|---|---|---|---|---|---|
| `<planned file>` | `<planned size>` | `<planned role>` | `<owner-resolved recommendation>` | `adaptive` / `no-crop` | `ai` / `web` / `slice` | Pending | `<acquisition brief>` |
**Required per non-skipped row**: `Acquire Via` and `Status`. `Reference` is required for every `web` / `slice` row and every newly authored `ai` row. An existing `ai` row whose `Reference` is omitted or blank may continue only through the declared inference in [`image-generator.md`](./image-generator.md) §8; no other path may infer it.
**Required per non-skipped row**: `Acquire Via` and `Status`. `Reference` is required for every `web` / `slice` row, every newly authored `ai` row, and every prepared derivative regardless of source class. An existing `ai` row whose `Reference` is omitted or blank may continue only through the declared inference in [`image-generator.md`](./image-generator.md) §8; no other path may infer it.
**Quick Generate ownership**: explicit user assets, URLs, and path instructions win. Otherwise the main agent chooses required `user` / `ai` / `web` / `slice` / `formula` rows and AI path `auto`, without interaction.
**Mandatory — consume the resolved path**: Default consumes Strategist-chosen §VIII rows; Quick resolves once in active context before preparation. This phase never adds or reselects a treatment:
| Path | Behavior |
|---|---|
| `none` | Use the canonical bitmap unchanged |
| `native` | No new file; SVG owns crop/clip, transform, opacity, frame/shadow/scrim/vignette, and overlap |
| `prepared derivative` | Separate file only for pixel blur, desaturation/grayscale, duotone, brightness/contrast, or existing cutout/registered-layer preparation |
Choosing `none` is valid. Never bake a native treatment into a derivative.
**Reference — exact pattern mapping, not activation**: An adopted pattern may use this mapping; its id alone creates nothing.
| Pattern | Preparation |
|---|---|
| `P*`, `M*`, `C*` | Use existing assets with native SVG/PPT composition; no automatic derivative |
| `A1-02` / `A1-03` | `image_treat.py` blur / duotone |
| `A1-01` / `A1-04` | Existing prepared composite or host/AI path; `image_treat.py` does not blend |
| `A2-01` | Existing/host-prepared RGBA or flat-key AI/slice asset; when source-scene registration is required, use `A2-02` / `A2-03` + [`image-generator.md`](./image-generator.md) §4.4 |
| `A2-02` / `A2-03` | [`image-generator.md`](./image-generator.md) §4.4 registered layers |
| `A3-01` | Original/subject plus registered `image_treat.py` blur/tone/desaturate derivative |
| `A3-02` | Registered full-canvas `image_treat.py` blur derivative; crop panels natively |
| `A3-03` | `image_treat.py` desaturated base plus existing/§4.4 color subject layer |
---
## 3. Path Dispatch
For each row with `Status: Pending`:
Classify `Reference: Derived from <canonical bare filename>; treatment=<operation>; ...` before `Acquire Via`. Its distinct, non-derived parent must be `user`, `web`, `ai`, or `slice`; reject formula/placeholder parents, chains, cycles, and self-reference. For each Pending row:
| Acquire Via | Load reference | Run | Success status |
| Row kind / Acquire Via | Load reference | Run | Success status |
|---|---|---|---|
| Deterministic prepared derivative | This common reference | After parent is usable, run `image_treat.py` to a distinct `.png`; preserve source | Inherit parent: `user → Existing`, `web → Sourced`, `ai/slice → Generated` |
| Registered-layer derivative | [`image-generator.md`](./image-generator.md) §4.4 | After parent is usable, run §4.4 | Supplied final: `user → Existing`; generated/reconstructed: `ai → Generated` |
| `ai` | [`image-generator.md`](./image-generator.md) | `image_gen.py` | `Generated` |
| `web` | [`image-searcher.md`](./image-searcher.md) | `image_search.py` | `Sourced` |
| `slice` | [`image-generator.md`](./image-generator.md) §4.3 | `slice_images.py` after parent AI sheet is `Generated` | `Generated` |
@@ -51,12 +76,10 @@ For each row with `Status: Pending`:
## 4. Analysis Phase
Before processing any row:
1. Read the Default Design Spec/lock, or reuse Quick's active-context resource and visual/page decisions
2. Group resource list rows by `Acquire Via`
3. Confirm `project/images/` exists
4. Materialize explicit user assets, render declared formulas, and finish triggered ai/web/slice acquisition before SVG authoring begins
1. Read the Default Design Spec/lock, or reuse Quick's active-context resource/page decisions.
2. Separate derivatives before grouping canonical rows by `Acquire Via`; ensure `project/images/` exists.
3. Finish user/formula and triggered ai/web/slice canonical preparation.
4. Materialize only declared derivatives from usable parents, preserve originals, then run `analyze_images.py` once before SVG.
---
@@ -65,6 +88,7 @@ Before processing any row:
After all rows reach terminal status:
- Every non-skipped row has a file at `project/images/<filename>`, or is marked `Needs-Manual`
- Each derivative has its distinct file and usable parent; web provenance is copied in `image_sources.json`
- Every `slice` row has a generated element file, or is marked `Needs-Manual` because its parent sheet is not available
- No `Pending` or `Failed` rows remain
- `image_prompts.json` exists when ≥1 ai row processed; every entry has `status ∈ {Generated, Needs-Manual}` (no `Pending` or `Failed` remaining)
@@ -72,7 +96,7 @@ After all rows reach terminal status:
> `Needs-Manual` is terminal for acquisition, not export readiness. A later
> supplied/replaced file must be validated and its row reconciled to
> `Generated`, `Sourced`, or `Rendered` with the matching manifest evidence.
> `Existing`, `Generated`, `Sourced`, or `Rendered` with matching evidence.
> Quick blocks every required row that still says `Needs-Manual`, regardless of
> whether an unverified candidate file happens to exist. See
> [`image-generator.md`](./image-generator.md) §7.
@@ -118,6 +142,8 @@ Executor reads the manifest per slide and renders inline credits when needed —
The `Reference` field is **intent**, not a query. Strategist owns it by default; Quick's main agent resolves it in active context. The receiving role translates without reopening it.
For derivatives, the required lineage/treatment prefix is intent metadata, not a provider query.
| ✅ Intent | ❌ Pre-processed |
|---|---|
| `"Diverse engineering team in modern office, natural light"` | `"team office light"` |
@@ -132,11 +158,11 @@ SVG authoring consumes the active profile's resource authority plus:
| Artifact | Path | Purpose |
|---|---|---|
| Image files | `project/images/*.{jpg,png,webp}` | `<image>` references |
| Manifest | `project/images/image_sources.json` | `license_tier` per Sourced image |
| Manifest | `project/images/image_sources.json` | License/provenance for sourced images and their derivatives |
**Default Generate boundary**: Executor does NOT invoke `image_gen.py` / `image_search.py` / `slice_images.py`; missing material returns to Strategist-owned preparation.
**Default Generate boundary**: Executor does NOT invoke `image_gen.py` / `image_search.py` / `slice_images.py` / `image_treat.py`; missing material returns to Strategist-owned preparation.
**Quick Generate boundary**: the main agent finishes acquisition before SVG authoring, then neither acquires nor reselects while drawing.
**Quick Generate boundary**: the main agent finishes acquisition and planned derivation before SVG authoring, then neither acquires, derives, nor reselects while drawing.
---
@@ -165,7 +165,7 @@ The following three patterns are topologically different and are not interchange
## 5. Asset-Dependent Treatments
**Prepared-asset gate**: every treatment below consumes its named project-local asset; it does not authorize creation during SVG realization. Embedded lettering belongs to the artwork only when deliberately fixed; authoritative or editable labels remain native SVG. If a required asset is absent, return to the active workflow's preparation owner or choose a native treatment.
**Prepared-asset gate**: every treatment below consumes its named project-local asset; it does not authorize creation during SVG realization. Embedded lettering belongs to the artwork only when deliberately fixed; authoritative or editable labels remain native SVG. If a required asset is absent, return to the active workflow's preparation owner or choose a native treatment. [`image-base.md`](./image-base.md) §2 maps these ids to preparation paths without auto-triggering them.
### 5.1 A1 · Prepared Composites and Appearance
@@ -18,7 +18,9 @@ For illustration, apply this precedence: confirmed `none` → explicit user inte
**Default — one coherent sheet for compatible same-family spots (may override when aspect, detail, quality, or semantic needs differ)**: prefer one Illustration Sheet when several AI-generated spots can share a useful cell shape and production treatment; generate them independently when forcing one sheet would weaken a planned element. When a sheet is chosen, plan one unplaced `ai` Illustration Sheet row plus one placed `slice` row per used element; only slice rows enter `spec_lock.md images`. State the intended placement shape family in the sheet reference and use separate sheets for incompatible shapes. [`image-generator.md`](./image-generator.md) §4.3 owns grid, ratio, slicing, and execution details. Final Stage 2 chooses the AI execution path under `image-generator.md` §7; do not pre-empt or re-pick it here.
**Mandatory — subject-layer capability scan**: When a supplied reference or the intended composition shows a subject crossing a native title, panel, frame, or shape, plan a registered reconstruction group before writing §VIII. Add one clean full-canvas base row and the minimum same-canvas RGBA midground/subject/foreground rows, give full-canvas members `Crop Policy: no-crop`, name their shared source and registration relationship in `Reference`, and suggest `#A2-03`. Padded-bbox-disjoint objects may share one generated plate if the final SVG gives each one an independent picture crop. Use `Acquire Via: user` only when every final asset is already supplied; otherwise use `ai` for the prepared derivatives. [`image-generator.md`](./image-generator.md) §4.4 owns preparation. A simple floating cutout that does not re-layer over its source may use `#A2-01` instead.
**Mandatory — image-treatment path scan, not a quota**: Per selected image choose `none` (unchanged), `native` (SVG crop/clip, transform, opacity, frame/depth, overlap), or `prepared derivative` (separate pixel blur/tone or cutout/registered layers); `none` is valid.
When a subject crosses a native title, panel, frame, or shape, the prepared path is mandatory: plan a clean full-canvas base plus minimum registered RGBA layers; set full-canvas members `no-crop`; name their shared source/registration in `Reference`; suggest `#A2-03`. A shared plate requires padded-bbox-disjoint objects and independent final crops. Use `user` only when every final asset is supplied, otherwise `ai`; [`image-generator.md`](./image-generator.md) §4.4 owns preparation. An independent floating cutout may use `#A2-01`.
## 2. AI Image Strategy — always propose three; lock only for confirmed `ai`
@@ -53,7 +55,9 @@ Follow `latex_render.py --help` for the manifest fields. The renderer writes dim
## 4. Image Resource List
Add §VIII rows for the image resources actually planned from the confirmed source boundary and for every selected formula; a formula-only plan contains only formula rows. A permitted but unused source needs no row. Author each row's filename, dimensions/ratio, preferred layout suggestion, crop policy, purpose/type, acquisition, status, reference, and conditional AI fields as part of the complete Design Spec. `Acquire Via` is `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). When a planned or explicitly required asset is not yet available, retain its row as `Pending` or `Needs-Manual`; never remove the row or change `Acquire Via` to make the Design Spec look complete. After §VIII passes final confirmation, project every placed row into `spec_lock.md images` as `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>` and omit unplaced Illustration Sheets. `source` and `crop` preserve the exact confirmed §VIII text; `pattern` preserves the non-empty free-form suggestion, including any optional hierarchical catalog ids, while remaining preferred expression rather than locked geometry.
Add §VIII rows only for planned images and every selected formula; permitted unused sources create no row. Fill filename, dimensions/ratio, layout suggestion, crop, purpose/type, acquisition, status, reference, and conditional AI fields. `Acquire Via` is `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). Keep any unavailable planned/required asset `Pending` or `Needs-Manual`; never delete or reclassify it to appear complete. After final confirmation, project each placed row into `spec_lock.md images` as `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>` and omit unplaced source/sheet rows. Preserve exact confirmed `source`/`crop`; keep non-empty `pattern`, including optional catalog ids, as preferred expression rather than locked geometry.
**Prepared derivatives**: Keep canonical; `Reference`: `Derived from <bare filename>; treatment=<operation>;`. Deterministic child: distinct `.png`, inherits acquisition; §4.4 follows `user`/`ai` above. Lock placed children; [`image-base.md`](./image-base.md) §23 owns preparation.
References describe visual intent: AI uses subject + intent + composition without repeating rendering or HEX; web records exact subject, view/mood, focal/quiet region, and crop safety with positive quality cues; Image_Searcher later derives a separate short, specific provider query without rewriting this locked intent, while complete entity names or necessary disambiguation may use more words; formula preserves source LaTeX and placement intent. Any subject direction, focal placement, quiet region, or overlay-safety requirement that must affect acquisition/generation belongs in `Reference` or the matching §IX block, not only in `Layout pattern`.
@@ -38,6 +38,8 @@ solution + production gate:
Do not force communication intent into one catalog label; Stage 1 records composite intent in prose. Editable prose fields are recommendation drafts, not required inputs: confirmation preserves current text and blanks; never repopulate a cleared field. Stage 2 confirms narrative spine, reading density, page budget, visual system, image direction, production mechanics, and how any installed template should be used. It never chooses or installs a template. Inspect only project-local template spec/prototypes, present one editable application plan, and keep exporter reuse/adherence internal. First author exactly three complete, project-fit solution directions from the confirmed contract and source; only then project each direction into mode, visual style, color, type, icons, and generated-image rendering for lower-level adjustment. Every direction projects a project-specific `custom` mode, `custom` visual style, and `custom` generated-image rendering; the fixed catalogs remain conservative lower-level single-select alternatives. All three must be viable and distinguishable as whole solutions, but do not force safe / shifted / bold archetypes, different catalog bases, or artificial extremes. After all three bundles are complete, compare them against the confirmed contract and source, choose the strongest overall fit, and write its actual zero-based index as `design_directions.selected` (`0`, `1`, or `2`); array order never determines preference. Every direction carries a complete generated-image rendering candidate even when AI imagery is not recommended; `recommend.image_usage` independently decides whether AI is proposed. Generated images inherit deck colors—there is no second image palette. Proactive defaults are speaker notes `true`, custom animations `false`, and narration audio `false`; a prior explicit user instruction overrides the matching recommendation, and effective narration audio requires effective speaker notes. Author each stage once; same-stage edits update only visible browser state through documented deterministic dependencies, without another AI/backend recommendation. Launch/derive/wait mechanics live in [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4; item specs keep `a``h`.
**Default — continuity-aware whole solution (may override when a scene reset communicates better)**: Within active-profile invariants and before recommending page count or production mechanics, judge whether adjacent explanation beats can remain within one recognizable mental map while a visible state changes. Where that choice lowers cognitive switching and motion has a named communication job, let it shape the solution's narrative spine, page rhythm, visual approach, and enabled notes/narration segmentation, and recommend the existing `proactive_custom_animations: true`. This is one positive signal, not the only reason to enable animation; absent it, retain the existing default or other valid evidence. Topic or wording repetition alone is insufficient. A `Motion suggestion` remains optional advice and never changes the effective outcome.
**Hard rule — Stage-1 source boundary**: Build the communication recommendation only from the current user request, source facts, conversation constraints, and project-initialization state. Author it before loading index summaries for a chat listing, and do not read any candidate spec, prototype, asset, or template-owned canvas. The same Stage-1 surface may display template controls, but their values are confirmation state, not recommendation evidence. Do not load or apply [`strategist-template.md`](./strategist-template.md) until Stage 1 is confirmed and the selected workspace has been installed for Stage 2.
> **Execution discipline**: Step 3 is non-interactive candidate preparation. Stage 1 is the first BLOCKING checkpoint and closes communication plus template/free-design choice in one confirmation. Its receipt is intermediate and MUST NOT end the task or produce a final chat reply. In the same active run, install/fuse any selection, complete its handoff, author fresh Stage 2, and enter the final confirmation wait. After final confirmation, proceed without another pause unless spec refinement is enabled.
@@ -462,6 +464,8 @@ required meaning in the visible page and confirmed presenter channel.
**Recommendation signals**: derive the initial reading mode from the confirmed `audience`, `delivery_context`, and `artifact_afterlife`. Asynchronous review, reference, approval, audit, and leave-behind use lean `text`; presenter-led projection, large-room delivery, launch, or classroom explanation lean `presentation`; hybrid review / roadshow use leans `balanced`. When live projection and durable afterlife both matter, recommend `balanced` unless the contract clearly prioritizes one. If the user confirms `presentation`, support afterlife through enabled notes, appendix pages, captions, and visible sources instead of crowding every slide.
**Default — visible-state sequence (may override when a new composition is clearer)**: Before freezing the §IX roster and enabled notes/narration boundaries, compare adjacent semantic beats within the active profile's roster/content invariants. When recurring roles, relationships, and spatial orientation form one mental map and the next beat has a meaningful state or focus change, plan neighboring pages as visible states of that scene: preserve recognizable anchors, make the semantic delta legible, and align each enabled notes/narration segment with its supporting visible state. This is a content-and-rhythm strategy, not a page quota. Reset the composition when the mental map changes or continuity adds no clarity. Within the confirmed page count, every state page must carry content and an `Audience move`; the effective motion outcome changes realization, not roster authority.
**Per-block expression**: let the semantic relationship choose the form. Causal explanation, argument, interpretation, and narrative continuity use prose. Truly parallel, ordered, or enumerable items may use bullets / numbers. Never create bullets merely because copy is long or a template exposes a list slot. In `presentation`, distill one assertion and move its explanation into enabled notes rather than turning every sentence into a fragment; when notes are disabled, keep the necessary explanation in the visible page or confirmed presenter channel. Source texture remains a secondary cue: an article / transcript / talk leans prose, while a data sheet or inventory may lean structured labels. Write complete, usable phrasing into §IX; do not leave skeletons for Executor. It is preferred wording unless literal preservation applies; Executor owns faithful expression adaptation under [`executor-base.md`](./executor-base.md) §2.1's content-vs-expression contract.
This is what makes the axis meaningful: a `presentation` deck and a `text` deck built from the **same source and communication contract** must differ in page grammar, page count recommendation, per-page text volume, visual burden, layout density, rhythm, and enabled notes—not only in font size. Page count stays the user's call; reading mode informs the recommendation when the user has not fixed one. Record it as **Reading Mode** in `design_spec.md §I` (compatibility key `delivery_purpose`, lock key `consumption_mode`). Separately, `communication_intent` / `audience_outcome` determine what the outline must accomplish, while `delivery_context` and `artifact_afterlife` help select the reading mode and still remain independent constraints after selection. The `page_rhythm` leans are a bias, not a quota. Preservation paths keep source wording and structure verbatim: honor reading mode only in styling and enabled notes, never by rephrasing or re-paginating.
@@ -512,7 +516,7 @@ includes transitions.
- **Custom behavior is concise and executable**: For confirmed `custom` mode or visual style, project one resolved `mode_behavior` / `visual_style_behavior` sentence or short paragraph. When the direction actually combines or borrows catalog entries, also project the exact, comma-separated `mode_references` / `visual_style_references`; omit the field for a genuinely novel direction and never fabricate a nearby reference. Preserve the confirmed direction, reference locked role names such as `colors.primary` when needed, and omit selection history, contradictions, precedence explanations, or other Design Spec provenance. Executor reads these fields from the retained lock and loads every referenced catalog entry once per valid context.
- **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing`. This is what breaks the uniform "every page is a card grid" feel. New locks may not omit the section; consumer omission behavior is owned by [`executor-base.md`](executor-base.md) §2.1.
- **Fact IDs and scenario labels are mandatory when applicable**: Read any `sources/*.facts.json`. For each §IX page, list the stable IDs actually used; never cite an ID whose claim is absent from the page. Mark invented KPIs/targets/internal ratios as `Data class: scenario` and state which values are scenario data. Executor carries external sources into notes/footnotes and renders a visible scenario label for scenario figures.
- **Mandatory — whole-roster rhythm check**: During the same §IX composition, compare neighbors and section arcs to judge whether chapter entries visibly reset, extended same-density runs are intentional, extended same-carrier or same-topology runs form an intentional semantic sub-arc, repeated dominant geometry carries a continuity job, each section follows a mode-fitting progression—including framework → explanation/evidence → judgment/action when it serves the objective—and the final arc resolves the communication objective before a genuine ending lowers information load. Repair the existing roster, `Layout`, and `page_rhythm` choices in place. This is judgment, not quota; preserve intentional continuity, legitimately all-`dense` material, and 1:1/literal order. Do not invent filler pages to manufacture rhythm; a `breathing` page marks a meaningful pause—chapter transition, standalone emphasis, or SCQA bridge—and must stand alone. Create no field, lock row, artifact, or second review/execution pass.
- **Mandatory — whole-roster rhythm check**: During the same §IX composition, compare neighbors and section arcs to judge whether chapter entries visibly reset, extended same-density runs are intentional, extended same-carrier or same-topology runs form an intentional semantic sub-arc, repeated dominant geometry carries a continuity job, any qualifying §6.1 visible-state sequence preserves a recognizable mental map while making its next semantic change legible, each section follows a mode-fitting progression—including framework → explanation/evidence → judgment/action when it serves the objective—and the final arc resolves the communication objective before a genuine ending lowers information load. Repair the existing roster, `Layout`, and `page_rhythm` choices in place. This is judgment, not quota; preserve intentional continuity, legitimately all-`dense` material, and 1:1/literal order. Do not invent filler pages to manufacture rhythm; a `breathing` page marks a meaningful pause—chapter transition, standalone emphasis, or SCQA bridge—and must stand alone. Create no field, lock row, artifact, or second review/execution pass.
- **Cover impact is mandatory**: In `design_spec.md §IX`, give `P01` one concrete hook from the source's strongest claim, metaphor, number, moment, or conflict plus a recommended composition. The hook binds; Executor may adapt the composition to prepared assets and explicit constraints. With no suitable image, recommend a native-SVG hook instead of a generic title treatment. Beautify / template-fill preservation paths are exempt.
- **Cover rhythm lock**: `P01` remains `anchor`. Default away from generic content-page templates; a card grid, agenda, or equal-weight columns remains valid when content, user direction, or the template makes it the clearest cover.
- **Closing impact (only when the deck closes)**: For a genuine conclusion / CTA / final takeaway, name the binding takeaway plus a recommended composition; Executor may adapt the latter. Do not default to an information-empty "Thank you", contact-only slide, or cover reprise; an explicit contact/event CTA may serve the purpose. **Do NOT invent a closing page to satisfy this**. Preservation paths are exempt.
@@ -29,12 +29,12 @@ and filter/clip contracts.
| Status | Meaning | Executor Handling |
|--------|---------|-------------------|
| **Pending** | Acquisition needed (`Acquire Via: ai` / `web`) or derivation needed (`Acquire Via: slice`); not yet attempted | Image Acquisition Phase (Step 5) consumes this; must not remain after Step 5 |
| **Pending** | Acquisition or declared derivation is needed; not yet attempted | Step 5 consumes this; must not remain afterward |
| **Failed** | The latest automatic acquisition attempt failed; this is retryable and non-terminal | Step 5 reruns the owning manifest or explicitly resolves the row to `Needs-Manual`; Executor must never treat `Failed` as usable content |
| **Generated** | AI-generated file exists at expected path, or sliced element file exists at expected path | Reference from `../images/`; no on-slide credit needed. **Exception**: an `Illustration Sheet` row is only a slice source — it lives in §VIII but never in `spec_lock.md images`, so the Executor never places it |
| **Generated** | AI/slice output exists | Reference from `../images/`; manifest records govern attribution. An `Illustration Sheet` stays in §VIII only as an unplaced slice source |
| **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [`executor-web-image.md`](./executor-web-image.md) §1 and [`image-searcher.md`](./image-searcher.md) §7 for the attribution contract) |
| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use a legal anchor with `meet` for the complete placement (centered default: `xMidYMid meet`) and do not crop |
| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the selected path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Default Generate may use a dashed placeholder until its readiness gate. Quick Generate blocks every required row still in this status, even if an unverified candidate file exists; validate a supplied replacement and reconcile it to `Generated`, `Sourced`, or `Rendered` first. For `slice`, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files. |
| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the selected path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Default Generate may use a dashed placeholder until its readiness gate. Quick Generate blocks every required row still in this status, even if an unverified candidate file exists; validate a supplied replacement and reconcile it to `Existing`, `Generated`, `Sourced`, or `Rendered` first. For `slice`, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files. |
| **Existing** | User already has image (`Acquire Via: user`) | Place in `images/`, reference with `<image>` |
| **Placeholder** | Intentionally not prepared yet (`Acquire Via: placeholder`) | Dashed border placeholder; replace later |
@@ -49,6 +49,7 @@ and filter/clip contracts.
2. Prepare project-local resources before SVG authoring:
- user → materialize the explicit source under project/images/ → Existing
- formula → write formula_manifest.json and run latex_render.py → Rendered
- Pending prepared derivative → follow [`image-base.md`](./image-base.md) §3 before ordinary `Acquire Via` dispatch
- Pending / Failed + ai → Image_Generator runs image_gen.py → Generated
- Pending / Failed + web → Image_Searcher runs image_search.py → Sourced
- Pending + slice → after parent AI sheet is Generated, slice_images.py cuts element files → Generated
@@ -52,27 +52,26 @@ SVG roster exists.
## 2. Scene and Page Planning
Judge quality by reduced audience understanding cost, not spectacle. Resolve the
spoken argument, visible evidence, mental map, and motion job together before
choosing effects.
**Default — quality follows purpose (may override)**: explanation prioritizes
understanding; promotion or brand work may prioritize emotion, recall, or
impact. Give every change a communication job.
| Narrative relationship | Page treatment |
|---|---|
| Several lines explain one idea | Keep one page/scene and reveal only the semantic units needed for that explanation |
| One process or system persists | Retain its main roles and relative positions as a stable visual anchor |
| New evidence expands one part of a known map | Keep the map, enlarge or emphasize the active region, and de-emphasize context only as needed |
| The same object changes position, scale, containment, or state | Author compatible adjacent endpoints and use the Morph contract when motion is active |
| One system persists | Before roster/notes freeze, derive states from the prior composition; keep orienting cues and change the semantic delta |
| New evidence expands a known map | Retain orienting cues; adapt the active region and context as needed |
| The same object changes position, scale, containment, or state | Consider compatible Morph endpoints when movement improves orientation |
| The audience must adopt a genuinely new mental map | Start a new composition and make the transition explicit |
**Default — stable visual anchors (may override when the subject resets)**:
within one explanation, keep recurring roles, relative positions, and governing
structure stable. Prefer moving, enlarging, revealing, or dimming within that
map over replacing the entire layout at every beat.
**Default — stable visual anchors (may override when the mental map resets)**:
within one explanation, preserve recognizable roles, relationships, or spatial
cues. Position, scale, and style may change while identity and orientation
remain legible; reset for a new map.
**Default — one semantic focus change per beat (may override for one inseparable
idea)**: several elements may change together only when they communicate one
unit. Do not change unrelated title, diagram, annotation, and footer regions at
the same moment merely to make the frame busier.
idea)**: change several elements together only for one communication unit. Do
not alter unrelated regions merely for busyness.
**Default — scene chrome earns its place (may override for navigation, identity,
attribution, or fidelity)**: for newly authored recorded, self-running, or video
@@ -88,9 +87,10 @@ on-screen copy)**: place keywords, structure, evidence, and relationships on the
slide; keep full explanation in notes. Do not duplicate the narration script as
body copy.
**Page-count rule**: derive page count from semantic scenes, visual-state
endpoints, and target duration. Never derive it from subtitle-cue or sentence
count.
**Page-count rule**: derive page/notes boundaries from scenes, mental-map arcs,
endpoints, and duration—not cues or sentences. Profile-fixed count/order/content,
including 1:1/fidelity, permits only existing-neighbor evaluation; never alter
those invariants for motion.
---
@@ -120,14 +120,22 @@ A pre-SVG `notes/total.md` is an enabled production artifact, not a forbidden
planning checkpoint; Quick still creates no root Design Spec, lock,
confirmation payload, or storyboard.
**Hard rule — Quick video Custom Animations**: when Quick generates a PPTX for
recorded, self-running, or video delivery, enable Custom Animations before SVG
authoring and complete the custom-animation stage before base export. Use
semantic groups and page-specific choreography; deck-wide `-a auto` and page
transitions do not satisfy this requirement. Individual pages or groups may
remain static, so this is not an animation-coverage quota. A validated
`animations.json` is required unless the user explicitly requests static or
page-transition-only playback.
**Mandatory — Quick direct video input**: when Quick must deliver a narrated
video or MP4 rather than only a deck for later recording, enable Speaker Notes,
Narration Audio, and video export; write the complete per-scene narration to
`notes/total.md` before P01 and use it as page-design input. After the SVG
roster, only agent-authored wording may be finalized; final/literal input remains
verbatim. Before audio, choose static/page-transition-only,
narration-independent deck-wide exporter motion, or page/object-specific Custom
Animations; for the last, decide whether narration governs any group timing.
verbatim. Before audio, complete the required Custom Animations configuration
and decide whether narration governs any group timing.
**Production outcomes**:
@@ -136,13 +144,16 @@ Animations; for the last, decide whether narration governs any group timing.
| Spoken delivery or a supplied final script | Enable Speaker Notes |
| User asks the workflow to synthesize narration | Enable Narration Audio; Speaker Notes is its dependency |
| Progressive reveal, continuing geometry, or timed emphasis materially aids explanation | Enable/load the appropriate animation capability |
| Quick directly delivers a narrated video or MP4 | Enable Speaker Notes, Narration Audio, and video export; resolve motion before audio, requiring timestamped page-local SRT only for narration-cue sync or subtitle delivery |
| Video is manually narrated or static playback is sufficient | Keep Narration Audio and/or object animation off as applicable |
| Quick generates a PPTX for recorded, self-running, or video delivery | Enable Custom Animations before SVG authoring and validate `animations.json` before base export |
| Quick directly delivers a narrated video or MP4 | Also enable Speaker Notes, Narration Audio, and video export; resolve narration-governed timing before audio, requiring timestamped page-local SRT for cue sync or subtitle delivery |
| The user explicitly requests static playback or disables object motion | Keep object animation off; retain the remaining notes/audio/video outcomes as requested |
**Capability boundary**: a deck intended for later video use does not force
object animation or generated audio. Direct Quick narrated-video delivery uses
the mandatory row above but may intentionally remain static or
page-transition-only. Explicit user instructions remain authoritative.
**Capability boundary**: Default generation does not force object animation or
generated audio merely because a deck may later be recorded. Quick with an
effective recorded/self-running/video delivery purpose does require Custom
Animations, while explicit user instructions for static or
page-transition-only playback remain authoritative. This requirement selects
the capability, not motion coverage or one effect for every page.
---
@@ -178,13 +189,18 @@ validate canonical `animations.json`. After the base PPTX/report and timestamped
page audio/SRT, map timed groups in `narration_timing.json`, derive
`narration_animations.json`, and export the narrated PPTX/MP4. Only derived
triggers/delays wait for SRT; object identity, effect, and order do not. `-a
auto` or inherited fixed stagger is not semantic synchronization. For
static/page-transition-only or narration-independent deck-wide motion, omit
these sidecars and the object-sync claim.
auto` or inherited fixed stagger is not semantic synchronization. For an
explicit user-selected static/page-transition-only Quick exception, or for
ordinary Default narration-independent deck-wide motion, omit these sidecars
and the object-sync claim.
**Sound effects**: add them only on explicit request and only from prepared,
project-local assets. Do not introduce sound search, trimming, or normalization
as an implicit video step.
**Sound effects**: exclude them from this pass and planning artifacts. After
final SVG/motion, animation post-processing owns on-demand selection and native
PPTX configuration; otherwise remain silent. For direct narrated MP4 delivery,
`generate-audio` owns the selected sound-delivery branch: native PowerPoint
encoding plus triggered post-export mix, or an explicitly requested real-time
PowerPoint slideshow capture. Video gain and limiting never enter
`animations.json`; capture uses the balance actually heard during Slide Show.
**Production sequence**: after the final SVG check, validate any pre-SVG
narration against the visible pages; ordinary draft-source runs instead use the
@@ -199,14 +215,30 @@ object-sync claim before the narrated PPTX and MP4.
## 5. Delivery Boundary
**Canonical artifact**: the editable PPTX remains canonical. `generate-audio` owns provider/voice/rate
selection, page audio/SRT generation, semantic narration timing, narrated PPTX
export, and optional native PowerPoint video export.
**Canonical artifact**: the editable PPTX remains canonical. `generate-audio`
owns provider/voice/rate selection, page audio/SRT generation, semantic
narration timing, narrated PPTX export, optional native PowerPoint video export,
the explicit slideshow-capture handoff, and the triggered sound-effects mix for
direct MP4 delivery.
**Conditional MP4**: run `powerpoint_video.py --check` only when MP4 delivery is
selected. If native Windows PowerPoint export is unavailable, keep the narrated
PPTX as the successful upstream artifact; do not substitute screenshots, HTML,
or a third-party renderer and call it equivalent.
**Conditional MP4**: run `powerpoint_video.py --check` only for the native-export
branch. If native Windows PowerPoint export is unavailable, keep the narrated
PPTX as the successful upstream artifact. An explicit slideshow-capture choice
may hand that artifact to a user-operated Windows PowerPoint recorder; it is not
complete until the capture is returned and accepted. Do not substitute
screenshots, HTML, or a third-party renderer and call it equivalent.
**Hard rule — choose one PowerPoint video sound boundary**:
| Delivery branch | Sound contract |
|---|---|
| Native encoder | PowerPoint supplies visual animation and narration but may omit transition/object sounds. With resolved cues, treat its MP4 as raw and require the verified `video_sound_mix.py` output. |
| Real-time slideshow capture | PowerPoint remains the renderer and audio player; a recorder captures the full-screen Slide Show and exactly one application/system-audio source. The accepted capture must contain narration and every configured cue once, and must not enter `video_sound_mix.py`. |
The branches are mutually exclusive because mixing a capture would duplicate
its cues. Keep the native cue configuration in the canonical PPTX. Slideshow
capture is explicit and human-audited; it does not inherit the native mix
receipt or become an automatic fallback.
**Current boundary**: importing and automatically splitting one long finished
recording is unsupported. Require page-level audio or an explicit page/time map;
@@ -55,8 +55,9 @@ python3 scripts/update_repo.py
| SVG pipeline | `preset_shape_svg.py`, `shape_boolean_svg.py`, `svg_authoring_view.py`, `compact_svg_coordinates.py`, `mirror_template_materialize.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `extract_svg_pictures.py`, `animation_config.py`, `notes_to_audio.py`, `narration_sync.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native shape authoring](../references/native-shape-authoring.md) |
| PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) |
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Animation resources | `sound_sync.py` | [sound catalog](../templates/sounds/README.md); [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Spec maintenance | `update_spec.py`, `visualization_recall.py`; legacy `chart_recall.py` | [docs/update_spec.md](./docs/update_spec.md); [docs/visualization-recall.md](./docs/visualization-recall.md) |
| Image tools | `image_gen.py`, `latex_render.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
| Image tools | `image_gen.py`, `image_treat.py`, `latex_render.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
| Maintenance smokes | Inline temporary-project commands | [advanced image and motion](./docs/advanced-image-motion-smoke.md); [mask and gradient](./docs/mask-gradient-smoke.md); [multilingual text](./docs/multilingual-text-smoke.md) |
| Repo maintenance | `update_repo.py` | README install/update section |
| Troubleshooting | validation, preview, export, dependency issues | [docs/troubleshooting.md](./docs/troubleshooting.md) |
@@ -163,6 +163,39 @@ MINIMAX_API_KEY=your-api-key
# MINIMAX_MODEL=image-01
```
## `image_treat.py`
Create a non-destructive PNG derivative from one bitmap already prepared under
`<project_path>/images/`. Use this only when a slide needs a baked bitmap effect;
crop, mask, rotation, mirror, opacity, shadow, scrim, outline, and overlap remain
native SVG/PPT treatments. This tool does not perform semantic background
removal: use `slice_images.py --alpha` for flat-color keys, an already prepared
RGBA asset or the active host image editor for a standalone cutout, and
[`image-generator.md`](../../references/image-generator.md) §4.4 only for
registered subject/base layers.
```bash
python3 scripts/image_treat.py projects/demo hero.jpg \
--output hero_soft.png --brightness 0.9 --contrast 1.1 --blur 12
python3 scripts/image_treat.py projects/demo hero.jpg \
--output hero_duotone.png --duotone "#14213D" "#FCA311"
```
Supported operations are brightness, contrast, desaturation/grayscale,
duotone, and Gaussian blur. They compose in a fixed order: brightness →
contrast → tone treatment → blur. Desaturation, grayscale, and duotone are
mutually exclusive. At least one option must produce a real change; animated
or multi-frame sources are rejected rather than reduced to one frame.
Both input and output are bare filenames directly under `images/`; output must
be a new `.png` file. The tool keeps the EXIF-corrected display dimensions,
leaves any alpha mask unchanged, and never overwrites the source or an existing
derivative. If `images/image_sources.json` contains the source filename, the
new record inherits that legal provenance and records `derived_from` plus the
ordered `treatments`. Run `analyze_images.py` after all planned derivatives are
ready so the inventory reflects the files that SVG authoring will consume.
## `analyze_images.py`
Analyze objective image-file facts in a project directory before writing the
@@ -44,7 +44,7 @@ One resolved row contains these fields:
| Order | Positive integer sidecar order; ties retain stable SVG group order, then `effects[]` index |
| Effect options | Effect-specific `direction`, `amount`, `color`, `font_name` (one installed PowerPoint face, required for Change Font; not a CSS list), `relative`, or `size` values from PowerPoint `EffectParameters` |
| Timing options | Repeat count/span, auto-reverse, rewind, accelerate/decelerate, bounce-end ratio, and restart policy |
| Completion | Optional dim/hide behavior and packaged `.m4a`/`.mp3`/`.wav` sound |
| Completion / cue | Optional dim/hide behavior and packaged `.m4a`/`.mp3`/`.wav` sound |
Modes resolve before XML writing:
@@ -62,6 +62,15 @@ can be audited without replaying the resolver.
`animation_config.py scaffold` is neutral: object defaults are `none`, and
empty `{}` group placeholders inherit no motion until populated.
Bundled sound discovery/materialization is a workflow concern, not part of the
animation core. After the SVG and object-motion solution are complete,
`sound_sync.py` may copy only selected namespaced ids into
`<project>/sounds/`; new sidecars then reference those project-relative WAV
paths. Existing low-level project-relative/absolute `.m4a`, `.mp3`, and `.wav`
inputs remain compatible. The core never resolves a library id or reads
`templates/sounds/` directly; see [`animations.md`](../../references/animations.md)
§2.2.
---
## 3. Canonical Registry and Compatibility Inputs
@@ -25,6 +25,7 @@ must not build, replace, or patch a transition with route-local XML or regex.
| Layer | Meaning | OOXML |
|---|---|---|
| Enter | How the current slide appears from the preceding slide | Native effect, Effect Options, and duration |
| Sound | Optional cue played with the current slide's transition | `p:sndAc/p:stSnd/p:snd` with an embedded WAV relationship |
| Advance | How the current slide leaves for the next slide | advClick and advTm |
Enter policy:
@@ -45,8 +46,9 @@ Advance mode:
| both | Click or timed advance, whichever occurs first |
| narration | Timed advance from narration lead-in, audio duration, and page-tail padding; click disabled |
**Hard rule**: enter=none may coexist with a timed advance. The valid result is
a timing-only p:transition with no visual-effect child.
**Hard rule**: enter=none may coexist with a sound and/or timed advance. The
valid result is a non-visual `p:transition` with `p:sndAc`, advance attributes,
or both, and no visual-effect child.
---
@@ -177,11 +179,19 @@ Example:
"direction": "left",
"pages": "double"
},
"duration": 0.6
"duration": 0.6,
"sound": "sounds/bigsoundbank/<file>.wav"
}
}
~~~
`transition.sound` is optional and accepts a `.wav` path resolved by the
generated-project adapter. A bundled-library choice is synced on demand only
after the visual transition plan is complete, then referenced by its
project-relative `sounds/<namespace>/<file>.wav` path. The adapter packages the
file and passes the shared core an embedded relationship id/name; the core
never reads `templates/sounds/` or resolves library ids.
Inspect the exact contract, including compatibility desugaring:
~~~bash
@@ -232,7 +242,7 @@ continue to preserve existing object names and transition XML.
| Route | Default enter | Default advance | Compatibility note |
|---|---|---|---|
| Generated PPTX CLI | fade, 0.4s | click | auto-advance maps to both |
| Generated PPTX CLI | fade, 0.4s; no sound | click | auto-advance maps to both; an optional sidecar sound is project-local |
| Recorded narration | Preserve resolved enter | narration | none remains visually none |
| Template Fill | preserve source | preserve source | explicit effects replace; legacy advance_after maps to both |
| Native Enhance | Confirmed global/per-slide plan effect | Confirmed timing module | With audio off, an enabled global transition or explicit global `none` applies to all pages; with audio on, the scope flag controls non-narrated pages |
@@ -269,7 +279,14 @@ Mutation rules:
| preserve | Leave unchanged | Leave wrapper and branches unchanged |
| advance-only | Patch direct attributes | Patch Choice and Fallback identically |
| replace | Replace the direct carrier | Remove the whole wrapper, then write one carrier |
| none | Remove visual carrier; retain timing-only carrier when needed | Remove the whole wrapper; retain timing-only carrier when needed |
| none | Remove visual effect; retain a non-visual carrier when sound or timing is needed | Remove the whole wrapper; write a non-visual carrier when sound or timing is needed |
**Sound placement**: for a direct transition, append `p:sndAc` inside
`p:transition` after the visual-effect child, when present. For
`mc:AlternateContent`, write the same sound action into both Choice and
Fallback transition carriers so older Office consumers do not lose the cue.
The slide relationship targets one packaged WAV part; replacing or removing a
requested generated sound must not leave a dangling relationship.
**MCE prefix rule**: Requires and Ignorable values contain textual prefix
names. Serialization must retain bindings for those exact names. Renaming an
@@ -290,6 +307,8 @@ Reject:
- unknown option fields or values for the selected effect;
- non-finite values, including NaN and Infinity;
- duration less than or equal to zero;
- a missing/non-file transition sound, a non-WAV transition sound, or an
unresolved/dangling sound relationship;
- negative advance or narration padding;
- booleans passed as numeric API values;
- multiple logical transition carriers;
@@ -300,8 +319,8 @@ Reject:
Read-back must report the canonical native effect and complete effective
options, while keeping the primary Choice child separate from the fallback. It
must also report raw effect attributes, carrier type, duration, click mode, and
automatic advance time. Package validation must run after writing, not only
before mutation.
automatic advance time, plus the transition sound relationship/name when
present. Package validation must run after writing, not only before mutation.
Use inline smoke commands and gitignored projects/_smoke_* artifacts. Do not
add a tests directory or test_*.py files.
@@ -648,8 +648,8 @@ Behavior:
- Narration text is read strictly from the matching `notes/*.md` file; the script only skips Markdown heading lines (`# ...`) and does not summarize, rewrite, or filter delivery notes
- `--recorded-narration audio` prepares PowerPoint's "recorded timings and narrations": every slide must have matching `m4a` / `mp3` / `wav` audio, `ffprobe` must read every duration, and `--animation-trigger on-click` is rejected
- `--recorded-narration audio` keeps speaker notes, embeds each matching audio file, and writes slide auto-advance timings from page-start lead-in + audio duration + page-tail padding. `--narration-start-floor` and `--narration-padding` are independent optional seconds; their defaults are `0.8` and `0.5`, and the post-transition lead-in is `max(0, start floor - transition duration)`
- When either animation sidecar exists, narrated export defaults to `<project>/narration_animations.json`; a canonical `animations.json` without that derived file remains a blocking synchronization error
- Without animation sidecars, Generate narration reads base-report deck motion via `--inherit-motion-from`; direct low-level omission keeps legacy `fade` / no object builds. Use `--animation-config animations.json` for canonical animation, or `--no-animations` to remove object/page motion while retaining narration timings
- While motion remains enabled, narrated export without an explicit `--animation-config` selects `<project>/narration_animations.json` when either animation sidecar exists; canonical-only cue synchronization therefore blocks until the derived file exists. Narration-independent custom motion explicitly passes `--animation-config animations.json`, even when a derived sidecar also exists
- Without animation sidecars, Generate narration may inherit base-report deck motion via `--inherit-motion-from`; direct low-level omission keeps legacy `fade` / no object builds. Use `--no-animations` to remove object/page motion while retaining narration timings
- Non-narrated export keeps the existing optional `<project>/animations.json` default
- Narration timing merges into the existing slide timing DOM. While motion remains enabled, object-animation rows and the resolved page transition are preserved rather than regenerated; inherited `-a none` suppresses object rows, and `--no-animations` removes both motion layers
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
@@ -675,6 +675,7 @@ Behavior:
supplies the default gap between successive non-trigger-shape rows in
`after-previous` mode (default `0.5`)
- Optional object-level overrides live in `<project>/animations.json` or a path passed via `--animation-config`; build and validate them with `animation_config.py scaffold|validate`. The scaffold is neutral (`defaults.animation.effect: none`, untouched groups `{}`). A populated group uses either the fully compatible legacy single-effect fields or a non-empty `effects[]`, never both; every `effects[]` row names an explicit effect
- Transition/object sound remains off by default. After SVG and visual motion are complete, discover bundled ids with `sound_sync.py list [--query <term>]` and copy only selected ids with `sound_sync.py <project> <namespace>/<sound_id> [...]`. `transition.sound` references a project-relative `.wav`; object-animation `sound` accepts the existing `.m4a`/`.mp3`/`.wav` path contract, while bundled selections use the synced project-relative `.wav`. With no selected cue, do not create `<project>/sounds/`. Export never resolves ids or reads `templates/sounds/` directly
- One `effects[]` row becomes one Animation Pane record on the group's shape target. Each row may independently set sequence `order`, `delay`, `duration`, `trigger`, and `trigger_shape`; ordinary rows use page-wide order, while `trigger_shape` rows keep relative order in separate interactive sequences and imply `on-click`
- Animation configuration is strict: unknown effects/modes/triggers, invalid finite/range/order values, missing slides/groups, and structural-layer targets fail export without fallback or silent omission
- Generated export reads every slide back and verifies animation row order, including repeated rows on one shape target, trigger, shape target, resolved effect tuple and native behavior signature, duration, and offset. Package validation then checks timing placement, `p:cTn` ids, and `p:spTgt` references before publication
@@ -0,0 +1,526 @@
#!/usr/bin/env python3
"""
PPT Master - Image Treatment
Create a non-destructive PNG derivative from one project-local bitmap while
preserving its display dimensions, alpha mask, and any matching web provenance.
Usage:
python3 scripts/image_treat.py <project_path> <source> --output <filename.png> [options]
Examples:
python3 scripts/image_treat.py projects/demo hero.jpg --output hero_soft.png --blur 12
python3 scripts/image_treat.py projects/demo hero.jpg --output hero_duotone.png \
--contrast 1.1 --duotone "#14213D" "#FCA311"
Dependencies:
Pillow; project image-search dependencies when copying web provenance
Treatments run in this fixed order: brightness, contrast, tone treatment
(desaturate / grayscale / duotone), then Gaussian blur.
"""
from __future__ import annotations
import argparse
import copy
import math
import os
import re
import sys
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Optional
from PIL import (
Image,
ImageCms,
ImageEnhance,
ImageFilter,
ImageOps,
UnidentifiedImageError,
)
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
configure_utf8_stdio()
_HEX_COLOR_RE = re.compile(r"^#?([0-9A-Fa-f]{6})$")
def _read_sources_manifest(path: Path) -> dict:
from image_search import _read_existing_manifest
return _read_existing_manifest(path)
def _write_sources_manifest(path: Path, item: dict) -> Path:
from image_search import write_sources_manifest
return write_sources_manifest(path, item)
def _finite_float(value: str) -> float:
try:
number = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"expected a number, got {value!r}") from exc
if not math.isfinite(number):
raise argparse.ArgumentTypeError("value must be finite")
return number
def _nonnegative_float(value: str) -> float:
number = _finite_float(value)
if number < 0:
raise argparse.ArgumentTypeError("value must be greater than or equal to 0")
return number
def _positive_float(value: str) -> float:
number = _finite_float(value)
if number <= 0:
raise argparse.ArgumentTypeError("value must be greater than 0")
return number
def _unit_float(value: str) -> float:
number = _finite_float(value)
if not 0 <= number <= 1:
raise argparse.ArgumentTypeError("value must be between 0 and 1")
return number
def _hex_color(value: str) -> str:
match = _HEX_COLOR_RE.fullmatch(value)
if match is None:
raise argparse.ArgumentTypeError("color must be #RRGGBB or RRGGBB")
return f"#{match.group(1).upper()}"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Create a project-local PNG derivative. Processing order: brightness -> "
"contrast -> desaturate/grayscale/duotone -> blur."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_path", help="Project root containing an images/ directory.")
parser.add_argument(
"source",
help="Existing bare bitmap filename under <project_path>/images (no path components).",
)
parser.add_argument(
"--output",
required=True,
help="New bare .png filename under <project_path>/images; existing files are never replaced.",
)
parser.add_argument(
"--brightness",
type=_positive_float,
help="Brightness factor greater than 0; 1 is unchanged.",
)
parser.add_argument(
"--contrast",
type=_positive_float,
help="Contrast factor greater than 0; 1 is unchanged.",
)
tone_group = parser.add_mutually_exclusive_group()
tone_group.add_argument(
"--desaturate",
type=_unit_float,
help="Remove this fraction of color (0..1); 1 is grayscale.",
)
tone_group.add_argument(
"--grayscale",
action="store_true",
help="Convert RGB content to grayscale while preserving any alpha mask.",
)
tone_group.add_argument(
"--duotone",
nargs=2,
type=_hex_color,
metavar=("SHADOW", "HIGHLIGHT"),
help="Map sRGB luminance between two sRGB #RRGGBB colors.",
)
parser.add_argument(
"--blur",
type=_nonnegative_float,
help="Gaussian blur radius greater than or equal to 0; 0 is unchanged.",
)
return parser
def _validate_bare_filename(value: str, *, field_name: str) -> str:
if (
not value.strip()
or value in {".", ".."}
or "/" in value
or "\\" in value
or ":" in value
or Path(value).is_absolute()
):
raise ValueError(
f"{field_name} must be a bare filename without path components: {value!r}"
)
return value
def _assert_output_absent(output_path: Path) -> None:
for child in output_path.parent.iterdir():
if child.name.casefold() == output_path.name.casefold():
raise ValueError(f"output already exists or conflicts by casing: {child}")
def _resolve_paths(project_value: str, source_name: str, output_name: str) -> tuple[Path, Path, Path]:
source_name = _validate_bare_filename(source_name, field_name="source")
output_name = _validate_bare_filename(output_name, field_name="output")
if Path(output_name).suffix.casefold() != ".png":
raise ValueError("output must use the .png extension")
if source_name.casefold() == output_name.casefold():
raise ValueError("output must differ from source, including filename casing")
project_input = Path(project_value).expanduser()
try:
project_path = project_input.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError(f"project path does not resolve safely: {project_input} ({exc})") from exc
if not project_path.is_dir():
raise ValueError(f"project path is not a directory: {project_path}")
images_link = project_path / "images"
if images_link.is_symlink():
raise ValueError(f"project images directory must not be a symlink: {images_link}")
if not images_link.is_dir():
raise ValueError(f"project images directory does not exist: {images_link}")
images_path = images_link.resolve(strict=True)
if images_path.parent != project_path:
raise ValueError(f"project images directory escapes the project root: {images_link}")
source_link = images_path / source_name
if source_link.is_symlink():
raise ValueError(f"source must not be a symlink: {source_link}")
try:
source_path = source_link.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError(f"source does not resolve safely: {source_link} ({exc})") from exc
if source_path.parent != images_path or not source_path.is_file():
raise ValueError(f"source must be a file directly under project images/: {source_link}")
output_path = images_path / output_name
_assert_output_absent(output_path)
if output_path.exists() or output_path.is_symlink():
raise ValueError(f"output already exists: {output_path}")
if output_path.parent.resolve() != images_path:
raise ValueError(f"output escapes the project images directory: {output_path}")
return images_path, source_path, output_path
def _treatment_plan(args: argparse.Namespace) -> list[dict]:
plan: list[dict] = []
if args.brightness is not None and args.brightness != 1:
plan.append({"operation": "brightness", "factor": args.brightness})
if args.contrast is not None and args.contrast != 1:
plan.append({"operation": "contrast", "factor": args.contrast})
if args.desaturate is not None and args.desaturate > 0:
plan.append({"operation": "desaturate", "amount": args.desaturate})
elif args.grayscale:
plan.append({"operation": "grayscale"})
elif args.duotone is not None:
plan.append(
{
"operation": "duotone",
"shadow": args.duotone[0],
"highlight": args.duotone[1],
}
)
if args.blur is not None and args.blur > 0:
plan.append({"operation": "blur", "radius": args.blur})
if not plan:
raise ValueError(
"select at least one effective treatment; identity values such as "
"--brightness 1, --desaturate 0, or --blur 0 do not change the image"
)
return plan
def _validate_blur_radius(radius: Optional[float], width: int, height: int) -> None:
if radius is None or radius <= 0:
return
effective_maximum = max(width, height)
if radius > effective_maximum:
raise ValueError(
f"blur radius {radius:g} exceeds the effective maximum "
f"{effective_maximum} for a {width}x{height} image; choose --blur "
f"at or below {effective_maximum}"
)
def _convert_duotone_source_to_srgb(image: Image.Image) -> tuple[Image.Image, bytes]:
output_profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB"))
output_icc = output_profile.tobytes()
source_icc = image.info.get("icc_profile")
if not source_icc:
return image.convert("RGB"), output_icc
if not isinstance(source_icc, bytes):
raise ValueError(
"source ICC profile has an unsupported representation; convert the "
"source to sRGB or remove the invalid profile, then retry"
)
try:
input_profile = ImageCms.ImageCmsProfile(BytesIO(source_icc))
converted = ImageCms.profileToProfile(
image,
input_profile,
output_profile,
outputMode="RGB",
)
except (ImageCms.PyCMSError, OSError, TypeError, ValueError) as exc:
raise ValueError(
"source ICC profile is invalid or incompatible with the image mode; "
"convert the source to sRGB or remove the invalid profile, then retry"
) from exc
if converted is None:
raise RuntimeError("ICC conversion did not produce an image")
return converted, output_icc
def _has_alpha(image: Image.Image) -> bool:
return "A" in image.getbands() or "transparency" in image.info
def _apply_treatments(
source_path: Path,
temporary_path: Path,
args: argparse.Namespace,
) -> tuple[int, int]:
try:
with Image.open(source_path) as source:
if int(getattr(source, "n_frames", 1)) != 1:
raise RuntimeError(
f"animated or multi-frame images are unsupported: {source_path}"
)
oriented = ImageOps.exif_transpose(source)
try:
oriented.load()
width, height = oriented.size
_validate_blur_radius(args.blur, width, height)
has_alpha = _has_alpha(oriented)
rgba = oriented.convert("RGBA") if has_alpha else None
alpha = rgba.getchannel("A") if rgba is not None else None
if args.duotone is not None:
rgb, icc_profile = _convert_duotone_source_to_srgb(oriented)
else:
keep_icc = oriented.mode in {"RGB", "RGBA"}
icc_profile = oriented.info.get("icc_profile") if keep_icc else None
rgb = rgba.convert("RGB") if rgba is not None else oriented.convert("RGB")
if args.brightness is not None and args.brightness != 1:
rgb = ImageEnhance.Brightness(rgb).enhance(args.brightness)
if args.contrast is not None and args.contrast != 1:
rgb = ImageEnhance.Contrast(rgb).enhance(args.contrast)
if args.desaturate is not None and args.desaturate > 0:
rgb = ImageEnhance.Color(rgb).enhance(1 - args.desaturate)
elif args.grayscale:
rgb = ImageOps.grayscale(rgb).convert("RGB")
elif args.duotone is not None:
rgb = ImageOps.colorize(
ImageOps.grayscale(rgb),
black=args.duotone[0],
white=args.duotone[1],
)
if args.blur is not None and args.blur > 0:
rgb = rgb.filter(ImageFilter.GaussianBlur(radius=args.blur))
result = rgb.convert("RGBA") if alpha is not None else rgb
if alpha is not None:
result.putalpha(alpha)
save_options = {"format": "PNG"}
if isinstance(icc_profile, bytes) and icc_profile:
save_options["icc_profile"] = icc_profile
result.save(temporary_path, **save_options)
result.close()
if rgba is not None:
rgba.close()
if alpha is not None:
alpha.close()
rgb.close()
return width, height
finally:
if oriented is not source:
oriented.close()
except (OSError, UnidentifiedImageError) as exc:
raise RuntimeError(f"unable to read or treat source image {source_path}: {exc}") from exc
def _rewrite_attribution_text(item: dict, source_name: str, output_name: str) -> str:
value = item.get("attribution_text")
if not isinstance(value, str) or not value:
return value if isinstance(value, str) else ""
if not value.startswith(source_name):
return value
remainder = value[len(source_name) :]
if remainder and not (remainder[0].isspace() or remainder[0] in "—–-:"):
return value
return output_name + remainder
def _prepare_provenance(
manifest_path: Path,
source_name: str,
output_name: str,
treatments: list[dict],
) -> Optional[dict]:
if manifest_path.is_symlink():
raise ValueError(f"image source manifest must not be a symlink: {manifest_path}")
if not manifest_path.exists():
return None
payload = _read_sources_manifest(manifest_path)
items = payload.get("items") or []
source_item = None
for item in items:
filename = item.get("filename")
if not isinstance(filename, str):
continue
if filename.casefold() == output_name.casefold():
raise ValueError(
f"output already has an image_sources.json record: {filename!r}"
)
if filename.casefold() == source_name.casefold():
if filename != source_name:
raise ValueError(
"source filename casing does not match image_sources.json: "
f"{source_name!r} vs {filename!r}"
)
source_item = item
if source_item is None:
return None
derived_item = copy.deepcopy(source_item)
derived_item["filename"] = output_name
derived_item["attribution_text"] = _rewrite_attribution_text(
source_item,
source_name,
output_name,
)
derived_item["derived_from"] = source_name
derived_item["treatments"] = copy.deepcopy(treatments)
return derived_item
def _assert_manifest_output_absent(manifest_path: Path, output_name: str) -> None:
payload = _read_sources_manifest(manifest_path)
for item in payload.get("items") or []:
filename = item.get("filename")
if isinstance(filename, str) and filename.casefold() == output_name.casefold():
raise RuntimeError(
f"output already has an image_sources.json record: {filename!r}"
)
def _is_staged_output(temporary_path: Path, output_path: Path) -> bool:
try:
return not output_path.is_symlink() and os.path.samefile(temporary_path, output_path)
except OSError:
return False
def _write_derivative(
source_path: Path,
output_path: Path,
manifest_path: Path,
provenance_item: Optional[dict],
args: argparse.Namespace,
) -> None:
fd, temporary_name = tempfile.mkstemp(
prefix=f".{output_path.stem}.",
suffix=".tmp.png",
dir=str(output_path.parent),
)
os.close(fd)
temporary_path = Path(temporary_name)
try:
width, height = _apply_treatments(source_path, temporary_path, args)
if provenance_item is not None:
provenance_item["width"] = width
provenance_item["height"] = height
_assert_manifest_output_absent(manifest_path, output_path.name)
_assert_output_absent(output_path)
try:
os.chmod(temporary_path, 0o644)
except OSError:
pass
try:
os.link(temporary_path, output_path)
except FileExistsError as exc:
raise RuntimeError(
f"output appeared during processing and will not be replaced: {output_path}"
) from exc
if provenance_item is not None:
try:
if not _is_staged_output(temporary_path, output_path):
raise RuntimeError(
f"installed output changed before provenance commit: {output_path}"
)
_assert_manifest_output_absent(manifest_path, output_path.name)
_write_sources_manifest(manifest_path, provenance_item)
except Exception as exc:
try:
if _is_staged_output(temporary_path, output_path):
output_path.unlink()
except OSError as cleanup_exc:
raise RuntimeError(
f"manifest update failed and output rollback also failed: {cleanup_exc}"
) from exc
raise RuntimeError(f"manifest update failed; output was rolled back: {exc}") from exc
finally:
try:
temporary_path.unlink()
except FileNotFoundError:
pass
def main(argv: Optional[list[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
images_path, source_path, output_path = _resolve_paths(
args.project_path,
args.source,
args.output,
)
treatments = _treatment_plan(args)
manifest_path = images_path / "image_sources.json"
provenance_item = _prepare_provenance(
manifest_path,
args.source,
args.output,
treatments,
)
_write_derivative(
source_path,
output_path,
manifest_path,
provenance_item,
args,
)
except (OSError, RuntimeError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(output_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -22,8 +22,8 @@ Examples:
--pptx exports/demo.pptx --video exports/demo.mp4 --force
Dependencies:
ffprobe for animation-window validation. Optional exported-video calibration
additionally requires ffmpeg and numpy.
ffprobe for animation-window validation. Optional exported-video timeline
calibration additionally requires ffmpeg and numpy.
"""
from __future__ import annotations
@@ -127,6 +127,7 @@ class AnimationGroupState:
source_index: int
duration_ms: int
original_delay_ms: int
trigger: str
@dataclass(frozen=True)
@@ -174,6 +175,31 @@ class PowerPointTiming:
advance_ms: int
@dataclass(frozen=True)
class VideoSlideTiming:
"""One slide mapped from the PPTX clock to an exported-video clock."""
slide_name: str
transition_ms: int
narration_delay_ms: int
advance_ms: int
powerpoint_slide_start_ms: int
powerpoint_narration_start_ms: int
video_slide_start_ms: int
video_narration_start_ms: int
adjustment_ms: int
correlation: float
audio_path: Path
@dataclass(frozen=True)
class VideoTimelineCalibration:
"""Page-level calibration between one narrated PPTX and exported video."""
slides: tuple[VideoSlideTiming, ...]
powerpoint_timeline_ms: int
def _timestamp_to_ms(value: str) -> int:
hours_text, minutes_text, remainder = value.split(":")
seconds_text, milliseconds_text = remainder.split(",")
@@ -1010,6 +1036,7 @@ def _resolve_animation_groups(
source_index=source_index,
duration_ms=playback_duration_ms,
original_delay_ms=original_delay_ms,
trigger=effect_trigger,
)
)
return states, use_svg
@@ -1216,7 +1243,11 @@ def rebuild_animations(
f'Derived animation slide "{slide_name}" groups must be an object'
)
previous_end_ms = 0
# Start modes are row-relative; slide completion spans overlapping rows.
previous_start_ms = 0
previous_row_end_ms = 0
timeline_end_ms = 0
has_previous_row = False
referenced_cues: set[int] = set()
seen_groups: set[str] = set()
for state in states:
@@ -1227,20 +1258,24 @@ def rebuild_animations(
if first_group_effect
else None
)
if not has_previous_row:
sequence_base_ms = 0
elif state.trigger == "with-previous":
sequence_base_ms = previous_start_ms
else:
sequence_base_ms = previous_row_end_ms
if cue_number is None:
if first_group_effect:
fallback_count += 1
delay_ms = state.original_delay_ms
actual_start_ms = previous_end_ms + delay_ms
actual_start_ms = sequence_base_ms + delay_ms
else:
anchored_count += 1
referenced_cues.add(cue_number)
cue_start_ms = cues[cue_number - 1].start_ms
desired_start_ms = (
narration_lead_in_ms + cue_start_ms
)
actual_start_ms = max(desired_start_ms, previous_end_ms)
delay_ms = actual_start_ms - previous_end_ms
desired_start_ms = narration_lead_in_ms + cue_start_ms
actual_start_ms = max(desired_start_ms, sequence_base_ms)
delay_ms = actual_start_ms - sequence_base_ms
drift_ms = actual_start_ms - desired_start_ms
if drift_ms > 500:
drift_warnings.append(
@@ -1248,7 +1283,8 @@ def rebuild_animations(
f"starts at {_seconds_from_ms(cue_start_ms):.3f}s after "
f"a {_seconds_from_ms(narration_lead_in_ms):.3f}s lead-in; "
f"animation starts at {_seconds_from_ms(actual_start_ms):.3f}s "
f"(after-previous drift {_seconds_from_ms(drift_ms):.3f}s)"
f"({state.trigger} drift "
f"{_seconds_from_ms(drift_ms):.3f}s)"
)
group_value = groups_value.setdefault(state.group_id, {})
@@ -1271,8 +1307,11 @@ def rebuild_animations(
effect_value = derived_effect_entries[state.effect_index][1]
effect_value["order"] = state.order
effect_value["delay"] = _seconds_from_ms(delay_ms)
effect_value["trigger"] = "after-previous"
previous_end_ms = actual_start_ms + state.duration_ms
effect_value["trigger"] = state.trigger
previous_start_ms = actual_start_ms
previous_row_end_ms = actual_start_ms + state.duration_ms
timeline_end_ms = max(timeline_end_ms, previous_row_end_ms)
has_previous_row = True
ignored_cue_count += len(cues) - len(referenced_cues)
@@ -1284,10 +1323,10 @@ def rebuild_animations(
)
* 1000
)
if previous_end_ms > advance_ms:
if timeline_end_ms > advance_ms:
raise ValueError(
f'Animations on slide "{slide_name}" end at '
f"{_seconds_from_ms(previous_end_ms):.3f}s, after the recorded "
f"{_seconds_from_ms(timeline_end_ms):.3f}s, after the recorded "
f"slide advance at {_seconds_from_ms(advance_ms):.3f}s"
)
@@ -1347,7 +1386,8 @@ def rebuild_animations(
)
def _presentation_slide_members(package: zipfile.ZipFile) -> list[str]:
def presentation_slide_members(package: zipfile.ZipFile) -> list[str]:
"""Return slide package members in presentation order."""
try:
presentation_root = ET.fromstring(package.read("ppt/presentation.xml"))
relationships_root = ET.fromstring(
@@ -1400,7 +1440,7 @@ def _read_powerpoint_timings(
) -> list[PowerPointTiming]:
timings: list[PowerPointTiming] = []
with zipfile.ZipFile(pptx_path) as package:
slide_members = _presentation_slide_members(package)
slide_members = presentation_slide_members(package)
if len(slide_members) != slide_count:
raise ValueError(
f"Narrated PPTX has {len(slide_members)} slides, "
@@ -1467,7 +1507,7 @@ def _require_numpy() -> Any:
import numpy as np
except ImportError as exc:
raise RuntimeError(
"Exported-video subtitle calibration requires numpy. "
"Exported-video timeline calibration requires numpy. "
"Install it with: python3 -m pip install numpy"
) from exc
return np
@@ -1555,11 +1595,15 @@ def _best_correlation(search: Any, template: Any) -> tuple[int, float]:
def _alignment_template_bounds(
fine_envelope: Any,
cues: list[SubtitleCue],
cues: list[SubtitleCue] | None,
) -> tuple[int, int]:
duration_ms = len(fine_envelope)
start_ms = min(cues[0].start_ms, max(0, duration_ms - 500))
cue_end_ms = min(cues[-1].end_ms, duration_ms)
if cues:
start_ms = min(cues[0].start_ms, max(0, duration_ms - 500))
cue_end_ms = min(cues[-1].end_ms, duration_ms)
else:
start_ms = 0
cue_end_ms = duration_ms
end_ms = min(duration_ms, start_ms + _ALIGNMENT_TEMPLATE_MAX_MS)
end_ms = min(end_ms, max(start_ms + 1000, cue_end_ms))
if end_ms - start_ms < 500:
@@ -1572,7 +1616,7 @@ def _locate_audio_start(
video_coarse: Any,
audio_fine: Any,
audio_coarse: Any,
cues: list[SubtitleCue],
cues: list[SubtitleCue] | None,
predicted_start_ms: int,
) -> tuple[int, float]:
"""Locate one page narration near its predicted exported-video position."""
@@ -1624,7 +1668,7 @@ def _locate_audio_start(
def _align_audio_starts_to_video(
*,
slide_names: list[str],
local_cues: dict[str, list[SubtitleCue]],
local_cues: dict[str, list[SubtitleCue] | None],
theoretical_starts: list[int],
audio_dir: Path,
video_path: Path,
@@ -1635,7 +1679,7 @@ def _align_audio_starts_to_video(
ffmpeg_path = shutil.which("ffmpeg")
if ffmpeg_path is None:
raise RuntimeError(
"Exported-video subtitle calibration requires ffmpeg. "
"Exported-video timeline calibration requires ffmpeg. "
"Install ffmpeg and make it available on PATH."
)
@@ -1648,13 +1692,15 @@ def _align_audio_starts_to_video(
audio_path = _find_audio(audio_dir, slide_name)
audio_paths.append(audio_path)
audio_fine, audio_coarse = _decode_audio_envelopes(audio_path, ffmpeg_path)
slide_cues = local_cues.get(slide_name)
if (
local_cues[slide_name][-1].end_ms
slide_cues
and slide_cues[-1].end_ms
> len(audio_fine) + _ALIGNMENT_END_TOLERANCE_MS
):
raise ValueError(
f"{slide_name}.srt ends after its narration audio: "
f"cue end={_seconds_from_ms(local_cues[slide_name][-1].end_ms):.3f}s, "
f"cue end={_seconds_from_ms(slide_cues[-1].end_ms):.3f}s, "
f"decoded audio={_seconds_from_ms(len(audio_fine)):.3f}s"
)
if index == 0:
@@ -1670,7 +1716,7 @@ def _align_audio_starts_to_video(
video_coarse,
audio_fine,
audio_coarse,
local_cues[slide_name],
slide_cues,
predicted_start_ms,
)
if correlation < _ALIGNMENT_MIN_CORRELATION:
@@ -1683,16 +1729,14 @@ def _align_audio_starts_to_video(
raise ValueError(
f"Exported-video audio order is invalid at slide {slide_name}"
)
final_cue_end_ms = (
aligned_start_ms + local_cues[slide_name][-1].end_ms
)
final_audio_end_ms = aligned_start_ms + len(audio_fine)
if (
final_cue_end_ms
final_audio_end_ms
> len(video_fine) + _ALIGNMENT_END_TOLERANCE_MS
):
raise ValueError(
f"Exported video ends before the final cue on slide {slide_name}: "
f"cue end={_seconds_from_ms(final_cue_end_ms):.3f}s, "
f"Exported video ends before the narration on slide {slide_name}: "
f"audio end={_seconds_from_ms(final_audio_end_ms):.3f}s, "
f"decoded video audio={_seconds_from_ms(len(video_fine)):.3f}s"
)
aligned_starts.append(aligned_start_ms)
@@ -1701,6 +1745,106 @@ def _align_audio_starts_to_video(
return aligned_starts, correlations, audio_paths
def calibrate_video_timeline(
*,
slide_names: list[str],
pptx_path: Path,
audio_dir: Path,
video_path: Path,
subtitle_dir: Path | None = None,
) -> VideoTimelineCalibration:
"""Calibrate PPTX slide starts against narration in an exported video.
Page-local SRT improves the correlation template when available. Audio-only
narration remains supported by matching each complete page track.
"""
if not slide_names:
raise ValueError("Video timeline calibration requires at least one slide")
if len(set(slide_names)) != len(slide_names):
raise ValueError("Video timeline calibration slide names must be unique")
timings = _read_powerpoint_timings(pptx_path, len(slide_names))
theoretical_starts, timeline_ms = _powerpoint_audio_starts(timings)
local_cues: dict[str, list[SubtitleCue] | None] = {}
for slide_name in slide_names:
subtitle_path = (
subtitle_dir / f"{slide_name}.srt"
if subtitle_dir is not None
else None
)
local_cues[slide_name] = (
_parse_srt(subtitle_path)
if subtitle_path is not None and subtitle_path.is_file()
else None
)
aligned_starts, correlations, audio_paths = _align_audio_starts_to_video(
slide_names=slide_names,
local_cues=local_cues,
theoretical_starts=theoretical_starts,
audio_dir=audio_dir,
video_path=video_path,
)
slides: list[VideoSlideTiming] = []
powerpoint_slide_start_ms = 0
previous_video_slide_start_ms = -1
for (
slide_name,
timing,
powerpoint_narration_start_ms,
video_narration_start_ms,
correlation,
audio_path,
) in zip(
slide_names,
timings,
theoretical_starts,
aligned_starts,
correlations,
audio_paths,
):
raw_video_slide_start_ms = (
video_narration_start_ms
- timing.transition_ms
- timing.narration_delay_ms
)
if raw_video_slide_start_ms < -_ALIGNMENT_END_TOLERANCE_MS:
raise ValueError(
f"Exported-video calibration places slide {slide_name} before "
f"the video start: {raw_video_slide_start_ms}ms"
)
video_slide_start_ms = max(0, raw_video_slide_start_ms)
if video_slide_start_ms <= previous_video_slide_start_ms:
raise ValueError(
f"Exported-video slide order is invalid at {slide_name}"
)
slides.append(
VideoSlideTiming(
slide_name=slide_name,
transition_ms=timing.transition_ms,
narration_delay_ms=timing.narration_delay_ms,
advance_ms=timing.advance_ms,
powerpoint_slide_start_ms=powerpoint_slide_start_ms,
powerpoint_narration_start_ms=powerpoint_narration_start_ms,
video_slide_start_ms=video_slide_start_ms,
video_narration_start_ms=video_narration_start_ms,
adjustment_ms=(
video_narration_start_ms - powerpoint_narration_start_ms
),
correlation=correlation,
audio_path=audio_path,
)
)
previous_video_slide_start_ms = video_slide_start_ms
powerpoint_slide_start_ms += timing.transition_ms + timing.advance_ms
return VideoTimelineCalibration(
slides=tuple(slides),
powerpoint_timeline_ms=timeline_ms,
)
def _merge_subtitles_result(
project_path: Path,
*,
@@ -1740,13 +1884,21 @@ def _merge_subtitles_result(
audio_starts = theoretical_starts
else:
resolved_audio_dir = audio_dir or project_path / "audio"
audio_starts, correlations, audio_paths = _align_audio_starts_to_video(
calibration = calibrate_video_timeline(
slide_names=slide_names,
local_cues=local_cues,
theoretical_starts=theoretical_starts,
pptx_path=pptx_path,
audio_dir=resolved_audio_dir,
video_path=video_path,
subtitle_dir=subtitle_dir,
)
if calibration.powerpoint_timeline_ms != timeline_ms:
raise ValueError("Video calibration and subtitle timelines differ")
audio_starts = [
slide.video_narration_start_ms
for slide in calibration.slides
]
correlations = [slide.correlation for slide in calibration.slides]
audio_paths = [slide.audio_path for slide in calibration.slides]
_reject_output_alias(
output_path,
audio_paths,
@@ -44,6 +44,9 @@ P159_NS = "http://schemas.microsoft.com/office/powerpoint/2015/09/main"
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
RELATIONSHIPS_NS = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
)
PRESENTATION_PROPS_PART = "ppt/presProps.xml"
PRESENTATION_RELS_PART = "ppt/_rels/presentation.xml.rels"
@@ -54,6 +57,10 @@ PRESENTATION_PROPS_REL_TYPE = (
PRESENTATION_PROPS_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"
)
AUDIO_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
)
WAV_CONTENT_TYPES = frozenset({"audio/wav", "audio/x-wav"})
DEFAULT_TRANSITION = "fade"
DEFAULT_TRANSITION_DURATION = 0.4
@@ -882,6 +889,7 @@ TRANSITION_NAMESPACES = {
for _prefix, _uri in (
*TRANSITION_NAMESPACES.items(),
("mc", MC_NS),
("r", RELATIONSHIPS_NS),
):
try:
ET.register_namespace(_prefix, _uri)
@@ -924,6 +932,10 @@ class TransitionSummary:
effect_attributes: Mapping[str, str] = field(default_factory=dict)
canonical_effect: str | None = None
effect_options: Mapping[str, object] = field(default_factory=dict)
sound_relationship_id: str | None = None
sound_name: str | None = None
fallback_sound_relationship_id: str | None = None
fallback_sound_name: str | None = None
@dataclass(frozen=True)
@@ -1125,6 +1137,7 @@ def describe_transition_effect(effect: object) -> dict[str, Any]:
"duration": "positive seconds",
"auto_advance": "non-negative seconds",
},
"sound": "project-relative .wav path or null in animations.json",
}
@@ -1215,18 +1228,67 @@ def _transition_attributes(
return " " + " ".join(attrs) if attrs else ""
def _normalize_transition_sound(
sound: Mapping[str, object] | None,
) -> dict[str, str] | None:
"""Validate one packaged transition-sound relationship descriptor."""
if sound is None:
return None
if not isinstance(sound, Mapping):
raise ValueError("transition sound must be a relationship descriptor")
unknown = set(sound) - {"relationship_id", "name"}
if unknown:
raise ValueError(
"transition sound has unknown field(s): "
+ ", ".join(sorted(str(field) for field in unknown))
)
relationship_id = sound.get("relationship_id")
name = sound.get("name")
if not isinstance(relationship_id, str) or not relationship_id.strip():
raise ValueError(
"transition sound relationship_id must be a non-empty string"
)
if not isinstance(name, str) or not name.strip():
raise ValueError("transition sound name must be a non-empty string")
return {
"relationship_id": relationship_id,
"name": name,
}
def _transition_sound_xml(
sound: Mapping[str, str] | None,
*,
indent: str,
) -> str:
"""Return the optional p:sndAc payload at one indentation level."""
if sound is None:
return ""
relationship_id = quoteattr(sound["relationship_id"])
name = quoteattr(sound["name"])
return (
f"{indent}<p:sndAc>\n"
f"{indent} <p:stSnd>\n"
f"{indent} <p:snd r:embed={relationship_id} name={name}/>\n"
f"{indent} </p:stSnd>\n"
f"{indent}</p:sndAc>\n"
)
def create_transition_xml(
effect: str | None = DEFAULT_TRANSITION,
duration: float = 0.5,
advance_after: float | None = None,
advance_on_click: bool | None = None,
effect_options: Mapping[str, object] | None = None,
sound: Mapping[str, object] | None = None,
) -> str:
"""Build a direct or MCE-backed p:transition XML fragment."""
normalized_effect, normalized_options = normalize_transition_effect_request(
effect,
effect_options,
)
normalized_sound = _normalize_transition_sound(sound)
duration_ms = None
if normalized_effect is not None:
duration_ms = _seconds_to_ms(
@@ -1251,6 +1313,7 @@ def create_transition_xml(
normalized_effect is None
and advance_ms is None
and advance_on_click is None
and normalized_sound is None
):
return ""
@@ -1261,7 +1324,13 @@ def create_transition_xml(
declare_p14=normalized_effect is not None,
)
if normalized_effect is None:
return f" <p:transition{attr_text}/>"
if normalized_sound is None:
return f" <p:transition{attr_text}/>"
return (
f" <p:transition{attr_text}>\n"
+ _transition_sound_xml(normalized_sound, indent=" ")
+ " </p:transition>"
)
prefix, element_name, effect_attrs = _effect_xml(
normalized_effect,
@@ -1282,24 +1351,36 @@ def create_transition_xml(
advance_on_click=advance_on_click,
declare_p14=False,
)
choice_sound_xml = _transition_sound_xml(
normalized_sound,
indent=" ",
)
fallback_sound_xml = _transition_sound_xml(
normalized_sound,
indent=" ",
)
return (
f' <mc:AlternateContent xmlns:mc="{MC_NS}">\n'
f' <mc:Choice xmlns:{prefix}="{TRANSITION_NAMESPACES[prefix]}" '
f'Requires="{prefix}">\n'
f" <p:transition{attr_text}>\n"
f" <{prefix}:{element_name}{effect_attrs}/>\n"
f"{choice_sound_xml}"
" </p:transition>\n"
" </mc:Choice>\n"
" <mc:Fallback>\n"
f" <p:transition{fallback_attr_text}>\n"
f" <{fallback_prefix}:{fallback_name}{fallback_attrs}/>\n"
f"{fallback_sound_xml}"
" </p:transition>\n"
" </mc:Fallback>\n"
" </mc:AlternateContent>"
)
sound_xml = _transition_sound_xml(normalized_sound, indent=" ")
return (
f" <p:transition{attr_text}>\n"
f" <{prefix}:{element_name}{effect_attrs}/>\n"
f"{sound_xml}"
" </p:transition>"
)
@@ -1493,6 +1574,45 @@ def _effect_identity(
return None, None, {}
def _sound_identity(
transition: Any | None,
) -> tuple[str | None, str | None]:
"""Return one embedded transition sound relationship and display name."""
if transition is None:
return None, None
sound_action = next(
(
child
for child in list(transition)
if child.tag == _qn(PML_NS, "sndAc")
),
None,
)
if sound_action is None:
return None, None
start_sound = next(
(
child
for child in list(sound_action)
if child.tag == _qn(PML_NS, "stSnd")
),
None,
)
if start_sound is None:
return None, None
sound = next(
(
child
for child in list(start_sound)
if child.tag == _qn(PML_NS, "snd")
),
None,
)
if sound is None:
return None, None
return sound.get(_qn(RELATIONSHIPS_NS, "embed")), sound.get("name")
def _effective_transition_options(
effect: str,
options: Mapping[str, object] | None = None,
@@ -1573,6 +1693,10 @@ def read_slide_transition(slide_root: Any) -> TransitionSummary:
fallback_effect, fallback_namespace, _fallback_attributes = _effect_identity(
fallback
)
sound_relationship_id, sound_name = _sound_identity(primary)
fallback_sound_relationship_id, fallback_sound_name = _sound_identity(
fallback
)
canonical_effect, effect_options = _identify_native_transition(
effect,
effect_namespace,
@@ -1607,6 +1731,10 @@ def read_slide_transition(slide_root: Any) -> TransitionSummary:
else None
),
advance_after_ms=advance_after_ms,
sound_relationship_id=sound_relationship_id,
sound_name=sound_name,
fallback_sound_relationship_id=fallback_sound_relationship_id,
fallback_sound_name=fallback_sound_name,
)
@@ -1806,6 +1934,10 @@ def _visual_identity(summary: TransitionSummary) -> tuple[Any, ...]:
summary.fallback_effect_namespace,
summary.duration_ms,
summary.speed,
summary.sound_relationship_id,
summary.sound_name,
summary.fallback_sound_relationship_id,
summary.fallback_sound_name,
)
@@ -1908,6 +2040,11 @@ def _validate_applied_motion(
elif enter.policy == "none":
if after.effect is not None or after.fallback_effect is not None:
errors.append("none policy retained a visual transition")
if (
after.sound_relationship_id is not None
or after.fallback_sound_relationship_id is not None
):
errors.append("none policy retained a transition sound")
preserved_click = (
before.advance_on_click
@@ -2181,6 +2318,7 @@ def validate_generated_transition_xml(
advance_on_click: bool | None,
advance_after: object | None,
effect_options: Mapping[str, object] | None = None,
sound: Mapping[str, object] | None = None,
) -> TransitionSummary:
"""Validate a generated transition against its resolved settings."""
data = slide_xml.encode("utf-8") if isinstance(slide_xml, str) else slide_xml
@@ -2191,6 +2329,7 @@ def validate_generated_transition_xml(
effect,
effect_options,
)
normalized_sound = _normalize_transition_sound(sound)
expected_click = True if advance_on_click is None else advance_on_click
if not isinstance(expected_click, bool):
errors.append("transition advance_on_click must be a boolean or None")
@@ -2207,6 +2346,7 @@ def validate_generated_transition_xml(
normalized_effect is not None
or expected_after_ms is not None
or expected_click is False
or normalized_sound is not None
)
if not expects_carrier:
@@ -2228,6 +2368,18 @@ def validate_generated_transition_xml(
expected_fallback = None
expected_fallback_namespace = None
expected_attrs: dict[str, Any] = {}
expected_sound_relationship_id = (
normalized_sound["relationship_id"]
if normalized_sound is not None
else None
)
expected_sound_name = (
normalized_sound["name"]
if normalized_sound is not None
else None
)
expected_fallback_sound_relationship_id = None
expected_fallback_sound_name = None
if normalized_effect is not None:
(
expected_carrier,
@@ -2244,6 +2396,11 @@ def validate_generated_transition_xml(
normalized_effect,
normalized_options,
)
if expected_carrier == "alternate-content":
expected_fallback_sound_relationship_id = (
expected_sound_relationship_id
)
expected_fallback_sound_name = expected_sound_name
else:
expected_effect_options = {}
if (
@@ -2258,6 +2415,11 @@ def validate_generated_transition_xml(
or summary.duration_ms != expected_duration_ms
or summary.advance_on_click != expected_click
or summary.advance_after_ms != expected_after_ms
or summary.sound_relationship_id != expected_sound_relationship_id
or summary.sound_name != expected_sound_name
or summary.fallback_sound_relationship_id
!= expected_fallback_sound_relationship_id
or summary.fallback_sound_name != expected_fallback_sound_name
):
errors.append("generated transition read-back does not match its settings")
if normalized_effect is not None:
@@ -2284,6 +2446,177 @@ def validate_generated_transition_xml(
return summary
def _slide_relationships_part(slide_part: str) -> str:
directory = posixpath.dirname(slide_part)
filename = posixpath.basename(slide_part)
return posixpath.join(directory, "_rels", f"{filename}.rels")
def _resolve_slide_relationship_target(
slide_part: str,
target: str,
) -> str | None:
normalized_target = target.replace("\\", "/")
if not normalized_target:
return None
if normalized_target.startswith("/"):
resolved = posixpath.normpath(normalized_target.lstrip("/"))
else:
resolved = posixpath.normpath(
posixpath.join(posixpath.dirname(slide_part), normalized_target)
)
if resolved == ".." or resolved.startswith("../"):
return None
return resolved
def _transition_sound_elements(slide_root: Any) -> list[Any]:
sounds: list[Any] = []
for carrier in transition_carriers(slide_root):
for transition in _transition_elements(carrier):
for sound_action in list(transition):
if sound_action.tag != _qn(PML_NS, "sndAc"):
continue
for start_sound in list(sound_action):
if start_sound.tag != _qn(PML_NS, "stSnd"):
continue
sounds.extend(
child
for child in list(start_sound)
if child.tag == _qn(PML_NS, "snd")
)
return sounds
def _package_part_content_type(
package: zipfile.ZipFile,
target_part: str,
) -> str | None:
"""Resolve one package part's MIME type from defaults or overrides."""
content_types_root = ET.fromstring(package.read(CONTENT_TYPES_PART))
normalized_target = "/" + target_part.lstrip("/")
for entry in content_types_root:
if (
entry.tag == _qn(CONTENT_TYPES_NS, "Override")
and entry.get("PartName") == normalized_target
):
return entry.get("ContentType")
extension = Path(target_part).suffix.lstrip(".").lower()
for entry in content_types_root:
if (
entry.tag == _qn(CONTENT_TYPES_NS, "Default")
and str(entry.get("Extension") or "").lower() == extension
):
return entry.get("ContentType")
return None
def _validate_transition_sound_package_parts(
package: zipfile.ZipFile,
package_names: set[str],
slide_part: str,
slide_xml: bytes,
) -> list[str]:
"""Validate transition-sound relationships and embedded WAV targets."""
errors: list[str] = []
slide_root = (
LET.fromstring(slide_xml)
if LET is not None
else parse_source_xml(slide_xml)
)
sounds = _transition_sound_elements(slide_root)
if not sounds:
return errors
if CONTENT_TYPES_PART not in package_names:
return ["transition sound is missing [Content_Types].xml"]
relationships_part = _slide_relationships_part(slide_part)
if relationships_part not in package_names:
return [
f"transition sound is missing slide relationships: "
f"{relationships_part}"
]
try:
relationships_root = ET.fromstring(package.read(relationships_part))
except (KeyError, ET.ParseError) as exc:
return [f"unable to read {relationships_part}: {exc}"]
relationships: dict[str, Any] = {}
duplicate_ids: set[str] = set()
for relationship in relationships_root:
relationship_id = str(relationship.get("Id") or "")
if not relationship_id:
continue
if relationship_id in relationships:
duplicate_ids.add(relationship_id)
relationships[relationship_id] = relationship
if duplicate_ids:
errors.append(
f"{relationships_part} contains duplicate relationship id(s): "
+ ", ".join(sorted(duplicate_ids))
)
for sound in sounds:
relationship_id = sound.get(_qn(RELATIONSHIPS_NS, "embed"))
if not relationship_id:
errors.append("p:snd must declare a non-empty r:embed")
continue
relationship = relationships.get(relationship_id)
if relationship is None:
errors.append(
f"p:snd references missing slide relationship: "
f"{relationship_id}"
)
continue
if relationship.get("Type") != AUDIO_REL_TYPE:
errors.append(
f"transition sound relationship {relationship_id} must use "
"the OOXML audio relationship type"
)
if relationship.get("TargetMode") == "External":
errors.append(
f"transition sound relationship {relationship_id} must be internal"
)
continue
target = str(relationship.get("Target") or "")
target_part = _resolve_slide_relationship_target(slide_part, target)
if target_part is None:
errors.append(
f"transition sound relationship {relationship_id} has an "
f"invalid target: {target!r}"
)
continue
if Path(target_part).suffix.lower() != ".wav":
errors.append(
f"transition sound relationship {relationship_id} must target "
f"a .wav part: {target_part}"
)
if target_part not in package_names:
errors.append(
f"transition sound relationship {relationship_id} target is "
f"missing: {target_part}"
)
continue
content_type = _package_part_content_type(package, target_part)
if str(content_type or "").lower() not in WAV_CONTENT_TYPES:
errors.append(
f"transition sound target {target_part} must declare a WAV "
f"content type; found {content_type!r}"
)
payload = package.read(target_part)
if not (
len(payload) >= 12
and payload[:4] in {b"RIFF", b"RF64"}
and payload[8:12] == b"WAVE"
):
errors.append(
f"transition sound target {target_part} is not RIFF/RF64 WAVE"
)
return errors
def validate_pptx_transition_package(
pptx_path: Path,
*,
@@ -2311,6 +2644,7 @@ def validate_pptx_transition_package(
"duplicate package parts: " + ", ".join(duplicate_names)
)
package_names = set(names)
slide_names = sorted(
name
for name in names
@@ -2325,6 +2659,20 @@ def validate_pptx_transition_package(
summaries[slide_name] = read_slide_transition_xml(slide_xml)
except Exception as exc:
errors.append(f"{slide_name}: transition read-back failed: {exc}")
try:
sound_errors = _validate_transition_sound_package_parts(
package,
package_names,
slide_name,
slide_xml,
)
except Exception as exc:
errors.append(
f"{slide_name}: transition sound read-back failed: {exc}"
)
else:
for problem in sound_errors:
errors.append(f"{slide_name}: {problem}")
if require_use_timings:
errors.extend(_validate_package_use_timings(package, names))
@@ -2587,6 +2935,39 @@ def validate_slide_transition_structure(slide_root: Any) -> list[str]:
errors.append(f"transition carrier must precede p:{tag}")
for carrier in carriers:
for transition in _transition_elements(carrier):
sound_actions = [
child
for child in list(transition)
if child.tag == _qn(PML_NS, "sndAc")
]
if len(sound_actions) > 1:
errors.append(
"p:transition must contain at most one p:sndAc; "
f"found {len(sound_actions)}"
)
if sound_actions:
start_sounds = [
child
for child in list(sound_actions[0])
if child.tag == _qn(PML_NS, "stSnd")
]
if len(start_sounds) > 1:
errors.append(
"p:sndAc must contain at most one p:stSnd; "
f"found {len(start_sounds)}"
)
if start_sounds:
sounds = [
child
for child in list(start_sounds[0])
if child.tag == _qn(PML_NS, "snd")
]
if len(sounds) != 1:
errors.append(
"p:stSnd must contain exactly one p:snd; "
f"found {len(sounds)}"
)
if carrier.tag != _qn(MC_NS, "AlternateContent"):
continue
choices = [
@@ -373,7 +373,7 @@
"files": [
"skills/ppt-master/scripts/docs/visualization-recall.md"
],
"max_tokens": 105000
"max_tokens": 120000
},
"route.generate.planning-image": {
"description": "Generate-PPTX planning context after a non-none image source is proposed or confirmed, including the compact layout math and vocabulary.",
@@ -825,7 +825,7 @@
"files": [
"skills/ppt-master/references/video-design.md"
],
"max_tokens": 2500
"max_tokens": 3250
},
"stage.generate.refine-spec": {
"description": "Explicit post-confirmation spec refinement runbook.",
@@ -896,7 +896,7 @@
"files": [
"skills/ppt-master/references/animations.md"
],
"max_tokens": 9000
"max_tokens": 11000
},
"stage.shared.generate-audio": {
"description": "Shared narration-audio stage.",
@@ -904,7 +904,7 @@
"files": [
"skills/ppt-master/workflows/stages/generate-audio.md"
],
"max_tokens": 7000
"max_tokens": 8000
},
"governance.failure-recovery": {
"description": "Global stop, retry, and resume policy.",
@@ -1181,33 +1181,6 @@
],
"reason": "Corporate brand presets share the same consistent icon convention by design."
},
{
"kind": "exact",
"fingerprint": "da38ec6c641e",
"paths": [
"skills/ppt-master/SPONSORS.md",
"skills/ppt-master/SPONSORS_CN.md"
],
"reason": "English and Chinese sponsor pages intentionally reuse the same sponsor badge markup."
},
{
"kind": "exact",
"fingerprint": "fcc902f6644a",
"paths": [
"skills/ppt-master/SPONSORS.md",
"skills/ppt-master/SPONSORS_CN.md"
],
"reason": "English and Chinese sponsor pages intentionally reuse the same sponsor badge markup."
},
{
"kind": "exact",
"fingerprint": "092a8a1f88e9",
"paths": [
"skills/ppt-master/SPONSORS.md",
"skills/ppt-master/SPONSORS_CN.md"
],
"reason": "English and Chinese sponsor pages intentionally reuse the same sponsor badge markup."
},
{
"kind": "exact",
"fingerprint": "6a432b0cc1df",
@@ -1217,15 +1190,6 @@
],
"reason": "Sibling Deck and Layout docs keep the shared replication-mode explanation in lockstep."
},
{
"kind": "exact",
"fingerprint": "aa6e1929dd49",
"paths": [
"skills/ppt-master/SPONSORS.md",
"skills/ppt-master/SPONSORS_CN.md"
],
"reason": "English and Chinese sponsor pages intentionally reuse the same sponsor badge markup."
},
{
"kind": "exact",
"fingerprint": "286b55cb84d7",
@@ -2085,6 +2049,14 @@
"glob": "skills/ppt-master/templates/icons/THIRD_PARTY_NOTICES.md",
"reason": "Third-party icon license and provenance notice; distributed for compliance, never loaded as prompt context."
},
{
"glob": "skills/ppt-master/templates/sounds/README.md",
"reason": "Bundled sound-library usage and metadata reference; not loaded as runtime prompt context."
},
{
"glob": "skills/ppt-master/templates/sounds/THIRD_PARTY_NOTICES.md",
"reason": "Third-party sound license and provenance notice; distributed for compliance, never loaded as prompt context."
},
{
"glob": "skills/ppt-master/references/shared-standards.md",
"reason": "Compatibility router only; runtime load sets select the core and triggered modules directly."
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
PPT Master - Sound Sync
List the bundled CC0 sound catalog or copy explicitly selected sounds into a
project-local `sounds/` directory. The global library is never copied in bulk.
Usage:
python3 scripts/sound_sync.py list [--query TERM]
python3 scripts/sound_sync.py <project_path> <sound_id> [<sound_id> ...]
Examples:
python3 scripts/sound_sync.py list --query whoosh
python3 scripts/sound_sync.py projects/deck bigsoundbank/1797 kenney-interface/click_001
Dependencies:
None (standard library only).
See templates/sounds/README.md.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import sys
from pathlib import Path
from typing import Optional
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
_GLOBAL_SOUNDS_DIR = Path(__file__).resolve().parent.parent / "templates" / "sounds"
_INDEX_NAME = "sounds_index.json"
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
def _load_sounds(global_dir: Path) -> list[dict[str, object]]:
index_path = global_dir / _INDEX_NAME
try:
payload = json.loads(index_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise RuntimeError(f"sound index not found: {index_path}") from exc
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"cannot read sound index {index_path}: {exc}") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"sound index root is not an object: {index_path}")
if payload.get("version") != 1:
raise RuntimeError(f"unsupported sound index version in {index_path}")
sounds = payload.get("sounds")
if not isinstance(sounds, list):
raise RuntimeError(f"sound index has no sounds list: {index_path}")
if payload.get("asset_count") != len(sounds):
raise RuntimeError(f"sound index count does not match its sounds list: {index_path}")
seen_ids: set[str] = set()
for item in sounds:
if not isinstance(item, dict):
raise RuntimeError(f"sound index entry is not an object: {index_path}")
sound_id = item.get("id")
relative_file = item.get("file")
sha256 = item.get("sha256")
if not all(isinstance(value, str) and value for value in (sound_id, relative_file, sha256)):
raise RuntimeError(f"sound index entry lacks id/file/sha256: {index_path}")
if sound_id in seen_ids:
raise RuntimeError(f"duplicate sound id in index: {sound_id}")
seen_ids.add(sound_id)
relative_path = Path(relative_file)
if relative_path.is_absolute() or ".." in relative_path.parts:
raise RuntimeError(f"unsafe sound path in index: {relative_file}")
if relative_path.suffix.lower() != ".wav":
raise RuntimeError(f"sound index entry is not WAV: {relative_file}")
if relative_path.with_suffix("").as_posix() != sound_id:
raise RuntimeError(f"sound id/file mismatch in index: {sound_id} != {relative_file}")
return sounds
def list_sounds(query: str = "", global_dir: Path = _GLOBAL_SOUNDS_DIR) -> list[dict[str, object]]:
"""Return sounds whose metadata contains every case-insensitive query term."""
sounds = _load_sounds(global_dir)
terms = [term for term in query.casefold().split() if term]
if not terms:
return sounds
matches: list[dict[str, object]] = []
for item in sounds:
searchable = [
str(item.get("id", "")),
str(item.get("label", "")),
str(item.get("source", "")),
*(str(tag) for tag in item.get("tags", [])),
*(str(context) for context in item.get("contexts", [])),
]
if item.get("recommended") is True:
searchable.append("recommended")
haystack = " ".join(searchable).casefold()
if all(term in haystack for term in terms):
matches.append(item)
return matches
def sync_sounds(
project_path: Path,
sound_ids: list[str],
global_dir: Path = _GLOBAL_SOUNDS_DIR,
) -> tuple[list[str], list[str]]:
"""Copy selected sound IDs into `<project>/sounds/` after validating the full batch.
Returns `(copied_or_present, missing)`. Any missing ID is reported before
creating the project-local directory or copying a file.
"""
try:
project_root = project_path.resolve(strict=True)
except OSError as exc:
raise RuntimeError(f"cannot resolve project directory {project_path}: {exc}") from exc
if not project_root.is_dir():
raise RuntimeError(f"project is not a directory: {project_path}")
sounds = _load_sounds(global_dir)
catalog = {str(item["id"]): item for item in sounds}
requested = list(dict.fromkeys(sound_ids))
missing = [sound_id for sound_id in requested if sound_id not in catalog]
if missing:
return [], missing
validated: list[tuple[str, Path, Path, bool]] = []
for sound_id in requested:
item = catalog[sound_id]
relative_path = Path(str(item["file"]))
source = global_dir / relative_path
expected_sha256 = str(item["sha256"])
if not source.is_file():
raise RuntimeError(f"library file not found for {sound_id}: {source}")
actual_sha256 = _sha256(source)
if actual_sha256 != expected_sha256:
raise RuntimeError(
f"library checksum mismatch for {sound_id}: "
f"expected {expected_sha256}, got {actual_sha256}"
)
destination = project_root / "sounds" / relative_path
resolved_parent = destination.parent.resolve(strict=False)
resolved_destination = destination.resolve(strict=False)
if not _is_within(resolved_parent, project_root):
raise RuntimeError(
f"destination parent escapes project root for {sound_id}: {resolved_parent}"
)
if not _is_within(resolved_destination, project_root):
raise RuntimeError(
f"destination escapes project root for {sound_id}: {resolved_destination}"
)
if destination.is_symlink():
raise RuntimeError(f"destination symlink is not allowed for {sound_id}: {destination}")
already_present = False
if destination.exists():
if not destination.is_file():
raise RuntimeError(f"destination is not a regular file for {sound_id}: {destination}")
destination_sha256 = _sha256(destination)
if destination_sha256 != expected_sha256:
raise RuntimeError(
f"project sound conflicts with library id {sound_id}: {destination}; "
"remove or rename the existing file, then rerun"
)
already_present = True
validated.append((sound_id, source, destination, already_present))
copied: list[str] = []
for sound_id, source, destination, already_present in validated:
if already_present:
copied.append(f"{sound_id} (already in project)")
continue
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
copied.append(sound_id)
return copied, []
def _build_list_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="sound_sync.py list",
description="List bundled sounds, optionally filtered by metadata.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--query",
default="",
help="Case-insensitive terms matched against ID, label, source, tags, and contexts",
)
return parser
def _build_sync_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Copy explicitly selected library sounds into a project's sounds/ folder. "
"Use `sound_sync.py list [--query TERM]` for discovery."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_path", help="Existing project directory")
parser.add_argument("sound_ids", nargs="+", help="Sound IDs such as bigsoundbank/1797")
return parser
def _run_list(argv: list[str]) -> int:
args = _build_list_parser().parse_args(argv)
try:
sounds = list_sounds(args.query)
except RuntimeError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
for item in sounds:
marker = "*" if item.get("recommended") is True else " "
sound_id = str(item["id"])
duration = float(item.get("duration_seconds", 0.0))
label = str(item.get("label", ""))
contexts = ",".join(str(value) for value in item.get("contexts", []))
print(f"{marker} {sound_id:<42} {duration:>7.3f}s {label} [{contexts}]")
print(f"[OK] {len(sounds)} sound(s) matched", file=sys.stderr)
return 0
def _run_sync(argv: list[str]) -> int:
args = _build_sync_parser().parse_args(argv)
project = Path(args.project_path)
if not project.is_dir():
print(f"[ERROR] project not found: {project}", file=sys.stderr)
return 1
try:
copied, missing = sync_sounds(project, args.sound_ids)
except RuntimeError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
if missing:
print(
f"[MISSING] {len(missing)} sound ID(s) not in the library; nothing was copied:",
file=sys.stderr,
)
for sound_id in missing:
print(f" x {sound_id}", file=sys.stderr)
print(
"Run `python3 skills/ppt-master/scripts/sound_sync.py list --query <term>` "
"and select a listed ID.",
file=sys.stderr,
)
return 1
print(f"[OK] {len(copied)} sound(s) in {project / 'sounds'}:", file=sys.stderr)
for sound_id in copied:
print(f" + {sound_id}", file=sys.stderr)
return 0
def main(argv: Optional[list[str]] = None) -> int:
raw_args = list(sys.argv[1:] if argv is None else argv)
if raw_args and raw_args[0] == "list":
return _run_list(raw_args[1:])
return _run_sync(raw_args)
if __name__ == "__main__":
raise SystemExit(main())
@@ -6,7 +6,7 @@ import json
import math
import re
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import Any
from xml.etree import ElementTree as ET
@@ -541,7 +541,13 @@ def _transition_scope_errors(
errors = _unknown_field_errors(
transition,
frozenset({'effect', 'effect_options', 'duration', 'auto_advance'}),
frozenset({
'effect',
'effect_options',
'duration',
'auto_advance',
'sound',
}),
f'{label} transition',
)
effect = transition.get('effect', inherited_effect)
@@ -576,6 +582,24 @@ def _transition_scope_errors(
)
except ValueError as exc:
errors.append(str(exc))
if 'sound' in transition:
sound = transition['sound']
if sound is None:
return errors
if not isinstance(sound, str) or not sound.strip():
errors.append(
f'animations.json {label} transition sound must be a '
'non-empty project-relative .wav path or null'
)
elif Path(sound).is_absolute() or PureWindowsPath(sound).drive:
errors.append(
f'animations.json {label} transition sound must be '
f'project-relative: {sound!r}'
)
elif Path(sound).suffix.lower() != '.wav':
errors.append(
f'animations.json {label} transition sound must use .wav'
)
return errors
@@ -1252,6 +1276,77 @@ def _animation_sound_path_errors(
return errors
def _declared_transition_sounds(
config: dict[str, Any],
) -> tuple[tuple[str, object], ...]:
"""Return explicitly declared non-null transition sound values."""
sounds: list[tuple[str, object]] = []
defaults = config.get('defaults', {})
if isinstance(defaults, dict):
transition = defaults.get('transition', {})
if (
isinstance(transition, dict)
and transition.get('sound') is not None
):
sounds.append(('defaults transition', transition['sound']))
slides = config.get('slides', {})
if not isinstance(slides, dict):
return tuple(sounds)
for slide_name, slide_cfg in slides.items():
if not isinstance(slide_cfg, dict):
continue
transition = slide_cfg.get('transition', {})
if (
isinstance(transition, dict)
and transition.get('sound') is not None
):
sounds.append(
(f'slide "{slide_name}" transition', transition['sound'])
)
return tuple(sounds)
def _transition_sound_path_errors(
project_path: Path,
config: dict[str, Any],
) -> list[str]:
"""Validate transition sounds as project-contained WAV files."""
errors: list[str] = []
project_root = project_path.resolve()
for label, raw_sound in _declared_transition_sounds(config):
if not isinstance(raw_sound, str) or not raw_sound.strip():
continue
sound_path = Path(raw_sound)
if sound_path.is_absolute() or PureWindowsPath(raw_sound).drive:
errors.append(
f'animations.json {label} sound must be project-relative: '
f'{raw_sound!r}'
)
continue
if sound_path.suffix.lower() != '.wav':
continue
resolved_path = (project_root / sound_path).resolve()
try:
resolved_path.relative_to(project_root)
except ValueError:
errors.append(
f'animations.json {label} sound escapes the project root: '
f'{raw_sound!r}'
)
continue
if not resolved_path.exists():
errors.append(
f'animations.json {label} sound file not found: {resolved_path}'
)
elif not resolved_path.is_file():
errors.append(
f'animations.json {label} sound path is not a regular file: '
f'{resolved_path}'
)
return errors
def validate_animation_config(
project_path: Path,
config: dict[str, Any] | None = None,
@@ -1273,6 +1368,7 @@ def validate_animation_config(
return []
warnings = _animation_sound_path_errors(project_path, config)
warnings.extend(_transition_sound_path_errors(project_path, config))
targets_by_slide, anonymous_groups = scan_project_targets(
project_path,
svg_files=svg_files,
@@ -1431,7 +1527,11 @@ def build_scaffold(project_path: Path) -> dict[str, Any]:
to remind the editor that deck-wide overrides exist and most pages should
inherit them.
"""
transition_defaults = {'effect': 'fade', 'duration': 0.4}
transition_defaults = {
'effect': 'fade',
'duration': 0.4,
'sound': None,
}
animation_defaults = {
'effect': 'none',
'duration': 0.4,
@@ -1448,7 +1548,10 @@ def build_scaffold(project_path: Path) -> dict[str, Any]:
continue
groups[target.group_id] = {}
slides[slide_name] = {
'transition': dict(transition_defaults),
'transition': {
'effect': transition_defaults['effect'],
'duration': transition_defaults['duration'],
},
'animation': dict(animation_defaults),
'groups': groups,
}
@@ -19,7 +19,7 @@ import zipfile
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import Any
from xml.etree import ElementTree as ET
from xml.sax.saxutils import escape
@@ -4002,13 +4002,15 @@ def _slide_config(animation_config: dict[str, Any] | None, svg_stem: str) -> dic
def _slide_transition_settings(
default_transition_cfg: dict[str, Any],
slide_cfg: dict[str, Any],
transition: str | None,
transition_effect_options: dict[str, object] | None,
duration: float,
auto_advance: float | None,
transition_sound: str | None,
cli_overrides: dict[str, bool],
) -> tuple[str | None, dict[str, object], float, float | None]:
) -> tuple[str | None, dict[str, object], float, float | None, str | None]:
trans_value = slide_cfg.get('transition', {})
if not isinstance(trans_value, dict):
raise ValueError('animations.json slide transition must be an object')
@@ -4043,7 +4045,19 @@ def _slide_transition_settings(
"transition auto_advance",
allow_zero=True,
)
return effect, effect_options, duration, auto_advance
raw_sound = transition_sound
if raw_sound is None and not cli_overrides.get('transition_sound'):
raw_sound = default_transition_cfg.get('sound')
if 'sound' in trans_cfg:
raw_sound = trans_cfg['sound']
if raw_sound is not None and (
not isinstance(raw_sound, str) or not raw_sound.strip()
):
raise ValueError(
'animations.json transition sound must be a non-empty '
'project-relative .wav path or null'
)
return effect, effect_options, duration, auto_advance, raw_sound
def _slide_animation_settings(
@@ -4366,63 +4380,134 @@ def _next_relationship_id(rel_entries: list[dict[str, str]]) -> str:
return f'rId{candidate}'
def _materialize_slide_sound(
project_path: Path,
raw_sound: str,
media_files: dict[str, bytes],
rel_entries: list[dict[str, str]],
audio_exts_used: set[str],
packaged_by_source: dict[Path, tuple[str, str]],
*,
label: str,
media_prefix: str,
require_project_relative_wav: bool,
) -> dict[str, str]:
"""Package one slide sound and return its relationship descriptor."""
if not isinstance(raw_sound, str) or not raw_sound.strip():
raise ValueError(f'{label} sound must be a non-empty path string')
sound_path = Path(raw_sound)
if require_project_relative_wav:
if sound_path.is_absolute() or PureWindowsPath(raw_sound).drive:
raise ValueError(f'{label} sound must be project-relative: {raw_sound!r}')
extension = sound_path.suffix.lower()
if extension != '.wav':
raise ValueError(f'{label} sound must use .wav')
project_root = project_path.resolve()
sound_path = (project_root / sound_path).resolve()
try:
sound_path.relative_to(project_root)
except ValueError as exc:
raise ValueError(
f'{label} sound escapes the project root: {raw_sound!r}'
) from exc
else:
if not sound_path.is_absolute():
sound_path = project_path / sound_path
sound_path = sound_path.resolve()
extension = sound_path.suffix.lower()
if not sound_path.is_file():
raise ValueError(f'{label} sound file not found: {sound_path}')
if extension not in AUDIO_CONTENT_TYPES:
valid = ', '.join(sorted(AUDIO_CONTENT_TYPES))
raise ValueError(
f'unsupported {label} sound format {extension or "(none)"}; '
f'valid formats: {valid}'
)
packaged = packaged_by_source.get(sound_path)
if packaged is None:
payload = sound_path.read_bytes()
if require_project_relative_wav and not (
len(payload) >= 12
and payload[:4] in {b'RIFF', b'RF64'}
and payload[8:12] == b'WAVE'
):
raise ValueError(f'{label} sound is not a valid WAV file: {sound_path}')
digest = hashlib.sha256(payload).hexdigest()[:16]
media_name = f'{media_prefix}_{digest}{extension}'
relationship_id = _next_relationship_id(rel_entries)
media_files.setdefault(media_name, payload)
rel_entries.append(
{
'id': relationship_id,
'type': AUDIO_REL_TYPE,
'target': f'../media/{media_name}',
}
)
packaged = (relationship_id, media_name)
packaged_by_source[sound_path] = packaged
audio_exts_used.add(extension)
relationship_id, _media_name = packaged
return {
'relationship_id': relationship_id,
'name': sound_path.name,
}
def _materialize_transition_sound(
project_path: Path,
raw_sound: str | None,
media_files: dict[str, bytes],
rel_entries: list[dict[str, str]],
audio_exts_used: set[str],
packaged_by_source: dict[Path, tuple[str, str]],
) -> dict[str, str] | None:
"""Package one optional project-local WAV for a slide transition."""
if raw_sound is None:
return None
return _materialize_slide_sound(
project_path,
raw_sound,
media_files,
rel_entries,
audio_exts_used,
packaged_by_source,
label='transition',
media_prefix='transition_sound',
require_project_relative_wav=True,
)
def _materialize_animation_sounds(
project_path: Path,
targets: list[dict[str, Any]],
media_files: dict[str, bytes],
rel_entries: list[dict[str, str]],
audio_exts_used: set[str],
packaged_by_source: dict[Path, tuple[str, str]] | None = None,
) -> list[dict[str, Any]]:
"""Package sidecar sound files and replace paths with OOXML relationships."""
materialized: list[dict[str, Any]] = []
packaged_by_source: dict[Path, tuple[str, str]] = {}
packaged_by_source = packaged_by_source if packaged_by_source is not None else {}
for index, raw_target in enumerate(targets, 1):
target = dict(raw_target)
raw_sound = target.pop('_sound_path', None)
if raw_sound is None:
materialized.append(target)
continue
if not isinstance(raw_sound, str) or not raw_sound.strip():
raise ValueError(
f'animation target {index} sound must be a non-empty path string'
)
sound_path = Path(raw_sound)
if not sound_path.is_absolute():
sound_path = project_path / sound_path
sound_path = sound_path.resolve()
if not sound_path.is_file():
raise ValueError(f'animation sound file not found: {sound_path}')
extension = sound_path.suffix.lower()
if extension not in AUDIO_CONTENT_TYPES:
valid = ', '.join(sorted(AUDIO_CONTENT_TYPES))
raise ValueError(
f'unsupported animation sound format {extension or "(none)"}; '
f'valid formats: {valid}'
)
packaged = packaged_by_source.get(sound_path)
if packaged is None:
payload = sound_path.read_bytes()
digest = hashlib.sha256(payload).hexdigest()[:16]
media_name = f'animation_sound_{digest}{extension}'
relationship_id = _next_relationship_id(rel_entries)
media_files.setdefault(media_name, payload)
rel_entries.append(
{
'id': relationship_id,
'type': AUDIO_REL_TYPE,
'target': f'../media/{media_name}',
}
)
packaged = (relationship_id, media_name)
packaged_by_source[sound_path] = packaged
audio_exts_used.add(extension)
relationship_id, _media_name = packaged
target['sound'] = {
'relationship_id': relationship_id,
'name': sound_path.name,
}
target['sound'] = _materialize_slide_sound(
project_path,
raw_sound,
media_files,
rel_entries,
audio_exts_used,
packaged_by_source,
label=f'animation target {index}',
media_prefix='animation_sound',
require_project_relative_wav=False,
)
materialized.append(target)
return materialized
@@ -4683,6 +4768,7 @@ def create_pptx_with_native_svg(
expected_viewbox: str | None = None,
animation_resource_root: Path | None = None,
transition_effect_options: dict[str, object] | None = None,
transition_sound: str | None = None,
text_flow: str | None = None,
primary_language: str | None = None,
narration_start_floor: float = DEFAULT_NARRATION_START_FLOOR,
@@ -4698,12 +4784,15 @@ def create_pptx_with_native_svg(
canvas_format: Canvas format key.
expected_viewbox: Optional project/template-lock canvas contract. Every
public page and internal Layout definition must match it.
animation_resource_root: Base directory for relative animation sound
paths. Defaults to the parent of the SVG source directory.
animation_resource_root: Project root for sidecar sound paths. Object
animation sounds retain existing absolute-path compatibility;
transition sounds must remain project-relative WAV files.
verbose: Whether to output detailed information.
transition: Transition effect name.
transition_effect_options: PowerPoint Effect Options for the selected
native page transition.
transition_sound: Optional project-relative WAV path used by the
generated page transition.
transition_duration: Transition duration in seconds.
auto_advance: Auto-advance interval in seconds.
use_compat_mode: Retained for API compatibility; ignored in native mode.
@@ -5085,9 +5174,14 @@ def create_pptx_with_native_svg(
audio_exts_used: set[str] = set()
package_uses_timings = False
mixed_animation_offset = 0
animation_defaults_value = _as_dict(
_as_dict(animation_config).get('defaults')
).get('animation', {})
config_defaults = _as_dict(_as_dict(animation_config).get('defaults'))
transition_defaults_value = config_defaults.get('transition', {})
if not isinstance(transition_defaults_value, dict):
raise ValueError(
'animations.json defaults transition must be an object'
)
default_transition_cfg = transition_defaults_value
animation_defaults_value = config_defaults.get('animation', {})
if not isinstance(animation_defaults_value, dict):
raise ValueError(
'animations.json defaults animation must be an object'
@@ -5123,6 +5217,7 @@ def create_pptx_with_native_svg(
expected_animation_targets: list[dict[str, Any]] = []
expected_animation_duration = animation_duration
expected_animation_trigger = normalize_animation_trigger(animation_trigger)
expected_transition_sound: dict[str, str] | None = None
try:
# ---- Native shapes mode ----
@@ -5137,6 +5232,7 @@ def create_pptx_with_native_svg(
slide_transition_effect_options = {}
slide_transition_duration = transition_duration
slide_auto_advance = None
slide_transition_sound_path = None
slide_animation = None
slide_animation_duration = animation_duration
slide_animation_stagger = animation_stagger
@@ -5148,12 +5244,15 @@ def create_pptx_with_native_svg(
slide_transition_effect_options,
slide_transition_duration,
slide_auto_advance,
slide_transition_sound_path,
) = _slide_transition_settings(
default_transition_cfg,
slide_cfg,
transition,
transition_effect_options,
transition_duration,
auto_advance,
transition_sound,
animation_cli_overrides,
)
(
@@ -5295,12 +5394,30 @@ def create_pptx_with_native_svg(
# to precede <p:timing> inside <p:sld>. Both use the same
# </p:sld> string-replace anchor, so transition must be
# injected first and timing second.
if slide_transition is not None or slide_auto_advance is not None:
packaged_sounds_by_source: dict[Path, tuple[str, str]] = {}
expected_transition_sound = _materialize_transition_sound(
(
animation_resource_root
if animation_resource_root is not None
else svg_files[0].parent.parent
),
slide_transition_sound_path,
media_files_dict,
rel_entries,
audio_exts_used,
packaged_sounds_by_source,
)
if (
slide_transition is not None
or slide_auto_advance is not None
or expected_transition_sound is not None
):
transition_fragment = create_transition_xml(
effect=slide_transition,
duration=slide_transition_duration,
advance_after=slide_auto_advance,
effect_options=slide_transition_effect_options,
sound=expected_transition_sound,
)
if transition_fragment:
slide_xml = slide_xml.replace(
@@ -5338,6 +5455,7 @@ def create_pptx_with_native_svg(
media_files_dict,
rel_entries,
audio_exts_used,
packaged_sounds_by_source,
)
expected_animation_targets = seq_targets
if mixed_count:
@@ -5447,13 +5565,16 @@ def create_pptx_with_native_svg(
slide_transition_effect_options,
slide_transition_duration,
slide_auto_advance,
slide_transition_sound_path,
) = (
_slide_transition_settings(
default_transition_cfg,
slide_cfg,
transition,
transition_effect_options,
transition_duration,
auto_advance,
transition_sound,
animation_cli_overrides,
)
)
@@ -5640,6 +5761,7 @@ def create_pptx_with_native_svg(
duration=slide_transition_duration,
advance_on_click=resolved_advance_on_click,
advance_after=resolved_advance_after,
sound=expected_transition_sound,
)
except ValueError as exc:
raise RuntimeError(
@@ -875,9 +875,10 @@ Recorded narration:
- Keeps speaker notes when enabled
- Prepares PowerPoint recorded timings and narrations
- Requires one m4a/mp3/wav file per slide
- Uses narration_animations.json when animation sidecars exist
- Inherits source-bound deck motion from the base postflight report
- Use --animation-config animations.json for the canonical animation
- Unless --no-animations is set, recorded narration without an explicit
config selects narration_animations.json when either sidecar exists
- With no sidecar, --inherit-motion-from may restore base-report deck motion
- Use --animation-config animations.json for narration-independent custom motion
- Use --no-animations for narration and timings without animation motion
- Embeds per-slide audio matched by SVG filename / slide number
- Sets slide auto-advance from audio duration so video export can use
@@ -1064,9 +1065,11 @@ Recorded narration:
type=str,
default=None,
help=(
'Per-slide/per-object animation config. Recorded narration uses '
'<project>/narration_animations.json when an animation sidecar exists, '
'or may inherit base postflight motion with --inherit-motion-from. '
'Per-slide/per-object animation config. While motion remains enabled, '
'recorded narration without an explicit config selects '
'<project>/narration_animations.json when either animation sidecar '
'exists, or may inherit base postflight motion with '
'--inherit-motion-from when neither exists. '
'Other exports default to <project>/animations.json when present.'
),
)
@@ -1721,6 +1724,22 @@ Recorded narration:
"transition auto_advance",
allow_zero=True,
)
transition_sound = (
None
if args.no_animations
else (
inherited_transition['sound']
if 'sound' in inherited_transition
else transition_defaults.get('sound')
)
)
if transition_sound is not None and not isinstance(
transition_sound,
str,
):
raise ValueError(
'transition sound must be a project-relative .wav path or null'
)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
@@ -1813,6 +1832,7 @@ Recorded narration:
args.auto_advance is not None
or inherited_overrides.get('auto_advance') is True
),
'transition_sound': 'sound' in inherited_transition,
'animation': (
args.animation is not None
or inherited_overrides.get('animation') is True
@@ -1837,6 +1857,7 @@ Recorded narration:
'effect_options': transition_effect_options,
'duration': transition_duration,
'auto_advance': auto_advance,
'sound': transition_sound,
},
'animation': {
'effect': normalized_animation or 'none',
@@ -1900,6 +1921,7 @@ Recorded narration:
verbose=verbose,
transition=transition,
transition_effect_options=transition_effect_options,
transition_sound=transition_sound,
transition_duration=transition_duration,
auto_advance=auto_advance,
notes=notes,
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,10 @@
"""
PPT Master - Final Video Subtitles
Align the exact narration text frozen in page-local narration SRT files against the
audio track of a finished PowerPoint-exported video. This produces a delivery
SRT from the actual video timeline without rewriting speaker notes or relying
on theoretical slide offsets.
Align the exact narration text frozen in page-local narration SRT files against
the audio track of a finished PowerPoint-exported or slideshow-captured video.
This produces a delivery SRT from the actual video timeline without rewriting
speaker notes or relying on theoretical slide offsets.
Usage:
python3 scripts/video_subtitles.py <project_path> --video <video> --language <language>
@@ -311,7 +311,10 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--video",
required=True,
help="Finished PowerPoint-exported video; relative paths are project-relative",
help=(
"Finished PowerPoint-exported or slideshow-captured video; "
"relative paths are project-relative"
),
)
parser.add_argument(
"--language",
@@ -148,3 +148,8 @@ The `icons/` directory contains 12,027 vector icons across five libraries:
- **Usage & style rules**: [icons/README.md](./icons/README.md)
- **Versions, licenses & attribution**: [icons/THIRD_PARTY_NOTICES.md](./icons/THIRD_PARTY_NOTICES.md)
- **Search icons**: `rg --files skills/ppt-master/templates/icons/<library>/ | rg <keyword>`
## Sound Library
[`sounds/`](./sounds/) is post-motion discovery, not a template or
Strategist resource. Sync selected cues only; see [usage](./sounds/README.md).
@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.
@@ -0,0 +1,61 @@
# Sound Effect Library
This directory is PPT Master's global sound-effect library. It contains 186
CC0 sound effects prepared as PowerPoint-compatible WAV files:
| Namespace | Source | Files | Primary use |
|---|---|---:|---|
| `kenney-interface` | Kenney Interface Sounds | 100 | Interface and object-animation cues |
| `kenney-ui` | Kenney UI Audio | 51 | Click, rollover, and state-change cues |
| `bigsoundbank` | BigSoundBank selection | 35 | Whoosh, notification, chime, and pencil cues |
Source snapshots, license declarations, and modifications are recorded in
[THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md). Machine-readable metadata
for every sound lives in [sounds_index.json](./sounds_index.json).
## Project-local selection
The files here are a discovery library, not project inputs. Project creation
does not create a `sounds/` directory or copy the library. Copy only the sounds
explicitly selected for a deck:
```bash
python3 skills/ppt-master/scripts/sound_sync.py list --query whoosh
python3 skills/ppt-master/scripts/sound_sync.py projects/deck bigsoundbank/1797 kenney-interface/click_001
```
The second command copies only those files to:
```text
projects/deck/sounds/bigsoundbank/1797.wav
projects/deck/sounds/kenney-interface/click_001.wav
```
Animation and transition configuration must reference these project-relative
paths. Do not reference this global directory from a project. Unknown IDs are
reported and cause a non-zero exit before any file is copied. The same batch
preflight rejects a destination that escapes the resolved project root through
a symlink, as well as an existing project file whose content differs from the
selected library ID; the tool never silently overwrites that conflict.
## Discovery
List the full catalog or filter it by stable ID, label, tag, or context:
```bash
python3 skills/ppt-master/scripts/sound_sync.py list
python3 skills/ppt-master/scripts/sound_sync.py list --query transition
python3 skills/ppt-master/scripts/sound_sync.py list --query recommended
```
The `recommended` flag is a conservative discovery shortlist for presentation
use; it does not add sounds automatically. Sound remains opt-in, and the full
catalog stays available when another cue better fits the design.
## Format boundary
All bundled files are PCM signed 16-bit little-endian, 44.1 kHz WAV. Original
mono or stereo channel layout is preserved. The files were not trimmed and
received no loudness processing. This normalization keeps one predictable
package format while preserving each source recording's complete duration and
channel presentation.
@@ -0,0 +1,71 @@
# Third-Party Sound Notices
The WAV files under this directory are modified copies of third-party CC0
sound assets. PPT Master's MIT license covers PPT Master itself; the source
declarations below describe the bundled sounds.
## Bundled snapshot
Snapshot date: **2026-08-10**.
| Directory | Upstream snapshot | Bundled files | Local modification |
|---|---|---:|---|
| `kenney-interface` | [Kenney Interface Sounds](https://kenney.nl/assets/interface-sounds), archive supplied as version 1.0 | 100 | Ogg Vorbis transcoded to PCM 16-bit 44.1 kHz WAV while preserving mono/stereo layout |
| `kenney-ui` | [Kenney UI Audio](https://kenney.nl/assets/ui-audio), versionless current archive | 51 | Ogg Vorbis transcoded to PCM 16-bit 44.1 kHz WAV while preserving stereo layout; `Preview.ogg` excluded |
| `bigsoundbank` | [BigSoundBank](https://bigsoundbank.com/) sound IDs listed below | 35 | Source WAV resampled and, where needed, reduced to PCM 16-bit at 44.1 kHz while preserving mono/stereo layout |
No sound was trimmed, loudness-processed, or unnecessarily downmixed.
Final-file metadata and SHA-256 digests are recorded in `sounds_index.json`.
The Kenney archive URLs and archive digests are also recorded there.
The normalization pass is equivalent to:
```bash
ffmpeg -i <input> -map_metadata -1 -vn -c:a pcm_s16le -ar 44100 <output.wav>
```
No explicit channel-count option is used, so source mono/stereo layout remains
intact.
The UI Audio asset page reports 50 files, while the current downloaded archive
contains 51 usable files under `Audio/`. This snapshot includes all 51 and does
not count the separate preview track.
## Kenney
The source license files for both Kenney packs declare
[Creative Commons Zero (CC0 1.0)](https://creativecommons.org/publicdomain/zero/1.0/).
They permit personal, educational, and commercial use, and state that credit
to Kenney is appreciated but not mandatory.
- Interface Sounds: created and distributed by Kenney, version 1.0, source
creation date 2020-02-11.
- UI Audio: created by Kenney Vleugels / Kenney.nl.
## BigSoundBank
BigSoundBank identifies the selected files as
“CC0 (public domain): Free and royalty-free” and links them to its
[license page](https://bigsoundbank.com/licenses.html). The individual source
pages identify Joseph Sardin as the author.
The snapshot contains exactly these sound IDs:
- Whoosh: `0572`, `0573`, `1795``1802`
- Notification: `2059``2067`
- Chime: `2079``2091`
- Pencil signature: `3236``3238`
Each entry in `sounds_index.json` records its individual BigSoundBank source
page. The library keeps the full recordings, including the two long whooshes
and longer chimes; the `recommended` flag keeps those outside the conservative
default discovery shortlist without removing them.
## CC0 terms
CC0 1.0 lets the rights holder waive copyright and related rights to the
greatest extent allowed by law. It provides the work as-is and does not grant
patent, trademark, privacy, or publicity rights. See the bundled
[CC0 1.0 legal code](./CC0-1.0.txt), sourced from Creative Commons, or the
[official copy](https://creativecommons.org/publicdomain/zero/1.0/legalcode)
for the complete terms.

Some files were not shown because too many files have changed in this diff Show More