1#![allow(deprecated)] use crate::assetmap::ImfUuid;
12use crate::cpl::{EssenceDescriptor, SequenceAccess};
13use crate::diagnostics::{Severity, ValidationReport};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17use super::ValidationResult;
18use std::fmt::Write;
19#[cfg(feature = "typescript")]
20use ts_rs::TS;
21
22#[deprecated(
45 since = "2.3.0",
46 note = "Use `ValidationResult` from `validate()` / `Imferno::parse_and_validate()` instead. \
47 ImfReport is a lossy summary that drops essence descriptors, locales, and other parsed data."
48)]
49#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52#[cfg_attr(feature = "typescript", derive(TS))]
53#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
54pub struct ImfReport {
55 pub package: PackageSummary,
56 pub cpls: Vec<CplReport>,
57 pub validation: ValidationReport,
58}
59
60#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63#[cfg_attr(feature = "typescript", derive(TS))]
64#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
65pub struct CplReport {
66 pub id: String,
67 pub title: String,
68 pub application_profile: Option<String>,
70 pub edit_rate: Option<String>,
72 pub segment_count: usize,
74 pub timecode_start: Option<String>,
76 pub is_supplemental: bool,
78 pub unresolved_ancestor_asset_ids: Vec<String>,
81 pub markers: Vec<CplMarker>,
82 pub sequences: Vec<CplSequence>,
84}
85
86#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89#[cfg_attr(feature = "typescript", derive(TS))]
90#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
91pub struct CplMarker {
92 pub label: String,
93 pub offset: u64,
94 pub annotation: Option<String>,
95}
96
97#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100#[cfg_attr(feature = "typescript", derive(TS))]
101#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
102pub struct CplSequence {
103 pub r#type: String,
105 pub id: String,
106 pub track_id: String,
107 pub language: Option<String>,
109 pub channel_count: Option<u32>,
111 pub soundfield: Option<String>,
113 pub resources: Vec<CplResource>,
114}
115
116#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119#[cfg_attr(feature = "typescript", derive(TS))]
120#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
121pub struct CplResource {
122 pub id: String,
123 pub edit_rate: Option<String>,
125 pub intrinsic_duration: u64,
126 pub source_duration: Option<u64>,
127 pub entry_point: Option<u64>,
128 pub source_encoding: Option<String>,
129 pub track_file_id: Option<String>,
130}
131
132#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135#[cfg_attr(feature = "typescript", derive(TS))]
136#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
137pub struct PackageSummary {
138 pub asset_map_id: String,
139 pub volume_index: u32,
140 pub asset_count: usize,
141 pub cpl_count: usize,
142 pub issue_date: String,
143 pub issuer: Option<String>,
144 pub creator: Option<String>,
145 pub pkl_count: usize,
146 pub scm_count: usize,
147 pub sidecar_count: usize,
148 pub unreferenced_assets: Vec<UnreferencedAsset>,
149}
150
151#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154#[cfg_attr(feature = "typescript", derive(TS))]
155#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
156pub struct UnreferencedAsset {
157 pub id: String,
158 pub path: String,
159}
160
161fn parse_application_profile(url: &str) -> String {
165 if url.contains("2067-21") {
167 if url.ends_with("2021") || url.contains("/2021") {
168 return "App2E_2021".to_string();
169 }
170 if url.ends_with("2020") || url.contains("/2020") {
171 return "App2E_2020".to_string();
172 }
173 if url.ends_with("2014") || url.contains("/2014") {
174 return "App2E_2014".to_string();
175 }
176 return "App2E".to_string();
177 }
178 if url.contains("2067-20") {
180 return "App2".to_string();
181 }
182 if url.contains("2067-50") || url.contains("2067-5/") {
184 return "App5".to_string();
185 }
186 url.rsplit('/').next().unwrap_or(url).to_string()
188}
189
190fn map_resource(r: &crate::cpl::Resource, cpl_er: &Option<String>) -> CplResource {
192 CplResource {
193 id: r.id.to_string(),
194 edit_rate: r
195 .edit_rate
196 .as_ref()
197 .map(|er| format!("{}/{}", er.numerator, er.denominator))
198 .or_else(|| cpl_er.clone()),
199 intrinsic_duration: r.intrinsic_duration,
200 source_duration: r.source_duration,
201 entry_point: r.entry_point,
202 source_encoding: r.source_encoding.as_ref().map(|u| u.to_string()),
203 track_file_id: r.track_file_id.as_ref().map(|u| u.to_string()),
204 }
205}
206
207fn language_from_descriptor(ed: &EssenceDescriptor) -> Option<String> {
209 if let Some(wave) = &ed.wave_pcm_descriptor {
211 if let Some(subs) = &wave.sub_descriptors {
212 if let Some(sf) = &subs.soundfield_group_label_sub_descriptor {
213 if let Some(lang) = &sf.rfc5646_spoken_language {
214 let s = lang.as_str();
215 if !s.is_empty() {
216 return Some(s.to_string());
217 }
218 }
219 }
220 }
221 }
222 if let Some(iab) = &ed.iab_essence_descriptor {
224 if let Some(subs) = &iab.sub_descriptors {
225 if let Some(sf) = &subs.iab_soundfield_label_sub_descriptor {
226 if let Some(lang) = &sf.rfc5646_spoken_language {
227 let s = lang.as_str();
228 if !s.is_empty() {
229 return Some(s.to_string());
230 }
231 }
232 }
233 }
234 }
235 if let Some(tt) = &ed.dc_timed_text_descriptor {
237 let langs: Vec<&str> = tt
238 .rfc5646_language_tag_list
239 .iter()
240 .map(|lt| lt.as_str())
241 .filter(|s| !s.is_empty())
242 .collect();
243 if !langs.is_empty() {
244 return Some(langs.join(","));
245 }
246 }
247 None
248}
249
250fn channel_count_from_descriptor(ed: &EssenceDescriptor) -> Option<u32> {
252 if let Some(wave) = &ed.wave_pcm_descriptor {
253 return wave.channel_count;
254 }
255 if let Some(iab) = &ed.iab_essence_descriptor {
256 return iab.channel_count;
257 }
258 None
259}
260
261fn soundfield_from_descriptor(ed: &EssenceDescriptor) -> Option<String> {
263 if let Some(wave) = &ed.wave_pcm_descriptor {
264 if let Some(subs) = &wave.sub_descriptors {
265 if let Some(sf) = &subs.soundfield_group_label_sub_descriptor {
266 if let Some(mca) = &sf.mca_tag_symbol {
267 return Some(mca.to_string());
268 }
269 if let Some(name) = &sf.mca_tag_name {
270 return Some(name.clone());
271 }
272 }
273 }
274 }
275 if let Some(iab) = &ed.iab_essence_descriptor {
276 if let Some(subs) = &iab.sub_descriptors {
277 if let Some(sf) = &subs.iab_soundfield_label_sub_descriptor {
278 if let Some(mca) = &sf.mca_tag_symbol {
279 return Some(mca.to_string());
280 }
281 }
282 }
283 return Some("Atmos".to_string());
285 }
286 None
287}
288
289fn merge_sequences_dyn(
291 track_map: &mut HashMap<String, CplSequence>,
292 type_name: &str,
293 seq: &dyn SequenceAccess,
294 cpl_er: &Option<String>,
295 descriptors: &HashMap<ImfUuid, &EssenceDescriptor>,
296) {
297 let tid = seq.track_id().to_string();
298 let resources: Vec<CplResource> = seq
299 .resource_list()
300 .resources
301 .iter()
302 .map(|r| map_resource(r, cpl_er))
303 .collect();
304 if let Some(existing) = track_map.get_mut(&tid) {
305 existing.resources.extend(resources);
306 } else {
307 let ed = seq
309 .resource_list()
310 .resources
311 .first()
312 .and_then(|r| r.source_encoding.as_ref())
313 .and_then(|se| descriptors.get(se).copied());
314 let language = ed.and_then(language_from_descriptor);
315 let channel_count = ed.and_then(channel_count_from_descriptor);
316 let soundfield = ed.and_then(soundfield_from_descriptor);
317 track_map.insert(
318 tid.clone(),
319 CplSequence {
320 r#type: type_name.to_string(),
321 id: seq.id().to_string(),
322 track_id: tid,
323 language,
324 channel_count,
325 soundfield,
326 resources,
327 },
328 );
329 }
330}
331
332fn extract_sequences(cpl: &crate::cpl::CompositionPlaylist) -> (Option<String>, Vec<CplSequence>) {
334 let edit_rate = cpl
335 .edit_rate
336 .as_ref()
337 .map(|er| format!("{}/{}", er.numerator, er.denominator));
338
339 let descriptors: HashMap<ImfUuid, &EssenceDescriptor> =
341 if let Some(edl) = &cpl.essence_descriptor_list {
342 edl.essence_descriptors
343 .iter()
344 .map(|ed| (ed.id, ed))
345 .collect()
346 } else {
347 HashMap::new()
348 };
349
350 let mut track_map: HashMap<String, CplSequence> = HashMap::new();
351
352 for seg in &cpl.segment_list.segments {
353 for (seq, type_name) in seg.sequence_list.all_sequences_typed() {
354 merge_sequences_dyn(&mut track_map, type_name, seq, &edit_rate, &descriptors);
355 }
356 }
357
358 (edit_rate, track_map.into_values().collect())
359}
360
361fn collect_track_file_ids(cpl: &crate::cpl::CompositionPlaylist) -> Vec<String> {
363 let mut ids = Vec::new();
364 for seg in &cpl.segment_list.segments {
365 for seq in seg.sequence_list.all_sequences() {
366 for r in &seq.resource_list().resources {
367 if let Some(ref id) = r.track_file_id {
368 ids.push(id.to_string());
369 }
370 }
371 }
372 }
373 ids
374}
375
376#[deprecated(
378 since = "2.3.0",
379 note = "Use `validate()` or `Imferno::parse_and_validate()` instead, which return a \
380 `ValidationResult` with the full parsed package."
381)]
382pub fn build_report(
383 package: &super::Imferno,
384 options: &super::ValidationOptions,
385 ancestor: Option<&super::Imferno>,
386) -> Result<ImfReport, String> {
387 let unreferenced_assets: Vec<UnreferencedAsset> = package
389 .unreferenced_assets()
390 .iter()
391 .map(|a| {
392 let path = a
393 .chunk_list
394 .chunks
395 .first()
396 .map(|c| c.path.as_str())
397 .unwrap_or("")
398 .to_string();
399 UnreferencedAsset {
400 id: a.id.to_string(),
401 path,
402 }
403 })
404 .collect();
405
406 let scm_count = package.sidecar_composition_maps.len();
408 let sidecar_count: usize = package
409 .sidecar_composition_maps
410 .values()
411 .map(|s| s.sidecar_assets.len())
412 .sum();
413
414 let pkg_summary = PackageSummary {
415 asset_map_id: package.asset_map.id.to_string(),
416 volume_index: package.volume_index.index,
417 asset_count: package.asset_map.asset_list.assets.len(),
418 cpl_count: package.composition_playlists.len(),
419 issue_date: package.asset_map.issue_date.clone(),
420 issuer: package.asset_map.issuer.clone(),
421 creator: package.asset_map.creator.clone(),
422 pkl_count: package.packing_lists.len(),
423 scm_count,
424 sidecar_count,
425 unreferenced_assets,
426 };
427
428 let mut cpls = Vec::new();
430 for cpl in package.composition_playlists.values() {
431 let cpl_track_ids = collect_track_file_ids(cpl);
432 let unresolved_ancestor_asset_ids: Vec<String> = cpl_track_ids
433 .iter()
434 .filter(|id| {
435 package.get_asset_path_str(id).is_none()
436 && ancestor.is_none_or(|a| a.get_asset_path_str(id).is_none())
437 })
438 .cloned()
439 .collect();
440 let is_supplemental = cpl_track_ids
441 .iter()
442 .any(|id| package.get_asset_path_str(id).is_none());
443
444 let markers: Vec<CplMarker> = cpl
445 .segment_list
446 .segments
447 .iter()
448 .flat_map(|seg| seg.sequence_list.marker_sequences.iter())
449 .flat_map(|ms| ms.resource_list.resources.iter())
450 .flat_map(|res| res.markers.iter())
451 .map(|m| CplMarker {
452 label: m.label.to_string(),
453 offset: m.offset,
454 annotation: m.annotation.clone().filter(|a| !a.is_empty()),
455 })
456 .collect();
457
458 let application_profile = cpl
459 .extension_properties
460 .as_ref()
461 .and_then(|ep| ep.application_identification.as_ref())
462 .map(|url| parse_application_profile(url));
463
464 let segment_count = cpl.segment_list.segments.len();
465
466 let timecode_start = cpl
467 .composition_timecode
468 .as_ref()
469 .and_then(|tc| tc.timecode_start_address.clone());
470
471 let (edit_rate, sequences) = extract_sequences(cpl);
472
473 cpls.push(CplReport {
474 id: cpl.id.to_string(),
475 title: cpl.content_title.text.clone(),
476 application_profile,
477 edit_rate,
478 segment_count,
479 timecode_start,
480 is_supplemental,
481 unresolved_ancestor_asset_ids,
482 markers,
483 sequences,
484 });
485 }
486
487 let validation = package.validate(options);
489
490 Ok(ImfReport {
491 package: pkg_summary,
492 cpls,
493 validation,
494 })
495}
496
497fn ansi(code: &str, text: &str, enabled: bool) -> String {
500 if enabled {
501 format!("\x1b[{}m{}\x1b[0m", code, text)
502 } else {
503 text.to_string()
504 }
505}
506
507fn c_red(s: &str, on: bool) -> String {
508 ansi("31", s, on)
509}
510fn c_yellow(s: &str, on: bool) -> String {
511 ansi("33", s, on)
512}
513fn c_cyan(s: &str, on: bool) -> String {
514 ansi("36", s, on)
515}
516fn c_green(s: &str, on: bool) -> String {
517 ansi("32", s, on)
518}
519fn c_bold(s: &str, on: bool) -> String {
520 ansi("1", s, on)
521}
522fn c_dim(s: &str, on: bool) -> String {
523 ansi("2", s, on)
524}
525
526#[deprecated(
530 since = "2.3.0",
531 note = "Use `format_validation_result()` instead, which renders the full `ValidationResult`."
532)]
533pub fn format_report(report: &ImfReport, color: bool) -> String {
534 let mut out = String::new();
535 let pkg = &report.package;
536
537 let _ = writeln!(out, " {} VOLINDEX.xml found", c_green("ok", color));
539 let _ = writeln!(out, " {} ASSETMAP.xml found", c_green("ok", color));
540 let _ = writeln!(
541 out,
542 " {} {} assets mapped",
543 c_green("ok", color),
544 pkg.asset_count
545 );
546 let _ = writeln!(
547 out,
548 " {} {} CPL(s) parsed",
549 c_green("ok", color),
550 pkg.cpl_count
551 );
552
553 if pkg.scm_count > 0 {
555 let _ = writeln!(
556 out,
557 " {} {} SCM(s) parsed, {} sidecar asset(s) declared",
558 c_green("ok", color),
559 pkg.scm_count,
560 pkg.sidecar_count
561 );
562 }
563
564 if !pkg.unreferenced_assets.is_empty() {
566 let _ = writeln!(
567 out,
568 " {} {} unreferenced asset(s)",
569 c_yellow("info", color),
570 pkg.unreferenced_assets.len()
571 );
572 for asset in &pkg.unreferenced_assets {
573 let _ = writeln!(out, " {}", c_dim(&asset.path, color));
574 }
575 }
576
577 for cpl in &report.cpls {
579 if !cpl.sequences.is_empty() {
580 let _ = writeln!(
581 out,
582 "{}",
583 c_bold(
584 &format!("Timeline [CPL:{}]:", &cpl.id[..cpl.id.len().min(8)]),
585 color,
586 )
587 );
588 for seq in &cpl.sequences {
589 let label = match seq.language.as_deref() {
590 Some(lang) => format!("{} ({})", seq.r#type, lang),
591 None => seq.r#type.clone(),
592 };
593 let resource_count = seq.resources.len();
594 let _ = writeln!(
595 out,
596 " {} {} — {} resource(s)",
597 c_cyan("track", color),
598 c_bold(&label, color),
599 resource_count,
600 );
601 }
602 }
603 }
604
605 let all_issues: Vec<_> = report
607 .validation
608 .critical
609 .iter()
610 .chain(report.validation.errors.iter())
611 .chain(report.validation.warnings.iter())
612 .chain(report.validation.info.iter())
613 .collect();
614
615 if !all_issues.is_empty() {
616 let _ = writeln!(out, "{}", c_bold("Validation findings:", color));
617
618 for issue in &all_issues {
619 let (label, colorize): (&str, fn(&str, bool) -> String) = match issue.severity {
620 Severity::Critical => ("error", c_red),
621 Severity::Error => ("error", c_red),
622 Severity::Warning => ("warning", c_yellow),
623 Severity::Info => ("info", c_cyan),
624 };
625 let location = if let Some(ref c) = issue.location.cpl_id {
626 let s = c.to_string();
627 let short_id = &s[..s.len().min(8)];
628 let detail = match (&issue.location.cpl_filename, &issue.location.cpl_title) {
629 (Some(fname), Some(title)) => {
630 format!(" [CPL:{} {} {}]", short_id, fname, title)
631 }
632 (Some(fname), None) => format!(" [CPL:{} {}]", short_id, fname),
633 (None, Some(title)) => format!(" [CPL:{} {}]", short_id, title),
634 (None, None) => format!(" [CPL:{}]", short_id),
635 };
636 c_dim(&detail, color)
637 } else if let Some(ref f) = issue.location.file {
638 let fname = f.file_name().and_then(|n| n.to_str()).unwrap_or("?");
639 c_dim(&format!(" [{}]", fname), color)
640 } else {
641 String::new()
642 };
643 let _ = writeln!(
644 out,
645 " {} {}{} {}",
646 colorize(&format!("{:<7}", label), color),
647 c_bold(&issue.code, color),
648 location,
649 issue.message,
650 );
651 if let Some(ref s) = issue.suggestion {
652 let _ = writeln!(out, " {} {}", c_dim("→", color), c_dim(s, color));
653 }
654 }
655 }
656
657 let total_errors = report.validation.critical.len() + report.validation.errors.len();
659 let total_warnings = report.validation.warnings.len();
660 if total_errors > 0 {
661 let mut reasons = Vec::new();
662 reasons.push(format!("{} error(s)", total_errors));
663 if total_warnings > 0 {
664 reasons.push(format!("{} warning(s)", total_warnings));
665 }
666 let _ = writeln!(
667 out,
668 "{} {}",
669 c_red("failed", color),
670 c_bold(&reasons.join(", "), color)
671 );
672 } else if total_warnings > 0 {
673 let _ = writeln!(
674 out,
675 "{}",
676 c_yellow(&format!("valid {} warning(s)", total_warnings), color)
677 );
678 } else {
679 let _ = writeln!(out, "{}", c_green("valid", color));
680 }
681
682 out
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689pub enum ReportFormat {
690 Text,
692 Markdown,
694 Csv,
696}
697
698#[derive(Debug, Clone)]
700pub struct FormatOptions {
701 pub format: ReportFormat,
702 pub color: bool,
703}
704
705impl Default for FormatOptions {
706 fn default() -> Self {
707 Self {
708 format: ReportFormat::Text,
709 color: false,
710 }
711 }
712}
713
714pub fn format_validation_result(result: &ValidationResult, opts: &FormatOptions) -> String {
719 match opts.format {
720 ReportFormat::Text => format_text(result, opts.color),
721 ReportFormat::Markdown => format_markdown(result),
722 ReportFormat::Csv => format_csv(result),
723 }
724}
725
726fn format_text(result: &ValidationResult, color: bool) -> String {
727 use std::fmt::Write;
728 let mut out = String::new();
729 let pkg = &result.package;
730 let v = &result.validation;
731
732 let _ = writeln!(
734 out,
735 " {} ASSETMAP.xml — {} assets",
736 c_green("ok", color),
737 pkg.asset_map.asset_list.assets.len()
738 );
739 let _ = writeln!(
740 out,
741 " {} {} CPL(s), {} PKL(s)",
742 c_green("ok", color),
743 pkg.composition_playlists.len(),
744 pkg.packing_lists.len()
745 );
746 if !pkg.sidecar_composition_maps.is_empty() {
747 let _ = writeln!(
748 out,
749 " {} {} SCM(s)",
750 c_green("ok", color),
751 pkg.sidecar_composition_maps.len()
752 );
753 }
754
755 for (uuid, cpl) in &pkg.composition_playlists {
757 let title = &cpl.content_title;
758 let _ = writeln!(
759 out,
760 "\n{}",
761 c_bold(
762 &format!("CPL [{}] {}", &uuid.to_string()[..8], title),
763 color
764 )
765 );
766
767 for seg in &cpl.segment_list.segments {
768 for seq in seg.sequence_list.all_sequences_typed() {
769 let (s, type_name) = seq;
770 let resource_count = s.resource_list().resources.len();
771 let lang = language_from_descriptor_lookup(s, cpl);
772 let label = match lang {
773 Some(l) => format!("{} ({})", type_name, l),
774 None => type_name.to_string(),
775 };
776
777 let media_detail = descriptor_lookup(s, cpl).and_then(|ed| {
779 if let Some(vi) = video_info_from_descriptor(ed) {
780 Some(format!(
781 "{} {} {} {} {}",
782 vi.resolution, vi.frame_rate, vi.codec, vi.bit_depth, vi.dynamic_range
783 ))
784 } else if ed.iab_essence_descriptor.is_some() {
785 Some("IAB (Dolby Atmos)".into())
786 } else if let Some(ai) = audio_info_from_descriptor(ed) {
787 Some(format!("{} {} {}", ai.format, ai.sample_rate, ai.bit_depth))
788 } else {
789 None
790 }
791 });
792
793 let resources_str = s
794 .resource_list()
795 .resources
796 .iter()
797 .filter_map(|r| {
798 r.track_file_id
799 .as_ref()
800 .map(|id| id.to_string()[..8].to_string())
801 })
802 .collect::<Vec<_>>()
803 .join(", ");
804 let resources_display = if resources_str.is_empty() {
805 format!("{} resource(s)", resource_count)
806 } else {
807 resources_str
808 };
809
810 let detail_str = match media_detail {
811 Some(d) => format!(
812 " — {} — {}",
813 c_dim(&d, color),
814 c_dim(&resources_display, color)
815 ),
816 None => format!(" — {}", c_dim(&resources_display, color)),
817 };
818 let _ = writeln!(
819 out,
820 " {} {}{}",
821 c_cyan("track", color),
822 c_bold(&label, color),
823 detail_str,
824 );
825 }
826 }
827 }
828
829 format_issues_text(&mut out, v, color);
831 format_summary_text(&mut out, v, color);
832
833 out
834}
835
836fn format_markdown(result: &ValidationResult) -> String {
837 use std::fmt::Write;
838 let mut out = String::new();
839 let pkg = &result.package;
840 let v = &result.validation;
841
842 let _ = writeln!(out, "# IMF Package Validation Report\n");
843 let _ = writeln!(
844 out,
845 "**AssetMap:** {} | **CPLs:** {} | **PKLs:** {}\n",
846 pkg.asset_map.id,
847 pkg.composition_playlists.len(),
848 pkg.packing_lists.len()
849 );
850
851 for (uuid, cpl) in &pkg.composition_playlists {
853 let _ = writeln!(
854 out,
855 "## CPL: {} (`{}`)\n",
856 cpl.content_title,
857 &uuid.to_string()[..8]
858 );
859
860 let _ = writeln!(out, "| Track | Language | Resources |");
861 let _ = writeln!(out, "|-------|----------|-----------|");
862 for seg in &cpl.segment_list.segments {
863 for (s, type_name) in seg.sequence_list.all_sequences_typed() {
864 let lang =
865 language_from_descriptor_lookup(s, cpl).unwrap_or_else(|| "—".to_string());
866 let _ = writeln!(
867 out,
868 "| {} | {} | {} |",
869 type_name,
870 lang,
871 s.resource_list().resources.len()
872 );
873 }
874 }
875 let _ = writeln!(out);
876 }
877
878 let all_issues = collect_all_issues(v);
880 if !all_issues.is_empty() {
881 let _ = writeln!(out, "## Validation Findings\n");
882 let _ = writeln!(out, "| Severity | Code | Message |");
883 let _ = writeln!(out, "|----------|------|---------|");
884 for issue in &all_issues {
885 let sev = match issue.severity {
886 Severity::Critical => "🔴 Critical",
887 Severity::Error => "🔴 Error",
888 Severity::Warning => "🟡 Warning",
889 Severity::Info => "🔵 Info",
890 };
891 let msg = issue.message.replace('|', "\\|");
892 let _ = writeln!(out, "| {} | `{}` | {} |", sev, issue.code, msg);
893 }
894 let _ = writeln!(out);
895 }
896
897 let total_errors = v.critical.len() + v.errors.len();
899 if total_errors > 0 {
900 let _ = writeln!(
901 out,
902 "**Result: FAILED** — {} error(s), {} warning(s)",
903 total_errors,
904 v.warnings.len()
905 );
906 } else if !v.warnings.is_empty() {
907 let _ = writeln!(out, "**Result: VALID** — {} warning(s)", v.warnings.len());
908 } else {
909 let _ = writeln!(out, "**Result: VALID**");
910 }
911
912 out
913}
914
915fn format_csv(result: &ValidationResult) -> String {
916 use std::fmt::Write;
917 let mut out = String::new();
918
919 let _ = writeln!(out, "severity,code,message,cpl_id,suggestion");
921
922 for issue in collect_all_issues(&result.validation) {
924 let cpl_id = issue
925 .location
926 .cpl_id
927 .as_ref()
928 .map(|u| u.to_string())
929 .unwrap_or_default();
930 let suggestion = issue.suggestion.as_deref().unwrap_or("");
931 let _ = writeln!(
932 out,
933 "{},{},\"{}\",{},\"{}\"",
934 match issue.severity {
935 Severity::Critical => "critical",
936 Severity::Error => "error",
937 Severity::Warning => "warning",
938 Severity::Info => "info",
939 },
940 issue.code,
941 issue.message.replace('"', "\"\""),
942 cpl_id,
943 suggestion.replace('"', "\"\""),
944 );
945 }
946
947 out
948}
949
950fn collect_all_issues(v: &ValidationReport) -> Vec<&crate::diagnostics::ValidationIssue> {
953 v.critical
954 .iter()
955 .chain(v.errors.iter())
956 .chain(v.warnings.iter())
957 .chain(v.info.iter())
958 .collect()
959}
960
961fn format_issues_text(out: &mut String, v: &ValidationReport, color: bool) {
962 use std::fmt::Write;
963 let all_issues = collect_all_issues(v);
964 if all_issues.is_empty() {
965 return;
966 }
967
968 let _ = writeln!(out, "\n{}", c_bold("Validation findings:", color));
969 for issue in &all_issues {
970 let (label, colorize): (&str, fn(&str, bool) -> String) = match issue.severity {
971 Severity::Critical => ("error", c_red),
972 Severity::Error => ("error", c_red),
973 Severity::Warning => ("warning", c_yellow),
974 Severity::Info => ("info", c_cyan),
975 };
976 let location = if let Some(ref c) = issue.location.cpl_id {
977 let s = c.to_string();
978 let short_id = &s[..s.len().min(8)];
979 let detail = match (&issue.location.cpl_filename, &issue.location.cpl_title) {
980 (Some(fname), Some(title)) => {
981 format!(" [CPL:{} {} {}]", short_id, fname, title)
982 }
983 (Some(fname), None) => format!(" [CPL:{} {}]", short_id, fname),
984 (None, Some(title)) => format!(" [CPL:{} {}]", short_id, title),
985 (None, None) => format!(" [CPL:{}]", short_id),
986 };
987 c_dim(&detail, color)
988 } else if let Some(ref f) = issue.location.file {
989 let fname = f.file_name().and_then(|n| n.to_str()).unwrap_or("?");
990 c_dim(&format!(" [{}]", fname), color)
991 } else {
992 String::new()
993 };
994 let _ = writeln!(
995 out,
996 " {} {}{} {}",
997 colorize(&format!("{:<7}", label), color),
998 c_bold(&issue.code, color),
999 location,
1000 issue.message,
1001 );
1002 if let Some(ref s) = issue.suggestion {
1003 let _ = writeln!(out, " {} {}", c_dim("→", color), c_dim(s, color));
1004 }
1005 }
1006}
1007
1008fn format_summary_text(out: &mut String, v: &ValidationReport, color: bool) {
1009 use std::fmt::Write;
1010 let total_errors = v.critical.len() + v.errors.len();
1011 let total_warnings = v.warnings.len();
1012 if total_errors > 0 {
1013 let mut reasons = Vec::new();
1014 reasons.push(format!("{} error(s)", total_errors));
1015 if total_warnings > 0 {
1016 reasons.push(format!("{} warning(s)", total_warnings));
1017 }
1018 let _ = writeln!(
1019 out,
1020 "\n{} {}",
1021 c_red("failed", color),
1022 c_bold(&reasons.join(", "), color)
1023 );
1024 } else if total_warnings > 0 {
1025 let _ = writeln!(
1026 out,
1027 "\n{}",
1028 c_yellow(&format!("valid {} warning(s)", total_warnings), color)
1029 );
1030 } else {
1031 let _ = writeln!(out, "\n{}", c_green("valid", color));
1032 }
1033}
1034
1035struct VideoInfo {
1038 resolution: String,
1039 frame_rate: String,
1040 codec: String,
1041 dynamic_range: String,
1042 bit_depth: String,
1043}
1044
1045struct AudioInfo {
1046 format: String,
1047 sample_rate: String,
1048 bit_depth: String,
1049}
1050
1051fn video_info_from_descriptor(ed: &EssenceDescriptor) -> Option<VideoInfo> {
1052 let (width, height, sample_rate, tc, codec, bit_depth, sub_descs) =
1054 if let Some(ref d) = ed.cdci_descriptor {
1055 (
1056 d.stored_width,
1057 d.stored_height,
1058 d.sample_rate.as_ref(),
1059 d.transfer_characteristic.as_ref(),
1060 d.picture_compression.as_ref(),
1061 d.component_depth,
1062 d.sub_descriptors.as_ref(),
1063 )
1064 } else if let Some(ref d) = ed.rgba_descriptor {
1065 (
1066 d.stored_width,
1067 d.stored_height,
1068 d.sample_rate.as_ref(),
1069 d.transfer_characteristic.as_ref(),
1070 d.picture_compression.as_ref(),
1071 None,
1072 d.sub_descriptors.as_ref(),
1073 )
1074 } else {
1075 return None;
1076 };
1077
1078 let resolution = match (width, height) {
1079 (Some(w), Some(h)) => format!("{}x{}", w, h),
1080 _ => "?".into(),
1081 };
1082
1083 let frame_rate = match sample_rate {
1084 Some(r) => {
1085 let fps = r.numerator as f64 / r.denominator as f64;
1086 if r.denominator == 1 {
1087 format!("{}fps", r.numerator)
1088 } else {
1089 format!("{:.2}fps", fps)
1090 }
1091 }
1092 None => "?".into(),
1093 };
1094
1095 use crate::cpl::types::TransferCharacteristic;
1096 let has_dolby_vision = sub_descs
1097 .map(|s| s.phdr_metadata_track_sub_descriptor.is_some())
1098 .unwrap_or(false);
1099 let dynamic_range = if has_dolby_vision {
1100 "Dolby Vision".into()
1101 } else {
1102 match tc {
1103 Some(TransferCharacteristic::PqSt2084) => "HDR10 (PQ)".into(),
1104 Some(TransferCharacteristic::Hlg) => "HLG".into(),
1105 Some(TransferCharacteristic::Bt709) => "SDR".into(),
1106 Some(TransferCharacteristic::Bt2020) => "SDR (BT.2020)".into(),
1107 Some(TransferCharacteristic::Linear) => "Linear".into(),
1108 Some(TransferCharacteristic::Smpte240M) => "SDR (240M)".into(),
1109 Some(TransferCharacteristic::XvYcc709) => "SDR (xvYCC)".into(),
1110 Some(TransferCharacteristic::Unknown(s)) => format!("Unknown ({})", s),
1111 None => "?".into(),
1112 }
1113 };
1114
1115 use crate::cpl::types::VideoCodec;
1116 let codec = match codec {
1117 Some(VideoCodec::Jpeg2000) => "JPEG 2000".into(),
1118 Some(VideoCodec::Jpeg2000Imf2k) => "JPEG 2000 (2K)".into(),
1119 Some(VideoCodec::Jpeg2000Imf4k) => "JPEG 2000 (4K)".into(),
1120 Some(VideoCodec::Jpeg2000Broadcast) => "JPEG 2000 (BCP)".into(),
1121 Some(VideoCodec::Jpeg2000Ht) => "JPEG 2000 HT".into(),
1122 Some(VideoCodec::Vc5) => "VC-5".into(),
1123 Some(VideoCodec::Mpeg2) => "MPEG-2".into(),
1124 Some(VideoCodec::H264) => "H.264".into(),
1125 Some(VideoCodec::H265) => "H.265".into(),
1126 Some(VideoCodec::ProRes) => "ProRes".into(),
1127 Some(VideoCodec::Av1) => "AV1".into(),
1128 Some(VideoCodec::Unknown(s)) => format!("Unknown ({})", s),
1129 None => "?".into(),
1130 };
1131
1132 let bit_depth = match bit_depth {
1133 Some(d) => format!("{}-bit", d),
1134 None => "?".into(),
1135 };
1136
1137 Some(VideoInfo {
1138 resolution,
1139 frame_rate,
1140 codec,
1141 dynamic_range,
1142 bit_depth,
1143 })
1144}
1145
1146fn audio_info_from_descriptor(ed: &EssenceDescriptor) -> Option<AudioInfo> {
1147 if let Some(ref _iab) = ed.iab_essence_descriptor {
1148 return Some(AudioInfo {
1149 format: "IAB (Dolby Atmos)".into(),
1150 sample_rate: "—".into(),
1151 bit_depth: "—".into(),
1152 });
1153 }
1154
1155 let d = ed.wave_pcm_descriptor.as_ref()?;
1156 let channel_count = d.channel_count.unwrap_or(0);
1157
1158 use crate::cpl::types::McaTagSymbol;
1159 let mca = d
1160 .sub_descriptors
1161 .as_ref()
1162 .and_then(|s| s.soundfield_group_label_sub_descriptor.as_ref())
1163 .and_then(|s| s.mca_tag_symbol.as_ref());
1164
1165 let format = match mca {
1166 Some(McaTagSymbol::Sg51) => "5.1 Surround".into(),
1167 Some(McaTagSymbol::Sg71) => "7.1 Surround".into(),
1168 Some(McaTagSymbol::Sg71Ds) => "7.1 Dolby Surround".into(),
1169 Some(McaTagSymbol::SgSt) => "Stereo".into(),
1170 Some(McaTagSymbol::SgMono) => "Mono".into(),
1171 Some(McaTagSymbol::Iab) => "IAB (Dolby Atmos)".into(),
1172 Some(McaTagSymbol::Other(s)) => s.clone(),
1173 _ => match channel_count {
1174 1 => "Mono".into(),
1175 2 => "Stereo".into(),
1176 6 => "5.1".into(),
1177 8 => "7.1".into(),
1178 n => format!("{}ch", n),
1179 },
1180 };
1181
1182 let sample_rate = match d.audio_sample_rate.as_ref().or(d.sample_rate.as_ref()) {
1183 Some(r) => {
1184 let hz = r.numerator as f64 / r.denominator as f64;
1185 if hz >= 1000.0 {
1186 format!("{:.1}kHz", hz / 1000.0)
1187 } else {
1188 format!("{}Hz", hz as u32)
1189 }
1190 }
1191 None => "?".into(),
1192 };
1193
1194 let bit_depth = match d.quantization_bits {
1195 Some(b) => format!("{}-bit", b),
1196 None => "?".into(),
1197 };
1198
1199 Some(AudioInfo {
1200 format,
1201 sample_rate,
1202 bit_depth,
1203 })
1204}
1205
1206fn descriptor_lookup<'a>(
1208 seq: &dyn SequenceAccess,
1209 cpl: &'a crate::cpl::CompositionPlaylist,
1210) -> Option<&'a EssenceDescriptor> {
1211 let se = seq
1212 .resource_list()
1213 .resources
1214 .first()?
1215 .source_encoding
1216 .as_ref()?;
1217 let edl = cpl.essence_descriptor_list.as_ref()?;
1218 edl.essence_descriptors.iter().find(|e| &e.id == se)
1219}
1220
1221fn language_from_descriptor_lookup(
1223 seq: &dyn SequenceAccess,
1224 cpl: &crate::cpl::CompositionPlaylist,
1225) -> Option<String> {
1226 let se = seq
1227 .resource_list()
1228 .resources
1229 .first()?
1230 .source_encoding
1231 .as_ref()?;
1232 let edl = cpl.essence_descriptor_list.as_ref()?;
1233 let ed = edl.essence_descriptors.iter().find(|e| &e.id == se)?;
1234 language_from_descriptor(ed)
1235}