Skip to main content

ranim/cmd/
cli.rs

1//! The command line interface served by [`run_cli`].
2
3use anyhow::{Context, Result, bail};
4use clap::{Parser, Subcommand};
5use tracing::{error, info};
6
7use crate::{App, AppExt, Output, RanimApp, Scene, app::render_setup_of};
8
9use super::inspect::{self, InspectCommand};
10
11/// Initialize tracing logging for a project binary.
12///
13/// Called by [`run_cli`]. `RUST_LOG` overrides the default
14/// `ranim=info` directive.
15pub fn init_tracing() {
16    use tracing::level_filters::LevelFilter;
17    use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
18
19    fn build_filter() -> EnvFilter {
20        const DEFAULT_DIRECTIVES: &[(&str, LevelFilter)] = &[("ranim", LevelFilter::INFO)];
21        let mut filter = EnvFilter::from_default_env();
22        let env = std::env::var("RUST_LOG").unwrap_or_default();
23        for (name, level) in DEFAULT_DIRECTIVES
24            .iter()
25            .filter(|(name, _)| !env.contains(name))
26        {
27            filter = filter.add_directive(format!("{name}={level}").parse().unwrap());
28        }
29        filter
30    }
31
32    let indicatif_layer = tracing_indicatif::IndicatifLayer::new();
33
34    tracing_subscriber::registry()
35        .with(fmt::layer().with_writer(indicatif_layer.get_stderr_writer()))
36        .with(indicatif_layer)
37        .with(build_filter())
38        .init();
39}
40
41/// Select one scene of the project.
42///
43/// If `name` is omitted and the project contains exactly one scene, that scene
44/// is returned. If the project contains multiple scenes, the caller must
45/// choose one and a list of available scenes is included in the error.
46pub fn select_scene(app: &App, name: Option<&str>) -> Result<Scene> {
47    let scenes = app.scenes();
48    if scenes.is_empty() {
49        bail!("no scenes found in the project");
50    }
51
52    let available = scenes
53        .iter()
54        .map(|scene| scene.name.as_str())
55        .collect::<Vec<_>>()
56        .join(", ");
57
58    match name {
59        Some(name) => app.scene(name).with_context(|| {
60            format!("failed to find scene `{name}`. Available scenes: {available}")
61        }),
62        None if scenes.len() == 1 => Ok(app.first_scene().unwrap()),
63        None => {
64            bail!("multiple scenes found, choose one explicitly. Available scenes: {available}")
65        }
66    }
67}
68
69/// Render every [`Scene`] output declared with `#[output(...)]`.
70pub fn output_command(app: &App, scenes: &[String], buffer_count: usize) -> Result<()> {
71    use super::render::render_scene;
72
73    let setup = render_setup_of(app);
74    let all_scenes = app.scenes();
75    let scenes_to_render: Vec<Scene> = all_scenes
76        .iter()
77        .filter(|scene| scenes.is_empty() || scenes.iter().any(|name| name == &scene.name))
78        .cloned()
79        .collect();
80
81    if scenes_to_render.is_empty() {
82        if scenes.is_empty() {
83            info!("No scenes found to render");
84        } else {
85            error!("No matching scenes found for: {scenes:?}");
86            error!(
87                "Available scenes: {:?}",
88                all_scenes.iter().map(|s| &s.name).collect::<Vec<_>>()
89            );
90        }
91        bail!("No scenes to render");
92    }
93
94    for scene in scenes_to_render {
95        info!("Rendering scene: {}", scene.name);
96        render_scene(&scene, &setup, buffer_count);
97    }
98    Ok(())
99}
100
101/// Render a single scene once with default output settings
102/// (`1920x1080`, 60 fps, mp4). Unlike [`output_command`], this command does
103/// not read any `#[output(...)]` declaration and does not process
104/// `TimeMark::Capture`.
105pub fn render_command(app: &App, scene_name: &str, buffer_count: usize) -> Result<()> {
106    use super::render::render_scene_once;
107
108    let scene = select_scene(app, Some(scene_name))?;
109
110    let output = Output::default();
111    info!(
112        "Rendering scene {:?} once with default output: {}x{} {}fps {}",
113        scene.name, output.width, output.height, output.fps, output.format
114    );
115    render_scene_once(&scene, &render_setup_of(app), &output, buffer_count);
116    Ok(())
117}
118
119/// Preview a scene in the interactive preview app.
120#[cfg(feature = "cmd-preview")]
121pub fn preview_command(app: RanimApp, scene_name: Option<&str>) -> Result<()> {
122    let scene = match scene_name {
123        Some(scene) => app.scene(scene),
124        None => app.first_scene(),
125    }
126    .with_context(|| {
127        format!(
128            "failed to find preview scene. Available scenes: {}",
129            app.scenes()
130                .iter()
131                .map(|scene| scene.name.as_str())
132                .collect::<Vec<_>>()
133                .join(", ")
134        )
135    })?;
136    super::preview::preview_scene(app, scene);
137    Ok(())
138}
139
140#[derive(Subcommand, Debug)]
141enum Commands {
142    /// Launch the interactive preview app (defaults to the first scene)
143    #[cfg(feature = "cmd-preview")]
144    Preview { scene: Option<String> },
145    /// Render every `#[output(...)]` declared by the selected scenes
146    Output {
147        /// Optional scene names to render (if not provided, render all scenes)
148        #[arg(num_args = 0..)]
149        scenes: Vec<String>,
150
151        /// Number of GPU readback buffers (higher = more parallelism, more VRAM)
152        #[arg(long, default_value_t = 2)]
153        buffer_count: usize,
154    },
155    /// Render a single scene once with default output settings (`1920x1080`, 60 fps, mp4)
156    Render {
157        /// Scene name to render
158        scene: String,
159
160        /// Number of GPU readback buffers (higher = more parallelism, more VRAM)
161        #[arg(long, default_value_t = 2)]
162        buffer_count: usize,
163    },
164    /// Inspect scenes, animation trees, or evaluated frames without rendering
165    Inspect {
166        #[command(subcommand)]
167        command: InspectCommand,
168    },
169}
170
171#[derive(Parser, Debug)]
172#[command(about = "A Ranim animation project")]
173struct ProjectCli {
174    #[command(subcommand)]
175    command: Commands,
176}
177
178impl ProjectCli {
179    fn run(self, app: RanimApp) -> Result<()> {
180        match self.command {
181            #[cfg(feature = "cmd-preview")]
182            Commands::Preview { scene } => preview_command(app, scene.as_deref()),
183            Commands::Output {
184                scenes,
185                buffer_count,
186            } => output_command(&app, &scenes, buffer_count),
187            Commands::Render {
188                scene,
189                buffer_count,
190            } => render_command(&app, &scene, buffer_count),
191            Commands::Inspect { command } => inspect::inspect_command(&app, command),
192        }
193    }
194}
195
196/// Parse the process arguments and serve the `preview`, `output`, `render`,
197/// and `inspect` subcommands of this project binary:
198///
199/// ```ignore
200/// use ranim::prelude::*;
201///
202/// fn main() {
203///     let mut app = App::new();
204///     app.add_plugins(scenes::ScenesPlugin);
205///     run_cli(app.build());
206/// }
207/// ```
208///
209/// On a command failure the error is logged and the process exits with a
210/// non-zero status.
211pub fn run_cli(app: RanimApp) {
212    init_tracing();
213    if let Err(err) = ProjectCli::parse().run(app) {
214        error!("{err:#}");
215        std::process::exit(1);
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::SceneConstructor;
223
224    fn scene(name: &str) -> Scene {
225        Scene::new(name, |_: &mut crate::RanimScene| {})
226    }
227
228    #[test]
229    fn select_scene_resolves_omitted_and_named_scenes() {
230        let mut app = App::new();
231        app.add_scene(scene("only"));
232        assert_eq!(select_scene(&app, None).unwrap().name, "only");
233
234        let mut app = App::new();
235        app.add_scenes([scene("a"), scene("b")]);
236        let err = match select_scene(&app, None) {
237            Ok(_) => panic!("expected an error for multiple scenes"),
238            Err(err) => err,
239        };
240        assert!(err.to_string().contains("Available scenes: a, b"));
241
242        let selected = select_scene(&app, Some("b")).unwrap();
243        assert_eq!(selected.name, "b");
244        assert_eq!(selected.build_scene().total_secs(), 0.0);
245    }
246
247    #[test]
248    fn select_scene_reports_empty_and_missing_scenes() {
249        let app = App::new();
250        let Err(err) = select_scene(&app, None) else {
251            panic!("expected an error for an empty project");
252        };
253        assert!(err.to_string().contains("no scenes found"));
254
255        let mut app = App::new();
256        app.add_scene(scene("available"));
257        let Err(err) = select_scene(&app, Some("missing")) else {
258            panic!("expected an error for an unknown scene");
259        };
260        assert!(err.to_string().contains("failed to find scene `missing`"));
261        assert!(err.to_string().contains("Available scenes: available"));
262    }
263
264    #[test]
265    fn cli_parses_all_subcommands() {
266        let parse = |args: &[&str]| ProjectCli::try_parse_from(args);
267
268        let cli = parse(&["demo", "output"]).unwrap();
269        assert!(matches!(
270            cli.command,
271            Commands::Output {
272                scenes,
273                buffer_count: 2
274            } if scenes.is_empty()
275        ));
276
277        let cli = parse(&["demo", "render", "intro", "--buffer-count", "4"]).unwrap();
278        assert!(matches!(
279            cli.command,
280            Commands::Render {
281                scene,
282                buffer_count: 4
283            } if scene == "intro"
284        ));
285
286        #[cfg(feature = "cmd-preview")]
287        {
288            let cli = parse(&["demo", "preview"]).unwrap();
289            assert!(matches!(cli.command, Commands::Preview { scene: None }));
290        }
291        #[cfg(not(feature = "cmd-preview"))]
292        assert!(parse(&["demo", "preview"]).is_err());
293
294        let cli = parse(&["demo", "inspect", "scenes", "--format", "json"]).unwrap();
295        assert!(matches!(
296            cli.command,
297            Commands::Inspect {
298                command: InspectCommand::Scenes {
299                    format: inspect::FormatArg::Json
300                }
301            }
302        ));
303    }
304}