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}