@@ -1070,6 +1070,40 @@ struct MuxTrack {
10701070 stream_time_base : ff:: AVRational ,
10711071}
10721072
1073+ /// The mov muxer options that make the output fragmented.
1074+ ///
1075+ /// A fragment is cut at a video keyframe once it holds at least one second, the
1076+ /// same floor the Windows sink uses (`kFragmentDurationHns` in
1077+ /// wgc-capture/src/mf_encoder.cpp). The GOP is half a second (see
1078+ /// `VideoEncoder::open_backend`), so a fragment is two GOPs and a kill loses at
1079+ /// most about a second. `frag_keyframe` alone would cut every half second;
1080+ /// `frag_duration` alone would cut mid-GOP, leaving fragments that open on a
1081+ /// frame nothing can decode without the previous one. `min_frag_duration` is
1082+ /// the floor that waits for the keyframe. `default_base_moof` makes each moof's
1083+ /// data offsets relative to that moof rather than to the file, so a fragment
1084+ /// parses on its own.
1085+ ///
1086+ /// `flush_packets` hands every closed fragment to the kernel at once. Without
1087+ /// it the moov and the fragments wait in avio's 32 KB buffer, and a kill loses
1088+ /// that too: measured, a low-bitrate take killed after three seconds left a
1089+ /// 28-byte file. It costs one write per fragment, since the muxer holds each
1090+ /// fragment's samples until it closes.
1091+ ///
1092+ /// `delay_moov` is for the audio track. AAC's first packet sits 1024 samples
1093+ /// before zero (encoder priming), and an empty moov written with the header
1094+ /// cannot know that, so libavformat drops the edit list and shifts every track
1095+ /// 21 ms later instead: measured, the first video frame then lasts 55 ms and
1096+ /// every later one lands 21 ms after its capture time, against a cursor track
1097+ /// that does not move. Delaying the moov to the first fragment lets it carry
1098+ /// the same edit list the plain MP4 had; timestamps match it packet for packet.
1099+ /// The price is that a take killed in its first second keeps nothing, as every
1100+ /// killed take did before.
1101+ const FRAGMENT_OPTIONS : [ ( & CStr , & CStr ) ; 3 ] = [
1102+ ( c"movflags" , c"+frag_keyframe+empty_moov+default_base_moof+delay_moov" ) ,
1103+ ( c"min_frag_duration" , c"1000000" ) ,
1104+ ( c"flush_packets" , c"1" ) ,
1105+ ] ;
1106+
10731107/// An MP4 writer, taking however many streams [`Self::add_stream`] is called for.
10741108///
10751109/// WHAT A RECORDING ACTUALLY USES IS TWO: one H.264 video stream and one AAC
@@ -1084,11 +1118,15 @@ struct MuxTrack {
10841118/// and it also removes the question of reconciling two capture clocks in the
10851119/// container: there is only one audio timeline now, built in `AudioMix::pump`.
10861120///
1121+ /// The file is a FRAGMENTED MP4, like the Windows and macOS helpers write
1122+ /// (#338): a moov with no samples in front, then self-describing moof+mdat pairs. A plain
1123+ /// MP4 has no index until the trailer, so a killed helper, a crash or a power
1124+ /// loss used to leave gigabytes of frames and no way to read them (#944). Now it
1125+ /// plays up to the last complete fragment. See [`FRAGMENT_OPTIONS`].
1126+ ///
10871127/// `+faststart` is not used: it rewrites the whole file on close, which on a
1088- /// long recording means copying gigabytes. The moov atom is written at the end
1089- /// as usual, and the app reads these files locally, where a trailing moov costs
1090- /// nothing. This is the difference from the WebM path, which had NO index at all
1091- /// and could not be seeked even locally (see electron/recording/webm-seek-index.ts).
1128+ /// long recording means copying gigabytes, and a fragmented file has its moov
1129+ /// in front anyway.
10921130pub struct Muxer {
10931131 fmt : * mut ff:: AVFormatContext ,
10941132 tracks : Vec < MuxTrack > ,
@@ -1170,9 +1208,21 @@ impl Muxer {
11701208 if self . tracks . is_empty ( ) {
11711209 return Err ( "the output has no streams" . to_owned ( ) ) ;
11721210 }
1173- // SAFETY: `fmt` is ours and every stream was added through add_stream.
1211+ // SAFETY: `fmt` is ours and every stream was added through add_stream;
1212+ // the dictionary is ours and freed before leaving the block.
11741213 unsafe {
1175- let header = ff:: avformat_write_header ( self . fmt , ptr:: null_mut ( ) ) ;
1214+ let mut options: * mut ff:: AVDictionary = ptr:: null_mut ( ) ;
1215+ for ( key, value) in FRAGMENT_OPTIONS {
1216+ ff:: av_dict_set ( & mut options, key. as_ptr ( ) , value. as_ptr ( ) , 0 ) ;
1217+ }
1218+ let header = ff:: avformat_write_header ( self . fmt , & mut options) ;
1219+ // The muxer takes out every option it knows; one left over is a typo
1220+ // that would silently bring back the unfragmented file.
1221+ let unknown = ff:: av_dict_count ( options) ;
1222+ ff:: av_dict_free ( & mut options) ;
1223+ if header >= 0 && unknown > 0 {
1224+ return Err ( format ! ( "the mp4 muxer ignored {unknown} fragment option(s)" ) ) ;
1225+ }
11761226 if header < 0 {
11771227 return Err ( format ! (
11781228 "avformat_write_header: {}" ,
@@ -1234,7 +1284,7 @@ impl Drop for Muxer {
12341284 fn drop ( & mut self ) {
12351285 // SAFETY: `fmt` is either null or ours. `header_written` is still true
12361286 // only on the error path — `finish` clears it — and a file left without
1237- // its trailer would be unplayable , so write one on the way out.
1287+ // its trailer would lose its last fragment , so write one on the way out.
12381288 unsafe {
12391289 if self . fmt . is_null ( ) {
12401290 return ;
@@ -1470,6 +1520,78 @@ mod tests {
14701520 assert_eq ! ( ctx. color_trc, ff:: AVCOL_TRC_BT709 ) ;
14711521 }
14721522
1523+ #[ test]
1524+ fn a_killed_muxer_leaves_a_file_that_opens_and_holds_its_frames ( ) {
1525+ // Three seconds at 30 fps with audio, then a simulated kill: no trailer,
1526+ // no flush of the write buffer, no interleave queue drained (#944).
1527+ let ( width, height, fps) = ( 64 , 32 , 30 ) ;
1528+ let output = std:: env:: temp_dir ( ) . join ( "openscreen-killed-muxer.mp4" ) ;
1529+ let _ = std:: fs:: remove_file ( & output) ;
1530+ let mut video = VideoEncoder :: open (
1531+ VideoParams { width, height, fps, bitrate : 1_000_000 } ,
1532+ Some ( Backend :: Software ) ,
1533+ |_, _| { } ,
1534+ )
1535+ . expect ( "the software encoder always opens" ) ;
1536+ let mut audio = AudioEncoder :: open ( 128_000 ) . expect ( "aac" ) ;
1537+ let mut muxer = Muxer :: create ( & output) . expect ( "muxer" ) ;
1538+ let video_track = muxer. add_stream ( video. codec_context ( ) ) . expect ( "video track" ) ;
1539+ let audio_track = muxer. add_stream ( audio. codec_context ( ) ) . expect ( "audio track" ) ;
1540+ muxer. write_header ( ) . expect ( "header" ) ;
1541+
1542+ let stride = width as usize * 4 ;
1543+ let mut frame = vec ! [ 0u8 ; stride * height as usize ] ;
1544+ let samples = vec ! [ 0.0f32 ; ( AUDIO_SAMPLE_RATE / fps) as usize * AUDIO_CHANNELS ] ;
1545+ for pts in 0 ..3 * i64:: from ( fps) {
1546+ frame. fill ( pts as u8 ) ;
1547+ video
1548+ . submit ( & frame, stride, ff:: AV_PIX_FMT_BGRA , pts, |p| muxer. write ( video_track, p) )
1549+ . expect ( "video" ) ;
1550+ audio. push ( & samples, |p| muxer. write ( audio_track, p) ) . expect ( "audio" ) ;
1551+ }
1552+ std:: mem:: forget ( muxer) ;
1553+
1554+ // SAFETY: ffmpeg's own demuxer on the file just written; the context is
1555+ // closed before the block ends.
1556+ let ( streams, video_frames, second_frame_at) = unsafe {
1557+ let path = CString :: new ( output. as_os_str ( ) . as_encoded_bytes ( ) ) . unwrap ( ) ;
1558+ let mut input: * mut ff:: AVFormatContext = ptr:: null_mut ( ) ;
1559+ let opened =
1560+ ff:: avformat_open_input ( & mut input, path. as_ptr ( ) , ptr:: null ( ) , ptr:: null_mut ( ) ) ;
1561+ assert ! ( opened >= 0 , "a killed take must open: {}" , ff:: err_to_string( opened) ) ;
1562+ let probed = ff:: avformat_find_stream_info ( input, ptr:: null_mut ( ) ) ;
1563+ assert ! ( probed >= 0 , "stream info: {}" , ff:: err_to_string( probed) ) ;
1564+ let packet = ff:: av_packet_alloc ( ) ;
1565+ let mut video_frames = 0 ;
1566+ let mut second_frame_at = 0.0 ;
1567+ while ff:: av_read_frame ( input, packet) >= 0 {
1568+ let stream = * ( * input) . streams . add ( ( * packet) . stream_index as usize ) ;
1569+ if ( * ( * stream) . codecpar ) . codec_type == ff:: AVMEDIA_TYPE_VIDEO {
1570+ if video_frames == 1 {
1571+ let base = ( * stream) . time_base ;
1572+ second_frame_at = ( * packet) . pts as f64 * f64:: from ( base. num )
1573+ / f64:: from ( base. den ) ;
1574+ }
1575+ video_frames += 1 ;
1576+ }
1577+ ff:: av_packet_unref ( packet) ;
1578+ }
1579+ let mut packet = packet;
1580+ ff:: av_packet_free ( & mut packet) ;
1581+ let streams = ( * input) . nb_streams ;
1582+ ff:: avformat_close_input ( & mut input) ;
1583+ ( streams, video_frames, second_frame_at)
1584+ } ;
1585+ assert_eq ! ( streams, 2 , "both tracks are declared up front" ) ;
1586+ // Fragments close at the keyframes at 1 s and 2 s; only the one in
1587+ // flight, the last second, is lost.
1588+ assert ! ( video_frames >= 2 * fps, "only {video_frames} frames survived the kill" ) ;
1589+ // Without an edit list for AAC's priming, every frame after the first
1590+ // slides 21 ms off its capture time (see FRAGMENT_OPTIONS).
1591+ let one_frame = 1.0 / f64:: from ( fps) ;
1592+ assert ! ( ( second_frame_at - one_frame) . abs( ) < 1e-3 , "frame 1 at {second_frame_at} s" ) ;
1593+ }
1594+
14731595 #[ test]
14741596 fn radv_perftest_is_added_without_clobbering_an_existing_value ( ) {
14751597 std:: env:: set_var ( "RADV_PERFTEST" , "gpl" ) ;
0 commit comments