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