1use crate::engine::compose::Compositor;
19use crate::engine::export::GpuFrameRequest;
20use crate::engine::shapes::ShapeRasterizer;
21use crate::engine::text::TextRasterizer;
22use crate::media::{Backend, DecoderPool, Frame};
23use crate::model::{ClipKind, Project, TrackKind};
24use crate::settings::Settings;
25use std::collections::hash_map::DefaultHasher;
26use std::hash::{Hash, Hasher};
27use std::io::{Read, Seek, SeekFrom, Write};
28use std::path::{Path, PathBuf};
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::mpsc::{channel, Receiver, Sender};
31use std::sync::Arc;
32use std::time::Duration;
33
34const MAGIC: &[u8; 4] = b"SEPR";
35const VERSION: u32 = 2;
37const HEADER: u64 = 4 + 4 * 4;
38const CACHE_BUDGET: u64 = 8 << 30;
40
41#[derive(Default)]
42pub struct PreRender {
43 ranges: Vec<(f64, f64)>,
45 queue: Vec<i64>,
47 done: Vec<(i64, u64)>,
49 dirty: Vec<i64>,
51 inflight: Vec<(i64, u64)>,
53 generation: Arc<AtomicU64>,
55 worker: Option<Worker>,
57}
58
59const DEPTH: usize = 2;
62
63struct Worker {
65 jobs: Sender<Job>,
66 results: Receiver<(i64, u64, Res)>,
67}
68
69struct Job {
71 project: Arc<Project>,
72 sec: i64,
73 key: u64,
74 w: u32,
75 h: u32,
76 n: u32,
77 fps: f64,
78 generation: u64,
79 gpu: Option<Sender<GpuFrameRequest>>,
81}
82
83enum Res {
85 Done,
87 Stale,
89 Failed,
91}
92
93struct Work {
95 comp: Compositor,
96 pool: DecoderPool,
97 text: TextRasterizer,
98 frame: Frame,
99 shapes: ShapeRasterizer,
100 spare: Vec<Frame>,
101}
102
103struct Open {
105 tmp: PathBuf,
106 path: PathBuf,
107 f: Option<std::io::BufWriter<std::fs::File>>,
108}
109
110impl Work {
111 fn new() -> Self {
112 Work {
113 comp: Compositor::new(),
114 pool: DecoderPool::new(Backend::Auto),
115 text: TextRasterizer::new(),
116 frame: Frame::default(),
117 shapes: ShapeRasterizer::new(),
118 spare: Vec::new(),
119 }
120 }
121
122 fn gpu_render(&mut self, project: &Project, t: f64, w: u32, h: u32, tx: &Sender<GpuFrameRequest>) -> Option<Frame> {
125 let layers = crate::playback::decode_layers(
126 project,
127 t,
128 w,
129 h,
130 &mut self.pool,
131 &mut self.spare,
132 &mut self.text,
133 &mut self.shapes,
134 &mut self.comp,
135 );
136 let (reply, rx) = std::sync::mpsc::sync_channel(1);
137 tx.send(GpuFrameRequest { layers, t, w, h, reply }).ok()?;
138 rx.recv_timeout(Duration::from_secs(5)).ok().flatten()
139 }
140
141 fn render_sec(&mut self, job: &Job, generation: &AtomicU64) -> Res {
144 let Ok(mut o) = open_sec(job.key, job.w, job.h, job.n) else { return Res::Failed };
145 let mut gpu = job.gpu.clone();
146 for i in 0..job.n {
147 if generation.load(Ordering::Relaxed) != job.generation {
148 return Res::Stale; }
150 let t = job.sec as f64 + i as f64 / job.fps;
151 let bytes = match gpu.as_ref().and_then(|tx| self.gpu_render(&job.project, t, job.w, job.h, tx)) {
152 Some(f) => f.rgba,
153 None => {
154 gpu = None; self.comp.render(&job.project, t, job.w, job.h, &mut self.pool, &mut self.text, &mut self.frame);
156 self.frame.rgba.clone()
159 }
160 };
161 if !o.write(&bytes) {
162 return Res::Failed;
163 }
164 }
165 if o.finish() {
166 Res::Done
167 } else {
168 Res::Failed
169 }
170 }
171}
172
173impl Worker {
174 fn new(generation: Arc<AtomicU64>) -> Worker {
175 let (jobs, rx) = channel::<Job>();
176 let (tx, results) = channel();
177 std::thread::spawn(move || {
182 let mut work = Work::new();
183 while let Ok(job) = rx.recv() {
184 let r = work.render_sec(&job, &generation);
185 if tx.send((job.sec, job.key, r)).is_err() {
186 return;
187 }
188 }
189 });
190 Worker { jobs, results }
191 }
192}
193
194impl Open {
195 fn write(&mut self, b: &[u8]) -> bool {
196 self.f.as_mut().is_some_and(|f| f.write_all(b).is_ok())
197 }
198
199 fn finish(mut self) -> bool {
201 let Some(f) = self.f.take() else { return false };
202 let ok = f.into_inner().map_err(|e| e.into_error()).and_then(|_| std::fs::rename(&self.tmp, &self.path));
203 if ok.is_err() {
204 let _ = std::fs::remove_file(&self.tmp);
205 }
206 ok.is_ok()
207 }
208}
209
210impl Drop for Open {
211 fn drop(&mut self) {
212 if self.f.take().is_some() {
213 let _ = std::fs::remove_file(&self.tmp);
214 }
215 }
216}
217
218fn open_sec(key: u64, w: u32, h: u32, n: u32) -> std::io::Result<Open> {
220 let path = path_for(key);
221 let tmp = path.with_extension("tmp");
222 if let Some(d) = path.parent() {
223 std::fs::create_dir_all(d)?;
224 }
225 let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
226 f.write_all(MAGIC)?;
227 for v in [VERSION, w, h, n] {
228 f.write_all(&v.to_le_bytes())?;
229 }
230 Ok(Open { tmp, path, f: Some(f) })
231}
232
233impl PreRender {
234 pub fn new() -> Self {
235 Self::default()
236 }
237
238 pub fn invalidate(&mut self, from: f64, to: f64) {
240 self.generation.fetch_add(1, Ordering::Relaxed);
242 let (a, b) = (from.min(to), from.max(to));
243 for s in sec_range(a, b) {
244 self.done.retain(|(x, _)| *x != s);
245 if !self.dirty.contains(&s) {
246 self.dirty.push(s);
247 }
248 if self.ranges.iter().any(|(x, y)| (s as f64) < *y && (s + 1) as f64 > *x) && !self.queue.contains(&s) {
250 self.queue.push(s);
251 }
252 }
253 self.queue.sort_unstable();
254 }
255
256 pub fn request(&mut self, project: &Project, a: f64, b: f64) {
258 let (a, b) = (a.min(b).max(0.0), b.max(a));
259 if !(b > a) {
260 return;
261 }
262 self.ranges.push((a, b));
265 self.ranges.sort_by(|x, y| x.0.total_cmp(&y.0));
266 let mut merged: Vec<(f64, f64)> = Vec::with_capacity(self.ranges.len());
267 for (x, y) in self.ranges.drain(..) {
268 match merged.last_mut() {
269 Some(l) if x <= l.1 => l.1 = l.1.max(y),
270 _ => merged.push((x, y)),
271 }
272 }
273 self.ranges = merged;
274 for s in sec_range(a, b) {
275 let key = key_for(project, s);
276 if self.done.contains(&(s, key)) || self.queue.contains(&s) {
277 continue;
278 }
279 if path_for(key).exists() {
280 self.done.push((s, key));
281 self.dirty.retain(|d| *d != s);
282 continue;
283 }
284 self.queue.push(s);
285 }
286 self.queue.sort_unstable();
287 }
288
289 pub fn tick(&mut self, project: &Project, _budget_ms: f32, gpu: Option<Sender<GpuFrameRequest>>) -> bool {
294 if self.queue.is_empty() {
295 self.worker = None; self.inflight.clear();
297 return false;
298 }
299 let (w, h) = (project.width.max(1), project.height.max(1));
300 let fps = if project.fps > 1.0 { project.fps } else { 30.0 };
301 let n = fps.round().max(1.0) as u32;
302 let generation = self.generation.clone();
303 let worker = self.worker.take().unwrap_or_else(|| Worker::new(generation.clone()));
304 let PreRender { queue, done, dirty, inflight, .. } = self;
305 let mut landed = false;
306 while let Ok((sec, key, r)) = worker.results.try_recv() {
307 inflight.retain(|(s, _)| *s != sec);
308 match r {
309 Res::Failed => {
311 queue.clear();
312 inflight.clear();
313 return false;
314 }
315 Res::Stale => {} Res::Done => {
317 queue.retain(|s| *s != sec);
318 dirty.retain(|d| *d != sec);
319 if !done.contains(&(sec, key)) {
320 done.push((sec, key));
321 }
322 landed = true;
323 }
324 }
325 }
326 if landed {
327 let evicted = prune(&dir(), CACHE_BUDGET);
329 done.retain(|(_, k)| !evicted.contains(k));
330 }
331 let g = generation.load(Ordering::Relaxed);
333 let mut snap: Option<Arc<Project>> = None;
334 while inflight.len() < DEPTH {
335 let Some(sec) = queue.iter().copied().find(|s| !inflight.iter().any(|(x, _)| x == s)) else { break };
336 let key = key_for(project, sec);
337 if path_for(key).exists() {
338 queue.retain(|s| *s != sec);
339 dirty.retain(|d| *d != sec);
340 if !done.contains(&(sec, key)) {
341 done.push((sec, key));
342 }
343 continue;
344 }
345 let p = snap.get_or_insert_with(|| Arc::new(project.clone())).clone();
346 let job = Job { project: p, sec, key, w, h, n, fps, generation: g, gpu: gpu.clone() };
347 if worker.jobs.send(job).is_err() {
348 queue.clear();
349 return false;
350 }
351 inflight.push((sec, key));
352 }
353 self.worker = Some(worker);
354 !self.queue.is_empty()
355 }
356
357 pub fn frame(&self, project: &Project, t: f64) -> Option<Arc<Frame>> {
359 if t < 0.0 {
360 return None;
361 }
362 let sec = t.floor() as i64;
363 if self.dirty.contains(&sec) {
364 return None;
365 }
366 let key = key_for(project, sec);
367 if !self.done.contains(&(sec, key)) {
368 return None;
369 }
370 let fps = if project.fps > 1.0 { project.fps } else { 30.0 };
371 let i = ((t - sec as f64) * fps).floor().max(0.0) as u32;
372 read_frame(&path_for(key), i, t).map(Arc::new)
373 }
374
375 pub fn progress(&self) -> f32 {
377 let total: i64 = self.ranges.iter().map(|(a, b)| sec_range(*a, *b).count() as i64).sum();
378 if total <= 0 {
379 return 1.0;
380 }
381 let left = self.queue.len() as f32;
382 (1.0 - left / total as f32).clamp(0.0, 1.0)
383 }
384
385 pub fn segments(&self) -> Vec<(f64, f64, bool)> {
388 let mut secs: Vec<(i64, bool)> = Vec::new();
389 for (a, b) in &self.ranges {
390 for s in sec_range(*a, *b) {
391 let ready = !self.dirty.contains(&s) && self.done.iter().any(|(x, _)| *x == s);
392 match secs.iter_mut().find(|(x, _)| *x == s) {
393 Some(e) => e.1 |= ready,
394 None => secs.push((s, ready)),
395 }
396 }
397 }
398 secs.sort_by_key(|(s, _)| *s);
399 let mut out: Vec<(f64, f64, bool)> = Vec::new();
400 for (s, ready) in secs {
401 match out.last_mut() {
402 Some(l) if l.2 == ready && (l.1 - s as f64).abs() < 1e-9 => l.1 = (s + 1) as f64,
403 _ => out.push((s as f64, (s + 1) as f64, ready)),
404 }
405 }
406 out
407 }
408
409 pub fn clear(&mut self) {
410 self.generation.fetch_add(1, Ordering::Relaxed);
411 self.ranges.clear();
412 self.queue.clear();
413 self.done.clear();
414 self.dirty.clear();
415 self.inflight.clear();
416 self.worker = None;
417 }
418}
419
420fn sec_range(a: f64, b: f64) -> std::ops::Range<i64> {
422 if !(b > a) || !a.is_finite() || !b.is_finite() {
423 return 0..0;
424 }
425 a.max(0.0).floor() as i64..b.max(0.0).ceil() as i64
426}
427
428fn dir() -> PathBuf {
429 Settings::cache_dir().join("prerender")
430}
431
432fn path_for(key: u64) -> PathBuf {
433 dir().join(format!("{key:016x}.rgba"))
434}
435
436pub fn key_for(project: &Project, sec: i64) -> u64 {
439 let (a, b) = (sec as f64, sec as f64 + 1.0);
440 let mut h = DefaultHasher::new();
441 VERSION.hash(&mut h);
442 project.width.hash(&mut h);
443 project.height.hash(&mut h);
444 project.fps.to_bits().hash(&mut h);
445 project.scaler.hash(&mut h);
446 sec.hash(&mut h);
447 let mut any_sequence = false;
448 for (ti, track) in project.tracks.iter().enumerate() {
449 if track.kind != TrackKind::Video || !project.active(ti) {
450 continue;
451 }
452 for tr in &track.transitions {
453 if let Some((l, r)) = track.transition_clips(tr) {
454 if let Some((cut, half)) = tr.cut_half(l, r) {
455 if cut - half < b && cut + half > a {
456 hash_json(&mut h, tr);
457 }
458 }
459 }
460 }
461 for clip in &track.clips {
462 if clip.start >= b || clip.end() <= a || !clip.enabled {
463 continue;
464 }
465 hash_json(&mut h, clip);
466 if let Some(asset) = project.asset(clip.asset) {
467 hash_json(&mut h, asset);
468 }
469 any_sequence |= clip.kind == ClipKind::Sequence;
470 }
471 }
472 if any_sequence {
473 hash_json(&mut h, &project.sequences);
475 }
476 if project.show_subtitles {
477 project.subtitle_margin.to_bits().hash(&mut h);
478 hash_json(&mut h, &project.subtitle_style);
479 for cue in project.subtitles.iter().filter(|c| c.start < b && c.end > a) {
480 hash_json(&mut h, cue);
481 }
482 }
483 h.finish()
484}
485
486fn hash_json<T: serde::Serialize>(h: &mut DefaultHasher, v: &T) {
487 match serde_json::to_string(v) {
488 Ok(s) => s.hash(h),
489 Err(_) => std::time::SystemTime::now().hash(h),
491 }
492}
493
494fn read_frame(path: &PathBuf, i: u32, pts: f64) -> Option<Frame> {
496 let mut f = std::fs::File::open(path).ok()?;
497 let mut head = [0u8; HEADER as usize];
498 f.read_exact(&mut head).ok()?;
499 if &head[..4] != MAGIC {
500 return None;
501 }
502 let u = |o: usize| u32::from_le_bytes([head[o], head[o + 1], head[o + 2], head[o + 3]]);
503 if u(4) != VERSION {
504 return None;
505 }
506 let (w, h, n) = (u(8), u(12), u(16));
507 if w == 0 || h == 0 || n == 0 {
508 return None;
509 }
510 let i = i.min(n - 1);
511 let len = w as usize * h as usize * 4;
512 f.seek(SeekFrom::Start(HEADER + i as u64 * len as u64)).ok()?;
513 let mut out = Frame::new(w, h);
514 f.read_exact(&mut out.rgba).ok()?;
515 out.pts = pts;
516 let _ = len;
517 Some(out)
518}
519
520fn key_of(p: &Path) -> Option<u64> {
522 u64::from_str_radix(p.file_name()?.to_str()?.strip_suffix(".rgba")?, 16).ok()
523}
524
525fn prune(d: &Path, budget: u64) -> Vec<u64> {
528 let mut evicted = Vec::new();
529 let Ok(rd) = std::fs::read_dir(d) else { return evicted };
530 let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = rd
531 .flatten()
532 .filter_map(|e| {
533 let m = e.metadata().ok()?;
534 m.is_file().then(|| (m.modified().unwrap_or(std::time::UNIX_EPOCH), m.len(), e.path()))
535 })
536 .collect();
537 let mut total: u64 = files.iter().map(|(_, l, _)| *l).sum();
538 if total <= budget {
539 return evicted;
540 }
541 files.sort_by_key(|(t, _, _)| *t);
542 for (_, len, p) in files {
543 if total <= budget {
544 break;
545 }
546 if std::fs::remove_file(&p).is_ok() {
547 total = total.saturating_sub(len);
548 evicted.extend(key_of(&p));
549 }
550 }
551 evicted
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::model::{Clip, EffectKind, Project, Track};
558
559 #[test]
561 fn segments_merge_by_state() {
562 let mut pr = PreRender::new();
563 pr.ranges = vec![(0.0, 5.0)];
564 pr.done = vec![(0, 1), (1, 1), (3, 1)];
565 pr.dirty = vec![1];
566 assert_eq!(pr.segments(), vec![(0.0, 1.0, true), (1.0, 3.0, false), (3.0, 4.0, true), (4.0, 5.0, false)]);
568 assert!(PreRender::new().segments().is_empty());
569 }
570
571 fn write_file(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
573 if let Some(p) = path.parent() {
574 std::fs::create_dir_all(p)?;
575 }
576 std::fs::File::create(path)?.write_all(data)
577 }
578
579 fn project_with_clip() -> Project {
580 let mut p = Project::new();
581 p.width = 32;
582 p.height = 16;
583 p.fps = 10.0;
584 if p.tracks.is_empty() {
585 let id = p.new_id();
586 p.tracks.push(Track::new(id, TrackKind::Video, "V1"));
587 }
588 let id = p.new_id();
589 let mut c = Clip::new(id, ClipKind::Text, "t", 0.0, 4.0);
590 c.text = Some(crate::model::TextStyle::default());
591 let v = p.tracks.iter().position(|t| t.kind == TrackKind::Video).unwrap();
592 p.tracks[v].clips.push(c);
593 p
594 }
595
596 #[test]
597 fn key_tracks_the_picture_only() {
598 let mut p = project_with_clip();
599 let k0 = key_for(&p, 0);
600 assert_eq!(k0, key_for(&p, 0), "stable");
601 assert_ne!(k0, key_for(&p, 1), "different second, different key");
602
603 p.name = "renamed".into();
605 p.notes = "hello".into();
606 assert_eq!(k0, key_for(&p, 0));
607
608 let v = p.tracks.iter().position(|t| t.kind == TrackKind::Video).unwrap();
610 p.tracks[v].clips[0].x = crate::model::Animated::new(5.0);
611 let k1 = key_for(&p, 0);
612 assert_ne!(k0, k1);
613
614 p.tracks[v].clips[0].effects.push(crate::model::Effect::new(EffectKind::Blur));
616 let k2 = key_for(&p, 0);
617 assert_ne!(k1, k2);
618 p.tracks[v].clips[0].effects[0].params[0] = crate::model::Animated::new(3.0);
619 assert_ne!(k2, key_for(&p, 0));
620
621 let before = key_for(&p, 0);
623 let id = p.new_id();
624 p.tracks[v].clips.push(Clip::new(id, ClipKind::Text, "later", 10.0, 1.0));
625 assert_eq!(before, key_for(&p, 0));
626 assert_ne!(before, key_for(&p, 10));
627 }
628
629 #[test]
630 fn frame_is_none_for_dirty_and_unrendered_ranges() {
631 let p = project_with_clip();
632 let mut pr = PreRender::new();
633 assert!(pr.frame(&p, 0.0).is_none(), "nothing rendered yet");
634 pr.done.push((0, key_for(&p, 0)));
636 pr.dirty.push(0);
637 assert!(pr.frame(&p, 0.5).is_none(), "dirty range must never serve a frame");
638 pr.dirty.clear();
639 assert!(pr.frame(&p, 0.5).is_none());
641 assert!(pr.frame(&p, -1.0).is_none());
642 }
643
644 #[test]
645 fn invalidate_requeues_only_requested_seconds() {
646 let p = project_with_clip();
647 let mut pr = PreRender::new();
648 pr.ranges.push((0.0, 2.0));
649 pr.done.push((0, key_for(&p, 0)));
650 pr.done.push((5, key_for(&p, 5)));
651 pr.invalidate(0.2, 0.8);
652 assert!(pr.dirty.contains(&0));
653 assert!(pr.queue.contains(&0), "inside the requested range -> re-render");
654 pr.invalidate(5.0, 5.5);
655 assert!(pr.dirty.contains(&5));
656 assert!(!pr.queue.contains(&5), "outside every request -> just dropped");
657 assert!(pr.done.iter().all(|(s, _)| *s != 0 && *s != 5), "invalidated seconds are not ready");
658 }
659
660 #[test]
661 fn progress_counts_the_queue() {
662 let mut pr = PreRender::new();
663 assert_eq!(pr.progress(), 1.0, "nothing requested is nothing to wait for");
664 pr.ranges.push((0.0, 4.0));
665 pr.queue = vec![0, 1, 2, 3];
666 assert_eq!(pr.progress(), 0.0);
667 pr.queue = vec![3];
668 assert!((pr.progress() - 0.75).abs() < 1e-6);
669 pr.queue.clear();
670 assert_eq!(pr.progress(), 1.0);
671 }
672
673 #[test]
676 fn repeated_requests_merge_into_one_range() {
677 let p = project_with_clip();
678 let mut pr = PreRender::new();
679 for _ in 0..50 {
680 pr.request(&p, 0.0, 4.0);
681 }
682 assert_eq!(pr.ranges, vec![(0.0, 4.0)], "re-requesting the same range must not grow it");
683 pr.request(&p, 10.0, 12.0);
684 assert_eq!(pr.ranges, vec![(0.0, 4.0), (10.0, 12.0)], "a disjoint request stays its own run");
685 pr.request(&p, 3.0, 11.0);
686 assert_eq!(pr.ranges, vec![(0.0, 12.0)], "one that bridges them merges all three");
687 }
688
689 #[test]
690 fn seconds_cover_the_range() {
691 assert_eq!(sec_range(0.0, 1.0).collect::<Vec<_>>(), vec![0]);
692 assert_eq!(sec_range(0.5, 2.1).collect::<Vec<_>>(), vec![0, 1, 2]);
693 assert_eq!(sec_range(2.0, 2.0).count(), 0);
694 assert_eq!(sec_range(f64::NAN, 1.0).count(), 0);
695 }
696
697 #[test]
698 fn tick_hands_seconds_to_the_worker() {
699 let mut p = project_with_clip(); p.width = 33;
703 p.height = 17;
704 let paths: Vec<PathBuf> = (0..3).map(|s| path_for(key_for(&p, s))).collect();
705 for path in &paths {
706 let _ = std::fs::remove_file(path);
707 }
708 let mut pr = PreRender::new();
709 pr.request(&p, 0.0, 3.0);
710 assert_eq!(pr.queue, vec![0, 1, 2]);
711 let start = std::time::Instant::now();
712 while pr.tick(&p, 4.0, None) {
713 assert!(pr.inflight.len() <= DEPTH, "{} seconds in flight at once", pr.inflight.len());
714 assert!(start.elapsed() < std::time::Duration::from_secs(30), "not converging");
715 }
716 assert!(paths.iter().all(|path| path.exists()), "every finished second is published");
717 assert!(pr.frame(&p, 2.05).is_some(), "and served from the cache");
718 for path in &paths {
719 let _ = std::fs::remove_file(path);
720 }
721 }
722
723 #[test]
724 fn prune_reports_the_keys_it_deleted() {
725 let d = std::env::temp_dir().join(format!("se-prune-{}", std::process::id()));
726 let _ = std::fs::remove_dir_all(&d);
727 let keys = [0x11u64, 0x22, 0x33];
728 for k in keys {
729 write_file(&d.join(format!("{k:016x}.rgba")), &[0u8; 64]).unwrap();
730 }
731 assert!(prune(&d, 1 << 20).is_empty(), "inside the budget nothing is touched");
732 assert_eq!(std::fs::read_dir(&d).unwrap().count(), 3);
733 let mut got = prune(&d, 0);
734 got.sort_unstable();
735 assert_eq!(got, keys, "every deleted second must be reported");
736 assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0);
737 write_file(&d.join("half.tmp"), b"x").unwrap();
739 assert!(prune(&d, 0).is_empty());
740 assert_eq!(key_of(&path_for(0xdead_beef)), Some(0xdead_beef));
741 let _ = std::fs::remove_dir_all(&d);
742 }
743
744 #[test]
745 fn cache_file_round_trip() {
746 let dir = std::env::temp_dir().join(format!("se-prerender-{}", std::process::id()));
747 std::fs::create_dir_all(&dir).unwrap();
748 let path = dir.join("x.rgba");
749 let (w, h, n) = (2u32, 2u32, 3u32);
750 let mut buf = Vec::new();
751 buf.extend_from_slice(MAGIC);
752 for v in [VERSION, w, h, n] {
753 buf.extend_from_slice(&v.to_le_bytes());
754 }
755 for i in 0..n {
756 buf.extend(std::iter::repeat(i as u8 + 1).take((w * h * 4) as usize));
757 }
758 write_file(&path, &buf).unwrap();
759 let f = read_frame(&path, 1, 1.25).unwrap();
760 assert_eq!((f.width, f.height), (2, 2));
761 assert!(f.rgba.iter().all(|b| *b == 2), "frame 1 must be the second block");
762 assert!((f.pts - 1.25).abs() < 1e-9);
763 assert!(read_frame(&path, 99, 0.0).unwrap().rgba.iter().all(|b| *b == 3));
765 write_file(&path, b"nope").unwrap();
766 assert!(read_frame(&path, 0, 0.0).is_none());
767 let _ = std::fs::remove_dir_all(&dir);
768 }
769}