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("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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
69pub enum RenderBackend {
70 #[default]
72 Native,
73 Gpu,
75}
76
77#[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#[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 {
140 #[arg(
142 long,
143 short,
144 required_unless_present = "script",
145 conflicts_with = "script"
146 )]
147 composition: Option<String>,
148
149 #[arg(
151 long,
152 required_unless_present = "composition",
153 conflicts_with = "composition"
154 )]
155 script: Option<PathBuf>,
156
157 #[arg(long, short)]
159 props: Option<PathBuf>,
160
161 #[arg(long, short, default_value = "out.mp4")]
163 output: PathBuf,
164
165 #[arg(long = "audio", value_name = "PATH")]
167 audio: Vec<PathBuf>,
168
169 #[arg(long, default_value_t = 1920)]
171 width: u32,
172
173 #[arg(long, default_value_t = 1080)]
175 height: u32,
176
177 #[arg(long, default_value_t = 30.0)]
179 fps: f64,
180
181 #[arg(long, default_value_t = 150)]
183 duration: u32,
184
185 #[arg(long, value_enum, default_value_t = RenderBackend::Native)]
187 backend: RenderBackend,
188
189 #[arg(long, value_enum, default_value_t = RenderCodec::H264)]
191 codec: RenderCodec,
192
193 #[arg(long, default_value_t = 0)]
195 frame_start: u32,
196
197 #[arg(long)]
199 frame_end: Option<u32>,
200
201 #[arg(long)]
203 timeout_seconds: Option<u64>,
204
205 #[arg(long, default_value_t = 18)]
207 crf: u32,
208
209 #[arg(long, default_value = "fast")]
211 preset: String,
212 },
213}
214
215#[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
236pub 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
255pub 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
399pub 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 ®istry,
405 default_render_control(request),
406 )
407 .await
408}
409
410pub 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
423pub 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
446pub 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, ®istry, control).await
453}
454
455pub 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 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}