Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 137 additions & 1 deletion crates/compositor/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use crate::ffi::*;

use crate::regions::SpeedSegment;
use crate::scene::SceneAudio;
use crate::scene::{SceneAudio, SceneAudioTrack};
use anyhow::{bail, Result};
use std::f32::consts::PI;
use std::ffi::CString;
Expand Down Expand Up @@ -1381,6 +1381,84 @@ pub fn assemble_concatenated_pcm(
output
}

/// Mix imported audio tracks (issue #350) over the assembled programme.
///
/// Each track is decoded across its trim window — already resampled to 48 kHz
/// stereo by `decode_clip_audio`, the same path a clip's own audio takes — scaled
/// by its per-track gain (the same `10^(dB/20)` law as `finish_audio`), and summed
/// into the programme at `start_sec`. The programme length is NOT extended: a
/// track that runs past the video is truncated to it, so the audio and video
/// streams stay the same length for the muxer.
///
/// The decode window is capped up front at the room left in the programme after
/// `start_sec`, and a track starting at/after the end is skipped without decoding.
/// `decode_clip_audio` preallocates from the window, so this keeps a long track
/// pinned near a short programme's end from buffering (and clamping away) hours of
/// PCM. `trim_end_sec` must therefore be concrete — the renderer sends
/// `trimEnd ?? durationSec`.
///
/// A track whose file has no decodable audio is skipped — the same degradation a
/// stream-less clip gets.
pub fn mix_external_tracks(mut programme: PlanarPcm, tracks: &[SceneAudioTrack]) -> PlanarPcm {
let programme_len = programme.first().map(Vec::len).unwrap_or(0);
if programme_len == 0 {
return programme;
}
for track in tracks {
let offset = (track.start_sec.max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as usize;
// A track that starts at or past the programme end contributes nothing —
// skip it before decoding anything.
if offset >= programme_len {
continue;
}
let trim_start = track.trim_start_sec.max(0.0);
let Some(trim_end_full) = track.trim_end_sec else {
// Without a concrete end there is no safe window to decode (see the doc
// comment); the renderer always resolves one, so this only guards a
// hand-written scene.
continue;
};
// Cap the decode window at the room left in the programme. Everything past
// `offset` that overflows is discarded by `overlay_track_pcm` anyway, so
// decoding it only wastes time and memory — a three-hour track placed at
// second 9 of a ten-second export must not buffer three hours of PCM.
let remaining_sec = (programme_len - offset) as f64 / AUDIO_OUTPUT_SAMPLE_RATE as f64;
let trim_end = trim_end_full.min(trim_start + remaining_sec);
if trim_end <= trim_start {
continue;
}
let decoded = match decode_clip_audio(&track.path, trim_start, trim_end) {
Ok(Some(pcm)) => pcm,
_ => continue,
};
let gain = 10.0f32.powf(track.gain_db.clamp(-12.0, 12.0) / 20.0);
overlay_track_pcm(&mut programme, &decoded, offset, gain);
}
programme
}

/// Sum one decoded track into the programme at `offset` samples, scaled by `gain`,
/// truncated at the programme's end. Split out of `mix_external_tracks` so the
/// placement/gain/clamp math is testable without ffmpeg, exactly like
/// `mix_aligned_tracks` is split from the decode above.
fn overlay_track_pcm(programme: &mut PlanarPcm, decoded: &PlanarPcm, offset: usize, gain: f32) {
let programme_len = programme.first().map(Vec::len).unwrap_or(0);
if offset >= programme_len {
return;
}
let room = programme_len - offset;
for channel in 0..AUDIO_OUTPUT_CHANNELS {
let Some(source) = decoded.get(channel) else {
continue;
};
let count = source.len().min(room);
let dst = &mut programme[channel];
for k in 0..count {
dst[offset + k] += source[k] * gain;
}
}
}

/// Encodeur AAC attaché au muxer avant son header. Les paquets utilisent le même interleaver
/// que la vidéo ; les pts restent en unités échantillon jusqu'au rescale vers l'AVStream.
pub(crate) struct AacEncoder {
Expand Down Expand Up @@ -1692,6 +1770,64 @@ mod tests {
assert_eq!(mixed[1], vec![0.25, -0.5, 0.75]);
}

// Imported audio track overlay (issue #350).
#[test]
fn overlay_sums_at_offset_with_gain() {
let mut programme = planar(&[0.1, 0.1, 0.1, 0.1]);
// ×2 gain, placed at sample offset 1.
overlay_track_pcm(&mut programme, &planar(&[0.2, 0.2]), 1, 2.0);
assert_eq!(programme[0], vec![0.1, 0.5, 0.5, 0.1]);
assert_eq!(programme[1], vec![0.1, 0.5, 0.5, 0.1]);
}

#[test]
fn overlay_truncates_a_track_that_runs_past_the_programme() {
let mut programme = planar(&[0.0, 0.0, 0.0]);
// A 4-sample track placed at offset 2 has room for only 1 sample.
overlay_track_pcm(&mut programme, &planar(&[1.0, 1.0, 1.0, 1.0]), 2, 1.0);
assert_eq!(programme[0], vec![0.0, 0.0, 1.0]);
}

#[test]
fn overlay_past_the_end_is_a_no_op() {
let mut programme = planar(&[0.3, 0.3]);
overlay_track_pcm(&mut programme, &planar(&[1.0]), 5, 1.0);
assert_eq!(programme[0], vec![0.3, 0.3]);
}

#[test]
fn mix_external_tracks_skips_empty_windows() {
let programme = planar(&[0.4, 0.4]);
let tracks = vec![SceneAudioTrack {
path: "/nope.mp3".into(),
start_sec: 0.0,
gain_db: 0.0,
trim_start_sec: 2.0,
trim_end_sec: Some(1.0), // end <= start: empty window, never decoded
}];
// The empty window is skipped before any decode, so the programme is
// untouched even though the path does not exist.
let out = mix_external_tracks(programme, &tracks);
assert_eq!(out[0], vec![0.4, 0.4]);
}

#[test]
fn mix_external_tracks_skips_a_track_that_starts_past_the_programme() {
// 2 samples = ~0.00004 s of programme at 48 kHz; the track starts at 1 s, so
// its offset is past the end. It must be skipped before any decode is
// attempted (the path does not exist), never buffering its window.
let programme = planar(&[0.4, 0.4]);
let tracks = vec![SceneAudioTrack {
path: "/nope.mp3".into(),
start_sec: 1.0,
gain_db: 0.0,
trim_start_sec: 0.0,
trim_end_sec: Some(3600.0),
}];
let out = mix_external_tracks(programme, &tracks);
assert_eq!(out[0], vec![0.4, 0.4]);
}

#[test]
fn single_track_is_not_clamped() {
// Promesse de non-régression : une source mono-piste ressort telle quelle, y compris
Expand Down
13 changes: 11 additions & 2 deletions crates/compositor/src/pipeline_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use std::ffi::CString;
use std::ptr;

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio,
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
Expand Down Expand Up @@ -459,6 +459,12 @@ pub fn run_composited_multi(

let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
// Imported audio tracks (issue #350), cloned out of the borrowed scene so the
// mix step below owns them. Empty for a project with no imported audio.
let audio_tracks = scene
.as_ref()
.map(|scene| scene.audio_tracks.clone())
.unwrap_or_default();
// Ring de staging a 2 : l'export ne veut que du debit, une frame de latence
// ne se voit pas dans un fichier. Voir `Compositor::set_readback_depth` pour
// la raison pour laquelle la preview, elle, reste a 1.
Expand Down Expand Up @@ -552,7 +558,10 @@ pub fn run_composited_multi(
let declared_audio: Vec<bool> = clips.iter().map(|c| c.has_audio).collect();
let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64);
audio_encoder.encode(
&finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
&finish_audio(
mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks),
audio_settings,
),
octx,
)?;
crate::ffi::averr(crate::ffi::av_write_trailer(octx), "write_trailer")?;
Expand Down
12 changes: 10 additions & 2 deletions crates/compositor/src/pipeline_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
//! décodeurs, symétrique.

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio,
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
Expand Down Expand Up @@ -1078,6 +1078,11 @@ pub fn run_composited_multi(
// raconte avoir déjà coûté une fois.
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
// Imported audio tracks (issue #350), cloned out of the borrowed scene.
let audio_tracks = scene
.as_ref()
.map(|scene| scene.audio_tracks.clone())
.unwrap_or_default();
frames = unsafe {
crate::timeline_walk::walk_composited_timeline(
clips,
Expand Down Expand Up @@ -1146,7 +1151,10 @@ pub fn run_composited_multi(
let declared_audio: Vec<bool> = clips.iter().map(|clip| clip.has_audio).collect();
let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64);
audio_encoder.encode(
&finish_audio(assemble_concatenated_pcm(&clip_pcm, &plan), audio_settings),
&finish_audio(
mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks),
audio_settings,
),
octx,
)?;

Expand Down
9 changes: 7 additions & 2 deletions crates/compositor/src/pipeline_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! tout le run, deux lectures seulement. Rien dans la boucle ne peut fausser le fps.

use crate::audio::{
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio,
assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks,
AacEncoder, PlanarPcm,
};
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
Expand Down Expand Up @@ -1344,6 +1344,11 @@ unsafe fn run_multi_inner(
// fenêtrage par clip ; `walk_composited_timeline` s'en charge.
let scene = comp.scene_snapshot();
let audio_settings = scene.as_ref().map(|scene| scene.audio).unwrap_or_default();
// Imported audio tracks (issue #350), cloned out of the borrowed scene.
let audio_tracks = scene
.as_ref()
.map(|scene| scene.audio_tracks.clone())
.unwrap_or_default();

// ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ----
// Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur
Expand Down Expand Up @@ -1477,7 +1482,7 @@ unsafe fn run_multi_inner(
out_fps as f64,
);
let assembled_audio = finish_audio(
assemble_concatenated_pcm(&clip_pcm, &audio_plan),
mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &audio_plan), &audio_tracks),
audio_settings,
);
audio_encoder.encode(&assembled_audio, octx)?;
Expand Down
27 changes: 27 additions & 0 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,29 @@ pub struct SceneAudio {
pub gain_db: f32,
}

/// One imported audio track (issue #350) mixed over the assembled programme —
/// voiceover / BGM / SFX. Deliberately a SEPARATE `Scene` field rather than a
/// member of `SceneAudio`, so `SceneAudio` stays `Copy` and the pipelines keep
/// copying it out of a borrow unchanged.
///
/// `start_sec` is the track's head on the OUTPUT programme; `trim_start_sec` /
/// `trim_end_sec` window the source file (both source seconds). The renderer
/// resolves `start_sec` from the track's raw timeline position — equal to it when
/// the project has no trims/speed, which is the case this first cut mixes exactly.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneAudioTrack {
pub path: String,
#[serde(default)]
pub start_sec: f64,
#[serde(default)]
pub gain_db: f32,
#[serde(default)]
pub trim_start_sec: f64,
#[serde(default)]
pub trim_end_sec: Option<f64>,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneOutput {
Expand Down Expand Up @@ -500,6 +523,10 @@ pub struct Scene {
/// Global audio finishing. Default keeps old scene payloads bit-for-bit compatible.
#[serde(default)]
pub audio: SceneAudio,
/// Imported audio tracks mixed over the programme (issue #350). `#[serde(default)]`:
/// absent from every scene written before this, and from a project with none.
#[serde(default)]
pub audio_tracks: Vec<SceneAudioTrack>,
/// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS).
#[serde(default)]
pub crop_by_clip: Vec<Option<SceneCrop>>,
Expand Down
Loading
Loading