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