Skip to main content

oxideav_cli_convert/
probe.rs

1//! `--probe` runner — decode the input far enough to extract
2//! structural metadata (page count, mesh count, sample rate, …),
3//! print a compact summary to stdout, and skip any output write.
4//!
5//! Output flavours:
6//!
7//! * Pretty-printed (default): a list of `key: value` lines, with
8//!   nested groups (per-page dimensions, per-stream codec) indented.
9//! * JSON (`--json`): a single-line object with the same fields,
10//!   suitable for machine consumption.
11//!
12//! ## Routing
13//!
14//! The probe walks the same input-shape decision tree as
15//! [`crate::run`]:
16//!
17//! 1. PDF inputs → [`probe_pdf`] via `oxideav_pdf::read_pdf_to_scene`.
18//! 2. 3D inputs (mesh3d feature on) → [`probe_mesh3d`] via the
19//!    [`oxideav_mesh3d::Mesh3DRegistry`] populated by
20//!    `oxideav_meta::populate_mesh3d_registry`.
21//! 3. SVG inputs → [`probe_svg`] via `oxideav_svg::parse_svg` (a
22//!    second Scene-shaped input class).
23//! 4. Everything else → [`probe_container`], same path the `-ping`
24//!    runner uses (raster / audio / video).
25//!
26//! Per-input fields are best-effort; when a producer crate doesn't
27//! surface a particular field (e.g. PDF embedded font count without a
28//! resource walker) the probe leaves it `null` in JSON / `unknown` in
29//! pretty form rather than guessing.
30
31use std::fmt::Write as _;
32use std::fs;
33
34use oxideav_core::vector::{Group, Node, VectorFrame};
35use oxideav_core::{Error, MediaType, PixelFormat, Result, RuntimeContext, SourceOutput};
36use oxideav_pdf::read_pdf_to_scene;
37use oxideav_pdf::reader::DocumentReader;
38use oxideav_scene::Page;
39use oxideav_svg::parse_svg;
40
41use crate::op::ConvertPlan;
42use crate::pdf_runner::is_pdf_input;
43
44/// One field on the probe summary. Kept as a sum type so the
45/// pretty-print and JSON formatters share the same input.
46#[derive(Clone, Debug)]
47enum Field {
48    /// `key: value` (string).
49    Str(&'static str, String),
50    /// `key: value` (integer).
51    Int(&'static str, i64),
52    /// `key: value` (float; rendered with 2 decimal places by the
53    /// pretty formatter and as a JSON number with the `Display`
54    /// impl's default precision).
55    Float(&'static str, f64),
56    /// `key: value` (string array).
57    StrList(&'static str, Vec<String>),
58    /// `key: value` (group of nested fields).
59    Group(&'static str, Vec<Field>),
60    /// `key: value` (array of grouped fields — one group per array
61    /// element, keyed by the group's own field set).
62    GroupList(&'static str, Vec<Vec<Field>>),
63}
64
65/// A complete probe summary.
66#[derive(Clone, Debug, Default)]
67struct Summary {
68    /// Top-level fields. Common fields first (`path`, `kind`); then
69    /// kind-specific fields.
70    fields: Vec<Field>,
71}
72
73impl Summary {
74    fn push(&mut self, field: Field) {
75        self.fields.push(field);
76    }
77
78    /// Pretty-printed text summary — newline-separated `key: value`
79    /// lines, two-space indent per nesting level.
80    fn to_pretty(&self) -> String {
81        let mut out = String::new();
82        for f in &self.fields {
83            write_field_pretty(&mut out, f, 0);
84        }
85        out
86    }
87
88    /// JSON object on a single line. The serialisation is hand-rolled
89    /// (instead of `serde_json::to_string`) so the field order matches
90    /// the pretty-print version exactly — useful when the caller is
91    /// diffing two probes for regressions.
92    fn to_json(&self) -> String {
93        let mut out = String::from("{");
94        for (i, f) in self.fields.iter().enumerate() {
95            if i > 0 {
96                out.push(',');
97            }
98            write_field_json(&mut out, f);
99        }
100        out.push('}');
101        out
102    }
103}
104
105fn write_field_pretty(out: &mut String, field: &Field, depth: usize) {
106    let indent = "  ".repeat(depth);
107    match field {
108        Field::Str(k, v) => {
109            let _ = writeln!(out, "{indent}{k}: {v}");
110        }
111        Field::Int(k, v) => {
112            let _ = writeln!(out, "{indent}{k}: {v}");
113        }
114        Field::Float(k, v) => {
115            let _ = writeln!(out, "{indent}{k}: {v:.2}");
116        }
117        Field::StrList(k, items) => {
118            let _ = writeln!(out, "{indent}{k}: [{}]", items.join(", "));
119        }
120        Field::Group(k, sub) => {
121            let _ = writeln!(out, "{indent}{k}:");
122            for f in sub {
123                write_field_pretty(out, f, depth + 1);
124            }
125        }
126        Field::GroupList(k, items) => {
127            let _ = writeln!(out, "{indent}{k}:");
128            for (i, sub) in items.iter().enumerate() {
129                let _ = writeln!(out, "{indent}  - #{i}:");
130                for f in sub {
131                    write_field_pretty(out, f, depth + 2);
132                }
133            }
134        }
135    }
136}
137
138fn write_field_json(out: &mut String, field: &Field) {
139    match field {
140        Field::Str(k, v) => {
141            out.push_str(&json_string(k));
142            out.push(':');
143            out.push_str(&json_string(v));
144        }
145        Field::Int(k, v) => {
146            out.push_str(&json_string(k));
147            out.push(':');
148            let _ = write!(out, "{v}");
149        }
150        Field::Float(k, v) => {
151            out.push_str(&json_string(k));
152            out.push(':');
153            // Use the Display impl which avoids trailing zeros.
154            // f64 NaN / Inf would be invalid JSON; we don't produce
155            // those (every probe value is a measured count or
156            // dimension), but be explicit anyway.
157            if v.is_finite() {
158                let _ = write!(out, "{v}");
159            } else {
160                out.push_str("null");
161            }
162        }
163        Field::StrList(k, items) => {
164            out.push_str(&json_string(k));
165            out.push(':');
166            out.push('[');
167            for (i, item) in items.iter().enumerate() {
168                if i > 0 {
169                    out.push(',');
170                }
171                out.push_str(&json_string(item));
172            }
173            out.push(']');
174        }
175        Field::Group(k, sub) => {
176            out.push_str(&json_string(k));
177            out.push(':');
178            out.push('{');
179            for (i, f) in sub.iter().enumerate() {
180                if i > 0 {
181                    out.push(',');
182                }
183                write_field_json(out, f);
184            }
185            out.push('}');
186        }
187        Field::GroupList(k, items) => {
188            out.push_str(&json_string(k));
189            out.push(':');
190            out.push('[');
191            for (i, sub) in items.iter().enumerate() {
192                if i > 0 {
193                    out.push(',');
194                }
195                out.push('{');
196                for (j, f) in sub.iter().enumerate() {
197                    if j > 0 {
198                        out.push(',');
199                    }
200                    write_field_json(out, f);
201                }
202                out.push('}');
203            }
204            out.push(']');
205        }
206    }
207}
208
209/// Encode a Rust string as a JSON string literal. Escapes the
210/// minimum set required by RFC 8259 §7 — quotes, backslash, and the
211/// C0 control bytes — without pulling in a JSON dep.
212fn json_string(s: &str) -> String {
213    let mut out = String::with_capacity(s.len() + 2);
214    out.push('"');
215    for c in s.chars() {
216        match c {
217            '"' => out.push_str("\\\""),
218            '\\' => out.push_str("\\\\"),
219            '\n' => out.push_str("\\n"),
220            '\r' => out.push_str("\\r"),
221            '\t' => out.push_str("\\t"),
222            c if (c as u32) < 0x20 => {
223                let _ = write!(out, "\\u{:04x}", c as u32);
224            }
225            c => out.push(c),
226        }
227    }
228    out.push('"');
229    out
230}
231
232/// Print a probe summary for `plan.input` to stdout. Routing mirrors
233/// [`crate::run`] — PDF / 3D side-channels, then container fallback.
234///
235/// In `--watch` mode (paired with `--probe`), the probe is re-run
236/// whenever the input file's mtime changes, polling once per second.
237/// Each fresh report goes to stdout in the same format the one-shot
238/// mode would have used; in `--json` form each report is its own line
239/// so the output is well-formed JSON-lines. The loop runs forever and
240/// exits only on Ctrl+C / SIGINT.
241pub fn run(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
242    if plan.probe_watch {
243        return run_watch(plan, ctx);
244    }
245    print_one(plan, ctx)
246}
247
248/// One-shot probe: build the summary and print it.
249fn print_one(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
250    let line = render(plan, ctx)?;
251    if plan.probe_json {
252        println!("{line}");
253    } else {
254        // Pretty form already trails newlines per row; print as-is
255        // (no extra newline) so `convert --probe in.png | wc -l` is
256        // the row count.
257        print!("{line}");
258    }
259    Ok(())
260}
261
262/// Live-monitoring loop. Probes once at startup, then re-probes
263/// whenever the input file's mtime advances. Polling cadence is one
264/// second — fast enough for interactive feedback, slow enough that a
265/// long-running probe of a multi-MB PDF doesn't stack up.
266///
267/// The mtime is fetched via `std::fs::metadata` rather than an
268/// `inotify`-style watcher so the implementation stays portable
269/// (macOS, Linux, Windows) and dep-free. Errors during a re-probe
270/// (file truncated mid-write, transient I/O failure) are reported to
271/// stderr and the loop continues — losing one frame of output is
272/// strictly better than tearing down the whole watch session.
273fn run_watch(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
274    use std::thread::sleep;
275    use std::time::Duration;
276
277    // Initial probe + initial mtime baseline. The first probe failing
278    // is not graceful-degradation territory — the user gave us an
279    // unreadable input. Surface it.
280    let mut last_mtime = mtime_of(&plan.input);
281    print_one(plan, ctx)?;
282
283    let poll_interval = Duration::from_secs(1);
284    loop {
285        sleep(poll_interval);
286        let cur_mtime = mtime_of(&plan.input);
287        // Only re-probe when the mtime *changed* — both `Some(t)` →
288        // `Some(t')` (file was rewritten) and `Some(t)` → `None` (file
289        // disappeared) qualify; `None` → `None` and `Some(t)` →
290        // `Some(t)` don't. The match is exhaustive so a future
291        // SystemTime variant doesn't sneak through.
292        if cur_mtime == last_mtime {
293            continue;
294        }
295        last_mtime = cur_mtime;
296        if let Err(e) = print_one(plan, ctx) {
297            // Soft-fail: print a short diagnostic to stderr but keep
298            // watching. The most common cause is a half-written file
299            // (mtime advanced but the writer hasn't flushed). On the
300            // next mtime advance we'll try again.
301            eprintln!("convert --probe --watch: re-probe failed: {e:?}");
302        }
303    }
304}
305
306/// Read the input file's mtime, mapping I/O errors to `None`. Used
307/// by the watch loop to detect "file changed" without panicking when
308/// the file is briefly unavailable (rename-rotate writers).
309fn mtime_of(path: &str) -> Option<std::time::SystemTime> {
310    std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
311}
312
313/// Build the probe summary for `plan.input` and return it formatted
314/// as a single string — JSON when [`plan.probe_json`](ConvertPlan::probe_json)
315/// is on, pretty-printed `key: value` lines otherwise. Same data
316/// [`run`] would have printed; useful for callers (tests, embedders)
317/// that want to inspect the probe payload without going through stdout
318/// capture.
319pub fn render(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<String> {
320    let summary = build_summary(plan, ctx)?;
321    Ok(if plan.probe_json {
322        summary.to_json()
323    } else {
324        summary.to_pretty()
325    })
326}
327
328fn build_summary(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<Summary> {
329    if is_pdf_input(&plan.input) {
330        return probe_pdf(plan);
331    }
332    #[cfg(feature = "mesh3d")]
333    if crate::mesh3d_runner::is_mesh3d_input(&plan.input) {
334        return probe_mesh3d(plan);
335    }
336    if has_extension(&plan.input, &["svg"]) {
337        return probe_svg(plan);
338    }
339    probe_container(plan, ctx)
340}
341
342/// Best-effort extension check — same rule as the routing helpers in
343/// `mesh3d_runner` / `pdf_runner`.
344fn has_extension(path: &str, exts: &[&str]) -> bool {
345    let lc = path.to_ascii_lowercase();
346    exts.iter().any(|e| lc.ends_with(&format!(".{e}")))
347}
348
349// ─────────────────────────── PDF probe ───────────────────────────
350
351fn probe_pdf(plan: &ConvertPlan) -> Result<Summary> {
352    let bytes = fs::read(&plan.input)
353        .map_err(|e| Error::invalid(format!("convert: failed to read {}: {e}", plan.input)))?;
354    // Open via the low-level `DocumentReader` first so we can surface
355    // `is_encrypted` (the high-level `read_pdf_to_scene` succeeds on
356    // unencrypted PDFs and on encrypted-with-empty-password PDFs but
357    // doesn't expose which one we landed on). On encryption-failure
358    // (`open` errors) we still report the file as encrypted with the
359    // gap noted, rather than the whole probe failing.
360    let is_encrypted = match DocumentReader::open(&bytes) {
361        Ok(r) => r.is_encrypted(),
362        Err(_) => true, // open failed — almost certainly an /Encrypt
363                        // entry with a non-empty password we don't have
364    };
365    let scene = read_pdf_to_scene(&bytes)
366        .map_err(|e| Error::invalid(format!("convert: failed to parse PDF: {e:?}")))?;
367    let pages = scene.pages.as_deref().unwrap_or(&[]);
368
369    let mut s = Summary::default();
370    s.push(Field::Str("path", plan.input.clone()));
371    s.push(Field::Str("kind", "pdf".into()));
372    s.push(Field::Int("file_size_bytes", bytes.len() as i64));
373    s.push(Field::Str(
374        "is_encrypted",
375        if is_encrypted {
376            "yes".into()
377        } else {
378            "no".into()
379        },
380    ));
381    s.push(Field::Int("page_count", pages.len() as i64));
382
383    // Document-info fields surfaced via the `/Info` dictionary, lifted
384    // into `Scene::metadata` by the PDF reader. Each is reported only
385    // when populated — the absence of `producer` is a real signal
386    // ("this PDF wasn't tagged"), not the same as the empty string.
387    let md = &scene.metadata;
388    if let Some(title) = &md.title {
389        s.push(Field::Str("title", title.clone()));
390    }
391    if let Some(author) = &md.author {
392        s.push(Field::Str("author", author.clone()));
393    }
394    if let Some(subject) = &md.subject {
395        s.push(Field::Str("subject", subject.clone()));
396    }
397    if !md.keywords.is_empty() {
398        s.push(Field::StrList("keywords", md.keywords.clone()));
399    }
400    if let Some(creator) = &md.creator {
401        s.push(Field::Str("creator", creator.clone()));
402    }
403    if let Some(producer) = &md.producer {
404        s.push(Field::Str("producer", producer.clone()));
405    }
406    if let Some(created_at) = &md.created_at {
407        // Ship the raw ISO-8601 string the PDF reader normalised to;
408        // it round-trips to PDF's `D:YYYYMMDD…` format on write.
409        s.push(Field::Str("creation_date", created_at.clone()));
410    }
411    if let Some(modified_at) = &md.modified_at {
412        s.push(Field::Str("modification_date", modified_at.clone()));
413    }
414
415    // Per-page dimensions (PostScript points). Capped at 32 entries so
416    // a 10 000-page PDF doesn't blow up the summary; the count is
417    // always available via `page_count`.
418    let cap = 32usize;
419    let pages_for_summary: Vec<Vec<Field>> = pages
420        .iter()
421        .take(cap)
422        .map(|p| {
423            vec![
424                Field::Float("width_pt", p.width as f64),
425                Field::Float("height_pt", p.height as f64),
426                Field::Int("orientation_deg", p.orientation as i64),
427            ]
428        })
429        .collect();
430    s.push(Field::GroupList("pages", pages_for_summary));
431    if pages.len() > cap {
432        s.push(Field::Int("pages_truncated_at", cap as i64));
433    }
434
435    // Walk every page's VectorFrame and tally embedded raster images +
436    // groups (a rough proxy for nesting depth). Embedded font count is
437    // a follow-up: the PDF reader doesn't surface a /Font resource
438    // census today, and counting unique `font_name`s from
439    // PdfTextExtraction would over-count synthetic encoding splits.
440    let mut image_total = 0usize;
441    for page in pages {
442        image_total += count_images_in_frame(&page.content);
443    }
444    s.push(Field::Int("embedded_image_count", image_total as i64));
445    // Document-wide embedded font count is not surfaced by
446    // `read_pdf_to_scene` today; flag the gap rather than report 0
447    // which would imply "no fonts".
448    s.push(Field::Str("embedded_font_count", "unknown".into()));
449
450    Ok(s)
451}
452
453/// Count `Node::Image` occurrences anywhere in the vector tree. Used
454/// by the PDF probe to surface "how many embedded raster images is
455/// this page?". Cheap walk — every node visited once.
456fn count_images_in_frame(frame: &VectorFrame) -> usize {
457    let mut n = 0;
458    count_images_in_group(&frame.root, &mut n);
459    n
460}
461
462fn count_images_in_group(group: &Group, out: &mut usize) {
463    for child in &group.children {
464        count_images_in_node(child, out);
465    }
466}
467
468fn count_images_in_node(node: &Node, out: &mut usize) {
469    match node {
470        Node::Image(_) => *out += 1,
471        Node::Group(g) => count_images_in_group(g, out),
472        Node::SoftMask { mask, content, .. } => {
473            count_images_in_node(mask, out);
474            count_images_in_node(content, out);
475        }
476        Node::Path(_) => {}
477        // `Node` is `#[non_exhaustive]` upstream — any future variant
478        // (text-on-path, lottie animation node, …) just doesn't count
479        // as an embedded raster, which is the conservative answer.
480        _ => {}
481    }
482}
483
484// ─────────────────────────── SVG probe ───────────────────────────
485
486fn probe_svg(plan: &ConvertPlan) -> Result<Summary> {
487    let bytes = fs::read(&plan.input)
488        .map_err(|e| Error::invalid(format!("convert: failed to read {}: {e}", plan.input)))?;
489    let frame = parse_svg(&bytes)
490        .map_err(|e| Error::invalid(format!("convert: failed to parse SVG: {e:?}")))?;
491
492    let mut s = Summary::default();
493    s.push(Field::Str("path", plan.input.clone()));
494    s.push(Field::Str("kind", "svg".into()));
495    s.push(Field::Int("file_size_bytes", bytes.len() as i64));
496    // SVG is single-page by definition.
497    s.push(Field::Int("page_count", 1));
498
499    let pseudo_page = vec![Field::GroupList(
500        "pages",
501        vec![vec![
502            Field::Float("width_pt", frame.width as f64),
503            Field::Float("height_pt", frame.height as f64),
504            Field::Int("orientation_deg", 0),
505        ]],
506    )];
507    s.fields.extend(pseudo_page);
508
509    let mut image_total = 0usize;
510    count_images_in_group(&frame.root, &mut image_total);
511    s.push(Field::Int("embedded_image_count", image_total as i64));
512    s.push(Field::Str("embedded_font_count", "unknown".into()));
513    Ok(s)
514}
515
516// ─────────────────────────── 3D probe ───────────────────────────
517
518#[cfg(feature = "mesh3d")]
519fn probe_mesh3d(plan: &ConvertPlan) -> Result<Summary> {
520    use oxideav_mesh3d::{Mesh3DRegistry, Topology};
521
522    let in_ext = ext_of(&plan.input)
523        .ok_or_else(|| {
524            Error::invalid(format!(
525                "convert: input '{}' has no extension — cannot pick a 3D decoder",
526                plan.input
527            ))
528        })?
529        .to_ascii_lowercase();
530
531    let bytes = fs::read(&plan.input)
532        .map_err(|e| Error::invalid(format!("convert: failed to read {}: {e}", plan.input)))?;
533    let mut registry = Mesh3DRegistry::new();
534    crate::mesh3d_runner::populate_registry(&mut registry);
535    let mut decoder = registry.decoder_for_extension(&in_ext).ok_or_else(|| {
536        Error::unsupported(format!(
537            "convert: no 3D decoder registered for input extension '.{in_ext}'"
538        ))
539    })?;
540    let scene = decoder.decode(&bytes)?;
541
542    // Tally primitive / vertex counts across every primitive on every
543    // mesh. Vertex count is `positions.len()` — even when an index
544    // buffer is present, the buffer length describes the *index* count
545    // not the unique-vertex count; the spec model (matching glTF) says
546    // unique vertices are `positions.len()`.
547    let mut prim_total = 0usize;
548    let mut vert_total = 0usize;
549    let mut tri_total = 0usize;
550    for mesh in &scene.meshes {
551        for prim in &mesh.primitives {
552            prim_total += 1;
553            vert_total += prim.positions.len();
554            // Use the spec helper for triangle topologies; non-triangle
555            // primitives (Points / Lines) report 0, which is what we
556            // want.
557            tri_total += prim.triangle_count();
558        }
559    }
560
561    let bbox = compute_bbox(&scene);
562
563    let mut s = Summary::default();
564    s.push(Field::Str("path", plan.input.clone()));
565    s.push(Field::Str("kind", "mesh3d".into()));
566    s.push(Field::Str("format", in_ext));
567    s.push(Field::Int("file_size_bytes", bytes.len() as i64));
568    s.push(Field::Int("mesh_count", scene.meshes.len() as i64));
569    s.push(Field::Int("primitive_count", prim_total as i64));
570    s.push(Field::Int("vertex_count", vert_total as i64));
571    s.push(Field::Int("triangle_count", tri_total as i64));
572    s.push(Field::Int("material_count", scene.materials.len() as i64));
573    s.push(Field::Int("texture_count", scene.textures.len() as i64));
574    s.push(Field::Int("animation_count", scene.animations.len() as i64));
575    s.push(Field::Int("skin_count", scene.skins.len() as i64));
576    s.push(Field::Int("node_count", scene.nodes.len() as i64));
577    s.push(Field::Int("root_count", scene.roots.len() as i64));
578
579    // Per-mesh detail: name + primitive/vertex/triangle counts + bbox.
580    // Capped at 64 entries — the totals above are always reported via
581    // `mesh_count` / `primitive_count` / etc. so the field is
582    // answerable even on huge scenes.
583    let mesh_cap = 64usize;
584    let meshes_for_summary: Vec<Vec<Field>> = scene
585        .meshes
586        .iter()
587        .take(mesh_cap)
588        .enumerate()
589        .map(|(idx, m)| {
590            let mut prims = 0usize;
591            let mut verts = 0usize;
592            let mut tris = 0usize;
593            for prim in &m.primitives {
594                prims += 1;
595                verts += prim.positions.len();
596                tris += prim.triangle_count();
597            }
598            let mut row = vec![
599                Field::Int("index", idx as i64),
600                Field::Str("name", m.name.clone().unwrap_or_else(|| "(unnamed)".into())),
601                Field::Int("primitive_count", prims as i64),
602                Field::Int("vertex_count", verts as i64),
603                Field::Int("triangle_count", tris as i64),
604            ];
605            if let Some(b) = compute_mesh_bbox(m) {
606                row.push(Field::Group(
607                    "bounding_box",
608                    vec![
609                        Field::Float("min_x", b.0[0] as f64),
610                        Field::Float("min_y", b.0[1] as f64),
611                        Field::Float("min_z", b.0[2] as f64),
612                        Field::Float("max_x", b.1[0] as f64),
613                        Field::Float("max_y", b.1[1] as f64),
614                        Field::Float("max_z", b.1[2] as f64),
615                    ],
616                ));
617            }
618            row
619        })
620        .collect();
621    s.push(Field::GroupList("meshes", meshes_for_summary));
622    if scene.meshes.len() > mesh_cap {
623        s.push(Field::Int("meshes_truncated_at", mesh_cap as i64));
624    }
625
626    // Per-material detail: index + (best-effort) name. Materials don't
627    // carry a triangle/primitive count — references to materials live
628    // on `Primitive::material`, and a cross-walk would re-traverse the
629    // whole scene; the per-mesh totals above are the right place for
630    // that information today. Capped at 64.
631    let mat_cap = 64usize;
632    let materials_for_summary: Vec<Vec<Field>> = scene
633        .materials
634        .iter()
635        .take(mat_cap)
636        .enumerate()
637        .map(|(idx, m)| {
638            vec![
639                Field::Int("index", idx as i64),
640                Field::Str("name", m.name.clone().unwrap_or_else(|| "(unnamed)".into())),
641            ]
642        })
643        .collect();
644    s.push(Field::GroupList("materials", materials_for_summary));
645    if scene.materials.len() > mat_cap {
646        s.push(Field::Int("materials_truncated_at", mat_cap as i64));
647    }
648
649    // Per-animation detail: index + name + duration + channel count.
650    // Animation duration is the max keyframe across every channel — a
651    // single-channel translation that lasts 5.0s and a parallel rotation
652    // that lasts 8.0s yields a clip duration of 8.0s. Capped at 64.
653    let anim_cap = 64usize;
654    let animations_for_summary: Vec<Vec<Field>> = scene
655        .animations
656        .iter()
657        .take(anim_cap)
658        .enumerate()
659        .map(|(idx, a)| {
660            let dur = animation_duration(a);
661            vec![
662                Field::Int("index", idx as i64),
663                Field::Str("name", a.name.clone().unwrap_or_else(|| "(unnamed)".into())),
664                Field::Int("channel_count", a.channels.len() as i64),
665                Field::Float("duration_s", dur as f64),
666            ]
667        })
668        .collect();
669    s.push(Field::GroupList("animations", animations_for_summary));
670    if scene.animations.len() > anim_cap {
671        s.push(Field::Int("animations_truncated_at", anim_cap as i64));
672    }
673
674    // The Scene3D model is single-document (no separate "scene"
675    // collection like glTF's `scenes` array); surface the implicit
676    // index-zero so the field is present and answerable.
677    s.push(Field::Int("active_scene_index", 0));
678    s.push(Field::Str("scene_name", "(unnamed)".into()));
679
680    // Per-topology tally — useful when "vertex_count: 9" needs context
681    // (3 line segments? 3 triangles?). Build a small stable list.
682    let mut topo_counts: Vec<(Topology, usize)> = Vec::new();
683    for mesh in &scene.meshes {
684        for prim in &mesh.primitives {
685            if let Some(entry) = topo_counts.iter_mut().find(|(t, _)| *t == prim.topology) {
686                entry.1 += 1;
687            } else {
688                topo_counts.push((prim.topology, 1));
689            }
690        }
691    }
692    let topo_strs: Vec<String> = topo_counts
693        .iter()
694        .map(|(t, n)| format!("{}={n}", topology_label(*t)))
695        .collect();
696    s.push(Field::StrList("topologies", topo_strs));
697
698    if let Some(b) = bbox {
699        s.push(Field::Group(
700            "bounding_box",
701            vec![
702                Field::Float("min_x", b.0[0] as f64),
703                Field::Float("min_y", b.0[1] as f64),
704                Field::Float("min_z", b.0[2] as f64),
705                Field::Float("max_x", b.1[0] as f64),
706                Field::Float("max_y", b.1[1] as f64),
707                Field::Float("max_z", b.1[2] as f64),
708            ],
709        ));
710    } else {
711        s.push(Field::Str("bounding_box", "empty".into()));
712    }
713
714    Ok(s)
715}
716
717#[cfg(feature = "mesh3d")]
718fn topology_label(t: oxideav_mesh3d::Topology) -> &'static str {
719    use oxideav_mesh3d::Topology::*;
720    match t {
721        Triangles => "Triangles",
722        TriangleStrip => "TriangleStrip",
723        TriangleFan => "TriangleFan",
724        Lines => "Lines",
725        LineStrip => "LineStrip",
726        LineLoop => "LineLoop",
727        Points => "Points",
728    }
729}
730
731/// AABB of one mesh's primitives. `None` when the mesh has no
732/// position samples at all.
733#[cfg(feature = "mesh3d")]
734fn compute_mesh_bbox(mesh: &oxideav_mesh3d::Mesh) -> Option<([f32; 3], [f32; 3])> {
735    let mut bbox: Option<([f32; 3], [f32; 3])> = None;
736    for prim in &mesh.primitives {
737        for p in &prim.positions {
738            bbox = Some(match bbox {
739                None => (*p, *p),
740                Some((mn, mx)) => (
741                    [mn[0].min(p[0]), mn[1].min(p[1]), mn[2].min(p[2])],
742                    [mx[0].max(p[0]), mx[1].max(p[1]), mx[2].max(p[2])],
743                ),
744            });
745        }
746    }
747    bbox
748}
749
750/// Animation duration in seconds = max keyframe time across every
751/// channel's sampler. Returns `0.0` for an empty animation (no
752/// channels, or every channel empty) — matching what a glTF
753/// keyframes-empty animation would report.
754#[cfg(feature = "mesh3d")]
755fn animation_duration(anim: &oxideav_mesh3d::Animation) -> f32 {
756    let mut max = 0.0f32;
757    for ch in &anim.channels {
758        if let Some(&t) = ch.sampler.keyframes.last() {
759            if t > max {
760                max = t;
761            }
762        }
763    }
764    max
765}
766
767/// Compute the AABB of every position buffer across every mesh's
768/// every primitive. `None` when the scene has no positions at all.
769/// Positions are in the scene's own coordinate space (no node
770/// transforms applied) — matching what the spec model exposes.
771#[cfg(feature = "mesh3d")]
772fn compute_bbox(scene: &oxideav_mesh3d::Scene3D) -> Option<([f32; 3], [f32; 3])> {
773    let mut bbox: Option<([f32; 3], [f32; 3])> = None;
774    for mesh in &scene.meshes {
775        for prim in &mesh.primitives {
776            for p in &prim.positions {
777                bbox = Some(match bbox {
778                    None => (*p, *p),
779                    Some((mn, mx)) => (
780                        [mn[0].min(p[0]), mn[1].min(p[1]), mn[2].min(p[2])],
781                        [mx[0].max(p[0]), mx[1].max(p[1]), mx[2].max(p[2])],
782                    ),
783                });
784            }
785        }
786    }
787    bbox
788}
789
790#[cfg(feature = "mesh3d")]
791fn ext_of(path: &str) -> Option<&str> {
792    let last = path.rsplit('/').next().unwrap_or(path);
793    let last = last.split('?').next().unwrap_or(last);
794    let dot = last.rfind('.')?;
795    Some(&last[dot + 1..])
796}
797
798// ─────────────────────────── Container probe ───────────────────────────
799
800fn probe_container(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<Summary> {
801    let raw = match ctx.sources.open(&plan.input)? {
802        SourceOutput::Bytes(b) => b,
803        SourceOutput::Packets(_) => {
804            return Err(Error::unsupported(format!(
805                "convert --probe: {}: packet-shape source not supported",
806                plan.input
807            )));
808        }
809        SourceOutput::Frames(_) => {
810            return Err(Error::unsupported(format!(
811                "convert --probe: {}: frame-shape source not supported",
812                plan.input
813            )));
814        }
815        // Multi-title sources (BD-ROM, DVD-Video, multi-edition MKV)
816        // need a per-title fan-out the convert verb doesn't model;
817        // `--probe` is single-stream by design.
818        SourceOutput::MultiTitle(_) => {
819            return Err(Error::unsupported(format!(
820                "convert --probe: {}: multi-title sources are not supported",
821                plan.input
822            )));
823        }
824        // Any future `#[non_exhaustive]`-gated variant — the local
825        // umbrella's `oxideav-core` enables non_exhaustive but the
826        // currently-published crate doesn't, so the arm appears
827        // unreachable on CI. The allow keeps both builds green.
828        #[allow(unreachable_patterns)]
829        _ => {
830            return Err(Error::unsupported(format!(
831                "convert --probe: {}: source kind not supported by probe",
832                plan.input
833            )));
834        }
835    };
836    let file_size = fs::metadata(&plan.input).map(|m| m.len()).unwrap_or(0);
837    let mut handle: Box<dyn oxideav_core::ReadSeek> = Box::new(raw);
838    let ext = ext_from_uri(&plan.input);
839    let format = ctx
840        .containers
841        .probe_input(&mut *handle, ext.as_deref())
842        .map_err(|e| Error::invalid(format!("convert --probe: {}: {e}", plan.input)))?;
843    let demuxer = ctx
844        .containers
845        .open_demuxer(&format, handle, &ctx.codecs)
846        .map_err(|e| Error::invalid(format!("convert --probe: {}: {e}", plan.input)))?;
847
848    let format_label = format_label_for(&format, demuxer.format_name());
849    let streams = demuxer.streams();
850
851    let mut s = Summary::default();
852    s.push(Field::Str("path", plan.input.clone()));
853    // The stream classification picks the highest-information bucket
854    // available: video > audio > other. A movie file with both an
855    // audio and a video track reports `kind: video`; an MP3 reports
856    // `kind: audio`.
857    let kind = classify_kind(streams);
858    s.push(Field::Str("kind", kind.into()));
859    s.push(Field::Str("container", format_label));
860    s.push(Field::Int("file_size_bytes", file_size as i64));
861    s.push(Field::Int("stream_count", streams.len() as i64));
862
863    // Per-stream details: codec, media type, dimensions / sample rate
864    // / channel layout / duration, etc. The stream array is always
865    // present (even when empty) so callers iterating it don't have
866    // to special-case missing fields.
867    let stream_groups: Vec<Vec<Field>> = streams.iter().map(stream_summary_fields).collect();
868    s.push(Field::GroupList("streams", stream_groups));
869
870    // Container-level metadata via the Demuxer::metadata() trait
871    // method. Surfaced as a `metadata` group so callers can iterate
872    // `metadata.title` / `metadata.artist` / etc. without having to
873    // peer-into-streams. Demuxers that carry no metadata (the trait's
874    // default impl) just produce an empty group, which keeps the field
875    // present and answerable.
876    //
877    // The keys follow the loose convention defined on the Demuxer
878    // trait: `title`, `artist`, `album`, `comment`, `date`, etc. Keys
879    // we can't render as JSON object keys (control bytes etc.) are
880    // already escaped by `json_string`; values are passed through.
881    let meta = demuxer.metadata();
882    if !meta.is_empty() {
883        let meta_fields: Vec<Field> = meta
884            .iter()
885            .map(|(k, v)| Field::Str(intern_static_or_leak(k), v.clone()))
886            .collect();
887        s.push(Field::Group("metadata", meta_fields));
888    }
889    Ok(s)
890}
891
892/// `Field::Str`'s key field is `&'static str` (so the in-source
893/// constants don't allocate). Container-metadata keys are runtime
894/// strings, so we leak them into a static slot via `Box::leak`. The
895/// whole-process metadata-key footprint is bounded — there are ~30
896/// well-known keys (`title` / `artist` / `album` / …) and a handful of
897/// per-format extensions — so the leak is bounded too. Same shape
898/// `Field::Group("metadata", …)` would have if we changed the key
899/// type, just without rippling into every other field constructor.
900fn intern_static_or_leak(s: &str) -> &'static str {
901    // Hot path: well-known keys are interned literally so they don't
902    // get leaked once per probe call.
903    match s {
904        "title" => "title",
905        "artist" => "artist",
906        "album" => "album",
907        "album_artist" => "album_artist",
908        "comment" => "comment",
909        "date" => "date",
910        "year" => "year",
911        "track" => "track",
912        "genre" => "genre",
913        "composer" => "composer",
914        "performer" => "performer",
915        "copyright" => "copyright",
916        "encoder" => "encoder",
917        "language" => "language",
918        "publisher" => "publisher",
919        "description" => "description",
920        "disc" => "disc",
921        "lyrics" => "lyrics",
922        // Cold path: leak — container-format-specific keys
923        // (`sample_name:0`, `n_patterns`, …) are bounded per process.
924        other => Box::leak(other.to_string().into_boxed_str()),
925    }
926}
927
928fn classify_kind(streams: &[oxideav_core::StreamInfo]) -> &'static str {
929    let mut has_video = false;
930    let mut has_audio = false;
931    for st in streams {
932        match st.params.media_type {
933            MediaType::Video => has_video = true,
934            MediaType::Audio => has_audio = true,
935            _ => {}
936        }
937    }
938    if has_video {
939        // Single-frame "video" containers (PNG / JPEG / WebP single
940        // image / BMP) report a single video stream of n_frames=1; we
941        // call those `image` so the field communicates the user's
942        // mental model ("this PNG is an image, not a movie").
943        if streams.len() == 1 && is_single_frame(&streams[0]) {
944            return "image";
945        }
946        "video"
947    } else if has_audio {
948        "audio"
949    } else {
950        "other"
951    }
952}
953
954/// Heuristic: a container with a single video stream and no `frame_rate`
955/// / `duration` set is almost always a still image (PNG / JPEG / WebP /
956/// BMP). The real test would be the demuxer reporting `nb_frames == 1`,
957/// but `StreamInfo` doesn't carry that today; this proxy is correct for
958/// every still-image container in the workspace.
959fn is_single_frame(stream: &oxideav_core::StreamInfo) -> bool {
960    let p = &stream.params;
961    p.frame_rate.is_none() && stream.duration.is_none()
962}
963
964fn stream_summary_fields(stream: &oxideav_core::StreamInfo) -> Vec<Field> {
965    let p = &stream.params;
966    let mut out: Vec<Field> = Vec::new();
967    out.push(Field::Int("index", stream.index as i64));
968    out.push(Field::Str(
969        "media_type",
970        media_type_label(p.media_type).into(),
971    ));
972    out.push(Field::Str("codec_id", p.codec_id.as_str().into()));
973
974    match p.media_type {
975        MediaType::Video => {
976            let w = p.width.unwrap_or(0);
977            let h = p.height.unwrap_or(0);
978            out.push(Field::Int("width", w as i64));
979            out.push(Field::Int("height", h as i64));
980            let (depth, colorspace) = match p.pixel_format {
981                Some(pf) => describe_pixel_format(pf),
982                None => (0, "unknown"),
983            };
984            out.push(Field::Int("bit_depth", depth as i64));
985            out.push(Field::Str("color_space", colorspace.into()));
986            out.push(Field::Str(
987                "alpha",
988                if p.pixel_format.map(has_alpha).unwrap_or(false) {
989                    "yes".into()
990                } else {
991                    "no".into()
992                },
993            ));
994            if let Some(fr) = p.frame_rate {
995                out.push(Field::Float("frame_rate_fps", rational_as_f64(fr)));
996            }
997            if let Some(d) = stream.duration {
998                let secs = stream.time_base.seconds_of(d);
999                out.push(Field::Float("duration_s", secs));
1000            }
1001        }
1002        MediaType::Audio => {
1003            if let Some(sr) = p.sample_rate {
1004                out.push(Field::Int("sample_rate_hz", sr as i64));
1005            }
1006            if let Some(ch) = p.resolved_channels() {
1007                out.push(Field::Int("channels", ch as i64));
1008            }
1009            if let Some(layout) = p.channel_layout {
1010                out.push(Field::Str("channel_layout", layout.to_string()));
1011            }
1012            if let Some(sf) = p.sample_format {
1013                out.push(Field::Int("bit_depth", sample_format_bits(sf) as i64));
1014                out.push(Field::Str("sample_format", sample_format_label(sf).into()));
1015            }
1016            if let Some(d) = stream.duration {
1017                let secs = stream.time_base.seconds_of(d);
1018                out.push(Field::Float("duration_s", secs));
1019            }
1020            if let Some(br) = p.bit_rate {
1021                out.push(Field::Int("bit_rate", br as i64));
1022            }
1023        }
1024        _ => {}
1025    }
1026    out
1027}
1028
1029fn media_type_label(m: MediaType) -> &'static str {
1030    match m {
1031        MediaType::Video => "video",
1032        MediaType::Audio => "audio",
1033        MediaType::Subtitle => "subtitle",
1034        MediaType::Data => "data",
1035        MediaType::Unknown => "unknown",
1036    }
1037}
1038
1039fn rational_as_f64(r: oxideav_core::Rational) -> f64 {
1040    r.as_f64()
1041}
1042
1043/// Map a [`PixelFormat`] to (per-channel bit depth, color-space label).
1044/// Mirrors the equivalent helper in `ping.rs` — kept duplicated rather
1045/// than crossing module boundaries because the probe surface is
1046/// expected to grow more colour-model variants over time.
1047fn describe_pixel_format(pf: PixelFormat) -> (u8, &'static str) {
1048    use PixelFormat::*;
1049    match pf {
1050        Rgb24 | Rgba | Bgr24 | Bgra | Argb | Abgr => (8, "sRGB"),
1051        Rgb48Le | Rgba64Le => (16, "sRGB"),
1052        Yuv420P | Yuv422P | Yuv444P | YuvJ420P | YuvJ422P | YuvJ444P | Nv12 | Nv21 | Yuyv422
1053        | Uyvy422 | Yuva420P => (8, "sRGB"),
1054        Yuv420P10Le | Yuv422P10Le | Yuv444P10Le => (10, "sRGB"),
1055        Yuv420P12Le | Yuv422P12Le | Yuv444P12Le => (12, "sRGB"),
1056        Gray8 | Ya8 => (8, "Gray"),
1057        Gray10Le => (10, "Gray"),
1058        Gray12Le => (12, "Gray"),
1059        Gray16Le => (16, "Gray"),
1060        MonoBlack | MonoWhite => (1, "Gray"),
1061        Pal8 => (8, "sRGB"),
1062        Cmyk => (8, "CMYK"),
1063        _ => (0, "unknown"),
1064    }
1065}
1066
1067/// Whether the named pixel format carries an explicit alpha channel.
1068/// Conservative — only known-alpha variants return `true`.
1069fn has_alpha(pf: PixelFormat) -> bool {
1070    use PixelFormat::*;
1071    matches!(pf, Rgba | Bgra | Argb | Abgr | Rgba64Le | Yuva420P | Ya8)
1072}
1073
1074fn sample_format_bits(sf: oxideav_core::SampleFormat) -> u8 {
1075    use oxideav_core::SampleFormat::*;
1076    match sf {
1077        U8 | U8P | S8 => 8,
1078        S16 | S16P => 16,
1079        S24 => 24,
1080        S32 | S32P | F32 | F32P => 32,
1081        F64 | F64P => 64,
1082        // `SampleFormat` is `#[non_exhaustive]`; any new variant lands
1083        // here as 0 ("unknown") until the table is updated.
1084        _ => 0,
1085    }
1086}
1087
1088fn sample_format_label(sf: oxideav_core::SampleFormat) -> &'static str {
1089    use oxideav_core::SampleFormat::*;
1090    match sf {
1091        U8 => "u8",
1092        S8 => "s8",
1093        S16 => "s16",
1094        S24 => "s24",
1095        S32 => "s32",
1096        F32 => "f32",
1097        F64 => "f64",
1098        U8P => "u8p",
1099        S16P => "s16p",
1100        S32P => "s32p",
1101        F32P => "f32p",
1102        F64P => "f64p",
1103        _ => "unknown",
1104    }
1105}
1106
1107fn format_label_for(format: &str, format_name: &str) -> String {
1108    let chosen = if !format_name.is_empty() {
1109        format_name
1110    } else {
1111        format
1112    };
1113    chosen.to_ascii_uppercase()
1114}
1115
1116fn ext_from_uri(uri: &str) -> Option<String> {
1117    let last = uri.rsplit('/').next().unwrap_or(uri);
1118    let last = last.split('?').next().unwrap_or(last);
1119    let dot = last.rfind('.')?;
1120    Some(last[dot + 1..].to_ascii_lowercase())
1121}
1122
1123/// Take the page list (PDF) and an optional [`PageSelector`] and
1124/// resolve to the indices the probe should report. Currently unused
1125/// — full document by default — but kept here as an attachment point
1126/// for a future `--probe input.pdf[2-5]` invocation.
1127#[allow(dead_code)]
1128fn _selected_pages_from_plan(plan: &ConvertPlan, pages: &[Page]) -> Result<Vec<usize>> {
1129    match &plan.input_pages {
1130        Some(sel) => sel
1131            .resolve(pages.len())
1132            .map_err(|e| Error::invalid(format!("convert: input '{}': {e}", plan.input))),
1133        None => Ok((0..pages.len()).collect()),
1134    }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140
1141    #[test]
1142    fn json_string_escapes_control_chars() {
1143        assert_eq!(json_string("hello"), "\"hello\"");
1144        assert_eq!(json_string("a\nb"), "\"a\\nb\"");
1145        assert_eq!(json_string("a\tb"), "\"a\\tb\"");
1146        assert_eq!(json_string("\""), "\"\\\"\"");
1147        assert_eq!(json_string("\\"), "\"\\\\\"");
1148        assert_eq!(json_string("\x01"), "\"\\u0001\"");
1149    }
1150
1151    #[test]
1152    fn pretty_writer_indents_groups() {
1153        let mut s = Summary::default();
1154        s.push(Field::Str("kind", "test".into()));
1155        s.push(Field::Group(
1156            "inner",
1157            vec![Field::Int("count", 3), Field::Str("name", "foo".into())],
1158        ));
1159        let txt = s.to_pretty();
1160        assert!(txt.contains("kind: test\n"));
1161        assert!(txt.contains("inner:\n"));
1162        assert!(txt.contains("  count: 3\n"));
1163        assert!(txt.contains("  name: foo\n"));
1164    }
1165
1166    #[test]
1167    fn json_writer_quotes_fields() {
1168        let mut s = Summary::default();
1169        s.push(Field::Str("kind", "test".into()));
1170        s.push(Field::Int("count", 7));
1171        let json = s.to_json();
1172        assert!(json.starts_with('{'));
1173        assert!(json.ends_with('}'));
1174        assert!(json.contains("\"kind\":\"test\""));
1175        assert!(json.contains("\"count\":7"));
1176    }
1177
1178    #[test]
1179    fn json_writer_handles_group_list() {
1180        let mut s = Summary::default();
1181        s.push(Field::GroupList(
1182            "streams",
1183            vec![
1184                vec![
1185                    Field::Int("index", 0),
1186                    Field::Str("media_type", "video".into()),
1187                ],
1188                vec![
1189                    Field::Int("index", 1),
1190                    Field::Str("media_type", "audio".into()),
1191                ],
1192            ],
1193        ));
1194        let json = s.to_json();
1195        assert!(
1196            json.contains("\"streams\":[{\"index\":0,\"media_type\":\"video\"},{\"index\":1,\"media_type\":\"audio\"}]"),
1197            "unexpected json: {json}"
1198        );
1199    }
1200
1201    #[test]
1202    fn json_writer_str_list() {
1203        let mut s = Summary::default();
1204        s.push(Field::StrList(
1205            "topologies",
1206            vec!["Triangles=1".into(), "Lines=2".into()],
1207        ));
1208        let json = s.to_json();
1209        assert!(
1210            json.contains("\"topologies\":[\"Triangles=1\",\"Lines=2\"]"),
1211            "got: {json}"
1212        );
1213    }
1214
1215    #[test]
1216    fn float_pretty_uses_two_decimals() {
1217        let mut s = Summary::default();
1218        s.push(Field::Float("width_pt", 612.5));
1219        let txt = s.to_pretty();
1220        assert!(txt.contains("width_pt: 612.50\n"), "got: {txt:?}");
1221    }
1222
1223    #[test]
1224    fn ext_helper_matches_case_insensitively() {
1225        assert!(has_extension("foo.svg", &["svg"]));
1226        assert!(has_extension("foo.SVG", &["svg"]));
1227        assert!(has_extension("/abs/path/file.SvG", &["svg"]));
1228        assert!(!has_extension("foo.png", &["svg"]));
1229    }
1230}