simple_editor\engine/
gpu.rs

1//! GPU renderer: the effect chain as OpenGL shaders, using eframe's existing glow context.
2//!
3//! Why: the CPU compositor is fine for cuts but blur/VHS/motion-blur at 1080p+ are not. Every round-3
4//! effect is a fragment shader here; the CPU path (engine/effects.rs) keeps the older, cheap effects so
5//! the app still runs (degraded) when no GL context exists.
6//!
7//! Threading: GL belongs to the thread that owns the context — the UI thread. So:
8//!   * preview  → `render_to_texture` during `App::update`, painted straight into the preview pane
9//!                (no CPU readback at all).
10//!   * export   → the export thread posts `RenderRequest`s; the UI thread serves them with `render_frame`
11//!                (readback via `glReadPixels`) so preview and export run the SAME shaders.
12//!   * headless (`--selftest`, no GL) → callers fall back to `engine::compose::Compositor`.
13//!
14//! Pipeline per frame: decode (CPU, player threads) → upload layer textures (`upload`) → for each visual
15//! clip: build its chain (node graph when present, else the linear effect stack) → ping-pong FBOs →
16//! transform+blend into the canvas FBO → adjustment layers re-process the canvas → subtitles/markers.
17//! All textures are RGBA8 with straight alpha, matching `media::Frame`.
18//!
19//! What the caller owes us (`LayerSet`): one decoded frame per visual clip that is on screen at `t`,
20//! keyed by clip id — including both clips of a transition (they are rendered virtually extended past
21//! their own bounds), the rendered bitmap of a `Text`/`Shape` clip, the rendered canvas of a nested
22//! `Sequence` clip, and, under `LayerSet::SUBTITLES`, the subtitle bitmap. Anything missing is skipped.
23//! Footage is placed from its asset's native size, so any decode size works; text/shape/subtitle
24//! bitmaps are placed 1:1 and must be rasterised for `render_size(w, h, quality)`, not for `w`×`h`.
25//!
26//! Textures returned by `render_to_texture` / `apply_effect` / `mask_texture` / `eval_graph` stay valid
27//! until the next `render_to_texture` / `render_frame` call, which recycles them.
28
29use crate::engine::shaders;
30use crate::media::Frame;
31use crate::model::{BlendMode, Clip, ClipKind, Effect, EffectKind, Id, Mask, MaskShape, NodeGraph, NodeKind};
32use crate::model::{Project, Scaler, TrackKind};
33use eframe::glow;
34use eframe::glow::HasContext;
35use std::collections::HashMap;
36use std::num::NonZeroU32;
37use std::sync::Arc;
38
39/// One reusable off-screen target (texture + FBO).
40pub struct Target {
41    pub tex: glow::Texture,
42    pub fbo: glow::Framebuffer,
43    pub w: u32,
44    pub h: u32,
45}
46
47/// Compiled shader programs, cached by effect kind (and by source hash for `EffectKind::Shader`).
48#[derive(Default)]
49pub struct Programs {
50    map: HashMap<u64, Prog>,
51    /// Keys whose GLSL failed — never retried (and the first log is kept for the UI).
52    failed: HashMap<u64, String>,
53}
54
55struct Prog {
56    p: glow::Program,
57    uni: HashMap<&'static str, glow::UniformLocation>,
58}
59
60/// Every uniform any of our programs can declare; looked up once per link.
61const UNIFORMS: &[&str] = &[
62    "tex",
63    "u_res",
64    "u_time",
65    "u_scale",
66    "p0",
67    "p1",
68    "p2",
69    "p3",
70    "p4",
71    "p5",
72    "p6",
73    "p7",
74    // Curves declares p8..p11 itself; a program that does not is simply missing the location.
75    "p8",
76    "p9",
77    "p10",
78    "p11",
79    "u_mask",
80    "u_has_mask",
81    "u_dst",
82    "u_layer",
83    "u_inv",
84    "u_scaler",
85    "u_mode",
86    "u_opacity",
87    "u_b",
88    "u_matte_alpha",
89    "u_matte_invert",
90    "u_prev",
91    "u_next",
92    "u_frames",
93    "u_motion",
94    "m_shape",
95    "m_center",
96    "m_radius",
97    "m_rot",
98    "m_feather",
99    "m_expand",
100    "m_opacity",
101    "m_invert",
102    "m_count",
103];
104
105// Program cache keys. Effects live at EFFECT_BASE + kind index; user shaders at their source hash
106// (with the top bit set so they can never collide with the small ids).
107const K_COMPOSITE: u64 = 1;
108const K_MASK: u64 = 2;
109const K_COPY: u64 = 3;
110const K_MATTE: u64 = 4;
111const EFFECT_BASE: u64 = 16;
112const USER_BIT: u64 = 1 << 63;
113
114/// `vec4 effect(..)` bodies that are not per-effect: a pass-through and the two-input matte.
115const COPY_BODY: &str = "vec4 effect(vec4 src, vec2 uv) { return src; }";
116const MATTE_BODY: &str = r#"
117uniform sampler2D u_b;
118uniform int u_matte_alpha;
119uniform int u_matte_invert;
120vec4 effect(vec4 src, vec2 uv) {
121    vec4 m = texture(u_b, uv);
122    float k = (u_matte_alpha != 0) ? m.a : luma(m.rgb) * m.a;
123    if (u_matte_invert != 0) { k = 1.0 - k; }
124    return vec4(src.rgb, src.a * clamp(k, 0.0, 1.0));
125}
126"#;
127
128/// Texture units.
129const U_TEX: u32 = 0;
130const U_MASK: u32 = 1;
131const U_DST: u32 = 2;
132const U_B: u32 = 3;
133const U_PREV: u32 = 4;
134const U_NEXT: u32 = 5;
135
136/// Texture-cache keys for the neighbouring frames of a motion-blurred clip. Clip ids are small
137/// counters (`Project::new_id`), so the top bits are free to tag a variant of the same clip.
138const MOTION_PREV: u64 = 1 << 62;
139const MOTION_NEXT: u64 = 1 << 61;
140
141/// A layer texture and the revision of the frame currently in it.
142struct LayerTex {
143    tex: glow::Texture,
144    w: u32,
145    h: u32,
146    rev: (usize, u64),
147}
148
149/// Size-keyed target pool. GL objects are created by the renderer when `take` finds nothing reusable
150/// and deleted here when the free list outgrows its budget. Ping-pong falls out of it: `composite`
151/// takes a second canvas-sized target, draws into it and returns the old canvas here, so the two keep
152/// swapping.
153#[derive(Default)]
154struct Pool {
155    /// The context, so `put` can actually free what it evicts. `None` in the pure-bookkeeping tests.
156    gl: Option<Arc<glow::Context>>,
157    free: Vec<Target>,
158    /// Targets handed out and not yet returned.
159    live: usize,
160}
161
162impl Pool {
163    /// How many RGBA8 pixels the free list may hold (~256 MB: 8 × 4K, 30 × 1080p). Without a cap a
164    /// preview resize walks through one target per pixel width and never frees any of them.
165    const MAX_FREE_PX: usize = 64 << 20;
166
167    /// A free target of exactly this size, if there is one (callers create one otherwise).
168    fn take(&mut self, w: u32, h: u32) -> Option<Target> {
169        let i = self.free.iter().position(|t| t.w == w && t.h == h)?;
170        self.live += 1;
171        Some(self.free.swap_remove(i))
172    }
173    /// Count a target the caller just created.
174    fn created(&mut self) {
175        self.live += 1;
176    }
177    fn put(&mut self, t: Target) {
178        self.live = self.live.saturating_sub(1);
179        self.free.push(t);
180        // ponytail: FIFO eviction, not true LRU — the oldest entry is the stale size, and resize
181        // churn is the only thing that grows this. Sort by last use if a real workload thrashes.
182        let px = |t: &Target| t.w as usize * t.h as usize;
183        let mut total: usize = self.free.iter().map(px).sum();
184        while total > Self::MAX_FREE_PX && self.free.len() > 1 {
185            let old = self.free.remove(0);
186            total -= px(&old);
187            if let Some(gl) = &self.gl {
188                unsafe {
189                    gl.delete_framebuffer(old.fbo);
190                    gl.delete_texture(old.tex);
191                }
192            }
193        }
194    }
195}
196
197/// The renderer. One per app; lives on the UI thread.
198pub struct GpuRenderer {
199    gl: Arc<glow::Context>,
200    vao: glow::VertexArray,
201    vert: glow::Shader,
202    programs: Programs,
203    pool: Pool,
204    /// Uploaded layer textures by caller key.
205    textures: HashMap<u64, LayerTex>,
206    /// 1×1 white texture, bound where a sampler is unused.
207    blank: glow::Texture,
208    /// Targets handed to the caller; recycled at the start of the next frame.
209    loaned: Vec<Target>,
210    /// What `NodeKind::Text` rasterises with — the app hands over the same one the player and export
211    /// use, so fonts and the glyph cache are shared. None = Text nodes render nothing.
212    pub text: Option<Arc<std::sync::Mutex<crate::engine::text::TextRasterizer>>>,
213}
214
215impl GpuRenderer {
216    /// Compile the shader set. Err (with the GLSL log) when the driver rejects something — the caller
217    /// falls back to the CPU compositor and shows the message once.
218    pub fn new(gl: Arc<glow::Context>) -> Result<Self, String> {
219        unsafe {
220            let vao = gl.create_vertex_array().map_err(|e| format!("vertex array: {e}"))?;
221            let vert = compile(&gl, glow::VERTEX_SHADER, shaders::VERT)?;
222            let blank = gl.create_texture().map_err(|e| format!("texture: {e}"))?;
223            gl.bind_texture(glow::TEXTURE_2D, Some(blank));
224            tex_params(&gl);
225            gl.tex_image_2d(
226                glow::TEXTURE_2D,
227                0,
228                glow::RGBA8 as i32,
229                1,
230                1,
231                0,
232                glow::RGBA,
233                glow::UNSIGNED_BYTE,
234                glow::PixelUnpackData::Slice(Some(&[255, 255, 255, 255])),
235            );
236            let mut r = Self {
237                pool: Pool { gl: Some(gl.clone()), ..Pool::default() },
238                gl,
239                vao,
240                vert,
241                programs: Programs::default(),
242                textures: HashMap::new(),
243                blank,
244                loaned: Vec::new(),
245                text: None,
246            };
247            // The composite program is the one nothing works without: fail loudly here instead of
248            // silently rendering black later.
249            r.ensure(K_COMPOSITE)?;
250            Ok(r)
251        }
252    }
253
254    /// Upload/refresh a decoded layer as a texture keyed by (clip id, source time bucket). Returns the
255    /// texture to use; re-uploads only when the frame actually changed (`Frame` pointer/pts).
256    pub fn upload(&mut self, key: u64, frame: &Frame) -> glow::Texture {
257        if frame.is_empty() {
258            return self.blank;
259        }
260        let gl = self.gl.clone();
261        let rev = (frame.rgba.as_ptr() as usize, frame.pts.to_bits());
262        let (w, h) = (frame.width, frame.height);
263        unsafe {
264            match self.textures.get_mut(&key) {
265                Some(lt) if lt.w == w && lt.h == h => {
266                    if lt.rev == rev {
267                        return lt.tex;
268                    }
269                    lt.rev = rev;
270                    gl.bind_texture(glow::TEXTURE_2D, Some(lt.tex));
271                    gl.tex_sub_image_2d(
272                        glow::TEXTURE_2D,
273                        0,
274                        0,
275                        0,
276                        w as i32,
277                        h as i32,
278                        glow::RGBA,
279                        glow::UNSIGNED_BYTE,
280                        glow::PixelUnpackData::Slice(Some(&frame.rgba)),
281                    );
282                    return lt.tex;
283                }
284                _ => {}
285            }
286            if let Some(old) = self.textures.remove(&key) {
287                gl.delete_texture(old.tex);
288            }
289            let Ok(tex) = gl.create_texture() else { return self.blank };
290            gl.bind_texture(glow::TEXTURE_2D, Some(tex));
291            tex_params(&gl);
292            gl.tex_image_2d(
293                glow::TEXTURE_2D,
294                0,
295                glow::RGBA8 as i32,
296                w as i32,
297                h as i32,
298                0,
299                glow::RGBA,
300                glow::UNSIGNED_BYTE,
301                glow::PixelUnpackData::Slice(Some(&frame.rgba)),
302            );
303            self.textures.insert(key, LayerTex { tex, w, h, rev });
304            tex
305        }
306    }
307
308    /// Render the whole timeline at time `t` into an off-screen target and return it, so the preview can
309    /// paint it with `eframe::Frame::register_native_glow_texture`. `quality` (0.25..1.0) scales the
310    /// internal resolution (see `render_size`); the texture is always presented at `w`×`h`.
311    pub fn render_to_texture(
312        &mut self,
313        project: &Project,
314        t: f64,
315        w: u32,
316        h: u32,
317        quality: f32,
318        layers: &LayerSet,
319    ) -> Option<glow::Texture> {
320        self.reclaim();
321        let host = self.host_fbo();
322        let (rw, rh) = render_size(w, h, quality);
323        let canvas = self.render_canvas(project, t, rw, rh, layers);
324        self.restore(host);
325        let canvas = canvas?;
326        let tex = canvas.tex;
327        self.loaned.push(canvas);
328        Some(tex)
329    }
330
331    /// Same, but read back into `out` (export / export-frame / MCP `render.frame`).
332    pub fn render_frame(&mut self, project: &Project, t: f64, w: u32, h: u32, layers: &LayerSet, out: &mut Frame) {
333        self.reclaim();
334        let host = self.host_fbo();
335        out.resize(w, h);
336        out.pts = t;
337        if w == 0 || h == 0 {
338            return;
339        }
340        let Some(canvas) = self.render_canvas(project, t, w, h, layers) else {
341            self.restore(host);
342            return;
343        };
344        let gl = self.gl.clone();
345        unsafe {
346            gl.bind_framebuffer(glow::FRAMEBUFFER, Some(canvas.fbo));
347            gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
348            gl.read_pixels(
349                0,
350                0,
351                w as i32,
352                h as i32,
353                glow::RGBA,
354                glow::UNSIGNED_BYTE,
355                glow::PixelPackData::Slice(Some(&mut out.rgba)),
356            );
357        }
358        self.pool.put(canvas);
359        self.restore(host);
360    }
361
362    /// Apply one effect to a texture (used by the effect-thumbnail grid and the node editor previews).
363    /// Render the timeline into an off-screen texture the caller can paint directly (zero-copy preview).
364    /// The texture stays owned by the renderer and is valid until the next call; `reclaim()` recycles it
365    /// on the following frame, so the caller must paint it in the same frame it asked for it.
366    pub fn render_preview_texture(
367        &mut self,
368        project: &Project,
369        t: f64,
370        w: u32,
371        h: u32,
372        layers: &LayerSet,
373    ) -> Option<(glow::Texture, u32, u32)> {
374        if w == 0 || h == 0 {
375            return None;
376        }
377        self.reclaim();
378        let host = self.host_fbo();
379        let canvas = self.render_canvas(project, t, w, h, layers);
380        self.restore(host);
381        let canvas = canvas?;
382        let out = (canvas.tex, canvas.w, canvas.h);
383        // hand it out for this frame; `loaned` returns it to the pool on the next `reclaim`
384        self.loaned.push(canvas);
385        Some(out)
386    }
387
388    /// Render `effect` over `src` and read the result back into `out` (catalogue thumbnails).
389    /// `t` is the clip-local time to sample animated parameters at. False when the effect has no GPU body.
390    pub fn effect_preview(&mut self, src: &Frame, effect: &Effect, t: f64, out: &mut Frame) -> bool {
391        if src.is_empty() {
392            return false;
393        }
394        let (w, h) = (src.width, src.height);
395        let host = self.host_fbo();
396        let tex = self.upload(u64::MAX, src);
397        let blob = (effect.kind == EffectKind::BlobTrack)
398            .then(|| crate::engine::effects::track(src, &param_values(effect, t)))
399            .flatten();
400        let dst = self.run_effect(tex, (w, h), effect, t, 1.0, None, None, blob);
401        let ok = match dst {
402            Some(target) => {
403                out.resize(w, h);
404                let gl = self.gl.clone();
405                unsafe {
406                    gl.bind_framebuffer(glow::FRAMEBUFFER, Some(target.fbo));
407                    gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
408                    gl.read_pixels(
409                        0,
410                        0,
411                        w as i32,
412                        h as i32,
413                        glow::RGBA,
414                        glow::UNSIGNED_BYTE,
415                        glow::PixelPackData::Slice(Some(&mut out.rgba)),
416                    );
417                }
418                self.pool.put(target);
419                true
420            }
421            None => false,
422        };
423        self.restore(host);
424        ok
425    }
426
427    pub fn apply_effect(
428        &mut self,
429        src: glow::Texture,
430        size: (u32, u32),
431        effect: &Effect,
432        t: f64,
433    ) -> Option<glow::Texture> {
434        let host = self.host_fbo();
435        let dst = self.run_effect(src, size, effect, t, 1.0, None, None, None);
436        self.restore(host);
437        let dst = dst?;
438        let tex = dst.tex;
439        self.loaned.push(dst);
440        Some(tex)
441    }
442
443    /// Rasterise a mask into a single-channel texture (also used by the mask editor overlay).
444    pub fn mask_texture(&mut self, mask: &Mask, size: (u32, u32), t: f64, project_w: u32) -> Option<glow::Texture> {
445        let scale = size.0 as f32 / project_w.max(1) as f32;
446        let centre = (size.0 as f32 / 2.0, size.1 as f32 / 2.0);
447        let host = self.host_fbo();
448        let target = self.render_mask(mask, size, t, scale, centre);
449        self.restore(host);
450        let target = target?;
451        let tex = target.tex;
452        self.loaned.push(target);
453        Some(tex)
454    }
455
456    /// Evaluate a node graph, returning the output texture.
457    pub fn eval_graph(
458        &mut self,
459        graph: &NodeGraph,
460        clip: &Clip,
461        t: f64,
462        fps: f64,
463        layers: &LayerSet,
464    ) -> Option<glow::Texture> {
465        let input = layers.get(clip.id).map(|f| (self.upload(clip.id, f), f.width, f.height));
466        let size = input.map(|(_, w, h)| (w, h)).filter(|s| s.0 > 0 && s.1 > 0)?;
467        let host = self.host_fbo();
468        let out = self.eval_graph_on(graph, clip, t, fps, layers, input.map(|(tex, ..)| tex), size, 1.0);
469        self.restore(host);
470        let out = out?;
471        let tex = out.tex;
472        self.loaned.push(out);
473        Some(tex)
474    }
475
476    /// Drop cached textures for a clip/asset (source replaced, project closed).
477    pub fn invalidate(&mut self, key: Option<u64>) {
478        let gl = self.gl.clone();
479        unsafe {
480            match key {
481                Some(k) => {
482                    if let Some(t) = self.textures.remove(&k) {
483                        gl.delete_texture(t.tex);
484                    }
485                }
486                None => {
487                    for (_, t) in self.textures.drain() {
488                        gl.delete_texture(t.tex);
489                    }
490                }
491            }
492        }
493    }
494
495    pub fn scaler_supported(&self, _s: Scaler) -> bool {
496        true
497    }
498
499    /// The GLSL log of the last program that failed to compile, if any.
500    pub fn last_error(&self) -> Option<&str> {
501        self.programs.failed.values().next().map(|s| s.as_str())
502    }
503
504    /// The GLSL log this user shader body was rejected with, if it ever was.
505    pub fn shader_error(&self, src: &str) -> Option<&str> {
506        self.programs.failed.get(&user_key(src)).map(|s| s.as_str())
507    }
508
509    /// Compile + link a user shader body without rendering anything, so the editor can show the log
510    /// before the effect is applied. The program lands in the cache the renderer reads.
511    pub fn check_shader(&mut self, src: &str) -> Result<(), String> {
512        let key = user_key(src);
513        if self.programs.map.contains_key(&key) {
514            return Ok(());
515        }
516        if let Some(e) = self.programs.failed.get(&key) {
517            return Err(e.clone());
518        }
519        self.link(key, &shaders::user_shader(src))
520    }
521
522    // ---------------- internals ----------------
523
524    /// The framebuffer eframe/egui had bound when it called us.
525    fn host_fbo(&self) -> Option<glow::Framebuffer> {
526        unsafe {
527            NonZeroU32::new(self.gl.get_parameter_i32(glow::DRAW_FRAMEBUFFER_BINDING) as u32)
528                .map(glow::NativeFramebuffer)
529        }
530    }
531
532    /// Put back the GL state egui assumes (its framebuffer, unit 0, no program bound).
533    fn restore(&self, host: Option<glow::Framebuffer>) {
534        unsafe {
535            self.gl.bind_framebuffer(glow::FRAMEBUFFER, host);
536            self.gl.active_texture(glow::TEXTURE0);
537            self.gl.bind_texture(glow::TEXTURE_2D, None);
538            self.gl.use_program(None);
539        }
540    }
541
542    /// Take back everything handed out during the previous frame.
543    fn reclaim(&mut self) {
544        // ponytail: one frame of lag is enough because the UI paints within the frame it asked;
545        // a fence/refcount would be needed if a texture ever outlived its frame.
546        for t in std::mem::take(&mut self.loaned) {
547            self.pool.put(t);
548        }
549    }
550
551    fn acquire(&mut self, w: u32, h: u32) -> Option<Target> {
552        if w == 0 || h == 0 {
553            return None;
554        }
555        if let Some(t) = self.pool.take(w, h) {
556            return Some(t);
557        }
558        let gl = self.gl.clone();
559        unsafe {
560            let tex = gl.create_texture().ok()?;
561            gl.bind_texture(glow::TEXTURE_2D, Some(tex));
562            tex_params(&gl);
563            gl.tex_image_2d(
564                glow::TEXTURE_2D,
565                0,
566                glow::RGBA8 as i32,
567                w as i32,
568                h as i32,
569                0,
570                glow::RGBA,
571                glow::UNSIGNED_BYTE,
572                glow::PixelUnpackData::Slice(None),
573            );
574            let fbo = gl.create_framebuffer().ok()?;
575            gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
576            gl.framebuffer_texture_2d(glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some(tex), 0);
577            if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
578                gl.delete_framebuffer(fbo);
579                gl.delete_texture(tex);
580                gl.bind_framebuffer(glow::FRAMEBUFFER, None);
581                return None;
582            }
583            gl.bind_framebuffer(glow::FRAMEBUFFER, None);
584            self.pool.created();
585            Some(Target { tex, fbo, w, h })
586        }
587    }
588
589    fn clear(&self, t: &Target, c: [f32; 4]) {
590        let gl = &self.gl;
591        unsafe {
592            gl.bind_framebuffer(glow::FRAMEBUFFER, Some(t.fbo));
593            gl.disable(glow::SCISSOR_TEST);
594            gl.disable(glow::BLEND);
595            gl.clear_color(c[0], c[1], c[2], c[3]);
596            gl.clear(glow::COLOR_BUFFER_BIT);
597        }
598    }
599
600    /// Ensure a program is compiled and cached. `Err` carries the GLSL log.
601    fn ensure(&mut self, key: u64) -> Result<(), String> {
602        if self.programs.map.contains_key(&key) {
603            return Ok(());
604        }
605        if let Some(e) = self.programs.failed.get(&key) {
606            return Err(e.clone());
607        }
608        let src = match key {
609            K_COMPOSITE => format!("{}{}{}", shaders::PRELUDE, shaders::BLEND, shaders::COMPOSITE),
610            K_MASK => format!("{}{}", shaders::PRELUDE, shaders::MASK),
611            K_COPY => format!("{}\n{}\n{}", shaders::PRELUDE, COPY_BODY, shaders::MAIN),
612            K_MATTE => format!("{}\n{}\n{}", shaders::PRELUDE, MATTE_BODY, shaders::MAIN),
613            _ => return Err("no source for this program".into()),
614        };
615        self.link(key, &src)
616    }
617
618    fn link(&mut self, key: u64, src: &str) -> Result<(), String> {
619        let gl = self.gl.clone();
620        let r = unsafe { build(&gl, self.vert, src) };
621        match r {
622            Ok(p) => {
623                self.programs.map.insert(key, p);
624                Ok(())
625            }
626            Err(e) => {
627                // A failed effect program is otherwise a silent no-op: the chain just skips it.
628                // `last_error()` is what the UI should toast; this keeps a dev run from guessing.
629                if cfg!(debug_assertions) {
630                    eprintln!("GLSL program {key:#x} failed to build:\n{e}");
631                }
632                self.programs.failed.insert(key, e.clone());
633                Err(e)
634            }
635        }
636    }
637
638    /// Compile (once) the program for an effect; None when the kind has no GPU body.
639    fn effect_program(&mut self, effect: &Effect) -> Option<u64> {
640        let key =
641            if effect.kind == EffectKind::Shader { user_key(&effect.shader) } else { EFFECT_BASE + effect.kind as u64 };
642        if self.programs.map.contains_key(&key) {
643            return Some(key);
644        }
645        if self.programs.failed.contains_key(&key) {
646            return None;
647        }
648        let src = shaders::fragment(effect.kind, &effect.shader)?;
649        self.link(key, &src).ok().map(|_| key)
650    }
651
652    /// Bind `prog`, point the fixed sampler units at `tex`/`mask`, size uniforms, and draw the triangle.
653    fn draw(&self, key: u64, dst: &Target, tex: glow::Texture, mask: Option<glow::Texture>, set: impl Fn(&Prog)) {
654        let Some(prog) = self.programs.map.get(&key) else { return };
655        let gl = &self.gl;
656        unsafe {
657            gl.bind_framebuffer(glow::FRAMEBUFFER, Some(dst.fbo));
658            gl.viewport(0, 0, dst.w as i32, dst.h as i32);
659            gl.disable(glow::BLEND);
660            gl.disable(glow::SCISSOR_TEST);
661            gl.disable(glow::DEPTH_TEST);
662            gl.use_program(Some(prog.p));
663            gl.active_texture(glow::TEXTURE0 + U_TEX);
664            gl.bind_texture(glow::TEXTURE_2D, Some(tex));
665            gl.uniform_1_i32(prog.uni.get("tex"), U_TEX as i32);
666            gl.active_texture(glow::TEXTURE0 + U_MASK);
667            gl.bind_texture(glow::TEXTURE_2D, Some(mask.unwrap_or(self.blank)));
668            gl.uniform_1_i32(prog.uni.get("u_mask"), U_MASK as i32);
669            gl.uniform_1_i32(prog.uni.get("u_has_mask"), i32::from(mask.is_some()));
670            set(prog);
671            gl.bind_vertex_array(Some(self.vao));
672            gl.draw_arrays(glow::TRIANGLES, 0, 3);
673            gl.bind_vertex_array(None);
674        }
675    }
676
677    /// Copy `src` (stretched to the target) into a fresh target.
678    fn copy_to(&mut self, src: glow::Texture, size: (u32, u32)) -> Option<Target> {
679        self.ensure(K_COPY).ok()?;
680        let dst = self.acquire(size.0, size.1)?;
681        let gl = self.gl.clone();
682        self.draw(K_COPY, &dst, src, None, |p| unsafe {
683            gl.uniform_2_f32(p.uni.get("u_res"), size.0 as f32, size.1 as f32);
684        });
685        Some(dst)
686    }
687
688    /// One effect pass: `src` -> a new target of the same size. None when the effect has no GPU body.
689    /// `motion` is the (prev, next, count) sampler set a `needs_motion` kind wants; anything else
690    /// passes None.
691    #[allow(clippy::too_many_arguments)]
692    fn run_effect(
693        &mut self,
694        src: glow::Texture,
695        size: (u32, u32),
696        effect: &Effect,
697        t: f64,
698        scale: f32,
699        mask: Option<glow::Texture>,
700        motion: Option<(glow::Texture, glow::Texture, f32)>,
701        // BlobTrack only: (cx, cy, area) from engine::effects::track, in 0..1 layer coordinates
702        blob: Option<(f64, f64, f64)>,
703    ) -> Option<Target> {
704        let key = self.effect_program(effect)?;
705        let dst = self.acquire(size.0, size.1)?;
706        let gl = self.gl.clone();
707        let mut params = [0.0f32; PARAM_NAMES.len()];
708        for (i, p) in params.iter_mut().enumerate() {
709            *p = effect.at(i, t) as f32;
710        }
711        // uniforms persist on a cached program, so a motion kind must set these every draw
712        let motion = effect.kind.needs_motion().then(|| motion.unwrap_or((self.blank, self.blank, 0.0)));
713        self.draw(key, &dst, src, mask, |prog| unsafe {
714            gl.uniform_2_f32(prog.uni.get("u_res"), size.0 as f32, size.1 as f32);
715            gl.uniform_1_f32(prog.uni.get("u_time"), t as f32);
716            gl.uniform_1_f32(prog.uni.get("u_scale"), scale);
717            for (i, v) in params.iter().enumerate() {
718                gl.uniform_1_f32(prog.uni.get(PARAM_NAMES[i]), *v);
719            }
720            if let Some((prev, next, frames)) = motion {
721                gl.active_texture(glow::TEXTURE0 + U_PREV);
722                gl.bind_texture(glow::TEXTURE_2D, Some(prev));
723                gl.uniform_1_i32(prog.uni.get("u_prev"), U_PREV as i32);
724                gl.active_texture(glow::TEXTURE0 + U_NEXT);
725                gl.bind_texture(glow::TEXTURE_2D, Some(next));
726                gl.uniform_1_i32(prog.uni.get("u_next"), U_NEXT as i32);
727                gl.uniform_1_f32(prog.uni.get("u_frames"), frames);
728                // ponytail: no per-frame placement delta yet, so the 0-sample fallback (clip edge)
729                // blurs by nothing rather than guessing a direction.
730                gl.uniform_2_f32(prog.uni.get("u_motion"), 0.0, 0.0);
731            }
732            if effect.kind == EffectKind::BlobTrack {
733                // zero area = "nothing matched", which the shader reads as "draw no crosshair"
734                let (cx, cy, area) = blob.unwrap_or((0.5, 0.5, 0.0));
735                gl.uniform_3_f32(prog.uni.get("u_blob"), cx as f32, cy as f32, area as f32);
736            }
737        });
738        Some(dst)
739    }
740
741    /// `BlobTrack`: find the target colour's centroid on the clip's decoded frame. The tracking runs on
742    /// the CPU copy the decoder already produced, so no readback is needed; the shader only draws it.
743    fn track_blob(&self, effect: &Effect, clip: Id, t: f64, layers: &LayerSet) -> Option<(f64, f64, f64)> {
744        if effect.kind != EffectKind::BlobTrack {
745            return None;
746        }
747        let frame = layers.get(clip)?;
748        crate::engine::effects::track(frame, &param_values(effect, t))
749    }
750
751    /// The neighbouring frames a `needs_motion` effect wants: (prev, next, how many are real).
752    /// `LayerSet::motion` carries whatever the decoder managed to produce — 0, 1 or 2 samples.
753    fn motion_texs(&mut self, id: Id, layers: &LayerSet) -> (glow::Texture, glow::Texture, f32) {
754        let (mut before, mut after) = (None, None);
755        let (mut bo, mut ao) = (0.0f64, 0.0f64);
756        for (cid, off, f) in &layers.motion {
757            if *cid != id || f.is_empty() {
758                continue;
759            }
760            if *off < bo {
761                bo = *off;
762                before = Some(f);
763            } else if *off > ao {
764                ao = *off;
765                after = Some(f);
766            }
767        }
768        match (before, after) {
769            (None, None) => (self.blank, self.blank, 0.0),
770            (Some(f), None) => {
771                let tex = self.upload(id | MOTION_PREV, f);
772                (tex, tex, 1.0)
773            }
774            (None, Some(f)) => {
775                let tex = self.upload(id | MOTION_NEXT, f);
776                (tex, tex, 1.0)
777            }
778            (Some(p), Some(n)) => {
779                let prev = self.upload(id | MOTION_PREV, p);
780                let next = self.upload(id | MOTION_NEXT, n);
781                (prev, next, 2.0)
782            }
783        }
784    }
785
786    /// Rasterise `mask` at `size`; `scale` is canvas px per project px and `centre` the shape origin.
787    fn render_mask(&mut self, mask: &Mask, size: (u32, u32), t: f64, scale: f32, centre: (f32, f32)) -> Option<Target> {
788        self.ensure(K_MASK).ok()?;
789        let dst = self.acquire(size.0, size.1)?;
790        let gl = self.gl.clone();
791        let mut pts = Vec::with_capacity(mask.points.len().min(MAX_MASK_POINTS) * 2);
792        for (x, y) in mask.points.iter().take(MAX_MASK_POINTS) {
793            pts.push(centre.0 + x * scale);
794            pts.push(centre.1 + y * scale);
795        }
796        let n = (pts.len() / 2) as i32;
797        let shape = match mask.shape {
798            MaskShape::Rect => 0,
799            MaskShape::Ellipse => 1,
800            // ponytail: Path is rasterised as a polygon through its points — the editor densifies
801            // curves, so a real bezier SDF only matters for very sparse paths.
802            MaskShape::Polygon | MaskShape::Path => 2,
803        };
804        let (cx, cy) = (centre.0 + mask.cx.at(t) as f32 * scale, centre.1 + mask.cy.at(t) as f32 * scale);
805        let (rx, ry) = (mask.rx.at(t) as f32 * scale, mask.ry.at(t) as f32 * scale);
806        self.draw(K_MASK, &dst, self.blank, None, |p| unsafe {
807            gl.uniform_2_f32(p.uni.get("u_res"), size.0 as f32, size.1 as f32);
808            gl.uniform_1_i32(p.uni.get("m_shape"), shape);
809            gl.uniform_2_f32(p.uni.get("m_center"), cx, cy);
810            gl.uniform_2_f32(p.uni.get("m_radius"), rx.abs(), ry.abs());
811            gl.uniform_1_f32(p.uni.get("m_rot"), mask.rotation.at(t) as f32);
812            gl.uniform_1_f32(p.uni.get("m_feather"), (mask.feather.at(t) as f32 * scale).max(0.0));
813            gl.uniform_1_f32(p.uni.get("m_expand"), mask.expand.at(t) as f32 * scale);
814            gl.uniform_1_f32(p.uni.get("m_opacity"), mask.opacity.at(t).clamp(0.0, 1.0) as f32);
815            gl.uniform_1_i32(p.uni.get("m_invert"), i32::from(mask.invert));
816            gl.uniform_1_i32(p.uni.get("m_count"), n);
817            if !pts.is_empty() {
818                gl.uniform_2_f32_slice(p.uni.get("m_points"), &pts);
819            }
820        });
821        Some(dst)
822    }
823
824    /// Composite `layer` onto `canvas` through `inv` (canvas px -> layer uv). Consumes and returns the
825    /// canvas (ping-pong: you cannot sample the framebuffer you are drawing into).
826    #[allow(clippy::too_many_arguments)]
827    fn composite(
828        &mut self,
829        canvas: Target,
830        layer: glow::Texture,
831        layer_size: (u32, u32),
832        inv: [[f32; 3]; 3],
833        mode: BlendMode,
834        opacity: f32,
835        scaler: Scaler,
836        mask: Option<glow::Texture>,
837    ) -> Target {
838        if self.ensure(K_COMPOSITE).is_err() {
839            return canvas;
840        }
841        let Some(dst) = self.acquire(canvas.w, canvas.h) else { return canvas };
842        let gl = self.gl.clone();
843        let canvas_tex = canvas.tex;
844        let m = column_major(&inv);
845        let (cw, ch) = (canvas.w as f32, canvas.h as f32);
846        let mode_i = blend_index(mode);
847        let scaler_i = scaler_index(scaler);
848        self.draw(K_COMPOSITE, &dst, layer, mask, |p| unsafe {
849            gl.uniform_2_f32(p.uni.get("u_res"), cw, ch);
850            gl.uniform_2_f32(p.uni.get("u_layer"), layer_size.0 as f32, layer_size.1 as f32);
851            gl.uniform_matrix_3_f32_slice(p.uni.get("u_inv"), false, &m);
852            gl.uniform_1_i32(p.uni.get("u_scaler"), scaler_i);
853            gl.uniform_1_i32(p.uni.get("u_mode"), mode_i);
854            gl.uniform_1_f32(p.uni.get("u_opacity"), opacity);
855            gl.active_texture(glow::TEXTURE0 + U_DST);
856            gl.bind_texture(glow::TEXTURE_2D, Some(canvas_tex));
857            gl.uniform_1_i32(p.uni.get("u_dst"), U_DST as i32);
858        });
859        self.pool.put(canvas);
860        dst
861    }
862
863    /// The whole timeline at `t` into a fresh canvas target.
864    fn render_canvas(&mut self, project: &Project, t: f64, w: u32, h: u32, layers: &LayerSet) -> Option<Target> {
865        let mut canvas = self.acquire(w, h)?;
866        self.clear(&canvas, [0.0, 0.0, 0.0, 1.0]);
867        for (ti, track) in project.tracks.iter().enumerate() {
868            if track.kind != TrackKind::Video || !project.active(ti) {
869                continue;
870            }
871            if let Some((tr, left, right)) = track.transition_at(t) {
872                let p = crate::engine::compose::trans_progress(tr, left, right, t) as f32;
873                canvas = self.draw_transition(project, tr, left, right, p, t, canvas, layers);
874                continue;
875            }
876            for clip in &track.clips {
877                if !clip.enabled || !clip.contains(t) {
878                    continue;
879                }
880                canvas = self.draw_clip(project, clip, t, canvas, layers, Extra::NONE);
881            }
882        }
883        if project.show_subtitles {
884            if let Some(sub) = layers.get(LayerSet::SUBTITLES) {
885                canvas = self.draw_subtitles(project, sub, canvas);
886            }
887        }
888        Some(canvas)
889    }
890
891    /// Both sides of a transition, per kind (mirrors `compose::render_transition`). Edge transitions
892    /// have one side missing (In: no left, Out: no right): that side is nothing — the black canvas.
893    #[allow(clippy::too_many_arguments)]
894    fn draw_transition(
895        &mut self,
896        project: &Project,
897        tr: &crate::model::Transition,
898        left: Option<&Clip>,
899        right: Option<&Clip>,
900        p: f32,
901        t: f64,
902        mut canvas: Target,
903        layers: &LayerSet,
904    ) -> Target {
905        use crate::model::TransitionKind::*;
906        let (w, h) = (canvas.w as f32, canvas.h as f32);
907        match tr.kind {
908            CrossFade => {
909                if let Some(left) = left {
910                    // fading out to nothing, the outgoing clip itself thins; on a cut it stays opaque
911                    let op = if right.is_none() { 1.0 - p } else { 1.0 };
912                    canvas = self.draw_clip(project, left, t, canvas, layers, Extra { opacity: op, ..Extra::NONE });
913                }
914                if let Some(right) = right {
915                    canvas = self.draw_clip(project, right, t, canvas, layers, Extra { opacity: p, ..Extra::NONE });
916                }
917                canvas
918            }
919            FadeToColor => {
920                // A→colour for p < 0.5, colour→B after; an edge fades one clip over the whole window
921                let (clip, fade) = match (left, right) {
922                    (Some(l), Some(r)) => {
923                        if p < 0.5 {
924                            (l, 2.0 * p)
925                        } else {
926                            (r, 2.0 * (1.0 - p))
927                        }
928                    }
929                    (Some(l), None) => (l, p),
930                    (None, Some(r)) => (r, 1.0 - p),
931                    (None, None) => return canvas,
932                };
933                canvas = self.draw_clip(project, clip, t, canvas, layers, Extra::NONE);
934                self.fill_canvas(canvas, tr.color, fade.clamp(0.0, 1.0))
935            }
936            Push => {
937                let (dx, dy) = match tr.direction {
938                    0 => (-w, 0.0),
939                    1 => (w, 0.0),
940                    2 => (0.0, -h),
941                    _ => (0.0, h),
942                };
943                if let Some(left) = left {
944                    let a = Extra { dx: -p * dx, dy: -p * dy, ..Extra::NONE };
945                    canvas = self.draw_clip(project, left, t, canvas, layers, a);
946                }
947                if let Some(right) = right {
948                    let b = Extra { dx: (1.0 - p) * dx, dy: (1.0 - p) * dy, ..Extra::NONE };
949                    canvas = self.draw_clip(project, right, t, canvas, layers, b);
950                }
951                canvas
952            }
953            Wipe => {
954                // on a cut / In edge the incoming clip is limited to the wiped rectangle by a rect
955                // mask; on an Out edge black wipes over the clip, so the clip is drawn masked to the
956                // complement instead: the opposite direction's rectangle at 1 - p
957                let (clip, d, q) = match (left, right) {
958                    (l, Some(r)) => {
959                        if let Some(l) = l {
960                            canvas = self.draw_clip(project, l, t, canvas, layers, Extra::NONE);
961                        }
962                        (r, tr.direction, p)
963                    }
964                    (Some(l), None) => (l, tr.direction ^ 1, 1.0 - p),
965                    (None, None) => return canvas,
966                };
967                let (cx, cy, rx, ry) = match d {
968                    0 => (w * q / 2.0, h / 2.0, w * q / 2.0, h / 2.0),
969                    1 => (w - w * q / 2.0, h / 2.0, w * q / 2.0, h / 2.0),
970                    2 => (w / 2.0, h * q / 2.0, w / 2.0, h * q / 2.0),
971                    _ => (w / 2.0, h - h * q / 2.0, w / 2.0, h * q / 2.0),
972                };
973                let mut rect = Mask::new(MaskShape::Rect);
974                rect.rx = crate::model::Animated::new(rx as f64);
975                rect.ry = crate::model::Animated::new(ry as f64);
976                let size = (canvas.w, canvas.h);
977                let Some(mask) = self.render_mask(&rect, size, 0.0, 1.0, (cx, cy)) else { return canvas };
978                let extra = Extra { mask: Some(mask.tex), ..Extra::NONE };
979                let out = self.draw_clip(project, clip, t, canvas, layers, extra);
980                self.pool.put(mask);
981                out
982            }
983        }
984    }
985
986    /// Mix the whole canvas towards `color` by `f` (FadeToColor).
987    fn fill_canvas(&mut self, canvas: Target, color: [u8; 4], f: f32) -> Target {
988        if f <= 0.0 {
989            return canvas;
990        }
991        let Some(fill) = self.acquire(1, 1) else { return canvas };
992        self.clear(&fill, [color[0] as f32 / 255.0, color[1] as f32 / 255.0, color[2] as f32 / 255.0, 1.0]);
993        let inv = [[1.0 / canvas.w as f32, 0.0, 0.0], [0.0, 1.0 / canvas.h as f32, 0.0], [0.0, 0.0, 1.0]];
994        let out = self.composite(canvas, fill.tex, (1, 1), inv, BlendMode::Normal, f.min(1.0), Scaler::Nearest, None);
995        self.pool.put(fill);
996        out
997    }
998
999    /// The subtitle bitmap, bottom-centre (mirrors `compose::render`).
1000    fn draw_subtitles(&mut self, project: &Project, sub: &Arc<Frame>, canvas: Target) -> Target {
1001        if sub.is_empty() {
1002            return canvas;
1003        }
1004        let (w, h) = (canvas.w, canvas.h);
1005        let sv = h as f32 / project.height.max(1) as f32;
1006        let p = crate::engine::compose::Placement {
1007            cx: w as f32 / 2.0,
1008            cy: h as f32 - project.subtitle_margin * sv - sub.height as f32 / 2.0,
1009            w: sub.width as f32,
1010            h: sub.height as f32,
1011            rot: 0.0,
1012            yaw: 0.0,
1013            pitch: 0.0,
1014        };
1015        let Some(inv) = inverse_placement(&p, w as f32, 0.0) else { return canvas };
1016        let tex = self.upload(LayerSet::SUBTITLES, sub);
1017        let size = (sub.width, sub.height);
1018        self.composite(canvas, tex, size, inv, BlendMode::Normal, 1.0, project.scaler, None)
1019    }
1020
1021    /// One clip: layer -> chain -> composite. Returns the (possibly new) canvas.
1022    fn draw_clip(
1023        &mut self,
1024        project: &Project,
1025        clip: &Clip,
1026        t: f64,
1027        canvas: Target,
1028        layers: &LayerSet,
1029        extra: Extra,
1030    ) -> Target {
1031        let lt = clip.local(t);
1032        let opacity = clip.opacity.at(lt).clamp(0.0, 1.0) as f32 * extra.opacity * clip.fade_mult(lt) as f32;
1033        if opacity <= 0.0 || clip.kind == ClipKind::Audio {
1034            return canvas;
1035        }
1036        let (cw, ch) = (canvas.w, canvas.h);
1037        let s = cw as f32 / project.width.max(1) as f32;
1038
1039        if clip.kind == ClipKind::Adjustment {
1040            // re-process everything already on the canvas
1041            let Some(out) = self.run_chain(clip, t, project.fps, layers, canvas.tex, (cw, ch), s) else {
1042                return canvas;
1043            };
1044            self.pool.put(canvas);
1045            return out;
1046        }
1047
1048        let Some(frame) = layers.get(clip.id) else { return canvas };
1049        if frame.is_empty() {
1050            return canvas;
1051        }
1052        let contain = !matches!(clip.kind, ClipKind::Text | ClipKind::Shape);
1053        let native = self.native_size(project, clip, frame);
1054        let mut p = crate::engine::compose::placement(project, clip, t, native, cw, ch, contain);
1055        if !(p.w.is_finite() && p.h.is_finite() && p.w > 0.0 && p.h > 0.0) {
1056            return canvas;
1057        }
1058        // geometric effects move the layer instead of touching pixels
1059        let (mut focal, mut z0) = (cw as f32, 0.0f32);
1060        for e in clip.effects.iter().filter(|e| e.on_at(lt) && e.kind.is_geometric()) {
1061            match e.kind {
1062                EffectKind::Wobble => {
1063                    let (dx, dy, roll, yaw, pitch) = crate::engine::effects::wobble(e, lt);
1064                    p.cx += dx as f32 * s;
1065                    p.cy += dy as f32 * s;
1066                    p.rot += roll as f32;
1067                    p.yaw += yaw as f32;
1068                    p.pitch += pitch as f32;
1069                }
1070                EffectKind::Plane3d => {
1071                    p.yaw += e.at(0, lt) as f32;
1072                    p.pitch += e.at(1, lt) as f32;
1073                    p.rot += e.at(2, lt) as f32;
1074                    let fov = (e.at(4, lt) as f32).clamp(1.0, 179.0).to_radians();
1075                    focal = (cw as f32 / 2.0) / (fov / 2.0).tan() * (e.at(3, lt) as f32 / 2.0).max(0.01);
1076                    z0 = e.at(5, lt) as f32 * cw as f32;
1077                }
1078                _ => {}
1079            }
1080        }
1081        p.cx += extra.dx;
1082        p.cy += extra.dy;
1083        let Some(inv) = inverse_placement(&p, focal, z0) else { return canvas };
1084
1085        let tex = self.upload(clip.id, frame);
1086        let lsize = (frame.width, frame.height);
1087        // effect scale: layer px per project px (matches compose::apply_effects' img_scale — footage
1088        // is decoded at roughly the placed size, text/shape bitmaps arrive at canvas scale already)
1089        let img_scale = if contain { s * (lsize.0 as f32 / p.w.max(1e-3)) } else { s };
1090        let processed = self.run_chain(clip, t, project.fps, layers, tex, lsize, img_scale);
1091        let (src, size, own) = match &processed {
1092            Some(target) => (target.tex, (target.w, target.h), true),
1093            None => (tex, lsize, false),
1094        };
1095        let mask = match (extra.mask, &clip.mask) {
1096            (Some(m), _) => Some(MaskTex::Borrowed(m)),
1097            (None, Some(m)) if m.enabled => self.render_mask(m, (cw, ch), lt, s, (p.cx, p.cy)).map(MaskTex::Owned),
1098            _ => None,
1099        };
1100        let mask_tex = mask.as_ref().map(|m| m.tex());
1101        let out = self.composite(canvas, src, size, inv, clip.blend, opacity, project.scaler, mask_tex);
1102        if let Some(MaskTex::Owned(t)) = mask {
1103            self.pool.put(t);
1104        }
1105        if own {
1106            if let Some(t) = processed {
1107                self.pool.put(t);
1108            }
1109        }
1110        out
1111    }
1112
1113    /// The clip's effect chain (node graph when it has one, else the linear stack). None = unchanged.
1114    #[allow(clippy::too_many_arguments)]
1115    fn run_chain(
1116        &mut self,
1117        clip: &Clip,
1118        t: f64,
1119        fps: f64,
1120        layers: &LayerSet,
1121        src: glow::Texture,
1122        size: (u32, u32),
1123        scale: f32,
1124    ) -> Option<Target> {
1125        let lt = clip.local(t);
1126        if let Some(g) = clip.graph.as_ref().filter(|_| clip.uses_graph()) {
1127            return self.eval_graph_on(g, clip, t, fps, layers, Some(src), size, scale);
1128        }
1129        let mut cur: Option<Target> = None;
1130        for e in &clip.effects {
1131            if !e.on_at(lt) || e.kind.is_geometric() {
1132                continue;
1133            }
1134            let input = cur.as_ref().map(|t| t.tex).unwrap_or(src);
1135            let mask = e
1136                .mask
1137                .as_ref()
1138                .filter(|m| m.enabled)
1139                .and_then(|m| self.render_mask(m, size, lt, scale, (size.0 as f32 / 2.0, size.1 as f32 / 2.0)));
1140            let motion = e.kind.needs_motion().then(|| self.motion_texs(clip.id, layers));
1141            let blob = self.track_blob(e, clip.id, lt, layers);
1142            let next = self.run_effect(input, size, e, lt, scale, mask.as_ref().map(|m| m.tex), motion, blob);
1143            if let Some(m) = mask {
1144                self.pool.put(m);
1145            }
1146            if let Some(n) = next {
1147                if let Some(old) = cur.replace(n) {
1148                    self.pool.put(old);
1149                }
1150            }
1151        }
1152        cur
1153    }
1154
1155    /// Walk `graph` in evaluation order, one target per node. Missing inputs are transparent.
1156    #[allow(clippy::too_many_arguments)]
1157    fn eval_graph_on(
1158        &mut self,
1159        graph: &NodeGraph,
1160        clip: &Clip,
1161        t: f64,
1162        fps: f64,
1163        layers: &LayerSet,
1164        input: Option<glow::Texture>,
1165        size: (u32, u32),
1166        scale: f32,
1167    ) -> Option<Target> {
1168        let lt = clip.local(t);
1169        let order = graph.eval_order();
1170        // the logic half of the graph, resolved once for the whole frame
1171        let vals = graph.eval_values(lt, fps);
1172        let mut done: Vec<(Id, Target)> = Vec::with_capacity(order.len());
1173        let out_id = graph.output()?;
1174        for id in order {
1175            let Some(node) = graph.node(id) else { continue };
1176            let port = |p: usize| graph.input_of(id, p);
1177            let tex_of = |done: &Vec<(Id, Target)>, nid: Option<Id>| -> Option<glow::Texture> {
1178                let nid = nid?;
1179                done.iter().find(|(i, _)| *i == nid).map(|(_, t)| t.tex)
1180            };
1181            let a = tex_of(&done, port(0));
1182            let b = tex_of(&done, port(1));
1183            let result = if !node.enabled && node.kind.inputs() > 0 {
1184                a.and_then(|tex| self.copy_to(tex, size))
1185            } else {
1186                match &node.kind {
1187                    NodeKind::Input => match input {
1188                        Some(tex) => self.copy_to(tex, size),
1189                        None => self.transparent(size),
1190                    },
1191                    NodeKind::Color(c) => {
1192                        let target = self.acquire(size.0, size.1);
1193                        if let Some(target) = &target {
1194                            let a = c[3] as f32 / 255.0;
1195                            self.clear(target, [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, a]);
1196                        }
1197                        target
1198                    }
1199                    // an asset layer is decoded by the caller under the asset's own id (ids are unique
1200                    // project-wide), so both of these are just "someone else's bitmap"
1201                    NodeKind::Clip(other) | NodeKind::Asset(other) => match layers.get(*other) {
1202                        Some(f) if !f.is_empty() => {
1203                            let tex = self.upload(*other, f);
1204                            self.copy_to(tex, size)
1205                        }
1206                        _ => self.transparent(size),
1207                    },
1208                    NodeKind::String(style) => self.text_node(id, style, t, lt, fps, scale, size),
1209                    // a number is a grey card at its value, so logic can be used as a matte as it is
1210                    NodeKind::Number(_)
1211                    | NodeKind::Bool(_)
1212                    | NodeKind::Random { .. }
1213                    | NodeKind::Math(_)
1214                    | NodeKind::Compare(_)
1215                    | NodeKind::Logic(_) => {
1216                        let v = vals.get(&id).copied().unwrap_or(0.0).clamp(0.0, 1.0) as f32;
1217                        let target = self.acquire(size.0, size.1);
1218                        if let Some(target) = &target {
1219                            self.clear(target, [v, v, v, 1.0]);
1220                        }
1221                        target
1222                    }
1223                    NodeKind::Select => {
1224                        let cond = graph.input_of(id, 0).and_then(|f| vals.get(&f).copied()).unwrap_or(0.0);
1225                        match if cond >= 0.5 { b } else { tex_of(&done, port(2)) } {
1226                            Some(tex) => self.copy_to(tex, size),
1227                            None => self.transparent(size),
1228                        }
1229                    }
1230                    NodeKind::Effect(_) if a.is_none() => self.transparent(size),
1231                    NodeKind::Effect(e) => {
1232                        let owned = driven(e, graph, id, &vals);
1233                        let e = &*owned;
1234                        let src = a.unwrap_or(self.blank);
1235                        let mask = e.mask.as_ref().filter(|m| m.enabled).and_then(|m| {
1236                            self.render_mask(m, size, lt, scale, (size.0 as f32 / 2.0, size.1 as f32 / 2.0))
1237                        });
1238                        let motion = e.kind.needs_motion().then(|| self.motion_texs(clip.id, layers));
1239                        let blob = self.track_blob(e, clip.id, lt, layers);
1240                        let r = self.run_effect(src, size, e, lt, scale, mask.as_ref().map(|m| m.tex), motion, blob);
1241                        if let Some(m) = mask {
1242                            self.pool.put(m);
1243                        }
1244                        // an effect with no GPU body passes its input through
1245                        match r {
1246                            Some(target) => Some(target),
1247                            None => self.copy_to(src, size),
1248                        }
1249                    }
1250                    // Blend / Combine / Merge are all "b onto a" — mode and amount are the only difference
1251                    NodeKind::Blend { .. } | NodeKind::Combine { .. } | NodeKind::Merge => {
1252                        let (mode, amount) = match &node.kind {
1253                            NodeKind::Blend { mode, opacity } => (*mode, opacity.at(lt)),
1254                            NodeKind::Combine { mode, factor } => (*mode, factor.at(lt)),
1255                            _ => (BlendMode::Normal, 1.0),
1256                        };
1257                        // port 2 (when wired) computes the amount instead
1258                        let amount = graph.input_of(id, 2).and_then(|f| vals.get(&f).copied()).unwrap_or(amount);
1259                        let under = match a {
1260                            Some(tex) => self.copy_to(tex, size),
1261                            None => self.transparent(size),
1262                        };
1263                        match (under, b) {
1264                            (Some(under), Some(over)) => {
1265                                let inv =
1266                                    [[1.0 / size.0 as f32, 0.0, 0.0], [0.0, 1.0 / size.1 as f32, 0.0], [0.0, 0.0, 1.0]];
1267                                let op = amount.clamp(0.0, 1.0) as f32;
1268                                Some(self.composite(under, over, size, inv, mode, op, Scaler::Bilinear, None))
1269                            }
1270                            (u, _) => u,
1271                        }
1272                    }
1273                    NodeKind::Matte { invert, use_alpha } => match (a, b) {
1274                        (Some(a), Some(b)) => self.matte(a, b, size, *invert, *use_alpha),
1275                        (Some(a), None) => self.copy_to(a, size),
1276                        _ => self.transparent(size),
1277                    },
1278                    NodeKind::Mask(m) => {
1279                        self.render_mask(m, size, lt, scale, (size.0 as f32 / 2.0, size.1 as f32 / 2.0))
1280                    }
1281                    NodeKind::Output => match a {
1282                        Some(tex) => self.copy_to(tex, size),
1283                        None => self.transparent(size),
1284                    },
1285                }
1286            };
1287            if let Some(r) = result {
1288                done.push((id, r));
1289            }
1290        }
1291        self.finish_graph(done, out_id)
1292    }
1293
1294    /// Keep the output node's target, recycle the rest.
1295    fn finish_graph(&mut self, mut done: Vec<(Id, Target)>, out_id: Id) -> Option<Target> {
1296        let idx = done.iter().position(|(i, _)| *i == out_id);
1297        let out = idx.map(|i| done.swap_remove(i).1);
1298        for (_, t) in done {
1299            self.pool.put(t);
1300        }
1301        out
1302    }
1303
1304    fn transparent(&mut self, size: (u32, u32)) -> Option<Target> {
1305        let t = self.acquire(size.0, size.1)?;
1306        self.clear(&t, [0.0; 4]);
1307        Some(t)
1308    }
1309
1310    /// A Text node: expand the format, rasterise it (shared `TextRasterizer`) and centre it on an
1311    /// otherwise transparent canvas. A counter re-rasterises every frame — that is what it is for.
1312    #[allow(clippy::too_many_arguments)]
1313    fn text_node(
1314        &mut self,
1315        id: Id,
1316        style: &crate::model::TextStyle,
1317        t: f64,
1318        lt: f64,
1319        fps: f64,
1320        scale: f32,
1321        size: (u32, u32),
1322    ) -> Option<Target> {
1323        let rasterizer = self.text.clone()?;
1324        let mut style = style.clone();
1325        style.text = crate::model::expand_text(&style.text, t, lt, fps);
1326        let img = rasterizer.lock().ok()?.render(&style, scale);
1327        let base = self.transparent(size)?;
1328        if img.is_empty() || (img.width <= 1 && img.height <= 1) {
1329            return Some(base);
1330        }
1331        let p = crate::engine::compose::Placement {
1332            cx: size.0 as f32 / 2.0,
1333            cy: size.1 as f32 / 2.0,
1334            w: img.width as f32,
1335            h: img.height as f32,
1336            rot: 0.0,
1337            yaw: 0.0,
1338            pitch: 0.0,
1339        };
1340        let Some(inv) = inverse_placement(&p, size.0 as f32, 0.0) else { return Some(base) };
1341        let tex = self.upload(id, &img);
1342        let wh = (img.width, img.height);
1343        Some(self.composite(base, tex, wh, inv, BlendMode::Normal, 1.0, Scaler::Bilinear, None))
1344    }
1345
1346    fn matte(
1347        &mut self,
1348        a: glow::Texture,
1349        b: glow::Texture,
1350        size: (u32, u32),
1351        invert: bool,
1352        use_alpha: bool,
1353    ) -> Option<Target> {
1354        self.ensure(K_MATTE).ok()?;
1355        let dst = self.acquire(size.0, size.1)?;
1356        let gl = self.gl.clone();
1357        self.draw(K_MATTE, &dst, a, None, |p| unsafe {
1358            gl.uniform_2_f32(p.uni.get("u_res"), size.0 as f32, size.1 as f32);
1359            gl.active_texture(glow::TEXTURE0 + U_B);
1360            gl.bind_texture(glow::TEXTURE_2D, Some(b));
1361            gl.uniform_1_i32(p.uni.get("u_b"), U_B as i32);
1362            gl.uniform_1_i32(p.uni.get("u_matte_alpha"), i32::from(use_alpha));
1363            gl.uniform_1_i32(p.uni.get("u_matte_invert"), i32::from(invert));
1364        });
1365        Some(dst)
1366    }
1367
1368    /// The layer's "native" size for placement: the asset/sequence size for footage, the bitmap size
1369    /// for anything the caller rendered for us.
1370    fn native_size(&self, project: &Project, clip: &Clip, frame: &Frame) -> (u32, u32) {
1371        let wh = match clip.kind {
1372            ClipKind::Video | ClipKind::Image => project.asset(clip.asset).map(|a| (a.width, a.height)),
1373            ClipKind::Sequence => project.sequence(clip.sequence).map(|s| (s.width, s.height)),
1374            _ => None,
1375        };
1376        match wh {
1377            Some((w, h)) if w > 0 && h > 0 => (w, h),
1378            _ => (frame.width.max(1), frame.height.max(1)),
1379        }
1380    }
1381}
1382
1383impl Drop for GpuRenderer {
1384    fn drop(&mut self) {
1385        let gl = self.gl.clone();
1386        unsafe {
1387            for (_, t) in self.textures.drain() {
1388                gl.delete_texture(t.tex);
1389            }
1390            for t in self.loaned.drain(..).chain(self.pool.free.drain(..)) {
1391                gl.delete_framebuffer(t.fbo);
1392                gl.delete_texture(t.tex);
1393            }
1394            for (_, p) in self.programs.map.drain() {
1395                gl.delete_program(p.p);
1396            }
1397            gl.delete_shader(self.vert);
1398            gl.delete_texture(self.blank);
1399            gl.delete_vertex_array(self.vao);
1400        }
1401    }
1402}
1403
1404/// Either a mask we rendered (and must recycle) or one the caller owns.
1405enum MaskTex {
1406    Owned(Target),
1407    Borrowed(glow::Texture),
1408}
1409
1410impl MaskTex {
1411    fn tex(&self) -> glow::Texture {
1412        match self {
1413            MaskTex::Owned(t) => t.tex,
1414            MaskTex::Borrowed(t) => *t,
1415        }
1416    }
1417}
1418
1419/// Per-clip render tweaks used by transitions (mirrors `compose::Extra`).
1420#[derive(Clone, Copy)]
1421struct Extra {
1422    opacity: f32,
1423    dx: f32,
1424    dy: f32,
1425    mask: Option<glow::Texture>,
1426}
1427
1428impl Extra {
1429    const NONE: Extra = Extra { opacity: 1.0, dx: 0.0, dy: 0.0, mask: None };
1430}
1431
1432/// One name per parameter any effect can have (`Curves` has 12, everything else 8 or fewer).
1433const PARAM_NAMES: [&str; 12] = ["p0", "p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9", "p10", "p11"];
1434
1435/// `MASK`'s `m_points` array size.
1436pub const MAX_MASK_POINTS: usize = 64;
1437
1438/// An effect's parameters at time `t`, in `EffectKind::params()` order.
1439fn param_values(effect: &Effect, t: f64) -> Vec<f64> {
1440    (0..effect.kind.params().len()).map(|i| effect.at(i, t)).collect()
1441}
1442
1443/// Decoded layers for one timeline instant, produced by the player/export thread on the CPU and handed
1444/// to the renderer: (clip id, decoded frame). Motion-blur effects also get the neighbouring frames.
1445#[derive(Default)]
1446pub struct LayerSet {
1447    pub layers: Vec<(crate::model::Id, Arc<Frame>)>,
1448    /// Extra samples for shutter-based effects: (clip id, offset seconds, frame). `run_effect` binds
1449    /// the earliest as `u_prev` and the latest as `u_next` (both the same one when only one arrived);
1450    /// with none at all `u_frames` is 0 and the body falls back to the current frame.
1451    pub motion: Vec<(crate::model::Id, f64, Arc<Frame>)>,
1452}
1453
1454impl LayerSet {
1455    /// Reserved id for the subtitle overlay (clip ids are never 0).
1456    pub const SUBTITLES: crate::model::Id = 0;
1457
1458    pub fn get(&self, id: crate::model::Id) -> Option<&Arc<Frame>> {
1459        self.layers.iter().find(|(i, _)| *i == id).map(|(_, f)| f)
1460    }
1461}
1462
1463// ---------------- pure helpers (tested without a GL context) ----------------
1464
1465/// An effect node's parameter ports: a value node wired to port `i + 1` computes parameter `i` for
1466/// this frame, which is the graph's only way to drive a knob. Untouched effects are not cloned.
1467pub fn driven<'a>(e: &'a Effect, graph: &NodeGraph, id: Id, vals: &HashMap<Id, f64>) -> std::borrow::Cow<'a, Effect> {
1468    let mut out = std::borrow::Cow::Borrowed(e);
1469    for i in 0..e.specs().len() {
1470        let Some(v) = graph.input_of(id, i + 1).and_then(|f| vals.get(&f).copied()) else { continue };
1471        let e = out.to_mut();
1472        while e.params.len() <= i {
1473            e.params.push(crate::model::Animated::new(0.0));
1474        }
1475        e.params[i] = crate::model::Animated::new(v);
1476    }
1477    out
1478}
1479
1480/// The canvas size `render_to_texture` actually renders at for a given preview quality. Callers must
1481/// rasterise text/shape/subtitle layers at *this* size (they are placed 1:1, not fitted).
1482pub fn render_size(w: u32, h: u32, quality: f32) -> (u32, u32) {
1483    let q = quality.clamp(0.1, 1.0);
1484    (((w as f32 * q).round() as u32).max(1), ((h as f32 * q).round() as u32).max(1))
1485}
1486
1487/// The GLSL `int mode` for a blend mode: its index in `BlendMode::ALL`, which is the order
1488/// `engine::blend::blend_channel` matches.
1489pub fn blend_index(mode: BlendMode) -> i32 {
1490    BlendMode::ALL.iter().position(|m| *m == mode).unwrap_or(0) as i32
1491}
1492
1493pub fn scaler_index(s: Scaler) -> i32 {
1494    match s {
1495        Scaler::Nearest => 0,
1496        Scaler::Bilinear => 1,
1497        Scaler::Bicubic => 2,
1498    }
1499}
1500
1501/// The placed layer's four corners (TL, TR, BR, BL) in canvas pixels, projected through the
1502/// yaw/pitch tilt with focal length `f` and depth offset `z0`. `None` when a corner is behind the
1503/// camera. Mirrors `compose::draw_layer_perspective` (which uses f = canvas width, z0 = 0).
1504pub fn placement_quad(p: &crate::engine::compose::Placement, f: f32, z0: f32) -> Option<[[f32; 2]; 4]> {
1505    let (hw, hh) = (p.w / 2.0, p.h / 2.0);
1506    let (sy, cy) = p.yaw.to_radians().sin_cos();
1507    let (sp, cp) = p.pitch.to_radians().sin_cos();
1508    let (sr, cr) = p.rot.to_radians().sin_cos();
1509    let f = if f.is_finite() && f > 1.0 { f } else { 1.0 };
1510    let mut quad = [[0.0f32; 2]; 4];
1511    for (k, (x, y)) in [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].into_iter().enumerate() {
1512        let x1 = x * cy;
1513        let z1 = -x * sy;
1514        let y2 = y * cp - z1 * sp;
1515        let z2 = y * sp + z1 * cp + z0;
1516        let d = f + z2;
1517        if d < f * 0.05 {
1518            return None; // corner (nearly) behind the camera
1519        }
1520        let px = f * x1 / d;
1521        let py = f * y2 / d;
1522        quad[k] = [p.cx + px * cr - py * sr, p.cy + px * sr + py * cr];
1523    }
1524    Some(quad)
1525}
1526
1527/// Homography mapping the unit square (0,0)(1,0)(1,1)(0,1) onto `q` (Heckbert).
1528/// ponytail: copied from `compose::square_to_quad` (private there) so the GPU geometry cannot drift
1529/// from the CPU one — make that pair `pub(crate)` and this goes away.
1530pub fn square_to_quad(q: &[[f32; 2]; 4]) -> [[f32; 3]; 3] {
1531    let [p0, p1, p2, p3] = *q;
1532    let (dx1, dy1) = (p1[0] - p2[0], p1[1] - p2[1]);
1533    let (dx2, dy2) = (p3[0] - p2[0], p3[1] - p2[1]);
1534    let (sx, sy) = (p0[0] - p1[0] + p2[0] - p3[0], p0[1] - p1[1] + p2[1] - p3[1]);
1535    let (g, h) = if sx.abs() < 1e-6 && sy.abs() < 1e-6 {
1536        (0.0, 0.0)
1537    } else {
1538        let den = dx1 * dy2 - dx2 * dy1;
1539        if den.abs() < 1e-9 {
1540            (0.0, 0.0)
1541        } else {
1542            ((sx * dy2 - dx2 * sy) / den, (dx1 * sy - sx * dy1) / den)
1543        }
1544    };
1545    [
1546        [p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]],
1547        [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]],
1548        [g, h, 1.0],
1549    ]
1550}
1551
1552/// Inverse of a 3×3 (adjugate / det); None when singular.
1553pub fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> {
1554    let [a, b, c] = [m[0][0] as f64, m[0][1] as f64, m[0][2] as f64];
1555    let [d, e, f] = [m[1][0] as f64, m[1][1] as f64, m[1][2] as f64];
1556    let [g, h, i] = [m[2][0] as f64, m[2][1] as f64, m[2][2] as f64];
1557    let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
1558    if det.abs() < 1e-12 {
1559        return None;
1560    }
1561    let k = 1.0 / det;
1562    Some([
1563        [((e * i - f * h) * k) as f32, ((c * h - b * i) * k) as f32, ((b * f - c * e) * k) as f32],
1564        [((f * g - d * i) * k) as f32, ((a * i - c * g) * k) as f32, ((c * d - a * f) * k) as f32],
1565        [((d * h - e * g) * k) as f32, ((b * g - a * h) * k) as f32, ((a * e - b * d) * k) as f32],
1566    ])
1567}
1568
1569/// Canvas pixels -> layer uv for a placement (what `COMPOSITE`'s `u_inv` wants).
1570pub fn inverse_placement(p: &crate::engine::compose::Placement, f: f32, z0: f32) -> Option<[[f32; 3]; 3]> {
1571    invert3(&square_to_quad(&placement_quad(p, f, z0)?))
1572}
1573
1574/// Row-major 3×3 -> the column-major order `glUniformMatrix3fv` expects.
1575fn column_major(m: &[[f32; 3]; 3]) -> [f32; 9] {
1576    [m[0][0], m[1][0], m[2][0], m[0][1], m[1][1], m[2][1], m[0][2], m[1][2], m[2][2]]
1577}
1578
1579/// Cache key of a user shader body — shared by `effect_program`, `check_shader` and `shader_error`
1580/// so the editor reads the very program the renderer would build.
1581fn user_key(src: &str) -> u64 {
1582    USER_BIT | hash_str(src)
1583}
1584
1585fn hash_str(s: &str) -> u64 {
1586    use std::hash::{Hash, Hasher};
1587    let mut h = std::collections::hash_map::DefaultHasher::new();
1588    s.hash(&mut h);
1589    h.finish() & !USER_BIT
1590}
1591
1592unsafe fn tex_params(gl: &glow::Context) {
1593    // Sampling is done in the shaders (alpha-weighted, matching the CPU compositor), so the fixed
1594    // function filter must not interpolate for us.
1595    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32);
1596    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32);
1597    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32);
1598    gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32);
1599}
1600
1601unsafe fn compile(gl: &glow::Context, kind: u32, src: &str) -> Result<glow::Shader, String> {
1602    let sh = gl.create_shader(kind).map_err(|e| format!("create_shader: {e}"))?;
1603    gl.shader_source(sh, src);
1604    gl.compile_shader(sh);
1605    if !gl.get_shader_compile_status(sh) {
1606        let log = gl.get_shader_info_log(sh);
1607        gl.delete_shader(sh);
1608        return Err(log);
1609    }
1610    Ok(sh)
1611}
1612
1613unsafe fn build(gl: &glow::Context, vert: glow::Shader, frag_src: &str) -> Result<Prog, String> {
1614    let frag = compile(gl, glow::FRAGMENT_SHADER, frag_src)?;
1615    let p = gl.create_program().map_err(|e| format!("create_program: {e}"))?;
1616    gl.attach_shader(p, vert);
1617    gl.attach_shader(p, frag);
1618    gl.link_program(p);
1619    gl.detach_shader(p, vert);
1620    gl.detach_shader(p, frag);
1621    gl.delete_shader(frag);
1622    if !gl.get_program_link_status(p) {
1623        let log = gl.get_program_info_log(p);
1624        gl.delete_program(p);
1625        return Err(log);
1626    }
1627    let mut uni = HashMap::new();
1628    for name in UNIFORMS {
1629        if let Some(l) = gl.get_uniform_location(p, name) {
1630            uni.insert(*name, l);
1631        }
1632    }
1633    // arrays are reported as "name[0]" by some drivers
1634    if let Some(l) = gl.get_uniform_location(p, "m_points[0]") {
1635        uni.entry("m_points").or_insert(l);
1636    }
1637    Ok(Prog { p, uni })
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642    use super::*;
1643    use crate::engine::compose::Placement;
1644    use std::num::NonZeroU32;
1645
1646    #[test]
1647    fn a_value_node_drives_an_effect_parameter() {
1648        let mut next = 0;
1649        let mut nid = || {
1650            next += 1;
1651            next
1652        };
1653        let mut g = NodeGraph::new(&mut nid);
1654        let fx_id = nid();
1655        let num_id = nid();
1656        g.nodes.push(crate::model::Node {
1657            id: fx_id,
1658            kind: NodeKind::Effect(Effect::new(EffectKind::Blur)),
1659            x: 0.0,
1660            y: 0.0,
1661            enabled: true,
1662        });
1663        g.nodes.push(crate::model::Node {
1664            id: num_id,
1665            kind: NodeKind::Number(crate::model::Animated::new(42.0)),
1666            x: 0.0,
1667            y: 0.0,
1668            enabled: true,
1669        });
1670        let out = g.output().unwrap();
1671        assert!(g.connect(fx_id, out, 0));
1672        let fx = Effect::new(EffectKind::Blur);
1673        // nothing wired: the effect is passed straight through, not cloned
1674        let vals = g.eval_values(0.0, 30.0);
1675        assert!(matches!(driven(&fx, &g, fx_id, &vals), std::borrow::Cow::Borrowed(_)));
1676        // the number on the radius port replaces the radius for this frame
1677        assert!(g.connect(num_id, fx_id, 1));
1678        let vals = g.eval_values(0.0, 30.0);
1679        assert_eq!(driven(&fx, &g, fx_id, &vals).at(0, 0.0), 42.0);
1680        assert_eq!(fx.at(0, 0.0), Effect::new(EffectKind::Blur).at(0, 0.0), "the stored effect is untouched");
1681    }
1682
1683    fn fake_target(w: u32, h: u32, id: u32) -> Target {
1684        // GL handles are never touched by the pool itself.
1685        Target {
1686            tex: glow::NativeTexture(NonZeroU32::new(id).unwrap()),
1687            fbo: glow::NativeFramebuffer(NonZeroU32::new(id).unwrap()),
1688            w,
1689            h,
1690        }
1691    }
1692
1693    #[test]
1694    fn pool_reuses_only_matching_sizes() {
1695        let mut p = Pool::default();
1696        assert!(p.take(16, 16).is_none());
1697        p.created();
1698        assert_eq!(p.live, 1);
1699        p.put(fake_target(16, 16, 1));
1700        assert_eq!(p.live, 0);
1701        assert_eq!(p.free.len(), 1);
1702        // wrong size -> caller must create a new one
1703        assert!(p.take(32, 16).is_none());
1704        assert_eq!(p.free.len(), 1);
1705        let t = p.take(16, 16).expect("reuse");
1706        assert_eq!((t.w, t.h), (16, 16));
1707        assert_eq!(p.live, 1);
1708        assert!(p.free.is_empty());
1709        p.put(t);
1710        p.put(fake_target(8, 8, 2));
1711        assert_eq!(p.free.len(), 2);
1712        // live never goes negative
1713        assert_eq!(p.live, 0);
1714    }
1715
1716    #[test]
1717    fn pool_evicts_the_oldest_when_the_free_list_is_over_budget() {
1718        let mut p = Pool::default();
1719        // one target of exactly the budget, then a second one: the first must be dropped
1720        let side = (Pool::MAX_FREE_PX as f64).sqrt() as u32;
1721        p.put(fake_target(side, side, 1));
1722        p.put(fake_target(64, 64, 2));
1723        assert_eq!(p.free.len(), 1, "the oversized free list must shrink");
1724        assert_eq!((p.free[0].w, p.free[0].h), (64, 64), "the newest target survives");
1725        // a normal working set is untouched
1726        let mut p = Pool::default();
1727        for i in 0..8 {
1728            p.put(fake_target(1920, 1080, i + 1));
1729        }
1730        assert_eq!(p.free.len(), 8, "8 × 1080p is well inside the budget");
1731    }
1732
1733    #[test]
1734    fn every_effect_parameter_reaches_a_uniform() {
1735        let most = crate::model::EffectKind::ALL.iter().map(|k| k.params().len()).max().unwrap_or(0);
1736        assert!(most <= PARAM_NAMES.len(), "{most} params but only {} are uploaded", PARAM_NAMES.len());
1737        for n in PARAM_NAMES {
1738            assert!(UNIFORMS.contains(&n), "{n} is never located at link time");
1739        }
1740    }
1741
1742    #[test]
1743    fn blend_int_mapping_matches_engine_blend() {
1744        assert_eq!(blend_index(BlendMode::Normal), 0);
1745        for (i, m) in BlendMode::ALL.iter().enumerate() {
1746            assert_eq!(blend_index(*m), i as i32, "{}", m.name());
1747        }
1748        // the GLSL switch must list the same modes in the same order
1749        let mut seen = Vec::new();
1750        for line in shaders::BLEND.lines() {
1751            let Some(rest) = line.split("mode == ").nth(1) else { continue };
1752            let Some(n) = rest.split(')').next().and_then(|s| s.trim().parse::<usize>().ok()) else {
1753                continue;
1754            };
1755            let Some(name) = line.split("// ").nth(1) else { continue };
1756            seen.push((n, name.trim().to_string()));
1757        }
1758        seen.dedup();
1759        assert_eq!(seen.len(), 12, "expected one branch per non-Normal mode: {seen:?}");
1760        for (n, name) in seen {
1761            let want = BlendMode::ALL[n].name().replace(' ', "");
1762            assert_eq!(name, want, "GLSL branch {n} is {name}, enum says {want}");
1763        }
1764    }
1765
1766    #[test]
1767    fn render_size_scales_and_never_collapses() {
1768        assert_eq!(render_size(1920, 1080, 1.0), (1920, 1080));
1769        assert_eq!(render_size(1920, 1080, 0.5), (960, 540));
1770        assert_eq!(render_size(1, 1, 0.0), (1, 1), "quality is clamped, never zero-sized");
1771        assert_eq!(render_size(640, 480, 2.0), (640, 480), "never upscales past 1.0");
1772    }
1773
1774    #[test]
1775    fn scaler_mapping() {
1776        assert_eq!(scaler_index(Scaler::Nearest), 0);
1777        assert_eq!(scaler_index(Scaler::Bilinear), 1);
1778        assert_eq!(scaler_index(Scaler::Bicubic), 2);
1779    }
1780
1781    fn map(m: &[[f32; 3]; 3], x: f32, y: f32) -> (f32, f32) {
1782        let w = m[2][0] * x + m[2][1] * y + m[2][2];
1783        ((m[0][0] * x + m[0][1] * y + m[0][2]) / w, (m[1][0] * x + m[1][1] * y + m[1][2]) / w)
1784    }
1785
1786    #[test]
1787    fn flat_quad_matches_placement_bounds() {
1788        let p = Placement { cx: 100.0, cy: 60.0, w: 80.0, h: 40.0, rot: 0.0, yaw: 0.0, pitch: 0.0 };
1789        let q = placement_quad(&p, 640.0, 0.0).unwrap();
1790        let (x0, y0, x1, y1) = p.bounds();
1791        assert_eq!(q, [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);
1792        // rotated: the quad's bbox is the placement's bbox
1793        let r = Placement { rot: 30.0, ..p };
1794        let q = placement_quad(&r, 640.0, 0.0).unwrap();
1795        let (bx0, by0, bx1, by1) = r.bounds();
1796        let xs: Vec<f32> = q.iter().map(|c| c[0]).collect();
1797        let ys: Vec<f32> = q.iter().map(|c| c[1]).collect();
1798        let mn = |v: &[f32]| v.iter().cloned().fold(f32::MAX, f32::min);
1799        let mx = |v: &[f32]| v.iter().cloned().fold(f32::MIN, f32::max);
1800        for (a, b) in [(mn(&xs), bx0), (mx(&xs), bx1), (mn(&ys), by0), (mx(&ys), by1)] {
1801            assert!((a - b).abs() < 1e-3, "{a} vs {b}");
1802        }
1803    }
1804
1805    #[test]
1806    fn yaw_zero_is_the_affine_inverse() {
1807        let p = Placement { cx: 160.0, cy: 90.0, w: 320.0, h: 180.0, rot: 0.0, yaw: 0.0, pitch: 0.0 };
1808        let inv = inverse_placement(&p, 320.0, 0.0).unwrap();
1809        // the placement's corners map to the layer's uv corners
1810        for ((x, y), (u, v)) in [
1811            ((0.0f32, 0.0f32), (0.0f32, 0.0f32)),
1812            ((320.0, 0.0), (1.0, 0.0)),
1813            ((320.0, 180.0), (1.0, 1.0)),
1814            ((160.0, 90.0), (0.5, 0.5)),
1815        ] {
1816            let (mu, mv) = map(&inv, x, y);
1817            assert!((mu - u).abs() < 1e-4 && (mv - v).abs() < 1e-4, "({x},{y}) -> ({mu},{mv})");
1818        }
1819        // no perspective term
1820        assert!(inv[2][0].abs() < 1e-6 && inv[2][1].abs() < 1e-6);
1821    }
1822
1823    #[test]
1824    fn yaw_tilts_and_stays_invertible() {
1825        let p = Placement { cx: 160.0, cy: 90.0, w: 200.0, h: 100.0, rot: 0.0, yaw: 40.0, pitch: 0.0 };
1826        let q = placement_quad(&p, 320.0, 0.0).unwrap();
1827        // positive yaw swings the right edge towards the camera, so it projects taller
1828        let left_h = (q[3][1] - q[0][1]).abs();
1829        let right_h = (q[2][1] - q[1][1]).abs();
1830        assert!(right_h > left_h, "{right_h} !> {left_h}");
1831        // ...and negative yaw does the opposite
1832        let q2 = placement_quad(&Placement { yaw: -40.0, ..p }, 320.0, 0.0).unwrap();
1833        assert!((q2[2][1] - q2[1][1]).abs() < (q2[3][1] - q2[0][1]).abs());
1834        let inv = inverse_placement(&p, 320.0, 0.0).unwrap();
1835        let (u, v) = map(&inv, p.cx, p.cy);
1836        assert!((0.0..=1.0).contains(&u) && (v - 0.5).abs() < 1e-3, "centre -> ({u},{v})");
1837        // a corner behind the camera is refused rather than rendered inside out
1838        let bad = Placement { w: 4000.0, yaw: 89.0, ..p };
1839        assert!(placement_quad(&bad, 320.0, 0.0).is_none());
1840    }
1841
1842    #[test]
1843    fn matrix_uploads_column_major() {
1844        let m = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
1845        assert_eq!(column_major(&m), [1.0, 4.0, 7.0, 2.0, 5.0, 8.0, 3.0, 6.0, 9.0]);
1846    }
1847
1848    #[test]
1849    fn user_shader_keys_differ_per_source() {
1850        assert_ne!(hash_str("a"), hash_str("b"));
1851        assert_eq!(hash_str("a"), hash_str("a"));
1852        assert_eq!(hash_str("a") & USER_BIT, 0, "the user bit must be free for tagging");
1853        assert!((USER_BIT | hash_str("a")) > EFFECT_BASE + 64);
1854        // check_shader / shader_error look the program up by this key: a fixed source must not read
1855        // back the broken one's log (that was the whole point of the editor)
1856        assert_ne!(user_key("a"), user_key("b"));
1857        assert_eq!(user_key("a"), USER_BIT | hash_str("a"));
1858    }
1859
1860    /// The GLSL text of every program the renderer can build (the assembly `ensure`/`effect_program`
1861    /// do, without a context). Only a driver can answer whether they link — run that from a windowed
1862    /// harness — but a duplicate `main()` or a missing `effect()` is visible from here.
1863    #[test]
1864    fn every_program_source_has_exactly_one_entry_point() {
1865        let mut sources = vec![
1866            format!("{}{}{}", shaders::PRELUDE, shaders::BLEND, shaders::COMPOSITE),
1867            format!("{}{}", shaders::PRELUDE, shaders::MASK),
1868            format!(
1869                "{}
1870{}
1871{}",
1872                shaders::PRELUDE,
1873                COPY_BODY,
1874                shaders::MAIN
1875            ),
1876            format!(
1877                "{}
1878{}
1879{}",
1880                shaders::PRELUDE,
1881                MATTE_BODY,
1882                shaders::MAIN
1883            ),
1884        ];
1885        sources.extend(
1886            crate::model::EffectKind::ALL.iter().filter_map(|k| shaders::fragment(*k, crate::model::DEFAULT_SHADER)),
1887        );
1888        for src in &sources {
1889            let n = src.matches("void main()").count();
1890            assert_eq!(n, 1, "{n} entry points in {}", &src[..80.min(src.len())]);
1891            assert!(src.starts_with("#version 330 core"), "{}", &src[..40.min(src.len())]);
1892            // `effect()` is only ever called by MAIN, and only bodies that define it get MAIN
1893            assert_eq!(
1894                src.contains("effect(src, uv)"),
1895                src.contains("vec4 effect(vec4 src, vec2 uv)"),
1896                "{}",
1897                &src[..80.min(src.len())]
1898            );
1899        }
1900    }
1901}