Skip to main content

ranim/cmd/render/
audio.rs

1//! Audio muxing for rendered outputs.
2//!
3//! The scene's audio plane is mixed in one offline pass, written to a temporary
4//! WAV, and merged into the finished video by a second `ffmpeg` pass (video
5//! stream copied, audio encoded per container format).
6
7use std::path::Path;
8
9use anyhow::{Context, anyhow, bail};
10use ranim_core::audio::{MASTER_CHANNELS, MASTER_SAMPLE_RATE, write_wav};
11use tracing::info;
12
13use crate::OutputFormat;
14
15/// The audio codec for a container format; `None` where audio cannot be
16/// stored (GIF).
17pub(crate) fn audio_codec(format: OutputFormat) -> Option<&'static str> {
18    match format {
19        OutputFormat::Mp4 | OutputFormat::Mov => Some("aac"),
20        OutputFormat::Webm => Some("libopus"),
21        OutputFormat::Gif => None,
22    }
23}
24
25/// Mix and mux `pcm` into the finished video at `video_path`.
26///
27/// The WAV is written next to the video, merged with the video stream copied
28/// (no re-encode), and removed afterwards; the muxed result replaces the
29/// original file atomically via rename.
30pub(crate) fn mux_audio_into_video(
31    video_path: &Path,
32    pcm: &[f32],
33    format: OutputFormat,
34) -> anyhow::Result<()> {
35    let Some(codec) = audio_codec(format) else {
36        bail!("format {format:?} cannot store audio");
37    };
38    let wav_path = video_path.with_extension(format!("ranim-audio-{}.wav", std::process::id()));
39    write_wav(&wav_path, pcm, MASTER_SAMPLE_RATE, MASTER_CHANNELS)
40        .with_context(|| format!("failed to write {}", wav_path.display()))?;
41
42    let muxed_path = video_path.with_extension(format!(
43        "ranim-muxed-{}.{}",
44        std::process::id(),
45        video_path
46            .extension()
47            .and_then(|e| e.to_str())
48            .unwrap_or("mp4"),
49    ));
50
51    let ffmpeg = ["ffmpeg", "./ffmpeg"]
52        .into_iter()
53        .find(|bin| Path::new(bin).exists() || which::which(bin).is_ok())
54        .ok_or_else(|| anyhow!("ffmpeg not found"))?;
55    let status = std::process::Command::new(ffmpeg)
56        .args(["-y", "-v", "error", "-i"])
57        .arg(video_path)
58        .args(["-i"])
59        .arg(&wav_path)
60        .args([
61            "-map",
62            "0:v:0",
63            "-map",
64            "1:a:0",
65            "-c:v",
66            "copy",
67            "-c:a",
68            codec,
69            "-shortest",
70        ])
71        .arg(&muxed_path)
72        .status()
73        .context("failed to spawn ffmpeg for audio muxing")?;
74
75    let _ = std::fs::remove_file(&wav_path);
76    if !status.success() {
77        let _ = std::fs::remove_file(&muxed_path);
78        bail!("ffmpeg audio muxing failed with {status}");
79    }
80    std::fs::rename(&muxed_path, video_path).with_context(|| {
81        format!(
82            "failed to replace {} with muxed output",
83            video_path.display()
84        )
85    })?;
86    info!("muxed audio into {}", video_path.display());
87    Ok(())
88}