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",
|
||||
"ref": "main",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "98565e65bc3274ddf6eb0838734341714057178b",
|
||||
"syncedAt": "2026-07-21T16:00:00Z"
|
||||
"commit": "1bffae64edbb6b2023d1e78402cd088e9c9f6511",
|
||||
"syncedAt": "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "shadcn",
|
||||
@@ -60,8 +60,8 @@
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "3f47b9113a173bac4a72cefda4d4c3f2d89b8ab6",
|
||||
"syncedAt": "2026-07-22T16:00:00Z"
|
||||
"commit": "6cd3f4c65c361ab6554e06a77e6a0af9cf8b6e37",
|
||||
"syncedAt": "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "frontend-slides",
|
||||
@@ -96,8 +96,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "89759436bd336beed3a32da15830355bf732b14e",
|
||||
"syncedAt": "2026-07-22T16:00:00Z"
|
||||
"commit": "10f0adc0600ff28a470d55992133b1992c56968a",
|
||||
"syncedAt": "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "next-skills",
|
||||
@@ -105,8 +105,8 @@
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "63f14c6c90c4dae819966e180491eb9b0af16792",
|
||||
"syncedAt": "2026-07-22T16:00:00Z"
|
||||
"commit": "aa4f46a540b7c9176c7c2b7ef22421adb4b5688e",
|
||||
"syncedAt": "2026-07-23T16:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-07-22T16:02:51Z"
|
||||
"syncedAt": "2026-07-23T16:02:47Z"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "63f14c6c90c4dae819966e180491eb9b0af16792",
|
||||
"commit": "aa4f46a540b7c9176c7c2b7ef22421adb4b5688e",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-07-22T16:00:00Z"
|
||||
"syncedAt": "2026-07-23T16:00:00Z"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "89759436bd336beed3a32da15830355bf732b14e",
|
||||
"commit": "10f0adc0600ff28a470d55992133b1992c56968a",
|
||||
"adapter": "claude-skill",
|
||||
"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-duration` — per-element entrance seconds, default `0.4`.
|
||||
- `--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` |
|
||||
| Auto-play | `--auto-advance 5` |
|
||||
| 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` |
|
||||
| Use a single effect instead of auto | `--animation fade` |
|
||||
| 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.
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| `text` | Add interpretation or transition without reading the already self-contained page aloud. |
|
||||
| `balanced` | Connect the visible claim and evidence, explain the trade-off, and bridge to the next page. |
|
||||
| `presentation` | Carry the reasoning, context, and supporting detail intentionally omitted from the sparse projected surface. |
|
||||
| `text` | Interpret and connect a self-contained page; synthesize every independent SVG information group rather than omitting it. |
|
||||
| `balanced` | Connect visible claim and evidence, explain the trade-off, and bridge forward. |
|
||||
| `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:
|
||||
|
||||
```
|
||||
# 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.
|
||||
After `notes/total.md` is complete, return to Generate Step 7.1; the route authority owns splitting 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) |
|
||||
| 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 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) |
|
||||
|
||||
+19
-2
@@ -15,23 +15,40 @@
|
||||
--warning: #f5c542;
|
||||
--radius: 10px;
|
||||
--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; }
|
||||
|
||||
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 {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #1a1a2e;
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei",
|
||||
"PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
font-family: var(--ui-font-family);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---- fullscreen shell ---- */
|
||||
#app {
|
||||
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> --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 --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
|
||||
@@ -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
|
||||
- `--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
|
||||
- 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-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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@
|
||||
"""Generate per-slide narration audio from PPT Master notes.
|
||||
|
||||
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:
|
||||
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
|
||||
@@ -107,6 +108,12 @@ def main() -> int:
|
||||
default="+0%",
|
||||
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(
|
||||
"--elevenlabs-api-key-env",
|
||||
default="ELEVENLABS_API_KEY",
|
||||
@@ -245,6 +252,10 @@ def main() -> int:
|
||||
parser.error(f"--voice-id is required for --provider {args.provider}")
|
||||
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 not voice_id:
|
||||
parser.error("--voice-id is required for --provider elevenlabs")
|
||||
@@ -291,6 +302,9 @@ def main() -> int:
|
||||
notes_dir = project / "notes"
|
||||
output_dir = args.output or (project / "audio")
|
||||
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 = [
|
||||
path for path in sorted(notes_dir.glob("*.md"))
|
||||
@@ -367,14 +381,32 @@ def main() -> int:
|
||||
base_url=args.cosyvoice_base_url,
|
||||
)
|
||||
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:
|
||||
print(f"error: failed to generate {output_path}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
generated += 1
|
||||
print(f"[OK] {output_path}")
|
||||
if backend.provider == "edge":
|
||||
print(f" {subtitle_path}")
|
||||
|
||||
print(f"[Done] Generated {generated}/{len(note_files)} audio file(s): {output_dir}")
|
||||
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}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+97
-18
@@ -689,6 +689,9 @@ Recorded narration:
|
||||
- Keeps speaker notes when enabled
|
||||
- Prepares PowerPoint recorded timings and narrations
|
||||
- Requires one m4a/mp3/wav file per slide
|
||||
- Uses narration_animations.json 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
|
||||
- Sets slide auto-advance from audio duration so video export can use
|
||||
"recorded timings and narrations"
|
||||
@@ -819,9 +822,25 @@ Recorded narration:
|
||||
parser.add_argument('--animation-stagger', type=non_negative_float, default=None,
|
||||
help='Delay between elements in --animation-trigger=after-previous '
|
||||
'(seconds, default 0.5). Ignored in other modes.')
|
||||
parser.add_argument('--animation-config', type=str, default=None,
|
||||
help='Optional per-slide/per-object animation config. '
|
||||
'Default: <project>/animations.json when present.')
|
||||
animation_source = parser.add_mutually_exclusive_group()
|
||||
animation_source.add_argument(
|
||||
'--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',
|
||||
help='Disable speaker notes embedding (enabled by default)')
|
||||
@@ -1147,18 +1166,65 @@ Recorded narration:
|
||||
"unmatched slides will export without audio."
|
||||
)
|
||||
|
||||
if args.animation_config:
|
||||
config_path = Path(args.animation_config)
|
||||
if args.no_animations and any(
|
||||
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():
|
||||
config_path = project_path / config_path
|
||||
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
|
||||
|
||||
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:
|
||||
print(f"Error: Failed to load animation config: {exc}")
|
||||
print(f"Error: Failed to load animation config: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
config_errors: list[str] = []
|
||||
if animation_config:
|
||||
@@ -1187,10 +1253,15 @@ Recorded narration:
|
||||
return 1
|
||||
|
||||
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}")
|
||||
for warning in config_warnings:
|
||||
print(f" [warn] {warning}")
|
||||
elif args.no_animations and verbose:
|
||||
print(" Animations: disabled")
|
||||
|
||||
defaults = animation_config.get('defaults', {}) if animation_config else {}
|
||||
transition_defaults = _as_dict(defaults.get('transition')) if isinstance(defaults, dict) else {}
|
||||
@@ -1198,9 +1269,13 @@ Recorded narration:
|
||||
|
||||
transition_arg = args.transition
|
||||
transition_effect = (
|
||||
transition_arg
|
||||
if transition_arg is not None
|
||||
else transition_defaults.get('effect', 'fade')
|
||||
'none'
|
||||
if args.no_animations
|
||||
else (
|
||||
transition_arg
|
||||
if transition_arg is not None
|
||||
else transition_defaults.get('effect', 'fade')
|
||||
)
|
||||
)
|
||||
transition = None if transition_effect == 'none' else transition_effect
|
||||
try:
|
||||
@@ -1230,12 +1305,16 @@ Recorded narration:
|
||||
|
||||
try:
|
||||
animation_effect = (
|
||||
args.animation
|
||||
if args.animation is not None
|
||||
# Per-element entrance is opt-in by default: auto-firing element builds
|
||||
# read as the "AI deck" tell and were unsolicited. Page transitions stay
|
||||
# on (see transition default above). Re-enable with -a auto / animations.json.
|
||||
else animation_defaults.get('effect', 'none')
|
||||
'none'
|
||||
if args.no_animations
|
||||
else (
|
||||
args.animation
|
||||
if args.animation is not None
|
||||
# Per-element entrance is opt-in by default: auto-firing element builds
|
||||
# read as the "AI deck" tell and were unsolicited. Page transitions stay
|
||||
# on (see transition default above). Re-enable with -a auto / animations.json.
|
||||
else animation_defaults.get('effect', 'none')
|
||||
)
|
||||
)
|
||||
animation = normalize_animation_effect(animation_effect)
|
||||
animation_duration = validate_seconds(
|
||||
|
||||
+466
-2
@@ -2,10 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
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 = [
|
||||
("zh-CN", "zh-CN-XiaoxiaoNeural", "女声,普通话,清晰自然,默认推荐"),
|
||||
("zh-CN", "zh-CN-XiaoyiNeural", "女声,普通话,明亮"),
|
||||
@@ -43,7 +69,27 @@ def normalize_rate(rate: str) -> str:
|
||||
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:
|
||||
import edge_tts
|
||||
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))
|
||||
|
||||
|
||||
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:
|
||||
print("Common edge-tts voices:")
|
||||
print("Locale Voice Notes")
|
||||
@@ -83,4 +548,3 @@ async def print_voices(locale: str | None = None) -> None:
|
||||
gender = voice.get("Gender", "")
|
||||
friendly = voice.get("FriendlyName", "")
|
||||
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`.
|
||||
- **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.
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
> 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.
|
||||
|
||||
@@ -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`.
|
||||
- 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.
|
||||
- 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:
|
||||
- ElevenLabs: `ELEVENLABS_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> \
|
||||
--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> \
|
||||
--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.
|
||||
|
||||
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 | 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 |
|
||||
|
||||
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
|
||||
@@ -165,6 +236,10 @@ For Generate PPTX, `--recorded-narration audio` prepares PowerPoint's recorded t
|
||||
Output one summary block listing:
|
||||
|
||||
- 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 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`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "shadcn",
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"commit": "3f47b9113a173bac4a72cefda4d4c3f2d89b8ab6",
|
||||
"commit": "6cd3f4c65c361ab6554e06a77e6a0af9cf8b6e37",
|
||||
"adapter": "claude-skill",
|
||||
"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`.
|
||||
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||
- **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 `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||
- **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` |
|
||||
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||
| 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` |
|
||||
| Charts | `Chart` (wraps Recharts) |
|
||||
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- Items always inside their Group component
|
||||
- Callouts use Alert
|
||||
- Empty states use Empty component
|
||||
- Toast notifications use sonner
|
||||
- Toast notifications follow the project base
|
||||
- Choosing between overlay components
|
||||
- Dialog, Sheet, and Drawer always need a Title
|
||||
- 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
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "taste-skill",
|
||||
"repo": "https://github.com/Leonxlnx/taste-skill.git",
|
||||
"ref": "main",
|
||||
"commit": "98565e65bc3274ddf6eb0838734341714057178b",
|
||||
"commit": "1bffae64edbb6b2023d1e78402cd088e9c9f6511",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-07-21T16:00:00Z"
|
||||
"syncedAt": "2026-07-23T16:00:00Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user