Skip to main content

dinamika_core/
output.rs

1//! Saving timeline frames to disk.
2//!
3//! Frames are written as PNG with numeric names (`000001.png`, `000002.png`, …)
4//! into the output directory. The directory can be set explicitly via
5//! [`Timeline::render`], or saved to `outputs/<scene>` next to the scene's
6//! `.rs` file with a single call of the [`render!`](crate::render) macro.
7
8use std::io;
9use std::path::{Path, PathBuf};
10
11use crate::timeline::Timeline;
12
13/// Frame names: `000001.png`. The width is widened beyond this if needed.
14const FRAME_DIGITS: usize = 6;
15
16impl Timeline {
17    /// Renders the whole animation and saves the PNG frames into the `dir` directory.
18    ///
19    /// The directory (and its parents) is created if needed. Frames are numbered
20    /// from one: `000001.png`, `000002.png`, … Returns the number of frames
21    /// written. The frame size, background and frame rate are taken from
22    /// [`Timeline::new`].
23    ///
24    /// To save into `outputs/<scene>` next to the scene file, it is more
25    /// convenient to call the [`render!`](crate::render) macro — it fills in the
26    /// directory itself:
27    ///
28    /// ```no_run
29    /// # use dinamika_core::*;
30    /// let tl = Timeline::new(480, 240, Color::from_rgba8(20, 20, 24, 255), 30.0);
31    /// // Next to this .rs file: outputs/demo/000001.png …
32    /// render!(tl, "demo").unwrap();
33    /// // Or a directory of your own directly:
34    /// tl.render("D:/render/demo").unwrap();
35    /// ```
36    pub fn render(&self, dir: impl AsRef<Path>) -> io::Result<usize> {
37        let dir = dir.as_ref();
38        std::fs::create_dir_all(dir)?;
39
40        let frames = self.frames();
41        for (i, frame) in frames.iter().enumerate() {
42            let name = format!("{:0width$}.png", i + 1, width = FRAME_DIGITS);
43            frame.save_png(dir.join(name))?;
44        }
45        Ok(frames.len())
46    }
47}
48
49/// Default output directory for a scene: `<source-dir>/outputs/<scene>`.
50///
51/// Usually called not directly, but via the [`scene_dir!`](crate::scene_dir)
52/// macro, which substitutes `env!("CARGO_MANIFEST_DIR")` and `file!()` at the
53/// call site.
54pub fn scene_output_dir(manifest_dir: &str, source_file: &str, scene: &str) -> PathBuf {
55    resolve_source_dir(manifest_dir, source_file).join("outputs").join(scene)
56}
57
58/// Reconstructs the absolute directory of the source file from `file!()` and
59/// `CARGO_MANIFEST_DIR`.
60///
61/// Depending on the Cargo version and build method, `file!()` can be different:
62/// absolute, relative to the package (`src/main.rs`) or to the workspace root
63/// (`dinamika/src/main.rs`). We try the candidates and take the first existing
64/// one, otherwise the most likely one.
65fn resolve_source_dir(manifest_dir: &str, source_file: &str) -> PathBuf {
66    let file = Path::new(source_file);
67
68    let candidates: Vec<PathBuf> = if file.is_absolute() {
69        vec![file.to_path_buf()]
70    } else {
71        let manifest = Path::new(manifest_dir);
72        let mut v = vec![manifest.join(file)];
73        if let Some(workspace) = manifest.parent() {
74            v.push(workspace.join(file));
75        }
76        v.push(file.to_path_buf()); // relative to the current directory
77        v
78    };
79
80    let resolved = candidates
81        .iter()
82        .find(|p| p.exists())
83        .cloned()
84        .unwrap_or_else(|| candidates[0].clone());
85
86    resolved.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))
87}
88
89/// Default output directory for a scene — `outputs/<scene>` next to the
90/// `.rs` file where the macro is called.
91///
92/// Expands into a [`PathBuf`] that can be passed to [`Timeline::render`]:
93///
94/// ```no_run
95/// # use dinamika_core::*;
96/// # let tl = Timeline::new(480, 240, Color::BLACK, 30.0);
97/// tl.render(scene_dir!("demo")).unwrap();
98/// ```
99///
100/// To pick the directory and render in a single call, see [`render!`](crate::render).
101#[macro_export]
102macro_rules! scene_dir {
103    ($scene:expr $(,)?) => {
104        $crate::scene_output_dir(env!("CARGO_MANIFEST_DIR"), file!(), $scene)
105    };
106}
107
108/// Renders the whole timeline animation into the `outputs/<scene>` directory
109/// next to the `.rs` file where the macro is called; returns a
110/// [`std::io::Result`] carrying only success/failure. The number of saved
111/// frames is not returned — if you need it, call [`Timeline::render`] directly.
112///
113/// A convenient wrapper over [`Timeline::render`] and
114/// [`scene_dir!`](crate::scene_dir): the directory is chosen automatically, so a
115/// separate path macro call is not needed.
116///
117/// ```no_run
118/// # use dinamika_core::*;
119/// let tl = Timeline::new(480, 240, Color::BLACK, 30.0);
120/// render!(tl, "demo").unwrap(); // → outputs/demo next to the .rs
121/// ```
122#[macro_export]
123macro_rules! render {
124    ($timeline:expr, $scene:expr $(,)?) => {
125        $timeline.render($crate::scene_dir!($scene)).map(|_| ())
126    };
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn output_dir_sits_next_to_source() {
135        // Non-existent paths: we test the pure joining logic (fallback to the
136        // first candidate — `manifest/source`).
137        let dir = scene_output_dir("/proj/crate", "src/main.rs", "demo");
138        assert!(dir.ends_with("outputs/demo") || dir.ends_with("outputs\\demo"));
139        // The source directory is src, next to outputs.
140        assert!(dir.to_string_lossy().contains("src"));
141    }
142
143    #[test]
144    fn absolute_source_file_is_used_directly() {
145        let dir = scene_output_dir("/ignored", "/abs/scenes/intro.rs", "intro");
146        let s = dir.to_string_lossy().replace('\\', "/");
147        assert_eq!(s, "/abs/scenes/outputs/intro");
148    }
149}