Skip to main content

imferno_core/package/
report.rs

1//! Unified IMF report — the single JSON document for UI consumption
2//!
3//! Combines package metadata, validation, and structural analysis into one structure.
4//! Also provides `format_report()` for pretty-printing to a terminal.
5
6use 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// ── Report structs ───────────────────────────────────────────────────────────
18
19#[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    /// Application profile, e.g. "App2E_2021", "App2E_2014", "App5"
39    pub application_profile: Option<String>,
40    /// CPL-level edit rate, e.g. "24000/1001"
41    pub edit_rate: Option<String>,
42    /// Number of segments in this CPL
43    pub segment_count: usize,
44    /// Timecode start address, e.g. "01:00:00:00"
45    pub timecode_start: Option<String>,
46    /// True if this CPL references track files not present in the current package (supplemental IMP)
47    pub is_supplemental: bool,
48    /// Track file UUIDs referenced in this CPL that are not in the current package's AssetMap.
49    /// These must be resolved from an ancestor package.
50    pub unresolved_ancestor_asset_ids: Vec<String>,
51    pub markers: Vec<CplMarker>,
52    /// Virtual tracks (sequences) merged across all segments
53    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    /// Sequence type: "MainImage", "MainAudio", "Subtitles", etc.
74    pub r#type: String,
75    pub id: String,
76    pub track_id: String,
77    /// RFC 5646 language tag extracted from the essence descriptor (e.g. "en", "fr")
78    pub language: Option<String>,
79    /// Audio channel count (e.g. 2, 6, 8) — only for audio sequences
80    pub channel_count: Option<u32>,
81    /// MCA soundfield label (e.g. "5.1", "7.1", "Atmos") — only for audio sequences
82    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    /// Edit rate as "N/D" string, e.g. "24000/1001"
94    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
131// ── build_report ─────────────────────────────────────────────────────────────
132
133/// Map an ApplicationIdentification URL to a friendly profile name
134fn parse_application_profile(url: &str) -> String {
135    // SMPTE ST 2067-21 Application #2 Extended profiles
136    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    // SMPTE ST 2067-20 Application #2
149    if url.contains("2067-20") {
150        return "App2".to_string();
151    }
152    // SMPTE ST 2067-50 Application #5
153    if url.contains("2067-50") || url.contains("2067-5/") {
154        return "App5".to_string();
155    }
156    // Fallback: return the URL tail after the last '/'
157    url.rsplit('/').next().unwrap_or(url).to_string()
158}
159
160/// Map a single resource to a CplResource, falling back to the CPL edit rate
161fn 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
177/// Extract the language from an `EssenceDescriptor` by checking audio, timed-text, and IAB sub-descriptors.
178fn language_from_descriptor(ed: &EssenceDescriptor) -> Option<String> {
179    // Audio: WAVEPCMDescriptor → soundfield_group_label_sub_descriptor → rfc5646_spoken_language
180    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    // IAB: IABEssenceDescriptor → iab_soundfield_label_sub_descriptor → rfc5646_spoken_language
193    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    // Timed text: DCTimedTextDescriptor → rfc5646_language_tag_list
206    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
220/// Extract the audio channel count from an essence descriptor.
221fn 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
231/// Extract the soundfield label (e.g. "5.1", "7.1", "Atmos") from an essence descriptor.
232fn 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        // IAB without a label is Dolby Atmos
254        return Some("Atmos".to_string());
255    }
256    None
257}
258
259/// Merge a single trait-object sequence into the track map
260fn 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        // Resolve metadata from essence descriptor
278        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
302/// Extract all virtual tracks from a CPL, merged across segments
303fn 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    // Build essence descriptor lookup by ID for language resolution
310    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
331/// Collect all TrackFileIds referenced in a CPL across all sequence types
332fn 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
346/// Build a full ImfReport from a package, with optional ancestor package for supplemental IMPs
347pub fn build_report(
348    package: &super::Imferno,
349    options: &super::ValidationOptions,
350    ancestor: Option<&super::Imferno>,
351) -> Result<ImfReport, String> {
352    // Unreferenced assets
353    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    // SCM counts
372    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    // CPL reports
394    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    // Validation
453    let validation = package.validate(options);
454
455    Ok(ImfReport {
456        package: pkg_summary,
457        cpls,
458        validation,
459    })
460}
461
462// ── ANSI colour helpers ──────────────────────────────────────────────────────
463
464fn 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
491// ── format_report ────────────────────────────────────────────────────────────
492
493/// Render an `ImfReport` as a human-readable, optionally ANSI-coloured string.
494pub fn format_report(report: &ImfReport, color: bool) -> String {
495    let mut out = String::new();
496    let pkg = &report.package;
497
498    // Package structure
499    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    // SCMs
515    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    // Unreferenced assets
526    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    // Timeline / virtual tracks per CPL
539    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    // Validation findings
567    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    // Summary line
610    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// ── format_validation_result ────────────────────────────────────────────────
638
639/// Output format for `format_validation_result`.
640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641pub enum ReportFormat {
642    /// Human-readable plain text (with optional ANSI color).
643    Text,
644    /// Markdown — headers, tables, code blocks. Embeddable in PRs, Slack, Notion.
645    Markdown,
646    /// CSV — one row per validation issue. Importable into Excel, dashboards.
647    Csv,
648}
649
650/// Format options for `format_validation_result`.
651#[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
666/// Format a `ValidationResult` (full package + validation) as text, markdown, or CSV.
667///
668/// This is the recommended formatting function — it reads directly from the
669/// `Imferno` struct and `ValidationReport` without the lossy `ImfReport` intermediate.
670pub 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    // Package structure
685    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    // CPL timeline
708    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                // Per-track media info from essence descriptor
730                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    // Validation findings
782    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    // CPLs
804    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    // Validation issues
831    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    // Summary
850    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    // Header
872    let _ = writeln!(out, "severity,code,message,cpl_id,suggestion");
873
874    // One row per issue
875    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
902// ── Shared helpers for new formatter ────────────────────────────────────────
903
904fn 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
978// ── Media info helpers ──────────────────────────────────────────────────────
979
980struct 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    // Try CDCI first (most common for YCbCr), then RGBA
996    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
1149/// Look up the essence descriptor for a sequence from the CPL.
1150fn 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
1164/// Look up language for a sequence from the CPL's essence descriptor list.
1165fn 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}