aether_renderer_core/
input.rs1use glob::glob;
2use std::cmp::Ordering;
3use std::path::{Path, PathBuf};
4
5pub fn collect_input_frames(
11 input_folder: &Path,
12 file_pattern: Option<String>,
13) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
14 let pattern = file_pattern.unwrap_or_else(|| "*.png".to_string());
15 let (glob_pattern, sequence) = match sequence_to_glob(&pattern) {
16 Some(g) => (g, Some(pattern.clone())),
17 None => (pattern.clone(), None),
18 };
19 let folder = glob::Pattern::escape(&input_folder.to_string_lossy());
21 let glob_path = format!("{}/{}", folder, glob_pattern);
22 let mut frames: Vec<PathBuf> = glob(&glob_path)?
23 .filter_map(Result::ok)
24 .filter(|p| p.is_file())
25 .filter(|p| {
26 !p.file_name()
28 .and_then(|n| n.to_str())
29 .is_some_and(|n| n.starts_with("._"))
30 })
31 .filter(|p| match &sequence {
32 Some(seq) => matches_sequence(seq, p),
33 None => true,
34 })
35 .collect();
36 frames.sort_by(|a, b| natural_cmp(&a.to_string_lossy(), &b.to_string_lossy()));
37 Ok(frames)
38}
39
40fn sequence_to_glob(pattern: &str) -> Option<String> {
42 let mut out = String::new();
43 let mut found = false;
44 let mut chars = pattern.chars().peekable();
45 while let Some(c) = chars.next() {
46 if c == '%' {
47 let mut spec = String::new();
48 while let Some(&d) = chars.peek() {
49 if d.is_ascii_digit() {
50 spec.push(d);
51 chars.next();
52 } else {
53 break;
54 }
55 }
56 if chars.peek() == Some(&'d') {
57 chars.next();
58 out.push('*');
59 found = true;
60 continue;
61 }
62 out.push('%');
63 out.push_str(&spec);
64 } else {
65 out.push(c);
66 }
67 }
68 found.then_some(out)
69}
70
71fn matches_sequence(seq: &str, path: &Path) -> bool {
73 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
74 return false;
75 };
76 let Some(start) = seq.find('%') else {
77 return true;
78 };
79 let Some(d) = seq[start..].find('d').map(|i| start + i) else {
80 return true;
81 };
82 let (prefix, suffix) = (&seq[..start], &seq[d + 1..]);
83 let width: usize = seq[start + 1..d]
84 .trim_start_matches('0')
85 .parse()
86 .unwrap_or(0);
87 name.len() > prefix.len() + suffix.len()
88 && name.starts_with(prefix)
89 && name.ends_with(suffix)
90 && {
91 let digits = &name[prefix.len()..name.len() - suffix.len()];
92 digits.chars().all(|c| c.is_ascii_digit()) && digits.len() >= width
93 }
94}
95
96pub fn natural_cmp(a: &str, b: &str) -> Ordering {
98 let (mut a, mut b) = (a.as_bytes(), b.as_bytes());
99 loop {
100 match (a.first(), b.first()) {
101 (None, None) => return Ordering::Equal,
102 (None, _) => return Ordering::Less,
103 (_, None) => return Ordering::Greater,
104 (Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => {
105 let na = a.iter().take_while(|c| c.is_ascii_digit()).count();
106 let nb = b.iter().take_while(|c| c.is_ascii_digit()).count();
107 let (da, db) = (&a[..na], &b[..nb]);
108 let ta = trim_zeros(da);
109 let tb = trim_zeros(db);
110 let ord = ta
111 .len()
112 .cmp(&tb.len())
113 .then_with(|| ta.cmp(tb))
114 .then_with(|| na.cmp(&nb));
115 if ord != Ordering::Equal {
116 return ord;
117 }
118 a = &a[na..];
119 b = &b[nb..];
120 }
121 (Some(x), Some(y)) => {
122 if x != y {
123 return x.cmp(y);
124 }
125 a = &a[1..];
126 b = &b[1..];
127 }
128 }
129 }
130}
131
132fn trim_zeros(d: &[u8]) -> &[u8] {
133 let i = d.iter().position(|&c| c != b'0').unwrap_or(d.len());
134 &d[i..]
135}
136
137pub struct FrameSequence {
144 _dir: tempfile::TempDir,
145 pattern: PathBuf,
146}
147
148impl FrameSequence {
149 pub fn new(frames: &[PathBuf]) -> std::io::Result<Self> {
150 let dir = tempfile::Builder::new()
151 .prefix("aether-frames-")
152 .tempdir()?;
153 let ext = frames
154 .first()
155 .and_then(|f| f.extension())
156 .and_then(|e| e.to_str())
157 .unwrap_or("png")
158 .to_lowercase();
159 for (i, frame) in frames.iter().enumerate() {
160 let target = dir.path().join(format!("{i:06}.{ext}"));
161 link_or_copy(frame, &target)?;
162 }
163 let pattern = dir.path().join(format!("%06d.{ext}"));
164 Ok(Self { _dir: dir, pattern })
165 }
166
167 pub fn pattern(&self) -> &Path {
169 &self.pattern
170 }
171}
172
173fn link_or_copy(src: &Path, dst: &Path) -> std::io::Result<()> {
174 let src = std::fs::canonicalize(src)?;
175 #[cfg(unix)]
176 if std::os::unix::fs::symlink(&src, dst).is_ok() {
177 return Ok(());
178 }
179 if std::fs::hard_link(&src, dst).is_ok() {
180 return Ok(());
181 }
182 std::fs::copy(&src, dst).map(|_| ())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use std::fs::File;
189 use tempfile::tempdir;
190
191 #[test]
192 fn collects_all_png_by_default() -> Result<(), Box<dyn std::error::Error>> {
193 let dir = tempdir()?;
194 File::create(dir.path().join("a.png"))?;
195 File::create(dir.path().join("b.png"))?;
196 let frames = collect_input_frames(dir.path(), None)?;
197 assert_eq!(frames.len(), 2);
198 Ok(())
199 }
200
201 #[test]
202 fn collects_with_custom_pattern() -> Result<(), Box<dyn std::error::Error>> {
203 let dir = tempdir()?;
204 File::create(dir.path().join("scene1_001.png"))?;
205 File::create(dir.path().join("scene2_001.png"))?;
206 let frames = collect_input_frames(dir.path(), Some("scene1_*.png".into()))?;
207 assert_eq!(frames.len(), 1);
208 assert!(frames[0].ends_with("scene1_001.png"));
209 Ok(())
210 }
211
212 #[test]
213 fn numbered_pattern_and_natural_order() -> Result<(), Box<dyn std::error::Error>> {
214 let dir = tempdir()?;
215 for name in [
216 "f_10.png",
217 "f_9.png",
218 "f_1.png",
219 "f_x.png",
220 "._f_2.png",
221 "g_3.png",
222 ] {
223 File::create(dir.path().join(name))?;
224 }
225 let names = |v: Vec<PathBuf>| -> Vec<String> {
226 v.iter()
227 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
228 .collect()
229 };
230 let frames = collect_input_frames(dir.path(), Some("f_%d.png".into()))?;
231 assert_eq!(names(frames), ["f_1.png", "f_9.png", "f_10.png"]);
232 let frames = collect_input_frames(dir.path(), None)?;
233 assert_eq!(
234 names(frames),
235 ["f_1.png", "f_9.png", "f_10.png", "f_x.png", "g_3.png"]
236 );
237 Ok(())
238 }
239
240 #[test]
241 fn folder_with_glob_characters() -> Result<(), Box<dyn std::error::Error>> {
242 let dir = tempdir()?;
243 let sub = dir.path().join("renders [final]");
244 std::fs::create_dir(&sub)?;
245 File::create(sub.join("a.png"))?;
246 assert_eq!(collect_input_frames(&sub, None)?.len(), 1);
247 Ok(())
248 }
249
250 #[test]
251 fn natural_cmp_orders_numbers() {
252 assert_eq!(natural_cmp("a2", "a10"), Ordering::Less);
253 assert_eq!(natural_cmp("a010", "a9"), Ordering::Greater);
254 assert_eq!(natural_cmp("a01", "a1"), Ordering::Greater);
255 assert_eq!(natural_cmp("b", "a9"), Ordering::Greater);
256 }
257
258 #[test]
259 fn sequence_links_frames_in_order() -> Result<(), Box<dyn std::error::Error>> {
260 let dir = tempdir()?;
261 let a = dir.path().join("x 1.png");
262 let b = dir.path().join("x 2.png");
263 std::fs::write(&a, b"one")?;
264 std::fs::write(&b, b"two")?;
265 let seq = FrameSequence::new(&[b.clone(), a.clone()])?;
266 let folder = seq.pattern().parent().unwrap();
267 assert_eq!(std::fs::read(folder.join("000000.png"))?, b"two");
268 assert_eq!(std::fs::read(folder.join("000001.png"))?, b"one");
269 assert!(seq.pattern().ends_with("%06d.png"));
270 Ok(())
271 }
272}