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
33fn ffprobe_start_time(input: &Path) -> Result<f64, DashMpdError> {
34 match ffprobe(input) {
35 Ok(info) => if let Some(st) = info.format.start_time {
36 Ok(st.parse::<f64>()
37 .map_err(|_| DashMpdError::Io(
38 io::Error::other("reading start_time"),
39 String::from("")))?)
40 } else {
41 Ok(0.0)
42 },
43 Err(e) => {
44 warn!("Error probing metadata on {}: {e:?}", input.display());
45 Ok(0.0)
46 },
47 }
48}
49
50#[tracing::instrument(level="trace", skip(downloader))]
52pub async fn mux_multiaudio_video_ffmpeg(
53 downloader: &DashDownloader,
54 output_path: &Path,
55 audio_tracks: &Vec<AudioTrack>,
56 video_path: &Path) -> Result<(), DashMpdError> {
57 if audio_tracks.is_empty() {
58 return Err(DashMpdError::Muxing(String::from("no audio tracks")));
59 }
60 let container = match output_path.extension() {
61 Some(ext) => ext.to_str().unwrap_or("mp4"),
62 None => "mp4",
63 };
64 let muxer = match container {
66 "mkv" => "matroska",
67 "ts" => "mpegts",
68 _ => container,
69 };
70 let tmpout = tempfile::Builder::new()
71 .prefix("dashmpdrs")
72 .suffix(&format!(".{container}"))
73 .rand_bytes(5)
74 .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
75 .tempfile()
76 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
77 let tmppath = tmpout
78 .path()
79 .to_str()
80 .ok_or_else(|| DashMpdError::Io(
81 io::Error::other("obtaining tmpfile name"),
82 String::from("")))?;
83 let video_str = video_path
84 .to_str()
85 .ok_or_else(|| DashMpdError::Io(
86 io::Error::other("obtaining videopath name"),
87 String::from("")))?;
88 if downloader.verbosity > 0 {
89 info!(" Muxing audio ({} track{}) and video content with ffmpeg",
90 audio_tracks.len(),
91 if audio_tracks.len() == 1 { "" } else { "s" });
92 if let Ok(attr) = fs::metadata(video_path).await {
93 info!(" Video file {} of size {} octets", video_path.display(), attr.len());
94 }
95 }
96 let mut args = vec![
97 String::from("-hide_banner"),
98 String::from("-nostats"),
99 String::from("-loglevel"), String::from("error"), String::from("-y"), String::from("-nostdin")];
102 let mut mappings = Vec::new();
103 mappings.push(String::from("-map"));
104 mappings.push(String::from("0:v"));
105 args.push(String::from("-i"));
106 args.push(String::from(video_str));
107 for (i, at) in audio_tracks.iter().enumerate() {
109 mappings.push(String::from("-map"));
111 mappings.push(format!("{}:a", i+1));
112 mappings.push(format!("-metadata:s:a:{i}"));
113 let mut lang_sanitized = at.language.clone();
114 lang_sanitized.retain(|c: char| c.is_ascii_lowercase());
115 mappings.push(format!("language={lang_sanitized}"));
116 args.push(String::from("-i"));
117 let audio_str = at.path
118 .to_str()
119 .ok_or_else(|| DashMpdError::Io(
120 io::Error::other("obtaining audiopath name"),
121 String::from("")))?;
122 args.push(String::from(audio_str));
123 }
124 for m in mappings {
125 args.push(m);
126 }
127 args.push(String::from("-c:v"));
128 args.push(String::from("copy"));
129 args.push(String::from("-c:a"));
130 args.push(String::from("copy"));
131 args.push(String::from("-movflags"));
132 args.push(String::from("faststart"));
133 args.push(String::from("-preset"));
134 args.push(String::from("veryfast"));
135 args.push(String::from("-f"));
138 args.push(String::from(muxer));
139 args.push(String::from(tmppath));
140 if downloader.verbosity > 0 {
141 info!(" Running ffmpeg {}", args.join(" "));
142 }
143 let ffmpeg = Command::new(&downloader.ffmpeg_location)
144 .args(args)
145 .output()
146 .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg subprocess")))?;
147 let msg = partial_process_output(&ffmpeg.stdout);
148 if !msg.is_empty() {
149 info!(" ffmpeg stdout: {msg}");
150 }
151 let msg = partial_process_output(&ffmpeg.stderr);
152 if !msg.is_empty() {
153 info!(" ffmpeg stderr: {msg}");
154 }
155 if ffmpeg.status.success() {
156 {
158 let tmpfile = File::open(tmppath).await
159 .map_err(|e| DashMpdError::Io(e, String::from("opening ffmpeg output")))?;
160 let mut muxed = BufReader::new(tmpfile);
161 let outfile = File::create(output_path).await
162 .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
163 let mut sink = BufWriter::new(outfile);
164 io::copy(&mut muxed, &mut sink).await
165 .map_err(|e| DashMpdError::Io(e, String::from("copying ffmpeg output to output file")))?;
166 }
167 if env::var("DASHMPD_PERSIST_FILES").is_err() {
168 if let Err(e) = fs::remove_file(tmppath).await {
169 warn!(" Error deleting temporary ffmpeg output: {e}");
170 }
171 }
172 return Ok(());
173 }
174 Err(DashMpdError::Muxing(String::from("running ffmpeg")))
176}
177
178#[tracing::instrument(level="trace", skip(downloader))]
180async fn mux_audio_video_ffmpeg(
181 downloader: &DashDownloader,
182 output_path: &Path,
183 audio_tracks: &Vec<AudioTrack>,
184 video_path: &Path) -> Result<(), DashMpdError> {
185 let container = match output_path.extension() {
186 Some(ext) => ext.to_str().unwrap_or("mp4"),
187 None => "mp4",
188 };
189 let muxer = match container {
191 "mkv" => "matroska",
192 "ts" => "mpegts",
193 _ => container,
194 };
195 let tmpout = tempfile::Builder::new()
196 .prefix("dashmpdrs")
197 .suffix(&format!(".{container}"))
198 .rand_bytes(5)
199 .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
200 .tempfile()
201 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
202 let tmppath = tmpout
203 .path()
204 .to_str()
205 .ok_or_else(|| DashMpdError::Io(
206 io::Error::other("obtaining tmpfile name"),
207 String::from("")))?;
208 let video_str = video_path
209 .to_str()
210 .ok_or_else(|| DashMpdError::Io(
211 io::Error::other("obtaining videopath name"),
212 String::from("")))?;
213 if downloader.verbosity > 0 {
214 info!(" Muxing audio ({} track{}) and video content with ffmpeg",
215 audio_tracks.len(),
216 if audio_tracks.len() == 1 { "" } else { "s" });
217 if let Ok(attr) = fs::metadata(video_path).await {
218 info!(" Video file {} of size {} octets", video_path.display(), attr.len());
219 }
220 }
221 let mut audio_delay = 0.0;
222 let mut video_delay = 0.0;
223 if let Ok(audio_start_time) = ffprobe_start_time(&audio_tracks[0].path) {
224 if let Ok(video_start_time) = ffprobe_start_time(video_path) {
225 if audio_start_time > video_start_time {
226 video_delay = audio_start_time - video_start_time;
227 } else {
228 audio_delay = video_start_time - audio_start_time;
229 }
230 }
231 }
232 let mut args = vec![
233 String::from("-hide_banner"),
234 String::from("-nostats"),
235 String::from("-loglevel"), String::from("error"), String::from("-y"), String::from("-nostdin")];
238 let mut mappings = Vec::new();
239 mappings.push(String::from("-map"));
240 mappings.push(String::from("0:v"));
241 let vd = format!("{video_delay}");
242 if video_delay > 0.001 {
243 args.push(String::from("-ss"));
245 args.push(vd);
246 }
247 args.push(String::from("-i"));
248 args.push(String::from(video_str));
249 let ad = format!("{audio_delay}");
250 if audio_delay > 0.001 {
251 args.push(String::from("-ss"));
253 args.push(ad);
254 }
255 for (i, at) in audio_tracks.iter().enumerate() {
257 mappings.push(String::from("-map"));
260 mappings.push(format!("{}:a", i+1));
261 mappings.push(format!("-metadata:s:a:{i}"));
262 let mut lang_sanitized = at.language.clone();
263 lang_sanitized.retain(|c: char| c.is_ascii_lowercase());
264 mappings.push(format!("language={lang_sanitized}"));
265 args.push(String::from("-i"));
266 let audio_str = at.path
267 .to_str()
268 .ok_or_else(|| DashMpdError::Io(
269 io::Error::other("obtaining audiopath name"),
270 String::from("")))?;
271 args.push(String::from(audio_str));
272 }
273 for m in mappings {
274 args.push(m);
275 }
276 args.push(String::from("-c:v"));
277 args.push(String::from("copy"));
278 args.push(String::from("-c:a"));
279 args.push(String::from("copy"));
280 args.push(String::from("-movflags"));
281 args.push(String::from("faststart"));
282 args.push(String::from("-preset"));
283 args.push(String::from("veryfast"));
284 args.push(String::from("-f"));
287 args.push(String::from(muxer));
288 args.push(String::from(tmppath));
289 if downloader.verbosity > 0 {
290 info!(" Running ffmpeg {}", args.join(" "));
291 }
292 let ffmpeg = Command::new(&downloader.ffmpeg_location)
293 .args(args.clone())
294 .output()
295 .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg subprocess")))?;
296 let msg = partial_process_output(&ffmpeg.stdout);
297 if !msg.is_empty() {
298 info!(" ffmpeg stdout: {msg}");
299 }
300 let msg = partial_process_output(&ffmpeg.stderr);
301 if !msg.is_empty() {
302 info!(" ffmpeg stderr: {msg}");
303 }
304 if ffmpeg.status.success() {
305 {
307 let tmpfile = File::open(tmppath).await
308 .map_err(|e| DashMpdError::Io(e, String::from("opening ffmpeg output")))?;
309 let mut muxed = BufReader::new(tmpfile);
310 let outfile = File::create(output_path).await
311 .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
312 let mut sink = BufWriter::new(outfile);
313 io::copy(&mut muxed, &mut sink).await
314 .map_err(|e| DashMpdError::Io(e, String::from("copying ffmpeg output to output file")))?;
315 }
316 if env::var("DASHMPD_PERSIST_FILES").is_err() {
317 if let Err(e) = fs::remove_file(tmppath).await {
318 warn!(" Error deleting temporary ffmpeg output: {e}");
319 }
320 }
321 return Ok(());
322 }
323 args.retain(|a| !(a.eq("-c:v") || a.eq("copy") || a.eq("-c:a")));
330 if downloader.verbosity > 0 {
331 info!(" Running ffmpeg {}", args.join(" "));
332 }
333 let ffmpeg = Command::new(&downloader.ffmpeg_location)
334 .args(args)
335 .output()
336 .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg subprocess")))?;
337 let msg = partial_process_output(&ffmpeg.stdout);
338 if !msg.is_empty() {
339 info!(" ffmpeg stdout: {msg}");
340 }
341 let msg = partial_process_output(&ffmpeg.stderr);
342 if !msg.is_empty() {
343 info!(" ffmpeg stderr: {msg}");
344 }
345 if ffmpeg.status.success() {
346 {
348 let tmpfile = File::open(tmppath).await
349 .map_err(|e| DashMpdError::Io(e, String::from("opening ffmpeg output")))?;
350 let mut muxed = BufReader::new(tmpfile);
351 let outfile = File::create(output_path).await
352 .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
353 let mut sink = BufWriter::new(outfile);
354 io::copy(&mut muxed, &mut sink).await
355 .map_err(|e| DashMpdError::Io(e, String::from("copying ffmpeg output to output file")))?;
356 }
357 if env::var("DASHMPD_PERSIST_FILES").is_err() {
358 if let Err(e) = fs::remove_file(tmppath).await {
359 warn!(" Error deleting temporary ffmpeg output: {e}");
360 }
361 }
362 Ok(())
363 } else {
364 Err(DashMpdError::Muxing(String::from("running ffmpeg")))
365 }
366}
367
368
369fn ffmpeg_container_name(extension: &str) -> Option<String> {
371 match extension {
372 "mkv" => Some(String::from("matroska")),
373 "webm" => Some(String::from("webm")),
374 "avi" => Some(String::from("avi")),
375 "mov" => Some(String::from("mov")),
376 "mp4" => Some(String::from("mp4")),
377 "ts" => Some(String::from("mpegts")),
378 "ogg" => Some(String::from("ogg")),
379 "vob" => Some(String::from("vob")),
380 _ => None,
381 }
382}
383
384#[tracing::instrument(level="trace", skip(downloader))]
387async fn mux_stream_ffmpeg(
388 downloader: &DashDownloader,
389 output_path: &Path,
390 input_path: &Path) -> Result<(), DashMpdError> {
391 let container = match output_path.extension() {
392 Some(ext) => ext.to_str().unwrap_or("mp4"),
393 None => "mp4",
394 };
395 info!(" ffmpeg inserting stream into {container} container named {}", output_path.display());
396 let tmpout = tempfile::Builder::new()
397 .prefix("dashmpdrs")
398 .suffix(&format!(".{container}"))
399 .rand_bytes(5)
400 .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
401 .tempfile()
402 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
403 let tmppath = tmpout
404 .path()
405 .to_str()
406 .ok_or_else(|| DashMpdError::Io(
407 io::Error::other("obtaining tmpfile name"),
408 String::from("")))?;
409 let input = input_path
410 .to_str()
411 .ok_or_else(|| DashMpdError::Io(
412 io::Error::other("obtaining input name"),
413 String::from("")))?;
414 let cn: String;
415 let mut args = vec!("-hide_banner",
416 "-nostats",
417 "-loglevel", "error", "-y", "-nostdin",
420 "-i", input,
421 "-movflags", "faststart", "-preset", "veryfast");
422 if let Some(container_name) = ffmpeg_container_name(container) {
425 args.push("-f");
426 cn = container_name;
427 args.push(&cn);
428 }
429 args.push(tmppath);
430 if downloader.verbosity > 0 {
431 info!(" Running ffmpeg {}", args.join(" "));
432 }
433 let ffmpeg = Command::new(&downloader.ffmpeg_location)
434 .args(args)
435 .output()
436 .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg subprocess")))?;
437 let msg = partial_process_output(&ffmpeg.stdout);
438 if downloader.verbosity > 0 && !msg.is_empty() {
439 info!(" ffmpeg stdout: {msg}");
440 }
441 let msg = partial_process_output(&ffmpeg.stderr);
442 if downloader.verbosity > 0 && !msg.is_empty() {
443 info!(" ffmpeg stderr: {msg}");
444 }
445 if ffmpeg.status.success() {
446 {
448 let tmpfile = File::open(tmppath).await
449 .map_err(|e| DashMpdError::Io(e, String::from("opening ffmpeg output")))?;
450 let mut muxed = BufReader::new(tmpfile);
451 let outfile = File::create(output_path).await
452 .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
453 let mut sink = BufWriter::new(outfile);
454 io::copy(&mut muxed, &mut sink).await
455 .map_err(|e| DashMpdError::Io(e, String::from("copying ffmpeg output to output file")))?;
456 }
457 if env::var("DASHMPD_PERSIST_FILES").is_err() {
458 if let Err(e) = fs::remove_file(tmppath).await {
459 warn!(" Error deleting temporary ffmpeg output: {e}");
460 }
461 }
462 Ok(())
463 } else {
464 warn!(" unmuxed stream: {input}");
465 Err(DashMpdError::Muxing(String::from("running ffmpeg")))
466 }
467}
468
469
470#[tracing::instrument(level="trace", skip(downloader))]
473async fn mux_audio_video_vlc(
474 downloader: &DashDownloader,
475 output_path: &Path,
476 audio_tracks: &Vec<AudioTrack>,
477 video_path: &Path) -> Result<(), DashMpdError> {
478 if audio_tracks.len() > 1 {
479 error!("Cannot mux more than a single audio track with VLC");
480 return Err(DashMpdError::Muxing(String::from("cannot mux more than one audio track with VLC")));
481 }
482 let audio_path = &audio_tracks[0].path;
483 let container = match output_path.extension() {
484 Some(ext) => ext.to_str().unwrap_or("mp4"),
485 None => "mp4",
486 };
487 let muxer = match container {
488 "ogg" => "ogg",
489 "webm" => "mkv",
490 "mp3" => "raw",
491 "mpg" => "mpeg1",
492 _ => container,
493 };
494 let tmpout = tempfile::Builder::new()
495 .prefix("dashmpdrs")
496 .suffix(".mp4")
497 .rand_bytes(5)
498 .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
499 .tempfile()
500 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
501 let tmppath = tmpout
502 .path()
503 .to_str()
504 .ok_or_else(|| DashMpdError::Io(
505 io::Error::other("obtaining tmpfile name"),
506 String::from("")))?;
507 let audio_str = audio_path
508 .to_str()
509 .ok_or_else(|| DashMpdError::Io(
510 io::Error::other("obtaining audiopath name"),
511 String::from("")))?;
512 let video_str = video_path
513 .to_str()
514 .ok_or_else(|| DashMpdError::Io(
515 io::Error::other("obtaining videopath name"),
516 String::from("")))?;
517 let transcode = if container.eq("webm") {
518 "transcode{vcodec=VP90,acodec=vorb}:"
519 } else {
520 ""
521 };
522 let sout = format!("--sout=#{transcode}std{{access=file,mux={muxer},dst={tmppath}}}");
523 let args = vec![
524 "-I", "dummy",
525 "--no-repeat", "--no-loop",
526 video_str,
527 "--input-slave", audio_str,
528 "--sout-mp4-faststart",
529 &sout,
530 "--sout-keep",
531 "vlc://quit"];
532 if downloader.verbosity > 0 {
533 info!(" Running vlc {}", args.join(" "));
534 }
535 let vlc = Command::new(&downloader.vlc_location)
536 .args(args)
537 .output()
538 .map_err(|e| DashMpdError::Io(e, String::from("spawning VLC subprocess")))?;
539 let msg = partial_process_output(&vlc.stderr);
542 if downloader.verbosity > 0 && !msg.is_empty() {
543 info!(" vlc stderr: {msg}");
544 }
545 if vlc.status.success() && (!msg.contains("mp4 mux error")) {
546 {
547 let tmpfile = File::open(tmppath).await
548 .map_err(|e| DashMpdError::Io(e, String::from("opening VLC output")))?;
549 let mut muxed = BufReader::new(tmpfile);
550 let outfile = File::create(output_path).await
551 .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
552 let mut sink = BufWriter::new(outfile);
553 io::copy(&mut muxed, &mut sink).await
554 .map_err(|e| DashMpdError::Io(e, String::from("copying VLC output to output file")))?;
555 }
556 if env::var("DASHMPD_PERSIST_FILES").is_err() {
557 if let Err(e) = fs::remove_file(tmppath).await {
558 warn!(" Error deleting temporary VLC output: {e}");
559 }
560 }
561 Ok(())
562 } else {
563 let msg = partial_process_output(&vlc.stderr);
564 Err(DashMpdError::Muxing(format!("running VLC: {msg}")))
565 }
566}
567
568
569#[tracing::instrument(level="trace", skip(downloader))]
572async fn mux_audio_video_mp4box(
573 downloader: &DashDownloader,
574 output_path: &Path,
575 audio_tracks: &Vec<AudioTrack>,
576 video_path: &Path) -> Result<(), DashMpdError> {
577 if audio_tracks.len() > 1 {
578 error!("Cannot mux more than a single audio track with MP4Box");
579 return Err(DashMpdError::Muxing(String::from("cannot mux more than one audio track with MP4Box")));
580 }
581 let audio_path = &audio_tracks[0].path;
582 let container = match output_path.extension() {
583 Some(ext) => ext.to_str().unwrap_or("mp4"),
584 None => "mp4",
585 };
586 let tmpout = tempfile::Builder::new()
587 .prefix("dashmpdrs")
588 .suffix(&format!(".{container}"))
589 .rand_bytes(5)
590 .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
591 .tempfile()
592 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
593 let tmppath = tmpout
594 .path()
595 .to_str()
596 .ok_or_else(|| DashMpdError::Io(
597 io::Error::other("obtaining tmpfile name"),
598 String::from("")))?;
599 let audio_str = audio_path
600 .to_str()
601 .ok_or_else(|| DashMpdError::Io(
602 io::Error::other("obtaining audiopath name"),
603 String::from("")))?;
604 let video_str = video_path
605 .to_str()
606 .ok_or_else(|| DashMpdError::Io(
607 io::Error::other("obtaining videopath name"),
608 String::from("")))?;
609 let args = vec![
610 "-flat",
611 "-add", video_str,
612 "-add", audio_str,
613 "-new", tmppath];
614 if downloader.verbosity > 0 {
615 info!(" Running MP4Box {}", args.join(" "));
616 }
617 let cmd = Command::new(&downloader.mp4box_location)
618 .args(args)
619 .output()
620 .map_err(|e| DashMpdError::Io(e, String::from("spawning MP4Box subprocess")))?;
621 let msg = partial_process_output(&cmd.stderr);
622 if downloader.verbosity > 0 && !msg.is_empty() {
623 info!(" MP4Box stderr: {msg}");
624 }
625 if cmd.status.success() {
626 {
627 let tmpfile = File::open(tmppath).await
628 .map_err(|e| DashMpdError::Io(e, String::from("opening MP4Box output")))?;
629 let mut muxed = BufReader::new(tmpfile);
630 let outfile = File::create(output_path).await
631 .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
632 let mut sink = BufWriter::new(outfile);
633 io::copy(&mut muxed, &mut sink).await
634 .map_err(|e| DashMpdError::Io(e, String::from("copying MP4Box output to output file")))?;
635 }
636 if env::var("DASHMPD_PERSIST_FILES").is_err() {
637 if let Err(e) = fs::remove_file(tmppath).await {
638 warn!(" Error deleting temporary MP4Box output: {e}");
639 }
640 }
641 Ok(())
642 } else {
643 let msg = partial_process_output(&cmd.stderr);
644 Err(DashMpdError::Muxing(format!("running MP4Box: {msg}")))
645 }
646}
647
648#[tracing::instrument(level="trace", skip(downloader))]
651async fn mux_stream_mp4box(
652 downloader: &DashDownloader,
653 output_path: &Path,
654 input_path: &Path) -> Result<(), DashMpdError> {
655 let container = match output_path.extension() {
656 Some(ext) => ext.to_str().unwrap_or("mp4"),
657 None => "mp4",
658 };
659 let tmpout = tempfile::Builder::new()
660 .prefix("dashmpdrs")
661 .suffix(&format!(".{container}"))
662 .rand_bytes(5)
663 .tempfile()
664 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
665 let tmppath = tmpout
666 .path()
667 .to_str()
668 .ok_or_else(|| DashMpdError::Io(
669 io::Error::other("obtaining tmpfile name"),
670 String::from("")))?;
671 let input = input_path
672 .to_str()
673 .ok_or_else(|| DashMpdError::Io(
674 io::Error::other("obtaining input stream name"),
675 String::from("")))?;
676 let args = vec!["-add", input, "-new", tmppath];
677 if downloader.verbosity > 0 {
678 info!(" Running MP4Box {}", args.join(" "));
679 }
680 let cmd = Command::new(&downloader.mp4box_location)
681 .args(args)
682 .output()
683 .map_err(|e| DashMpdError::Io(e, String::from("spawning MP4Box subprocess")))?;
684 let msg = partial_process_output(&cmd.stderr);
685 if downloader.verbosity > 0 && !msg.is_empty() {
686 info!(" MP4box stderr: {msg}");
687 }
688 if cmd.status.success() {
689 {
690 let tmpfile = File::open(tmppath).await
691 .map_err(|e| DashMpdError::Io(e, String::from("opening MP4Box output")))?;
692 let mut muxed = BufReader::new(tmpfile);
693 let outfile = File::create(output_path).await
694 .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
695 let mut sink = BufWriter::new(outfile);
696 io::copy(&mut muxed, &mut sink).await
697 .map_err(|e| DashMpdError::Io(e, String::from("copying MP4Box output to output file")))?;
698 }
699 if env::var("DASHMPD_PERSIST_FILES").is_err() {
700 if let Err(e) = fs::remove_file(tmppath).await {
701 warn!(" Error deleting temporary MP4Box output: {e}");
702 }
703 }
704 Ok(())
705 } else {
706 let msg = partial_process_output(&cmd.stderr);
707 warn!(" MP4Box mux_stream failure: stdout {}", partial_process_output(&cmd.stdout));
708 warn!(" MP4Box stderr: {msg}");
709 Err(DashMpdError::Muxing(format!("running MP4Box: {msg}")))
710 }
711}
712
713#[tracing::instrument(level="trace", skip(downloader))]
714async fn mux_audio_video_mkvmerge(
715 downloader: &DashDownloader,
716 output_path: &Path,
717 audio_tracks: &Vec<AudioTrack>,
718 video_path: &Path) -> Result<(), DashMpdError> {
719 if audio_tracks.len() > 1 {
720 error!("Cannot mux more than a single audio track with mkvmerge");
721 return Err(DashMpdError::Muxing(String::from("cannot mux more than one audio track with mkvmerge")));
722 }
723 let audio_path = &audio_tracks[0].path;
724 let tmppath = temporary_outpath(".mkv")?;
725 let audio_str = audio_path
726 .to_str()
727 .ok_or_else(|| DashMpdError::Io(
728 io::Error::other("obtaining audiopath name"),
729 String::from("")))?;
730 let video_str = video_path
731 .to_str()
732 .ok_or_else(|| DashMpdError::Io(
733 io::Error::other("obtaining videopath name"),
734 String::from("")))?;
735 let args = vec!["--output", &tmppath,
736 "--no-video", audio_str,
737 "--no-audio", video_str];
738 if downloader.verbosity > 0 {
739 info!(" Running mkvmerge {}", args.join(" "));
740 }
741 let mkv = Command::new(&downloader.mkvmerge_location)
742 .args(args)
743 .output()
744 .map_err(|e| DashMpdError::Io(e, String::from("spawning mkvmerge subprocess")))?;
745 let msg = partial_process_output(&mkv.stderr);
746 if downloader.verbosity > 0 && !msg.is_empty() {
747 info!(" mkvmerge stderr: {msg}");
748 }
749 if mkv.status.success() {
750 {
751 let tmpfile = File::open(&tmppath).await
752 .map_err(|e| DashMpdError::Io(e, String::from("opening mkvmerge output")))?;
753 let mut muxed = BufReader::new(tmpfile);
754 let outfile = File::create(output_path).await
755 .map_err(|e| DashMpdError::Io(e, String::from("opening output file")))?;
756 let mut sink = BufWriter::new(outfile);
757 io::copy(&mut muxed, &mut sink).await
758 .map_err(|e| DashMpdError::Io(e, String::from("copying mkvmerge output to output file")))?;
759 }
760 if env::var("DASHMPD_PERSIST_FILES").is_err() {
761 if let Err(e) = fs::remove_file(tmppath).await {
762 warn!(" Error deleting temporary mkvmerge output: {e}");
763 }
764 }
765 Ok(())
766 } else {
767 let msg = String::from_utf8_lossy(&mkv.stdout);
769 Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
770 }
771}
772
773#[tracing::instrument(level="trace", skip(downloader))]
775async fn mux_video_mkvmerge(
776 downloader: &DashDownloader,
777 output_path: &Path,
778 video_path: &Path) -> Result<(), DashMpdError> {
779 let tmppath = temporary_outpath(".mkv")?;
780 let video_str = video_path
781 .to_str()
782 .ok_or_else(|| DashMpdError::Io(
783 io::Error::other("obtaining videopath name"),
784 String::from("")))?;
785 let args = vec!["--output", &tmppath, "--no-audio", video_str];
786 if downloader.verbosity > 0 {
787 info!(" Running mkvmerge {}", args.join(" "));
788 }
789 let mkv = Command::new(&downloader.mkvmerge_location)
790 .args(args)
791 .output()
792 .map_err(|e| DashMpdError::Io(e, String::from("spawning mkvmerge subprocess")))?;
793 let msg = partial_process_output(&mkv.stderr);
794 if downloader.verbosity > 0 && !msg.is_empty() {
795 info!(" mkvmerge stderr: {msg}");
796 }
797 if mkv.status.success() {
798 {
799 let tmpfile = File::open(&tmppath).await
800 .map_err(|e| DashMpdError::Io(e, String::from("opening mkvmerge output")))?;
801 let mut muxed = BufReader::new(tmpfile);
802 let outfile = File::create(output_path).await
803 .map_err(|e| DashMpdError::Io(e, String::from("opening output file")))?;
804 let mut sink = BufWriter::new(outfile);
805 io::copy(&mut muxed, &mut sink).await
806 .map_err(|e| DashMpdError::Io(e, String::from("copying mkvmerge output to output file")))?;
807 }
808 if env::var("DASHMPD_PERSIST_FILES").is_err() {
809 if let Err(e) = fs::remove_file(tmppath).await {
810 warn!(" Error deleting temporary mkvmerge output: {e}");
811 }
812 }
813 Ok(())
814 } else {
815 let msg = String::from_utf8_lossy(&mkv.stdout);
817 Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
818 }
819}
820
821
822#[tracing::instrument(level="trace", skip(downloader))]
824async fn mux_audio_mkvmerge(
825 downloader: &DashDownloader,
826 output_path: &Path,
827 audio_path: &Path) -> Result<(), DashMpdError> {
828 let tmppath = temporary_outpath(".mkv")?;
829 let audio_str = audio_path
830 .to_str()
831 .ok_or_else(|| DashMpdError::Io(
832 io::Error::other("obtaining audiopath name"),
833 String::from("")))?;
834 let args = vec!["--output", &tmppath, "--no-video", audio_str];
835 if downloader.verbosity > 0 {
836 info!(" Running mkvmerge {}", args.join(" "));
837 }
838 let mkv = Command::new(&downloader.mkvmerge_location)
839 .args(args)
840 .output()
841 .map_err(|e| DashMpdError::Io(e, String::from("spawning mkvmerge subprocess")))?;
842 let msg = partial_process_output(&mkv.stderr);
843 if downloader.verbosity > 0 && !msg.is_empty() {
844 info!(" mkvmerge stderr: {msg}");
845 }
846 if mkv.status.success() {
847 {
848 let tmpfile = File::open(&tmppath).await
849 .map_err(|e| DashMpdError::Io(e, String::from("opening mkvmerge output")))?;
850 let mut muxed = BufReader::new(tmpfile);
851 let outfile = File::create(output_path).await
852 .map_err(|e| DashMpdError::Io(e, String::from("opening output file")))?;
853 let mut sink = BufWriter::new(outfile);
854 io::copy(&mut muxed, &mut sink).await
855 .map_err(|e| DashMpdError::Io(e, String::from("copying mkvmerge output to output file")))?;
856 }
857 if env::var("DASHMPD_PERSIST_FILES").is_err() {
858 if let Err(e) = fs::remove_file(tmppath).await {
859 warn!(" Error deleting temporary mkvmerge output: {e}");
860 }
861 }
862 Ok(())
863 } else {
864 let msg = String::from_utf8_lossy(&mkv.stdout);
866 Err(DashMpdError::Muxing(format!("running mkvmerge: {msg}")))
867 }
868}
869
870
871#[tracing::instrument(level="trace", skip(downloader))]
875pub async fn mux_audio_video(
876 downloader: &DashDownloader,
877 output_path: &Path,
878 audio_tracks: &Vec<AudioTrack>,
879 video_path: &Path) -> Result<(), DashMpdError> {
880 trace!("Muxing {} audio tracks with video {}", audio_tracks.len(), video_path.display());
881 let container = match output_path.extension() {
882 Some(ext) => ext.to_str().unwrap_or("mp4"),
883 None => "mp4",
884 };
885 let mut muxer_preference = vec![];
886 if container.eq("mkv") {
887 muxer_preference.push("mkvmerge");
888 muxer_preference.push("ffmpeg");
889 muxer_preference.push("mp4box");
890 } else if container.eq("webm") {
891 muxer_preference.push("vlc");
895 muxer_preference.push("ffmpeg");
896 } else if container.eq("mp4") {
897 muxer_preference.push("ffmpeg");
898 muxer_preference.push("vlc");
899 muxer_preference.push("mp4box");
900 } else {
901 muxer_preference.push("ffmpeg");
902 muxer_preference.push("mp4box");
903 }
904 if let Some(ordering) = downloader.muxer_preference.get(container) {
905 muxer_preference.clear();
906 for m in ordering.split(',') {
907 muxer_preference.push(m);
908 }
909 }
910 info!(" Muxer preference for {container} is {muxer_preference:?}");
911 for muxer in muxer_preference {
912 info!(" Trying muxer {muxer}");
913 if muxer.eq("mkvmerge") {
914 if let Err(e) = mux_audio_video_mkvmerge(downloader, output_path, audio_tracks, video_path).await {
915 warn!(" Muxing with mkvmerge subprocess failed: {e}");
916 } else {
917 info!(" Muxing with mkvmerge subprocess succeeded");
918 return Ok(());
919 }
920 } else if muxer.eq("ffmpeg") {
921 if let Err(e) = mux_audio_video_ffmpeg(downloader, output_path, audio_tracks, video_path).await {
922 warn!(" Muxing with ffmpeg subprocess failed: {e}");
923 } else {
924 info!(" Muxing with ffmpeg subprocess succeeded");
925 return Ok(());
926 }
927 } else if muxer.eq("vlc") {
928 if let Err(e) = mux_audio_video_vlc(downloader, output_path, audio_tracks, video_path).await {
929 warn!(" Muxing with vlc subprocess failed: {e}");
930 } else {
931 info!(" Muxing with vlc subprocess succeeded");
932 return Ok(());
933 }
934 } else if muxer.eq("mp4box") {
935 if let Err(e) = mux_audio_video_mp4box(downloader, output_path, audio_tracks, video_path).await {
936 warn!(" Muxing with MP4Box subprocess failed: {e}");
937 } else {
938 info!(" Muxing with MP4Box subprocess succeeded");
939 return Ok(());
940 }
941 } else {
942 warn!(" Ignoring unknown muxer preference {muxer}");
943 }
944 }
945 warn!("All muxers failed");
946 warn!(" unmuxed audio streams: {}", audio_tracks.len());
947 warn!(" unmuxed video stream: {}", video_path.display());
948 Err(DashMpdError::Muxing(String::from("all muxers failed")))
949}
950
951
952#[tracing::instrument(level="trace", skip(downloader))]
953pub async fn copy_video_to_container(
954 downloader: &DashDownloader,
955 output_path: &Path,
956 video_path: &Path) -> Result<(), DashMpdError> {
957 trace!("Copying video {} to output container {}", video_path.display(), output_path.display());
958 let container = match output_path.extension() {
959 Some(ext) => ext.to_str().unwrap_or("mp4"),
960 None => "mp4",
961 };
962 if video_container_type(video_path)?.eq(container) {
965 let tmpfile_video = File::open(video_path).await
966 .map_err(|e| DashMpdError::Io(e, String::from("opening temporary video output file")))?;
967 let mut video = BufReader::new(tmpfile_video);
968 let output_file = File::create(output_path).await
969 .map_err(|e| DashMpdError::Io(e, String::from("creating output file for video")))?;
970 let mut sink = BufWriter::new(output_file);
971 io::copy(&mut video, &mut sink).await
972 .map_err(|e| DashMpdError::Io(e, String::from("copying video stream to output file")))?;
973 return Ok(());
974 }
975 let mut muxer_preference = vec![];
976 if container.eq("mkv") {
977 muxer_preference.push("mkvmerge");
978 muxer_preference.push("ffmpeg");
979 muxer_preference.push("mp4box");
980 } else {
981 muxer_preference.push("ffmpeg");
982 muxer_preference.push("mp4box");
983 }
984 if let Some(ordering) = downloader.muxer_preference.get(container) {
985 muxer_preference.clear();
986 for m in ordering.split(',') {
987 muxer_preference.push(m);
988 }
989 }
990 info!(" Muxer preference for {container} is {muxer_preference:?}");
991 for muxer in muxer_preference {
992 info!(" Trying muxer {muxer}");
993 if muxer.eq("mkvmerge") {
994 if let Err(e) = mux_video_mkvmerge(downloader, output_path, video_path).await {
995 warn!(" Muxing with mkvmerge subprocess failed: {e}");
996 } else {
997 info!(" Muxing with mkvmerge subprocess succeeded");
998 return Ok(());
999 }
1000 } else if muxer.eq("ffmpeg") {
1001 if let Err(e) = mux_stream_ffmpeg(downloader, output_path, video_path).await {
1002 warn!(" Muxing with ffmpeg subprocess failed: {e}");
1003 } else {
1004 info!(" Muxing with ffmpeg subprocess succeeded");
1005 return Ok(());
1006 }
1007 } else if muxer.eq("mp4box") {
1008 if let Err(e) = mux_stream_mp4box(downloader, output_path, video_path).await {
1009 warn!(" Muxing with MP4Box subprocess failed: {e}");
1010 } else {
1011 info!(" Muxing with MP4Box subprocess succeeded");
1012 return Ok(());
1013 }
1014 }
1015 }
1016 warn!(" All available muxers failed");
1017 warn!(" unmuxed video stream: {}", video_path.display());
1018 Err(DashMpdError::Muxing(String::from("all available muxers failed")))
1019}
1020
1021
1022#[tracing::instrument(level="trace", skip(downloader))]
1023pub async fn copy_audio_to_container(
1024 downloader: &DashDownloader,
1025 output_path: &Path,
1026 audio_path: &Path) -> Result<(), DashMpdError> {
1027 trace!("Copying audio {} to output container {}", audio_path.display(), output_path.display());
1028 let container = match output_path.extension() {
1029 Some(ext) => ext.to_str().unwrap_or("mp4"),
1030 None => "mp4",
1031 };
1032 if audio_container_type(audio_path)?.eq(container) {
1035 let tmpfile_video = File::open(audio_path).await
1036 .map_err(|e| DashMpdError::Io(e, String::from("opening temporary output file")))?;
1037 let mut video = BufReader::new(tmpfile_video);
1038 let output_file = File::create(output_path).await
1039 .map_err(|e| DashMpdError::Io(e, String::from("creating output file")))?;
1040 let mut sink = BufWriter::new(output_file);
1041 io::copy(&mut video, &mut sink).await
1042 .map_err(|e| DashMpdError::Io(e, String::from("copying audio stream to output file")))?;
1043 return Ok(());
1044 }
1045 let mut muxer_preference = vec![];
1046 if container.eq("mkv") {
1047 muxer_preference.push("mkvmerge");
1048 muxer_preference.push("ffmpeg");
1049 muxer_preference.push("mp4box");
1050 } else {
1051 muxer_preference.push("ffmpeg");
1052 muxer_preference.push("mp4box");
1053 }
1054 if let Some(ordering) = downloader.muxer_preference.get(container) {
1055 muxer_preference.clear();
1056 for m in ordering.split(',') {
1057 muxer_preference.push(m);
1058 }
1059 }
1060 info!(" Muxer preference for {container} is {muxer_preference:?}");
1061 for muxer in muxer_preference {
1062 info!(" Trying muxer {muxer}");
1063 if muxer.eq("mkvmerge") {
1064 if let Err(e) = mux_audio_mkvmerge(downloader, output_path, audio_path).await {
1065 warn!(" Muxing with mkvmerge subprocess failed: {e}");
1066 } else {
1067 info!(" Muxing with mkvmerge subprocess succeeded");
1068 return Ok(());
1069 }
1070 } else if muxer.eq("ffmpeg") {
1071 if let Err(e) = mux_stream_ffmpeg(downloader, output_path, audio_path).await {
1072 warn!(" Muxing with ffmpeg subprocess failed: {e}");
1073 } else {
1074 info!(" Muxing with ffmpeg subprocess succeeded");
1075 return Ok(());
1076 }
1077 } else if muxer.eq("mp4box") {
1078 if let Err(e) = mux_stream_mp4box(downloader, output_path, audio_path).await {
1079 warn!(" Muxing with MP4Box subprocess failed: {e}");
1080 } else {
1081 info!(" Muxing with MP4Box subprocess succeeded");
1082 return Ok(());
1083 }
1084 }
1085 }
1086 warn!(" All available muxers failed");
1087 warn!(" unmuxed audio stream: {}", audio_path.display());
1088 Err(DashMpdError::Muxing(String::from("all available muxers failed")))
1089}
1090
1091
1092#[tracing::instrument(level="trace")]
1101fn make_ffmpeg_concat_filter_args(paths: &[&Path]) -> Vec<String> {
1102 let n = paths.len();
1103 let mut args = Vec::new();
1104 let mut anullsrc = String::new();
1105 let mut link_labels = Vec::new();
1106 let mut have_audio = false;
1107 let mut have_video = false;
1108 for (i, path) in paths.iter().enumerate().take(n) {
1109 let mut included = false;
1110 if container_has_video(path) {
1111 included = true;
1112 args.push(String::from("-i"));
1113 args.push(path.display().to_string());
1114 have_video = true;
1115 link_labels.push(format!("[{i}:v]"));
1116 }
1117 if container_has_audio(path) {
1118 if !included {
1119 args.push(String::from("-i"));
1120 args.push(path.display().to_string());
1121 }
1122 link_labels.push(format!("[{i}:a]"));
1123 have_audio = true;
1124 } else {
1125 anullsrc += &format!("anullsrc=r=48000:cl=mono:d=1[anull{i}:a];{anullsrc}");
1128 link_labels.push(format!("[anull{i}:a]"));
1129 }
1130 }
1131 let mut filter = String::new();
1132 if have_audio {
1135 filter += &anullsrc;
1136 filter += &link_labels.join("");
1137 } else {
1138 for ll in link_labels {
1141 if ! ll.starts_with("[anull") {
1142 filter += ≪
1143 }
1144 }
1145 }
1146 filter += &format!(" concat=n={n}");
1147 if have_video {
1148 filter += ":v=1";
1149 } else {
1150 filter += ":v=0";
1151 }
1152 if have_audio {
1153 filter += ":a=1";
1154 } else {
1155 filter += ":a=0";
1156 }
1157 if have_video {
1158 filter += "[outv]";
1159 }
1160 if have_audio {
1161 filter += "[outa]";
1162 }
1163 args.push(String::from("-filter_complex"));
1164 args.push(filter);
1165 if have_video {
1166 args.push(String::from("-map"));
1167 args.push(String::from("[outv]"));
1168 }
1169 if have_audio {
1170 args.push(String::from("-map"));
1171 args.push(String::from("[outa]"));
1172 }
1173 args
1174}
1175
1176
1177#[tracing::instrument(level="trace", skip(downloader))]
1180pub(crate) async fn concat_output_files_ffmpeg_filter(
1181 downloader: &DashDownloader,
1182 paths: &[&Path]) -> Result<(), DashMpdError>
1183{
1184 if paths.len() < 2 {
1185 return Err(DashMpdError::Muxing(String::from("need at least two files")));
1186 }
1187 let container = match paths[0].extension() {
1188 Some(ext) => ext.to_str().unwrap_or("mp4"),
1189 None => "mp4",
1190 };
1191 let output_format = match container {
1193 "mkv" => "matroska",
1194 "ts" => "mpegts",
1195 _ => container,
1196 };
1197 let tmpout = tempfile::Builder::new()
1200 .prefix("dashmpdrs")
1201 .suffix(&format!(".{container}"))
1202 .rand_bytes(5)
1203 .tempfile()
1204 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1205 let tmppath = &tmpout.path();
1206 fs::copy(paths[0], tmppath).await
1207 .map_err(|e| DashMpdError::Io(e, String::from("copying first input path")))?;
1208 let mut args = vec!["-hide_banner", "-nostats",
1209 "-loglevel", "error", "-y",
1211 "-nostdin"];
1212 let mut inputs = Vec::<&Path>::new();
1213 inputs.push(tmppath);
1214 for p in &paths[1..] {
1215 inputs.push(p);
1216 }
1217 let filter_args = make_ffmpeg_concat_filter_args(&inputs);
1218 filter_args.iter().for_each(|a| args.push(a));
1219 args.push("-movflags");
1220 args.push("faststart+omit_tfhd_offset");
1221 args.push("-f");
1222 args.push(output_format);
1223 let target = paths[0].to_string_lossy();
1224 args.push(&target);
1225 if downloader.verbosity > 0 {
1226 info!(" Concatenating with ffmpeg concat filter {}", args.join(" "));
1227 }
1228 let ffmpeg = Command::new(&downloader.ffmpeg_location)
1229 .args(args)
1230 .output()
1231 .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg")))?;
1232 let msg = partial_process_output(&ffmpeg.stdout);
1233 if downloader.verbosity > 0 && !msg.is_empty() {
1234 info!(" ffmpeg stdout: {msg}");
1235 }
1236 let msg = partial_process_output(&ffmpeg.stderr);
1237 if downloader.verbosity > 0 && !msg.is_empty() {
1238 info!(" ffmpeg stderr: {msg}");
1239 }
1240 if ffmpeg.status.success() {
1241 Ok(())
1242 } else {
1243 warn!(" unconcatenated input files:");
1244 for p in paths {
1245 warn!(" {}", p.display());
1246 }
1247 Err(DashMpdError::Muxing(String::from("running ffmpeg")))
1248 }
1249}
1250
1251#[tracing::instrument(level="trace", skip(downloader))]
1262pub(crate) async fn concat_output_files_ffmpeg_demuxer(
1263 downloader: &DashDownloader,
1264 paths: &[&Path]) -> Result<(), DashMpdError>
1265{
1266 if paths.len() < 2 {
1267 return Err(DashMpdError::Muxing(String::from("need at least two files")));
1268 }
1269 let container = match paths[0].extension() {
1270 Some(ext) => ext.to_str().unwrap_or("mp4"),
1271 None => "mp4",
1272 };
1273 let output_format = match container {
1275 "mkv" => "matroska",
1276 "ts" => "mpegts",
1277 _ => container,
1278 };
1279 let tmpout = tempfile::Builder::new()
1282 .prefix("dashmpdrs")
1283 .suffix(&format!(".{container}"))
1284 .rand_bytes(5)
1285 .tempfile()
1286 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1287 let tmppath = &tmpout
1288 .path()
1289 .to_str()
1290 .ok_or_else(|| DashMpdError::Io(
1291 io::Error::other("obtaining tmpfile name"),
1292 String::from("")))?;
1293 fs::copy(paths[0], tmppath).await
1294 .map_err(|e| DashMpdError::Io(e, String::from("copying first input path")))?;
1295 let mut args = vec!["-hide_banner", "-nostats",
1296 "-loglevel", "error", "-y",
1298 "-nostdin"];
1299 let demuxlist = tempfile::Builder::new()
1301 .prefix("dashmpddemux")
1302 .suffix(".txt")
1303 .rand_bytes(5)
1304 .tempfile()
1305 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1306 writeln!(&demuxlist, "ffconcat version 1.0")
1308 .map_err(|e| DashMpdError::Io(e, String::from("writing to demuxer cmd file")))?;
1309 let canonical = fs::canonicalize(tmppath).await
1310 .map_err(|e| DashMpdError::Io(e, String::from("canonicalizing temporary filename")))?;
1311 writeln!(&demuxlist, "file '{}'", canonical.display())
1312 .map_err(|e| DashMpdError::Io(e, String::from("writing to demuxer cmd file")))?;
1313 for p in &paths[1..] {
1314 let canonical = fs::canonicalize(p).await
1315 .map_err(|e| DashMpdError::Io(e, String::from("canonicalizing temporary filename")))?;
1316 writeln!(&demuxlist, "file '{}'", canonical.display())
1317 .map_err(|e| DashMpdError::Io(e, String::from("writing to demuxer cmd file")))?;
1318 }
1319 let demuxlistpath = &demuxlist
1320 .path()
1321 .to_str()
1322 .ok_or_else(|| DashMpdError::Io(
1323 io::Error::other("obtaining tmpfile name"),
1324 String::from("")))?;
1325 args.push("-f");
1326 args.push("concat");
1327 args.push("-safe");
1330 args.push("0");
1331 args.push("-i");
1332 args.push(demuxlistpath);
1333 args.push("-c");
1334 args.push("copy");
1335 args.push("-movflags");
1336 args.push("faststart+omit_tfhd_offset");
1337 args.push("-f");
1338 args.push(output_format);
1339 let target = String::from("file:") + &paths[0].to_string_lossy();
1340 args.push(&target);
1341 if downloader.verbosity > 0 {
1342 info!(" Concatenating with ffmpeg concat demuxer {}", args.join(" "));
1343 }
1344 let ffmpeg = Command::new(&downloader.ffmpeg_location)
1345 .args(args)
1346 .output()
1347 .map_err(|e| DashMpdError::Io(e, String::from("spawning ffmpeg")))?;
1348 let msg = partial_process_output(&ffmpeg.stdout);
1349 if downloader.verbosity > 0 && !msg.is_empty() {
1350 info!(" ffmpeg stdout: {msg}");
1351 }
1352 let msg = partial_process_output(&ffmpeg.stderr);
1353 if downloader.verbosity > 0 && !msg.is_empty() {
1354 info!(" ffmpeg stderr: {msg}");
1355 }
1356 if ffmpeg.status.success() {
1357 Ok(())
1358 } else {
1359 warn!(" unconcatenated input files:");
1360 for p in paths {
1361 warn!(" {}", p.display());
1362 }
1363 Err(DashMpdError::Muxing(String::from("running ffmpeg")))
1364 }
1365}
1366
1367
1368#[tracing::instrument(level="trace", skip(downloader))]
1372pub(crate) async fn concat_output_files_mp4box(
1373 downloader: &DashDownloader,
1374 paths: &[&Path]) -> Result<(), DashMpdError>
1375{
1376 if paths.len() < 2 {
1377 return Err(DashMpdError::Muxing(String::from("need at least two files")));
1378 }
1379 let tmpout = tempfile::Builder::new()
1380 .prefix("dashmpdrs")
1381 .suffix(".mp4")
1382 .rand_bytes(5)
1383 .tempfile()
1384 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1385 let tmppath = &tmpout
1386 .path()
1387 .to_str()
1388 .ok_or_else(|| DashMpdError::Io(
1389 io::Error::other("obtaining tmpfile name"),
1390 String::from("")))?;
1391 let tmpout_std = tmpout.reopen()
1393 .map_err(|e| DashMpdError::Io(e, String::from("reopening tmpout")))?;
1394 let tmpout_tio = File::from_std(tmpout_std);
1395 let mut tmpoutb = BufWriter::new(tmpout_tio);
1396 let overwritten = File::open(paths[0]).await
1397 .map_err(|e| DashMpdError::Io(e, String::from("opening first container")))?;
1398 let mut overwritten = BufReader::new(overwritten);
1399 io::copy(&mut overwritten, &mut tmpoutb).await
1400 .map_err(|e| DashMpdError::Io(e, String::from("copying from overwritten file")))?;
1401 let out = paths[0].to_string_lossy();
1403 let mut args = vec!["-flat", "-add", &tmppath];
1404 for p in &paths[1..] {
1405 if let Some(ps) = p.to_str() {
1406 args.push("-cat");
1407 args.push(ps);
1408 } else {
1409 warn!(" Ignoring non-Unicode pathname {:?}", p);
1410 }
1411 }
1412 args.push(&out);
1413 if downloader.verbosity > 0 {
1414 info!(" Concatenating with MP4Box {}", args.join(" "));
1415 }
1416 let mp4box = Command::new(&downloader.mp4box_location)
1417 .args(args)
1418 .output()
1419 .map_err(|e| DashMpdError::Io(e, String::from("spawning MP4Box subprocess")))?;
1420 let msg = partial_process_output(&mp4box.stdout);
1421 if downloader.verbosity > 0 && !msg.is_empty() {
1422 info!(" MP4Box stdout: {msg}");
1423 }
1424 let msg = partial_process_output(&mp4box.stderr);
1425 if downloader.verbosity > 0 && !msg.is_empty() {
1426 info!(" MP4Box stderr: {msg}");
1427 }
1428 if mp4box.status.success() {
1429 Ok(())
1430 } else {
1431 warn!(" unconcatenated input files:");
1432 for p in paths {
1433 warn!(" {}", p.display());
1434 }
1435 Err(DashMpdError::Muxing(String::from("running MP4Box")))
1436 }
1437}
1438
1439#[tracing::instrument(level="trace", skip(downloader))]
1440pub(crate) async fn concat_output_files_mkvmerge(
1441 downloader: &DashDownloader,
1442 paths: &[&Path]) -> Result<(), DashMpdError>
1443{
1444 if paths.len() < 2 {
1445 return Err(DashMpdError::Muxing(String::from("need at least two files")));
1446 }
1447 let tmpout = tempfile::Builder::new()
1448 .prefix("dashmpdrs")
1449 .suffix(".mkv")
1450 .rand_bytes(5)
1451 .tempfile()
1452 .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
1453 let tmppath = &tmpout
1454 .path()
1455 .to_str()
1456 .ok_or_else(|| DashMpdError::Io(
1457 io::Error::other("obtaining tmpfile name"),
1458 String::from("")))?;
1459 let tmpout_std = tmpout.reopen()
1461 .map_err(|e| DashMpdError::Io(e, String::from("reopening tmpout")))?;
1462 let tmpout_tio = File::from_std(tmpout_std);
1463 let mut tmpoutb = BufWriter::new(tmpout_tio);
1464 let overwritten = File::open(paths[0]).await
1465 .map_err(|e| DashMpdError::Io(e, String::from("opening first container")))?;
1466 let mut overwritten = BufReader::new(overwritten);
1467 io::copy(&mut overwritten, &mut tmpoutb).await
1468 .map_err(|e| DashMpdError::Io(e, String::from("copying from overwritten file")))?;
1469 let mut args = Vec::new();
1471 if downloader.verbosity < 1 {
1472 args.push("--quiet");
1473 }
1474 args.push("--append-mode");
1475 args.push("file");
1476 args.push("-o");
1477 let out = paths[0].to_string_lossy();
1478 args.push(&out);
1479 args.push("[");
1480 args.push(tmppath);
1481 if let Some(inpaths) = paths.get(1..) {
1482 for p in inpaths {
1483 if let Some(ps) = p.to_str() {
1484 args.push(ps);
1485 }
1486 }
1487 }
1488 args.push("]");
1489 if downloader.verbosity > 1 {
1490 info!(" Concatenating with mkvmerge {}", args.join(" "));
1491 }
1492 let mkvmerge = Command::new(&downloader.mkvmerge_location)
1493 .args(args)
1494 .output()
1495 .map_err(|e| DashMpdError::Io(e, String::from("spawning mkvmerge")))?;
1496 let msg = partial_process_output(&mkvmerge.stdout);
1497 if downloader.verbosity > 0 && !msg.is_empty() {
1498 info!(" mkvmerge stdout: {msg}");
1499 }
1500 let msg = partial_process_output(&mkvmerge.stderr);
1501 if downloader.verbosity > 0 && !msg.is_empty() {
1502 info!(" mkvmerge stderr: {msg}");
1503 }
1504 if mkvmerge.status.success() {
1505 Ok(())
1506 } else {
1507 warn!(" unconcatenated input files:");
1508 for p in paths {
1509 warn!(" {}", p.display());
1510 }
1511 Err(DashMpdError::Muxing(String::from("running mkvmerge")))
1512 }
1513}
1514
1515#[tracing::instrument(level="trace", skip(downloader))]
1517pub(crate) async fn concat_output_files(
1518 downloader: &DashDownloader,
1519 paths: &[&Path]) -> Result<(), DashMpdError> {
1520 if paths.len() < 2 {
1521 return Ok(());
1522 }
1523 let container = if let Some(p0) = paths.first() {
1524 match p0.extension() {
1525 Some(ext) => ext.to_str().unwrap_or("mp4"),
1526 None => "mp4",
1527 }
1528 } else {
1529 "mp4"
1530 };
1531 let mut concat_preference = vec![];
1532 if container.eq("mp4") ||
1533 container.eq("mkv") ||
1534 container.eq("webm")
1535 {
1536 concat_preference.push("mkvmerge");
1541 concat_preference.push("ffmpeg");
1542 } else {
1543 concat_preference.push("ffmpeg");
1544 }
1545 if let Some(ordering) = downloader.concat_preference.get(container) {
1546 concat_preference.clear();
1547 for m in ordering.split(',') {
1548 concat_preference.push(m);
1549 }
1550 }
1551 info!(" Concat helper preference for {container} is {concat_preference:?}");
1552 for concat in concat_preference {
1553 info!(" Trying concat helper {concat}");
1554 if concat.eq("mkvmerge") {
1555 if let Err(e) = concat_output_files_mkvmerge(downloader, paths).await {
1556 warn!(" Concatenation with mkvmerge failed: {e}");
1557 } else {
1558 info!(" Concatenation with mkvmerge succeeded");
1559 return Ok(());
1560 }
1561 } else if concat.eq("ffmpeg") {
1562 if let Err(e) = concat_output_files_ffmpeg_filter(downloader, paths).await {
1563 warn!(" Concatenation with ffmpeg filter failed: {e}");
1564 } else {
1565 info!(" Concatenation with ffmpeg filter succeeded");
1566 return Ok(());
1567 }
1568 } else if concat.eq("ffmpegdemuxer") {
1569 if let Err(e) = concat_output_files_ffmpeg_demuxer(downloader, paths).await {
1570 warn!(" Concatenation with ffmpeg demuxer failed: {e}");
1571 } else {
1572 info!(" Concatenation with ffmpeg demuxer succeeded");
1573 return Ok(());
1574 }
1575 } else if concat.eq("mp4box") {
1576 if let Err(e) = concat_output_files_mp4box(downloader, paths).await {
1577 warn!(" Concatenation with MP4Box failed: {e}");
1578 } else {
1579 info!(" Concatenation with MP4Box succeeded");
1580 return Ok(());
1581 }
1582 } else {
1583 warn!(" Ignoring unknown concat helper preference {concat}");
1584 }
1585 }
1586 warn!(" All concat helpers failed");
1587 Err(DashMpdError::Muxing(String::from("all concat helpers failed")))
1588}
1589
1590
1591#[cfg(test)]
1593mod tests {
1594 use std::path::Path;
1595 use assert_cmd::Command;
1596 use tokio::fs;
1597
1598
1599 fn generate_mp4_hue_tone(filename: &Path, color: &str, tone: &str) {
1600 Command::new("ffmpeg")
1601 .args(["-y", "-nostdin",
1603 "-lavfi", &format!("color=c={color}:duration=5:size=50x50:rate=1;sine=frequency={tone}:sample_rate=48000:duration=5"),
1604 "-c:v", "libx264",
1610 "-pix_fmt", "yuv420p",
1611 "-profile:v", "baseline",
1612 "-framerate", "25",
1613 "-movflags", "faststart",
1614 filename.to_str().unwrap()])
1615 .assert()
1616 .success();
1617 }
1618
1619 #[tokio::test]
1626 async fn test_concat_helpers() {
1627 use crate::fetch::DashDownloader;
1628 use crate::ffmpeg::{
1629 concat_output_files_ffmpeg_filter,
1630 concat_output_files_ffmpeg_demuxer,
1631 concat_output_files_mkvmerge
1632 };
1633 use image::ImageReader;
1634 use image::Rgb;
1635
1636 async fn check_color_sequence(merged: &Path) {
1639 let tmpd = tempfile::tempdir().unwrap();
1640 let capture_red = tmpd.path().join("capture-red.png");
1641 Command::new("ffmpeg")
1642 .args(["-ss", "2.5",
1643 "-i", merged.to_str().unwrap(),
1644 "-frames:v", "1",
1645 capture_red.to_str().unwrap()])
1646 .assert()
1647 .success();
1648 let img = ImageReader::open(&capture_red).unwrap()
1649 .decode().unwrap()
1650 .into_rgb8();
1651 for pixel in img.pixels() {
1652 match pixel {
1653 Rgb(rgb) => {
1654 assert!(rgb[0] > 250);
1655 assert!(rgb[1] < 5);
1656 assert!(rgb[2] < 5);
1657 },
1658 };
1659 }
1660 fs::remove_file(&capture_red).await.unwrap();
1661 let capture_green = tmpd.path().join("capture-green.png");
1663 Command::new("ffmpeg")
1664 .args(["-ss", "7.5",
1665 "-i", merged.to_str().unwrap(),
1666 "-frames:v", "1",
1667 capture_green.to_str().unwrap()])
1668 .assert()
1669 .success();
1670 let img = ImageReader::open(&capture_green).unwrap()
1671 .decode().unwrap()
1672 .into_rgb8();
1673 for pixel in img.pixels() {
1674 match pixel {
1675 Rgb(rgb) => {
1676 assert!(rgb[0] < 5);
1677 assert!(rgb[1].abs_diff(127) < 5);
1678 assert!(rgb[2] < 5);
1679 },
1680 };
1681 }
1682 fs::remove_file(&capture_green).await.unwrap();
1683 let capture_blue = tmpd.path().join("capture-blue.png");
1685 Command::new("ffmpeg")
1686 .args(["-ss", "12.5",
1687 "-i", merged.to_str().unwrap(),
1688 "-frames:v", "1",
1689 capture_blue.to_str().unwrap()])
1690 .assert()
1691 .success();
1692 let img = ImageReader::open(&capture_blue).unwrap()
1693 .decode().unwrap()
1694 .into_rgb8();
1695 for pixel in img.pixels() {
1696 match pixel {
1697 Rgb(rgb) => {
1698 assert!(rgb[0] < 5);
1699 assert!(rgb[1] < 5);
1700 assert!(rgb[2] > 250);
1701 },
1702 };
1703 }
1704 fs::remove_file(&capture_blue).await.unwrap();
1705 }
1706
1707 let tmpd = tempfile::tempdir().unwrap();
1708 let red = tmpd.path().join("concat-red.mp4");
1709 let green = tmpd.path().join("concat-green.mp4");
1710 let blue = tmpd.path().join("concat-blue.mp4");
1711 generate_mp4_hue_tone(&red, "red", "400");
1712 generate_mp4_hue_tone(&green, "green", "600");
1713 generate_mp4_hue_tone(&blue, "blue", "800");
1714 let ddl = DashDownloader::new("https://www.example.com/")
1715 .verbosity(2);
1716
1717 let output_ffmpeg_filter = tmpd.path().join("output-ffmpeg-filter.mp4");
1718 fs::copy(&red, &output_ffmpeg_filter).await.unwrap();
1719 concat_output_files_ffmpeg_filter(
1720 &ddl,
1721 &[&output_ffmpeg_filter, &green, &blue]).await.unwrap();
1722 check_color_sequence(&output_ffmpeg_filter).await;
1723 fs::remove_file(&output_ffmpeg_filter).await.unwrap();
1724
1725 let output_ffmpeg_demuxer = tmpd.path().join("output-ffmpeg-demuxer.mp4");
1726 fs::copy(&red, &output_ffmpeg_demuxer).await.unwrap();
1727 concat_output_files_ffmpeg_demuxer(
1728 &ddl,
1729 &[&output_ffmpeg_demuxer, &green, &blue]).await.unwrap();
1730 check_color_sequence(&output_ffmpeg_demuxer).await;
1731 fs::remove_file(&output_ffmpeg_demuxer).await.unwrap();
1732
1733 let red = tmpd.path().join("concat-red.mkv");
1738 let green = tmpd.path().join("concat-green.mkv");
1739 let blue = tmpd.path().join("concat-blue.mkv");
1740 generate_mp4_hue_tone(&red, "red", "400");
1741 generate_mp4_hue_tone(&green, "green", "600");
1742 generate_mp4_hue_tone(&blue, "blue", "800");
1743
1744 let output_mkvmerge = tmpd.path().join("output-mkvmerge.mkv");
1745 fs::copy(&red, &output_mkvmerge).await.unwrap();
1746 concat_output_files_mkvmerge(
1747 &ddl,
1748 &[&output_mkvmerge, &green, &blue]).await.unwrap();
1749 check_color_sequence(&output_mkvmerge).await;
1750 fs::remove_file(&output_mkvmerge).await.unwrap();
1751
1752 let output_ffmpeg_filter = tmpd.path().join("output-ffmpeg-filter.mkv");
1753 fs::copy(&red, &output_ffmpeg_filter).await.unwrap();
1754 concat_output_files_ffmpeg_filter(
1755 &ddl,
1756 &[&output_ffmpeg_filter, &green, &blue]).await.unwrap();
1757 check_color_sequence(&output_ffmpeg_filter).await;
1758 fs::remove_file(&output_ffmpeg_filter).await.unwrap();
1759
1760 let output_ffmpeg_demuxer = tmpd.path().join("output-ffmpeg-demuxer.mkv");
1761 fs::copy(&red, &output_ffmpeg_demuxer).await.unwrap();
1762 concat_output_files_ffmpeg_demuxer(
1763 &ddl,
1764 &[&output_ffmpeg_demuxer, &green, &blue]).await.unwrap();
1765 check_color_sequence(&output_ffmpeg_demuxer).await;
1766 fs::remove_file(&output_ffmpeg_demuxer).await.unwrap();
1767
1768 let _ = fs::remove_dir_all(tmpd).await.unwrap();
1769 }
1770}