Skip to main content

dioxuscut_cli/
lib.rs

1//! CLI options, validation, native composition registry, and render execution.
2
3pub mod composition;
4#[cfg(feature = "rhai")]
5pub mod rhai_runtime;
6
7pub use composition::{
8    built_in_registry, Composition, CompositionError, CompositionRegistry,
9    CompositionRegistryError, HelloWorldComposition, NativeComposition, NativeCompositionContext,
10    PreparedComposition,
11};
12#[cfg(feature = "rhai")]
13pub use rhai_runtime::{RhaiComposition, SceneBuilder};
14
15use clap::{Parser, Subcommand, ValueEnum};
16use std::fs;
17use std::path::PathBuf;
18use thiserror::Error;
19
20/// Error types for CLI input parameter validation.
21#[derive(Error, Debug, PartialEq, Eq)]
22pub enum ValidationError {
23    #[error("Composition name cannot be empty")]
24    EmptyComposition,
25    #[error("Provide exactly one composition source: --composition <ID> or --script <PATH>")]
26    MissingCompositionSource,
27    #[error("--composition and --script cannot be used together")]
28    ConflictingCompositionSources,
29    #[error("Rhai script file not found: {0}")]
30    ScriptFileNotFound(PathBuf),
31    #[error("Props file not found: {0}")]
32    PropsFileNotFound(PathBuf),
33    #[error("Invalid resolution: width ({0}) and height ({1}) must be greater than 0")]
34    InvalidZeroResolution(u32, u32),
35    #[error("Invalid resolution: width ({0}) and height ({1}) must be even numbers for H.264 video encoding")]
36    InvalidOddResolution(u32, u32),
37    #[error("Invalid FPS: {0} must be a finite number greater than 0")]
38    InvalidFps(String),
39    #[error("Invalid duration: {0} must be greater than 0 frames")]
40    InvalidDuration(u32),
41}
42
43/// Native render backend selection.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
45pub enum RenderBackend {
46    /// Pure-Rust CPU rasterizer via tiny-skia. Default.
47    #[default]
48    Native,
49    /// GPU rasterizer via wgpu. Requires `--features gpu`.
50    Gpu,
51}
52
53/// Dioxuscut CLI — render registered Rust or Rhai compositions to video.
54#[derive(Parser, Debug, Clone, PartialEq)]
55#[command(author, version, about, long_about = None)]
56pub struct Cli {
57    #[command(subcommand)]
58    pub command: Commands,
59}
60
61#[derive(Subcommand, Debug, Clone, PartialEq)]
62pub enum Commands {
63    /// Render a registered Rust composition or Rhai script to a video file.
64    Render {
65        /// ID of the composition to render.
66        #[arg(
67            long,
68            short,
69            required_unless_present = "script",
70            conflicts_with = "script"
71        )]
72        composition: Option<String>,
73
74        /// Path to a Rhai composition script. Requires the `rhai` feature.
75        #[arg(
76            long,
77            required_unless_present = "composition",
78            conflicts_with = "composition"
79        )]
80        script: Option<PathBuf>,
81
82        /// Path to a JSON file containing input props.
83        #[arg(long, short)]
84        props: Option<PathBuf>,
85
86        /// Output video file path.
87        #[arg(long, short, default_value = "out.mp4")]
88        output: PathBuf,
89
90        /// Resolution width.
91        #[arg(long, default_value_t = 1920)]
92        width: u32,
93
94        /// Resolution height.
95        #[arg(long, default_value_t = 1080)]
96        height: u32,
97
98        /// Frames per second.
99        #[arg(long, default_value_t = 30.0)]
100        fps: f64,
101
102        /// Duration in frames.
103        #[arg(long, default_value_t = 150)]
104        duration: u32,
105
106        /// Rendering backend.
107        #[arg(long, value_enum, default_value_t = RenderBackend::Native)]
108        backend: RenderBackend,
109    },
110}
111
112/// A validated render request independent from argument parsing.
113#[derive(Debug, Clone, PartialEq)]
114pub struct RenderRequest {
115    pub composition: Option<String>,
116    pub script: Option<PathBuf>,
117    pub props: Option<PathBuf>,
118    pub output: PathBuf,
119    pub width: u32,
120    pub height: u32,
121    pub fps: f64,
122    pub duration: u32,
123    pub backend: RenderBackend,
124}
125
126/// Validates that a render request selects exactly one available composition source.
127pub fn validate_composition_source(
128    composition: Option<&str>,
129    script: Option<&PathBuf>,
130) -> Result<(), ValidationError> {
131    match (composition, script) {
132        (None, None) => Err(ValidationError::MissingCompositionSource),
133        (Some(_), Some(_)) => Err(ValidationError::ConflictingCompositionSources),
134        (Some(composition), None) if composition.trim().is_empty() => {
135            Err(ValidationError::EmptyComposition)
136        }
137        (Some(_), None) => Ok(()),
138        (None, Some(path)) if !path.is_file() => {
139            Err(ValidationError::ScriptFileNotFound(path.clone()))
140        }
141        (None, Some(_)) => Ok(()),
142    }
143}
144
145/// Validates command-line parameters prior to launching the renderer.
146pub fn validate_render_params(
147    composition: &str,
148    props: Option<&PathBuf>,
149    width: u32,
150    height: u32,
151    fps: f64,
152    duration: u32,
153) -> Result<(), ValidationError> {
154    if composition.trim().is_empty() {
155        return Err(ValidationError::EmptyComposition);
156    }
157    if let Some(path) = props {
158        if !path.is_file() {
159            return Err(ValidationError::PropsFileNotFound(path.clone()));
160        }
161    }
162    if width == 0 || height == 0 {
163        return Err(ValidationError::InvalidZeroResolution(width, height));
164    }
165    if !width.is_multiple_of(2) || !height.is_multiple_of(2) {
166        return Err(ValidationError::InvalidOddResolution(width, height));
167    }
168    if !fps.is_finite() || fps <= 0.0 {
169        return Err(ValidationError::InvalidFps(fps.to_string()));
170    }
171    if duration == 0 {
172        return Err(ValidationError::InvalidDuration(duration));
173    }
174    Ok(())
175}
176
177/// Execute a render using the compositions shipped with the standalone CLI.
178pub async fn execute_render_command(request: &RenderRequest) -> anyhow::Result<()> {
179    let registry = built_in_registry();
180    execute_render_command_with_registry(request, &registry).await
181}
182
183/// Execute a render using an application-provided composition registry.
184pub async fn execute_render_command_with_registry(
185    request: &RenderRequest,
186    registry: &CompositionRegistry,
187) -> anyhow::Result<()> {
188    validate_composition_source(request.composition.as_deref(), request.script.as_ref())?;
189    validate_render_params(
190        request.composition.as_deref().unwrap_or("RhaiScript"),
191        request.props.as_ref(),
192        request.width,
193        request.height,
194        request.fps,
195        request.duration,
196    )?;
197
198    let props = match &request.props {
199        Some(path) => {
200            let json = fs::read_to_string(path)?;
201            serde_json::from_str(&json).map_err(|error| {
202                anyhow::anyhow!("Invalid props JSON in {}: {error}", path.display())
203            })?
204        }
205        None => serde_json::Value::Object(Default::default()),
206    };
207    let context = NativeCompositionContext {
208        width: request.width,
209        height: request.height,
210        fps: request.fps,
211        duration_in_frames: request.duration,
212    };
213
214    #[cfg(feature = "rhai")]
215    let script_composition = request
216        .script
217        .as_deref()
218        .map(RhaiComposition::from_file)
219        .transpose()?;
220
221    #[cfg(feature = "rhai")]
222    let composition: &dyn Composition = match script_composition.as_ref() {
223        Some(composition) => composition,
224        None => registry.get(
225            request
226                .composition
227                .as_deref()
228                .expect("validated native composition ID"),
229        )?,
230    };
231
232    #[cfg(not(feature = "rhai"))]
233    let composition: &dyn Composition = {
234        if request.script.is_some() {
235            anyhow::bail!(
236                "Rhai support is not compiled in. Rebuild with `--features rhai`:\n  \
237                 cargo build -p dioxuscut-cli --features rhai"
238            );
239        }
240        registry.get(
241            request
242                .composition
243                .as_deref()
244                .expect("validated native composition ID"),
245        )?
246    };
247
248    let prepared = composition.prepare(&props, context)?;
249
250    // Validate the first frame before starting FFmpeg. Dynamic compositions
251    // therefore report syntax, type, and API errors without creating an output.
252    prepared.render(0)?;
253
254    tracing::info!(
255        composition = composition.id(),
256        backend = ?request.backend,
257        "Starting browser-free native render"
258    );
259
260    match request.backend {
261        RenderBackend::Native => {
262            use dioxuscut_rasterizer::{
263                render_to_ffmpeg_pipe_fallible, PipeConfig, TinySkiaBackend,
264            };
265
266            let rasterizer = TinySkiaBackend::new();
267            let pipe_config = PipeConfig::new(
268                request.width,
269                request.height,
270                request.fps,
271                request.duration,
272                &request.output,
273            );
274            render_to_ffmpeg_pipe_fallible(&rasterizer, &pipe_config, |frame| {
275                prepared.render(frame)
276            })?;
277        }
278        RenderBackend::Gpu => {
279            #[cfg(not(feature = "gpu"))]
280            anyhow::bail!(
281                "GPU backend is not compiled in. Rebuild with `--features gpu`:\n  \
282                 cargo build -p dioxuscut-cli --features gpu"
283            );
284
285            #[cfg(feature = "gpu")]
286            {
287                use dioxuscut_rasterizer::{
288                    render_to_ffmpeg_pipe_fallible, PipeConfig, WgpuBackend,
289                };
290
291                let rasterizer = WgpuBackend::new()
292                    .map_err(|error| anyhow::anyhow!("GPU backend init failed: {error}"))?;
293                let pipe_config = PipeConfig::new(
294                    request.width,
295                    request.height,
296                    request.fps,
297                    request.duration,
298                    &request.output,
299                );
300                render_to_ffmpeg_pipe_fallible(&rasterizer, &pipe_config, |frame| {
301                    prepared.render(frame)
302                })?;
303            }
304        }
305    }
306
307    tracing::info!(output = %request.output.display(), "Render completed");
308    Ok(())
309}