Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-07-24 00:02:47 +08:00
parent a61b88d6d9
commit 8745278d76
21 changed files with 2880 additions and 91 deletions
@@ -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 25 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) |
@@ -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
@@ -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(
@@ -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.
@@ -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.
@@ -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"
}