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