simple_editor\engine/
prerender.rs

1//! Pre-render ("movie mode"): render ranges at full quality into a cache so playback shows exactly what
2//! an export would, without re-running the effect chain every frame.
3//!
4//! Cache: `Settings::cache_dir()/prerender/<hash>.rgba`, one file per second of timeline at project
5//! resolution, keyed by a hash of everything affecting the picture in that second. A worker thread
6//! renders whole seconds with the CPU compositor; the UI thread only hands out seconds and collects
7//! finished ones, so a slow frame lands on the worker and never on a repaint.
8//!
9//! File layout: `SEPR` + version + width + height + frame count (u32 LE each), then that many
10//! `width * height * 4` RGBA frames. `frame()` seeks to the one it needs, so a served frame costs one
11//! read and never a whole second of RAM.
12//!
13//! Rendering decodes on the worker thread and, when the GPU renderer is up, composites by round-tripping
14//! layers to the UI thread's GL context through a `GpuFrameRequest` — the same channel `export::GpuScratch`
15//! uses, so movie mode gets the identical shaders the preview does. Falls back to the CPU `Compositor`
16//! per-second if the GPU stops answering (renderer died, or it was never on).
17
18use 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";
35// 2: video clips apply fade in/out as opacity — old tiles rendered before that are stale
36const VERSION: u32 = 2;
37const HEADER: u64 = 4 + 4 * 4;
38/// Stop growing the cache past this (raw RGBA is big); the oldest files go first.
39const CACHE_BUDGET: u64 = 8 << 30;
40
41#[derive(Default)]
42pub struct PreRender {
43    /// Requested ranges, seconds, merged and clamped to whole seconds.
44    ranges: Vec<(f64, f64)>,
45    /// Whole seconds still to render, in order. A second stays here while a worker has it.
46    queue: Vec<i64>,
47    /// Seconds rendered this session with the key they were rendered under.
48    done: Vec<(i64, u64)>,
49    /// Seconds invalidated since they were rendered (never served, always re-rendered).
50    dirty: Vec<i64>,
51    /// Seconds handed to the worker, with the key each is rendering under.
52    inflight: Vec<(i64, u64)>,
53    /// Bumped by every invalidation; a job behind it is dropped mid-second.
54    generation: Arc<AtomicU64>,
55    /// Spawned on the first queued second, dropped when the queue drains.
56    worker: Option<Worker>,
57}
58
59/// Seconds handed over at once: one rendering plus one waiting, so the worker never idles between
60/// frames of the UI but an edit still gets picked up within a second of work.
61const DEPTH: usize = 2;
62
63/// The render worker. Dropping it closes the job channel, so the thread finishes its second and exits.
64struct Worker {
65    jobs: Sender<Job>,
66    results: Receiver<(i64, u64, Res)>,
67}
68
69/// One second of timeline for one worker, against a snapshot of the project it was queued from.
70struct 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 compositor to round-trip frames through, when the renderer was on at request time.
80    gpu: Option<Sender<GpuFrameRequest>>,
81}
82
83/// How a worker's second ended.
84enum Res {
85    /// Written and published.
86    Done,
87    /// An edit overtook it: still queued, re-rendered under the new key.
88    Stale,
89    /// Out of disk / no cache dir: give up on pre-rendering.
90    Failed,
91}
92
93/// Per-worker scratch: a compositor with its own decoders and font cache.
94struct Work {
95    comp: Compositor,
96    pool: DecoderPool,
97    text: TextRasterizer,
98    frame: Frame,
99    shapes: ShapeRasterizer,
100    spare: Vec<Frame>,
101}
102
103/// A half-written second; dropping it without `finish()` throws the temp file away.
104struct 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    /// One frame through the GPU: decode here, composite on the UI thread, exactly like `export::GpuScratch`.
123    /// None = the renderer died or stopped answering — caller falls back to the CPU compositor.
124    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    /// Render one whole second into the cache, streaming frames out as they are made (a second of 1080p
142    /// RGBA is 250 MB, far too much to buffer). Decoding stays sequential, so the decoder never re-seeks.
143    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; // an edit landed: dropping `o` deletes the half-written file
149            }
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; // dead for the rest of this second; the next second gets a fresh Job
155                    self.comp.render(&job.project, t, job.w, job.h, &mut self.pool, &mut self.text, &mut self.frame);
156                    // clone, not take: `Frame::resize` is a no-op when width/height already match, so an
157                    // emptied-out rgba would stay empty on the next same-size render
158                    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        // ponytail: one thread, measured, not guessed. A pool renders seconds concurrently, which means
178        // one decoder per worker seeking into the same file: on 1080p H.264 two Media Foundation readers
179        // bought nothing and four were 6x SLOWER than one (they each spawn a core's worth of internal
180        // threads). Split by *source file* if a multi-track project ever needs the cores.
181        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    /// Flush the second and publish it (rename over the real name).
200    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
218/// Create the temp file for one second and write its header.
219fn 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    /// Mark a range dirty (an edit touched it).
239    pub fn invalidate(&mut self, from: f64, to: f64) {
240        // unconditional: the app invalidates the whole timeline per edit, so anything in flight is dead
241        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            // a dirty second inside a requested range goes back on the queue
249            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    /// Queue [a, b) for rendering.
257    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        // merged, not appended: `after_edit` re-requests the whole timeline on every single edit, and an
263        // unmerged list makes `segments()` and `progress()` — both drawn every frame — grow without end
264        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    /// Collect finished seconds and keep the worker fed (call once per frame). Returns true while work
290    /// is outstanding. The budget is ignored: rendering left the UI thread, so there is nothing to slice.
291    /// `gpu`: the GPU renderer's request channel when it is on, so new jobs composite through it (falling
292    /// back to the CPU compositor per-second if it stops answering); pass None to force CPU rendering.
293    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; // idle: closing the job channel lets the thread exit
296            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                // out of disk / no cache dir: stop trying, playback falls back to live render
310                Res::Failed => {
311                    queue.clear();
312                    inflight.clear();
313                    return false;
314                }
315                Res::Stale => {} // still queued, re-dispatched below under the new key
316                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            // an evicted second is no longer ready: drop it so the next request re-renders it
328            let evicted = prune(&dir(), CACHE_BUDGET);
329            done.retain(|(_, k)| !evicted.contains(k));
330        }
331        // keep the worker fed, off a snapshot of the project as it is right now
332        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    /// A pre-rendered frame for time t, when the cache holds a valid one.
358    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    /// Fraction of the requested range that is ready.
376    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    /// Requested ranges as merged runs of whole seconds with their state: `(from, to, ready)`.
386    /// Drawn as the pre-render bar at the top of the timeline.
387    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
420/// Whole seconds covered by [a, b).
421fn 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
436/// Hash of everything that changes the picture during second `sec`: format, and every clip (with its
437/// effects, graph, mask and asset) visible in that second on an active video track.
438pub 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        // ponytail: a nested sequence rehashes wholesale — cheap enough, and always correct.
474        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        // unserialisable => treat as always-changed rather than silently equal
490        Err(_) => std::time::SystemTime::now().hash(h),
491    }
492}
493
494/// Frame `i` of a cache file, or None when the file is missing/short/foreign.
495fn 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
520/// The cache key a file name encodes (`path_for`'s inverse); None for anything else in the folder.
521fn key_of(p: &Path) -> Option<u64> {
522    u64::from_str_radix(p.file_name()?.to_str()?.strip_suffix(".rgba")?, 16).ok()
523}
524
525/// Keep the cache under `budget` bytes, oldest files first. Returns the keys it deleted so the caller
526/// can forget the seconds they held.
527fn 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    /// The timeline bar: rendered seconds merge into ready runs, everything else stays pending.
560    #[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        // 0 ready, 1 dirty, 2 pending, 3 ready, 4 pending
567        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    /// Write a whole cache file at once (the renderer streams it; the tests do not need to).
572    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        // irrelevant edits keep the key
604        p.name = "renamed".into();
605        p.notes = "hello".into();
606        assert_eq!(k0, key_for(&p, 0));
607
608        // a clip move changes it
609        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        // so does adding an effect, and changing one of its parameters
615        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        // a clip outside the second does not
622        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        // pretend second 0 is in the cache
635        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        // the file is not there, so it still declines (but for the other reason)
640        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    /// Every edit re-requests the whole timeline. Unmerged, `ranges` grows with the edit count and
674    /// takes `segments()` and `progress()` — drawn every frame — with it, until the UI stalls.
675    #[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(); // 33×17 @ 10 fps, one text clip: no decoder needed
700                                         // an odd size gives this test a cache key of its own, so the tests that assert "no file
701                                         // for this second" never race with the one this writes
702        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        // a stray file is still pruned, it just has no key to forget
738        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        // past the end clamps to the last frame, a foreign file is refused
764        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}