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