1use crate::engine::scene::BaseScene;
2use crate::engine::scene::Scene2D;
3use crate::render::AnimationWindow;
4#[cfg(feature = "export")]
5use image::GenericImageView;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8#[cfg(feature = "export")]
9use std::fs;
10#[cfg(feature = "export")]
11use std::io::{self, Write};
12use std::path::PathBuf;
13use vello::peniko::Color;
14
15const DEFAULT_FPS: u32 = 60;
16const DEFAULT_WIDTH: u32 = 800;
17const DEFAULT_HEIGHT: u32 = 600;
18const DEFAULT_TITLE: &str = "motion-canvas-rs";
19const DEFAULT_OUTPUT_PATH: &str = "output";
20const DEFAULT_BACKGROUND_COLOR: Color = Color::rgb8(0x1a, 0x1a, 0x1a);
21const DEFAULT_USE_CACHE: bool = true;
22const DEFAULT_USE_GPU: bool = true;
23const DEFAULT_USE_FFMPEG: bool = false;
24
25#[derive(Serialize, Deserialize, Default)]
26pub struct CacheManifest {
27 pub width: u32,
28 pub height: u32,
29 pub frames: HashMap<u32, u64>, }
31
32pub struct Project {
33 pub width: u32,
34 pub height: u32,
35 pub fps: u32,
36 pub title: String,
37 pub scene: BaseScene,
38 pub output_path: PathBuf,
39 pub use_cache: bool,
40 pub use_ffmpeg: bool,
41 pub use_gpu: bool,
42 pub background_color: Color,
43 pub close_on_finish: bool,
44 pub current_time: std::time::Duration,
45 pub paused: bool,
46 pub speed: f32,
47}
48
49impl Project {
50 pub fn new(width: u32, height: u32) -> Self {
51 Self {
52 width,
53 height,
54 fps: DEFAULT_FPS,
55 title: DEFAULT_TITLE.to_string(),
56 scene: BaseScene::new(),
57 output_path: PathBuf::from(DEFAULT_OUTPUT_PATH),
58 use_cache: DEFAULT_USE_CACHE,
59 use_ffmpeg: DEFAULT_USE_FFMPEG,
60 use_gpu: DEFAULT_USE_GPU,
61 background_color: DEFAULT_BACKGROUND_COLOR,
62 close_on_finish: false,
63 current_time: std::time::Duration::ZERO,
64 paused: false,
65 speed: 1.0,
66 }
67 }
68}
69
70impl Default for Project {
71 fn default() -> Self {
72 Self::new(DEFAULT_WIDTH, DEFAULT_HEIGHT)
73 }
74}
75
76impl Project {
77 pub fn with_fps(mut self, fps: u32) -> Self {
78 self.fps = fps;
79 self
80 }
81
82 pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
83 self.width = width;
84 self.height = height;
85 self
86 }
87
88 pub fn with_title(mut self, title: &str) -> Self {
89 self.title = title.to_string();
90 self
91 }
92
93 pub fn with_output_path(mut self, path: &str) -> Self {
94 self.output_path = PathBuf::from(path);
95 self
96 }
97
98 pub fn with_cache(mut self, use_cache: bool) -> Self {
99 self.use_cache = use_cache;
100 self
101 }
102
103 pub fn with_ffmpeg(mut self, use_ffmpeg: bool) -> Self {
104 self.use_ffmpeg = use_ffmpeg;
105 self
106 }
107
108 pub fn with_gpu(mut self, use_gpu: bool) -> Self {
109 self.use_gpu = use_gpu;
110 self
111 }
112
113 pub fn with_background(mut self, color: Color) -> Self {
114 self.background_color = color;
115 self
116 }
117
118 pub fn with_close_on_finish(mut self, close: bool) -> Self {
119 self.close_on_finish = close;
120 self
121 }
122
123 pub fn close_on_finish(self) -> Self {
124 self.with_close_on_finish(true)
125 }
126
127 pub fn export(&mut self) -> crate::Result<()> {
128 #[cfg(not(feature = "export"))]
129 return Err("Export failed: 'export' feature is disabled.".into());
130
131 #[cfg(feature = "export")]
132 {
133 println!("Exporting project: {}", self.title);
134 fs::create_dir_all(&self.output_path)?;
135
136 let cache_file = self.output_path.join(".motion_canvas_cache");
137 let mut manifest: CacheManifest = (self.use_cache && cache_file.exists())
138 .then(|| fs::read_to_string(&cache_file).ok())
139 .flatten()
140 .and_then(|c| serde_json::from_str(&c).ok())
141 .filter(|m: &CacheManifest| m.width == self.width && m.height == self.height)
142 .unwrap_or(CacheManifest {
143 width: self.width,
144 height: self.height,
145 frames: HashMap::new(),
146 });
147
148 #[cfg(feature = "audio")]
149 crate::engine::nodes::audio::set_audio_playback(false);
150
151 #[cfg(feature = "audio")]
152 crate::engine::nodes::audio::set_audio_playback(false);
153
154 let mut exporter = crate::render::export::Exporter::new(
155 self.width,
156 self.height,
157 self.use_gpu,
158 self.background_color,
159 );
160 let dt = std::time::Duration::from_secs_f32(1.0 / self.fps as f32);
161 let mut frame_count = 0;
162 let mut rendered_count = 0;
163 let mut skipped_count = 0;
164
165 #[cfg(feature = "audio")]
166 let mut audio_events = Vec::new();
167 let video_duration = self.scene.video_timeline.duration();
168 let audio_duration = {
169 #[cfg(feature = "audio")]
170 {
171 self.scene.audio_timeline.duration()
172 }
173 #[cfg(not(feature = "audio"))]
174 {
175 std::time::Duration::ZERO
176 }
177 };
178 let total_duration = video_duration.max(audio_duration);
179 let total_frames = (total_duration.as_secs_f32() * self.fps as f32).ceil() as u32;
180
181 let (tx, rx) = std::sync::mpsc::channel::<(Vec<u8>, PathBuf)>();
183 let width = self.width;
184 let height = self.height;
185 use std::sync::atomic::{AtomicU32, Ordering};
186 let saved_count = std::sync::Arc::new(AtomicU32::new(0));
187 let saved_count_clone = saved_count.clone();
188
189 let mut ffmpeg_process = self
191 .use_ffmpeg
192 .then(|| {
193 crate::engine::util::export::start_ffmpeg(
194 &self.title,
195 width,
196 height,
197 self.fps,
198 cfg!(feature = "audio"),
199 )
200 .map_err(|e| {
201 eprintln!("Failed to start FFmpeg: {}. Falling back to PNGs.", e);
202 e
203 })
204 .ok()
205 .flatten()
206 })
207 .flatten();
208
209 let saving_thread = std::thread::spawn(move || {
210 while let Ok((pixels, path)) = rx.recv() {
211 let buffer: image::ImageBuffer<image::Rgba<u8>, _> =
212 image::ImageBuffer::from_raw(width, height, pixels).unwrap();
213 buffer.save(path).unwrap();
214 saved_count_clone.fetch_add(1, Ordering::SeqCst);
215 }
216 });
217
218 loop {
220 let hash = self.scene.state_hash();
221 let frame_name = self.get_frame_name(frame_count);
222 let frame_path = self.output_path.join(frame_name);
223
224 let is_cached = self.use_cache
226 && manifest.frames.get(&frame_count) == Some(&hash)
227 && frame_path.exists();
228
229 if is_cached {
230 skipped_count += 1;
231 saved_count.fetch_add(1, Ordering::SeqCst);
232 if let Some(ref mut stdin) = ffmpeg_process {
234 match image::open(&frame_path) {
235 Ok(img) => {
236 let (w, h) = img.dimensions();
237 if w == self.width && h == self.height {
238 let pixels = img.to_rgba8().into_raw();
239 stdin.write_all(&pixels)?;
240 } else {
241 eprintln!("\nWarning: Cached frame resolution mismatch at {:?} (expected {}x{}, found {}x{}). Re-rendering...", frame_path, self.width, self.height, w, h);
242 let pixels = exporter.export_frame(&self.scene);
243 stdin.write_all(&pixels)?;
244 tx.send((pixels, frame_path)).unwrap();
245 rendered_count += 1;
246 }
247 }
248 Err(e) => {
249 eprintln!(
250 "\nCache corruption detected at {:?}: {}. Re-rendering...",
251 frame_path, e
252 );
253 let pixels = exporter.export_frame(&self.scene);
254 stdin.write_all(&pixels)?;
255 tx.send((pixels, frame_path)).unwrap();
256 rendered_count += 1;
257 }
258 }
259 }
260 } else {
261 let pixels = exporter.export_frame(&self.scene);
262
263 if let Some(ref mut stdin) = ffmpeg_process {
265 stdin.write_all(&pixels)?;
266 }
267
268 tx.send((pixels, frame_path)).unwrap();
270 manifest.frames.insert(frame_count, hash);
271 rendered_count += 1;
272 }
273
274 let current_saved = saved_count.load(Ordering::SeqCst);
276 let progress = if total_frames > 0 {
277 (current_saved as f32 / total_frames as f32).min(1.0)
278 } else {
279 1.0
280 };
281 let bar_len = 20;
282 let filled = (progress * bar_len as f32) as usize;
283 let bar: String = std::iter::repeat('=')
284 .take(filled)
285 .chain(std::iter::once('>'))
286 .chain(std::iter::repeat(' ').take(bar_len - filled))
287 .collect();
288
289 print!(
290 "\r[Exporting] Frame {}/{} [{}] {:.0}% (Skipped {})",
291 current_saved.min(total_frames),
292 total_frames,
293 bar,
294 progress * 100.0,
295 skipped_count
296 );
297 io::stdout().flush()?;
298
299 let is_video_finished = self.scene.video_timeline.finished();
300 let is_audio_finished = {
301 #[cfg(feature = "audio")]
302 {
303 self.scene.audio_timeline.finished()
304 }
305 #[cfg(not(feature = "audio"))]
306 {
307 true
308 }
309 };
310
311 if is_video_finished && is_audio_finished {
312 break;
313 }
314
315 #[cfg(feature = "audio")]
316 {
317 let current_time =
318 std::time::Duration::from_secs_f32(frame_count as f32 / self.fps as f32);
319 self.scene
320 .collect_audio_events(current_time, &mut audio_events);
321 }
322
323 self.scene.update(dt);
324 frame_count += 1;
325 }
326
327 drop(tx);
329
330 while saved_count.load(Ordering::SeqCst) < frame_count + 1 {
332 let current_saved = saved_count.load(Ordering::SeqCst);
333 let progress = if total_frames > 0 {
334 (current_saved as f32 / total_frames as f32).min(1.0)
335 } else {
336 1.0
337 };
338 let bar_len = 20;
339 let filled = (progress * bar_len as f32) as usize;
340 let bar: String = std::iter::repeat('=')
341 .take(filled)
342 .chain(std::iter::once('>'))
343 .chain(std::iter::repeat(' ').take(bar_len - filled))
344 .collect();
345
346 print!(
347 "\r[Exporting] Frame {}/{} [{}] {:.0}% (Skipped {})",
348 current_saved.min(total_frames),
349 total_frames,
350 bar,
351 progress * 100.0,
352 skipped_count
353 );
354 io::stdout().flush()?;
355 std::thread::sleep(std::time::Duration::from_millis(50));
356 }
357
358 saving_thread.join().unwrap();
359 if let Some(stdin) = ffmpeg_process {
360 drop(stdin); }
362
363 if self.use_cache {
365 let json = serde_json::to_string_pretty(&manifest)?;
366 fs::write(self.output_path.join(".motion_canvas_cache"), json)?;
367 }
368
369 println!(
370 "\nExport finished: {} frames rendered, {} skipped.",
371 rendered_count, skipped_count
372 );
373
374 #[cfg(feature = "audio")]
375 if self.use_ffmpeg {
376 crate::engine::util::export::merge_audio(&self.title, &audio_events)?;
377 }
378
379 #[cfg(feature = "audio")]
380 crate::engine::nodes::audio::set_audio_playback(true);
381
382 Ok(())
383 }
384 }
385
386 pub fn show(self) -> crate::Result<()> {
387 let window = AnimationWindow::new(self)?;
388 window.run()
389 }
390
391 pub fn seek_to(&mut self, target_time: std::time::Duration) {
392 self.scene.reset();
393 self.current_time = std::time::Duration::ZERO;
394 let dt = std::time::Duration::from_secs_f32(1.0 / self.fps as f32);
395 while self.current_time < target_time {
396 self.scene.update(dt);
397 self.current_time += dt;
398 }
399 }
400
401 fn sanitize_title(&self) -> String {
402 crate::engine::util::export::sanitize_title(&self.title)
403 }
404
405 pub fn get_frame_name(&self, frame_count: u32) -> String {
406 let sanitized = self.sanitize_title();
407 format!("{}_{:04}.png", sanitized, frame_count)
408 }
409}