284 lines
10 KiB
Rust
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()
|
|
}
|