oxideav_cli_convert/lib.rs
1//! `oxideav convert` engine.
2//!
3//! Translates ImageMagick-style arg syntax into an in-memory
4//! [`oxideav_pipeline::Job`] and runs it through the same executor
5//! the `oxideav run` subcommand uses. One code path serves image,
6//! video, and audio inputs — a PNG → JPG is just a 1-frame stream.
7//!
8//! Entry point: [`run`] takes the slice of arguments that come after
9//! `oxideav convert` plus the caller-supplied registries, and does
10//! everything.
11//!
12//! # Example
13//!
14//! ```no_run
15//! use oxideav_core::RuntimeContext;
16//!
17//! let mut ctx = RuntimeContext::new();
18//! oxideav_source::register(&mut ctx);
19//!
20//! // Equivalent to:
21//! // oxideav convert in.png -resize 800x600 out.jpg
22//! oxideav_cli_convert::run(
23//! &[
24//! "in.png".into(),
25//! "-resize".into(),
26//! "800x600".into(),
27//! "out.jpg".into(),
28//! ],
29//! &ctx,
30//! ).unwrap();
31//! ```
32
33pub mod args;
34#[cfg(feature = "ico")]
35pub mod ico_runner;
36#[cfg(feature = "mesh3d")]
37pub mod mesh3d_render;
38#[cfg(feature = "mesh3d")]
39pub mod mesh3d_runner;
40pub mod op;
41pub mod pdf_runner;
42pub mod ping;
43pub mod pixel_xform;
44pub mod plan_to_job;
45pub mod probe;
46pub mod raster_io;
47pub mod suggest;
48
49pub use op::{AlphaOp, ConvertPlan, Dither, Op, PrintfTemplate, ResizeMode};
50
51use oxideav_core::{Error, RuntimeContext};
52
53/// Run convert with a caller-supplied [`RuntimeContext`].
54///
55/// The CLI passes the same context produced by `oxideav::with_all_features()`
56/// it uses for `remux` / `transcode` / `run`; third-party embedders can
57/// pass a narrower set.
58pub fn run(args: &[String], ctx: &RuntimeContext) -> Result<(), Error> {
59 let plan = args::parse(args)?;
60
61 // `-ping` short-circuits everything: print one IM-format header
62 // line per "image" (page / video stream) to stdout and exit
63 // without any pixel decode or output write. Routed BEFORE the PDF
64 // side-channel so the ping module owns the PDF-vs-container split
65 // for itself.
66 if plan.ping {
67 return ping::run(&plan, ctx);
68 }
69
70 // `--probe` is the structural-inspection mode: decode the input
71 // far enough to extract metadata (page count, mesh count, sample
72 // rate, …), print a summary to stdout, and skip any output write.
73 // The args parser already enforced the "no output positional"
74 // rule, so the probe module only deals with one shape (input-only).
75 if plan.probe {
76 return probe::run(&plan, ctx);
77 }
78
79 // Side-channel: PDF inputs go through a Scene-aware runner that
80 // bypasses the regular FrameSource pipeline. PDF pages don't fit
81 // the `Frame::Video` shape `oxideav-pipeline` expects, and the
82 // routing rule (encoder accepts Scene → pass through; otherwise
83 // fan out per page when the filename has a `%d` template) is
84 // specific to `convert`. See `pdf_runner` module docs.
85 if pdf_runner::is_pdf_input(&plan.input) {
86 return pdf_runner::run(&plan);
87 }
88
89 // Side-channel: 3D-asset inputs (STL/OBJ/glTF/GLB/USDZ/MTL) go
90 // through a Mesh3DRegistry-driven decode→encode bridge for
91 // same-class round-trips, OR through the 3D→raster software
92 // renderer when paired with a raster output (.png/.jpg/.bmp/
93 // .webp). 3D scenes don't fit any of the codec/container shapes
94 // the regular pipeline walks, and the dispatch contract is a
95 // separate registry from `RuntimeContext`'s codec/container/
96 // filter/source path. See `mesh3d_runner` and `mesh3d_render`
97 // module docs.
98 #[cfg(feature = "mesh3d")]
99 if mesh3d_runner::is_mesh3d_input(&plan.input) {
100 if plan.output_template.is_some() {
101 return Err(Error::invalid(format!(
102 "convert: output '{}' has a `%d` template but 3D scenes are single-document; remove the template",
103 plan.output
104 )));
105 }
106 if mesh3d_runner::is_mesh3d_output(&plan.output) {
107 if !plan.ops.is_empty() {
108 eprintln!(
109 "convert: note: raster ops ignored on 3D-asset conversion (stl/obj/gltf/glb/mtl/usdz have no pixel grid)"
110 );
111 }
112 return mesh3d_runner::run(&plan.input, &plan.output, &plan.mesh3d_options);
113 }
114 // Raster output? Route to the 3D→raster software renderer.
115 if matches!(
116 raster_io::classify_output(&plan.output),
117 Ok(raster_io::OutputClass::Raster(_))
118 ) {
119 return mesh3d_render::run(&plan.input, &plan.output, &plan.ops, &plan.mesh3d_options);
120 }
121 // Anything else (e.g. .pdf, .svg, .mp4, …): emit the
122 // pairing-mismatch error with a did-you-mean hint over the
123 // 3D-output set.
124 let out_ext = plan.output.rsplit('.').next().unwrap_or("");
125 let hint = suggest::format_hint(out_ext, &["stl", "obj", "gltf", "glb", "mtl", "usdz"]);
126 return Err(Error::invalid(format!(
127 "convert: 3D input '{}' must pair with a 3D output (.stl/.obj/.gltf/.glb/.mtl/.usdz) OR a raster output (.png/.jpg/.bmp/.webp); got '{}'{hint}",
128 plan.input, plan.output
129 )));
130 }
131
132 // Side-channel: `.ico` output. The ICO container carries one OR
133 // more sub-images at different resolutions, driven on the IM CLI
134 // by `-define icon:auto-resize=W1,W2,…`. The regular pipeline
135 // path's "one Frame::Video per track" shape doesn't fit that
136 // multi-image fan-out, and the `-define` key parsing + per-size
137 // resize is convert-specific. See `ico_runner` module docs.
138 #[cfg(feature = "ico")]
139 if ico_runner::is_ico_output(&plan.output) {
140 return ico_runner::run(&plan);
141 }
142
143 let job = plan_to_job::plan_to_job(&plan, ctx)?;
144 let stats = oxideav_pipeline::Executor::new(&job, ctx).run()?;
145 eprintln!(
146 "convert: {} packet(s) read, {} frame(s) decoded, {} frame(s) written",
147 stats.packets_read, stats.frames_decoded, stats.frames_written
148 );
149 Ok(())
150}