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:
@@ -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.
|
||||
|
||||
+5
-5
@@ -323,9 +323,9 @@ Prefer per-component boundaries inside the page (patterns #1–#5) over one big
|
||||
|
||||
Patterns 1–9 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 1–9) 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 1–9) 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).
|
||||
|
||||
+11
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" height="40" /></a>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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) §2–3 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.
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+26
-7
@@ -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 = [
|
||||
|
||||
+12
-40
@@ -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())
|
||||
+107
-4
@@ -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,
|
||||
}
|
||||
|
||||
+173
-51
@@ -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(
|
||||
|
||||
+28
-6
@@ -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.
|
||||
+71
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user