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#[doc(hidden)]
35pub mod frame_tap;
36#[cfg(feature = "ico")]
37#[doc(hidden)]
38pub mod ico_runner;
39#[doc(hidden)]
40pub mod image_sink;
41#[cfg(feature = "mesh3d")]
42#[doc(hidden)]
43pub mod mesh3d_render;
44#[cfg(feature = "mesh3d")]
45#[doc(hidden)]
46pub mod mesh3d_runner;
47pub mod op;
48#[doc(hidden)]
49pub mod pdf_runner;
50#[doc(hidden)]
51pub mod ping;
52#[doc(hidden)]
53pub mod pixel_xform;
54pub mod plan_to_job;
55#[doc(hidden)]
56pub mod probe;
57#[doc(hidden)]
58pub mod raster_io;
59pub mod route;
60#[doc(hidden)]
61pub mod suggest;
62
63/// `OXIDEAV_CONVERT_TIMING=1` stage spans for the pipeline route.
64mod timing {
65 use std::time::Duration;
66
67 /// True when `OXIDEAV_CONVERT_TIMING` is set to a non-empty value
68 /// other than `0`.
69 pub fn enabled() -> bool {
70 std::env::var_os("OXIDEAV_CONVERT_TIMING").is_some_and(|v| !v.is_empty() && v != "0")
71 }
72
73 /// Print one `convert: timing: <stage> <ms>` line per span.
74 pub fn report(spans: &[(&str, Duration)]) {
75 for (stage, d) in spans {
76 eprintln!(
77 "convert: timing: {stage:<44} {:>9.1} ms",
78 d.as_secs_f64() * 1000.0
79 );
80 }
81 }
82}
83
84pub use op::{AlphaOp, ConvertPlan, Dither, Op, PrintfTemplate, ResizeMode};
85pub use route::Route;
86
87use oxideav_core::{Error, RuntimeContext};
88
89/// Run convert with a caller-supplied [`RuntimeContext`].
90///
91/// The CLI passes the same context produced by `oxideav::with_all_features()`
92/// it uses for `remux` / `transcode` / `run`; third-party embedders can
93/// pass a narrower set.
94pub fn run(args: &[String], ctx: &RuntimeContext) -> Result<(), Error> {
95 let plan = args::parse(args)?;
96
97 // All routing decisions live in `route::decide` (pure, matrix-
98 // tested); this function only performs the dispatched work. See
99 // the `route` module docs for the precedence order and why input
100 // classification outranks output classification.
101 match route::decide(&plan)? {
102 // `--help`: print the usage synopsis (generated from the
103 // parser's own flag table) and do nothing else.
104 Route::Help => {
105 print!("{}", args::usage());
106 Ok(())
107 }
108
109 // `-ping`: one IM-format header line per "image" (page /
110 // video stream) to stdout, no pixel decode, no output write.
111 // The ping module owns the PDF-vs-container split for itself.
112 Route::Ping => ping::run(&plan, ctx),
113
114 // `--probe`: decode the input far enough to extract metadata
115 // (page count, mesh count, sample rate, …), print a summary
116 // to stdout, and skip any output write. The args parser
117 // already enforced the "no output positional" rule, so the
118 // probe module only deals with one shape (input-only).
119 Route::Probe => probe::run(&plan, ctx),
120
121 // PDF inputs go through a Scene-aware runner that bypasses
122 // the regular FrameSource pipeline. PDF pages don't fit the
123 // `Frame::Video` shape `oxideav-pipeline` expects, and the
124 // routing rule (encoder accepts Scene → pass through;
125 // otherwise fan out per page when the filename has a `%d`
126 // template) is specific to `convert`. See `pdf_runner`.
127 Route::PdfSideChannel => pdf_runner::run(&plan),
128
129 // 3D-asset same-class round-trip: decode → re-encode through
130 // the `Mesh3DRegistry`. 3D scenes don't fit any of the
131 // codec/container shapes the regular pipeline walks. See
132 // `mesh3d_runner` module docs.
133 #[cfg(feature = "mesh3d")]
134 Route::Mesh3dToMesh3d => {
135 if !plan.ops.is_empty() {
136 eprintln!(
137 "convert: note: raster ops ignored on 3D-asset conversion (stl/obj/gltf/glb/mtl/usdz have no pixel grid)"
138 );
139 }
140 mesh3d_runner::run(&plan.input, &plan.output, &plan.mesh3d_options)
141 }
142
143 // 3D-asset input paired with a raster output: software-render
144 // the scene to RGBA, then apply raster ops and encode. See
145 // `mesh3d_render` module docs.
146 #[cfg(feature = "mesh3d")]
147 Route::Mesh3dToRaster => {
148 mesh3d_render::run(&plan.input, &plan.output, &plan.ops, &plan.mesh3d_options)
149 }
150
151 // `.ico` output: the ICO container carries one OR more
152 // sub-images at different resolutions, driven on the IM CLI
153 // by `-define icon:auto-resize=W1,W2,…`. The regular pipeline
154 // path's "one Frame::Video per track" shape doesn't fit that
155 // multi-image fan-out. See `ico_runner` module docs.
156 #[cfg(feature = "ico")]
157 Route::IcoOutput => ico_runner::run(&plan),
158
159 Route::Pipeline => {
160 let timing = timing::enabled();
161 let t0 = std::time::Instant::now();
162 let planned = plan_to_job::plan_pipeline(&plan, ctx)?;
163 let t_plan = t0.elapsed();
164 for note in &planned.notes {
165 eprintln!("{note}");
166 }
167 match planned.tap {
168 None => {
169 let t1 = std::time::Instant::now();
170 let stats = oxideav_pipeline::Executor::new(&planned.job, ctx).run()?;
171 eprintln!(
172 "convert: {} packet(s) read, {} frame(s) decoded, {} frame(s) written",
173 stats.packets_read, stats.frames_decoded, stats.frames_written
174 );
175 if timing {
176 timing::report(&[
177 ("plan (incl. input header probe)", t_plan),
178 ("pipeline (demux+decode+convert+encode+mux)", t1.elapsed()),
179 ("total", t0.elapsed()),
180 ]);
181 }
182 }
183 Some(tap) => {
184 let t1 = std::time::Instant::now();
185 let (stats, written) = frame_tap::run(&planned.job, ctx, &tap)?;
186 eprintln!(
187 "convert: {} packet(s) read, {} frame(s) decoded, {} file(s) written",
188 stats.packets_read, stats.frames_decoded, written.files
189 );
190 if timing {
191 let run = t1.elapsed();
192 let first = written.first_frame.unwrap_or_default();
193 timing::report(&[
194 ("plan (incl. input header probe)", t_plan),
195 ("first frame (demux+decode+filters+convert)", first),
196 ("encode", written.encode),
197 ("mux + write", written.mux),
198 ("run", run),
199 ("total", t0.elapsed()),
200 ]);
201 }
202 }
203 }
204 Ok(())
205 }
206 }
207}