1use 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
11pub 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
41pub 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
69pub 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
101pub 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#[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 #[cfg(feature = "cmd-preview")]
144 Preview { scene: Option<String> },
145 Output {
147 #[arg(num_args = 0..)]
149 scenes: Vec<String>,
150
151 #[arg(long, default_value_t = 2)]
153 buffer_count: usize,
154 },
155 Render {
157 scene: String,
159
160 #[arg(long, default_value_t = 2)]
162 buffer_count: usize,
163 },
164 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
196pub 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}