1#![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#[derive(Clone, Debug)]
53enum Field {
54 Str(&'static str, String),
56 Int(&'static str, i64),
58 Float(&'static str, f64),
62 StrList(&'static str, Vec<String>),
64 Group(&'static str, Vec<Field>),
66 GroupList(&'static str, Vec<Vec<Field>>),
69}
70
71#[derive(Clone, Debug, Default)]
73struct Summary {
74 fields: Vec<Field>,
77}
78
79impl Summary {
80 fn push(&mut self, field: Field) {
81 self.fields.push(field);
82 }
83
84 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 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 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
215fn 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
238pub 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
254fn print_one(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
256 let line = render(plan, ctx)?;
257 if plan.probe_json {
258 println!("{line}");
259 } else {
260 print!("{line}");
264 }
265 Ok(())
266}
267
268fn run_watch(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
280 use std::thread::sleep;
281 use std::time::Duration;
282
283 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 if cur_mtime == last_mtime {
299 continue;
300 }
301 last_mtime = cur_mtime;
302 if let Err(e) = print_one(plan, ctx) {
303 eprintln!("convert --probe --watch: re-probe failed: {e:?}");
308 }
309 }
310}
311
312fn mtime_of(path: &str) -> Option<std::time::SystemTime> {
316 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
317}
318
319pub 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
348fn 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
355fn 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 let is_encrypted = match DocumentReader::open(&bytes) {
367 Ok(r) => r.is_encrypted(),
368 Err(_) => true, };
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 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 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 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 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 s.push(Field::Str("embedded_font_count", "unknown".into()));
455
456 Ok(s)
457}
458
459fn 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 _ => {}
487 }
488}
489
490fn 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 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#[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 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 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 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 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 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 s.push(Field::Int("active_scene_index", 0));
684 s.push(Field::Str("scene_name", "(unnamed)".into()));
685
686 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#[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#[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#[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
804fn 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 SourceOutput::MultiTitle(_) => {
825 return Err(Error::unsupported(format!(
826 "convert --probe: {}: multi-title sources are not supported",
827 plan.input
828 )));
829 }
830 #[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 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 let stream_groups: Vec<Vec<Field>> = streams.iter().map(stream_summary_fields).collect();
874 s.push(Field::GroupList("streams", stream_groups));
875
876 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
898fn intern_static_or_leak(s: &str) -> &'static str {
907 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 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 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
960fn 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
1049fn 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
1073fn 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 _ => 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#[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}