返回 ppt-master
video_sound_mix.py
根目录 / skills / ppt-master / scripts / video_sound_mix.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Video Sound Mix
4
5 Recover transition and object-animation sound cues from the final narrated
6 PPTX, calibrate them to a PowerPoint-exported video, render an independent SFX
7 stem, and mix that stem with the video's narration track.
8 See workflows/stages/generate-audio.md for the owning delivery stage.
9
10 Usage:
11 python3 scripts/video_sound_mix.py <project_path> --pptx <pptx> \
12 --trace <trace.json> --video <raw.mp4> [options]
13
14 Examples:
15 python3 scripts/video_sound_mix.py projects/demo \
16 --pptx exports/demo_narrated.pptx \
17 --trace validation/demo_narrated.trace.json \
18 --video exports/demo_raw.mp4 --force
19
20 Dependencies:
21 ffmpeg, ffprobe, and numpy
22 """
23
24 from __future__ import annotations
25
26 import argparse
27 import hashlib
28 import json
29 import math
30 import os
31 import re
32 import shutil
33 import subprocess
34 import sys
35 import tempfile
36 import zipfile
37 from dataclasses import dataclass
38 from fractions import Fraction
39 from pathlib import Path
40 from typing import Any
41 from xml.etree import ElementTree as ET
42
43 _SCRIPTS_DIR = Path(__file__).resolve().parent
44 if str(_SCRIPTS_DIR) not in sys.path:
45 sys.path.insert(0, str(_SCRIPTS_DIR))
46
47 from console_encoding import configure_utf8_stdio # noqa: E402
48 from narration_sync import ( # noqa: E402
49 VideoTimelineCalibration,
50 calibrate_video_timeline,
51 presentation_slide_members,
52 )
53 from pptx_animations import read_slide_animation_sequence # noqa: E402
54 from pptx_opc_validation import ( # noqa: E402
55 canonical_opc_part_path,
56 resolve_internal_opc_target,
57 )
58 from pptx_transitions import ( # noqa: E402
59 AUDIO_REL_TYPE,
60 read_slide_transition_xml,
61 )
62
63 configure_utf8_stdio()
64
65
66 _PACKAGE_REL_NS = (
67 "http://schemas.openxmlformats.org/package/2006/relationships"
68 )
69 _RELATIONSHIP_TAG = f"{{{_PACKAGE_REL_NS}}}Relationship"
70 _DEFAULT_TRANSITION_GAIN_DB = -9.1
71 _DEFAULT_ANIMATION_GAIN_DB = -12.0
72 _DEFAULT_LIMITER_DBFS = -1.0
73 _OUTPUT_SAMPLE_RATE = 48_000
74 _OUTPUT_AUDIO_BITRATE = "192k"
75 _START_TIME_TOLERANCE_MS = 1
76 _VERIFICATION_SAMPLE_RATE = 8000
77 _MIN_VERIFICATION_WINDOW_SAMPLES = 2
78 _MIN_SOUND_MIX_CORRELATION = 0.20
79
80
81 @dataclass(frozen=True)
82 class SoundCue:
83 """One resolved PPTX sound cue placed on the exported-video timeline."""
84
85 kind: str
86 slide_num: int
87 slide_name: str
88 row_index: int | None
89 shape_id: int | None
90 group_id: str | None
91 relationship_id: str
92 sound_name: str
93 package_part: str
94 media_sha256: str
95 media_path: Path
96 source_duration_ms: int
97 powerpoint_time_ms: int
98 video_time_ms: int
99 gain_db: float
100
101
102 @dataclass(frozen=True)
103 class SoundMixResult:
104 """Published files and cue count from one video sound mix."""
105
106 output_path: Path
107 stem_path: Path
108 report_path: Path
109 cue_count: int
110 verification_correlation: float
111 true_peak_dbfs: float
112
113
114 def _read_json_object(path: Path, label: str) -> dict[str, Any]:
115 try:
116 value = json.loads(path.read_text(encoding="utf-8"))
117 except json.JSONDecodeError as exc:
118 raise ValueError(f"{label} is not valid JSON: {path}: {exc}") from exc
119 if not isinstance(value, dict):
120 raise ValueError(f"{label} must contain one JSON object: {path}")
121 return value
122
123
124 def _sha256_path(path: Path) -> str:
125 digest = hashlib.sha256()
126 with path.open("rb") as stream:
127 for chunk in iter(lambda: stream.read(1024 * 1024), b""):
128 digest.update(chunk)
129 return digest.hexdigest()
130
131
132 def _project_input_path(project_path: Path, value: str) -> Path:
133 path = Path(value).expanduser()
134 return (path if path.is_absolute() else project_path / path).resolve()
135
136
137 def _project_output_path(
138 project_path: Path,
139 value: str | None,
140 default: Path,
141 ) -> Path:
142 path = Path(value).expanduser() if value else default
143 return (path if path.is_absolute() else project_path / path).resolve()
144
145
146 def _require_tool(name: str) -> str:
147 path = shutil.which(name)
148 if path is None:
149 raise RuntimeError(
150 f"Video sound mixing requires {name} on PATH. Install ffmpeg "
151 "and make both ffmpeg and ffprobe available."
152 )
153 return path
154
155
156 def _run_command(command: list[str], label: str) -> subprocess.CompletedProcess[str]:
157 result = subprocess.run(
158 command,
159 capture_output=True,
160 check=False,
161 text=True,
162 encoding="utf-8",
163 errors="replace",
164 )
165 if result.returncode != 0:
166 details = result.stderr.strip() or result.stdout.strip()
167 raise RuntimeError(f"{label} failed:\n{details}")
168 return result
169
170
171 def _probe_media(path: Path, ffprobe_path: str) -> dict[str, Any]:
172 result = _run_command(
173 [
174 ffprobe_path,
175 "-v",
176 "error",
177 "-show_entries",
178 (
179 "format=duration,start_time:"
180 "stream=index,codec_type,codec_name,time_base,start_pts,"
181 "start_time,duration_ts,duration,sample_rate,channels,"
182 "width,height,avg_frame_rate"
183 ),
184 "-of",
185 "json",
186 str(path),
187 ],
188 f"ffprobe inspection for {path}",
189 )
190 try:
191 payload = json.loads(result.stdout)
192 except json.JSONDecodeError as exc:
193 raise RuntimeError(f"ffprobe returned invalid JSON for {path}") from exc
194 if not isinstance(payload, dict):
195 raise RuntimeError(f"ffprobe returned an invalid payload for {path}")
196 return payload
197
198
199 def _duration_ms(probe: dict[str, Any], label: str) -> int:
200 format_value = probe.get("format")
201 raw_duration = (
202 format_value.get("duration")
203 if isinstance(format_value, dict)
204 else None
205 )
206 try:
207 duration = float(raw_duration)
208 except (TypeError, ValueError) as exc:
209 raise ValueError(f"Unable to read {label} duration with ffprobe") from exc
210 if not math.isfinite(duration) or duration <= 0:
211 raise ValueError(f"{label} duration must be positive and finite")
212 return int(round(duration * 1000))
213
214
215 def _stream_duration_ms(
216 stream: dict[str, Any],
217 *,
218 fallback_ms: int,
219 ) -> int:
220 try:
221 duration = float(stream.get("duration"))
222 except (TypeError, ValueError):
223 return fallback_ms
224 if not math.isfinite(duration) or duration <= 0:
225 return fallback_ms
226 return int(round(duration * 1000))
227
228
229 def _stream_start_ms(stream: dict[str, Any]) -> int:
230 try:
231 start = float(stream.get("start_time", 0))
232 except (TypeError, ValueError):
233 return 0
234 if not math.isfinite(start):
235 return 0
236 return int(round(start * 1000))
237
238
239 def _frame_duration_ms(stream: dict[str, Any]) -> float:
240 value = stream.get("avg_frame_rate")
241 if not isinstance(value, str) or "/" not in value:
242 return 1000 / 30
243 numerator_text, denominator_text = value.split("/", 1)
244 try:
245 numerator = float(numerator_text)
246 denominator = float(denominator_text)
247 except ValueError:
248 return 1000 / 30
249 if numerator <= 0 or denominator <= 0:
250 return 1000 / 30
251 return 1000 * denominator / numerator
252
253
254 def _stream_timing_signature(stream: dict[str, Any]) -> dict[str, object]:
255 """Return the packet-clock fields that must survive video stream copy."""
256 signature: dict[str, object] = {}
257 for key in ("time_base", "start_pts", "duration_ts"):
258 value = stream.get(key)
259 if value is None:
260 raise ValueError(f"Video stream is missing {key}")
261 signature[key] = value
262 return signature
263
264
265 def _audio_sample_count(stream: dict[str, Any]) -> int:
266 """Resolve an audio stream duration to exact samples from its time base."""
267 try:
268 sample_rate = int(stream.get("sample_rate"))
269 duration_ts = int(stream.get("duration_ts"))
270 time_base = Fraction(str(stream.get("time_base")))
271 except (TypeError, ValueError, ZeroDivisionError) as exc:
272 raise ValueError("Unable to read exact audio sample count") from exc
273 samples = duration_ts * time_base * sample_rate
274 if samples.denominator != 1:
275 raise ValueError(
276 "Audio duration does not resolve to a whole number of samples"
277 )
278 return samples.numerator
279
280
281 def _target_sample_count(duration_ms: int) -> int:
282 return int(round(duration_ms * _OUTPUT_SAMPLE_RATE / 1000))
283
284
285 def _streams(probe: dict[str, Any], kind: str) -> list[dict[str, Any]]:
286 raw_streams = probe.get("streams")
287 if not isinstance(raw_streams, list):
288 return []
289 return [
290 stream
291 for stream in raw_streams
292 if isinstance(stream, dict) and stream.get("codec_type") == kind
293 ]
294
295
296 def _stream_hash(path: Path, selector: str, ffmpeg_path: str) -> str:
297 result = _run_command(
298 [
299 ffmpeg_path,
300 "-v",
301 "error",
302 "-i",
303 str(path),
304 "-map",
305 selector,
306 "-c",
307 "copy",
308 "-f",
309 "hash",
310 "-hash",
311 "sha256",
312 "-",
313 ],
314 f"stream hash for {path}",
315 )
316 match = re.search(r"SHA256=([0-9a-fA-F]{64})", result.stdout)
317 if match is None:
318 raise RuntimeError(f"Unable to parse stream hash for {path}")
319 return match.group(1).lower()
320
321
322 def _maximum_volume_db(path: Path, ffmpeg_path: str) -> float | None:
323 result = subprocess.run(
324 [
325 ffmpeg_path,
326 "-nostdin",
327 "-hide_banner",
328 "-i",
329 str(path),
330 "-map",
331 "0:a:0",
332 "-af",
333 "volumedetect",
334 "-f",
335 "null",
336 "-",
337 ],
338 capture_output=True,
339 check=False,
340 text=True,
341 encoding="utf-8",
342 errors="replace",
343 )
344 if result.returncode != 0:
345 raise RuntimeError(
346 f"Volume inspection failed for {path}:\n{result.stderr.strip()}"
347 )
348 match = re.search(r"max_volume:\s*(-?inf|[-+]?\d+(?:\.\d+)?)\s*dB", result.stderr)
349 if match is None:
350 raise RuntimeError(f"Unable to read maximum volume for {path}")
351 if match.group(1) == "-inf":
352 return None
353 return float(match.group(1))
354
355
356 def _true_peak_dbfs(path: Path, ffmpeg_path: str) -> float | None:
357 result = subprocess.run(
358 [
359 ffmpeg_path,
360 "-nostdin",
361 "-hide_banner",
362 "-nostats",
363 "-i",
364 str(path),
365 "-map",
366 "0:a:0",
367 "-af",
368 "ebur128=peak=true",
369 "-f",
370 "null",
371 "-",
372 ],
373 capture_output=True,
374 check=False,
375 text=True,
376 encoding="utf-8",
377 errors="replace",
378 )
379 if result.returncode != 0:
380 raise RuntimeError(
381 f"True-peak inspection failed for {path}:\n{result.stderr.strip()}"
382 )
383 matches = re.findall(
384 r"Peak:\s*(-?inf|[-+]?\d+(?:\.\d+)?)\s*dBFS",
385 result.stderr,
386 )
387 if not matches:
388 raise RuntimeError(f"Unable to read true peak for {path}")
389 if matches[-1] == "-inf":
390 return None
391 return float(matches[-1])
392
393
394 def _decode_audio_f32(path: Path, ffmpeg_path: str) -> Any:
395 try:
396 import numpy as np
397 except ImportError as exc:
398 raise RuntimeError(
399 "Video sound verification requires numpy. Install it with: "
400 "python3 -m pip install numpy"
401 ) from exc
402 result = subprocess.run(
403 [
404 ffmpeg_path,
405 "-v",
406 "error",
407 "-i",
408 str(path),
409 "-map",
410 "0:a:0",
411 "-vn",
412 "-ac",
413 "1",
414 "-ar",
415 str(_VERIFICATION_SAMPLE_RATE),
416 "-f",
417 "f32le",
418 "-",
419 ],
420 capture_output=True,
421 check=False,
422 )
423 if result.returncode != 0:
424 details = result.stderr.decode("utf-8", errors="replace").strip()
425 raise RuntimeError(f"Unable to decode audio for verification: {path}\n{details}")
426 return np.frombuffer(result.stdout, dtype="<f4").astype(np.float64)
427
428
429 def _sound_mix_correlation(
430 *,
431 raw_video_path: Path,
432 mixed_video_path: Path,
433 stem_path: Path,
434 cues: list[SoundCue],
435 duration_ms: int,
436 ffmpeg_path: str,
437 ) -> float:
438 """Correlate the added final-audio component with the rendered SFX stem."""
439 try:
440 import numpy as np
441 except ImportError as exc:
442 raise RuntimeError(
443 "Video sound verification requires numpy. Install it with: "
444 "python3 -m pip install numpy"
445 ) from exc
446
447 target_samples = int(round(duration_ms * _VERIFICATION_SAMPLE_RATE / 1000))
448 decoded = [
449 _decode_audio_f32(path, ffmpeg_path)
450 for path in (raw_video_path, mixed_video_path, stem_path)
451 ]
452 arrays: list[Any] = []
453 for samples in decoded:
454 if len(samples) < target_samples:
455 samples = np.pad(samples, (0, target_samples - len(samples)))
456 arrays.append(samples[:target_samples])
457 raw_audio, mixed_audio, stem_audio = arrays
458
459 cue_mask = np.zeros(target_samples, dtype=bool)
460 for cue in cues:
461 start = int(round(cue.video_time_ms * _VERIFICATION_SAMPLE_RATE / 1000))
462 end = int(
463 round(
464 (cue.video_time_ms + cue.source_duration_ms)
465 * _VERIFICATION_SAMPLE_RATE
466 / 1000
467 )
468 )
469 cue_mask[max(0, start):min(target_samples, end)] = True
470 cue_indices = np.flatnonzero(cue_mask)
471 if len(cue_indices) < _MIN_VERIFICATION_WINDOW_SAMPLES:
472 raise RuntimeError("SFX cue windows are too short for mix verification")
473
474 background = ~cue_mask
475 raw_background = raw_audio[background]
476 mixed_background = mixed_audio[background]
477 denominator = float(np.dot(raw_background, raw_background))
478 narration_scale = (
479 float(np.dot(mixed_background, raw_background)) / denominator
480 if denominator > 1e-12
481 else 1.0
482 )
483 added_audio = mixed_audio - narration_scale * raw_audio
484
485 best_correlation = -1.0
486 maximum_lag = max(1, _VERIFICATION_SAMPLE_RATE // 200)
487 for lag in range(-maximum_lag, maximum_lag + 1):
488 shifted_indices = cue_indices + lag
489 valid = (shifted_indices >= 0) & (shifted_indices < target_samples)
490 if not valid.any():
491 continue
492 observed = added_audio[shifted_indices[valid]]
493 expected = stem_audio[cue_indices[valid]]
494 observed = observed - observed.mean()
495 expected = expected - expected.mean()
496 norm = float(np.linalg.norm(observed) * np.linalg.norm(expected))
497 if norm <= 1e-12:
498 continue
499 correlation = float(np.dot(observed, expected) / norm)
500 best_correlation = max(best_correlation, correlation)
501 if best_correlation < _MIN_SOUND_MIX_CORRELATION:
502 raise RuntimeError(
503 "Mixed-video audio does not reliably contain the rendered SFX stem: "
504 f"correlation={best_correlation:.3f}, "
505 f"required>={_MIN_SOUND_MIX_CORRELATION:.2f}"
506 )
507 return best_correlation
508
509
510 def _trace_slides(trace: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
511 raw_slides = trace.get("slides")
512 if not isinstance(raw_slides, list) or not raw_slides:
513 raise ValueError("Conversion trace must contain a non-empty slides array")
514 slide_count = trace.get("slide_count")
515 if slide_count != len(raw_slides):
516 raise ValueError(
517 "Conversion trace slide_count does not match its slides array"
518 )
519
520 slides: list[dict[str, Any]] = []
521 slide_names: list[str] = []
522 for expected_number, raw_slide in enumerate(raw_slides, 1):
523 if not isinstance(raw_slide, dict):
524 raise ValueError(
525 f"Conversion trace slide {expected_number} must be an object"
526 )
527 if raw_slide.get("slide_num") != expected_number:
528 raise ValueError(
529 "Conversion trace slides must be numbered consecutively from 1"
530 )
531 raw_svg = raw_slide.get("svg")
532 if not isinstance(raw_svg, str) or not raw_svg.strip():
533 raise ValueError(
534 f"Conversion trace slide {expected_number} has no SVG path"
535 )
536 slide_name = Path(raw_svg).stem
537 if not slide_name:
538 raise ValueError(
539 f"Conversion trace slide {expected_number} has an invalid SVG path"
540 )
541 slides.append(raw_slide)
542 slide_names.append(slide_name)
543 if len(set(slide_names)) != len(slide_names):
544 raise ValueError("Conversion trace slide names must be unique")
545 return slides, slide_names
546
547
548 def _shape_group_ids(trace_slide: dict[str, Any]) -> dict[int, str]:
549 events = trace_slide.get("events")
550 if not isinstance(events, list):
551 return {}
552 mapping: dict[int, str] = {}
553 for event in events:
554 if not isinstance(event, dict):
555 continue
556 shape_id = event.get("shape_id")
557 group_id = event.get("id")
558 if (
559 isinstance(shape_id, int)
560 and not isinstance(shape_id, bool)
561 and isinstance(group_id, str)
562 and group_id
563 ):
564 mapping[shape_id] = group_id
565 return mapping
566
567
568 def _package_part_map(package: zipfile.ZipFile) -> dict[str, str]:
569 mapping: dict[str, str] = {}
570 for member in package.namelist():
571 key = canonical_opc_part_path(member)
572 if key is None:
573 continue
574 prior = mapping.get(key)
575 if prior is not None and prior != member:
576 raise ValueError(
577 "PPTX contains OPC-equivalent duplicate package parts: "
578 f"{prior!r}, {member!r}"
579 )
580 mapping[key] = member
581 return mapping
582
583
584 def _relationships_part(slide_part: str) -> str:
585 slide_path = Path(slide_part)
586 return (
587 slide_path.parent / "_rels" / f"{slide_path.name}.rels"
588 ).as_posix()
589
590
591 def _slide_relationships(
592 package: zipfile.ZipFile,
593 rels_part: str,
594 ) -> dict[str, ET.Element]:
595 try:
596 root = ET.fromstring(package.read(rels_part))
597 except KeyError as exc:
598 raise ValueError(f"PPTX is missing slide relationships: {rels_part}") from exc
599 except ET.ParseError as exc:
600 raise ValueError(f"Invalid slide relationships XML: {rels_part}") from exc
601 relationships: dict[str, ET.Element] = {}
602 for relationship in root.findall(_RELATIONSHIP_TAG):
603 relationship_id = relationship.get("Id")
604 if not relationship_id:
605 continue
606 if relationship_id in relationships:
607 raise ValueError(
608 f"Duplicate relationship id {relationship_id!r} in {rels_part}"
609 )
610 relationships[relationship_id] = relationship
611 return relationships
612
613
614 def _extract_sound(
615 *,
616 package: zipfile.ZipFile,
617 package_parts: dict[str, str],
618 relationships: dict[str, ET.Element],
619 rels_part: str,
620 relationship_id: str,
621 temp_dir: Path,
622 ffprobe_path: str,
623 extracted: dict[str, tuple[str, str, Path, int]],
624 ) -> tuple[str, str, Path, int]:
625 relationship = relationships.get(relationship_id)
626 if relationship is None:
627 raise ValueError(
628 f"PPTX sound relationship is missing: {rels_part}#{relationship_id}"
629 )
630 if relationship.get("Type") != AUDIO_REL_TYPE:
631 raise ValueError(
632 f"PPTX sound relationship is not audio: {rels_part}#{relationship_id}"
633 )
634 if relationship.get("TargetMode", "Internal") == "External":
635 raise ValueError(
636 f"PPTX sound relationship must be internal: {rels_part}#{relationship_id}"
637 )
638 target = relationship.get("Target") or ""
639 target_key = resolve_internal_opc_target(rels_part, target)
640 if target_key is None:
641 raise ValueError(
642 f"PPTX sound relationship target is invalid: {rels_part}#{relationship_id}"
643 )
644 package_part = package_parts.get(target_key)
645 if package_part is None:
646 raise ValueError(
647 f"PPTX sound relationship target is missing: {target!r}"
648 )
649 cached = extracted.get(package_part)
650 if cached is not None:
651 return cached
652
653 payload = package.read(package_part)
654 media_sha256 = hashlib.sha256(payload).hexdigest()
655 extension = Path(package_part).suffix.lower()
656 if extension not in {".m4a", ".mp3", ".wav"}:
657 raise ValueError(
658 f"PPTX animation sound uses an unsupported media type: {package_part}"
659 )
660 media_path = temp_dir / f"{media_sha256}{extension}"
661 media_path.write_bytes(payload)
662 source_duration_ms = _duration_ms(
663 _probe_media(media_path, ffprobe_path),
664 f"PPTX sound {package_part}",
665 )
666 resolved = (package_part, media_sha256, media_path, source_duration_ms)
667 extracted[package_part] = resolved
668 return resolved
669
670
671 def _require_equal(actual: object, expected: object, field: str) -> None:
672 if actual != expected:
673 raise ValueError(
674 f"Final narrated trace does not match the PPTX at {field}: "
675 f"trace={actual!r}, pptx={expected!r}"
676 )
677
678
679 def _resolve_sound_cues(
680 *,
681 pptx_path: Path,
682 trace_slides: list[dict[str, Any]],
683 calibration: VideoTimelineCalibration,
684 transition_gain_db: float,
685 animation_gain_db: float,
686 video_duration_ms: int,
687 temp_dir: Path,
688 ffprobe_path: str,
689 ) -> list[SoundCue]:
690 cues: list[SoundCue] = []
691 extracted: dict[str, tuple[str, str, Path, int]] = {}
692 with zipfile.ZipFile(pptx_path) as package:
693 slide_members = presentation_slide_members(package)
694 if len(slide_members) != len(trace_slides):
695 raise ValueError(
696 f"Final narrated PPTX has {len(slide_members)} slides, but "
697 f"the trace has {len(trace_slides)}"
698 )
699 package_parts = _package_part_map(package)
700
701 for slide_num, (slide_part, trace_slide, calibrated) in enumerate(
702 zip(slide_members, trace_slides, calibration.slides),
703 1,
704 ):
705 slide_xml = package.read(slide_part)
706 transition = read_slide_transition_xml(slide_xml)
707 animation = read_slide_animation_sequence(
708 slide_xml,
709 require_supported_effects=True,
710 )
711 trace_motion = trace_slide.get("motion")
712 trace_animation = trace_slide.get("animation")
713 if not isinstance(trace_motion, dict) or not isinstance(
714 trace_animation, dict
715 ):
716 raise ValueError(
717 f"Conversion trace slide {slide_num} lacks resolved motion"
718 )
719
720 _require_equal(
721 trace_motion.get("duration_ms"),
722 transition.duration_ms,
723 f"slide {slide_num} transition duration",
724 )
725 _require_equal(
726 trace_motion.get("advance_after_ms"),
727 transition.advance_after_ms,
728 f"slide {slide_num} recorded advance",
729 )
730 _require_equal(
731 trace_motion.get("sound_relationship_id"),
732 transition.sound_relationship_id,
733 f"slide {slide_num} transition sound relationship",
734 )
735 _require_equal(
736 trace_motion.get("sound_name"),
737 transition.sound_name,
738 f"slide {slide_num} transition sound name",
739 )
740 _require_equal(
741 trace_motion.get("fallback_sound_relationship_id"),
742 transition.fallback_sound_relationship_id,
743 f"slide {slide_num} fallback transition sound relationship",
744 )
745 _require_equal(
746 trace_motion.get("fallback_sound_name"),
747 transition.fallback_sound_name,
748 f"slide {slide_num} fallback transition sound name",
749 )
750 if transition.fallback_sound_relationship_id not in {
751 None,
752 transition.sound_relationship_id,
753 }:
754 raise ValueError(
755 f"Slide {slide_num} transition primary and fallback "
756 "branches reference different sounds"
757 )
758
759 raw_rows = trace_animation.get("rows")
760 if not isinstance(raw_rows, list):
761 raise ValueError(
762 f"Conversion trace slide {slide_num} animation rows are invalid"
763 )
764 if len(raw_rows) != len(animation.rows):
765 raise ValueError(
766 f"Final narrated trace has {len(raw_rows)} animation rows "
767 f"on slide {slide_num}; PPTX has {len(animation.rows)}"
768 )
769 for row_index, (raw_row, actual_row) in enumerate(
770 zip(raw_rows, animation.rows),
771 1,
772 ):
773 if not isinstance(raw_row, dict):
774 raise ValueError(
775 f"Conversion trace slide {slide_num} row {row_index} "
776 "must be an object"
777 )
778 for key, expected in (
779 ("shape_id", actual_row.shape_id),
780 ("offset_ms", actual_row.offset_ms),
781 ("trigger", actual_row.trigger),
782 ("sound_relationship_id", actual_row.sound_relationship_id),
783 ("sound_name", actual_row.sound_name),
784 ):
785 _require_equal(
786 raw_row.get(key),
787 expected,
788 f"slide {slide_num} animation row {row_index} {key}",
789 )
790
791 sound_relationship_ids = {
792 relationship_id
793 for relationship_id in (
794 transition.sound_relationship_id,
795 *(
796 row.sound_relationship_id
797 for row in animation.rows
798 ),
799 )
800 if relationship_id is not None
801 }
802 relationships: dict[str, ET.Element] = {}
803 rels_part = _relationships_part(slide_part)
804 if sound_relationship_ids:
805 relationships = _slide_relationships(package, rels_part)
806
807 group_ids = _shape_group_ids(trace_slide)
808 if transition.sound_relationship_id is not None:
809 package_part, media_sha256, media_path, source_duration_ms = (
810 _extract_sound(
811 package=package,
812 package_parts=package_parts,
813 relationships=relationships,
814 rels_part=rels_part,
815 relationship_id=transition.sound_relationship_id,
816 temp_dir=temp_dir,
817 ffprobe_path=ffprobe_path,
818 extracted=extracted,
819 )
820 )
821 cues.append(
822 SoundCue(
823 kind="transition",
824 slide_num=slide_num,
825 slide_name=calibrated.slide_name,
826 row_index=None,
827 shape_id=None,
828 group_id=None,
829 relationship_id=transition.sound_relationship_id,
830 sound_name=transition.sound_name or Path(package_part).name,
831 package_part=package_part,
832 media_sha256=media_sha256,
833 media_path=media_path,
834 source_duration_ms=source_duration_ms,
835 powerpoint_time_ms=calibrated.powerpoint_slide_start_ms,
836 video_time_ms=calibrated.video_slide_start_ms,
837 gain_db=transition_gain_db,
838 )
839 )
840
841 for row_index, row in enumerate(animation.rows, 1):
842 if row.sound_relationship_id is None:
843 continue
844 if row.trigger not in {"after-previous", "with-previous"}:
845 raise ValueError(
846 f"Slide {slide_num} animation row {row_index} uses "
847 f"{row.trigger!r}; video sound timing requires a "
848 "click-free row"
849 )
850 package_part, media_sha256, media_path, source_duration_ms = (
851 _extract_sound(
852 package=package,
853 package_parts=package_parts,
854 relationships=relationships,
855 rels_part=rels_part,
856 relationship_id=row.sound_relationship_id,
857 temp_dir=temp_dir,
858 ffprobe_path=ffprobe_path,
859 extracted=extracted,
860 )
861 )
862 powerpoint_time_ms = (
863 calibrated.powerpoint_slide_start_ms
864 + calibrated.transition_ms
865 + row.offset_ms
866 )
867 video_time_ms = (
868 calibrated.video_slide_start_ms
869 + calibrated.transition_ms
870 + row.offset_ms
871 )
872 cues.append(
873 SoundCue(
874 kind="animation",
875 slide_num=slide_num,
876 slide_name=calibrated.slide_name,
877 row_index=row_index,
878 shape_id=row.shape_id,
879 group_id=group_ids.get(row.shape_id),
880 relationship_id=row.sound_relationship_id,
881 sound_name=row.sound_name or Path(package_part).name,
882 package_part=package_part,
883 media_sha256=media_sha256,
884 media_path=media_path,
885 source_duration_ms=source_duration_ms,
886 powerpoint_time_ms=powerpoint_time_ms,
887 video_time_ms=video_time_ms,
888 gain_db=animation_gain_db,
889 )
890 )
891
892 cues.sort(
893 key=lambda cue: (
894 cue.video_time_ms,
895 cue.slide_num,
896 0 if cue.kind == "transition" else 1,
897 cue.row_index or 0,
898 )
899 )
900 if not cues:
901 raise ValueError(
902 "Final narrated PPTX and trace contain no transition or "
903 "object-animation sound cues; use the PowerPoint video directly"
904 )
905 for cue in cues:
906 if cue.video_time_ms < 0 or cue.video_time_ms >= video_duration_ms:
907 raise ValueError(
908 f"Sound cue falls outside the video: slide {cue.slide_num}, "
909 f"time={cue.video_time_ms}ms, video={video_duration_ms}ms"
910 )
911 cue_end_ms = cue.video_time_ms + cue.source_duration_ms
912 if cue_end_ms > video_duration_ms:
913 raise ValueError(
914 f"Sound cue would be truncated by the video end: slide "
915 f"{cue.slide_num}, cue end={cue_end_ms}ms, "
916 f"video={video_duration_ms}ms"
917 )
918 return cues
919
920
921 def _render_stem(
922 *,
923 cues: list[SoundCue],
924 duration_ms: int,
925 output_path: Path,
926 ffmpeg_path: str,
927 ) -> None:
928 target_samples = _target_sample_count(duration_ms)
929 command = [
930 ffmpeg_path,
931 "-nostdin",
932 "-hide_banner",
933 "-v",
934 "error",
935 "-f",
936 "lavfi",
937 "-i",
938 f"anullsrc=r={_OUTPUT_SAMPLE_RATE}:cl=stereo",
939 ]
940 for cue in cues:
941 command.extend(["-i", str(cue.media_path)])
942
943 filters = [
944 (
945 f"[0:a:0]aformat=sample_fmts=fltp:sample_rates={_OUTPUT_SAMPLE_RATE}:"
946 "channel_layouts=stereo,"
947 f"atrim=end_sample={target_samples},asetpts=N/SR/TB[base]"
948 )
949 ]
950 labels = ["[base]"]
951 for input_index, cue in enumerate(cues, 1):
952 label = f"cue{input_index}"
953 delay_samples = int(
954 round(cue.video_time_ms * _OUTPUT_SAMPLE_RATE / 1000)
955 )
956 filters.append(
957 f"[{input_index}:a:0]aresample={_OUTPUT_SAMPLE_RATE},"
958 "aformat=sample_fmts=fltp:"
959 f"sample_rates={_OUTPUT_SAMPLE_RATE}:channel_layouts=stereo,"
960 f"asetpts=N/SR/TB,volume={cue.gain_db:.3f}dB,"
961 f"adelay={delay_samples}S:all=1,"
962 f"apad=whole_len={target_samples},"
963 f"atrim=end_sample={target_samples}[{label}]"
964 )
965 labels.append(f"[{label}]")
966 filters.append(
967 "".join(labels)
968 + f"amix=inputs={len(labels)}:duration=first:"
969 "dropout_transition=0:normalize=0,"
970 f"atrim=end_sample={target_samples},asetpts=N/SR/TB[stem]"
971 )
972 command.extend(
973 [
974 "-filter_complex",
975 ";".join(filters),
976 "-map",
977 "[stem]",
978 "-c:a",
979 "pcm_f32le",
980 "-ar",
981 str(_OUTPUT_SAMPLE_RATE),
982 "-ac",
983 "2",
984 "-y",
985 str(output_path),
986 ]
987 )
988 _run_command(command, "SFX stem render")
989
990
991 def _mix_video(
992 *,
993 video_path: Path,
994 stem_path: Path,
995 duration_ms: int,
996 limiter_dbfs: float,
997 output_path: Path,
998 ffmpeg_path: str,
999 ) -> None:
1000 target_samples = _target_sample_count(duration_ms)
1001 limiter_ratio = 10 ** (limiter_dbfs / 20)
1002 filter_graph = (
1003 f"[0:a:0]aresample={_OUTPUT_SAMPLE_RATE}:first_pts=0,"
1004 "aformat=sample_fmts=fltp:channel_layouts=stereo,"
1005 f"asetpts=N/SR/TB,apad=whole_len={target_samples},"
1006 f"atrim=end_sample={target_samples}[narration];"
1007 f"[1:a:0]aresample={_OUTPUT_SAMPLE_RATE}:first_pts=0,"
1008 "aformat=sample_fmts=fltp:channel_layouts=stereo,"
1009 f"asetpts=N/SR/TB,apad=whole_len={target_samples},"
1010 f"atrim=end_sample={target_samples}[sfx];"
1011 "[narration][sfx]amix=inputs=2:duration=first:"
1012 "dropout_transition=0:normalize=0,"
1013 f"alimiter=limit={limiter_ratio:.9f}:attack=5:release=50:"
1014 "level=false:latency=true,"
1015 f"apad=whole_len={target_samples},"
1016 f"atrim=end_sample={target_samples},asetpts=N/SR/TB[mixed]"
1017 )
1018 _run_command(
1019 [
1020 ffmpeg_path,
1021 "-nostdin",
1022 "-hide_banner",
1023 "-v",
1024 "error",
1025 "-i",
1026 str(video_path),
1027 "-i",
1028 str(stem_path),
1029 "-filter_complex",
1030 filter_graph,
1031 "-map",
1032 "0:v:0",
1033 "-map",
1034 "[mixed]",
1035 "-map_metadata",
1036 "0",
1037 "-c:v",
1038 "copy",
1039 "-c:a",
1040 "aac",
1041 "-b:a",
1042 _OUTPUT_AUDIO_BITRATE,
1043 "-ar",
1044 str(_OUTPUT_SAMPLE_RATE),
1045 "-ac",
1046 "2",
1047 "-movflags",
1048 "+faststart",
1049 "-y",
1050 str(output_path),
1051 ],
1052 "Narration and SFX mix",
1053 )
1054
1055
1056 def _temporary_output(path: Path, suffix: str) -> Path:
1057 path.parent.mkdir(parents=True, exist_ok=True)
1058 descriptor, raw_path = tempfile.mkstemp(
1059 prefix=f".{path.stem}.",
1060 suffix=suffix,
1061 dir=path.parent,
1062 )
1063 os.close(descriptor)
1064 temporary = Path(raw_path)
1065 temporary.unlink()
1066 return temporary
1067
1068
1069 def _publish_outputs(replacements: list[tuple[Path, Path]]) -> None:
1070 """Publish verified outputs together, restoring prior files on failure."""
1071 backups: dict[Path, Path] = {}
1072 published: list[Path] = []
1073 try:
1074 for _temporary, destination in replacements:
1075 if not destination.exists():
1076 continue
1077 backup = _temporary_output(destination, f".backup{destination.suffix}")
1078 os.replace(destination, backup)
1079 backups[destination] = backup
1080 for temporary, destination in replacements:
1081 os.replace(temporary, destination)
1082 published.append(destination)
1083 except OSError as exc:
1084 rollback_errors: list[str] = []
1085 for destination in reversed(published):
1086 backup = backups.pop(destination, None)
1087 try:
1088 if backup is None:
1089 destination.unlink(missing_ok=True)
1090 else:
1091 os.replace(backup, destination)
1092 except OSError as rollback_exc:
1093 rollback_errors.append(f"{destination}: {rollback_exc}")
1094 for destination, backup in backups.items():
1095 try:
1096 os.replace(backup, destination)
1097 except OSError as rollback_exc:
1098 rollback_errors.append(f"{destination}: {rollback_exc}")
1099 if rollback_errors:
1100 raise RuntimeError(
1101 "Output publication failed and rollback was incomplete: "
1102 + "; ".join(rollback_errors)
1103 ) from exc
1104 raise
1105 else:
1106 for backup in backups.values():
1107 backup.unlink(missing_ok=True)
1108
1109
1110 def _cue_report(cue: SoundCue) -> dict[str, Any]:
1111 return {
1112 "kind": cue.kind,
1113 "slide_num": cue.slide_num,
1114 "slide_name": cue.slide_name,
1115 "row_index": cue.row_index,
1116 "shape_id": cue.shape_id,
1117 "group_id": cue.group_id,
1118 "relationship_id": cue.relationship_id,
1119 "sound_name": cue.sound_name,
1120 "package_part": cue.package_part,
1121 "media_sha256": cue.media_sha256,
1122 "source_duration_ms": cue.source_duration_ms,
1123 "powerpoint_time_ms": cue.powerpoint_time_ms,
1124 "video_time_ms": cue.video_time_ms,
1125 "gain_db": cue.gain_db,
1126 }
1127
1128
1129 def mix_video_sounds(
1130 project_path: Path,
1131 *,
1132 pptx_path: Path,
1133 trace_path: Path,
1134 video_path: Path,
1135 audio_dir: Path,
1136 subtitle_dir: Path | None,
1137 output_path: Path,
1138 stem_path: Path,
1139 report_path: Path,
1140 transition_gain_db: float = _DEFAULT_TRANSITION_GAIN_DB,
1141 animation_gain_db: float = _DEFAULT_ANIMATION_GAIN_DB,
1142 limiter_dbfs: float = _DEFAULT_LIMITER_DBFS,
1143 force: bool = False,
1144 ) -> SoundMixResult:
1145 """Render and publish one calibrated animation-sound video mix."""
1146 project_path = project_path.resolve()
1147 pptx_path = pptx_path.resolve()
1148 trace_path = trace_path.resolve()
1149 video_path = video_path.resolve()
1150 audio_dir = audio_dir.resolve()
1151 subtitle_dir = subtitle_dir.resolve() if subtitle_dir is not None else None
1152 output_path = output_path.resolve()
1153 stem_path = stem_path.resolve()
1154 report_path = report_path.resolve()
1155 if not project_path.is_dir():
1156 raise FileNotFoundError(f"Project path does not exist: {project_path}")
1157 for label, path in (
1158 ("Final narrated PPTX", pptx_path),
1159 ("Final narrated conversion trace", trace_path),
1160 ("Raw PowerPoint video", video_path),
1161 ):
1162 if not path.is_file():
1163 raise FileNotFoundError(f"{label} does not exist: {path}")
1164 if pptx_path.suffix.lower() != ".pptx":
1165 raise ValueError(f"Final narrated PPTX must use .pptx: {pptx_path}")
1166 if video_path.suffix.lower() != ".mp4":
1167 raise ValueError(f"Raw PowerPoint video must use .mp4: {video_path}")
1168 if output_path.suffix.lower() != ".mp4":
1169 raise ValueError(f"Mixed video output must use .mp4: {output_path}")
1170 if stem_path.suffix.lower() != ".wav":
1171 raise ValueError(f"SFX stem output must use .wav: {stem_path}")
1172 if report_path.suffix.lower() != ".json":
1173 raise ValueError(f"Sound mix report must use .json: {report_path}")
1174
1175 outputs = [output_path, stem_path, report_path]
1176 if len(set(outputs)) != len(outputs):
1177 raise ValueError("Mixed video, SFX stem, and report paths must differ")
1178 invalid_outputs = [
1179 path for path in outputs if path.exists() and not path.is_file()
1180 ]
1181 if invalid_outputs:
1182 raise ValueError(
1183 "Sound mix outputs must be regular files or unused paths: "
1184 + ", ".join(str(path) for path in invalid_outputs)
1185 )
1186 existing = [path for path in outputs if path.exists()]
1187 if existing and not force:
1188 raise FileExistsError(
1189 "Sound mix output already exists; use --force to replace it: "
1190 + ", ".join(str(path) for path in existing)
1191 )
1192 for label, value in (
1193 ("transition gain", transition_gain_db),
1194 ("animation gain", animation_gain_db),
1195 ):
1196 if not math.isfinite(value) or value < -60 or value > 0:
1197 raise ValueError(f"{label} must be finite and between -60 and 0 dB")
1198 if not math.isfinite(limiter_dbfs) or not -12 <= limiter_dbfs < 0:
1199 raise ValueError("limiter ceiling must be finite, at least -12, and below 0 dBFS")
1200
1201 ffmpeg_path = _require_tool("ffmpeg")
1202 ffprobe_path = _require_tool("ffprobe")
1203 trace = _read_json_object(trace_path, "Conversion trace")
1204 trace_slides, slide_names = _trace_slides(trace)
1205 raw_probe = _probe_media(video_path, ffprobe_path)
1206 raw_format_duration_ms = _duration_ms(raw_probe, "raw PowerPoint video")
1207 raw_video_streams = _streams(raw_probe, "video")
1208 raw_audio_streams = _streams(raw_probe, "audio")
1209 if len(raw_video_streams) != 1 or len(raw_audio_streams) != 1:
1210 raise ValueError(
1211 "Raw PowerPoint video must contain one video stream and one "
1212 "narration audio stream"
1213 )
1214 raw_duration_ms = _stream_duration_ms(
1215 raw_video_streams[0],
1216 fallback_ms=raw_format_duration_ms,
1217 )
1218 raw_video_start_ms = _stream_start_ms(raw_video_streams[0])
1219 raw_audio_start_ms = _stream_start_ms(raw_audio_streams[0])
1220 start_delta_ms = abs(raw_video_start_ms - raw_audio_start_ms)
1221 frame_duration_ms = _frame_duration_ms(raw_video_streams[0])
1222 if (
1223 abs(raw_video_start_ms) > _START_TIME_TOLERANCE_MS
1224 or abs(raw_audio_start_ms) > _START_TIME_TOLERANCE_MS
1225 or start_delta_ms > _START_TIME_TOLERANCE_MS
1226 ):
1227 raise ValueError(
1228 "Raw PowerPoint video audio and video streams must both start at "
1229 f"zero: video={raw_video_start_ms}ms, audio={raw_audio_start_ms}ms"
1230 )
1231 raw_video_timing = _stream_timing_signature(raw_video_streams[0])
1232
1233 calibration = calibrate_video_timeline(
1234 slide_names=slide_names,
1235 pptx_path=pptx_path,
1236 audio_dir=audio_dir,
1237 video_path=video_path,
1238 subtitle_dir=subtitle_dir,
1239 )
1240 protected_inputs = {
1241 pptx_path,
1242 trace_path,
1243 video_path,
1244 *(slide.audio_path.resolve() for slide in calibration.slides),
1245 }
1246 aliases = protected_inputs.intersection(outputs)
1247 if aliases:
1248 raise ValueError(
1249 "Sound mix outputs must not overwrite inputs: "
1250 + ", ".join(str(path) for path in sorted(aliases))
1251 )
1252 temporary_stem = _temporary_output(stem_path, ".wav")
1253 temporary_video = _temporary_output(output_path, ".mp4")
1254 temporary_report = _temporary_output(report_path, ".json")
1255 try:
1256 with tempfile.TemporaryDirectory(prefix="ppt-master-video-sounds-") as raw_dir:
1257 cues = _resolve_sound_cues(
1258 pptx_path=pptx_path,
1259 trace_slides=trace_slides,
1260 calibration=calibration,
1261 transition_gain_db=transition_gain_db,
1262 animation_gain_db=animation_gain_db,
1263 video_duration_ms=raw_duration_ms,
1264 temp_dir=Path(raw_dir),
1265 ffprobe_path=ffprobe_path,
1266 )
1267 _render_stem(
1268 cues=cues,
1269 duration_ms=raw_duration_ms,
1270 output_path=temporary_stem,
1271 ffmpeg_path=ffmpeg_path,
1272 )
1273 stem_peak_db = _maximum_volume_db(temporary_stem, ffmpeg_path)
1274 if stem_peak_db is None or stem_peak_db < -90:
1275 raise RuntimeError("Rendered SFX stem is silent")
1276 _mix_video(
1277 video_path=video_path,
1278 stem_path=temporary_stem,
1279 duration_ms=raw_duration_ms,
1280 limiter_dbfs=limiter_dbfs,
1281 output_path=temporary_video,
1282 ffmpeg_path=ffmpeg_path,
1283 )
1284
1285 stem_probe = _probe_media(temporary_stem, ffprobe_path)
1286 mixed_probe = _probe_media(temporary_video, ffprobe_path)
1287 stem_duration_ms = _duration_ms(stem_probe, "SFX stem")
1288 mixed_duration_ms = _duration_ms(mixed_probe, "mixed video")
1289 mixed_video_streams = _streams(mixed_probe, "video")
1290 mixed_audio_streams = _streams(mixed_probe, "audio")
1291 if len(mixed_video_streams) != 1 or len(mixed_audio_streams) != 1:
1292 raise RuntimeError(
1293 "Mixed video must contain one copied video stream and one audio stream"
1294 )
1295 mixed_video_duration_ms = _stream_duration_ms(
1296 mixed_video_streams[0],
1297 fallback_ms=mixed_duration_ms,
1298 )
1299 mixed_audio_duration_ms = _stream_duration_ms(
1300 mixed_audio_streams[0],
1301 fallback_ms=mixed_duration_ms,
1302 )
1303 target_samples = _target_sample_count(raw_duration_ms)
1304 stem_samples = _audio_sample_count(_streams(stem_probe, "audio")[0])
1305 if stem_samples != target_samples:
1306 raise RuntimeError(
1307 "SFX stem sample count does not match the raw video target: "
1308 f"stem={stem_samples}, target={target_samples}"
1309 )
1310 mixed_video_timing = _stream_timing_signature(mixed_video_streams[0])
1311 if mixed_video_timing != raw_video_timing:
1312 raise RuntimeError(
1313 "Mixed output changed the PowerPoint video packet timeline: "
1314 f"raw={raw_video_timing!r}, mixed={mixed_video_timing!r}"
1315 )
1316 duration_tolerance_ms = max(
1317 frame_duration_ms,
1318 1024 * 1000 / _OUTPUT_SAMPLE_RATE,
1319 ) + 1
1320 if abs(mixed_audio_duration_ms - raw_duration_ms) > duration_tolerance_ms:
1321 raise RuntimeError(
1322 "Mixed audio duration does not match the raw video: "
1323 f"audio={mixed_audio_duration_ms}ms, raw={raw_duration_ms}ms"
1324 )
1325
1326 raw_video_hash = _stream_hash(video_path, "0:v:0", ffmpeg_path)
1327 mixed_video_hash = _stream_hash(temporary_video, "0:v:0", ffmpeg_path)
1328 if raw_video_hash != mixed_video_hash:
1329 raise RuntimeError("Mixed output changed the PowerPoint video stream")
1330 raw_audio_hash = _stream_hash(video_path, "0:a:0", ffmpeg_path)
1331 mixed_audio_hash = _stream_hash(temporary_video, "0:a:0", ffmpeg_path)
1332 if raw_audio_hash == mixed_audio_hash:
1333 raise RuntimeError("Mixed output audio is unchanged from the raw video")
1334 mixed_peak_db = _maximum_volume_db(temporary_video, ffmpeg_path)
1335 true_peak_dbfs = _true_peak_dbfs(temporary_video, ffmpeg_path)
1336 if true_peak_dbfs is None or true_peak_dbfs >= 0:
1337 raise RuntimeError(
1338 "Mixed-video audio is silent or clips after encoding: "
1339 f"true_peak={true_peak_dbfs!r} dBFS"
1340 )
1341 verification_correlation = _sound_mix_correlation(
1342 raw_video_path=video_path,
1343 mixed_video_path=temporary_video,
1344 stem_path=temporary_stem,
1345 cues=cues,
1346 duration_ms=raw_duration_ms,
1347 ffmpeg_path=ffmpeg_path,
1348 )
1349
1350 trace_output = trace.get("output")
1351 trace_output_matches_pptx = False
1352 if isinstance(trace_output, str) and trace_output.strip():
1353 trace_output_matches_pptx = Path(trace_output).expanduser().resolve() == pptx_path
1354
1355 report = {
1356 "schema": "ppt-master.video-sound-mix-report.v1",
1357 "version": 1,
1358 "status": "passed",
1359 "inputs": {
1360 "pptx": {
1361 "path": str(pptx_path),
1362 "sha256": _sha256_path(pptx_path),
1363 },
1364 "conversion_trace": {
1365 "path": str(trace_path),
1366 "sha256": _sha256_path(trace_path),
1367 "declared_output_matches_pptx": trace_output_matches_pptx,
1368 },
1369 "raw_video": {
1370 "path": str(video_path),
1371 "sha256": _sha256_path(video_path),
1372 "duration_ms": raw_duration_ms,
1373 "video_start_ms": _stream_start_ms(raw_video_streams[0]),
1374 "audio_start_ms": _stream_start_ms(raw_audio_streams[0]),
1375 "video_stream_sha256": raw_video_hash,
1376 "video_timing": raw_video_timing,
1377 "audio_stream_sha256": raw_audio_hash,
1378 "video_codec": raw_video_streams[0].get("codec_name"),
1379 "audio_codec": raw_audio_streams[0].get("codec_name"),
1380 },
1381 },
1382 "settings": {
1383 "transition_gain_db": transition_gain_db,
1384 "animation_gain_db": animation_gain_db,
1385 "limiter_dbfs": limiter_dbfs,
1386 "mix_normalize": False,
1387 "ducking": False,
1388 "background_music": False,
1389 "sample_rate": _OUTPUT_SAMPLE_RATE,
1390 "audio_bitrate": _OUTPUT_AUDIO_BITRATE,
1391 },
1392 "timeline": {
1393 "method": "page narration correlation against raw video audio",
1394 "powerpoint_timeline_ms": calibration.powerpoint_timeline_ms,
1395 "minimum_correlation": min(
1396 slide.correlation for slide in calibration.slides
1397 ),
1398 "slides": [
1399 {
1400 "slide_num": index,
1401 "slide_name": slide.slide_name,
1402 "powerpoint_slide_start_ms": slide.powerpoint_slide_start_ms,
1403 "powerpoint_narration_start_ms": (
1404 slide.powerpoint_narration_start_ms
1405 ),
1406 "video_slide_start_ms": slide.video_slide_start_ms,
1407 "video_narration_start_ms": slide.video_narration_start_ms,
1408 "adjustment_ms": slide.adjustment_ms,
1409 "correlation": slide.correlation,
1410 }
1411 for index, slide in enumerate(calibration.slides, 1)
1412 ],
1413 },
1414 "cues": [_cue_report(cue) for cue in cues],
1415 "outputs": {
1416 "sfx_stem": {
1417 "path": str(stem_path),
1418 "sha256": _sha256_path(temporary_stem),
1419 "duration_ms": stem_duration_ms,
1420 "sample_count": stem_samples,
1421 "maximum_volume_db": stem_peak_db,
1422 },
1423 "mixed_video": {
1424 "path": str(output_path),
1425 "sha256": _sha256_path(temporary_video),
1426 "duration_ms": mixed_duration_ms,
1427 "video_duration_ms": mixed_video_duration_ms,
1428 "audio_duration_ms": mixed_audio_duration_ms,
1429 "video_stream_sha256": mixed_video_hash,
1430 "video_timing": mixed_video_timing,
1431 "audio_stream_sha256": mixed_audio_hash,
1432 "video_codec": mixed_video_streams[0].get("codec_name"),
1433 "audio_codec": mixed_audio_streams[0].get("codec_name"),
1434 "maximum_volume_db": mixed_peak_db,
1435 "true_peak_dbfs": true_peak_dbfs,
1436 },
1437 },
1438 "validation": {
1439 "trace_matches_pptx_motion": True,
1440 "cue_count": len(cues),
1441 "stem_non_silent": True,
1442 "mixed_audio_present": True,
1443 "video_stream_preserved": True,
1444 "duration_delta_ms": mixed_video_duration_ms - raw_duration_ms,
1445 "video_timeline_preserved": True,
1446 "limiter_applied": True,
1447 "sound_mix_correlation": verification_correlation,
1448 "minimum_sound_mix_correlation": _MIN_SOUND_MIX_CORRELATION,
1449 },
1450 }
1451 temporary_report.write_text(
1452 json.dumps(report, ensure_ascii=False, indent=2) + "\n",
1453 encoding="utf-8",
1454 )
1455
1456 _publish_outputs(
1457 [
1458 (temporary_stem, stem_path),
1459 (temporary_video, output_path),
1460 (temporary_report, report_path),
1461 ]
1462 )
1463 finally:
1464 for temporary in (temporary_stem, temporary_video, temporary_report):
1465 temporary.unlink(missing_ok=True)
1466
1467 return SoundMixResult(
1468 output_path=output_path,
1469 stem_path=stem_path,
1470 report_path=report_path,
1471 cue_count=len(cues),
1472 verification_correlation=verification_correlation,
1473 true_peak_dbfs=true_peak_dbfs,
1474 )
1475
1476
1477 def build_parser() -> argparse.ArgumentParser:
1478 parser = argparse.ArgumentParser(
1479 description=(
1480 "Mix PPTX transition/object-animation sounds into a "
1481 "PowerPoint-exported narrated video."
1482 ),
1483 formatter_class=argparse.RawDescriptionHelpFormatter,
1484 )
1485 parser.add_argument("project_path", help="PPT Master project directory")
1486 parser.add_argument(
1487 "--pptx",
1488 required=True,
1489 help="Final narrated PPTX; relative paths resolve under the project",
1490 )
1491 parser.add_argument(
1492 "--trace",
1493 required=True,
1494 help="Final narrated conversion trace; relative paths resolve under the project",
1495 )
1496 parser.add_argument(
1497 "--video",
1498 required=True,
1499 help="Raw PowerPoint-exported video; relative paths resolve under the project",
1500 )
1501 parser.add_argument(
1502 "--audio-dir",
1503 default=None,
1504 help="Page narration audio directory; default: <project>/audio",
1505 )
1506 parser.add_argument(
1507 "--subtitle-dir",
1508 default=None,
1509 help=(
1510 "Optional page-local SRT directory used to refine calibration; "
1511 "default: <project>/audio"
1512 ),
1513 )
1514 parser.add_argument(
1515 "-o",
1516 "--output",
1517 default=None,
1518 help="Final mixed MP4; default: beside raw video as *_mixed.mp4",
1519 )
1520 parser.add_argument(
1521 "--stem-output",
1522 default=None,
1523 help="Independent SFX WAV; default: beside raw video as *_sfx.wav",
1524 )
1525 parser.add_argument(
1526 "--report-output",
1527 default=None,
1528 help="Sound mix JSON receipt; default: beside raw video as *_sound_mix.json",
1529 )
1530 parser.add_argument(
1531 "--transition-gain-db",
1532 type=float,
1533 default=_DEFAULT_TRANSITION_GAIN_DB,
1534 help="Transition cue gain in dB (default: -9.1, about 35%%)",
1535 )
1536 parser.add_argument(
1537 "--animation-gain-db",
1538 type=float,
1539 default=_DEFAULT_ANIMATION_GAIN_DB,
1540 help="Object-animation cue gain in dB (default: -12, about 25%%)",
1541 )
1542 parser.add_argument(
1543 "--limiter-dbfs",
1544 type=float,
1545 default=_DEFAULT_LIMITER_DBFS,
1546 help="Final peak limiter ceiling in dBFS (default: -1)",
1547 )
1548 parser.add_argument(
1549 "--force",
1550 action="store_true",
1551 help="Replace existing mixed video, stem, and report outputs",
1552 )
1553 return parser
1554
1555
1556 def main(argv: list[str] | None = None) -> int:
1557 parser = build_parser()
1558 args = parser.parse_args(argv)
1559 project_path = Path(args.project_path).expanduser().resolve()
1560 if not project_path.is_dir():
1561 parser.error(f"Project path does not exist: {project_path}")
1562
1563 pptx_path = _project_input_path(project_path, args.pptx)
1564 trace_path = _project_input_path(project_path, args.trace)
1565 video_path = _project_input_path(project_path, args.video)
1566 audio_dir = _project_output_path(
1567 project_path,
1568 args.audio_dir,
1569 Path("audio"),
1570 )
1571 subtitle_dir = _project_output_path(
1572 project_path,
1573 args.subtitle_dir,
1574 Path("audio"),
1575 )
1576 default_output = video_path.with_name(f"{video_path.stem}_mixed.mp4")
1577 default_stem = video_path.with_name(f"{video_path.stem}_sfx.wav")
1578 default_report = video_path.with_name(f"{video_path.stem}_sound_mix.json")
1579 output_path = _project_output_path(project_path, args.output, default_output)
1580 stem_path = _project_output_path(
1581 project_path,
1582 args.stem_output,
1583 default_stem,
1584 )
1585 report_path = _project_output_path(
1586 project_path,
1587 args.report_output,
1588 default_report,
1589 )
1590
1591 try:
1592 result = mix_video_sounds(
1593 project_path,
1594 pptx_path=pptx_path,
1595 trace_path=trace_path,
1596 video_path=video_path,
1597 audio_dir=audio_dir,
1598 subtitle_dir=subtitle_dir,
1599 output_path=output_path,
1600 stem_path=stem_path,
1601 report_path=report_path,
1602 transition_gain_db=args.transition_gain_db,
1603 animation_gain_db=args.animation_gain_db,
1604 limiter_dbfs=args.limiter_dbfs,
1605 force=args.force,
1606 )
1607 except (FileNotFoundError, FileExistsError, OSError, RuntimeError, ValueError) as exc:
1608 print(f"Video sound mix failed: {exc}", file=sys.stderr)
1609 return 1
1610
1611 print(
1612 f"[POSTFLIGHT] status=passed cues={result.cue_count} "
1613 "video_stream=preserved audio_mix=verified "
1614 f"correlation={result.verification_correlation:.3f} "
1615 f"true_peak={result.true_peak_dbfs:.1f}dBFS"
1616 )
1617 print(f"[Done] Mixed video: {result.output_path}")
1618 print(f"[Done] SFX stem: {result.stem_path}")
1619 print(f"[REPORT] Video sound mix: {result.report_path}")
1620 return 0
1621
1622
1623 if __name__ == "__main__":
1624 raise SystemExit(main())
1625
1625 lines PYTHON