1625 lines
57 KiB
Python
1625 lines
57 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
PPT Master - Video Sound Mix
|
|
|
|
Recover transition and object-animation sound cues from the final narrated
|
|
PPTX, calibrate them to a PowerPoint-exported video, render an independent SFX
|
|
stem, and mix that stem with the video's narration track.
|
|
See workflows/stages/generate-audio.md for the owning delivery stage.
|
|
|
|
Usage:
|
|
python3 scripts/video_sound_mix.py <project_path> --pptx <pptx> \
|
|
--trace <trace.json> --video <raw.mp4> [options]
|
|
|
|
Examples:
|
|
python3 scripts/video_sound_mix.py projects/demo \
|
|
--pptx exports/demo_narrated.pptx \
|
|
--trace validation/demo_narrated.trace.json \
|
|
--video exports/demo_raw.mp4 --force
|
|
|
|
Dependencies:
|
|
ffmpeg, ffprobe, and numpy
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from fractions import Fraction
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from xml.etree import ElementTree as ET
|
|
|
|
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
if str(_SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
|
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
|
from narration_sync import ( # noqa: E402
|
|
VideoTimelineCalibration,
|
|
calibrate_video_timeline,
|
|
presentation_slide_members,
|
|
)
|
|
from pptx_animations import read_slide_animation_sequence # noqa: E402
|
|
from pptx_opc_validation import ( # noqa: E402
|
|
canonical_opc_part_path,
|
|
resolve_internal_opc_target,
|
|
)
|
|
from pptx_transitions import ( # noqa: E402
|
|
AUDIO_REL_TYPE,
|
|
read_slide_transition_xml,
|
|
)
|
|
|
|
configure_utf8_stdio()
|
|
|
|
|
|
_PACKAGE_REL_NS = (
|
|
"http://schemas.openxmlformats.org/package/2006/relationships"
|
|
)
|
|
_RELATIONSHIP_TAG = f"{{{_PACKAGE_REL_NS}}}Relationship"
|
|
_DEFAULT_TRANSITION_GAIN_DB = -9.1
|
|
_DEFAULT_ANIMATION_GAIN_DB = -12.0
|
|
_DEFAULT_LIMITER_DBFS = -1.0
|
|
_OUTPUT_SAMPLE_RATE = 48_000
|
|
_OUTPUT_AUDIO_BITRATE = "192k"
|
|
_START_TIME_TOLERANCE_MS = 1
|
|
_VERIFICATION_SAMPLE_RATE = 8000
|
|
_MIN_VERIFICATION_WINDOW_SAMPLES = 2
|
|
_MIN_SOUND_MIX_CORRELATION = 0.20
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SoundCue:
|
|
"""One resolved PPTX sound cue placed on the exported-video timeline."""
|
|
|
|
kind: str
|
|
slide_num: int
|
|
slide_name: str
|
|
row_index: int | None
|
|
shape_id: int | None
|
|
group_id: str | None
|
|
relationship_id: str
|
|
sound_name: str
|
|
package_part: str
|
|
media_sha256: str
|
|
media_path: Path
|
|
source_duration_ms: int
|
|
powerpoint_time_ms: int
|
|
video_time_ms: int
|
|
gain_db: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SoundMixResult:
|
|
"""Published files and cue count from one video sound mix."""
|
|
|
|
output_path: Path
|
|
stem_path: Path
|
|
report_path: Path
|
|
cue_count: int
|
|
verification_correlation: float
|
|
true_peak_dbfs: float
|
|
|
|
|
|
def _read_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"{label} is not valid JSON: {path}: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} must contain one JSON object: {path}")
|
|
return value
|
|
|
|
|
|
def _sha256_path(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _project_input_path(project_path: Path, value: str) -> Path:
|
|
path = Path(value).expanduser()
|
|
return (path if path.is_absolute() else project_path / path).resolve()
|
|
|
|
|
|
def _project_output_path(
|
|
project_path: Path,
|
|
value: str | None,
|
|
default: Path,
|
|
) -> Path:
|
|
path = Path(value).expanduser() if value else default
|
|
return (path if path.is_absolute() else project_path / path).resolve()
|
|
|
|
|
|
def _require_tool(name: str) -> str:
|
|
path = shutil.which(name)
|
|
if path is None:
|
|
raise RuntimeError(
|
|
f"Video sound mixing requires {name} on PATH. Install ffmpeg "
|
|
"and make both ffmpeg and ffprobe available."
|
|
)
|
|
return path
|
|
|
|
|
|
def _run_command(command: list[str], label: str) -> subprocess.CompletedProcess[str]:
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
check=False,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
if result.returncode != 0:
|
|
details = result.stderr.strip() or result.stdout.strip()
|
|
raise RuntimeError(f"{label} failed:\n{details}")
|
|
return result
|
|
|
|
|
|
def _probe_media(path: Path, ffprobe_path: str) -> dict[str, Any]:
|
|
result = _run_command(
|
|
[
|
|
ffprobe_path,
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
(
|
|
"format=duration,start_time:"
|
|
"stream=index,codec_type,codec_name,time_base,start_pts,"
|
|
"start_time,duration_ts,duration,sample_rate,channels,"
|
|
"width,height,avg_frame_rate"
|
|
),
|
|
"-of",
|
|
"json",
|
|
str(path),
|
|
],
|
|
f"ffprobe inspection for {path}",
|
|
)
|
|
try:
|
|
payload = json.loads(result.stdout)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"ffprobe returned invalid JSON for {path}") from exc
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError(f"ffprobe returned an invalid payload for {path}")
|
|
return payload
|
|
|
|
|
|
def _duration_ms(probe: dict[str, Any], label: str) -> int:
|
|
format_value = probe.get("format")
|
|
raw_duration = (
|
|
format_value.get("duration")
|
|
if isinstance(format_value, dict)
|
|
else None
|
|
)
|
|
try:
|
|
duration = float(raw_duration)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"Unable to read {label} duration with ffprobe") from exc
|
|
if not math.isfinite(duration) or duration <= 0:
|
|
raise ValueError(f"{label} duration must be positive and finite")
|
|
return int(round(duration * 1000))
|
|
|
|
|
|
def _stream_duration_ms(
|
|
stream: dict[str, Any],
|
|
*,
|
|
fallback_ms: int,
|
|
) -> int:
|
|
try:
|
|
duration = float(stream.get("duration"))
|
|
except (TypeError, ValueError):
|
|
return fallback_ms
|
|
if not math.isfinite(duration) or duration <= 0:
|
|
return fallback_ms
|
|
return int(round(duration * 1000))
|
|
|
|
|
|
def _stream_start_ms(stream: dict[str, Any]) -> int:
|
|
try:
|
|
start = float(stream.get("start_time", 0))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
if not math.isfinite(start):
|
|
return 0
|
|
return int(round(start * 1000))
|
|
|
|
|
|
def _frame_duration_ms(stream: dict[str, Any]) -> float:
|
|
value = stream.get("avg_frame_rate")
|
|
if not isinstance(value, str) or "/" not in value:
|
|
return 1000 / 30
|
|
numerator_text, denominator_text = value.split("/", 1)
|
|
try:
|
|
numerator = float(numerator_text)
|
|
denominator = float(denominator_text)
|
|
except ValueError:
|
|
return 1000 / 30
|
|
if numerator <= 0 or denominator <= 0:
|
|
return 1000 / 30
|
|
return 1000 * denominator / numerator
|
|
|
|
|
|
def _stream_timing_signature(stream: dict[str, Any]) -> dict[str, object]:
|
|
"""Return the packet-clock fields that must survive video stream copy."""
|
|
signature: dict[str, object] = {}
|
|
for key in ("time_base", "start_pts", "duration_ts"):
|
|
value = stream.get(key)
|
|
if value is None:
|
|
raise ValueError(f"Video stream is missing {key}")
|
|
signature[key] = value
|
|
return signature
|
|
|
|
|
|
def _audio_sample_count(stream: dict[str, Any]) -> int:
|
|
"""Resolve an audio stream duration to exact samples from its time base."""
|
|
try:
|
|
sample_rate = int(stream.get("sample_rate"))
|
|
duration_ts = int(stream.get("duration_ts"))
|
|
time_base = Fraction(str(stream.get("time_base")))
|
|
except (TypeError, ValueError, ZeroDivisionError) as exc:
|
|
raise ValueError("Unable to read exact audio sample count") from exc
|
|
samples = duration_ts * time_base * sample_rate
|
|
if samples.denominator != 1:
|
|
raise ValueError(
|
|
"Audio duration does not resolve to a whole number of samples"
|
|
)
|
|
return samples.numerator
|
|
|
|
|
|
def _target_sample_count(duration_ms: int) -> int:
|
|
return int(round(duration_ms * _OUTPUT_SAMPLE_RATE / 1000))
|
|
|
|
|
|
def _streams(probe: dict[str, Any], kind: str) -> list[dict[str, Any]]:
|
|
raw_streams = probe.get("streams")
|
|
if not isinstance(raw_streams, list):
|
|
return []
|
|
return [
|
|
stream
|
|
for stream in raw_streams
|
|
if isinstance(stream, dict) and stream.get("codec_type") == kind
|
|
]
|
|
|
|
|
|
def _stream_hash(path: Path, selector: str, ffmpeg_path: str) -> str:
|
|
result = _run_command(
|
|
[
|
|
ffmpeg_path,
|
|
"-v",
|
|
"error",
|
|
"-i",
|
|
str(path),
|
|
"-map",
|
|
selector,
|
|
"-c",
|
|
"copy",
|
|
"-f",
|
|
"hash",
|
|
"-hash",
|
|
"sha256",
|
|
"-",
|
|
],
|
|
f"stream hash for {path}",
|
|
)
|
|
match = re.search(r"SHA256=([0-9a-fA-F]{64})", result.stdout)
|
|
if match is None:
|
|
raise RuntimeError(f"Unable to parse stream hash for {path}")
|
|
return match.group(1).lower()
|
|
|
|
|
|
def _maximum_volume_db(path: Path, ffmpeg_path: str) -> float | None:
|
|
result = subprocess.run(
|
|
[
|
|
ffmpeg_path,
|
|
"-nostdin",
|
|
"-hide_banner",
|
|
"-i",
|
|
str(path),
|
|
"-map",
|
|
"0:a:0",
|
|
"-af",
|
|
"volumedetect",
|
|
"-f",
|
|
"null",
|
|
"-",
|
|
],
|
|
capture_output=True,
|
|
check=False,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"Volume inspection failed for {path}:\n{result.stderr.strip()}"
|
|
)
|
|
match = re.search(r"max_volume:\s*(-?inf|[-+]?\d+(?:\.\d+)?)\s*dB", result.stderr)
|
|
if match is None:
|
|
raise RuntimeError(f"Unable to read maximum volume for {path}")
|
|
if match.group(1) == "-inf":
|
|
return None
|
|
return float(match.group(1))
|
|
|
|
|
|
def _true_peak_dbfs(path: Path, ffmpeg_path: str) -> float | None:
|
|
result = subprocess.run(
|
|
[
|
|
ffmpeg_path,
|
|
"-nostdin",
|
|
"-hide_banner",
|
|
"-nostats",
|
|
"-i",
|
|
str(path),
|
|
"-map",
|
|
"0:a:0",
|
|
"-af",
|
|
"ebur128=peak=true",
|
|
"-f",
|
|
"null",
|
|
"-",
|
|
],
|
|
capture_output=True,
|
|
check=False,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"True-peak inspection failed for {path}:\n{result.stderr.strip()}"
|
|
)
|
|
matches = re.findall(
|
|
r"Peak:\s*(-?inf|[-+]?\d+(?:\.\d+)?)\s*dBFS",
|
|
result.stderr,
|
|
)
|
|
if not matches:
|
|
raise RuntimeError(f"Unable to read true peak for {path}")
|
|
if matches[-1] == "-inf":
|
|
return None
|
|
return float(matches[-1])
|
|
|
|
|
|
def _decode_audio_f32(path: Path, ffmpeg_path: str) -> Any:
|
|
try:
|
|
import numpy as np
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"Video sound verification requires numpy. Install it with: "
|
|
"python3 -m pip install numpy"
|
|
) from exc
|
|
result = subprocess.run(
|
|
[
|
|
ffmpeg_path,
|
|
"-v",
|
|
"error",
|
|
"-i",
|
|
str(path),
|
|
"-map",
|
|
"0:a:0",
|
|
"-vn",
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
str(_VERIFICATION_SAMPLE_RATE),
|
|
"-f",
|
|
"f32le",
|
|
"-",
|
|
],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
details = result.stderr.decode("utf-8", errors="replace").strip()
|
|
raise RuntimeError(f"Unable to decode audio for verification: {path}\n{details}")
|
|
return np.frombuffer(result.stdout, dtype="<f4").astype(np.float64)
|
|
|
|
|
|
def _sound_mix_correlation(
|
|
*,
|
|
raw_video_path: Path,
|
|
mixed_video_path: Path,
|
|
stem_path: Path,
|
|
cues: list[SoundCue],
|
|
duration_ms: int,
|
|
ffmpeg_path: str,
|
|
) -> float:
|
|
"""Correlate the added final-audio component with the rendered SFX stem."""
|
|
try:
|
|
import numpy as np
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"Video sound verification requires numpy. Install it with: "
|
|
"python3 -m pip install numpy"
|
|
) from exc
|
|
|
|
target_samples = int(round(duration_ms * _VERIFICATION_SAMPLE_RATE / 1000))
|
|
decoded = [
|
|
_decode_audio_f32(path, ffmpeg_path)
|
|
for path in (raw_video_path, mixed_video_path, stem_path)
|
|
]
|
|
arrays: list[Any] = []
|
|
for samples in decoded:
|
|
if len(samples) < target_samples:
|
|
samples = np.pad(samples, (0, target_samples - len(samples)))
|
|
arrays.append(samples[:target_samples])
|
|
raw_audio, mixed_audio, stem_audio = arrays
|
|
|
|
cue_mask = np.zeros(target_samples, dtype=bool)
|
|
for cue in cues:
|
|
start = int(round(cue.video_time_ms * _VERIFICATION_SAMPLE_RATE / 1000))
|
|
end = int(
|
|
round(
|
|
(cue.video_time_ms + cue.source_duration_ms)
|
|
* _VERIFICATION_SAMPLE_RATE
|
|
/ 1000
|
|
)
|
|
)
|
|
cue_mask[max(0, start):min(target_samples, end)] = True
|
|
cue_indices = np.flatnonzero(cue_mask)
|
|
if len(cue_indices) < _MIN_VERIFICATION_WINDOW_SAMPLES:
|
|
raise RuntimeError("SFX cue windows are too short for mix verification")
|
|
|
|
background = ~cue_mask
|
|
raw_background = raw_audio[background]
|
|
mixed_background = mixed_audio[background]
|
|
denominator = float(np.dot(raw_background, raw_background))
|
|
narration_scale = (
|
|
float(np.dot(mixed_background, raw_background)) / denominator
|
|
if denominator > 1e-12
|
|
else 1.0
|
|
)
|
|
added_audio = mixed_audio - narration_scale * raw_audio
|
|
|
|
best_correlation = -1.0
|
|
maximum_lag = max(1, _VERIFICATION_SAMPLE_RATE // 200)
|
|
for lag in range(-maximum_lag, maximum_lag + 1):
|
|
shifted_indices = cue_indices + lag
|
|
valid = (shifted_indices >= 0) & (shifted_indices < target_samples)
|
|
if not valid.any():
|
|
continue
|
|
observed = added_audio[shifted_indices[valid]]
|
|
expected = stem_audio[cue_indices[valid]]
|
|
observed = observed - observed.mean()
|
|
expected = expected - expected.mean()
|
|
norm = float(np.linalg.norm(observed) * np.linalg.norm(expected))
|
|
if norm <= 1e-12:
|
|
continue
|
|
correlation = float(np.dot(observed, expected) / norm)
|
|
best_correlation = max(best_correlation, correlation)
|
|
if best_correlation < _MIN_SOUND_MIX_CORRELATION:
|
|
raise RuntimeError(
|
|
"Mixed-video audio does not reliably contain the rendered SFX stem: "
|
|
f"correlation={best_correlation:.3f}, "
|
|
f"required>={_MIN_SOUND_MIX_CORRELATION:.2f}"
|
|
)
|
|
return best_correlation
|
|
|
|
|
|
def _trace_slides(trace: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
|
|
raw_slides = trace.get("slides")
|
|
if not isinstance(raw_slides, list) or not raw_slides:
|
|
raise ValueError("Conversion trace must contain a non-empty slides array")
|
|
slide_count = trace.get("slide_count")
|
|
if slide_count != len(raw_slides):
|
|
raise ValueError(
|
|
"Conversion trace slide_count does not match its slides array"
|
|
)
|
|
|
|
slides: list[dict[str, Any]] = []
|
|
slide_names: list[str] = []
|
|
for expected_number, raw_slide in enumerate(raw_slides, 1):
|
|
if not isinstance(raw_slide, dict):
|
|
raise ValueError(
|
|
f"Conversion trace slide {expected_number} must be an object"
|
|
)
|
|
if raw_slide.get("slide_num") != expected_number:
|
|
raise ValueError(
|
|
"Conversion trace slides must be numbered consecutively from 1"
|
|
)
|
|
raw_svg = raw_slide.get("svg")
|
|
if not isinstance(raw_svg, str) or not raw_svg.strip():
|
|
raise ValueError(
|
|
f"Conversion trace slide {expected_number} has no SVG path"
|
|
)
|
|
slide_name = Path(raw_svg).stem
|
|
if not slide_name:
|
|
raise ValueError(
|
|
f"Conversion trace slide {expected_number} has an invalid SVG path"
|
|
)
|
|
slides.append(raw_slide)
|
|
slide_names.append(slide_name)
|
|
if len(set(slide_names)) != len(slide_names):
|
|
raise ValueError("Conversion trace slide names must be unique")
|
|
return slides, slide_names
|
|
|
|
|
|
def _shape_group_ids(trace_slide: dict[str, Any]) -> dict[int, str]:
|
|
events = trace_slide.get("events")
|
|
if not isinstance(events, list):
|
|
return {}
|
|
mapping: dict[int, str] = {}
|
|
for event in events:
|
|
if not isinstance(event, dict):
|
|
continue
|
|
shape_id = event.get("shape_id")
|
|
group_id = event.get("id")
|
|
if (
|
|
isinstance(shape_id, int)
|
|
and not isinstance(shape_id, bool)
|
|
and isinstance(group_id, str)
|
|
and group_id
|
|
):
|
|
mapping[shape_id] = group_id
|
|
return mapping
|
|
|
|
|
|
def _package_part_map(package: zipfile.ZipFile) -> dict[str, str]:
|
|
mapping: dict[str, str] = {}
|
|
for member in package.namelist():
|
|
key = canonical_opc_part_path(member)
|
|
if key is None:
|
|
continue
|
|
prior = mapping.get(key)
|
|
if prior is not None and prior != member:
|
|
raise ValueError(
|
|
"PPTX contains OPC-equivalent duplicate package parts: "
|
|
f"{prior!r}, {member!r}"
|
|
)
|
|
mapping[key] = member
|
|
return mapping
|
|
|
|
|
|
def _relationships_part(slide_part: str) -> str:
|
|
slide_path = Path(slide_part)
|
|
return (
|
|
slide_path.parent / "_rels" / f"{slide_path.name}.rels"
|
|
).as_posix()
|
|
|
|
|
|
def _slide_relationships(
|
|
package: zipfile.ZipFile,
|
|
rels_part: str,
|
|
) -> dict[str, ET.Element]:
|
|
try:
|
|
root = ET.fromstring(package.read(rels_part))
|
|
except KeyError as exc:
|
|
raise ValueError(f"PPTX is missing slide relationships: {rels_part}") from exc
|
|
except ET.ParseError as exc:
|
|
raise ValueError(f"Invalid slide relationships XML: {rels_part}") from exc
|
|
relationships: dict[str, ET.Element] = {}
|
|
for relationship in root.findall(_RELATIONSHIP_TAG):
|
|
relationship_id = relationship.get("Id")
|
|
if not relationship_id:
|
|
continue
|
|
if relationship_id in relationships:
|
|
raise ValueError(
|
|
f"Duplicate relationship id {relationship_id!r} in {rels_part}"
|
|
)
|
|
relationships[relationship_id] = relationship
|
|
return relationships
|
|
|
|
|
|
def _extract_sound(
|
|
*,
|
|
package: zipfile.ZipFile,
|
|
package_parts: dict[str, str],
|
|
relationships: dict[str, ET.Element],
|
|
rels_part: str,
|
|
relationship_id: str,
|
|
temp_dir: Path,
|
|
ffprobe_path: str,
|
|
extracted: dict[str, tuple[str, str, Path, int]],
|
|
) -> tuple[str, str, Path, int]:
|
|
relationship = relationships.get(relationship_id)
|
|
if relationship is None:
|
|
raise ValueError(
|
|
f"PPTX sound relationship is missing: {rels_part}#{relationship_id}"
|
|
)
|
|
if relationship.get("Type") != AUDIO_REL_TYPE:
|
|
raise ValueError(
|
|
f"PPTX sound relationship is not audio: {rels_part}#{relationship_id}"
|
|
)
|
|
if relationship.get("TargetMode", "Internal") == "External":
|
|
raise ValueError(
|
|
f"PPTX sound relationship must be internal: {rels_part}#{relationship_id}"
|
|
)
|
|
target = relationship.get("Target") or ""
|
|
target_key = resolve_internal_opc_target(rels_part, target)
|
|
if target_key is None:
|
|
raise ValueError(
|
|
f"PPTX sound relationship target is invalid: {rels_part}#{relationship_id}"
|
|
)
|
|
package_part = package_parts.get(target_key)
|
|
if package_part is None:
|
|
raise ValueError(
|
|
f"PPTX sound relationship target is missing: {target!r}"
|
|
)
|
|
cached = extracted.get(package_part)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
payload = package.read(package_part)
|
|
media_sha256 = hashlib.sha256(payload).hexdigest()
|
|
extension = Path(package_part).suffix.lower()
|
|
if extension not in {".m4a", ".mp3", ".wav"}:
|
|
raise ValueError(
|
|
f"PPTX animation sound uses an unsupported media type: {package_part}"
|
|
)
|
|
media_path = temp_dir / f"{media_sha256}{extension}"
|
|
media_path.write_bytes(payload)
|
|
source_duration_ms = _duration_ms(
|
|
_probe_media(media_path, ffprobe_path),
|
|
f"PPTX sound {package_part}",
|
|
)
|
|
resolved = (package_part, media_sha256, media_path, source_duration_ms)
|
|
extracted[package_part] = resolved
|
|
return resolved
|
|
|
|
|
|
def _require_equal(actual: object, expected: object, field: str) -> None:
|
|
if actual != expected:
|
|
raise ValueError(
|
|
f"Final narrated trace does not match the PPTX at {field}: "
|
|
f"trace={actual!r}, pptx={expected!r}"
|
|
)
|
|
|
|
|
|
def _resolve_sound_cues(
|
|
*,
|
|
pptx_path: Path,
|
|
trace_slides: list[dict[str, Any]],
|
|
calibration: VideoTimelineCalibration,
|
|
transition_gain_db: float,
|
|
animation_gain_db: float,
|
|
video_duration_ms: int,
|
|
temp_dir: Path,
|
|
ffprobe_path: str,
|
|
) -> list[SoundCue]:
|
|
cues: list[SoundCue] = []
|
|
extracted: dict[str, tuple[str, str, Path, int]] = {}
|
|
with zipfile.ZipFile(pptx_path) as package:
|
|
slide_members = presentation_slide_members(package)
|
|
if len(slide_members) != len(trace_slides):
|
|
raise ValueError(
|
|
f"Final narrated PPTX has {len(slide_members)} slides, but "
|
|
f"the trace has {len(trace_slides)}"
|
|
)
|
|
package_parts = _package_part_map(package)
|
|
|
|
for slide_num, (slide_part, trace_slide, calibrated) in enumerate(
|
|
zip(slide_members, trace_slides, calibration.slides),
|
|
1,
|
|
):
|
|
slide_xml = package.read(slide_part)
|
|
transition = read_slide_transition_xml(slide_xml)
|
|
animation = read_slide_animation_sequence(
|
|
slide_xml,
|
|
require_supported_effects=True,
|
|
)
|
|
trace_motion = trace_slide.get("motion")
|
|
trace_animation = trace_slide.get("animation")
|
|
if not isinstance(trace_motion, dict) or not isinstance(
|
|
trace_animation, dict
|
|
):
|
|
raise ValueError(
|
|
f"Conversion trace slide {slide_num} lacks resolved motion"
|
|
)
|
|
|
|
_require_equal(
|
|
trace_motion.get("duration_ms"),
|
|
transition.duration_ms,
|
|
f"slide {slide_num} transition duration",
|
|
)
|
|
_require_equal(
|
|
trace_motion.get("advance_after_ms"),
|
|
transition.advance_after_ms,
|
|
f"slide {slide_num} recorded advance",
|
|
)
|
|
_require_equal(
|
|
trace_motion.get("sound_relationship_id"),
|
|
transition.sound_relationship_id,
|
|
f"slide {slide_num} transition sound relationship",
|
|
)
|
|
_require_equal(
|
|
trace_motion.get("sound_name"),
|
|
transition.sound_name,
|
|
f"slide {slide_num} transition sound name",
|
|
)
|
|
_require_equal(
|
|
trace_motion.get("fallback_sound_relationship_id"),
|
|
transition.fallback_sound_relationship_id,
|
|
f"slide {slide_num} fallback transition sound relationship",
|
|
)
|
|
_require_equal(
|
|
trace_motion.get("fallback_sound_name"),
|
|
transition.fallback_sound_name,
|
|
f"slide {slide_num} fallback transition sound name",
|
|
)
|
|
if transition.fallback_sound_relationship_id not in {
|
|
None,
|
|
transition.sound_relationship_id,
|
|
}:
|
|
raise ValueError(
|
|
f"Slide {slide_num} transition primary and fallback "
|
|
"branches reference different sounds"
|
|
)
|
|
|
|
raw_rows = trace_animation.get("rows")
|
|
if not isinstance(raw_rows, list):
|
|
raise ValueError(
|
|
f"Conversion trace slide {slide_num} animation rows are invalid"
|
|
)
|
|
if len(raw_rows) != len(animation.rows):
|
|
raise ValueError(
|
|
f"Final narrated trace has {len(raw_rows)} animation rows "
|
|
f"on slide {slide_num}; PPTX has {len(animation.rows)}"
|
|
)
|
|
for row_index, (raw_row, actual_row) in enumerate(
|
|
zip(raw_rows, animation.rows),
|
|
1,
|
|
):
|
|
if not isinstance(raw_row, dict):
|
|
raise ValueError(
|
|
f"Conversion trace slide {slide_num} row {row_index} "
|
|
"must be an object"
|
|
)
|
|
for key, expected in (
|
|
("shape_id", actual_row.shape_id),
|
|
("offset_ms", actual_row.offset_ms),
|
|
("trigger", actual_row.trigger),
|
|
("sound_relationship_id", actual_row.sound_relationship_id),
|
|
("sound_name", actual_row.sound_name),
|
|
):
|
|
_require_equal(
|
|
raw_row.get(key),
|
|
expected,
|
|
f"slide {slide_num} animation row {row_index} {key}",
|
|
)
|
|
|
|
sound_relationship_ids = {
|
|
relationship_id
|
|
for relationship_id in (
|
|
transition.sound_relationship_id,
|
|
*(
|
|
row.sound_relationship_id
|
|
for row in animation.rows
|
|
),
|
|
)
|
|
if relationship_id is not None
|
|
}
|
|
relationships: dict[str, ET.Element] = {}
|
|
rels_part = _relationships_part(slide_part)
|
|
if sound_relationship_ids:
|
|
relationships = _slide_relationships(package, rels_part)
|
|
|
|
group_ids = _shape_group_ids(trace_slide)
|
|
if transition.sound_relationship_id is not None:
|
|
package_part, media_sha256, media_path, source_duration_ms = (
|
|
_extract_sound(
|
|
package=package,
|
|
package_parts=package_parts,
|
|
relationships=relationships,
|
|
rels_part=rels_part,
|
|
relationship_id=transition.sound_relationship_id,
|
|
temp_dir=temp_dir,
|
|
ffprobe_path=ffprobe_path,
|
|
extracted=extracted,
|
|
)
|
|
)
|
|
cues.append(
|
|
SoundCue(
|
|
kind="transition",
|
|
slide_num=slide_num,
|
|
slide_name=calibrated.slide_name,
|
|
row_index=None,
|
|
shape_id=None,
|
|
group_id=None,
|
|
relationship_id=transition.sound_relationship_id,
|
|
sound_name=transition.sound_name or Path(package_part).name,
|
|
package_part=package_part,
|
|
media_sha256=media_sha256,
|
|
media_path=media_path,
|
|
source_duration_ms=source_duration_ms,
|
|
powerpoint_time_ms=calibrated.powerpoint_slide_start_ms,
|
|
video_time_ms=calibrated.video_slide_start_ms,
|
|
gain_db=transition_gain_db,
|
|
)
|
|
)
|
|
|
|
for row_index, row in enumerate(animation.rows, 1):
|
|
if row.sound_relationship_id is None:
|
|
continue
|
|
if row.trigger not in {"after-previous", "with-previous"}:
|
|
raise ValueError(
|
|
f"Slide {slide_num} animation row {row_index} uses "
|
|
f"{row.trigger!r}; video sound timing requires a "
|
|
"click-free row"
|
|
)
|
|
package_part, media_sha256, media_path, source_duration_ms = (
|
|
_extract_sound(
|
|
package=package,
|
|
package_parts=package_parts,
|
|
relationships=relationships,
|
|
rels_part=rels_part,
|
|
relationship_id=row.sound_relationship_id,
|
|
temp_dir=temp_dir,
|
|
ffprobe_path=ffprobe_path,
|
|
extracted=extracted,
|
|
)
|
|
)
|
|
powerpoint_time_ms = (
|
|
calibrated.powerpoint_slide_start_ms
|
|
+ calibrated.transition_ms
|
|
+ row.offset_ms
|
|
)
|
|
video_time_ms = (
|
|
calibrated.video_slide_start_ms
|
|
+ calibrated.transition_ms
|
|
+ row.offset_ms
|
|
)
|
|
cues.append(
|
|
SoundCue(
|
|
kind="animation",
|
|
slide_num=slide_num,
|
|
slide_name=calibrated.slide_name,
|
|
row_index=row_index,
|
|
shape_id=row.shape_id,
|
|
group_id=group_ids.get(row.shape_id),
|
|
relationship_id=row.sound_relationship_id,
|
|
sound_name=row.sound_name or Path(package_part).name,
|
|
package_part=package_part,
|
|
media_sha256=media_sha256,
|
|
media_path=media_path,
|
|
source_duration_ms=source_duration_ms,
|
|
powerpoint_time_ms=powerpoint_time_ms,
|
|
video_time_ms=video_time_ms,
|
|
gain_db=animation_gain_db,
|
|
)
|
|
)
|
|
|
|
cues.sort(
|
|
key=lambda cue: (
|
|
cue.video_time_ms,
|
|
cue.slide_num,
|
|
0 if cue.kind == "transition" else 1,
|
|
cue.row_index or 0,
|
|
)
|
|
)
|
|
if not cues:
|
|
raise ValueError(
|
|
"Final narrated PPTX and trace contain no transition or "
|
|
"object-animation sound cues; use the PowerPoint video directly"
|
|
)
|
|
for cue in cues:
|
|
if cue.video_time_ms < 0 or cue.video_time_ms >= video_duration_ms:
|
|
raise ValueError(
|
|
f"Sound cue falls outside the video: slide {cue.slide_num}, "
|
|
f"time={cue.video_time_ms}ms, video={video_duration_ms}ms"
|
|
)
|
|
cue_end_ms = cue.video_time_ms + cue.source_duration_ms
|
|
if cue_end_ms > video_duration_ms:
|
|
raise ValueError(
|
|
f"Sound cue would be truncated by the video end: slide "
|
|
f"{cue.slide_num}, cue end={cue_end_ms}ms, "
|
|
f"video={video_duration_ms}ms"
|
|
)
|
|
return cues
|
|
|
|
|
|
def _render_stem(
|
|
*,
|
|
cues: list[SoundCue],
|
|
duration_ms: int,
|
|
output_path: Path,
|
|
ffmpeg_path: str,
|
|
) -> None:
|
|
target_samples = _target_sample_count(duration_ms)
|
|
command = [
|
|
ffmpeg_path,
|
|
"-nostdin",
|
|
"-hide_banner",
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
f"anullsrc=r={_OUTPUT_SAMPLE_RATE}:cl=stereo",
|
|
]
|
|
for cue in cues:
|
|
command.extend(["-i", str(cue.media_path)])
|
|
|
|
filters = [
|
|
(
|
|
f"[0:a:0]aformat=sample_fmts=fltp:sample_rates={_OUTPUT_SAMPLE_RATE}:"
|
|
"channel_layouts=stereo,"
|
|
f"atrim=end_sample={target_samples},asetpts=N/SR/TB[base]"
|
|
)
|
|
]
|
|
labels = ["[base]"]
|
|
for input_index, cue in enumerate(cues, 1):
|
|
label = f"cue{input_index}"
|
|
delay_samples = int(
|
|
round(cue.video_time_ms * _OUTPUT_SAMPLE_RATE / 1000)
|
|
)
|
|
filters.append(
|
|
f"[{input_index}:a:0]aresample={_OUTPUT_SAMPLE_RATE},"
|
|
"aformat=sample_fmts=fltp:"
|
|
f"sample_rates={_OUTPUT_SAMPLE_RATE}:channel_layouts=stereo,"
|
|
f"asetpts=N/SR/TB,volume={cue.gain_db:.3f}dB,"
|
|
f"adelay={delay_samples}S:all=1,"
|
|
f"apad=whole_len={target_samples},"
|
|
f"atrim=end_sample={target_samples}[{label}]"
|
|
)
|
|
labels.append(f"[{label}]")
|
|
filters.append(
|
|
"".join(labels)
|
|
+ f"amix=inputs={len(labels)}:duration=first:"
|
|
"dropout_transition=0:normalize=0,"
|
|
f"atrim=end_sample={target_samples},asetpts=N/SR/TB[stem]"
|
|
)
|
|
command.extend(
|
|
[
|
|
"-filter_complex",
|
|
";".join(filters),
|
|
"-map",
|
|
"[stem]",
|
|
"-c:a",
|
|
"pcm_f32le",
|
|
"-ar",
|
|
str(_OUTPUT_SAMPLE_RATE),
|
|
"-ac",
|
|
"2",
|
|
"-y",
|
|
str(output_path),
|
|
]
|
|
)
|
|
_run_command(command, "SFX stem render")
|
|
|
|
|
|
def _mix_video(
|
|
*,
|
|
video_path: Path,
|
|
stem_path: Path,
|
|
duration_ms: int,
|
|
limiter_dbfs: float,
|
|
output_path: Path,
|
|
ffmpeg_path: str,
|
|
) -> None:
|
|
target_samples = _target_sample_count(duration_ms)
|
|
limiter_ratio = 10 ** (limiter_dbfs / 20)
|
|
filter_graph = (
|
|
f"[0:a:0]aresample={_OUTPUT_SAMPLE_RATE}:first_pts=0,"
|
|
"aformat=sample_fmts=fltp:channel_layouts=stereo,"
|
|
f"asetpts=N/SR/TB,apad=whole_len={target_samples},"
|
|
f"atrim=end_sample={target_samples}[narration];"
|
|
f"[1:a:0]aresample={_OUTPUT_SAMPLE_RATE}:first_pts=0,"
|
|
"aformat=sample_fmts=fltp:channel_layouts=stereo,"
|
|
f"asetpts=N/SR/TB,apad=whole_len={target_samples},"
|
|
f"atrim=end_sample={target_samples}[sfx];"
|
|
"[narration][sfx]amix=inputs=2:duration=first:"
|
|
"dropout_transition=0:normalize=0,"
|
|
f"alimiter=limit={limiter_ratio:.9f}:attack=5:release=50:"
|
|
"level=false:latency=true,"
|
|
f"apad=whole_len={target_samples},"
|
|
f"atrim=end_sample={target_samples},asetpts=N/SR/TB[mixed]"
|
|
)
|
|
_run_command(
|
|
[
|
|
ffmpeg_path,
|
|
"-nostdin",
|
|
"-hide_banner",
|
|
"-v",
|
|
"error",
|
|
"-i",
|
|
str(video_path),
|
|
"-i",
|
|
str(stem_path),
|
|
"-filter_complex",
|
|
filter_graph,
|
|
"-map",
|
|
"0:v:0",
|
|
"-map",
|
|
"[mixed]",
|
|
"-map_metadata",
|
|
"0",
|
|
"-c:v",
|
|
"copy",
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
_OUTPUT_AUDIO_BITRATE,
|
|
"-ar",
|
|
str(_OUTPUT_SAMPLE_RATE),
|
|
"-ac",
|
|
"2",
|
|
"-movflags",
|
|
"+faststart",
|
|
"-y",
|
|
str(output_path),
|
|
],
|
|
"Narration and SFX mix",
|
|
)
|
|
|
|
|
|
def _temporary_output(path: Path, suffix: str) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, raw_path = tempfile.mkstemp(
|
|
prefix=f".{path.stem}.",
|
|
suffix=suffix,
|
|
dir=path.parent,
|
|
)
|
|
os.close(descriptor)
|
|
temporary = Path(raw_path)
|
|
temporary.unlink()
|
|
return temporary
|
|
|
|
|
|
def _publish_outputs(replacements: list[tuple[Path, Path]]) -> None:
|
|
"""Publish verified outputs together, restoring prior files on failure."""
|
|
backups: dict[Path, Path] = {}
|
|
published: list[Path] = []
|
|
try:
|
|
for _temporary, destination in replacements:
|
|
if not destination.exists():
|
|
continue
|
|
backup = _temporary_output(destination, f".backup{destination.suffix}")
|
|
os.replace(destination, backup)
|
|
backups[destination] = backup
|
|
for temporary, destination in replacements:
|
|
os.replace(temporary, destination)
|
|
published.append(destination)
|
|
except OSError as exc:
|
|
rollback_errors: list[str] = []
|
|
for destination in reversed(published):
|
|
backup = backups.pop(destination, None)
|
|
try:
|
|
if backup is None:
|
|
destination.unlink(missing_ok=True)
|
|
else:
|
|
os.replace(backup, destination)
|
|
except OSError as rollback_exc:
|
|
rollback_errors.append(f"{destination}: {rollback_exc}")
|
|
for destination, backup in backups.items():
|
|
try:
|
|
os.replace(backup, destination)
|
|
except OSError as rollback_exc:
|
|
rollback_errors.append(f"{destination}: {rollback_exc}")
|
|
if rollback_errors:
|
|
raise RuntimeError(
|
|
"Output publication failed and rollback was incomplete: "
|
|
+ "; ".join(rollback_errors)
|
|
) from exc
|
|
raise
|
|
else:
|
|
for backup in backups.values():
|
|
backup.unlink(missing_ok=True)
|
|
|
|
|
|
def _cue_report(cue: SoundCue) -> dict[str, Any]:
|
|
return {
|
|
"kind": cue.kind,
|
|
"slide_num": cue.slide_num,
|
|
"slide_name": cue.slide_name,
|
|
"row_index": cue.row_index,
|
|
"shape_id": cue.shape_id,
|
|
"group_id": cue.group_id,
|
|
"relationship_id": cue.relationship_id,
|
|
"sound_name": cue.sound_name,
|
|
"package_part": cue.package_part,
|
|
"media_sha256": cue.media_sha256,
|
|
"source_duration_ms": cue.source_duration_ms,
|
|
"powerpoint_time_ms": cue.powerpoint_time_ms,
|
|
"video_time_ms": cue.video_time_ms,
|
|
"gain_db": cue.gain_db,
|
|
}
|
|
|
|
|
|
def mix_video_sounds(
|
|
project_path: Path,
|
|
*,
|
|
pptx_path: Path,
|
|
trace_path: Path,
|
|
video_path: Path,
|
|
audio_dir: Path,
|
|
subtitle_dir: Path | None,
|
|
output_path: Path,
|
|
stem_path: Path,
|
|
report_path: Path,
|
|
transition_gain_db: float = _DEFAULT_TRANSITION_GAIN_DB,
|
|
animation_gain_db: float = _DEFAULT_ANIMATION_GAIN_DB,
|
|
limiter_dbfs: float = _DEFAULT_LIMITER_DBFS,
|
|
force: bool = False,
|
|
) -> SoundMixResult:
|
|
"""Render and publish one calibrated animation-sound video mix."""
|
|
project_path = project_path.resolve()
|
|
pptx_path = pptx_path.resolve()
|
|
trace_path = trace_path.resolve()
|
|
video_path = video_path.resolve()
|
|
audio_dir = audio_dir.resolve()
|
|
subtitle_dir = subtitle_dir.resolve() if subtitle_dir is not None else None
|
|
output_path = output_path.resolve()
|
|
stem_path = stem_path.resolve()
|
|
report_path = report_path.resolve()
|
|
if not project_path.is_dir():
|
|
raise FileNotFoundError(f"Project path does not exist: {project_path}")
|
|
for label, path in (
|
|
("Final narrated PPTX", pptx_path),
|
|
("Final narrated conversion trace", trace_path),
|
|
("Raw PowerPoint video", video_path),
|
|
):
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"{label} does not exist: {path}")
|
|
if pptx_path.suffix.lower() != ".pptx":
|
|
raise ValueError(f"Final narrated PPTX must use .pptx: {pptx_path}")
|
|
if video_path.suffix.lower() != ".mp4":
|
|
raise ValueError(f"Raw PowerPoint video must use .mp4: {video_path}")
|
|
if output_path.suffix.lower() != ".mp4":
|
|
raise ValueError(f"Mixed video output must use .mp4: {output_path}")
|
|
if stem_path.suffix.lower() != ".wav":
|
|
raise ValueError(f"SFX stem output must use .wav: {stem_path}")
|
|
if report_path.suffix.lower() != ".json":
|
|
raise ValueError(f"Sound mix report must use .json: {report_path}")
|
|
|
|
outputs = [output_path, stem_path, report_path]
|
|
if len(set(outputs)) != len(outputs):
|
|
raise ValueError("Mixed video, SFX stem, and report paths must differ")
|
|
invalid_outputs = [
|
|
path for path in outputs if path.exists() and not path.is_file()
|
|
]
|
|
if invalid_outputs:
|
|
raise ValueError(
|
|
"Sound mix outputs must be regular files or unused paths: "
|
|
+ ", ".join(str(path) for path in invalid_outputs)
|
|
)
|
|
existing = [path for path in outputs if path.exists()]
|
|
if existing and not force:
|
|
raise FileExistsError(
|
|
"Sound mix output already exists; use --force to replace it: "
|
|
+ ", ".join(str(path) for path in existing)
|
|
)
|
|
for label, value in (
|
|
("transition gain", transition_gain_db),
|
|
("animation gain", animation_gain_db),
|
|
):
|
|
if not math.isfinite(value) or value < -60 or value > 0:
|
|
raise ValueError(f"{label} must be finite and between -60 and 0 dB")
|
|
if not math.isfinite(limiter_dbfs) or not -12 <= limiter_dbfs < 0:
|
|
raise ValueError("limiter ceiling must be finite, at least -12, and below 0 dBFS")
|
|
|
|
ffmpeg_path = _require_tool("ffmpeg")
|
|
ffprobe_path = _require_tool("ffprobe")
|
|
trace = _read_json_object(trace_path, "Conversion trace")
|
|
trace_slides, slide_names = _trace_slides(trace)
|
|
raw_probe = _probe_media(video_path, ffprobe_path)
|
|
raw_format_duration_ms = _duration_ms(raw_probe, "raw PowerPoint video")
|
|
raw_video_streams = _streams(raw_probe, "video")
|
|
raw_audio_streams = _streams(raw_probe, "audio")
|
|
if len(raw_video_streams) != 1 or len(raw_audio_streams) != 1:
|
|
raise ValueError(
|
|
"Raw PowerPoint video must contain one video stream and one "
|
|
"narration audio stream"
|
|
)
|
|
raw_duration_ms = _stream_duration_ms(
|
|
raw_video_streams[0],
|
|
fallback_ms=raw_format_duration_ms,
|
|
)
|
|
raw_video_start_ms = _stream_start_ms(raw_video_streams[0])
|
|
raw_audio_start_ms = _stream_start_ms(raw_audio_streams[0])
|
|
start_delta_ms = abs(raw_video_start_ms - raw_audio_start_ms)
|
|
frame_duration_ms = _frame_duration_ms(raw_video_streams[0])
|
|
if (
|
|
abs(raw_video_start_ms) > _START_TIME_TOLERANCE_MS
|
|
or abs(raw_audio_start_ms) > _START_TIME_TOLERANCE_MS
|
|
or start_delta_ms > _START_TIME_TOLERANCE_MS
|
|
):
|
|
raise ValueError(
|
|
"Raw PowerPoint video audio and video streams must both start at "
|
|
f"zero: video={raw_video_start_ms}ms, audio={raw_audio_start_ms}ms"
|
|
)
|
|
raw_video_timing = _stream_timing_signature(raw_video_streams[0])
|
|
|
|
calibration = calibrate_video_timeline(
|
|
slide_names=slide_names,
|
|
pptx_path=pptx_path,
|
|
audio_dir=audio_dir,
|
|
video_path=video_path,
|
|
subtitle_dir=subtitle_dir,
|
|
)
|
|
protected_inputs = {
|
|
pptx_path,
|
|
trace_path,
|
|
video_path,
|
|
*(slide.audio_path.resolve() for slide in calibration.slides),
|
|
}
|
|
aliases = protected_inputs.intersection(outputs)
|
|
if aliases:
|
|
raise ValueError(
|
|
"Sound mix outputs must not overwrite inputs: "
|
|
+ ", ".join(str(path) for path in sorted(aliases))
|
|
)
|
|
temporary_stem = _temporary_output(stem_path, ".wav")
|
|
temporary_video = _temporary_output(output_path, ".mp4")
|
|
temporary_report = _temporary_output(report_path, ".json")
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="ppt-master-video-sounds-") as raw_dir:
|
|
cues = _resolve_sound_cues(
|
|
pptx_path=pptx_path,
|
|
trace_slides=trace_slides,
|
|
calibration=calibration,
|
|
transition_gain_db=transition_gain_db,
|
|
animation_gain_db=animation_gain_db,
|
|
video_duration_ms=raw_duration_ms,
|
|
temp_dir=Path(raw_dir),
|
|
ffprobe_path=ffprobe_path,
|
|
)
|
|
_render_stem(
|
|
cues=cues,
|
|
duration_ms=raw_duration_ms,
|
|
output_path=temporary_stem,
|
|
ffmpeg_path=ffmpeg_path,
|
|
)
|
|
stem_peak_db = _maximum_volume_db(temporary_stem, ffmpeg_path)
|
|
if stem_peak_db is None or stem_peak_db < -90:
|
|
raise RuntimeError("Rendered SFX stem is silent")
|
|
_mix_video(
|
|
video_path=video_path,
|
|
stem_path=temporary_stem,
|
|
duration_ms=raw_duration_ms,
|
|
limiter_dbfs=limiter_dbfs,
|
|
output_path=temporary_video,
|
|
ffmpeg_path=ffmpeg_path,
|
|
)
|
|
|
|
stem_probe = _probe_media(temporary_stem, ffprobe_path)
|
|
mixed_probe = _probe_media(temporary_video, ffprobe_path)
|
|
stem_duration_ms = _duration_ms(stem_probe, "SFX stem")
|
|
mixed_duration_ms = _duration_ms(mixed_probe, "mixed video")
|
|
mixed_video_streams = _streams(mixed_probe, "video")
|
|
mixed_audio_streams = _streams(mixed_probe, "audio")
|
|
if len(mixed_video_streams) != 1 or len(mixed_audio_streams) != 1:
|
|
raise RuntimeError(
|
|
"Mixed video must contain one copied video stream and one audio stream"
|
|
)
|
|
mixed_video_duration_ms = _stream_duration_ms(
|
|
mixed_video_streams[0],
|
|
fallback_ms=mixed_duration_ms,
|
|
)
|
|
mixed_audio_duration_ms = _stream_duration_ms(
|
|
mixed_audio_streams[0],
|
|
fallback_ms=mixed_duration_ms,
|
|
)
|
|
target_samples = _target_sample_count(raw_duration_ms)
|
|
stem_samples = _audio_sample_count(_streams(stem_probe, "audio")[0])
|
|
if stem_samples != target_samples:
|
|
raise RuntimeError(
|
|
"SFX stem sample count does not match the raw video target: "
|
|
f"stem={stem_samples}, target={target_samples}"
|
|
)
|
|
mixed_video_timing = _stream_timing_signature(mixed_video_streams[0])
|
|
if mixed_video_timing != raw_video_timing:
|
|
raise RuntimeError(
|
|
"Mixed output changed the PowerPoint video packet timeline: "
|
|
f"raw={raw_video_timing!r}, mixed={mixed_video_timing!r}"
|
|
)
|
|
duration_tolerance_ms = max(
|
|
frame_duration_ms,
|
|
1024 * 1000 / _OUTPUT_SAMPLE_RATE,
|
|
) + 1
|
|
if abs(mixed_audio_duration_ms - raw_duration_ms) > duration_tolerance_ms:
|
|
raise RuntimeError(
|
|
"Mixed audio duration does not match the raw video: "
|
|
f"audio={mixed_audio_duration_ms}ms, raw={raw_duration_ms}ms"
|
|
)
|
|
|
|
raw_video_hash = _stream_hash(video_path, "0:v:0", ffmpeg_path)
|
|
mixed_video_hash = _stream_hash(temporary_video, "0:v:0", ffmpeg_path)
|
|
if raw_video_hash != mixed_video_hash:
|
|
raise RuntimeError("Mixed output changed the PowerPoint video stream")
|
|
raw_audio_hash = _stream_hash(video_path, "0:a:0", ffmpeg_path)
|
|
mixed_audio_hash = _stream_hash(temporary_video, "0:a:0", ffmpeg_path)
|
|
if raw_audio_hash == mixed_audio_hash:
|
|
raise RuntimeError("Mixed output audio is unchanged from the raw video")
|
|
mixed_peak_db = _maximum_volume_db(temporary_video, ffmpeg_path)
|
|
true_peak_dbfs = _true_peak_dbfs(temporary_video, ffmpeg_path)
|
|
if true_peak_dbfs is None or true_peak_dbfs >= 0:
|
|
raise RuntimeError(
|
|
"Mixed-video audio is silent or clips after encoding: "
|
|
f"true_peak={true_peak_dbfs!r} dBFS"
|
|
)
|
|
verification_correlation = _sound_mix_correlation(
|
|
raw_video_path=video_path,
|
|
mixed_video_path=temporary_video,
|
|
stem_path=temporary_stem,
|
|
cues=cues,
|
|
duration_ms=raw_duration_ms,
|
|
ffmpeg_path=ffmpeg_path,
|
|
)
|
|
|
|
trace_output = trace.get("output")
|
|
trace_output_matches_pptx = False
|
|
if isinstance(trace_output, str) and trace_output.strip():
|
|
trace_output_matches_pptx = Path(trace_output).expanduser().resolve() == pptx_path
|
|
|
|
report = {
|
|
"schema": "ppt-master.video-sound-mix-report.v1",
|
|
"version": 1,
|
|
"status": "passed",
|
|
"inputs": {
|
|
"pptx": {
|
|
"path": str(pptx_path),
|
|
"sha256": _sha256_path(pptx_path),
|
|
},
|
|
"conversion_trace": {
|
|
"path": str(trace_path),
|
|
"sha256": _sha256_path(trace_path),
|
|
"declared_output_matches_pptx": trace_output_matches_pptx,
|
|
},
|
|
"raw_video": {
|
|
"path": str(video_path),
|
|
"sha256": _sha256_path(video_path),
|
|
"duration_ms": raw_duration_ms,
|
|
"video_start_ms": _stream_start_ms(raw_video_streams[0]),
|
|
"audio_start_ms": _stream_start_ms(raw_audio_streams[0]),
|
|
"video_stream_sha256": raw_video_hash,
|
|
"video_timing": raw_video_timing,
|
|
"audio_stream_sha256": raw_audio_hash,
|
|
"video_codec": raw_video_streams[0].get("codec_name"),
|
|
"audio_codec": raw_audio_streams[0].get("codec_name"),
|
|
},
|
|
},
|
|
"settings": {
|
|
"transition_gain_db": transition_gain_db,
|
|
"animation_gain_db": animation_gain_db,
|
|
"limiter_dbfs": limiter_dbfs,
|
|
"mix_normalize": False,
|
|
"ducking": False,
|
|
"background_music": False,
|
|
"sample_rate": _OUTPUT_SAMPLE_RATE,
|
|
"audio_bitrate": _OUTPUT_AUDIO_BITRATE,
|
|
},
|
|
"timeline": {
|
|
"method": "page narration correlation against raw video audio",
|
|
"powerpoint_timeline_ms": calibration.powerpoint_timeline_ms,
|
|
"minimum_correlation": min(
|
|
slide.correlation for slide in calibration.slides
|
|
),
|
|
"slides": [
|
|
{
|
|
"slide_num": index,
|
|
"slide_name": slide.slide_name,
|
|
"powerpoint_slide_start_ms": slide.powerpoint_slide_start_ms,
|
|
"powerpoint_narration_start_ms": (
|
|
slide.powerpoint_narration_start_ms
|
|
),
|
|
"video_slide_start_ms": slide.video_slide_start_ms,
|
|
"video_narration_start_ms": slide.video_narration_start_ms,
|
|
"adjustment_ms": slide.adjustment_ms,
|
|
"correlation": slide.correlation,
|
|
}
|
|
for index, slide in enumerate(calibration.slides, 1)
|
|
],
|
|
},
|
|
"cues": [_cue_report(cue) for cue in cues],
|
|
"outputs": {
|
|
"sfx_stem": {
|
|
"path": str(stem_path),
|
|
"sha256": _sha256_path(temporary_stem),
|
|
"duration_ms": stem_duration_ms,
|
|
"sample_count": stem_samples,
|
|
"maximum_volume_db": stem_peak_db,
|
|
},
|
|
"mixed_video": {
|
|
"path": str(output_path),
|
|
"sha256": _sha256_path(temporary_video),
|
|
"duration_ms": mixed_duration_ms,
|
|
"video_duration_ms": mixed_video_duration_ms,
|
|
"audio_duration_ms": mixed_audio_duration_ms,
|
|
"video_stream_sha256": mixed_video_hash,
|
|
"video_timing": mixed_video_timing,
|
|
"audio_stream_sha256": mixed_audio_hash,
|
|
"video_codec": mixed_video_streams[0].get("codec_name"),
|
|
"audio_codec": mixed_audio_streams[0].get("codec_name"),
|
|
"maximum_volume_db": mixed_peak_db,
|
|
"true_peak_dbfs": true_peak_dbfs,
|
|
},
|
|
},
|
|
"validation": {
|
|
"trace_matches_pptx_motion": True,
|
|
"cue_count": len(cues),
|
|
"stem_non_silent": True,
|
|
"mixed_audio_present": True,
|
|
"video_stream_preserved": True,
|
|
"duration_delta_ms": mixed_video_duration_ms - raw_duration_ms,
|
|
"video_timeline_preserved": True,
|
|
"limiter_applied": True,
|
|
"sound_mix_correlation": verification_correlation,
|
|
"minimum_sound_mix_correlation": _MIN_SOUND_MIX_CORRELATION,
|
|
},
|
|
}
|
|
temporary_report.write_text(
|
|
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
_publish_outputs(
|
|
[
|
|
(temporary_stem, stem_path),
|
|
(temporary_video, output_path),
|
|
(temporary_report, report_path),
|
|
]
|
|
)
|
|
finally:
|
|
for temporary in (temporary_stem, temporary_video, temporary_report):
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
return SoundMixResult(
|
|
output_path=output_path,
|
|
stem_path=stem_path,
|
|
report_path=report_path,
|
|
cue_count=len(cues),
|
|
verification_correlation=verification_correlation,
|
|
true_peak_dbfs=true_peak_dbfs,
|
|
)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Mix PPTX transition/object-animation sounds into a "
|
|
"PowerPoint-exported narrated video."
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument("project_path", help="PPT Master project directory")
|
|
parser.add_argument(
|
|
"--pptx",
|
|
required=True,
|
|
help="Final narrated PPTX; relative paths resolve under the project",
|
|
)
|
|
parser.add_argument(
|
|
"--trace",
|
|
required=True,
|
|
help="Final narrated conversion trace; relative paths resolve under the project",
|
|
)
|
|
parser.add_argument(
|
|
"--video",
|
|
required=True,
|
|
help="Raw PowerPoint-exported video; relative paths resolve under the project",
|
|
)
|
|
parser.add_argument(
|
|
"--audio-dir",
|
|
default=None,
|
|
help="Page narration audio directory; default: <project>/audio",
|
|
)
|
|
parser.add_argument(
|
|
"--subtitle-dir",
|
|
default=None,
|
|
help=(
|
|
"Optional page-local SRT directory used to refine calibration; "
|
|
"default: <project>/audio"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
default=None,
|
|
help="Final mixed MP4; default: beside raw video as *_mixed.mp4",
|
|
)
|
|
parser.add_argument(
|
|
"--stem-output",
|
|
default=None,
|
|
help="Independent SFX WAV; default: beside raw video as *_sfx.wav",
|
|
)
|
|
parser.add_argument(
|
|
"--report-output",
|
|
default=None,
|
|
help="Sound mix JSON receipt; default: beside raw video as *_sound_mix.json",
|
|
)
|
|
parser.add_argument(
|
|
"--transition-gain-db",
|
|
type=float,
|
|
default=_DEFAULT_TRANSITION_GAIN_DB,
|
|
help="Transition cue gain in dB (default: -9.1, about 35%%)",
|
|
)
|
|
parser.add_argument(
|
|
"--animation-gain-db",
|
|
type=float,
|
|
default=_DEFAULT_ANIMATION_GAIN_DB,
|
|
help="Object-animation cue gain in dB (default: -12, about 25%%)",
|
|
)
|
|
parser.add_argument(
|
|
"--limiter-dbfs",
|
|
type=float,
|
|
default=_DEFAULT_LIMITER_DBFS,
|
|
help="Final peak limiter ceiling in dBFS (default: -1)",
|
|
)
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Replace existing mixed video, stem, and report outputs",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
project_path = Path(args.project_path).expanduser().resolve()
|
|
if not project_path.is_dir():
|
|
parser.error(f"Project path does not exist: {project_path}")
|
|
|
|
pptx_path = _project_input_path(project_path, args.pptx)
|
|
trace_path = _project_input_path(project_path, args.trace)
|
|
video_path = _project_input_path(project_path, args.video)
|
|
audio_dir = _project_output_path(
|
|
project_path,
|
|
args.audio_dir,
|
|
Path("audio"),
|
|
)
|
|
subtitle_dir = _project_output_path(
|
|
project_path,
|
|
args.subtitle_dir,
|
|
Path("audio"),
|
|
)
|
|
default_output = video_path.with_name(f"{video_path.stem}_mixed.mp4")
|
|
default_stem = video_path.with_name(f"{video_path.stem}_sfx.wav")
|
|
default_report = video_path.with_name(f"{video_path.stem}_sound_mix.json")
|
|
output_path = _project_output_path(project_path, args.output, default_output)
|
|
stem_path = _project_output_path(
|
|
project_path,
|
|
args.stem_output,
|
|
default_stem,
|
|
)
|
|
report_path = _project_output_path(
|
|
project_path,
|
|
args.report_output,
|
|
default_report,
|
|
)
|
|
|
|
try:
|
|
result = mix_video_sounds(
|
|
project_path,
|
|
pptx_path=pptx_path,
|
|
trace_path=trace_path,
|
|
video_path=video_path,
|
|
audio_dir=audio_dir,
|
|
subtitle_dir=subtitle_dir,
|
|
output_path=output_path,
|
|
stem_path=stem_path,
|
|
report_path=report_path,
|
|
transition_gain_db=args.transition_gain_db,
|
|
animation_gain_db=args.animation_gain_db,
|
|
limiter_dbfs=args.limiter_dbfs,
|
|
force=args.force,
|
|
)
|
|
except (FileNotFoundError, FileExistsError, OSError, RuntimeError, ValueError) as exc:
|
|
print(f"Video sound mix failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(
|
|
f"[POSTFLIGHT] status=passed cues={result.cue_count} "
|
|
"video_stream=preserved audio_mix=verified "
|
|
f"correlation={result.verification_correlation:.3f} "
|
|
f"true_peak={result.true_peak_dbfs:.1f}dBFS"
|
|
)
|
|
print(f"[Done] Mixed video: {result.output_path}")
|
|
print(f"[Done] SFX stem: {result.stem_path}")
|
|
print(f"[REPORT] Video sound mix: {result.report_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|