//! Stream-copy export. Container timestamps restart independently at every split; //! video frames and AAC samples therefore have separate exact clocks. use crate::{ ffmpeg::{self, Rational, Rescale, codec, encoder, format, media}, init, }; use clipbin_core::store::Metadata; use std::{fs, path::Path, time::Duration}; /// Writes and closes MP4 under a temporary name before publishing it. /// Missing/Removed footage is skipped, retaining a visible timestamp jump. pub fn export( metadata: &Metadata, start: Duration, end: Duration, separate_audio: bool, destination: &Path, ) -> Result<(), String> { init(); let (start, end) = metadata.export_range(start, end); let pieces = metadata.covering(start, end); if pieces.is_empty() { return Err("No retained footage in this range".into()); } if pieces.iter().any(|p| p.segment.dropped_frames > 0) { return Err("This range has dropped frames; exact export placement is unvalidated".into()); } if pieces .iter() .any(|p| p.segment.frames_per_second != pieces[0].segment.frames_per_second) { return Err("Export ranges spanning a frame-rate change are unsupported".into()); } if pieces.iter().any(|p| !p.segment.complete) { return Err("Footage is still being written".into()); } let temporary = destination.with_extension("partial.mp4"); let result = remux(metadata, start, end, separate_audio, &temporary); match result { Ok(()) => { fs::File::open(&temporary) .and_then(|f| f.sync_all()) .map_err(|e| e.to_string())?; fs::rename(&temporary, destination).map_err(|e| e.to_string()) } Err(error) => { let _ = fs::remove_file(&temporary); Err(error.to_string()) } } } /// Counts AAC samples in a finalised file, without decoding or rounded durations. pub fn audio_samples(path: &Path) -> Result, ffmpeg::Error> { init(); let mut input = format::input(path)?; let audio: Vec<_> = input .streams() .filter(|s| s.parameters().medium() == media::Type::Audio) .map(|s| s.index()) .collect(); let mut samples = vec![0; audio.len()]; loop { let mut packet = ffmpeg::Packet::empty(); match packet.read(&mut input) { Ok(()) => {} Err(ffmpeg::Error::Eof) => break, Err(e) => return Err(e), } if let Some(index) = audio.iter().position(|s| *s == packet.stream()) { samples[index] += 1024; } } Ok(samples) } fn audio_start( metadata: &Metadata, index: usize, tracks: usize, ) -> Result, ffmpeg::Error> { let segment = &metadata.segments[index]; if segment.audio_start_samples.len() == tracks { return Ok(segment.audio_start_samples.clone()); } if segment.frame_start == 0 { return Ok(vec![0; tracks]); } // Old metadata can be reconstructed while its preceding files still exist. let mut samples = vec![0; tracks]; for previous in metadata.segments[..index].iter().rev() { if !previous.available || previous.path.parent() != segment.path.parent() { return Err(ffmpeg::Error::InvalidData); } let counts = audio_samples(&previous.path)?; if counts.len() != tracks { return Err(ffmpeg::Error::InvalidData); } for (sum, count) in samples.iter_mut().zip(counts) { *sum += count; } if previous.audio_start_samples.len() == tracks { for (sum, start) in samples.iter_mut().zip(&previous.audio_start_samples) { *sum += start; } return Ok(samples); } if previous.frame_start == 0 { return Ok(samples); } } Err(ffmpeg::Error::InvalidData) } fn signed_ticks(time: Duration, origin: Duration, rate: i32) -> i64 { if time >= origin { ticks(time - origin, rate) } else { -ticks(origin - time, rate) } } fn ticks(time: Duration, rate: i32) -> i64 { ((time.as_nanos() * rate as u128 + 500_000_000) / 1_000_000_000) as i64 } fn remux( metadata: &Metadata, start: Duration, end: Duration, separate: bool, path: &Path, ) -> Result<(), ffmpeg::Error> { let pieces = metadata.covering(start, end); let first = format::input(&pieces[0].segment.path)?; let audio: Vec<_> = first .streams() .filter(|s| s.parameters().medium() == media::Type::Audio) .map(|s| s.index()) .collect(); // OBS writes system, mic, combined in this order; one track is already mixed. let selected_audio: Vec<_> = if audio.len() == 3 { if separate { audio[..2].to_vec() } else { vec![audio[2]] } } else { audio.clone() }; let mut output = format::output(path)?; let mut mapping = vec![None; first.nb_streams() as usize]; let mut clocks = vec![(0, 0); first.nb_streams() as usize]; for stream in first.streams() { let video = stream.parameters().medium() == media::Type::Video; if !video && !selected_audio.contains(&stream.index()) { continue; } let rate = if video { pieces[0].segment.frames_per_second as i32 } else { codec::context::Context::from_parameters(stream.parameters())? .decoder() .audio()? .rate() as i32 }; let mut target = output.add_stream(encoder::find(codec::Id::None))?; target.set_parameters(stream.parameters()); target.set_time_base(Rational(1, rate)); mapping[stream.index()] = Some((target.index(), video, rate)); clocks[stream.index()] = (0, if video { 1 } else { 1024 }); } output.write_header()?; let mut previous_end = None; let mut wrote_video = false; for piece in pieces { let adjacent = previous_end == Some(piece.segment.start); let mut input = format::input(&piece.segment.path)?; if input.nb_streams() != first.nb_streams() { return Err(ffmpeg::Error::InvalidData); } for stream in input.streams() { let original = first.stream(stream.index()).unwrap(); if stream.parameters().id() != original.parameters().id() { return Err(ffmpeg::Error::InvalidData); } if stream.parameters().medium() == media::Type::Video { let current = codec::context::Context::from_parameters(stream.parameters())? .decoder() .video()?; let initial = codec::context::Context::from_parameters(original.parameters())? .decoder() .video()?; if (current.width(), current.height()) != (initial.width(), initial.height()) { return Err(ffmpeg::Error::InvalidData); } } } let mut counts = vec![0i64; mapping.len()]; let mut first_pts = vec![None; mapping.len()]; let mut bases = vec![0i64; mapping.len()]; let origins = audio_start(metadata, piece.index, audio.len())?; let run_base = piece .segment .start .saturating_sub(clipbin_core::store::frame_offset( piece.segment.frame_start, piece.segment.frames_per_second, )); for (index, mapped) in mapping.iter().enumerate() { if let Some((_, video, rate)) = mapped { bases[index] = if *video { if adjacent { clocks[index].0 } else { signed_ticks(piece.segment.start, start, *rate) } } else { let ordinal = audio.iter().position(|i| *i == index).unwrap(); signed_ticks(run_base, start, *rate) + origins[ordinal] as i64 }; } } loop { let mut packet = ffmpeg::Packet::empty(); match packet.read(&mut input) { Ok(()) => {} Err(ffmpeg::Error::Eof) => break, Err(e) => return Err(e), } let index = packet.stream(); let stream = input.stream(index).ok_or(ffmpeg::Error::StreamNotFound)?; let Some((target, video, rate)) = mapping[index] else { continue; }; let step = clocks[index].1; let local = if video { let pts = packet.pts().ok_or(ffmpeg::Error::InvalidData)?; let origin = *first_pts[index].get_or_insert(pts); (pts - origin).rescale(stream.time_base(), Rational(1, rate)) } else { counts[index] * step }; counts[index] += 1; let from = ticks(piece.from, rate); let to = ticks(piece.to, rate); let pts = bases[index] + local; if video && (local + step <= from || local >= to) { continue; } if !video && pts >= ticks(end - start, rate) { continue; } if pts < 0 { continue; } let tb = Rational(1, rate); let out_tb = output.stream(target).unwrap().time_base(); let reorder = packet.pts().unwrap_or(0) - packet.dts().unwrap_or(0); let delay = reorder.rescale(stream.time_base(), tb); packet.set_pts(Some(pts)); packet.set_dts(Some(pts - delay)); packet.set_duration(step); packet.rescale_ts(tb, out_tb); packet.set_position(-1); packet.set_stream(target); packet.write_interleaved(&mut output)?; wrote_video |= video; } for (index, mapped) in mapping.iter().enumerate() { if let Some((_, true, rate)) = mapped && counts[index] != ticks(piece.segment.end - piece.segment.start, *rate) { return Err(ffmpeg::Error::InvalidData); } } for index in 0..mapping.len() { clocks[index].0 = bases[index] + counts[index] * clocks[index].1; } previous_end = Some(piece.segment.end); } if !wrote_video { return Err(ffmpeg::Error::InvalidData); } output.write_trailer() }