dash_mpd/
ffmpeg.rs

1//! Muxing support using mkvmerge/ffmpeg/vlc/mp4box as a subprocess.
2//
3// Also see the alternative method of using ffmpeg via its "libav" shared library API, implemented
4// in file "libav.rs".
5
6// TODO: on Linux we should try to use bubblewrap to execute the muxers in a sandboxed environment,
7// along the lines of
8//
9//  bwrap --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 --ro-bind /etc /etc --dev /dev --tmpfs /tmp --bind ~/Vidéos/foo.mkv /tmp/video.mkv -- /usr/bin/ffprobe /tmp/video.mkv
10
11
12use std::env;
13use std::io;
14use std::io::{Write, BufReader, BufWriter};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use fs_err as fs;
18use fs::File;
19use ffprobe::ffprobe;
20use tracing::{trace, info, warn, error};
21use crate::DashMpdError;
22use crate::fetch::{DashDownloader, partial_process_output};
23use crate::media::{
24    audio_container_type,
25    video_container_type,
26    container_has_video,
27    container_has_audio,
28    temporary_outpath,
29    AudioTrack,
30};
31
32fn ffprobe_start_time(input: &Path) -> Result<f64, DashMpdError> {
33    match ffprobe(input) {
34        Ok(info) => if let Some(st) = info.format.start_time {
35            Ok(st.parse::<f64>()
36                .map_err(|_| DashMpdError::Io(
37                    io::Error::other("reading start_time"),
38                    String::from("")))?)
39        } else {
40            Ok(0.0)
41        },
42        Err(e) => {
43            warn!("Error probing metadata on {}: {e:?}", input.display());
44            Ok(0.0)
45        },
46    }
47}
48
49// Mux one video track with multiple audio tracks
50#[tracing::instrument(level="trace", skip(downloader))]
51pub fn mux_multiaudio_video_ffmpeg(
52    downloader: &DashDownloader,
53    output_path: &Path,
54    audio_tracks: &Vec<AudioTrack>,
55    video_path: &Path) -> Result<(), DashMpdError> {
56    if audio_tracks.is_empty() {
57        return Err(DashMpdError::Muxing(String::from("no audio tracks")));
58    }
59    let container = match output_path.extension() {
60        Some(ext) => ext.to_str().unwrap_or("mp4"),
61        None => "mp4",
62    };
63    // See output from "ffmpeg -muxers"
64    let muxer = match container {
65        "mkv" => "matroska",
66        "ts" => "mpegts",
67        _ => container,
68    };
69    let tmpout = tempfile::Builder::new()
70        .prefix("dashmpdrs")
71        .suffix(&format!(".{container}"))
72        .rand_bytes(5)
73        .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
74        .tempfile()
75        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
76    let tmppath = tmpout
77        .path()
78        .to_str()
79        .ok_or_else(|| DashMpdError::Io(
80            io::Error::other("obtaining tmpfile name"),
81            String::from("")))?;
82    let video_str = video_path
83        .to_str()
84        .ok_or_else(|| DashMpdError::Io(
85            io::Error::other("obtaining videopath name"),
86            String::from("")))?;
87    if downloader.verbosity > 0 {
88        info!("  Muxing audio ({} track{}) and video content with ffmpeg",
89              audio_tracks.len(),
90              if audio_tracks.len() == 1 { "" } else { "s" });
91        if let Ok(attr) = fs::metadata(video_path) {
92            info!("  Video file {} of size {} octets", video_path.display(), attr.len());
93        }
94    }
95    let mut args = vec![
96        String::from("-hide_banner"),
97        String::from("-nostats"),
98        String::from("-loglevel"), String::from("error"),  // or "warning", "info"
99        String::from("-y"),  // overwrite output file if it exists
100        String::from("-nostdin")];
101    let mut mappings = Vec::new();
102    mappings.push(String::from("-map"));
103    mappings.push(String::from("0:v"));
104    args.push(String::from("-i"));
105    args.push(String::from(video_str));
106    // https://superuser.com/questions/1078298/ffmpeg-combine-multiple-audio-files-and-one-video-in-to-the-multi-language-vid
107    for (i, at) in audio_tracks.iter().enumerate() {
108        // note that the -map commandline argument counts from 1, whereas the -metadata argument counts from 0
109        mappings.push(String::from("-map"));
110        mappings.push(format!("{}:a", i+1));
111        mappings.push(format!("-metadata:s:a:{i}"));
112        let mut lang_sanitized = at.language.clone();
113        lang_sanitized.retain(|c: char| c.is_ascii_lowercase());
114        mappings.push(format!("language={lang_sanitized}"));
115        args.push(String::from("-i"));
116        let audio_str = at.path
117            .to_str()
118            .ok_or_else(|| DashMpdError::Io(
119                io::Error::other("obtaining audiopath name"),
120                String::from("")))?;
121        args.push(String::from(audio_str));
122    }
123    for m in mappings {
124        args.push(m);
125    }
126    args.push(String::from("-c:v"));
127    args.push(String::from("copy"));
128    args.push(String::from("-c:a"));
129    args.push(String::from("copy"));
130    args.push(String::from("-movflags"));
131    args.push(String::from("faststart"));
132    args.push(String::from("-preset"));
133    args.push(String::from("veryfast"));
134    // select the muxer explicitly (debateable whether this is better than ffmpeg's
135    // heuristics based on output filename)
136    args.push(String::from("-f"));
137    args.push(String::from(muxer));
138    args.push(String::from(tmppath));
139    if downloader.verbosity > 0 {
140        info!("  Running ffmpeg {}", args.join(" "));
141    }
142    let ffmpeg = Command::new(&downloader.ffmpeg_location)
143        .args(args)
144        .output()
145        .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg subprocess")))?;
146    let msg = partial_process_output(&ffmpeg.stdout);
147    if !msg.is_empty() {
148        info!("  ffmpeg stdout: {msg}");
149    }
150    let msg = partial_process_output(&ffmpeg.stderr);
151    if !msg.is_empty() {
152        info!("  ffmpeg stderr: {msg}");
153    }
154    if ffmpeg.status.success() {
155        // local scope so that tmppath is not busy on Windows and can be deleted
156        {
157            let tmpfile = File::open(tmppath)
158                .map_err(|e| DashMpdError::Io(e, String::from("opening ffmpeg output")))?;
159            let mut muxed = BufReader::new(tmpfile);
160            let outfile = File::create(output_path)
161                .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
162            let mut sink = BufWriter::new(outfile);
163            io::copy(&mut muxed, &mut sink)
164                .map_err(|e| DashMpdError::Io(e, String::from("copying ffmpeg output to output file")))?;
165        }
166        if env::var("DASHMPD_PERSIST_FILES").is_err() {
167	    if let Err(e) = fs::remove_file(tmppath) {
168                warn!("  Error deleting temporary ffmpeg output: {e}");
169            }
170        }
171        return Ok(());
172    }
173    // TODO: try again without -c:a copy and -c:v copy
174    Err(DashMpdError::Muxing(String::from("running ffmpeg")))
175}
176
177// ffmpeg can mux to many container types including mp4, mkv, avi
178#[tracing::instrument(level="trace", skip(downloader))]
179fn mux_audio_video_ffmpeg(
180    downloader: &DashDownloader,
181    output_path: &Path,
182    audio_tracks: &Vec<AudioTrack>,
183    video_path: &Path) -> Result<(), DashMpdError> {
184    let container = match output_path.extension() {
185        Some(ext) => ext.to_str().unwrap_or("mp4"),
186        None => "mp4",
187    };
188    // See output from "ffmpeg -muxers"
189    let muxer = match container {
190        "mkv" => "matroska",
191        "ts" => "mpegts",
192        _ => container,
193    };
194    let tmpout = tempfile::Builder::new()
195        .prefix("dashmpdrs")
196        .suffix(&format!(".{container}"))
197        .rand_bytes(5)
198        .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
199        .tempfile()
200        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
201    let tmppath = tmpout
202        .path()
203        .to_str()
204        .ok_or_else(|| DashMpdError::Io(
205            io::Error::other("obtaining tmpfile name"),
206            String::from("")))?;
207    let video_str = video_path
208        .to_str()
209        .ok_or_else(|| DashMpdError::Io(
210            io::Error::other("obtaining videopath name"),
211            String::from("")))?;
212    if downloader.verbosity > 0 {
213        info!("  Muxing audio ({} track{}) and video content with ffmpeg",
214              audio_tracks.len(),
215              if audio_tracks.len() == 1 { "" } else { "s" });
216        if let Ok(attr) = fs::metadata(video_path) {
217            info!("  Video file {} of size {} octets", video_path.display(), attr.len());
218        }
219    }
220    let mut audio_delay = 0.0;
221    let mut video_delay = 0.0;
222    if let Ok(audio_start_time) = ffprobe_start_time(&audio_tracks[0].path) {
223        if let Ok(video_start_time) = ffprobe_start_time(video_path) {
224            if audio_start_time > video_start_time {
225                video_delay = audio_start_time - video_start_time;
226            } else {
227                audio_delay = video_start_time - audio_start_time;
228            }
229        }
230    }
231    let mut args = vec![
232        String::from("-hide_banner"),
233        String::from("-nostats"),
234        String::from("-loglevel"), String::from("error"),  // or "warning", "info"
235        String::from("-y"),  // overwrite output file if it exists
236        String::from("-nostdin")];
237    let mut mappings = Vec::new();
238    mappings.push(String::from("-map"));
239    mappings.push(String::from("0:v"));
240    let vd = format!("{video_delay}");
241    if video_delay > 0.001 {
242        // "-itsoffset", &format!("{}", video_delay),
243        args.push(String::from("-ss"));
244        args.push(vd);
245    }
246    args.push(String::from("-i"));
247    args.push(String::from(video_str));
248    let ad = format!("{audio_delay}");
249    if audio_delay > 0.001 {
250        // "-itsoffset", &format!("{audio_delay}"),
251        args.push(String::from("-ss"));
252        args.push(ad);
253    }
254    // https://superuser.com/questions/1078298/ffmpeg-combine-multiple-audio-files-and-one-video-in-to-the-multi-language-vid
255    for (i, at) in audio_tracks.iter().enumerate() {
256        // Note that the -map commandline argument counts from 1, whereas the -metadata argument
257        // counts from 0.
258        mappings.push(String::from("-map"));
259        mappings.push(format!("{}:a", i+1));
260        mappings.push(format!("-metadata:s:a:{i}"));
261        let mut lang_sanitized = at.language.clone();
262        lang_sanitized.retain(|c: char| c.is_ascii_lowercase());
263        mappings.push(format!("language={lang_sanitized}"));
264        args.push(String::from("-i"));
265        let audio_str = at.path
266            .to_str()
267            .ok_or_else(|| DashMpdError::Io(
268                io::Error::other("obtaining audiopath name"),
269                String::from("")))?;
270        args.push(String::from(audio_str));
271    }
272    for m in mappings {
273        args.push(m);
274    }
275    args.push(String::from("-c:v"));
276    args.push(String::from("copy"));
277    args.push(String::from("-c:a"));
278    args.push(String::from("copy"));
279    args.push(String::from("-movflags"));
280    args.push(String::from("faststart"));
281    args.push(String::from("-preset"));
282    args.push(String::from("veryfast"));
283    // select the muxer explicitly (debateable whether this is better than ffmpeg's
284    // heuristics based on output filename)
285    args.push(String::from("-f"));
286    args.push(String::from(muxer));
287    args.push(String::from(tmppath));
288    if downloader.verbosity > 0 {
289        info!("  Running ffmpeg {}", args.join(" "));
290    }
291    let ffmpeg = Command::new(&downloader.ffmpeg_location)
292        .args(args.clone())
293        .output()
294        .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg subprocess")))?;
295    let msg = partial_process_output(&ffmpeg.stdout);
296    if !msg.is_empty() {
297        info!("  ffmpeg stdout: {msg}");
298    }
299    let msg = partial_process_output(&ffmpeg.stderr);
300    if !msg.is_empty() {
301        info!("  ffmpeg stderr: {msg}");
302    }
303    if ffmpeg.status.success() {
304        // local scope so that tmppath is not busy on Windows and can be deleted
305        {
306            let tmpfile = File::open(tmppath)
307                .map_err(|e| DashMpdError::Io(e, String::from("opening ffmpeg output")))?;
308            let mut muxed = BufReader::new(tmpfile);
309            let outfile = File::create(output_path)
310                .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
311            let mut sink = BufWriter::new(outfile);
312            io::copy(&mut muxed, &mut sink)
313                .map_err(|e| DashMpdError::Io(e, String::from("copying ffmpeg output to output file")))?;
314        }
315        if env::var("DASHMPD_PERSIST_FILES").is_err() {
316	    if let Err(e) = fs::remove_file(tmppath) {
317                warn!("  Error deleting temporary ffmpeg output: {e}");
318            }
319        }
320        return Ok(());
321    }
322    // The muxing may have failed only due to the "-c:v copy -c:a copy" argument to ffmpeg, which
323    // instructs it to copy the audio and video streams without any reencoding. That is not possible
324    // for certain output containers: for instance a WebM container must contain video using VP8,
325    // VP9 or AV1 codecs and Vorbis or Opus audio codecs. (Unfortunately, ffmpeg doesn't seem to
326    // return a distinct recognizable error message in this specific case.) So we try invoking
327    // ffmpeg again, this time allowing reencoding.
328    args.retain(|a| !(a.eq("-c:v") || a.eq("copy") || a.eq("-c:a")));
329    if downloader.verbosity > 0 {
330        info!("  Running ffmpeg {}", args.join(" "));
331    }
332    let ffmpeg = Command::new(&downloader.ffmpeg_location)
333        .args(args)
334        .output()
335        .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg subprocess")))?;
336    let msg = partial_process_output(&ffmpeg.stdout);
337    if !msg.is_empty() {
338        info!("  ffmpeg stdout: {msg}");
339    }
340    let msg = partial_process_output(&ffmpeg.stderr);
341    if !msg.is_empty() {
342        info!("  ffmpeg stderr: {msg}");
343    }
344    if ffmpeg.status.success() {
345        // local scope so that tmppath is not busy on Windows and can be deleted
346        {
347            let tmpfile = File::open(tmppath)
348                .map_err(|e| DashMpdError::Io(e, String::from("opening ffmpeg output")))?;
349            let mut muxed = BufReader::new(tmpfile);
350            let outfile = File::create(output_path)
351                .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
352            let mut sink = BufWriter::new(outfile);
353            io::copy(&mut muxed, &mut sink)
354                .map_err(|e| DashMpdError::Io(e, String::from("copying ffmpeg output to output file")))?;
355        }
356        if env::var("DASHMPD_PERSIST_FILES").is_err() {
357	    if let Err(e) = fs::remove_file(tmppath) {
358                warn!("  Error deleting temporary ffmpeg output: {e}");
359            }
360        }
361        Ok(())
362    } else {
363        Err(DashMpdError::Muxing(String::from("running ffmpeg")))
364    }
365}
366
367
368// See "ffmpeg -formats"
369fn ffmpeg_container_name(extension: &str) -> Option<String> {
370    match extension {
371        "mkv" => Some(String::from("matroska")),
372        "webm" => Some(String::from("webm")),
373        "avi" => Some(String::from("avi")),
374        "mov" => Some(String::from("mov")),
375        "mp4" => Some(String::from("mp4")),
376        "ts" => Some(String::from("mpegts")),
377        "ogg" => Some(String::from("ogg")),
378        "vob" => Some(String::from("vob")),
379        _ => None,
380    }
381}
382
383// This can be used to package either an audio stream or a video stream into the container format
384// that is determined by the extension of output_path.
385#[tracing::instrument(level="trace", skip(downloader))]
386fn mux_stream_ffmpeg(
387    downloader: &DashDownloader,
388    output_path: &Path,
389    input_path: &Path) -> Result<(), DashMpdError> {
390    let container = match output_path.extension() {
391        Some(ext) => ext.to_str().unwrap_or("mp4"),
392        None => "mp4",
393    };
394    info!("  ffmpeg inserting stream into {container} container named {}", output_path.display());
395    let tmpout = tempfile::Builder::new()
396        .prefix("dashmpdrs")
397        .suffix(&format!(".{container}"))
398        .rand_bytes(5)
399        .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
400        .tempfile()
401        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
402    let tmppath = tmpout
403        .path()
404        .to_str()
405        .ok_or_else(|| DashMpdError::Io(
406            io::Error::other("obtaining tmpfile name"),
407            String::from("")))?;
408    let input = input_path
409        .to_str()
410        .ok_or_else(|| DashMpdError::Io(
411            io::Error::other("obtaining input name"),
412            String::from("")))?;
413    let cn: String;
414    let mut args = vec!("-hide_banner",
415                        "-nostats",
416                        "-loglevel", "error",  // or "warning", "info"
417                        "-y",  // overwrite output file if it exists
418                        "-nostdin",
419                        "-i", input,
420                        "-movflags", "faststart", "-preset", "veryfast");
421    // We can select the muxer explicitly (otherwise it is determined using heuristics based on the
422    // filename extension).
423    if let Some(container_name) = ffmpeg_container_name(container) {
424        args.push("-f");
425        cn = container_name;
426        args.push(&cn);
427    }
428    args.push(tmppath);
429    if downloader.verbosity > 0 {
430        info!("  Running ffmpeg {}", args.join(" "));
431    }
432    let ffmpeg = Command::new(&downloader.ffmpeg_location)
433        .args(args)
434        .output()
435        .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg subprocess")))?;
436    let msg = partial_process_output(&ffmpeg.stdout);
437    if downloader.verbosity > 0 && !msg.is_empty() {
438        info!("  ffmpeg stdout: {msg}");
439    }
440    let msg = partial_process_output(&ffmpeg.stderr);
441    if downloader.verbosity > 0 && !msg.is_empty() {
442        info!("  ffmpeg stderr: {msg}");
443    }
444    if ffmpeg.status.success() {
445        // local scope so that tmppath is not busy on Windows and can be deleted
446        {
447            let tmpfile = File::open(tmppath)
448                .map_err(|e| DashMpdError::Io(e, String::from("opening ffmpeg output")))?;
449            let mut muxed = BufReader::new(tmpfile);
450            let outfile = File::create(output_path)
451                .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
452            let mut sink = BufWriter::new(outfile);
453            io::copy(&mut muxed, &mut sink)
454                .map_err(|e| DashMpdError::Io(e, String::from("copying ffmpeg output to output file")))?;
455        }
456        if env::var("DASHMPD_PERSIST_FILES").is_err() {
457	    if let Err(e) = fs::remove_file(tmppath) {
458                warn!("  Error deleting temporary ffmpeg output: {e}");
459            }
460        }
461        Ok(())
462    } else {
463        warn!("  unmuxed stream: {input}");
464        Err(DashMpdError::Muxing(String::from("running ffmpeg")))
465    }
466}
467
468
469// See https://wiki.videolan.org/Transcode/
470// VLC could also mux to an mkv container if needed
471#[tracing::instrument(level="trace", skip(downloader))]
472fn mux_audio_video_vlc(
473    downloader: &DashDownloader,
474    output_path: &Path,
475    audio_tracks: &Vec<AudioTrack>,
476    video_path: &Path) -> Result<(), DashMpdError> {
477    if audio_tracks.len() > 1 {
478        error!("Cannot mux more than a single audio track with VLC");
479        return Err(DashMpdError::Muxing(String::from("cannot mux more than one audio track with VLC")));
480    }
481    let audio_path = &audio_tracks[0].path;
482    let container = match output_path.extension() {
483        Some(ext) => ext.to_str().unwrap_or("mp4"),
484        None => "mp4",
485    };
486    let muxer = match container {
487        "ogg" => "ogg",
488        "webm" => "mkv",
489        "mp3" => "raw",
490        "mpg" => "mpeg1",
491        _ => container,
492    };
493    let tmpout = tempfile::Builder::new()
494        .prefix("dashmpdrs")
495        .suffix(".mp4")
496        .rand_bytes(5)
497        .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
498        .tempfile()
499        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
500    let tmppath = tmpout
501        .path()
502        .to_str()
503        .ok_or_else(|| DashMpdError::Io(
504            io::Error::other("obtaining tmpfile name"),
505            String::from("")))?;
506    let audio_str = audio_path
507        .to_str()
508        .ok_or_else(|| DashMpdError::Io(
509            io::Error::other("obtaining audiopath name"),
510            String::from("")))?;
511    let video_str = video_path
512        .to_str()
513        .ok_or_else(|| DashMpdError::Io(
514            io::Error::other("obtaining videopath name"),
515            String::from("")))?;
516    let transcode = if container.eq("webm") {
517        "transcode{vcodec=VP90,acodec=vorb}:"
518    } else {
519        ""
520    };
521    let sout = format!("--sout=#{transcode}std{{access=file,mux={muxer},dst={tmppath}}}");
522    let args = vec![
523        "-I", "dummy",
524        "--no-repeat", "--no-loop",
525        video_str,
526        "--input-slave", audio_str,
527        "--sout-mp4-faststart",
528        &sout,
529        "--sout-keep",
530        "vlc://quit"];
531    if downloader.verbosity > 0 {
532        info!("  Running vlc {}", args.join(" "));
533    }
534    let vlc = Command::new(&downloader.vlc_location)
535        .args(args)
536        .output()
537        .map_err(|e| DashMpdError::Io(e, String::from("spawning VLC subprocess")))?;
538    // VLC is erroneously returning a 0 (success) return code even when it fails to mux, so we need
539    // to look for a specific error message to check for failure.
540    let msg = partial_process_output(&vlc.stderr);
541    if downloader.verbosity > 0 && !msg.is_empty() {
542        info!("  vlc stderr: {msg}");
543    }
544    if vlc.status.success() && (!msg.contains("mp4 mux error")) {
545        {
546            let tmpfile = File::open(tmppath)
547                .map_err(|e| DashMpdError::Io(e, String::from("opening VLC output")))?;
548            let mut muxed = BufReader::new(tmpfile);
549            let outfile = File::create(output_path)
550                .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
551            let mut sink = BufWriter::new(outfile);
552            io::copy(&mut muxed, &mut sink)
553                .map_err(|e| DashMpdError::Io(e, String::from("copying VLC output to output file")))?;
554        }
555        if env::var("DASHMPD_PERSIST_FILES").is_err() {
556            if let Err(e) = fs::remove_file(tmppath) {
557                warn!("  Error deleting temporary VLC output: {e}");
558            }
559        }
560        Ok(())
561    } else {
562        let msg = partial_process_output(&vlc.stderr);
563        Err(DashMpdError::Muxing(format!("running VLC: {msg}")))
564    }
565}
566
567
568// MP4Box from the GPAC suite for muxing audio and video streams
569// https://github.com/gpac/gpac/wiki/MP4Box
570#[tracing::instrument(level="trace", skip(downloader))]
571fn mux_audio_video_mp4box(
572    downloader: &DashDownloader,
573    output_path: &Path,
574    audio_tracks: &Vec<AudioTrack>,
575    video_path: &Path) -> Result<(), DashMpdError> {
576    if audio_tracks.len() > 1 {
577        error!("Cannot mux more than a single audio track with MP4Box");
578        return Err(DashMpdError::Muxing(String::from("cannot mux more than one audio track with MP4Box")));
579    }
580    let audio_path = &audio_tracks[0].path;
581    let container = match output_path.extension() {
582        Some(ext) => ext.to_str().unwrap_or("mp4"),
583        None => "mp4",
584    };
585    let tmpout = tempfile::Builder::new()
586        .prefix("dashmpdrs")
587        .suffix(&format!(".{container}"))
588        .rand_bytes(5)
589        .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
590        .tempfile()
591        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
592    let tmppath = tmpout
593        .path()
594        .to_str()
595        .ok_or_else(|| DashMpdError::Io(
596            io::Error::other("obtaining tmpfile name"),
597            String::from("")))?;
598    let audio_str = audio_path
599        .to_str()
600        .ok_or_else(|| DashMpdError::Io(
601            io::Error::other("obtaining audiopath name"),
602            String::from("")))?;
603    let video_str = video_path
604        .to_str()
605        .ok_or_else(|| DashMpdError::Io(
606            io::Error::other("obtaining videopath name"),
607            String::from("")))?;
608    let args = vec![
609        "-flat",
610        "-add", video_str,
611        "-add", audio_str,
612        "-new", tmppath];
613    if downloader.verbosity > 0 {
614        info!("  Running MP4Box {}", args.join(" "));
615    }
616    let cmd = Command::new(&downloader.mp4box_location)
617        .args(args)
618        .output()
619        .map_err(|e| DashMpdError::Io(e, String::from("spawning MP4Box subprocess")))?;
620    let msg = partial_process_output(&cmd.stderr);
621    if downloader.verbosity > 0 && !msg.is_empty() {
622        info!("  MP4Box stderr: {msg}");
623    }
624    if cmd.status.success() {
625        {
626            let tmpfile = File::open(tmppath)
627                .map_err(|e| DashMpdError::Io(e, String::from("opening MP4Box output")))?;
628            let mut muxed = BufReader::new(tmpfile);
629            let outfile = File::create(output_path)
630                .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
631            let mut sink = BufWriter::new(outfile);
632            io::copy(&mut muxed, &mut sink)
633                .map_err(|e| DashMpdError::Io(e, String::from("copying MP4Box output to output file")))?;
634        }
635        if env::var("DASHMPD_PERSIST_FILES").is_err() {
636	    if let Err(e) = fs::remove_file(tmppath) {
637                warn!("  Error deleting temporary MP4Box output: {e}");
638            }
639        }
640        Ok(())
641    } else {
642        let msg = partial_process_output(&cmd.stderr);
643        Err(DashMpdError::Muxing(format!("running MP4Box: {msg}")))
644    }
645}
646
647// This can be used to package either an audio stream or a video stream into the container format
648// that is determined by the extension of output_path.
649#[tracing::instrument(level="trace", skip(downloader))]
650fn mux_stream_mp4box(
651    downloader: &DashDownloader,
652    output_path: &Path,
653    input_path: &Path) -> Result<(), DashMpdError> {
654    let container = match output_path.extension() {
655        Some(ext) => ext.to_str().unwrap_or("mp4"),
656        None => "mp4",
657    };
658    let tmpout = tempfile::Builder::new()
659        .prefix("dashmpdrs")
660        .suffix(&format!(".{container}"))
661        .rand_bytes(5)
662        .tempfile()
663        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
664    let tmppath = tmpout
665        .path()
666        .to_str()
667        .ok_or_else(|| DashMpdError::Io(
668            io::Error::other("obtaining tmpfile name"),
669            String::from("")))?;
670    let input = input_path
671        .to_str()
672        .ok_or_else(|| DashMpdError::Io(
673            io::Error::other("obtaining input stream name"),
674            String::from("")))?;
675    let args = vec!["-add", input, "-new", tmppath];
676    if downloader.verbosity > 0 {
677        info!("  Running MP4Box {}", args.join(" "));
678    }
679    let cmd = Command::new(&downloader.mp4box_location)
680        .args(args)
681        .output()
682        .map_err(|e| DashMpdError::Io(e, String::from("spawning MP4Box subprocess")))?;
683    let msg = partial_process_output(&cmd.stderr);
684    if downloader.verbosity > 0 && !msg.is_empty() {
685        info!("  MP4box stderr: {msg}");
686    }
687    if cmd.status.success() {
688        {
689            let tmpfile = File::open(tmppath)
690                .map_err(|e| DashMpdError::Io(e, String::from("opening MP4Box output")))?;
691            let mut muxed = BufReader::new(tmpfile);
692            let outfile = File::create(output_path)
693                .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
694            let mut sink = BufWriter::new(outfile);
695            io::copy(&mut muxed, &mut sink)
696                .map_err(|e| DashMpdError::Io(e, String::from("copying MP4Box output to output file")))?;
697        }
698        if env::var("DASHMPD_PERSIST_FILES").is_err() {
699	    if let Err(e) = fs::remove_file(tmppath) {
700                warn!("  Error deleting temporary MP4Box output: {e}");
701            }
702        }
703        Ok(())
704    } else {
705        let msg = partial_process_output(&cmd.stderr);
706        warn!("  MP4Box mux_stream failure: stdout {}", partial_process_output(&cmd.stdout));
707        warn!("  MP4Box stderr: {msg}");
708        Err(DashMpdError::Muxing(format!("running MP4Box: {msg}")))
709    }
710}
711
712#[tracing::instrument(level="trace", skip(downloader))]
713fn mux_audio_video_mkvmerge(
714    downloader: &DashDownloader,
715    output_path: &Path,
716    audio_tracks: &Vec<AudioTrack>,
717    video_path: &Path) -> Result<(), DashMpdError> {
718    if audio_tracks.len() > 1 {
719        error!("Cannot mux more than a single audio track with mkvmerge");
720        return Err(DashMpdError::Muxing(String::from("cannot mux more than one audio track with mkvmerge")));
721    }
722    let audio_path = &audio_tracks[0].path;
723    let tmppath = temporary_outpath(".mkv")?;
724    let audio_str = audio_path
725        .to_str()
726        .ok_or_else(|| DashMpdError::Io(
727            io::Error::other("obtaining audiopath name"),
728            String::from("")))?;
729    let video_str = video_path
730        .to_str()
731        .ok_or_else(|| DashMpdError::Io(
732            io::Error::other("obtaining videopath name"),
733            String::from("")))?;
734    let args = vec!["--output", &tmppath,
735                    "--no-video", audio_str,
736                    "--no-audio", video_str];
737    if downloader.verbosity > 0 {
738        info!("  Running mkvmerge {}", args.join(" "));
739    }
740    let mkv = Command::new(&downloader.mkvmerge_location)
741        .args(args)
742        .output()
743        .map_err(|e| DashMpdError::Io(e, String::from("spawning mkvmerge subprocess")))?;
744    let msg = partial_process_output(&mkv.stderr);
745    if downloader.verbosity > 0 && !msg.is_empty() {
746        info!("  mkvmerge stderr: {msg}");
747    }
748    if mkv.status.success() {
749        {
750            let tmpfile = File::open(&tmppath)
751                .map_err(|e| DashMpdError::Io(e, String::from("opening mkvmerge output")))?;
752            let mut muxed = BufReader::new(tmpfile);
753            let outfile = File::create(output_path)
754                .map_err(|e| DashMpdError::Io(e, String::from("opening output file")))?;
755            let mut sink = BufWriter::new(outfile);
756            io::copy(&mut muxed, &mut sink)
757                .map_err(|e| DashMpdError::Io(e, String::from("copying mkvmerge output to output file")))?;
758        }
759        if env::var("DASHMPD_PERSIST_FILES").is_err() {
760            if let Err(e) = fs::remove_file(tmppath) {
761                warn!("  Error deleting temporary mkvmerge output: {e}");
762            }
763        }
764        Ok(())
765    } else {
766        // mkvmerge writes error messages to stdout, not to stderr
767        let msg = String::from_utf8_lossy(&mkv.stdout);
768        Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
769    }
770}
771
772// Copy video stream at video_path into Matroska container at output_path.
773#[tracing::instrument(level="trace", skip(downloader))]
774fn mux_video_mkvmerge(
775    downloader: &DashDownloader,
776    output_path: &Path,
777    video_path: &Path) -> Result<(), DashMpdError> {
778    let tmppath = temporary_outpath(".mkv")?;
779    let video_str = video_path
780        .to_str()
781        .ok_or_else(|| DashMpdError::Io(
782            io::Error::other("obtaining videopath name"),
783            String::from("")))?;
784    let args = vec!["--output", &tmppath, "--no-audio", video_str];
785    if downloader.verbosity > 0 {
786        info!("  Running mkvmerge {}", args.join(" "));
787    }
788    let mkv = Command::new(&downloader.mkvmerge_location)
789        .args(args)
790        .output()
791        .map_err(|e| DashMpdError::Io(e, String::from("spawning mkvmerge subprocess")))?;
792    let msg = partial_process_output(&mkv.stderr);
793    if downloader.verbosity > 0 && !msg.is_empty() {
794        info!("  mkvmerge stderr: {msg}");
795    }
796    if mkv.status.success() {
797        {
798            let tmpfile = File::open(&tmppath)
799                .map_err(|e| DashMpdError::Io(e, String::from("opening mkvmerge output")))?;
800            let mut muxed = BufReader::new(tmpfile);
801            let outfile = File::create(output_path)
802                .map_err(|e| DashMpdError::Io(e, String::from("opening output file")))?;
803            let mut sink = BufWriter::new(outfile);
804            io::copy(&mut muxed, &mut sink)
805                .map_err(|e| DashMpdError::Io(e, String::from("copying mkvmerge output to output file")))?;
806        }
807        if env::var("DASHMPD_PERSIST_FILES").is_err() {
808            if let Err(e) = fs::remove_file(tmppath) {
809                warn!("  Error deleting temporary mkvmerge output: {e}");
810            }
811        }
812        Ok(())
813    } else {
814        // mkvmerge writes error messages to stdout, not to stderr
815        let msg = String::from_utf8_lossy(&mkv.stdout);
816        Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
817    }
818}
819
820
821// Copy audio stream at video_path into Matroska container at output_path.
822#[tracing::instrument(level="trace", skip(downloader))]
823fn mux_audio_mkvmerge(
824    downloader: &DashDownloader,
825    output_path: &Path,
826    audio_path: &Path) -> Result<(), DashMpdError> {
827    let tmppath = temporary_outpath(".mkv")?;
828    let audio_str = audio_path
829        .to_str()
830        .ok_or_else(|| DashMpdError::Io(
831            io::Error::other("obtaining audiopath name"),
832            String::from("")))?;
833    let args = vec!["--output", &tmppath, "--no-video", audio_str];
834    if downloader.verbosity > 0 {
835        info!("  Running mkvmerge {}", args.join(" "));
836    }
837    let mkv = Command::new(&downloader.mkvmerge_location)
838        .args(args)
839        .output()
840        .map_err(|e| DashMpdError::Io(e, String::from("spawning mkvmerge subprocess")))?;
841    let msg = partial_process_output(&mkv.stderr);
842    if downloader.verbosity > 0 && !msg.is_empty() {
843        info!("  mkvmerge stderr: {msg}");
844    }
845    if mkv.status.success() {
846        {
847            let tmpfile = File::open(&tmppath)
848                .map_err(|e| DashMpdError::Io(e, String::from("opening mkvmerge output")))?;
849            let mut muxed = BufReader::new(tmpfile);
850            let outfile = File::create(output_path)
851                .map_err(|e| DashMpdError::Io(e, String::from("opening output file")))?;
852            let mut sink = BufWriter::new(outfile);
853            io::copy(&mut muxed, &mut sink)
854                .map_err(|e| DashMpdError::Io(e, String::from("copying mkvmerge output to output file")))?;
855        }
856        if env::var("DASHMPD_PERSIST_FILES").is_err() {
857            if let Err(e) = fs::remove_file(tmppath) {
858                warn!("  Error deleting temporary mkvmerge output: {e}");
859            }
860        }
861        Ok(())
862    } else {
863        // mkvmerge writes error messages to stdout, not to stderr
864        let msg = String::from_utf8_lossy(&mkv.stdout);
865        Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
866    }
867}
868
869
870// Mux (merge) audio and video using an external tool, selecting the tool based on the output
871// container format and on the user-specified muxer preference ordering (e.g. "ffmpeg,vlc,mp4box")
872// or our hardcoded container-dependent preference ordering.
873#[tracing::instrument(level="trace", skip(downloader))]
874pub fn mux_audio_video(
875    downloader: &DashDownloader,
876    output_path: &Path,
877    audio_tracks: &Vec<AudioTrack>,
878    video_path: &Path) -> Result<(), DashMpdError> {
879    trace!("Muxing {} audio tracks with video {}", audio_tracks.len(), video_path.display());
880    let container = match output_path.extension() {
881        Some(ext) => ext.to_str().unwrap_or("mp4"),
882        None => "mp4",
883    };
884    let mut muxer_preference = vec![];
885    if container.eq("mkv") {
886        muxer_preference.push("mkvmerge");
887        muxer_preference.push("ffmpeg");
888        muxer_preference.push("mp4box");
889    } else if container.eq("webm") {
890        // VLC is a better default than ffmpeg, because ffmpeg (with the options we supply) doesn't
891        // automatically reencode the vidoe and audio streams when they are incompatible with the
892        // container format requested, whereas VLC does do so.
893        muxer_preference.push("vlc");
894        muxer_preference.push("ffmpeg");
895    } else if container.eq("mp4") {
896        muxer_preference.push("ffmpeg");
897        muxer_preference.push("vlc");
898        muxer_preference.push("mp4box");
899    } else {
900        muxer_preference.push("ffmpeg");
901        muxer_preference.push("mp4box");
902    }
903    if let Some(ordering) = downloader.muxer_preference.get(container) {
904        muxer_preference.clear();
905        for m in ordering.split(',') {
906            muxer_preference.push(m);
907        }
908    }
909    info!("  Muxer preference for {container} is {muxer_preference:?}");
910    for muxer in muxer_preference {
911        info!("  Trying muxer {muxer}");
912        if muxer.eq("mkvmerge") {
913            if let Err(e) =  mux_audio_video_mkvmerge(downloader, output_path, audio_tracks, video_path) {
914                warn!("  Muxing with mkvmerge subprocess failed: {e}");
915            } else {
916                info!("  Muxing with mkvmerge subprocess succeeded");
917                return Ok(());
918            }
919        } else if muxer.eq("ffmpeg") {
920            if let Err(e) = mux_audio_video_ffmpeg(downloader, output_path, audio_tracks, video_path) {
921                warn!("  Muxing with ffmpeg subprocess failed: {e}");
922            } else {
923                info!("  Muxing with ffmpeg subprocess succeeded");
924                return Ok(());
925            }
926        } else if muxer.eq("vlc") {
927            if let Err(e) = mux_audio_video_vlc(downloader, output_path, audio_tracks, video_path) {
928                warn!("  Muxing with vlc subprocess failed: {e}");
929            } else {
930                info!("  Muxing with vlc subprocess succeeded");
931                return Ok(());
932            }
933        } else if muxer.eq("mp4box") {
934            if let Err(e) = mux_audio_video_mp4box(downloader, output_path, audio_tracks, video_path) {
935                warn!("  Muxing with MP4Box subprocess failed: {e}");
936            } else {
937                info!("  Muxing with MP4Box subprocess succeeded");
938                return Ok(());
939            }
940        } else {
941            warn!("  Ignoring unknown muxer preference {muxer}");
942        }
943    }
944    warn!("All muxers failed");
945    warn!("  unmuxed audio streams: {}", audio_tracks.len());
946    warn!("  unmuxed video stream: {}", video_path.display());
947    Err(DashMpdError::Muxing(String::from("all muxers failed")))
948}
949
950
951#[tracing::instrument(level="trace", skip(downloader))]
952pub fn copy_video_to_container(
953    downloader: &DashDownloader,
954    output_path: &Path,
955    video_path: &Path) -> Result<(), DashMpdError> {
956    trace!("Copying video {} to output container {}", video_path.display(), output_path.display());
957    let container = match output_path.extension() {
958        Some(ext) => ext.to_str().unwrap_or("mp4"),
959        None => "mp4",
960    };
961    // If the video stream is already in the desired container format, we can just copy it to the
962    // output file.
963    if video_container_type(video_path)?.eq(container) {
964        let tmpfile_video = File::open(video_path)
965            .map_err(|e| DashMpdError::Io(e, String::from("opening temporary video output file")))?;
966        let mut video = BufReader::new(tmpfile_video);
967        let output_file = File::create(output_path)
968            .map_err(|e| DashMpdError::Io(e, String::from("creating output file for video")))?;
969        let mut sink = BufWriter::new(output_file);
970        io::copy(&mut video, &mut sink)
971            .map_err(|e| DashMpdError::Io(e, String::from("copying video stream to output file")))?;
972        return Ok(());
973    }
974    let mut muxer_preference = vec![];
975    if container.eq("mkv") {
976        muxer_preference.push("mkvmerge");
977        muxer_preference.push("ffmpeg");
978        muxer_preference.push("mp4box");
979    } else {
980        muxer_preference.push("ffmpeg");
981        muxer_preference.push("mp4box");
982    }
983    if let Some(ordering) = downloader.muxer_preference.get(container) {
984        muxer_preference.clear();
985        for m in ordering.split(',') {
986            muxer_preference.push(m);
987        }
988    }
989    info!("  Muxer preference for {container} is {muxer_preference:?}");
990    for muxer in muxer_preference {
991        info!("  Trying muxer {muxer}");
992        if muxer.eq("mkvmerge") {
993            if let Err(e) =  mux_video_mkvmerge(downloader, output_path, video_path) {
994                warn!("  Muxing with mkvmerge subprocess failed: {e}");
995            } else {
996                info!("  Muxing with mkvmerge subprocess succeeded");
997                return Ok(());
998            }
999        } else if muxer.eq("ffmpeg") {
1000            if let Err(e) = mux_stream_ffmpeg(downloader, output_path, video_path) {
1001                warn!("  Muxing with ffmpeg subprocess failed: {e}");
1002            } else {
1003                info!("  Muxing with ffmpeg subprocess succeeded");
1004                return Ok(());
1005            }
1006        } else if muxer.eq("mp4box") {
1007            if let Err(e) = mux_stream_mp4box(downloader, output_path, video_path) {
1008                warn!("  Muxing with MP4Box subprocess failed: {e}");
1009            } else {
1010                info!("  Muxing with MP4Box subprocess succeeded");
1011                return Ok(());
1012            }
1013        }
1014    }
1015    warn!("  All available muxers failed");
1016    warn!("    unmuxed video stream: {}", video_path.display());
1017    Err(DashMpdError::Muxing(String::from("all available muxers failed")))
1018}
1019
1020
1021#[tracing::instrument(level="trace", skip(downloader))]
1022pub fn copy_audio_to_container(
1023    downloader: &DashDownloader,
1024    output_path: &Path,
1025    audio_path: &Path) -> Result<(), DashMpdError> {
1026    trace!("Copying audio {} to output container {}", audio_path.display(), output_path.display());
1027    let container = match output_path.extension() {
1028        Some(ext) => ext.to_str().unwrap_or("mp4"),
1029        None => "mp4",
1030    };
1031    // If the audio stream is already in the desired container format, we can just copy it to the
1032    // output file.
1033    if audio_container_type(audio_path)?.eq(container) {
1034        let tmpfile_video = File::open(audio_path)
1035            .map_err(|e| DashMpdError::Io(e, String::from("opening temporary output file")))?;
1036        let mut video = BufReader::new(tmpfile_video);
1037        let output_file = File::create(output_path)
1038            .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
1039        let mut sink = BufWriter::new(output_file);
1040        io::copy(&mut video, &mut sink)
1041            .map_err(|e| DashMpdError::Io(e, String::from("copying audio stream to output file")))?;
1042        return Ok(());
1043    }
1044    let mut muxer_preference = vec![];
1045    if container.eq("mkv") {
1046        muxer_preference.push("mkvmerge");
1047        muxer_preference.push("ffmpeg");
1048        muxer_preference.push("mp4box");
1049    } else {
1050        muxer_preference.push("ffmpeg");
1051        muxer_preference.push("mp4box");
1052    }
1053    if let Some(ordering) = downloader.muxer_preference.get(container) {
1054        muxer_preference.clear();
1055        for m in ordering.split(',') {
1056            muxer_preference.push(m);
1057        }
1058    }
1059    info!("  Muxer preference for {container} is {muxer_preference:?}");
1060    for muxer in muxer_preference {
1061        info!("  Trying muxer {muxer}");
1062        if muxer.eq("mkvmerge") {
1063            if let Err(e) =  mux_audio_mkvmerge(downloader, output_path, audio_path) {
1064                warn!("  Muxing with mkvmerge subprocess failed: {e}");
1065            } else {
1066                info!("  Muxing with mkvmerge subprocess succeeded");
1067                return Ok(());
1068            }
1069        } else if muxer.eq("ffmpeg") {
1070            if let Err(e) = mux_stream_ffmpeg(downloader, output_path, audio_path) {
1071                warn!("  Muxing with ffmpeg subprocess failed: {e}");
1072            } else {
1073                info!("  Muxing with ffmpeg subprocess succeeded");
1074                return Ok(());
1075            }
1076        } else if muxer.eq("mp4box") {
1077            if let Err(e) = mux_stream_mp4box(downloader, output_path, audio_path) {
1078                warn!("  Muxing with MP4Box subprocess failed: {e}");
1079            } else {
1080                info!("  Muxing with MP4Box subprocess succeeded");
1081                return Ok(());
1082            }
1083        }
1084    }
1085    warn!("  All available muxers failed");
1086    warn!("    unmuxed audio stream: {}", audio_path.display());
1087    Err(DashMpdError::Muxing(String::from("all available muxers failed")))
1088}
1089
1090
1091// Generate an appropriate "complex" filter for the ffmpeg concat filter.
1092// See https://trac.ffmpeg.org/wiki/Concatenate and
1093//  https://ffmpeg.org/ffmpeg-filters.html#concat
1094//
1095// Example for n=3: "[0:v:0][0:a:0][1:v:0][1:a:0][2:v:0][2:a:0]concat=n=3:v=1:a=1[outv][outa]"
1096//
1097// Example for n=2 with only audio:
1098//   -i /tmp/audio1 -i /tmp/audio2 -filter_complex "[0:a][1:a] concat=n=2:v=0:a=1 [outa]" -map "[outa]" 
1099#[tracing::instrument(level="trace")]
1100fn make_ffmpeg_concat_filter_args(paths: &[PathBuf]) -> Vec<String> {
1101    let n = paths.len();
1102    let mut args = Vec::new();
1103    let mut anullsrc = String::new();
1104    let mut link_labels = Vec::new();
1105    let mut have_audio = false;
1106    let mut have_video = false;
1107    for (i, path) in paths.iter().enumerate().take(n) {
1108        let mut included = false;
1109        if container_has_video(path) {
1110            included = true;
1111            args.push(String::from("-i"));
1112            args.push(path.display().to_string());
1113            have_video = true;
1114            link_labels.push(format!("[{i}:v]"));
1115        }
1116        if container_has_audio(path) {
1117            if !included {
1118                args.push(String::from("-i"));
1119                args.push(path.display().to_string());
1120            }
1121            link_labels.push(format!("[{i}:a]"));
1122            have_audio = true;
1123        } else {
1124            // Use a null audio src. Without this null audio track the concat filter is generating
1125            // errors, with ffmpeg version 6.1.1.
1126            anullsrc += &format!("anullsrc=r=48000:cl=mono:d=1[anull{i}:a];{anullsrc}");
1127            link_labels.push(format!("[anull{i}:a]"));
1128        }
1129    }
1130    let mut filter = String::new();
1131    // Only include the null audio track and the audio link labels to the concat filter when at
1132    // least one of our component segments has a audio component.
1133    if have_audio {
1134        filter += &anullsrc;
1135        filter += &link_labels.join("");
1136    } else {
1137        // We need to delete the link_labels of the form [anull{i}] that refer to null audio sources
1138        // that we aren't including in the filter graph.
1139        for ll in link_labels {
1140            if ! ll.starts_with("[anull") {
1141                filter += &ll;
1142            }
1143        }
1144    }
1145    filter += &format!(" concat=n={n}");
1146    if have_video {
1147        filter += ":v=1";
1148    } else {
1149        filter += ":v=0";
1150    }
1151    if have_audio {
1152        filter += ":a=1";
1153    } else {
1154        filter += ":a=0";
1155    }
1156    if have_video {
1157        filter += "[outv]";
1158    }
1159    if have_audio {
1160        filter += "[outa]";
1161    }
1162    args.push(String::from("-filter_complex"));
1163    args.push(filter);
1164    if have_video {
1165        args.push(String::from("-map"));
1166        args.push(String::from("[outv]"));
1167    }
1168    if have_audio {
1169        args.push(String::from("-map"));
1170        args.push(String::from("[outa]"));
1171    }
1172    args
1173}
1174
1175
1176/// This function concatenates files using the ffmpeg "concat filter". This reencodes all streams so
1177/// is slow, but works in situations where the concat protocol doesn't work.
1178#[tracing::instrument(level="trace", skip(downloader))]
1179pub(crate) fn concat_output_files_ffmpeg_filter(
1180    downloader: &DashDownloader,
1181    paths: &[PathBuf]) -> Result<(), DashMpdError>
1182{
1183    if paths.len() < 2 {
1184        return Err(DashMpdError::Muxing(String::from("need at least two files")));
1185    }
1186    let container = match paths[0].extension() {
1187        Some(ext) => ext.to_str().unwrap_or("mp4"),
1188        None => "mp4",
1189    };
1190    // See output from "ffmpeg -muxers"
1191    let output_format = match container {
1192        "mkv" => "matroska",
1193        "ts" => "mpegts",
1194        _ => container,
1195    };
1196    // First copy the contents of the first file to a temporary file, as ffmpeg will be overwriting the
1197    // contents of the first file.
1198    let tmpout = tempfile::Builder::new()
1199        .prefix("dashmpdrs")
1200        .suffix(&format!(".{container}"))
1201        .rand_bytes(5)
1202        .tempfile()
1203        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1204    let tmppath = &tmpout
1205        .path()
1206        .to_str()
1207        .ok_or_else(|| DashMpdError::Io(
1208            io::Error::other("obtaining tmpfile name"),
1209            String::from("")))?;
1210    fs::copy(paths[0].clone(), tmppath)
1211        .map_err(|e| DashMpdError::Io(e, String::from("copying first input path")))?;
1212    let mut args = vec!["-hide_banner", "-nostats",
1213                        "-loglevel", "error",  // or "warning", "info"
1214                        "-y",
1215                        "-nostdin"];
1216    let mut inputs = Vec::<PathBuf>::new();
1217    inputs.push(tmppath.into());
1218    for p in &paths[1..] {
1219        inputs.push(p.to_path_buf());
1220    }
1221    let filter_args = make_ffmpeg_concat_filter_args(&inputs);
1222    filter_args.iter().for_each(|a| args.push(a));
1223    args.push("-movflags");
1224    args.push("faststart+omit_tfhd_offset");
1225    args.push("-f");
1226    args.push(output_format);
1227    let target = paths[0].to_string_lossy();
1228    args.push(&target);
1229    if downloader.verbosity > 0 {
1230        info!("  Concatenating with ffmpeg concat filter {}", args.join(" "));
1231    }
1232    let ffmpeg = Command::new(&downloader.ffmpeg_location)
1233        .args(args)
1234        .output()
1235        .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg")))?;
1236    let msg = partial_process_output(&ffmpeg.stdout);
1237    if downloader.verbosity > 0 && !msg.is_empty() {
1238        info!("  ffmpeg stdout: {msg}");
1239    }
1240    let msg = partial_process_output(&ffmpeg.stderr);
1241    if downloader.verbosity > 0 && !msg.is_empty() {
1242        info!("  ffmpeg stderr: {msg}");
1243    }
1244    if ffmpeg.status.success() {
1245        Ok(())
1246    } else {
1247        warn!("  unconcatenated input files:");
1248        for p in paths {
1249            warn!("      {}", p.display());
1250        }
1251        Err(DashMpdError::Muxing(String::from("running ffmpeg")))
1252    }
1253}
1254
1255// This function concatenates files using the ffmpeg concat demuxer. All files must have the same
1256// streams (same codecs, same time base, etc.) but can be wrapped in different container formats.
1257// This concatenation helper is very fast because it copies the media streams, rather than
1258// reencoding them.
1259//
1260// In a typical use case of a multi-period DASH manifest with DAI (where Periods containing
1261// advertising have been intermixed with Periods of content), where it is possible to drop the
1262// advertising segments (using minimum_period_duration() or using an XSLT filter on Period
1263// elements), the content segments are likely to all use the same codecs and encoding parameters, so
1264// this helper should work well.
1265#[tracing::instrument(level="trace", skip(downloader))]
1266pub(crate) fn concat_output_files_ffmpeg_demuxer(
1267    downloader: &DashDownloader,
1268    paths: &[PathBuf]) -> Result<(), DashMpdError>
1269{
1270    if paths.len() < 2 {
1271        return Err(DashMpdError::Muxing(String::from("need at least two files")));
1272    }
1273    let container = match paths[0].extension() {
1274        Some(ext) => ext.to_str().unwrap_or("mp4"),
1275        None => "mp4",
1276    };
1277    // See output from "ffmpeg -muxers"
1278    let output_format = match container {
1279        "mkv" => "matroska",
1280        "ts" => "mpegts",
1281        _ => container,
1282    };
1283    // First copy the contents of the first file to a temporary file, as ffmpeg will be overwriting the
1284    // contents of the first file.
1285    let tmpout = tempfile::Builder::new()
1286        .prefix("dashmpdrs")
1287        .suffix(&format!(".{container}"))
1288        .rand_bytes(5)
1289        .tempfile()
1290        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1291    let tmppath = &tmpout
1292        .path()
1293        .to_str()
1294        .ok_or_else(|| DashMpdError::Io(
1295            io::Error::other("obtaining tmpfile name"),
1296            String::from("")))?;
1297    fs::copy(paths[0].clone(), tmppath)
1298        .map_err(|e| DashMpdError::Io(e, String::from("copying first input path")))?;
1299    let mut args = vec!["-hide_banner", "-nostats",
1300                        "-loglevel", "error",  // or "warning", "info"
1301                        "-y",
1302                        "-nostdin"];
1303    // https://trac.ffmpeg.org/wiki/Concatenate
1304    let demuxlist = tempfile::Builder::new()
1305        .prefix("dashmpddemux")
1306        .suffix(".txt")
1307        .rand_bytes(5)
1308        .tempfile()
1309        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1310    // https://ffmpeg.org/ffmpeg-formats.html#concat
1311    writeln!(&demuxlist, "ffconcat version 1.0")
1312        .map_err(|e| DashMpdError::Io(e, String::from("writing to demuxer cmd file")))?;
1313    let canonical = fs::canonicalize(tmppath)
1314        .map_err(|e| DashMpdError::Io(e, String::from("canonicalizing temporary filename")))?;
1315    writeln!(&demuxlist, "file '{}'", canonical.display())
1316        .map_err(|e| DashMpdError::Io(e, String::from("writing to demuxer cmd file")))?;
1317    for p in &paths[1..] {
1318        let canonical = fs::canonicalize(p)
1319            .map_err(|e| DashMpdError::Io(e, String::from("canonicalizing temporary filename")))?;
1320        writeln!(&demuxlist, "file '{}'", canonical.display())
1321            .map_err(|e| DashMpdError::Io(e, String::from("writing to demuxer cmd file")))?;
1322    }
1323    let demuxlistpath = &demuxlist
1324        .path()
1325        .to_str()
1326        .ok_or_else(|| DashMpdError::Io(
1327            io::Error::other("obtaining tmpfile name"),
1328            String::from("")))?;
1329    args.push("-f");
1330    args.push("concat");
1331    // We can't use "safe" file paths because our input files have names that are absolute, rather
1332    // than relative.
1333    args.push("-safe");
1334    args.push("0");
1335    args.push("-i");
1336    args.push(demuxlistpath);
1337    args.push("-c");
1338    args.push("copy");
1339    args.push("-movflags");
1340    args.push("faststart+omit_tfhd_offset");
1341    args.push("-f");
1342    args.push(output_format);
1343    let target = String::from("file:") + &paths[0].to_string_lossy();
1344    args.push(&target);
1345    if downloader.verbosity > 0 {
1346        info!("  Concatenating with ffmpeg concat demuxer {}", args.join(" "));
1347    }
1348    let ffmpeg = Command::new(&downloader.ffmpeg_location)
1349        .args(args)
1350        .output()
1351        .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg")))?;
1352    let msg = partial_process_output(&ffmpeg.stdout);
1353    if downloader.verbosity > 0 && !msg.is_empty() {
1354        info!("  ffmpeg stdout: {msg}");
1355    }
1356    let msg = partial_process_output(&ffmpeg.stderr);
1357    if downloader.verbosity > 0 && !msg.is_empty() {
1358        info!("  ffmpeg stderr: {msg}");
1359    }
1360    if ffmpeg.status.success() {
1361        Ok(())
1362    } else {
1363        warn!("  unconcatenated input files:");
1364        for p in paths {
1365            warn!("      {}", p.display());
1366        }
1367        Err(DashMpdError::Muxing(String::from("running ffmpeg")))
1368    }
1369}
1370
1371
1372// Merge all media files named by paths into the file named by the first element of the vector.
1373//
1374// This concat helper does not seem to work in a satisfactory manner.
1375#[tracing::instrument(level="trace", skip(downloader))]
1376pub(crate) fn concat_output_files_mp4box(
1377    downloader: &DashDownloader,
1378    paths: &[PathBuf]) -> Result<(), DashMpdError>
1379{
1380    if paths.len() < 2 {
1381        return Err(DashMpdError::Muxing(String::from("need at least two files")));
1382    }
1383    let tmpout = tempfile::Builder::new()
1384        .prefix("dashmpdrs")
1385        .suffix(".mp4")
1386        .rand_bytes(5)
1387        .tempfile()
1388        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1389    let tmppath = &tmpout
1390        .path()
1391        .to_str()
1392        .ok_or_else(|| DashMpdError::Io(
1393            io::Error::other("obtaining tmpfile name"),
1394            String::from("")))?;
1395    let mut tmpoutb = BufWriter::new(&tmpout);
1396    let overwritten = File::open(paths[0].clone())
1397        .map_err(|e| DashMpdError::Io(e, String::from("opening first container")))?;
1398    let mut overwritten = BufReader::new(overwritten);
1399    io::copy(&mut overwritten, &mut tmpoutb)
1400        .map_err(|e| DashMpdError::Io(e, String::from("copying from overwritten file")))?;
1401    // MP4Box -add file1.mp4 -cat file2.mp4 -cat file3.mp4 output.mp4"
1402    let out = paths[0].to_string_lossy();
1403    let mut args = vec!["-flat", "-add", &tmppath];
1404    for p in &paths[1..] {
1405        if let Some(ps) = p.to_str() {
1406            args.push("-cat");
1407            args.push(ps);
1408        } else {
1409            warn!("  Ignoring non-Unicode pathname {:?}", p);
1410        }
1411    }
1412    args.push(&out);
1413    if downloader.verbosity > 0 {
1414        info!("  Concatenating with MP4Box {}", args.join(" "));
1415    }
1416    let mp4box = Command::new(&downloader.mp4box_location)
1417        .args(args)
1418        .output()
1419        .map_err(|e| DashMpdError::Io(e, String::from("spawning MP4Box subprocess")))?;
1420    let msg = partial_process_output(&mp4box.stdout);
1421    if downloader.verbosity > 0 && !msg.is_empty() {
1422        info!("  MP4Box stdout: {msg}");
1423    }
1424    let msg = partial_process_output(&mp4box.stderr);
1425    if downloader.verbosity > 0 && !msg.is_empty() {
1426        info!("  MP4Box stderr: {msg}");
1427    }
1428    if mp4box.status.success() {
1429        Ok(())
1430    } else {
1431        warn!("  unconcatenated input files:");
1432        for p in paths {
1433            warn!("      {}", p.display());
1434        }
1435        Err(DashMpdError::Muxing(String::from("running MP4Box")))
1436    }
1437}
1438
1439#[tracing::instrument(level="trace", skip(downloader))]
1440pub(crate) fn concat_output_files_mkvmerge(
1441    downloader: &DashDownloader,
1442    paths: &[PathBuf]) -> Result<(), DashMpdError>
1443{
1444    if paths.len() < 2 {
1445        return Err(DashMpdError::Muxing(String::from("need at least two files")));
1446    }
1447    let tmpout = tempfile::Builder::new()
1448        .prefix("dashmpdrs")
1449        .suffix(".mkv")
1450        .rand_bytes(5)
1451        .tempfile()
1452        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1453    let tmppath = &tmpout
1454        .path()
1455        .to_str()
1456        .ok_or_else(|| DashMpdError::Io(
1457            io::Error::other("obtaining tmpfile name"),
1458            String::from("")))?;
1459    let mut tmpoutb = BufWriter::new(&tmpout);
1460    let overwritten = File::open(paths[0].clone())
1461        .map_err(|e| DashMpdError::Io(e, String::from("opening first container")))?;
1462    let mut overwritten = BufReader::new(overwritten);
1463    io::copy(&mut overwritten, &mut tmpoutb)
1464        .map_err(|e| DashMpdError::Io(e, String::from("copying from overwritten file")))?;
1465    // https://mkvtoolnix.download/doc/mkvmerge.html
1466    let mut args = Vec::new();
1467    if downloader.verbosity < 1 {
1468        args.push("--quiet");
1469    }
1470    args.push("-o");
1471    let out = paths[0].to_string_lossy();
1472    args.push(&out);
1473    args.push("[");
1474    args.push(tmppath);
1475    if let Some(inpaths) = paths.get(1..) {
1476        for p in inpaths {
1477            if let Some(ps) = p.to_str() {
1478                args.push(ps);
1479            }
1480        }
1481    }
1482    args.push("]");
1483    if downloader.verbosity > 1 {
1484        info!("  Concatenating with mkvmerge {}", args.join(" "));
1485    }
1486    let mkvmerge = Command::new(&downloader.mkvmerge_location)
1487        .args(args)
1488        .output()
1489        .map_err(|e| DashMpdError::Io(e, String::from("spawning mkvmerge")))?;
1490    let msg = partial_process_output(&mkvmerge.stdout);
1491    if downloader.verbosity > 0 && !msg.is_empty() {
1492        info!("  mkvmerge stdout: {msg}");
1493        println!("  mkvmerge stdout: {msg}");
1494    }
1495    let msg = partial_process_output(&mkvmerge.stderr);
1496    if downloader.verbosity > 0 && !msg.is_empty() {
1497        info!("  mkvmerge stderr: {msg}");
1498        println!("  mkvmerge stderr: {msg}");
1499    }
1500    if mkvmerge.status.success() {
1501        Ok(())
1502    } else {
1503        warn!("  unconcatenated input files:");
1504        for p in paths {
1505            warn!("      {}", p.display());
1506        }
1507        Err(DashMpdError::Muxing(String::from("running mkvmerge")))
1508    }
1509}
1510
1511// Merge all media files named by paths into the file named by the first element of the vector.
1512#[tracing::instrument(level="trace", skip(downloader))]
1513pub(crate) fn concat_output_files(
1514    downloader: &DashDownloader,
1515    paths: &[PathBuf]) -> Result<(), DashMpdError> {
1516    if paths.len() < 2 {
1517        return Ok(());
1518    }
1519    let container = if let Some(p0) = paths.first() {
1520        match p0.extension() {
1521            Some(ext) => ext.to_str().unwrap_or("mp4"),
1522            None => "mp4",
1523        }
1524    } else {
1525        "mp4"
1526    };
1527    let mut concat_preference = vec![];
1528    if container.eq("mp4") ||
1529        container.eq("mkv") ||
1530        container.eq("webm")
1531    {
1532        // We will probably make ffmpegdemuxer the default concat helper in a future release; it's
1533        // much more robust than mkvmerge and much faster than ffmpeg ("concat filter"). But wait
1534        // until it gets more testing.
1535        // concat_preference.push("ffmpegdemuxer");
1536        concat_preference.push("mkvmerge");
1537        concat_preference.push("ffmpeg");
1538    } else {
1539        concat_preference.push("ffmpeg");
1540    }
1541    if let Some(ordering) = downloader.concat_preference.get(container) {
1542        concat_preference.clear();
1543        for m in ordering.split(',') {
1544            concat_preference.push(m);
1545        }
1546    }
1547    info!("  Concat helper preference for {container} is {concat_preference:?}");
1548    for concat in concat_preference {
1549        info!("  Trying concat helper {concat}");
1550        if concat.eq("mkvmerge") {
1551            if let Err(e) = concat_output_files_mkvmerge(downloader, paths) {
1552                warn!("  Concatenation with mkvmerge failed: {e}");
1553            } else {
1554                info!("  Concatenation with mkvmerge succeeded");
1555                return Ok(());
1556            }
1557        } else if concat.eq("ffmpeg") {
1558            if let Err(e) = concat_output_files_ffmpeg_filter(downloader, paths) {
1559                warn!("  Concatenation with ffmpeg filter failed: {e}");
1560            } else {
1561                info!("  Concatenation with ffmpeg filter succeeded");
1562                return Ok(());
1563            }
1564        } else if concat.eq("ffmpegdemuxer") {
1565            if let Err(e) = concat_output_files_ffmpeg_demuxer(downloader, paths) {
1566                warn!("  Concatenation with ffmpeg demuxer failed: {e}");
1567            } else {
1568                info!("  Concatenation with ffmpeg demuxer succeeded");
1569                return Ok(());
1570            }
1571        } else if concat.eq("mp4box") {
1572            if let Err(e) = concat_output_files_mp4box(downloader, paths) {
1573                warn!("  Concatenation with MP4Box failed: {e}");
1574            } else {
1575                info!("  Concatenation with MP4Box succeeded");
1576                return Ok(());
1577            }
1578        } else {
1579            warn!("  Ignoring unknown concat helper preference {concat}");
1580        }
1581    }
1582    warn!("  All concat helpers failed");
1583    Err(DashMpdError::Muxing(String::from("all concat helpers failed")))
1584}
1585
1586
1587// Run these tests with "cargo test -- --nocapture" to see all tracing logs.
1588#[cfg(test)]
1589mod tests {
1590    use std::path::Path;
1591    use assert_cmd::Command;
1592    use fs_err as fs;
1593
1594    fn generate_mp4_hue_tone(filename: &Path, color: &str, tone: &str) {
1595        Command::new("ffmpeg")
1596            .args(["-y",  // overwrite output file if it exists
1597                   "-nostdin",
1598                   "-lavfi", &format!("color=c={color}:duration=5:size=50x50:rate=1;sine=frequency={tone}:sample_rate=48000:duration=5"),
1599                   // Force the use of the libx264 encoder. ffmpeg defaults to platform-specific
1600                   // encoders (which may allow hardware encoding) on certain builds, which may have
1601                   // stronger restrictions on acceptable frame rates and so on. For example, the
1602                   // h264_mediacodec encoder on Android has more constraints than libx264 regarding the
1603                   // number of keyframes.
1604                   "-c:v", "libx264",
1605                   "-pix_fmt", "yuv420p",
1606                   "-profile:v", "baseline",
1607                   "-framerate", "25",
1608                   "-movflags", "faststart",
1609                   filename.to_str().unwrap()])
1610            .assert()
1611            .success();
1612    }
1613
1614    // Generate 3 5-second dummy MP4 files, one with a red background color, the second with green,
1615    // the third with blue. Concatenate them into the first red file. Check that at second 2.5 we
1616    // have a red background, at second 7.5 a green background, and at second 12.5 a blue
1617    // background.
1618    //
1619    // We run this test once for each of the concat helpers: ffmpeg, ffmpegdemuxer, mkvmerge.
1620    #[test]
1621    fn test_concat_helpers() {
1622        use crate::fetch::DashDownloader;
1623        use crate::ffmpeg::{
1624            concat_output_files_ffmpeg_filter,
1625            concat_output_files_ffmpeg_demuxer,
1626            concat_output_files_mkvmerge
1627        };
1628        use image::ImageReader;
1629        use image::Rgb;
1630
1631        // Check that the media file merged contains a first sequence with red background, then with
1632        // green background, then with blue background.
1633        fn check_color_sequence(merged: &Path) {
1634            let tmpd = tempfile::tempdir().unwrap();
1635            let capture_red = tmpd.path().join("capture-red.png");
1636            Command::new("ffmpeg")
1637                .args(["-ss", "2.5",
1638                       "-i", merged.to_str().unwrap(),
1639                       "-frames:v", "1",
1640                       capture_red.to_str().unwrap()])
1641                .assert()
1642                .success();
1643            let img = ImageReader::open(&capture_red).unwrap()
1644                .decode().unwrap()
1645                .into_rgb8();
1646            for pixel in img.pixels() {
1647                match pixel {
1648                    Rgb(rgb) => {
1649                        assert!(rgb[0] > 250);
1650                        assert!(rgb[1] < 5);
1651                        assert!(rgb[2] < 5);
1652                    },
1653                };
1654            }
1655            fs::remove_file(&capture_red).unwrap();
1656            // The green color used by ffmpeg is Rgb(0,127,0)
1657            let capture_green = tmpd.path().join("capture-green.png");
1658            Command::new("ffmpeg")
1659                .args(["-ss", "7.5",
1660                       "-i", merged.to_str().unwrap(),
1661                       "-frames:v", "1",
1662                       capture_green.to_str().unwrap()])
1663                .assert()
1664                .success();
1665            let img = ImageReader::open(&capture_green).unwrap()
1666                .decode().unwrap()
1667                .into_rgb8();
1668            for pixel in img.pixels() {
1669                match pixel {
1670                    Rgb(rgb) => {
1671                        assert!(rgb[0] < 5);
1672                        assert!(rgb[1].abs_diff(127) < 5);
1673                        assert!(rgb[2] < 5);
1674                    },
1675                };
1676            }
1677            fs::remove_file(&capture_green).unwrap();
1678            // The "blue" color chosen by ffmpeg is Rgb(0,0,254)
1679            let capture_blue = tmpd.path().join("capture-blue.png");
1680            Command::new("ffmpeg")
1681                .args(["-ss", "12.5",
1682                       "-i", merged.to_str().unwrap(),
1683                       "-frames:v", "1",
1684                       capture_blue.to_str().unwrap()])
1685                .assert()
1686                .success();
1687            let img = ImageReader::open(&capture_blue).unwrap()
1688                .decode().unwrap()
1689                .into_rgb8();
1690            for pixel in img.pixels() {
1691                match pixel {
1692                    Rgb(rgb) => {
1693                        assert!(rgb[0] < 5);
1694                        assert!(rgb[1] < 5);
1695                        assert!(rgb[2] > 250);
1696                    },
1697                };
1698            }
1699            fs::remove_file(&capture_blue).unwrap();
1700        }
1701
1702        let tmpd = tempfile::tempdir().unwrap();
1703        let red = tmpd.path().join("concat-red.mp4");
1704        let green = tmpd.path().join("concat-green.mp4");
1705        let blue = tmpd.path().join("concat-blue.mp4");
1706        generate_mp4_hue_tone(&red, "red", "400");
1707        generate_mp4_hue_tone(&green, "green", "600");
1708        generate_mp4_hue_tone(&blue, "blue", "800");
1709        let ddl = DashDownloader::new("https://www.example.com/")
1710            .verbosity(2);
1711
1712        let output_ffmpeg_filter = tmpd.path().join("output-ffmpeg-filter.mp4");
1713        fs::copy(&red, &output_ffmpeg_filter).unwrap();
1714        concat_output_files_ffmpeg_filter(
1715            &ddl,
1716            &[output_ffmpeg_filter.clone(), green.clone(), blue.clone()]).unwrap();
1717        check_color_sequence(&output_ffmpeg_filter);
1718        fs::remove_file(&output_ffmpeg_filter).unwrap();
1719
1720        let output_ffmpeg_demuxer = tmpd.path().join("output-ffmpeg-demuxer.mp4");
1721        fs::copy(&red, &output_ffmpeg_demuxer).unwrap();
1722        concat_output_files_ffmpeg_demuxer(
1723            &ddl,
1724            &[output_ffmpeg_demuxer.clone(), green.clone(), blue.clone()]).unwrap();
1725        check_color_sequence(&output_ffmpeg_demuxer);
1726        fs::remove_file(&output_ffmpeg_demuxer).unwrap();
1727
1728        // mkvmerge fails to concatenate our test MP4 files generated with ffmpeg (its Quicktime/MP4
1729        // reader complains about "Could not read chunk number XX/YY with size XX from position
1730        // XX"). So test it instead with Matroska files for which it should be more robust. We
1731        // also test the ffmpeg_filter and ffmpeg_demuxer concat helpers on the Matroska files.
1732        let red = tmpd.path().join("concat-red.mkv");
1733        let green = tmpd.path().join("concat-green.mkv");
1734        let blue = tmpd.path().join("concat-blue.mkv");
1735        generate_mp4_hue_tone(&red, "red", "400");
1736        generate_mp4_hue_tone(&green, "green", "600");
1737        generate_mp4_hue_tone(&blue, "blue", "800");
1738
1739        let output_mkvmerge = tmpd.path().join("output-mkvmerge.mkv");
1740        fs::copy(&red, &output_mkvmerge).unwrap();
1741        concat_output_files_mkvmerge(
1742            &ddl,
1743            &[output_mkvmerge.clone(), green.clone(), blue.clone()]).unwrap();
1744        check_color_sequence(&output_mkvmerge);
1745        fs::remove_file(&output_mkvmerge).unwrap();
1746
1747        let output_ffmpeg_filter = tmpd.path().join("output-ffmpeg-filter.mkv");
1748        fs::copy(&red, &output_ffmpeg_filter).unwrap();
1749        concat_output_files_ffmpeg_filter(
1750            &ddl,
1751            &[output_ffmpeg_filter.clone(), green.clone(), blue.clone()]).unwrap();
1752        check_color_sequence(&output_ffmpeg_filter);
1753        fs::remove_file(&output_ffmpeg_filter).unwrap();
1754
1755        let output_ffmpeg_demuxer = tmpd.path().join("output-ffmpeg-demuxer.mkv");
1756        fs::copy(&red, &output_ffmpeg_demuxer).unwrap();
1757        concat_output_files_ffmpeg_demuxer(
1758            &ddl,
1759            &[output_ffmpeg_demuxer.clone(), green.clone(), blue.clone()]).unwrap();
1760        check_color_sequence(&output_ffmpeg_demuxer);
1761        fs::remove_file(&output_ffmpeg_demuxer).unwrap();
1762
1763        let _ = fs::remove_dir_all(tmpd);
1764    }
1765}