1use 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#[derive(Clone, Debug)]
47enum Field {
48 Str(&'static str, String),
50 Int(&'static str, i64),
52 Float(&'static str, f64),
56 StrList(&'static str, Vec<String>),
58 Group(&'static str, Vec<Field>),
60 GroupList(&'static str, Vec<Vec<Field>>),
63}
64
65#[derive(Clone, Debug, Default)]
67struct Summary {
68 fields: Vec<Field>,
71}
72
73impl Summary {
74 fn push(&mut self, field: Field) {
75 self.fields.push(field);
76 }
77
78 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 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 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
209fn 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
232pub 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
248fn print_one(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
250 let line = render(plan, ctx)?;
251 if plan.probe_json {
252 println!("{line}");
253 } else {
254 print!("{line}");
258 }
259 Ok(())
260}
261
262fn run_watch(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
274 use std::thread::sleep;
275 use std::time::Duration;
276
277 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 if cur_mtime == last_mtime {
293 continue;
294 }
295 last_mtime = cur_mtime;
296 if let Err(e) = print_one(plan, ctx) {
297 eprintln!("convert --probe --watch: re-probe failed: {e:?}");
302 }
303 }
304}
305
306fn mtime_of(path: &str) -> Option<std::time::SystemTime> {
310 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
311}
312
313pub 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
342fn 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
349fn 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 let is_encrypted = match DocumentReader::open(&bytes) {
361 Ok(r) => r.is_encrypted(),
362 Err(_) => true, };
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 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 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 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 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 s.push(Field::Str("embedded_font_count", "unknown".into()));
449
450 Ok(s)
451}
452
453fn 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 _ => {}
481 }
482}
483
484fn 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 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#[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 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 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 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 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 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 s.push(Field::Int("active_scene_index", 0));
678 s.push(Field::Str("scene_name", "(unnamed)".into()));
679
680 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#[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#[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#[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
798fn 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 SourceOutput::MultiTitle(_) => {
819 return Err(Error::unsupported(format!(
820 "convert --probe: {}: multi-title sources are not supported",
821 plan.input
822 )));
823 }
824 #[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 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 let stream_groups: Vec<Vec<Field>> = streams.iter().map(stream_summary_fields).collect();
868 s.push(Field::GroupList("streams", stream_groups));
869
870 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
892fn intern_static_or_leak(s: &str) -> &'static str {
901 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 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 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
954fn 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
1043fn 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
1067fn 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 _ => 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#[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}