1use 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#[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 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 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 }
108 }
109 }
110 let mut args = vec![
111 String::from("-hide_banner"),
112 String::from("-nostats"),
113 String::from("-loglevel"), String::from("error"), String::from("-y"), 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 for (i, at) in audio_tracks.iter().enumerate() {
128 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 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 {
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 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 {
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#[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 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"), String::from("-y"), 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 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 args.push(String::from("-ss"));
314 args.push(ad);
315 }
316 for (i, at) in audio_tracks.iter().enumerate() {
318 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 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 {
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 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 {
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
430fn 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#[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", "-y", "-nostdin",
481 "-i", input,
482 "-movflags", "faststart", "-preset", "veryfast");
483 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 {
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#[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 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#[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#[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 let msg = String::from_utf8_lossy(&mkv.stdout);
830 return Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
831 }
832}
833
834#[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 let msg = String::from_utf8_lossy(&mkv.stdout);
878 return Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
879 }
880}
881
882
883#[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 let msg = String::from_utf8_lossy(&mkv.stdout);
927 return Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
928 }
929}
930
931
932#[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 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_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 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 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#[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 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 if have_audio {
1197 filter += &anullsrc;
1198 filter += &link_labels.join("");
1199 } else {
1200 for ll in link_labels {
1203 if ! ll.starts_with("[anull") {
1204 filter += ≪
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#[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 let output_format = match container {
1255 "mkv" => "matroska",
1256 "ts" => "mpegts",
1257 _ => container,
1258 };
1259 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", "-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#[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 let output_format = match container {
1337 "mkv" => "matroska",
1338 "ts" => "mpegts",
1339 _ => container,
1340 };
1341 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", "-y",
1360 "-nostdin"];
1361 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 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 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#[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 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 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 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 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#[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 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#[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", "-nostdin",
1665 "-lavfi", &format!("color=c={color}:duration=5:size=50x50:rate=1;sine=frequency={tone}:sample_rate=48000:duration=5"),
1666 "-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 #[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 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 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 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 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}