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
171 changes: 171 additions & 0 deletions crates/compositor/src/export_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,145 @@ pub(crate) fn with_staged_output<T>(
result
}

/// Render and close an MP4, then verify a displayable decoded frame before
/// publishing it. A positive encoder submission count alone is insufficient:
/// a zero-duration MP4 sample can be flagged discard by the demuxer.
pub(crate) fn with_validated_mp4_output<T>(
target: &Path,
control: &ExportControl,
render: impl FnOnce(File, &Path) -> Result<T>,
) -> Result<T> {
with_staged_output(target, control, |file, staged| {
let stats = render(file, staged)?;
validate_video_frame(staged, control).context("validating exported video")?;
Ok(stats)
})
}

/// Own every FFmpeg allocation so validation closes the file before publication
/// or cleanup, including on cancellation and partially initialized error paths.
#[derive(Default)]
struct VideoValidation {
input: *mut crate::ffi::AVFormatContext,
decoder: *mut crate::ffi::AVCodecContext,
packet: *mut crate::ffi::AVPacket,
frame: *mut crate::ffi::AVFrame,
}

impl Drop for VideoValidation {
/// Release decoder buffers and close the input before rename or cleanup.
fn drop(&mut self) {
unsafe {
crate::ffi::av_frame_free(&mut self.frame);
crate::ffi::av_packet_free(&mut self.packet);
crate::ffi::avcodec_free_context(&mut self.decoder);
if !self.input.is_null() {
crate::ffi::avformat_close_input(&mut self.input);
}
}
}
}

