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("Audio file not found: {0}")]
34    AudioFileNotFound(PathBuf),
35    #[error("Invalid resolution: width ({0}) and height ({1}) must be greater than 0")]
36    InvalidZeroResolution(u32, u32),
37    #[error(
38        "Invalid resolution: width ({0}) and height ({1}) must be even numbers for video encoding"
39    )]
40    InvalidOddResolution(u32, u32),
41    #[error("Invalid FPS: {0} must be a finite number greater than 0")]
42    InvalidFps(String),
43    #[error("Invalid duration: {0} must be greater than 0 frames")]
44    InvalidDuration(u32),
45    #[error("Invalid frame range: start {start}, end {end}, composition duration {duration}")]
46    InvalidFrameRange { start: u32, end: u32, duration: u32 },
47    #[error("Output extension '.{actual}' is invalid for {codec}; expected {expected}")]
48    InvalidOutputExtension {
49        codec: String,
50        actual: String,
51        expected: String,
52    },
53    #[error("Audio tracks are not supported for {0} output")]
54    AudioNotSupported(String),
55    #[error("Timeout must be greater than zero seconds")]
56    InvalidTimeout,
57    #[error("Invalid CRF {value} for {codec}; expected {range}")]
58    InvalidCrf {
59        codec: String,
60        value: u32,
61        range: String,
62    },
63    #[error("Invalid encoder preset '{0}'; expected ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, or placebo")]
64    InvalidPreset(String),
65}
66
67/// Native render backend selection.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
69pub enum RenderBackend {
70    /// Pure-Rust CPU rasterizer via tiny-skia. Default.
71    #[default]
72    Native,
73    /// GPU rasterizer via wgpu. Requires `--features gpu`.
74    Gpu,
75}
76
77/// Output codec or still-image format.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
79pub enum RenderCodec {
80    #[default]
81    H264,
82    H265,
83    Vp9,
84    Av1,
85    #[value(name = "prores", alias = "pro-res")]
86    ProRes,
87    Gif,
88    Png,
89    Jpeg,
90    Webp,
91}
92
93impl RenderCodec {
94    fn still_format(self) -> Option<dioxuscut_rasterizer::StillImageFormat> {
95        match self {
96            Self::Png => Some(dioxuscut_rasterizer::StillImageFormat::Png),
97            Self::Jpeg => Some(dioxuscut_rasterizer::StillImageFormat::Jpeg),
98            Self::Webp => Some(dioxuscut_rasterizer::StillImageFormat::WebP),
99            _ => None,
100        }
101    }
102
103    fn video_codec(self) -> Option<dioxuscut_rasterizer::VideoCodec> {
104        match self {
105            Self::H264 => Some(dioxuscut_rasterizer::VideoCodec::H264),
106            Self::H265 => Some(dioxuscut_rasterizer::VideoCodec::H265),
107            Self::Vp9 => Some(dioxuscut_rasterizer::VideoCodec::Vp9),
108            Self::Av1 => Some(dioxuscut_rasterizer::VideoCodec::Av1),
109            Self::ProRes => Some(dioxuscut_rasterizer::VideoCodec::ProRes),
110            Self::Gif => Some(dioxuscut_rasterizer::VideoCodec::Gif),
111            Self::Png | Self::Jpeg | Self::Webp => None,
112        }
113    }
114
115    fn extensions(self) -> &'static [&'static str] {
116        match self {
117            Self::H264 | Self::H265 => &["mp4"],
118            Self::Vp9 | Self::Av1 => &["webm"],
119            Self::ProRes => &["mov"],
120            Self::Gif => &["gif"],
121            Self::Png => &["png"],
122            Self::Jpeg => &["jpg", "jpeg"],
123            Self::Webp => &["webp"],
124        }
125    }
126}
127
128/// Dioxuscut CLI — render registered Rust or Rhai compositions to video.
129#[derive(Parser, Debug, Clone, PartialEq)]
130#[command(author, version, about, long_about = None)]
131pub struct Cli {
132    #[command(subcommand)]
133    pub command: Commands,
134}
135
136#[derive(Subcommand, Debug, Clone, PartialEq)]
137pub enum Commands {
138    /// Render a registered Rust composition or Rhai script to a media file.
139    Render {
140        /// ID of the composition to render.
141        #[arg(
142            long,
143            short,
144            required_unless_present = "script",
145            conflicts_with = "script"
146        )]
147        composition: Option<String>,
148
149        /// Path to a Rhai composition script. Requires the `rhai` feature.
150        #[arg(
151            long,
152            required_unless_present = "composition",
153            conflicts_with = "composition"
154        )]
155        script: Option<PathBuf>,
156
157        /// Path to a JSON file containing input props.
158        #[arg(long, short)]
159        props: Option<PathBuf>,
160
161        /// Output media file path.
162        #[arg(long, short, default_value = "out.mp4")]
163        output: PathBuf,
164
165        /// Local audio file to mix into the output. May be repeated.
166        #[arg(long = "audio", value_name = "PATH")]
167        audio: Vec<PathBuf>,
168
169        /// Resolution width.
170        #[arg(long, default_value_t = 1920)]
171        width: u32,
172
173        /// Resolution height.
174        #[arg(long, default_value_t = 1080)]
175        height: u32,
176
177        /// Frames per second.
178        #[arg(long, default_value_t = 30.0)]
179        fps: f64,
180
181        /// Duration in frames.
182        #[arg(long, default_value_t = 150)]
183        duration: u32,
184
185        /// Rendering backend.
186        #[arg(long, value_enum, default_value_t = RenderBackend::Native)]
187        backend: RenderBackend,
188
189        /// Video codec or still-image output format.
190        #[arg(long, value_enum, default_value_t = RenderCodec::H264)]
191        codec: RenderCodec,
192
193        /// First composition frame to render.
194        #[arg(long, default_value_t = 0)]
195        frame_start: u32,
196
197        /// Last composition frame to render, inclusive.
198        #[arg(long)]
199        frame_end: Option<u32>,
200
201        /// Abort the render after this many seconds.
202        #[arg(long)]
203        timeout_seconds: Option<u64>,
204
205        /// Codec quality value. Lower is generally higher quality.
206        #[arg(long, default_value_t = 18)]
207        crf: u32,
208
209        /// FFmpeg encoder preset for H.264 and H.265.
210        #[arg(long, default_value = "fast")]
211        preset: String,
212    },
213}
214
215/// A validated render request independent from argument parsing.
216#[derive(Debug, Clone, PartialEq)]
217pub struct RenderRequest {
218    pub composition: Option<String>,
219    pub script: Option<PathBuf>,
220    pub props: Option<PathBuf>,
221    pub output: PathBuf,
222    pub audio: Vec<PathBuf>,
223    pub width: u32,
224    pub height: u32,
225    pub fps: f64,
226    pub duration: u32,
227    pub backend: RenderBackend,
228    pub codec: RenderCodec,
229    pub frame_start: u32,
230    pub frame_end: Option<u32>,
231    pub timeout_seconds: Option<u64>,
232    pub crf: u32,
233    pub preset: String,
234}
235
236/// Validates that a render request selects exactly one available composition source.
237pub fn validate_composition_source(
238    composition: Option<&str>,
239    script: Option<&PathBuf>,
240) -> Result<(), ValidationError> {
241    match (composition, script) {
242        (None, None) => Err(ValidationError::MissingCompositionSource),
243        (Some(_), Some(_)) => Err(ValidationError::ConflictingCompositionSources),
244        (Some(composition), None) if composition.trim().is_empty() => {
245            Err(ValidationError::EmptyComposition)
246        }
247        (Some(_), None) => Ok(()),
248        (None, Some(path)) if !path.is_file() => {
249            Err(ValidationError::ScriptFileNotFound(path.clone()))
250        }
251        (None, Some(_)) => Ok(()),
252    }
253}
254
255/// Validates command-line parameters prior to launching the renderer.
256pub fn validate_render_params(
257    composition: &str,
258    props: Option<&PathBuf>,
259    width: u32,
260    height: u32,
261    fps: f64,
262    duration: u32,
263) -> Result<(), ValidationError> {
264    validate_render_params_for_codec(
265        composition,
266        props,
267        width,
268        height,
269        fps,
270        duration,
271        RenderCodec::H264,
272    )
273}
274
275#[allow(clippy::too_many_arguments)]
276fn validate_render_params_for_codec(
277    composition: &str,
278    props: Option<&PathBuf>,
279    width: u32,
280    height: u32,
281    fps: f64,
282    duration: u32,
283    codec: RenderCodec,
284) -> Result<(), ValidationError> {
285    if composition.trim().is_empty() {
286        return Err(ValidationError::EmptyComposition);
287    }
288    if let Some(path) = props {
289        if !path.is_file() {
290            return Err(ValidationError::PropsFileNotFound(path.clone()));
291        }
292    }
293    if width == 0 || height == 0 {
294        return Err(ValidationError::InvalidZeroResolution(width, height));
295    }
296    let requires_even_dimensions = matches!(
297        codec,
298        RenderCodec::H264
299            | RenderCodec::H265
300            | RenderCodec::Vp9
301            | RenderCodec::Av1
302            | RenderCodec::ProRes
303    );
304    if requires_even_dimensions && (!width.is_multiple_of(2) || !height.is_multiple_of(2)) {
305        return Err(ValidationError::InvalidOddResolution(width, height));
306    }
307    if !fps.is_finite() || fps <= 0.0 {
308        return Err(ValidationError::InvalidFps(fps.to_string()));
309    }
310    if duration == 0 {
311        return Err(ValidationError::InvalidDuration(duration));
312    }
313    Ok(())
314}
315
316fn validate_render_options(request: &RenderRequest) -> Result<(u32, u32), ValidationError> {
317    let end = request.frame_end.unwrap_or_else(|| {
318        if request.codec.still_format().is_some() {
319            request.frame_start
320        } else {
321            request.duration.saturating_sub(1)
322        }
323    });
324    if request.frame_start > end || end >= request.duration {
325        return Err(ValidationError::InvalidFrameRange {
326            start: request.frame_start,
327            end,
328            duration: request.duration,
329        });
330    }
331    if request.timeout_seconds == Some(0) {
332        return Err(ValidationError::InvalidTimeout);
333    }
334    if request.codec.still_format().is_some() && end != request.frame_start {
335        return Err(ValidationError::InvalidFrameRange {
336            start: request.frame_start,
337            end,
338            duration: request.duration,
339        });
340    }
341    let max_crf = match request.codec {
342        RenderCodec::H264 | RenderCodec::H265 => Some(51),
343        RenderCodec::Vp9 | RenderCodec::Av1 => Some(63),
344        RenderCodec::ProRes
345        | RenderCodec::Gif
346        | RenderCodec::Png
347        | RenderCodec::Jpeg
348        | RenderCodec::Webp => None,
349    };
350    if max_crf.is_some_and(|max| request.crf > max) {
351        return Err(ValidationError::InvalidCrf {
352            codec: format!("{:?}", request.codec),
353            value: request.crf,
354            range: format!("0..={}", max_crf.expect("checked above")),
355        });
356    }
357    if matches!(request.codec, RenderCodec::H264 | RenderCodec::H265)
358        && !matches!(
359            request.preset.as_str(),
360            "ultrafast"
361                | "superfast"
362                | "veryfast"
363                | "faster"
364                | "fast"
365                | "medium"
366                | "slow"
367                | "slower"
368                | "veryslow"
369                | "placebo"
370        )
371    {
372        return Err(ValidationError::InvalidPreset(request.preset.clone()));
373    }
374    let actual = request
375        .output
376        .extension()
377        .and_then(|extension| extension.to_str())
378        .unwrap_or("")
379        .to_ascii_lowercase();
380    let expected = request.codec.extensions();
381    if !expected.contains(&actual.as_str()) {
382        return Err(ValidationError::InvalidOutputExtension {
383            codec: format!("{:?}", request.codec),
384            actual,
385            expected: expected.join(" or ."),
386        });
387    }
388    if !request.audio.is_empty()
389        && (request.codec.still_format().is_some() || request.codec == RenderCodec::Gif)
390    {
391        return Err(ValidationError::AudioNotSupported(format!(
392            "{:?}",
393            request.codec
394        )));
395    }
396    Ok((request.frame_start, end))
397}
398
399/// Execute a render using the compositions shipped with the standalone CLI.
400pub async fn execute_render_command(request: &RenderRequest) -> anyhow::Result<()> {
401    let registry = built_in_registry();
402    execute_render_command_with_registry_and_control(
403        request,
404        &registry,
405        default_render_control(request),
406    )
407    .await
408}
409
410/// Execute a render using an application-provided composition registry.
411pub async fn execute_render_command_with_registry(
412    request: &RenderRequest,
413    registry: &CompositionRegistry,
414) -> anyhow::Result<()> {
415    execute_render_command_with_registry_and_control(
416        request,
417        registry,
418        default_render_control(request),
419    )
420    .await
421}
422
423/// Build the standard CLI progress and timeout controls for a render request.
424pub fn default_render_control(request: &RenderRequest) -> dioxuscut_rasterizer::RenderControl {
425    let mut control = dioxuscut_rasterizer::RenderControl::new().with_progress(|progress| {
426        let total = u64::from(progress.total_frames.max(1));
427        let completed = u64::from(progress.completed_frames);
428        let percent = completed.saturating_mul(100) / total;
429        let previous_percent = completed.saturating_sub(1).saturating_mul(100) / total;
430        if completed == 1 || completed == total || percent != previous_percent {
431            tracing::info!(
432                completed = progress.completed_frames,
433                total = progress.total_frames,
434                frame = progress.frame,
435                percent,
436                "Render progress"
437            );
438        }
439    });
440    if let Some(seconds) = request.timeout_seconds {
441        control = control.with_timeout(std::time::Duration::from_secs(seconds));
442    }
443    control
444}
445
446/// Execute a render with caller-owned progress, cancellation, and timeout controls.
447pub async fn execute_render_command_with_control(
448    request: &RenderRequest,
449    control: dioxuscut_rasterizer::RenderControl,
450) -> anyhow::Result<()> {
451    let registry = built_in_registry();
452    execute_render_command_with_registry_and_control(request, &registry, control).await
453}
454
455/// Execute an application-provided composition registry with caller-owned controls.
456pub async fn execute_render_command_with_registry_and_control(
457    request: &RenderRequest,
458    registry: &CompositionRegistry,
459    control: dioxuscut_rasterizer::RenderControl,
460) -> anyhow::Result<()> {
461    validate_composition_source(request.composition.as_deref(), request.script.as_ref())?;
462    validate_render_params_for_codec(
463        request.composition.as_deref().unwrap_or("RhaiScript"),
464        request.props.as_ref(),
465        request.width,
466        request.height,
467        request.fps,
468        request.duration,
469        request.codec,
470    )?;
471    let (frame_start, frame_end) = validate_render_options(request)?;
472    let frame_count = frame_end - frame_start + 1;
473    for path in &request.audio {
474        if !path.is_file() {
475            return Err(ValidationError::AudioFileNotFound(path.clone()).into());
476        }
477    }
478
479    let props = match &request.props {
480        Some(path) => {
481            let json = fs::read_to_string(path)?;
482            serde_json::from_str(&json).map_err(|error| {
483                anyhow::anyhow!("Invalid props JSON in {}: {error}", path.display())
484            })?
485        }
486        None => serde_json::Value::Object(Default::default()),
487    };
488    let context = NativeCompositionContext {
489        width: request.width,
490        height: request.height,
491        fps: request.fps,
492        duration_in_frames: request.duration,
493    };
494
495    #[cfg(feature = "rhai")]
496    let script_composition = request
497        .script
498        .as_deref()
499        .map(RhaiComposition::from_file)
500        .transpose()?;
501
502    #[cfg(feature = "rhai")]
503    let composition: &dyn Composition = match script_composition.as_ref() {
504        Some(composition) => composition,
505        None => registry.get(
506            request
507                .composition
508                .as_deref()
509                .expect("validated native composition ID"),
510        )?,
511    };
512
513    #[cfg(not(feature = "rhai"))]
514    let composition: &dyn Composition = {
515        if request.script.is_some() {
516            anyhow::bail!(
517                "Rhai support is not compiled in. Rebuild with `--features rhai`:\n  \
518                 cargo build -p dioxuscut-cli --features rhai"
519            );
520        }
521        registry.get(
522            request
523                .composition
524                .as_deref()
525                .expect("validated native composition ID"),
526        )?
527    };
528
529    let prepared = composition.prepare(&props, context)?;
530
531    // Validate the first frame before starting FFmpeg. Dynamic compositions
532    // therefore report syntax, type, and API errors without creating an output.
533    let first_scene = prepared.render(0)?;
534    let mut audio_tracks = first_scene.audio_tracks();
535    audio_tracks.extend(
536        request
537            .audio
538            .iter()
539            .map(|path| dioxuscut_rasterizer::AudioTrack::new(path.to_string_lossy().into_owned())),
540    );
541
542    tracing::info!(
543        composition = composition.id(),
544        backend = ?request.backend,
545        codec = ?request.codec,
546        frame_start,
547        frame_end,
548        "Starting browser-free native render"
549    );
550
551    match request.backend {
552        RenderBackend::Native => {
553            use dioxuscut_rasterizer::{
554                render_still_fallible, render_to_ffmpeg_pipe_fallible, PipeConfig, TinySkiaBackend,
555            };
556
557            let rasterizer = TinySkiaBackend::new();
558            if let Some(format) = request.codec.still_format() {
559                render_still_fallible(
560                    &rasterizer,
561                    request.width,
562                    request.height,
563                    request.fps,
564                    frame_start,
565                    &request.output,
566                    format,
567                    &control,
568                    |frame| prepared.render(frame),
569                )?;
570            } else {
571                let pipe_config = PipeConfig::new(
572                    request.width,
573                    request.height,
574                    request.fps,
575                    frame_count,
576                    &request.output,
577                )
578                .with_frame_start(frame_start)
579                .with_codec(request.codec.video_codec().expect("video codec validated"))
580                .with_quality(request.crf, &request.preset)
581                .with_audio_tracks(audio_tracks.clone())
582                .with_control(control.clone());
583                render_to_ffmpeg_pipe_fallible(&rasterizer, &pipe_config, |frame| {
584                    prepared.render(frame)
585                })?;
586            }
587        }
588        RenderBackend::Gpu => {
589            #[cfg(not(feature = "gpu"))]
590            anyhow::bail!(
591                "GPU backend is not compiled in. Rebuild with `--features gpu`:\n  \
592                 cargo build -p dioxuscut-cli --features gpu"
593            );
594
595            #[cfg(feature = "gpu")]
596            {
597                use dioxuscut_rasterizer::{
598                    render_still_fallible, render_to_ffmpeg_pipe_fallible, PipeConfig, WgpuBackend,
599                };
600
601                let rasterizer = WgpuBackend::new()
602                    .map_err(|error| anyhow::anyhow!("GPU backend init failed: {error}"))?;
603                if let Some(format) = request.codec.still_format() {
604                    render_still_fallible(
605                        &rasterizer,
606                        request.width,
607                        request.height,
608                        request.fps,
609                        frame_start,
610                        &request.output,
611                        format,
612                        &control,
613                        |frame| prepared.render(frame),
614                    )?;
615                } else {
616                    let pipe_config = PipeConfig::new(
617                        request.width,
618                        request.height,
619                        request.fps,
620                        frame_count,
621                        &request.output,
622                    )
623                    .with_frame_start(frame_start)
624                    .with_codec(request.codec.video_codec().expect("video codec validated"))
625                    .with_quality(request.crf, &request.preset)
626                    .with_audio_tracks(audio_tracks.clone())
627                    .with_control(control.clone());
628                    render_to_ffmpeg_pipe_fallible(&rasterizer, &pipe_config, |frame| {
629                        prepared.render(frame)
630                    })?;
631                }
632            }
633        }
634    }
635
636    tracing::info!(output = %request.output.display(), "Render completed");
637    Ok(())
638}