Files
clipbin-desktop/crates/clipbin-media/src/export.rs
T

284 lines
10 KiB
Rust

//! 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<Vec<u64>, 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<Vec<u64>, 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()
}