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