feat: export clips, protect bookmarks and clean session storage
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
//! 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()
|
||||
}
|
||||
Reference in New Issue
Block a user