返回 ppt-master
narration_sync.py
根目录 / skills / ppt-master / scripts / narration_sync.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Narration Sync Tool
4
5 Derive click-free narration timing from the canonical animation config and
6 page-local SRT cues, then merge those subtitles against timing values read
7 from the final narrated PPTX.
8 See workflows/stages/generate-audio.md for the owning stage.
9
10 Usage:
11 python3 scripts/narration_sync.py fingerprint <project_path>
12 python3 scripts/narration_sync.py animations <project_path>
13 python3 scripts/narration_sync.py subtitles <project_path> --pptx <pptx> --force
14 python3 scripts/narration_sync.py subtitles <project_path> --pptx <pptx> \
15 --video <powerpoint_video> --force
16
17 Examples:
18 python3 scripts/narration_sync.py fingerprint projects/demo
19 python3 scripts/narration_sync.py animations projects/demo
20 python3 scripts/narration_sync.py subtitles projects/demo --pptx exports/demo.pptx --force
21 python3 scripts/narration_sync.py subtitles projects/demo \
22 --pptx exports/demo.pptx --video exports/demo.mp4 --force
23
24 Dependencies:
25 ffprobe for animation-window validation. Optional exported-video calibration
26 additionally requires ffmpeg and numpy.
27 """
28
29 from __future__ import annotations
30
31 import argparse
32 import copy
33 import hashlib
34 import json
35 import math
36 import os
37 import posixpath
38 import re
39 import shutil
40 import subprocess
41 import sys
42 import tempfile
43 import zipfile
44 from dataclasses import dataclass
45 from pathlib import Path
46 from typing import Any
47 from xml.etree import ElementTree as ET
48
49 _SCRIPTS_DIR = Path(__file__).resolve().parent
50 if str(_SCRIPTS_DIR) not in sys.path:
51 sys.path.insert(0, str(_SCRIPTS_DIR))
52
53 from console_encoding import configure_utf8_stdio # noqa: E402
54 from pptx_animations import ( # noqa: E402
55 ANIMATION_TIMING_OPTION_FIELDS,
56 animation_seconds_to_milliseconds,
57 normalize_animation_effect,
58 normalize_animation_trigger,
59 )
60 from pptx_transitions import read_slide_transition_xml # noqa: E402
61 from svg_to_pptx.animation_config import ( # noqa: E402
62 animation_group_effect_entries,
63 scan_project_targets,
64 scan_svg_targets,
65 validate_animation_config_errors,
66 validate_transition_config,
67 )
68 from svg_to_pptx.pptx_package.narration import ( # noqa: E402
69 NARRATION_EXTENSIONS,
70 probe_audio_duration,
71 )
72
73 configure_utf8_stdio()
74
75
76 _PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
77 _REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
78 _DOC_REL_NS = (
79 "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
80 )
81 _TIMING_RE = re.compile(
82 r"^(?P<start>\d+:\d{2}:\d{2},\d{3})\s+-->\s+"
83 r"(?P<end>\d+:\d{2}:\d{2},\d{3})(?:\s+.*)?$"
84 )
85 _ALIGNMENT_SAMPLE_RATE = 8000
86 _ALIGNMENT_FINE_HZ = 1000
87 _ALIGNMENT_COARSE_FACTOR = 10
88 _ALIGNMENT_SEARCH_WINDOW_MS = 2000
89 _ALIGNMENT_REFINE_WINDOW_MS = 100
90 _ALIGNMENT_TEMPLATE_MAX_MS = 12_000
91 _ALIGNMENT_MIN_CORRELATION = 0.85
92 _ALIGNMENT_END_TOLERANCE_MS = 100
93
94
95 @dataclass(frozen=True)
96 class SubtitleCue:
97 """One parsed SRT cue on a millisecond timeline."""
98
99 start_ms: int
100 end_ms: int
101 text: str
102
103
104 @dataclass(frozen=True)
105 class TimingPlanEntry:
106 """One ordered animation target and its optional SRT cue anchor."""
107
108 group_id: str
109 cue_number: int | None
110
111
112 @dataclass(frozen=True)
113 class AnimationGroupState:
114 """One effective canonical animation row before narration timing."""
115
116 group_id: str
117 effect_index: int | None
118 order: int
119 source_index: int
120 duration_ms: int
121 original_delay_ms: int
122
123
124 @dataclass(frozen=True)
125 class SlideAnimationSettings:
126 """Effective animation settings inherited by one slide."""
127
128 effect: str | None
129 duration_ms: int
130 stagger_ms: int
131 trigger: str
132 timing_options: dict[str, Any]
133
134
135 @dataclass(frozen=True)
136 class AnimationBuildResult:
137 """Summary of one narration animation derivation."""
138
139 slide_count: int
140 group_count: int
141 anchored_count: int
142 fallback_count: int
143 ignored_cue_count: int
144 svg_fallback_slide_count: int
145 timing_plan_written: bool
146
147
148 @dataclass(frozen=True)
149 class SubtitleMergeResult:
150 """Summary of one page-local SRT merge."""
151
152 slide_count: int
153 cue_count: int
154 powerpoint_timeline_ms: int
155 minimum_video_adjustment_ms: int | None = None
156 maximum_video_adjustment_ms: int | None = None
157 minimum_video_correlation: float | None = None
158
159
160 def _timestamp_to_ms(value: str) -> int:
161 hours_text, minutes_text, remainder = value.split(":")
162 seconds_text, milliseconds_text = remainder.split(",")
163 hours = int(hours_text)
164 minutes = int(minutes_text)
165 seconds = int(seconds_text)
166 milliseconds = int(milliseconds_text)
167 if minutes >= 60 or seconds >= 60:
168 raise ValueError(f"Invalid SRT timestamp: {value}")
169 return (((hours * 60) + minutes) * 60 + seconds) * 1000 + milliseconds
170
171
172 def _ms_to_timestamp(value: int) -> str:
173 if value < 0:
174 raise ValueError(f"SRT timestamp cannot be negative: {value}")
175 hours, remainder = divmod(value, 3_600_000)
176 minutes, remainder = divmod(remainder, 60_000)
177 seconds, milliseconds = divmod(remainder, 1000)
178 return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
179
180
181 def _parse_srt(path: Path) -> list[SubtitleCue]:
182 text = path.read_text(encoding="utf-8-sig")
183 blocks = re.split(r"\r?\n\s*\r?\n", text.strip())
184 cues: list[SubtitleCue] = []
185 previous_end = -1
186
187 for block_number, block in enumerate(blocks, 1):
188 lines = block.splitlines()
189 if len(lines) < 3:
190 raise ValueError(f"{path}: malformed SRT block {block_number}")
191 try:
192 cue_number = int(lines[0].strip())
193 except ValueError as exc:
194 raise ValueError(
195 f"{path}: invalid cue number in block {block_number}"
196 ) from exc
197 if cue_number != block_number:
198 raise ValueError(
199 f"{path}: cue numbers must be consecutive from 1; "
200 f"block {block_number} is numbered {cue_number}"
201 )
202 timing_match = _TIMING_RE.match(lines[1].strip())
203 if timing_match is None:
204 raise ValueError(
205 f"{path}: invalid cue timing in block {block_number}"
206 )
207 start_ms = _timestamp_to_ms(timing_match.group("start"))
208 end_ms = _timestamp_to_ms(timing_match.group("end"))
209 cue_text = "\n".join(lines[2:]).strip()
210 if not cue_text:
211 raise ValueError(f"{path}: empty cue text in block {block_number}")
212 if end_ms <= start_ms:
213 raise ValueError(
214 f"{path}: cue {block_number} must end after it starts"
215 )
216 if start_ms < previous_end:
217 raise ValueError(
218 f"{path}: cue {block_number} overlaps the preceding cue"
219 )
220 cues.append(SubtitleCue(start_ms, end_ms, cue_text))
221 previous_end = end_ms
222
223 if not cues:
224 raise ValueError(f"No subtitle cues found: {path}")
225 return cues
226
227
228 def _format_srt(cues: list[SubtitleCue]) -> str:
229 blocks = []
230 for index, cue in enumerate(cues, 1):
231 blocks.append(
232 f"{index}\n"
233 f"{_ms_to_timestamp(cue.start_ms)} --> "
234 f"{_ms_to_timestamp(cue.end_ms)}\n"
235 f"{cue.text}"
236 )
237 return "\n\n".join(blocks) + "\n"
238
239
240 def _atomic_write_text(path: Path, text: str) -> None:
241 path.parent.mkdir(parents=True, exist_ok=True)
242 descriptor, temporary_name = tempfile.mkstemp(
243 prefix=f".{path.name}.",
244 suffix=".tmp",
245 dir=str(path.parent),
246 )
247 temporary_path = Path(temporary_name)
248 try:
249 stream = os.fdopen(descriptor, "w", encoding="utf-8", newline="\n")
250 descriptor = -1
251 with stream:
252 stream.write(text)
253 stream.flush()
254 os.fsync(stream.fileno())
255 os.replace(temporary_path, path)
256 finally:
257 if descriptor >= 0:
258 os.close(descriptor)
259 temporary_path.unlink(missing_ok=True)
260
261
262 def _project_path(project_path: Path, value: str | None, default: Path) -> Path:
263 path = Path(value) if value else default
264 return path if path.is_absolute() else project_path / path
265
266
267 def _project_input_path(project_path: Path, value: str) -> Path:
268 path = Path(value)
269 if path.is_absolute():
270 return path
271 return project_path / path
272
273
274 def _require_replaceable(path: Path, force: bool) -> None:
275 if path.exists() and not force:
276 raise FileExistsError(f"Output already exists: {path}; pass --force to replace it")
277
278
279 def _reject_output_alias(
280 output_path: Path,
281 input_paths: list[Path],
282 *,
283 label: str,
284 ) -> None:
285 output_resolved = output_path.resolve()
286 for input_path in input_paths:
287 if output_resolved == input_path.resolve():
288 raise ValueError(
289 f"{label} output must not overwrite an input file: {output_path}"
290 )
291
292
293 def _subtitle_fingerprint(slide_names: list[str], subtitle_dir: Path) -> str:
294 digest = hashlib.sha256()
295 for slide_name in slide_names:
296 path = subtitle_dir / f"{slide_name}.srt"
297 digest.update(slide_name.encode("utf-8"))
298 digest.update(b"\0")
299 digest.update(path.read_bytes())
300 digest.update(b"\0")
301 return digest.hexdigest()
302
303
304 def _load_timing_plan(
305 path: Path,
306 ) -> tuple[str, float, dict[str, list[TimingPlanEntry]]]:
307 raw = json.loads(path.read_text(encoding="utf-8"))
308 if not isinstance(raw, dict):
309 raise ValueError(f"Narration timing plan must be a JSON object: {path}")
310 unknown_top = set(raw) - {
311 "version",
312 "srt_sha256",
313 "narration_padding",
314 "slides",
315 }
316 if unknown_top:
317 raise ValueError(
318 f"Narration timing plan has unknown top-level field(s): "
319 f"{', '.join(sorted(unknown_top))}"
320 )
321 if raw.get("version") != 1:
322 raise ValueError(
323 f"Unsupported narration timing plan version: {raw.get('version')!r}"
324 )
325 srt_sha256 = raw.get("srt_sha256")
326 if (
327 not isinstance(srt_sha256, str)
328 or re.fullmatch(r"[0-9a-f]{64}", srt_sha256) is None
329 ):
330 raise ValueError(
331 'Narration timing plan field "srt_sha256" must be a lowercase '
332 "SHA-256 digest of the ordered page-local SRT files"
333 )
334 narration_padding = raw.get("narration_padding")
335 if (
336 isinstance(narration_padding, bool)
337 or not isinstance(narration_padding, (int, float))
338 or not math.isfinite(float(narration_padding))
339 or narration_padding < 0
340 ):
341 raise ValueError(
342 'Narration timing plan field "narration_padding" must be a '
343 "finite non-negative number"
344 )
345 slides = raw.get("slides")
346 if not isinstance(slides, dict):
347 raise ValueError('Narration timing plan field "slides" must be an object')
348
349 result: dict[str, list[TimingPlanEntry]] = {}
350 for slide_name, slide_raw in slides.items():
351 if not isinstance(slide_name, str) or not slide_name:
352 raise ValueError("Narration timing plan slide names must be non-empty strings")
353 if not isinstance(slide_raw, dict) or set(slide_raw) != {"groups"}:
354 raise ValueError(
355 f'Narration timing plan slide "{slide_name}" must contain only "groups"'
356 )
357 groups = slide_raw["groups"]
358 if not isinstance(groups, list):
359 raise ValueError(
360 f'Narration timing plan slide "{slide_name}" groups must be a list'
361 )
362
363 entries: list[TimingPlanEntry] = []
364 seen_groups: set[str] = set()
365 for position, entry_raw in enumerate(groups, 1):
366 if not isinstance(entry_raw, dict):
367 raise ValueError(
368 f'Narration timing plan "{slide_name}" group #{position} '
369 "must be an object"
370 )
371 unknown_fields = set(entry_raw) - {"id", "cue"}
372 if unknown_fields:
373 raise ValueError(
374 f'Narration timing plan "{slide_name}" group #{position} '
375 f"has unknown field(s): {', '.join(sorted(unknown_fields))}"
376 )
377 group_id = entry_raw.get("id")
378 if not isinstance(group_id, str) or not group_id.strip():
379 raise ValueError(
380 f'Narration timing plan "{slide_name}" group #{position} '
381 'field "id" must be a non-empty string'
382 )
383 if group_id in seen_groups:
384 raise ValueError(
385 f'Narration timing plan "{slide_name}" repeats group "{group_id}"'
386 )
387 cue_number = entry_raw.get("cue")
388 if cue_number is not None and (
389 isinstance(cue_number, bool)
390 or not isinstance(cue_number, int)
391 or cue_number <= 0
392 ):
393 raise ValueError(
394 f'Narration timing plan "{slide_name}/{group_id}" cue '
395 "must be a positive integer or null"
396 )
397 entries.append(TimingPlanEntry(group_id, cue_number))
398 seen_groups.add(group_id)
399 result[slide_name] = entries
400 return srt_sha256, float(narration_padding), result
401
402
403 def _load_canonical_animation_config(path: Path) -> dict[str, Any]:
404 """Load and field-validate the read-only canonical animation sidecar."""
405 if not path.is_file():
406 raise FileNotFoundError(
407 f"Canonical animation config is missing: {path}. "
408 "Complete the customize-animations stage first; narration sync "
409 "does not create or replace animations.json."
410 )
411 raw = json.loads(path.read_text(encoding="utf-8"))
412 if not isinstance(raw, dict):
413 raise ValueError(f"Canonical animation config must be a JSON object: {path}")
414 if raw.get("version", 1) != 1:
415 raise ValueError(
416 f"Unsupported canonical animation config version: {raw.get('version')!r}"
417 )
418 errors = list(
419 dict.fromkeys(
420 [
421 *validate_transition_config(raw),
422 *validate_animation_config_errors(raw),
423 ]
424 )
425 )
426 if errors:
427 details = "\n".join(f"- {error}" for error in errors)
428 raise ValueError(
429 f"Canonical animation config is invalid: {path}\n{details}"
430 )
431 return raw
432
433
434 def _page_subtitle_names(subtitle_dir: Path) -> list[str]:
435 """Return ordered page-local SRT stems, excluding the merged sidecar."""
436 if not subtitle_dir.is_dir():
437 raise FileNotFoundError(f"Page-local SRT directory not found: {subtitle_dir}")
438 names = [
439 path.stem
440 for path in sorted(subtitle_dir.glob("*.srt"))
441 if path.stem != "total"
442 ]
443 if not names:
444 raise FileNotFoundError(f"No page-local SRT files found under: {subtitle_dir}")
445 return names
446
447
448 def _animation_slide_names(
449 project_path: Path,
450 config: dict[str, Any],
451 subtitle_dir: Path,
452 ) -> list[str]:
453 """Resolve the page roster without parsing SVG unless a slide is missing."""
454 slides = config.get("slides", {})
455 if not isinstance(slides, dict):
456 raise ValueError('Canonical animations.json field "slides" must be an object')
457 canonical_names = list(slides)
458 subtitle_names = _page_subtitle_names(subtitle_dir)
459 subtitle_set = set(subtitle_names)
460
461 missing_subtitles = [
462 slide_name for slide_name in canonical_names if slide_name not in subtitle_set
463 ]
464 if missing_subtitles:
465 raise FileNotFoundError(
466 "Missing page-local SRT for canonical animation slide(s): "
467 + ", ".join(missing_subtitles)
468 )
469
470 canonical_set = set(canonical_names)
471 extra_subtitles = [
472 slide_name for slide_name in subtitle_names if slide_name not in canonical_set
473 ]
474 unexpected = [
475 slide_name
476 for slide_name in extra_subtitles
477 if not (project_path / "svg_output" / f"{slide_name}.svg").is_file()
478 ]
479 if unexpected:
480 raise ValueError(
481 "Page-local SRT has no matching canonical animation slide or SVG: "
482 + ", ".join(unexpected)
483 )
484 if not extra_subtitles:
485 return canonical_names
486 return subtitle_names
487
488
489 def _animation_scope(
490 scope: dict[str, Any],
491 *,
492 label: str,
493 ) -> dict[str, Any]:
494 value = scope.get("animation", {})
495 if not isinstance(value, dict):
496 raise ValueError(f'{label} field "animation" must be an object')
497 return value
498
499
500 def _effective_slide_animation(
501 config: dict[str, Any],
502 slide_cfg: dict[str, Any],
503 ) -> SlideAnimationSettings:
504 defaults = config.get("defaults", {})
505 if not isinstance(defaults, dict):
506 raise ValueError('Canonical animations.json field "defaults" must be an object')
507 default_animation = _animation_scope(
508 defaults,
509 label="Canonical animations.json defaults",
510 )
511 slide_animation = _animation_scope(
512 slide_cfg,
513 label="Canonical animations.json slide",
514 )
515 effect = normalize_animation_effect(
516 slide_animation.get(
517 "effect",
518 default_animation.get("effect", "none"),
519 )
520 )
521 duration_ms = animation_seconds_to_milliseconds(
522 slide_animation.get(
523 "duration",
524 default_animation.get("duration", 0.4),
525 ),
526 "canonical animation duration",
527 allow_zero=False,
528 )
529 stagger_ms = animation_seconds_to_milliseconds(
530 slide_animation.get(
531 "stagger",
532 default_animation.get("stagger", 0.5),
533 ),
534 "canonical animation stagger",
535 allow_zero=True,
536 )
537 trigger = normalize_animation_trigger(
538 slide_animation.get(
539 "trigger",
540 default_animation.get("trigger", "after-previous"),
541 )
542 )
543 timing_options = {
544 field: (
545 slide_animation[field]
546 if field in slide_animation
547 else default_animation[field]
548 )
549 for field in ANIMATION_TIMING_OPTION_FIELDS
550 if field in slide_animation or field in default_animation
551 }
552 return SlideAnimationSettings(
553 effect=effect,
554 duration_ms=duration_ms,
555 stagger_ms=stagger_ms,
556 trigger=trigger,
557 timing_options=timing_options,
558 )
559
560
561 def _animation_playback_duration_ms(
562 duration_ms: int,
563 timing_options: dict[str, Any],
564 *,
565 label: str,
566 ) -> int:
567 """Return schedule duration after repeat and auto-reverse parameters."""
568 if 'repeat_duration' in timing_options:
569 return animation_seconds_to_milliseconds(
570 timing_options['repeat_duration'],
571 f'{label} repeat_duration',
572 allow_zero=False,
573 )
574 one_play = duration_ms * (
575 2 if timing_options.get('auto_reverse') is True else 1
576 )
577 repeat_count = timing_options.get('repeat_count', 1)
578 if (
579 isinstance(repeat_count, bool)
580 or not isinstance(repeat_count, (int, float))
581 or not math.isfinite(float(repeat_count))
582 or float(repeat_count) <= 0
583 ):
584 raise ValueError(f'{label} repeat_count must be a positive number')
585 return max(1, round(one_play * float(repeat_count)))
586
587
588 def _active_group_effect_entries(
589 slide_name: str,
590 group_id: str,
591 group_cfg: dict[str, Any],
592 slide_effect: str | None,
593 ) -> tuple[tuple[int | None, str, dict[str, Any]], ...]:
594 """Return active legacy or multi-effect rows with stable write locations."""
595 group_path = (
596 f'slides[{json.dumps(slide_name, ensure_ascii=False)}]'
597 f'.groups[{json.dumps(group_id, ensure_ascii=False)}]'
598 )
599 effect_entries = animation_group_effect_entries(
600 group_cfg,
601 path=group_path,
602 )
603 is_multi_effect = 'effects' in group_cfg
604 active: list[tuple[int | None, str, dict[str, Any]]] = []
605 for index, (effect_path, effect_cfg) in enumerate(effect_entries):
606 effect = normalize_animation_effect(
607 effect_cfg.get("effect", slide_effect)
608 )
609 if effect is None:
610 continue
611 active.append((
612 index if is_multi_effect else None,
613 effect_path,
614 effect_cfg,
615 ))
616 return tuple(active)
617
618
619 def _group_is_animated(
620 slide_name: str,
621 group_id: str,
622 group_cfg: dict[str, Any],
623 slide_effect: str | None,
624 ) -> bool:
625 return bool(
626 _active_group_effect_entries(
627 slide_name,
628 group_id,
629 group_cfg,
630 slide_effect,
631 )
632 )
633
634
635 def _active_group_order_requires_svg(
636 slide_name: str,
637 groups_cfg: dict[str, Any],
638 group_ids: list[str],
639 slide_effect: str | None,
640 ) -> bool:
641 """Return whether SVG group order is needed to break sequence ambiguity."""
642 if len(group_ids) <= 1:
643 return False
644 order_owners: dict[int, str] = {}
645 for group_id in group_ids:
646 for _effect_index, _effect_path, effect_cfg in _active_group_effect_entries(
647 slide_name,
648 group_id,
649 groups_cfg[group_id],
650 slide_effect,
651 ):
652 order = effect_cfg.get("order")
653 if order is None:
654 return True
655 previous_group = order_owners.setdefault(order, group_id)
656 if previous_group != group_id:
657 return True
658 return False
659
660
661 def _needs_svg_group_resolution(
662 slide_name: str,
663 settings: SlideAnimationSettings,
664 groups_cfg: dict[str, Any],
665 plan_entries: list[TimingPlanEntry] | None,
666 ) -> bool:
667 """Return whether JSON alone cannot prove the effective group sequence."""
668 active_explicit = [
669 group_id
670 for group_id, group_cfg in groups_cfg.items()
671 if isinstance(group_cfg, dict)
672 and _group_is_animated(
673 slide_name,
674 group_id,
675 group_cfg,
676 settings.effect,
677 )
678 ]
679 if plan_entries is None:
680 if settings.effect is not None:
681 return True
682 return _active_group_order_requires_svg(
683 slide_name,
684 groups_cfg,
685 active_explicit,
686 settings.effect,
687 )
688
689 candidate_ids = list(
690 dict.fromkeys(
691 [
692 *(entry.group_id for entry in plan_entries),
693 *active_explicit,
694 ]
695 )
696 )
697 active_candidates = [
698 group_id
699 for group_id in candidate_ids
700 if group_id in groups_cfg
701 and isinstance(groups_cfg[group_id], dict)
702 and _group_is_animated(
703 slide_name,
704 group_id,
705 groups_cfg[group_id],
706 settings.effect,
707 )
708 ]
709 return _active_group_order_requires_svg(
710 slide_name,
711 groups_cfg,
712 active_candidates,
713 settings.effect,
714 )
715
716
717 def _resolve_animation_groups(
718 project_path: Path,
719 slide_name: str,
720 slide_cfg: dict[str, Any],
721 settings: SlideAnimationSettings,
722 plan_entries: list[TimingPlanEntry] | None,
723 ) -> tuple[list[AnimationGroupState], bool]:
724 """Resolve one effective sequence, parsing only an ambiguous page SVG."""
725 groups_value = slide_cfg.get("groups", {})
726 if not isinstance(groups_value, dict):
727 raise ValueError(
728 f'Canonical animations.json slide "{slide_name}" groups must be an object'
729 )
730 groups_cfg: dict[str, dict[str, Any]] = {}
731 for group_id, group_cfg in groups_value.items():
732 if not isinstance(group_cfg, dict):
733 raise ValueError(
734 f'Canonical animations.json group "{slide_name}/{group_id}" '
735 "must be an object"
736 )
737 groups_cfg[group_id] = group_cfg
738 use_svg = _needs_svg_group_resolution(
739 slide_name,
740 settings,
741 groups_cfg,
742 plan_entries,
743 )
744 candidate_ids: list[str]
745 if use_svg:
746 svg_path = project_path / "svg_output" / f"{slide_name}.svg"
747 if not svg_path.is_file():
748 raise FileNotFoundError(
749 f"Animation group mapping is ambiguous and requires the page SVG: "
750 f"{svg_path}"
751 )
752 targets, anonymous_groups = scan_svg_targets(svg_path)
753 duplicate_ids = sorted(
754 group_id
755 for group_id in {target.group_id for target in targets}
756 if sum(target.group_id == group_id for target in targets) > 1
757 )
758 if duplicate_ids:
759 raise ValueError(
760 f'SVG slide "{slide_name}" has duplicate top-level group id(s): '
761 + ", ".join(duplicate_ids)
762 )
763 for item in anonymous_groups:
764 print(
765 f"Warning: {item} has no id and cannot participate in narration timing",
766 file=sys.stderr,
767 )
768
769 targets_by_id = {target.group_id: target for target in targets}
770 referenced_ids = set(groups_cfg)
771 if plan_entries is not None:
772 referenced_ids.update(entry.group_id for entry in plan_entries)
773 missing_ids = sorted(referenced_ids - set(targets_by_id))
774 if missing_ids:
775 raise ValueError(
776 f'Animation mapping for slide "{slide_name}" references missing '
777 f"top-level group(s): {', '.join(missing_ids)}"
778 )
779 structural_ids = sorted(
780 group_id
781 for group_id in referenced_ids
782 if targets_by_id[group_id].structurally_static
783 and (
784 group_id not in groups_cfg
785 or _group_is_animated(
786 slide_name,
787 group_id,
788 groups_cfg[group_id],
789 settings.effect,
790 )
791 )
792 )
793 if structural_ids:
794 raise ValueError(
795 f'Animation mapping for slide "{slide_name}" targets structural '
796 f"group(s): {', '.join(structural_ids)}"
797 )
798
799 candidate_ids = []
800 for target in targets:
801 if target.structurally_static:
802 continue
803 group_cfg = groups_cfg.get(target.group_id, {})
804 explicitly_animated = (
805 target.group_id in groups_cfg
806 and _group_is_animated(
807 slide_name,
808 target.group_id,
809 group_cfg,
810 settings.effect,
811 )
812 )
813 if target.chrome and not explicitly_animated:
814 continue
815 candidate_ids.append(target.group_id)
816 elif plan_entries is not None:
817 candidate_ids = list(
818 dict.fromkeys(
819 [
820 *(entry.group_id for entry in plan_entries),
821 *groups_cfg,
822 ]
823 )
824 )
825 else:
826 candidate_ids = list(groups_cfg)
827
828 preliminaries: list[
829 tuple[
830 int,
831 int,
832 int,
833 str,
834 int | None,
835 str,
836 dict[str, Any],
837 str,
838 ]
839 ] = []
840 for source_index, group_id in enumerate(candidate_ids):
841 group_cfg = groups_cfg.get(group_id, {})
842 effect_entries = _active_group_effect_entries(
843 slide_name,
844 group_id,
845 group_cfg,
846 settings.effect,
847 )
848 for effect_position, (
849 effect_index,
850 effect_path,
851 effect_cfg,
852 ) in enumerate(effect_entries):
853 order = effect_cfg.get("order", source_index + 1)
854 if isinstance(order, bool) or not isinstance(order, int) or order <= 0:
855 raise ValueError(
856 f'Canonical animation order for "{effect_path}" '
857 f"must be a positive integer: {order!r}"
858 )
859 if effect_cfg.get("trigger_shape") is not None:
860 raise ValueError(
861 f'Recorded narration cannot synchronize trigger-shape '
862 f'animation "{effect_path}" on slide "{slide_name}"'
863 )
864 effect_trigger = normalize_animation_trigger(
865 effect_cfg.get("trigger", settings.trigger)
866 )
867 if effect_trigger == "on-click":
868 raise ValueError(
869 f'Recorded narration cannot synchronize on-click animation '
870 f'"{effect_path}" on slide "{slide_name}"'
871 )
872 preliminaries.append((
873 order,
874 source_index,
875 effect_position,
876 group_id,
877 effect_index,
878 effect_path,
879 effect_cfg,
880 effect_trigger,
881 ))
882 preliminaries.sort(key=lambda item: (item[0], item[1], item[2]))
883
884 states: list[AnimationGroupState] = []
885 for sequence_index, (
886 order,
887 source_index,
888 _effect_position,
889 group_id,
890 effect_index,
891 effect_path,
892 effect_cfg,
893 effect_trigger,
894 ) in enumerate(preliminaries):
895 duration_ms = animation_seconds_to_milliseconds(
896 effect_cfg.get("duration", settings.duration_ms / 1000),
897 f'canonical animation duration for "{effect_path}"',
898 allow_zero=False,
899 )
900 timing_options = dict(settings.timing_options)
901 timing_options.update(
902 {
903 field: effect_cfg[field]
904 for field in ANIMATION_TIMING_OPTION_FIELDS
905 if field in effect_cfg
906 }
907 )
908 playback_duration_ms = _animation_playback_duration_ms(
909 duration_ms,
910 timing_options,
911 label=f'canonical animation for "{effect_path}"',
912 )
913 default_delay = (
914 settings.stagger_ms / 1000
915 if effect_trigger == "after-previous" and sequence_index > 0
916 else 0
917 )
918 original_delay_ms = animation_seconds_to_milliseconds(
919 effect_cfg.get("delay", default_delay),
920 f'canonical animation delay for "{effect_path}"',
921 allow_zero=True,
922 )
923 states.append(
924 AnimationGroupState(
925 group_id=group_id,
926 effect_index=effect_index,
927 order=order,
928 source_index=source_index,
929 duration_ms=playback_duration_ms,
930 original_delay_ms=original_delay_ms,
931 )
932 )
933 return states, use_svg
934
935
936 def _find_audio(audio_dir: Path, slide_name: str) -> Path:
937 matches = [
938 path
939 for path in audio_dir.iterdir()
940 if path.is_file()
941 and path.stem == slide_name
942 and path.suffix.lower() in NARRATION_EXTENSIONS
943 ]
944 if not matches:
945 raise FileNotFoundError(f"Missing narration audio for slide: {slide_name}")
946 if len(matches) > 1:
947 rendered = ", ".join(str(path) for path in matches)
948 raise ValueError(f"Multiple narration audio files match {slide_name}: {rendered}")
949 return matches[0]
950
951
952 def _seconds_from_ms(value: int) -> float:
953 return round(value / 1000, 3)
954
955
956 def rebuild_animations(
957 project_path: Path,
958 *,
959 canonical_path: Path,
960 plan_path: Path,
961 subtitle_dir: Path,
962 audio_dir: Path,
963 output_path: Path,
964 narration_padding: float,
965 force: bool,
966 ) -> AnimationBuildResult:
967 """Derive narration timing without modifying the canonical animation file."""
968 if not math.isfinite(narration_padding) or narration_padding < 0:
969 raise ValueError("Narration padding must be finite and non-negative")
970 _reject_output_alias(
971 output_path,
972 [canonical_path, plan_path],
973 label="Narration animation config",
974 )
975 canonical = _load_canonical_animation_config(canonical_path)
976 slide_names = _animation_slide_names(project_path, canonical, subtitle_dir)
977 subtitle_paths = [subtitle_dir / f"{slide_name}.srt" for slide_name in slide_names]
978 _reject_output_alias(
979 output_path,
980 subtitle_paths,
981 label="Narration animation config",
982 )
983
984 timing_plan: dict[str, list[TimingPlanEntry]] | None = None
985 if plan_path.is_file():
986 expected_srt_sha256, planned_padding, loaded_plan = _load_timing_plan(
987 plan_path
988 )
989 if not math.isclose(
990 narration_padding,
991 planned_padding,
992 rel_tol=0,
993 abs_tol=1e-9,
994 ):
995 raise ValueError(
996 "Narration padding differs from the timing plan: "
997 f"plan={planned_padding}, command={narration_padding}"
998 )
999 current_srt_sha256 = _subtitle_fingerprint(slide_names, subtitle_dir)
1000 if current_srt_sha256 != expected_srt_sha256:
1001 raise ValueError(
1002 "Narration timing plan was authored for a different SRT set: "
1003 f"plan={expected_srt_sha256}, current={current_srt_sha256}"
1004 )
1005 if set(loaded_plan) != set(slide_names):
1006 raise ValueError(
1007 "Narration timing plan slides do not match animations.json"
1008 )
1009 timing_plan = loaded_plan
1010
1011 derived = copy.deepcopy(canonical)
1012 canonical_slides = canonical.get("slides", {})
1013 derived_slides = derived.setdefault("slides", {})
1014 if not isinstance(canonical_slides, dict) or not isinstance(
1015 derived_slides,
1016 dict,
1017 ):
1018 raise ValueError('Canonical animations.json field "slides" must be an object')
1019
1020 anchored_count = 0
1021 fallback_count = 0
1022 ignored_cue_count = 0
1023 svg_fallback_slide_count = 0
1024 drift_warnings: list[str] = []
1025 positional_slides: list[tuple[str, int, int]] = []
1026 audio_paths: list[Path] = []
1027
1028 for slide_name in slide_names:
1029 cues = _parse_srt(subtitle_dir / f"{slide_name}.srt")
1030 audio_path = _find_audio(audio_dir, slide_name)
1031 audio_paths.append(audio_path)
1032 audio_duration = probe_audio_duration(audio_path)
1033 if audio_duration is None:
1034 raise RuntimeError(
1035 f"Unable to read narration duration with ffprobe: {audio_path}"
1036 )
1037
1038 canonical_slide = canonical_slides.get(slide_name, {})
1039 if not isinstance(canonical_slide, dict):
1040 raise ValueError(
1041 f'Canonical animations.json slide "{slide_name}" must be an object'
1042 )
1043 derived_slide = derived_slides.setdefault(
1044 slide_name,
1045 copy.deepcopy(canonical_slide),
1046 )
1047 if not isinstance(derived_slide, dict):
1048 raise ValueError(
1049 f'Derived animation slide "{slide_name}" must be an object'
1050 )
1051 settings = _effective_slide_animation(canonical, canonical_slide)
1052 plan_entries = timing_plan.get(slide_name) if timing_plan else None
1053 states, used_svg = _resolve_animation_groups(
1054 project_path,
1055 slide_name,
1056 canonical_slide,
1057 settings,
1058 plan_entries,
1059 )
1060 if used_svg:
1061 svg_fallback_slide_count += 1
1062 if timing_plan is None and states:
1063 positional_slides.append((
1064 slide_name,
1065 len({state.group_id for state in states}),
1066 len(cues),
1067 ))
1068
1069 state_ids = {state.group_id for state in states}
1070 cue_by_group: dict[str, int | None] = {}
1071 if plan_entries is not None:
1072 for entry in plan_entries:
1073 if entry.group_id not in state_ids:
1074 raise ValueError(
1075 f'Narration timing plan references a non-animated group: '
1076 f"{slide_name}/{entry.group_id}"
1077 )
1078 if entry.cue_number is not None and entry.cue_number > len(cues):
1079 raise ValueError(
1080 f'Narration timing plan "{slide_name}/{entry.group_id}" '
1081 f"references cue {entry.cue_number}, but the SRT has "
1082 f"{len(cues)} cues"
1083 )
1084 cue_by_group[entry.group_id] = entry.cue_number
1085 else:
1086 ordered_group_ids = list(
1087 dict.fromkeys(state.group_id for state in states)
1088 )
1089 cue_by_group = {
1090 group_id: index + 1 if index < len(cues) else None
1091 for index, group_id in enumerate(ordered_group_ids)
1092 }
1093
1094 animation_value = derived_slide.setdefault("animation", {})
1095 if not isinstance(animation_value, dict):
1096 raise ValueError(
1097 f'Derived animation slide "{slide_name}" animation must be an object'
1098 )
1099 animation_value["trigger"] = "after-previous"
1100 groups_value = derived_slide.setdefault("groups", {})
1101 if not isinstance(groups_value, dict):
1102 raise ValueError(
1103 f'Derived animation slide "{slide_name}" groups must be an object'
1104 )
1105
1106 previous_end_ms = 0
1107 referenced_cues: set[int] = set()
1108 seen_groups: set[str] = set()
1109 for state in states:
1110 first_group_effect = state.group_id not in seen_groups
1111 seen_groups.add(state.group_id)
1112 cue_number = (
1113 cue_by_group.get(state.group_id)
1114 if first_group_effect
1115 else None
1116 )
1117 if cue_number is None:
1118 if first_group_effect:
1119 fallback_count += 1
1120 delay_ms = state.original_delay_ms
1121 actual_start_ms = previous_end_ms + delay_ms
1122 else:
1123 anchored_count += 1
1124 referenced_cues.add(cue_number)
1125 desired_start_ms = cues[cue_number - 1].start_ms
1126 actual_start_ms = max(desired_start_ms, previous_end_ms)
1127 delay_ms = actual_start_ms - previous_end_ms
1128 drift_ms = actual_start_ms - desired_start_ms
1129 if drift_ms > 500:
1130 drift_warnings.append(
1131 f"{slide_name}/{state.group_id}: cue {cue_number} "
1132 f"starts at {_seconds_from_ms(desired_start_ms):.3f}s, "
1133 f"animation starts at {_seconds_from_ms(actual_start_ms):.3f}s "
1134 f"(after-previous drift {_seconds_from_ms(drift_ms):.3f}s)"
1135 )
1136
1137 group_value = groups_value.setdefault(state.group_id, {})
1138 if not isinstance(group_value, dict):
1139 raise ValueError(
1140 f'Derived animation group "{slide_name}/{state.group_id}" '
1141 "must be an object"
1142 )
1143 if state.effect_index is None:
1144 effect_value = group_value
1145 else:
1146 group_path = (
1147 f'slides[{json.dumps(slide_name, ensure_ascii=False)}]'
1148 f'.groups[{json.dumps(state.group_id, ensure_ascii=False)}]'
1149 )
1150 derived_effect_entries = animation_group_effect_entries(
1151 group_value,
1152 path=group_path,
1153 )
1154 effect_value = derived_effect_entries[state.effect_index][1]
1155 effect_value["order"] = state.order
1156 effect_value["delay"] = _seconds_from_ms(delay_ms)
1157 effect_value["trigger"] = "after-previous"
1158 previous_end_ms = actual_start_ms + state.duration_ms
1159
1160 ignored_cue_count += len(cues) - len(referenced_cues)
1161
1162 advance_ms = int((audio_duration + narration_padding) * 1000)
1163 if previous_end_ms > advance_ms:
1164 raise ValueError(
1165 f'Animations on slide "{slide_name}" end at '
1166 f"{_seconds_from_ms(previous_end_ms):.3f}s, after the recorded "
1167 f"slide advance at {_seconds_from_ms(advance_ms):.3f}s"
1168 )
1169
1170 _reject_output_alias(
1171 output_path,
1172 audio_paths,
1173 label="Narration animation config",
1174 )
1175 _require_replaceable(output_path, force)
1176 derived_errors = list(
1177 dict.fromkeys(
1178 [
1179 *validate_transition_config(derived),
1180 *validate_animation_config_errors(derived),
1181 ]
1182 )
1183 )
1184 if derived_errors:
1185 details = "\n".join(f"- {error}" for error in derived_errors)
1186 raise ValueError(f"Derived narration animation config is invalid:\n{details}")
1187 _atomic_write_text(
1188 output_path,
1189 json.dumps(derived, ensure_ascii=False, indent=2) + "\n",
1190 )
1191
1192 for warning in drift_warnings:
1193 print(f"Warning: {warning}", file=sys.stderr)
1194 if positional_slides:
1195 print(
1196 "Warning: no narration_timing.json found — object reveals were mapped "
1197 "positionally (group N -> subtitle cue N). This mistimes any page whose "
1198 "narration is longer than its object count: later objects reveal early, "
1199 "while the narrator is still on an earlier point. Author "
1200 f"{plan_path} mapping each SVG group to the subtitle cue that speaks "
1201 "about it (omit a group's cue to keep its canonical delay), then re-run.",
1202 file=sys.stderr,
1203 )
1204 risky = [
1205 (name, group_count, cue_count)
1206 for name, group_count, cue_count in positional_slides
1207 if group_count > 1 and cue_count > group_count + 1
1208 ]
1209 for name, group_count, cue_count in risky:
1210 print(
1211 f"Warning: {name}: {group_count} object(s) but {cue_count} "
1212 "subtitle cue(s) — later objects likely reveal too early",
1213 file=sys.stderr,
1214 )
1215 return AnimationBuildResult(
1216 slide_count=len(slide_names),
1217 group_count=anchored_count + fallback_count,
1218 anchored_count=anchored_count,
1219 fallback_count=fallback_count,
1220 ignored_cue_count=ignored_cue_count,
1221 svg_fallback_slide_count=svg_fallback_slide_count,
1222 timing_plan_written=False,
1223 )
1224
1225
1226 def _presentation_slide_members(package: zipfile.ZipFile) -> list[str]:
1227 try:
1228 presentation_root = ET.fromstring(package.read("ppt/presentation.xml"))
1229 relationships_root = ET.fromstring(
1230 package.read("ppt/_rels/presentation.xml.rels")
1231 )
1232 except KeyError as exc:
1233 raise ValueError(
1234 f"Narrated PPTX is missing presentation ordering data: {exc}"
1235 ) from exc
1236
1237 relationship_targets: dict[str, str] = {}
1238 for relationship in relationships_root.iter(f"{{{_REL_NS}}}Relationship"):
1239 relationship_id = relationship.get("Id")
1240 target = relationship.get("Target")
1241 if (
1242 relationship_id
1243 and target
1244 and relationship.get("TargetMode", "Internal") != "External"
1245 ):
1246 relationship_targets[relationship_id] = target.replace("\\", "/")
1247
1248 slide_list = presentation_root.find(f"{{{_PML_NS}}}sldIdLst")
1249 if slide_list is None:
1250 raise ValueError("Narrated PPTX presentation has no slide order")
1251
1252 members: list[str] = []
1253 for slide_id in slide_list.findall(f"{{{_PML_NS}}}sldId"):
1254 relationship_id = slide_id.get(f"{{{_DOC_REL_NS}}}id")
1255 target = relationship_targets.get(relationship_id or "")
1256 if target is None:
1257 raise ValueError(
1258 "Narrated PPTX slide order references a missing relationship: "
1259 f"{relationship_id!r}"
1260 )
1261 if target.startswith("/"):
1262 member = posixpath.normpath(target.lstrip("/"))
1263 else:
1264 member = posixpath.normpath(posixpath.join("ppt", target))
1265 if member not in package.namelist():
1266 raise ValueError(
1267 f"Narrated PPTX slide relationship target is missing: {member}"
1268 )
1269 members.append(member)
1270 return members
1271
1272
1273 def _read_powerpoint_timings(pptx_path: Path, slide_count: int) -> list[tuple[int, int]]:
1274 timings: list[tuple[int, int]] = []
1275 with zipfile.ZipFile(pptx_path) as package:
1276 slide_members = _presentation_slide_members(package)
1277 if len(slide_members) != slide_count:
1278 raise ValueError(
1279 f"Narrated PPTX has {len(slide_members)} slides, "
1280 f"but the project has {slide_count}"
1281 )
1282 for slide_index, member in enumerate(slide_members, 1):
1283 summary = read_slide_transition_xml(package.read(member))
1284 if summary.logical_count != 1:
1285 raise ValueError(
1286 f"Narrated PPTX slide {slide_index} has "
1287 f"{summary.logical_count} logical transition carriers"
1288 )
1289 advance_ms = summary.advance_after_ms
1290 if advance_ms is None:
1291 raise ValueError(
1292 f"Narrated PPTX slide {slide_index} has no recorded advance time"
1293 )
1294 transition_ms = summary.duration_ms or 0
1295 if advance_ms <= 0 or transition_ms < 0:
1296 raise ValueError(
1297 f"Narrated PPTX slide {slide_index} has invalid timing values"
1298 )
1299 timings.append((transition_ms, advance_ms))
1300 return timings
1301
1302
1303 def _powerpoint_audio_starts(
1304 timings: list[tuple[int, int]],
1305 ) -> tuple[list[int], int]:
1306 """Return theoretical narration starts and the complete PPTX timeline."""
1307 audio_starts: list[int] = []
1308 timeline_ms = 0
1309 for transition_ms, advance_ms in timings:
1310 audio_start_ms = timeline_ms + transition_ms
1311 audio_starts.append(audio_start_ms)
1312 timeline_ms = audio_start_ms + advance_ms
1313 return audio_starts, timeline_ms
1314
1315
1316 def _require_numpy() -> Any:
1317 try:
1318 import numpy as np
1319 except ImportError as exc:
1320 raise RuntimeError(
1321 "Exported-video subtitle calibration requires numpy. "
1322 "Install it with: python3 -m pip install numpy"
1323 ) from exc
1324 return np
1325
1326
1327 def _decode_audio_envelopes(path: Path, ffmpeg_path: str) -> tuple[Any, Any]:
1328 """Decode the first audio stream and return 1 ms and 10 ms RMS envelopes."""
1329 np = _require_numpy()
1330 command = [
1331 ffmpeg_path,
1332 "-v",
1333 "error",
1334 "-i",
1335 str(path),
1336 "-map",
1337 "0:a:0",
1338 "-vn",
1339 "-ac",
1340 "1",
1341 "-ar",
1342 str(_ALIGNMENT_SAMPLE_RATE),
1343 "-f",
1344 "s16le",
1345 "-",
1346 ]
1347 result = subprocess.run(command, capture_output=True, check=False)
1348 if result.returncode != 0:
1349 details = result.stderr.decode("utf-8", errors="replace").strip()
1350 raise RuntimeError(f"Unable to decode audio with ffmpeg: {path}\n{details}")
1351
1352 samples = np.frombuffer(result.stdout, dtype="<i2")
1353 samples_per_frame = _ALIGNMENT_SAMPLE_RATE // _ALIGNMENT_FINE_HZ
1354 usable_sample_count = len(samples) // samples_per_frame * samples_per_frame
1355 if usable_sample_count < samples_per_frame * 500:
1356 raise ValueError(f"Audio is too short for subtitle calibration: {path}")
1357
1358 frames = (
1359 samples[:usable_sample_count]
1360 .astype(np.float32)
1361 .reshape(-1, samples_per_frame)
1362 )
1363 fine = np.sqrt(np.mean(frames * frames, axis=1) + 1e-12)
1364 fine = np.log1p(120 * fine)
1365 smooth_width = max(1, _ALIGNMENT_FINE_HZ // 200)
1366 fine = np.convolve(
1367 fine,
1368 np.ones(smooth_width, dtype=np.float64) / smooth_width,
1369 mode="same",
1370 ).astype(np.float64)
1371
1372 coarse_count = len(fine) // _ALIGNMENT_COARSE_FACTOR
1373 coarse = fine[
1374 :coarse_count * _ALIGNMENT_COARSE_FACTOR
1375 ].reshape(coarse_count, _ALIGNMENT_COARSE_FACTOR).mean(axis=1)
1376 return fine, coarse
1377
1378
1379 def _best_correlation(search: Any, template: Any) -> tuple[int, float]:
1380 """Return the best normalized-correlation index for one search window."""
1381 np = _require_numpy()
1382 if len(search) < len(template):
1383 raise ValueError("Video alignment search window is shorter than its template")
1384
1385 centered_template = template - template.mean()
1386 template_norm = float(np.linalg.norm(centered_template))
1387 if template_norm <= 1e-9:
1388 raise ValueError("Narration audio has no usable variation for video alignment")
1389
1390 numerators = np.correlate(search, centered_template, mode="valid")
1391 prefix = np.concatenate(([0.0], np.cumsum(search, dtype=np.float64)))
1392 squared_prefix = np.concatenate(
1393 ([0.0], np.cumsum(search * search, dtype=np.float64))
1394 )
1395 width = len(template)
1396 window_sums = prefix[width:] - prefix[:-width]
1397 window_squared_sums = squared_prefix[width:] - squared_prefix[:-width]
1398 window_variances = np.maximum(
1399 window_squared_sums - (window_sums * window_sums / width),
1400 1e-18,
1401 )
1402 scores = numerators / (np.sqrt(window_variances) * template_norm)
1403 best_index = int(np.argmax(scores))
1404 return best_index, float(scores[best_index])
1405
1406
1407 def _alignment_template_bounds(
1408 fine_envelope: Any,
1409 cues: list[SubtitleCue],
1410 ) -> tuple[int, int]:
1411 duration_ms = len(fine_envelope)
1412 start_ms = min(cues[0].start_ms, max(0, duration_ms - 500))
1413 cue_end_ms = min(cues[-1].end_ms, duration_ms)
1414 end_ms = min(duration_ms, start_ms + _ALIGNMENT_TEMPLATE_MAX_MS)
1415 end_ms = min(end_ms, max(start_ms + 1000, cue_end_ms))
1416 if end_ms - start_ms < 500:
1417 raise ValueError("Narration cue range is too short for video alignment")
1418 return start_ms, end_ms
1419
1420
1421 def _locate_audio_start(
1422 video_fine: Any,
1423 video_coarse: Any,
1424 audio_fine: Any,
1425 audio_coarse: Any,
1426 cues: list[SubtitleCue],
1427 predicted_start_ms: int,
1428 ) -> tuple[int, float]:
1429 """Locate one page narration near its predicted exported-video position."""
1430 start_ms, end_ms = _alignment_template_bounds(audio_fine, cues)
1431 coarse_start = start_ms // _ALIGNMENT_COARSE_FACTOR
1432 coarse_end = max(
1433 coarse_start + 50,
1434 end_ms // _ALIGNMENT_COARSE_FACTOR,
1435 )
1436 coarse_template = audio_coarse[coarse_start:coarse_end]
1437 predicted_coarse = predicted_start_ms // _ALIGNMENT_COARSE_FACTOR
1438 coarse_window = _ALIGNMENT_SEARCH_WINDOW_MS // _ALIGNMENT_COARSE_FACTOR
1439 search_start = max(
1440 0,
1441 predicted_coarse + coarse_start - coarse_window,
1442 )
1443 search_end = min(
1444 len(video_coarse),
1445 predicted_coarse + coarse_start + coarse_window + len(coarse_template),
1446 )
1447 coarse_index, _coarse_score = _best_correlation(
1448 video_coarse[search_start:search_end],
1449 coarse_template,
1450 )
1451 coarse_audio_start_ms = (
1452 search_start + coarse_index - coarse_start
1453 ) * _ALIGNMENT_COARSE_FACTOR
1454
1455 fine_template = audio_fine[start_ms:end_ms]
1456 fine_search_start = max(
1457 0,
1458 coarse_audio_start_ms + start_ms - _ALIGNMENT_REFINE_WINDOW_MS,
1459 )
1460 fine_search_end = min(
1461 len(video_fine),
1462 coarse_audio_start_ms
1463 + start_ms
1464 + _ALIGNMENT_REFINE_WINDOW_MS
1465 + len(fine_template),
1466 )
1467 fine_index, fine_score = _best_correlation(
1468 video_fine[fine_search_start:fine_search_end],
1469 fine_template,
1470 )
1471 audio_start_ms = fine_search_start + fine_index - start_ms
1472 return audio_start_ms, fine_score
1473
1474
1475 def _align_audio_starts_to_video(
1476 *,
1477 slide_names: list[str],
1478 local_cues: dict[str, list[SubtitleCue]],
1479 theoretical_starts: list[int],
1480 audio_dir: Path,
1481 video_path: Path,
1482 ) -> tuple[list[int], list[float], list[Path]]:
1483 """Align every page narration to the audio track of an exported video."""
1484 if not video_path.is_file():
1485 raise FileNotFoundError(f"Exported video does not exist: {video_path}")
1486 ffmpeg_path = shutil.which("ffmpeg")
1487 if ffmpeg_path is None:
1488 raise RuntimeError(
1489 "Exported-video subtitle calibration requires ffmpeg. "
1490 "Install ffmpeg and make it available on PATH."
1491 )
1492
1493 video_fine, video_coarse = _decode_audio_envelopes(video_path, ffmpeg_path)
1494 aligned_starts: list[int] = []
1495 correlations: list[float] = []
1496 audio_paths: list[Path] = []
1497
1498 for index, slide_name in enumerate(slide_names):
1499 audio_path = _find_audio(audio_dir, slide_name)
1500 audio_paths.append(audio_path)
1501 audio_fine, audio_coarse = _decode_audio_envelopes(audio_path, ffmpeg_path)
1502 if (
1503 local_cues[slide_name][-1].end_ms
1504 > len(audio_fine) + _ALIGNMENT_END_TOLERANCE_MS
1505 ):
1506 raise ValueError(
1507 f"{slide_name}.srt ends after its narration audio: "
1508 f"cue end={_seconds_from_ms(local_cues[slide_name][-1].end_ms):.3f}s, "
1509 f"decoded audio={_seconds_from_ms(len(audio_fine)):.3f}s"
1510 )
1511 if index == 0:
1512 predicted_start_ms = theoretical_starts[index]
1513 else:
1514 predicted_start_ms = (
1515 aligned_starts[index - 1]
1516 + theoretical_starts[index]
1517 - theoretical_starts[index - 1]
1518 )
1519 aligned_start_ms, correlation = _locate_audio_start(
1520 video_fine,
1521 video_coarse,
1522 audio_fine,
1523 audio_coarse,
1524 local_cues[slide_name],
1525 predicted_start_ms,
1526 )
1527 if correlation < _ALIGNMENT_MIN_CORRELATION:
1528 raise ValueError(
1529 f"Exported-video audio match is unreliable for {slide_name}: "
1530 f"correlation={correlation:.3f}, "
1531 f"required>={_ALIGNMENT_MIN_CORRELATION:.2f}"
1532 )
1533 if aligned_starts and aligned_start_ms <= aligned_starts[-1]:
1534 raise ValueError(
1535 f"Exported-video audio order is invalid at slide {slide_name}"
1536 )
1537 final_cue_end_ms = (
1538 aligned_start_ms + local_cues[slide_name][-1].end_ms
1539 )
1540 if (
1541 final_cue_end_ms
1542 > len(video_fine) + _ALIGNMENT_END_TOLERANCE_MS
1543 ):
1544 raise ValueError(
1545 f"Exported video ends before the final cue on slide {slide_name}: "
1546 f"cue end={_seconds_from_ms(final_cue_end_ms):.3f}s, "
1547 f"decoded video audio={_seconds_from_ms(len(video_fine)):.3f}s"
1548 )
1549 aligned_starts.append(aligned_start_ms)
1550 correlations.append(correlation)
1551
1552 return aligned_starts, correlations, audio_paths
1553
1554
1555 def _merge_subtitles_result(
1556 project_path: Path,
1557 *,
1558 pptx_path: Path,
1559 subtitle_dir: Path,
1560 output_path: Path,
1561 force: bool,
1562 audio_dir: Path | None = None,
1563 video_path: Path | None = None,
1564 ) -> SubtitleMergeResult:
1565 """Merge local SRT files on the PPTX or exported-video timeline."""
1566 targets_by_slide, _anonymous_groups = scan_project_targets(project_path)
1567 slide_names = list(targets_by_slide)
1568 if not slide_names:
1569 raise ValueError(f"No SVG slides found under: {project_path / 'svg_output'}")
1570
1571 local_subtitle_paths = [
1572 subtitle_dir / f"{slide_name}.srt"
1573 for slide_name in slide_names
1574 ]
1575 _reject_output_alias(
1576 output_path,
1577 [pptx_path, *local_subtitle_paths, *([video_path] if video_path else [])],
1578 label="Merged subtitle",
1579 )
1580 _require_replaceable(output_path, force)
1581 timings = _read_powerpoint_timings(pptx_path, len(slide_names))
1582 theoretical_starts, timeline_ms = _powerpoint_audio_starts(timings)
1583 local_cues = {
1584 slide_name: _parse_srt(subtitle_dir / f"{slide_name}.srt")
1585 for slide_name in slide_names
1586 }
1587 video_adjustments: list[int] = []
1588 correlations: list[float] = []
1589
1590 if video_path is None:
1591 audio_starts = theoretical_starts
1592 else:
1593 resolved_audio_dir = audio_dir or project_path / "audio"
1594 audio_starts, correlations, audio_paths = _align_audio_starts_to_video(
1595 slide_names=slide_names,
1596 local_cues=local_cues,
1597 theoretical_starts=theoretical_starts,
1598 audio_dir=resolved_audio_dir,
1599 video_path=video_path,
1600 )
1601 _reject_output_alias(
1602 output_path,
1603 audio_paths,
1604 label="Merged subtitle",
1605 )
1606 video_adjustments = [
1607 actual - theoretical
1608 for actual, theoretical in zip(audio_starts, theoretical_starts)
1609 ]
1610
1611 merged_cues: list[SubtitleCue] = []
1612
1613 for slide_name, audio_start_ms, (_transition_ms, advance_ms) in zip(
1614 slide_names,
1615 audio_starts,
1616 timings,
1617 ):
1618 slide_cues = local_cues[slide_name]
1619 if slide_cues[-1].end_ms > advance_ms:
1620 raise ValueError(
1621 f"{slide_name}.srt ends at "
1622 f"{_seconds_from_ms(slide_cues[-1].end_ms):.3f}s, after the "
1623 f"PowerPoint slide advance at {_seconds_from_ms(advance_ms):.3f}s"
1624 )
1625 for cue in slide_cues:
1626 merged_cue = SubtitleCue(
1627 cue.start_ms + audio_start_ms,
1628 cue.end_ms + audio_start_ms,
1629 cue.text,
1630 )
1631 if merged_cues and merged_cue.start_ms < merged_cues[-1].end_ms:
1632 raise ValueError(
1633 f"Video-calibrated subtitle overlap before slide {slide_name}"
1634 )
1635 merged_cues.append(merged_cue)
1636
1637 _atomic_write_text(output_path, _format_srt(merged_cues))
1638 return SubtitleMergeResult(
1639 slide_count=len(slide_names),
1640 cue_count=len(merged_cues),
1641 powerpoint_timeline_ms=timeline_ms,
1642 minimum_video_adjustment_ms=(
1643 min(video_adjustments) if video_adjustments else None
1644 ),
1645 maximum_video_adjustment_ms=(
1646 max(video_adjustments) if video_adjustments else None
1647 ),
1648 minimum_video_correlation=(
1649 min(correlations) if correlations else None
1650 ),
1651 )
1652
1653
1654 def merge_subtitles(
1655 project_path: Path,
1656 *,
1657 pptx_path: Path,
1658 subtitle_dir: Path,
1659 output_path: Path,
1660 force: bool,
1661 ) -> tuple[int, int, int]:
1662 """Merge local SRT files using timing values read from the final PPTX."""
1663 result = _merge_subtitles_result(
1664 project_path,
1665 pptx_path=pptx_path,
1666 subtitle_dir=subtitle_dir,
1667 output_path=output_path,
1668 force=force,
1669 )
1670 return (
1671 result.slide_count,
1672 result.cue_count,
1673 result.powerpoint_timeline_ms,
1674 )
1675
1676
1677 def merge_subtitles_to_video(
1678 project_path: Path,
1679 *,
1680 pptx_path: Path,
1681 subtitle_dir: Path,
1682 audio_dir: Path,
1683 video_path: Path,
1684 output_path: Path,
1685 force: bool,
1686 ) -> SubtitleMergeResult:
1687 """Merge local SRT files after calibrating page starts to an exported video."""
1688 return _merge_subtitles_result(
1689 project_path,
1690 pptx_path=pptx_path,
1691 subtitle_dir=subtitle_dir,
1692 output_path=output_path,
1693 force=force,
1694 audio_dir=audio_dir,
1695 video_path=video_path,
1696 )
1697
1698
1699 def build_parser() -> argparse.ArgumentParser:
1700 parser = argparse.ArgumentParser(
1701 description=(
1702 "Derive narrated object animation timings and merge page-local "
1703 "SRT files on PowerPoint's final timeline."
1704 ),
1705 formatter_class=argparse.RawDescriptionHelpFormatter,
1706 )
1707 subparsers = parser.add_subparsers(dest="command", required=True)
1708
1709 fingerprint = subparsers.add_parser(
1710 "fingerprint",
1711 help="print the SHA-256 used to bind a timing plan to page-local SRT files",
1712 )
1713 fingerprint.add_argument("project_path", help="Project directory")
1714 fingerprint.add_argument(
1715 "--subtitle-dir",
1716 default=None,
1717 help="Page-local SRT directory; default: <project>/notes/subtitles",
1718 )
1719
1720 animations = subparsers.add_parser(
1721 "animations",
1722 help="derive narration_animations.json from animations.json and page-local SRT",
1723 )
1724 animations.add_argument("project_path", help="Project directory")
1725 animations.add_argument(
1726 "--animation-config",
1727 default=None,
1728 help="Read-only canonical config; default: <project>/animations.json",
1729 )
1730 animations.add_argument(
1731 "--plan",
1732 default=None,
1733 help="Timing plan; default: <project>/narration_timing.json",
1734 )
1735 animations.add_argument(
1736 "--subtitle-dir",
1737 default=None,
1738 help="Page-local SRT directory; default: <project>/notes/subtitles",
1739 )
1740 animations.add_argument(
1741 "--audio-dir",
1742 default=None,
1743 help="Narration audio directory; default: <project>/audio",
1744 )
1745 animations.add_argument(
1746 "-o",
1747 "--output",
1748 default=None,
1749 help=(
1750 "Narration animation output; "
1751 "default: <project>/narration_animations.json"
1752 ),
1753 )
1754 animations.add_argument(
1755 "--narration-padding",
1756 type=float,
1757 default=0.5,
1758 help="Seconds added after each narration before slide advance (default: 0.5)",
1759 )
1760 animations.add_argument(
1761 "--force",
1762 action="store_true",
1763 help="Replace an existing narration_animations.json",
1764 )
1765
1766 subtitles = subparsers.add_parser(
1767 "subtitles",
1768 help="merge page-local SRT using timings read from a narrated PPTX",
1769 )
1770 subtitles.add_argument("project_path", help="Project directory")
1771 subtitles.add_argument(
1772 "--pptx",
1773 required=True,
1774 help=(
1775 "Final narrated PPTX whose recorded timing values define the "
1776 "timeline; relative paths are resolved under the project"
1777 ),
1778 )
1779 subtitles.add_argument(
1780 "--subtitle-dir",
1781 default=None,
1782 help="Page-local SRT directory; default: <project>/notes/subtitles",
1783 )
1784 subtitles.add_argument(
1785 "--video",
1786 default=None,
1787 help=(
1788 "PowerPoint-exported video whose audio track calibrates page starts; "
1789 "relative paths are resolved under the project"
1790 ),
1791 )
1792 subtitles.add_argument(
1793 "--audio-dir",
1794 default=None,
1795 help=(
1796 "Page-local narration audio used with --video; "
1797 "default: <project>/audio"
1798 ),
1799 )
1800 subtitles.add_argument(
1801 "-o",
1802 "--output",
1803 default=None,
1804 help="Merged SRT output; default: <project>/notes/subtitles/total.srt",
1805 )
1806 subtitles.add_argument(
1807 "--force",
1808 action="store_true",
1809 help="Replace an existing merged SRT",
1810 )
1811 return parser
1812
1813
1814 def main(argv: list[str] | None = None) -> int:
1815 parser = build_parser()
1816 args = parser.parse_args(argv)
1817 project_path = Path(args.project_path).resolve()
1818 if not project_path.is_dir():
1819 parser.error(f"Project path does not exist: {project_path}")
1820
1821 try:
1822 if args.command == "fingerprint":
1823 subtitle_dir = _project_path(
1824 project_path,
1825 args.subtitle_dir,
1826 Path("notes/subtitles"),
1827 )
1828 slide_names = _page_subtitle_names(subtitle_dir)
1829 for slide_name in slide_names:
1830 _parse_srt(subtitle_dir / f"{slide_name}.srt")
1831 print(_subtitle_fingerprint(slide_names, subtitle_dir))
1832 return 0
1833
1834 if args.command == "animations":
1835 canonical_path = _project_path(
1836 project_path,
1837 args.animation_config,
1838 Path("animations.json"),
1839 )
1840 plan_path = _project_path(
1841 project_path,
1842 args.plan,
1843 Path("narration_timing.json"),
1844 )
1845 subtitle_dir = _project_path(
1846 project_path,
1847 args.subtitle_dir,
1848 Path("notes/subtitles"),
1849 )
1850 audio_dir = _project_path(
1851 project_path,
1852 args.audio_dir,
1853 Path("audio"),
1854 )
1855 output_path = _project_path(
1856 project_path,
1857 args.output,
1858 Path("narration_animations.json"),
1859 )
1860 result = rebuild_animations(
1861 project_path,
1862 canonical_path=canonical_path,
1863 plan_path=plan_path,
1864 subtitle_dir=subtitle_dir,
1865 audio_dir=audio_dir,
1866 output_path=output_path,
1867 narration_padding=args.narration_padding,
1868 force=args.force,
1869 )
1870 print(f"Narration animation config written: {output_path}")
1871 print(
1872 f"Slides: {result.slide_count}; groups: {result.group_count}; "
1873 f"SRT-anchored: {result.anchored_count}; "
1874 f"canonical-delay fallback: {result.fallback_count}; "
1875 f"SVG fallback slides: {result.svg_fallback_slide_count}; "
1876 f"unused cues: {result.ignored_cue_count}"
1877 )
1878 return 0
1879
1880 pptx_path = _project_input_path(project_path, args.pptx).resolve()
1881 subtitle_dir = _project_path(
1882 project_path,
1883 args.subtitle_dir,
1884 Path("notes/subtitles"),
1885 )
1886 audio_dir = _project_path(
1887 project_path,
1888 args.audio_dir,
1889 Path("audio"),
1890 )
1891 video_path = (
1892 _project_input_path(project_path, args.video).resolve()
1893 if args.video
1894 else None
1895 )
1896 output_path = _project_path(
1897 project_path,
1898 args.output,
1899 Path("notes/subtitles/total.srt"),
1900 )
1901 result = _merge_subtitles_result(
1902 project_path,
1903 pptx_path=pptx_path,
1904 subtitle_dir=subtitle_dir,
1905 audio_dir=audio_dir,
1906 video_path=video_path,
1907 output_path=output_path,
1908 force=args.force,
1909 )
1910 print(f"Merged subtitle written: {output_path}")
1911 print(
1912 f"Slides: {result.slide_count}; cues: {result.cue_count}; "
1913 "PowerPoint timeline: "
1914 f"{_seconds_from_ms(result.powerpoint_timeline_ms):.3f}s"
1915 )
1916 if result.minimum_video_correlation is not None:
1917 print(
1918 "Exported-video calibration: page adjustment "
1919 f"{_seconds_from_ms(result.minimum_video_adjustment_ms or 0):+.3f}s "
1920 "to "
1921 f"{_seconds_from_ms(result.maximum_video_adjustment_ms or 0):+.3f}s; "
1922 f"minimum correlation: {result.minimum_video_correlation:.3f}"
1923 )
1924 return 0
1925 except (
1926 ET.ParseError,
1927 OSError,
1928 OverflowError,
1929 ValueError,
1930 RuntimeError,
1931 zipfile.BadZipFile,
1932 ) as exc:
1933 print(f"Error: {exc}", file=sys.stderr)
1934 return 1
1935
1936
1937 if __name__ == "__main__":
1938 raise SystemExit(main())
1939
1939 lines PYTHON