Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
@@ -51,8 +51,8 @@
|
|||||||
"repo": "https://github.com/Leonxlnx/taste-skill.git",
|
"repo": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "98565e65bc3274ddf6eb0838734341714057178b",
|
"commit": "1bffae64edbb6b2023d1e78402cd088e9c9f6511",
|
||||||
"syncedAt": "2026-07-21T16:00:00Z"
|
"syncedAt": "2026-07-23T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "shadcn",
|
"id": "shadcn",
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "3f47b9113a173bac4a72cefda4d4c3f2d89b8ab6",
|
"commit": "6cd3f4c65c361ab6554e06a77e6a0af9cf8b6e37",
|
||||||
"syncedAt": "2026-07-22T16:00:00Z"
|
"syncedAt": "2026-07-23T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "frontend-slides",
|
"id": "frontend-slides",
|
||||||
@@ -96,8 +96,8 @@
|
|||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "89759436bd336beed3a32da15830355bf732b14e",
|
"commit": "10f0adc0600ff28a470d55992133b1992c56968a",
|
||||||
"syncedAt": "2026-07-22T16:00:00Z"
|
"syncedAt": "2026-07-23T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "next-skills",
|
"id": "next-skills",
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "63f14c6c90c4dae819966e180491eb9b0af16792",
|
"commit": "aa4f46a540b7c9176c7c2b7ef22421adb4b5688e",
|
||||||
"syncedAt": "2026-07-22T16:00:00Z"
|
"syncedAt": "2026-07-23T16:00:00Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"name": "playwright浏览器自动化操作",
|
"name": "playwright浏览器自动化操作",
|
||||||
"version": "20260605",
|
"version": "20260605",
|
||||||
"keySource": "none",
|
"keySource": "none",
|
||||||
"syncedAt": "2026-07-22T16:02:51Z"
|
"syncedAt": "2026-07-23T16:02:47Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "next-skills",
|
"sourceId": "next-skills",
|
||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"commit": "63f14c6c90c4dae819966e180491eb9b0af16792",
|
"commit": "aa4f46a540b7c9176c7c2b7ef22421adb4b5688e",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"sourcePath": "skills",
|
||||||
"syncedAt": "2026-07-22T16:00:00Z"
|
"syncedAt": "2026-07-23T16:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "ppt-master",
|
"sourceId": "ppt-master",
|
||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "89759436bd336beed3a32da15830355bf732b14e",
|
"commit": "10f0adc0600ff28a470d55992133b1992c56968a",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/ppt-master",
|
"sourcePath": "skills/ppt-master",
|
||||||
"syncedAt": "2026-07-22T16:00:00Z"
|
"syncedAt": "2026-07-23T16:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,9 +148,10 @@ Flags:
|
|||||||
- `--animation-trigger` — Start mode (matches PowerPoint): `on-click`, `with-previous`, or `after-previous` (default).
|
- `--animation-trigger` — Start mode (matches PowerPoint): `on-click`, `with-previous`, or `after-previous` (default).
|
||||||
- `--animation-duration` — per-element entrance seconds, default `0.4`.
|
- `--animation-duration` — per-element entrance seconds, default `0.4`.
|
||||||
- `--animation-stagger` — gap between elements in `after-previous` mode (seconds, default `0.5`). Ignored otherwise.
|
- `--animation-stagger` — gap between elements in `after-previous` mode (seconds, default `0.5`). Ignored otherwise.
|
||||||
- `--animation-config` — sidecar path. Default: `<project>/animations.json` when present.
|
- `--animation-config` — explicit sidecar path. Narrated export defaults to `<project>/narration_animations.json`; other export defaults to `<project>/animations.json` when present.
|
||||||
|
- `--no-animations` — ignore animation sidecars and disable both object animations and page-transition motion. Narration audio and recorded slide-advance timing remain active.
|
||||||
|
|
||||||
> Note: `--recorded-narration` rejects `on-click`; use `after-previous` or `with-previous` for video-ready narrated decks.
|
> Note: `--recorded-narration` rejects `on-click`; use its default `narration_animations.json`, pass `--animation-config animations.json` for the canonical presentation animation, or pass `--no-animations`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -199,6 +200,9 @@ Narration injection merges audio timing into an existing direct `p:sld/p:timing`
|
|||||||
| Slower transition | `--transition-duration 0.8` |
|
| Slower transition | `--transition-duration 0.8` |
|
||||||
| Auto-play | `--auto-advance 5` |
|
| Auto-play | `--auto-advance 5` |
|
||||||
| Disable element animation | `-a none` |
|
| Disable element animation | `-a none` |
|
||||||
|
| Narrated export with synchronized animation | `--recorded-narration audio` |
|
||||||
|
| Narrated export with canonical animation | `--recorded-narration audio --animation-config animations.json` |
|
||||||
|
| Narrated export without animation motion | `--recorded-narration audio --no-animations` |
|
||||||
| Switch to on-click trigger | `-a auto --animation-trigger on-click` |
|
| Switch to on-click trigger | `-a auto --animation-trigger on-click` |
|
||||||
| Use a single effect instead of auto | `--animation fade` |
|
| Use a single effect instead of auto | `--animation fade` |
|
||||||
| All groups animate together | `-a auto --animation-trigger with-previous` |
|
| All groups animate together | `-a auto --animation-trigger with-previous` |
|
||||||
|
|||||||
@@ -6,49 +6,35 @@ Conditional late-stage authority for generating the complete speaker-notes docum
|
|||||||
|
|
||||||
**Trigger**: load only after all SVG pages pass the final quality check.
|
**Trigger**: load only after all SVG pages pass the final quality check.
|
||||||
|
|
||||||
## 1. Speaker Notes Generation Framework
|
## 1. Complete Speaker-notes Document
|
||||||
|
|
||||||
### 1.1 Generate Complete Speaker Notes Document
|
Write the complete deck to `notes/total.md` in one batch for coherent transitions. Use `# <number>_<page_title>` per page and `---` between pages; only the heading is stripped before TTS.
|
||||||
|
|
||||||
After all SVG pages are finalized, enter Logic Construction Phase and write the full notes to `notes/total.md`. Batch-writing (not per-page) lets transitions plan coherently.
|
**Pure spoken narration**: `notes_to_audio.py` reads the body verbatim. Write prose only; never add Markdown list/bullet markup, stage markers, key-point labels, duration lines, or other metadata.
|
||||||
|
|
||||||
**Pure spoken narration**: notes are read aloud verbatim by `notes_to_audio.py` (TTS). Write only what should be spoken. No visible markers, no labeled meta-lines, no enumerated key-point lists, no duration annotations — anything you write outside the heading will be vocalized.
|
**Length follows content**: size natural sentences to semantic burden. Two to five is typical, not a cap; anchor pages may use less and dense pages more. Honor `design_spec.md` style, detail, and source rules. Duration is pacing guidance only: never pad, repeat, compress, or omit meaning to hit it.
|
||||||
|
|
||||||
**Per-page structure**: `# <number>_<page_title>` heading (the `#` heading line is the only thing stripped before TTS), pages separated by `---`. Body is 2–5 natural sentences carrying the page's core message. Page-to-page transitions live inside the opening sentence as natural prose ("接下来……" / "Having framed X, let's turn to Y") — no bracketed `[过渡]` / `[Transition]` tags.
|
## 2. Final-SVG Grounding and Coverage
|
||||||
|
|
||||||
**Reading-mode note burden**:
|
**Hard rule — the final SVG is the visible page authority**: read every finalized `svg_output/<slide>.svg` in slide order. Use the locked plan and approved sources for context; never write from the outline or core message alone.
|
||||||
|
|
||||||
|
Before drafting, internally inventory the visible title/subtitle and every information-bearing direct-root `<g id>`; structured placeholder content still counts. Coverage requires its unique claim, evidence, example, relationship, qualifier, or implication—not merely its label—to enter the narration.
|
||||||
|
|
||||||
|
- Text blocks, comparisons, and processes retain every independent fact or relationship; combine related short groups causally or comparatively.
|
||||||
|
- Charts, tables, and KPIs state the takeaway, decisive values or trend, comparison basis, implication, and material uncertainty—not every axis, row, or cell.
|
||||||
|
- Quotes retain the decisive clause, material attribution, and relevance. Explain semantic images or text-free diagrams only from the SVG plus locked plan/source; never infer facts from appearance.
|
||||||
|
- Speak a source or page-local footer only when attribution, uncertainty, or qualification changes the argument. Omit backgrounds, decoration, repeated chrome, page numbers, and fixed Master/Layout atoms.
|
||||||
|
|
||||||
|
Form one coherent argument in intended reading/reveal order: proposition → evidence or mechanism → implication or bridge. DOM order need not be speaking order. A sentence may cover related groups and a complex group may need several sentences, but no independent group may disappear to meet a sentence count. Keep the inventory internal: never vocalize IDs, positions, colors, icons, repetitive "this card shows" descriptions, or coverage markers.
|
||||||
|
|
||||||
|
## 3. Reading Mode and TTS
|
||||||
|
|
||||||
| `consumption_mode` | Notes emphasis |
|
| `consumption_mode` | Notes emphasis |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `text` | Add interpretation or transition without reading the already self-contained page aloud. |
|
| `text` | Interpret and connect a self-contained page; synthesize every independent SVG information group rather than omitting it. |
|
||||||
| `balanced` | Connect the visible claim and evidence, explain the trade-off, and bridge to the next page. |
|
| `balanced` | Connect visible claim and evidence, explain the trade-off, and bridge forward. |
|
||||||
| `presentation` | Carry the reasoning, context, and supporting detail intentionally omitted from the sparse projected surface. |
|
| `presentation` | Carry reasoning, context, and supporting detail intentionally omitted from the sparse page. |
|
||||||
|
|
||||||
**Concrete examples** — same shape applies to any language; just write naturally in that language.
|
Put transitions naturally in the opening sentence when useful; never label them. Keep one language. Spell out digits or symbols when literal TTS would sound wrong (for example, Chinese "百分之六十八" rather than "68%").
|
||||||
|
|
||||||
中文 deck:
|
After `notes/total.md` is complete, return to Generate Step 7.1; the route authority owns splitting and its success criterion.
|
||||||
|
|
||||||
```
|
|
||||||
# 02_市场格局
|
|
||||||
|
|
||||||
在明确了行业背景之后,我们来看具体的市场格局。当前线上零售集中度持续上升,前三大平台合计份额已经达到百分之六十八,腰部玩家正在被快速挤压,留给新进入者的窗口期不超过十八个月。这意味着我们的策略必须聚焦,而不是铺开。
|
|
||||||
```
|
|
||||||
|
|
||||||
英文 deck:
|
|
||||||
|
|
||||||
```
|
|
||||||
# 02_market_landscape
|
|
||||||
|
|
||||||
Having framed the industry backdrop, let's look at the actual market landscape. Online retail concentration keeps rising — the top three platforms now hold sixty-eight percent of combined share, mid-tier players are being squeezed fast, and the window for new entrants is under eighteen months. This means our strategy has to focus, not spread.
|
|
||||||
```
|
|
||||||
|
|
||||||
> 日本語 / 한국어 / 其他语言:照搬同样的结构,用对应语言自然书写即可。
|
|
||||||
|
|
||||||
**Number readability**: TTS reads digits and symbols literally. Prefer fully-spelled forms in the language being spoken when literal pronunciation would be awkward (e.g. Chinese "百分之六十八" reads better than "68%"; "1-2分钟" reads as "一减二分钟"). Plain integers and percentages in English are fine as-is.
|
|
||||||
|
|
||||||
**Common mistakes to avoid**:
|
|
||||||
- Leaving any bracketed stage marker (`[过渡]` / `[Transition]` / `[Pause]` / `[Data]` / `[Scan Room]` / `[Interactive]` / `[Benchmark]` etc.) in the text — they will be read aloud literally.
|
|
||||||
- Adding `要点:① …` / `Key points: (1) …` / `时长:2分钟` / `Duration: 2 minutes` / `Flex: …` lines — TTS will speak "要点 一 …".
|
|
||||||
- Mixing languages within one deck's notes.
|
|
||||||
|
|
||||||
After `notes/total.md` is complete, return to Generate Step 7.1. The route authority owns the serial split command and its success criterion.
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ python3 scripts/update_repo.py
|
|||||||
|------|-----------------|---------------|
|
|------|-----------------|---------------|
|
||||||
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) |
|
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) |
|
||||||
| Project management | `project_manager.py`, `page_context.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) |
|
| Project management | `project_manager.py`, `page_context.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) |
|
||||||
| SVG pipeline | `preset_shape_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` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native preset authoring](../references/native-shape-authoring.md) |
|
| SVG pipeline | `preset_shape_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 preset authoring](../references/native-shape-authoring.md) |
|
||||||
| PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.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) |
|
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
|
||||||
| Spec maintenance | `update_spec.py`, `chart_recall.py` | [docs/update_spec.md](./docs/update_spec.md); [docs/chart-recall.md](./docs/chart-recall.md) |
|
| Spec maintenance | `update_spec.py`, `chart_recall.py` | [docs/update_spec.md](./docs/update_spec.md); [docs/chart-recall.md](./docs/chart-recall.md) |
|
||||||
|
|||||||
+19
-2
@@ -15,23 +15,40 @@
|
|||||||
--warning: #f5c542;
|
--warning: #f5c542;
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.22);
|
--shadow: 0 4px 24px rgba(0, 0, 0, 0.22);
|
||||||
|
--ui-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
html { height: 100%; }
|
html { height: 100%; }
|
||||||
|
|
||||||
|
html:lang(ja) {
|
||||||
|
--ui-font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic UI",
|
||||||
|
"Yu Gothic", "Meiryo UI", Meiryo, "Noto Sans CJK JP", "Noto Sans JP", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:lang(zh) {
|
||||||
|
--ui-font-family: "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||||
|
"Noto Sans CJK SC", "Noto Sans SC", "Hiragino Sans GB", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei",
|
font-family: var(--ui-font-family);
|
||||||
"PingFang SC", "Hiragino Sans GB", sans-serif;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- fullscreen shell ---- */
|
/* ---- fullscreen shell ---- */
|
||||||
#app {
|
#app {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -294,6 +294,8 @@ python3 scripts/svg_to_pptx.py <project_path> --auto-advance 3
|
|||||||
python3 scripts/svg_to_pptx.py <project_path> --animation mixed --animation-duration 0.8
|
python3 scripts/svg_to_pptx.py <project_path> --animation mixed --animation-duration 0.8
|
||||||
python3 scripts/svg_to_pptx.py <project_path> --no-merge # strict line-fidelity mode (see below)
|
python3 scripts/svg_to_pptx.py <project_path> --no-merge # strict line-fidelity mode (see below)
|
||||||
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio
|
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio --animation-config animations.json
|
||||||
|
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio --no-animations
|
||||||
```
|
```
|
||||||
|
|
||||||
The normal command reads `pptx_structure.mode` from `spec_lock.md`. For legacy
|
The normal command reads `pptx_structure.mode` from `spec_lock.md`. For legacy
|
||||||
@@ -357,6 +359,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
|
- 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` 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 audio duration
|
- `--recorded-narration audio` keeps speaker notes, embeds each matching audio file, and writes slide auto-advance timings from audio duration
|
||||||
|
- Narrated export defaults to `<project>/narration_animations.json`; pass `--animation-config animations.json` for the canonical presentation animation, or `--no-animations` to remove object animations and page-transition motion while retaining narration and slide timings
|
||||||
|
- Non-narrated export keeps the existing optional `<project>/animations.json` default
|
||||||
- Narration timing is merged into the existing slide timing DOM; object entrance rows and the resolved page transition are preserved rather than regenerated
|
- Narration timing is merged into the existing slide timing DOM; object entrance rows and the resolved page transition are preserved rather than regenerated
|
||||||
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
|
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
|
||||||
- Either narration flag names the default-flow export `<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports in the same directory
|
- Either narration flag names the default-flow export `<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports in the same directory
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@
|
|||||||
"""Generate per-slide narration audio from PPT Master notes.
|
"""Generate per-slide narration audio from PPT Master notes.
|
||||||
|
|
||||||
This script uses provider backends for the same per-slide output contract on
|
This script uses provider backends for the same per-slide output contract on
|
||||||
macOS, Linux, and Windows. `edge-tts` remains the default no-key backend.
|
macOS, Linux, and Windows. `edge-tts` remains the default no-key backend and
|
||||||
|
also writes one compact, word-timed SRT file per slide from the same TTS stream.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
|
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
|
||||||
@@ -107,6 +108,12 @@ def main() -> int:
|
|||||||
default="+0%",
|
default="+0%",
|
||||||
help='edge-tts speaking rate, e.g. "+0%%", "-10%%", "+15%%" (default: +0%%). Ignored by cloud providers.',
|
help='edge-tts speaking rate, e.g. "+0%%", "-10%%", "+15%%" (default: +0%%). Ignored by cloud providers.',
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--subtitle-max-chars",
|
||||||
|
type=int,
|
||||||
|
default=backend_edge.DEFAULT_SUBTITLE_MAX_CHARS,
|
||||||
|
help="maximum visible characters per Edge subtitle cue (default: 20)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--elevenlabs-api-key-env",
|
"--elevenlabs-api-key-env",
|
||||||
default="ELEVENLABS_API_KEY",
|
default="ELEVENLABS_API_KEY",
|
||||||
@@ -245,6 +252,10 @@ def main() -> int:
|
|||||||
parser.error(f"--voice-id is required for --provider {args.provider}")
|
parser.error(f"--voice-id is required for --provider {args.provider}")
|
||||||
raise AssertionError("unreachable")
|
raise AssertionError("unreachable")
|
||||||
|
|
||||||
|
if args.subtitle_max_chars < 1:
|
||||||
|
parser.error("--subtitle-max-chars must be at least 1")
|
||||||
|
raise AssertionError("unreachable")
|
||||||
|
|
||||||
if args.provider == "elevenlabs":
|
if args.provider == "elevenlabs":
|
||||||
if not voice_id:
|
if not voice_id:
|
||||||
parser.error("--voice-id is required for --provider elevenlabs")
|
parser.error("--voice-id is required for --provider elevenlabs")
|
||||||
@@ -291,6 +302,9 @@ def main() -> int:
|
|||||||
notes_dir = project / "notes"
|
notes_dir = project / "notes"
|
||||||
output_dir = args.output or (project / "audio")
|
output_dir = args.output or (project / "audio")
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
subtitle_dir = notes_dir / "subtitles"
|
||||||
|
if backend.provider == "edge":
|
||||||
|
subtitle_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
note_files = [
|
note_files = [
|
||||||
path for path in sorted(notes_dir.glob("*.md"))
|
path for path in sorted(notes_dir.glob("*.md"))
|
||||||
@@ -367,13 +381,31 @@ def main() -> int:
|
|||||||
base_url=args.cosyvoice_base_url,
|
base_url=args.cosyvoice_base_url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
asyncio.run(backend_edge.generate(text, output_path, voice=args.voice, rate=args.rate))
|
subtitle_path = subtitle_dir / f"{note_path.stem}.srt"
|
||||||
|
asyncio.run(
|
||||||
|
backend_edge.generate(
|
||||||
|
text,
|
||||||
|
output_path,
|
||||||
|
voice=args.voice,
|
||||||
|
rate=args.rate,
|
||||||
|
subtitle_path=subtitle_path,
|
||||||
|
subtitle_max_chars=args.subtitle_max_chars,
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"error: failed to generate {output_path}: {exc}", file=sys.stderr)
|
print(f"error: failed to generate {output_path}: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
generated += 1
|
generated += 1
|
||||||
print(f"[OK] {output_path}")
|
print(f"[OK] {output_path}")
|
||||||
|
if backend.provider == "edge":
|
||||||
|
print(f" {subtitle_path}")
|
||||||
|
|
||||||
|
if backend.provider == "edge":
|
||||||
|
print(
|
||||||
|
f"[Done] Generated {generated}/{len(note_files)} audio/SRT pair(s): "
|
||||||
|
f"{output_dir} + {subtitle_dir}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
print(f"[Done] Generated {generated}/{len(note_files)} audio file(s): {output_dir}")
|
print(f"[Done] Generated {generated}/{len(note_files)} audio file(s): {output_dir}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+88
-9
@@ -689,6 +689,9 @@ Recorded narration:
|
|||||||
- Keeps speaker notes when enabled
|
- Keeps speaker notes when enabled
|
||||||
- Prepares PowerPoint recorded timings and narrations
|
- Prepares PowerPoint recorded timings and narrations
|
||||||
- Requires one m4a/mp3/wav file per slide
|
- Requires one m4a/mp3/wav file per slide
|
||||||
|
- Uses narration_animations.json by default
|
||||||
|
- Use --animation-config animations.json for the canonical animation
|
||||||
|
- Use --no-animations for narration and timings without animation motion
|
||||||
- Embeds per-slide audio matched by SVG filename / slide number
|
- Embeds per-slide audio matched by SVG filename / slide number
|
||||||
- Sets slide auto-advance from audio duration so video export can use
|
- Sets slide auto-advance from audio duration so video export can use
|
||||||
"recorded timings and narrations"
|
"recorded timings and narrations"
|
||||||
@@ -819,9 +822,25 @@ Recorded narration:
|
|||||||
parser.add_argument('--animation-stagger', type=non_negative_float, default=None,
|
parser.add_argument('--animation-stagger', type=non_negative_float, default=None,
|
||||||
help='Delay between elements in --animation-trigger=after-previous '
|
help='Delay between elements in --animation-trigger=after-previous '
|
||||||
'(seconds, default 0.5). Ignored in other modes.')
|
'(seconds, default 0.5). Ignored in other modes.')
|
||||||
parser.add_argument('--animation-config', type=str, default=None,
|
animation_source = parser.add_mutually_exclusive_group()
|
||||||
help='Optional per-slide/per-object animation config. '
|
animation_source.add_argument(
|
||||||
'Default: <project>/animations.json when present.')
|
'--animation-config',
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
'Per-slide/per-object animation config. Recorded narration defaults '
|
||||||
|
'to <project>/narration_animations.json; other exports default to '
|
||||||
|
'<project>/animations.json when present.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
animation_source.add_argument(
|
||||||
|
'--no-animations',
|
||||||
|
action='store_true',
|
||||||
|
help=(
|
||||||
|
'Export without object animations or page-transition motion. '
|
||||||
|
'Narration audio and slide advance timings are preserved.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument('--no-notes', action='store_true',
|
parser.add_argument('--no-notes', action='store_true',
|
||||||
help='Disable speaker notes embedding (enabled by default)')
|
help='Disable speaker notes embedding (enabled by default)')
|
||||||
@@ -1147,18 +1166,65 @@ Recorded narration:
|
|||||||
"unmatched slides will export without audio."
|
"unmatched slides will export without audio."
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.animation_config:
|
if args.no_animations and any(
|
||||||
config_path = Path(args.animation_config)
|
value is not None
|
||||||
|
for value in (
|
||||||
|
args.transition,
|
||||||
|
args.transition_duration,
|
||||||
|
args.animation,
|
||||||
|
args.animation_duration,
|
||||||
|
args.animation_trigger,
|
||||||
|
args.animation_stagger,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
"Error: --no-animations cannot be combined with transition or "
|
||||||
|
"object-animation overrides.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
effective_animation_config = args.animation_config
|
||||||
|
if (
|
||||||
|
effective_animation_config is None
|
||||||
|
and args.recorded_narration
|
||||||
|
and not args.no_animations
|
||||||
|
):
|
||||||
|
effective_animation_config = 'narration_animations.json'
|
||||||
|
|
||||||
|
if effective_animation_config:
|
||||||
|
config_path = Path(effective_animation_config)
|
||||||
if not config_path.is_absolute():
|
if not config_path.is_absolute():
|
||||||
config_path = project_path / config_path
|
config_path = project_path / config_path
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
print(f"Error: Animation config does not exist: {config_path}")
|
print(
|
||||||
|
f"Error: Animation config does not exist: {config_path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
args.recorded_narration
|
||||||
|
and args.animation_config is None
|
||||||
|
and config_path.name == 'narration_animations.json'
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
"Generate it with narration_sync.py animations, select the "
|
||||||
|
"canonical config with --animation-config animations.json, "
|
||||||
|
"or disable animations with --no-animations.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
animation_config = load_animation_config(project_path, args.animation_config)
|
animation_config = (
|
||||||
|
None
|
||||||
|
if args.no_animations
|
||||||
|
else load_animation_config(
|
||||||
|
project_path,
|
||||||
|
effective_animation_config,
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"Error: Failed to load animation config: {exc}")
|
print(f"Error: Failed to load animation config: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
config_errors: list[str] = []
|
config_errors: list[str] = []
|
||||||
if animation_config:
|
if animation_config:
|
||||||
@@ -1187,10 +1253,15 @@ Recorded narration:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
if animation_config and verbose:
|
if animation_config and verbose:
|
||||||
config_label = args.animation_config or str(project_path / 'animations.json')
|
config_label = (
|
||||||
|
effective_animation_config
|
||||||
|
or str(project_path / 'animations.json')
|
||||||
|
)
|
||||||
print(f" Animation config: {config_label}")
|
print(f" Animation config: {config_label}")
|
||||||
for warning in config_warnings:
|
for warning in config_warnings:
|
||||||
print(f" [warn] {warning}")
|
print(f" [warn] {warning}")
|
||||||
|
elif args.no_animations and verbose:
|
||||||
|
print(" Animations: disabled")
|
||||||
|
|
||||||
defaults = animation_config.get('defaults', {}) if animation_config else {}
|
defaults = animation_config.get('defaults', {}) if animation_config else {}
|
||||||
transition_defaults = _as_dict(defaults.get('transition')) if isinstance(defaults, dict) else {}
|
transition_defaults = _as_dict(defaults.get('transition')) if isinstance(defaults, dict) else {}
|
||||||
@@ -1198,10 +1269,14 @@ Recorded narration:
|
|||||||
|
|
||||||
transition_arg = args.transition
|
transition_arg = args.transition
|
||||||
transition_effect = (
|
transition_effect = (
|
||||||
|
'none'
|
||||||
|
if args.no_animations
|
||||||
|
else (
|
||||||
transition_arg
|
transition_arg
|
||||||
if transition_arg is not None
|
if transition_arg is not None
|
||||||
else transition_defaults.get('effect', 'fade')
|
else transition_defaults.get('effect', 'fade')
|
||||||
)
|
)
|
||||||
|
)
|
||||||
transition = None if transition_effect == 'none' else transition_effect
|
transition = None if transition_effect == 'none' else transition_effect
|
||||||
try:
|
try:
|
||||||
transition_duration = validate_seconds(
|
transition_duration = validate_seconds(
|
||||||
@@ -1230,6 +1305,9 @@ Recorded narration:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
animation_effect = (
|
animation_effect = (
|
||||||
|
'none'
|
||||||
|
if args.no_animations
|
||||||
|
else (
|
||||||
args.animation
|
args.animation
|
||||||
if args.animation is not None
|
if args.animation is not None
|
||||||
# Per-element entrance is opt-in by default: auto-firing element builds
|
# Per-element entrance is opt-in by default: auto-firing element builds
|
||||||
@@ -1237,6 +1315,7 @@ Recorded narration:
|
|||||||
# on (see transition default above). Re-enable with -a auto / animations.json.
|
# on (see transition default above). Re-enable with -a auto / animations.json.
|
||||||
else animation_defaults.get('effect', 'none')
|
else animation_defaults.get('effect', 'none')
|
||||||
)
|
)
|
||||||
|
)
|
||||||
animation = normalize_animation_effect(animation_effect)
|
animation = normalize_animation_effect(animation_effect)
|
||||||
animation_duration = validate_seconds(
|
animation_duration = validate_seconds(
|
||||||
(
|
(
|
||||||
|
|||||||
+466
-2
@@ -2,10 +2,36 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SUBTITLE_MAX_CHARS = 20
|
||||||
|
DEFAULT_BOUNDARY_OVERLAP_TOLERANCE_MS = 100
|
||||||
|
_TICKS_PER_MILLISECOND = 10_000
|
||||||
|
_SENTENCE_END = frozenset("。!?!?")
|
||||||
|
_CLAUSE_END = frozenset(",,;;::")
|
||||||
|
_CLOSING_PUNCTUATION = frozenset('”’」』)》)"\'')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _MappedWord:
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
source_start: int
|
||||||
|
source_end: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SubtitleCue:
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
COMMON_VOICES = [
|
COMMON_VOICES = [
|
||||||
("zh-CN", "zh-CN-XiaoxiaoNeural", "女声,普通话,清晰自然,默认推荐"),
|
("zh-CN", "zh-CN-XiaoxiaoNeural", "女声,普通话,清晰自然,默认推荐"),
|
||||||
("zh-CN", "zh-CN-XiaoyiNeural", "女声,普通话,明亮"),
|
("zh-CN", "zh-CN-XiaoyiNeural", "女声,普通话,明亮"),
|
||||||
@@ -43,7 +69,27 @@ def normalize_rate(rate: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
async def generate(text: str, output_path: Path, *, voice: str, rate: str) -> None:
|
async def generate(
|
||||||
|
text: str,
|
||||||
|
output_path: Path,
|
||||||
|
*,
|
||||||
|
voice: str,
|
||||||
|
rate: str,
|
||||||
|
subtitle_path: Path | None = None,
|
||||||
|
subtitle_max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
|
||||||
|
) -> None:
|
||||||
|
"""Generate narration audio and, when requested, its compact SRT."""
|
||||||
|
if subtitle_path is not None:
|
||||||
|
await _generate_with_subtitles(
|
||||||
|
text,
|
||||||
|
output_path,
|
||||||
|
subtitle_path,
|
||||||
|
voice=voice,
|
||||||
|
rate=rate,
|
||||||
|
max_chars=subtitle_max_chars,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import edge_tts
|
import edge_tts
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -56,6 +102,425 @@ async def generate(text: str, output_path: Path, *, voice: str, rate: str) -> No
|
|||||||
await communicate.save(str(output_path))
|
await communicate.save(str(output_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _temporary_path(target: Path, suffix: str) -> tuple[int, Path]:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, raw_path = tempfile.mkstemp(
|
||||||
|
prefix=f".{target.name}.",
|
||||||
|
suffix=suffix,
|
||||||
|
dir=target.parent,
|
||||||
|
)
|
||||||
|
return descriptor, Path(raw_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_pair(
|
||||||
|
staged_audio: Path,
|
||||||
|
output_path: Path,
|
||||||
|
staged_subtitle: Path,
|
||||||
|
subtitle_path: Path,
|
||||||
|
) -> None:
|
||||||
|
targets = (output_path, subtitle_path)
|
||||||
|
if output_path.resolve() == subtitle_path.resolve():
|
||||||
|
raise ValueError("audio and subtitle outputs must use different paths")
|
||||||
|
|
||||||
|
backups: dict[Path, Path] = {}
|
||||||
|
published: set[Path] = set()
|
||||||
|
try:
|
||||||
|
for target in targets:
|
||||||
|
if not target.exists():
|
||||||
|
continue
|
||||||
|
descriptor, backup = _temporary_path(target, ".bak")
|
||||||
|
os.close(descriptor)
|
||||||
|
backup.unlink()
|
||||||
|
os.replace(target, backup)
|
||||||
|
backups[target] = backup
|
||||||
|
|
||||||
|
for staged, target in (
|
||||||
|
(staged_audio, output_path),
|
||||||
|
(staged_subtitle, subtitle_path),
|
||||||
|
):
|
||||||
|
os.replace(staged, target)
|
||||||
|
published.add(target)
|
||||||
|
except Exception:
|
||||||
|
for target in published:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
for target, backup in backups.items():
|
||||||
|
if backup.exists():
|
||||||
|
os.replace(backup, target)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
staged_audio.unlink(missing_ok=True)
|
||||||
|
staged_subtitle.unlink(missing_ok=True)
|
||||||
|
for backup in backups.values():
|
||||||
|
backup.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _text_key(text: str) -> str:
|
||||||
|
return "".join(character.casefold() for character in text if character.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
def _source_key_positions(text: str) -> tuple[str, list[int]]:
|
||||||
|
key: list[str] = []
|
||||||
|
positions: list[int] = []
|
||||||
|
for index, character in enumerate(text):
|
||||||
|
if not character.isalnum():
|
||||||
|
continue
|
||||||
|
normalized = character.casefold()
|
||||||
|
key.extend(normalized)
|
||||||
|
positions.extend([index] * len(normalized))
|
||||||
|
return "".join(key), positions
|
||||||
|
|
||||||
|
|
||||||
|
def _map_word_boundaries(text: str, boundaries: list[dict]) -> list[_MappedWord]:
|
||||||
|
source_key, source_positions = _source_key_positions(text)
|
||||||
|
boundary_keys = [_text_key(boundary["text"]) for boundary in boundaries]
|
||||||
|
boundary_key = "".join(boundary_keys)
|
||||||
|
if not source_key or source_key != boundary_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Edge TTS word boundaries could not be aligned with the narration text; "
|
||||||
|
"subtitle timing was not generated"
|
||||||
|
)
|
||||||
|
|
||||||
|
mapped: list[_MappedWord] = []
|
||||||
|
key_offset = 0
|
||||||
|
for boundary, word_key in zip(boundaries, boundary_keys):
|
||||||
|
if not word_key:
|
||||||
|
continue
|
||||||
|
key_end = key_offset + len(word_key)
|
||||||
|
mapped.append(
|
||||||
|
_MappedWord(
|
||||||
|
start=boundary["offset"],
|
||||||
|
end=boundary["offset"] + boundary["duration"],
|
||||||
|
source_start=source_positions[key_offset],
|
||||||
|
source_end=source_positions[key_end - 1] + 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
key_offset = key_end
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_span(text: str, start: int, end: int) -> tuple[int, int]:
|
||||||
|
while start < end and text[start].isspace():
|
||||||
|
start += 1
|
||||||
|
while end > start and text[end - 1].isspace():
|
||||||
|
end -= 1
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _display_length(text: str, start: int, end: int) -> int:
|
||||||
|
return sum(not character.isspace() for character in text[start:end])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sentence_end(text: str, index: int) -> bool:
|
||||||
|
character = text[index]
|
||||||
|
if character in _SENTENCE_END:
|
||||||
|
return True
|
||||||
|
if character != ".":
|
||||||
|
return False
|
||||||
|
previous = text[index - 1] if index else ""
|
||||||
|
following = text[index + 1] if index + 1 < len(text) else ""
|
||||||
|
return not (previous.isdigit() and following.isdigit())
|
||||||
|
|
||||||
|
|
||||||
|
def _sentence_spans(text: str) -> list[tuple[int, int]]:
|
||||||
|
spans: list[tuple[int, int]] = []
|
||||||
|
start = 0
|
||||||
|
index = 0
|
||||||
|
while index < len(text):
|
||||||
|
if not _is_sentence_end(text, index):
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
end = index + 1
|
||||||
|
while end < len(text) and text[end] in _CLOSING_PUNCTUATION:
|
||||||
|
end += 1
|
||||||
|
span = _trim_span(text, start, end)
|
||||||
|
if span[0] < span[1]:
|
||||||
|
spans.append(span)
|
||||||
|
start = end
|
||||||
|
index = end
|
||||||
|
span = _trim_span(text, start, len(text))
|
||||||
|
if span[0] < span[1]:
|
||||||
|
spans.append(span)
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def _hard_split_span(
|
||||||
|
text: str,
|
||||||
|
span: tuple[int, int],
|
||||||
|
words: list[_MappedWord],
|
||||||
|
max_chars: int,
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
start, end = span
|
||||||
|
parts: list[tuple[int, int]] = []
|
||||||
|
while _display_length(text, start, end) > max_chars:
|
||||||
|
remaining_length = _display_length(text, start, end)
|
||||||
|
remaining_parts = (remaining_length + max_chars - 1) // max_chars
|
||||||
|
target_length = (remaining_length + remaining_parts - 1) // remaining_parts
|
||||||
|
candidates = [
|
||||||
|
(word.source_end, _display_length(text, start, word.source_end))
|
||||||
|
for word in words
|
||||||
|
if start < word.source_end < end
|
||||||
|
and _display_length(text, start, word.source_end) <= max_chars
|
||||||
|
]
|
||||||
|
if candidates:
|
||||||
|
split_at, _ = min(
|
||||||
|
candidates,
|
||||||
|
key=lambda candidate: (
|
||||||
|
abs(candidate[1] - target_length),
|
||||||
|
-candidate[1],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
split_at = next(
|
||||||
|
(
|
||||||
|
word.source_end
|
||||||
|
for word in words
|
||||||
|
if start < word.source_end < end
|
||||||
|
),
|
||||||
|
end,
|
||||||
|
)
|
||||||
|
if split_at >= end:
|
||||||
|
break
|
||||||
|
part = _trim_span(text, start, split_at)
|
||||||
|
if part[0] < part[1]:
|
||||||
|
parts.append(part)
|
||||||
|
start = split_at
|
||||||
|
part = _trim_span(text, start, end)
|
||||||
|
if part[0] < part[1]:
|
||||||
|
parts.append(part)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sentence_span(
|
||||||
|
text: str,
|
||||||
|
sentence: tuple[int, int],
|
||||||
|
words: list[_MappedWord],
|
||||||
|
max_chars: int,
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
if _display_length(text, *sentence) <= max_chars:
|
||||||
|
return [sentence]
|
||||||
|
|
||||||
|
start, end = sentence
|
||||||
|
clauses: list[tuple[int, int]] = []
|
||||||
|
clause_start = start
|
||||||
|
for index in range(start, end):
|
||||||
|
if text[index] not in _CLAUSE_END:
|
||||||
|
continue
|
||||||
|
clause = _trim_span(text, clause_start, index + 1)
|
||||||
|
if clause[0] < clause[1]:
|
||||||
|
clauses.append(clause)
|
||||||
|
clause_start = index + 1
|
||||||
|
clause = _trim_span(text, clause_start, end)
|
||||||
|
if clause[0] < clause[1]:
|
||||||
|
clauses.append(clause)
|
||||||
|
|
||||||
|
atoms = [
|
||||||
|
part
|
||||||
|
for clause in clauses
|
||||||
|
for part in _hard_split_span(text, clause, words, max_chars)
|
||||||
|
]
|
||||||
|
merged: list[tuple[int, int]] = []
|
||||||
|
for atom in atoms:
|
||||||
|
if not merged:
|
||||||
|
merged.append(atom)
|
||||||
|
continue
|
||||||
|
candidate = (merged[-1][0], atom[1])
|
||||||
|
if _display_length(text, *candidate) <= max_chars:
|
||||||
|
merged[-1] = candidate
|
||||||
|
else:
|
||||||
|
merged.append(atom)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_small_overlaps(
|
||||||
|
cues: list[_SubtitleCue],
|
||||||
|
*,
|
||||||
|
tolerance_ms: int = DEFAULT_BOUNDARY_OVERLAP_TOLERANCE_MS,
|
||||||
|
) -> list[_SubtitleCue]:
|
||||||
|
tolerance = tolerance_ms * _TICKS_PER_MILLISECOND
|
||||||
|
normalized: list[_SubtitleCue] = []
|
||||||
|
for cue in cues:
|
||||||
|
if normalized and cue.start < normalized[-1].end:
|
||||||
|
overlap = normalized[-1].end - cue.start
|
||||||
|
if overlap > tolerance:
|
||||||
|
overlap_ms = overlap / _TICKS_PER_MILLISECOND
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Edge TTS returned {overlap_ms:g} ms of overlapping "
|
||||||
|
"word-boundary timing; audio and subtitles were not published"
|
||||||
|
)
|
||||||
|
cue = _SubtitleCue(
|
||||||
|
start=normalized[-1].end,
|
||||||
|
end=cue.end,
|
||||||
|
text=cue.text,
|
||||||
|
)
|
||||||
|
if cue.end <= cue.start:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Edge TTS returned an invalid subtitle timing interval; "
|
||||||
|
"audio and subtitles were not published"
|
||||||
|
)
|
||||||
|
normalized.append(cue)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _subtitle_cues(
|
||||||
|
text: str,
|
||||||
|
boundaries: list[dict],
|
||||||
|
max_chars: int,
|
||||||
|
) -> list[_SubtitleCue]:
|
||||||
|
if max_chars < 1:
|
||||||
|
raise ValueError("subtitle_max_chars must be at least 1")
|
||||||
|
words = _map_word_boundaries(text, boundaries)
|
||||||
|
spans = [
|
||||||
|
span
|
||||||
|
for sentence in _sentence_spans(text)
|
||||||
|
for span in _split_sentence_span(text, sentence, words, max_chars)
|
||||||
|
]
|
||||||
|
|
||||||
|
pending: list[tuple[int, int, str]] = []
|
||||||
|
assigned_word_indexes: list[int] = []
|
||||||
|
for start, end in spans:
|
||||||
|
matching = [
|
||||||
|
(index, word)
|
||||||
|
for index, word in enumerate(words)
|
||||||
|
if word.source_start >= start and word.source_end <= end
|
||||||
|
]
|
||||||
|
if not matching:
|
||||||
|
continue
|
||||||
|
cue_text = re.sub(r"\s+", " ", text[start:end]).strip()
|
||||||
|
assigned_word_indexes.extend(index for index, _ in matching)
|
||||||
|
pending.append((matching[0][1].start, matching[-1][1].end, cue_text))
|
||||||
|
|
||||||
|
if assigned_word_indexes != list(range(len(words))):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Edge TTS word boundaries crossed subtitle split points; "
|
||||||
|
"subtitle timing was not generated"
|
||||||
|
)
|
||||||
|
if not pending:
|
||||||
|
raise RuntimeError("Edge TTS produced no timed subtitle cues")
|
||||||
|
|
||||||
|
cues: list[_SubtitleCue] = []
|
||||||
|
for index, (start, word_end, cue_text) in enumerate(pending):
|
||||||
|
next_start = pending[index + 1][0] if index + 1 < len(pending) else None
|
||||||
|
end = next_start if next_start is not None and next_start > word_end else word_end
|
||||||
|
cues.append(_SubtitleCue(start=start, end=end, text=cue_text))
|
||||||
|
cues = _clamp_small_overlaps(cues)
|
||||||
|
|
||||||
|
source_text = re.sub(r"\s+", "", text)
|
||||||
|
subtitle_text = re.sub(r"\s+", "", "".join(cue.text for cue in cues))
|
||||||
|
if subtitle_text != source_text:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Generated subtitle text does not match the narration text; "
|
||||||
|
"audio and subtitles were not published"
|
||||||
|
)
|
||||||
|
if any(_display_length(cue.text, 0, len(cue.text)) > max_chars for cue in cues):
|
||||||
|
raise RuntimeError(
|
||||||
|
"A single Edge TTS word boundary exceeds the subtitle character limit; "
|
||||||
|
"audio and subtitles were not published"
|
||||||
|
)
|
||||||
|
return cues
|
||||||
|
|
||||||
|
|
||||||
|
def _srt_timestamp(ticks: int) -> str:
|
||||||
|
total_milliseconds = round(ticks / 10_000)
|
||||||
|
hours, remainder = divmod(total_milliseconds, 3_600_000)
|
||||||
|
minutes, remainder = divmod(remainder, 60_000)
|
||||||
|
seconds, milliseconds = divmod(remainder, 1_000)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_srt(cues: list[_SubtitleCue]) -> str:
|
||||||
|
blocks = [
|
||||||
|
(
|
||||||
|
f"{index}\n"
|
||||||
|
f"{_srt_timestamp(cue.start)} --> {_srt_timestamp(cue.end)}\n"
|
||||||
|
f"{cue.text}"
|
||||||
|
)
|
||||||
|
for index, cue in enumerate(cues, 1)
|
||||||
|
]
|
||||||
|
return "\n\n".join(blocks) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_with_subtitles(
|
||||||
|
text: str,
|
||||||
|
output_path: Path,
|
||||||
|
subtitle_path: Path,
|
||||||
|
*,
|
||||||
|
voice: str,
|
||||||
|
rate: str,
|
||||||
|
max_chars: int,
|
||||||
|
) -> None:
|
||||||
|
"""Generate one MP3 and compact SRT from the same Edge word-timing stream."""
|
||||||
|
try:
|
||||||
|
import edge_tts
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Missing dependency `edge-tts`. Install it with: "
|
||||||
|
"python3 -m pip install edge-tts"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
communicate = edge_tts.Communicate(
|
||||||
|
text,
|
||||||
|
voice=voice,
|
||||||
|
rate=normalize_rate(rate),
|
||||||
|
boundary="WordBoundary",
|
||||||
|
)
|
||||||
|
audio_descriptor = -1
|
||||||
|
subtitle_descriptor = -1
|
||||||
|
staged_audio: Path | None = None
|
||||||
|
staged_subtitle: Path | None = None
|
||||||
|
boundaries: list[dict] = []
|
||||||
|
received_audio = False
|
||||||
|
try:
|
||||||
|
audio_descriptor, staged_audio = _temporary_path(output_path, ".tmp")
|
||||||
|
subtitle_descriptor, staged_subtitle = _temporary_path(subtitle_path, ".tmp")
|
||||||
|
|
||||||
|
audio_stream = os.fdopen(audio_descriptor, "wb")
|
||||||
|
audio_descriptor = -1
|
||||||
|
with audio_stream:
|
||||||
|
async for chunk in communicate.stream():
|
||||||
|
if chunk["type"] == "audio":
|
||||||
|
audio_stream.write(chunk["data"])
|
||||||
|
received_audio = True
|
||||||
|
elif chunk["type"] == "WordBoundary":
|
||||||
|
boundaries.append(chunk)
|
||||||
|
audio_stream.flush()
|
||||||
|
os.fsync(audio_stream.fileno())
|
||||||
|
|
||||||
|
if not received_audio:
|
||||||
|
raise RuntimeError("Edge TTS returned no audio data")
|
||||||
|
if not boundaries:
|
||||||
|
raise RuntimeError("Edge TTS returned no word-boundary timing")
|
||||||
|
subtitle_text = _format_srt(_subtitle_cues(text, boundaries, max_chars))
|
||||||
|
|
||||||
|
subtitle_stream = os.fdopen(
|
||||||
|
subtitle_descriptor,
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
subtitle_descriptor = -1
|
||||||
|
with subtitle_stream:
|
||||||
|
subtitle_stream.write(subtitle_text)
|
||||||
|
subtitle_stream.flush()
|
||||||
|
os.fsync(subtitle_stream.fileno())
|
||||||
|
assert staged_audio is not None
|
||||||
|
assert staged_subtitle is not None
|
||||||
|
_publish_pair(
|
||||||
|
staged_audio,
|
||||||
|
output_path,
|
||||||
|
staged_subtitle,
|
||||||
|
subtitle_path,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if audio_descriptor >= 0:
|
||||||
|
os.close(audio_descriptor)
|
||||||
|
if subtitle_descriptor >= 0:
|
||||||
|
os.close(subtitle_descriptor)
|
||||||
|
if staged_audio is not None:
|
||||||
|
staged_audio.unlink(missing_ok=True)
|
||||||
|
if staged_subtitle is not None:
|
||||||
|
staged_subtitle.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def print_common_voices() -> None:
|
def print_common_voices() -> None:
|
||||||
print("Common edge-tts voices:")
|
print("Common edge-tts voices:")
|
||||||
print("Locale Voice Notes")
|
print("Locale Voice Notes")
|
||||||
@@ -83,4 +548,3 @@ async def print_voices(locale: str | None = None) -> None:
|
|||||||
gender = voice.get("Gender", "")
|
gender = voice.get("Gender", "")
|
||||||
friendly = voice.get("FriendlyName", "")
|
friendly = voice.get("FriendlyName", "")
|
||||||
print(f"{voice_locale:<8} {short_name:<34} {gender:<8} {friendly}")
|
print(f"{voice_locale:<8} {short_name:<34} {gender:<8} {friendly}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PPT Master - Final Video Subtitles
|
||||||
|
|
||||||
|
Align the exact narration text frozen in page-local Edge 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.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/video_subtitles.py <project_path> --video <video> --language <language>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 scripts/video_subtitles.py projects/demo --video exports/demo.mp4 --language zh --force
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
ffmpeg
|
||||||
|
python3 -m pip install stable-ts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from console_encoding import configure_utf8_stdio
|
||||||
|
|
||||||
|
configure_utf8_stdio()
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MAX_CHARS = 20
|
||||||
|
_TIMING_RE = re.compile(
|
||||||
|
r"^(?P<start>\d+:\d{2}:\d{2},\d{3})\s+-->\s+"
|
||||||
|
r"(?P<end>\d+:\d{2}:\d{2},\d{3})(?:\s+.*)?$"
|
||||||
|
)
|
||||||
|
_CLAUSE_END = frozenset(",,;;::")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubtitleCue:
|
||||||
|
"""One validated SRT cue."""
|
||||||
|
|
||||||
|
start_ms: int
|
||||||
|
end_ms: int
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_to_ms(value: str) -> int:
|
||||||
|
hours_text, minutes_text, remainder = value.split(":")
|
||||||
|
seconds_text, milliseconds_text = remainder.split(",")
|
||||||
|
hours = int(hours_text)
|
||||||
|
minutes = int(minutes_text)
|
||||||
|
seconds = int(seconds_text)
|
||||||
|
milliseconds = int(milliseconds_text)
|
||||||
|
if minutes >= 60 or seconds >= 60:
|
||||||
|
raise ValueError(f"Invalid SRT timestamp: {value}")
|
||||||
|
return (((hours * 60) + minutes) * 60 + seconds) * 1000 + milliseconds
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_srt(path: Path) -> list[SubtitleCue]:
|
||||||
|
"""Read one strict, non-overlapping SRT file."""
|
||||||
|
text = path.read_text(encoding="utf-8-sig")
|
||||||
|
blocks = re.split(r"\r?\n\s*\r?\n", text.strip())
|
||||||
|
cues: list[SubtitleCue] = []
|
||||||
|
previous_end = -1
|
||||||
|
|
||||||
|
for block_number, block in enumerate(blocks, 1):
|
||||||
|
lines = block.splitlines()
|
||||||
|
if len(lines) < 3:
|
||||||
|
raise ValueError(f"{path}: malformed SRT block {block_number}")
|
||||||
|
try:
|
||||||
|
cue_number = int(lines[0].strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"{path}: invalid cue number in block {block_number}"
|
||||||
|
) from exc
|
||||||
|
if cue_number != block_number:
|
||||||
|
raise ValueError(
|
||||||
|
f"{path}: cue numbers must be consecutive from 1; "
|
||||||
|
f"block {block_number} is numbered {cue_number}"
|
||||||
|
)
|
||||||
|
timing_match = _TIMING_RE.match(lines[1].strip())
|
||||||
|
if timing_match is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{path}: invalid cue timing in block {block_number}"
|
||||||
|
)
|
||||||
|
start_ms = _timestamp_to_ms(timing_match.group("start"))
|
||||||
|
end_ms = _timestamp_to_ms(timing_match.group("end"))
|
||||||
|
cue_text = re.sub(r"\s+", " ", " ".join(lines[2:])).strip()
|
||||||
|
if not cue_text:
|
||||||
|
raise ValueError(f"{path}: empty cue text in block {block_number}")
|
||||||
|
if end_ms <= start_ms:
|
||||||
|
raise ValueError(
|
||||||
|
f"{path}: cue {block_number} must end after it starts"
|
||||||
|
)
|
||||||
|
if start_ms < previous_end:
|
||||||
|
raise ValueError(
|
||||||
|
f"{path}: cue {block_number} overlaps the preceding cue"
|
||||||
|
)
|
||||||
|
cues.append(SubtitleCue(start_ms, end_ms, cue_text))
|
||||||
|
previous_end = end_ms
|
||||||
|
|
||||||
|
if not cues:
|
||||||
|
raise ValueError(f"No subtitle cues found: {path}")
|
||||||
|
return cues
|
||||||
|
|
||||||
|
|
||||||
|
def _display_length(text: str) -> int:
|
||||||
|
return sum(not character.isspace() for character in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _hard_split(text: str, max_chars: int) -> list[str]:
|
||||||
|
"""Split an overlong clause without dropping characters."""
|
||||||
|
output: list[str] = []
|
||||||
|
remaining = text.strip()
|
||||||
|
while _display_length(remaining) > max_chars:
|
||||||
|
visible = 0
|
||||||
|
split_at = 0
|
||||||
|
whitespace_split = 0
|
||||||
|
for index, character in enumerate(remaining, 1):
|
||||||
|
if character.isspace():
|
||||||
|
whitespace_split = index
|
||||||
|
continue
|
||||||
|
visible += 1
|
||||||
|
if visible > max_chars:
|
||||||
|
break
|
||||||
|
split_at = index
|
||||||
|
if whitespace_split and whitespace_split <= split_at:
|
||||||
|
split_at = whitespace_split
|
||||||
|
if split_at <= 0:
|
||||||
|
raise ValueError("Unable to split an overlong subtitle clause")
|
||||||
|
output.append(remaining[:split_at].strip())
|
||||||
|
remaining = remaining[split_at:].strip()
|
||||||
|
if remaining:
|
||||||
|
output.append(remaining)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sentence(text: str, max_chars: int) -> list[str]:
|
||||||
|
"""Keep one sentence unless its display length requires clause splitting."""
|
||||||
|
sentence = re.sub(r"\s+", " ", text).strip()
|
||||||
|
if _display_length(sentence) <= max_chars:
|
||||||
|
return [sentence]
|
||||||
|
|
||||||
|
clauses: list[str] = []
|
||||||
|
start = 0
|
||||||
|
for index, character in enumerate(sentence):
|
||||||
|
if character not in _CLAUSE_END:
|
||||||
|
continue
|
||||||
|
clause = sentence[start:index + 1].strip()
|
||||||
|
if clause:
|
||||||
|
clauses.append(clause)
|
||||||
|
start = index + 1
|
||||||
|
tail = sentence[start:].strip()
|
||||||
|
if tail:
|
||||||
|
clauses.append(tail)
|
||||||
|
|
||||||
|
atoms = [
|
||||||
|
part
|
||||||
|
for clause in clauses
|
||||||
|
for part in _hard_split(clause, max_chars)
|
||||||
|
]
|
||||||
|
output: list[str] = []
|
||||||
|
for atom in atoms:
|
||||||
|
if not output:
|
||||||
|
output.append(atom)
|
||||||
|
continue
|
||||||
|
candidate = f"{output[-1]}{atom}"
|
||||||
|
if _display_length(candidate) <= max_chars:
|
||||||
|
output[-1] = candidate
|
||||||
|
else:
|
||||||
|
output.append(atom)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _page_subtitle_paths(subtitle_dir: Path) -> list[Path]:
|
||||||
|
"""Resolve the ordered page-local SRT set without reading notes."""
|
||||||
|
paths = [
|
||||||
|
path
|
||||||
|
for path in sorted(subtitle_dir.glob("*.srt"))
|
||||||
|
if path.stem != "total"
|
||||||
|
]
|
||||||
|
if not paths:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No page-local Edge SRT files found under {subtitle_dir}"
|
||||||
|
)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _frozen_transcript_lines(
|
||||||
|
subtitle_dir: Path,
|
||||||
|
max_chars: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return display lines derived from the exact text used for TTS."""
|
||||||
|
lines: list[str] = []
|
||||||
|
for path in _page_subtitle_paths(subtitle_dir):
|
||||||
|
for cue in _parse_srt(path):
|
||||||
|
lines.extend(_split_sentence(cue.text, max_chars))
|
||||||
|
if not lines:
|
||||||
|
raise ValueError("The page-local SRT set contains no narration text")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _require_stable_whisper() -> Any:
|
||||||
|
try:
|
||||||
|
import stable_whisper
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Final-video subtitle alignment requires stable-ts. "
|
||||||
|
"Install it with: python3 -m pip install stable-ts"
|
||||||
|
) from exc
|
||||||
|
return stable_whisper
|
||||||
|
|
||||||
|
|
||||||
|
def _text_key(text: str) -> str:
|
||||||
|
return "".join(character for character in text if not character.isspace())
|
||||||
|
|
||||||
|
|
||||||
|
def align_video_subtitles(
|
||||||
|
*,
|
||||||
|
video_path: Path,
|
||||||
|
subtitle_dir: Path,
|
||||||
|
output_path: Path,
|
||||||
|
language: str,
|
||||||
|
model_name: str,
|
||||||
|
device: str | None,
|
||||||
|
max_chars: int,
|
||||||
|
force: bool,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Align frozen narration text to the final video's actual audio track."""
|
||||||
|
if not video_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Finished video does not exist: {video_path}")
|
||||||
|
if shutil.which("ffmpeg") is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Final-video subtitle alignment requires ffmpeg on PATH"
|
||||||
|
)
|
||||||
|
if max_chars < 1:
|
||||||
|
raise ValueError("max_chars must be at least 1")
|
||||||
|
if output_path.resolve() == video_path.resolve():
|
||||||
|
raise ValueError("Subtitle output must not overwrite the finished video")
|
||||||
|
if output_path.exists() and not force:
|
||||||
|
raise FileExistsError(
|
||||||
|
f"Output already exists: {output_path}; pass --force to replace it"
|
||||||
|
)
|
||||||
|
|
||||||
|
transcript_lines = _frozen_transcript_lines(
|
||||||
|
subtitle_dir,
|
||||||
|
max_chars,
|
||||||
|
)
|
||||||
|
transcript = "\n".join(transcript_lines)
|
||||||
|
stable_whisper = _require_stable_whisper()
|
||||||
|
load_options: dict[str, Any] = {}
|
||||||
|
if device:
|
||||||
|
load_options["device"] = device
|
||||||
|
model = stable_whisper.load_model(model_name, **load_options)
|
||||||
|
result = model.align(
|
||||||
|
str(video_path),
|
||||||
|
transcript,
|
||||||
|
language=language,
|
||||||
|
original_split=True,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError("stable-ts could not align the narration transcript")
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{output_path.stem}.",
|
||||||
|
suffix=".srt",
|
||||||
|
dir=str(output_path.parent),
|
||||||
|
)
|
||||||
|
os.close(descriptor)
|
||||||
|
temporary_path = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
result.to_srt_vtt(
|
||||||
|
str(temporary_path),
|
||||||
|
segment_level=True,
|
||||||
|
word_level=False,
|
||||||
|
)
|
||||||
|
output_cues = _parse_srt(temporary_path)
|
||||||
|
output_text = _text_key("".join(cue.text for cue in output_cues))
|
||||||
|
expected_text = _text_key("".join(transcript_lines))
|
||||||
|
if output_text != expected_text:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Aligned subtitle text differs from the frozen TTS transcript; "
|
||||||
|
"the final SRT was not published"
|
||||||
|
)
|
||||||
|
if len(output_cues) != len(transcript_lines):
|
||||||
|
raise RuntimeError(
|
||||||
|
"stable-ts did not preserve the requested sentence/line "
|
||||||
|
"boundaries; the final SRT was not published"
|
||||||
|
)
|
||||||
|
os.replace(temporary_path, output_path)
|
||||||
|
finally:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
return len(transcript_lines), output_cues[-1].end_ms
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("project_path", type=Path, help="Project directory")
|
||||||
|
parser.add_argument(
|
||||||
|
"--video",
|
||||||
|
required=True,
|
||||||
|
help="Finished PowerPoint-exported video; relative paths are project-relative",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--language",
|
||||||
|
required=True,
|
||||||
|
help="Narration language passed to stable-ts, e.g. zh, en, ja, or ko",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--subtitle-dir",
|
||||||
|
default=None,
|
||||||
|
help="Page-local Edge SRT directory; default: <project>/notes/subtitles",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
default=None,
|
||||||
|
help="Final SRT path; default: beside the video with the same stem",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--model",
|
||||||
|
default="base",
|
||||||
|
help="Whisper model used only for forced alignment (default: base)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
default=None,
|
||||||
|
help="Optional stable-ts device, e.g. cpu or cuda",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-chars",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_MAX_CHARS,
|
||||||
|
help="Maximum non-space characters per final subtitle cue (default: 20)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Replace an existing final SRT",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
project_path = args.project_path.resolve()
|
||||||
|
if not project_path.is_dir():
|
||||||
|
parser.error(f"Project path does not exist: {project_path}")
|
||||||
|
|
||||||
|
video_path = Path(args.video)
|
||||||
|
if not video_path.is_absolute():
|
||||||
|
video_path = project_path / video_path
|
||||||
|
video_path = video_path.resolve()
|
||||||
|
subtitle_dir = (
|
||||||
|
Path(args.subtitle_dir)
|
||||||
|
if args.subtitle_dir
|
||||||
|
else Path("notes/subtitles")
|
||||||
|
)
|
||||||
|
if not subtitle_dir.is_absolute():
|
||||||
|
subtitle_dir = project_path / subtitle_dir
|
||||||
|
output_path = Path(args.output) if args.output else video_path.with_suffix(".srt")
|
||||||
|
if not output_path.is_absolute():
|
||||||
|
output_path = project_path / output_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
cue_count, final_end_ms = align_video_subtitles(
|
||||||
|
video_path=video_path,
|
||||||
|
subtitle_dir=subtitle_dir.resolve(),
|
||||||
|
output_path=output_path.resolve(),
|
||||||
|
language=args.language,
|
||||||
|
model_name=args.model,
|
||||||
|
device=args.device,
|
||||||
|
max_chars=args.max_chars,
|
||||||
|
force=args.force,
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(output_path.resolve())
|
||||||
|
print(
|
||||||
|
f"Aligned {cue_count} final-video subtitle cue(s); "
|
||||||
|
f"last cue ends at {final_end_ms / 1000:.3f}s",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -410,7 +410,7 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --stage final
|
|||||||
- The JSON report is written to `validation/svg_quality_report.json`. `inherited` prototype diagnostics and `source-import` compatibility losses are informational provenance; only changed/new warnings remain `introduced`, and all release-blocking failures remain `blocking`.
|
- The JSON report is written to `validation/svg_quality_report.json`. `inherited` prototype diagnostics and `source-import` compatibility losses are informational provenance; only changed/new warnings remain `introduced`, and all release-blocking failures remain `blocking`.
|
||||||
- **Hard rule — token-safe report handling**: On a successful checker run, use the exit status and terminal summary as gate evidence. Do not open, `cat`, or otherwise load the complete JSON report into model context. Read it only for failure investigation, an explicit audit request, or a field absent from stdout; extract only the required field(s).
|
- **Hard rule — token-safe report handling**: On a successful checker run, use the exit status and terminal summary as gate evidence. Do not open, `cat`, or otherwise load the complete JSON report into model context. Read it only for failure investigation, an explicit audit request, or a field absent from stdout; extract only the required field(s).
|
||||||
|
|
||||||
**Logic Construction Phase**: after the SVG quality gate passes, load [`executor-notes.md`](../references/executor-notes.md) and generate speaker notes → `<project_path>/notes/total.md`
|
**Logic Construction Phase**: after the SVG quality gate passes, load [`executor-notes.md`](../references/executor-notes.md), ground each page's narration in all information-bearing content in its final SVG, and generate speaker notes → `<project_path>/notes/total.md`
|
||||||
|
|
||||||
**✅ Internal checkpoint — execution complete**: verify live preview timing, the P01 method gate, uninterrupted remaining-page generation, consolidated repair of any complete failure set, exact §IX roster coverage, one-frame prose wrapping, a final checker result of 0 errors, and `notes/total.md`. Do not print this checklist. Run the applicable conditional gates below, then proceed to Step 7 under the compact status rule above.
|
**✅ Internal checkpoint — execution complete**: verify live preview timing, the P01 method gate, uninterrupted remaining-page generation, consolidated repair of any complete failure set, exact §IX roster coverage, one-frame prose wrapping, a final checker result of 0 errors, and `notes/total.md`. Do not print this checklist. Run the applicable conditional gates below, then proceed to Step 7 under the compact status rule above.
|
||||||
|
|
||||||
|
|||||||
+2
@@ -88,6 +88,8 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
|
|||||||
|
|
||||||
**Per-page motion brief**: for each slide, decide transition effect, transition duration, object reveal sequence, object effects, and timing. Use `design_spec.md` for slide role, `spec_lock.md` for rhythm, speaker notes for narration order, and SVG group ids for target validity.
|
**Per-page motion brief**: for each slide, decide transition effect, transition duration, object reveal sequence, object effects, and timing. Use `design_spec.md` for slide role, `spec_lock.md` for rhythm, speaker notes for narration order, and SVG group ids for target validity.
|
||||||
|
|
||||||
|
**Title reveal decision**: when present, treat the page title as a first-class object in the per-page reveal plan, never an afterthought. Consciously choose one of — static (`effect: none`), immediate entrance, delayed entrance, entrance after the page's hero visual, synchronous with related content, or, when narration is part of the workflow, narration-cued — driven by the user's request, slide role, transition, and narration order. This stage uses the effect (§3.2), order, duration, and timing fields already defined below; narration-cued timing is realized later by the audio stage. It does not preset which choice a title uses. A real title must not drop out of the plan merely because its id resembles a legacy chrome name: use the documented sidecar override (§2 / §4) when animation is intended. Explicit structural or static markers remain authoritative; if they incorrectly mark a title that should animate, repair the SVG semantics before continuing.
|
||||||
|
|
||||||
**Hard rule**: a custom animation pass must not only edit group effects. It must also decide whether each slide should inherit the default transition or need a slide-specific `transition` override.
|
**Hard rule**: a custom animation pass must not only edit group effects. It must also decide whether each slide should inherit the default transition or need a slide-specific `transition` override.
|
||||||
|
|
||||||
**Timing guidance**: prefer content-aware durations when the deck has varied slide rhythm or object importance. Uniform timing is acceptable when it matches the user's requested style or the deck's pacing.
|
**Timing guidance**: prefer content-aware durations when the deck has varied slide rhythm or object importance. Uniform timing is acceptable when it matches the user's requested style or the deck's pacing.
|
||||||
|
|||||||
+80
-5
@@ -4,7 +4,7 @@ description: Shared post-processing stage for per-slide narration audio and opti
|
|||||||
|
|
||||||
# Generate Audio Stage
|
# Generate Audio Stage
|
||||||
|
|
||||||
> Shared narration stage. Run from the Generate PPTX route after notes/export readiness, or from the Enhance Native PPTX narration module after its notes step. Produces one audio file per slide via `edge-tts` by default, or a cloud TTS provider (`elevenlabs` / `minimax` / `qwen` / `cosyvoice`) when the user chooses high-quality narration or a cloned voice. The caller owns final PPTX integration.
|
> Shared narration stage. Run from the Generate PPTX route after notes/export readiness, or from the Enhance Native PPTX narration module after its notes step. By default, `edge-tts` produces one audio/SRT pair per slide from the same streaming request. Cloud TTS providers (`elevenlabs` / `minimax` / `qwen` / `cosyvoice`) currently produce audio only. The caller owns final PPTX integration.
|
||||||
|
|
||||||
This stage is **context-independent**: it reads `notes/*.md` and queries the selected TTS voice catalog, so either owning route may invoke it in a fresh session. It does not choose the top-level route and does not patch slide design.
|
This stage is **context-independent**: it reads `notes/*.md` and queries the selected TTS voice catalog, so either owning route may invoke it in a fresh session. It does not choose the top-level route and does not patch slide design.
|
||||||
|
|
||||||
@@ -12,9 +12,10 @@ This stage is **context-independent**: it reads `notes/*.md` and queries the sel
|
|||||||
|
|
||||||
- Per-page narration files exist at `notes/*.md`. In Generate PPTX, split `notes/total.md` during Step 7.1. In Enhance Native PPTX, the notes module writes numeric files such as `001.md`.
|
- Per-page narration files exist at `notes/*.md`. In Generate PPTX, split `notes/total.md` during Step 7.1. In Enhance Native PPTX, the notes module writes numeric files such as `001.md`.
|
||||||
- Default mode: `edge-tts` is installed (`python3 -m pip install edge-tts`).
|
- Default mode: `edge-tts` is installed (`python3 -m pip install edge-tts`).
|
||||||
- The stage is page-level only: one notes file becomes one audio file. Do not use a single long audio track or attempt automatic long-audio splitting.
|
- The stage is page-level only: with edge, one notes file becomes `audio/<stem>.mp3` plus `notes/subtitles/<stem>.srt`; with a cloud provider, it becomes one audio file. Do not use a single long audio track or attempt automatic long-audio splitting.
|
||||||
- PPT narration assets must be PowerPoint-reliable audio: `m4a` (AAC), `mp3`, or `wav`. The built-in TTS path defaults to `mp3`; provider formats such as `pcm`, `opus`, or `flac` must be transcoded before embedding.
|
- PPT narration assets must be PowerPoint-reliable audio: `m4a` (AAC), `mp3`, or `wav`. The built-in TTS path defaults to `mp3`; provider formats such as `pcm`, `opus`, or `flac` must be transcoded before embedding.
|
||||||
- PowerPoint recorded narration export requires `ffprobe` so slide timings can be written from actual audio duration.
|
- PowerPoint recorded narration export requires `ffprobe` so slide timings can be written from actual audio duration.
|
||||||
|
- Optional post-export video calibration requires `ffmpeg` plus `numpy`; it runs only when the caller supplies the finished PowerPoint video.
|
||||||
- High-quality cloud mode: provider API key is set before use:
|
- High-quality cloud mode: provider API key is set before use:
|
||||||
- ElevenLabs: `ELEVENLABS_API_KEY`
|
- ElevenLabs: `ELEVENLABS_API_KEY`
|
||||||
- MiniMax: `MINIMAX_API_KEY`
|
- MiniMax: `MINIMAX_API_KEY`
|
||||||
@@ -142,22 +143,92 @@ python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> \
|
|||||||
--provider cosyvoice --voice-id <chosen-voice> \
|
--provider cosyvoice --voice-id <chosen-voice> \
|
||||||
--cosyvoice-model cosyvoice-v3-flash
|
--cosyvoice-model cosyvoice-v3-flash
|
||||||
|
|
||||||
# 2A. Generate PPTX caller only: re-export with audio embedded
|
# 2A. Before derivation, author or refresh narration_timing.json by matching
|
||||||
|
# SVG group semantics to SRT topics while preserving animations.json behavior.
|
||||||
|
# Reuse current SVG group/content semantics already present in context;
|
||||||
|
# otherwise read only the missing or stale svg_output pages.
|
||||||
|
python3 skills/ppt-master/scripts/narration_sync.py animations <project_path> \
|
||||||
|
--narration-padding 0.5 --force
|
||||||
|
|
||||||
|
# 2B. Re-export with audio embedded
|
||||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
|
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
|
||||||
--recorded-narration audio
|
--no-merge --recorded-narration audio --narration-padding 0.5
|
||||||
|
|
||||||
|
# Optional: use the canonical presentation animation instead
|
||||||
|
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
|
||||||
|
--no-merge --recorded-narration audio --narration-padding 0.5 \
|
||||||
|
--animation-config animations.json
|
||||||
|
|
||||||
|
# Optional: export narration with no object or page-transition animation
|
||||||
|
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
|
||||||
|
--no-merge --recorded-narration audio --narration-padding 0.5 \
|
||||||
|
--no-animations
|
||||||
|
|
||||||
|
# 2C. Merge page-local SRT against timing values read from the final PPTX
|
||||||
|
python3 skills/ppt-master/scripts/narration_sync.py subtitles <project_path> \
|
||||||
|
--pptx <final_narrated_pptx> --force
|
||||||
|
|
||||||
|
# 2D. After PowerPoint exports the video, align the frozen narration text
|
||||||
|
# against the finished video's actual audio track
|
||||||
|
python3 skills/ppt-master/scripts/video_subtitles.py <project_path> \
|
||||||
|
--video <powerpoint_exported_video> --language <language> --force
|
||||||
```
|
```
|
||||||
|
|
||||||
If `notes_to_audio.py` errors with a missing dependency or missing provider API key, fix the prerequisite and re-run — do NOT swallow the error.
|
If `notes_to_audio.py` errors with a missing dependency or missing provider API key, fix the prerequisite and re-run — do NOT swallow the error.
|
||||||
|
|
||||||
|
The edge command writes each MP3 and its internal page SRT from the same `edge-tts` stream. SRT cues use the service's `WordBoundary` timing: sentence-ending punctuation always closes a cue; text over the default 20-visible-character limit first splits at commas, semicolons, or colons, then at the nearest word boundary. Override the limit with `--subtitle-max-chars`. Adjacent timing overlap up to 100 ms is tolerated by moving the later cue start to the previous cue end; larger overlap fails instead of silently distorting timing. Each SRT uses a page-local timeline whose origin is `00:00:00,000`, including any leading silence before the first cue. Cloud-provider commands currently write audio only.
|
||||||
|
|
||||||
|
**Mandatory — semantic animation context**: Before writing or refreshing `<project_path>/narration_timing.json`, determine whether the active context already contains the current top-level SVG group IDs and visible group-content semantics for every affected page. Reuse that context without rereading SVG when it is complete and still matches the current `svg_output/`. If any page is missing, stale, or represented only by group IDs/order without content meaning, read only that page's SVG as a read-only source and extract the missing group semantics. Always combine those semantics with the page SRT topics/timestamps and `animations.json`; group order alone is not a semantic narration mapping.
|
||||||
|
|
||||||
|
> Authoring `narration_timing.json` is not optional polish. When it is absent, `narration_sync.py animations` still runs but maps groups **positionally** (group N → subtitle cue N) and prints a warning listing at-risk slides (those with more cues than objects, where later objects reveal while the narrator is still on an earlier point). Treat that warning as a required-repair signal: author the semantic plan and re-derive.
|
||||||
|
|
||||||
|
**Narration animation ownership**: `animations.json` must already exist and remains read-only. The audio stage deep-copies it to `narration_animations.json`, preserves transitions, effects, durations, order, and explicit `effect: none`, then changes only the derived trigger/delay values needed for click-free narration playback. The authored `narration_timing.json` maps each animated content group to the SRT cue that speaks about that content. The command may still read an affected SVG page to resolve structural group order when a sparse sidecar cannot identify every effective group; this structural fallback does not replace the semantic-context step and never edits SVG, notes, or `animations.json`. Unmatched groups keep their canonical relative delay.
|
||||||
|
|
||||||
|
**Title timing handoff**: preserve the title reveal decision already made by the custom-animation pass. Assign a title group to an SRT cue only when the user's request or the active motion plan explicitly chose `narration-cued`; otherwise leave its `cue` omitted in `narration_timing.json` so it keeps the canonical relative delay from `animations.json`. Do not infer `narration-cued` merely because speaker notes mention the title.
|
||||||
|
|
||||||
|
**Narrated export animation selection**: `--recorded-narration` defaults to `<project_path>/narration_animations.json` and fails with a repair hint when that file is missing. Pass `--animation-config animations.json` to keep the canonical presentation animation, or `--no-animations` to disable both object animations and page-transition motion while preserving narration audio and recorded slide-advance timings. Non-narrated export keeps its existing optional `<project_path>/animations.json` default.
|
||||||
|
|
||||||
|
`<project_path>/narration_timing.json` is the explicit semantic mapping for narrated object animation. It is fingerprinted to the ordered SRT set; `cue` is the 1-based subtitle cue, and omitted `cue` keeps that group's canonical relative delay. Reuse a complete current mapping when its fingerprint and SVG group semantics remain valid; rebuild only affected pages when either input changed.
|
||||||
|
|
||||||
|
Get the exact fingerprint value with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ppt-master/scripts/narration_sync.py fingerprint <project_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"srt_sha256": "<sha256 of the ordered page-local SRT set>",
|
||||||
|
"narration_padding": 0.5,
|
||||||
|
"slides": {
|
||||||
|
"01_title": {
|
||||||
|
"groups": [
|
||||||
|
{ "id": "page-title", "cue": 1 },
|
||||||
|
{ "id": "supporting-visual" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`narration_sync.py subtitles` may still write `<project_path>/notes/subtitles/total.srt` as a PPTX-timeline diagnostic. It is not the delivery subtitle for a finished video.
|
||||||
|
|
||||||
|
After PowerPoint exports the video, `video_subtitles.py` takes the exact narration text frozen in the page SRT set and force-aligns it against the finished video's actual audio track with `stable-ts`. Long delivery cues may be split for display at this final stage. This writes a same-stem external SRT without changing the MP4, notes, page SRT, or animation files.
|
||||||
|
|
||||||
|
This stage keeps subtitles as external SRT files. It does not embed subtitles into PPTX or export MP4 directly.
|
||||||
|
|
||||||
**Caller integration**:
|
**Caller integration**:
|
||||||
|
|
||||||
| Caller | After audio generation |
|
| Caller | After audio generation |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Generate PPTX | When embedding was confirmed, run `svg_to_pptx.py --recorded-narration audio` as shown above |
|
| Generate PPTX | With Edge SRT and an existing `animations.json`, derive `narration_animations.json`, export with `--recorded-narration audio` (derived animation by default; canonical or no-animation modes remain explicit), and generate the delivery SRT only after PowerPoint exports the video. |
|
||||||
| Enhance Native PPTX | Return to [`native-enhance-pptx`](../native-enhance-pptx.md) Step 9; its `apply` command owns audio relationships, timings, transitions, and the enhanced export |
|
| Enhance Native PPTX | Return to [`native-enhance-pptx`](../native-enhance-pptx.md) Step 9; its `apply` command owns audio relationships, timings, transitions, and the enhanced export |
|
||||||
|
|
||||||
For Generate PPTX, `--recorded-narration audio` prepares PowerPoint's recorded timings and narrations: every slide must have a matching supported audio file, every duration must be readable by `ffprobe`, and object animations must not use `--animation-trigger on-click`. Use `after-previous` or `with-previous` for narrated/video export. Narration changes the slide-advance layer only: the resolved page-transition effect remains unchanged, `-t none` remains visually transition-free, and narration advance disables click while using audio duration plus padding. The re-export is saved as `exports/<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports.
|
For Generate PPTX, `--recorded-narration audio` prepares PowerPoint's recorded timings and narrations: every slide must have a matching supported audio file, every duration must be readable by `ffprobe`, and object animations must not use `--animation-trigger on-click`. Use `after-previous` or `with-previous` for narrated/video export. Narration changes the slide-advance layer only: the resolved page-transition effect remains unchanged, `-t none` remains visually transition-free, and narration advance disables click while using audio duration plus padding. The re-export is saved as `exports/<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports.
|
||||||
|
|
||||||
|
**Narrated SVG export**: keep `--no-merge` on the final synchronized export. Separate SVG line frames preserve authored coordinates; default paragraph merging can make PowerPoint recalculate multiline text geometry and introduce visible offsets.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 5: Completion report
|
## Step 5: Completion report
|
||||||
@@ -165,6 +236,10 @@ For Generate PPTX, `--recorded-narration audio` prepares PowerPoint's recorded t
|
|||||||
Output one summary block listing:
|
Output one summary block listing:
|
||||||
|
|
||||||
- Number of audio files generated and their location (`<project_path>/audio/*`).
|
- Number of audio files generated and their location (`<project_path>/audio/*`).
|
||||||
|
- For edge, number of matching page-local SRT files and their location (`<project_path>/notes/subtitles/*`).
|
||||||
|
- For narrated object animation, whether current SVG semantics were reused or which missing/stale pages were reread, plus semantic mapping coverage and fallback count.
|
||||||
|
- For Generate PPTX with Edge SRT, derived narration animation group count and `narration_animations.json` path.
|
||||||
|
- When a finished video was supplied, the final aligned sidecar SRT path.
|
||||||
- The provider, voice, and rate/settings actually used.
|
- The provider, voice, and rate/settings actually used.
|
||||||
- The caller-owned integration result: narrated SVG export path, enhanced native PPTX path, or “audio only”.
|
- The caller-owned integration result: narrated SVG export path, enhanced native PPTX path, or “audio only”.
|
||||||
- For Generate PPTX when embedding was skipped, one-line hint: `python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> --recorded-narration audio`.
|
- For Generate PPTX when embedding was skipped, one-line hint: `python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> --recorded-narration audio`.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "shadcn",
|
"sourceId": "shadcn",
|
||||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "3f47b9113a173bac4a72cefda4d4c3f2d89b8ab6",
|
"commit": "6cd3f4c65c361ab6554e06a77e6a0af9cf8b6e37",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/shadcn",
|
"sourcePath": "skills/shadcn",
|
||||||
"syncedAt": "2026-07-22T16:00:00Z"
|
"syncedAt": "2026-07-23T16:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ These rules are **always enforced**. Each links to a file with Incorrect/Correct
|
|||||||
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||||
- **Callouts use `Alert`.** Don't build custom styled divs.
|
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||||
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||||
- **Toast via `sonner`.** Use `toast()` from `sonner`.
|
- **Toast follows the project base.** Use `toast` from the `toast` component for
|
||||||
|
Base UI projects. Use `toast()` from `sonner` for Radix and React Aria
|
||||||
|
projects.
|
||||||
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||||
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||||
- **Use `Badge`** instead of custom styled spans.
|
- **Use `Badge`** instead of custom styled spans.
|
||||||
@@ -135,7 +137,7 @@ These are the most common patterns that differentiate correct shadcn/ui code. Fo
|
|||||||
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||||
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||||
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||||
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
| Feedback | `toast` (Base UI), `sonner` (Radix/Aria), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||||
| Command palette | `Command` inside `Dialog` |
|
| Command palette | `Command` inside `Dialog` |
|
||||||
| Charts | `Chart` (wraps Recharts) |
|
| Charts | `Chart` (wraps Recharts) |
|
||||||
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- Items always inside their Group component
|
- Items always inside their Group component
|
||||||
- Callouts use Alert
|
- Callouts use Alert
|
||||||
- Empty states use Empty component
|
- Empty states use Empty component
|
||||||
- Toast notifications use sonner
|
- Toast notifications follow the project base
|
||||||
- Choosing between overlay components
|
- Choosing between overlay components
|
||||||
- Dialog, Sheet, and Drawer always need a Title
|
- Dialog, Sheet, and Drawer always need a Title
|
||||||
- Card structure
|
- Card structure
|
||||||
@@ -88,7 +88,19 @@ Chat components nest in a fixed order (`MessageScrollerProvider` → `MessageScr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Toast notifications use sonner
|
## Toast notifications follow the project base
|
||||||
|
|
||||||
|
For Base UI projects, use the `toast` component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: "Changes saved.",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
For Radix and React Aria projects, use Sonner:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "taste-skill",
|
"sourceId": "taste-skill",
|
||||||
"repo": "https://github.com/Leonxlnx/taste-skill.git",
|
"repo": "https://github.com/Leonxlnx/taste-skill.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "98565e65bc3274ddf6eb0838734341714057178b",
|
"commit": "1bffae64edbb6b2023d1e78402cd088e9c9f6511",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"sourcePath": "skills",
|
||||||
"syncedAt": "2026-07-21T16:00:00Z"
|
"syncedAt": "2026-07-23T16:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user