1pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
45pub enum RenderBackend {
46 #[default]
48 Native,
49 Gpu,
51}
52
53#[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 {
65 #[arg(
67 long,
68 short,
69 required_unless_present = "script",
70 conflicts_with = "script"
71 )]
72 composition: Option<String>,
73
74 #[arg(
76 long,
77 required_unless_present = "composition",
78 conflicts_with = "composition"
79 )]
80 script: Option<PathBuf>,
81
82 #[arg(long, short)]
84 props: Option<PathBuf>,
85
86 #[arg(long, short, default_value = "out.mp4")]
88 output: PathBuf,
89
90 #[arg(long, default_value_t = 1920)]
92 width: u32,
93
94 #[arg(long, default_value_t = 1080)]
96 height: u32,
97
98 #[arg(long, default_value_t = 30.0)]
100 fps: f64,
101
102 #[arg(long, default_value_t = 150)]
104 duration: u32,
105
106 #[arg(long, value_enum, default_value_t = RenderBackend::Native)]
108 backend: RenderBackend,
109 },
110}
111
112#[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
126pub 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
145pub 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
177pub async fn execute_render_command(request: &RenderRequest) -> anyhow::Result<()> {
179 let registry = built_in_registry();
180 execute_render_command_with_registry(request, ®istry).await
181}
182
183pub 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 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}