Skip to main content

ffmpeg_rewriter/
lib.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use chrono::{DateTime, Local};
5use frame_source::{FrameDataSource, h264_source::SeekableH264Source};
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9use ffmpeg_writer::{FfmpegCodecArgs, FfmpegWriter};
10use strand_cam_remote_control::{H264Metadata, Mp4Codec, Mp4RecordingConfig, RecordingFrameRate};
11
12#[derive(thiserror::Error, Debug)]
13pub enum Error {
14    #[error("IO error: {0}")]
15    Io(#[from] std::io::Error),
16    #[error("ffmpeg_writer {0}")]
17    FfmpegWriter(#[from] ffmpeg_writer::Error),
18    #[error("cannot reencode")]
19    CannotReencode,
20    #[error("filename does not end with '.mp4'")]
21    FilenameDoesNotEndWithMp4,
22    #[error("filename not unicode")]
23    FilenameNotUnicode,
24    #[error("source does not contain H264 video")]
25    SourceIsNotH264,
26    #[error("MP4 writer error: {0}")]
27    Mp4WriterError(#[from] mp4_writer::Error),
28    #[error("frame source error: {0}")]
29    FrameSourceError(#[from] frame_source::Error),
30    #[error("serde json error: {0}")]
31    SerdeJsonError(#[from] serde_json::Error),
32}
33type Result<T> = std::result::Result<T, Error>;
34
35#[derive(Serialize, Deserialize)]
36struct SrtMsg {
37    timestamp: DateTime<chrono::Local>,
38}
39
40/// Save to a video using [FfmpegWriter] but when done, read the newly-written
41/// file and resave the data (without transcoding) with timestamps and other
42/// metadata.
43pub struct FfmpegReWriter {
44    mp4_filename: String,
45    ffmpeg_wtr: FfmpegWriter,
46    mp4_cfg: Mp4RecordingConfig,
47    srt_file_path: String,
48    swtr: srt_writer::BufferingSrtFrameWriter,
49    json_file_path: Option<String>,
50}
51
52impl FfmpegReWriter {
53    pub fn new(
54        mp4_path: impl AsRef<std::path::Path>,
55        ffmpeg_codec_args: FfmpegCodecArgs,
56        rate: Option<(usize, usize)>,
57        h264_metadata: Option<H264Metadata>,
58    ) -> Result<Self> {
59        tracing::debug!(
60            "Creating FfmpegReWriter for {} with h264_metadata: {h264_metadata:?}",
61            mp4_path.as_ref().display()
62        );
63        let mp4_filename = PathBuf::from(mp4_path.as_ref())
64            .into_os_string()
65            .into_string()
66            .map_err(|_| Error::FilenameNotUnicode)?;
67        let basename = if let Some(basename) = mp4_filename.strip_suffix(".mp4") {
68            basename
69        } else {
70            return Err(Error::FilenameDoesNotEndWithMp4);
71        };
72
73        // Choose filename that makes conflict unlikely if the user also writes
74        // an SRT file. They are likely to use "{basename}.srt" as this will
75        // then play in VLC and likely other players. As this SRT file is only
76        // temporary, it doesn't matter much what exactly it is called, but it
77        // shouldn't have a high likelihood of conflict.
78        let srt_file_path = format!("{basename}-ffmpeg-rewriter.srt");
79        let json_file_path = if let Some(h264_metadata) = &h264_metadata {
80            // Save the metadata to a file in case we crash before
81            // Self::close(). That way this information can be recovered.
82            let jpath = format!("{basename}-metadata.json");
83            let buf = serde_json::to_string(h264_metadata)?;
84            std::fs::write(&jpath, buf)?;
85            Some(jpath)
86        } else {
87            None
88        };
89
90        let ffmpeg_wtr = FfmpegWriter::new(&mp4_filename, ffmpeg_codec_args, rate)?;
91        let mp4_cfg = Mp4RecordingConfig {
92            codec: Mp4Codec::H264RawStream,
93            max_framerate: RecordingFrameRate::Unlimited,
94            h264_metadata: h264_metadata.clone(),
95        };
96
97        let out_fd = std::fs::File::create(&srt_file_path)?;
98        let swtr = srt_writer::BufferingSrtFrameWriter::new(Box::new(out_fd));
99        Ok(Self {
100            mp4_filename: mp4_filename.to_string(),
101            ffmpeg_wtr,
102            mp4_cfg,
103            srt_file_path,
104            swtr,
105            json_file_path,
106        })
107    }
108
109    /// Write a frame and timestamp.
110    pub fn write_dynamic_frame<TS>(
111        &mut self,
112        frame: &strand_dynamic_frame::DynamicFrame,
113        timestamp: TS,
114    ) -> Result<()>
115    where
116        TS: Into<DateTime<Local>>,
117    {
118        let timestamp = timestamp.into();
119
120        let mp4_pts = self
121            .ffmpeg_wtr
122            .write_dynamic_frame(frame)
123            .map_err(Error::FfmpegWriter)?;
124
125        let msg = SrtMsg { timestamp };
126        let msg = serde_json::to_string(&msg).unwrap();
127        self.swtr.add_frame(mp4_pts, msg)?;
128        self.swtr.flush()?;
129
130        Ok(())
131    }
132
133    pub fn close(self) -> Result<()> {
134        // finish with ffmpeg and finish writing SRT
135        self.ffmpeg_wtr.close()?;
136        self.swtr.close()?;
137        tracing::debug!("Done creating original .mp4 and .srt files.");
138
139        // Create reader for h264 data from .mp4 and timestamps from .srt.
140        let mut frame_src = frame_source::FrameSourceBuilder::new(&self.mp4_filename)
141            .do_decode_h264(false)
142            .timestamp_source(frame_source::TimestampSource::SrtFile)
143            .srt_file_path(Some(PathBuf::from(&self.srt_file_path)))
144            .build_h264_in_mp4_source()?;
145
146        let frame0_time = frame_src.frame0_time().unwrap();
147
148        // Create new .mp4 file, also with original h264 metadata.
149        let fname2 = format!("{}-rewritten.mp4", self.mp4_filename);
150        tracing::debug!(
151            "Copying original .mp4 file into new .mp4 files with timestamps and metadata. frame0_time: {frame0_time}, mp4_cfg: {:?}",
152            self.mp4_cfg
153        );
154        let fd = std::fs::File::create(&fname2)?;
155        let mut new_mp4 = mp4_writer::Mp4Writer::new(fd, self.mp4_cfg, None)?;
156        let h264_src = frame_src.as_seekable_h264_source();
157        new_mp4.set_first_sps_pps(h264_src.first_sps(), h264_src.first_pps());
158
159        let insert_precision_timestamp = true;
160        let width = frame_src.width();
161        let height = frame_src.height();
162
163        // Snapshot the source's per-sample timing (stts + ctts) before
164        // iterating (which borrows `frame_src` mutably). Preserving this timing
165        // verbatim is what keeps reordered (B-frame) streams correct: the
166        // container ordering comes from the source, while the precise capture
167        // time is carried per-frame in the precision-timestamp SEI.
168        let sample_timing: Option<Vec<_>> = frame_src.mp4_sample_timing().map(|t| t.to_vec());
169
170        let mut count = 0;
171        for frame in frame_src.decode_order_iter() {
172            let frame = frame?;
173            let timestamp = frame0_time + frame.timestamp().unwrap_duration();
174            let idx = frame.idx();
175            let data = match frame.image() {
176                frame_source::ImageData::EncodedH264(data) => &data.data,
177                _ => {
178                    return Err(Error::SourceIsNotH264);
179                }
180            };
181            match sample_timing.as_ref().and_then(|t| t.get(idx)) {
182                Some(st) => new_mp4.write_h264_buf_passthrough(
183                    data,
184                    width,
185                    height,
186                    st.decode_duration,
187                    st.composition_offset,
188                    timestamp,
189                    insert_precision_timestamp,
190                )?,
191                None => new_mp4.write_h264_buf(
192                    data,
193                    width,
194                    height,
195                    timestamp,
196                    frame0_time,
197                    insert_precision_timestamp,
198                )?,
199            }
200            count += 1;
201        }
202
203        new_mp4.finish()?;
204        tracing::debug!("Finished writing new .mp4 file with {count} frames.");
205
206        tracing::debug!(
207            "Renaming new .mp4 file to original .mp4 \
208            name, thereby deleting original."
209        );
210        std::fs::rename(fname2, self.mp4_filename)?;
211
212        // Remove no longer need .srt and .json files.
213        std::fs::remove_file(&self.srt_file_path)?;
214        if let Some(jpath) = self.json_file_path {
215            std::fs::remove_file(jpath)?;
216        }
217
218        Ok(())
219    }
220}
221
222#[cfg(test)]
223mod test {
224    use super::*;
225    use machine_vision_formats::{owned::OImage, pixel_format::RGB8};
226
227    use test_log::test;
228
229    #[test]
230    fn test_ffmpeg_rewriter() -> Result<()> {
231        let tempdir = tempfile::tempdir()?;
232        let mp4_fname = tempdir.path().join("out.mp4");
233
234        // let mp4_fname = "out.mp4";
235
236        let timestamp_micros: i64 = 1_662_921_288_000_000; // Sun, 11 Sep 2022 18:34:48 UTC
237
238        let mut timestamps = vec![
239            DateTime::from_timestamp_micros(timestamp_micros).unwrap(),
240            DateTime::from_timestamp_micros(timestamp_micros + 1).unwrap(),
241            DateTime::from_timestamp_micros(timestamp_micros + 100).unwrap(),
242        ];
243
244        for delta in 1..10 {
245            let micros = delta * 10_000;
246            timestamps.push(DateTime::from_timestamp_micros(timestamp_micros + micros).unwrap());
247        }
248
249        tracing::debug!("Encoding {} frames", timestamps.len());
250
251        let w = 640;
252        let h = 480;
253        {
254            // let ffmpeg_codec_args = ffmpeg_writer::platform_hardware_encoder()?;
255            let ffmpeg_codec_args = Default::default();
256
257            let rate = None;
258            let h264_metadata = None;
259            let mut wtr = FfmpegReWriter::new(&mp4_fname, ffmpeg_codec_args, rate, h264_metadata)?;
260
261            for (i, ts) in timestamps.iter().enumerate() {
262                let value = (i % 255) as u8;
263                let frame: OImage<RGB8> = OImage::new(
264                    w,
265                    h,
266                    w as usize * 3,
267                    vec![value; w as usize * h as usize * 3],
268                )
269                .unwrap();
270                let frame = strand_dynamic_frame::DynamicFrameOwned::from_static(frame);
271                wtr.write_dynamic_frame(&frame.borrow(), *ts)?;
272            }
273            wtr.close()?;
274        }
275
276        let mut frame_src = frame_source::FrameSourceBuilder::new(&mp4_fname)
277            .do_decode_h264(false)
278            .timestamp_source(frame_source::TimestampSource::MispMicrosectime)
279            .build_source()?;
280
281        let frame0_time = frame_src.frame0_time().unwrap();
282        assert_eq!(frame0_time, timestamps[0]);
283
284        assert_eq!(frame_src.width(), w);
285        assert_eq!(frame_src.height(), h);
286
287        // Frames are read back in decode order, which differs from
288        // presentation (input) order for B-frame streams. Every precise
289        // timestamp must nonetheless round-trip intact; the per-frame SEI
290        // carries each frame's own capture time regardless of decode order.
291        // (Correct playback *ordering* is covered by the end-to-end smoke test.)
292        let mut got: Vec<_> = Vec::new();
293        for frame in frame_src.decode_order_iter() {
294            let frame = frame?;
295            got.push(frame0_time + frame.timestamp().unwrap_duration());
296        }
297        assert_eq!(got.len(), timestamps.len());
298        got.sort();
299        let mut expected = timestamps.clone();
300        expected.sort();
301        assert_eq!(got, expected);
302
303        Ok(())
304    }
305
306    /// Regression test for re-muxing a reordered (B-frame) stream so that it
307    /// plays back in the correct presentation order.
308    ///
309    /// The intermediate libx264 pass stores frames in *decode* order with
310    /// non-zero composition offsets (B-frames). Before the fix, [`FfmpegReWriter`]
311    /// could not represent this: `mp4-writer` hardcoded a zero composition
312    /// offset (no `ctts`) and paired each SRT capture time with the frame by
313    /// decode index, so the re-muxed file had its capture times scrambled
314    /// relative to the true display order (frames played e.g. 5,1,2,3,4,...).
315    ///
316    /// Here we force B-frames, re-mux, and then read the result back. We
317    /// reconstruct each sample's presentation time from the container timing
318    /// (`stts` decode duration + `ctts` composition offset) and assert that,
319    /// walked in presentation order, the per-frame precision-timestamp SEI
320    /// capture times are strictly increasing — i.e. the file plays in order.
321    /// Prior to the fix (composition offset forced to zero, SEI paired by
322    /// decode index) this ordering was violated.
323    #[test]
324    fn test_bframe_stream_remuxes_in_presentation_order() -> Result<()> {
325        use frame_source::h264_source::Mp4SampleTiming;
326
327        let tempdir = tempfile::tempdir()?;
328        let mp4_fname = tempdir.path().join("out.mp4");
329
330        // 25 fps nominal cadence; the SRT carries the real (here identical)
331        // capture times.
332        let n_frames = 24usize;
333        let base_micros: i64 = 1_662_921_288_000_000; // Sun, 11 Sep 2022 18:34:48 UTC
334        let frame_interval_micros = 40_000i64; // 25 fps
335        let timestamps: Vec<_> = (0..n_frames)
336            .map(|i| {
337                DateTime::from_timestamp_micros(base_micros + i as i64 * frame_interval_micros)
338                    .unwrap()
339            })
340            .collect();
341
342        let w = 64u32;
343        let h = 48u32;
344        {
345            // Force libx264 to insert a fixed pattern of B-frames (b_adapt=0
346            // takes the content out of the decision) so the re-mux definitely
347            // exercises the reordered path. A single keyframe keeps one GOP.
348            let ffmpeg_codec_args = FfmpegCodecArgs {
349                device_args: None,
350                pre_codec_args: None,
351                codec: Some("libx264".to_string()),
352                post_codec_args: Some(vec![
353                    ("-bf".to_string(), "3".to_string()),
354                    (
355                        "-x264-params".to_string(),
356                        "b_adapt=0:scenecut=0:keyint=1000:min-keyint=1000".to_string(),
357                    ),
358                ]),
359                pixfmt: Some("yuv420p".to_string()),
360                // This test deliberately forces B-frames via `-bf 3` in
361                // `post_codec_args` to exercise reordering, so do not emit the
362                // default `-bf 0`.
363                max_bframes: None,
364            };
365
366            let mut wtr = FfmpegReWriter::new(&mp4_fname, ffmpeg_codec_args, None, None)?;
367
368            for (i, ts) in timestamps.iter().enumerate() {
369                // Vary the content per frame so the encoder has real motion to
370                // reorder around.
371                let mut data = vec![0u8; w as usize * h as usize * 3];
372                for (px, chunk) in data.chunks_exact_mut(3).enumerate() {
373                    let v = ((px + i * 7) % 256) as u8;
374                    chunk[0] = v;
375                    chunk[1] = v.wrapping_mul(3);
376                    chunk[2] = v.wrapping_add(i as u8 * 11);
377                }
378                let frame: OImage<RGB8> = OImage::new(w, h, w as usize * 3, data).unwrap();
379                let frame = strand_dynamic_frame::DynamicFrameOwned::from_static(frame);
380                wtr.write_dynamic_frame(&frame.borrow(), *ts)?;
381            }
382            wtr.close()?;
383        }
384
385        // Read the re-muxed file back, keeping the H264 in decode order and
386        // recovering the container timing so we can reconstruct presentation
387        // order.
388        let mut frame_src = frame_source::FrameSourceBuilder::new(&mp4_fname)
389            .do_decode_h264(false)
390            .timestamp_source(frame_source::TimestampSource::MispMicrosectime)
391            .build_h264_in_mp4_source()?;
392
393        let frame0_time = frame_src.frame0_time().unwrap();
394
395        // Snapshot per-sample timing (stts + ctts) before iterating (which
396        // borrows the source mutably).
397        let sample_timing: Vec<Mp4SampleTiming> = frame_src
398            .mp4_sample_timing()
399            .expect("MP4 source must expose per-sample timing")
400            .to_vec();
401        assert_eq!(sample_timing.len(), n_frames);
402
403        // The re-mux is only meaningful as a reordering test if the encoder
404        // actually produced B-frames (non-zero composition offsets).
405        let has_reordering = sample_timing
406            .iter()
407            .any(|t| t.composition_offset != chrono::Duration::zero());
408        assert!(
409            has_reordering,
410            "expected libx264 to emit B-frames (non-zero ctts); test would be vacuous otherwise"
411        );
412
413        // Collect the SEI capture time for each sample, in decode order.
414        let mut sei_times = vec![None; n_frames];
415        for frame in frame_src.decode_order_iter() {
416            let frame = frame?;
417            sei_times[frame.idx()] = Some(frame0_time + frame.timestamp().unwrap_duration());
418        }
419
420        // Reconstruct each sample's presentation time: presentation = decode +
421        // composition_offset, where the decode time is the running sum of the
422        // per-sample decode durations (stts), all in decode order.
423        let mut decode_time = chrono::Duration::zero();
424        let mut presentation = Vec::with_capacity(n_frames);
425        for (i, timing) in sample_timing.iter().enumerate() {
426            let pts = decode_time
427                + chrono::Duration::from_std(timing.decode_duration).unwrap()
428                + timing.composition_offset;
429            let sei = sei_times[i].expect("every sample must carry a SEI timestamp");
430            presentation.push((pts, sei));
431            decode_time += chrono::Duration::from_std(timing.decode_duration).unwrap();
432        }
433
434        // Walk samples in presentation order and assert the SEI capture times
435        // are strictly increasing: the file plays back in the order it was
436        // recorded.
437        presentation.sort_by_key(|(pts, _)| *pts);
438        let ordered_sei: Vec<_> = presentation.iter().map(|(_, sei)| *sei).collect();
439        for pair in ordered_sei.windows(2) {
440            assert!(
441                pair[0] < pair[1],
442                "SEI capture times must strictly increase in presentation order, \
443                 but got {:?} then {:?} (out-of-order playback)",
444                pair[0],
445                pair[1]
446            );
447        }
448
449        // And the set of capture times must match what we wrote.
450        assert_eq!(ordered_sei, timestamps);
451
452        Ok(())
453    }
454}