Skip to main content

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}