1use std::ffi::OsString;
4use std::path::{Path, PathBuf};
5
6use crate::config::MuxerKind;
7use crate::datetime::current_local_iso_timestamp;
8use crate::error::{Error, Result};
9use crate::manifest::{EncryptionMethod, MediaType, Stream};
10
11#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
13#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
14pub enum MuxFormat {
15 #[default]
17 Mp4,
18 Mkv,
20 Ts,
22}
23
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub enum MergeOutputFormat {
27 #[default]
29 Mp4,
30 Mkv,
32 Flv,
34 M4a,
36 Ts,
38 Eac3,
40 Aac,
42 Ac3,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct MuxCommandPlan {
49 pub program: PathBuf,
51 pub arguments: String,
53 pub working_directory: PathBuf,
55 pub output_path: PathBuf,
57}
58
59#[derive(Clone, Debug, Default, Eq, PartialEq)]
61pub struct FfmpegMergeMetadata {
62 pub poster: Option<PathBuf>,
63 pub ddp_audio: Option<PathBuf>,
65 pub audio_name: String,
66 pub title: String,
67 pub copyright: String,
68 pub comment: String,
69 pub encoding_tool: String,
70 pub recording_time: Option<String>,
71 pub date_string: Option<String>,
72}
73
74#[derive(Clone, Copy, Debug)]
76pub struct FfmpegMergeRequest<'a> {
77 pub binary: &'a Path,
79 pub files: &'a [PathBuf],
81 pub output_base_path: &'a Path,
83 pub format: MergeOutputFormat,
85 pub use_aac_filter: bool,
87 pub fast_start: bool,
89 pub write_date: bool,
91 pub use_concat_demuxer: bool,
93 pub concat_list_path: Option<&'a Path>,
95 pub metadata: &'a FfmpegMergeMetadata,
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum Mp4forgeSupport {
102 Supported,
104 RequiresProbe { reason: String },
106 Unsupported { reason: String },
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct Mp4forgeSupportMatrix {
113 pub version: String,
114 pub video_codecs: Vec<String>,
115 pub audio_codecs: Vec<String>,
116}
117
118impl Default for Mp4forgeSupportMatrix {
119 fn default() -> Self {
120 Self {
121 version: "haki-dl-mp4forge-matrix-v1".to_string(),
122 video_codecs: vec![
123 "avc1".to_string(),
124 "h264".to_string(),
125 "hvc1".to_string(),
126 "hev1".to_string(),
127 "hevc".to_string(),
128 "dvhe".to_string(),
129 "dvh1".to_string(),
130 ],
131 audio_codecs: vec![
132 "mp4a".to_string(),
133 "aac".to_string(),
134 "ec-3".to_string(),
135 "ec3".to_string(),
136 "ac-3".to_string(),
137 "ac3".to_string(),
138 ],
139 }
140 }
141}
142
143#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct OutputArtifact {
147 pub path: PathBuf,
149 pub media_type: Option<crate::manifest::MediaType>,
151 pub language: Option<String>,
153 pub description: Option<String>,
155}
156
157impl OutputArtifact {
158 pub fn new(path: impl Into<PathBuf>) -> Self {
160 Self {
161 path: path.into(),
162 media_type: None,
163 language: None,
164 description: None,
165 }
166 }
167}
168
169#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct MuxImport {
173 pub path: PathBuf,
175 pub language: Option<String>,
177 pub name: Option<String>,
179 pub index: i32,
181}
182
183impl MuxImport {
184 pub fn new(path: impl Into<PathBuf>) -> Self {
186 Self {
187 path: path.into(),
188 language: None,
189 name: None,
190 index: 999,
191 }
192 }
193}
194
195#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
197#[derive(Clone, Debug, Eq, PartialEq)]
198pub struct MuxOptions {
199 pub use_mkvmerge: bool,
201 pub format: MuxFormat,
203 pub keep_files: bool,
205 pub skip_subtitle: bool,
207 pub bin_path: Option<PathBuf>,
209}
210
211impl Default for MuxOptions {
212 fn default() -> Self {
213 Self {
214 use_mkvmerge: false,
215 format: MuxFormat::Mp4,
216 keep_files: false,
217 skip_subtitle: false,
218 bin_path: None,
219 }
220 }
221}
222
223#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
225#[derive(Clone, Debug, Default, Eq, PartialEq)]
226pub struct MediaInfo {
227 pub id: Option<String>,
228 pub text: Option<String>,
229 pub base_info: Option<String>,
230 pub bitrate: Option<String>,
231 pub resolution: Option<String>,
232 pub fps: Option<String>,
233 pub media_type: Option<String>,
234 pub start_time_millis: Option<i64>,
235 pub dolby_vision: bool,
236 pub hdr: bool,
237}
238
239#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
241#[derive(Clone, Debug, Eq, PartialEq)]
242pub struct OutputFile {
243 pub media_type: Option<crate::manifest::MediaType>,
245 pub index: i32,
247 pub file_path: PathBuf,
249 pub lang_code: Option<String>,
251 pub description: Option<String>,
253 pub media_infos: Vec<MediaInfo>,
255}
256
257impl OutputFile {
258 pub fn new(index: i32, file_path: impl Into<PathBuf>) -> Self {
260 Self {
261 media_type: None,
262 index,
263 file_path: file_path.into(),
264 lang_code: None,
265 description: None,
266 media_infos: Vec::new(),
267 }
268 }
269}
270
271pub fn mux_extension(format: MuxFormat) -> &'static str {
273 match format {
274 MuxFormat::Mp4 => ".mp4",
275 MuxFormat::Mkv => ".mkv",
276 MuxFormat::Ts => ".ts",
277 }
278}
279
280pub fn merge_extension(format: MergeOutputFormat) -> &'static str {
282 match format {
283 MergeOutputFormat::Mp4 => ".mp4",
284 MergeOutputFormat::Mkv => ".mkv",
285 MergeOutputFormat::Flv => ".flv",
286 MergeOutputFormat::M4a | MergeOutputFormat::Aac => ".m4a",
287 MergeOutputFormat::Ts => ".ts",
288 MergeOutputFormat::Eac3 => ".eac3",
289 MergeOutputFormat::Ac3 => ".ac3",
290 }
291}
292
293pub async fn combine_files(files: &[PathBuf], output_path: &Path) -> Result<()> {
295 if files.is_empty() {
296 return Ok(());
297 }
298 if let Some(parent) = output_path.parent() {
299 tokio::fs::create_dir_all(parent).await?;
300 }
301 if files.len() == 1 {
302 tokio::fs::copy(&files[0], output_path).await?;
303 return Ok(());
304 }
305 let mut output = tokio::fs::File::create(output_path).await?;
306 for path in files {
307 if path.as_os_str().is_empty() {
308 continue;
309 }
310 let mut input = tokio::fs::File::open(path).await?;
311 tokio::io::copy(&mut input, &mut output).await?;
312 }
313 Ok(())
314}
315
316pub async fn partial_combine_files(files: &[PathBuf]) -> Result<Vec<PathBuf>> {
318 if files.is_empty() {
319 return Ok(Vec::new());
320 }
321 let div = if files.len() <= 90_000 { 100 } else { 200 };
322 let parent = files[0]
323 .parent()
324 .ok_or_else(|| Error::mux("input file has no parent directory"))?;
325 let mut new_files = Vec::new();
326 for (index, chunk) in files.chunks(div).enumerate() {
327 if chunk.is_empty() {
328 continue;
329 }
330 let output = parent.join(format!("T{index:04}.ts"));
331 combine_files(chunk, &output).await?;
332 for path in chunk {
333 if tokio::fs::try_exists(path).await? {
334 tokio::fs::remove_file(path).await?;
335 }
336 }
337 new_files.push(output);
338 }
339 Ok(new_files)
340}
341
342pub fn plan_ffmpeg_merge(request: FfmpegMergeRequest<'_>) -> Result<MuxCommandPlan> {
344 let FfmpegMergeRequest {
345 binary,
346 files,
347 output_base_path,
348 format,
349 use_aac_filter,
350 fast_start,
351 write_date,
352 use_concat_demuxer,
353 concat_list_path,
354 metadata,
355 } = request;
356 let first = files
357 .first()
358 .ok_or_else(|| Error::mux("ffmpeg merge requires at least one input"))?;
359 let working_directory = first
360 .parent()
361 .map(Path::to_path_buf)
362 .ok_or_else(|| Error::mux("input file has no parent directory"))?;
363 let mut command = String::from("-loglevel warning -nostdin ");
364 if use_concat_demuxer {
365 let concat_list_path = concat_list_path
366 .ok_or_else(|| Error::mux("ffmpeg concat demuxer requires a concat list path"))?;
367 let concat_list_path = absolute_output_base_path(concat_list_path)?;
368 command.push_str(&format!(
369 " -f concat -safe 0 -i \"{}\"",
370 concat_list_path.display()
371 ));
372 } else {
373 command.push_str(" -i concat:\"");
374 for path in files {
375 if let Some(name) = path.file_name().and_then(|value| value.to_str()) {
376 command.push_str(name);
377 command.push('|');
378 }
379 }
380 command.push('"');
381 }
382
383 let output_base_path = absolute_output_base_path(output_base_path)?;
384 let output_path = path_with_appended_extension(&output_base_path, merge_extension(format));
385 let generated_date;
386 let date_string = match metadata
387 .recording_time
388 .as_deref()
389 .or(metadata.date_string.as_deref())
390 {
391 Some(value) => value,
392 None => {
393 generated_date = current_local_iso_timestamp();
394 generated_date.as_str()
395 }
396 };
397 let ddp_audio = metadata.ddp_audio.as_ref();
398 let mut use_aac_filter = use_aac_filter;
399 if ddp_audio.is_some() {
400 use_aac_filter = false;
401 }
402 match format {
403 MergeOutputFormat::Mp4 => {
404 if let Some(poster) = &metadata.poster {
405 command.push_str(&format!(" -i \"{}\"", poster.display()));
406 }
407 if let Some(ddp_audio) = ddp_audio {
408 command.push_str(&format!(" -i \"{}\"", ddp_audio.display()));
409 }
410 command.push_str(" -map 0:v?");
411 if ddp_audio.is_some() {
412 let ddp_input_index = if metadata.poster.is_some() { 2 } else { 1 };
413 command.push_str(&format!(" -map {ddp_input_index}:a -map 0:a?"));
414 } else {
415 command.push_str(" -map 0:a?");
416 }
417 command.push_str(" -map 0:s?");
418 if metadata.poster.is_some() {
419 command.push_str(" -map 1 -c:v:1 copy -disposition:v:1 attached_pic");
420 }
421 if write_date {
422 command.push_str(&format!(" -metadata date=\"{date_string}\""));
423 }
424 let audio_metadata_index = if ddp_audio.is_some() { 1 } else { 0 };
425 command.push_str(&format!(
426 " -metadata encoding_tool=\"{}\" -metadata title=\"{}\" -metadata copyright=\"{}\" -metadata comment=\"{}\" -metadata:s:a:{audio_metadata_index} title=\"{}\" -metadata:s:a:{audio_metadata_index} handler=\"{}\"",
427 metadata.encoding_tool,
428 metadata.title,
429 metadata.copyright,
430 metadata.comment,
431 metadata.audio_name,
432 metadata.audio_name
433 ));
434 if ddp_audio.is_some() {
435 command.push_str(" -metadata:s:a:0 title=\"DD+\" -metadata:s:a:0 handler=\"DD+\"");
436 }
437 if fast_start {
438 command.push_str(" -movflags +faststart");
439 }
440 command.push_str(" -c copy -y");
441 if use_aac_filter {
442 command.push_str(" -bsf:a aac_adtstoasc");
443 }
444 command.push_str(&format!(" \"{}\"", output_path.display()));
445 }
446 MergeOutputFormat::Mkv => append_simple_ffmpeg_output(
447 &mut command,
448 " -map 0 -c copy -y",
449 use_aac_filter,
450 &output_path,
451 ),
452 MergeOutputFormat::Flv => append_simple_ffmpeg_output(
453 &mut command,
454 " -map 0 -c copy -y",
455 use_aac_filter,
456 &output_path,
457 ),
458 MergeOutputFormat::M4a => append_simple_ffmpeg_output(
459 &mut command,
460 " -map 0 -c copy -f mp4 -y",
461 use_aac_filter,
462 &output_path,
463 ),
464 MergeOutputFormat::Ts => {
465 command.push_str(&format!(
466 " -map 0 -c copy -y -f mpegts -bsf:v h264_mp4toannexb \"{}\"",
467 output_path.display()
468 ));
469 }
470 MergeOutputFormat::Eac3 => {
471 command.push_str(&format!(
472 " -map 0:a -c copy -y \"{}\"",
473 output_path.display()
474 ));
475 }
476 MergeOutputFormat::Aac => {
477 command.push_str(&format!(
478 " -map 0:a -c copy -y \"{}\"",
479 output_path.display()
480 ));
481 }
482 MergeOutputFormat::Ac3 => {
483 command.push_str(&format!(
484 " -map 0:a -c copy -y \"{}\"",
485 output_path.display()
486 ));
487 }
488 }
489
490 Ok(MuxCommandPlan {
491 program: binary.to_path_buf(),
492 arguments: command,
493 working_directory,
494 output_path,
495 })
496}
497
498pub fn plan_ffmpeg_mux(
500 binary: impl Into<PathBuf>,
501 files: &[OutputFile],
502 output_base_path: &Path,
503 format: MuxFormat,
504 date_info: bool,
505 date_string: Option<&str>,
506) -> Result<MuxCommandPlan> {
507 let mut command = String::from("-loglevel warning -nostdin -y -dn ");
508 for file in files {
509 command.push_str(&format!(" -i \"{}\" ", file.file_path.display()));
510 }
511 for index in 0..files.len() {
512 command.push_str(&format!(" -map {index} "));
513 }
514 let has_srt = files
515 .iter()
516 .any(|file| has_extension(&file.file_path, "srt"));
517 match format {
518 MuxFormat::Mp4 => {
519 command.push_str(" -strict unofficial -c:a copy -c:v copy -c:s mov_text ")
520 }
521 MuxFormat::Ts => command.push_str(" -strict unofficial -c:a copy -c:v copy "),
522 MuxFormat::Mkv => {
523 command.push_str(" -strict unofficial -c:a copy -c:v copy -c:s ");
524 command.push_str(if has_srt { "srt " } else { "webvtt " });
525 }
526 }
527 command.push_str(" -map_metadata -1 ");
528 let mut stream_index = 0_usize;
529 for file in files {
530 let (language, description) = mux_language_and_description(file);
531 command.push_str(&format!(
532 " -metadata:s:{stream_index} language=\"{}\" ",
533 language
534 ));
535 if let Some(description) = &description
536 && !description.is_empty()
537 {
538 command.push_str(&format!(
539 " -metadata:s:{stream_index} title=\"{description}\" "
540 ));
541 }
542 stream_index += file.media_infos.len().max(1);
543 }
544 if files.iter().any(|file| {
545 file.media_type != Some(MediaType::Audio) && file.media_type != Some(MediaType::Subtitles)
546 }) {
547 command.push_str(" -disposition:v:0 default ");
548 }
549 let audio_count = files
550 .iter()
551 .filter(|file| file.media_type == Some(MediaType::Audio))
552 .count();
553 if audio_count > 0 {
554 command.push_str(" -disposition:a:0 default ");
555 for index in 1..audio_count {
556 command.push_str(&format!(" -disposition:a:{index} 0 "));
557 }
558 command.push_str(" -disposition:s 0 ");
559 }
560 if date_info {
561 let generated_date;
562 let date_string = match date_string {
563 Some(value) => value,
564 None => {
565 generated_date = current_local_iso_timestamp();
566 generated_date.as_str()
567 }
568 };
569 command.push_str(&format!(" -metadata date=\"{}\" ", date_string));
570 }
571 command.push_str(" -ignore_unknown -copy_unknown ");
572 let output_base_path = absolute_output_base_path(output_base_path)?;
573 let output_path = path_with_appended_extension(&output_base_path, mux_extension(format));
574 command.push_str(&format!(" \"{}\"", output_path.display()));
575 Ok(MuxCommandPlan {
576 program: binary.into(),
577 arguments: command,
578 working_directory: std::env::current_dir()?,
579 output_path,
580 })
581}
582
583pub fn plan_mkvmerge_mux(
585 binary: impl Into<PathBuf>,
586 files: &[OutputFile],
587 output_base_path: &Path,
588) -> Result<MuxCommandPlan> {
589 let output_base_path = absolute_output_base_path(output_base_path)?;
590 let output_path = path_with_appended_extension(&output_base_path, ".mkv");
591 let mut command = format!("-q --output \"{}\" --no-chapters ", output_path.display());
592 let mut audio_default_seen = false;
593 for file in files {
594 let (language, description) = mux_language_and_description(file);
595 command.push_str(&format!(" --language 0:\"{}\" ", language));
596 if file.media_type == Some(MediaType::Subtitles) {
597 command.push_str(" --default-track 0:no ");
598 }
599 if file.media_type == Some(MediaType::Audio) {
600 if audio_default_seen {
601 command.push_str(" --default-track 0:no ");
602 }
603 audio_default_seen = true;
604 }
605 if let Some(description) = &description
606 && !description.is_empty()
607 {
608 command.push_str(&format!(" --track-name 0:\"{description}\" "));
609 }
610 command.push_str(&format!(" \"{}\" ", file.file_path.display()));
611 }
612 Ok(MuxCommandPlan {
613 program: binary.into(),
614 arguments: command,
615 working_directory: std::env::current_dir()?,
616 output_path,
617 })
618}
619
620fn mux_language_and_description(file: &OutputFile) -> (String, Option<String>) {
621 let Some(original) = file.lang_code.as_deref().filter(|value| !value.is_empty()) else {
622 return ("und".to_string(), file.description.clone());
623 };
624 let language = convert_language_code(original);
625 let description = file
626 .description
627 .as_ref()
628 .filter(|value| !value.is_empty())
629 .cloned()
630 .or_else(|| language_display_name(original));
631 (language, description)
632}
633
634fn convert_language_code(value: &str) -> String {
635 let normalized = value.trim();
636 let lower = normalized.to_ascii_lowercase();
637 if let Some(language) = language_code_map(lower.as_str()) {
638 return language.to_string();
639 }
640 if let Some((prefix, _)) = lower.split_once('-')
641 && let Some(language) = language_code_map(prefix)
642 {
643 return language.to_string();
644 }
645 if normalized.len() == 3 && normalized.chars().all(|ch| ch.is_ascii_alphabetic()) {
646 return lower;
647 }
648 "und".to_string()
649}
650
651fn language_display_name(value: &str) -> Option<String> {
652 let lower = value.trim().to_ascii_lowercase();
653 language_name_map(lower.as_str())
654 .or_else(|| {
655 lower
656 .split_once('-')
657 .and_then(|(prefix, _)| language_name_map(prefix))
658 })
659 .map(str::to_string)
660 .or_else(|| Some(value.to_string()))
661}
662
663fn language_code_map(value: &str) -> Option<&'static str> {
664 match value {
665 "default" => Some("und"),
666 "ar" => Some("ara"),
667 "bg" => Some("bul"),
668 "ca" => Some("cat"),
669 "zh" | "zho" | "chi" | "chs" | "zh-cn" | "zh-sg" | "zh-mo" | "zh-hans" | "zh-hant"
670 | "zh-tw" | "zh-hant-tw" | "zh-hk" | "zh-hant-hk" | "yue" | "cmn" | "cmn-hans"
671 | "cmn-hant" | "cantonese" | "mandarin" | "cn" | "cc" | "cz" => Some("chi"),
672 "cs" => Some("ces"),
673 "da" => Some("dan"),
674 "de" => Some("deu"),
675 "el" => Some("ell"),
676 "en" | "english" => Some("eng"),
677 "es" => Some("spa"),
678 "fi" => Some("fin"),
679 "fr" => Some("fra"),
680 "he" => Some("heb"),
681 "hu" => Some("hun"),
682 "is" => Some("isl"),
683 "it" => Some("ita"),
684 "ja" | "japanese" => Some("jpn"),
685 "ko" | "korean" => Some("kor"),
686 "nl" => Some("nld"),
687 "nb" => Some("nob"),
688 "pl" => Some("pol"),
689 "pt" => Some("por"),
690 "ro" => Some("ron"),
691 "ru" => Some("rus"),
692 "hr" => Some("hrv"),
693 "sk" => Some("slk"),
694 "sq" => Some("sqi"),
695 "sv" => Some("swe"),
696 "th" | "thai" => Some("tha"),
697 "tr" => Some("tur"),
698 "ur" => Some("urd"),
699 "id" => Some("ind"),
700 "uk" => Some("ukr"),
701 "vi" | "vietnamese" => Some("vie"),
702 "ms" | "ma" => Some("msa"),
703 _ => None,
704 }
705}
706
707fn language_name_map(value: &str) -> Option<&'static str> {
708 match value {
709 "default" => Some("default"),
710 "en" | "eng" | "english" => Some("English"),
711 "ja" | "jpn" | "japanese" => Some("\u{65e5}\u{672c}\u{8a9e}"),
712 "ko" | "kor" | "korean" => Some("\u{d55c}\u{ad6d}\u{c5b4}"),
713 "vi" | "vie" | "vietnamese" => Some("Vietnamese"),
714 "th" | "tha" | "thai" => Some("Thai"),
715 "fr" | "fra" => Some("French"),
716 "es" | "spa" => Some("Spanish"),
717 "de" | "deu" => Some("German"),
718 "it" | "ita" => Some("Italian"),
719 "pt" | "por" => Some("Portuguese"),
720 "ru" | "rus" => Some("Russian"),
721 "zh" | "zho" | "chi" | "chs" | "zh-cn" | "zh-sg" | "zh-hans" | "cmn" | "cmn-hans"
722 | "mandarin" | "cn" | "cz" => Some("\u{4e2d}\u{6587}"),
723 "zh-mo" | "zh-hant" | "zh-tw" | "zh-hant-tw" | "zh-hk" | "zh-hant-hk" | "yue"
724 | "cmn-hant" | "cantonese" | "cc" => Some("\u{4e2d}\u{6587}"),
725 _ => None,
726 }
727}
728
729fn path_with_appended_extension(base: &Path, extension: &str) -> PathBuf {
730 let mut value = OsString::from(base.as_os_str());
731 value.push(extension);
732 PathBuf::from(value)
733}
734
735fn absolute_output_base_path(path: &Path) -> Result<PathBuf> {
736 if path.is_absolute() {
737 return Ok(path.to_path_buf());
738 }
739 Ok(std::env::current_dir()?.join(path))
740}
741
742pub fn output_files_with_imports(
744 mut outputs: Vec<OutputFile>,
745 imports: &[MuxImport],
746 skip_subtitle: bool,
747) -> Vec<OutputFile> {
748 outputs.extend(imports.iter().map(|import| OutputFile {
749 media_type: None,
750 index: import.index,
751 file_path: import.path.clone(),
752 lang_code: import.language.clone(),
753 description: import.name.clone(),
754 media_infos: Vec::new(),
755 }));
756 if skip_subtitle {
757 outputs.retain(|file| file.media_type != Some(MediaType::Subtitles));
758 }
759 outputs.sort_by_key(|file| file.index);
760 outputs
761}
762
763pub fn validate_mp4forge_mux_after_done(
765 format: MuxFormat,
766 muxer: MuxerKind,
767 files: &[OutputFile],
768 matrix: &Mp4forgeSupportMatrix,
769) -> Result<()> {
770 if muxer != MuxerKind::Mp4forge {
771 return Ok(());
772 }
773 if format != MuxFormat::Mp4 {
774 return Err(Error::mux(
775 "mp4forge mux-after-done supports only mp4 output",
776 ));
777 }
778 for file in files {
779 if file.media_type == Some(MediaType::Subtitles) {
780 return Err(Error::mux(
781 "mp4forge mux-after-done does not support subtitle inputs",
782 ));
783 }
784 if let Some(info) = file.media_infos.first()
785 && let Some(codec) = info.base_info.as_deref().or(info.text.as_deref())
786 {
787 validate_codec_text(codec, file.media_type, matrix)?;
788 }
789 }
790 Ok(())
791}
792
793pub fn validate_mp4forge_merge_request(
795 output_path: &Path,
796 stream: &Stream,
797 binary_concat_only: bool,
798 live_pipe_mux: bool,
799 matrix: &Mp4forgeSupportMatrix,
800) -> Result<Mp4forgeSupport> {
801 if binary_concat_only {
802 return Err(Error::mux(
803 "mp4forge merge cannot be used for binary-concat-only paths",
804 ));
805 }
806 if live_pipe_mux {
807 return Err(Error::mux(
808 "mp4forge merge cannot be used for live pipe mux paths",
809 ));
810 }
811 if stream.media_type == Some(MediaType::Subtitles) {
812 return Err(Error::mux(
813 "mp4forge merge does not support subtitle outputs",
814 ));
815 }
816 let extension = output_path
817 .extension()
818 .and_then(|value| value.to_str())
819 .unwrap_or_default()
820 .to_ascii_lowercase();
821 if !matches!(extension.as_str(), "mp4" | "m4a") {
822 return Err(Error::mux(
823 "mp4forge merge supports only mp4 and m4a outputs",
824 ));
825 }
826 mp4forge_support_for_stream(stream, matrix)
827}
828
829pub fn mp4forge_support_for_stream(
831 stream: &Stream,
832 matrix: &Mp4forgeSupportMatrix,
833) -> Result<Mp4forgeSupport> {
834 if stream.playlist.as_ref().is_some_and(|playlist| {
835 playlist.media_parts.iter().any(|part| {
836 part.media_segments.iter().any(|segment| {
837 !matches!(
838 segment.encryption.method,
839 EncryptionMethod::None | EncryptionMethod::Aes128 | EncryptionMethod::Aes128Ecb
840 )
841 })
842 })
843 }) {
844 return Ok(Mp4forgeSupport::Unsupported {
845 reason: "unsupported encrypted sample family".to_string(),
846 });
847 }
848 let Some(codecs) = stream.codecs.as_deref().filter(|value| !value.is_empty()) else {
849 return Ok(Mp4forgeSupport::RequiresProbe {
850 reason: "codec metadata is not available before media probing".to_string(),
851 });
852 };
853 validate_codec_text(codecs, stream.media_type, matrix)?;
854 Ok(Mp4forgeSupport::Supported)
855}
856
857fn validate_codec_text(
858 codec_text: &str,
859 media_type: Option<MediaType>,
860 matrix: &Mp4forgeSupportMatrix,
861) -> Result<()> {
862 let codecs = codec_text
863 .split(',')
864 .map(|codec| codec.trim().to_ascii_lowercase())
865 .filter(|codec| !codec.is_empty())
866 .collect::<Vec<_>>();
867 if codecs.is_empty() {
868 return Ok(());
869 }
870 let supported = match media_type {
871 Some(MediaType::Audio) => codecs
872 .iter()
873 .all(|codec| has_any_prefix(codec, &matrix.audio_codecs)),
874 Some(MediaType::Video) | None => codecs.iter().all(|codec| {
875 has_any_prefix(codec, &matrix.video_codecs)
876 || has_any_prefix(codec, &matrix.audio_codecs)
877 }),
878 Some(MediaType::Subtitles | MediaType::ClosedCaptions) => false,
879 };
880 if supported {
881 Ok(())
882 } else {
883 Err(Error::mux(format!(
884 "mp4forge does not support codec family: {codec_text}"
885 )))
886 }
887}
888
889fn has_any_prefix(value: &str, prefixes: &[String]) -> bool {
890 prefixes.iter().any(|prefix| value.starts_with(prefix))
891}
892
893fn append_simple_ffmpeg_output(
894 command: &mut String,
895 base: &str,
896 use_aac_filter: bool,
897 output_path: &Path,
898) {
899 command.push_str(base);
900 if use_aac_filter {
901 command.push_str(" -bsf:a aac_adtstoasc");
902 }
903 command.push_str(&format!(" \"{}\"", output_path.display()));
904}
905
906fn has_extension(path: &Path, extension: &str) -> bool {
907 path.extension()
908 .and_then(|value| value.to_str())
909 .is_some_and(|value| value.eq_ignore_ascii_case(extension))
910}