Skip to content

Commit ec93475

Browse files
committed
fix(linux): write fragmented MP4 so a killed helper keeps its take
1 parent c9c5642 commit ec93475

2 files changed

Lines changed: 132 additions & 10 deletions

File tree

‎electron/native/pipewire-capture/src/encoder.rs‎

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
10921130
pub 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");

‎electron/native/pipewire-capture/src/main.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,9 +1284,9 @@ fn run<W: Write>(
12841284

12851285
/// Writes the trailer and reports what the recording cost.
12861286
///
1287-
/// Runs even when the loop broke on an error: a file whose moov atom was never
1288-
/// written is unplayable, and a partial recording is worth more to the user than
1289-
/// none.
1287+
/// Runs even when the loop broke on an error: a file whose trailer was never
1288+
/// written loses its last fragment, and a partial recording is worth more to the
1289+
/// user than none.
12901290
fn finish_capture<W: Write>(
12911291
emitter: &mut Emitter<W>,
12921292
capture: Capture,

0 commit comments

Comments
 (0)