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