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