| 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 |