Skip to main content

motion_canvas_rs/engine/
project.rs

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>, // frame_index -> state_hash
30}
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            // Use rayon for background PNG saving
182            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            // Initialize FFmpeg if requested
190            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            // Export until all animations are finished
219            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                // Check cache
225                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 we are skipping, we still need to feed FFmpeg the frame if it's open
233                    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                    // Write to FFmpeg if active
264                    if let Some(ref mut stdin) = ffmpeg_process {
265                        stdin.write_all(&pixels)?;
266                    }
267
268                    // Send to background PNG saver
269                    tx.send((pixels, frame_path)).unwrap();
270                    manifest.frames.insert(frame_count, hash);
271                    rendered_count += 1;
272                }
273
274                // Progress Bar (now reflects saved count)
275                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            // Clean up
328            drop(tx);
329
330            // Wait for all frames to be saved while updating the progress bar
331            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); // Flush and close FFmpeg pipe
361            }
362
363            // Save updated cache
364            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}