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