#!/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 timeline 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_TIMING_OPTION_FIELDS, animation_seconds_to_milliseconds, normalize_animation_effect, normalize_animation_trigger, ) from pptx_transitions import ( # noqa: E402 DEFAULT_TRANSITION_DURATION, normalize_transition_effect_request, read_slide_transition_xml, validate_seconds, ) from svg_to_pptx.animation_config import ( # noqa: E402 animation_group_effect_entries, scan_project_targets, scan_svg_targets, validate_animation_config_errors, validate_transition_config, ) from svg_to_pptx.pptx_package.narration import ( # noqa: E402 DEFAULT_NARRATION_START_FLOOR, NARRATION_EXTENSIONS, narration_lead_in_seconds, probe_audio_duration, read_narration_start_delay_xml, ) 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 effect_index: int | None order: int source_index: int duration_ms: int original_delay_ms: int trigger: str @dataclass(frozen=True) class SlideAnimationSettings: """Effective animation settings inherited by one slide.""" effect: str | None duration_ms: int stagger_ms: int trigger: str timing_options: dict[str, Any] @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 @dataclass(frozen=True) class PowerPointTiming: """One slide's transition, narration, and advance timing in milliseconds.""" transition_ms: int narration_delay_ms: int advance_ms: int @dataclass(frozen=True) class VideoSlideTiming: """One slide mapped from the PPTX clock to an exported-video clock.""" slide_name: str transition_ms: int narration_delay_ms: int advance_ms: int powerpoint_slide_start_ms: int powerpoint_narration_start_ms: int video_slide_start_ms: int video_narration_start_ms: int adjustment_ms: int correlation: float audio_path: Path @dataclass(frozen=True) class VideoTimelineCalibration: """Page-level calibration between one narrated PPTX and exported video.""" slides: tuple[VideoSlideTiming, ...] powerpoint_timeline_ms: int def _timestamp_to_ms(value: str) -> int: hours_text, minutes_text, remainder = value.split(":") seconds_text, milliseconds_text = remainder.split(",") 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 _finite_non_negative_seconds(value: object, field: str) -> float: if ( isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or value < 0 ): raise ValueError(f'{field} must be a finite non-negative number') return float(value) def _load_timing_plan( path: Path, ) -> tuple[str, float, float | None, 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", "narration_start_floor", "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 = _finite_non_negative_seconds( raw.get("narration_padding"), 'Narration timing plan field "narration_padding"', ) narration_start_floor = ( _finite_non_negative_seconds( raw["narration_start_floor"], 'Narration timing plan field "narration_start_floor"', ) if "narration_start_floor" in raw else None ) 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, narration_padding, narration_start_floor, 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 _transition_scope( scope: dict[str, Any], *, label: str, ) -> dict[str, Any]: value = scope.get("transition", {}) if not isinstance(value, dict): raise ValueError(f'{label} field "transition" must be an object') return value def _effective_transition_duration_ms( config: dict[str, Any], slide_cfg: dict[str, Any], ) -> int: """Resolve the destination slide's effective transition duration.""" defaults = config.get("defaults", {}) if not isinstance(defaults, dict): raise ValueError('Canonical animations.json field "defaults" must be an object') default_transition = _transition_scope( defaults, label="Canonical animations.json defaults", ) default_effect, _default_options = normalize_transition_effect_request( default_transition.get("effect", "fade"), default_transition.get("effect_options"), ) default_duration = validate_seconds( default_transition.get("duration", DEFAULT_TRANSITION_DURATION), "canonical transition duration", allow_zero=default_effect is None, ) slide_transition = _transition_scope( slide_cfg, label="Canonical animations.json slide", ) if "effect" in slide_transition: effect, _effect_options = normalize_transition_effect_request( slide_transition["effect"], slide_transition.get("effect_options"), ) else: effect = default_effect duration = validate_seconds( slide_transition.get("duration", default_duration), "canonical slide transition duration", allow_zero=effect is None, ) return 0 if effect is None else round(duration * 1000) 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 = normalize_animation_trigger( slide_animation.get( "trigger", default_animation.get("trigger", "after-previous"), ) ) timing_options = { field: ( slide_animation[field] if field in slide_animation else default_animation[field] ) for field in ANIMATION_TIMING_OPTION_FIELDS if field in slide_animation or field in default_animation } return SlideAnimationSettings( effect=effect, duration_ms=duration_ms, stagger_ms=stagger_ms, trigger=trigger, timing_options=timing_options, ) def _animation_playback_duration_ms( duration_ms: int, timing_options: dict[str, Any], *, label: str, ) -> int: """Return schedule duration after repeat and auto-reverse parameters.""" if 'repeat_duration' in timing_options: return animation_seconds_to_milliseconds( timing_options['repeat_duration'], f'{label} repeat_duration', allow_zero=False, ) one_play = duration_ms * ( 2 if timing_options.get('auto_reverse') is True else 1 ) repeat_count = timing_options.get('repeat_count', 1) if ( isinstance(repeat_count, bool) or not isinstance(repeat_count, (int, float)) or not math.isfinite(float(repeat_count)) or float(repeat_count) <= 0 ): raise ValueError(f'{label} repeat_count must be a positive number') return max(1, round(one_play * float(repeat_count))) def _active_group_effect_entries( slide_name: str, group_id: str, group_cfg: dict[str, Any], slide_effect: str | None, ) -> tuple[tuple[int | None, str, dict[str, Any]], ...]: """Return active legacy or multi-effect rows with stable write locations.""" group_path = ( f'slides[{json.dumps(slide_name, ensure_ascii=False)}]' f'.groups[{json.dumps(group_id, ensure_ascii=False)}]' ) effect_entries = animation_group_effect_entries( group_cfg, path=group_path, ) is_multi_effect = 'effects' in group_cfg active: list[tuple[int | None, str, dict[str, Any]]] = [] for index, (effect_path, effect_cfg) in enumerate(effect_entries): effect = normalize_animation_effect( effect_cfg.get("effect", slide_effect) ) if effect is None: continue active.append(( index if is_multi_effect else None, effect_path, effect_cfg, )) return tuple(active) def _group_is_animated( slide_name: str, group_id: str, group_cfg: dict[str, Any], slide_effect: str | None, ) -> bool: return bool( _active_group_effect_entries( slide_name, group_id, group_cfg, slide_effect, ) ) def _active_group_order_requires_svg( slide_name: str, groups_cfg: dict[str, Any], group_ids: list[str], slide_effect: str | None, ) -> bool: """Return whether SVG group order is needed to break sequence ambiguity.""" if len(group_ids) <= 1: return False order_owners: dict[int, str] = {} for group_id in group_ids: for _effect_index, _effect_path, effect_cfg in _active_group_effect_entries( slide_name, group_id, groups_cfg[group_id], slide_effect, ): order = effect_cfg.get("order") if order is None: return True previous_group = order_owners.setdefault(order, group_id) if previous_group != group_id: return True return False def _needs_svg_group_resolution( slide_name: str, 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( slide_name, group_id, group_cfg, settings.effect, ) ] if plan_entries is None: if settings.effect is not None: return True return _active_group_order_requires_svg( slide_name, groups_cfg, active_explicit, settings.effect, ) 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( slide_name, group_id, groups_cfg[group_id], settings.effect, ) ] return _active_group_order_requires_svg( slide_name, groups_cfg, active_candidates, settings.effect, ) 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( slide_name, 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( slide_name, group_id, 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( slide_name, target.group_id, 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, int, str, int | None, str, dict[str, Any], str, ] ] = [] for source_index, group_id in enumerate(candidate_ids): group_cfg = groups_cfg.get(group_id, {}) effect_entries = _active_group_effect_entries( slide_name, group_id, group_cfg, settings.effect, ) for effect_position, ( effect_index, effect_path, effect_cfg, ) in enumerate(effect_entries): order = effect_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 "{effect_path}" ' f"must be a positive integer: {order!r}" ) if effect_cfg.get("trigger_shape") is not None: raise ValueError( f'Recorded narration cannot synchronize trigger-shape ' f'animation "{effect_path}" on slide "{slide_name}"' ) effect_trigger = normalize_animation_trigger( effect_cfg.get("trigger", settings.trigger) ) if effect_trigger == "on-click": raise ValueError( f'Recorded narration cannot synchronize on-click animation ' f'"{effect_path}" on slide "{slide_name}"' ) preliminaries.append(( order, source_index, effect_position, group_id, effect_index, effect_path, effect_cfg, effect_trigger, )) preliminaries.sort(key=lambda item: (item[0], item[1], item[2])) states: list[AnimationGroupState] = [] for sequence_index, ( order, source_index, _effect_position, group_id, effect_index, effect_path, effect_cfg, effect_trigger, ) in enumerate(preliminaries): duration_ms = animation_seconds_to_milliseconds( effect_cfg.get("duration", settings.duration_ms / 1000), f'canonical animation duration for "{effect_path}"', allow_zero=False, ) timing_options = dict(settings.timing_options) timing_options.update( { field: effect_cfg[field] for field in ANIMATION_TIMING_OPTION_FIELDS if field in effect_cfg } ) playback_duration_ms = _animation_playback_duration_ms( duration_ms, timing_options, label=f'canonical animation for "{effect_path}"', ) default_delay = ( settings.stagger_ms / 1000 if effect_trigger == "after-previous" and sequence_index > 0 else 0 ) original_delay_ms = animation_seconds_to_milliseconds( effect_cfg.get("delay", default_delay), f'canonical animation delay for "{effect_path}"', allow_zero=True, ) states.append( AnimationGroupState( group_id=group_id, effect_index=effect_index, order=order, source_index=source_index, duration_ms=playback_duration_ms, original_delay_ms=original_delay_ms, trigger=effect_trigger, ) ) 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, narration_start_floor: float = DEFAULT_NARRATION_START_FLOOR, ) -> AnimationBuildResult: """Derive narration timing without modifying the canonical animation file.""" narration_padding = _finite_non_negative_seconds( narration_padding, "Narration padding", ) narration_start_floor = _finite_non_negative_seconds( narration_start_floor, "Narration start floor", ) _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, planned_start_floor, 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}" ) if planned_start_floor is not None and not math.isclose( narration_start_floor, planned_start_floor, rel_tol=0, abs_tol=1e-9, ): raise ValueError( "Narration start floor differs from the timing plan: " f"plan={planned_start_floor}, command={narration_start_floor}" ) 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) transition_duration_ms = _effective_transition_duration_ms( canonical, canonical_slide, ) narration_lead_in_ms = round( narration_lead_in_seconds( transition_duration_ms / 1000, start_floor=narration_start_floor, ) * 1000 ) 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({state.group_id for state in 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: ordered_group_ids = list( dict.fromkeys(state.group_id for state in states) ) cue_by_group = { group_id: index + 1 if index < len(cues) else None for index, group_id in enumerate(ordered_group_ids) } 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' ) # Start modes are row-relative; slide completion spans overlapping rows. previous_start_ms = 0 previous_row_end_ms = 0 timeline_end_ms = 0 has_previous_row = False referenced_cues: set[int] = set() seen_groups: set[str] = set() for state in states: first_group_effect = state.group_id not in seen_groups seen_groups.add(state.group_id) cue_number = ( cue_by_group.get(state.group_id) if first_group_effect else None ) if not has_previous_row: sequence_base_ms = 0 elif state.trigger == "with-previous": sequence_base_ms = previous_start_ms else: sequence_base_ms = previous_row_end_ms if cue_number is None: if first_group_effect: fallback_count += 1 delay_ms = state.original_delay_ms actual_start_ms = sequence_base_ms + delay_ms else: anchored_count += 1 referenced_cues.add(cue_number) cue_start_ms = cues[cue_number - 1].start_ms desired_start_ms = narration_lead_in_ms + cue_start_ms actual_start_ms = max(desired_start_ms, sequence_base_ms) delay_ms = actual_start_ms - sequence_base_ms drift_ms = actual_start_ms - desired_start_ms if drift_ms > 500: drift_warnings.append( f"{slide_name}/{state.group_id}: cue {cue_number} " f"starts at {_seconds_from_ms(cue_start_ms):.3f}s after " f"a {_seconds_from_ms(narration_lead_in_ms):.3f}s lead-in; " f"animation starts at {_seconds_from_ms(actual_start_ms):.3f}s " f"({state.trigger} drift " f"{_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" ) if state.effect_index is None: effect_value = group_value else: group_path = ( f'slides[{json.dumps(slide_name, ensure_ascii=False)}]' f'.groups[{json.dumps(state.group_id, ensure_ascii=False)}]' ) derived_effect_entries = animation_group_effect_entries( group_value, path=group_path, ) effect_value = derived_effect_entries[state.effect_index][1] effect_value["order"] = state.order effect_value["delay"] = _seconds_from_ms(delay_ms) effect_value["trigger"] = state.trigger previous_start_ms = actual_start_ms previous_row_end_ms = actual_start_ms + state.duration_ms timeline_end_ms = max(timeline_end_ms, previous_row_end_ms) has_previous_row = True ignored_cue_count += len(cues) - len(referenced_cues) advance_ms = round( ( audio_duration + narration_padding + narration_lead_in_ms / 1000 ) * 1000 ) if timeline_end_ms > advance_ms: raise ValueError( f'Animations on slide "{slide_name}" end at ' f"{_seconds_from_ms(timeline_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]: """Return slide package members in presentation order.""" 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[PowerPointTiming]: timings: list[PowerPointTiming] = [] 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): slide_xml = package.read(member) summary = read_slide_transition_xml(slide_xml) 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 narration_delay_ms = read_narration_start_delay_xml( slide_xml.decode("utf-8") ) if ( advance_ms <= 0 or transition_ms < 0 or narration_delay_ms < 0 or narration_delay_ms >= advance_ms ): raise ValueError( f"Narrated PPTX slide {slide_index} has invalid timing values" ) timings.append( PowerPointTiming( transition_ms=transition_ms, narration_delay_ms=narration_delay_ms, advance_ms=advance_ms, ) ) return timings def _powerpoint_audio_starts( timings: list[PowerPointTiming], ) -> tuple[list[int], int]: """Return theoretical narration starts and the complete PPTX timeline.""" audio_starts: list[int] = [] timeline_ms = 0 for timing in timings: slide_start_ms = timeline_ms audio_start_ms = ( slide_start_ms + timing.transition_ms + timing.narration_delay_ms ) audio_starts.append(audio_start_ms) timeline_ms = ( slide_start_ms + timing.transition_ms + timing.advance_ms ) return audio_starts, timeline_ms def _require_numpy() -> Any: try: import numpy as np except ImportError as exc: raise RuntimeError( "Exported-video timeline 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] | None, ) -> tuple[int, int]: duration_ms = len(fine_envelope) if cues: start_ms = min(cues[0].start_ms, max(0, duration_ms - 500)) cue_end_ms = min(cues[-1].end_ms, duration_ms) else: start_ms = 0 cue_end_ms = duration_ms end_ms = min(duration_ms, start_ms + _ALIGNMENT_TEMPLATE_MAX_MS) end_ms = min(end_ms, max(start_ms + 1000, cue_end_ms)) if end_ms - start_ms < 500: 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] | None, 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] | None], 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 timeline 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) slide_cues = local_cues.get(slide_name) if ( slide_cues and slide_cues[-1].end_ms > len(audio_fine) + _ALIGNMENT_END_TOLERANCE_MS ): raise ValueError( f"{slide_name}.srt ends after its narration audio: " f"cue end={_seconds_from_ms(slide_cues[-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, slide_cues, 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_audio_end_ms = aligned_start_ms + len(audio_fine) if ( final_audio_end_ms > len(video_fine) + _ALIGNMENT_END_TOLERANCE_MS ): raise ValueError( f"Exported video ends before the narration on slide {slide_name}: " f"audio end={_seconds_from_ms(final_audio_end_ms):.3f}s, " f"decoded video audio={_seconds_from_ms(len(video_fine)):.3f}s" ) aligned_starts.append(aligned_start_ms) correlations.append(correlation) return aligned_starts, correlations, audio_paths def calibrate_video_timeline( *, slide_names: list[str], pptx_path: Path, audio_dir: Path, video_path: Path, subtitle_dir: Path | None = None, ) -> VideoTimelineCalibration: """Calibrate PPTX slide starts against narration in an exported video. Page-local SRT improves the correlation template when available. Audio-only narration remains supported by matching each complete page track. """ if not slide_names: raise ValueError("Video timeline calibration requires at least one slide") if len(set(slide_names)) != len(slide_names): raise ValueError("Video timeline calibration slide names must be unique") timings = _read_powerpoint_timings(pptx_path, len(slide_names)) theoretical_starts, timeline_ms = _powerpoint_audio_starts(timings) local_cues: dict[str, list[SubtitleCue] | None] = {} for slide_name in slide_names: subtitle_path = ( subtitle_dir / f"{slide_name}.srt" if subtitle_dir is not None else None ) local_cues[slide_name] = ( _parse_srt(subtitle_path) if subtitle_path is not None and subtitle_path.is_file() else None ) aligned_starts, correlations, audio_paths = _align_audio_starts_to_video( slide_names=slide_names, local_cues=local_cues, theoretical_starts=theoretical_starts, audio_dir=audio_dir, video_path=video_path, ) slides: list[VideoSlideTiming] = [] powerpoint_slide_start_ms = 0 previous_video_slide_start_ms = -1 for ( slide_name, timing, powerpoint_narration_start_ms, video_narration_start_ms, correlation, audio_path, ) in zip( slide_names, timings, theoretical_starts, aligned_starts, correlations, audio_paths, ): raw_video_slide_start_ms = ( video_narration_start_ms - timing.transition_ms - timing.narration_delay_ms ) if raw_video_slide_start_ms < -_ALIGNMENT_END_TOLERANCE_MS: raise ValueError( f"Exported-video calibration places slide {slide_name} before " f"the video start: {raw_video_slide_start_ms}ms" ) video_slide_start_ms = max(0, raw_video_slide_start_ms) if video_slide_start_ms <= previous_video_slide_start_ms: raise ValueError( f"Exported-video slide order is invalid at {slide_name}" ) slides.append( VideoSlideTiming( slide_name=slide_name, transition_ms=timing.transition_ms, narration_delay_ms=timing.narration_delay_ms, advance_ms=timing.advance_ms, powerpoint_slide_start_ms=powerpoint_slide_start_ms, powerpoint_narration_start_ms=powerpoint_narration_start_ms, video_slide_start_ms=video_slide_start_ms, video_narration_start_ms=video_narration_start_ms, adjustment_ms=( video_narration_start_ms - powerpoint_narration_start_ms ), correlation=correlation, audio_path=audio_path, ) ) previous_video_slide_start_ms = video_slide_start_ms powerpoint_slide_start_ms += timing.transition_ms + timing.advance_ms return VideoTimelineCalibration( slides=tuple(slides), powerpoint_timeline_ms=timeline_ms, ) def _merge_subtitles_result( project_path: Path, *, 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" calibration = calibrate_video_timeline( slide_names=slide_names, pptx_path=pptx_path, audio_dir=resolved_audio_dir, video_path=video_path, subtitle_dir=subtitle_dir, ) if calibration.powerpoint_timeline_ms != timeline_ms: raise ValueError("Video calibration and subtitle timelines differ") audio_starts = [ slide.video_narration_start_ms for slide in calibration.slides ] correlations = [slide.correlation for slide in calibration.slides] audio_paths = [slide.audio_path for slide in calibration.slides] _reject_output_alias( output_path, audio_paths, 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, timing in zip( slide_names, audio_starts, timings, ): slide_cues = local_cues[slide_name] narration_window_ms = timing.advance_ms - timing.narration_delay_ms if slide_cues[-1].end_ms > narration_window_ms: raise ValueError( f"{slide_name}.srt ends at " f"{_seconds_from_ms(slide_cues[-1].end_ms):.3f}s, after the " "available narration window before PowerPoint advances at " f"{_seconds_from_ms(narration_window_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: /audio", ) 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: /audio", ) 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( "--narration-start-floor", type=float, default=DEFAULT_NARRATION_START_FLOOR, help=( "Minimum seconds from transition start to narration start; " "0 waits only for transition completion " f"(default: {DEFAULT_NARRATION_START_FLOOR:g})" ), ) 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: /audio", ) 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: /audio/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("audio"), ) 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("audio"), ) 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, narration_start_floor=args.narration_start_floor, 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("audio"), ) 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("audio/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())