Skip to main content

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