/// Decode only until the first displayable frame. Decode discard-marked preroll
/// packets to preserve reference state, but never accept discard-marked frames.
/// This excludes zero-duration MP4 edit-list samples. Drain delayed frames
/// at EOF, and remain cancellable while scanning interleaved audio packets.
fn validate_video_frame(path: &Path, control: &ExportControl) -> Result<()> {
use crate::ffi::*;
use std::{ffi::CString, ptr};

control.check()?;
let path = CString::new(path.to_string_lossy().as_bytes())?;
let mut video = VideoValidation::default();
unsafe {
averr(
avformat_open_input(
&mut video.input,
path.as_ptr(),
ptr::null(),
ptr::null_mut(),
),
"open exported video",
)?;
averr(
avformat_find_stream_info(video.input, ptr::null_mut()),
"read exported video streams",
)?;
let index = av_find_best_stream(
video.input,
AVMediaType::AVMEDIA_TYPE_VIDEO,
-1,
-1,
ptr::null_mut(),
0,
);
averr(index, "find exported video stream")?;
let stream = sn_fmt_stream(video.input, index);
let params = (*stream).codecpar;
let codec = avcodec_find_decoder((*params).codec_id);
if codec.is_null() {
bail!("No decoder is available for the exported video.");
}
video.decoder = avcodec_alloc_context3(codec);
video.packet = av_packet_alloc();
video.frame = av_frame_alloc();
if video.decoder.is_null() || video.packet.is_null() || video.frame.is_null() {
bail!("Could not allocate exported video validation buffers.");
}
averr(
avcodec_parameters_to_context(video.decoder, params),
"configure exported video decoder",
)?;
averr(
avcodec_open2(video.decoder, codec, ptr::null_mut()),
"open exported video decoder",
)?;
let mut draining = false;
loop {
control.check()?;
let received = avcodec_receive_frame(video.decoder, video.frame);
if received >= 0 {
let displayable = (*video.frame).flags & AV_FRAME_FLAG_DISCARD as i32 == 0
&& (*video.frame).width > 0
&& (*video.frame).height > 0;
av_frame_unref(video.frame);
if displayable {
return Ok(());
}
continue;
}
if received == AVERROR_EOF || (draining && received == AVERROR_EAGAIN) {
bail!("The exported video has no decodable displayable frames. The destination has not been replaced.");
}
averr(
if received == AVERROR_EAGAIN {
0
} else {
received
},
"decode exported video frame",
)?;
let read = av_read_frame(video.input, video.packet);
if read == AVERROR_EOF {
averr(
avcodec_send_packet(video.decoder, ptr::null()),
"flush exported video decoder",
)?;
draining = true;
} else {
averr(read, "read exported video packet")?;
let sent = if (*video.packet).stream_index == index {
avcodec_send_packet(video.decoder, video.packet)
} else {
0
};
av_packet_unref(video.packet);
averr(sent, "send exported video packet")?;
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -129,6 +268,38 @@ mod tests {
fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); }
}

/// An invalid completed file must not replace the destination or leave a partial.
#[test]
fn invalid_mp4_is_rejected_before_publication() {
let dir = TestDir::new();
let target = dir.0.join("out.mp4");
fs::write(&target, b"previous export").unwrap();
let result = with_validated_mp4_output(&target, &ExportControl::default(), |mut file, _| {
file.write_all(b"not an MP4")?;
Ok(1)
});
assert!(format!("{:#}", result.unwrap_err()).contains("validating exported video"));
assert_eq!(fs::read(&target).unwrap(), b"previous export");
dir.assert_only("out.mp4");
}

/// Cancellation after rendering wins before validation and publication.
#[test]
fn cancellation_before_mp4_validation_preserves_destination() {
let dir = TestDir::new();
let target = dir.0.join("out.mp4");
fs::write(&target, b"previous export").unwrap();
let control = ExportControl::default();
let result = with_validated_mp4_output(&target, &control, |mut file, _| {
file.write_all(b"not an MP4")?;
assert!(control.cancel());
Ok(1)
});
assert!(result.unwrap_err().is::<ExportCancelled>());
assert_eq!(fs::read(&target).unwrap(), b"previous export");
dir.assert_only("out.mp4");
}

#[test]
fn cancellation_before_rendering_never_opens_output() {
let dir = TestDir::new();
Expand Down
6 changes: 4 additions & 2 deletions crates/compositor/src/pipeline_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ use crate::audio::{
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
use crate::config::Cfg;
use crate::d3d::Gpu;
use crate::export_control::{with_staged_output, ExportControl};
use crate::export_control::{with_validated_mp4_output, ExportControl};
use crate::ffi::AVFrame;
use crate::linux_decode::SwDecoder;
use crate::timeline_walk::NextFrameTime;
Expand Down Expand Up @@ -872,12 +872,14 @@ pub fn run_composited_multi_cancellable(
progress: &mut dyn FnMut(u64),
control: &ExportControl,
) -> Result<Stats> {
with_staged_output(Path::new(out), control, |file, staged| {
with_validated_mp4_output(Path::new(out), control, |file, staged| {
drop(file); // ffmpeg le rouvre par son nom
run_multi_inner(clips, &staged.to_string_lossy(), gpu, comp, cfg, params, progress, control)
})
}

/// Encode and close the staged MP4. The caller must validate a displayable
/// decoded frame before publishing it; returned stats count composed frames.
fn run_multi_inner(
clips: &[ClipSource],
out: &str,
Expand Down
8 changes: 5 additions & 3 deletions crates/compositor/src/pipeline_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use crate::audio::{
use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs};
use crate::compositor::Compositor;
use crate::d3d::Gpu;
use crate::export_control::{with_staged_output, ExportControl};
use crate::export_control::{with_validated_mp4_output, ExportControl};
use crate::timeline_walk::NextFrameTime;
use anyhow::{anyhow, bail, Result};
use std::ffi::{c_void, CString};
Expand Down Expand Up @@ -1185,12 +1185,14 @@ pub fn run_composited_multi_cancellable(
progress: &mut dyn FnMut(u64),
control: &ExportControl,
) -> Result<Stats> {
with_staged_output(Path::new(out), control, |file, staged| {
with_validated_mp4_output(Path::new(out), control, |file, staged| {
drop(file); // ffmpeg le rouvre par son nom
run_multi_inner(clips, &staged.to_string_lossy(), gpu, comp, cfg, params, progress, control)
})
}

/// Encode and close the staged MP4. The caller must validate a displayable
/// decoded frame before publishing it; returned stats count composed frames.
fn run_multi_inner(
clips: &[ClipSource],
out: &str,
Expand Down Expand Up @@ -1391,7 +1393,7 @@ fn run_multi_inner(
"write_trailer",
)?;
// Fichier fermé et contexte libéré par `_output` en fin de portée, avant que
// `with_staged_output` ne renomme le MP4.
// `with_validated_mp4_output` ne renomme le MP4.
}

let wall_s = t0.elapsed().as_secs_f64();
Expand Down
8 changes: 5 additions & 3 deletions crates/compositor/src/pipeline_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::config::Cfg;
use crate::cpu_frames::CpuFrames;
use crate::cursor::CursorTrack;
use crate::d3d::{Backend, Gpu};
use crate::export_control::{with_staged_output, ExportControl};
use crate::export_control::{with_validated_mp4_output, ExportControl};
use crate::ffi::*;
use crate::regions::{speed_segments_for_window, SpeedSegment};
use crate::scene::Scene;
Expand Down Expand Up @@ -1343,7 +1343,7 @@ pub fn run_composited_multi_cancellable(
progress: &mut dyn FnMut(u64),
control: &ExportControl,
) -> Result<Stats> {
with_staged_output(Path::new(out), control, |file, staged| {
with_validated_mp4_output(Path::new(out), control, |file, staged| {
drop(file); // ffmpeg le rouvre par son nom
unsafe {
run_multi_inner(clips, &staged.to_string_lossy(), gpu, comp, cfg, params, progress, control)
Expand Down Expand Up @@ -1680,6 +1680,8 @@ impl Default for ExportParams {
}


/// Encode and close the staged MP4. The caller must validate a displayable
/// decoded frame before publishing it; returned stats count composed frames.
unsafe fn run_multi_inner(
clips: &[ClipSource],
out: &str,
Expand Down Expand Up @@ -1875,7 +1877,7 @@ unsafe fn run_multi_inner(
let wall_s = t0.elapsed().as_secs_f64();

// teardown : les gardes et les Drop (décodeurs, encodeurs) en fin de portée — fichier fermé
// avant que `with_staged_output` ne le renomme.
// avant que `with_validated_mp4_output` ne le renomme.
let fps = frames as f64 / wall_s;
Ok(Stats { frames, wall_s, fps, video_duration_s: frames as f64 / out_fps as f64 })
}
Expand Down
55 changes: 46 additions & 9 deletions crates/compositor/src/timeline_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::frame_geometry::webcam_is_real;
use crate::pipeline::{ClipSource, Decoder};
use crate::regions::{speed_segments_for_window, SpeedSegment};
use crate::scene::Scene;
use anyhow::Result;
use anyhow::{bail, Context, Result};
use std::collections::HashMap;

/// Ce que le décodeur sait de la PROCHAINE frame, sans l'adopter.
Expand Down Expand Up @@ -158,7 +158,11 @@ pub(crate) unsafe fn walk_composited_timeline(
on_frame: &mut dyn FnMut(u64) -> Result<()>,
on_clip_end: &mut dyn FnMut(usize, f64, u64, &[SpeedSegment]) -> Result<()>,
) -> Result<u64> {
walk_timeline(clips, gpu, comp, cfg, out_fps, scene, screen_decs, webcam_decs, None, on_frame, on_clip_end)
let frames = walk_timeline(clips, gpu, comp, cfg, out_fps, scene, screen_decs, webcam_decs, None, on_frame, on_clip_end)?;
if frames == 0 {
bail!("No video frames were exported. Check that the clips contain readable video before exporting again.");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Ok(frames)
}

/// Une frame de sortie relue par `render_frames`.
Expand Down Expand Up @@ -253,6 +257,18 @@ unsafe fn catch_up(dec: &mut Decoder, target: f64, clip_start: f64) -> Result<()
}
}

/// Normal recordings can have audio up to roughly half a second longer than
/// video. Hold the last frame only in that margin: an unbounded fallback would
/// hide a genuinely truncated camera recording.
const CLIP_START_TAIL_TOLERANCE_SEC: f64 = 0.5;

/// Seek a clip start, holding the last frame only within the normal tail margin.
unsafe fn seek_clip_start(decoder: &mut Decoder, target: f64) -> Result<bool> {
let frame = decoder.seek_to_or_last(target)?;
Ok(!frame.is_null()
&& target - decoder.cur_time_sec() <= CLIP_START_TAIL_TOLERANCE_SEC + 1e-6)
}

/// `walk_composited_timeline`, qui ne compose que les frames de sortie `only` (triées) quand il y
/// en a : les autres avancent le compteur sans décoder ni composer, et la marche s'arrête après
/// la dernière. Un flux sans pts fiable avance d'une frame par frame de sortie : sauter le rend
Expand Down Expand Up @@ -294,6 +310,12 @@ unsafe fn walk_timeline(
comp.set_segmentation_deterministic(true);

'clips: for (clip_index, clip) in clips.iter().enumerate() {
// A completed inspection must not open or seek an unrelated later clip.
if let Some(only) = only {
if only.last().map_or(true, |&last| frames > last) {
break 'clips;
}
}
// Le preset de layout est GLOBAL (un seul panneau pour toute la timeline) mais la
// caméra est PAR CLIP : un projet mélange sans problème un enregistrement avec webcam
// et un import qui n'en a pas. Le preset ne doit donc s'appliquer qu'aux clips qui ont
Expand Down Expand Up @@ -350,6 +372,12 @@ unsafe fn walk_timeline(
// par le même offset que le seek (`webcam_time = screen_time - offset`).
let webcam_available_screen_end =
webcam_available_duration.map(|duration| duration + clip.webcam_offset_sec);
if has_camera && webcam_available_screen_end.is_some_and(|end| end + CLIP_START_TAIL_TOLERANCE_SEC < clip.source_start_sec) {
bail!(
"Cannot export clip {}: the camera video ends before this clip starts. Record the camera again or remove its track before exporting.",
clip_index + 1,
);
}
let mut source_end_sec = clip.source_end_sec;
if let Some(duration) = screen_available_duration {
source_end_sec = source_end_sec.min(duration);
Expand Down Expand Up @@ -391,14 +419,23 @@ unsafe fn walk_timeline(

// un seul seek keyframe, puis chaque décodeur avance selon son propre pts jusqu'aux
// temps source demandés par les spans de vitesse.
if sdec.seek_to(clip.source_start_sec)?.is_null() {
continue; // clip vide / au-delà de la source
}
if wdec
.seek_to((clip.source_start_sec - clip.webcam_offset_sec).max(0.0))?
.is_null()
if !seek_clip_start(sdec, clip.source_start_sec)
.with_context(|| format!("Cannot export clip {}: could not read the screen video.", clip_index + 1))?
{
Comment thread
coderabbitai[bot] marked this conversation as resolved.
continue;
bail!("Cannot export clip {}: the screen video has no readable frame at the clip's start.", clip_index + 1);
}
let webcam_start = if has_camera {
(clip.source_start_sec - clip.webcam_offset_sec).max(0.0)
} else {
clip.source_start_sec
};
let webcam_error = if has_camera {
format!("Cannot export clip {}: the camera video has no readable frame at the clip's start. Record the camera again or remove its track before exporting.", clip_index + 1)
} else {
format!("Cannot export clip {}: the screen video has no readable frame at the clip's start.", clip_index + 1)
};
if !seek_clip_start(wdec, webcam_start).with_context(|| webcam_error.clone())? {
bail!(webcam_error);
}

if cursor_enabled {
Expand Down
Loading
Loading