diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 276fabcd..124327f7 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -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" } ] } diff --git a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json index ae8c3bde..d8280abd 100644 --- a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json @@ -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" } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index c22a3fd1..f373bf2e 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-08-10T16:02:03Z" + "syncedAt": "2026-08-11T16:02:06Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index b0a21531..b066ff31 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "1722e45c3957d1e6ce2aaff482a9b9ece28864a1", + "commit": "bbec4fe2bd7222aaa9b4de58269d821ab128fab8", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-08-10T15:59:59Z" + "syncedAt": "2026-08-11T16:00:01Z" } diff --git a/plugins/codex/plugins/next-skills/skills/next-cache-components-optimizer/SKILL.md b/plugins/codex/plugins/next-skills/skills/next-cache-components-optimizer/SKILL.md index a195dc27..415b3ba9 100644 --- a/plugins/codex/plugins/next-skills/skills/next-cache-components-optimizer/SKILL.md +++ b/plugins/codex/plugins/next-skills/skills/next-cache-components-optimizer/SKILL.md @@ -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 `` (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 ``, 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 `` 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. diff --git a/plugins/codex/plugins/next-skills/skills/next-cache-components-optimizer/reference/patterns.md b/plugins/codex/plugins/next-skills/skills/next-cache-components-optimizer/reference/patterns.md index 6891b647..9ec0cc04 100644 --- a/plugins/codex/plugins/next-skills/skills/next-cache-components-optimizer/reference/patterns.md +++ b/plugins/codex/plugins/next-skills/skills/next-cache-components-optimizer/reference/patterns.md @@ -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 `` 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). diff --git a/plugins/codex/plugins/next-skills/skills/next-partial-prefetching-adoption/SKILL.md b/plugins/codex/plugins/next-skills/skills/next-partial-prefetching-adoption/SKILL.md index d9f38018..0b0f784d 100644 --- a/plugins/codex/plugins/next-skills/skills/next-partial-prefetching-adoption/SKILL.md +++ b/plugins/codex/plugins/next-skills/skills/next-partial-prefetching-adoption/SKILL.md @@ -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 `` 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 `` 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 `` destination — cache the data, or move runtime-prefetch behavior to a docs-supported ``. +- **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 `` destination — cache the data, or move per-link-prefetch behavior to a docs-supported ``. - **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 [``](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 [``](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 diff --git a/plugins/codex/plugins/ppt-master/README.md b/plugins/codex/plugins/ppt-master/README.md index 0292425a..52c4ca74 100644 --- a/plugins/codex/plugins/ppt-master/README.md +++ b/plugins/codex/plugins/ppt-master/README.md @@ -13,7 +13,7 @@ English | [中文](./README_CN.md)
-This project is kept free and open source with the support of Kimi, PackyCode, APIKEY.FUN, RunAPI, YouYun ZhiSuan and other sponsors. +This project is kept free and open source with the support of Kimi, PackyCode, APIKEY.FUN, RunAPI, YouYun ZhiSuan and other sponsors.

