aether_renderer_core/
lib.rs1pub mod config;
2pub mod ffmpeg;
3pub mod input;
4pub mod utils;
5
6pub use config::RenderConfig;
7
8use indicatif::{ProgressBar, ProgressStyle};
9use std::process::Command;
10use std::time::Duration;
11
12pub fn render_from_config(config_path: &str) -> Result<String, String> {
14 let args = RenderConfig::from_file(config_path)?;
15 render(args)
16}
17
18pub fn render(args: RenderConfig) -> Result<String, String> {
20 match {
22 let mut cmd = Command::new("ffmpeg");
23 cmd.arg("-version");
24
25 if !args.verbose_ffmpeg {
26 cmd.stdout(std::process::Stdio::null());
27 cmd.stderr(std::process::Stdio::null());
28 }
29
30 cmd.status()
31 } {
32 Ok(s) if s.success() => {}
33 Ok(_) => {
34 return Err("❌ ffmpeg failed to run correctly.".into());
35 }
36 Err(e) => {
37 if e.kind() == std::io::ErrorKind::NotFound {
38 return Err(
39 "❌ ffmpeg not found. Please install ffmpeg and ensure it is in your PATH."
40 .into(),
41 );
42 } else {
43 return Err(format!("❌ Failed to execute ffmpeg: {}", e));
44 }
45 }
46 }
47
48 if !args.input.exists() {
49 return Err(format!(
50 "❌ Input path '{}' does not exist.",
51 args.input.display()
52 ));
53 }
54
55 let input_path = &args.input;
56 let (working_input_path, _temp_guard) = if input_path
57 .extension()
58 .map(|ext| ext == "zip")
59 .unwrap_or(false)
60 {
61 let (path, guard) =
62 utils::unzip_frames(input_path, args.verbose).map_err(|e| e.to_string())?;
63 (path, Some(guard))
64 } else {
65 (input_path.clone(), None)
66 };
67
68 let pattern = args
69 .file_pattern
70 .clone()
71 .unwrap_or_else(|| "*.png".to_string());
72 let frames = input::collect_input_frames(&working_input_path, Some(pattern.clone()))
73 .map_err(|e| format!("❌ Failed to read frames: {}", e))?;
74 let frame_count = frames.len() as u32;
75
76 let input_pattern = working_input_path.join(&pattern);
78 let input_str = input_pattern.to_str().unwrap();
79
80 if frame_count == 0 {
81 return Err(format!(
82 "❌ No input files found in '{}' matching pattern '{}'.",
83 working_input_path.display(),
84 pattern
85 ));
86 }
87
88 let duration = frame_count as f32 / args.fps as f32;
89
90 let mut fade_filter = String::new();
91 if args.fade_in > 0.0 {
92 fade_filter.push_str(&format!("fade=t=in:st=0:d={}", args.fade_in));
93 }
94 if args.fade_out > 0.0 {
95 if !fade_filter.is_empty() {
96 fade_filter.push(',');
97 }
98 let start = (duration - args.fade_out).max(0.0);
99 fade_filter.push_str(&format!("fade=t=out:st={}:d={}", start, args.fade_out));
100 }
101
102 if args.verbose {
103 println!(
104 "🌿 Rendering {} → {} at {} FPS...",
105 input_str, args.output, args.fps
106 );
107 }
108
109 let maybe_spinner = if args.verbose {
110 let pb = ProgressBar::new_spinner();
111 pb.set_style(
112 ProgressStyle::with_template(
113 "{spinner:.green} 🌿 Rendering with FFmpeg... {elapsed_precise}",
114 )
115 .unwrap()
116 .tick_chars("⠁⠃⠇⠧⠷⠿⠻⠟⠯⠷⠾⠽⠻⠛⠋"),
117 );
118 pb.enable_steady_tick(Duration::from_millis(120));
119 Some(pb)
120 } else {
121 None
122 };
123
124 if args.format == "gif" {
125 ffmpeg::gif::render_gif(
126 input_str,
127 &args.output,
128 args.fps,
129 Some(&fade_filter),
130 args.verbose_ffmpeg,
131 )?;
132 } else {
133 ffmpeg::video::render_video(
134 input_str,
135 &args.output,
136 args.fps,
137 &args.format,
138 args.bitrate.as_deref(),
139 args.crf,
140 Some(&fade_filter),
141 args.verbose_ffmpeg,
142 )?;
143 }
144
145 if let Some(pb) = &maybe_spinner {
146 pb.finish_with_message("✅ FFmpeg rendering complete!");
147 }
148
149 if args.preview {
150 if let Err(e) = utils::open_output(&args.output) {
151 eprintln!("⚠️ Failed to open video preview: {}", e);
152 }
153 }
154 Ok(args.output.clone())
155}