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