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