Kimi @@ -35,8 +35,8 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring this 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 7% of official rates. Register through our dedicated link for an exclusive perk: up to 5% off on top-ups, permanently. - RunAPI - 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 10% of official rates, 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 our dedicated link to claim ¥7 in free credit. + RunAPI + 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 10% of official rates, 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 our dedicated link to claim ¥7 in free credit. YouYun ZhiSuan @@ -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   APIKEY.FUN   -RunAPI +RunAPI   YouYun ZhiSuan   diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index 3a56b3d8..fb2c387f 100644 --- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "ppt-master", "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", - "commit": "182c6b8229a44990cdc5b394545f90992be377d6", + "commit": "4e6ecbcb0dc079efebd3c79b775c0f02581509fe", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-08-10T15:59:59Z" + "syncedAt": "2026-08-11T16:00:01Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS.md index d8865e77..817227a6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS.md @@ -36,9 +36,9 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring PPT M ### RunAPI -RunAPI +RunAPI -[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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS_CN.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS_CN.md index ec735218..10f395cc 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS_CN.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS_CN.md @@ -36,9 +36,9 @@ PPT Master 始终免费开源。以下赞助方共同支持项目的持续维护 ### RunAPI -RunAPI +RunAPI -[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 免费额度**。 ### 优云智算 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md index c7f82d40..921d6648 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md @@ -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 `, 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 `/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//.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 `/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 + +# Materialize only the chosen ids +python3 skills/ppt-master/scripts/sound_sync.py \ + / [/ ...] +``` + +`sound_sync.py` is the only bundled-library materialization path. Stable ids +include their namespace; copied files remain under +`/sounds//`. 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 ` 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 `; 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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md index 9f8b1b0b..9fab0d03 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md @@ -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`. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-image.md index 7164eae1..bad57955 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-image.md @@ -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. | diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-web-image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-web-image.md index 49fdf497..aa10327e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-web-image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-web-image.md @@ -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 | |---|---| diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-base.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-base.md index aa5765cb..ae15b0f3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-base.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-base.md @@ -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 |---|---|---|---|---|---|---|---| | `` | `` | `` | `` | `adaptive` / `no-crop` | `ai` / `web` / `slice` | Pending | `` | -**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 ; treatment=; ...` 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/`, 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}` | `` 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. --- diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md index 0ba94dfc..488fc271 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md @@ -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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md index 59cb0e07..8e6edcfe 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md @@ -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 ` | source= | pattern= | 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 ` | source= | pattern= | 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 ; treatment=;`. 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`. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md index 5f0e9438..f0499ec9 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md @@ -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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-image-embedding.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-image-embedding.md index 8c0ce506..f702454c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-image-embedding.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-image-embedding.md @@ -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 `` | | **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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/video-design.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/video-design.md index 33b8a144..1edd89df 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/video-design.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/video-design.md @@ -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; diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md index 89946e1e..253ca2aa 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md @@ -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) | diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md index 9e3c6aaa..7162831d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md @@ -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 +`/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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md index 8a396400..82e33ad1 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md @@ -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 +`/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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md index 89c278a4..9756633e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md @@ -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/.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//.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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md index d12b9cdd..10ba0cf3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md @@ -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 `/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 `/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 `/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 `/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 ]` and copy only selected ids with `sound_sync.py / [...]`. `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 `/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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_treat.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_treat.py new file mode 100644 index 00000000..e0ba92f6 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_treat.py @@ -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 --output [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 /images (no path components).", + ) + parser.add_argument( + "--output", + required=True, + help="New bare .png filename under /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()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py index b32b246e..35bca053 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py @@ -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, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_transitions.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_transitions.py index d96da2b4..22e202eb 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_transitions.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_transitions.py @@ -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}\n" + f"{indent} \n" + f"{indent} \n" + f"{indent} \n" + f"{indent}\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" " + if normalized_sound is None: + return f" " + return ( + f" \n" + + _transition_sound_xml(normalized_sound, indent=" ") + + " " + ) 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' \n' f' \n' f" \n" f" <{prefix}:{element_name}{effect_attrs}/>\n" + f"{choice_sound_xml}" " \n" " \n" " \n" f" \n" f" <{fallback_prefix}:{fallback_name}{fallback_attrs}/>\n" + f"{fallback_sound_xml}" " \n" " \n" " " ) + sound_xml = _transition_sound_xml(normalized_sound, indent=" ") return ( f" \n" f" <{prefix}:{element_name}{effect_attrs}/>\n" + f"{sound_xml}" " " ) @@ -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 = [ diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json index 5b493fac..e20e55d4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json @@ -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." diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/sound_sync.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/sound_sync.py new file mode 100644 index 00000000..8aa7b0db --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/sound_sync.py @@ -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 [ ...] + +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 `/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 ` " + "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()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/animation_config.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/animation_config.py index 3adbb289..2b95c09e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/animation_config.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/animation_config.py @@ -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, } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py index 61dbd01a..e448b507 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py @@ -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 inside . Both use the same # 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( diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py index 0648875b..aaeb7b8b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py @@ -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 ' - '/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 ' + '/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 /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, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_sound_mix.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_sound_mix.py new file mode 100644 index 00000000..69c401bf --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_sound_mix.py @@ -0,0 +1,1624 @@ +#!/usr/bin/env python3 +""" +PPT Master - Video Sound Mix + +Recover transition and object-animation sound cues from the final narrated +PPTX, calibrate them to a PowerPoint-exported video, render an independent SFX +stem, and mix that stem with the video's narration track. +See workflows/stages/generate-audio.md for the owning delivery stage. + +Usage: + python3 scripts/video_sound_mix.py --pptx \ + --trace --video [options] + +Examples: + python3 scripts/video_sound_mix.py projects/demo \ + --pptx exports/demo_narrated.pptx \ + --trace validation/demo_narrated.trace.json \ + --video exports/demo_raw.mp4 --force + +Dependencies: + ffmpeg, ffprobe, and numpy +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile +from dataclasses import dataclass +from fractions import Fraction +from pathlib import Path +from typing import Any +from xml.etree import ElementTree as ET + +_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 +from narration_sync import ( # noqa: E402 + VideoTimelineCalibration, + calibrate_video_timeline, + presentation_slide_members, +) +from pptx_animations import read_slide_animation_sequence # noqa: E402 +from pptx_opc_validation import ( # noqa: E402 + canonical_opc_part_path, + resolve_internal_opc_target, +) +from pptx_transitions import ( # noqa: E402 + AUDIO_REL_TYPE, + read_slide_transition_xml, +) + +configure_utf8_stdio() + + +_PACKAGE_REL_NS = ( + "http://schemas.openxmlformats.org/package/2006/relationships" +) +_RELATIONSHIP_TAG = f"{{{_PACKAGE_REL_NS}}}Relationship" +_DEFAULT_TRANSITION_GAIN_DB = -9.1 +_DEFAULT_ANIMATION_GAIN_DB = -12.0 +_DEFAULT_LIMITER_DBFS = -1.0 +_OUTPUT_SAMPLE_RATE = 48_000 +_OUTPUT_AUDIO_BITRATE = "192k" +_START_TIME_TOLERANCE_MS = 1 +_VERIFICATION_SAMPLE_RATE = 8000 +_MIN_VERIFICATION_WINDOW_SAMPLES = 2 +_MIN_SOUND_MIX_CORRELATION = 0.20 + + +@dataclass(frozen=True) +class SoundCue: + """One resolved PPTX sound cue placed on the exported-video timeline.""" + + kind: str + slide_num: int + slide_name: str + row_index: int | None + shape_id: int | None + group_id: str | None + relationship_id: str + sound_name: str + package_part: str + media_sha256: str + media_path: Path + source_duration_ms: int + powerpoint_time_ms: int + video_time_ms: int + gain_db: float + + +@dataclass(frozen=True) +class SoundMixResult: + """Published files and cue count from one video sound mix.""" + + output_path: Path + stem_path: Path + report_path: Path + cue_count: int + verification_correlation: float + true_peak_dbfs: float + + +def _read_json_object(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"{label} is not valid JSON: {path}: {exc}") from exc + if not isinstance(value, dict): + raise ValueError(f"{label} must contain one JSON object: {path}") + return value + + +def _sha256_path(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _project_input_path(project_path: Path, value: str) -> Path: + path = Path(value).expanduser() + return (path if path.is_absolute() else project_path / path).resolve() + + +def _project_output_path( + project_path: Path, + value: str | None, + default: Path, +) -> Path: + path = Path(value).expanduser() if value else default + return (path if path.is_absolute() else project_path / path).resolve() + + +def _require_tool(name: str) -> str: + path = shutil.which(name) + if path is None: + raise RuntimeError( + f"Video sound mixing requires {name} on PATH. Install ffmpeg " + "and make both ffmpeg and ffprobe available." + ) + return path + + +def _run_command(command: list[str], label: str) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + capture_output=True, + check=False, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + details = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"{label} failed:\n{details}") + return result + + +def _probe_media(path: Path, ffprobe_path: str) -> dict[str, Any]: + result = _run_command( + [ + ffprobe_path, + "-v", + "error", + "-show_entries", + ( + "format=duration,start_time:" + "stream=index,codec_type,codec_name,time_base,start_pts," + "start_time,duration_ts,duration,sample_rate,channels," + "width,height,avg_frame_rate" + ), + "-of", + "json", + str(path), + ], + f"ffprobe inspection for {path}", + ) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"ffprobe returned invalid JSON for {path}") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"ffprobe returned an invalid payload for {path}") + return payload + + +def _duration_ms(probe: dict[str, Any], label: str) -> int: + format_value = probe.get("format") + raw_duration = ( + format_value.get("duration") + if isinstance(format_value, dict) + else None + ) + try: + duration = float(raw_duration) + except (TypeError, ValueError) as exc: + raise ValueError(f"Unable to read {label} duration with ffprobe") from exc + if not math.isfinite(duration) or duration <= 0: + raise ValueError(f"{label} duration must be positive and finite") + return int(round(duration * 1000)) + + +def _stream_duration_ms( + stream: dict[str, Any], + *, + fallback_ms: int, +) -> int: + try: + duration = float(stream.get("duration")) + except (TypeError, ValueError): + return fallback_ms + if not math.isfinite(duration) or duration <= 0: + return fallback_ms + return int(round(duration * 1000)) + + +def _stream_start_ms(stream: dict[str, Any]) -> int: + try: + start = float(stream.get("start_time", 0)) + except (TypeError, ValueError): + return 0 + if not math.isfinite(start): + return 0 + return int(round(start * 1000)) + + +def _frame_duration_ms(stream: dict[str, Any]) -> float: + value = stream.get("avg_frame_rate") + if not isinstance(value, str) or "/" not in value: + return 1000 / 30 + numerator_text, denominator_text = value.split("/", 1) + try: + numerator = float(numerator_text) + denominator = float(denominator_text) + except ValueError: + return 1000 / 30 + if numerator <= 0 or denominator <= 0: + return 1000 / 30 + return 1000 * denominator / numerator + + +def _stream_timing_signature(stream: dict[str, Any]) -> dict[str, object]: + """Return the packet-clock fields that must survive video stream copy.""" + signature: dict[str, object] = {} + for key in ("time_base", "start_pts", "duration_ts"): + value = stream.get(key) + if value is None: + raise ValueError(f"Video stream is missing {key}") + signature[key] = value + return signature + + +def _audio_sample_count(stream: dict[str, Any]) -> int: + """Resolve an audio stream duration to exact samples from its time base.""" + try: + sample_rate = int(stream.get("sample_rate")) + duration_ts = int(stream.get("duration_ts")) + time_base = Fraction(str(stream.get("time_base"))) + except (TypeError, ValueError, ZeroDivisionError) as exc: + raise ValueError("Unable to read exact audio sample count") from exc + samples = duration_ts * time_base * sample_rate + if samples.denominator != 1: + raise ValueError( + "Audio duration does not resolve to a whole number of samples" + ) + return samples.numerator + + +def _target_sample_count(duration_ms: int) -> int: + return int(round(duration_ms * _OUTPUT_SAMPLE_RATE / 1000)) + + +def _streams(probe: dict[str, Any], kind: str) -> list[dict[str, Any]]: + raw_streams = probe.get("streams") + if not isinstance(raw_streams, list): + return [] + return [ + stream + for stream in raw_streams + if isinstance(stream, dict) and stream.get("codec_type") == kind + ] + + +def _stream_hash(path: Path, selector: str, ffmpeg_path: str) -> str: + result = _run_command( + [ + ffmpeg_path, + "-v", + "error", + "-i", + str(path), + "-map", + selector, + "-c", + "copy", + "-f", + "hash", + "-hash", + "sha256", + "-", + ], + f"stream hash for {path}", + ) + match = re.search(r"SHA256=([0-9a-fA-F]{64})", result.stdout) + if match is None: + raise RuntimeError(f"Unable to parse stream hash for {path}") + return match.group(1).lower() + + +def _maximum_volume_db(path: Path, ffmpeg_path: str) -> float | None: + result = subprocess.run( + [ + ffmpeg_path, + "-nostdin", + "-hide_banner", + "-i", + str(path), + "-map", + "0:a:0", + "-af", + "volumedetect", + "-f", + "null", + "-", + ], + capture_output=True, + check=False, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + raise RuntimeError( + f"Volume inspection failed for {path}:\n{result.stderr.strip()}" + ) + match = re.search(r"max_volume:\s*(-?inf|[-+]?\d+(?:\.\d+)?)\s*dB", result.stderr) + if match is None: + raise RuntimeError(f"Unable to read maximum volume for {path}") + if match.group(1) == "-inf": + return None + return float(match.group(1)) + + +def _true_peak_dbfs(path: Path, ffmpeg_path: str) -> float | None: + result = subprocess.run( + [ + ffmpeg_path, + "-nostdin", + "-hide_banner", + "-nostats", + "-i", + str(path), + "-map", + "0:a:0", + "-af", + "ebur128=peak=true", + "-f", + "null", + "-", + ], + capture_output=True, + check=False, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + raise RuntimeError( + f"True-peak inspection failed for {path}:\n{result.stderr.strip()}" + ) + matches = re.findall( + r"Peak:\s*(-?inf|[-+]?\d+(?:\.\d+)?)\s*dBFS", + result.stderr, + ) + if not matches: + raise RuntimeError(f"Unable to read true peak for {path}") + if matches[-1] == "-inf": + return None + return float(matches[-1]) + + +def _decode_audio_f32(path: Path, ffmpeg_path: str) -> Any: + try: + import numpy as np + except ImportError as exc: + raise RuntimeError( + "Video sound verification requires numpy. Install it with: " + "python3 -m pip install numpy" + ) from exc + result = subprocess.run( + [ + ffmpeg_path, + "-v", + "error", + "-i", + str(path), + "-map", + "0:a:0", + "-vn", + "-ac", + "1", + "-ar", + str(_VERIFICATION_SAMPLE_RATE), + "-f", + "f32le", + "-", + ], + capture_output=True, + check=False, + ) + if result.returncode != 0: + details = result.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError(f"Unable to decode audio for verification: {path}\n{details}") + return np.frombuffer(result.stdout, dtype=" float: + """Correlate the added final-audio component with the rendered SFX stem.""" + try: + import numpy as np + except ImportError as exc: + raise RuntimeError( + "Video sound verification requires numpy. Install it with: " + "python3 -m pip install numpy" + ) from exc + + target_samples = int(round(duration_ms * _VERIFICATION_SAMPLE_RATE / 1000)) + decoded = [ + _decode_audio_f32(path, ffmpeg_path) + for path in (raw_video_path, mixed_video_path, stem_path) + ] + arrays: list[Any] = [] + for samples in decoded: + if len(samples) < target_samples: + samples = np.pad(samples, (0, target_samples - len(samples))) + arrays.append(samples[:target_samples]) + raw_audio, mixed_audio, stem_audio = arrays + + cue_mask = np.zeros(target_samples, dtype=bool) + for cue in cues: + start = int(round(cue.video_time_ms * _VERIFICATION_SAMPLE_RATE / 1000)) + end = int( + round( + (cue.video_time_ms + cue.source_duration_ms) + * _VERIFICATION_SAMPLE_RATE + / 1000 + ) + ) + cue_mask[max(0, start):min(target_samples, end)] = True + cue_indices = np.flatnonzero(cue_mask) + if len(cue_indices) < _MIN_VERIFICATION_WINDOW_SAMPLES: + raise RuntimeError("SFX cue windows are too short for mix verification") + + background = ~cue_mask + raw_background = raw_audio[background] + mixed_background = mixed_audio[background] + denominator = float(np.dot(raw_background, raw_background)) + narration_scale = ( + float(np.dot(mixed_background, raw_background)) / denominator + if denominator > 1e-12 + else 1.0 + ) + added_audio = mixed_audio - narration_scale * raw_audio + + best_correlation = -1.0 + maximum_lag = max(1, _VERIFICATION_SAMPLE_RATE // 200) + for lag in range(-maximum_lag, maximum_lag + 1): + shifted_indices = cue_indices + lag + valid = (shifted_indices >= 0) & (shifted_indices < target_samples) + if not valid.any(): + continue + observed = added_audio[shifted_indices[valid]] + expected = stem_audio[cue_indices[valid]] + observed = observed - observed.mean() + expected = expected - expected.mean() + norm = float(np.linalg.norm(observed) * np.linalg.norm(expected)) + if norm <= 1e-12: + continue + correlation = float(np.dot(observed, expected) / norm) + best_correlation = max(best_correlation, correlation) + if best_correlation < _MIN_SOUND_MIX_CORRELATION: + raise RuntimeError( + "Mixed-video audio does not reliably contain the rendered SFX stem: " + f"correlation={best_correlation:.3f}, " + f"required>={_MIN_SOUND_MIX_CORRELATION:.2f}" + ) + return best_correlation + + +def _trace_slides(trace: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: + raw_slides = trace.get("slides") + if not isinstance(raw_slides, list) or not raw_slides: + raise ValueError("Conversion trace must contain a non-empty slides array") + slide_count = trace.get("slide_count") + if slide_count != len(raw_slides): + raise ValueError( + "Conversion trace slide_count does not match its slides array" + ) + + slides: list[dict[str, Any]] = [] + slide_names: list[str] = [] + for expected_number, raw_slide in enumerate(raw_slides, 1): + if not isinstance(raw_slide, dict): + raise ValueError( + f"Conversion trace slide {expected_number} must be an object" + ) + if raw_slide.get("slide_num") != expected_number: + raise ValueError( + "Conversion trace slides must be numbered consecutively from 1" + ) + raw_svg = raw_slide.get("svg") + if not isinstance(raw_svg, str) or not raw_svg.strip(): + raise ValueError( + f"Conversion trace slide {expected_number} has no SVG path" + ) + slide_name = Path(raw_svg).stem + if not slide_name: + raise ValueError( + f"Conversion trace slide {expected_number} has an invalid SVG path" + ) + slides.append(raw_slide) + slide_names.append(slide_name) + if len(set(slide_names)) != len(slide_names): + raise ValueError("Conversion trace slide names must be unique") + return slides, slide_names + + +def _shape_group_ids(trace_slide: dict[str, Any]) -> dict[int, str]: + events = trace_slide.get("events") + if not isinstance(events, list): + return {} + mapping: dict[int, str] = {} + for event in events: + if not isinstance(event, dict): + continue + shape_id = event.get("shape_id") + group_id = event.get("id") + if ( + isinstance(shape_id, int) + and not isinstance(shape_id, bool) + and isinstance(group_id, str) + and group_id + ): + mapping[shape_id] = group_id + return mapping + + +def _package_part_map(package: zipfile.ZipFile) -> dict[str, str]: + mapping: dict[str, str] = {} + for member in package.namelist(): + key = canonical_opc_part_path(member) + if key is None: + continue + prior = mapping.get(key) + if prior is not None and prior != member: + raise ValueError( + "PPTX contains OPC-equivalent duplicate package parts: " + f"{prior!r}, {member!r}" + ) + mapping[key] = member + return mapping + + +def _relationships_part(slide_part: str) -> str: + slide_path = Path(slide_part) + return ( + slide_path.parent / "_rels" / f"{slide_path.name}.rels" + ).as_posix() + + +def _slide_relationships( + package: zipfile.ZipFile, + rels_part: str, +) -> dict[str, ET.Element]: + try: + root = ET.fromstring(package.read(rels_part)) + except KeyError as exc: + raise ValueError(f"PPTX is missing slide relationships: {rels_part}") from exc + except ET.ParseError as exc: + raise ValueError(f"Invalid slide relationships XML: {rels_part}") from exc + relationships: dict[str, ET.Element] = {} + for relationship in root.findall(_RELATIONSHIP_TAG): + relationship_id = relationship.get("Id") + if not relationship_id: + continue + if relationship_id in relationships: + raise ValueError( + f"Duplicate relationship id {relationship_id!r} in {rels_part}" + ) + relationships[relationship_id] = relationship + return relationships + + +def _extract_sound( + *, + package: zipfile.ZipFile, + package_parts: dict[str, str], + relationships: dict[str, ET.Element], + rels_part: str, + relationship_id: str, + temp_dir: Path, + ffprobe_path: str, + extracted: dict[str, tuple[str, str, Path, int]], +) -> tuple[str, str, Path, int]: + relationship = relationships.get(relationship_id) + if relationship is None: + raise ValueError( + f"PPTX sound relationship is missing: {rels_part}#{relationship_id}" + ) + if relationship.get("Type") != AUDIO_REL_TYPE: + raise ValueError( + f"PPTX sound relationship is not audio: {rels_part}#{relationship_id}" + ) + if relationship.get("TargetMode", "Internal") == "External": + raise ValueError( + f"PPTX sound relationship must be internal: {rels_part}#{relationship_id}" + ) + target = relationship.get("Target") or "" + target_key = resolve_internal_opc_target(rels_part, target) + if target_key is None: + raise ValueError( + f"PPTX sound relationship target is invalid: {rels_part}#{relationship_id}" + ) + package_part = package_parts.get(target_key) + if package_part is None: + raise ValueError( + f"PPTX sound relationship target is missing: {target!r}" + ) + cached = extracted.get(package_part) + if cached is not None: + return cached + + payload = package.read(package_part) + media_sha256 = hashlib.sha256(payload).hexdigest() + extension = Path(package_part).suffix.lower() + if extension not in {".m4a", ".mp3", ".wav"}: + raise ValueError( + f"PPTX animation sound uses an unsupported media type: {package_part}" + ) + media_path = temp_dir / f"{media_sha256}{extension}" + media_path.write_bytes(payload) + source_duration_ms = _duration_ms( + _probe_media(media_path, ffprobe_path), + f"PPTX sound {package_part}", + ) + resolved = (package_part, media_sha256, media_path, source_duration_ms) + extracted[package_part] = resolved + return resolved + + +def _require_equal(actual: object, expected: object, field: str) -> None: + if actual != expected: + raise ValueError( + f"Final narrated trace does not match the PPTX at {field}: " + f"trace={actual!r}, pptx={expected!r}" + ) + + +def _resolve_sound_cues( + *, + pptx_path: Path, + trace_slides: list[dict[str, Any]], + calibration: VideoTimelineCalibration, + transition_gain_db: float, + animation_gain_db: float, + video_duration_ms: int, + temp_dir: Path, + ffprobe_path: str, +) -> list[SoundCue]: + cues: list[SoundCue] = [] + extracted: dict[str, tuple[str, str, Path, int]] = {} + with zipfile.ZipFile(pptx_path) as package: + slide_members = presentation_slide_members(package) + if len(slide_members) != len(trace_slides): + raise ValueError( + f"Final narrated PPTX has {len(slide_members)} slides, but " + f"the trace has {len(trace_slides)}" + ) + package_parts = _package_part_map(package) + + for slide_num, (slide_part, trace_slide, calibrated) in enumerate( + zip(slide_members, trace_slides, calibration.slides), + 1, + ): + slide_xml = package.read(slide_part) + transition = read_slide_transition_xml(slide_xml) + animation = read_slide_animation_sequence( + slide_xml, + require_supported_effects=True, + ) + trace_motion = trace_slide.get("motion") + trace_animation = trace_slide.get("animation") + if not isinstance(trace_motion, dict) or not isinstance( + trace_animation, dict + ): + raise ValueError( + f"Conversion trace slide {slide_num} lacks resolved motion" + ) + + _require_equal( + trace_motion.get("duration_ms"), + transition.duration_ms, + f"slide {slide_num} transition duration", + ) + _require_equal( + trace_motion.get("advance_after_ms"), + transition.advance_after_ms, + f"slide {slide_num} recorded advance", + ) + _require_equal( + trace_motion.get("sound_relationship_id"), + transition.sound_relationship_id, + f"slide {slide_num} transition sound relationship", + ) + _require_equal( + trace_motion.get("sound_name"), + transition.sound_name, + f"slide {slide_num} transition sound name", + ) + _require_equal( + trace_motion.get("fallback_sound_relationship_id"), + transition.fallback_sound_relationship_id, + f"slide {slide_num} fallback transition sound relationship", + ) + _require_equal( + trace_motion.get("fallback_sound_name"), + transition.fallback_sound_name, + f"slide {slide_num} fallback transition sound name", + ) + if transition.fallback_sound_relationship_id not in { + None, + transition.sound_relationship_id, + }: + raise ValueError( + f"Slide {slide_num} transition primary and fallback " + "branches reference different sounds" + ) + + raw_rows = trace_animation.get("rows") + if not isinstance(raw_rows, list): + raise ValueError( + f"Conversion trace slide {slide_num} animation rows are invalid" + ) + if len(raw_rows) != len(animation.rows): + raise ValueError( + f"Final narrated trace has {len(raw_rows)} animation rows " + f"on slide {slide_num}; PPTX has {len(animation.rows)}" + ) + for row_index, (raw_row, actual_row) in enumerate( + zip(raw_rows, animation.rows), + 1, + ): + if not isinstance(raw_row, dict): + raise ValueError( + f"Conversion trace slide {slide_num} row {row_index} " + "must be an object" + ) + for key, expected in ( + ("shape_id", actual_row.shape_id), + ("offset_ms", actual_row.offset_ms), + ("trigger", actual_row.trigger), + ("sound_relationship_id", actual_row.sound_relationship_id), + ("sound_name", actual_row.sound_name), + ): + _require_equal( + raw_row.get(key), + expected, + f"slide {slide_num} animation row {row_index} {key}", + ) + + sound_relationship_ids = { + relationship_id + for relationship_id in ( + transition.sound_relationship_id, + *( + row.sound_relationship_id + for row in animation.rows + ), + ) + if relationship_id is not None + } + relationships: dict[str, ET.Element] = {} + rels_part = _relationships_part(slide_part) + if sound_relationship_ids: + relationships = _slide_relationships(package, rels_part) + + group_ids = _shape_group_ids(trace_slide) + if transition.sound_relationship_id is not None: + package_part, media_sha256, media_path, source_duration_ms = ( + _extract_sound( + package=package, + package_parts=package_parts, + relationships=relationships, + rels_part=rels_part, + relationship_id=transition.sound_relationship_id, + temp_dir=temp_dir, + ffprobe_path=ffprobe_path, + extracted=extracted, + ) + ) + cues.append( + SoundCue( + kind="transition", + slide_num=slide_num, + slide_name=calibrated.slide_name, + row_index=None, + shape_id=None, + group_id=None, + relationship_id=transition.sound_relationship_id, + sound_name=transition.sound_name or Path(package_part).name, + package_part=package_part, + media_sha256=media_sha256, + media_path=media_path, + source_duration_ms=source_duration_ms, + powerpoint_time_ms=calibrated.powerpoint_slide_start_ms, + video_time_ms=calibrated.video_slide_start_ms, + gain_db=transition_gain_db, + ) + ) + + for row_index, row in enumerate(animation.rows, 1): + if row.sound_relationship_id is None: + continue + if row.trigger not in {"after-previous", "with-previous"}: + raise ValueError( + f"Slide {slide_num} animation row {row_index} uses " + f"{row.trigger!r}; video sound timing requires a " + "click-free row" + ) + package_part, media_sha256, media_path, source_duration_ms = ( + _extract_sound( + package=package, + package_parts=package_parts, + relationships=relationships, + rels_part=rels_part, + relationship_id=row.sound_relationship_id, + temp_dir=temp_dir, + ffprobe_path=ffprobe_path, + extracted=extracted, + ) + ) + powerpoint_time_ms = ( + calibrated.powerpoint_slide_start_ms + + calibrated.transition_ms + + row.offset_ms + ) + video_time_ms = ( + calibrated.video_slide_start_ms + + calibrated.transition_ms + + row.offset_ms + ) + cues.append( + SoundCue( + kind="animation", + slide_num=slide_num, + slide_name=calibrated.slide_name, + row_index=row_index, + shape_id=row.shape_id, + group_id=group_ids.get(row.shape_id), + relationship_id=row.sound_relationship_id, + sound_name=row.sound_name or Path(package_part).name, + package_part=package_part, + media_sha256=media_sha256, + media_path=media_path, + source_duration_ms=source_duration_ms, + powerpoint_time_ms=powerpoint_time_ms, + video_time_ms=video_time_ms, + gain_db=animation_gain_db, + ) + ) + + cues.sort( + key=lambda cue: ( + cue.video_time_ms, + cue.slide_num, + 0 if cue.kind == "transition" else 1, + cue.row_index or 0, + ) + ) + if not cues: + raise ValueError( + "Final narrated PPTX and trace contain no transition or " + "object-animation sound cues; use the PowerPoint video directly" + ) + for cue in cues: + if cue.video_time_ms < 0 or cue.video_time_ms >= video_duration_ms: + raise ValueError( + f"Sound cue falls outside the video: slide {cue.slide_num}, " + f"time={cue.video_time_ms}ms, video={video_duration_ms}ms" + ) + cue_end_ms = cue.video_time_ms + cue.source_duration_ms + if cue_end_ms > video_duration_ms: + raise ValueError( + f"Sound cue would be truncated by the video end: slide " + f"{cue.slide_num}, cue end={cue_end_ms}ms, " + f"video={video_duration_ms}ms" + ) + return cues + + +def _render_stem( + *, + cues: list[SoundCue], + duration_ms: int, + output_path: Path, + ffmpeg_path: str, +) -> None: + target_samples = _target_sample_count(duration_ms) + command = [ + ffmpeg_path, + "-nostdin", + "-hide_banner", + "-v", + "error", + "-f", + "lavfi", + "-i", + f"anullsrc=r={_OUTPUT_SAMPLE_RATE}:cl=stereo", + ] + for cue in cues: + command.extend(["-i", str(cue.media_path)]) + + filters = [ + ( + f"[0:a:0]aformat=sample_fmts=fltp:sample_rates={_OUTPUT_SAMPLE_RATE}:" + "channel_layouts=stereo," + f"atrim=end_sample={target_samples},asetpts=N/SR/TB[base]" + ) + ] + labels = ["[base]"] + for input_index, cue in enumerate(cues, 1): + label = f"cue{input_index}" + delay_samples = int( + round(cue.video_time_ms * _OUTPUT_SAMPLE_RATE / 1000) + ) + filters.append( + f"[{input_index}:a:0]aresample={_OUTPUT_SAMPLE_RATE}," + "aformat=sample_fmts=fltp:" + f"sample_rates={_OUTPUT_SAMPLE_RATE}:channel_layouts=stereo," + f"asetpts=N/SR/TB,volume={cue.gain_db:.3f}dB," + f"adelay={delay_samples}S:all=1," + f"apad=whole_len={target_samples}," + f"atrim=end_sample={target_samples}[{label}]" + ) + labels.append(f"[{label}]") + filters.append( + "".join(labels) + + f"amix=inputs={len(labels)}:duration=first:" + "dropout_transition=0:normalize=0," + f"atrim=end_sample={target_samples},asetpts=N/SR/TB[stem]" + ) + command.extend( + [ + "-filter_complex", + ";".join(filters), + "-map", + "[stem]", + "-c:a", + "pcm_f32le", + "-ar", + str(_OUTPUT_SAMPLE_RATE), + "-ac", + "2", + "-y", + str(output_path), + ] + ) + _run_command(command, "SFX stem render") + + +def _mix_video( + *, + video_path: Path, + stem_path: Path, + duration_ms: int, + limiter_dbfs: float, + output_path: Path, + ffmpeg_path: str, +) -> None: + target_samples = _target_sample_count(duration_ms) + limiter_ratio = 10 ** (limiter_dbfs / 20) + filter_graph = ( + f"[0:a:0]aresample={_OUTPUT_SAMPLE_RATE}:first_pts=0," + "aformat=sample_fmts=fltp:channel_layouts=stereo," + f"asetpts=N/SR/TB,apad=whole_len={target_samples}," + f"atrim=end_sample={target_samples}[narration];" + f"[1:a:0]aresample={_OUTPUT_SAMPLE_RATE}:first_pts=0," + "aformat=sample_fmts=fltp:channel_layouts=stereo," + f"asetpts=N/SR/TB,apad=whole_len={target_samples}," + f"atrim=end_sample={target_samples}[sfx];" + "[narration][sfx]amix=inputs=2:duration=first:" + "dropout_transition=0:normalize=0," + f"alimiter=limit={limiter_ratio:.9f}:attack=5:release=50:" + "level=false:latency=true," + f"apad=whole_len={target_samples}," + f"atrim=end_sample={target_samples},asetpts=N/SR/TB[mixed]" + ) + _run_command( + [ + ffmpeg_path, + "-nostdin", + "-hide_banner", + "-v", + "error", + "-i", + str(video_path), + "-i", + str(stem_path), + "-filter_complex", + filter_graph, + "-map", + "0:v:0", + "-map", + "[mixed]", + "-map_metadata", + "0", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + _OUTPUT_AUDIO_BITRATE, + "-ar", + str(_OUTPUT_SAMPLE_RATE), + "-ac", + "2", + "-movflags", + "+faststart", + "-y", + str(output_path), + ], + "Narration and SFX mix", + ) + + +def _temporary_output(path: Path, suffix: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, raw_path = tempfile.mkstemp( + prefix=f".{path.stem}.", + suffix=suffix, + dir=path.parent, + ) + os.close(descriptor) + temporary = Path(raw_path) + temporary.unlink() + return temporary + + +def _publish_outputs(replacements: list[tuple[Path, Path]]) -> None: + """Publish verified outputs together, restoring prior files on failure.""" + backups: dict[Path, Path] = {} + published: list[Path] = [] + try: + for _temporary, destination in replacements: + if not destination.exists(): + continue + backup = _temporary_output(destination, f".backup{destination.suffix}") + os.replace(destination, backup) + backups[destination] = backup + for temporary, destination in replacements: + os.replace(temporary, destination) + published.append(destination) + except OSError as exc: + rollback_errors: list[str] = [] + for destination in reversed(published): + backup = backups.pop(destination, None) + try: + if backup is None: + destination.unlink(missing_ok=True) + else: + os.replace(backup, destination) + except OSError as rollback_exc: + rollback_errors.append(f"{destination}: {rollback_exc}") + for destination, backup in backups.items(): + try: + os.replace(backup, destination) + except OSError as rollback_exc: + rollback_errors.append(f"{destination}: {rollback_exc}") + if rollback_errors: + raise RuntimeError( + "Output publication failed and rollback was incomplete: " + + "; ".join(rollback_errors) + ) from exc + raise + else: + for backup in backups.values(): + backup.unlink(missing_ok=True) + + +def _cue_report(cue: SoundCue) -> dict[str, Any]: + return { + "kind": cue.kind, + "slide_num": cue.slide_num, + "slide_name": cue.slide_name, + "row_index": cue.row_index, + "shape_id": cue.shape_id, + "group_id": cue.group_id, + "relationship_id": cue.relationship_id, + "sound_name": cue.sound_name, + "package_part": cue.package_part, + "media_sha256": cue.media_sha256, + "source_duration_ms": cue.source_duration_ms, + "powerpoint_time_ms": cue.powerpoint_time_ms, + "video_time_ms": cue.video_time_ms, + "gain_db": cue.gain_db, + } + + +def mix_video_sounds( + project_path: Path, + *, + pptx_path: Path, + trace_path: Path, + video_path: Path, + audio_dir: Path, + subtitle_dir: Path | None, + output_path: Path, + stem_path: Path, + report_path: Path, + transition_gain_db: float = _DEFAULT_TRANSITION_GAIN_DB, + animation_gain_db: float = _DEFAULT_ANIMATION_GAIN_DB, + limiter_dbfs: float = _DEFAULT_LIMITER_DBFS, + force: bool = False, +) -> SoundMixResult: + """Render and publish one calibrated animation-sound video mix.""" + project_path = project_path.resolve() + pptx_path = pptx_path.resolve() + trace_path = trace_path.resolve() + video_path = video_path.resolve() + audio_dir = audio_dir.resolve() + subtitle_dir = subtitle_dir.resolve() if subtitle_dir is not None else None + output_path = output_path.resolve() + stem_path = stem_path.resolve() + report_path = report_path.resolve() + if not project_path.is_dir(): + raise FileNotFoundError(f"Project path does not exist: {project_path}") + for label, path in ( + ("Final narrated PPTX", pptx_path), + ("Final narrated conversion trace", trace_path), + ("Raw PowerPoint video", video_path), + ): + if not path.is_file(): + raise FileNotFoundError(f"{label} does not exist: {path}") + if pptx_path.suffix.lower() != ".pptx": + raise ValueError(f"Final narrated PPTX must use .pptx: {pptx_path}") + if video_path.suffix.lower() != ".mp4": + raise ValueError(f"Raw PowerPoint video must use .mp4: {video_path}") + if output_path.suffix.lower() != ".mp4": + raise ValueError(f"Mixed video output must use .mp4: {output_path}") + if stem_path.suffix.lower() != ".wav": + raise ValueError(f"SFX stem output must use .wav: {stem_path}") + if report_path.suffix.lower() != ".json": + raise ValueError(f"Sound mix report must use .json: {report_path}") + + outputs = [output_path, stem_path, report_path] + if len(set(outputs)) != len(outputs): + raise ValueError("Mixed video, SFX stem, and report paths must differ") + invalid_outputs = [ + path for path in outputs if path.exists() and not path.is_file() + ] + if invalid_outputs: + raise ValueError( + "Sound mix outputs must be regular files or unused paths: " + + ", ".join(str(path) for path in invalid_outputs) + ) + existing = [path for path in outputs if path.exists()] + if existing and not force: + raise FileExistsError( + "Sound mix output already exists; use --force to replace it: " + + ", ".join(str(path) for path in existing) + ) + for label, value in ( + ("transition gain", transition_gain_db), + ("animation gain", animation_gain_db), + ): + if not math.isfinite(value) or value < -60 or value > 0: + raise ValueError(f"{label} must be finite and between -60 and 0 dB") + if not math.isfinite(limiter_dbfs) or not -12 <= limiter_dbfs < 0: + raise ValueError("limiter ceiling must be finite, at least -12, and below 0 dBFS") + + ffmpeg_path = _require_tool("ffmpeg") + ffprobe_path = _require_tool("ffprobe") + trace = _read_json_object(trace_path, "Conversion trace") + trace_slides, slide_names = _trace_slides(trace) + raw_probe = _probe_media(video_path, ffprobe_path) + raw_format_duration_ms = _duration_ms(raw_probe, "raw PowerPoint video") + raw_video_streams = _streams(raw_probe, "video") + raw_audio_streams = _streams(raw_probe, "audio") + if len(raw_video_streams) != 1 or len(raw_audio_streams) != 1: + raise ValueError( + "Raw PowerPoint video must contain one video stream and one " + "narration audio stream" + ) + raw_duration_ms = _stream_duration_ms( + raw_video_streams[0], + fallback_ms=raw_format_duration_ms, + ) + raw_video_start_ms = _stream_start_ms(raw_video_streams[0]) + raw_audio_start_ms = _stream_start_ms(raw_audio_streams[0]) + start_delta_ms = abs(raw_video_start_ms - raw_audio_start_ms) + frame_duration_ms = _frame_duration_ms(raw_video_streams[0]) + if ( + abs(raw_video_start_ms) > _START_TIME_TOLERANCE_MS + or abs(raw_audio_start_ms) > _START_TIME_TOLERANCE_MS + or start_delta_ms > _START_TIME_TOLERANCE_MS + ): + raise ValueError( + "Raw PowerPoint video audio and video streams must both start at " + f"zero: video={raw_video_start_ms}ms, audio={raw_audio_start_ms}ms" + ) + raw_video_timing = _stream_timing_signature(raw_video_streams[0]) + + calibration = calibrate_video_timeline( + slide_names=slide_names, + pptx_path=pptx_path, + audio_dir=audio_dir, + video_path=video_path, + subtitle_dir=subtitle_dir, + ) + protected_inputs = { + pptx_path, + trace_path, + video_path, + *(slide.audio_path.resolve() for slide in calibration.slides), + } + aliases = protected_inputs.intersection(outputs) + if aliases: + raise ValueError( + "Sound mix outputs must not overwrite inputs: " + + ", ".join(str(path) for path in sorted(aliases)) + ) + temporary_stem = _temporary_output(stem_path, ".wav") + temporary_video = _temporary_output(output_path, ".mp4") + temporary_report = _temporary_output(report_path, ".json") + try: + with tempfile.TemporaryDirectory(prefix="ppt-master-video-sounds-") as raw_dir: + cues = _resolve_sound_cues( + pptx_path=pptx_path, + trace_slides=trace_slides, + calibration=calibration, + transition_gain_db=transition_gain_db, + animation_gain_db=animation_gain_db, + video_duration_ms=raw_duration_ms, + temp_dir=Path(raw_dir), + ffprobe_path=ffprobe_path, + ) + _render_stem( + cues=cues, + duration_ms=raw_duration_ms, + output_path=temporary_stem, + ffmpeg_path=ffmpeg_path, + ) + stem_peak_db = _maximum_volume_db(temporary_stem, ffmpeg_path) + if stem_peak_db is None or stem_peak_db < -90: + raise RuntimeError("Rendered SFX stem is silent") + _mix_video( + video_path=video_path, + stem_path=temporary_stem, + duration_ms=raw_duration_ms, + limiter_dbfs=limiter_dbfs, + output_path=temporary_video, + ffmpeg_path=ffmpeg_path, + ) + + stem_probe = _probe_media(temporary_stem, ffprobe_path) + mixed_probe = _probe_media(temporary_video, ffprobe_path) + stem_duration_ms = _duration_ms(stem_probe, "SFX stem") + mixed_duration_ms = _duration_ms(mixed_probe, "mixed video") + mixed_video_streams = _streams(mixed_probe, "video") + mixed_audio_streams = _streams(mixed_probe, "audio") + if len(mixed_video_streams) != 1 or len(mixed_audio_streams) != 1: + raise RuntimeError( + "Mixed video must contain one copied video stream and one audio stream" + ) + mixed_video_duration_ms = _stream_duration_ms( + mixed_video_streams[0], + fallback_ms=mixed_duration_ms, + ) + mixed_audio_duration_ms = _stream_duration_ms( + mixed_audio_streams[0], + fallback_ms=mixed_duration_ms, + ) + target_samples = _target_sample_count(raw_duration_ms) + stem_samples = _audio_sample_count(_streams(stem_probe, "audio")[0]) + if stem_samples != target_samples: + raise RuntimeError( + "SFX stem sample count does not match the raw video target: " + f"stem={stem_samples}, target={target_samples}" + ) + mixed_video_timing = _stream_timing_signature(mixed_video_streams[0]) + if mixed_video_timing != raw_video_timing: + raise RuntimeError( + "Mixed output changed the PowerPoint video packet timeline: " + f"raw={raw_video_timing!r}, mixed={mixed_video_timing!r}" + ) + duration_tolerance_ms = max( + frame_duration_ms, + 1024 * 1000 / _OUTPUT_SAMPLE_RATE, + ) + 1 + if abs(mixed_audio_duration_ms - raw_duration_ms) > duration_tolerance_ms: + raise RuntimeError( + "Mixed audio duration does not match the raw video: " + f"audio={mixed_audio_duration_ms}ms, raw={raw_duration_ms}ms" + ) + + raw_video_hash = _stream_hash(video_path, "0:v:0", ffmpeg_path) + mixed_video_hash = _stream_hash(temporary_video, "0:v:0", ffmpeg_path) + if raw_video_hash != mixed_video_hash: + raise RuntimeError("Mixed output changed the PowerPoint video stream") + raw_audio_hash = _stream_hash(video_path, "0:a:0", ffmpeg_path) + mixed_audio_hash = _stream_hash(temporary_video, "0:a:0", ffmpeg_path) + if raw_audio_hash == mixed_audio_hash: + raise RuntimeError("Mixed output audio is unchanged from the raw video") + mixed_peak_db = _maximum_volume_db(temporary_video, ffmpeg_path) + true_peak_dbfs = _true_peak_dbfs(temporary_video, ffmpeg_path) + if true_peak_dbfs is None or true_peak_dbfs >= 0: + raise RuntimeError( + "Mixed-video audio is silent or clips after encoding: " + f"true_peak={true_peak_dbfs!r} dBFS" + ) + verification_correlation = _sound_mix_correlation( + raw_video_path=video_path, + mixed_video_path=temporary_video, + stem_path=temporary_stem, + cues=cues, + duration_ms=raw_duration_ms, + ffmpeg_path=ffmpeg_path, + ) + + trace_output = trace.get("output") + trace_output_matches_pptx = False + if isinstance(trace_output, str) and trace_output.strip(): + trace_output_matches_pptx = Path(trace_output).expanduser().resolve() == pptx_path + + report = { + "schema": "ppt-master.video-sound-mix-report.v1", + "version": 1, + "status": "passed", + "inputs": { + "pptx": { + "path": str(pptx_path), + "sha256": _sha256_path(pptx_path), + }, + "conversion_trace": { + "path": str(trace_path), + "sha256": _sha256_path(trace_path), + "declared_output_matches_pptx": trace_output_matches_pptx, + }, + "raw_video": { + "path": str(video_path), + "sha256": _sha256_path(video_path), + "duration_ms": raw_duration_ms, + "video_start_ms": _stream_start_ms(raw_video_streams[0]), + "audio_start_ms": _stream_start_ms(raw_audio_streams[0]), + "video_stream_sha256": raw_video_hash, + "video_timing": raw_video_timing, + "audio_stream_sha256": raw_audio_hash, + "video_codec": raw_video_streams[0].get("codec_name"), + "audio_codec": raw_audio_streams[0].get("codec_name"), + }, + }, + "settings": { + "transition_gain_db": transition_gain_db, + "animation_gain_db": animation_gain_db, + "limiter_dbfs": limiter_dbfs, + "mix_normalize": False, + "ducking": False, + "background_music": False, + "sample_rate": _OUTPUT_SAMPLE_RATE, + "audio_bitrate": _OUTPUT_AUDIO_BITRATE, + }, + "timeline": { + "method": "page narration correlation against raw video audio", + "powerpoint_timeline_ms": calibration.powerpoint_timeline_ms, + "minimum_correlation": min( + slide.correlation for slide in calibration.slides + ), + "slides": [ + { + "slide_num": index, + "slide_name": slide.slide_name, + "powerpoint_slide_start_ms": slide.powerpoint_slide_start_ms, + "powerpoint_narration_start_ms": ( + slide.powerpoint_narration_start_ms + ), + "video_slide_start_ms": slide.video_slide_start_ms, + "video_narration_start_ms": slide.video_narration_start_ms, + "adjustment_ms": slide.adjustment_ms, + "correlation": slide.correlation, + } + for index, slide in enumerate(calibration.slides, 1) + ], + }, + "cues": [_cue_report(cue) for cue in cues], + "outputs": { + "sfx_stem": { + "path": str(stem_path), + "sha256": _sha256_path(temporary_stem), + "duration_ms": stem_duration_ms, + "sample_count": stem_samples, + "maximum_volume_db": stem_peak_db, + }, + "mixed_video": { + "path": str(output_path), + "sha256": _sha256_path(temporary_video), + "duration_ms": mixed_duration_ms, + "video_duration_ms": mixed_video_duration_ms, + "audio_duration_ms": mixed_audio_duration_ms, + "video_stream_sha256": mixed_video_hash, + "video_timing": mixed_video_timing, + "audio_stream_sha256": mixed_audio_hash, + "video_codec": mixed_video_streams[0].get("codec_name"), + "audio_codec": mixed_audio_streams[0].get("codec_name"), + "maximum_volume_db": mixed_peak_db, + "true_peak_dbfs": true_peak_dbfs, + }, + }, + "validation": { + "trace_matches_pptx_motion": True, + "cue_count": len(cues), + "stem_non_silent": True, + "mixed_audio_present": True, + "video_stream_preserved": True, + "duration_delta_ms": mixed_video_duration_ms - raw_duration_ms, + "video_timeline_preserved": True, + "limiter_applied": True, + "sound_mix_correlation": verification_correlation, + "minimum_sound_mix_correlation": _MIN_SOUND_MIX_CORRELATION, + }, + } + temporary_report.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + _publish_outputs( + [ + (temporary_stem, stem_path), + (temporary_video, output_path), + (temporary_report, report_path), + ] + ) + finally: + for temporary in (temporary_stem, temporary_video, temporary_report): + temporary.unlink(missing_ok=True) + + return SoundMixResult( + output_path=output_path, + stem_path=stem_path, + report_path=report_path, + cue_count=len(cues), + verification_correlation=verification_correlation, + true_peak_dbfs=true_peak_dbfs, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Mix PPTX transition/object-animation sounds into a " + "PowerPoint-exported narrated video." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("project_path", help="PPT Master project directory") + parser.add_argument( + "--pptx", + required=True, + help="Final narrated PPTX; relative paths resolve under the project", + ) + parser.add_argument( + "--trace", + required=True, + help="Final narrated conversion trace; relative paths resolve under the project", + ) + parser.add_argument( + "--video", + required=True, + help="Raw PowerPoint-exported video; relative paths resolve under the project", + ) + parser.add_argument( + "--audio-dir", + default=None, + help="Page narration audio directory; default: /audio", + ) + parser.add_argument( + "--subtitle-dir", + default=None, + help=( + "Optional page-local SRT directory used to refine calibration; " + "default: /audio" + ), + ) + parser.add_argument( + "-o", + "--output", + default=None, + help="Final mixed MP4; default: beside raw video as *_mixed.mp4", + ) + parser.add_argument( + "--stem-output", + default=None, + help="Independent SFX WAV; default: beside raw video as *_sfx.wav", + ) + parser.add_argument( + "--report-output", + default=None, + help="Sound mix JSON receipt; default: beside raw video as *_sound_mix.json", + ) + parser.add_argument( + "--transition-gain-db", + type=float, + default=_DEFAULT_TRANSITION_GAIN_DB, + help="Transition cue gain in dB (default: -9.1, about 35%%)", + ) + parser.add_argument( + "--animation-gain-db", + type=float, + default=_DEFAULT_ANIMATION_GAIN_DB, + help="Object-animation cue gain in dB (default: -12, about 25%%)", + ) + parser.add_argument( + "--limiter-dbfs", + type=float, + default=_DEFAULT_LIMITER_DBFS, + help="Final peak limiter ceiling in dBFS (default: -1)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Replace existing mixed video, stem, and report outputs", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + project_path = Path(args.project_path).expanduser().resolve() + if not project_path.is_dir(): + parser.error(f"Project path does not exist: {project_path}") + + pptx_path = _project_input_path(project_path, args.pptx) + trace_path = _project_input_path(project_path, args.trace) + video_path = _project_input_path(project_path, args.video) + audio_dir = _project_output_path( + project_path, + args.audio_dir, + Path("audio"), + ) + subtitle_dir = _project_output_path( + project_path, + args.subtitle_dir, + Path("audio"), + ) + default_output = video_path.with_name(f"{video_path.stem}_mixed.mp4") + default_stem = video_path.with_name(f"{video_path.stem}_sfx.wav") + default_report = video_path.with_name(f"{video_path.stem}_sound_mix.json") + output_path = _project_output_path(project_path, args.output, default_output) + stem_path = _project_output_path( + project_path, + args.stem_output, + default_stem, + ) + report_path = _project_output_path( + project_path, + args.report_output, + default_report, + ) + + try: + result = mix_video_sounds( + project_path, + pptx_path=pptx_path, + trace_path=trace_path, + video_path=video_path, + audio_dir=audio_dir, + subtitle_dir=subtitle_dir, + output_path=output_path, + stem_path=stem_path, + report_path=report_path, + transition_gain_db=args.transition_gain_db, + animation_gain_db=args.animation_gain_db, + limiter_dbfs=args.limiter_dbfs, + force=args.force, + ) + except (FileNotFoundError, FileExistsError, OSError, RuntimeError, ValueError) as exc: + print(f"Video sound mix failed: {exc}", file=sys.stderr) + return 1 + + print( + f"[POSTFLIGHT] status=passed cues={result.cue_count} " + "video_stream=preserved audio_mix=verified " + f"correlation={result.verification_correlation:.3f} " + f"true_peak={result.true_peak_dbfs:.1f}dBFS" + ) + print(f"[Done] Mixed video: {result.output_path}") + print(f"[Done] SFX stem: {result.stem_path}") + print(f"[REPORT] Video sound mix: {result.report_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py index 9fe6f05c..62e6b02d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py @@ -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 --video