diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 858a9559..71e49d4c 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -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" } ] } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 67cbbe25..4bcec3ed 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-07-22T16:02:51Z" + "syncedAt": "2026-07-23T16:02:47Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index ad1214d0..9246a1be 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "63f14c6c90c4dae819966e180491eb9b0af16792", + "commit": "aa4f46a540b7c9176c7c2b7ef22421adb4b5688e", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-07-22T16:00:00Z" + "syncedAt": "2026-07-23T16:00:00Z" } diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index becd1172..837f4eed 100644 --- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "ppt-master", "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", - "commit": "89759436bd336beed3a32da15830355bf732b14e", + "commit": "10f0adc0600ff28a470d55992133b1992c56968a", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-07-22T16:00:00Z" + "syncedAt": "2026-07-23T16:00:00Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md index 026017ce..3d53820e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md @@ -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: `/animations.json` when present. +- `--animation-config` — explicit sidecar path. Narrated export defaults to `/narration_animations.json`; other export defaults to `/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` | diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-notes.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-notes.md index ff252678..88e3418b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-notes.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-notes.md @@ -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 `# _` 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**: `# _` 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/.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 ``; 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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md index a3663957..d5923fec 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md @@ -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) | diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/style.css b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/style.css index 65c5d217..58ed6ec7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/style.css +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/style.css @@ -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; diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md index 9105fb16..ce104495 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md @@ -294,6 +294,8 @@ python3 scripts/svg_to_pptx.py --auto-advance 3 python3 scripts/svg_to_pptx.py --animation mixed --animation-duration 0.8 python3 scripts/svg_to_pptx.py --no-merge # strict line-fidelity mode (see below) python3 scripts/svg_to_pptx.py --recorded-narration audio +python3 scripts/svg_to_pptx.py --recorded-narration audio --animation-config animations.json +python3 scripts/svg_to_pptx.py --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 `/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 `/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 `__narrated.pptx`, telling it apart from silent exports in the same directory diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py new file mode 100644 index 00000000..db3ad751 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py @@ -0,0 +1,1709 @@ +#!/usr/bin/env python3 +""" +PPT Master - Narration Sync Tool + +Derive click-free narration timing from the canonical animation config and +page-local SRT cues, then merge those subtitles against timing values read +from the final narrated PPTX. +See workflows/stages/generate-audio.md for the owning stage. + +Usage: + python3 scripts/narration_sync.py fingerprint + python3 scripts/narration_sync.py animations + python3 scripts/narration_sync.py subtitles --pptx --force + python3 scripts/narration_sync.py subtitles --pptx \ + --video --force + +Examples: + python3 scripts/narration_sync.py fingerprint projects/demo + python3 scripts/narration_sync.py animations projects/demo + python3 scripts/narration_sync.py subtitles projects/demo --pptx exports/demo.pptx --force + python3 scripts/narration_sync.py subtitles projects/demo \ + --pptx exports/demo.pptx --video exports/demo.mp4 --force + +Dependencies: + ffprobe for animation-window validation. Optional exported-video calibration + additionally requires ffmpeg and numpy. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import math +import os +import posixpath +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from xml.etree import ElementTree as ET + +_SCRIPTS_DIR = Path(__file__).resolve().parent +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from console_encoding import configure_utf8_stdio # noqa: E402 +from pptx_animations import ( # noqa: E402 + animation_seconds_to_milliseconds, + normalize_animation_effect, +) +from pptx_transitions import read_slide_transition_xml # noqa: E402 +from svg_to_pptx.animation_config import ( # noqa: E402 + scan_project_targets, + scan_svg_targets, + validate_animation_config_errors, + validate_transition_config, +) +from svg_to_pptx.pptx_package.narration import ( # noqa: E402 + NARRATION_EXTENSIONS, + probe_audio_duration, +) + +configure_utf8_stdio() + + +_PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" +_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +_DOC_REL_NS = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +) +_TIMING_RE = re.compile( + r"^(?P\d+:\d{2}:\d{2},\d{3})\s+-->\s+" + r"(?P\d+:\d{2}:\d{2},\d{3})(?:\s+.*)?$" +) +_ALIGNMENT_SAMPLE_RATE = 8000 +_ALIGNMENT_FINE_HZ = 1000 +_ALIGNMENT_COARSE_FACTOR = 10 +_ALIGNMENT_SEARCH_WINDOW_MS = 2000 +_ALIGNMENT_REFINE_WINDOW_MS = 100 +_ALIGNMENT_TEMPLATE_MAX_MS = 12_000 +_ALIGNMENT_MIN_CORRELATION = 0.85 +_ALIGNMENT_END_TOLERANCE_MS = 100 + + +@dataclass(frozen=True) +class SubtitleCue: + """One parsed SRT cue on a millisecond timeline.""" + + start_ms: int + end_ms: int + text: str + + +@dataclass(frozen=True) +class TimingPlanEntry: + """One ordered animation target and its optional SRT cue anchor.""" + + group_id: str + cue_number: int | None + + +@dataclass(frozen=True) +class AnimationGroupState: + """One effective canonical animation row before narration timing.""" + + group_id: str + order: int + source_index: int + duration_ms: int + original_delay_ms: int + + +@dataclass(frozen=True) +class SlideAnimationSettings: + """Effective animation settings inherited by one slide.""" + + effect: str | None + duration_ms: int + stagger_ms: int + trigger: str + + +@dataclass(frozen=True) +class AnimationBuildResult: + """Summary of one narration animation derivation.""" + + slide_count: int + group_count: int + anchored_count: int + fallback_count: int + ignored_cue_count: int + svg_fallback_slide_count: int + timing_plan_written: bool + + +@dataclass(frozen=True) +class SubtitleMergeResult: + """Summary of one page-local SRT merge.""" + + slide_count: int + cue_count: int + powerpoint_timeline_ms: int + minimum_video_adjustment_ms: int | None = None + maximum_video_adjustment_ms: int | None = None + minimum_video_correlation: float | None = None + + +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 _ms_to_timestamp(value: int) -> str: + if value < 0: + raise ValueError(f"SRT timestamp cannot be negative: {value}") + hours, remainder = divmod(value, 3_600_000) + minutes, remainder = divmod(remainder, 60_000) + seconds, milliseconds = divmod(remainder, 1000) + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}" + + +def _parse_srt(path: Path) -> list[SubtitleCue]: + 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 = "\n".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 _format_srt(cues: list[SubtitleCue]) -> str: + blocks = [] + for index, cue in enumerate(cues, 1): + blocks.append( + f"{index}\n" + f"{_ms_to_timestamp(cue.start_ms)} --> " + f"{_ms_to_timestamp(cue.end_ms)}\n" + f"{cue.text}" + ) + return "\n\n".join(blocks) + "\n" + + +def _atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=str(path.parent), + ) + temporary_path = Path(temporary_name) + try: + stream = os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") + descriptor = -1 + with stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, path) + finally: + if descriptor >= 0: + os.close(descriptor) + temporary_path.unlink(missing_ok=True) + + +def _project_path(project_path: Path, value: str | None, default: Path) -> Path: + path = Path(value) if value else default + return path if path.is_absolute() else project_path / path + + +def _project_input_path(project_path: Path, value: str) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return project_path / path + + +def _require_replaceable(path: Path, force: bool) -> None: + if path.exists() and not force: + raise FileExistsError(f"Output already exists: {path}; pass --force to replace it") + + +def _reject_output_alias( + output_path: Path, + input_paths: list[Path], + *, + label: str, +) -> None: + output_resolved = output_path.resolve() + for input_path in input_paths: + if output_resolved == input_path.resolve(): + raise ValueError( + f"{label} output must not overwrite an input file: {output_path}" + ) + + +def _subtitle_fingerprint(slide_names: list[str], subtitle_dir: Path) -> str: + digest = hashlib.sha256() + for slide_name in slide_names: + path = subtitle_dir / f"{slide_name}.srt" + digest.update(slide_name.encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def _load_timing_plan( + path: Path, +) -> tuple[str, float, dict[str, list[TimingPlanEntry]]]: + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"Narration timing plan must be a JSON object: {path}") + unknown_top = set(raw) - { + "version", + "srt_sha256", + "narration_padding", + "slides", + } + if unknown_top: + raise ValueError( + f"Narration timing plan has unknown top-level field(s): " + f"{', '.join(sorted(unknown_top))}" + ) + if raw.get("version") != 1: + raise ValueError( + f"Unsupported narration timing plan version: {raw.get('version')!r}" + ) + srt_sha256 = raw.get("srt_sha256") + if ( + not isinstance(srt_sha256, str) + or re.fullmatch(r"[0-9a-f]{64}", srt_sha256) is None + ): + raise ValueError( + 'Narration timing plan field "srt_sha256" must be a lowercase ' + "SHA-256 digest of the ordered page-local SRT files" + ) + narration_padding = raw.get("narration_padding") + if ( + isinstance(narration_padding, bool) + or not isinstance(narration_padding, (int, float)) + or not math.isfinite(float(narration_padding)) + or narration_padding < 0 + ): + raise ValueError( + 'Narration timing plan field "narration_padding" must be a ' + "finite non-negative number" + ) + slides = raw.get("slides") + if not isinstance(slides, dict): + raise ValueError('Narration timing plan field "slides" must be an object') + + result: dict[str, list[TimingPlanEntry]] = {} + for slide_name, slide_raw in slides.items(): + if not isinstance(slide_name, str) or not slide_name: + raise ValueError("Narration timing plan slide names must be non-empty strings") + if not isinstance(slide_raw, dict) or set(slide_raw) != {"groups"}: + raise ValueError( + f'Narration timing plan slide "{slide_name}" must contain only "groups"' + ) + groups = slide_raw["groups"] + if not isinstance(groups, list): + raise ValueError( + f'Narration timing plan slide "{slide_name}" groups must be a list' + ) + + entries: list[TimingPlanEntry] = [] + seen_groups: set[str] = set() + for position, entry_raw in enumerate(groups, 1): + if not isinstance(entry_raw, dict): + raise ValueError( + f'Narration timing plan "{slide_name}" group #{position} ' + "must be an object" + ) + unknown_fields = set(entry_raw) - {"id", "cue"} + if unknown_fields: + raise ValueError( + f'Narration timing plan "{slide_name}" group #{position} ' + f"has unknown field(s): {', '.join(sorted(unknown_fields))}" + ) + group_id = entry_raw.get("id") + if not isinstance(group_id, str) or not group_id.strip(): + raise ValueError( + f'Narration timing plan "{slide_name}" group #{position} ' + 'field "id" must be a non-empty string' + ) + if group_id in seen_groups: + raise ValueError( + f'Narration timing plan "{slide_name}" repeats group "{group_id}"' + ) + cue_number = entry_raw.get("cue") + if cue_number is not None and ( + isinstance(cue_number, bool) + or not isinstance(cue_number, int) + or cue_number <= 0 + ): + raise ValueError( + f'Narration timing plan "{slide_name}/{group_id}" cue ' + "must be a positive integer or null" + ) + entries.append(TimingPlanEntry(group_id, cue_number)) + seen_groups.add(group_id) + result[slide_name] = entries + return srt_sha256, float(narration_padding), result + + +def _load_canonical_animation_config(path: Path) -> dict[str, Any]: + """Load and field-validate the read-only canonical animation sidecar.""" + if not path.is_file(): + raise FileNotFoundError( + f"Canonical animation config is missing: {path}. " + "Complete the customize-animations stage first; narration sync " + "does not create or replace animations.json." + ) + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"Canonical animation config must be a JSON object: {path}") + if raw.get("version", 1) != 1: + raise ValueError( + f"Unsupported canonical animation config version: {raw.get('version')!r}" + ) + errors = list( + dict.fromkeys( + [ + *validate_transition_config(raw), + *validate_animation_config_errors(raw), + ] + ) + ) + if errors: + details = "\n".join(f"- {error}" for error in errors) + raise ValueError( + f"Canonical animation config is invalid: {path}\n{details}" + ) + return raw + + +def _page_subtitle_names(subtitle_dir: Path) -> list[str]: + """Return ordered page-local SRT stems, excluding the merged sidecar.""" + if not subtitle_dir.is_dir(): + raise FileNotFoundError(f"Page-local SRT directory not found: {subtitle_dir}") + names = [ + path.stem + for path in sorted(subtitle_dir.glob("*.srt")) + if path.stem != "total" + ] + if not names: + raise FileNotFoundError(f"No page-local SRT files found under: {subtitle_dir}") + return names + + +def _animation_slide_names( + project_path: Path, + config: dict[str, Any], + subtitle_dir: Path, +) -> list[str]: + """Resolve the page roster without parsing SVG unless a slide is missing.""" + slides = config.get("slides", {}) + if not isinstance(slides, dict): + raise ValueError('Canonical animations.json field "slides" must be an object') + canonical_names = list(slides) + subtitle_names = _page_subtitle_names(subtitle_dir) + subtitle_set = set(subtitle_names) + + missing_subtitles = [ + slide_name for slide_name in canonical_names if slide_name not in subtitle_set + ] + if missing_subtitles: + raise FileNotFoundError( + "Missing page-local SRT for canonical animation slide(s): " + + ", ".join(missing_subtitles) + ) + + canonical_set = set(canonical_names) + extra_subtitles = [ + slide_name for slide_name in subtitle_names if slide_name not in canonical_set + ] + unexpected = [ + slide_name + for slide_name in extra_subtitles + if not (project_path / "svg_output" / f"{slide_name}.svg").is_file() + ] + if unexpected: + raise ValueError( + "Page-local SRT has no matching canonical animation slide or SVG: " + + ", ".join(unexpected) + ) + if not extra_subtitles: + return canonical_names + return subtitle_names + + +def _animation_scope( + scope: dict[str, Any], + *, + label: str, +) -> dict[str, Any]: + value = scope.get("animation", {}) + if not isinstance(value, dict): + raise ValueError(f'{label} field "animation" must be an object') + return value + + +def _effective_slide_animation( + config: dict[str, Any], + slide_cfg: dict[str, Any], +) -> SlideAnimationSettings: + defaults = config.get("defaults", {}) + if not isinstance(defaults, dict): + raise ValueError('Canonical animations.json field "defaults" must be an object') + default_animation = _animation_scope( + defaults, + label="Canonical animations.json defaults", + ) + slide_animation = _animation_scope( + slide_cfg, + label="Canonical animations.json slide", + ) + effect = normalize_animation_effect( + slide_animation.get( + "effect", + default_animation.get("effect", "none"), + ) + ) + duration_ms = animation_seconds_to_milliseconds( + slide_animation.get( + "duration", + default_animation.get("duration", 0.4), + ), + "canonical animation duration", + allow_zero=False, + ) + stagger_ms = animation_seconds_to_milliseconds( + slide_animation.get( + "stagger", + default_animation.get("stagger", 0.5), + ), + "canonical animation stagger", + allow_zero=True, + ) + trigger = slide_animation.get( + "trigger", + default_animation.get("trigger", "after-previous"), + ) + if not isinstance(trigger, str): + raise ValueError(f"Canonical animation trigger must be a string: {trigger!r}") + return SlideAnimationSettings( + effect=effect, + duration_ms=duration_ms, + stagger_ms=stagger_ms, + trigger=trigger, + ) + + +def _group_is_animated( + group_cfg: dict[str, Any], + slide_effect: str | None, +) -> bool: + if "effect" not in group_cfg: + return slide_effect is not None + return normalize_animation_effect(group_cfg["effect"]) is not None + + +def _needs_svg_group_resolution( + settings: SlideAnimationSettings, + groups_cfg: dict[str, Any], + plan_entries: list[TimingPlanEntry] | None, +) -> bool: + """Return whether JSON alone cannot prove the effective group sequence.""" + active_explicit = [ + group_id + for group_id, group_cfg in groups_cfg.items() + if isinstance(group_cfg, dict) + and _group_is_animated(group_cfg, settings.effect) + ] + if plan_entries is None: + if settings.effect is not None: + return True + return ( + len(active_explicit) > 1 + and any("order" not in groups_cfg[group_id] for group_id in active_explicit) + ) + + candidate_ids = list( + dict.fromkeys( + [ + *(entry.group_id for entry in plan_entries), + *active_explicit, + ] + ) + ) + active_candidates = [ + group_id + for group_id in candidate_ids + if group_id in groups_cfg + and isinstance(groups_cfg[group_id], dict) + and _group_is_animated(groups_cfg[group_id], settings.effect) + ] + if len(active_candidates) <= 1: + return False + return any("order" not in groups_cfg[group_id] for group_id in active_candidates) + + +def _resolve_animation_groups( + project_path: Path, + slide_name: str, + slide_cfg: dict[str, Any], + settings: SlideAnimationSettings, + plan_entries: list[TimingPlanEntry] | None, +) -> tuple[list[AnimationGroupState], bool]: + """Resolve one effective sequence, parsing only an ambiguous page SVG.""" + groups_value = slide_cfg.get("groups", {}) + if not isinstance(groups_value, dict): + raise ValueError( + f'Canonical animations.json slide "{slide_name}" groups must be an object' + ) + groups_cfg: dict[str, dict[str, Any]] = {} + for group_id, group_cfg in groups_value.items(): + if not isinstance(group_cfg, dict): + raise ValueError( + f'Canonical animations.json group "{slide_name}/{group_id}" ' + "must be an object" + ) + groups_cfg[group_id] = group_cfg + + use_svg = _needs_svg_group_resolution(settings, groups_cfg, plan_entries) + candidate_ids: list[str] + if use_svg: + svg_path = project_path / "svg_output" / f"{slide_name}.svg" + if not svg_path.is_file(): + raise FileNotFoundError( + f"Animation group mapping is ambiguous and requires the page SVG: " + f"{svg_path}" + ) + targets, anonymous_groups = scan_svg_targets(svg_path) + duplicate_ids = sorted( + group_id + for group_id in {target.group_id for target in targets} + if sum(target.group_id == group_id for target in targets) > 1 + ) + if duplicate_ids: + raise ValueError( + f'SVG slide "{slide_name}" has duplicate top-level group id(s): ' + + ", ".join(duplicate_ids) + ) + for item in anonymous_groups: + print( + f"Warning: {item} has no id and cannot participate in narration timing", + file=sys.stderr, + ) + + targets_by_id = {target.group_id: target for target in targets} + referenced_ids = set(groups_cfg) + if plan_entries is not None: + referenced_ids.update(entry.group_id for entry in plan_entries) + missing_ids = sorted(referenced_ids - set(targets_by_id)) + if missing_ids: + raise ValueError( + f'Animation mapping for slide "{slide_name}" references missing ' + f"top-level group(s): {', '.join(missing_ids)}" + ) + structural_ids = sorted( + group_id + for group_id in referenced_ids + if targets_by_id[group_id].structurally_static + and ( + group_id not in groups_cfg + or _group_is_animated(groups_cfg[group_id], settings.effect) + ) + ) + if structural_ids: + raise ValueError( + f'Animation mapping for slide "{slide_name}" targets structural ' + f"group(s): {', '.join(structural_ids)}" + ) + + candidate_ids = [] + for target in targets: + if target.structurally_static: + continue + group_cfg = groups_cfg.get(target.group_id, {}) + explicitly_animated = ( + target.group_id in groups_cfg + and _group_is_animated(group_cfg, settings.effect) + ) + if target.chrome and not explicitly_animated: + continue + candidate_ids.append(target.group_id) + elif plan_entries is not None: + candidate_ids = list( + dict.fromkeys( + [ + *(entry.group_id for entry in plan_entries), + *groups_cfg, + ] + ) + ) + else: + candidate_ids = list(groups_cfg) + + preliminaries: list[tuple[int, int, str, dict[str, Any]]] = [] + for source_index, group_id in enumerate(candidate_ids): + group_cfg = groups_cfg.get(group_id, {}) + if not _group_is_animated(group_cfg, settings.effect): + continue + order = group_cfg.get("order", source_index + 1) + if isinstance(order, bool) or not isinstance(order, int) or order <= 0: + raise ValueError( + f'Canonical animation order for "{slide_name}/{group_id}" ' + f"must be a positive integer: {order!r}" + ) + preliminaries.append((order, source_index, group_id, group_cfg)) + preliminaries.sort(key=lambda item: (item[0], item[1])) + + states: list[AnimationGroupState] = [] + for sequence_index, (order, source_index, group_id, group_cfg) in enumerate( + preliminaries + ): + duration_ms = animation_seconds_to_milliseconds( + group_cfg.get("duration", settings.duration_ms / 1000), + f'canonical animation duration for "{slide_name}/{group_id}"', + allow_zero=False, + ) + original_delay_ms = animation_seconds_to_milliseconds( + group_cfg.get( + "delay", + 0 if sequence_index == 0 else settings.stagger_ms / 1000, + ), + f'canonical animation delay for "{slide_name}/{group_id}"', + allow_zero=True, + ) + states.append( + AnimationGroupState( + group_id=group_id, + order=order, + source_index=source_index, + duration_ms=duration_ms, + original_delay_ms=original_delay_ms, + ) + ) + return states, use_svg + + +def _find_audio(audio_dir: Path, slide_name: str) -> Path: + matches = [ + path + for path in audio_dir.iterdir() + if path.is_file() + and path.stem == slide_name + and path.suffix.lower() in NARRATION_EXTENSIONS + ] + if not matches: + raise FileNotFoundError(f"Missing narration audio for slide: {slide_name}") + if len(matches) > 1: + rendered = ", ".join(str(path) for path in matches) + raise ValueError(f"Multiple narration audio files match {slide_name}: {rendered}") + return matches[0] + + +def _seconds_from_ms(value: int) -> float: + return round(value / 1000, 3) + + +def rebuild_animations( + project_path: Path, + *, + canonical_path: Path, + plan_path: Path, + subtitle_dir: Path, + audio_dir: Path, + output_path: Path, + narration_padding: float, + force: bool, +) -> AnimationBuildResult: + """Derive narration timing without modifying the canonical animation file.""" + if not math.isfinite(narration_padding) or narration_padding < 0: + raise ValueError("Narration padding must be finite and non-negative") + _reject_output_alias( + output_path, + [canonical_path, plan_path], + label="Narration animation config", + ) + canonical = _load_canonical_animation_config(canonical_path) + slide_names = _animation_slide_names(project_path, canonical, subtitle_dir) + subtitle_paths = [subtitle_dir / f"{slide_name}.srt" for slide_name in slide_names] + _reject_output_alias( + output_path, + subtitle_paths, + label="Narration animation config", + ) + + timing_plan: dict[str, list[TimingPlanEntry]] | None = None + if plan_path.is_file(): + expected_srt_sha256, planned_padding, loaded_plan = _load_timing_plan( + plan_path + ) + if not math.isclose( + narration_padding, + planned_padding, + rel_tol=0, + abs_tol=1e-9, + ): + raise ValueError( + "Narration padding differs from the timing plan: " + f"plan={planned_padding}, command={narration_padding}" + ) + current_srt_sha256 = _subtitle_fingerprint(slide_names, subtitle_dir) + if current_srt_sha256 != expected_srt_sha256: + raise ValueError( + "Narration timing plan was authored for a different SRT set: " + f"plan={expected_srt_sha256}, current={current_srt_sha256}" + ) + if set(loaded_plan) != set(slide_names): + raise ValueError( + "Narration timing plan slides do not match animations.json" + ) + timing_plan = loaded_plan + + derived = copy.deepcopy(canonical) + canonical_slides = canonical.get("slides", {}) + derived_slides = derived.setdefault("slides", {}) + if not isinstance(canonical_slides, dict) or not isinstance( + derived_slides, + dict, + ): + raise ValueError('Canonical animations.json field "slides" must be an object') + + anchored_count = 0 + fallback_count = 0 + ignored_cue_count = 0 + svg_fallback_slide_count = 0 + drift_warnings: list[str] = [] + positional_slides: list[tuple[str, int, int]] = [] + audio_paths: list[Path] = [] + + for slide_name in slide_names: + cues = _parse_srt(subtitle_dir / f"{slide_name}.srt") + audio_path = _find_audio(audio_dir, slide_name) + audio_paths.append(audio_path) + audio_duration = probe_audio_duration(audio_path) + if audio_duration is None: + raise RuntimeError( + f"Unable to read narration duration with ffprobe: {audio_path}" + ) + + canonical_slide = canonical_slides.get(slide_name, {}) + if not isinstance(canonical_slide, dict): + raise ValueError( + f'Canonical animations.json slide "{slide_name}" must be an object' + ) + derived_slide = derived_slides.setdefault( + slide_name, + copy.deepcopy(canonical_slide), + ) + if not isinstance(derived_slide, dict): + raise ValueError( + f'Derived animation slide "{slide_name}" must be an object' + ) + settings = _effective_slide_animation(canonical, canonical_slide) + plan_entries = timing_plan.get(slide_name) if timing_plan else None + states, used_svg = _resolve_animation_groups( + project_path, + slide_name, + canonical_slide, + settings, + plan_entries, + ) + if used_svg: + svg_fallback_slide_count += 1 + if timing_plan is None and states: + positional_slides.append((slide_name, len(states), len(cues))) + + state_ids = {state.group_id for state in states} + cue_by_group: dict[str, int | None] = {} + if plan_entries is not None: + for entry in plan_entries: + if entry.group_id not in state_ids: + raise ValueError( + f'Narration timing plan references a non-animated group: ' + f"{slide_name}/{entry.group_id}" + ) + if entry.cue_number is not None and entry.cue_number > len(cues): + raise ValueError( + f'Narration timing plan "{slide_name}/{entry.group_id}" ' + f"references cue {entry.cue_number}, but the SRT has " + f"{len(cues)} cues" + ) + cue_by_group[entry.group_id] = entry.cue_number + else: + cue_by_group = { + state.group_id: index + 1 if index < len(cues) else None + for index, state in enumerate(states) + } + + animation_value = derived_slide.setdefault("animation", {}) + if not isinstance(animation_value, dict): + raise ValueError( + f'Derived animation slide "{slide_name}" animation must be an object' + ) + animation_value["trigger"] = "after-previous" + groups_value = derived_slide.setdefault("groups", {}) + if not isinstance(groups_value, dict): + raise ValueError( + f'Derived animation slide "{slide_name}" groups must be an object' + ) + + previous_end_ms = 0 + referenced_cues: set[int] = set() + for state in states: + cue_number = cue_by_group.get(state.group_id) + if cue_number is None: + fallback_count += 1 + delay_ms = state.original_delay_ms + actual_start_ms = previous_end_ms + delay_ms + else: + anchored_count += 1 + referenced_cues.add(cue_number) + desired_start_ms = cues[cue_number - 1].start_ms + actual_start_ms = max(desired_start_ms, previous_end_ms) + delay_ms = actual_start_ms - previous_end_ms + drift_ms = actual_start_ms - desired_start_ms + if drift_ms > 500: + drift_warnings.append( + f"{slide_name}/{state.group_id}: cue {cue_number} " + f"starts at {_seconds_from_ms(desired_start_ms):.3f}s, " + f"animation starts at {_seconds_from_ms(actual_start_ms):.3f}s " + f"(after-previous drift {_seconds_from_ms(drift_ms):.3f}s)" + ) + + group_value = groups_value.setdefault(state.group_id, {}) + if not isinstance(group_value, dict): + raise ValueError( + f'Derived animation group "{slide_name}/{state.group_id}" ' + "must be an object" + ) + group_value["order"] = state.order + group_value["delay"] = _seconds_from_ms(delay_ms) + previous_end_ms = actual_start_ms + state.duration_ms + + ignored_cue_count += len(cues) - len(referenced_cues) + + advance_ms = int((audio_duration + narration_padding) * 1000) + if previous_end_ms > advance_ms: + raise ValueError( + f'Animations on slide "{slide_name}" end at ' + f"{_seconds_from_ms(previous_end_ms):.3f}s, after the recorded " + f"slide advance at {_seconds_from_ms(advance_ms):.3f}s" + ) + + _reject_output_alias( + output_path, + audio_paths, + label="Narration animation config", + ) + _require_replaceable(output_path, force) + derived_errors = list( + dict.fromkeys( + [ + *validate_transition_config(derived), + *validate_animation_config_errors(derived), + ] + ) + ) + if derived_errors: + details = "\n".join(f"- {error}" for error in derived_errors) + raise ValueError(f"Derived narration animation config is invalid:\n{details}") + _atomic_write_text( + output_path, + json.dumps(derived, ensure_ascii=False, indent=2) + "\n", + ) + + for warning in drift_warnings: + print(f"Warning: {warning}", file=sys.stderr) + if positional_slides: + print( + "Warning: no narration_timing.json found — object reveals were mapped " + "positionally (group N -> subtitle cue N). This mistimes any page whose " + "narration is longer than its object count: later objects reveal early, " + "while the narrator is still on an earlier point. Author " + f"{plan_path} mapping each SVG group to the subtitle cue that speaks " + "about it (omit a group's cue to keep its canonical delay), then re-run.", + file=sys.stderr, + ) + risky = [ + (name, group_count, cue_count) + for name, group_count, cue_count in positional_slides + if group_count > 1 and cue_count > group_count + 1 + ] + for name, group_count, cue_count in risky: + print( + f"Warning: {name}: {group_count} object(s) but {cue_count} " + "subtitle cue(s) — later objects likely reveal too early", + file=sys.stderr, + ) + return AnimationBuildResult( + slide_count=len(slide_names), + group_count=anchored_count + fallback_count, + anchored_count=anchored_count, + fallback_count=fallback_count, + ignored_cue_count=ignored_cue_count, + svg_fallback_slide_count=svg_fallback_slide_count, + timing_plan_written=False, + ) + + +def _presentation_slide_members(package: zipfile.ZipFile) -> list[str]: + try: + presentation_root = ET.fromstring(package.read("ppt/presentation.xml")) + relationships_root = ET.fromstring( + package.read("ppt/_rels/presentation.xml.rels") + ) + except KeyError as exc: + raise ValueError( + f"Narrated PPTX is missing presentation ordering data: {exc}" + ) from exc + + relationship_targets: dict[str, str] = {} + for relationship in relationships_root.iter(f"{{{_REL_NS}}}Relationship"): + relationship_id = relationship.get("Id") + target = relationship.get("Target") + if ( + relationship_id + and target + and relationship.get("TargetMode", "Internal") != "External" + ): + relationship_targets[relationship_id] = target.replace("\\", "/") + + slide_list = presentation_root.find(f"{{{_PML_NS}}}sldIdLst") + if slide_list is None: + raise ValueError("Narrated PPTX presentation has no slide order") + + members: list[str] = [] + for slide_id in slide_list.findall(f"{{{_PML_NS}}}sldId"): + relationship_id = slide_id.get(f"{{{_DOC_REL_NS}}}id") + target = relationship_targets.get(relationship_id or "") + if target is None: + raise ValueError( + "Narrated PPTX slide order references a missing relationship: " + f"{relationship_id!r}" + ) + if target.startswith("/"): + member = posixpath.normpath(target.lstrip("/")) + else: + member = posixpath.normpath(posixpath.join("ppt", target)) + if member not in package.namelist(): + raise ValueError( + f"Narrated PPTX slide relationship target is missing: {member}" + ) + members.append(member) + return members + + +def _read_powerpoint_timings(pptx_path: Path, slide_count: int) -> list[tuple[int, int]]: + timings: list[tuple[int, int]] = [] + with zipfile.ZipFile(pptx_path) as package: + slide_members = _presentation_slide_members(package) + if len(slide_members) != slide_count: + raise ValueError( + f"Narrated PPTX has {len(slide_members)} slides, " + f"but the project has {slide_count}" + ) + for slide_index, member in enumerate(slide_members, 1): + summary = read_slide_transition_xml(package.read(member)) + if summary.logical_count != 1: + raise ValueError( + f"Narrated PPTX slide {slide_index} has " + f"{summary.logical_count} logical transition carriers" + ) + advance_ms = summary.advance_after_ms + if advance_ms is None: + raise ValueError( + f"Narrated PPTX slide {slide_index} has no recorded advance time" + ) + transition_ms = summary.duration_ms or 0 + if advance_ms <= 0 or transition_ms < 0: + raise ValueError( + f"Narrated PPTX slide {slide_index} has invalid timing values" + ) + timings.append((transition_ms, advance_ms)) + return timings + + +def _powerpoint_audio_starts( + timings: list[tuple[int, int]], +) -> tuple[list[int], int]: + """Return theoretical narration starts and the complete PPTX timeline.""" + audio_starts: list[int] = [] + timeline_ms = 0 + for transition_ms, advance_ms in timings: + audio_start_ms = timeline_ms + transition_ms + audio_starts.append(audio_start_ms) + timeline_ms = audio_start_ms + advance_ms + return audio_starts, timeline_ms + + +def _require_numpy() -> Any: + try: + import numpy as np + except ImportError as exc: + raise RuntimeError( + "Exported-video subtitle calibration requires numpy. " + "Install it with: python3 -m pip install numpy" + ) from exc + return np + + +def _decode_audio_envelopes(path: Path, ffmpeg_path: str) -> tuple[Any, Any]: + """Decode the first audio stream and return 1 ms and 10 ms RMS envelopes.""" + np = _require_numpy() + command = [ + ffmpeg_path, + "-v", + "error", + "-i", + str(path), + "-map", + "0:a:0", + "-vn", + "-ac", + "1", + "-ar", + str(_ALIGNMENT_SAMPLE_RATE), + "-f", + "s16le", + "-", + ] + result = subprocess.run(command, capture_output=True, check=False) + if result.returncode != 0: + details = result.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError(f"Unable to decode audio with ffmpeg: {path}\n{details}") + + samples = np.frombuffer(result.stdout, dtype=" tuple[int, float]: + """Return the best normalized-correlation index for one search window.""" + np = _require_numpy() + if len(search) < len(template): + raise ValueError("Video alignment search window is shorter than its template") + + centered_template = template - template.mean() + template_norm = float(np.linalg.norm(centered_template)) + if template_norm <= 1e-9: + raise ValueError("Narration audio has no usable variation for video alignment") + + numerators = np.correlate(search, centered_template, mode="valid") + prefix = np.concatenate(([0.0], np.cumsum(search, dtype=np.float64))) + squared_prefix = np.concatenate( + ([0.0], np.cumsum(search * search, dtype=np.float64)) + ) + width = len(template) + window_sums = prefix[width:] - prefix[:-width] + window_squared_sums = squared_prefix[width:] - squared_prefix[:-width] + window_variances = np.maximum( + window_squared_sums - (window_sums * window_sums / width), + 1e-18, + ) + scores = numerators / (np.sqrt(window_variances) * template_norm) + best_index = int(np.argmax(scores)) + return best_index, float(scores[best_index]) + + +def _alignment_template_bounds( + fine_envelope: Any, + cues: list[SubtitleCue], +) -> tuple[int, int]: + duration_ms = len(fine_envelope) + start_ms = min(cues[0].start_ms, max(0, duration_ms - 500)) + cue_end_ms = min(cues[-1].end_ms, duration_ms) + end_ms = min(duration_ms, start_ms + _ALIGNMENT_TEMPLATE_MAX_MS) + end_ms = min(end_ms, max(start_ms + 1000, cue_end_ms)) + if end_ms - start_ms < 500: + raise ValueError("Narration cue range is too short for video alignment") + return start_ms, end_ms + + +def _locate_audio_start( + video_fine: Any, + video_coarse: Any, + audio_fine: Any, + audio_coarse: Any, + cues: list[SubtitleCue], + predicted_start_ms: int, +) -> tuple[int, float]: + """Locate one page narration near its predicted exported-video position.""" + start_ms, end_ms = _alignment_template_bounds(audio_fine, cues) + coarse_start = start_ms // _ALIGNMENT_COARSE_FACTOR + coarse_end = max( + coarse_start + 50, + end_ms // _ALIGNMENT_COARSE_FACTOR, + ) + coarse_template = audio_coarse[coarse_start:coarse_end] + predicted_coarse = predicted_start_ms // _ALIGNMENT_COARSE_FACTOR + coarse_window = _ALIGNMENT_SEARCH_WINDOW_MS // _ALIGNMENT_COARSE_FACTOR + search_start = max( + 0, + predicted_coarse + coarse_start - coarse_window, + ) + search_end = min( + len(video_coarse), + predicted_coarse + coarse_start + coarse_window + len(coarse_template), + ) + coarse_index, _coarse_score = _best_correlation( + video_coarse[search_start:search_end], + coarse_template, + ) + coarse_audio_start_ms = ( + search_start + coarse_index - coarse_start + ) * _ALIGNMENT_COARSE_FACTOR + + fine_template = audio_fine[start_ms:end_ms] + fine_search_start = max( + 0, + coarse_audio_start_ms + start_ms - _ALIGNMENT_REFINE_WINDOW_MS, + ) + fine_search_end = min( + len(video_fine), + coarse_audio_start_ms + + start_ms + + _ALIGNMENT_REFINE_WINDOW_MS + + len(fine_template), + ) + fine_index, fine_score = _best_correlation( + video_fine[fine_search_start:fine_search_end], + fine_template, + ) + audio_start_ms = fine_search_start + fine_index - start_ms + return audio_start_ms, fine_score + + +def _align_audio_starts_to_video( + *, + slide_names: list[str], + local_cues: dict[str, list[SubtitleCue]], + theoretical_starts: list[int], + audio_dir: Path, + video_path: Path, +) -> tuple[list[int], list[float], list[Path]]: + """Align every page narration to the audio track of an exported video.""" + if not video_path.is_file(): + raise FileNotFoundError(f"Exported video does not exist: {video_path}") + ffmpeg_path = shutil.which("ffmpeg") + if ffmpeg_path is None: + raise RuntimeError( + "Exported-video subtitle calibration requires ffmpeg. " + "Install ffmpeg and make it available on PATH." + ) + + video_fine, video_coarse = _decode_audio_envelopes(video_path, ffmpeg_path) + aligned_starts: list[int] = [] + correlations: list[float] = [] + audio_paths: list[Path] = [] + + for index, slide_name in enumerate(slide_names): + audio_path = _find_audio(audio_dir, slide_name) + audio_paths.append(audio_path) + audio_fine, audio_coarse = _decode_audio_envelopes(audio_path, ffmpeg_path) + if ( + local_cues[slide_name][-1].end_ms + > len(audio_fine) + _ALIGNMENT_END_TOLERANCE_MS + ): + raise ValueError( + f"{slide_name}.srt ends after its narration audio: " + f"cue end={_seconds_from_ms(local_cues[slide_name][-1].end_ms):.3f}s, " + f"decoded audio={_seconds_from_ms(len(audio_fine)):.3f}s" + ) + if index == 0: + predicted_start_ms = theoretical_starts[index] + else: + predicted_start_ms = ( + aligned_starts[index - 1] + + theoretical_starts[index] + - theoretical_starts[index - 1] + ) + aligned_start_ms, correlation = _locate_audio_start( + video_fine, + video_coarse, + audio_fine, + audio_coarse, + local_cues[slide_name], + predicted_start_ms, + ) + if correlation < _ALIGNMENT_MIN_CORRELATION: + raise ValueError( + f"Exported-video audio match is unreliable for {slide_name}: " + f"correlation={correlation:.3f}, " + f"required>={_ALIGNMENT_MIN_CORRELATION:.2f}" + ) + if aligned_starts and aligned_start_ms <= aligned_starts[-1]: + raise ValueError( + f"Exported-video audio order is invalid at slide {slide_name}" + ) + final_cue_end_ms = ( + aligned_start_ms + local_cues[slide_name][-1].end_ms + ) + if ( + final_cue_end_ms + > len(video_fine) + _ALIGNMENT_END_TOLERANCE_MS + ): + raise ValueError( + f"Exported video ends before the final cue on slide {slide_name}: " + f"cue end={_seconds_from_ms(final_cue_end_ms):.3f}s, " + f"decoded video audio={_seconds_from_ms(len(video_fine)):.3f}s" + ) + aligned_starts.append(aligned_start_ms) + correlations.append(correlation) + + return aligned_starts, correlations, audio_paths + + +def _merge_subtitles_result( + project_path: Path, + *, + pptx_path: Path, + subtitle_dir: Path, + output_path: Path, + force: bool, + audio_dir: Path | None = None, + video_path: Path | None = None, +) -> SubtitleMergeResult: + """Merge local SRT files on the PPTX or exported-video timeline.""" + targets_by_slide, _anonymous_groups = scan_project_targets(project_path) + slide_names = list(targets_by_slide) + if not slide_names: + raise ValueError(f"No SVG slides found under: {project_path / 'svg_output'}") + + local_subtitle_paths = [ + subtitle_dir / f"{slide_name}.srt" + for slide_name in slide_names + ] + _reject_output_alias( + output_path, + [pptx_path, *local_subtitle_paths, *([video_path] if video_path else [])], + label="Merged subtitle", + ) + _require_replaceable(output_path, force) + timings = _read_powerpoint_timings(pptx_path, len(slide_names)) + theoretical_starts, timeline_ms = _powerpoint_audio_starts(timings) + local_cues = { + slide_name: _parse_srt(subtitle_dir / f"{slide_name}.srt") + for slide_name in slide_names + } + video_adjustments: list[int] = [] + correlations: list[float] = [] + + if video_path is None: + audio_starts = theoretical_starts + else: + resolved_audio_dir = audio_dir or project_path / "audio" + audio_starts, correlations, audio_paths = _align_audio_starts_to_video( + slide_names=slide_names, + local_cues=local_cues, + theoretical_starts=theoretical_starts, + audio_dir=resolved_audio_dir, + video_path=video_path, + ) + _reject_output_alias( + output_path, + audio_paths, + label="Merged subtitle", + ) + video_adjustments = [ + actual - theoretical + for actual, theoretical in zip(audio_starts, theoretical_starts) + ] + + merged_cues: list[SubtitleCue] = [] + + for slide_name, audio_start_ms, (_transition_ms, advance_ms) in zip( + slide_names, + audio_starts, + timings, + ): + slide_cues = local_cues[slide_name] + if slide_cues[-1].end_ms > advance_ms: + raise ValueError( + f"{slide_name}.srt ends at " + f"{_seconds_from_ms(slide_cues[-1].end_ms):.3f}s, after the " + f"PowerPoint slide advance at {_seconds_from_ms(advance_ms):.3f}s" + ) + for cue in slide_cues: + merged_cue = SubtitleCue( + cue.start_ms + audio_start_ms, + cue.end_ms + audio_start_ms, + cue.text, + ) + if merged_cues and merged_cue.start_ms < merged_cues[-1].end_ms: + raise ValueError( + f"Video-calibrated subtitle overlap before slide {slide_name}" + ) + merged_cues.append(merged_cue) + + _atomic_write_text(output_path, _format_srt(merged_cues)) + return SubtitleMergeResult( + slide_count=len(slide_names), + cue_count=len(merged_cues), + powerpoint_timeline_ms=timeline_ms, + minimum_video_adjustment_ms=( + min(video_adjustments) if video_adjustments else None + ), + maximum_video_adjustment_ms=( + max(video_adjustments) if video_adjustments else None + ), + minimum_video_correlation=( + min(correlations) if correlations else None + ), + ) + + +def merge_subtitles( + project_path: Path, + *, + pptx_path: Path, + subtitle_dir: Path, + output_path: Path, + force: bool, +) -> tuple[int, int, int]: + """Merge local SRT files using timing values read from the final PPTX.""" + result = _merge_subtitles_result( + project_path, + pptx_path=pptx_path, + subtitle_dir=subtitle_dir, + output_path=output_path, + force=force, + ) + return ( + result.slide_count, + result.cue_count, + result.powerpoint_timeline_ms, + ) + + +def merge_subtitles_to_video( + project_path: Path, + *, + pptx_path: Path, + subtitle_dir: Path, + audio_dir: Path, + video_path: Path, + output_path: Path, + force: bool, +) -> SubtitleMergeResult: + """Merge local SRT files after calibrating page starts to an exported video.""" + return _merge_subtitles_result( + project_path, + pptx_path=pptx_path, + subtitle_dir=subtitle_dir, + output_path=output_path, + force=force, + audio_dir=audio_dir, + video_path=video_path, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Derive narrated object animation timings and merge page-local " + "SRT files on PowerPoint's final timeline." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + fingerprint = subparsers.add_parser( + "fingerprint", + help="print the SHA-256 used to bind a timing plan to page-local SRT files", + ) + fingerprint.add_argument("project_path", help="Project directory") + fingerprint.add_argument( + "--subtitle-dir", + default=None, + help="Page-local SRT directory; default: /notes/subtitles", + ) + + animations = subparsers.add_parser( + "animations", + help="derive narration_animations.json from animations.json and page-local SRT", + ) + animations.add_argument("project_path", help="Project directory") + animations.add_argument( + "--animation-config", + default=None, + help="Read-only canonical config; default: /animations.json", + ) + animations.add_argument( + "--plan", + default=None, + help="Timing plan; default: /narration_timing.json", + ) + animations.add_argument( + "--subtitle-dir", + default=None, + help="Page-local SRT directory; default: /notes/subtitles", + ) + animations.add_argument( + "--audio-dir", + default=None, + help="Narration audio directory; default: /audio", + ) + animations.add_argument( + "-o", + "--output", + default=None, + help=( + "Narration animation output; " + "default: /narration_animations.json" + ), + ) + animations.add_argument( + "--narration-padding", + type=float, + default=0.5, + help="Seconds added after each narration before slide advance (default: 0.5)", + ) + animations.add_argument( + "--force", + action="store_true", + help="Replace an existing narration_animations.json", + ) + + subtitles = subparsers.add_parser( + "subtitles", + help="merge page-local SRT using timings read from a narrated PPTX", + ) + subtitles.add_argument("project_path", help="Project directory") + subtitles.add_argument( + "--pptx", + required=True, + help=( + "Final narrated PPTX whose recorded timing values define the " + "timeline; relative paths are resolved under the project" + ), + ) + subtitles.add_argument( + "--subtitle-dir", + default=None, + help="Page-local SRT directory; default: /notes/subtitles", + ) + subtitles.add_argument( + "--video", + default=None, + help=( + "PowerPoint-exported video whose audio track calibrates page starts; " + "relative paths are resolved under the project" + ), + ) + subtitles.add_argument( + "--audio-dir", + default=None, + help=( + "Page-local narration audio used with --video; " + "default: /audio" + ), + ) + subtitles.add_argument( + "-o", + "--output", + default=None, + help="Merged SRT output; default: /notes/subtitles/total.srt", + ) + subtitles.add_argument( + "--force", + action="store_true", + help="Replace an existing merged SRT", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + project_path = Path(args.project_path).resolve() + if not project_path.is_dir(): + parser.error(f"Project path does not exist: {project_path}") + + try: + if args.command == "fingerprint": + subtitle_dir = _project_path( + project_path, + args.subtitle_dir, + Path("notes/subtitles"), + ) + slide_names = _page_subtitle_names(subtitle_dir) + for slide_name in slide_names: + _parse_srt(subtitle_dir / f"{slide_name}.srt") + print(_subtitle_fingerprint(slide_names, subtitle_dir)) + return 0 + + if args.command == "animations": + canonical_path = _project_path( + project_path, + args.animation_config, + Path("animations.json"), + ) + plan_path = _project_path( + project_path, + args.plan, + Path("narration_timing.json"), + ) + subtitle_dir = _project_path( + project_path, + args.subtitle_dir, + Path("notes/subtitles"), + ) + audio_dir = _project_path( + project_path, + args.audio_dir, + Path("audio"), + ) + output_path = _project_path( + project_path, + args.output, + Path("narration_animations.json"), + ) + result = rebuild_animations( + project_path, + canonical_path=canonical_path, + plan_path=plan_path, + subtitle_dir=subtitle_dir, + audio_dir=audio_dir, + output_path=output_path, + narration_padding=args.narration_padding, + force=args.force, + ) + print(f"Narration animation config written: {output_path}") + print( + f"Slides: {result.slide_count}; groups: {result.group_count}; " + f"SRT-anchored: {result.anchored_count}; " + f"canonical-delay fallback: {result.fallback_count}; " + f"SVG fallback slides: {result.svg_fallback_slide_count}; " + f"unused cues: {result.ignored_cue_count}" + ) + return 0 + + pptx_path = _project_input_path(project_path, args.pptx).resolve() + subtitle_dir = _project_path( + project_path, + args.subtitle_dir, + Path("notes/subtitles"), + ) + audio_dir = _project_path( + project_path, + args.audio_dir, + Path("audio"), + ) + video_path = ( + _project_input_path(project_path, args.video).resolve() + if args.video + else None + ) + output_path = _project_path( + project_path, + args.output, + Path("notes/subtitles/total.srt"), + ) + result = _merge_subtitles_result( + project_path, + pptx_path=pptx_path, + subtitle_dir=subtitle_dir, + audio_dir=audio_dir, + video_path=video_path, + output_path=output_path, + force=args.force, + ) + print(f"Merged subtitle written: {output_path}") + print( + f"Slides: {result.slide_count}; cues: {result.cue_count}; " + "PowerPoint timeline: " + f"{_seconds_from_ms(result.powerpoint_timeline_ms):.3f}s" + ) + if result.minimum_video_correlation is not None: + print( + "Exported-video calibration: page adjustment " + f"{_seconds_from_ms(result.minimum_video_adjustment_ms or 0):+.3f}s " + "to " + f"{_seconds_from_ms(result.maximum_video_adjustment_ms or 0):+.3f}s; " + f"minimum correlation: {result.minimum_video_correlation:.3f}" + ) + return 0 + except ( + ET.ParseError, + OSError, + OverflowError, + ValueError, + RuntimeError, + zipfile.BadZipFile, + ) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py index 7009e5a1..f0bc1aa5 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py @@ -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 --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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py index 358f02dd..b3da5a76 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py @@ -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: /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 /narration_animations.json; other exports default to ' + '/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( diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tts_backends/backend_edge.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tts_backends/backend_edge.py index 8f8445e4..8f0e433f 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tts_backends/backend_edge.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tts_backends/backend_edge.py @@ -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}") - diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py new file mode 100644 index 00000000..1335f75d --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py @@ -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 --video